luizmachado.dev

PT EN

Sessão 010 — CDK Pipelines: Stages customizados, ShellSteps e self-mutation em ação

Duração estimada: 60 minutos
Pré-requisitos: session-009 — CDK Pipelines: bootstrap cross-account, OIDC connection e estrutura da pipeline


Objetivo

Ao final, você conseguirá adicionar um Stage com múltiplos stacks em sequência e em paralelo, inserir ShellSteps de validação entre stages (ex: smoke tests pós-deploy), observar o ciclo de self-mutation (a pipeline re-executa a si mesma quando o código da pipeline muda antes de qualquer deploy de aplicação), e debugar um deploy que falha na fase de publicação de assets.


Contexto

[FATO] A sessão anterior cobriu a estrutura mínima da pipeline (Source → Build → UpdatePipeline). Esta sessão aprofunda os blocos que vêm depois: como organizar stages, como adicionar validações entre eles, e como usar outputs de stacks deployados em steps subsequentes.

[CONSENSO] O padrão recomendado para pipelines de produção é: deploy em dev → smoke tests automáticos → aprovação manual → deploy em prod → smoke tests automáticos. O CDK Pipelines tem suporte nativo a cada um desses passos via pre, post, ShellStep e ManualApprovalStep.


Conceitos principais

1. Stages sequenciais e em paralelo — addStage e Wave

Por padrão, stages adicionados com addStage são executados sequencialmente:

// Sequencial: dev termina antes de staging começar
pipeline.addStage(new AppStage(this, 'Dev',     { env: devEnv }));
pipeline.addStage(new AppStage(this, 'Staging', { env: stagingEnv }));
pipeline.addStage(new AppStage(this, 'Prod',    { env: prodEnv }));

Para stages em paralelo, use Wave:

// Wave: eu-west-1 e ap-southeast-1 deployam ao mesmo tempo
const multiRegionWave = pipeline.addWave('MultiRegionDeploy');

multiRegionWave.addStage(new AppStage(this, 'ProdEU', {
  env: { account: PROD_ACCOUNT, region: 'eu-west-1' },
}));

multiRegionWave.addStage(new AppStage(this, 'ProdAP', {
  env: { account: PROD_ACCOUNT, region: 'ap-southeast-1' },
}));

// Depois da wave, um stage sequencial aguarda ambas terminarem
pipeline.addStage(new MonitoringStage(this, 'GlobalMonitoring', {
  env: { account: TOOLS_ACCOUNT, region: 'us-east-1' },
}));

Diagrama de execução:

Sequential:                         Wave (parallel):
  Dev  ──► Staging ──► Prod           ┌── ProdEU ──┐
  (uma de cada vez)                   │             ├──► GlobalMonitoring
                                      └── ProdAP ──┘
                                      (simultâneos)

Ordem de stacks dentro de um Stage:

[FATO] Dentro de um Stage com múltiplos stacks, o CDK determina a ordem automaticamente com base nas dependências entre os stacks. Stacks independentes são deployados em paralelo; stacks com dependências são ordenados corretamente.

export class AppStage extends cdk.Stage {
  constructor(scope: Construct, id: string, props: cdk.StageProps) {
    super(scope, id, props);

    const network = new NetworkStack(this, 'Network');
    const data    = new DataStack(this, 'Data', { vpc: network.vpc });
    const app     = new AppStack(this, 'App',  { vpc: network.vpc, table: data.table });
    // CDK infere: Network → (Data e App em paralelo, depois App espera Data)
    // Network deploya primeiro; Data e App deployam depois em paralelo se não há dep entre eles
  }
}

Para forçar dependência explícita entre stacks no mesmo Stage:

app.addDependency(data);   // garante que Data termina antes de App começar

2. ShellStep — validações entre stages

ShellStep é o bloco fundamental para inserir comandos shell em qualquer ponto da pipeline. Pode ser usado como pre (antes do deploy do stage) ou post (depois do deploy).

