Capstone: Pipeline Completo de CI/CD

Capstone: Pipeline Completo de CI/CD

O pipeline tratado como produto de engenharia, em que o desenvolvedor é o usuário: a detecção de quais serviços mudaram no monorepo, o workflow reutilizável de CI com testes e cobertura mínima, o build com cache e SBOM, o deploy via GitOps com ArgoCD e a verificação de métricas pós-deploy.
DevOps

• • 26 min de leitura

Um pipeline de CI/CD de qualidade não é uma sequência de scripts colados — é um produto de engenharia com suas próprias preocupações de confiabilidade, manutenibilidade e experiência do usuário. O desenvolvedor que faz um push é o usuário desse produto. O feedback que recebe — rápido ou lento, claro ou confuso, confiável ou instável — determina diretamente a velocidade e a segurança com que o time entrega software.

O pipeline completo do capstone integra todas as práticas abordadas na série: testes automatizados com cobertura mínima, scanning de segurança em múltiplas camadas, build de imagens otimizado com cache, deploy via GitOps com ArgoCD, verificação pós-deploy com rollback automático e notificações contextuais. Para um monorepo com cinco serviços, o pipeline detecta inteligentemente quais serviços foram modificados e executa apenas o necessário — evitando o custo de buildar e deployar tudo a cada commit.

Detecção de Mudanças no Monorepo

O primeiro desafio de um monorepo é determinar quais serviços foram afetados por um conjunto de commits. A solução usa git diff para comparar os arquivos modificados com os prefixos de cada serviço:

# .github/workflows/detectar-mudancas.yml
# Workflow reutilizável — detecta quais serviços foram modificados
name: Detectar Mudanças

on:
  workflow_call:
    outputs:
      catalog:
        value: ${{ jobs.detectar.outputs.catalog }}
      user:
        value: ${{ jobs.detectar.outputs.user }}
      order:
        value: ${{ jobs.detectar.outputs.order }}
      notification:
        value: ${{ jobs.detectar.outputs.notification }}
      api-gateway:
        value: ${{ jobs.detectar.outputs.api-gateway }}
      infraestrutura:
        value: ${{ jobs.detectar.outputs.infraestrutura }}

