Capstone: Pipeline Completo de CI/CD

[319] 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

O Que Vem a Seguir

O próximo e último artigo do capstone — e da série — implementa as operações em produção: dashboards de observabilidade calibrados para a plataforma de e-commerce, alertas com runbooks detalhados, um experimento de Chaos Engineering executado no sistema completo e uma retrospectiva sobre a jornada de doze meses desta série.

Referências para Aprofundamento

GitHub Actions

GitOps e ArgoCD

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 do artigo anterior 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

Compute, Storage e Redes no Azure
Compute, Storage e Redes no Azure

Os serviços que formam a base de qualquer aplicação no Azure, com o detalhe…

Geradores de Senhas Seguras: Implementações em 5 Linguagens de Programação
Geradores de Senhas Seguras: Implementações em 5 Linguagens de Programação

Quinze implementações de gerador de senha em Go, Rust, JavaScript, PHP e…

Performance e FinOps: Otimizando Custo e Velocidade na Cloud
Performance e FinOps: Otimizando Custo e Velocidade na Cloud

Custo de cloud tratado como responsabilidade de engenharia: a estratégia de…