import * as pipelines from 'aws-cdk-lib/pipelines';

// Pre-step: roda antes do deploy do stage
pipeline.addStage(new AppStage(this, 'Dev', { env: devEnv }), {
  pre: [
    new pipelines.ShellStep('LintAndTest', {
      commands: [
        'npm ci',
        'npm run lint',
        'npm test',
      ],
    }),
  ],
  post: [
    new pipelines.ShellStep('SmokeTest', {
      commands: [
        // Acessa a URL da aplicação e verifica o health endpoint
        'curl -f $APP_URL/health || exit 1',
        'echo "Smoke test passed"',
      ],
      envFromCfnOutputs: {
        APP_URL: appStageRef.appUrlOutput,  // output da stack (ver seção 4)
      },
    }),
  ],
});

Casos de uso comuns para ShellStep:

PRE-DEPLOY:
  ✅ Lint e testes unitários antes de deployar
  ✅ Validação de segurança (checkov, cfn-nag, cfn-guard)
  ✅ Geração de documentação ou artefatos
  ✅ ManualApprovalStep (tipo especial de pre-step)

POST-DEPLOY:
  ✅ Smoke tests (HTTP health checks, ping endpoints)
  ✅ Integration tests contra o ambiente recém-deployado
  ✅ Notificações (Slack, PagerDuty) de deploy bem-sucedido
  ✅ Invalidação de cache CDN
  ✅ Atualização de registro de deploy (JIRA, Backstage)

3. CodeBuildStep — ShellStep com mais controle

CodeBuildStep é uma versão mais poderosa do ShellStep que dá acesso a configurações do CodeBuild: imagem customizada, políticas IAM adicionais, variáveis de ambiente e timeout.

import * as codebuild from 'aws-cdk-lib/aws-codebuild';