jobs:
  detectar:
    runs-on: ubuntu-latest
    outputs:
      catalog:       ${{ steps.diff.outputs.catalog }}
      user:          ${{ steps.diff.outputs.user }}
      order:         ${{ steps.diff.outputs.order }}
      notification:  ${{ steps.diff.outputs.notification }}
      api-gateway:   ${{ steps.diff.outputs.api-gateway }}
      infraestrutura: ${{ steps.diff.outputs.infraestrutura }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detecta serviços modificados
        id: diff
        run: |
          # Compara com o commit anterior em push, ou com a base em PR
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE="${{ github.event.pull_request.base.sha }}"
          else
            BASE="${{ github.event.before }}"
            # Primeiro push — compara com HEAD~1
            if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
              BASE="HEAD~1"
            fi
          fi

          ARQUIVOS_MODIFICADOS=$(git diff --name-only "$BASE" HEAD)
          echo "Arquivos modificados:"
          echo "$ARQUIVOS_MODIFICADOS"

          # Verifica cada serviço
          for SERVICO in catalog user order notification api-gateway; do
            if echo "$ARQUIVOS_MODIFICADOS" | grep -q "^services/${SERVICO}-service/\|^packages/"; then
              echo "${SERVICO}=true" >> $GITHUB_OUTPUT
              echo "✓ ${SERVICO}-service: modificado"
            else
              echo "${SERVICO}=false" >> $GITHUB_OUTPUT
              echo "  ${SERVICO}-service: sem mudanças"
            fi
          done

          # Verifica infraestrutura
          if echo "$ARQUIVOS_MODIFICADOS" | grep -q "^infrastructure/"; then
            echo "infraestrutura=true" >> $GITHUB_OUTPUT
            echo "✓ infraestrutura: modificada"
          else
            echo "infraestrutura=false" >> $GITHUB_OUTPUT
          fi

Pipeline Principal de CI

# .github/workflows/ci.yml
name: CI — Integração Contínua

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true  # Cancela runs anteriores do mesmo branch

jobs:
  # ── Detecção de mudanças ──────────────────────────────────────────
  detectar:
    uses: ./.github/workflows/detectar-mudancas.yml

  # ── Segurança: secrets e SAST ─────────────────────────────────────
  seguranca-base:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detecta segredos com Gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Análise estática com Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: >
            p/nodejs
            p/secrets
            p/owasp-top-ten
            .semgrep/regras-customizadas.yml

      - name: Verifica IaC com Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: infrastructure/
          framework: terraform,kubernetes,dockerfile
          soft_fail: false

  # ── CI de cada serviço (executado em paralelo) ────────────────────
  ci-catalog:
    needs: [detectar, seguranca-base]
    if: needs.detectar.outputs.catalog == 'true'
    uses: ./.github/workflows/ci-servico.yml
    with:
      servico: catalog
      porta: 3001
    secrets: inherit

  ci-user:
    needs: [detectar, seguranca-base]
    if: needs.detectar.outputs.user == 'true'
    uses: ./.github/workflows/ci-servico.yml
    with:
      servico: user
      porta: 3002
    secrets: inherit

  ci-order:
    needs: [detectar, seguranca-base]
    if: needs.detectar.outputs.order == 'true'
    uses: ./.github/workflows/ci-servico.yml
    with:
      servico: order
      porta: 3003
    secrets: inherit

  ci-notification:
    needs: [detectar, seguranca-base]
    if: needs.detectar.outputs.notification == 'true'
    uses: ./.github/workflows/ci-servico.yml
    with:
      servico: notification
      porta: 3004
    secrets: inherit

  ci-api-gateway:
    needs: [detectar, seguranca-base]
    if: needs.detectar.outputs.api-gateway == 'true'
    uses: ./.github/workflows/ci-servico.yml
    with:
      servico: api-gateway
      porta: 3000
    secrets: inherit

  # ── Gate de qualidade — só prossegue se tudo passou ───────────────
  ci-concluido:
    needs: [ci-catalog, ci-user, ci-order, ci-notification, ci-api-gateway]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Verifica resultado de todos os CIs
        run: |
          RESULTADOS=(
            "${{ needs.ci-catalog.result }}"
            "${{ needs.ci-user.result }}"
            "${{ needs.ci-order.result }}"
            "${{ needs.ci-notification.result }}"
            "${{ needs.ci-api-gateway.result }}"
          )

          for RESULTADO in "${RESULTADOS[@]}"; do
            if [ "$RESULTADO" = "failure" ]; then
              echo "::error::CI falhou — deploy bloqueado"
              exit 1
            fi
          done

          echo "✅ Todos os CIs passaram ou foram pulados"

Workflow Reutilizável de CI por Serviço

# .github/workflows/ci-servico.yml
name: CI Serviço (Reutilizável)

on:
  workflow_call:
    inputs:
      servico:
        required: true
        type: string
      porta:
        required: true
        type: number

jobs:
  testar:
    runs-on: ubuntu-latest
    name: Testar ${{ inputs.servico }}-service

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_password
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
        ports:
          - 5432:5432

      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
        ports:
          - 6379:6379

    defaults:
      run:
        working-directory: services/${{ inputs.servico }}-service

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: |
            services/${{ inputs.servico }}-service/package-lock.json
            packages/core/package-lock.json

      - name: Instala dependências do core
        run: npm ci
        working-directory: packages/core

      - name: Instala dependências do serviço
        run: npm ci

      - name: Verifica lint
        run: npm run lint

      - name: Executa testes unitários
        run: npm run test:unit -- --coverage

      - name: Executa testes de integração
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
          REDIS_URL:    redis://localhost:6379
          NODE_ENV:     test

      - name: Verifica cobertura mínima
        run: |
          COBERTURA=$(cat coverage/coverage-summary.json \
            | jq '.total.lines.pct')
          echo "Cobertura: ${COBERTURA}%"

          if (( $(echo "$COBERTURA < 80" | bc -l) )); then
            echo "::error::Cobertura abaixo do mínimo: ${COBERTURA}% (mínimo: 80%)"
            exit 1
          fi

      - name: Upload cobertura para Codecov
        uses: codecov/codecov-action@v4
        with:
          flags: ${{ inputs.servico }}
          token: ${{ secrets.CODECOV_TOKEN }}

  construir-imagem:
    runs-on: ubuntu-latest
    needs: testar
    name: Construir imagem ${{ inputs.servico }}-service
    outputs:
      imagem: ${{ steps.build.outputs.imagem }}
      digest: ${{ steps.build.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache-${{ inputs.servico }}
          key: ${{ runner.os }}-buildx-${{ inputs.servico }}-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-${{ inputs.servico }}-

      - name: Extrai metadados da imagem
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository_owner }}/${{ inputs.servico }}-service
          tags: |
            type=sha,prefix=sha-,format=short
            type=ref,event=branch
            type=semver,pattern={{version}}

      - name: Constrói e publica imagem
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          file: services/${{ inputs.servico }}-service/Dockerfile
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=local,src=/tmp/.buildx-cache-${{ inputs.servico }}
          cache-to: type=local,dest=/tmp/.buildx-cache-${{ inputs.servico }}-new,mode=max
          provenance: true
          sbom: true  # Gera Software Bill of Materials

      - name: Rotaciona cache do Buildx
        run: |
          rm -rf /tmp/.buildx-cache-${{ inputs.servico }}
          mv /tmp/.buildx-cache-${{ inputs.servico }}-new \
             /tmp/.buildx-cache-${{ inputs.servico }}

      - name: Escaneia imagem com Trivy
        if: github.event_name != 'pull_request'
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/${{ github.repository_owner }}/${{ inputs.servico }}-service:sha-${{ github.sha }}
          format: sarif
          output: trivy-${{ inputs.servico }}.sarif
          severity: CRITICAL,HIGH
          exit-code: '1'
          ignore-unfixed: true

      - name: Upload resultados Trivy
        if: always() && github.event_name != 'pull_request'
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-${{ inputs.servico }}.sarif

      - name: Exporta outputs
        id: exportar
        run: |
          IMAGEM="ghcr.io/${{ github.repository_owner }}/${{ inputs.servico }}-service:sha-${{ github.sha }}"
          echo "imagem=${IMAGEM}" >> $GITHUB_OUTPUT

Pipeline de Deploy via GitOps

# .github/workflows/deploy.yml
name: Deploy — Produção

on:
  push:
    branches: [main]

jobs:
  # ── Reutiliza o CI completo ────────────────────────────────────────
  detectar:
    uses: ./.github/workflows/detectar-mudancas.yml

  ci:
    needs: detectar
    uses: ./.github/workflows/ci.yml
    secrets: inherit

  # ── Deploy em staging primeiro ────────────────────────────────────
  deploy-staging:
    needs: ci
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.loja.empresa.com
    outputs:
      sha-curto: ${{ steps.vars.outputs.sha-curto }}

    steps:
      - uses: actions/checkout@v4

      - name: Exporta variáveis
        id: vars
        run: echo "sha-curto=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

      - name: Clona repositório GitOps
        uses: actions/checkout@v4
        with:
          repository: empresa/gitops-loja
          token: ${{ secrets.GITOPS_TOKEN }}
          path: gitops

      - name: Atualiza imagens no staging
        run: |
          cd gitops
          SHA="${{ steps.vars.outputs.sha-curto }}"

          # Atualiza a tag de cada serviço modificado
          for SERVICO in catalog user order notification api-gateway; do
            MODIFICADO="needs.detectar.outputs.${SERVICO}"
            if [ "${!MODIFICADO}" = "true" ] || true; then
              cd workloads/${SERVICO}-service/staging
              kustomize edit set image \
                ghcr.io/empresa/${SERVICO}-service:sha-${SHA}
              cd ../../..
            fi
          done

          git config user.email "pipeline@empresa.com"
          git config user.name "Pipeline CI/CD"
          git add .
          git diff --staged --quiet || git commit -m \
            "chore(staging): deploy sha-${SHA}

          Serviços atualizados pelo pipeline.
          Repositório: ${{ github.repository }}
          Run: ${{ github.run_id }}"
          git push

      - name: Aguarda sincronização do ArgoCD em staging
        run: |
          # Instala argocd CLI
          curl -sSL -o argocd \
            https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
          chmod +x argocd
          sudo mv argocd /usr/local/bin/

          argocd login ${{ vars.ARGOCD_SERVER }} \
            --auth-token ${{ secrets.ARGOCD_TOKEN }} \
            --grpc-web

          # Aguarda cada aplicação sincronizar e ficar saudável
          for SERVICO in catalog user order notification api-gateway; do
            argocd app wait loja-staging-${SERVICO} \
              --sync \
              --health \
              --timeout 300
          done

      - name: Executa smoke tests em staging
        run: |
          BASE_URL="https://staging.loja.empresa.com"

          echo "Verificando saúde dos serviços em staging..."

          verificar() {
            local URL="$1"
            local DESCRICAO="$2"
            local STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
              --max-time 10 "$URL")

            if [ "$STATUS" = "200" ]; then
              echo "  ✓ $DESCRICAO ($STATUS)"
            else
              echo "  ✗ $DESCRICAO ($STATUS)"
              return 1
            fi
          }

          verificar "${BASE_URL}/api/health"        "API Gateway health"
          verificar "${BASE_URL}/api/v1/produtos"   "Catálogo — listar produtos"
          verificar "${BASE_URL}/api/v1/health/live" "Order service liveness"

          echo "✅ Smoke tests em staging passaram"

  # ── Deploy em produção com aprovação ─────────────────────────────
  deploy-producao:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://loja.empresa.com

    steps:
      - uses: actions/checkout@v4

      - name: Clona repositório GitOps
        uses: actions/checkout@v4
        with:
          repository: empresa/gitops-loja
          token: ${{ secrets.GITOPS_TOKEN }}
          path: gitops

      - name: Atualiza imagens em produção
        run: |
          cd gitops
          SHA="${{ needs.deploy-staging.outputs.sha-curto }}"

          for SERVICO in catalog user order notification api-gateway; do
            cd workloads/${SERVICO}-service/producao
            kustomize edit set image \
              ghcr.io/empresa/${SERVICO}-service:sha-${SHA}
            cd ../../..
          done

          git config user.email "pipeline@empresa.com"
          git config user.name "Pipeline CI/CD"
          git add .
          git diff --staged --quiet || git commit -m \
            "chore(producao): deploy sha-${SHA}

          Deploy promovido do staging após aprovação.
          Aprovador: ${{ github.actor }}
          Run: ${{ github.run_id }}"
          git push

      - name: Aguarda sincronização do ArgoCD em produção
        run: |
          argocd login ${{ vars.ARGOCD_SERVER }} \
            --auth-token ${{ secrets.ARGOCD_TOKEN }} \
            --grpc-web

          for SERVICO in catalog user order notification api-gateway; do
            echo "Aguardando loja-producao-${SERVICO}..."
            argocd app wait loja-producao-${SERVICO} \
              --sync \
              --health \
              --timeout 600
          done

      - name: Verifica métricas pós-deploy
        id: verificar-metricas
        run: |
          # Aguarda 2 minutos para métricas estabilizarem
          echo "Aguardando estabilização das métricas..."
          sleep 120

          PROMETHEUS="${{ vars.PROMETHEUS_URL }}"

          # Verifica taxa de erros (deve ser < 1%)
          TAXA_ERROS=$(curl -s "${PROMETHEUS}/api/v1/query" \
            --data-urlencode 'query=
              sum(rate(http_requests_total{
                status=~"5..",
                namespace="producao"
              }[2m]))
              /
              sum(rate(http_requests_total{
                namespace="producao"
              }[2m])) * 100
            ' | jq '.data.result[0].value[1] | tonumber')

          echo "Taxa de erros: ${TAXA_ERROS}%"

          # Verifica latência p99 (deve ser < 1s)
          LATENCIA_P99=$(curl -s "${PROMETHEUS}/api/v1/query" \
            --data-urlencode 'query=
              histogram_quantile(0.99,
                sum(rate(http_request_duration_seconds_bucket{
                  namespace="producao"
                }[2m])) by (le)
              )
            ' | jq '.data.result[0].value[1] | tonumber')

          echo "Latência p99: ${LATENCIA_P99}s"

          # Falha se métricas estiverem fora do threshold
          if (( $(echo "$TAXA_ERROS > 1" | bc -l) )); then
            echo "::error::Taxa de erros elevada: ${TAXA_ERROS}%"
            echo "rollback=true" >> $GITHUB_OUTPUT
            exit 1
          fi

          if (( $(echo "$LATENCIA_P99 > 1" | bc -l) )); then
            echo "::error::Latência p99 elevada: ${LATENCIA_P99}s"
            echo "rollback=true" >> $GITHUB_OUTPUT
            exit 1
          fi

          echo "✅ Métricas pós-deploy dentro dos thresholds"
          echo "rollback=false" >> $GITHUB_OUTPUT

      - name: Rollback automático em caso de falha
        if: failure() && steps.verificar-metricas.outputs.rollback == 'true'
        run: |
          echo "⚠️  Iniciando rollback automático..."

          argocd login ${{ vars.ARGOCD_SERVER }} \
            --auth-token ${{ secrets.ARGOCD_TOKEN }} \
            --grpc-web

          for SERVICO in catalog user order notification api-gateway; do
            argocd app rollback loja-producao-${SERVICO} --hard-refresh || true
          done

          echo "Rollback concluído"

      - name: Notifica resultado do deploy
        if: always()
        uses: slackapi/slack-github-action@v1.26.0
        with:
          channel-id: ${{ vars.SLACK_DEPLOYS_CHANNEL }}
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && '✅ Deploy em Produção — Sucesso' || '❌ Deploy em Produção — Falha' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*SHA:*\n`${{ needs.deploy-staging.outputs.sha-curto }}`"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Deployado por:*\n${{ github.actor }}"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Branch:*\n${{ github.ref_name }}"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Status:*\n${{ job.status }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "Ver Pipeline" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    },
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "Grafana" },
                      "url": "${{ vars.GRAFANA_URL }}/d/loja-overview"
                    }
                  ]
                }
              ]
            }
        env:
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

