diff --git a/.github/workflows/gitops-update.yml b/.github/workflows/gitops-update.yml index f62713e0..da6e4e43 100644 --- a/.github/workflows/gitops-update.yml +++ b/.github/workflows/gitops-update.yml @@ -1,28 +1,24 @@ name: "GitOps Update" # Reusable workflow for updating GitOps repository with new image tags -# Supports multiple servers (Firmino, Clotilde) and environments: dev (beta), stg (rc), prd (production), sandbox +# Supports multiple clusters and environments: dev (beta), stg (rc), prd (production), sandbox # Handles single or multiple components with flexible YAML key mapping on: workflow_call: inputs: gitops_repository: - description: 'GitOps repository to update (org/repo format)' + description: 'GitOps repository to update (org/repo format). Defaults to the GITOPS_REPOSITORY org-level variable when empty.' type: string - default: 'LerianStudio/midaz-firmino-gitops' + default: '' app_name: description: 'Application name (defaults to repository name if not provided)' type: string required: false - deploy_in_firmino: - description: 'Force-off override for Firmino. Set to false to suppress deployment even if the manifest includes this app on Firmino.' - type: boolean - default: true - deploy_in_clotilde: - description: 'Force-off override for Clotilde. Set to false to suppress deployment even if the manifest includes this app on Clotilde.' + update_sandbox: + description: 'Include sandbox environment on production tags. Set to false to skip sandbox even on a production release.' type: boolean - default: true + default: false deploy_in_anacleto: description: 'Force-off override for Anacleto. Set to false to suppress deployment even if the manifest includes this app on Anacleto.' type: boolean @@ -48,10 +44,6 @@ on: description: 'Prefix for commit message (defaults to repository name if not provided)' type: string required: false - runner_type: - description: 'GitHub runner type to use' - type: string - default: 'blacksmith-4vcpu-ubuntu-2404' enable_argocd_sync: description: 'Enable ArgoCD sync after GitOps update' type: boolean @@ -103,12 +95,13 @@ on: jobs: update_gitops: - runs-on: ${{ inputs.runner_type }} + runs-on: ${{ vars.GITOPS_RUNNERS || 'blacksmith-4vcpu-ubuntu-2404' }} outputs: app_name: ${{ steps.setup.outputs.app_name }} sync_matrix: ${{ steps.apply_tags.outputs.sync_matrix }} has_sync_targets: ${{ steps.apply_tags.outputs.has_sync_targets }} env: + GITOPS_REPO: ${{ inputs.gitops_repository || vars.GITOPS_REPOSITORY }} IS_RC: ${{ contains(github.ref, '-rc.') }} IS_BETA: ${{ contains(github.ref, '-beta.') }} IS_SANDBOX: ${{ contains(github.ref, '-sandbox.') }} @@ -122,20 +115,24 @@ jobs: password: ${{ secrets.DOCKERHUB_IMAGE_PULL_TOKEN }} # Trust model: this is a `workflow_call` reusable workflow, NOT triggered by - # untrusted PRs. `inputs.gitops_repository` is supplied by trusted caller - # workflows (default: LerianStudio/midaz-firmino-gitops) and the MANAGE_TOKEN - # is required for the subsequent commit/push step. The allowlist step below - # enforces that the repository is a LerianStudio-owned `*-gitops` repo so a - # compromised caller cannot redirect the privileged checkout to an arbitrary - # target (CodeQL: `actions/untrusted-checkout`). + # untrusted PRs. The gitops repository is resolved from inputs.gitops_repository + # (caller-supplied) or the GITOPS_REPOSITORY org-level variable, and the + # MANAGE_TOKEN is required for the subsequent commit/push step. The allowlist + # step below enforces that the repository is a LerianStudio-owned `*-gitops` + # repo so a compromised caller cannot redirect the privileged checkout to an + # arbitrary target (CodeQL: `actions/untrusted-checkout`). - name: Validate gitops_repository against allowlist shell: bash env: - GITOPS_REPOSITORY: ${{ inputs.gitops_repository }} + GITOPS_REPOSITORY: ${{ env.GITOPS_REPO }} run: | set -euo pipefail + if [[ -z "$GITOPS_REPOSITORY" ]]; then + echo "::error::gitops_repository is empty. Set the input or the GITOPS_REPOSITORY org-level variable." + exit 1 + fi if [[ ! "$GITOPS_REPOSITORY" =~ ^LerianStudio/[A-Za-z0-9._-]+-gitops$ ]]; then - echo "::error::gitops_repository '$GITOPS_REPOSITORY' is not allowed. Must match ^LerianStudio/-gitops$ (e.g. LerianStudio/midaz-firmino-gitops)." + echo "::error::gitops_repository '$GITOPS_REPOSITORY' is not allowed. Must match ^LerianStudio/-gitops$ (e.g. LerianStudio/lerian-internal-gitops)." exit 1 fi echo "gitops_repository validated: $GITOPS_REPOSITORY" @@ -143,7 +140,7 @@ jobs: - name: Checkout GitOps Repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - repository: ${{ inputs.gitops_repository }} + repository: ${{ env.GITOPS_REPO }} token: ${{ secrets.MANAGE_TOKEN }} path: gitops fetch-depth: 0 @@ -194,14 +191,20 @@ jobs: - name: Install yq shell: bash + env: + YQ_VERSION: ${{ inputs.yq_version }} run: | set -euo pipefail - YQ_VERSION="${{ inputs.yq_version }}" - YQ_BIN="yq_linux_amd64" - YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BIN}" - - sudo curl -L "$YQ_URL" -o /usr/local/bin/yq - sudo chmod +x /usr/local/bin/yq + if command -v yq &>/dev/null; then + echo "yq already installed: $(yq --version)" + else + YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" + mkdir -p ~/.local/bin + curl -sSL "$YQ_URL" -o ~/.local/bin/yq + chmod +x ~/.local/bin/yq + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + fi yq --version - name: Install kustomize @@ -258,8 +261,6 @@ jobs: env: APP_NAME: ${{ steps.setup.outputs.app_name }} MANIFEST: shared-workflows/${{ inputs.deployment_matrix_file }} - DEPLOY_FIRMINO: ${{ inputs.deploy_in_firmino }} - DEPLOY_CLOTILDE: ${{ inputs.deploy_in_clotilde }} DEPLOY_ANACLETO: ${{ inputs.deploy_in_anacleto }} run: | set -euo pipefail @@ -310,18 +311,6 @@ jobs: while IFS= read -r cluster; do [[ -z "$cluster" ]] && continue case "$cluster" in - firmino) - if [[ "$DEPLOY_FIRMINO" == "false" ]]; then - echo " ✗ firmino — suppressed by deploy_in_firmino=false" - continue - fi - ;; - clotilde) - if [[ "$DEPLOY_CLOTILDE" == "false" ]]; then - echo " ✗ clotilde — suppressed by deploy_in_clotilde=false" - continue - fi - ;; anacleto) if [[ "$DEPLOY_ANACLETO" == "false" ]]; then echo " ✗ anacleto — suppressed by deploy_in_anacleto=false" @@ -344,6 +333,30 @@ jobs: echo "clusters=$FILTERED" >> "$GITHUB_OUTPUT" echo "has_clusters=true" >> "$GITHUB_OUTPUT" + # Emit a JSON map of per-cluster env_suffixes / suffix_excludes_envs for + # downstream steps. Defaults preserve the pre-multi-variant behavior: + # - env_suffixes defaults to [""] (one expansion, empty suffix) + # - suffix_excludes_envs defaults to [] + # Clusters that did not declare these fields therefore produce the exact + # same env list as before (e.g. dev/stg/prd/sandbox for a prod tag). + # Build a JSON array of resolved cluster names for jq. Splitting on the + # known single-space delimiter (FILTERED is built with " " separators + # above), then feeding each element to jq -R explicitly avoids the need + # to rely on word-splitting via `$FILTERED` (shellcheck SC2086). + CLUSTERS_JSON=$(printf '%s' "$FILTERED" | tr ' ' '\n' | jq -R . | jq -s .) + ENV_CONFIG=$(yq -o=json '.' "$MANIFEST" \ + | jq -c --argjson clusters "$CLUSTERS_JSON" ' + .clusters + | with_entries(select(.key as $k | $clusters | index($k))) + | with_entries(.value = { + env_suffixes: (.value.env_suffixes // [""]), + suffix_excludes_envs: (.value.suffix_excludes_envs // []), + env_contexts: (.value.env_contexts // []) + }) + ') + echo "env_config=$ENV_CONFIG" >> "$GITHUB_OUTPUT" + echo "Per-cluster env config: $ENV_CONFIG" + - name: Git pull before update shell: bash run: | @@ -415,6 +428,7 @@ jobs: APP_NAME: ${{ steps.setup.outputs.app_name }} HAS_CLUSTERS: ${{ steps.resolve_clusters.outputs.has_clusters }} RESOLVED_SERVERS: ${{ steps.resolve_clusters.outputs.clusters }} + ENV_CONFIG: ${{ steps.resolve_clusters.outputs.env_config }} MAPPINGS: ${{ inputs.yaml_key_mappings }} CONFIGMAP_MAPPINGS: ${{ inputs.configmap_updates }} GITOPS_LAYOUT: ${{ inputs.gitops_layout }} @@ -422,6 +436,7 @@ jobs: KUSTOMIZE_IMAGE_NAME: ${{ inputs.kustomize_image_name }} KUSTOMIZE_ENVIRONMENTS: ${{ inputs.kustomize_environments }} MANIFEST_FILE: shared-workflows/${{ inputs.deployment_matrix_file }} + UPDATE_SANDBOX: ${{ inputs.update_sandbox }} run: | set -euo pipefail @@ -433,7 +448,11 @@ jobs: ENVIRONMENTS="stg" ENV_LABEL="rc/stg" elif [[ "$IS_PRODUCTION" == "true" ]]; then - ENVIRONMENTS="dev stg prd sandbox" + if [[ "$UPDATE_SANDBOX" == "true" ]]; then + ENVIRONMENTS="dev stg prd sandbox" + else + ENVIRONMENTS="dev stg prd" + fi ENV_LABEL="production" elif [[ "$IS_SANDBOX" == "true" ]]; then ENVIRONMENTS="sandbox" @@ -519,9 +538,62 @@ jobs: return 1 } + # Compute the effective env list for a given server by expanding + # tag-derived envs against the cluster's env_suffixes and env_contexts, + # except for envs listed in suffix_excludes_envs which stay bare. + # When env_contexts is set, each expansion is prefixed: {context}/{env-variant}. + # Falls back to a single empty-suffix expansion when env_config is empty + # (e.g. kustomize layout with sentinel env "-", or clusters without the new fields). + expand_envs_for_server() { + local server="$1" + local base_envs="$2" + # Kustomize sentinel env "-" or empty config → return base_envs unchanged. + if [[ -z "$ENV_CONFIG" || "$base_envs" == "-" ]]; then + echo "$base_envs" + return + fi + local suffixes excludes contexts + suffixes=$(echo "$ENV_CONFIG" | jq -r --arg s "$server" '.[$s].env_suffixes // [""] | .[]' 2>/dev/null || echo "") + excludes=$(echo "$ENV_CONFIG" | jq -r --arg s "$server" '.[$s].suffix_excludes_envs // [] | .[]' 2>/dev/null || echo "") + contexts=$(echo "$ENV_CONFIG" | jq -r --arg s "$server" '.[$s].env_contexts // [] | .[]' 2>/dev/null || echo "") + if [[ -z "$suffixes" && -z "$contexts" ]]; then + echo "$base_envs" + return + fi + local out="" + for base_env in $base_envs; do + # Expand base_env × suffixes (or keep bare if excluded). + local expanded="" + if echo "$excludes" | grep -qx "$base_env" 2>/dev/null; then + expanded="$base_env" + else + while IFS= read -r suffix; do + expanded="${expanded:+$expanded }${base_env}${suffix}" + done <<< "$suffixes" + fi + # Apply contexts: prefix each expanded variant with "{context}/". + # When no contexts are declared, emit variants as-is. + if [[ -n "$contexts" ]]; then + while IFS= read -r ctx; do + for variant in $expanded; do + out="${out:+$out }${ctx}/${variant}" + done + done <<< "$contexts" + else + out="${out:+$out }$expanded" + fi + done + echo "$out" + } + # Process each server and environment for SERVER in $SERVERS; do - for ENV in $ENVIRONMENTS; do + SERVER_ENVS=$(expand_envs_for_server "$SERVER" "$ENVIRONMENTS") + if [[ "$SERVER_ENVS" != "$ENVIRONMENTS" ]]; then + echo "" + echo "Server $SERVER env expansion: '$ENVIRONMENTS' -> '$SERVER_ENVS'" + fi + for ENV in $SERVER_ENVS; do if [[ "$GITOPS_LAYOUT" == "kustomize" ]]; then # Resolve kustomize target dir (substitute ${SERVER} / ${ENV} placeholders). @@ -650,10 +722,14 @@ jobs: done < <(echo "$CONFIGMAP_MAPPINGS" | jq -r 'to_entries[] | "\(.key)|\(.value)"') fi - # Only track as updated if changes were actually made + # Only track as updated if changes were actually made. + # When app_helmfile_env overrides the env (e.g. forge: cross), the + # sync target's env must use the override (HELMFILE_ENV), not the + # tag-derived env, so the ArgoCD app name resolves correctly + # (e.g. benedita-forge-cross, not benedita-forge-dev-st). if [[ "$FILE_CHANGED" == "true" ]]; then UPDATED_FILES="${UPDATED_FILES}${VALUES_FILE}\n" - UPDATED_SERVERS_ENVS="${UPDATED_SERVERS_ENVS}${SERVER}:${ENV}\n" + UPDATED_SERVERS_ENVS="${UPDATED_SERVERS_ENVS}${SERVER}:${HELMFILE_ENV}\n" else echo " No changes needed for this file" fi @@ -678,14 +754,17 @@ jobs: echo -e "$MISSING_FILES" fi - # Save updated servers/envs for ArgoCD sync - only include files that actually changed + # Save updated servers/envs for ArgoCD sync - only include files that actually changed. + # Deduplicate (server:env) pairs: cross apps are visited once per outer env + # iteration (e.g. 7 times on benedita for a prod tag) but always write to + # the same override path, so a single sync target is correct. if [[ -n "$UPDATED_SERVERS_ENVS" ]]; then - echo -e "$UPDATED_SERVERS_ENVS" | grep -v '^$' > /tmp/updated_servers_envs.txt + echo -e "$UPDATED_SERVERS_ENVS" | grep -v '^$' | sort -u > /tmp/updated_servers_envs.txt # Output sync targets as JSON array for matrix job - SYNC_MATRIX=$(echo -e "$UPDATED_SERVERS_ENVS" | grep -v '^$' | while IFS=: read -r s e; do + SYNC_MATRIX=$(while IFS=: read -r s e; do [[ -n "$s" && -n "$e" ]] && echo "{\"server\":\"$s\",\"env\":\"$e\"}" - done | jq -s -c '.') + done < /tmp/updated_servers_envs.txt | jq -s -c '.') echo "sync_matrix=$SYNC_MATRIX" >> "$GITHUB_OUTPUT" echo "has_sync_targets=true" >> "$GITHUB_OUTPUT" echo "Sync matrix: $SYNC_MATRIX" @@ -762,7 +841,7 @@ jobs: name: ArgoCD Sync (${{ matrix.target.server }}/${{ matrix.target.env }}) needs: [update_gitops] if: needs.update_gitops.outputs.has_sync_targets == 'true' && inputs.enable_argocd_sync - runs-on: ${{ inputs.runner_type }} + runs-on: ${{ vars.GITOPS_RUNNERS || 'blacksmith-4vcpu-ubuntu-2404' }} strategy: fail-fast: false matrix: @@ -772,9 +851,16 @@ jobs: shell: bash run: | set -euo pipefail - curl -sSL -o /tmp/argocd https://github.com/argoproj/argo-cd/releases/download/v3.0.6/argocd-linux-amd64 - sudo install -m 755 /tmp/argocd /usr/local/bin/argocd - argocd version --client + if command -v argocd &>/dev/null; then + echo "argocd already installed: $(argocd version --client --short 2>/dev/null || argocd version --client)" + else + mkdir -p ~/.local/bin + curl -sSL -o ~/.local/bin/argocd https://github.com/argoproj/argo-cd/releases/download/v3.0.6/argocd-linux-amd64 + chmod +x ~/.local/bin/argocd + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + argocd version --client + fi - name: Resolve ArgoCD application name id: resolve_argocd_app @@ -790,6 +876,8 @@ jobs: NAME="${NAME//\{server\}/$SERVER}" NAME="${NAME//\{app\}/$APP}" NAME="${NAME//\{env\}/$ENV_NAME}" + # Normalize context-prefixed envs: "chaos/dev-st" → "chaos-dev-st". + NAME="${NAME//\//-}" # Collapse "--" sequences (sentinel env "-" produces "--" inside template). while [[ "$NAME" == *--* ]]; do NAME="${NAME//--/-}"; done # Strip trailing "-" left over when sentinel env "-" lands at the end. @@ -800,8 +888,8 @@ jobs: - name: Sync ArgoCD application shell: bash env: - ARGOCD_URL: ${{ secrets.ARGOCD_URL }} - ARGOCD_TOKEN: ${{ secrets.ARGOCD_GHUSER_TOKEN }} + ARGOCD_URL: ${{ vars.ARGOCD_URL }} + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} APP_NAME: ${{ steps.resolve_argocd_app.outputs.name }} ARGOCD_PRUNE: ${{ inputs.argocd_prune }} run: | @@ -813,13 +901,13 @@ jobs: fi echo "::group::argocd app get $APP_NAME" - if ! argocd app get "$APP_NAME" --server "$ARGOCD_URL" --auth-token "$ARGOCD_TOKEN" --grpc-web; then - rc=$? - echo "::endgroup::" - echo "::warning::Failed to get ArgoCD app '$APP_NAME' (exit $rc). Skipping sync — check whether the app exists, auth is valid, and the user has 'applications, get' permission." + argocd_rc=0 + argocd app get "$APP_NAME" --server "$ARGOCD_URL" --auth-token "$ARGOCD_TOKEN" --grpc-web || argocd_rc=$? + echo "::endgroup::" + if [[ "$argocd_rc" -ne 0 ]]; then + echo "::warning::Failed to get ArgoCD app '$APP_NAME' (exit $argocd_rc). Skipping sync — check whether the app exists, auth is valid, and the user has 'applications, get' permission." exit 0 fi - echo "::endgroup::" echo "::group::argocd app sync $APP_NAME" for attempt in 1 2 3 4 5; do diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index fed79b20..2b58dc15 100644 --- a/.github/workflows/go-release.yml +++ b/.github/workflows/go-release.yml @@ -114,9 +114,13 @@ on: type: boolean default: true gitops_repository: - description: 'GitOps repository to update (org/repo format)' + description: 'GitOps repository to update (org/repo format). Defaults to the GITOPS_REPOSITORY org-level variable when empty.' type: string - default: 'LerianStudio/midaz-firmino-gitops' + default: '' + update_sandbox: + description: 'Include sandbox environment on production tags. Set to false to skip sandbox even on a production release.' + type: boolean + default: false gitops_artifact_pattern: description: 'Pattern to download GitOps artifacts (defaults to "gitops-tags-*")' type: string @@ -125,10 +129,10 @@ on: description: 'JSON object mapping artifact names to YAML keys (e.g., {"app.tag": ".api.image.tag"})' type: string default: '' - gitops_runner_type: - description: 'Runner for the gitops-update (deploy) job. Deploy needs cluster access, so this defaults to eveo-lxc-runners rather than the build runner.' + deployment_matrix_ref: + description: 'Git ref of LerianStudio/github-actions-shared-workflows to read the deployment matrix from. Defaults to main. Override only when testing a branch.' type: string - default: 'eveo-lxc-runners' + default: 'main' enable_argocd_sync: description: 'Trigger ArgoCD sync after updating the GitOps repository' type: boolean @@ -137,14 +141,6 @@ on: description: 'Prefix for the GitOps commit message. When empty, falls back to app_name_prefix, then the repository name.' type: string default: '' - deploy_in_firmino: - description: 'Force-off override for Firmino. Set to false to suppress deployment even if the manifest includes this app on Firmino.' - type: boolean - default: true - deploy_in_clotilde: - description: 'Force-off override for Clotilde. Set to false to suppress deployment even if the manifest includes this app on Clotilde.' - type: boolean - default: true use_dynamic_mapping: description: 'Use dynamic artifact-to-YAML key mapping in gitops-update' type: boolean @@ -464,15 +460,14 @@ jobs: packages: write uses: ./.github/workflows/gitops-update.yml with: - runner_type: ${{ inputs.gitops_runner_type }} gitops_repository: ${{ inputs.gitops_repository }} + update_sandbox: ${{ inputs.update_sandbox }} app_name: ${{ inputs.app_name || inputs.app_name_prefix }} artifact_pattern: ${{ inputs.gitops_artifact_pattern || format('gitops-tags-{0}*', github.event.repository.name) }} yaml_key_mappings: ${{ inputs.gitops_yaml_key_mappings }} + deployment_matrix_ref: ${{ inputs.deployment_matrix_ref }} enable_argocd_sync: ${{ inputs.enable_argocd_sync }} commit_message_prefix: ${{ inputs.commit_message_prefix || inputs.app_name_prefix }} - deploy_in_firmino: ${{ inputs.deploy_in_firmino }} - deploy_in_clotilde: ${{ inputs.deploy_in_clotilde }} use_dynamic_mapping: ${{ inputs.use_dynamic_mapping }} configmap_updates: ${{ inputs.configmap_updates }} enable_docker_login: ${{ inputs.enable_docker_login }} diff --git a/config/deployment-matrix.yml b/config/deployment-matrix.yml index f8a67b48..1a1b527d 100644 --- a/config/deployment-matrix.yml +++ b/config/deployment-matrix.yml @@ -9,10 +9,31 @@ # script described in docs/gitops-update-workflow.md (or grep across blocks). # # Schema: -# version integer, required, currently 1 -# apps.registry list of every app name allowed to appear in any cluster -# clusters. cluster block with explicit `apps:` list (no `all_apps` -# shortcut today — every cluster has its own subset) +# version integer, required, currently 1 +# apps.registry list of every app name allowed to appear in any cluster +# clusters. cluster block with explicit `apps:` list (no `all_apps` +# shortcut today — every cluster has its own subset) +# clusters..env_contexts optional list of context prefixes inserted before the +# env in the helmfile path. When set, the path becomes +# helmfile/applications/{context}/{env}/{app}/values.yaml +# instead of helmfile/applications/{env}/{app}/values.yaml. +# Combines with env_suffixes: full expansion is +# contexts × envs × suffixes. Example: env_contexts: +# ["chaos", "fuzzing"] + env_suffixes: ["-st", "-mt"] on a +# beta tag yields chaos/dev-st, chaos/dev-mt, +# fuzzing/dev-st, fuzzing/dev-mt. Defaults to [] (no prefix). +# clusters..env_suffixes optional list of suffixes to append to each tag-derived +# env (e.g. ["-st", "-mt"] produces dev-st, dev-mt, stg-st, +# stg-mt, ...). Defaults to [""] (no suffix) for backward +# compatibility. Use this when one cluster hosts multiple +# parallel variants per environment (e.g. single-tenant and +# multi-tenant) as sibling namespaces / helmfile dirs / +# ArgoCD apps. +# clusters..suffix_excludes_envs +# optional list of tag-derived env names that should NOT +# receive any suffix from `env_suffixes` and stay bare +# (e.g. ["sandbox"] keeps sandbox as "sandbox", not +# "sandbox-st" / "sandbox-mt"). Defaults to []. # # Resolution at runtime: # 1. Workflow reads this file at the pinned ref of the shared-workflows repo. @@ -21,13 +42,20 @@ # 3. The `deploy_in_` workflow inputs act as **force-off** overrides: # setting any of them to `false` removes that cluster from the resolved # set even if the manifest includes the app. +# 4. For each resolved cluster, the tag-derived env list (e.g. dev/stg/prd/sandbox +# for a production tag) is expanded against `env_suffixes` and `env_contexts`, +# except for envs listed in `suffix_excludes_envs` which remain bare. The final +# env list is what the workflow uses to build helmfile paths and ArgoCD app names. # # Out of scope (managed manually or by other tooling — do NOT add here): -# - underwriter, jd-mock-api, mock-btg-server, control-plane, +# - underwriter, jd-mock-api, mock-btg-server, fakebtg, control-plane, # platform-console (no CI commit history in gitops repo) # - ledger, dockerhub-secret (no values.yaml — kustomize / k8s Secret) # - hortencia-apps (legacy umbrella, not workflow-managed) # - environments/production/* (platform infra: addons, observability) +# - ungoliant-controller and severino-bot use gitops_layout=kustomize — they ARE +# in this manifest for cluster resolution, but callers must set gitops_layout=kustomize, +# kustomize_base_path, and kustomize_image_name; no helmfile values.yaml exists for them version: 1 @@ -46,71 +74,36 @@ apps: # Plugins - plugin-access-manager # caller repo may push as plugin-identity / plugin-auth / casdoor - plugin-fees - - plugin-br-pix-direct-jd - plugin-br-pix-indirect-btg - - plugin-br-pix-switch # multi-component PIX hub (firmino-only today) - - plugin-br-bank-transfer # generic build (firmino + clotilde) - - plugin-br-bank-transfer-jd # JD-specific build (anacleto only) + - plugin-br-pix-switch + - plugin-br-bank-transfer + - plugin-br-payments - plugin-bc-correios - # Lerian internal platform suite (Clotilde + Benedita) + # Lerian internal platform suite (Benedita cross/) - backoffice-console - cs-platform - forge - - lerian-hq + - gestao-acessos-console - lerian-map - tenant-manager - # Anacleto-exclusive - - ungoliant-controller + # Kustomize-managed (gitops_layout=kustomize callers; no helmfile values.yaml) + - ungoliant-controller # anacleto only + - severino-bot # benedita only # Benedita-exclusive - - severino-bot # internal Slack bot (benedita only) - - rosiehr # internal HR app (benedita only); values at cross/ — see app_helmfile_env below - - go-boilerplate-ddd # Go service boilerplate (reference/template); benedita only + - rosiehr # internal HR app (benedita only); values at cross/ + - go-boilerplate-ddd # Go service boilerplate (reference/template) clusters: - firmino: - apps: - - midaz - - fetcher - - flowker - - matcher - - reporter - - tracer - - product-console - - lerian-notification - - plugin-access-manager - - plugin-fees - - plugin-br-pix-direct-jd - - plugin-br-pix-indirect-btg - - plugin-br-pix-switch - - plugin-br-bank-transfer - - plugin-bc-correios - - clotilde: - apps: - - midaz - - fetcher - - flowker - - matcher - - reporter - - tracer - - product-console - - plugin-access-manager - - plugin-fees - - plugin-br-pix-direct-jd - - plugin-br-pix-indirect-btg - - plugin-br-bank-transfer - - plugin-bc-correios - - backoffice-console - - cs-platform - - forge - - lerian-map - - tenant-manager - - lerian-notification - anacleto: + # Anacleto hosts two parallel test types (chaos, fuzzing), each with two + # tenant variants per environment. The two-level path structure is: + # helmfile/applications/{context}/{env-with-suffix}/{app}/values.yaml + # where context ∈ {chaos, fuzzing} and env-with-suffix ∈ {dev-st, dev-mt}. + env_contexts: ["chaos", "fuzzing"] + env_suffixes: ["-st", "-mt"] apps: - midaz - fetcher @@ -121,27 +114,35 @@ clusters: - product-console - plugin-access-manager - plugin-fees - - plugin-br-pix-direct-jd - plugin-br-pix-indirect-btg - - plugin-br-bank-transfer-jd + - plugin-br-pix-switch - ungoliant-controller - - lerian-notification benedita: + # Benedita hosts two parallel variants per environment as sibling namespaces: + # single-tenant (-st): dev-st, stg-st, prd-st + # multi-tenant (-mt): stg-mt (dev-mt and prd-mt do not exist) + # Sandbox is shared across variants and stays bare (no suffix). + env_suffixes: ["-st", "-mt"] + suffix_excludes_envs: ["sandbox"] + # app_helmfile_env: per-app override for the helmfile applications sub-directory. # Default path: environments/benedita/helmfile/applications/{env}/{app}/values.yaml - # where {env} = dev | stg | prd | sandbox (derived from the git tag type). - # Apps whose values.yaml lives under cross/ instead of dev/ must be listed here; - # otherwise the gitops-update workflow warns "values file not found" and skips them. + # where {env} is the tag-derived env with env_suffixes applied (dev-st, stg-mt, ...). + # Apps whose values.yaml lives under cross/ must be listed here; otherwise the + # workflow warns "values file not found" and skips them. When an override is set, + # env_suffixes are NOT applied — the app deploys exactly once at the override path. # # IMPORTANT: only list apps whose ArgoCD app name follows the standard - # {server}-{app}-{env} pattern (e.g. benedita-rosiehr-dev). Apps like - # cs-platform, forge, lerian-map use non-standard ArgoCD names (no -dev suffix) - # so their gitops-update sync target would resolve incorrectly. Add them here - # only after aligning their ArgoCD app naming with the standard convention. + # {server}-{app}-{env} pattern (e.g. benedita-rosiehr-cross, benedita-forge-cross). app_helmfile_env: - rosiehr: cross + backoffice-console: cross + cs-platform: cross forge: cross + gestao-acessos-console: cross + lerian-map: cross + rosiehr: cross + tenant-manager: cross apps: - midaz - fetcher @@ -152,16 +153,18 @@ clusters: - product-console - plugin-access-manager - plugin-fees - - plugin-br-pix-direct-jd - plugin-br-pix-indirect-btg + - plugin-br-pix-switch - plugin-br-bank-transfer + - plugin-br-payments + - plugin-bc-correios - backoffice-console - cs-platform - forge - - lerian-hq + - gestao-acessos-console - lerian-map - tenant-manager - - severino-bot - lerian-notification - rosiehr - go-boilerplate-ddd + - severino-bot diff --git a/docs/gitops-update-workflow.md b/docs/gitops-update-workflow.md index f5421937..da564c48 100644 --- a/docs/gitops-update-workflow.md +++ b/docs/gitops-update-workflow.md @@ -1,22 +1,27 @@ -# GitOps Update Workflow + + + + + +
Lerian