const integrationTest = new pipelines.CodeBuildStep('IntegrationTest', {
  commands: [
    'npm ci',
    'npm run test:integration',
  ],

  // Variáveis de ambiente estáticas
  env: {
    ENVIRONMENT: 'dev',
    LOG_LEVEL: 'debug',
  },

  // Imagem do CodeBuild customizada
  buildEnvironment: {
    buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
    computeType: codebuild.ComputeType.MEDIUM,
    privileged: false,
  },

  // Políticas IAM adicionais para o CodeBuild project
  rolePolicyStatements: [
    new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['ssm:GetParameter'],
      resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/test/*`],
    }),
    new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['secretsmanager:GetSecretValue'],
      resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:test-*`],
    }),
  ],

  // Timeout do CodeBuild project
  timeout: cdk.Duration.minutes(30),

  // Variáveis vindas de outputs de stacks deployados (ver seção 4)
  envFromCfnOutputs: {
    API_ENDPOINT: apiStack.endpointOutput,
  },
});

pipeline.addStage(new AppStage(this, 'Dev', { env: devEnv }), {
  post: [integrationTest],
});

4. Outputs de stacks deployados em ShellSteps

Um padrão muito comum é: deploy o stack → obter o endpoint/URL do que foi criado → usá-lo no smoke test. O CDK Pipelines tem suporte nativo via envFromCfnOutputs.

Passo 1 — expor o output no Stage:

// lib/app-stage.ts
export class AppStage extends cdk.Stage {
  // Expor os outputs que serão usados em steps
  public readonly apiEndpointOutput: CfnOutput;
  public readonly loadBalancerDnsOutput: CfnOutput;

  constructor(scope: Construct, id: string, props: cdk.StageProps) {
    super(scope, id, props);

    const appStack = new AppStack(this, 'App');

    // Os outputs são CfnOutput do stack
    this.apiEndpointOutput = appStack.apiEndpoint;       // CfnOutput definido em AppStack
    this.loadBalancerDnsOutput = appStack.lbDnsName;     // CfnOutput definido em AppStack
  }
}

// lib/app-stack.ts
export class AppStack extends cdk.Stack {
  public readonly apiEndpoint: CfnOutput;
  public readonly lbDnsName: CfnOutput;

  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    const api = new apigw.RestApi(this, 'Api');

    this.apiEndpoint = new CfnOutput(this, 'ApiEndpoint', {
      value: api.url,
    });

    const lb = new elbv2.ApplicationLoadBalancer(this, 'LB', { vpc, internetFacing: true });

    this.lbDnsName = new CfnOutput(this, 'LbDnsName', {
      value: lb.loadBalancerDnsName,
    });
  }
}

Passo 2 — usar os outputs no pipeline:

// lib/pipeline-stack.ts
const devStage = new AppStage(this, 'Dev', { env: devEnv });

pipeline.addStage(devStage, {
  post: [
    new pipelines.ShellStep('SmokeTests', {
      commands: [
        // API_ENDPOINT e LB_DNS são populados automaticamente
        // com os valores dos CfnOutputs após o deploy
        'curl -f https://$API_ENDPOINT/health',
        'curl -f http://$LB_DNS/ping',
        'echo "All smoke tests passed"',
      ],
      envFromCfnOutputs: {
        API_ENDPOINT: devStage.apiEndpointOutput,
        LB_DNS:       devStage.loadBalancerDnsOutput,
      },
    }),
  ],
});

O que o CDK faz por baixo:

[FATO] O envFromCfnOutputs cria uma dependência implícita: o ShellStep só começa após o stage inteiro estar deployado, e o CodeBuild project recebe as variáveis via CodePipeline (que lê os outputs do CloudFormation stack deployado).


5. Self-mutation em ação — observando o ciclo

Esta seção documenta o comportamento do self-mutation para que você reconheça o que está acontecendo quando a pipeline reinicia.

Cenário: você adiciona um novo Stage à pipeline

Estado antes: pipeline tem Dev e Prod
Você adiciona Staging entre Dev e Prod e faz push
Execução #N (com o novo código):

  Source:          ✅ Baixa código com o novo stage Staging
  Build:           ✅ cdk synth → cloud assembly com Dev + Staging + Prod
  UpdatePipeline:  ⚠️  Pipeline atual: Dev → Prod
                       Cloud assembly: Dev → Staging → Prod
                       DIFERENTE → aplica mudança → REINICIA

Execução #N+1 (mesma branch, pipeline atualizada):

  Source:          ✅ Mesmo código
  Build:           ✅ Mesmo cloud assembly
  UpdatePipeline:  ✅ Pipeline atual = cloud assembly → sem mudança → AVANÇA

  Assets:          Upload de assets
  Dev:             Deploy na conta dev
  SmokeTests:      Testes pós-deploy dev
  Staging:         Deploy na conta staging  ← novo stage funcionando
  Approve:         Aprovação manual
  Prod:            Deploy na conta prod

Como observar o restart no Console:

CodePipeline → MeuAppPipeline → Executions
  Execution #N:   Status: Superseded  ← foi substituída pelo restart
  Execution #N+1: Status: Succeeded   ← a que realmente executou tudo

[FATO] Quando a pipeline reinicia devido ao self-mutation, a execução anterior fica com status Superseded (não Failed). Se você ver Superseded, a pipeline funcionou corretamente — não é um erro.

Forçando um restart manual:

# Equivalente ao botão "Release Change" no console
aws codepipeline start-pipeline-execution \
  --name MeuAppPipeline \
  --profile pipeline

6. Debugando falhas na publicação de assets

A fase de publicação de assets (Assets stage) é onde o CDK faz upload do código Lambda e imagens Docker. Falhas aqui têm causas específicas.

Estrutura do stage Assets:

Assets
  ├── Publish-Asset-HASH_A (Lambda zip)
  ├── Publish-Asset-HASH_B (outro Lambda zip)
  └── Publish-Asset-HASH_C (imagem Docker)

[FATO] Cada asset tem seu próprio CodeBuild action paralelo. Uma falha em um não cancela os outros imediatamente — eles podem falhar em paralelo.

Erros comuns e diagnóstico:

Erro 1: AccessDenied ao fazer upload para S3

Error: AccessDenied: Access Denied (Service: S3, Status Code: 403)

Causa: O CodeBuild do asset publishing não tem permissão para escrever no bucket
       do bootstrap da conta de destino.

Diagnóstico:
  1. Verifique se a conta de destino foi bootstrapada com --trust
  2. Verifique se a role cdk-XXXX-file-publishing-role existe na conta de destino
  3. Verifique se a trust policy da role inclui a conta da pipeline

Fix:
  cdk bootstrap aws://DEST_ACCOUNT/REGION \
    --profile dest \
    --trust PIPELINE_ACCOUNT \
    --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess

Erro 2: Docker not available para image assets

Error: Cannot connect to the Docker daemon at unix:///var/run/docker.sock

Causa: O CodeBuild project do asset publishing não tem Docker habilitado.

Fix no CDK:
  const pipeline = new pipelines.CodePipeline(this, 'Pipeline', {
    synth,
    assetPublishingCodeBuildDefaults: {
      buildEnvironment: {
        privileged: true,  // habilita Docker no CodeBuild
      },
    },
  });

⚠️  Se a pipeline já está deployada:
  1. Sete privileged: true
  2. Faça push e aguarde o self-mutation aplicar a mudança
  3. Só então adicione o asset Docker
  Motivo: mudar privileged numa pipeline existente exige recrear o CodeBuild project
          via self-mutation antes de usar Docker

Erro 3: Cannot find module no Lambda após deploy

Error: Runtime.ImportModuleError: Cannot find module 'sharp'

Causa: Módulo com binário nativo foi bundled pelo esbuild para a arquitetura errada
       (ver sessão 007 — externalModules vs nodeModules).

Diagnóstico:
  1. Verifique se 'sharp' está em externalModules (errado) ou nodeModules (correto)
  2. Verifique se a arquitetura do NodejsFunction bate com o módulo compilado

Fix:
  bundling: {
    nodeModules: ['sharp'],   // compila dentro do Docker para Amazon Linux
  }

Inspecionando logs de um asset publishing que falhou:

# Encontrar o build ID do CodeBuild que falhou
aws codepipeline get-pipeline-state \
  --name MeuAppPipeline \
  --profile pipeline \
  --query 'stageStates[?stageName==`Assets`].actionStates[?currentRevision!=null].latestExecution.externalExecutionId' \
  --output text

# Ver os logs do CodeBuild
aws codebuild batch-get-builds \
  --ids BUILD_ID \
  --profile pipeline \
  --query 'builds[0].logs.deepLink'
# Abre o CloudWatch Logs com os logs completos do build

7. Pipeline completa com todos os recursos

// lib/pipeline-stack.ts
export class PipelineStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    const source = pipelines.CodePipelineSource.connection(
      'minha-org/meu-repo', 'main',
      { connectionArn: CONNECTION_ARN }
    );

    const synth = new pipelines.ShellStep('Synth', {
      input: source,
      commands: ['npm ci', 'npm run build', 'npx cdk synth'],
    });

    const pipeline = new pipelines.CodePipeline(this, 'Pipeline', {
      pipelineName: 'MeuAppPipeline',
      synth,
      selfMutation: true,
      publishAssetsInParallel: true,
      codeBuildDefaults: {
        buildEnvironment: {
          buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
        },
      },
    });

    // ── Stage Dev ─────────────────────────────────────────────────────
    const devStage = new AppStage(this, 'Dev', { env: DEV_ENV });

    pipeline.addStage(devStage, {
      pre: [
        new pipelines.ShellStep('UnitTests', {
          commands: ['npm ci', 'npm test'],
        }),
      ],
      post: [
        new pipelines.CodeBuildStep('IntegrationTests', {
          commands: [
            'npm ci',
            'npm run test:integration',
          ],
          envFromCfnOutputs: {
            API_URL: devStage.apiEndpointOutput,
          },
          rolePolicyStatements: [
            new iam.PolicyStatement({
              actions: ['execute-api:Invoke'],
              resources: ['*'],
            }),
          ],
        }),
      ],
    });

    // ── Wave: Staging multi-região (paralelo) ─────────────────────────
    const stagingWave = pipeline.addWave('Staging', {
      pre: [new pipelines.ManualApprovalStep('ApproveStaging')],
    });

    stagingWave.addStage(new AppStage(this, 'StagingUS', {
      env: { account: STAGING_ACCOUNT, region: 'us-east-1' },
    }));

    stagingWave.addStage(new AppStage(this, 'StagingEU', {
      env: { account: STAGING_ACCOUNT, region: 'eu-west-1' },
    }));

    // ── Stage Prod ────────────────────────────────────────────────────
    const prodStage = new AppStage(this, 'Prod', { env: PROD_ENV });

    pipeline.addStage(prodStage, {
      pre: [
        new pipelines.ManualApprovalStep('ApproveProd'),
        // Gating automático: bloqueia se IAM permissions aumentaram
        new pipelines.ConfirmPermissionsBroadening('CheckPermissions', {
          stage: prodStage,
        }),
      ],
      post: [
        new pipelines.ShellStep('ProdSmokeTests', {
          commands: ['curl -f $API_URL/health || exit 1'],
          envFromCfnOutputs: {
            API_URL: prodStage.apiEndpointOutput,
          },
        }),
      ],
    });
  }
}

Armadilhas comuns

1. envFromCfnOutputs com output de stack errado

Se você passa um CfnOutput de um stack que não foi deployado neste stage, a pipeline falha com erro de referência inválida. O envFromCfnOutputs só aceita outputs de stacks dentro do stage que precede o step. Para usar um output de outro stage, você precisa passá-lo via SSM Parameter Store ou Secrets Manager.

2. Wave com stages que têm dependências cruzadas

Stages dentro de uma Wave são deployados em paralelo — o que pressupõe que são independentes. Se o Stage A depende de um output do Stage B (ambos na mesma Wave), isso cria uma dependência circular que o CDK detecta com erro. Mova os estágios dependentes para fora da Wave.

3. Modificar privileged numa pipeline existente sem fazer self-mutation primeiro

Se você habilitar Docker assets num projeto que já tem uma pipeline deployada, mas não esperar o self-mutation aplicar o privileged: true antes de adicionar o asset Docker, o CodeBuild project ainda terá privileged: false na execução em que o asset aparece pela primeira vez. A falha vai acontecer nessa execução. A solução: faça dois commits — primeiro um com apenas privileged: true, espere a pipeline se mutar; depois um segundo commit com o asset Docker.


Exercício de reflexão

Você tem uma pipeline com dev → staging → prod. Os smoke tests do stage dev estão falhando intermitentemente: 70% das vezes passam, 30% falham com curl: (7) Failed to connect. Você suspeita que os testes começam antes da aplicação estar completamente pronta após o deploy.

Quais são as estratégias possíveis para resolver isso? Considere pelo menos três abordagens diferentes (desde ajustes no ShellStep até mudanças na arquitetura do health check). Para cada uma, descreva o trade-off entre complexidade de implementação, confiabilidade e impacto no tempo total da pipeline. Qual você implementaria primeiro e por quê?


Recursos para aprofundar

Pipelines readme completo:
- aws-cdk-lib.pipelines module — referência completa de ShellStep, CodeBuildStep, Wave, envFromCfnOutputs, ConfirmPermissionsBroadening e todas as opções de configuração do CodePipeline construct.

CDK Pipelines guide:
- Continuous integration and delivery (CI/CD) using CDK Pipelines — seção de troubleshooting cobre os erros mais comuns de asset publishing e self-mutation.

Blog post original:
- CDK Pipelines: Continuous Delivery for AWS CDK Applications — walk-through completo com o raciocínio por trás do design do self-mutation e a progressão Source → Build → UpdatePipeline.