Pipeline de Infraestrutura

# .github/workflows/deploy-infraestrutura.yml
name: Deploy — Infraestrutura

on:
  push:
    branches: [main]
    paths:
      - 'infrastructure/terraform/**'
  pull_request:
    branches: [main]
    paths:
      - 'infrastructure/terraform/**'
  workflow_dispatch:
    inputs:
      ambiente:
        description: 'Ambiente alvo'
        required: true
        type: choice
        options: [staging, production]
      acao:
        description: 'Ação Terraform'
        required: true
        type: choice
        options: [plan, apply]
        default: plan

jobs:
  validar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "~1.7"

      - name: Terraform Format Check
        run: terraform fmt -check -recursive infrastructure/

      - name: Terraform Validate — Staging
        run: |
          cd infrastructure/terraform/environments/staging
          terraform init -backend=false
          terraform validate

      - name: Terraform Validate — Production
        run: |
          cd infrastructure/terraform/environments/production
          terraform init -backend=false
          terraform validate

      - name: Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: infrastructure/terraform/
          framework: terraform

  plan-staging:
    needs: validar
    runs-on: ubuntu-latest
    environment: staging
    if: github.event_name == 'pull_request' || inputs.ambiente == 'staging'

    permissions:
      contents: read
      id-token: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_TERRAFORM_ROLE_ARN }}
          aws-region: us-east-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "~1.7"

      - name: Terraform Init
        run: |
          cd infrastructure/terraform/environments/staging
          terraform init \
            -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}" \
            -backend-config="key=staging/terraform.tfstate" \
            -backend-config="region=us-east-1" \
            -backend-config="dynamodb_table=${{ vars.TF_LOCK_TABLE }}"

      - name: Terraform Plan
        id: plan
        run: |
          cd infrastructure/terraform/environments/staging
          terraform plan \
            -var="environment=staging" \
            -no-color \
            -out=tfplan \
            2>&1 | tee plan-output.txt

          # Extrai sumário do plano
          echo "SUMARIO<<EOF" >> $GITHUB_ENV
          grep -E "^Plan:|^No changes" plan-output.txt || echo "Plano gerado"
          echo "EOF" >> $GITHUB_ENV

      - name: Comenta plano no PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const planOutput = fs.readFileSync(
              'infrastructure/terraform/environments/staging/plan-output.txt',
              'utf8'
            );

            // Trunca se muito longo
            const truncated = planOutput.length > 65000
              ? planOutput.substring(0, 65000) + '\n... (truncado)'
              : planOutput;

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Terraform Plan — Staging\n\`\`\`\n${truncated}\n\`\`\``
            });

  apply-producao:
    needs: validar
    runs-on: ubuntu-latest
    environment: production
    if: |
      github.event_name == 'push' && github.ref == 'refs/heads/main' ||
      (inputs.ambiente == 'production' && inputs.acao == 'apply')

    permissions:
      contents: read
      id-token: write

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_TERRAFORM_ROLE_ARN }}
          aws-region: us-east-1

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Init
        run: |
          cd infrastructure/terraform/environments/production
          terraform init \
            -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}" \
            -backend-config="key=production/terraform.tfstate" \
            -backend-config="region=us-east-1"

      - name: Terraform Plan
        run: |
          cd infrastructure/terraform/environments/production
          terraform plan \
            -var="environment=production" \
            -out=tfplan

      - name: Terraform Apply
        run: |
          cd infrastructure/terraform/environments/production
          terraform apply -auto-approve tfplan

