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
- GitHub Actions Documentation — docs.github.com — Documentação completa do GitHub Actions com referência de sintaxe de workflows, contexts, expressions e os runners disponíveis.
- Reusable Workflows — docs.github.com — Guia completo de workflows reutilizáveis no GitHub Actions com exemplos de passagem de inputs, outputs e secrets entre workflows.
GitOps e ArgoCD
- ArgoCD App of Apps Pattern — argo-cd.readthedocs.io — Documentação do padrão App of Apps no ArgoCD para gerenciamento de múltiplas aplicações a partir de um único repositório GitOps.
- GitOps with ArgoCD and Kustomize — codefresh.io — Guia prático de GitOps combinando ArgoCD e Kustomize para gerenciamento de múltiplos ambientes com overlays.
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.