gitops-update

-Reusable workflow for updating GitOps repository with new image tags across multiple servers and environments. +Reusable workflow for updating GitOps repository with new image tags across multiple clusters and environments. ## Features - **Manifest-driven topology**: Cluster membership per app is declared in [`config/deployment-matrix.yml`](../config/deployment-matrix.yml) — no caller-side configuration required to add a cluster to an existing app -- **Multi-server deployment**: Deploy to Firmino, Clotilde and/or Anacleto with dynamic path generation +- **Multi-cluster deployment**: Deploy to Anacleto and Benedita with dynamic path generation +- **Per-cluster env variants**: `env_suffixes` and `env_contexts` support multi-tenant (`-st`/`-mt`) and context-based (`chaos/`, `fuzzing/`) layouts - **Force-off overrides**: `deploy_in_` inputs can suppress a cluster declared in the manifest, useful for emergency containment without editing the manifest - **Convention-based configuration**: Auto-generates paths, names, and patterns from repository name - **Multi-environment support**: dev (beta), stg (rc), prd (production), sandbox -- **Production sync**: Production releases automatically update all environments (dev, stg, prd, sandbox) on all servers +- **Production sync**: Production releases automatically update all environments on all clusters - **File existence validation**: Graceful handling of missing values files with warnings (never fails) - **Flexible tag mapping**: Static or dynamic YAML key mapping - **Automatic environment detection**: Based on git tag suffix -- **ArgoCD integration**: Automatic sync for each server/environment combination where files were updated +- **ArgoCD integration**: Automatic sync for each cluster/environment combination where files were updated - **App existence check**: Verifies ArgoCD app exists before attempting sync -- **Docker Hub login**: Enabled by default to avoid rate limits -- **Customizable runners**: Support for different GitHub runner types +- **Org-level configuration**: Runner, gitops repo, and ArgoCD URL resolved from org variables (`GITOPS_RUNNERS`, `GITOPS_REPOSITORY`, `ARGOCD_URL`) ## Usage @@ -32,7 +37,9 @@ update_gitops: secrets: inherit ``` -> **Required Secrets**: `MANAGE_TOKEN`, `LERIAN_CI_CD_USER_NAME`, `LERIAN_CI_CD_USER_EMAIL`, `ARGOCD_GHUSER_TOKEN`, `ARGOCD_URL`, `DOCKER_USERNAME`, `DOCKER_PASSWORD` +> **Required Secrets**: `MANAGE_TOKEN`, `LERIAN_CI_CD_USER_NAME`, `LERIAN_CI_CD_USER_EMAIL`, `ARGOCD_TOKEN`, `DOCKER_USERNAME`, `DOCKER_PASSWORD` +> +> **Required Variables**: `GITOPS_REPOSITORY`, `GITOPS_RUNNERS`, `ARGOCD_URL` The workflow reads `config/deployment-matrix.yml` from the shared-workflows repo (by default from `main`, override via `deployment_matrix_ref`) and resolves the cluster set automatically based on `app_name`. No `deploy_in_*` inputs are required for the common case. @@ -114,8 +121,6 @@ update_gitops: |-------|------|---------|-------------| | `gitops_repository` | string | `LerianStudio/midaz-firmino-gitops` | GitOps repository to update | | `app_name` | string | (repo name) | Application name (auto-detected from repository) | -| `deploy_in_firmino` | boolean | `true` | Force-off override for Firmino (`false` = subtract from manifest-resolved set) | -| `deploy_in_clotilde` | boolean | `true` | Force-off override for Clotilde (`false` = subtract from manifest-resolved set) | | `deploy_in_anacleto` | boolean | `true` | Force-off override for Anacleto (`false` = subtract from manifest-resolved set) | | `deployment_matrix_file` | string | `config/deployment-matrix.yml` | Path to the deployment matrix manifest within the shared-workflows checkout | | `deployment_matrix_ref` | string | `main` | Git ref of `LerianStudio/github-actions-shared-workflows` to read the deployment matrix from. Default `main` ensures all callers see manifest updates immediately, regardless of the workflow ref they pin. Override only when testing a branch. | @@ -151,8 +156,15 @@ update_gitops: | Secret | Description | |--------|-------------| -| `ARGOCD_GHUSER_TOKEN` | ArgoCD authentication token | -| `ARGOCD_URL` | ArgoCD server URL | +| `ARGOCD_TOKEN` | ArgoCD authentication token | + +### Required Variables + +| Variable | Description | +|----------|-------------| +| `GITOPS_REPOSITORY` | GitOps repository to update (e.g. `LerianStudio/lerian-internal-gitops`) | +| `GITOPS_RUNNERS` | GitHub Actions runner label for the gitops/deploy jobs (e.g. `eveo-lxc-runners`) | +| `ARGOCD_URL` | ArgoCD server hostname without protocol (e.g. `argocd.eveo.lerian.net`) | ### Required Secrets (Docker Hub) @@ -197,6 +209,97 @@ clusters: - Each `clusters..apps` is an explicit list of which apps this cluster hosts. - A cluster is added by appending one block. A cluster is removed by deleting it. Affects only this repo — caller workflows are untouched. +### Per-cluster env context directories (Anacleto) + +Some clusters organize their helmfile tree with an extra **context** level before the env. Anacleto uses this to separate chaos and fuzzing test suites: + +``` +environments/anacleto/helmfile/applications/ +├── chaos/ +│ ├── dev-st/{app}/values.yaml +│ └── dev-mt/{app}/values.yaml +└── fuzzing/ + ├── dev-st/{app}/values.yaml + └── dev-mt/{app}/values.yaml +``` + +Declare the contexts on the cluster block alongside `env_suffixes`: + +```yaml +clusters: + anacleto: + env_contexts: ["chaos", "fuzzing"] # path prefix before the env + env_suffixes: ["-st", "-mt"] # variant suffix appended to the env + apps: [midaz, fetcher, ...] +``` + +| Field | Default | Effect | +|---|---|---| +| `env_contexts` | `[]` | List of subdirectory prefixes inserted before the env in the helmfile path. When empty, no prefix is added (existing behavior). | +| `env_suffixes` | `[""]` | List of suffixes appended to each tag-derived env. | + +**Resolution on a beta tag** (env: `dev`), with the manifest above: + +1. Tag-type → base env: `dev` +2. Suffix expansion: `dev` → `dev-st`, `dev-mt` +3. Context expansion: for each context × each variant: + - `chaos` × `dev-st` → `chaos/dev-st` + - `chaos` × `dev-mt` → `chaos/dev-mt` + - `fuzzing` × `dev-st` → `fuzzing/dev-st` + - `fuzzing` × `dev-mt` → `fuzzing/dev-mt` +4. Final env list for anacleto: `chaos/dev-st chaos/dev-mt fuzzing/dev-st fuzzing/dev-mt` +5. Values paths: `environments/anacleto/helmfile/applications/{context/env}/{app}/values.yaml` +6. ArgoCD app name: `/` normalized to `-`, so `{server}-{app}-chaos/dev-st` → `anacleto-midaz-chaos-dev-st` + +Clusters without `env_contexts` (benedita) are unaffected — the field defaults to `[]`. + +### Per-cluster env suffix variants (`-st`, `-mt`, ...) + +Some clusters host **multiple parallel variants per environment** as sibling namespaces, helmfile directories, and ArgoCD apps. For example, Benedita runs both single-tenant (`-st`) and multi-tenant (`-mt`) variants: + +``` +environments/benedita/helmfile/applications/ +├── dev-st/midaz/values.yaml +├── dev-mt/midaz/values.yaml +├── stg-st/midaz/values.yaml +├── stg-mt/midaz/values.yaml +├── prd-st/midaz/values.yaml +├── prd-mt/midaz/values.yaml +└── sandbox/midaz/values.yaml # shared, no suffix +``` + +Declare the suffixes on the cluster block: + +```yaml +clusters: + benedita: + env_suffixes: ["-st", "-mt"] # produces dev-st, dev-mt, stg-st, ... + suffix_excludes_envs: ["sandbox"] # sandbox stays bare (no suffix) + apps: [midaz, fetcher, ...] +``` + +| Field | Default | Effect | +|---|---|---| +| `env_suffixes` | `[""]` | List of suffixes appended to each tag-derived env. `[""]` (the default) preserves the pre-existing single-variant behavior. | +| `suffix_excludes_envs` | `[]` | Tag-derived env names that stay bare (no suffix expansion). Useful for shared envs like `sandbox`. | + +**Resolution on a production tag**, with the manifest above: + +1. Tag-type → env list (existing logic): `dev stg prd sandbox` +2. Cluster expansion (new logic): for benedita, expand each env not in `suffix_excludes_envs` against `env_suffixes`: + - `dev` → `dev-st`, `dev-mt` + - `stg` → `stg-st`, `stg-mt` + - `prd` → `prd-st`, `prd-mt` + - `sandbox` (excluded) → `sandbox` +3. Final env list for benedita: `dev-st dev-mt stg-st stg-mt prd-st prd-mt sandbox` (7 entries) +4. ArgoCD app name template (default `{server}-{app}-{env}`) resolves to `benedita-midaz-dev-st`, `benedita-midaz-dev-mt`, ..., `benedita-midaz-sandbox` + +Firmino, Clotilde, Anacleto behavior is byte-identical to before: with both fields absent, `env_suffixes` defaults to `[""]` (single empty-suffix expansion), so the final env list equals the tag-derived list verbatim. + +#### Interaction with `app_helmfile_env` + +When an app has an `app_helmfile_env` override (e.g. `forge: cross`), the override path takes precedence and **the suffix expansion is skipped for that app** — it updates once at the override path. The sync target's env is the override value (`cross`), so the ArgoCD app name resolves to `benedita-forge-cross`. + ### Adding a new app to a cluster 1. Open a PR in this repo editing `config/deployment-matrix.yml`: @@ -237,7 +340,7 @@ gitops/environments//helmfile/applications///values.yaml ``` Where: -- ``: any cluster resolved from the deployment matrix (current set: `firmino`, `clotilde`, `anacleto`), minus those force-off via `deploy_in_: false` +- ``: any cluster resolved from the deployment matrix (current set: `anacleto`, `benedita`), minus those force-off via `deploy_in_: false` - ``: `dev`, `stg`, `prd`, or `sandbox` (determined by tag type) - ``: from `inputs.app_name` or auto-detected from repository name @@ -278,9 +381,8 @@ When `enable_argocd_sync` is `true`, the workflow syncs ArgoCD applications for ArgoCD apps are named using the pattern: `--` Examples: -- `firmino-midaz-dev`, `firmino-midaz-stg`, `firmino-midaz-prd` -- `clotilde-midaz-dev`, `clotilde-midaz-stg`, `clotilde-midaz-sandbox` -- `anacleto-midaz-dev` +- `anacleto-midaz-chaos-dev-st`, `anacleto-midaz-fuzzing-dev-mt` +- `benedita-midaz-dev-st`, `benedita-midaz-stg-mt`, `benedita-midaz-sandbox` ### Sync Behavior @@ -362,7 +464,7 @@ update_gitops: - `environment_detection`, `manual_environment` - Simplified to automatic detection only 2. **Inputs that became force-off overrides:** - - `deploy_in_firmino`, `deploy_in_clotilde`, `deploy_in_anacleto` (all default `true`) — only **subtract** clusters from the manifest-resolved set; cannot add a cluster the manifest does not list + - `deploy_in_anacleto` (default `true`) — only **subtracts** clusters from the manifest-resolved set; cannot add a cluster the manifest does not list 3. **New inputs:** - `deployment_matrix_file` (default: `config/deployment-matrix.yml`) — alternative manifest path for forks/testing @@ -380,11 +482,9 @@ update_gitops: > ⚠️ **Semantic change to `deploy_in_*` inputs** — callers that previously relied on `deploy_in_firmino: true` (etc.) to **include** a cluster will now silently deploy nowhere if their app is not listed in the manifest. The inputs only **subtract** from the manifest-resolved set; they never add. The prerequisite for any deployment is a manifest entry. Workflow logs a warning when `app_name` is missing from every cluster, so these cases surface quickly — but add your app to the manifest before merging this bump if you haven't already. -If your caller currently passes `deploy_in_firmino: true, deploy_in_clotilde: true` explicitly: - 1. Add your `app_name` to `apps.registry` and to the appropriate `clusters..apps` lists in [`config/deployment-matrix.yml`](../config/deployment-matrix.yml) (single PR in this repo). -2. Once merged and the caller bumps to the new shared-workflows ref (Renovate/Dependabot), the explicit `deploy_in_*: true` inputs become redundant and can be removed from the caller. -3. Keep `deploy_in_: false` only where you want to force-off a cluster the manifest declares. +2. Once merged and the caller bumps to the new shared-workflows ref (Renovate/Dependabot), any explicit `deploy_in_*: true` inputs become redundant and can be removed from the caller. +3. Keep `deploy_in_anacleto: false` only where you want to force-off that cluster. ## Troubleshooting @@ -394,11 +494,11 @@ This is normal if the tag already exists in the GitOps repository. The workflow ### Values file not found warnings -If you see warnings like "Values file not found for firmino/dev", this means the values.yaml file doesn't exist for that server/environment combination. The workflow will skip this combination and continue with others. +If you see warnings like "Values file not found for anacleto/chaos/dev-st", this means the values.yaml file doesn't exist for that server/environment combination. The workflow will skip this combination and continue with others. ### ArgoCD app does not exist -If you see warnings like "ArgoCD app firmino-myapp-dev does not exist, sync skipped", this means the ArgoCD application hasn't been created yet. The workflow will log a warning and continue. +If you see warnings like "ArgoCD app anacleto-myapp-chaos-dev-st does not exist, sync skipped", this means the ArgoCD application hasn't been created yet. The workflow will log a warning and continue. ### Artifact not found diff --git a/src/lint/deployment-matrix/action.yml b/src/lint/deployment-matrix/action.yml index f33f544a..694f637c 100644 --- a/src/lint/deployment-matrix/action.yml +++ b/src/lint/deployment-matrix/action.yml @@ -131,6 +131,92 @@ runs: err(f'clusters.{cluster_name}.apps lists "{app}" which is missing from apps.registry.') referenced_apps.add(app) + # ── env_suffixes (optional) ── + # List of strings; defaults to [""] (empty suffix) in the workflow. + # Allows clusters that host multiple parallel variants per environment + # (e.g. -st single-tenant + -mt multi-tenant on benedita). + env_suffixes = cluster_spec.get('env_suffixes') + if env_suffixes is not None: + if not isinstance(env_suffixes, list): + err(f'clusters.{cluster_name}.env_suffixes must be a list of strings.') + else: + if not env_suffixes: + err(f'clusters.{cluster_name}.env_suffixes is empty; omit the field or set ["", ...].') + seen_suffix = set() + for i, s in enumerate(env_suffixes): + if not isinstance(s, str): + err(f'clusters.{cluster_name}.env_suffixes[{i}] must be a string.') + continue + if s in seen_suffix: + err(f'Duplicate suffix "{s}" in clusters.{cluster_name}.env_suffixes.') + seen_suffix.add(s) + + # ── suffix_excludes_envs (optional) ── + # List of tag-derived env names that should NOT receive any suffix + # (e.g. ["sandbox"] keeps sandbox bare on a multi-variant cluster). + # Only meaningful when env_suffixes is set with non-empty suffixes. + suffix_excludes_envs = cluster_spec.get('suffix_excludes_envs') + valid_envs = {'dev', 'stg', 'prd', 'sandbox'} + if suffix_excludes_envs is not None: + if not isinstance(suffix_excludes_envs, list): + err(f'clusters.{cluster_name}.suffix_excludes_envs must be a list of env names.') + else: + seen_env = set() + for i, e in enumerate(suffix_excludes_envs): + if not isinstance(e, str) or not e.strip(): + err(f'clusters.{cluster_name}.suffix_excludes_envs[{i}] must be a non-empty string.') + continue + if e in seen_env: + err(f'Duplicate env "{e}" in clusters.{cluster_name}.suffix_excludes_envs.') + seen_env.add(e) + if e not in valid_envs: + warn(f'clusters.{cluster_name}.suffix_excludes_envs lists "{e}" which is not one of the tag-derived envs ({sorted(valid_envs)}); it will have no effect.') + if seen_env and not env_suffixes: + warn(f'clusters.{cluster_name}.suffix_excludes_envs is set but env_suffixes is not — suffix_excludes_envs will have no effect.') + + # ── env_contexts (optional) ── + # List of context prefix strings inserted before the env in the helmfile path. + # When set, path becomes applications/{context}/{env}/{app}/ instead of + # applications/{env}/{app}/. Combines with env_suffixes for full expansion. + env_contexts = cluster_spec.get('env_contexts') + if env_contexts is not None: + if not isinstance(env_contexts, list): + err(f'clusters.{cluster_name}.env_contexts must be a list of strings.') + else: + if not env_contexts: + err(f'clusters.{cluster_name}.env_contexts is empty; omit the field or provide at least one context name.') + seen_ctx = set() + for i, c in enumerate(env_contexts): + if not isinstance(c, str) or not c.strip(): + err(f'clusters.{cluster_name}.env_contexts[{i}] must be a non-empty string.') + continue + if '/' in c: + err(f'clusters.{cluster_name}.env_contexts[{i}] "{c}" must not contain "/" (it is a single path segment).') + if c in seen_ctx: + err(f'Duplicate context "{c}" in clusters.{cluster_name}.env_contexts.') + seen_ctx.add(c) + if env_contexts and not env_suffixes: + warn(f'clusters.{cluster_name}.env_contexts is set but env_suffixes is not — contexts will host bare envs (no suffix variants).') + + # ── app_helmfile_env (optional) ── + # Per-app override for the helmfile applications sub-directory. + # Existing field; validate keys reference apps in the cluster's app list. + app_helmfile_env = cluster_spec.get('app_helmfile_env') + if app_helmfile_env is not None: + if not isinstance(app_helmfile_env, dict): + err(f'clusters.{cluster_name}.app_helmfile_env must be a mapping of app -> env-name.') + else: + cluster_app_set = set(seen) + for k, v in app_helmfile_env.items(): + if not isinstance(k, str) or not k.strip(): + err(f'clusters.{cluster_name}.app_helmfile_env has a non-string key {k!r}.') + continue + if not isinstance(v, str) or not v.strip(): + err(f'clusters.{cluster_name}.app_helmfile_env["{k}"] must be a non-empty string.') + continue + if k not in cluster_app_set: + err(f'clusters.{cluster_name}.app_helmfile_env references app "{k}" which is not listed in clusters.{cluster_name}.apps.') + # ── orphans (warning only) ── orphans = registry_set - referenced_apps for app in sorted(orphans):