Configuração do ArgoCD para o Capstone

# infrastructure/kubernetes/platform/argocd/apps-loja.yaml
# App of Apps — ArgoCD gerencia todas as aplicações da loja
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: loja-apps
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/empresa/gitops-loja
    targetRevision: main
    path: apps
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
---
# apps/loja-producao-order.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: loja-producao-order
  namespace: argocd
spec:
  project: loja-producao

  source:
    repoURL: https://github.com/empresa/gitops-loja
    targetRevision: main
    path: workloads/order-service/producao

  destination:
    server: https://kubernetes.default.svc
    namespace: producao

  syncPolicy:
    automated:
      prune: true
      selfHeal: true
      allowEmpty: false
    syncOptions:
      - CreateNamespace=false
      - PrunePropagationPolicy=foreground
    retry:
      limit: 3
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m

  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers:
        - /spec/replicas  # Gerenciado pelo HPA

Pipeline de monorepo vive ou morre pela detecção de mudanças: sem ela, alterar o README dispara build e deploy dos três serviços, e o tempo de espera ensina o time a agrupar commits — exatamente o oposto do que a integração contínua pretende. A separação entre CI e entrega via GitOps completa o quadro, com o pipeline atualizando o manifesto e o ArgoCD reconciliando o cluster, de modo que o estado desejado continua sendo o repositório e não o último comando que alguém rodou.

Fontes e leituras recomendadas

GitHub Actions

GitOps e ArgoCD

Segurança e ordem de aplicação

Exercícios

Exercício 1

Todo o trabalho do workflow detectar-mudancas serve para deployar apenas os serviços modificados. Examine o loop que consome esse resultado no deploy-staging:

for SERVICO in catalog user order notification api-gateway; do
  MODIFICADO="needs.detectar.outputs.${SERVICO}"
  if [ "${!MODIFICADO}" = "true" ] || true; then
    cd workloads/${SERVICO}-service/staging
    kustomize edit set image ghcr.io/empresa/${SERVICO}-service:sha-${SHA}
    cd ../../..
  fi
done

Um commit altera apenas services/catalog-service/src/. O que esse loop faz — e qual é a consequência no cluster?

Ver resposta

✓ Resposta: Ele atualiza a tag de todos os cinco serviços, e quatro deles passam a apontar para uma imagem que não existe — resultando em ImagePullBackOff em produção.

São dois defeitos somados. O primeiro é o || true: em shell, if [ … ] || true; then avalia a condição, descarta o resultado e prossegue sempre, porque true nunca falha. A verificação é decorativa. O segundo é a expansão: needs.detectar.outputs.catalog é uma expressão do GitHub Actions, avaliada antes do shell existir — ela precisaria estar dentro de ${{ }}. Como string de shell, ${!MODIFICADO} tenta uma indireção sobre um nome com pontos, que não é identificador válido, e devolve vazio. Ou seja: mesmo sem o || true, a condição seria sempre falsa, e nada seria deployado. Os dois erros se cancelam para produzir "sempre verdadeiro".

O encadeamento com o CI é o que torna isso grave. Os jobs ci-user, ci-order, ci-notification e ci-api-gateway têm if: needs.detectar.outputs.X == 'true' e são pulados — corretamente, pois nada mudou neles. Logo, suas imagens sha-{commit} nunca foram construídas nem publicadas. O loop de deploy, ignorando tudo isso, grava essa tag inexistente no repositório GitOps; o ArgoCD sincroniza obediente; e o kubelet tenta baixar quatro imagens que não estão no registry.

O sintoma é cruel: o serviço que de fato mudou entra no ar sem problemas, e os quatro que ninguém tocou caem. Como o Deployment usa maxUnavailable: 0, os pods antigos permanecem servindo — então o site não sai do ar, mas os rollouts ficam presos e todo deploy seguinte é bloqueado.

A correção usa a expressão do Actions diretamente, sem passar pelo shell:

      - name: Atualiza imagem do catalog
        if: needs.detectar.outputs.catalog == 'true'
        run: |
          cd gitops/workloads/catalog-service/staging
          kustomize edit set image ghcr.io/empresa/catalog-service:sha-${SHA}

Ou, mantendo o loop, montando a lista de serviços modificados como saída do próprio job de detecção — uma string que o shell possa percorrer. Repare que o deploy-producao nem tenta filtrar: percorre os cinco serviços incondicionalmente, com o mesmo efeito.

Exercício 2

No ci-servico.yml, o passo de build usa push: ${{ github.event_name != 'pull_request' }} e, logo em seguida, o Trivy escaneia a imagem com exit-code: '1'. O Trivy encontra uma CVE crítica e o job falha. A imagem vulnerável está no registry?

Ver resposta

✓ Resposta: Sim. O docker/build-push-action com push: true publica a imagem no ghcr.io como parte da sua própria execução — o Trivy só roda depois, e por isso precisa referenciar a imagem por image-ref no registry. Quando o escaneamento reprova, a publicação já aconteceu e é irreversível pelo pipeline.

O gate existe e funciona no sentido de barrar o deploy: o job falha, o ci-concluido reprova e nenhum manifesto GitOps é atualizado. Mas a imagem fica disponível para quem souber o nome. Qualquer pessoa ou processo com acesso de leitura ao registry pode referenciá-la — um kubectl set image manual durante um incidente, um manifesto de outro ambiente, uma equipe que copiou a tag do log do pipeline. O artefato reprovado passa a existir e a ser puxável indefinidamente.

A ordem correta é escanear antes de publicar. Com o Buildx isso se resolve carregando a imagem localmente no runner primeiro:

      - name: Constrói (sem publicar)
        uses: docker/build-push-action@v5
        with:
          context: .
          load: true            # carrega no daemon local do runner
          push: false
          tags: imagem-candidata:${{ github.sha }}

      - name: Escaneia com Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: imagem-candidata:${{ github.sha }}
          severity: CRITICAL,HIGH
          exit-code: '1'
          ignore-unfixed: true

      - name: Publica (só se o scan passou)
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}

O segundo build reaproveita integralmente o cache do primeiro, então o custo em tempo é pequeno.

Vale notar que o pipeline já faz duas coisas certas nessa etapa e que ajudam justamente no cenário em que algo escapa: provenance: true e sbom: true anexam à imagem a origem da construção e a lista de componentes. Com o SBOM publicado, quando uma CVE nova aparecer amanhã em uma biblioteca, é possível descobrir quais imagens já publicadas a contêm sem reconstruir nada — que é exatamente a pergunta difícil de responder num incidente de supply chain.

Exercício 3

O workflow ci.yml declara concurrency com cancel-in-progress: true. O deploy.yml, que roda a cada push em main, não declara concurrency alguma. Dois PRs são mergeados com 40 segundos de diferença. O que pode acontecer?

Ver resposta

✓ Resposta: Dois pipelines de deploy correm em paralelo e disputam o mesmo repositório GitOps, com dois desfechos possíveis — nenhum bom.

O mais provável é a falha de push. Ambos clonam gitops-loja no mesmo commit base, ambos rodam kustomize edit set image, ambos commitam. O primeiro a chegar empurra com sucesso; o segundo recebe rejected — non-fast-forward, porque o remoto avançou. Como não há git pull --rebase nem retry no script, o passo falha e o deploy do segundo merge simplesmente não acontece. O pior é o modo como isso se manifesta: o merge foi feito, o CI passou, e a mudança nunca chega a produção — sem que ninguém procure por ela.

O desfecho alternativo é a inversão de versões. Se o segundo pipeline for mais rápido em alguma etapa e vencer a corrida, o mais antigo pode sobrescrever a tag mais nova, colocando em produção o commit anterior. O Git registra a sequência correta de merges e o cluster roda outra coisa.

A correção é serializar os deploys, e o próprio GitHub oferece o mecanismo — sem cancel-in-progress, que aqui seria perigoso:

concurrency:
  group: deploy-producao
  cancel-in-progress: false   # enfileira; nunca cancela um deploy em andamento

A distinção entre os dois workflows é justamente essa. No CI, cancelar o run anterior é desejável: ninguém se importa com o resultado dos testes de um commit que já foi substituído. No deploy, cancelar significa interromper uma promoção pela metade — imagens atualizadas no Git sem verificação pós-deploy, ou um rollback abortado no meio. Deploy se enfileira, não se cancela.

Como defesa em profundidade, vale também tornar o push resiliente, já que o repositório GitOps é escrito por vários pipelines:

for tentativa in 1 2 3; do
  git pull --rebase origin main && git push origin main && break
  sleep $((tentativa * 5))
done

Exercício 4

Quando as métricas pós-deploy reprovam, o pipeline executa:

argocd app rollback loja-producao-${SERVICO} --hard-refresh || true

As Applications têm syncPolicy.automated com selfHeal: true, e o repositório GitOps continua apontando para a tag defeituosa. Esse rollback resolve a situação?

Ver resposta

✓ Resposta: Ele tira a versão ruim do ar, mas deixa o sistema em um estado degradado e silencioso — e o problema real, que é o conteúdo do Git, permanece.

O argocd app rollback reverte o cluster para uma revisão anterior do histórico da Application e, para conseguir manter esse estado, desativa o auto-sync — do contrário o selfHeal reimporia o que está no Git em segundos. O resultado imediato é o desejado: os pods voltam à versão boa. O resultado duradouro é que as cinco Applications ficam fora do GitOps, com o Git divergindo do cluster e sem ninguém reconciliando.

As consequências aparecem depois. O próximo merge em main atualiza o repositório GitOps normalmente e o pipeline reporta sucesso — mas o ArgoCD não sincroniza, porque o auto-sync está desligado. Os deploys param de chegar a produção sem qualquer erro, até alguém perceber que o cluster está congelado há dias. E enquanto isso o Git segue afirmando que a versão defeituosa é a desejada, de modo que qualquer reativação do auto-sync — por um terraform apply nos manifestos, ou por alguém "consertando" a Application — reintroduz o bug em produção.

Em GitOps, o rollback correto acontece no Git, porque é ele a fonte da verdade:

cd gitops
git revert --no-edit HEAD          # desfaz o commit de deploy
git push origin main               # ArgoCD sincroniza sozinho, auto-sync intacto

Isso preserva o modelo inteiro: o histórico registra a reversão com autor e horário, o auto-sync continua ativo, e o estado do cluster volta a ser dedutível do repositório. É mais lento que o rollback imperativo — depende do ciclo de sincronização —, e por isso vale reduzir o timeout de reconciliação em vez de contornar o mecanismo.

Há ainda o || true ao final do comando, que merece atenção pelo mesmo motivo do exercício 1: ele engole a falha do rollback. Se o comando não funcionar — token expirado, aplicação inexistente, timeout —, o passo termina com sucesso e o log de conclusão anuncia "Rollback concluído" sem que nada tenha sido revertido. Num caminho de emergência, mascarar erro é o oposto do que se quer.

Exercício 5

Compare como a infraestrutura é aplicada em cada ambiente. Em staging, o job plan-staging gera um plano e o publica como comentário no PR. Em produção, o job apply-producao dispara com github.event_name == 'push' && github.ref == 'refs/heads/main' e executa terraform apply -auto-approve tfplan. Que garantia existe — e qual falta?

Ver resposta

✓ Resposta: A garantia que existe é o environment: production do GitHub, que pode exigir aprovadores obrigatórios antes de o job iniciar. O que falta é qualquer garantia no código de que essa proteção esteja configurada — e, mais importante, de que o que foi aprovado seja o que será aplicado.

O environment é definido na interface do repositório, não no arquivo de workflow. Um projeto novo, um repositório clonado ou uma configuração revertida deixam o job idêntico, mas sem nenhum gate — e a partir daí todo merge em main que toque infrastructure/terraform/** aplica automaticamente em produção. Nada no YAML denuncia a diferença, e o script de Capstone: Provisionando a Infraestrutura Completa mostrava a alternativa explícita: uma confirmação digitada antes do apply.

O ponto mais sutil é o plano que ninguém revisou. O plan-staging só roda para staging, e comenta no PR o plano daquele ambiente. O job de produção gera o seu próprio plano no instante do apply e o aplica na sequência. Se o aprovador clicou em "approve" olhando o plano de staging, ele aprovou outra coisa: a produção tem multi-AZ, instâncias maiores, deletion_protection e um estado diferente. A revisão não cobre o que é executado.

O padrão que fecha essa lacuna separa plan e apply em jobs, com o gate entre eles, e transporta o arquivo de plano como artefato:

  plan-producao:
    steps:
      - run: terraform plan -var="environment=production" -out=tfplan
      - uses: actions/upload-artifact@v4
        with: { name: tfplan-producao, path: infrastructure/…/tfplan }

  apply-producao:
    needs: plan-producao
    environment: production          # aprovação acontece AQUI
    steps:
      - uses: actions/download-artifact@v4
        with: { name: tfplan-producao }
      - run: terraform apply tfplan   # aplica exatamente o plano aprovado

Um plano salvo é imutável: se a infraestrutura tiver mudado nesse meio-tempo, o apply recusa executá-lo em vez de aplicar algo diferente do revisado. É a diferença entre aprovar uma intenção e aprovar uma ação.

Repare, por fim, que o terraform init de produção não passa dynamodb_table, enquanto o de staging passa. Sem a tabela de lock, dois apply simultâneos podem corromper o state — e o gatilho por push em main, sem concurrency declarada, é precisamente o que permite dois runs ao mesmo tempo.

Comentários

Mais em DevOps

Seus Primeiros Recursos na AWS com Terraform
Seus Primeiros Recursos na AWS com Terraform

Uma infraestrutura AWS completa provisionada inteiramente por código: a…

Introdução ao Monitoramento: Métricas, Logs e Traces
Introdução ao Monitoramento: Métricas, Logs e Traces

Os três pilares da observabilidade e a stack que os sustenta: métricas com…

Capstone: Operações em Produção e Retrospectiva da Jornada
Capstone: Operações em Produção e Retrospectiva da Jornada

O sistema não termina no último deploy — começa: dashboards com as métricas…