diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..94c04bda --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Fixed + +- Make Repo 2 deployment actions invoke the lockfile-installed `cdk-cicd` CLI through an npm script + instead of allowing `npx` to resolve a registry version at deployment time. +- Use explicit non-secret placeholders for KMS key examples and test fixtures. diff --git a/MIGRATION.md b/MIGRATION.md index 123d99c1..23712f9d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -59,7 +59,7 @@ export default defineCICD({ | `ACCOUNT_` / `CDK_QUALIFIER` / `npm_package_config_*` env | fields in `cicd.config.ts` (env interpolation is still allowed) | | `RepositorySource.codecommit()/github()/s3()` | `Repository.codecommit()/github()/s3()`, plus `Repository.codestarConnection(name, connectionArn)` for GitHub via a CodeStar connection | | `IResourceProvider` + `ResourceContext.instance()` singleton | same DI concept, de-singletoned and typed (`SupportResources`), lazily provisioned | -| `ComplianceBucketProvider` / `ComplianceLogBucketStack` (`IComplianceBucket`) / `deploymentDefinition[stage].complianceLogBucketName` | `complianceLogBucketName` field (`ResolvedCicdConfig`/`cicd.config.ts`), threaded by the CodePipeline engine into `SupportResources.complianceLogBucket`, provisioned on first read (the engine forces that read whenever the field is set, matching Blueprint's default-on-when-configured behaviour); a plain CDK-managed `Bucket` instead of Blueprint's custom-resource Lambda (the "bucket already exists" tolerance that Lambda existed for doesn't arise here, since this construct's stack owns the bucket for the pipeline's lifetime). Folds in the TLS/SSE bucket-policy correctness fix Blueprint's Stage-1 change (`0b7ae02`) made: the at-rest-encryption Deny statement uses the `Null` condition operator (checking the encryption header's _absence_) rather than `Bool` (which never matches a request that omits the header entirely, so it silently let unencrypted uploads through) | +| `ComplianceBucketProvider` / `ComplianceLogBucketStack` (`IComplianceBucket`) / `deploymentDefinition[stage].complianceLogBucketName` | `complianceLogBucketName` field (`ResolvedCicdConfig`/`cicd.config.ts`), supported by all three engines and provisioned through `SupportResources.complianceLogBucket` whenever configured. Autopilot uses a plain CDK-managed SSE-S3 bucket by default; set `createComplianceLogBucket: false` during migration to reference the existing Blueprint bucket without asking CloudFormation to recreate it. An externally referenced bucket remains owner-managed, including same-account/same-Region placement, SSE-S3 encryption, disabled Object Lock and Requester Pays, TLS enforcement, public-access block, and its `logging.s3.amazonaws.com` delivery policy; a name-only CDK import cannot validate those live settings. Managed buckets retain/delete their generated policy with the bucket. Autopilot prevents the destination from logging to itself and scopes managed log delivery with `aws:SourceAccount` and `aws:SourceArn`. It deliberately does not deny `PutObject` requests that omit an encryption header: default SSE-S3 encrypts those objects, while such a deny would block S3 server-access-log delivery. Because S3 access logging cannot cross accounts or Regions, Autopilot rejects non-co-located application stages instead of applying Blueprint's bucket-name substitution convention. | | `VPCProvider` / `ManagedVPCStack` / `NoVPCStack` / `VPCFromLookUpStack` (`IVpcConfig`) | `vpc` field (`VpcConfig`, either `managedVpc` or `vpcId`) in `cicd.config.ts`, resolved into `SupportResources.vpcNetworking` (`CodePipelineEngine`) / applied directly via `CdkPipelinesEngine`'s `codeBuildDefaults`; attached to every CodeBuild project the pipeline creates (build, self-update, each per-stage deploy, the container-mode `BuildImage` project, and CDK Pipelines' own synth/self-mutation/asset-publishing projects), unlike Blueprint's per-stage VPC stack. Same `cidrBlock`/`subnetCidrMask`/`maxAzs`/`flowLogsBucketName`/`codeBuildVpcInterfaces` defaults and the `resolve:ssm:`-prefixed lookup convention; fixes Blueprint's `restrictDefaultSecurityGroup`/`allowAllOutbound` defect (`props.x \|\| true`, which forced both flags on even when a caller explicitly passed `false`) | | `HttpProxyProvider` / `IProxyConfig` (`PipelineBlueprint.proxy(...)`) | `proxy` field (`ProxyConfig`) in `cicd.config.ts`; same `proxySecretArn`/`noProxy`/`proxyTestUrl` shape. Applied to every CodeBuild project `CodePipelineEngine` creates (build, self-update, each per-stage deploy, and the container-mode `BuildImage` project) and to `CdkPipelinesEngine`'s Synth step; CDK Pipelines' own self-mutation/asset-publishing projects have no per-step buildspec/secrets hook to reach and are not covered | | `CodeArtifactPlugin` / `NPMRegistryConfig` (generic private-registry basic-auth, not CodeArtifact -- see `codeArtifact` below for that) | `npmRegistry` field (`NpmRegistryConfig`: `url`/`basicAuthSecretArn`/`scope`) in `cicd.config.ts`; writes a scoped `.npmrc` (`registry=`/`_authToken=` lines) before `npm ci` in the CI build project and, for container mode (Repo 2), the deploy build project -- the bearer token resolves from Secrets Manager at CodeBuild container-start time, never appears in a log | @@ -77,6 +77,15 @@ export default defineCICD({ | container / two-repo image mode | `defineDeployment` (Repo 2, config-only) + `deployerImage`/`BuildImage` (Repo 1) in `cicd.config.ts`; `deploy-ci` auto-routes `cicd.config.ts`→CI-image-build pipeline, `deploy.config.ts`→CD pipeline, `cdk-cicd deploy --from-image` runs the pinned image once per (target × region). Repo 1 build/push and the Repo 2 executor are both real-AWS-proven (`test/proof/container-verify.sh`/`container-deploy-verify.sh`); the CD-pipeline-in-CodePipeline round trip (task `m6-container`) is unit-tested but its own end-to-end AWS proof is still open | | `@cdklabs/cdk-cicd-wrapper-projen` project type | replaced by `cicd.config.ts` (+ `cdk-cicd` CLI); the projen product is deprecated and removed at the major | +For an existing Blueprint compliance bucket, keep its current owner as the source of truth: + +1. Set `complianceLogBucketName` to the existing name and `createComplianceLogBucket: false`. +2. Keep default bucket encryption at SSE-S3 (`AES256`) and block all public access. +3. Merge—do not replace—the owner-side policy statements documented under + [Compliance access logging](docs/content/developer_guides/configuration.md#compliance-access-logging). +4. Deploy the pipeline. Autopilot will reference the bucket by name and synthesize no bucket or + bucket-policy resource for it. + #### Notable Autopilot behaviours worth knowing - **Flat footprint.** The CodePipeline engine builds ONE pipeline: source → one CI build → a @@ -88,6 +97,22 @@ export default defineCICD({ env by default). Docker mode is roadmap. - **Private registry.** Set `codeArtifact` in `cicd.config.ts` to have every build authenticate to a private npm repo before `npm ci`. +- **App staging is not a pipeline migration target.** `APP_STAGING` is valid for direct/local + `cdk deploy` and Repo 1's image-only build, but every generated deployment pipeline rejects it: + flat `CODEPIPELINE`, Repo 2, `CDK_PIPELINES`, and `GITHUB_ACTIONS`. Direct/local use supports a custom + qualifier and custom `deployRole` / `cfnExecutionRole` application-stack identities. The alpha + staging support stack remains separate and deploys with caller/base credentials. A deploy-role + `ExternalId` is not exposed by the alpha deployment-identity API and is rejected. +- **Role hand-off.** A CodeBuild deploy action assumes the configured deployment role, and that assumed + role passes the CloudFormation execution role to CloudFormation. Put `iam:PassRole` on the deployment + role, not directly on the CodeBuild project role. +- **Private ECR images.** A CodeBuild environment image must be in the build project's Region. Flat + CodePipeline and CDK Pipelines also require it in the pipeline account. Repo 2 permits its explicit + cross-account image path only after the repository owner grants pull access and + `crossAccountEcrRepositoryPolicyConfigured` is set to `true`. +- **GitHub approval acknowledgement.** GitHub workflow YAML references Environments but does not configure + required reviewers. Configure those rules first, then set + `githubActions.environmentProtectionConfigured: true`. - **Optional async deploy.** `asyncDeploy: true` hands the CloudFormation wait to a Lambda instead of billing build compute for it. Opt-in; cross-account stages are not supported under it yet. @@ -118,8 +143,8 @@ new MyStack(app, 'myapp', { stackName: stageStackName('myapp', { stageFirst: tru **`uppercaseStage` matches Blueprint's _default_ stages only.** cdk prefixed the stack name with your stage id _verbatim_ — it did not uppercase. `uppercaseStage: true` is right only because the built-in stages are -`RES`/`DEV`/`INT`. If you defined lowercase or custom-case stages in Blueprint (`staging`, `gamma`, `Prod`), -**drop `uppercaseStage`** (the stage is used as-is) or set `stackName` to your literal Blueprint name — otherwise +`RES`/`DEV`/`INT`. Lowercase custom stages (`staging`, `gamma`) match the helper's default. For a custom-case +stage such as `Prod`, use `preserveStageCase: true` or set `stackName` to your literal Blueprint name — otherwise you will deploy a differently-cased name and recreate resources. If your Blueprint stack set an explicit `stackName` (no stage prefix at all), just reuse that literal string. diff --git a/README.md b/README.md index b7d8be1a..9b719469 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ This provisions the pipeline from `cicd.config.ts` alone — nothing else needs #### What the pipeline does -**Source** → **Build** (`npm ci`, then your `ci.steps` or the default `npx cdk-cicd check`, then `cdk synth` with CDK Nag) → **self-update** → one **deploy** action per configured stage, in order, each gated by a manual approval except the inner-loop stage names `dev` and `res` (auto-approved by default), unless you set `manualApproval` explicitly. Autopilot reserves no stage names — `dev`/`res` are simply the two that default to auto-approve; every other name is gated. +**Source** → **Build** (`npm ci`, then your `ci.steps` or the default `npx cdk-cicd check`, then `cdk synth` with CDK Nag) → **self-update** → one **deploy** action per configured stage, in order, each gated by a manual approval except the inner-loop stage names `dev` and `res` (auto-approved by default), unless you set `manualApproval` explicitly. `dev`/`res` are simply approval defaults; the flat CodePipeline engine reserves only its plumbing stage names: `Source`, `Build`, and `UpdatePipeline`. Supporting resources — the encryption key, VPC networking for the pipeline's own CodeBuild projects, a compliance bucket — are **lazily provisioned**, so a pipeline only pays for what its configuration actually references. @@ -236,12 +236,35 @@ The `engine` field in `cicd.config.ts` selects how the pipeline is rendered. The - **`EngineType.CDK_PIPELINES`** — the Blueprint-compatible self-mutating pipeline built on `aws-cdk-lib/pipelines` (Source → Synth → Assets → one wave per stage). Choose it when you want a pipeline shaped like a Blueprint (`0.x`) one, e.g. to keep a migration's topology familiar. - **`EngineType.GITHUB_ACTIONS`** — renders a GitHub Actions workflow instead of an AWS-hosted pipeline. Requires `repository: Repository.github(...)` and a `githubActions` config block. +#### Deployment contracts + +- **`APP_STAGING` is direct-deploy only.** It is valid for local/direct `cdk deploy` (including local + `cdk-cicd deploy --from-image`) and may be preserved while Repo 1 builds a deployer image, because that + pipeline deploys no application stacks. Every wrapper-generated deployment pipeline rejects it: flat + `CODEPIPELINE`, Repo 2, `CDK_PIPELINES`, and `GITHUB_ACTIONS`. On the direct/local path, a custom + bootstrap qualifier and custom `deployRole` / `cfnExecutionRole` identities are supported for + application stacks. The separate staging support stack deploys with caller/base credentials, and + deploy-role `ExternalId` values remain unsupported. +- **Deployment and CloudFormation roles are distinct.** CodeBuild assumes the configured deployment + role. That assumed role passes the CloudFormation execution role to CloudFormation, so + `iam:PassRole` belongs on the deployment role—not directly on the CodeBuild project role. +- **Private ECR build images are environment-bound.** A custom ECR image used as a CodeBuild environment + image must be in the same Region as the project; the flat and CDK Pipelines CI paths also require the + pipeline account. Repo 2's explicit cross-account image path requires an owner-side repository policy + and `crossAccountEcrRepositoryPolicyConfigured: true`; it does not relax the build-image Region rule. +- **GitHub approvals are configured in GitHub.** When `manualApproval` is used, configure required + reviewers on every generated GitHub Environment, then acknowledge that setup with + `githubActions.environmentProtectionConfigured: true`. + ```typescript import { defineCICD, Repository, EngineType } from '@cdklabs/cdk-cicd-wrapper'; export default defineCICD({ application: 'my-app', - repository: Repository.github('my-org/my-app'), + repository: Repository.codestarConnection( + 'my-org/my-app', + 'arn:aws:codestar-connections:eu-west-1:111111111111:connection/01234567-89ab-cdef-0123-456789abcdef', + ), engine: EngineType.CDK_PIPELINES, // omit for the default CODEPIPELINE stages: ['dev', 'prod'], }); diff --git a/docs/content/developer_guides/cd.md b/docs/content/developer_guides/cd.md index 6c024bb0..e7e704d3 100644 --- a/docs/content/developer_guides/cd.md +++ b/docs/content/developer_guides/cd.md @@ -8,7 +8,7 @@ This iterative process helps reduce the chance that you develop new code based o ### Stage -A stage is a [deployment environment](https://en.wikipedia.org/wiki/Deployment_environment) the solution is deployed to — for example `dev`, `int`, `prod`. Unlike Blueprint (0.x), Autopilot has no reserved stage names (no forced `RES`, no built-in `DEV`/`INT`/`PROD`): every stage you list in `cicd.config.ts`'s `stages` array is deployed, in the order listed, by the pipeline running in whichever account/region your ambient credentials point at when you run `cdk-cicd deploy-ci`. +A stage is a [deployment environment](https://en.wikipedia.org/wiki/Deployment_environment) the solution is deployed to — for example `dev`, `int`, `prod`. Unlike Blueprint (0.x), Autopilot does not force lifecycle names such as `RES`/`DEV`/`INT`/`PROD`: every stage you list in `cicd.config.ts`'s `stages` array is deployed in order. The flat CodePipeline engine reserves only its infrastructure stage names, `Source`, `Build`, and `UpdatePipeline`. ### Stack @@ -44,6 +44,11 @@ A stage's `deployment` field can force a specific deploy role / CloudFormation e { name: 'prod', env: { account: '333333333333', region: 'eu-west-1' }, deployment: { deployRole: 'arn:aws:iam::333333333333:role/Deployer', cfnExecutionRole: 'arn:aws:iam::333333333333:role/CfnExec' } } ``` +For CodeBuild-backed deployments, the CodeBuild project assumes `deployRole`. The assumed deployment +role then passes `cfnExecutionRole` to CloudFormation. Grant that deployment role +`iam:PassRole` on the execution role; do not grant the CodeBuild project role direct +`iam:PassRole` merely because an execution role is configured. + ## Deploying different stacks per stage There is no `addStack()`/provider-callback API in Autopilot — `bin/` is plain CDK, so you construct whichever stacks you want directly. `cdk-cicd exec` sets `CDK_STAGE` to the active stage's name (also readable through `stageStackName`'s default), so conditional stacks are ordinary TypeScript: diff --git a/docs/content/developer_guides/ci.md b/docs/content/developer_guides/ci.md index 765e1c96..97105ec9 100644 --- a/docs/content/developer_guides/ci.md +++ b/docs/content/developer_guides/ci.md @@ -97,6 +97,68 @@ ci: { }, ``` -### Custom CI CodeBuild image +### Custom CI build image + +`ci.image` is engine-specific: + +- `CODEPIPELINE` and `CDK_PIPELINES` use it as the CI/Synth CodeBuild environment image. + - `aws/codebuild/...` selects an AWS-managed image with CodeBuild-managed pull credentials. + - A private ECR image must be in the pipeline account and the same Region as the CodeBuild project. + The wrapper grants the project role permission to pull that repository. + - An external registry image is anonymous by default. For an authenticated registry, configure a + Secrets Manager credential containing the registry `username` and `password`: + + ```typescript + ci: { + image: 'registry.example.com/private/ci:2026-09', + codeBuildImageCredentials: { + secretArn: + 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:ci-registry-AbCdEf', + // Include this only when the secret uses a customer-managed KMS key. + encryptionKeyArn: + 'arn:aws:kms:eu-west-1:111111111111:key/EXAMPLE_NOT_A_SECRET', + }, + }, + ``` + + The wrapper passes the imported secret to + `LinuxBuildImage.fromDockerRegistry(..., { secretsManagerCredentials })`; CDK renders the + CodeBuild registry credential and grants the CI/Synth role secret read. When the secret uses an + imported customer-managed key, the wrapper also grants that role `kms:Decrypt` on the exact key + ARN. `codeBuildImageCredentials` is rejected for managed CodeBuild and private ECR images because + those image classes use different pull-credential models. + +- `GITHUB_ACTIONS` uses `ci.image` as the Build-Synth GitHub job container. + - `aws/codebuild/...` is rejected: it is a CodeBuild image ID, not a pullable OCI job-container + reference. + - Private ECR is rejected because GitHub pulls the job container before the workflow can obtain AWS + credentials and exchange them for an ECR authorization token. + - Authenticate an external registry with GitHub Actions secret names: + + ```typescript + ci: { + image: 'registry.example.com/private/ci:2026-09', + }, + githubActions: { + buildContainerCredentials: { + usernameSecretName: 'REGISTRY_USERNAME', + passwordSecretName: 'REGISTRY_PASSWORD', + }, + }, + ``` + + The workflow contains `${{ secrets.REGISTRY_USERNAME }}` and + `${{ secrets.REGISTRY_PASSWORD }}` expressions, never literal credentials. Secret names may use + letters, numbers, and underscores, must not start with a number, and must not start with + `GITHUB_`. + +All engines reject image references containing inline registry userinfo such as +`user:password@registry.example.com/image`. + +For the CodeBuild engines, the same-Region ECR requirement is imposed when CodeBuild provisions the +build environment; logging in from the buildspec happens too late to make a cross-Region ECR +environment image usable. Repo 2's cross-account deployer-image acknowledgement is a separate runtime +pull path and does not change this `ci.image` contract. -Set `ci.image` to override the CodeBuild image the CI build project runs on. +For GitHub Actions, `githubActions.publishAssetsAuthRegion` controls the Region used to assume the OIDC +role while publishing assets. When omitted, it defaults to the concrete pipeline stack Region. diff --git a/docs/content/developer_guides/configuration.md b/docs/content/developer_guides/configuration.md index 7da5c5f3..c126f89e 100644 --- a/docs/content/developer_guides/configuration.md +++ b/docs/content/developer_guides/configuration.md @@ -10,7 +10,10 @@ import { defineCICD, Repository } from '@cdklabs/cdk-cicd-wrapper'; export default defineCICD({ application: 'my-app', - repository: Repository.github('my-org/my-app'), + repository: Repository.codestarConnection( + 'my-org/my-app', + 'arn:aws:codestar-connections:eu-west-1:111111111111:connection/01234567-89ab-cdef-0123-456789abcdef', + ), stages: ['dev', { name: 'prod', env: { account: '111111111111', region: 'eu-west-1' } }], }); ``` @@ -19,14 +22,14 @@ export default defineCICD({ | Field | Type | Default | Purpose | | ------------------------- | ----------------------------- | -------------------------------------- | --------------------------------------------------------------------------- | -| `application` | `string` | — | Application name; drives the bootstrap qualifier and asset naming. | -| `qualifier` | `string` | derived from `application` (≤10 chars) | CDK bootstrap qualifier. | +| `application` | `string` | — | Application name; drives asset naming and the default-synthesizer qualifier. | +| `qualifier` | `string` | derived from `application` (≤10 chars) | Target-stack CDK bootstrap qualifier. | | `pipelineStackName` | `string` | `${application}-pipeline` | CloudFormation stack name for the self-mutating pipeline stack (`CDK_PIPELINES`/`GITHUB_ACTIONS`). See [Pipeline stack name](#pipeline-stack-name). | | `repository` | `Repository` | — (required) | The pipeline's source. See [Repository](#repository). | | `stages` | `Array` | — (required) | Deployment stages, in order. See [Stages](#stages). | | `engine` | `EngineType` | `CODEPIPELINE` | Which engine renders the pipeline. See [Engine](#engine). | | `githubActions` | `GitHubActionsConfig` | — | GitHub Actions engine config; read only when `engine` is `GITHUB_ACTIONS`. | -| `synthesizer` | `{ type: SynthesizerType }` | `DEFAULT` | Which stack synthesizer to install. | +| `synthesizer` | `{ type: SynthesizerType, appId?: string }` | `DEFAULT` | Stack synthesizer. `appId` defaults to `application` for `APP_STAGING`. | | `ci` | `CiConfigInput` | engine defaults | Build steps and which stages CI synthesizes. See [CI](#ci). | | `deployModel` | `DeployModel` | `ASSEMBLY_PROMOTION` | How the deployed assembly is produced. See [Deploy model](#deploy-model). | | `codeArtifact` | `CodeArtifactConfig` | — | Private CodeArtifact npm repo the builds authenticate against. | @@ -34,7 +37,8 @@ export default defineCICD({ | `proxy` | `ProxyConfigInput` | — | HTTP(S) proxy every build project routes through. | | `warmAccountsFromSsm` | `boolean` | `false` | Export `ACCOUNT_` env vars in a self-mutating engine's synth step by scanning SSM. See [Warming accounts from SSM](#warming-accounts-from-ssm). | | `vpc` | `VpcConfig` | no VPC | VPC the pipeline's CodeBuild projects run in. See [VPC](#vpc). | -| `complianceLogBucketName` | `string` | — | Compliance/access-log destination bucket name. | +| `complianceLogBucketName` | `string` | — | Compliance/access-log destination bucket name; all logged buckets must share its account and Region. | +| `createComplianceLogBucket` | `boolean` | `true` | Create/manage the compliance bucket; set `false` to reference a pre-existing owner-managed Blueprint bucket. | | `pipelineRoleNames` | `PipelineRoleNames` | CDK-generated names | Force IAM role names on the `CDK_PIPELINES` engine's roles. See [Pipeline role names](#pipeline-role-names). | | `codePipelineRoleNames` | `CodePipelineRoleNames` | CDK-generated names | Force IAM role names on the flat `CODEPIPELINE` engine's roles. See [Pipeline role names](#pipeline-role-names). | | `deployRoleExternalId` | `string` | — | Pipeline-level default ExternalId for the forced deploy-role assumption. See [Cross-account externalId](#cross-account-externalid). | @@ -44,11 +48,26 @@ export default defineCICD({ | `deployerImage` | `BuildImage` | — | Container mode: build & push a deployer image instead of deploying. | | `plugins` | `PluginRef[]` | the default-on hardening set | Security plugins (hardening Aspects) applied tree-wide. See [Security plugins](#security-plugins). | +`APP_STAGING` is valid for direct/local application deployment (`cdk deploy`, including local +`cdk-cicd deploy --from-image`) and Repo 1 container-image builds, which deploy no application stacks. +Every wrapper-generated deployment pipeline rejects it: flat `CODEPIPELINE`, Repo 2, `CDK_PIPELINES`, +and `GITHUB_ACTIONS`. The pinned alpha honors `bootstrapQualifier`, so a custom qualifier works on the +direct/local path. It also maps `deployment.deployRole` and `deployment.cfnExecutionRole` to +`DeploymentIdentities.specifyRoles`, so those custom identities govern application-stack deployments. +The separate staging support stack uses `BootstraplessSynthesizer` and deploys with caller/base +credentials; the application-stack identities do not govern those support resources. + ## `application` and `qualifier` -`application` names the app and drives asset naming. `qualifier` is the CDK bootstrap qualifier; when -omitted it is derived from `application` — lowercased, non-alphanumerics stripped, truncated to 10 -characters (falling back to `cdkcicd` if that leaves nothing). +`application` names the app and drives asset naming. `qualifier` is derived from `application` when +omitted — lowercased, non-alphanumerics stripped, truncated to 10 characters (falling back to `cdkcicd` +if that leaves nothing). An explicit qualifier is trimmed and must match `[A-Za-z0-9_-]{1,10}`; +blank, invalid, or overlong values are rejected. `APP_STAGING` additionally uses its separate `appId` for application-specific +staging resources and threads the configured qualifier into its bootstrap-role contract on direct/local +deployments and into the configuration baked by a Repo 1 image build. + +The engine-owned pipeline stack itself always uses the standard hub-account bootstrap qualifier. A +target application's custom qualifier does not require a second custom bootstrap for the pipeline stack. ## Pipeline stack name @@ -86,17 +105,20 @@ The source repository, constructed through a `Repository` factory. The tracked b `main`. ```typescript -Repository.github('my-org/my-app'); // via a CodeStar (CodeConnections) connection +Repository.codestarConnection('my-org/my-app', connArn); // GitHub or another provider via an existing connection ARN Repository.codecommit('my-repo'); // AWS CodeCommit -Repository.codestarConnection('my-org/my-app', connArn); // any provider via an existing connection ARN Repository.s3('my-bucket/my-key'); // a versioned S3 object -// each factory takes an optional trailing `branch` argument, e.g. Repository.github('my-org/my-app', 'develop') +Repository.github('my-org/my-app'); // GitHub Actions engine only +// each factory takes an optional trailing `branch` argument +Repository.codestarConnection('my-org/my-app', connArn, 'develop'); // CodeCommit is CREATED by default; pass { existing: true } to import an existing repo instead: Repository.codecommit('my-repo', 'main', { existing: true }); ``` -When `engine` is `GITHUB_ACTIONS`, `repository` must be `Repository.github(...)` — the workflow runs -where GitHub already checked the source out. +The default `CODEPIPELINE` and `CDK_PIPELINES` engines require +`Repository.codestarConnection(...)` for GitHub sources. When `engine` is `GITHUB_ACTIONS`, +`repository` must instead be `Repository.github(...)` because the workflow runs where GitHub already +checked the source out. ## Stages @@ -120,10 +142,15 @@ stages: [ every other stage. Set it explicitly to override. - **`regionOrder`** — `RegionOrder.SEQUENTIAL` (default) rolls regions out one after another; `RegionOrder.PARALLEL` deploys them at once. -- **`deployment`** — force a `deployRole` (`cdk deploy --role-arn`) and/or `cfnExecutionRole` for the - stage, and optionally an `externalId` presented when assuming `deployRole`. See +- **`deployment`** — force the deployment role CDK assumes and/or the distinct `cfnExecutionRole` + CloudFormation assumes for the stage, and optionally an `externalId` presented when assuming + `deployRole`. See [Cross-account externalId](#cross-account-externalid). +For CodeBuild-backed deployment actions, the project role assumes `deployRole`. That assumed deployment +role passes `cfnExecutionRole` to CloudFormation, so the deployment role needs `iam:PassRole` for the +execution role. The CodeBuild project role does not need direct `iam:PassRole` on it. + See [Continuous Deployment](./cd.md) for the deeper stage model. ## Engine @@ -153,7 +180,14 @@ githubActions: { workflowPath: '.github/workflows/deploy.yml', workflowName: 'deploy', workflowTriggers: { push: { branches: ['main'] } }, // cdk-pipelines-github WorkflowTriggers - publishAssetsAuthRegion: 'us-west-2', // region the OIDC role is assumed in when publishing assets + publishAssetsAuthRegion: 'eu-west-1', // defaults to the pipeline stack Region + buildContainerCredentials: { + usernameSecretName: 'REGISTRY_USERNAME', + passwordSecretName: 'REGISTRY_TOKEN', + }, + // Required when any stage has manualApproval: true, after required reviewers are configured + // on the generated GitHub Environments. + environmentProtectionConfigured: true, }, ``` @@ -168,7 +202,15 @@ githubActions: { - **`workflowTriggers`** — the workflow's triggers (default: push to the tracked branch plus manual dispatch). - **`publishAssetsAuthRegion`** — the region the OIDC role is assumed in when publishing assets (not the - region assets publish to). Default `us-west-2`. + region assets publish to). Defaults to the pipeline stack Region. +- **`buildContainerCredentials`** — GitHub Actions secret names used to authenticate an external + `ci.image` job container. The workflow renders `${{ secrets.NAME }}` expressions; literal credentials + are never accepted. This does not support private ECR, whose authorization-token exchange cannot run + before GitHub pulls the job container. +- **`environmentProtectionConfigured`** — explicit acknowledgement that required-reviewer rules have + been configured on every generated GitHub Environment used by a stage with `manualApproval: true`. + Workflow YAML can reference an environment but cannot create its protection rule, so the engine fails + closed when an approval-gated stage exists and this flag is not `true`. ## CI @@ -178,7 +220,11 @@ githubActions: { ci: { steps: { lint: 'npx cdk-cicd validate', test: 'npx jest' }, // empty => the engine's default check set synthStages: 'all', // 'all' (every stage), an explicit list, or omit for the engine default - // image: 'aws/codebuild/standard:7.0', + image: 'registry.example.com/platform/ci:stable', + codeBuildImageCredentials: { + secretArn: 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:registry-AbCdEf', + // encryptionKeyArn: 'arn:aws:kms:eu-west-1:111111111111:key/...', + }, // partialBuildSpec: codebuild.BuildSpec.fromObject({ ... }), // merged into the CI build project only }, ``` @@ -188,7 +234,20 @@ ci: { want those checks. - **`synthStages`** — `'all'` synthesizes every stage; an explicit list names stages; omitting it uses the engine default (every stage under `ASSEMBLY_PROMOTION`, one env under `DEPLOY_TIME_SYNTH`). -- **`image`** — an optional CodeBuild image override for the CI build project. +- **`image`** — an optional image override for the CI build. On CodeBuild-backed engines, + `aws/codebuild/...` IDs use CodeBuild-managed pull credentials. A private ECR build image must be in + the pipeline account and the same Region as the CodeBuild project; the generated role receives the + repository pull grant. Repo 2's separately documented, explicitly acknowledged cross-account image + path does not relax the same-Region requirement for a CodeBuild environment image. On + `GITHUB_ACTIONS`, this must instead be a pullable OCI job-container reference; managed CodeBuild IDs + and private ECR images are rejected. +- **`codeBuildImageCredentials`** — CodeBuild engines only: the complete ARN of a Secrets Manager secret + containing `username` and `password` fields for an authenticated external registry, plus + `encryptionKeyArn` when that secret uses a customer-managed KMS key. CDK renders the CodeBuild + registry credential and grants the project role secret/decrypt access. It is rejected for + `aws/codebuild/...` and private ECR images, which use their own credential models. Public external + images remain anonymous when this field is omitted. GitHub Actions uses + `githubActions.buildContainerCredentials` instead. - **`partialBuildSpec`** — a CodeBuild spec fragment deep-merged into the CI build project's generated buildspec (the CI project only — not self-update or per-stage deploy projects). @@ -210,13 +269,14 @@ Three independent, optional blocks let the pipeline's builds install private pac before `npm ci`. Fields: `domain` and `repository` (required); `account` and `region` default to the pipeline's own; `npmScope` binds an npm scope (e.g. `cdklabs` for `@cdklabs/*`). - **`npmRegistry`** — any npm-compatible registry authenticated with a bearer token; the build writes a - `.npmrc` with a token read from Secrets Manager. Fields: `url` (the registry URL) and + temporary npm config outside the promoted artifact tree with a token read from Secrets Manager. + Fields: `url` (the registry URL) and `basicAuthSecretArn` (the Secrets Manager secret) are required; `scope` binds an npm scope, omit to - override the default registry. + override the default registry. Set `encryptionKeyArn` when the secret uses a customer-managed KMS key. - **`proxy`** — route every build through an HTTP(S) proxy. `proxySecretArn` (required) is the Secrets Manager secret holding the proxy credentials; the build exports `HTTP(S)_PROXY` and curls `proxyTestUrl` to prove the tunnel before installs. `noProxy` defaults to `[]`; `proxyTestUrl` defaults - to `https://aws.amazon.com`. + to `https://aws.amazon.com`. Set `encryptionKeyArn` when the secret uses a customer-managed KMS key. ## Warming accounts from SSM @@ -239,6 +299,81 @@ and `GITHUB_ACTIONS` warm their self-mutating synth step, and the flat `CODEPIPE `Build` synth project. The scan runs ahead of `cdk synth` in the same shell, so the exported `ACCOUNT_` vars are visible to the app. +## Compliance access logging + +`complianceLogBucketName` provisions an SSE-S3 destination bucket and configures S3 server access +logging on pipeline and application buckets for all three engines. The destination never logs to +itself. Its log-delivery policy is limited to `logging.s3.amazonaws.com`, the pipeline account, and S3 +source ARNs; TLS remains mandatory. The managed bucket and generated bucket policy share the same +lifecycle: both are retained by default, and both are deleted for a disposable pipeline. + +To keep a compliance bucket created by Blueprint, set `createComplianceLogBucket: false` alongside +its existing name: + +```typescript +complianceLogBucketName: 'my-existing-blueprint-compliance-bucket', +createComplianceLogBucket: false, +``` + +This is an external reference, not CloudFormation adoption: Autopilot creates, updates, and deletes +neither the bucket nor its policy, and a name-only CDK import cannot verify the live bucket. Before +deployment, the bucket owner must confirm that it exists in the same account and Region as every +logged source bucket, uses SSE-S3 (not SSE-KMS), has neither Object Lock/default retention nor +Requester Pays enabled, does not log to itself, blocks public access, denies non-TLS access, and +allows `logging.s3.amazonaws.com` to `s3:PutObject` with `aws:SourceAccount` restricted to the +pipeline account and `aws:SourceArn` restricted to that account's S3 bucket ARNs. +`RemovalPolicy.DESTROY` is rejected for this mode because the external owner controls the lifecycle. + +Merge these statements into the existing policy—do not replace unrelated owner-managed statements. +Replace the bucket name, account id, and partition placeholders: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "S3ServerAccessLogsPolicy", + "Effect": "Allow", + "Principal": { "Service": "logging.s3.amazonaws.com" }, + "Action": "s3:PutObject", + "Resource": "arn::s3:::/*", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "" + }, + "ArnLike": { + "aws:SourceArn": "arn::s3:::*" + } + } + }, + { + "Sid": "DenyInsecureTransport", + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": [ + "arn::s3:::", + "arn::s3:::/*" + ], + "Condition": { + "Bool": { + "aws:SecureTransport": "false" + } + } + } + ] +} +``` + +Configure the destination bucket's default encryption as SSE-S3 (`AES256`). Do not require log +delivery requests to include an encryption header; S3 applies the bucket default after accepting the +object. + +S3 server access logging cannot cross accounts or Regions. The wrapper therefore requires a concrete +pipeline environment and rejects any configured application stage outside that same account and Region +instead of inventing a bucket name that may not exist. Omit `complianceLogBucketName` for cross-account +or multi-Region pipelines, or provide logging independently in each target environment. + ## VPC `vpc` runs the pipeline's CodeBuild projects inside a VPC. Set `managedVpc` to have the wrapper create @@ -299,8 +434,15 @@ codePipelineRoleNames: { When a stage forces a `deployRole` (see [Stages](#stages)), you can present an `ExternalId` on the role assumption — the `sts:ExternalId` condition a hardened cross-account trust policy requires. It threads -into the synthesizer as `DefaultStackSynthesizer.deployRoleExternalId`, so it applies to the -`cdk deploy --role-arn` assumption the wrapper performs; it is a no-op without a `deployRole`. +into the synthesized cloud assembly as `DefaultStackSynthesizer.deployRoleExternalId`; the CDK CLI then +uses it while assuming the assembly's deployment role. It is not CloudFormation's execution-role +`RoleARN`, and it is a no-op without a `deployRole`. + +The installed `CDK_PIPELINES` engine does not carry an ExternalId from the assembly, and the installed +GitHub engine hardcodes a different value. Those engines therefore reject configured deploy-role +ExternalIds instead of silently ignoring them. `APP_STAGING` accepts custom deployment and +CloudFormation execution roles, but the alpha deployment-identity API does not expose an ExternalId. +It therefore rejects a nonblank ExternalId paired with a deploy role. Set a pipeline-level default with `deployRoleExternalId`, and override per stage with `deployment.externalId` (the per-stage value wins): @@ -308,7 +450,10 @@ Set a pipeline-level default with `deployRoleExternalId`, and override per stage ```typescript export default defineCICD({ application: 'my-app', - repository: Repository.github('my-org/my-app'), + repository: Repository.codestarConnection( + 'my-org/my-app', + 'arn:aws:codestar-connections:eu-west-1:111111111111:connection/01234567-89ab-cdef-0123-456789abcdef', + ), deployRoleExternalId: 'org-wide-external-id', // pipeline-level default stages: [ { @@ -325,7 +470,9 @@ export default defineCICD({ Either value may be a literal, or a `resolve:secretsmanager:` reference resolved at exec time from the secret's `SecretString` (so the ExternalId can live in Secrets Manager rather than in -`cicd.config.ts`). +`cicd.config.ts`). The generated roles grant `secretsmanager:GetSecretValue`; use the Secrets Manager +AWS-managed encryption key. A customer-managed KMS key additionally needs `kms:Decrypt`, which this +configuration does not currently accept. ## Security plugins diff --git a/docs/content/developer_guides/container_mode.md b/docs/content/developer_guides/container_mode.md index f78c4463..0a28859a 100644 --- a/docs/content/developer_guides/container_mode.md +++ b/docs/content/developer_guides/container_mode.md @@ -13,9 +13,10 @@ they do not build or consume a deployer image. Reach for container mode when you want to build the deployable artifact **once** and promote that exact artifact through stages — including across repositories or teams — rather than re-synthesizing per stage -from source. Because the image bakes the CDK app and its npm dependencies, the CD side needs no source, -no `npm install`, and no registry access at deploy time: it can synth-and-deploy **offline** against each -target's configuration. +from application source. Because the image bakes the CDK app and its npm dependencies, the deployer +container itself needs no npm install or package-registry access. The small Repo 2 CodeBuild wrapper still +runs `npm ci` in the config repository before launching that container, so its wrapper dependency must be +available through public npm, `codeArtifact`, or `npmRegistry`. ## Repo 1 — build the deployer image @@ -28,21 +29,29 @@ import { defineCICD, Repository, BuildImage, ImageTagStrategy } from '@cdklabs/c export default defineCICD({ application: 'my-app', - repository: Repository.github('my-org/my-app'), + repository: Repository.codestarConnection( + 'my-org/my-app', + 'arn:aws:codestar-connections:eu-west-1:111111111111:connection/01234567-89ab-cdef-0123-456789abcdef', + ), + stages: ['dev'], // required by defineCICD; deployerImage mode creates no deploy actions deployerImage: BuildImage.docker({ dockerfile: 'Dockerfile', // default; the image payload is your app + deps, NOT cdk.out // repositoryName: 'my-app-deployer', // reference an existing ECR repo; omit to provision one - // tagStrategy: ImageTagStrategy.GIT_SHA, // default: tag by resolved commit; or ImageTagStrategy.LATEST + // tagStrategy: ImageTagStrategy.GIT_SHA, // default: immutable revision tag; or ImageTagStrategy.LATEST }), }); ``` +`stages` remains required by the `defineCICD` API, but `deployerImage` mode does not render deployment +actions for those stages. + `cdk-cicd deploy-ci` provisions the CI pipeline. Its single build project: 1. runs `npm ci` and `cdk-cicd check` (CI as a validation gate), 2. logs in to ECR, 3. `docker build`s your Dockerfile and pushes the image, tagged by strategy (`GIT_SHA` by default — - immutable; `LATEST` is simplest but not immutable). + the lowercase commit SHA for Git sources or a deterministic SHA-256 of another source revision; + `LATEST` is simplest but not immutable). If you do not name an existing repository, the pipeline **provisions** one named `-deployer`; a disposable pipeline (`deploy-ci --disposable`) empties and deletes it on teardown. @@ -58,11 +67,28 @@ config rows a single image is run against, not pipeline resources. The CD repository is a small, app-agnostic **config repository** (no CDK code). It declares **which image** to run and **where** to deploy it, via `defineDeployment` in a `deploy.config.ts`: +Pin the wrapper packages in Repo 2's lockfile and expose the installed CLI through a project script: + +```json +{ + "scripts": { + "cdk-cicd": "cdk-cicd" + } +} +``` + +The generated pipeline runs `npm ci` followed by `npm run cdk-cicd -- deploy ...`. A missing dependency +or script therefore fails deterministically; deployment never asks `npx` to fetch a registry version. + ```typescript // deploy.config.ts import { defineDeployment, Repository } from '@cdklabs/cdk-cicd-wrapper'; export default defineDeployment({ + // Repeat the deployer image's application/qualifier so Repo 2 can scope bootstrap-role IAM. + application: 'my-app', + qualifier: 'myapp', + // The BASE deployer image repository (no tag). The per-stage version is appended at deploy time. image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/my-app-deployer', @@ -72,8 +98,17 @@ export default defineDeployment({ // Targets say WHERE (account/region/role) + gating. The VERSION each runs comes from config/.json. targets: [ - { stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }, - { stage: 'int', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: true }, + { + stage: 'dev', + env: { account: '111111111111', region: 'eu-west-1' }, + complianceLogBucketName: 'my-app-compliance-111111111111-eu-west-1', + }, + { + stage: 'int', + env: { account: '111111111111', region: 'eu-west-1' }, + manualApproval: true, + complianceLogBucketName: 'my-app-compliance-111111111111-eu-west-1', + }, { stage: 'prod', env: { account: '222222222222', regions: ['eu-west-1', 'us-east-1'] }, @@ -84,6 +119,29 @@ export default defineDeployment({ }); ``` +`application`, `qualifier`, and `synthesizer` must match the image's `cicd.config`. Repo 1 may preserve +`APP_STAGING` in the image because it only builds and pushes that image. The generated Repo 2 deployment +pipeline rejects `APP_STAGING`; omit `repository` and run `cdk-cicd deploy --from-image` directly if the +image requires it. That direct/local path supports a custom qualifier and custom +`deployment.deployRole` / `deployment.cfnExecutionRole` identities for application stacks. The staging +support resources deploy separately with caller/base credentials. A deploy-role ExternalId remains +unsupported by the alpha deployment-identity API. + +### Compliance logging from the deployer container + +Repo 2 can preserve application-bucket access logging by naming an existing destination with +`complianceLogBucketName`. Set it on a target, as above, or once at the top level as the default for +co-located targets. `defineDeployment` resolves each logged target to a complete bucket/account/Region +triple and fails closed unless the target has a concrete 12-digit account and exactly one matching +Region. Reusing one bucket name across different account/Region coordinates is also rejected. + +Repo 2 does not create or manage these buckets. The owner must keep the required S3 log-delivery policy, +SSE-S3 encryption, and other destination controls in place. At runtime the outer executor forwards +`CDK_CICD_COMPLIANCE_LOG_BUCKET_NAME`, `CDK_CICD_COMPLIANCE_LOG_BUCKET_ACCOUNT`, and +`CDK_CICD_COMPLIANCE_LOG_BUCKET_REGION` into Docker together; omitting logging explicitly clears all +three so image-baked values cannot take control. A multi-Region target cannot use one destination +bucket because S3 server access logging cannot cross Regions. + Each stage's **version lives in its own config file** in the CD repository (a hash or semver) — _not_ baked in the image: @@ -93,21 +151,32 @@ baked in the image: ``` The deploy resolves `image = :.json>`, so `dev` can run a newer -version than `prod`, and the version is plain config, reviewable in a pull request. +version than `prod`, and the version is plain config, reviewable in a pull request. If the file exists +but is unreadable, malformed, or lacks a non-empty string `version`, deployment fails instead of +silently falling back to the base image. ### Provision the CD pipeline, or run locally With a `repository` set, `cdk-cicd deploy-ci` provisions the CD pipeline (the deploy-side twin of the CI -`deploy-ci`): a `Source` stage, then a `Deploy` stage with every **ungated** target running in parallel, -then a `DeployGated` stage where each **gated** target sits behind its own manual-approval action. Each -deploy action runs `cdk-cicd deploy --from-image --target `, which reads that stage's `version` at -run time, pulls `:`, and synth-and-deploys the stage. +`deploy-ci`): a `Source` stage followed by ordered deployment waves. Adjacent **ungated** targets share +a parallel wave; each **gated** target gets its own approval/deploy stage. Declaration order is +preserved, so a gate blocks that target and every target declared after it. Each deploy action runs +`cdk-cicd deploy --from-image --target `, which reads that stage's `version` at run time, pulls +`:`, and synth-and-deploys the stage. + +For an image in an ECR account different from the Repo 2 pipeline, the repository owner must grant the +generated CodeBuild role `ecr:BatchGetImage`, `ecr:GetDownloadUrlForLayer`, and +`ecr:BatchCheckLayerAvailability`, plus `ecr:DescribeImages` so mutable tags can be resolved to an +immutable digest before the skip comparison. Set `crossAccountEcrRepositoryPolicyConfigured: true` only +after that owner-side policy exists; otherwise pipeline rendering fails closed. This acknowledgement +covers the deployer image pulled after the build starts. A private ECR image used as CodeBuild's own +environment image must still be in the pipeline Region. The same executor runs locally without any pipeline: ```bash -npx cdk-cicd deploy --from-image # every target (gated targets require --yes) -npx cdk-cicd deploy --from-image --target dev +npm run cdk-cicd -- deploy --from-image # every target (gated targets require --yes) +npm run cdk-cicd -- deploy --from-image --target dev ``` On a runner whose default Docker network cannot reach AWS, add `--docker-network host`. The local diff --git a/docs/content/developer_guides/vcs_github.md b/docs/content/developer_guides/vcs_github.md index 2a34c5b3..4a4db1ec 100644 --- a/docs/content/developer_guides/vcs_github.md +++ b/docs/content/developer_guides/vcs_github.md @@ -51,15 +51,28 @@ export default defineCICD({ githubActions: { // roleName defaults to '-github-role'; subjectClaims defaults to every // ref/environment of 'owner/my-repo' when omitted. + // Configure required reviewers on the generated `prod` GitHub Environment first. + environmentProtectionConfigured: true, }, }); ``` `cdk-cicd deploy-ci` only deploys the OIDC role (`GitHubActionRole`) the generated workflow assumes — the workflow itself is what runs the pipeline once you push it. See the [`Autopilot pipelines` workshop](../workshops/autopilot-pipeline/index.md) for a walkthrough. +GitHub workflow YAML can name an Environment but cannot create its required-reviewer protection rule. +When any stage resolves to `manualApproval: true`, rendering fails unless +`githubActions.environmentProtectionConfigured` is explicitly `true`. Set that flag only after the +matching GitHub Environments have required reviewers configured. + **Current limitations:** -- `codeArtifact`/`proxy` are not yet wired for the GitHub Actions engine — the generated workflow includes the same login/proxy-export commands the other engines use, but not the IAM grants or environment variables that make them work at runtime (tracked in `findings.json` as `migration-github-actions-engine-missing-codeartifact-proxy-plumbing`). Don't rely on `codeArtifact`/`proxy` with this engine yet. +- `APP_STAGING` is not supported by the generated workflow; use `DEFAULT` or deploy the application + directly/local instead. +- Caller-configured `deployRoleExternalId` / `deployment.externalId` values are rejected because the + installed GitHub engine cannot forward them. This is separate from the dependency's fixed ExternalId + described below. +- A resolved deployment-role path or name cannot contain the literal `cfn-exec`, including through + the effective bootstrap qualifier, because the installed GitHub engine rewrites that segment to `deploy`. - **Bootstrap prerequisite:** the generated workflow's deploy step assumes the CDK deploy role with an explicit `ExternalId` (a `cdk-pipelines-github` default, not something this wrapper controls). If your environment was bootstrapped with the current CDK CLI default (`cdk bootstrap`'s `--deny-external-id`, enabled by default), that assume-role call is rejected outright and the deploy job fails. You need to either re-bootstrap without `--deny-external-id`, or allow-list exactly that `ExternalId` on the deploy role's trust policy (a minimally-customized `cdk bootstrap --template`, adding one statement, is enough — see `findings.json`'s `migration-github-actions-engine-deny-external-id-incompatibility` for the exact shape). This is a real prerequisite, not an edge case — it will block your very first real deploy on a freshly-bootstrapped account. ### Known Issues diff --git a/docs/content/getting_started/prerequisites.md b/docs/content/getting_started/prerequisites.md index 836acefd..47b9ac62 100644 --- a/docs/content/getting_started/prerequisites.md +++ b/docs/content/getting_started/prerequisites.md @@ -4,7 +4,7 @@ This documentation provides a step-by-step guide for setting up the necessary pr ## AWS Account -- You need access to an AWS account for each stage you define in `cicd.config.ts` (Autopilot has no reserved stage names — `RES`/`DEV`/`INT`/`PROD` are just an example naming, not a requirement). +- You need access to an AWS account for each stage you define in `cicd.config.ts`. `RES`/`DEV`/`INT`/`PROD` are examples, not required lifecycle names; the flat CodePipeline engine reserves only `Source`, `Build`, and `UpdatePipeline` for its own plumbing. ## Operating System diff --git a/docs/content/workshops/autopilot-pipeline/00-prerequisites.md b/docs/content/workshops/autopilot-pipeline/00-prerequisites.md index 3866644f..f45f1b1d 100644 --- a/docs/content/workshops/autopilot-pipeline/00-prerequisites.md +++ b/docs/content/workshops/autopilot-pipeline/00-prerequisites.md @@ -11,8 +11,9 @@ - **Node.js 20+** and the **AWS CDK v2** CLI (`npx cdk --version`). - **Python 3** on your `PATH` — the `cdk-cicd` CLI's security checks resolve a Python interpreter when it starts, so any `cdk-cicd` command needs Python 3 available (`python3 --version`). -- Your account(s)/region(s) **bootstrapped** for CDK: `npx cdk bootstrap aws:///`. Every - stage region a pipeline deploys to must be bootstrapped. +- The hub account/region and every stage account/region **bootstrapped** for CDK: + `npx cdk bootstrap aws:///`. The engine-owned pipeline stack always uses the standard + bootstrap qualifier; a target application's custom qualifier applies only to its target stacks. - A **source** the pipeline reads from — a CodeCommit repo, a GitHub repo via a CodeStar (CodeConnections) connection, or a versioned S3 object. diff --git a/docs/content/workshops/autopilot-pipeline/01-config-driven-pipeline.md b/docs/content/workshops/autopilot-pipeline/01-config-driven-pipeline.md index 4955773f..08c7aa46 100644 --- a/docs/content/workshops/autopilot-pipeline/01-config-driven-pipeline.md +++ b/docs/content/workshops/autopilot-pipeline/01-config-driven-pipeline.md @@ -31,7 +31,7 @@ import { defineCICD, Repository } from '@cdklabs/cdk-cicd-wrapper'; export default defineCICD({ application: 'my-app', - repository: Repository.codecommit('my-app'), // or .github('org/my-app', 'main') / .s3('bucket/app.zip') + repository: Repository.codecommit('my-app'), // or .s3('bucket/app.zip') stages: ['dev', 'prod'], }); ``` @@ -76,14 +76,14 @@ only ever write the few you need, because the wrapper resolves sensible defaults | Field | What it does | Why it matters | |---|---|---| | `application` | Logical name for the app and its resources. Defaults from `package.json#name`. | The prefix on pipeline and support-stack names — set it once so resources are recognizable. | -| `qualifier` | Short (≤10 char) sanitized id used to disambiguate shared resources. Derived from `application`. | Only set it if two apps would otherwise collide on shared names. | -| `repository` | The pipeline's source: `Repository.github('org/repo', branch?)`, `Repository.codecommit('name', branch?)`, or `Repository.s3('bucket/key', branch?)`. | This is *where* the pipeline reads code and *what* triggers it — the one field you almost always set explicitly. | +| `qualifier` | Short (≤10 char) sanitized bootstrap id. Derived from `application`; both synthesizers honor an explicit value. | Set it when the target accounts use a non-default bootstrap qualifier. | +| `repository` | The pipeline's source: `Repository.codestarConnection('org/repo', connectionArn, branch?)` for GitHub with the AWS-hosted engines, `Repository.codecommit(...)`, or `Repository.s3(...)`. `Repository.github(...)` is reserved for `GITHUB_ACTIONS`. | This is *where* the pipeline reads code and *what* triggers it — the one field you almost always set explicitly. | | `stages` | Ordered list of deployment stages — bare names or objects with `env`, `manualApproval`, `deployment`. | Your promotion path (dev → prod). Config-as-data, not pipeline code. Covered in the next chapter. | | `ci` | Customizes the CI phase: `steps`, `synthStages`, `image`. | Add your own build/test steps or a custom image. See [Customizing CI](#customizing-ci) below. | | `codeArtifact` | Authenticates builds to a private CodeArtifact repo (`domain`, `repository`, `account?`, `region?`, `npmScope?`). | Needed when your deps (or the wrapper itself, pre-release) live in a private registry. See chapter 4. | | `deployModel` | `DeployModel.ASSEMBLY_PROMOTION` (default) or `DeployModel.DEPLOY_TIME_SYNTH`. | Controls when synth happens — one synth per run vs per-stage at deploy time. See chapter 3. | | `asyncDeploy` | `boolean` (default `false`). Hands the CloudFormation wait to a Lambda instead of holding a build. | Saves build compute when the CloudFormation wait dominates. See chapter 3. | -| `synthesizer` | `{ type?: SynthesizerType.DEFAULT \| SynthesizerType.APP_STAGING }`. | `DEFAULT` (`DefaultStackSynthesizer`) suits most apps; opt into `APP_STAGING` for per-app staging + roles-only bootstrap. | +| `synthesizer` | `{ type?: SynthesizerType.DEFAULT \| SynthesizerType.APP_STAGING, appId?: string }`. | `DEFAULT` suits generated pipelines. `APP_STAGING` is limited to direct/local deployment and Repo 1 image-only builds; every generated deployment pipeline rejects it. | | `engine` | Selects the CD engine (`EngineType`). | `EngineType.CODEPIPELINE` is the default and covers most cases — you rarely set it. Two alternates exist: `CDK_PIPELINES` (plain CDK Pipelines, no CodePipeline-specific extras) and `GITHUB_ACTIONS` (renders a `.github/workflows/deploy.yml` instead of an AWS-hosted pipeline — see [GitHub as source & CD engine](../../developer_guides/vcs_github.md)). Tuning for the default engine lives on the stages and `ci` (chapter 3), not here. | | `githubActions` | GitHub Actions engine config (`roleName`, `subjectClaims`, `workflowTriggers`, etc.). | Only read when `engine` is `EngineType.GITHUB_ACTIONS`. | | `deployerImage` | Turns the pipeline into a config-agnostic image builder (`BuildImage.docker({...})`). | The container-mode entry point. See chapter 5. | @@ -93,6 +93,13 @@ only ever write the few you need, because the wrapper resolves sensible defaults engine defaults to CodePipeline, and the synthesizer defaults to `DefaultStackSynthesizer`. Add fields only when a default doesn't fit. +!!! warning "`APP_STAGING` is not a generated-pipeline synthesizer" + The installed alpha supports a custom bootstrap qualifier for direct/local `cdk deploy`, and Repo 1 + may bake that configuration into a deployer image. Flat CodePipeline, Repo 2, CDK Pipelines, and + GitHub Actions deployment pipelines reject it. On the direct/local path, custom `deployRole` and + `cfnExecutionRole` values become the application stack's deployment identities. The separate + staging support stack uses caller/base credentials, and a deploy-role ExternalId is unsupported. + ## Customizing CI The `ci` block shapes the Build phase. All three sub-fields are optional; with none set, the build runs @@ -107,7 +114,10 @@ import { defineCICD, Repository } from '@cdklabs/cdk-cicd-wrapper'; export default defineCICD({ application: 'my-app', - repository: Repository.github('my-org/my-app'), + repository: Repository.codestarConnection( + 'my-org/my-app', + 'arn:aws:codestar-connections:eu-west-1:111111111111:connection/01234567-89ab-cdef-0123-456789abcdef', + ), stages: ['dev', 'prod'], ci: { steps: { @@ -129,6 +139,14 @@ you need tools the default image doesn't ship: }, ``` +For an AWS-hosted engine, a private ECR `ci.image` must be in the pipeline account and the same Region +as its CodeBuild project. Public ECR references such as `public.ecr.aws/...` are not subject to the +private-repository pull contract. For an authenticated Docker Hub, GHCR, or custom registry image, +add `ci.codeBuildImageCredentials` with the complete ARN of a Secrets Manager secret containing +`username` and `password` (and `encryptionKeyArn` for a customer-managed KMS key). GitHub Actions uses +GitHub secret names under `githubActions.buildContainerCredentials` instead; it rejects CodeBuild image +IDs and private ECR job containers because those credentials cannot be established before the pull. + **Synth scope (`ci.synthStages`)** — `'all'` synthesizes every stage as a validation gate; a list narrows it to specific stages when synth cost matters. This interacts with the deploy model, so it's covered in chapter 3. diff --git a/docs/content/workshops/autopilot-pipeline/02-stages-approvals-naming.md b/docs/content/workshops/autopilot-pipeline/02-stages-approvals-naming.md index af1e24f0..39d9c807 100644 --- a/docs/content/workshops/autopilot-pipeline/02-stages-approvals-naming.md +++ b/docs/content/workshops/autopilot-pipeline/02-stages-approvals-naming.md @@ -60,6 +60,10 @@ CloudFormation execution role — the wrapper threads them into synth and deploy Forcing a controlled role per stage is exactly what enterprise "deploy only through role X" policies need. Container mode (chapter 5) uses the same `deployment.deployRole` shape on its deploy targets. +The CodeBuild project assumes `deployment.deployRole`; that assumed deployment role passes +`cfnExecutionRole` to CloudFormation. The deployment role therefore needs `iam:PassRole` on the +execution role. The CodeBuild project role does not need that direct grant. + ## Controlling the CloudFormation stack name Autopilot synthesizes the same `bin/` once per stage, so a bare `new MyStack(app, 'my-app')` deploys the same diff --git a/docs/content/workshops/autopilot-pipeline/05-container-mode.md b/docs/content/workshops/autopilot-pipeline/05-container-mode.md index 46513976..96f8824b 100644 --- a/docs/content/workshops/autopilot-pipeline/05-container-mode.md +++ b/docs/content/workshops/autopilot-pipeline/05-container-mode.md @@ -22,22 +22,36 @@ import { defineCICD, Repository, BuildImage } from '@cdklabs/cdk-cicd-wrapper'; export default defineCICD({ application: 'my-app', - repository: Repository.github('my-org/my-app'), + repository: Repository.codestarConnection( + 'my-org/my-app', + 'arn:aws:codestar-connections:eu-west-1:111111111111:connection/01234567-89ab-cdef-0123-456789abcdef', + ), + stages: ['dev'], // required by defineCICD; deployerImage mode creates no deploy actions deployerImage: BuildImage.docker({ dockerfile: 'Dockerfile', // default; the image payload is your app + deps, NOT cdk.out // repositoryName: 'my-app-deployer', // reference an existing ECR repo; omit to provision one - // tagStrategy: ImageTagStrategy.GIT_SHA, // default: tag by commit; or LATEST + // tagStrategy: ImageTagStrategy.GIT_SHA, // default: immutable revision tag; or LATEST }), }); ``` +`stages` remains required by the `defineCICD` API, but `deployerImage` mode does not render deployment +actions for those stages. + +Repo 1 may preserve `APP_STAGING` in the image because this pipeline performs no application deployment. +The generated Repo 2 pipeline rejects that synthesizer; an `APP_STAGING` image must be run through the +local/direct `cdk-cicd deploy --from-image` path. Its targets may configure `deployRole` and +`cfnExecutionRole` for application-stack deployment; staging support resources still use caller/base +credentials. A deploy-role ExternalId remains unsupported. + ### What the Repo 1 pipeline does `cdk-cicd deploy-ci` provisions a **secondary CodePipeline** whose single build project: 1. runs `npm ci` and your CI scripts (`npm run audit`/`build`/`test`, CI as a validation gate), 2. logs in to ECR (`aws ecr get-login-password | docker login …`), -3. `docker build`s your Dockerfile and pushes the image, tagged by the resolved commit. +3. `docker build`s your Dockerfile and pushes the image, tagged by the lowercase Git commit SHA or, + for a non-Git source revision, a deterministic SHA-256 of that revision. @@ -46,21 +60,29 @@ disposable pipeline empties and deletes it on teardown. ### Why the image, not `cdk.out` -The image bakes code + deps but **never** `cdk.out`, so Repo 2 can synth-and-deploy it **offline** against -any target's config — no `npm install` and no registry access at deploy time. That's what collapses the -per-target pipeline sprawl: targets become config rows a single image is run against, not pipeline -resources. +The image bakes code + deps but **never** `cdk.out`, so the deployer container can synth-and-deploy +without installing the application or contacting its package registry. Repo 2's small outer CodeBuild +step still runs `npm ci` to install the wrapper before launching the image, so configure its package +registry access when the wrapper is not available from public npm. ## Repo 2 — the CD pipeline that deploys the image Repo 2 is a small, app-agnostic **config repo** (no CDK code) that says **which image** to run and **where** to deploy it. Describe that with `defineDeployment` in a `deploy.config.ts`: +Pin the wrapper packages in Repo 2's lockfile and add `"cdk-cicd": "cdk-cicd"` to its +`package.json` scripts. The generated pipeline uses `npm run cdk-cicd -- ...`, so it fails if the pinned +CLI is missing instead of allowing `npx` to fetch a different version during deployment. + ```ts // deploy.config.ts import { defineDeployment, Repository } from '@cdklabs/cdk-cicd-wrapper'; export default defineDeployment({ + // Match the application/qualifier baked into the deployer image. + application: 'my-app', + qualifier: 'myapp', + // The BASE deployer image repo (no tag). The per-stage version is appended at deploy time. image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/my-app-deployer', @@ -69,7 +91,11 @@ export default defineDeployment({ // Targets say WHERE (account/region/role) + gating. The VERSION each runs comes from config/.json. targets: [ - { stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }, + { + stage: 'dev', + env: { account: '111111111111', region: 'eu-west-1' }, + complianceLogBucketName: 'my-app-compliance-111111111111-eu-west-1', + }, { stage: 'int', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: true }, { stage: 'prod', @@ -81,6 +107,22 @@ export default defineDeployment({ }); ``` +`complianceLogBucketName` references an existing destination; Repo 2 does not create it. A target using +one must provide a concrete 12-digit account and exactly one Region. The executor verifies that complete +bucket/account/Region coordinate and passes the exact +`CDK_CICD_COMPLIANCE_LOG_BUCKET_NAME`, `CDK_CICD_COMPLIANCE_LOG_BUCKET_ACCOUNT`, and +`CDK_CICD_COMPLIANCE_LOG_BUCKET_REGION` variables into the deployer container. Use a different bucket +name for a target in another account or Region; multi-Region targets cannot share one S3 logging +destination. + +If the deployer image belongs to a different AWS account than the Repo 2 pipeline, its repository owner +must grant the generated CodeBuild role `ecr:BatchGetImage`, `ecr:GetDownloadUrlForLayer`, and +`ecr:BatchCheckLayerAvailability`, plus `ecr:DescribeImages` so a mutable tag is fingerprinted by its +current digest. Only then set +`crossAccountEcrRepositoryPolicyConfigured: true`; rendering fails closed without that acknowledgement. +This is a runtime pull of the deployer image. Any private ECR image used as CodeBuild's own environment +image must still be in the pipeline Region. + Each stage's **version lives in its own config file** in the CD repo (a hash or semver) — _not_ baked in the image: @@ -89,11 +131,12 @@ the image: { "version": "1.5.0" } { "version": "1.4.2" } { "version": "1.4.2" } ``` -The deploy resolves `image = :.json>`. Provision the CD pipeline — -the deploy-side twin of `deploy-ci` for a CI pipeline: +The deploy resolves `image = :.json>`. An existing malformed +version file fails deployment rather than silently selecting the base image. Provision the CD pipeline +— the deploy-side twin of `deploy-ci` for a CI pipeline: ```bash -npx cdk-cicd deploy-ci # sees deploy.config.ts (not cicd.config.ts) → provisions the CD pipeline +npm run cdk-cicd -- deploy-ci # sees deploy.config.ts (not cicd.config.ts) → provisions the CD pipeline ``` This renders a second CodePipeline with **one Deploy action per target**. Each action runs @@ -102,8 +145,8 @@ This renders a second CodePipeline with **one Deploy action per target**. Each a - **Per-stage versions in config:** bump `config/dev.json`'s `version`, commit → only `dev` redeploys on it. `dev` can run a newer version than `prod`, and the version is plain config, reviewable in a PR. -- **Parallel deploys:** ungated targets deploy in parallel; a gated target (e.g. `int`/`prod`) waits on its - **manual-approval** action, then runs — so `int` and `prod` promote in parallel once approved. +- **Deployment order:** adjacent ungated targets share a parallel wave. Each gated target gets its own + approval/deploy stage, and declaration order is preserved, so its approval blocks every later target. - **Two pipelines total:** the CI pipeline (Repo 1) _pushes_ the image; the CD pipeline (Repo 2) _pulls_ it. @@ -128,10 +171,9 @@ through to each target's deploy. !!! info "Manual-approval gates" A target's `manualApproval: true` is honored on both paths. The **CD pipeline** renders two deploy -stages: a `Deploy` stage runs every ungated target in parallel, then a `DeployGated` stage places each -gated target behind its own manual-approval action (so `int` and `prod` each wait on their own -approval, then deploy). The **local executor** (`cdk-cicd deploy --from-image`) is fail-closed — it -refuses a gated target unless you pass `--yes`. +shapes: one `Deploy` stage runs every ungated target in parallel, then each gated target gets its own +pairwise approval/deploy stage in config order. The **local executor** +(`cdk-cicd deploy --from-image`) is fail-closed—it refuses a gated target unless you pass `--yes`. !!! tip "Rollback is a retag" To roll back, point `image:` at the previous version tag (e.g. `:1.4.1`) and re-run diff --git a/docs/content/workshops/autopilot-pipeline/06-migrating-from-v2.md b/docs/content/workshops/autopilot-pipeline/06-migrating-from-v2.md index e0fc76a9..a8e2a582 100644 --- a/docs/content/workshops/autopilot-pipeline/06-migrating-from-v2.md +++ b/docs/content/workshops/autopilot-pipeline/06-migrating-from-v2.md @@ -47,9 +47,9 @@ new MyStack(app, 'my-app', { ``` `uppercaseStage` matches Blueprint's *default* stages (`RES`/`DEV`/`INT` — no `PROD` unless you called -`.defineStages(...)` yourself). If your Blueprint stages were -lowercase or custom-case, drop it (the stage is used verbatim), or set `stackName` to your literal Blueprint -name. +`.defineStages(...)` yourself). Lowercase custom stages already match the helper's default. For a +custom-case id such as `Gamma`, use `{ stageFirst: true, preserveStageCase: true }`, or set `stackName` +to your literal Blueprint name. ## Verify before switching the pipeline over diff --git a/docs/design/pipeline-role-names-and-cross-account.md b/docs/design/pipeline-role-names-and-cross-account.md index cf307335..2ec0f212 100644 --- a/docs/design/pipeline-role-names-and-cross-account.md +++ b/docs/design/pipeline-role-names-and-cross-account.md @@ -51,7 +51,7 @@ export interface PipelineRoleNames { /** Flat CODEPIPELINE engine role names. */ export interface CodePipelineRoleNames { readonly pipeline?: string; // the CodePipeline role - readonly buildRolePrefix?: string; // per-stage CodeBuild roles → `-` + readonly buildRolePrefix?: string; // CodeBuild roles → `-` } ``` @@ -91,10 +91,9 @@ match does not apply. that sets `RoleName` on the CodePipeline role and the two asset roles, distinguishing them by their path segment under the pipeline scope. Aspect runs at `AspectPriority.MUTATING` (before the readonly `AwsSolutionsChecks` added in `pipeline-assembler.ts`). -- **CODEPIPELINE (flat)**: the engine constructs its own `codepipeline.Pipeline` and per-stage - `PipelineProject`s, so it can set names directly at construct time (pipeline role) / via an Aspect on - its scope (per-stage build roles → `-`). Preferred: construct-time where the - role object is in hand, Aspect only where CDK generates the role lazily. +- **CODEPIPELINE (flat)**: after every `PipelineProject` exists, the engine names its role + `-` (`BuildProject` -> `build`, `UpdatePipeline` -> + `updatepipeline`, `Deploy-dev` -> `deploy-dev`). The pipeline role is named directly. - Omitting a field ⇒ no `RoleName` override ⇒ CDK default. No regression for existing users. ## externalId mechanism (honest end-to-end path) @@ -103,8 +102,8 @@ The wrapper's `sts:AssumeRole` policy grants only say a project *may* assume a r supplied by the **caller at assume time** and enforced by the **target role's trust policy**. The one place the wrapper actually assumes the forced deploy role is the synthesizer: `inject.ts:resolveSynthesizer` builds `new DefaultStackSynthesizer({ deployRoleArn, cloudFormationExecutionRole })` -from `DEPLOY_ROLE_FLAG`/`CFN_EXEC_ROLE_FLAG` env vars the CLI sets (`ExecCommand.forcedRoleEnv`), and -`DeployCommand` passes `--role-arn`. +from `DEPLOY_ROLE_FLAG`/`CFN_EXEC_ROLE_FLAG` env vars the CLI sets (`ExecCommand.forcedRoleEnv`). +Those identities are persisted in the synthesized cloud assembly. `DefaultStackSynthesizer` natively supports `deployRoleExternalId` (verified in the installed aws-cdk-lib). So the wiring is: @@ -115,9 +114,9 @@ DeploymentConfig.externalId ?? ResolvedCicdConfig.deployRoleExternalId → resolveSynthesizer reads it → DefaultStackSynthesizer({ deployRoleArn, deployRoleExternalId, ... }) ``` -`--role-arn` on `cdk deploy` (DeployCommand) is the flat engine's per-stage deploy action path; the -synthesizer path above is what bakes the externalId into the change-set assumption, so the env-var -seam is the single source of truth both consume. +`DeployCommand` deliberately does not pass `--role-arn`: in the CDK CLI that flag is the +CloudFormation execution role, not the deployment role. The synthesized assembly is the single source +of truth for both role identities and the deployment-role ExternalId. **Value source**: a literal, or a `resolve:secretsmanager:` reference resolved at synth time — the same `resolve:` convention `VpcConfig.vpcId` already uses. See Open questions on the secrecy @@ -128,10 +127,10 @@ trade-off. - In `CdkPipelinesEngine`, when `config.complianceLogBucketName` is set, construct a `SupportResources` and force-read `support.complianceLogBucket` (mirroring `CodePipelineEngine`'s `void support.complianceLogBucket`), so the bucket is provisioned in the pipeline stack. -- Attach `AccessLogsForBucketAspect({ complianceLogBucketName, mainRegion })` to the app at - `AspectPriority.MUTATING`, so its L1 `loggingConfiguration` override lands **before** the readonly - `AwsSolutionsChecks` runs — otherwise `AwsSolutions-S1` false-fails (nag sees the bucket before the - logging config is applied). No `NagSuppression` is added for S1; ordering is the fix. +- Attach `AccessLogsForBucketAspect` with the destination name, account, Region, and concrete bucket + reference at `AspectPriority.MUTATING`, so its L1 `loggingConfiguration` override lands **before** + the readonly `AwsSolutionsChecks` runs — otherwise `AwsSolutions-S1` false-fails (nag sees the bucket + before the logging config is applied). No `NagSuppression` is added for S1; ordering is the fix. - Reconcile the `AccessLogsForBucketAspect` header comment (it currently says the compliance bucket and its config field don't exist yet — they do, and this wires it). @@ -141,13 +140,13 @@ trade-off. `AWS::IAM::Role` `RoleName` properties equal the configured values. A control synth without the field asserts no `RoleName` override (CDK default preserved). 2. **Role names — flat**: synthesize with `codePipelineRoleNames`; assert the CodePipeline role name and - `-` build-role names; control synth for the default. + `-` build-role names; control synth for the default. 3. **externalId**: unit-test the CLI env seam (`forcedRoleEnv` emits the flag from per-stage and from the pipeline-level default, per-stage wins) and `resolveSynthesizer` (the synthesizer artifact carries `assumeRoleExternalId`); test the `resolve:secretsmanager:` parse path. 4. **Compliance bucket**: synthesize a CDK_PIPELINES pipeline with `complianceLogBucketName`; assert the - `ComplianceLogBucket` is present, that a secondary-region stack's bucket logs to the region-substituted - name, and that nag passes S1 (no suppression). + destination is present, application stacks in the same account/Region log to it, cross-Region targets + fail closed, and nag passes S1 (no suppression). 5. Local synth proof (step 4 of SDLC): a CDK_PIPELINES pipeline showing (a) the three deterministic role names and (b) the compliance bucket with the per-region name in a secondary-region stack. @@ -160,9 +159,9 @@ trade-off. ## Open questions -- **O1 — flat-engine build-role granularity.** `buildRolePrefix` yields `-` for the - per-stage CodeBuild roles. Per-stage explicit names are possible but add surface; the prefix is the - proposed shape. (Maintainer decision welcome.) +- **O1 — flat-engine build-role granularity.** `buildRolePrefix` yields + `-` for every flat-engine CodeBuild role. Per-project explicit names + remain intentionally out of scope. - **O2 — externalId secrecy.** A `resolve:secretsmanager:` value is resolved at **synth** time, so the ExternalId is baked into the synthesized synthesizer config / template. That matches the existing `resolve:` convention but does not keep the value secret at rest in the artifact. If secrecy at rest diff --git a/docs/design/v3-devops-experience.md b/docs/design/v3-devops-experience.md index 214c11a7..7577ddb5 100644 --- a/docs/design/v3-devops-experience.md +++ b/docs/design/v3-devops-experience.md @@ -234,7 +234,9 @@ new DefaultStackSynthesizer({ Mechanics (bin stays untouched): - The CLI exports the configured role ARNs as env vars (`CDK_CICD_DEPLOY_ROLE_ARN`, …) during synth - and passes them at deploy time (`--role-arn`, cdk-assets role overrides). + so `DefaultStackSynthesizer` records the deployment and CloudFormation execution identities in the + cloud assembly. Deploy consumes that assembly without reinterpreting the deployment role as CDK's + `--role-arn` flag. - If full synthesizer control is required (asset-publishing roles are baked into the asset manifest at synth), the wrapper offers an optional one-liner for the bin file: `new App({ defaultStackSynthesizer: CicdSynthesizer.fromConfig() })` — the documented escape diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/.projen/deps.json b/packages/@cdklabs/cdk-cicd-wrapper-cli/.projen/deps.json index 10e9df27..0d37dfcd 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/.projen/deps.json +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/.projen/deps.json @@ -75,6 +75,11 @@ "name": "typescript", "type": "build" }, + { + "name": "@aws-cdk/cloud-assembly-schema", + "version": "^48.12.0", + "type": "runtime" + }, { "name": "@aws-sdk/client-s3", "type": "runtime" diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/package.json b/packages/@cdklabs/cdk-cicd-wrapper-cli/package.json index ba332b12..8b67d57e 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/package.json +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/package.json @@ -47,6 +47,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@aws-cdk/cloud-assembly-schema": "^48.12.0", "@aws-sdk/client-s3": "^3.1120.0", "@aws-sdk/credential-providers": "^3.1041.0", "@cdklabs/cdk-cicd-wrapper": "^0.0.0", diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/CicdConfig.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/CicdConfig.ts index 3c3e173e..574f089b 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/CicdConfig.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/CicdConfig.ts @@ -86,6 +86,7 @@ function requireConfigFile(file: string): T { /** * Load and return the resolved pipeline config, or undefined when there is no config file. The file's * `default` export is the `defineCICD(...)` result -- already normalized to `ResolvedCicdConfig`. + * Errors from an existing file deliberately propagate; only an absent file means "not configured". */ export function load(cwd: string): ResolvedCicdConfig | undefined { const file = discover(cwd); diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DeployCommand.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DeployCommand.ts index 8c0468a1..526582e5 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DeployCommand.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DeployCommand.ts @@ -6,25 +6,35 @@ // and (only if drift is clean) `cdk deploy` that assembly. The promoted unit is code + deps, so the // synth happens here at deploy, not from a prebuilt assembly. -import { spawnSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; +import { spawn, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; import * as path from 'path'; +import { specializeDefaultSynthesizerRoleArn } from '@cdklabs/cdk-cicd-wrapper'; import * as yargs from 'yargs'; import { load as loadCicdConfig, loadDeployment, stageByName } from './CicdConfig'; -import { runFromImage } from './DeployFromImage'; -import { checkAssembly } from './DriftCheck'; +import { RegionalInvocationResult, runFromImage, runRegionalInvocations } from './DeployFromImage'; +import { checkAssembly, ManifestReader, parseEnvironment, stacksFromAssembly } from './DriftCheck'; +import { buildContextJson, CFN_EXEC_ROLE_FLAG, DEPLOY_ROLE_FLAG } from './ExecCommand'; import { synthTargets } from './SynthCommand'; import { logger } from '../../utils/Logging'; /** - * The `cdk` argv to deploy one already-synthesized assembly, optionally under a forced deploy role. + * The `cdk` argv to deploy one already-synthesized assembly. * * With `changeSetName` it PREPARES instead of deploying: `--no-execute` publishes the assets and creates * the change sets, then returns immediately. That is what lets the Lambda deploy driver own the * CloudFormation wait -- the expensive part -- rather than a build container (D-deploy-wait). + * + * Deployment and CloudFormation execution roles are deliberately absent from this argv. They are + * synthesized into the cloud assembly as `assumeRoleArn` and `cloudFormationExecutionRoleArn`. + * CDK's `--role-arn` means the latter, so passing a deployment role there would override the assembly's + * execution role and collapse two distinct IAM contracts. */ -export function deployArgs(outDir: string, deployRole?: string, changeSetName?: string, express = false): string[] { - const args = ['cdk', 'deploy', '--app', outDir, '--all', '--require-approval', 'never']; +export function deployArgs(outDir: string, changeSetName?: string, express = false): string[] { + // The installed CDK CLI maps `--all` to the main cloud assembly only. A glob selector is matched + // against every stack's hierarchical id, so `**` includes stacks synthesized below cdk.Stage too. + // spawn() passes this as a literal argv item; no shell expands it. + const args = ['cdk', 'deploy', '--app', outDir, '**', '--require-approval', 'never']; if (changeSetName !== undefined) { args.push('--no-execute', '--change-set-name', changeSetName); } else if (express) { @@ -35,13 +45,146 @@ export function deployArgs(outDir: string, deployRole?: string, changeSetName?: // for inspection -- which is why express is for fast iterative dev deploys, not production. args.push('--express'); } - if (deployRole !== undefined && deployRole.length > 0) { - // cdk assumes this role to perform the deployment (the forced deployer role). - args.push('--role-arn', deployRole); - } return args; } +/** + * Apply a command-line deployment-role override to the process that synthesizes the assembly. + * + * Presence is authoritative, including an empty value supplied by Repo 2 to clear a role baked into + * the image's cicd.config. With no CLI override the ambient environment is returned unchanged, so those + * presence-sensitive Repo 2 flags survive into `cdk synth`. + */ +export function deploymentEnvironment(ambient: NodeJS.ProcessEnv, deployRoleOverride?: string): NodeJS.ProcessEnv { + return deployRoleOverride === undefined ? ambient : { ...ambient, [DEPLOY_ROLE_FLAG]: deployRoleOverride }; +} + +/** + * Resolve the exact role identity the synth child should embed. + * + * An environment variable's presence is authoritative, including an empty value that clears the + * stage configuration. Trimming mirrors the runtime synthesizer's env parsing. + */ +export function expectedSynthesizedRole( + environment: NodeJS.ProcessEnv, + flag: string, + configuredRole?: string, + target?: { + readonly qualifier: string; + readonly account: string; + readonly region: string; + }, + source: 'synthesis' | 'promoted-assembly' = 'synthesis', +): string | undefined { + const selected = + source === 'promoted-assembly' + ? configuredRole + : Object.prototype.hasOwnProperty.call(environment, flag) + ? environment[flag] + : configuredRole; + const trimmed = selected?.trim(); + if (trimmed === undefined || trimmed.length === 0) return undefined; + return target === undefined + ? trimmed + : specializeDefaultSynthesizerRoleArn(trimmed, { + ...target, + }); +} + +const BOOTSTRAP_QUALIFIER_CONTEXT = '@aws-cdk/core:bootstrapQualifier'; +const DEFAULT_BOOTSTRAP_QUALIFIER = 'hnb659fds'; + +/** + * Resolve the qualifier used by the application synthesizer with CDK's precedence: + * explicit wrapper config, merged CDK context, then the standard bootstrap default. + * + * A promoted assembly is validated only against repository context. Ambient `CDK_CONTEXT_JSON` may + * contain caller- or CDK-CLI-injected values that did not participate in the promoted synthesis and + * therefore cannot authorize a different bootstrap role contract. + */ +export function effectiveBootstrapQualifier( + configuredQualifier: string | undefined, + cwd: string, + environment: NodeJS.ProcessEnv, + source: 'synthesis' | 'promoted-assembly' = 'synthesis', +): string { + const contextEnvironment = source === 'promoted-assembly' ? {} : environment; + const context = JSON.parse(buildContextJson({}, {}, contextEnvironment, cwd)) as { [key: string]: unknown }; + const selected = configuredQualifier ?? context[BOOTSTRAP_QUALIFIER_CONTEXT] ?? DEFAULT_BOOTSTRAP_QUALIFIER; + if (typeof selected !== 'string' || !/^[A-Za-z0-9_-]{1,10}$/.test(selected)) { + throw new Error( + `cdk-cicd deploy: bootstrap qualifier from '${BOOTSTRAP_QUALIFIER_CONTEXT}' must match ` + + '`[A-Za-z0-9_-]{1,10}`', + ); + } + return selected; +} + +/** + * Prefer the configured target account; ambient credentials are authoritative only for an agnostic + * target. If neither is known, fail closed: accepting a concrete assembly account in that state would + * let a hard-coded foreign account bypass drift validation. + */ +export function driftAccountForTarget( + configuredAccount: string | undefined, + ambientAccount: string | undefined, +): string { + const account = configuredAccount ?? ambientAccount; + if (account === undefined) { + throw new Error('cdk-cicd deploy: cannot validate an account-agnostic target without an ambient STS account'); + } + return account; +} + +/** + * A promoted assembly already contains its deployment and CloudFormation execution roles. Replacing + * either role at deploy time would reinterpret the synthesized security contract. + */ +export function assertDeployRoleOverrideAllowed(fromAssembly: boolean, deployRole: string | undefined): void { + if (fromAssembly && deployRole !== undefined) { + throw new Error( + 'cdk-cicd deploy: --deploy-role cannot be used with --from-assembly because deployment roles ' + + 'are already embedded in the promoted cloud assembly', + ); + } +} + +/** Reject option combinations whose flags would otherwise be silently ignored by the selected mode. */ +export function assertDeploymentModeOptions(options: { + readonly fromImage: boolean; + readonly fromAssembly: boolean; + readonly deployRole?: string; + readonly stage?: string; + readonly region?: string; + readonly prepareOnly: boolean; + readonly planParameter?: string; + readonly target?: string; + readonly dockerNetwork?: string; +}): void { + if (options.fromImage) { + const incompatible = [ + options.fromAssembly ? '--from-assembly' : undefined, + options.deployRole !== undefined ? '--deploy-role' : undefined, + options.stage !== undefined ? '--stage' : undefined, + options.region !== undefined ? '--region' : undefined, + options.prepareOnly ? '--prepare-only' : undefined, + options.planParameter !== undefined ? '--plan-parameter' : undefined, + ].filter((flag): flag is string => flag !== undefined); + if (incompatible.length > 0) { + throw new Error(`cdk-cicd deploy: --from-image cannot be combined with ${incompatible.join(', ')}`); + } + return; + } + + assertDeployRoleOverrideAllowed(options.fromAssembly, options.deployRole); + if (options.target !== undefined || options.dockerNetwork !== undefined) { + throw new Error('cdk-cicd deploy: --target and --docker-network require --from-image'); + } + if (!options.prepareOnly && options.planParameter !== undefined) { + throw new Error('cdk-cicd deploy: --plan-parameter requires --prepare-only'); + } +} + /** * Assert `outDir` already holds a synthesized cloud assembly -- the promoted-artifact deploy model, * where the Build stage synthed every stage once and published `cdk.out` as the deploy input. @@ -68,65 +211,62 @@ export interface PlanEntry { readonly region: string; } -/** Reads and parses the `manifest.json` of the assembly rooted at `dir`. Injectable for tests. */ -export type ManifestReader = (dir: string) => any; -const readManifestFromDisk: ManifestReader = (dir) => - JSON.parse(readFileSync(path.join(dir, 'manifest.json'), 'utf-8')); - /** * The stacks of a synthesized assembly at `outDir`, in **dependency order**, as change-set entries for * the Lambda deploy driver to execute one at a time. * - * Recurses into `aws:cloud-assembly` artifacts. That is not optional: a `cdk.Stage` (mainstream CDK) - * synthesizes its stacks into a NESTED assembly, and `cdk deploy --all --no-execute` creates change sets + * Recurses into `cdk:cloud-assembly` artifacts. That is not optional: a `cdk.Stage` (mainstream CDK) + * synthesizes its stacks into a NESTED assembly, and `cdk deploy '**' --no-execute` creates change sets * for those nested stacks. A flat, top-level-only scan would miss them -- the driver would then execute * nothing (or only the top-level stacks) and the pipeline action would still go GREEN, deploying part or - * none of the app. So each nested assembly is read from its `directory` and its stacks folded in. + * none of the app. The shared assembly walker follows the installed schema's `properties.directoryName`. * * Order matters and is not decorative: a stack that consumes another's export must be executed after it, - * which is ordering `cdk deploy` normally does for us. `dependencies` reference artifact ids within a - * manifest, so this topologically sorts within each manifest; a nested assembly is emitted where its - * artifact sits in the parent order, after anything it depends on. + * which is ordering `cdk deploy` normally does for us. */ export function planFromAssembly( outDir: string, - region: string, + fallbackRegion: string, changeSetName: string, - readManifest: ManifestReader = readManifestFromDisk, + readManifest?: ManifestReader, ): PlanEntry[] { - const collect = (dir: string): string[] => { - const artifacts: { [id: string]: any } = readManifest(dir)?.artifacts ?? {}; - const ids = Object.keys(artifacts); - const relevant = (id: string) => - artifacts[id]?.type === 'aws:cloudformation:stack' || artifacts[id]?.type === 'aws:cloud-assembly'; - - const ordered: string[] = []; - const visiting = new Set(); - const visit = (id: string): void => { - if (ordered.includes(id) || visiting.has(id) || !relevant(id)) return; - visiting.add(id); - for (const dep of (artifacts[id]?.dependencies ?? []) as string[]) { - if (relevant(dep)) visit(dep); - } - visiting.delete(id); - ordered.push(id); + return stacksFromAssembly(outDir, readManifest).map((stack) => { + const artifactRegion = stack.environment === undefined ? undefined : parseEnvironment(stack.environment).region; + return { + stackName: stack.stackName, + changeSetName, + region: + artifactRegion === undefined || artifactRegion.length === 0 || artifactRegion === 'unknown-region' + ? fallbackRegion + : artifactRegion, }; - ids.filter(relevant).forEach(visit); + }); +} - // Flatten in order: a stack emits its own name; a nested assembly emits its stacks, recursively. - return ordered.flatMap((id) => { - const a = artifacts[id]; - if (a.type === 'aws:cloud-assembly') { - return collect(path.join(dir, a.properties.directory)); - } - return [(a.properties?.stackName as string) ?? id]; - }); - }; +/** Result of one region's complete synth/drift/deploy workflow. */ +export interface RegionalDeploymentResult extends RegionalInvocationResult { + readonly plan: PlanEntry[]; +} - return collect(outDir).map((stackName) => ({ stackName, changeSetName, region })); +/** + * Run one complete deployment workflow per region and collect plans in configured region order. + * Parallel completion order never changes either the selected failure code or the serialized plan. + */ +export async function runRegionalDeployments( + targets: readonly T[], + regionOrder: string, + deploy: (target: T, index: number) => R | Promise, +): Promise<{ readonly code: number; readonly plan: PlanEntry[]; readonly results: R[] }> { + const results = await runRegionalInvocations(targets, regionOrder, deploy); + const failure = results.find((result) => result.code !== 0); + return { + code: failure?.code ?? 0, + plan: results.flatMap((result) => result.plan), + results, + }; } -/** The account the deploy will actually run against (the ambient creds), for the drift check. */ +/** Resolve the ambient account used only when the configured deployment target is account-agnostic. */ function resolveDeployAccount(): string | undefined { const result = spawnSync('aws', ['sts', 'get-caller-identity', '--query', 'Account', '--output', 'text'], { encoding: 'utf-8', @@ -135,6 +275,28 @@ function resolveDeployAccount(): string | undefined { return /^[0-9]{12}$/.test(account) ? account : undefined; } +interface InheritedProcessResult { + readonly status: number | null; + readonly error?: Error; +} + +/** Spawn a command with inherited output without blocking other regional invocations. */ +function spawnInherited( + command: string, + args: readonly string[], + options: { readonly cwd: string; readonly env: NodeJS.ProcessEnv }, +): Promise { + return new Promise((resolve) => { + try { + const child = spawn(command, args, { stdio: 'inherit', cwd: options.cwd, env: options.env }); + child.once('error', (error) => resolve({ status: null, error })); + child.once('close', (status) => resolve({ status })); + } catch (error) { + resolve({ status: null, error: error instanceof Error ? error : new Error(String(error)) }); + } + }); +} + class Command implements yargs.CommandModule { public command = 'deploy'; public describe = 'Synth, drift-check and deploy a stage across its regions'; @@ -156,8 +318,7 @@ class Command implements yargs.CommandModule { }) .option('deploy-role', { type: 'string', - describe: - 'Deploy role that overrides the stage config deployRole for every region of this run (used by container mode)', + describe: 'Deployment role override synthesized into the assembly for every region of this run', }) .option('from-image', { type: 'boolean', @@ -191,15 +352,33 @@ class Command implements yargs.CommandModule { public async handler(args: yargs.Arguments) { const cwd = process.cwd(); + const fromImage = args.fromImage as boolean; + const fromAssembly = args.fromAssembly as boolean; + try { + assertDeploymentModeOptions({ + fromImage, + fromAssembly, + deployRole: args.deployRole as string | undefined, + stage: args.stage as string | undefined, + region: args.region as string | undefined, + prepareOnly: args.prepareOnly as boolean, + planParameter: args.planParameter as string | undefined, + target: args.target as string | undefined, + dockerNetwork: args.dockerNetwork as string | undefined, + }); + } catch (error) { + logger.error((error as Error).message); + process.exit(1); + } - if (args.fromImage as boolean) { + if (fromImage) { // Container mode (Repo 2): the topology comes from deploy.config's targets, not a single stage. const deployment = loadDeployment(cwd); if (deployment === undefined) { logger.error('cdk-cicd deploy --from-image: no deploy.config.ts found next to cdk.json'); process.exit(1); } - const code = runFromImage(deployment, { + const code = await runFromImage(deployment, { yes: args.yes as boolean, network: args.dockerNetwork as string | undefined, target: args.target as string | undefined, @@ -229,21 +408,31 @@ class Command implements yargs.CommandModule { process.exit(1); } - // The account we will deploy into (ambient creds), NOT the stage config account -- so a manifest - // synthesized for a foreign/hardcoded account is caught by drift even when the stage omitted one. - const deployAccount = resolveDeployAccount(); - if (deployAccount === undefined) { - logger.warn('cdk-cicd deploy: could not resolve the deploy account via STS; drift will not check the account'); - } - const regionOverride = args.region as string | undefined; - const targets = synthTargets(config, stageName, regionOverride); + // process.env carries the Repo 2 account override and the ambient-region fallback used by bare + // stages. Passing it explicitly makes those deployment inputs authoritative over the image config. + const targets = synthTargets(config, stageName, regionOverride, process.env); if (targets.length === 0) { - logger.warn(`cdk-cicd deploy: stage '${stageName}' has no regions -- nothing to deploy`); - return; + logger.error( + `cdk-cicd deploy: stage '${stageName}' has no configured or ambient region; ` + + 'configure a region or set CDK_DEFAULT_REGION/AWS_REGION', + ); + process.exit(1); + } + + // An explicit stage/Repo 2 target is authoritative even when the caller currently holds credentials + // in a different pipeline account: CDK reaches the target by assuming the assembly's deployment role. + // Ambient STS identity is consulted only for a genuinely account-agnostic target. + const needsAmbientDeployAccount = targets.some((target) => target.account === undefined); + const ambientDeployAccount = needsAmbientDeployAccount ? resolveDeployAccount() : undefined; + if (needsAmbientDeployAccount && ambientDeployAccount === undefined) { + logger.error( + 'cdk-cicd deploy: could not resolve the ambient deploy account via STS; refusing to deploy ' + + 'an account-agnostic target without account drift validation', + ); + process.exit(1); } - const fromAssembly = args.fromAssembly as boolean; const prepareOnly = args.prepareOnly as boolean; const planParameter = args.planParameter as string | undefined; if (prepareOnly && (planParameter === undefined || planParameter.length === 0)) { @@ -254,81 +443,144 @@ class Command implements yargs.CommandModule { // Unique per execution: reusing a name across runs collides with the change set still sitting on the // stack from the previous one. const changeSetName = `cdk-cicd-${process.env.CODEBUILD_BUILD_NUMBER ?? Date.now()}`; - const plan: PlanEntry[] = []; + const deployProcessEnv = deploymentEnvironment(process.env, args.deployRole as string | undefined); + let bootstrapQualifier: string; + try { + bootstrapQualifier = effectiveBootstrapQualifier( + config.qualifier, + cwd, + process.env, + fromAssembly ? 'promoted-assembly' : 'synthesis', + ); + } catch (error) { + logger.error((error as Error).message); + process.exit(1); + } + type DeployLog = { readonly level: 'info' | 'warn' | 'error'; readonly message: string }; + type CommandRegionalDeploymentResult = RegionalDeploymentResult & { readonly logs: DeployLog[] }; - for (const target of targets) { - logger.info(`cdk-cicd deploy: ${target.stage} -> ${target.region}`); + const regionalDeployment = await runRegionalDeployments( + targets, + stage.env.regionOrder, + async (target): Promise => { + logger.info(`cdk-cicd deploy: ${target.stage} -> ${target.region}`); + const logs: DeployLog[] = []; + const log = (level: DeployLog['level'], message: string): void => { + logs.push({ level, message }); + }; + const fail = (code: number, message: string): CommandRegionalDeploymentResult => { + log('error', message); + return { code, plan: [], logs }; + }; - if (fromAssembly) { - // The promoted-assembly model: Build already synthed this stage, so deploying is all that is - // left. Costs one synth per pipeline run instead of one per stage. try { - assertPromotedAssembly(target.outDir); - } catch (error) { - logger.error((error as Error).message); - process.exit(1); - } - logger.info(`cdk-cicd deploy: using the promoted assembly at ${target.outDir} (no synth)`); - } else { - const synth = spawnSync('npx', ['cdk', 'synth', '--output', target.outDir], { - stdio: 'inherit', - cwd, - env: { ...process.env, ...target.env }, - }); - if (synth.error) { - logger.error( - `cdk-cicd deploy: could not run cdk synth for ${target.stage}/${target.region}: ${synth.error.message}`, + const driftAccount = driftAccountForTarget(target.account, ambientDeployAccount); + const roleTarget = { + qualifier: bootstrapQualifier, + account: driftAccount, + region: target.region, + }; + const expectedDeployRoleArn = expectedSynthesizedRole( + deployProcessEnv, + DEPLOY_ROLE_FLAG, + stage.deployment?.deployRole, + roleTarget, + fromAssembly ? 'promoted-assembly' : 'synthesis', + ); + const expectedCloudFormationExecutionRoleArn = expectedSynthesizedRole( + deployProcessEnv, + CFN_EXEC_ROLE_FLAG, + stage.deployment?.cfnExecutionRole, + roleTarget, + fromAssembly ? 'promoted-assembly' : 'synthesis', ); - process.exit(1); - } - if (synth.status !== 0) { - logger.error(`cdk-cicd deploy: synth failed for ${target.stage}/${target.region}`); - process.exit(synth.status ?? 1); - } - } - const drift = checkAssembly(target.outDir, { account: deployAccount, region: target.region }); - drift.warnings.forEach((w) => logger.warn(w)); - if (!drift.ok) { - drift.errors.forEach((e) => logger.error(e)); - logger.error(`cdk-cicd deploy: drift refuses ${target.stage}/${target.region} -- aborting the stage`); - process.exit(1); - } + if (fromAssembly) { + // The promoted-assembly model: Build already synthed this stage, so deploying is all that is + // left. Costs one synth per pipeline run instead of one per stage. + assertPromotedAssembly(target.outDir); + log('info', `cdk-cicd deploy: using the promoted assembly at ${target.outDir} (no synth)`); + } else { + const synth = await spawnInherited('npx', ['cdk', 'synth', '--output', target.outDir], { + cwd, + env: { ...deployProcessEnv, ...target.env }, + }); + if (synth.error !== undefined) { + return fail( + 1, + `cdk-cicd deploy: could not run cdk synth for ${target.stage}/${target.region}: ${synth.error.message}`, + ); + } + if (synth.status !== 0) { + return fail(synth.status ?? 1, `cdk-cicd deploy: synth failed for ${target.stage}/${target.region}`); + } + } - // A --deploy-role flag (container mode) overrides the stage config's forced role. - const deployRole = (args.deployRole as string | undefined) ?? stage.deployment?.deployRole; - const deploy = spawnSync( - 'npx', - deployArgs(target.outDir, deployRole, prepareOnly ? changeSetName : undefined, config.express), - { - stdio: 'inherit', - cwd, - env: { ...process.env, ...target.env }, - }, - ); - if (deploy.error) { - logger.error( - `cdk-cicd deploy: could not run cdk deploy for ${target.stage}/${target.region}: ${deploy.error.message}`, - ); - process.exit(1); - } - if (deploy.status !== 0) { - logger.error(`cdk-cicd deploy: deploy failed for ${target.stage}/${target.region}`); - process.exit(deploy.status ?? 1); - } + const drift = checkAssembly(target.outDir, { + account: driftAccount, + region: target.region, + qualifier: bootstrapQualifier, + deployRoleArn: expectedDeployRoleArn, + cloudFormationExecutionRoleArn: expectedCloudFormationExecutionRoleArn, + }); + drift.warnings.forEach((warning) => log('warn', warning)); + drift.errors.forEach((error) => log('error', error)); + if (!drift.ok) { + return fail(1, `cdk-cicd deploy: drift refuses ${target.stage}/${target.region} -- aborting the stage`); + } + + const deploy = await spawnInherited( + 'npx', + deployArgs(target.outDir, prepareOnly ? changeSetName : undefined, config.express), + { + cwd, + env: { ...deployProcessEnv, ...target.env }, + }, + ); + if (deploy.error !== undefined) { + return fail( + 1, + `cdk-cicd deploy: could not run cdk deploy for ${target.stage}/${target.region}: ${deploy.error.message}`, + ); + } + if (deploy.status !== 0) { + return fail(deploy.status ?? 1, `cdk-cicd deploy: deploy failed for ${target.stage}/${target.region}`); + } - if (prepareOnly) { - // Record what the driver must execute -- recursing into nested assemblies. Written per target, so - // a multi-region stage accumulates every region's change sets into one plan the Lambda walks. - plan.push(...planFromAssembly(target.outDir, target.region, changeSetName)); + return { + code: 0, + plan: prepareOnly ? planFromAssembly(target.outDir, target.region, changeSetName) : [], + logs, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return fail(1, `cdk-cicd deploy: ${target.stage}/${target.region} failed: ${message}`); + } + }, + ); + + // Promise.all preserves target order, so logs, failure selection, and prepare plans remain stable + // even when parallel regions complete in a different order. + for (const result of regionalDeployment.results) { + for (const entry of result.logs) { + if (entry.level === 'info') { + logger.info(entry.message); + } else if (entry.level === 'warn') { + logger.warn(entry.message); + } else { + logger.error(entry.message); + } } } + if (regionalDeployment.code !== 0) { + process.exit(regionalDeployment.code); + } + const plan = regionalDeployment.plan; if (prepareOnly) { // A deploy stage always has at least one stack, so an empty plan is never legitimate here -- it - // means `synthTargets` produced nothing (a region-less stage; see the deploy-time-synth caveat) or - // the assembly parse missed every stack. Writing it would let the driver "successfully" deploy - // nothing and go green, so fail loudly at prepare instead. + // means the assembly parse missed every stack. Writing it would let the driver "successfully" + // deploy nothing and go green, so fail loudly at prepare instead. if (plan.length === 0) { logger.error( `cdk-cicd deploy: --prepare-only produced an empty plan for '${stageName}' -- no stacks to ` + @@ -336,11 +588,9 @@ class Command implements yargs.CommandModule { ); process.exit(1); } - // No assumeRoleArn: a stage's `deployRole` is a CloudFormation SERVICE role (trusted by - // cloudformation.amazonaws.com), passed to `cdk deploy` as --role-arn and baked into the change - // set's RoleARN -- CloudFormation assumes it at execute time. The driver must NOT sts:AssumeRole it - // (that role does not trust the Lambda); it executes the change set under its own identity and - // CloudFormation uses the baked role. Cross-account is refused at render time (engine). + // The synthesized assembly keeps deployment-role assumption separate from the CloudFormation + // execution role. Preparing the change set bakes the latter into its RoleARN; the driver then + // executes the prepared change set under its own identity and must not reinterpret either role. const document = JSON.stringify({ stacks: plan }); const put = spawnSync( 'aws', diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DeployFromImage.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DeployFromImage.ts index 990aa6e9..78311c17 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DeployFromImage.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DeployFromImage.ts @@ -12,31 +12,61 @@ // via `deploy --region`: the target's env overrides whatever region set the image's own cicd.config // carries, so this repo -- not the image -- decides the deployment topology. -import { spawnSync, SpawnSyncReturns } from 'child_process'; +import { spawn as spawnProcess } from 'child_process'; import { existsSync, readFileSync } from 'fs'; import * as path from 'path'; import type { ResolvedDeploymentConfig, ResolvedDeploymentTarget } from '@cdklabs/cdk-cicd-wrapper'; +import { + CFN_EXEC_ROLE_FLAG, + COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, + COMPLIANCE_LOG_BUCKET_NAME_FLAG, + COMPLIANCE_LOG_BUCKET_REGION_FLAG, + DEPLOY_ROLE_EXTERNAL_ID_FLAG, + DEPLOY_ROLE_FLAG, + resolveExternalId, +} from './ExecCommand'; import { logger } from '../../utils/Logging'; +const AWS_ACCOUNT_ID = /^\d{12}$/; +const AWS_REGION = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+-\d+$/; +const S3_BUCKET_NAME = /^(?!\d{1,3}(?:\.\d{1,3}){3}$)(?!.*\.\.)[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])$/; + /** Reads the deployed `version` (hash or semver) for a stage from `config/.json`. Injectable for tests. */ export type VersionReader = (cwd: string, stage: string) => string | undefined; -const readVersionFromConfig: VersionReader = (cwd, stage) => { +export const readVersionFromConfig: VersionReader = (cwd, stage) => { const file = path.join(cwd, 'config', `${stage}.json`); if (!existsSync(file)) return undefined; + + let document: unknown; try { - const value = JSON.parse(readFileSync(file, 'utf-8')).version; - return typeof value === 'string' && value.length > 0 ? value : undefined; - } catch { - return undefined; + document = JSON.parse(readFileSync(file, 'utf-8')); + } catch (error) { + throw new Error( + `cdk-cicd deploy --from-image: ${file} exists but could not be read as JSON ` + + `(${error instanceof Error ? error.message : String(error)})`, + ); + } + + const version = + document !== null && typeof document === 'object' && !Array.isArray(document) + ? (document as { version?: unknown }).version + : undefined; + if (typeof version !== 'string' || version.length === 0 || version.trim() !== version) { + throw new Error( + `cdk-cicd deploy --from-image: ${file} must contain a non-empty string 'version' with no surrounding whitespace`, + ); } + return version; }; /** - * The full deployer image to run for a target. The base repo comes from the target's `image` (override) or - * the config-level `image`; the VERSION (tag) comes from the CD repo's `config/.json` `version` - * field (a hash or semver) -- so bumping a stage's version file and committing redeploys just that stage. - * If a version is present it replaces any tag on the base (`repo[:oldtag]` -> `repo:`); with no - * version file the base is used as-is. Returns undefined when there is no base image at all. + * The full deployer image to run for a target. A target-level digest is the strongest possible pin and + * remains authoritative even when `config/.json` contains a version. Otherwise the base repo + * comes from the target's `image` (override) or the config-level `image`; the VERSION (tag) comes from + * the stage version file, so bumping that file and committing redeploys just that stage. If a version is + * present it replaces any tag on the base (`repo[:oldtag]` -> `repo:`). A config-level digest + * still conflicts with a separate per-stage version because neither source is target-specific enough to + * choose silently. Returns undefined when there is no base image at all. */ export function resolveTargetImage( target: ResolvedDeploymentTarget, @@ -44,11 +74,27 @@ export function resolveTargetImage( cwd: string, readVersion: VersionReader = readVersionFromConfig, ): string | undefined { + // Validate existing stage metadata before applying image precedence. A target-level digest remains + // authoritative, but it must not let a malformed config/.json bypass fail-closed validation. + const version = readVersion(cwd, target.stage); + if (target.image?.includes('@')) { + return target.image; + } const base = target.image ?? config.image; if (base === undefined) return undefined; - const version = readVersion(cwd, target.stage); - // Strip a trailing `:tag` (a tag has no `/`) before appending the version; leaves a bare repo untouched. - return version !== undefined ? `${base.replace(/:[^/]+$/, '')}:${version}` : base; + if (version === undefined) return base; + if (base.includes('@')) { + throw new Error( + `cdk-cicd deploy --from-image: image '${base}' is pinned by digest and cannot be combined with ` + + `config/${target.stage}.json version '${version}'; remove the separate version or use a tag-based image`, + ); + } + + // Replace a tag only when its colon occurs after the final slash; this preserves registry ports. + const lastSlash = base.lastIndexOf('/'); + const lastColon = base.lastIndexOf(':'); + const repository = lastColon > lastSlash ? base.slice(0, lastColon) : base; + return `${repository}:${version}`; } /** One concrete (target x region) run: the stage/account/region/role the container deploys. */ @@ -57,8 +103,53 @@ export interface DockerTarget { /** Undefined for an environment-agnostic target (deploy against the container's ambient region). */ readonly region?: string; readonly account?: string; - /** Forced deploy (CloudFormation service) role for this target, passed through to the inner deploy. */ + /** Forced deploy role for this target, passed through to the inner synth via environment. */ readonly deployRole?: string; + /** Forced CloudFormation execution role for this target, passed through to the inner synth. */ + readonly cfnExecutionRole?: string; + /** Resolved ExternalId for the forced deploy role. Never a `resolve:secretsmanager:` reference. */ + readonly externalId?: string; + /** Existing same-account/same-Region compliance destination for application S3 access logs. */ + readonly complianceLogBucketName?: string; + readonly complianceLogBucketAccount?: string; + readonly complianceLogBucketRegion?: string; +} + +interface ComplianceLoggingCoordinates { + readonly bucketName: string; + readonly account: string; + readonly region: string; +} + +function complianceLoggingForTarget( + config: ResolvedDeploymentConfig, + target: ResolvedDeploymentTarget, +): ComplianceLoggingCoordinates | undefined { + const bucketName = target.complianceLogBucketName ?? config.complianceLogBucketName; + const account = target.complianceLogBucketAccount; + const region = target.complianceLogBucketRegion; + const values = [bucketName, account, region]; + if (values.every((value) => value === undefined)) return undefined; + if ( + values.some((value) => value === undefined || value.trim().length === 0) || + !S3_BUCKET_NAME.test(bucketName!) || + !AWS_ACCOUNT_ID.test(account!) || + !AWS_REGION.test(region!) + ) { + throw new Error( + `cdk-cicd deploy --from-image: target '${target.stage}' has incomplete or invalid compliance ` + + 'logging coordinates; bucket name, 12-digit account, and AWS Region must be resolved together.', + ); + } + if (target.env.account !== account || target.env.regions.length !== 1 || target.env.regions[0] !== region) { + throw new Error( + `cdk-cicd deploy --from-image: target '${target.stage}' is ${target.env.account ?? 'account-agnostic'}/` + + `${target.env.regions.length === 1 ? target.env.regions[0] : 'multi-or-region-agnostic'}, but compliance ` + + `bucket '${bucketName}' is resolved for ${account}/${region}. S3 server access logs require the source ` + + 'and destination buckets to be in the same account and Region.', + ); + } + return { bucketName: bucketName!, account: account!, region: region! }; } /** @@ -76,16 +167,81 @@ export function dockerRunArgs(image: string, target: DockerTarget, options: { ne const passEnv = (name: string) => env.push('-e', name); // inherit the host value by name setEnv('CDK_STAGE', target.stage); + // Presence is authoritative even when empty: an environment-agnostic Repo 2 target must clear any + // account baked into the image and let the inner CDK CLI resolve the caller's ambient credentials. + setEnv('CDK_CICD_ACCOUNT_OVERRIDE', target.account ?? ''); if (target.account !== undefined) { setEnv('CDK_DEFAULT_ACCOUNT', target.account); setEnv('CDK_DEPLOY_ACCOUNT', target.account); + } else { + passEnv('CDK_DEFAULT_ACCOUNT'); + passEnv('CDK_DEPLOY_ACCOUNT'); } + // The same presence contract applies to region. For an agnostic target, inherit every standard + // ambient region variable by name; the inner synth then bypasses image config and selects that region. + setEnv('CDK_CICD_REGION_OVERRIDE', target.region ?? ''); if (target.region !== undefined) { setEnv('CDK_DEFAULT_REGION', target.region); setEnv('CDK_DEPLOY_REGION', target.region); // The CDK CLI re-derives its region from AWS_REGION/profile before running the app; pin both. setEnv('AWS_REGION', target.region); setEnv('AWS_DEFAULT_REGION', target.region); + } else { + passEnv('CDK_DEFAULT_REGION'); + passEnv('CDK_DEPLOY_REGION'); + passEnv('AWS_REGION'); + passEnv('AWS_DEFAULT_REGION'); + } + // Repo 2 owns the role contract. Always set or clear these flags so an image-baked cicd.config cannot + // override the deployment target. ExternalId is inherited by name from the docker client process rather + // than embedded in argv, keeping the resolved value out of command logs and the process argument list. + setEnv(DEPLOY_ROLE_FLAG, target.deployRole ?? ''); + setEnv(CFN_EXEC_ROLE_FLAG, target.cfnExecutionRole ?? ''); + if (target.externalId !== undefined) { + passEnv(DEPLOY_ROLE_EXTERNAL_ID_FLAG); + } else { + setEnv(DEPLOY_ROLE_EXTERNAL_ID_FLAG, ''); + } + const complianceValues = [ + target.complianceLogBucketName, + target.complianceLogBucketAccount, + target.complianceLogBucketRegion, + ]; + if ( + complianceValues.some((value) => value !== undefined) && + complianceValues.some((value) => value === undefined || value.trim().length === 0) + ) { + throw new Error( + `cdk-cicd deploy --from-image: target '${target.stage}' must provide compliance bucket name, ` + + 'account, and Region together.', + ); + } + if (target.complianceLogBucketName !== undefined) { + if ( + !S3_BUCKET_NAME.test(target.complianceLogBucketName) || + !AWS_ACCOUNT_ID.test(target.complianceLogBucketAccount!) || + !AWS_REGION.test(target.complianceLogBucketRegion!) + ) { + throw new Error( + `cdk-cicd deploy --from-image: target '${target.stage}' has invalid compliance bucket, ` + + 'account, or Region coordinates.', + ); + } + if (target.account !== target.complianceLogBucketAccount || target.region !== target.complianceLogBucketRegion) { + throw new Error( + `cdk-cicd deploy --from-image: target '${target.stage}' compliance destination must match the ` + + 'deployment account and Region.', + ); + } + setEnv(COMPLIANCE_LOG_BUCKET_NAME_FLAG, target.complianceLogBucketName); + setEnv(COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, target.complianceLogBucketAccount!); + setEnv(COMPLIANCE_LOG_BUCKET_REGION_FLAG, target.complianceLogBucketRegion!); + } else { + // Presence is authoritative here too: clear any image-baked values so Repo 2 config decides whether + // runtime injection applies the compliance aspect. + setEnv(COMPLIANCE_LOG_BUCKET_NAME_FLAG, ''); + setEnv(COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, ''); + setEnv(COMPLIANCE_LOG_BUCKET_REGION_FLAG, ''); } // Creds inherited from the caller (who assumed the target account, for cross-account deploys). ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN'].forEach(passEnv); @@ -94,9 +250,6 @@ export function dockerRunArgs(image: string, target: DockerTarget, options: { ne if (target.region !== undefined) { inner.push('--region', target.region); } - if (target.deployRole !== undefined) { - inner.push('--deploy-role', target.deployRole); - } // `--network` lets the caller pick the container's network mode. The default docker bridge is right for // most runners; `host` (or a named network) is what a constrained/air-gapped runner needs so the deploy @@ -106,24 +259,101 @@ export function dockerRunArgs(image: string, target: DockerTarget, options: { ne } /** The (target x region) runs for one target: one per region, or a single region-agnostic run. */ -export function targetRuns(target: ResolvedDeploymentTarget): DockerTarget[] { - const base = { stage: target.stage, account: target.env.account, deployRole: target.deployment?.deployRole }; +export function targetRuns( + target: ResolvedDeploymentTarget, + resolvedExternalId?: string, + complianceLogging?: ComplianceLoggingCoordinates, +): DockerTarget[] { + if ( + complianceLogging !== undefined && + (target.env.account !== complianceLogging.account || + target.env.regions.length !== 1 || + target.env.regions[0] !== complianceLogging.region) + ) { + throw new Error( + `cdk-cicd deploy --from-image: target '${target.stage}' compliance destination must match its ` + + 'concrete account and single Region.', + ); + } + const base = { + stage: target.stage, + account: target.env.account, + deployRole: target.deployment?.deployRole, + cfnExecutionRole: target.deployment?.cfnExecutionRole, + externalId: resolvedExternalId, + complianceLogBucketName: complianceLogging?.bucketName, + complianceLogBucketAccount: complianceLogging?.account, + complianceLogBucketRegion: complianceLogging?.region, + }; if (target.env.regions.length === 0) { return [base]; } return target.env.regions.map((region) => ({ ...base, region })); } +/** Minimal result contract shared by sequential and parallel regional invocations. */ +export interface RegionalInvocationResult { + readonly code: number; +} + +/** + * Invoke one operation per region. Parallel mode launches every operation immediately but preserves + * input ordering in the returned results; sequential mode awaits each operation and stops on failure. + */ +export async function runRegionalInvocations( + items: readonly T[], + regionOrder: string, + invoke: (item: T, index: number) => R | Promise, +): Promise { + if (regionOrder === 'parallel' && items.length > 1) { + return Promise.all(items.map((item, index) => invoke(item, index))); + } + + const results: R[] = []; + for (let index = 0; index < items.length; index += 1) { + const result = await invoke(items[index], index); + results.push(result); + if (result.code !== 0) { + break; + } + } + return results; +} + +/** Result from one docker process. */ +export interface DockerSpawnResult { + readonly status: number | null; + readonly error?: Error; +} + +/** Per-process environment used to pass a resolved ExternalId to `docker run -e NAME` by name. */ +export interface DockerSpawnOptions { + readonly env?: NodeJS.ProcessEnv; +} + /** Spawner seam so tests can assert the docker argv without a docker daemon. */ -export type DockerSpawn = (args: string[]) => SpawnSyncReturns; -const spawnDocker: DockerSpawn = (args) => spawnSync('docker', args, { stdio: 'inherit' }); +export type DockerSpawn = ( + args: string[], + options?: DockerSpawnOptions, +) => DockerSpawnResult | Promise; +const spawnDocker: DockerSpawn = (args, options) => + new Promise((resolve) => { + try { + const child = spawnProcess('docker', args, { stdio: 'inherit', env: options?.env }); + child.once('error', (error) => resolve({ status: null, error })); + child.once('close', (status) => resolve({ status })); + } catch (error) { + resolve({ status: null, error: error instanceof Error ? error : new Error(String(error)) }); + } + }); /** * Run the pinned image against every target/region in the deployment config. A gated target (manual * approval) is refused unless `yes` is set -- the same fail-closed contract as `deploy --stage`, since - * the direct CLI has no approval action to wait on. Returns a non-zero exit code on the first failure. + * the direct CLI has no approval action to wait on. Targets remain ordered; a parallel target waits for + * all of its launched regions, then propagates the first failure in configured region order. */ -export function runFromImage( +export async function runFromImage( config: ResolvedDeploymentConfig, options: { yes: boolean; @@ -132,9 +362,11 @@ export function runFromImage( cwd?: string; readVersion?: VersionReader; spawn?: DockerSpawn; + resolveExternalId?: (value?: string) => Promise; }, -): number { +): Promise { const spawn = options.spawn ?? spawnDocker; + const resolveTargetExternalId = options.resolveExternalId ?? resolveExternalId; const cwd = options.cwd ?? process.cwd(); // `target` deploys just that one stage (its own image version) -- how a CD pipeline runs one action per @@ -146,6 +378,7 @@ export function runFromImage( return 1; } + const bucketCoordinates = new Map(); for (const target of targets) { if (target.manualApproval && !options.yes) { logger.error( @@ -154,9 +387,34 @@ export function runFromImage( return 1; } + let complianceLogging: ComplianceLoggingCoordinates | undefined; + try { + complianceLogging = complianceLoggingForTarget(config, target); + if (complianceLogging !== undefined) { + const coordinates = `${complianceLogging.account}/${complianceLogging.region}`; + const previous = bucketCoordinates.get(complianceLogging.bucketName); + if (previous !== undefined && previous !== coordinates) { + throw new Error( + `cdk-cicd deploy --from-image: compliance bucket '${complianceLogging.bucketName}' is ` + + `assigned to both ${previous} and ${coordinates}.`, + ); + } + bucketCoordinates.set(complianceLogging.bucketName, coordinates); + } + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + return 1; + } + // Each target runs its OWN version: base repo (target/config image) + the `version` from // config/.json in this (CD) repo. Bump that file, commit, and only this stage redeploys. - const image = resolveTargetImage(target, config, cwd, options.readVersion); + let image: string | undefined; + try { + image = resolveTargetImage(target, config, cwd, options.readVersion); + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + return 1; + } if (image === undefined) { logger.error( `cdk-cicd deploy --from-image: target '${target.stage}' has no image -- set the config-level (or target) image, plus a version in config/${target.stage}.json`, @@ -164,17 +422,59 @@ export function runFromImage( return 1; } - for (const run of targetRuns(target)) { - logger.info(`cdk-cicd deploy --from-image: ${run.stage} -> ${run.region ?? 'ambient region'} (${image})`); - const result = spawn(dockerRunArgs(image, run, { network: options.network })); - if (result.error) { - logger.error(`cdk-cicd deploy --from-image: could not run docker for ${run.stage}: ${result.error.message}`); + let externalId: string | undefined; + if ((target.deployment?.deployRole ?? '').trim().length > 0) { + try { + externalId = await resolveTargetExternalId(target.deployment?.externalId); + } catch (error) { + logger.error( + `cdk-cicd deploy --from-image: could not resolve the deploy-role ExternalId for target ` + + `'${target.stage}': ${error instanceof Error ? error.message : String(error)}`, + ); return 1; } - if (result.status !== 0) { + } + + const runs = targetRuns(target, externalId, complianceLogging); + const results = await runRegionalInvocations(runs, target.env.regionOrder, async (run) => { + logger.info(`cdk-cicd deploy --from-image: ${run.stage} -> ${run.region ?? 'ambient region'} (${image})`); + try { + const result = await spawn(dockerRunArgs(image, run, { network: options.network }), { + env: + run.externalId === undefined + ? process.env + : { ...process.env, [DEPLOY_ROLE_EXTERNAL_ID_FLAG]: run.externalId }, + }); + return { + code: result.error !== undefined ? 1 : (result.status ?? 1), + error: result.error, + }; + } catch (error) { + return { + code: 1, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + }); + + let firstFailure: number | undefined; + for (let index = 0; index < results.length; index += 1) { + const result = results[index]; + if (result.code === 0) { + continue; + } + const run = runs[index]; + if (result.error !== undefined) { + logger.error(`cdk-cicd deploy --from-image: could not run docker for ${run.stage}: ${result.error.message}`); + } else { logger.error(`cdk-cicd deploy --from-image: ${run.stage} -> ${run.region ?? 'ambient region'} failed`); - return result.status ?? 1; } + if (firstFailure === undefined) { + firstFailure = result.code; + } + } + if (firstFailure !== undefined) { + return firstFailure; } } return 0; diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DriftCheck.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DriftCheck.ts index cc0dd516..4d6b58e8 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DriftCheck.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/DriftCheck.ts @@ -1,34 +1,53 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // -// The drift rule: after synth, read each stack's target environment out of the cloud-assembly -// manifest and compare it to the stage's intended account/region. +// The drift rule: after synth, read each stack's target environment and deployment identities out of +// the cloud-assembly manifest and compare them to the stage's intended account/region/roles. // -// env-agnostic (unknown-account/unknown-region) -> OK (resolved at deploy from ambient creds) -// region mismatch -> WARN, continue +// unknown account + configured target account -> ERROR, abort (would use ambient credentials) +// unknown region -> OK when every concrete dimension matches +// region mismatch -> ERROR, abort // account mismatch -> ERROR, abort the stage +// deployment-role mismatch -> ERROR, abort the stage +// CloudFormation execution-role mismatch -> ERROR, abort the stage // // It lives here, in the CLI as a post-synth manifest reader, because the resolved account/region only -// exist in the synthesized assembly (`manifest.json` artifact `environment: "aws:///"`). +// and role identities exist in the synthesized assembly (`environment`, `assumeRoleArn`, and +// `cloudFormationExecutionRoleArn`). // This is what makes the hardcoded-env fixture (a foreign account baked into bin/) safe: the mismatch // is caught at synth time and the deploy never runs. -import { existsSync, readFileSync } from 'fs'; +import { existsSync } from 'fs'; import * as path from 'path'; +import { Manifest } from '@aws-cdk/cloud-assembly-schema'; + +// These values are the installed @aws-cdk/cloud-assembly-schema ArtifactType members. Keep the +// traversal schema-driven: cdk.Stage emits NESTED_CLOUD_ASSEMBLY with properties.directoryName. +const CLOUDFORMATION_STACK_ARTIFACT = 'aws:cloudformation:stack'; +const NESTED_CLOUD_ASSEMBLY_ARTIFACT = 'cdk:cloud-assembly'; /** The intended target for a synth. `account` omitted means "whatever the creds resolve" (no account check). */ export interface DriftTarget { readonly account?: string; readonly region: string; + /** Effective CDK bootstrap qualifier after config/context/default resolution. */ + readonly qualifier: string; + /** Exact deployment role expected from the stage/env override, when one was explicitly selected. */ + readonly deployRoleArn?: string; + /** Exact CloudFormation execution role expected from the stage/env override, when explicitly selected. */ + readonly cloudFormationExecutionRoleArn?: string; } -export type DriftKind = 'ok' | 'agnostic' | 'region-mismatch' | 'account-mismatch'; +export type DriftKind = + 'ok' | 'agnostic' | 'region-mismatch' | 'account-mismatch' | 'deploy-role-mismatch' | 'cfn-execution-role-mismatch'; /** Per-stack drift outcome. */ export interface StackDrift { readonly stack: string; readonly account: string; readonly region: string; + readonly deployRoleArn?: string; + readonly cloudFormationExecutionRoleArn?: string; readonly kind: DriftKind; readonly message: string; } @@ -38,14 +57,149 @@ export interface DriftResult { readonly stacks: StackDrift[]; readonly warnings: string[]; readonly errors: string[]; - /** True when nothing account-mismatched -- i.e. the assembly is safe to deploy. */ + /** True when every synthesized target and deployment identity matches the intended contract. */ readonly ok: boolean; } const AGNOSTIC = new Set(['unknown-account', 'unknown-region']); +/** Reads and parses the manifest for the assembly rooted at `dir`. Injectable for focused tests. */ +export type ManifestReader = (dir: string) => any; + +export const readManifestFromDisk: ManifestReader = (dir) => { + const manifestPath = path.join(dir, 'manifest.json'); + if (!existsSync(manifestPath)) { + throw new Error(`cloud assembly: no manifest.json at ${manifestPath}`); + } + try { + return Manifest.loadAssemblyManifest(manifestPath); + } catch (error) { + throw new Error(`cloud assembly: ${manifestPath} is invalid (${(error as Error).message})`); + } +}; + +/** One recursively discovered CloudFormation stack artifact. */ +export interface AssemblyStack { + /** Artifact id qualified by its nested-assembly path, for diagnostics. */ + readonly id: string; + /** Physical CloudFormation stack name, falling back to the artifact id for old manifests. */ + readonly stackName: string; + readonly environment?: string; + /** CDK CLI deployment role (`assumeRoleArn`) synthesized into this stack artifact. */ + readonly assumeRoleArn?: string; + /** CloudFormation execution role synthesized into this stack artifact. */ + readonly cloudFormationExecutionRoleArn?: string; +} + +function isRecord(value: unknown): value is { [key: string]: any } { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Discover every stack in a cloud assembly in dependency order, including stacks synthesized under + * cdk.Stage. The installed schema represents those stages as `cdk:cloud-assembly` artifacts whose + * nested directory is `properties.directoryName`. + */ +export function stacksFromAssembly( + outDir: string, + readManifest: ManifestReader = readManifestFromDisk, +): AssemblyStack[] { + const root = path.resolve(outDir); + const active = new Set(); + const visited = new Set(); + + const collect = (dir: string, parentIds: readonly string[]): AssemblyStack[] => { + const resolvedDir = path.resolve(dir); + if (active.has(resolvedDir)) { + throw new Error(`cloud assembly: nested assembly cycle includes ${dir}`); + } + if (visited.has(resolvedDir)) { + throw new Error(`cloud assembly: nested assembly ${dir} is referenced more than once`); + } + if (resolvedDir !== root && !resolvedDir.startsWith(`${root}${path.sep}`)) { + throw new Error(`cloud assembly: nested assembly ${dir} escapes the root assembly ${outDir}`); + } + + active.add(resolvedDir); + const manifest = readManifest(dir); + const artifacts = isRecord(manifest?.artifacts) ? manifest.artifacts : {}; + const ordered: string[] = []; + const states = new Map(); + + const visit = (id: string): void => { + const state = states.get(id); + if (state === 'visited') return; + if (state === 'visiting') { + throw new Error(`cloud assembly: artifact dependency cycle in ${dir} includes '${id}'`); + } + + const artifact = artifacts[id]; + if (!isRecord(artifact) || typeof artifact.type !== 'string') { + throw new Error(`cloud assembly: artifact '${id}' in ${dir} is malformed`); + } + if (artifact.dependencies !== undefined && !Array.isArray(artifact.dependencies)) { + throw new Error(`cloud assembly: artifact '${id}' in ${dir} has malformed dependencies`); + } + + states.set(id, 'visiting'); + for (const dependency of (artifact.dependencies ?? []) as unknown[]) { + if (typeof dependency !== 'string' || !(dependency in artifacts)) { + throw new Error(`cloud assembly: artifact '${id}' in ${dir} depends on missing artifact '${dependency}'`); + } + visit(dependency); + } + states.set(id, 'visited'); + ordered.push(id); + }; + + Object.keys(artifacts).forEach(visit); + + const stacks = ordered.flatMap((id): AssemblyStack[] => { + const artifact = artifacts[id]; + if (artifact.type === CLOUDFORMATION_STACK_ARTIFACT) { + const qualifiedId = [...parentIds, id].join('/'); + const configuredStackName = artifact.properties?.stackName; + const assumeRoleArn = artifact.properties?.assumeRoleArn; + const cloudFormationExecutionRoleArn = artifact.properties?.cloudFormationExecutionRoleArn; + return [ + { + id: qualifiedId, + stackName: + typeof configuredStackName === 'string' && configuredStackName.length > 0 ? configuredStackName : id, + environment: typeof artifact.environment === 'string' ? artifact.environment : undefined, + assumeRoleArn: typeof assumeRoleArn === 'string' ? assumeRoleArn : undefined, + cloudFormationExecutionRoleArn: + typeof cloudFormationExecutionRoleArn === 'string' ? cloudFormationExecutionRoleArn : undefined, + }, + ]; + } + if (artifact.type === NESTED_CLOUD_ASSEMBLY_ARTIFACT) { + const directoryName = artifact.properties?.directoryName; + if (typeof directoryName !== 'string' || directoryName.trim().length === 0) { + throw new Error( + `cloud assembly: nested artifact '${[...parentIds, id].join('/')}' in ${dir} ` + + 'has no properties.directoryName', + ); + } + return collect(path.join(dir, directoryName), [...parentIds, id]); + } + return []; + }); + + active.delete(resolvedDir); + visited.add(resolvedDir); + return stacks; + }; + + const stacks = collect(outDir, []); + if (stacks.length === 0) { + throw new Error(`cloud assembly at ${outDir} contains no deployable CloudFormation stacks`); + } + return stacks; +} + /** Parse `aws:///` into its parts (either may be `unknown-*`). */ -function parseEnvironment(environment: string): { account: string; region: string } { +export function parseEnvironment(environment: string): { account: string; region: string } { const withoutScheme = environment.replace(/^aws:\/\//, ''); const slash = withoutScheme.indexOf('/'); return { @@ -54,42 +208,158 @@ function parseEnvironment(environment: string): { account: string; region: strin }; } -/** Pure drift analysis of a parsed cloud-assembly manifest against a target. */ -export function analyzeManifest(manifest: any, target: DriftTarget): DriftResult { +function roleAccount(roleArn: string): string | undefined { + // DefaultStackSynthesizer leaves the partition as the literal CloudFormation pseudo-parameter + // `${AWS::Partition}` even when account/region are concrete. + return /^arn:(?:\$\{AWS::Partition\}|[^:]+):iam::([^:]+):role\/.+$/.exec(roleArn)?.[1]; +} + +function specializeRoleEnvironment(roleArn: string, target: DriftTarget): string { + return roleArn + .split('${Qualifier}') + .join(target.qualifier) + .split('${AWS::AccountId}') + .join(target.account ?? '${AWS::AccountId}') + .split('${AWS::Region}') + .join(target.region); +} + +/** + * Compare a manifest role with the configured template after specializing the target environment. + */ +function roleMatchesExpected(actual: string, expected: string, target: DriftTarget): boolean { + const normalizedActual = specializeRoleEnvironment(actual, target); + const normalizedExpected = specializeRoleEnvironment(expected, target); + return normalizedActual === normalizedExpected; +} + +function roleDrift( + artifact: AssemblyStack, + target: DriftTarget, +): + | { + readonly kind: 'deploy-role-mismatch' | 'cfn-execution-role-mismatch'; + readonly message: string; + } + | undefined { + const validate = ( + label: string, + actual: string | undefined, + expected: string | undefined, + kind: 'deploy-role-mismatch' | 'cfn-execution-role-mismatch', + ): { readonly kind: typeof kind; readonly message: string } | undefined => { + if (expected !== undefined && (actual === undefined || !roleMatchesExpected(actual, expected, target))) { + return { + kind, + message: + actual === undefined + ? `${artifact.id} is missing the configured ${label} '${expected}' -- refusing to reinterpret the synthesized deployment identity` + : `${artifact.id} synthesized ${label} '${actual}', expected '${expected}' -- refusing to deploy`, + }; + } + + if (actual !== undefined && target.account !== undefined) { + const account = roleAccount(specializeRoleEnvironment(actual, target)); + if (account === undefined || !/^[0-9]{12}$/.test(account)) { + return { + kind, + message: `${artifact.id} has an unverifiable ${label} '${actual}' -- refusing to deploy to account ${target.account}`, + }; + } + if (account !== target.account) { + return { + kind, + message: `${artifact.id} has ${label} in account ${account}, stage target is ${target.account} -- refusing to deploy`, + }; + } + } + return undefined; + }; + + return ( + validate('deployment role', artifact.assumeRoleArn, target.deployRoleArn, 'deploy-role-mismatch') ?? + validate( + 'CloudFormation execution role', + artifact.cloudFormationExecutionRoleArn, + target.cloudFormationExecutionRoleArn, + 'cfn-execution-role-mismatch', + ) + ); +} + +function analyzeStacks(stackArtifacts: readonly AssemblyStack[], target: DriftTarget): DriftResult { const stacks: StackDrift[] = []; const warnings: string[] = []; const errors: string[] = []; - const artifacts = manifest?.artifacts ?? {}; - for (const [name, artifact] of Object.entries(artifacts)) { - if (artifact?.type !== 'aws:cloudformation:stack' || typeof artifact.environment !== 'string') { - continue; - } - const { account, region } = parseEnvironment(artifact.environment); + for (const artifact of stackArtifacts) { + const environment = artifact.environment ?? 'aws://unknown-account/unknown-region'; + const { account, region } = parseEnvironment(environment); let kind: DriftKind; let message: string; - if (AGNOSTIC.has(account) || AGNOSTIC.has(region)) { - kind = 'agnostic'; - message = `${name} is environment-agnostic (${artifact.environment}); resolved at deploy`; - } else if (target.account !== undefined && account !== target.account) { + const accountAgnostic = AGNOSTIC.has(account); + const regionAgnostic = AGNOSTIC.has(region); + if (target.account !== undefined && (accountAgnostic || account !== target.account)) { kind = 'account-mismatch'; - message = `${name} targets a different account than stage target -- refusing to deploy`; + message = accountAgnostic + ? `${artifact.id} does not bind the configured target account ${target.account} -- refusing to deploy with ambient credentials` + : `${artifact.id} targets a different account than stage target -- refusing to deploy`; errors.push(message); - } else if (region !== target.region) { + } else if (!regionAgnostic && region !== target.region) { kind = 'region-mismatch'; - message = `${name} targets region ${region}, stage target is ${target.region} -- continuing`; - warnings.push(message); + message = `${artifact.id} targets region ${region}, stage target is ${target.region} -- refusing to deploy`; + errors.push(message); } else { - kind = 'ok'; - message = `${name} matches the stage target`; + const roleMismatch = roleDrift(artifact, target); + if (roleMismatch !== undefined) { + kind = roleMismatch.kind; + message = roleMismatch.message; + errors.push(message); + } else if (accountAgnostic || regionAgnostic) { + kind = 'agnostic'; + message = `${artifact.id} is partially environment-agnostic (${environment}); unresolved dimensions resolve at deploy`; + } else { + kind = 'ok'; + message = `${artifact.id} matches the stage target and deployment identities`; + } } - stacks.push({ stack: name, account, region, kind, message }); + stacks.push({ + stack: artifact.id, + account, + region, + deployRoleArn: artifact.assumeRoleArn, + cloudFormationExecutionRoleArn: artifact.cloudFormationExecutionRoleArn, + kind, + message, + }); } return { stacks, warnings, errors, ok: errors.length === 0 }; } +/** Pure drift analysis of one parsed manifest. Use checkAssembly for recursive on-disk assemblies. */ +export function analyzeManifest(manifest: any, target: DriftTarget): DriftResult { + const artifacts = isRecord(manifest?.artifacts) ? manifest.artifacts : {}; + const stacks = Object.entries(artifacts) + .filter(([, artifact]) => artifact?.type === CLOUDFORMATION_STACK_ARTIFACT) + .map(([id, artifact]) => ({ + id, + stackName: + typeof artifact.properties?.stackName === 'string' && artifact.properties.stackName.length > 0 + ? artifact.properties.stackName + : id, + environment: typeof artifact.environment === 'string' ? artifact.environment : undefined, + assumeRoleArn: + typeof artifact.properties?.assumeRoleArn === 'string' ? artifact.properties.assumeRoleArn : undefined, + cloudFormationExecutionRoleArn: + typeof artifact.properties?.cloudFormationExecutionRoleArn === 'string' + ? artifact.properties.cloudFormationExecutionRoleArn + : undefined, + })); + return analyzeStacks(stacks, target); +} + /** Read `/manifest.json` and analyze it. Throws (with a drift-check message) if the manifest * is missing or not valid JSON. */ export function checkAssembly(outDir: string, target: DriftTarget): DriftResult { @@ -97,11 +367,10 @@ export function checkAssembly(outDir: string, target: DriftTarget): DriftResult if (!existsSync(manifestPath)) { throw new Error(`drift-check: no cloud assembly at ${manifestPath} -- synth first`); } - let manifest: any; try { - manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + return analyzeStacks(stacksFromAssembly(outDir), target); } catch (error) { - throw new Error(`drift-check: ${manifestPath} is not valid JSON (${(error as Error).message})`); + const message = (error as Error).message; + throw new Error(message.startsWith('drift-check:') ? message : `drift-check: ${message}`); } - return analyzeManifest(manifest, target); } diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/ExecCommand.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/ExecCommand.ts index 7d260c88..42af5bd7 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/ExecCommand.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/ExecCommand.ts @@ -15,7 +15,7 @@ import { logger } from '../../utils/Logging'; * 1. resolve the active stage's config (the app-config layer), * 2. export the stage's account/region so the stock `env: { account: process.env.CDK_DEFAULT_ACCOUNT, ... }` * line resolves the right target -- no `cfg.aws.*` reference in the user's code, - * 3. merge the config into CDK_CONTEXT_JSON as `cicd:config` WITHOUT clobbering user context, + * 3. merge app and wrapper config into separate CDK_CONTEXT_JSON keys WITHOUT clobbering user context, * 4. spawn the user entry under the register preload so App is subclassed at construction. * * The account/region export, context key, and diagnostic-arming flag are a contract with the @@ -24,6 +24,12 @@ import { logger } from '../../utils/Logging'; /** The wrapper's config context key. Mirrors AppConfig.CONTEXT_KEY. */ const CONFIG_CONTEXT_KEY = 'cicd:config'; +/** Wrapper-owned runtime context, kept separate so AppConfig.of() returns only stage application data. */ +const WRAPPER_CONFIG_CONTEXT_KEY = 'cicd:wrapper'; +/** Wrapper-owned target account; survives CDK CLI rewrites and overrides configured application values. */ +export const ACCOUNT_OVERRIDE_FLAG = 'CDK_CICD_ACCOUNT_OVERRIDE'; +/** Wrapper-owned target Region; survives CDK CLI rewrites and overrides configured application values. */ +export const REGION_OVERRIDE_FLAG = 'CDK_CICD_REGION_OVERRIDE'; // Arms the bundled-app diagnostic in the register preload. This literal is the runtime contract with // the constructs package's inject.ts EXEC_FLAG; kept in sync by the test asserting they match, and @@ -33,9 +39,12 @@ const EXEC_FLAG = 'CDK_CICD_EXEC'; // The forced-role env vars the preload's resolveSynthesizer reads (same cross-package literal contract // as EXEC_FLAG; the constructs package exports these as DEPLOY_ROLE_FLAG / CFN_EXEC_ROLE_FLAG / // DEPLOY_ROLE_EXTERNAL_ID_FLAG). -const DEPLOY_ROLE_FLAG = 'CDK_CICD_DEPLOY_ROLE_ARN'; -const CFN_EXEC_ROLE_FLAG = 'CDK_CICD_CFN_EXEC_ROLE_ARN'; -const DEPLOY_ROLE_EXTERNAL_ID_FLAG = 'CDK_CICD_DEPLOY_ROLE_EXTERNAL_ID'; +export const DEPLOY_ROLE_FLAG = 'CDK_CICD_DEPLOY_ROLE_ARN'; +export const CFN_EXEC_ROLE_FLAG = 'CDK_CICD_CFN_EXEC_ROLE_ARN'; +export const DEPLOY_ROLE_EXTERNAL_ID_FLAG = 'CDK_CICD_DEPLOY_ROLE_EXTERNAL_ID'; +export const COMPLIANCE_LOG_BUCKET_NAME_FLAG = 'CDK_CICD_COMPLIANCE_LOG_BUCKET_NAME'; +export const COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG = 'CDK_CICD_COMPLIANCE_LOG_BUCKET_ACCOUNT'; +export const COMPLIANCE_LOG_BUCKET_REGION_FLAG = 'CDK_CICD_COMPLIANCE_LOG_BUCKET_REGION'; /** Prefix marking a config value as a Secrets Manager reference to resolve at exec time. */ const SECRET_REF_PREFIX = 'resolve:secretsmanager:'; @@ -44,33 +53,68 @@ const SECRET_REF_PREFIX = 'resolve:secretsmanager:'; * Resolve a deploy-role ExternalId value: a literal is returned as-is; a `resolve:secretsmanager:` * reference is fetched from Secrets Manager (the `SecretString`) at exec time. Undefined/blank -> undefined. * - * The `@aws-sdk/client-secrets-manager` client is loaded through an UNTYPED dynamic import on purpose: it - * is not a declared build dependency (adding it drags a nested `@smithy/*` that conflicts with the CLI's - * hoisted copy and breaks `tsc --build`). The AWS SDK v3 is ambient in the pipeline/CI runtime where a - * `resolve:secretsmanager:` reference is actually used; a literal externalId needs no SDK at all. Kept - * async and isolated so the import is only paid for when a secret reference is present. + * Secret references use the AWS CLI already required by the wrapper's deployment workflows. Arguments + * are passed without a shell, the full JSON response is parsed, and failures name the referenced secret + * without ever logging its value. A reader can be injected for deterministic unit tests. */ -export async function resolveExternalId(value?: string): Promise { +export async function resolveExternalId( + value?: string, + readSecret: (secretId: string) => string | Promise = readSecretStringFromAwsCli, +): Promise { const trimmed = value?.trim(); if (trimmed === undefined || trimmed.length === 0) return undefined; if (!trimmed.startsWith(SECRET_REF_PREFIX)) return trimmed; const secretId = trimmed.slice(SECRET_REF_PREFIX.length); - const moduleName = '@aws-sdk/client-secrets-manager'; - // Untyped import (see the doc comment): tsc must not load this client's .d.ts. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sdk: any = await import(moduleName).catch(() => { + if (secretId.length === 0) { + throw new Error('cdk-cicd exec: resolve:secretsmanager: externalId reference is missing a secret id'); + } + const secret = await readSecret(secretId); + if (secret.length === 0) { + throw new Error(`cdk-cicd exec: secret '${secretId}' for the deploy-role externalId has no SecretString`); + } + return secret; +} + +/** + * Read a Secrets Manager `SecretString` through AWS CLI v2/v1. Kept synchronous because exec must + * finish resolving the role contract before it starts the child CDK process. + */ +export function readSecretStringFromAwsCli(secretId: string, runner: typeof spawnSync = spawnSync): string { + const arnRegion = /^arn:[^:]+:secretsmanager:([^:]+):/.exec(secretId)?.[1]; + const args = ['secretsmanager', 'get-secret-value', '--secret-id', secretId, '--output', 'json']; + if (arnRegion !== undefined) { + args.push('--region', arnRegion); + } + const result = runner('aws', args, { + encoding: 'utf-8', + env: { ...process.env, AWS_PAGER: '' }, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + if (result.error !== undefined) { throw new Error( - `cdk-cicd exec: resolving '${trimmed}' needs @aws-sdk/client-secrets-manager, which is not available ` + - 'in this environment. Provide the deploy-role externalId as a literal, or run where the AWS SDK v3 is present.', + `cdk-cicd exec: could not run AWS CLI to resolve Secrets Manager secret '${secretId}': ` + + `${result.error.message}. Install/configure the AWS CLI or provide a literal externalId.`, ); - }); - const client = new sdk.SecretsManager({}); - const res = await client.getSecretValue({ SecretId: secretId }); - const secret: string | undefined = res.SecretString; - if (secret === undefined || secret.length === 0) { + } + if (result.status !== 0) { + const detail = String(result.stderr ?? '').trim(); + throw new Error( + `cdk-cicd exec: AWS CLI could not read Secrets Manager secret '${secretId}'` + + (detail.length > 0 ? `: ${detail}` : ` (exit ${String(result.status)})`), + ); + } + + let response: { SecretString?: unknown }; + try { + response = JSON.parse(String(result.stdout ?? '')) as { SecretString?: unknown }; + } catch { + throw new Error(`cdk-cicd exec: AWS CLI returned invalid JSON for Secrets Manager secret '${secretId}'`); + } + if (typeof response.SecretString !== 'string' || response.SecretString.length === 0) { throw new Error(`cdk-cicd exec: secret '${secretId}' for the deploy-role externalId has no SecretString`); } - return secret; + return response.SecretString; } /** @@ -81,20 +125,36 @@ export async function resolveExternalId(value?: string): Promise { const out: { [key: string]: string } = {}; const deployment = cicdStage?.deployment; - if (deployment?.deployRole) { - out[DEPLOY_ROLE_FLAG] = deployment.deployRole; + const hasOverride = (name: string): boolean => Object.prototype.hasOwnProperty.call(overrides, name); + const deployRole = hasOverride(DEPLOY_ROLE_FLAG) + ? firstNonEmpty(overrides[DEPLOY_ROLE_FLAG]) + : firstNonEmpty(deployment?.deployRole); + const cfnExecutionRole = hasOverride(CFN_EXEC_ROLE_FLAG) + ? firstNonEmpty(overrides[CFN_EXEC_ROLE_FLAG]) + : firstNonEmpty(deployment?.cfnExecutionRole); + + if (deployRole !== undefined) { + out[DEPLOY_ROLE_FLAG] = deployRole; } - if (deployment?.cfnExecutionRole) { - out[CFN_EXEC_ROLE_FLAG] = deployment.cfnExecutionRole; + if (cfnExecutionRole !== undefined) { + out[CFN_EXEC_ROLE_FLAG] = cfnExecutionRole; } - // Per-stage externalId overrides the pipeline-level default. Only meaningful with a forced deployRole, - // but resolved whenever configured -- an externalId without a deployRole is a harmless no-op downstream. - const externalId = await resolveExternalId(deployment?.externalId ?? pipelineDeployRoleExternalId); - if (externalId !== undefined) { - out[DEPLOY_ROLE_EXTERNAL_ID_FLAG] = externalId; + + // Repo 2 resolves ExternalId references before launching Docker and passes the resolved value in the + // environment. Presence is authoritative, including an empty value that explicitly clears image-baked + // configuration. Do not feed an override back through resolveExternalId: a secret's literal value may + // itself begin with `resolve:secretsmanager:`. + if (deployRole !== undefined) { + const externalId = hasOverride(DEPLOY_ROLE_EXTERNAL_ID_FLAG) + ? firstNonEmpty(overrides[DEPLOY_ROLE_EXTERNAL_ID_FLAG]) + : await resolveExternalId(deployment?.externalId ?? pipelineDeployRoleExternalId); + if (externalId !== undefined) { + out[DEPLOY_ROLE_EXTERNAL_ID_FLAG] = externalId; + } } return out; } @@ -121,17 +181,17 @@ export interface CicdStageEnv { /** * Resolve the inner-loop deploy target's account/region by precedence (highest first): * - * 1. the chosen app-config file's `aws.accountId` / `aws.region` - * 2. the matching `cicd.config` stage's `env.account` / `env.regions[0]` - * 3. the per-stage `ACCOUNT_` / `REGION_` env vars (populated from SSM by the synth + * 1. `CDK_CICD_ACCOUNT_OVERRIDE` / `CDK_CICD_REGION_OVERRIDE` (the wrapper's explicit target contract) + * 2. the chosen app-config file's `aws.accountId` / `aws.region` + * 3. the matching `cicd.config` stage's `env.account` / `env.regions[0]` + * 4. the per-stage `ACCOUNT_` / `REGION_` env vars (populated from SSM by the synth * step's warming commands, or set by hand) - * 4. the ambient `CDK_DEFAULT_ACCOUNT` / `CDK_DEFAULT_REGION` + * 5. ambient `CDK_DEFAULT_*` / `AWS_*REGION` values * - * This is the INNER-LOOP resolution only (a plain `cdk deploy`/`synth` running one target). The - * self-mutating pipeline REPLAY path does not come through here: the assembler - * (`runtime/pipeline-assembler`) pins `CDK_DEFAULT_*` per stage in its own process and re-runs the - * entry, so a replayed stage's target is fixed by the assembler and never resolved by this function -- - * which is why moving `CDK_DEFAULT_*` to the bottom here is safe for multi-region pipelines. + * The CDK CLI can rewrite `CDK_DEFAULT_*` from its active credentials/profile before invoking the app, + * so those variables are never trusted over repository configuration. SynthCommand converts each + * resolved target into the wrapper-owned override flags before entering the CDK CLI. Presence of an + * override is authoritative even when empty, preserving Repo 2's ability to clear image-baked targets. * * `` is the resolved stage name uppercased (`resolveStage`). An absent value at every rung stays * absent, so an env-agnostic app stays agnostic. @@ -146,9 +206,22 @@ export function resolveEnvTarget( const stageKey = (stage ?? '').trim().toUpperCase(); const accountEnv = stageKey.length > 0 ? envIn[`ACCOUNT_${stageKey}`] : undefined; const regionEnv = stageKey.length > 0 ? envIn[`REGION_${stageKey}`] : undefined; + const hasAccountOverride = Object.prototype.hasOwnProperty.call(envIn, ACCOUNT_OVERRIDE_FLAG); + const hasRegionOverride = Object.prototype.hasOwnProperty.call(envIn, REGION_OVERRIDE_FLAG); return { - account: firstNonEmpty(aws.accountId, cicdStage?.env?.account, accountEnv, envIn.CDK_DEFAULT_ACCOUNT), - region: firstNonEmpty(aws.region, cicdStage?.env?.regions?.[0], regionEnv, envIn.CDK_DEFAULT_REGION), + account: hasAccountOverride + ? firstNonEmpty(envIn[ACCOUNT_OVERRIDE_FLAG]) + : firstNonEmpty(aws.accountId, cicdStage?.env?.account, accountEnv, envIn.CDK_DEFAULT_ACCOUNT), + region: hasRegionOverride + ? firstNonEmpty(envIn[REGION_OVERRIDE_FLAG]) + : firstNonEmpty( + aws.region, + cicdStage?.env?.regions?.[0], + regionEnv, + envIn.CDK_DEFAULT_REGION, + envIn.AWS_REGION, + envIn.AWS_DEFAULT_REGION, + ), }; } @@ -170,14 +243,130 @@ export function stageEnv(stage: string, target: { account?: string; region?: str return out; } +/** + * Compliance/access-log destination coordinates for the application child process. + * + * Presence of any compliance flag in `overrides` is authoritative, including an empty value used by + * Repo 2 to clear image-baked configuration. Otherwise a configured bucket must resolve to one physical + * location shared by every configured stage; S3 server access logging cannot reinterpret one bucket as + * living in a different account or Region for each invocation. + */ +export function complianceLoggingEnv( + cicd: Pick | undefined, + target: { account?: string; region?: string }, + overrides: NodeJS.ProcessEnv = {}, +): { [key: string]: string } { + const flags = [ + COMPLIANCE_LOG_BUCKET_NAME_FLAG, + COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, + COMPLIANCE_LOG_BUCKET_REGION_FLAG, + ]; + if (flags.some((flag) => Object.prototype.hasOwnProperty.call(overrides, flag))) { + return {}; + } + + const bucketName = cicd?.complianceLogBucketName; + if (cicd === undefined || bucketName === undefined) { + return {}; + } + + let bucketAccount: string | undefined; + let bucketRegion: string | undefined; + for (const stage of cicd.stages) { + const account = firstNonEmpty(stage.env.account); + const regions = stage.env.regions.filter((region) => region.trim().length > 0); + if (account === undefined || regions.length !== 1) { + throw new Error( + `cdk-cicd exec: compliance bucket '${bucketName}' requires every configured stage to resolve ` + + 'to one concrete shared account and Region', + ); + } + if (bucketAccount === undefined) { + bucketAccount = account; + bucketRegion = regions[0]; + } else if (bucketAccount !== account || bucketRegion !== regions[0]) { + throw new Error( + `cdk-cicd exec: compliance bucket '${bucketName}' cannot represent one physical bucket across ` + + 'multiple configured accounts or Regions', + ); + } + } + if (bucketAccount === undefined || bucketRegion === undefined) { + throw new Error( + `cdk-cicd exec: compliance bucket '${bucketName}' requires at least one configured stage with ` + + 'a concrete account and Region', + ); + } + if (target.account === undefined || target.region === undefined) { + throw new Error(`cdk-cicd exec: compliance bucket '${bucketName}' requires a resolved target account and Region`); + } + if (target.account !== bucketAccount || target.region !== bucketRegion) { + throw new Error( + `cdk-cicd exec: target ${target.account}/${target.region} does not match compliance bucket ` + + `'${bucketName}' location ${bucketAccount}/${bucketRegion}`, + ); + } + return { + [COMPLIANCE_LOG_BUCKET_NAME_FLAG]: bucketName, + [COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG]: bucketAccount, + [COMPLIANCE_LOG_BUCKET_REGION_FLAG]: bucketRegion, + }; +} + +/** + * Overlay an explicit wrapper target onto the application config injected as `cicd:config`. + * + * Exporting only `CDK_DEFAULT_*` is insufficient for applications that derive stack environments from + * `AppConfig.of(...).aws`: without this overlay they continue to see the configured first target rather + * than the per-region target selected by DeployCommand. Ambient CDK/AWS values are deliberately ignored + * here because the CDK CLI can rewrite them from credentials or a profile. Presence of a wrapper override + * is authoritative even when empty; an unresolved target therefore removes that dimension from the + * image config instead of restoring its stale value. + */ +export function appConfigForTarget( + config: { [key: string]: any }, + target: { account?: string; region?: string }, + env: NodeJS.ProcessEnv, +): { [key: string]: any } { + const hasAccountOverride = Object.prototype.hasOwnProperty.call(env, ACCOUNT_OVERRIDE_FLAG); + const hasRegionOverride = Object.prototype.hasOwnProperty.call(env, REGION_OVERRIDE_FLAG); + if (!hasAccountOverride && !hasRegionOverride) { + return config; + } + + const configuredAws = + config.aws !== null && typeof config.aws === 'object' && !Array.isArray(config.aws) ? config.aws : {}; + const aws: { [key: string]: any } = { ...configuredAws }; + if (hasAccountOverride) { + if (target.account === undefined) { + delete aws.accountId; + } else { + aws.accountId = target.account; + } + } + if (hasRegionOverride) { + if (target.region === undefined) { + delete aws.region; + } else { + aws.region = target.region; + } + } + return { ...config, aws }; +} + /** * Build the `CDK_CONTEXT_JSON` value the spawned app will read. Starts from whatever context already * exists -- the CLI sets `CDK_CONTEXT_JSON` from `cdk.json` + `cdk.context.json` + `--context` when it * invokes the app command; when exec is run standalone, fall back to reading those files ourselves -- - * then adds `cicd:config`. A user-set `cicd:config` is never clobbered, and no other user context key - * is touched. + * then adds the app config under `cicd:config` and the wrapper-owned config under `cicd:wrapper`. + * User-set values at either key are never clobbered, and no other context key is touched. */ -export function buildContextJson(config: { [key: string]: any }, env: NodeJS.ProcessEnv, cwd: string): string { +export function buildContextJson( + config: { [key: string]: any }, + wrapperConfig: { [key: string]: any }, + env: NodeJS.ProcessEnv, + cwd: string, +): string { let base: { [key: string]: any } = {}; const existing = env.CDK_CONTEXT_JSON; @@ -194,9 +383,61 @@ export function buildContextJson(config: { [key: string]: any }, env: NodeJS.Pro if (!(CONFIG_CONTEXT_KEY in base)) { base[CONFIG_CONTEXT_KEY] = config; } + if (Object.keys(wrapperConfig).length > 0 && !(WRAPPER_CONFIG_CONTEXT_KEY in base)) { + base[WRAPPER_CONFIG_CONTEXT_KEY] = wrapperConfig; + } return JSON.stringify(base); } +/** Select only wrapper-owned cicd.config fields for the separate runtime context. */ +export function wrapperRuntimeConfig(cicd?: ResolvedCicdConfig): { [key: string]: any } { + if (cicd === undefined) return {}; + const config: { [key: string]: any } = { + application: cicd.application, + qualifier: cicd.qualifier, + synthesizer: cicd.synthesizer, + plugins: cicd.plugins, + }; + return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined)); +} + +/** + * Resolve stage ExternalIds before the synchronous self-mutating assembler replays the application. + * Each stage receives an explicit resolved value, and the top-level fallback is cleared so replay can + * never accidentally pass a `resolve:secretsmanager:` marker to STS as the literal ExternalId. + */ +export async function resolvePipelineExternalIds( + cicd: ResolvedCicdConfig, + resolver: (value?: string) => Promise = resolveExternalId, +): Promise { + const cache = new Map>(); + const resolveOnce = (value?: string): Promise => { + if (value === undefined) return Promise.resolve(undefined); + const cached = cache.get(value); + if (cached !== undefined) return cached; + const pending = resolver(value); + cache.set(value, pending); + return pending; + }; + + const stages = await Promise.all( + cicd.stages.map(async (stage) => { + if (stage.deployment?.deployRole === undefined || stage.deployment.deployRole.trim().length === 0) { + return stage; + } + const externalId = await resolveOnce(stage.deployment.externalId ?? cicd.deployRoleExternalId); + return { + ...stage, + deployment: { + ...stage.deployment, + externalId, + }, + }; + }), + ); + return { ...cicd, stages, deployRoleExternalId: undefined }; +} + function safeParseObject(raw: string): { [key: string]: any } { try { const parsed = JSON.parse(raw); @@ -307,9 +548,10 @@ async function renderPipeline(entry: string, cicd: ResolvedCicdConfig | undefine const engineValue = cicd.engine as string | undefined; if (engineValue === 'cdk-pipelines' || engineValue === 'github-actions') { + const resolvedCicd = await resolvePipelineExternalIds(cicd); // eslint-disable-next-line @typescript-eslint/no-require-imports const { assemblePipelineApp } = require('@cdklabs/cdk-cicd-wrapper/lib/runtime/pipeline-assembler'); - assemblePipelineApp(cicd, path.resolve(cwd, entry)).synth(); + assemblePipelineApp(resolvedCicd, path.resolve(cwd, entry)).synth(); return; } const { PipelineApp } = await import('@cdklabs/cdk-cicd-wrapper'); @@ -336,18 +578,10 @@ class Command implements yargs.CommandModule { // Two layers: app-config drives the injected cicd:config context (the app tree); the cicd.config // stage supplies the deploy target account/region when the caller has not already pinned one. const config = await loadConfig(stage); - // A broken cicd.config must not take down the zero-touch path: exec runs on every `cdk deploy`, - // and a single-region app may not depend on the pipeline config at all. Warn and fall through to - // app-config resolution rather than aborting the app command. - let cicd; - try { - cicd = loadCicdConfig(cwd); - } catch (error) { - logger.warn( - `cdk-cicd exec: ignoring an unloadable cicd.config (${(error as Error).message}); ` + - 'resolving the deploy target from app-config only', - ); - } + // `undefined` means no cicd.config exists. If a discovered config cannot be parsed, imported, or + // validated, propagate that error: silently dropping its stages, roles, synthesizer, and plugins + // would execute a materially different application or pipeline. + const cicd = loadCicdConfig(cwd); const cicdStage = cicd ? stageByName(cicd, stage) : undefined; const target = resolveEnvTarget(process.env, config, cicdStage, stage); @@ -366,14 +600,17 @@ class Command implements yargs.CommandModule { return; } + const contextConfig = appConfigForTarget(config, target, process.env); const childEnv: NodeJS.ProcessEnv = { ...process.env, ...stageEnv(stage, target), ...(await forcedRoleEnv( cicdStage, (cicd as { deployRoleExternalId?: string } | undefined)?.deployRoleExternalId, + process.env, )), - CDK_CONTEXT_JSON: buildContextJson(config, process.env, cwd), + ...complianceLoggingEnv(cicd, target, process.env), + CDK_CONTEXT_JSON: buildContextJson(contextConfig, wrapperRuntimeConfig(cicd), process.env, cwd), // The `-r ts-node/register` preload takes no options, so the module kind has to come from the // environment. Same requirement as the config loader: the entry is `require`d, so it must // transpile to CommonJS or Node throws on the first `import` (see TS_NODE_COMPILER_OPTIONS). diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/SynthCommand.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/SynthCommand.ts index 9e77b025..65d99a3b 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/SynthCommand.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/src/cmds/autopilot/SynthCommand.ts @@ -12,7 +12,7 @@ import * as path from 'path'; import type { ResolvedCicdConfig } from '@cdklabs/cdk-cicd-wrapper'; import * as yargs from 'yargs'; import { load as loadCicdConfig, stageByName } from './CicdConfig'; -import { stageEnv } from './ExecCommand'; +import { ACCOUNT_OVERRIDE_FLAG, REGION_OVERRIDE_FLAG, stageEnv } from './ExecCommand'; import { logger } from '../../utils/Logging'; /** One synth target: a single (stage, region), its output dir and the environment overrides it needs. */ @@ -24,6 +24,13 @@ export interface SynthTarget { readonly env: { [key: string]: string }; } +/** Ambient values that can complete or override a target resolved from cicd.config. */ +export type SynthTargetEnvironment = NodeJS.ProcessEnv; + +function firstNonEmpty(...values: Array): string | undefined { + return values.map((value) => value?.trim()).find((value): value is string => value !== undefined && value.length > 0); +} + /** * Enumerate the (stage × region) synth targets. `stageName` undefined selects every stage (the * CI-validation `--all` case); a name selects that one stage's region list. Order follows the config. @@ -33,24 +40,58 @@ export interface SynthTarget { * target's env is authoritative for where to deploy, so a single-region run must be able to override * whatever region set the image's own `cicd.config` happens to carry -- including an env-agnostic stage * with no regions, which then still yields one target rather than nothing. + * + * A stage with no configured regions falls back to the ambient CDK/AWS region. Container mode also + * supplies wrapper-owned overrides; a present empty override clears image configuration and is resolved + * against the ambient process before entering the CDK CLI. Every resulting target then carries explicit + * wrapper override flags so later CDK CLI rewrites of `CDK_DEFAULT_*` cannot change its authority. */ -export function synthTargets(config: ResolvedCicdConfig, stageName?: string, regionOverride?: string): SynthTarget[] { +export function synthTargets( + config: ResolvedCicdConfig, + stageName?: string, + regionOverride?: string, + ambient: SynthTargetEnvironment = process.env, +): SynthTarget[] { const stages = stageName !== undefined ? config.stages.filter((s) => s.name === stageName) : config.stages; const targets: SynthTarget[] = []; for (const stage of stages) { - const regions = regionOverride !== undefined ? [regionOverride] : stage.env.regions; + const hasRegionOverride = Object.prototype.hasOwnProperty.call(ambient, REGION_OVERRIDE_FLAG); + const ambientRegion = firstNonEmpty( + ambient[REGION_OVERRIDE_FLAG], + ambient.CDK_DEFAULT_REGION, + ambient.AWS_REGION, + ambient.AWS_DEFAULT_REGION, + ); + const regions = + regionOverride !== undefined + ? [regionOverride] + : hasRegionOverride + ? ambientRegion !== undefined + ? [ambientRegion] + : [] + : stage.env.regions.length > 0 + ? stage.env.regions + : ambientRegion !== undefined + ? [ambientRegion] + : []; + const hasAccountOverride = Object.prototype.hasOwnProperty.call(ambient, ACCOUNT_OVERRIDE_FLAG); + const account = hasAccountOverride + ? firstNonEmpty(ambient[ACCOUNT_OVERRIDE_FLAG], ambient.CDK_DEFAULT_ACCOUNT) + : stage.env.account; for (const region of regions) { targets.push({ stage: stage.name, region, - account: stage.env.account, + account, outDir: path.join('cdk.out', stage.name, region), // AWS_REGION/AWS_DEFAULT_REGION as well as the CDK_* pair: the CDK CLI re-derives the app's // CDK_DEFAULT_REGION from AWS_REGION/profile before running the app command, so setting only // CDK_DEFAULT_REGION would be silently overridden -- the per-region synth has to steer the // CLI's own region resolution. env: { - ...stageEnv(stage.name, { account: stage.env.account, region }), + ...stageEnv(stage.name, { account, region }), + [ACCOUNT_OVERRIDE_FLAG]: account ?? '', + [REGION_OVERRIDE_FLAG]: region, AWS_REGION: region, AWS_DEFAULT_REGION: region, }, @@ -88,13 +129,13 @@ class Command implements yargs.CommandModule { process.exit(1); } - const targets = synthTargets(config, stageName); + const targets = synthTargets(config, stageName, undefined, process.env); if (targets.length === 0) { - // A stage with no regions (env-agnostic) selects nothing -- say so rather than exiting 0 silently. - logger.warn( - `cdk-cicd synth: nothing to synthesize (${stageName ?? 'all stages'} produced no stage x region targets)`, + logger.error( + `cdk-cicd synth: ${stageName ?? 'all stages'} produced no stage x region targets; ` + + 'configure a region or set CDK_DEFAULT_REGION/AWS_REGION', ); - return; + process.exit(1); } for (const target of targets) { logger.info(`cdk-cicd synth: ${target.stage} -> ${target.region} (${target.outDir})`); diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/CicdConfig.test.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/CicdConfig.test.ts index 67848b47..e5a38565 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/CicdConfig.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/CicdConfig.test.ts @@ -77,6 +77,24 @@ describe('m3-config-discovery: load + stageByName', () => { expect(cfg?.application).toBe('from-ts'); expect(cfg?.stages[0].name).toBe('dev'); }); + + test('propagates a syntax error from an existing config', () => { + const dir = tempDir(); + fs.writeFileSync(path.join(dir, 'cicd.config.js'), 'module.exports.default = {'); + expect(() => load(dir)).toThrow(); + }); + + test('propagates an import error from an existing config', () => { + const dir = tempDir(); + fs.writeFileSync(path.join(dir, 'cicd.config.js'), "require('./missing-config-dependency');"); + expect(() => load(dir)).toThrow(/missing-config-dependency/); + }); + + test('propagates validation failures raised while evaluating an existing config', () => { + const dir = tempDir(); + fs.writeFileSync(path.join(dir, 'cicd.config.js'), "throw new Error('config validation failed');"); + expect(() => load(dir)).toThrow(/config validation failed/); + }); }); describe('m6-container: loadDeployment (Repo 2 deploy.config discovery)', () => { diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DeployCommand.test.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DeployCommand.test.ts index d6edd49a..a61d1d0f 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DeployCommand.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DeployCommand.test.ts @@ -4,8 +4,23 @@ // Unit tests for deploy's pure argv builder. The full synth->drift->deploy orchestration (spawns cdk // and aws) is proven end to end by the m3-verify real-AWS gate. +import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; -import { assertPromotedAssembly, deployArgs, planFromAssembly } from '../../src/cmds/autopilot/DeployCommand'; +import { + assertDeployRoleOverrideAllowed, + assertDeploymentModeOptions, + assertPromotedAssembly, + deployArgs, + deploymentEnvironment, + driftAccountForTarget, + effectiveBootstrapQualifier, + expectedSynthesizedRole, + RegionalDeploymentResult, + runRegionalDeployments, + planFromAssembly, +} from '../../src/cmds/autopilot/DeployCommand'; +import { CFN_EXEC_ROLE_FLAG, DEPLOY_ROLE_FLAG } from '../../src/cmds/autopilot/ExecCommand'; describe('m3-deploy: deployArgs', () => { test('deploys the assembly with no approval prompt and no role when none is configured', () => { @@ -14,23 +29,31 @@ describe('m3-deploy: deployArgs', () => { 'deploy', '--app', 'cdk.out/dev/us-west-2', - '--all', + '**', '--require-approval', 'never', ]); }); - test('appends --role-arn when the stage has a forced deploy role', () => { - const args = deployArgs('cdk.out/prod/us-west-1', 'arn:aws:iam::111111111111:role/Deploy'); - expect(args.slice(-2)).toEqual(['--role-arn', 'arn:aws:iam::111111111111:role/Deploy']); + test('uses the recursive stack selector instead of CDK --all, which is top-level-only', () => { + expect(deployArgs('cdk.out/dev/us-west-2')).toContain('**'); + expect(deployArgs('cdk.out/dev/us-west-2')).not.toContain('--all'); }); - test('an empty role string is treated as no role', () => { - expect(deployArgs('cdk.out/dev/us-west-2', '')).not.toContain('--role-arn'); + test('never passes a deployment role as CDK --role-arn', () => { + const env = deploymentEnvironment({}, 'arn:aws:iam::111111111111:role/Deploy'); + expect(env[DEPLOY_ROLE_FLAG]).toBe('arn:aws:iam::111111111111:role/Deploy'); + expect(deployArgs('cdk.out/prod/us-west-1')).not.toContain('--role-arn'); + }); + + test('a present-but-empty Repo 2 role override survives into synthesis to clear an image-baked role', () => { + const ambient = { [DEPLOY_ROLE_FLAG]: '' }; + expect(deploymentEnvironment(ambient)).toBe(ambient); + expect(deploymentEnvironment(ambient)[DEPLOY_ROLE_FLAG]).toBe(''); }); test('express mode adds --express (rollback stays disabled -- --rollback conflicts with express + nested stacks)', () => { - const args = deployArgs('cdk.out/dev/us-west-2', undefined, undefined, true); + const args = deployArgs('cdk.out/dev/us-west-2', undefined, true); expect(args).toContain('--express'); expect(args).not.toContain('--rollback'); }); @@ -40,7 +63,7 @@ describe('m3-deploy: deployArgs', () => { }); test('prepare mode (change set) takes precedence over express -- no --express with --no-execute', () => { - const args = deployArgs('cdk.out/dev/us-west-2', undefined, 'cdk-cicd-9', true); + const args = deployArgs('cdk.out/dev/us-west-2', 'cdk-cicd-9', true); expect(args).toContain('--no-execute'); expect(args).not.toContain('--express'); }); @@ -48,7 +71,7 @@ describe('m3-deploy: deployArgs', () => { describe('m4-deploy-observer: deployArgs prepare mode', () => { test('a change-set name turns deploy into prepare-without-executing', () => { - const args = deployArgs('cdk.out/dev/us-west-2', undefined, 'cdk-cicd-42'); + const args = deployArgs('cdk.out/dev/us-west-2', 'cdk-cicd-42'); // --no-execute is the whole point: assets get published and the change set created, then cdk returns // instead of holding the build container for the CloudFormation wait. expect(args).toContain('--no-execute'); @@ -57,15 +80,253 @@ describe('m4-deploy-observer: deployArgs prepare mode', () => { test('without a change-set name the argv is unchanged, so the proven path is untouched', () => { expect(deployArgs('cdk.out/dev/us-west-2')).not.toContain('--no-execute'); - expect(deployArgs('cdk.out/dev/us-west-2', 'arn:aws:iam::111111111111:role/D')).not.toContain('--no-execute'); + }); +}); + +describe('promoted assembly role contract', () => { + test('rejects --deploy-role with --from-assembly because the promoted manifest owns the roles', () => { + expect(() => assertDeployRoleOverrideAllowed(true, 'arn:aws:iam::111111111111:role/Deploy')).toThrow( + /--deploy-role cannot be used with --from-assembly.*already embedded/s, + ); + }); + + test('allows a role override only when synthesis still runs', () => { + expect(() => assertDeployRoleOverrideAllowed(false, 'arn:aws:iam::111111111111:role/Deploy')).not.toThrow(); + expect(() => assertDeployRoleOverrideAllowed(true, undefined)).not.toThrow(); + }); +}); + +describe('synthesized role expectations', () => { + const target = { + account: '111111111111', + region: 'eu-west-1', + qualifier: 'hnb659fds', + }; + + test('uses the configured stage roles when no environment override is present', () => { + expect(expectedSynthesizedRole({}, DEPLOY_ROLE_FLAG, ' arn:aws:iam::111111111111:role/Deploy ', target)).toBe( + 'arn:aws:iam::111111111111:role/Deploy', + ); + expect(expectedSynthesizedRole({}, CFN_EXEC_ROLE_FLAG, 'arn:aws:iam::111111111111:role/CfnExec', target)).toBe( + 'arn:aws:iam::111111111111:role/CfnExec', + ); + }); + + test('an environment role override is authoritative and normalized like the runtime synthesizer', () => { + expect( + expectedSynthesizedRole( + { [DEPLOY_ROLE_FLAG]: ' arn:aws:iam::222222222222:role/Override ' }, + DEPLOY_ROLE_FLAG, + 'arn:aws:iam::111111111111:role/Configured', + target, + ), + ).toBe('arn:aws:iam::222222222222:role/Override'); + }); + + test('a present empty environment value explicitly clears the configured role expectation', () => { + expect( + expectedSynthesizedRole( + { [DEPLOY_ROLE_FLAG]: ' ' }, + DEPLOY_ROLE_FLAG, + 'arn:aws:iam::111111111111:role/Configured', + target, + ), + ).toBeUndefined(); + }); + + test('a promoted assembly ignores ambient role flags and preserves the configured role expectation', () => { + const configured = 'arn:aws:iam::111111111111:role/Configured'; + expect( + expectedSynthesizedRole({ [DEPLOY_ROLE_FLAG]: '' }, DEPLOY_ROLE_FLAG, configured, target, 'promoted-assembly'), + ).toBe(configured); + expect( + expectedSynthesizedRole( + { [DEPLOY_ROLE_FLAG]: 'arn:aws:iam::111111111111:role/Ambient' }, + DEPLOY_ROLE_FLAG, + configured, + target, + 'promoted-assembly', + ), + ).toBe(configured); + }); + + test('specializes the exact role template CDK emits for the target while preserving its partition token', () => { + expect( + expectedSynthesizedRole( + {}, + DEPLOY_ROLE_FLAG, + 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-deploy-${AWS::Region}-${AWS::Region}', + target, + ), + ).toBe('arn:${AWS::Partition}:iam::111111111111:role/cdk-hnb659fds-deploy-eu-west-1-eu-west-1'); + }); + + test('uses the configured bootstrap qualifier when specializing a role template', () => { + expect( + expectedSynthesizedRole( + {}, + CFN_EXEC_ROLE_FLAG, + 'arn:aws:iam::${AWS::AccountId}:role/cdk-${Qualifier}-cfn-exec-${AWS::Region}', + { ...target, qualifier: 'shop123' }, + ), + ).toBe('arn:aws:iam::111111111111:role/cdk-shop123-cfn-exec-eu-west-1'); + }); +}); + +describe('effective bootstrap qualifier', () => { + test('explicit config wins over context', () => { + expect( + effectiveBootstrapQualifier('configured', '.', { + CDK_CONTEXT_JSON: JSON.stringify({ '@aws-cdk/core:bootstrapQualifier': 'context' }), + }), + ).toBe('configured'); + }); + + test('uses the qualifier from merged CDK context when config omits it', () => { + expect( + effectiveBootstrapQualifier(undefined, '.', { + CDK_CONTEXT_JSON: JSON.stringify({ '@aws-cdk/core:bootstrapQualifier': 'ctxqual' }), + }), + ).toBe('ctxqual'); + }); + + test('uses CDK on-disk context precedence when no context environment is present', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-qualifier-')); + try { + fs.writeFileSync( + path.join(dir, 'cdk.json'), + JSON.stringify({ context: { '@aws-cdk/core:bootstrapQualifier': 'fromjson' } }), + ); + fs.writeFileSync( + path.join(dir, 'cdk.context.json'), + JSON.stringify({ '@aws-cdk/core:bootstrapQualifier': 'ctxfile' }), + ); + expect(effectiveBootstrapQualifier(undefined, dir, {})).toBe('ctxfile'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('uses the standard CDK qualifier when config and context omit it', () => { + expect(effectiveBootstrapQualifier(undefined, '.', { CDK_CONTEXT_JSON: '{}' })).toBe('hnb659fds'); + }); + + test('promoted assemblies ignore ambient context injection and use repository context', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-promoted-qualifier-')); + try { + fs.writeFileSync( + path.join(dir, 'cdk.json'), + JSON.stringify({ context: { '@aws-cdk/core:bootstrapQualifier': 'repoqual' } }), + ); + expect( + effectiveBootstrapQualifier( + undefined, + dir, + { CDK_CONTEXT_JSON: JSON.stringify({ '@aws-cdk/core:bootstrapQualifier': 'ambient' }) }, + 'promoted-assembly', + ), + ).toBe('repoqual'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('deploy mode contracts', () => { + const direct = { + fromImage: false, + fromAssembly: false, + prepareOnly: false, + }; + + test('rejects promoted-assembly and role flags in image mode instead of ignoring them', () => { + expect(() => + assertDeploymentModeOptions({ + ...direct, + fromImage: true, + fromAssembly: true, + deployRole: 'arn:aws:iam::111111111111:role/Deploy', + }), + ).toThrow(/--from-image cannot be combined with --from-assembly, --deploy-role/); + }); + + test('rejects direct-mode flags that image mode does not consume', () => { + expect(() => + assertDeploymentModeOptions({ + ...direct, + fromImage: true, + stage: 'dev', + region: 'eu-west-1', + prepareOnly: true, + planParameter: '/plan', + }), + ).toThrow(/--stage, --region, --prepare-only, --plan-parameter/); + }); + + test('rejects image-only and prepare-only companion flags in direct mode', () => { + expect(() => assertDeploymentModeOptions({ ...direct, target: 'dev' })).toThrow(/require --from-image/); + expect(() => assertDeploymentModeOptions({ ...direct, planParameter: '/plan' })).toThrow(/requires --prepare-only/); + }); +}); + +describe('deploy drift account selection', () => { + test('an explicit cross-account target wins over the ambient pipeline account', () => { + expect(driftAccountForTarget('222222222222', '111111111111')).toBe('222222222222'); + }); + + test('ambient identity is used only for a genuinely account-agnostic target', () => { + expect(driftAccountForTarget(undefined, '111111111111')).toBe('111111111111'); + expect(() => driftAccountForTarget(undefined, undefined)).toThrow(/without an ambient STS account/); + }); +}); + +describe('regional deploy ordering', () => { + const plan = (region: string) => [{ stackName: `stack-${region}`, changeSetName: 'cs', region }]; + + test('parallel launches every region, then selects failures and plans in configured order', async () => { + const regions = ['eu-west-1', 'us-east-1', 'ap-southeast-2', 'sa-east-1']; + const started: string[] = []; + const pending = new Map void>(); + const deployment = runRegionalDeployments(regions, 'parallel', (region) => { + started.push(region); + return new Promise((resolve) => pending.set(region, resolve)); + }); + + expect(started).toEqual(regions); + pending.get('sa-east-1')!({ code: 0, plan: plan('sa-east-1') }); + pending.get('ap-southeast-2')!({ code: 7, plan: [] }); + pending.get('us-east-1')!({ code: 5, plan: [] }); + pending.get('eu-west-1')!({ code: 0, plan: plan('eu-west-1') }); + + const result = await deployment; + expect(result.code).toBe(5); + expect(result.results.map((entry) => entry.code)).toEqual([0, 5, 7, 0]); + expect(result.plan.map((entry) => entry.region)).toEqual(['eu-west-1', 'sa-east-1']); + }); + + test('sequential preserves order and does not start regions after a failure', async () => { + const started: string[] = []; + const result = await runRegionalDeployments( + ['eu-west-1', 'us-east-1', 'ap-southeast-2'], + 'sequential', + async (region) => { + started.push(region); + return region === 'us-east-1' ? { code: 2, plan: [] } : { code: 0, plan: plan(region) }; + }, + ); + + expect(started).toEqual(['eu-west-1', 'us-east-1']); + expect(result.code).toBe(2); + expect(result.plan.map((entry) => entry.region)).toEqual(['eu-west-1']); }); }); describe('m4-deploy-observer: planFromAssembly', () => { - const stack = (deps: string[] = [], stackName?: string) => ({ + const stack = (deps: string[] = [], stackName?: string, environment?: string) => ({ type: 'aws:cloudformation:stack', dependencies: deps, properties: stackName ? { stackName } : {}, + ...(environment === undefined ? {} : { environment }), }); // A reader mapping directory -> manifest, so nested assemblies are modelled without touching disk. const reader = (manifests: { [dir: string]: any }) => (dir: string) => { @@ -99,13 +360,34 @@ describe('m4-deploy-observer: planFromAssembly', () => { ]); }); + test("carries each stack artifact's actual region instead of stamping the invocation region", () => { + const r = reader({ + o: { + artifacts: { + East: stack([], 'east-stack', 'aws://111111111111/us-east-1'), + Europe: stack(['East'], 'europe-stack', 'aws://111111111111/eu-west-1'), + Agnostic: stack(['Europe'], 'agnostic-stack', 'aws://unknown-account/unknown-region'), + }, + }, + }); + expect(planFromAssembly('o', 'us-west-2', 'cs', r)).toEqual([ + { stackName: 'east-stack', changeSetName: 'cs', region: 'us-east-1' }, + { stackName: 'europe-stack', changeSetName: 'cs', region: 'eu-west-1' }, + { stackName: 'agnostic-stack', changeSetName: 'cs', region: 'us-west-2' }, + ]); + }); + test('RECURSES into nested cloud assemblies (cdk.Stage) -- else those stacks silently never deploy', () => { // A cdk.Stage synthesizes into a nested assembly. A flat scan of the top manifest misses its stacks, // the driver executes nothing for them, and the action goes green having deployed part (or none). const r = reader({ o: { artifacts: { - Prod: { type: 'aws:cloud-assembly', dependencies: [], properties: { directory: 'assembly-Prod' } }, + Prod: { + type: 'cdk:cloud-assembly', + dependencies: [], + properties: { directoryName: 'assembly-Prod' }, + }, Top: stack([], 'top-stack'), }, }, @@ -118,25 +400,55 @@ describe('m4-deploy-observer: planFromAssembly', () => { ]); }); + test('matches the installed CDK Stage schema and stacksRecursively API', () => { + // aws-cdk-lib is the wrapper package's installed peer and is used here only as the schema oracle. + // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies + const cdk: any = require('aws-cdk-lib'); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-plan-stage-')); + try { + const app = new cdk.App({ outdir: outDir }); + const stage = new cdk.Stage(app, 'Prod', { + env: { account: '222222222222', region: 'eu-west-1' }, + }); + new cdk.Stack(stage, 'Nested', { stackName: 'nested-prod-stack' }); + const assembly = app.synth(); + + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'manifest.json'), 'utf-8')); + const nestedArtifact = Object.values(manifest.artifacts).find( + (artifact) => artifact.type === 'cdk:cloud-assembly', + ); + expect(nestedArtifact).toMatchObject({ + type: 'cdk:cloud-assembly', + properties: { directoryName: expect.any(String) }, + }); + + const plan = planFromAssembly(outDir, 'us-west-2', 'cs'); + expect(plan.map((entry) => entry.stackName)).toEqual( + assembly.stacksRecursively.map((artifact: any) => artifact.stackName), + ); + expect(plan).toEqual([{ stackName: 'nested-prod-stack', changeSetName: 'cs', region: 'eu-west-1' }]); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); + test('falls back to the artifact id when the manifest carries no stackName', () => { const r = reader({ o: { artifacts: { OnlyId: stack() } } }); expect(planFromAssembly('o', 'us-west-2', 'cs', r)[0].stackName).toEqual('OnlyId'); }); - test('a dependency cycle still terminates instead of hanging the build', () => { + test('a dependency cycle fails closed instead of hanging or inventing an order', () => { const r = reader({ o: { artifacts: { A: stack(['B'], 'a'), B: stack(['A'], 'b') } } }); - expect( - planFromAssembly('o', 'us-west-2', 'cs', r) - .map((e) => e.stackName) - .sort(), - ).toEqual(['a', 'b']); + expect(() => planFromAssembly('o', 'us-west-2', 'cs', r)).toThrow(/artifact dependency cycle/); }); - test('an assembly with no stacks yields an empty plan, not a crash', () => { - expect( + test('an assembly with no stacks fails closed instead of producing a green empty deployment', () => { + expect(() => planFromAssembly('o', 'us-west-2', 'cs', reader({ o: { artifacts: { Tree: { type: 'cdk:tree' } } } })), - ).toEqual([]); - expect(planFromAssembly('o', 'us-west-2', 'cs', reader({ o: {} }))).toEqual([]); + ).toThrow(/contains no deployable CloudFormation stacks/); + expect(() => planFromAssembly('o', 'us-west-2', 'cs', reader({ o: {} }))).toThrow( + /contains no deployable CloudFormation stacks/, + ); }); }); diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DeployFromImage.test.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DeployFromImage.test.ts index 6a278b30..19456563 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DeployFromImage.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DeployFromImage.test.ts @@ -2,8 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 import { SpawnSyncReturns } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { ResolvedDeploymentConfig } from '@cdklabs/cdk-cicd-wrapper'; -import { dockerRunArgs, resolveTargetImage, runFromImage, targetRuns } from '../../src/cmds/autopilot/DeployFromImage'; +import { + DockerSpawnOptions, + dockerRunArgs, + readVersionFromConfig, + resolveTargetImage, + runFromImage, + targetRuns, +} from '../../src/cmds/autopilot/DeployFromImage'; +import { + CFN_EXEC_ROLE_FLAG, + COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, + COMPLIANCE_LOG_BUCKET_NAME_FLAG, + COMPLIANCE_LOG_BUCKET_REGION_FLAG, + DEPLOY_ROLE_EXTERNAL_ID_FLAG, + DEPLOY_ROLE_FLAG, +} from '../../src/cmds/autopilot/ExecCommand'; const IMAGE = 'acct.dkr.ecr.eu-west-1.amazonaws.com/my-app-deployer:1.4.2'; @@ -18,12 +36,23 @@ const ok = (): SpawnSyncReturns => ({ }); describe('m6-container: dockerRunArgs', () => { + test('uses the exact runtime compliance-injection environment contract', () => { + expect(COMPLIANCE_LOG_BUCKET_NAME_FLAG).toBe('CDK_CICD_COMPLIANCE_LOG_BUCKET_NAME'); + expect(COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG).toBe('CDK_CICD_COMPLIANCE_LOG_BUCKET_ACCOUNT'); + expect(COMPLIANCE_LOG_BUCKET_REGION_FLAG).toBe('CDK_CICD_COMPLIANCE_LOG_BUCKET_REGION'); + }); + test('a full target renders env pins, creds-by-name, image and the inner single-region deploy', () => { const args = dockerRunArgs(IMAGE, { stage: 'prod', region: 'eu-west-1', account: '333333333333', deployRole: 'arn:aws:iam::333333333333:role/deployer', + cfnExecutionRole: 'arn:aws:iam::333333333333:role/cfn-exec', + externalId: 'repo-2-external-id', + complianceLogBucketName: 'prod-compliance-logs', + complianceLogBucketAccount: '333333333333', + complianceLogBucketRegion: 'eu-west-1', }); // structure: docker run --rm @@ -36,21 +65,32 @@ describe('m6-container: dockerRunArgs', () => { expect(envPart).toEqual( expect.arrayContaining([ 'CDK_STAGE=prod', + 'CDK_CICD_ACCOUNT_OVERRIDE=333333333333', 'CDK_DEFAULT_ACCOUNT=333333333333', 'CDK_DEPLOY_ACCOUNT=333333333333', + 'CDK_CICD_REGION_OVERRIDE=eu-west-1', 'CDK_DEFAULT_REGION=eu-west-1', 'CDK_DEPLOY_REGION=eu-west-1', 'AWS_REGION=eu-west-1', 'AWS_DEFAULT_REGION=eu-west-1', + `${DEPLOY_ROLE_FLAG}=arn:aws:iam::333333333333:role/deployer`, + `${CFN_EXEC_ROLE_FLAG}=arn:aws:iam::333333333333:role/cfn-exec`, + DEPLOY_ROLE_EXTERNAL_ID_FLAG, + `${COMPLIANCE_LOG_BUCKET_NAME_FLAG}=prod-compliance-logs`, + `${COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG}=333333333333`, + `${COMPLIANCE_LOG_BUCKET_REGION_FLAG}=eu-west-1`, ]), ); + expect(envPart).not.toContain(`${DEPLOY_ROLE_EXTERNAL_ID_FLAG}=repo-2-external-id`); + expect(args).not.toContain('repo-2-external-id'); // credentials are passed by NAME only (inherited), never as name=value -- no secrets in argv expect(envPart).toEqual( expect.arrayContaining(['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN']), ); expect(envPart.some((e) => e.startsWith('AWS_ACCESS_KEY_ID='))).toBe(false); - // inner command deploys the one stage, one region, with the forced role, non-interactively + // The role is synthesized from its presence-sensitive env flag; it must never be forwarded as + // `cdk deploy --role-arn`, whose CDK semantics are CloudFormation execution role. expect(args.slice(imageIdx + 1)).toEqual([ 'cdk-cicd', 'deploy', @@ -59,9 +99,8 @@ describe('m6-container: dockerRunArgs', () => { '--yes', '--region', 'eu-west-1', - '--deploy-role', - 'arn:aws:iam::333333333333:role/deployer', ]); + expect(args).not.toContain('--deploy-role'); }); test('a network option inserts --network right after run --rm, before the env flags', () => { @@ -85,36 +124,146 @@ describe('m6-container: dockerRunArgs', () => { expect(args).not.toContain('--network'); }); - test('a region-agnostic target omits every region pin and the inner --region', () => { + test('an env-agnostic target clears image targets, inherits ambient values, and omits the inner --region', () => { const args = dockerRunArgs(IMAGE, { stage: 'dev' }); - expect(args.some((a) => a.startsWith('CDK_DEFAULT_REGION') || a.startsWith('AWS_REGION'))).toBe(false); const imageIdx = args.indexOf(IMAGE); + expect(args.slice(2, imageIdx)).toEqual( + expect.arrayContaining([ + 'CDK_CICD_ACCOUNT_OVERRIDE=', + 'CDK_DEFAULT_ACCOUNT', + 'CDK_DEPLOY_ACCOUNT', + 'CDK_CICD_REGION_OVERRIDE=', + 'CDK_DEFAULT_REGION', + 'CDK_DEPLOY_REGION', + 'AWS_REGION', + 'AWS_DEFAULT_REGION', + ]), + ); expect(args.slice(imageIdx + 1)).toEqual(['cdk-cicd', 'deploy', '--stage', 'dev', '--yes']); }); - test('no account and no deploy role omit their pins/flags', () => { + test('no account emits an explicit clear while no deploy role still clears its role flags', () => { const args = dockerRunArgs(IMAGE, { stage: 'dev', region: 'us-west-2' }); - expect(args.some((a) => a.startsWith('CDK_DEFAULT_ACCOUNT'))).toBe(false); + expect(args).toContain('CDK_CICD_ACCOUNT_OVERRIDE='); + expect(args).toContain('CDK_DEFAULT_ACCOUNT'); expect(args).not.toContain('--deploy-role'); + expect(args).toContain(`${DEPLOY_ROLE_FLAG}=`); + expect(args).toContain(`${CFN_EXEC_ROLE_FLAG}=`); + expect(args).toContain(`${DEPLOY_ROLE_EXTERNAL_ID_FLAG}=`); + expect(args).toContain(`${COMPLIANCE_LOG_BUCKET_NAME_FLAG}=`); + expect(args).toContain(`${COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG}=`); + expect(args).toContain(`${COMPLIANCE_LOG_BUCKET_REGION_FLAG}=`); // region-only target still pins the region expect(args).toContain('CDK_DEFAULT_REGION=us-west-2'); }); + + test('partial or cross-environment compliance coordinates fail before Docker can run', () => { + expect(() => + dockerRunArgs(IMAGE, { + stage: 'dev', + account: '111111111111', + region: 'eu-west-1', + complianceLogBucketName: 'compliance-logs', + }), + ).toThrow(/must provide compliance bucket name, account, and Region together/); + + expect(() => + dockerRunArgs(IMAGE, { + stage: 'dev', + account: '111111111111', + region: 'eu-west-1', + complianceLogBucketName: 'compliance-logs', + complianceLogBucketAccount: '222222222222', + complianceLogBucketRegion: 'eu-west-1', + }), + ).toThrow(/must match the deployment account and Region/); + }); }); describe('m6-container: targetRuns', () => { test('a multi-region target expands to one run per region, carrying account and role', () => { - const runs = targetRuns({ - stage: 'prod', - env: { account: '333333333333', regions: ['eu-west-1', 'us-east-1'], regionOrder: 'sequential' as any }, - manualApproval: true, - deployment: { deployRole: 'arn:role/x' }, - }); + const runs = targetRuns( + { + stage: 'prod', + env: { account: '333333333333', regions: ['eu-west-1', 'us-east-1'], regionOrder: 'sequential' as any }, + manualApproval: true, + deployment: { + deployRole: 'arn:role/x', + cfnExecutionRole: 'arn:role/cfn', + externalId: 'resolve:secretsmanager:secret', + }, + }, + 'resolved-secret', + ); + expect(runs).toEqual([ + { + stage: 'prod', + account: '333333333333', + deployRole: 'arn:role/x', + cfnExecutionRole: 'arn:role/cfn', + externalId: 'resolved-secret', + region: 'eu-west-1', + }, + { + stage: 'prod', + account: '333333333333', + deployRole: 'arn:role/x', + cfnExecutionRole: 'arn:role/cfn', + externalId: 'resolved-secret', + region: 'us-east-1', + }, + ]); + }); + + test('a single-region target carries resolved compliance coordinates', () => { + const runs = targetRuns( + { + stage: 'dev', + env: { account: '111111111111', regions: ['eu-west-1'], regionOrder: 'sequential' as any }, + manualApproval: false, + }, + undefined, + { + bucketName: 'dev-compliance-logs', + account: '111111111111', + region: 'eu-west-1', + }, + ); + expect(runs).toEqual([ - { stage: 'prod', account: '333333333333', deployRole: 'arn:role/x', region: 'eu-west-1' }, - { stage: 'prod', account: '333333333333', deployRole: 'arn:role/x', region: 'us-east-1' }, + expect.objectContaining({ + stage: 'dev', + account: '111111111111', + region: 'eu-west-1', + complianceLogBucketName: 'dev-compliance-logs', + complianceLogBucketAccount: '111111111111', + complianceLogBucketRegion: 'eu-west-1', + }), ]); }); + test('a multi-region target cannot carry one compliance destination', () => { + expect(() => + targetRuns( + { + stage: 'prod', + env: { + account: '111111111111', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: 'sequential' as any, + }, + manualApproval: true, + }, + undefined, + { + bucketName: 'prod-compliance-logs', + account: '111111111111', + region: 'eu-west-1', + }, + ), + ).toThrow(/must match its concrete account and single Region/); + }); + test('an env-agnostic target yields a single region-less run', () => { const runs = targetRuns({ stage: 'dev', @@ -122,7 +271,18 @@ describe('m6-container: targetRuns', () => { manualApproval: false, deployment: undefined, }); - expect(runs).toEqual([{ stage: 'dev', account: undefined, deployRole: undefined }]); + expect(runs).toEqual([ + { + stage: 'dev', + account: undefined, + deployRole: undefined, + cfnExecutionRole: undefined, + externalId: undefined, + complianceLogBucketName: undefined, + complianceLogBucketAccount: undefined, + complianceLogBucketRegion: undefined, + }, + ]); }); }); @@ -142,6 +302,20 @@ describe('m6-container: resolveTargetImage (version from config/.json)', test('no version file -> the base is used as-is', () => { expect(resolveTargetImage(target('dev'), config('repo/app:latest'), '/x', () => undefined)).toBe('repo/app:latest'); }); + test('a digest reference is preserved when no separate version is configured', () => { + const digest = `repo/app@sha256:${'a'.repeat(64)}`; + expect(resolveTargetImage(target('dev'), config(digest), '/x', () => undefined)).toBe(digest); + }); + test('a digest reference plus a separate version is rejected instead of producing an invalid image', () => { + const digest = `repo/app@sha256:${'a'.repeat(64)}`; + expect(() => resolveTargetImage(target('dev'), config(digest), '/x', () => '1.5.0')).toThrow( + /pinned by digest.*config\/dev\.json version '1\.5\.0'/, + ); + }); + test('a target-level digest remains authoritative even when the stage version file exists', () => { + const digest = `repo/app@sha256:${'b'.repeat(64)}`; + expect(resolveTargetImage(target('dev', digest), config('repo/app'), '/x', () => '1.5.0')).toBe(digest); + }); test('a target image overrides the config base repo', () => { expect(resolveTargetImage(target('dev', 'repo/app'), config('other/base'), '/x', () => '2.0.0')).toBe( 'repo/app:2.0.0', @@ -152,17 +326,51 @@ describe('m6-container: resolveTargetImage (version from config/.json)', }); }); +describe('m6-container: version file validation', () => { + let cwd: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-version-')); + fs.mkdirSync(path.join(cwd, 'config')); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + test('an absent version file leaves the configured image unchanged', () => { + expect(readVersionFromConfig(cwd, 'dev')).toBeUndefined(); + }); + + test.each([ + ['malformed JSON', '{'], + ['missing version', '{}'], + ['non-string version', '{"version":42}'], + ['empty version', '{"version":""}'], + ['whitespace-only version', '{"version":" "}'], + ['surrounding whitespace', '{"version":" 1.2.3 "}'], + ])('rejects an existing %s file instead of silently selecting the base image', (_name, contents) => { + fs.writeFileSync(path.join(cwd, 'config', 'dev.json'), contents); + expect(() => readVersionFromConfig(cwd, 'dev')).toThrow(/deploy --from-image: .*config.*dev\.json/); + }); + + test('returns a valid non-empty string version', () => { + fs.writeFileSync(path.join(cwd, 'config', 'dev.json'), '{"version":"1.2.3"}'); + expect(readVersionFromConfig(cwd, 'dev')).toBe('1.2.3'); + }); +}); + describe('m6-container: runFromImage', () => { const config = (targets: any[]): ResolvedDeploymentConfig => ({ image: IMAGE, targets }) as unknown as ResolvedDeploymentConfig; - test('runs the image once per target x region and returns 0 on success', () => { + test('runs the image once per target x region and returns 0 on success', async () => { const calls: string[][] = []; const spawn = (args: string[]) => { calls.push(args); return ok(); }; - const code = runFromImage( + const code = await runFromImage( config([ { stage: 'dev', env: { regions: ['us-west-2'], regionOrder: 'sequential' }, manualApproval: false }, { @@ -179,7 +387,83 @@ describe('m6-container: runFromImage', () => { expect(calls.map((c) => c[c.indexOf('--stage') + 1])).toEqual(['dev', 'prod', 'prod']); }); - test('resolves each target version from config/.json at run time (base repo + version)', () => { + test('forwards resolved Repo 2 compliance coordinates into the application container', async () => { + const calls: string[][] = []; + const cfg = { + image: IMAGE, + complianceLogBucketName: 'dev-compliance-logs', + targets: [ + { + stage: 'dev', + env: { account: '111111111111', regions: ['eu-west-1'], regionOrder: 'sequential' }, + manualApproval: false, + complianceLogBucketName: 'dev-compliance-logs', + complianceLogBucketAccount: '111111111111', + complianceLogBucketRegion: 'eu-west-1', + }, + ], + } as unknown as ResolvedDeploymentConfig; + + const code = await runFromImage(cfg, { + yes: true, + spawn: (args) => { + calls.push(args); + return ok(); + }, + }); + + expect(code).toBe(0); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual( + expect.arrayContaining([ + `${COMPLIANCE_LOG_BUCKET_NAME_FLAG}=dev-compliance-logs`, + `${COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG}=111111111111`, + `${COMPLIANCE_LOG_BUCKET_REGION_FLAG}=eu-west-1`, + ]), + ); + }); + + test.each([ + { + name: 'partial coordinates', + target: { + stage: 'dev', + env: { account: '111111111111', regions: ['eu-west-1'], regionOrder: 'sequential' }, + manualApproval: false, + complianceLogBucketName: 'dev-compliance-logs', + }, + }, + { + name: 'coordinates that differ from the target environment', + target: { + stage: 'dev', + env: { account: '111111111111', regions: ['eu-west-1'], regionOrder: 'sequential' }, + manualApproval: false, + complianceLogBucketName: 'dev-compliance-logs', + complianceLogBucketAccount: '222222222222', + complianceLogBucketRegion: 'eu-west-1', + }, + }, + ])('rejects $name before invoking Docker', async ({ target }) => { + const calls: string[][] = []; + const cfg = { + image: IMAGE, + targets: [target], + } as unknown as ResolvedDeploymentConfig; + + const code = await runFromImage(cfg, { + yes: true, + spawn: (args) => { + calls.push(args); + return ok(); + }, + }); + + expect(code).toBe(1); + expect(calls).toHaveLength(0); + }); + + test('resolves each target version from config/.json at run time (base repo + version)', async () => { const calls: string[][] = []; const spawn = (args: string[]) => { calls.push(args); @@ -189,12 +473,169 @@ describe('m6-container: runFromImage', () => { image: 'acct.dkr.ecr.eu-west-1.amazonaws.com/app', // base repo, no tag targets: [{ stage: 'dev', env: { regions: ['us-west-2'], regionOrder: 'sequential' }, manualApproval: false }], } as unknown as ResolvedDeploymentConfig; - const code = runFromImage(cfg, { yes: true, spawn, readVersion: () => '9.9.9' }); + const code = await runFromImage(cfg, { yes: true, spawn, readVersion: () => '9.9.9' }); expect(code).toBe(0); expect(calls[0]).toContain('acct.dkr.ecr.eu-west-1.amazonaws.com/app:9.9.9'); }); - test('--target deploys just that one target (its own image version)', () => { + test('resolves a target ExternalId before Docker and carries the complete Repo 2 role contract', async () => { + const secretRef = 'resolve:secretsmanager:arn:aws:secretsmanager:eu-west-1:111111111111:secret:external'; + const resolvedExternalId = 'resolved-external-id'; + const calls: Array<{ args: string[]; options?: DockerSpawnOptions }> = []; + const resolver = jest.fn(async () => resolvedExternalId); + const cfg = { + image: IMAGE, + targets: [ + { + stage: 'prod', + env: { account: '333333333333', regions: ['eu-west-1'], regionOrder: 'sequential' }, + manualApproval: false, + deployment: { + deployRole: 'arn:aws:iam::333333333333:role/deployer', + cfnExecutionRole: 'arn:aws:iam::333333333333:role/cfn-exec', + externalId: secretRef, + }, + }, + ], + } as unknown as ResolvedDeploymentConfig; + + const code = await runFromImage(cfg, { + yes: true, + resolveExternalId: resolver, + spawn: (args, options) => { + calls.push({ args, options }); + return ok(); + }, + }); + + expect(code).toBe(0); + expect(resolver).toHaveBeenCalledWith(secretRef); + expect(calls).toHaveLength(1); + expect(calls[0].args).toContain(`${DEPLOY_ROLE_FLAG}=arn:aws:iam::333333333333:role/deployer`); + expect(calls[0].args).toContain(`${CFN_EXEC_ROLE_FLAG}=arn:aws:iam::333333333333:role/cfn-exec`); + expect(calls[0].args).toContain(DEPLOY_ROLE_EXTERNAL_ID_FLAG); + expect(calls[0].args.join(' ')).not.toContain(secretRef); + expect(calls[0].args.join(' ')).not.toContain(resolvedExternalId); + expect(calls[0].options?.env?.[DEPLOY_ROLE_EXTERNAL_ID_FLAG]).toBe(resolvedExternalId); + }); + + test('an ExternalId secret-resolution failure stops before Docker', async () => { + const calls: string[][] = []; + const cfg = { + image: IMAGE, + targets: [ + { + stage: 'prod', + env: { regions: ['eu-west-1'], regionOrder: 'sequential' }, + manualApproval: false, + deployment: { + deployRole: 'arn:aws:iam::333333333333:role/deployer', + externalId: 'resolve:secretsmanager:secret', + }, + }, + ], + } as unknown as ResolvedDeploymentConfig; + + const code = await runFromImage(cfg, { + yes: true, + resolveExternalId: async () => { + throw new Error('AccessDenied'); + }, + spawn: (args) => { + calls.push(args); + return ok(); + }, + }); + + expect(code).toBe(1); + expect(calls).toHaveLength(0); + }); + + test('a digest image with a separate stage version fails before docker is invoked', async () => { + const calls: string[][] = []; + const digest = `acct.dkr.ecr.eu-west-1.amazonaws.com/app@sha256:${'a'.repeat(64)}`; + const cfg = { + image: digest, + targets: [{ stage: 'dev', env: { regions: ['us-west-2'], regionOrder: 'sequential' }, manualApproval: false }], + } as unknown as ResolvedDeploymentConfig; + const code = await runFromImage(cfg, { + yes: true, + readVersion: () => '9.9.9', + spawn: (args) => { + calls.push(args); + return ok(); + }, + }); + expect(code).toBe(1); + expect(calls).toHaveLength(0); + }); + + test('a target-level digest bypasses the stage version and is passed to Docker unchanged', async () => { + const calls: string[][] = []; + const digest = `acct.dkr.ecr.eu-west-1.amazonaws.com/app@sha256:${'b'.repeat(64)}`; + const cfg = { + image: 'acct.dkr.ecr.eu-west-1.amazonaws.com/app', + targets: [ + { + stage: 'dev', + image: digest, + env: { regions: ['us-west-2'], regionOrder: 'sequential' }, + manualApproval: false, + }, + ], + } as unknown as ResolvedDeploymentConfig; + const code = await runFromImage(cfg, { + yes: true, + readVersion: () => '9.9.9', + spawn: (args) => { + calls.push(args); + return ok(); + }, + }); + + expect(code).toBe(0); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain(digest); + expect(calls[0]).not.toContain('acct.dkr.ecr.eu-west-1.amazonaws.com/app:9.9.9'); + }); + + test('a target-level digest still rejects malformed stage version metadata before Docker', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-version-')); + const calls: string[][] = []; + const digest = `acct.dkr.ecr.eu-west-1.amazonaws.com/app@sha256:${'c'.repeat(64)}`; + fs.mkdirSync(path.join(cwd, 'config')); + fs.writeFileSync(path.join(cwd, 'config', 'dev.json'), '{'); + + try { + const cfg = { + image: 'acct.dkr.ecr.eu-west-1.amazonaws.com/app', + targets: [ + { + stage: 'dev', + image: digest, + env: { regions: ['us-west-2'], regionOrder: 'sequential' }, + manualApproval: false, + }, + ], + } as unknown as ResolvedDeploymentConfig; + + const code = await runFromImage(cfg, { + yes: true, + cwd, + spawn: (args) => { + calls.push(args); + return ok(); + }, + }); + + expect(code).toBe(1); + expect(calls).toHaveLength(0); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('--target deploys just that one target (its own image version)', async () => { const calls: string[][] = []; const spawn = (args: string[]) => { calls.push(args); @@ -212,7 +653,7 @@ describe('m6-container: runFromImage', () => { { stage: 'prod', env: { regions: ['eu-west-1'], regionOrder: 'sequential' }, manualApproval: true }, ], } as unknown as ResolvedDeploymentConfig; - const code = runFromImage(cfg, { yes: true, target: 'dev', spawn }); + const code = await runFromImage(cfg, { yes: true, target: 'dev', spawn }); expect(code).toBe(0); // only dev ran, and it used the target's OWN image (not the config default) expect(calls).toHaveLength(1); @@ -220,8 +661,8 @@ describe('m6-container: runFromImage', () => { expect(calls[0][calls[0].indexOf('--stage') + 1]).toBe('dev'); }); - test('--target with an unknown stage errors', () => { - const code = runFromImage( + test('--target with an unknown stage errors', async () => { + const code = await runFromImage( config([{ stage: 'dev', env: { regions: ['us-west-2'], regionOrder: 'sequential' }, manualApproval: false }]), { yes: true, @@ -232,21 +673,21 @@ describe('m6-container: runFromImage', () => { expect(code).toBe(1); }); - test('a target with no image (and no config default) errors', () => { + test('a target with no image (and no config default) errors', async () => { const cfg = { targets: [{ stage: 'dev', env: { regions: ['us-west-2'], regionOrder: 'sequential' }, manualApproval: false }], } as unknown as ResolvedDeploymentConfig; - const code = runFromImage(cfg, { yes: true, spawn: () => ok() }); + const code = await runFromImage(cfg, { yes: true, spawn: () => ok() }); expect(code).toBe(1); }); - test('a gated target is refused without --yes, before any docker run', () => { + test('a gated target is refused without --yes, before any docker run', async () => { const calls: string[][] = []; const spawn = (args: string[]) => { calls.push(args); return ok(); }; - const code = runFromImage( + const code = await runFromImage( config([{ stage: 'prod', env: { regions: ['eu-west-1'], regionOrder: 'sequential' }, manualApproval: true }]), { yes: false, @@ -257,29 +698,95 @@ describe('m6-container: runFromImage', () => { expect(calls).toHaveLength(0); }); - test('a non-zero docker status is propagated and stops the run', () => { - let n = 0; - const spawn = () => { - n += 1; - return { ...ok(), status: n === 1 ? 0 : 2 } as SpawnSyncReturns; + test('parallel regions launch together and propagate the first failure in configured order', async () => { + const started: string[] = []; + const pending = new Map) => void>(); + const spawn = (args: string[]) => { + const region = args[args.indexOf('--region') + 1]; + started.push(region); + return new Promise>((resolve) => pending.set(region, resolve)); + }; + const deployment = runFromImage( + config([ + { + stage: 'prod', + env: { regions: ['eu-west-1', 'us-east-1', 'ap-southeast-2'], regionOrder: 'parallel' }, + manualApproval: false, + }, + ]), + { yes: true, spawn }, + ); + + expect(started).toEqual(['eu-west-1', 'us-east-1', 'ap-southeast-2']); + pending.get('us-east-1')!({ ...ok(), status: 7 }); + pending.get('ap-southeast-2')!(ok()); + pending.get('eu-west-1')!({ ...ok(), status: 5 }); + + expect(await deployment).toBe(5); + }); + + test('a later target waits for every parallel region of the preceding target', async () => { + const started: string[] = []; + const pending = new Map) => void>(); + const spawn = (args: string[]) => { + const stage = args[args.indexOf('--stage') + 1]; + const region = args[args.indexOf('--region') + 1]; + started.push(`${stage}/${region}`); + if (stage === 'dev') { + return new Promise>((resolve) => pending.set(region, resolve)); + } + return ok(); + }; + const deployment = runFromImage( + config([ + { + stage: 'dev', + env: { regions: ['eu-west-1', 'us-east-1'], regionOrder: 'parallel' }, + manualApproval: false, + }, + { + stage: 'prod', + env: { regions: ['ap-southeast-2'], regionOrder: 'sequential' }, + manualApproval: false, + }, + ]), + { yes: true, spawn }, + ); + + expect(started).toEqual(['dev/eu-west-1', 'dev/us-east-1']); + pending.get('us-east-1')!(ok()); + await Promise.resolve(); + expect(started).toEqual(['dev/eu-west-1', 'dev/us-east-1']); + pending.get('eu-west-1')!(ok()); + + expect(await deployment).toBe(0); + expect(started).toEqual(['dev/eu-west-1', 'dev/us-east-1', 'prod/ap-southeast-2']); + }); + + test('sequential regions preserve order and stop after the first failure', async () => { + const started: string[] = []; + const spawn = (args: string[]) => { + const region = args[args.indexOf('--region') + 1]; + started.push(region); + return { ...ok(), status: region === 'us-east-1' ? 2 : 0 } as SpawnSyncReturns; }; - const code = runFromImage( + const code = await runFromImage( config([ { stage: 'prod', - env: { regions: ['eu-west-1', 'us-east-1'], regionOrder: 'sequential' }, + env: { regions: ['eu-west-1', 'us-east-1', 'ap-southeast-2'], regionOrder: 'sequential' }, manualApproval: false, }, ]), { yes: true, spawn }, ); expect(code).toBe(2); - expect(n).toBe(2); // stopped after the failing second region, did not continue + expect(started).toEqual(['eu-west-1', 'us-east-1']); }); - test('a spawn error returns 1', () => { + test('a spawn error returns 1', async () => { const spawn = () => ({ ...ok(), error: new Error('docker not found') }) as SpawnSyncReturns; - const code = runFromImage( + const code = await runFromImage( config([{ stage: 'dev', env: { regions: [], regionOrder: 'sequential' }, manualApproval: false }]), { yes: true, diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DriftCheck.test.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DriftCheck.test.ts index 6392fb34..e16e8256 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DriftCheck.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/DriftCheck.test.ts @@ -6,11 +6,40 @@ import * as os from 'os'; import * as path from 'path'; import { analyzeManifest, checkAssembly } from '../../src/cmds/autopilot/DriftCheck'; -function manifestWith(environment: string): any { - return { artifacts: { TheStack: { type: 'aws:cloudformation:stack', environment } } }; +function manifestWith( + environment: string, + roles: { assumeRoleArn?: string; cloudFormationExecutionRoleArn?: string } = {}, +): any { + return { + artifacts: { + TheStack: { + type: 'aws:cloudformation:stack', + environment, + properties: roles, + }, + }, + }; } -const TARGET = { account: '111111111111', region: 'us-west-2' }; +function diskManifestWith( + environment: string, + roles: { assumeRoleArn?: string; cloudFormationExecutionRoleArn?: string } = {}, +): any { + return { + version: '41.0.0', + artifacts: { + TheStack: { + type: 'aws:cloudformation:stack', + environment, + properties: { templateFile: 'TheStack.template.json', ...roles }, + }, + }, + }; +} + +const TARGET = { account: '111111111111', region: 'us-west-2', qualifier: 'hnb659fds' }; +const DEPLOY_ROLE = 'arn:${AWS::Partition}:iam::111111111111:role/Deploy'; +const CFN_EXEC_ROLE = 'arn:${AWS::Partition}:iam::111111111111:role/CfnExec'; describe('m3-drift-check: analyzeManifest', () => { test('an exact match is ok and deployable', () => { @@ -21,17 +50,18 @@ describe('m3-drift-check: analyzeManifest', () => { expect(r.errors).toEqual([]); }); - test('an environment-agnostic stack is OK (resolved at deploy)', () => { + test('an unknown account fails closed when the target account is configured', () => { const r = analyzeManifest(manifestWith('aws://unknown-account/unknown-region'), TARGET); - expect(r.stacks[0].kind).toBe('agnostic'); - expect(r.ok).toBe(true); + expect(r.stacks[0].kind).toBe('account-mismatch'); + expect(r.errors).toHaveLength(1); + expect(r.ok).toBe(false); }); - test('a region mismatch warns but stays deployable', () => { + test('a region mismatch errors and blocks the deploy', () => { const r = analyzeManifest(manifestWith('aws://111111111111/eu-west-1'), TARGET); expect(r.stacks[0].kind).toBe('region-mismatch'); - expect(r.warnings).toHaveLength(1); - expect(r.ok).toBe(true); + expect(r.errors).toHaveLength(1); + expect(r.ok).toBe(false); }); test('an account mismatch errors and blocks the deploy', () => { @@ -42,11 +72,35 @@ describe('m3-drift-check: analyzeManifest', () => { }); test('with no target account, the account is not checked (region still is)', () => { - const r = analyzeManifest(manifestWith('aws://999999999999/us-west-2'), { region: 'us-west-2' }); + const r = analyzeManifest(manifestWith('aws://999999999999/us-west-2'), { + region: 'us-west-2', + qualifier: 'hnb659fds', + }); expect(r.stacks[0].kind).toBe('ok'); expect(r.ok).toBe(true); }); + test('with no target account, an unknown account is allowed when the concrete region matches', () => { + const r = analyzeManifest(manifestWith('aws://unknown-account/us-west-2'), { + region: 'us-west-2', + qualifier: 'hnb659fds', + }); + expect(r.stacks[0].kind).toBe('agnostic'); + expect(r.ok).toBe(true); + }); + + test('an unknown region does not hide a foreign concrete account', () => { + const r = analyzeManifest(manifestWith('aws://000000000000/unknown-region'), TARGET); + expect(r.stacks[0].kind).toBe('account-mismatch'); + expect(r.ok).toBe(false); + }); + + test('an unknown region is allowed only after the concrete account matches', () => { + const r = analyzeManifest(manifestWith('aws://111111111111/unknown-region'), TARGET); + expect(r.stacks[0].kind).toBe('agnostic'); + expect(r.ok).toBe(true); + }); + test('the hardcoded-env shape (foreign account AND region) is an account-mismatch, not just a warning', () => { // Mirrors hardcoded-env-app: env baked to 000000000000/eu-west-1. Account wins -> abort. const r = analyzeManifest(manifestWith('aws://000000000000/eu-west-1'), TARGET); @@ -66,6 +120,158 @@ describe('m3-drift-check: analyzeManifest', () => { expect(r.ok).toBe(false); }); + test('accepts the exact configured deployment and CloudFormation execution roles', () => { + const r = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + assumeRoleArn: DEPLOY_ROLE, + cloudFormationExecutionRoleArn: CFN_EXEC_ROLE, + }), + { + ...TARGET, + deployRoleArn: DEPLOY_ROLE, + cloudFormationExecutionRoleArn: CFN_EXEC_ROLE, + }, + ); + expect(r.stacks[0]).toMatchObject({ + kind: 'ok', + deployRoleArn: DEPLOY_ROLE, + cloudFormationExecutionRoleArn: CFN_EXEC_ROLE, + }); + expect(r.ok).toBe(true); + }); + + test('fails closed when the configured deployment role is missing or changed in the assembly', () => { + const missing = analyzeManifest(manifestWith('aws://111111111111/us-west-2'), { + ...TARGET, + deployRoleArn: DEPLOY_ROLE, + }); + expect(missing.stacks[0].kind).toBe('deploy-role-mismatch'); + expect(missing.errors[0]).toMatch(/missing the configured deployment role/); + + const changed = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + assumeRoleArn: 'arn:aws:iam::111111111111:role/OtherDeploy', + }), + { ...TARGET, deployRoleArn: DEPLOY_ROLE }, + ); + expect(changed.stacks[0].kind).toBe('deploy-role-mismatch'); + expect(changed.errors[0]).toMatch(/expected/); + }); + + test('fails closed when the configured CloudFormation execution role changes', () => { + const r = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + cloudFormationExecutionRoleArn: 'arn:aws:iam::111111111111:role/OtherCfnExec', + }), + { ...TARGET, cloudFormationExecutionRoleArn: CFN_EXEC_ROLE }, + ); + expect(r.stacks[0].kind).toBe('cfn-execution-role-mismatch'); + expect(r.ok).toBe(false); + }); + + test('rejects a synthesized role in a foreign account even without an exact configured role', () => { + const deploy = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + assumeRoleArn: 'arn:aws:iam::222222222222:role/Deploy', + }), + TARGET, + ); + expect(deploy.stacks[0].kind).toBe('deploy-role-mismatch'); + expect(deploy.errors[0]).toMatch(/account 222222222222.*stage target is 111111111111/); + + const cfn = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + cloudFormationExecutionRoleArn: 'arn:aws:iam::222222222222:role/CfnExec', + }), + TARGET, + ); + expect(cfn.stacks[0].kind).toBe('cfn-execution-role-mismatch'); + }); + + test('accepts CDK default-role ARNs with an unresolved partition and the concrete target account', () => { + const r = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + assumeRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/cdk-hnb659fds-deploy-role', + cloudFormationExecutionRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role', + }), + TARGET, + ); + expect(r.stacks[0].kind).toBe('ok'); + expect(r.ok).toBe(true); + }); + + test('accepts role environment placeholders retained by an environment-agnostic stack', () => { + const r = analyzeManifest( + manifestWith('aws://111111111111/unknown-region', { + assumeRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/cdk-shop-deploy-role-111111111111-${AWS::Region}', + }), + { + ...TARGET, + deployRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/cdk-shop-deploy-role-111111111111-us-west-2', + }, + ); + expect(r.stacks[0].kind).toBe('agnostic'); + expect(r.ok).toBe(true); + }); + + test('requires qualifier placeholders to resolve to the configured effective qualifier', () => { + const matching = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + assumeRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/cdk-ctxqual-deploy-role-111111111111-us-west-2', + }), + { + ...TARGET, + qualifier: 'ctxqual', + deployRoleArn: + 'arn:${AWS::Partition}:iam::111111111111:role/cdk-${Qualifier}-deploy-role-111111111111-us-west-2', + }, + ); + expect(matching.stacks[0].kind).toBe('ok'); + expect(matching.ok).toBe(true); + + const changed = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + assumeRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/cdk-other-deploy-role-111111111111-us-west-2', + }), + { + ...TARGET, + qualifier: 'ctxqual', + deployRoleArn: + 'arn:${AWS::Partition}:iam::111111111111:role/cdk-${Qualifier}-deploy-role-111111111111-us-west-2', + }, + ); + expect(changed.stacks[0].kind).toBe('deploy-role-mismatch'); + expect(changed.ok).toBe(false); + }); + + test('requires repeated qualifier placeholders to resolve to the same identifier', () => { + const matching = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + assumeRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/ctxqual/ctxqual', + }), + { + ...TARGET, + qualifier: 'ctxqual', + deployRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/${Qualifier}/${Qualifier}', + }, + ); + expect(matching.stacks[0].kind).toBe('ok'); + expect(matching.ok).toBe(true); + + const r = analyzeManifest( + manifestWith('aws://111111111111/us-west-2', { + assumeRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/ctxqual/other', + }), + { + ...TARGET, + qualifier: 'ctxqual', + deployRoleArn: 'arn:${AWS::Partition}:iam::111111111111:role/${Qualifier}/${Qualifier}', + }, + ); + expect(r.stacks[0].kind).toBe('deploy-role-mismatch'); + expect(r.ok).toBe(false); + }); + test('non-stack artifacts (assets, tree) are skipped', () => { const manifest = { artifacts: { @@ -82,11 +288,116 @@ describe('m3-drift-check: analyzeManifest', () => { describe('m3-drift-check: checkAssembly', () => { test('reads manifest.json from the assembly dir', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-')); - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifestWith('aws://000000000000/us-west-2'))); - expect(checkAssembly(dir, TARGET).ok).toBe(false); + try { + fs.writeFileSync( + path.join(dir, 'manifest.json'), + JSON.stringify(diskManifestWith('aws://000000000000/us-west-2')), + ); + expect(checkAssembly(dir, TARGET).ok).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('recurses through an actual synthesized cdk.Stage and blocks its foreign-account stack', () => { + // aws-cdk-lib is the wrapper package's installed peer and is used here only as the schema oracle. + // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies + const cdk: any = require('aws-cdk-lib'); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-stage-')); + try { + const app = new cdk.App({ outdir: outDir }); + const stage = new cdk.Stage(app, 'Prod', { + env: { account: '000000000000', region: TARGET.region }, + }); + new cdk.Stack(stage, 'Foreign', { stackName: 'foreign-prod-stack' }); + app.synth(); + + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'manifest.json'), 'utf-8')); + expect(Object.values(manifest.artifacts)).toContainEqual( + expect.objectContaining({ + type: 'cdk:cloud-assembly', + properties: expect.objectContaining({ directoryName: expect.any(String) }), + }), + ); + + const result = checkAssembly(outDir, TARGET); + expect(result.ok).toBe(false); + expect(result.stacks).toHaveLength(1); + expect(result.stacks[0]).toMatchObject({ + account: '000000000000', + region: TARGET.region, + kind: 'account-mismatch', + }); + expect(result.stacks[0].stack).toContain('assembly-Prod/'); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); + + test('fails closed when a deployable assembly contains no stacks', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-empty-')); + try { + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ version: '41.0.0', artifacts: {} })); + expect(() => checkAssembly(dir, TARGET)).toThrow(/contains no deployable CloudFormation stacks/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); test('throws a clear error when there is no assembly', () => { - expect(() => checkAssembly(fs.mkdtempSync(path.join(os.tmpdir(), 'drift-')), TARGET)).toThrow(/synth first/); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-')); + try { + expect(() => checkAssembly(dir, TARGET)).toThrow(/synth first/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('rejects an unsupported cloud-assembly schema version before traversing artifacts', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-version-')); + try { + fs.writeFileSync( + path.join(dir, 'manifest.json'), + JSON.stringify({ ...diskManifestWith('aws://111111111111/us-west-2'), version: '999.0.0' }), + ); + expect(() => checkAssembly(dir, TARGET)).toThrow(/Maximum schema version supported.*999\.0\.0/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('accepts schema version 48 assemblies supported by the installed CDK CLI', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-version-')); + try { + fs.writeFileSync( + path.join(dir, 'manifest.json'), + JSON.stringify({ ...diskManifestWith('aws://111111111111/us-west-2'), version: '48.0.0' }), + ); + expect(checkAssembly(dir, TARGET).ok).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('rejects a malformed stack artifact before creating a partial deployment plan', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'drift-malformed-')); + try { + fs.writeFileSync( + path.join(dir, 'manifest.json'), + JSON.stringify({ + version: '41.0.0', + artifacts: { + Broken: { + type: 'aws:cloudformation:stack', + environment: 'aws://111111111111/us-west-2', + properties: {}, + }, + }, + }), + ); + expect(() => checkAssembly(dir, TARGET)).toThrow(/Invalid assembly manifest|templateFile/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); }); diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/ExecCommand.test.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/ExecCommand.test.ts index ee4e3036..741a7264 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/ExecCommand.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/ExecCommand.test.ts @@ -8,17 +8,28 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { EngineType } from '@cdklabs/cdk-cicd-wrapper'; +import { defineCICD, EngineType, RegionOrder, Repository } from '@cdklabs/cdk-cicd-wrapper'; import { + appConfigForTarget, buildContextJson, + CFN_EXEC_ROLE_FLAG, + COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, + COMPLIANCE_LOG_BUCKET_NAME_FLAG, + COMPLIANCE_LOG_BUCKET_REGION_FLAG, + complianceLoggingEnv, + DEPLOY_ROLE_EXTERNAL_ID_FLAG, + DEPLOY_ROLE_FLAG, execInvocation, forcedRoleEnv, isPipelineMode, preloadArgs, + readSecretStringFromAwsCli, resolveEnvTarget, resolveExternalId, + resolvePipelineExternalIds, resolveStage, stageEnv, + wrapperRuntimeConfig, } from '../../src/cmds/autopilot/ExecCommand'; describe('exec: resolveStage', () => { @@ -71,8 +82,7 @@ describe('exec: resolveEnvTarget precedence', () => { const appConfig = { aws: { accountId: 'app-acct', region: 'app-region' } }; const cicdStage = { env: { account: 'cicd-acct', regions: ['cicd-region', 'other'] } }; - test('the app-config file wins (config-file-first for the inner loop)', () => { - // Even with a cicd.config stage, per-stage env vars, and CDK_DEFAULT_* all set, the config file wins. + test('configured application target wins over ambient CDK defaults rewritten by credentials', () => { expect( resolveEnvTarget( { @@ -88,22 +98,76 @@ describe('exec: resolveEnvTarget precedence', () => { ).toEqual({ account: 'app-acct', region: 'app-region' }); }); - test('with no config file, the cicd.config stage is next', () => { - expect(resolveEnvTarget({}, {}, cicdStage, 'dev')).toEqual({ account: 'cicd-acct', region: 'cicd-region' }); + test('the Repo 2 account override wins over app, pipeline, stage, and ambient accounts', () => { + expect( + resolveEnvTarget( + { + CDK_CICD_ACCOUNT_OVERRIDE: 'target-acct', + CDK_DEFAULT_ACCOUNT: 'env-acct', + ACCOUNT_DEV: 'stage-acct', + }, + appConfig, + cicdStage, + 'dev', + ), + ).toEqual({ account: 'target-acct', region: 'app-region' }); }); - test('then the per-stage ACCOUNT_/REGION_ env vars (keyed by the uppercased stage)', () => { + test('an empty Repo 2 account override clears image config without trusting ambient CDK defaults', () => { + expect( + resolveEnvTarget( + { + CDK_CICD_ACCOUNT_OVERRIDE: '', + CDK_DEFAULT_ACCOUNT: 'ambient-acct', + }, + appConfig, + cicdStage, + 'dev', + ), + ).toEqual({ account: undefined, region: 'app-region' }); + }); + + test('the Repo 2 region override wins over app, pipeline, stage, and ambient regions', () => { expect( resolveEnvTarget( - { ACCOUNT_DEV: 'stage-acct', REGION_DEV: 'stage-region', CDK_DEFAULT_ACCOUNT: 'env-acct' }, - {}, - undefined, + { + CDK_CICD_REGION_OVERRIDE: 'target-region', + CDK_DEFAULT_REGION: 'env-region', + REGION_DEV: 'stage-region', + }, + appConfig, + cicdStage, 'dev', ), - ).toEqual({ account: 'stage-acct', region: 'stage-region' }); + ).toEqual({ account: 'app-acct', region: 'target-region' }); }); - test('finally CDK_DEFAULT_* is the last resort', () => { + test('an empty Repo 2 region override clears image config without trusting ambient AWS defaults', () => { + expect( + resolveEnvTarget( + { + CDK_CICD_REGION_OVERRIDE: '', + AWS_REGION: 'ambient-region', + }, + appConfig, + cicdStage, + 'dev', + ), + ).toEqual({ account: 'app-acct', region: undefined }); + }); + + test('with no config file, the cicd.config stage is next', () => { + expect(resolveEnvTarget({}, {}, cicdStage, 'dev')).toEqual({ account: 'cicd-acct', region: 'cicd-region' }); + }); + + test('then the per-stage ACCOUNT_/REGION_ env vars (keyed by the uppercased stage)', () => { + expect(resolveEnvTarget({ ACCOUNT_DEV: 'stage-acct', REGION_DEV: 'stage-region' }, {}, undefined, 'dev')).toEqual({ + account: 'stage-acct', + region: 'stage-region', + }); + }); + + test('ambient CDK defaults remain a last resort when repository configuration is absent', () => { expect( resolveEnvTarget({ CDK_DEFAULT_ACCOUNT: 'env-acct', CDK_DEFAULT_REGION: 'env-region' }, {}, undefined, 'dev'), ).toEqual({ account: 'env-acct', region: 'env-region' }); @@ -114,23 +178,166 @@ describe('exec: resolveEnvTarget precedence', () => { }); }); +describe('exec: compliance logging env', () => { + const stage = (account: string | undefined, regions: string[]) => ({ + name: 'dev', + env: { account, regions, regionOrder: RegionOrder.SEQUENTIAL }, + manualApproval: false, + }); + const cicd = { + complianceLogBucketName: 'application-compliance-logs', + stages: [stage('111111111111', ['eu-west-1'])], + }; + const target = { account: '111111111111', region: 'eu-west-1' }; + + test('exports the single configured physical bucket location for an application-mode child', () => { + expect(complianceLoggingEnv(cicd, target)).toEqual({ + [COMPLIANCE_LOG_BUCKET_NAME_FLAG]: 'application-compliance-logs', + [COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG]: '111111111111', + [COMPLIANCE_LOG_BUCKET_REGION_FLAG]: 'eu-west-1', + }); + }); + + test.each([COMPLIANCE_LOG_BUCKET_NAME_FLAG, COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, COMPLIANCE_LOG_BUCKET_REGION_FLAG])( + 'treats present env key %s as authoritative even when empty', + (flag) => { + const overrides = { [flag]: '' }; + expect(complianceLoggingEnv(cicd, target, overrides)).toEqual({}); + expect({ ...overrides, ...complianceLoggingEnv(cicd, target, overrides) }).toEqual(overrides); + }, + ); + + test('does nothing when no compliance bucket is configured', () => { + expect(complianceLoggingEnv(undefined, target)).toEqual({}); + expect(complianceLoggingEnv({ stages: [] }, target)).toEqual({}); + }); + + test.each([ + ['accounts', [stage('111111111111', ['eu-west-1']), stage('222222222222', ['eu-west-1'])]], + ['Regions', [stage('111111111111', ['eu-west-1']), stage('111111111111', ['us-east-1'])]], + ['Regions', [stage('111111111111', ['eu-west-1', 'us-east-1'])]], + ])('rejects a configured bucket topology spanning multiple %s', (_dimension, stages) => { + expect(() => complianceLoggingEnv({ ...cicd, stages }, target)).toThrow( + /one physical bucket|one concrete shared account and Region/, + ); + }); + + test('rejects an invocation target outside the configured physical bucket location', () => { + expect(() => complianceLoggingEnv(cicd, { account: '111111111111', region: 'us-east-1' })).toThrow( + /does not match compliance bucket 'application-compliance-logs' location 111111111111\/eu-west-1/, + ); + }); + + test.each([ + ['account', { region: 'eu-west-1' }], + ['Region', { account: '111111111111' }], + ])('fails closed when the target %s is unresolved', (_missing, unresolvedTarget) => { + expect(() => complianceLoggingEnv(cicd, unresolvedTarget)).toThrow( + /compliance bucket 'application-compliance-logs' requires a resolved target account and Region/, + ); + }); +}); + +describe('exec: Repo 2 AppConfig target overlay', () => { + test('overlays authoritative account and region into the injected AppConfig context', () => { + const imageConfig = { + application: 'shop', + aws: { accountId: '111111111111', region: 'us-west-2', partition: 'aws' }, + }; + const env = { + CDK_CICD_ACCOUNT_OVERRIDE: '222222222222', + CDK_CICD_REGION_OVERRIDE: 'eu-west-1', + }; + const target = resolveEnvTarget(env, imageConfig, undefined, 'prod'); + const context = JSON.parse(buildContextJson(appConfigForTarget(imageConfig, target, env), {}, {}, '/nonexistent')); + + expect(target).toEqual({ account: '222222222222', region: 'eu-west-1' }); + expect(context['cicd:config']).toEqual({ + application: 'shop', + aws: { accountId: '222222222222', region: 'eu-west-1', partition: 'aws' }, + }); + }); + + test('present-but-empty overrides remove stale image values when the target stays agnostic', () => { + const imageConfig = { + application: 'shop', + aws: { accountId: '111111111111', region: 'us-west-2', partition: 'aws' }, + }; + const env = { + CDK_CICD_ACCOUNT_OVERRIDE: '', + CDK_CICD_REGION_OVERRIDE: '', + }; + const target = resolveEnvTarget(env, imageConfig, undefined, 'dev'); + + expect(target).toEqual({ account: undefined, region: undefined }); + expect(appConfigForTarget(imageConfig, target, env)).toEqual({ + application: 'shop', + aws: { partition: 'aws' }, + }); + }); + + test('without Repo 2 override flags the application config is unchanged', () => { + const config = { aws: { accountId: '111111111111', region: 'us-west-2' } }; + expect(appConfigForTarget(config, { account: '222222222222', region: 'eu-west-1' }, {})).toBe(config); + }); + + test('does not overlay ambient CDK defaults into AppConfig', () => { + const config = { aws: { accountId: '111111111111', region: 'us-west-2' } }; + const env = { + CDK_DEFAULT_ACCOUNT: '222222222222', + CDK_DEFAULT_REGION: 'eu-central-1', + AWS_REGION: 'eu-central-1', + }; + const target = resolveEnvTarget(env, config, undefined, 'prod'); + + expect(target).toEqual({ account: '111111111111', region: 'us-west-2' }); + expect(appConfigForTarget(config, target, env)).toBe(config); + }); + + test('explicit synth-target flags survive ambient cross-account CDK rewrites', () => { + const config = { aws: { accountId: '222222222222', region: 'eu-west-1' } }; + const env = { + CDK_CICD_ACCOUNT_OVERRIDE: '222222222222', + CDK_CICD_REGION_OVERRIDE: 'eu-west-1', + CDK_DEFAULT_ACCOUNT: '111111111111', + CDK_DEFAULT_REGION: 'us-east-1', + AWS_REGION: 'us-east-1', + }; + const target = resolveEnvTarget(env, config, undefined, 'prod'); + + expect(target).toEqual({ account: '222222222222', region: 'eu-west-1' }); + expect(appConfigForTarget(config, target, env)).toEqual(config); + }); +}); + describe('exec: buildContextJson', () => { const config = { application: 'shop', aws: { accountId: '111111111111' } }; + const wrapperConfig = { + qualifier: 'shop', + synthesizer: { type: 'default' }, + plugins: [{ name: 'AwsSolutionsChecks', version: '1' }], + }; - test('adds cicd:config on top of the CLI-provided CDK_CONTEXT_JSON without touching other keys', () => { + test('adds separate app and wrapper config without touching other context keys', () => { const existing = JSON.stringify({ '@aws-cdk/core:bootstrapQualifier': 'hnb', userKey: 'keep' }); - const out = JSON.parse(buildContextJson(config, { CDK_CONTEXT_JSON: existing }, '/nonexistent')); + const out = JSON.parse(buildContextJson(config, wrapperConfig, { CDK_CONTEXT_JSON: existing }, '/nonexistent')); expect(out.userKey).toBe('keep'); expect(out['@aws-cdk/core:bootstrapQualifier']).toBe('hnb'); expect(out['cicd:config']).toEqual(config); + expect(out['cicd:wrapper']).toEqual(wrapperConfig); + expect(out['cicd:config']).not.toHaveProperty('plugins'); }); - test('does not clobber a user-set cicd:config', () => { - const existing = JSON.stringify({ 'cicd:config': { application: 'user-wins' } }); - const out = JSON.parse(buildContextJson(config, { CDK_CONTEXT_JSON: existing }, '/nonexistent')); + test('does not clobber user-set app or wrapper config', () => { + const existing = JSON.stringify({ + 'cicd:config': { application: 'user-wins' }, + 'cicd:wrapper': { qualifier: 'userqual' }, + }); + const out = JSON.parse(buildContextJson(config, wrapperConfig, { CDK_CONTEXT_JSON: existing }, '/nonexistent')); expect(out['cicd:config']).toEqual({ application: 'user-wins' }); + expect(out['cicd:wrapper']).toEqual({ qualifier: 'userqual' }); }); test('when no CDK_CONTEXT_JSON is set, merges cdk.json context then cdk.context.json (last wins)', () => { @@ -142,19 +349,39 @@ describe('exec: buildContextJson', () => { ); fs.writeFileSync(path.join(dir, 'cdk.context.json'), JSON.stringify({ b: 'from-context-json' })); - const out = JSON.parse(buildContextJson(config, {}, dir)); + const out = JSON.parse(buildContextJson(config, wrapperConfig, {}, dir)); expect(out.a).toBe('from-cdk-json'); expect(out.b).toBe('from-context-json'); // cdk.context.json overrides cdk.json expect(out['cicd:config']).toEqual(config); + expect(out['cicd:wrapper']).toEqual(wrapperConfig); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('a malformed existing CDK_CONTEXT_JSON is treated as empty, not fatal', () => { - const out = JSON.parse(buildContextJson(config, { CDK_CONTEXT_JSON: '{not json' }, '/nonexistent')); + const out = JSON.parse(buildContextJson(config, {}, { CDK_CONTEXT_JSON: '{not json' }, '/nonexistent')); expect(out['cicd:config']).toEqual(config); + expect(out['cicd:wrapper']).toBeUndefined(); + }); +}); + +describe('exec: wrapperRuntimeConfig', () => { + test('selects only wrapper-owned fields from cicd.config', () => { + const cicd = defineCICD({ + application: 'shop', + qualifier: 'shopqual', + repository: Repository.codecommit('shop'), + stages: ['dev'], + plugins: [], + }); + expect(wrapperRuntimeConfig(cicd)).toEqual({ + application: 'shop', + qualifier: 'shopqual', + synthesizer: { type: 'default' }, + plugins: [], + }); }); }); @@ -232,6 +459,57 @@ describe('exec: forcedRoleEnv', () => { CDK_CICD_DEPLOY_ROLE_EXTERNAL_ID: 'ext-stage', }); }); + + test('an externalId without a forced deployRole is ignored without resolving it', async () => { + await expect( + forcedRoleEnv({ deployment: { externalId: 'resolve:secretsmanager:must-not-be-read' } }, 'pipeline-default'), + ).resolves.toEqual({}); + }); + + test('Repo 2 role env overrides image-baked roles and uses its pre-resolved ExternalId literally', async () => { + const resolvedValueThatLooksLikeAReference = 'resolve:secretsmanager:this-is-the-literal-secret-value'; + await expect( + forcedRoleEnv( + { + deployment: { + deployRole: 'arn:image:deploy', + cfnExecutionRole: 'arn:image:cfn', + externalId: 'image-external-id', + }, + }, + 'image-default-external-id', + { + [DEPLOY_ROLE_FLAG]: 'arn:repo2:deploy', + [CFN_EXEC_ROLE_FLAG]: 'arn:repo2:cfn', + [DEPLOY_ROLE_EXTERNAL_ID_FLAG]: resolvedValueThatLooksLikeAReference, + }, + ), + ).resolves.toEqual({ + [DEPLOY_ROLE_FLAG]: 'arn:repo2:deploy', + [CFN_EXEC_ROLE_FLAG]: 'arn:repo2:cfn', + [DEPLOY_ROLE_EXTERNAL_ID_FLAG]: resolvedValueThatLooksLikeAReference, + }); + }); + + test('present-but-empty Repo 2 role env clears every image-baked role value', async () => { + await expect( + forcedRoleEnv( + { + deployment: { + deployRole: 'arn:image:deploy', + cfnExecutionRole: 'arn:image:cfn', + externalId: 'image-external-id', + }, + }, + 'image-default-external-id', + { + [DEPLOY_ROLE_FLAG]: '', + [CFN_EXEC_ROLE_FLAG]: '', + [DEPLOY_ROLE_EXTERNAL_ID_FLAG]: '', + }, + ), + ).resolves.toEqual({}); + }); }); describe('exec: resolveExternalId', () => { @@ -241,13 +519,87 @@ describe('exec: resolveExternalId', () => { expect(await resolveExternalId(undefined)).toBeUndefined(); }); - test('a resolve:secretsmanager: reference without the SDK present fails with a clear, actionable error', async () => { - // @aws-sdk/client-secrets-manager is intentionally NOT a build dependency (nested-smithy conflict); it - // is loaded via an untyped runtime import that is present in the pipeline/CI runtime. When absent, the - // resolver must fail with guidance rather than an opaque MODULE_NOT_FOUND. + test('a resolve:secretsmanager reference uses the injected secret reader', async () => { + const reader = jest.fn(async () => 'resolved-value'); await expect( - resolveExternalId('resolve:secretsmanager:arn:aws:secretsmanager:us-west-2:111111111111:secret:x'), - ).rejects.toThrow(/needs @aws-sdk\/client-secrets-manager|has no SecretString/); + resolveExternalId('resolve:secretsmanager:arn:aws:secretsmanager:us-west-2:111111111111:secret:x', reader), + ).resolves.toBe('resolved-value'); + expect(reader).toHaveBeenCalledWith('arn:aws:secretsmanager:us-west-2:111111111111:secret:x'); + }); + + test('the AWS CLI resolver parses SecretString without invoking a shell', () => { + const runnerMock = jest.fn().mockReturnValue({ + status: 0, + stdout: JSON.stringify({ SecretString: 'from-cli' }), + stderr: '', + }); + const runner = runnerMock as unknown as typeof import('child_process').spawnSync; + + const secretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:x'; + expect(readSecretStringFromAwsCli(secretArn, runner)).toBe('from-cli'); + expect(runner).toHaveBeenCalledWith( + 'aws', + ['secretsmanager', 'get-secret-value', '--secret-id', secretArn, '--output', 'json', '--region', 'eu-west-1'], + expect.objectContaining({ encoding: 'utf-8' }), + ); + expect(runnerMock.mock.calls[0][2]).not.toHaveProperty('shell'); + }); + + test('a non-ARN secret id uses the ambient AWS region', () => { + const runnerMock = jest.fn().mockReturnValue({ + status: 0, + stdout: JSON.stringify({ SecretString: 'from-cli' }), + stderr: '', + }); + const runner = runnerMock as unknown as typeof import('child_process').spawnSync; + + expect(readSecretStringFromAwsCli('external-id-secret', runner)).toBe('from-cli'); + expect(runnerMock.mock.calls[0][1]).toEqual([ + 'secretsmanager', + 'get-secret-value', + '--secret-id', + 'external-id-secret', + '--output', + 'json', + ]); + }); + + test('the AWS CLI resolver reports command failures without exposing a secret value', () => { + const runner = jest.fn().mockReturnValue({ + status: 254, + stdout: '', + stderr: 'AccessDeniedException', + }) as unknown as typeof import('child_process').spawnSync; + expect(() => readSecretStringFromAwsCli('secret-id', runner)).toThrow( + /could not read Secrets Manager secret 'secret-id': AccessDeniedException/, + ); + }); +}); + +describe('exec: self-mutating pipeline externalIds', () => { + test('resolves per-stage/default references only for stages with a deployRole', async () => { + const cicd = defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + deployRoleExternalId: 'resolve:secretsmanager:default', + stages: [ + { name: 'dev', deployment: { deployRole: 'arn:dev' } }, + { + name: 'prod', + deployment: { deployRole: 'arn:prod', externalId: 'resolve:secretsmanager:prod' }, + }, + { name: 'qa', deployment: { externalId: 'resolve:secretsmanager:ignored' } }, + ], + }); + const resolver = jest.fn(async (value?: string) => (value === undefined ? undefined : `resolved:${value}`)); + + const resolved = await resolvePipelineExternalIds(cicd, resolver); + + expect(resolved.stages[0].deployment?.externalId).toBe('resolved:resolve:secretsmanager:default'); + expect(resolved.stages[1].deployment?.externalId).toBe('resolved:resolve:secretsmanager:prod'); + expect(resolved.stages[2].deployment?.externalId).toBe('resolve:secretsmanager:ignored'); + expect(resolved.deployRoleExternalId).toBeUndefined(); + expect(resolver).toHaveBeenCalledTimes(2); }); }); @@ -261,5 +613,13 @@ describe('exec: cross-package env-flag literals match the constructs package', ( expect(src).toContain(`const DEPLOY_ROLE_FLAG = '${inject.DEPLOY_ROLE_FLAG}'`); expect(src).toContain(`const CFN_EXEC_ROLE_FLAG = '${inject.CFN_EXEC_ROLE_FLAG}'`); expect(src).toContain(`const DEPLOY_ROLE_EXTERNAL_ID_FLAG = '${inject.DEPLOY_ROLE_EXTERNAL_ID_FLAG}'`); + expect(src).toContain(`const COMPLIANCE_LOG_BUCKET_NAME_FLAG = '${inject.COMPLIANCE_LOG_BUCKET_NAME_FLAG}'`); + expect(src).toContain(`const COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG = '${inject.COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG}'`); + expect(src).toContain(`const COMPLIANCE_LOG_BUCKET_REGION_FLAG = '${inject.COMPLIANCE_LOG_BUCKET_REGION_FLAG}'`); + // This constant is new in the source under test, so read the constructs source rather than a + // potentially stale pre-test lib/ build. + const injectSrc = fs.readFileSync(path.join(__dirname, '../../../cdk-cicd-wrapper/src/runtime/inject.ts'), 'utf-8'); + expect(src).toContain("const WRAPPER_CONFIG_CONTEXT_KEY = 'cicd:wrapper'"); + expect(injectSrc).toContain("export const WRAPPER_CONFIG_CONTEXT_KEY = 'cicd:wrapper'"); }); }); diff --git a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/SynthCommand.test.ts b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/SynthCommand.test.ts index 2e19ef90..ea4f852c 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/SynthCommand.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper-cli/test/autopilot/SynthCommand.test.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { ResolvedCicdConfig } from '@cdklabs/cdk-cicd-wrapper'; +import { ACCOUNT_OVERRIDE_FLAG, REGION_OVERRIDE_FLAG, resolveEnvTarget } from '../../src/cmds/autopilot/ExecCommand'; import { synthTargets } from '../../src/cmds/autopilot/SynthCommand'; // A minimal resolved config: one single-region stage, one multi-region stage. @@ -42,6 +43,7 @@ describe('m3-synth: synthTargets', () => { // The override carries into the env pins, so the synth actually targets that region. const [only] = synthTargets(CONFIG, 'prod', 'ap-southeast-2'); expect(only.env.CDK_DEFAULT_REGION).toBe('ap-southeast-2'); + expect(only.env[REGION_OVERRIDE_FLAG]).toBe('ap-southeast-2'); expect(only.env.AWS_REGION).toBe('ap-southeast-2'); expect(only.account).toBe('222222222222'); }); @@ -58,8 +60,102 @@ describe('m3-synth: synthTargets', () => { ], } as unknown as ResolvedCicdConfig; expect(synthTargets(agnostic, 'dev', 'us-west-2').map((t) => t.region)).toEqual(['us-west-2']); - // Without the override the same stage produces nothing. - expect(synthTargets(agnostic, 'dev')).toEqual([]); + }); + + test('an env-agnostic stage falls back to CDK_DEFAULT_REGION, then AWS_REGION', () => { + const agnostic = { + ...CONFIG, + stages: [ + { + name: 'dev', + env: { account: undefined, regions: [], regionOrder: 'sequential' as any }, + manualApproval: false, + }, + ], + } as unknown as ResolvedCicdConfig; + + expect( + synthTargets(agnostic, 'dev', undefined, { + CDK_DEFAULT_REGION: 'eu-central-1', + AWS_REGION: 'us-east-1', + }).map((t) => t.region), + ).toEqual(['eu-central-1']); + expect(synthTargets(agnostic, 'dev', undefined, { AWS_REGION: 'us-east-1' }).map((t) => t.region)).toEqual([ + 'us-east-1', + ]); + expect(synthTargets(agnostic, 'dev', undefined, {})).toEqual([]); + }); + + test('the Repo 2 account override wins over the account baked into cicd.config', () => { + const [target] = synthTargets(CONFIG, 'prod', undefined, { + CDK_CICD_ACCOUNT_OVERRIDE: '999999999999', + }); + expect(target.account).toBe('999999999999'); + expect(target.env.CDK_DEFAULT_ACCOUNT).toBe('999999999999'); + expect(target.env.CDK_DEPLOY_ACCOUNT).toBe('999999999999'); + }); + + test('empty Repo 2 overrides clear image targets and use ambient account/region values', () => { + const [target] = synthTargets(CONFIG, 'prod', undefined, { + CDK_CICD_ACCOUNT_OVERRIDE: '', + CDK_DEFAULT_ACCOUNT: '999999999999', + CDK_CICD_REGION_OVERRIDE: '', + AWS_REGION: 'eu-central-1', + }); + expect(target.account).toBe('999999999999'); + expect(target.region).toBe('eu-central-1'); + expect(target.env.CDK_DEFAULT_ACCOUNT).toBe('999999999999'); + expect(target.env.CDK_DEFAULT_REGION).toBe('eu-central-1'); + expect(target.env[ACCOUNT_OVERRIDE_FLAG]).toBe('999999999999'); + expect(target.env[REGION_OVERRIDE_FLAG]).toBe('eu-central-1'); + }); + + test('an intentionally account-agnostic target carries an empty explicit account contract', () => { + const agnostic = { + ...CONFIG, + stages: [ + { + name: 'dev', + env: { account: undefined, regions: ['eu-west-1'], regionOrder: 'sequential' as any }, + manualApproval: false, + }, + ], + } as unknown as ResolvedCicdConfig; + const [target] = synthTargets(agnostic, 'dev', undefined, { + CDK_DEFAULT_ACCOUNT: '111111111111', + }); + + expect(target.account).toBeUndefined(); + expect(target.env[ACCOUNT_OVERRIDE_FLAG]).toBe(''); + expect( + resolveEnvTarget( + { ...target.env, CDK_DEFAULT_ACCOUNT: '111111111111' }, + { aws: { accountId: '999999999999' } }, + undefined, + 'dev', + ).account, + ).toBeUndefined(); + }); + + test('configured cross-account targets survive ambient CDK CLI account and region rewrites', () => { + const [target] = synthTargets(CONFIG, 'prod', undefined, { + CDK_DEFAULT_ACCOUNT: '111111111111', + CDK_DEFAULT_REGION: 'us-east-1', + AWS_REGION: 'us-east-1', + }); + const rewrittenByCdk = { + ...target.env, + CDK_DEFAULT_ACCOUNT: '111111111111', + CDK_DEFAULT_REGION: 'us-east-1', + AWS_REGION: 'us-east-1', + }; + + expect(target.env[ACCOUNT_OVERRIDE_FLAG]).toBe('222222222222'); + expect(target.env[REGION_OVERRIDE_FLAG]).toBe('us-west-1'); + expect(resolveEnvTarget(rewrittenByCdk, {}, undefined, 'prod')).toEqual({ + account: '222222222222', + region: 'us-west-1', + }); }); test('each target carries the per-region env and a segregated output dir', () => { @@ -69,8 +165,10 @@ describe('m3-synth: synthTargets', () => { CDK_STAGE: 'prod', CDK_DEFAULT_ACCOUNT: '222222222222', CDK_DEPLOY_ACCOUNT: '222222222222', + [ACCOUNT_OVERRIDE_FLAG]: '222222222222', CDK_DEFAULT_REGION: 'us-west-1', CDK_DEPLOY_REGION: 'us-west-1', + [REGION_OVERRIDE_FLAG]: 'us-west-1', // steer the CDK CLI's own region derivation, not just the app's CDK_DEFAULT_REGION AWS_REGION: 'us-west-1', AWS_DEFAULT_REGION: 'us-west-1', diff --git a/packages/@cdklabs/cdk-cicd-wrapper/.projen/deps.json b/packages/@cdklabs/cdk-cicd-wrapper/.projen/deps.json index 5ff980a0..b266b473 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/.projen/deps.json +++ b/packages/@cdklabs/cdk-cicd-wrapper/.projen/deps.json @@ -143,6 +143,11 @@ "version": "^10.5.0", "type": "peer" }, + { + "name": "@aws-cdk/app-staging-synthesizer-alpha", + "version": "2.195.0-alpha.0", + "type": "runtime" + }, { "name": "@cloudcomponents/cdk-pull-request-approval-rule", "type": "runtime" diff --git a/packages/@cdklabs/cdk-cicd-wrapper/.projen/tasks.json b/packages/@cdklabs/cdk-cicd-wrapper/.projen/tasks.json index 12fcc3aa..e6c1ac9f 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/.projen/tasks.json +++ b/packages/@cdklabs/cdk-cicd-wrapper/.projen/tasks.json @@ -221,7 +221,7 @@ "description": "Runs after successful compilation", "steps": [ { - "exec": "for DEP in cdk-nag cdk-pipelines-github; do cp -rf ../../../node_modules/$DEP ./node_modules/ 2>/dev/null; done;" + "exec": "for DEP in cdk-nag cdk-pipelines-github; do cp -rf ../../../node_modules/$DEP ./node_modules/ 2>/dev/null; done; if [ -d ../../../node_modules/@aws-cdk/app-staging-synthesizer-alpha ]; then mkdir -p ./node_modules/@aws-cdk; cp -rf ../../../node_modules/@aws-cdk/app-staging-synthesizer-alpha ./node_modules/@aws-cdk/; fi;" }, { "spawn": "docgen" diff --git a/packages/@cdklabs/cdk-cicd-wrapper/API.md b/packages/@cdklabs/cdk-cicd-wrapper/API.md index 890c93d4..53226a93 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/API.md +++ b/packages/@cdklabs/cdk-cicd-wrapper/API.md @@ -156,11 +156,15 @@ public readonly pipeline: CodePipeline; ### DeploymentPipeline -Renders the CD CodePipeline into `scope` (a Stack): Source (the config repo) -> a "Deploy" stage with one privileged-CodeBuild action per ungated target (parallel), then a "DeployGated" stage with the gated targets, each behind its own manual approval. +Renders the CD CodePipeline into `scope` (a Stack): Source (the config repo) followed by ordered deployment stages. -Each action runs `cdk-cicd deploy --from-image --target -` -- pulling that target's own image version, read from deploy.config at run time. The CLI is -installed from the source repo's `package.json` (`npm ci`), so the config repo carries no CDK code. +Each contiguous run of ungated targets shares one stage and can deploy in parallel. +Each gated target has its own stage, with its approval at run order 1 and only that target's deploy +action(s) at run order 2, so the gate blocks every later target without reordering the declaration. +A sequential target uses one action for all regions; a parallel multi-region target fans out one action +per region. Each action runs `cdk-cicd deploy --from-image --target ` -- pulling that target's own +image version, read from deploy.config at run time. The CLI is installed from the source repo's +`package.json` (`npm ci`), so the config repo carries no CDK code. #### Initializers @@ -642,10 +646,9 @@ Exposed so a test or an opt-in `bin/` can reach it. A GitHub Actions workflow rendered from an Autopilot config + a stage factory. Reproduces the Blueprint shape: a -`GitHubActionRole` the workflow assumes over OIDC, a Synth job, and one job (with a GitHub Environment, -so an environment protection rule set up on GitHub's side gates it) per deployment stage. Manual-approval -config is NOT translated into a CDK step here -- as in Blueprint, GitHub Environments are the gate; every stage -gets its own environment regardless of `manualApproval`, and gating is configured in the GitHub UI. +`GitHubActionRole` the workflow assumes over OIDC, a Synth job, and one job (with a GitHub Environment) +per deployment stage. GitHub owns the environment protection rules, so approval-gated stages are accepted +only when config explicitly acknowledges that required reviewers are configured on those environments. #### Initializers @@ -1306,20 +1309,17 @@ The compliance/access-log destination bucket (Blueprint `ComplianceBucketProvide Created on first read, same as every other property here. Requires `complianceLogBucketName`: unlike `artifactBucket`, this bucket's name must be explicit and predictable so other buckets' logging -configuration (and, cross-region, Blueprint's name-substitution convention) can reference it. +configuration can reference it by name. -Blueprint provisioned this bucket via a custom-resource Lambda so a redeploy could tolerate the bucket -already existing (`BucketAlreadyOwnedByYou`); Autopilot provisions it as a plain, CloudFormation-managed -`Bucket` instead -- simpler, and the "already exists" case Blueprint tolerated doesn't arise here since -this construct's stack owns the bucket for the life of the pipeline. +By default Autopilot provisions a plain, CloudFormation-managed `Bucket`. For an in-place Blueprint +migration, set `createComplianceLogBucket: false` to reference the existing bucket by name instead. +CDK intentionally cannot mutate an imported bucket policy, so that mode leaves the bucket and policy +entirely under their current owner's lifecycle. -Folds in the TLS/SSE policy fix Blueprint's Stage-1 change (`0b7ae02`) made and Autopilot must not regress: -enforcing encryption-in-transit works with a plain `Bool` condition on `aws:SecureTransport` -(`enforceSSL`, below) because that key is always present on every request. Enforcing encryption -*at rest* does not: `s3:x-amz-server-side-encryption` is only present in the request context when -the caller actually sets the header, so a `Bool` check against `"false"` never matches a request -that omits the header entirely -- exactly the unencrypted upload this statement exists to block. -The `Null` operator below checks for the header's *absence*, which a `Bool` check cannot. +The bucket uses default SSE-S3 encryption. Writers, including the S3 server-access-log delivery +service, do not need to send an `x-amz-server-side-encryption` header: S3 encrypts the object at +rest after accepting it. A bucket-policy deny based on that header would block valid log delivery, +so transport encryption is enforced here while at-rest encryption is enforced by bucket defaults. --- @@ -1371,8 +1371,24 @@ const accessLogsForBucketAspectProps: AccessLogsForBucketAspectProps = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | +| complianceLogBucketAccount | string | AWS account that owns the compliance bucket. | | complianceLogBucketName | string | The name of the bucket every visited bucket's access logs are delivered to. | -| mainRegion | string | The region the compliance log bucket lives in. | +| complianceLogBucketRegion | string | AWS Region containing the compliance bucket. | +| complianceLogBucket | aws-cdk-lib.aws_s3.IBucket | The concrete destination bucket when it exists in the same CDK app. | + +--- + +##### `complianceLogBucketAccount`Required + +```typescript +public readonly complianceLogBucketAccount: string; +``` + +- *Type:* string + +AWS account that owns the compliance bucket. + +S3 access-log delivery cannot cross accounts. --- @@ -1388,19 +1404,32 @@ The name of the bucket every visited bucket's access logs are delivered to. --- -##### `mainRegion`Required +##### `complianceLogBucketRegion`Required ```typescript -public readonly mainRegion: string; +public readonly complianceLogBucketRegion: string; ``` - *Type:* string -The region the compliance log bucket lives in. +AWS Region containing the compliance bucket. + +S3 access-log delivery cannot cross Regions. + +--- + +##### `complianceLogBucket`Optional + +```typescript +public readonly complianceLogBucket: IBucket; +``` + +- *Type:* aws-cdk-lib.aws_s3.IBucket + +The concrete destination bucket when it exists in the same CDK app. -When a visited bucket's stack is deployed to a -different region, `complianceLogBucketName` is rewritten by substituting `mainRegion` for that -stack's region -- same cross-region name convention as Blueprint. +Supplying it lets same-stack +source buckets depend explicitly on the destination bucket and its policy. --- @@ -1784,6 +1813,7 @@ const ciConfig: CiConfig = { ... } | --- | --- | --- | | steps | {[ key: string ]: string} | Named build steps as shell commands, e.g. `{ lint: 'npx cdk-cicd validate' }`. Empty means the engine applies its built-in default set. | | synthStages | string[] | Which stages CI synthesizes. | +| codeBuildImageCredentials | CodeBuildImageCredentials | Secrets Manager credentials for an authenticated external-registry `image`. | | image | string | Optional CodeBuild image override. | | partialBuildSpec | aws-cdk-lib.aws_codebuild.BuildSpec | Escape hatch (Blueprint `CDKPipelineProps.ciBuildSpec`, migrated): deep-merged into the CI build project's generated buildspec via `codebuild.mergeBuildSpecs`, augmenting rather than replacing the engine's own phases. Scoped the same way Blueprint scoped it -- the CI build project only, not self-update or per-stage deploy projects. | @@ -1817,6 +1847,21 @@ explicitly; `defineCICD`'s `'all'` shorthand resolves to the full stage list her --- +##### `codeBuildImageCredentials`Optional + +```typescript +public readonly codeBuildImageCredentials: CodeBuildImageCredentials; +``` + +- *Type:* CodeBuildImageCredentials + +Secrets Manager credentials for an authenticated external-registry `image`. + +Supported by the CodeBuild engines only. Managed CodeBuild images and private ECR images use +their own credential models and reject this setting. + +--- + ##### `image`Optional ```typescript @@ -1934,6 +1979,51 @@ Defaults to the pipeline's own region. --- +### CodeBuildImageCredentials + +Secrets Manager credentials used by CodeBuild to pull an authenticated external-registry image. + +#### Initializer + +```typescript +import { CodeBuildImageCredentials } from '@cdklabs/cdk-cicd-wrapper' + +const codeBuildImageCredentials: CodeBuildImageCredentials = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| secretArn | string | Complete ARN of the Secrets Manager secret containing the registry username and password. | +| encryptionKeyArn | string | Customer-managed KMS key encrypting `secretArn`, when one is used. | + +--- + +##### `secretArn`Required + +```typescript +public readonly secretArn: string; +``` + +- *Type:* string + +Complete ARN of the Secrets Manager secret containing the registry username and password. + +--- + +##### `encryptionKeyArn`Optional + +```typescript +public readonly encryptionKeyArn: string; +``` + +- *Type:* string + +Customer-managed KMS key encrypting `secretArn`, when one is used. + +--- + ### CodeCommitSourceOptions Options for a CodeCommit source. @@ -1988,7 +2078,7 @@ const codePipelineEngineProps: CodePipelineEngineProps = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | -| buildImage | string | CodeBuild image for the CI and deploy projects. | +| buildImage | string | CodeBuild image for the CI Build project only. | | removalPolicy | aws-cdk-lib.RemovalPolicy | Removal policy for the pipeline's own support resources (artifact bucket, encryption key). | --- @@ -2001,9 +2091,10 @@ public readonly buildImage: string; - *Type:* string -CodeBuild image for the CI and deploy projects. +CodeBuild image for the CI Build project only. -Defaults to the standard Amazon Linux image. +Overrides `config.ci.image`; defaults to the +standard Amazon Linux image. --- @@ -2042,7 +2133,7 @@ const codePipelineRoleNames: CodePipelineRoleNames = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | -| buildRolePrefix | string | Prefix for the per-stage CodeBuild project roles: each stage's build role is named `-` (plus the CI/self-update projects, `-build` / `-selfupdate`). | +| buildRolePrefix | string | Prefix for every flat-engine CodeBuild project role. | | pipeline | string | `RoleName` forced on the CodePipeline pipeline role. | --- @@ -2055,7 +2146,11 @@ public readonly buildRolePrefix: string; - *Type:* string -Prefix for the per-stage CodeBuild project roles: each stage's build role is named `-` (plus the CI/self-update projects, `-build` / `-selfupdate`). +Prefix for every flat-engine CodeBuild project role. + +The suffix is the lower-cased construct id +with a trailing `Project` removed: `BuildProject` -> `build`, `UpdatePipeline` -> +`updatepipeline`, and `Deploy-dev` -> `deploy-dev`. Omit to keep CDK-generated names. @@ -2178,6 +2273,81 @@ Fields whose absence is reported as `MISSING_KEY`. --- +### DefaultSynthesizerRoleArnOptions + +Target values used to specialize a DefaultStackSynthesizer role ARN. + +#### Initializer + +```typescript +import { DefaultSynthesizerRoleArnOptions } from '@cdklabs/cdk-cicd-wrapper' + +const defaultSynthesizerRoleArnOptions: DefaultSynthesizerRoleArnOptions = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| account | string | Concrete target AWS account. | +| region | string | Concrete target AWS Region. | +| partition | string | Concrete target AWS partition. | +| qualifier | string | Bootstrap qualifier. | + +--- + +##### `account`Required + +```typescript +public readonly account: string; +``` + +- *Type:* string + +Concrete target AWS account. + +--- + +##### `region`Required + +```typescript +public readonly region: string; +``` + +- *Type:* string + +Concrete target AWS Region. + +--- + +##### `partition`Optional + +```typescript +public readonly partition: string; +``` + +- *Type:* string + +Concrete target AWS partition. + +When omitted, `${AWS::Partition}` remains intact to match the role ARN emitted in a cloud assembly. + +--- + +##### `qualifier`Optional + +```typescript +public readonly qualifier: string; +``` + +- *Type:* string + +Bootstrap qualifier. + +Defaults to the CDK default qualifier (`hnb659fds`). + +--- + ### DeploymentConfig Forced deployer / CloudFormation-execution roles for a stage. @@ -2195,7 +2365,7 @@ const deploymentConfig: DeploymentConfig = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | | cfnExecutionRole | string | ARN CloudFormation assumes to execute the change set. | -| deployRole | string | ARN the CLI assumes to deploy (passed as `cdk deploy --role-arn`). | +| deployRole | string | ARN CDK assumes for deployment operations. | | externalId | string | ExternalId presented when assuming `deployRole` (the `sts:ExternalId` a hardened cross-account trust policy requires). | --- @@ -2220,7 +2390,10 @@ public readonly deployRole: string; - *Type:* string -ARN the CLI assumes to deploy (passed as `cdk deploy --role-arn`). +ARN CDK assumes for deployment operations. + +It is written to the cloud assembly as the stack's +deployment-role assumption; it is not CloudFormation's execution `RoleARN`. --- @@ -2237,7 +2410,9 @@ ExternalId presented when assuming `deployRole` (the `sts:ExternalId` a hardened Overrides the pipeline-level `ResolvedCicdConfig.deployRoleExternalId` for this stage. A literal, or a `resolve:secretsmanager:` reference resolved at synth time (the same `resolve:` convention `VpcConfig.vpcId` uses). Ignored when `deployRole` is unset -- an -ExternalId only applies to a role assumption the wrapper actually performs. +ExternalId only applies to a role assumption the wrapper actually performs. Secret references +currently require the Secrets Manager AWS-managed encryption key; customer-managed KMS keys need +an additional `kms:Decrypt` grant that this config shape cannot identify. --- @@ -2513,6 +2688,8 @@ const gitHubActionsConfig: GitHubActionsConfig = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | +| buildContainerCredentials | GitHubBuildContainerCredentials | GitHub Actions secret names used as the Build-Synth container's registry credentials. | +| environmentProtectionConfigured | boolean | Confirms that every generated GitHub Environment used by a stage with `manualApproval: true` has a required-reviewer protection rule configured in GitHub. | | openIdConnectProviderArn | string | An existing GitHub OIDC provider's ARN. | | publishAssetsAuthRegion | string | Region the workflow assumes the OIDC role in when publishing assets (NOT the region assets publish to). | | roleName | string | Name of the OIDC role the workflow assumes to deploy. | @@ -2524,6 +2701,38 @@ const gitHubActionsConfig: GitHubActionsConfig = { ... } --- +##### `buildContainerCredentials`Optional + +```typescript +public readonly buildContainerCredentials: GitHubBuildContainerCredentials; +``` + +- *Type:* GitHubBuildContainerCredentials + +GitHub Actions secret names used as the Build-Synth container's registry credentials. + +Only external-registry `ci.image` values support this setting. Values are rendered as +`${{ secrets.NAME }}` expressions; literal credentials are never accepted. + +--- + +##### `environmentProtectionConfigured`Optional + +```typescript +public readonly environmentProtectionConfigured: boolean; +``` + +- *Type:* boolean +- *Default:* false + +Confirms that every generated GitHub Environment used by a stage with `manualApproval: true` has a required-reviewer protection rule configured in GitHub. + +The workflow file can name an environment, but GitHub does not let CDK configure that +environment's protection rules. The engine therefore fails closed for approval-gated stages +unless this acknowledgement is explicitly set. + +--- + ##### `openIdConnectProviderArn`Optional ```typescript @@ -2545,7 +2754,7 @@ public readonly publishAssetsAuthRegion: string; ``` - *Type:* string -- *Default:* "us-west-2" +- *Default:* the concrete pipeline stack region Region the workflow assumes the OIDC role in when publishing assets (NOT the region assets publish to). @@ -2691,6 +2900,51 @@ Falls back to `githubActions.workflowName` when set; otherwise `cdk-pipelines-gi --- +### GitHubBuildContainerCredentials + +GitHub secret names used to authenticate the Build-Synth job container to an external registry. + +#### Initializer + +```typescript +import { GitHubBuildContainerCredentials } from '@cdklabs/cdk-cicd-wrapper' + +const gitHubBuildContainerCredentials: GitHubBuildContainerCredentials = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| passwordSecretName | string | GitHub Actions secret containing the registry password or access token. | +| usernameSecretName | string | GitHub Actions secret containing the registry username. | + +--- + +##### `passwordSecretName`Required + +```typescript +public readonly passwordSecretName: string; +``` + +- *Type:* string + +GitHub Actions secret containing the registry password or access token. + +--- + +##### `usernameSecretName`Required + +```typescript +public readonly usernameSecretName: string; +``` + +- *Type:* string + +GitHub Actions secret containing the registry username. + +--- + ### LambdaDLQAspectProps Constructor props for {@link LambdaDLQAspect}. @@ -2902,8 +3156,9 @@ is configured (no NAT egress; the CodeBuild VPC endpoints below cover AWS API ca A generic private npm registry the pipeline's builds authenticate against with a bearer token (Blueprint `NPMRegistryConfig`, migrated). Unlike `CodeArtifactConfig` (an `aws codeartifact login`), this covers -any npm-compatible registry: when set, every build project writes a `.npmrc` -- scoped to `scope` when -given, otherwise overriding the default registry -- with an auth token read from Secrets Manager. +any npm-compatible registry: jobs that install packages use a temporary npm config outside the source +checkout -- scoped to `scope` when given, otherwise overriding the default registry -- with an auth +token read from Secrets Manager. #### Initializer @@ -2919,6 +3174,7 @@ const npmRegistryConfig: NpmRegistryConfig = { ... } | --- | --- | --- | | basicAuthSecretArn | string | ARN of the Secrets Manager secret holding the bearer token (the secret's plain `SecretString`). | | url | string | The registry URL, e.g. `https://npm.example.com/`. | +| encryptionKeyArn | string | Customer-managed KMS key encrypting `basicAuthSecretArn`, when one is used. | | scope | string | npm scope to bind to the registry, e.g. `cdklabs` for `@cdklabs/*`. Omit to override the default registry. | --- @@ -2947,6 +3203,18 @@ The registry URL, e.g. `https://npm.example.com/`. --- +##### `encryptionKeyArn`Optional + +```typescript +public readonly encryptionKeyArn: string; +``` + +- *Type:* string + +Customer-managed KMS key encrypting `basicAuthSecretArn`, when one is used. + +--- + ##### `scope`Optional ```typescript @@ -3140,6 +3408,7 @@ const proxyConfig: ProxyConfig = { ... } | noProxy | string[] | Hosts that bypass the proxy. | | proxySecretArn | string | ARN of the Secrets Manager secret holding the proxy credentials, as the keys `username`, `password`, `http_proxy_port`, `https_proxy_port` and `proxy_domain`. | | proxyTestUrl | string | URL curl'd (through the proxy) to confirm it works before the install phase's real commands run. | +| encryptionKeyArn | string | Customer-managed KMS key encrypting `proxySecretArn`, when one is used. | --- @@ -3182,6 +3451,18 @@ URL curl'd (through the proxy) to confirm it works before the install phase's re --- +##### `encryptionKeyArn`Optional + +```typescript +public readonly encryptionKeyArn: string; +``` + +- *Type:* string + +Customer-managed KMS key encrypting `proxySecretArn`, when one is used. + +--- + ### RemovalPolicies Retention of stateful resources. @@ -3302,6 +3583,7 @@ const resolvedCicdConfig: ResolvedCicdConfig = { ... } | codeBuildEnvSettings | aws-cdk-lib.aws_codebuild.BuildEnvironment | CodeBuild environment overrides -- privileged mode, compute type, environment variables -- applied to every CodeBuild project the pipeline creates (Blueprint `codeBuildEnvSettings`, migrated from `CodeBuildFactoryProvider`/`PipelineBlueprint.codeBuildEnvSettings(...)`). Reuses CDK's own `BuildEnvironment` rather than a bespoke type, so it stays a drop-in for Blueprint callers. `buildImage` here is a full `IBuildImage` (e.g. an ARM or GPU managed image); it is distinct from the engines' own `buildImage` constructor prop, which takes a Docker-registry image string -- that prop wins when both are set. | | codePipelineRoleNames | CodePipelineRoleNames | Forced IAM role names for the flat `CODEPIPELINE` engine's own roles. | | complianceLogBucketName | string | The name of the compliance/access-log destination bucket, if configured (Blueprint `ComplianceBucketProvider`/`ComplianceLogBucketStack`, migrated). | +| createComplianceLogBucket | boolean | Whether the pipeline stack creates and manages `complianceLogBucketName`. | | deployerImage | BuildImage | Container mode (Repo 1): when set, the pipeline runs CI then builds & pushes a config-agnostic deployer image to ECR instead of deploying stages. | | deployRoleExternalId | string | Pipeline-level default ExternalId presented when assuming a stage's forced `deployRole`. | | express | boolean | Deploy with **CloudFormation express mode** (`cdk deploy --express`). | @@ -3420,7 +3702,7 @@ public readonly application: string; Application name; -drives the bootstrap qualifier and asset naming. +drives the derived bootstrap qualifier and asset naming. --- @@ -3478,6 +3760,23 @@ Threaded into --- +##### `createComplianceLogBucket`Optional + +```typescript +public readonly createComplianceLogBucket: boolean; +``` + +- *Type:* boolean +- *Default:* true + +Whether the pipeline stack creates and manages `complianceLogBucketName`. + +Set to `false` during a Blueprint migration to reference an existing, owner-managed bucket. +The pipeline then creates neither the bucket nor its bucket policy; the bucket owner must keep +the encryption, TLS-enforcement, and S3 server-access-log delivery policy in place. + +--- + ##### `deployerImage`Optional ```typescript @@ -3505,7 +3804,8 @@ Pipeline-level default ExternalId presented when assuming a stage's forced `depl A stage's own `DeploymentConfig.externalId` overrides this. A literal or a `resolve:secretsmanager:` -reference resolved at synth time. +reference resolved at synth time. Secret references currently require the Secrets Manager +AWS-managed encryption key; customer-managed KMS keys are not supported without an external grant. --- @@ -3670,10 +3970,15 @@ const resolvedDeploymentConfig: ResolvedDeploymentConfig = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | | targets | ResolvedDeploymentTarget[] | The deployment targets, in order. | +| application | string | Application name baked into the deployer image. | | codeArtifact | CodeArtifactConfig | Private CodeArtifact repo the CD build authenticates against before `npm ci` (to install the wrapper CLI when it is pre-release / not on public npm). | +| complianceLogBucketName | string | Default compliance/access-log destination bucket name for targets that do not provide their own. | +| crossAccountEcrRepositoryPolicyConfigured | boolean | Confirms that owner-side repository policies permit the generated pipeline role to pull every cross-account ECR image referenced by this deployment config. | | image | string | The default deployer image to run targets against (an ECR/OCI reference, tag or digest). | | npmRegistry | NpmRegistryConfig | Generic private npm registry the CD build authenticates against before `npm ci`. | +| qualifier | string | Bootstrap qualifier used by the deployer image; | | repository | Repository | The config-only source repository the CD pipeline watches (where `deploy.config.ts` lives -- no CDK code). Optional: when omitted, the config drives only the local `cdk-cicd deploy --from-image` executor; set it to provision a CD CodePipeline (`cdk-cicd deploy-ci`) whose CodeBuild pulls the image and deploys each target. This is the deploy-side twin of `ResolvedCicdConfig.repository`. | +| synthesizer | SynthesizerConfig | Synthesizer used by the deployer image; must match its `cicd.config`. | --- @@ -3689,6 +3994,23 @@ The deployment targets, in order. --- +##### `application`Optional + +```typescript +public readonly application: string; +``` + +- *Type:* string + +Application name baked into the deployer image. + +Optional for the default synthesizer; required +(or supply `synthesizer.appId`) when the image uses `APP_STAGING` for direct `deploy --from-image`. +The generated Repo 2 CodePipeline itself supports only `DEFAULT`. Direct APP_STAGING targets can +configure deployment roles, but cannot attach an ExternalId to the deployment role. + +--- + ##### `codeArtifact`Optional ```typescript @@ -3703,6 +4025,35 @@ Same shape as the pipeline-config `codeArtifact`. --- +##### `complianceLogBucketName`Optional + +```typescript +public readonly complianceLogBucketName: string; +``` + +- *Type:* string + +Default compliance/access-log destination bucket name for targets that do not provide their own. + +Repo 2 references an existing bucket; it does not create one. A target using this default must +have a concrete account and exactly one concrete Region so S3's same-account/same-Region delivery +requirement can be verified. Targets in other environments may override the name individually. + +--- + +##### `crossAccountEcrRepositoryPolicyConfigured`Optional + +```typescript +public readonly crossAccountEcrRepositoryPolicyConfigured: boolean; +``` + +- *Type:* boolean +- *Default:* false + +Confirms that owner-side repository policies permit the generated pipeline role to pull every cross-account ECR image referenced by this deployment config. + +--- + ##### `image`Optional ```typescript @@ -3734,6 +4085,20 @@ pipeline-config `npmRegistry`. --- +##### `qualifier`Optional + +```typescript +public readonly qualifier: string; +``` + +- *Type:* string + +Bootstrap qualifier used by the deployer image; + +derived from `application` when omitted. + +--- + ##### `repository`Optional ```typescript @@ -3746,6 +4111,21 @@ The config-only source repository the CD pipeline watches (where `deploy.config. --- +##### `synthesizer`Optional + +```typescript +public readonly synthesizer: SynthesizerConfig; +``` + +- *Type:* SynthesizerConfig + +Synthesizer used by the deployer image; must match its `cicd.config`. + +Optional for compatibility with pre-synthesizer Repo 2 configs; consumers must treat omission +as `SynthesizerType.DEFAULT`. + +--- + ### ResolvedDeploymentTarget A resolved deployment target for container mode (Repo 2): a stage to deploy the pinned image against, with its own environment and optional forced roles. @@ -3769,6 +4149,9 @@ const resolvedDeploymentTarget: ResolvedDeploymentTarget = { ... } | env | StageEnvironment | Where this target deploys. | | manualApproval | boolean | Whether a manual approval gates this target. | | stage | string | The stage in the image's app to deploy (passed to the in-container `cdk-cicd deploy --stage`). | +| complianceLogBucketAccount | string | Account containing `complianceLogBucketName`. | +| complianceLogBucketName | string | Compliance/access-log destination bucket for this target, after applying the deployment-wide default. | +| complianceLogBucketRegion | string | Region containing `complianceLogBucketName`. | | deployment | DeploymentConfig | Forced roles for this target, if any. | | image | string | The deployer image (tag/digest) to run for THIS target, overriding the config-level `image`. | @@ -3810,6 +4193,45 @@ The stage in the image's app to deploy (passed to the in-container `cdk-cicd dep --- +##### `complianceLogBucketAccount`Optional + +```typescript +public readonly complianceLogBucketAccount: string; +``` + +- *Type:* string + +Account containing `complianceLogBucketName`. + +--- + +##### `complianceLogBucketName`Optional + +```typescript +public readonly complianceLogBucketName: string; +``` + +- *Type:* string + +Compliance/access-log destination bucket for this target, after applying the deployment-wide default. + +When set, `complianceLogBucketAccount` and `complianceLogBucketRegion` are also set and +exactly match this target's concrete, single-Region environment. + +--- + +##### `complianceLogBucketRegion`Optional + +```typescript +public readonly complianceLogBucketRegion: string; +``` + +- *Type:* string + +Region containing `complianceLogBucketName`. + +--- + ##### `deployment`Optional ```typescript @@ -3989,12 +4411,28 @@ const stageStackNameOptions: StageStackNameOptions = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | +| preserveStageCase | boolean | Preserve the stage segment's original casing. | | stage | string | The stage to fold into the name. | | stageFirst | boolean | Put the stage BEFORE the base (`-`) instead of after. | | uppercaseStage | boolean | Uppercase the stage segment. | --- +##### `preserveStageCase`Optional + +```typescript +public readonly preserveStageCase: boolean; +``` + +- *Type:* boolean + +Preserve the stage segment's original casing. + +Use this only to match an existing stack whose stage +id was custom-case; the backward-compatible default lowercases the segment. + +--- + ##### `stage`Optional ```typescript @@ -4058,7 +4496,8 @@ const supportResourcesProps: SupportResourcesProps = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | -| complianceLogBucketName | string | The name of the compliance/access-log bucket -- Blueprint's `IComplianceBucket.bucketName` (`ComplianceBucketProvider`). Required only if `complianceLogBucket` is read; an explicit, predictable name is what lets other buckets' S3 server-access-logging destination (and Blueprint's cross-region name-substitution convention for multi-region deployments) point at it. | +| complianceLogBucketName | string | The name of the compliance/access-log bucket -- Blueprint's `IComplianceBucket.bucketName` (`ComplianceBucketProvider`). Required only if `complianceLogBucket` is read; an explicit, predictable name is what lets same-account, same-Region application buckets point their S3 server-access logging at it without creating CloudFormation cross-stack references. | +| createComplianceLogBucket | boolean | Whether this construct creates and manages `complianceLogBucketName`. | | removalPolicy | aws-cdk-lib.RemovalPolicy | Removal policy for the support resources. | | useProxy | boolean | Whether an HTTP(S) proxy is configured (`ResolvedCicdConfig.proxy`). A managed VPC uses isolated subnets when true, matching Blueprint's `VPCProvider`. | | vpc | VpcConfig | VPC every CodeBuild project the pipeline creates runs in, if configured. | @@ -4073,7 +4512,26 @@ public readonly complianceLogBucketName: string; - *Type:* string -The name of the compliance/access-log bucket -- Blueprint's `IComplianceBucket.bucketName` (`ComplianceBucketProvider`). Required only if `complianceLogBucket` is read; an explicit, predictable name is what lets other buckets' S3 server-access-logging destination (and Blueprint's cross-region name-substitution convention for multi-region deployments) point at it. +The name of the compliance/access-log bucket -- Blueprint's `IComplianceBucket.bucketName` (`ComplianceBucketProvider`). Required only if `complianceLogBucket` is read; an explicit, predictable name is what lets same-account, same-Region application buckets point their S3 server-access logging at it without creating CloudFormation cross-stack references. + +--- + +##### `createComplianceLogBucket`Optional + +```typescript +public readonly createComplianceLogBucket: boolean; +``` + +- *Type:* boolean +- *Default:* true + +Whether this construct creates and manages `complianceLogBucketName`. + +Set to `false` to reference a pre-existing, owner-managed Blueprint compliance bucket. Imported +buckets synthesize no `AWS::S3::Bucket` or `AWS::S3::BucketPolicy`; the owner must maintain the +bucket's same-account/same-Region placement, SSE-S3 encryption, TLS enforcement, public-access +block, disabled Object Lock and Requester Pays settings, and S3 server-access-log delivery policy. +A name-only CDK import cannot inspect or validate those live settings. --- @@ -4136,6 +4594,7 @@ const synthesizerConfig: SynthesizerConfig = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | | type | SynthesizerType | The synthesizer to install. | +| appId | string | Application-unique id for `APP_STAGING` resources. | --- @@ -4151,6 +4610,21 @@ The synthesizer to install. --- +##### `appId`Optional + +```typescript +public readonly appId: string; +``` + +- *Type:* string + +Application-unique id for `APP_STAGING` resources. + +Defaults to `application`; the alpha +synthesizer normalizes it to a lowercase, dash-separated value of at most 20 characters. + +--- + ### VpcConfig VPC configuration for the pipeline's own CodeBuild projects (Blueprint `IVpcConfig`, migrated from `VPCProvider`). @@ -4270,7 +4744,10 @@ Undefined for a looked-up VPC (CodeBuild then selects private subnets). - *Implements:* aws-cdk-lib.IAspect -Configures S3 server access logging (destination + prefix) on every L1 `CfnBucket` it visits that does not already set a logging destination, matching Blueprint's default-on `AccessLogsForBucketPlugin`. +Configures S3 server access logging on every L1 `CfnBucket` it visits. + +The compliance destination +always wins; an existing user prefix is preserved, otherwise a bucket-specific prefix is generated. #### Initializers @@ -5490,14 +5967,14 @@ How the pushed image is tagged. | **Name** | **Description** | | --- | --- | -| GIT_SHA | Tag with the resolved source commit sha (CODEBUILD_RESOLVED_SOURCE_VERSION). | +| GIT_SHA | Tag with the resolved Git commit SHA, or a deterministic SHA-256 of a non-Git source revision. | | LATEST | Tag `latest` only. | --- ##### `GIT_SHA` -Tag with the resolved source commit sha (CODEBUILD_RESOLVED_SOURCE_VERSION). +Tag with the resolved Git commit SHA, or a deterministic SHA-256 of a non-Git source revision. The default. @@ -5637,5 +6114,9 @@ Which stack synthesizer the wrapper installs. `AppStagingSynthesizer` -- opt-in, still alpha. +Supported for direct/local application +deployments and Repo 1 image synthesis; wrapper-generated deployment pipelines reject it. +Its staging support stack cannot honor a configured deploy or CloudFormation execution role. + --- diff --git a/packages/@cdklabs/cdk-cicd-wrapper/README.md b/packages/@cdklabs/cdk-cicd-wrapper/README.md index d0eeaaba..dd5a96d2 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/README.md +++ b/packages/@cdklabs/cdk-cicd-wrapper/README.md @@ -15,6 +15,7 @@ The [CDK CI/CD Wrapper](https://cdklabs.github.io/cdk-cicd-wrapper/) is a compre - [Defining Stages](#defining-stages) - [Configuring Stacks](#configuring-stacks) - [Customizing CI/CD Steps](#customizing-cicd-steps) +- [Autopilot Deployment Contracts](#autopilot-deployment-contracts) - [Contributing](#contributing) - [License](#license) @@ -83,6 +84,16 @@ Configure the CDK stacks you want to deploy in each stage. The CDK CI/CD Wrapper Tailor the CI/CD pipeline to meet your project's specific requirements. The CDK CI/CD Wrapper provides built-in dependency injection, allowing you to customize the CI/CD steps seamlessly. +## Autopilot Deployment Contracts + +| Area | Contract | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `APP_STAGING` | Valid for direct/local `cdk deploy` (including local `cdk-cicd deploy --from-image`) and Repo 1 container-image synthesis. Wrapper-generated deployment pipelines—flat `CODEPIPELINE`, Repo 2, `CDK_PIPELINES`, and `GITHUB_ACTIONS`—reject it. On direct/local paths, custom deploy and CloudFormation execution roles govern application stacks; staging support resources use caller/base credentials. | +| Deploy-role `ExternalId` | `CDK_PIPELINES` and `GITHUB_ACTIONS` reject configured deploy-role ExternalIds rather than silently dropping them. `APP_STAGING` accepts custom deployment identities but rejects a deploy-role ExternalId because the alpha API does not expose one. | +| CloudFormation execution role | CodeBuild assumes the deployment role; that assumed role passes the configured CloudFormation execution role to CloudFormation. Grant `iam:PassRole` to the deployment role, not directly to the CodeBuild project role. | +| Custom CodeBuild ECR image | A private ECR environment image must be in the CodeBuild project's Region; flat CodePipeline and CDK Pipelines also require the pipeline account. Repo 2 supports its explicit cross-account image path only after the owner-side repository policy is configured and `crossAccountEcrRepositoryPolicyConfigured` is acknowledged; the build-image Region rule still applies. | +| GitHub manual approval | Configure required reviewers on each generated GitHub Environment, then set `githubActions.environmentProtectionConfigured: true`. Referencing an environment in workflow YAML does not configure its protection rules. | + ## Contributing Contributions to the CDK CI/CD Wrapper are welcome! If you'd like to contribute, please follow the guidelines outlined in the [CONTRIBUTING.md](CONTRIBUTING.md) file. diff --git a/packages/@cdklabs/cdk-cicd-wrapper/package.json b/packages/@cdklabs/cdk-cicd-wrapper/package.json index 492203ec..7943e8f3 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/package.json +++ b/packages/@cdklabs/cdk-cicd-wrapper/package.json @@ -73,6 +73,7 @@ "constructs": "^10.5.0" }, "dependencies": { + "@aws-cdk/app-staging-synthesizer-alpha": "2.195.0-alpha.0", "@cloudcomponents/cdk-pull-request-approval-rule": "^2.4.0", "@cloudcomponents/cdk-pull-request-check": "^2.4.0", "yaml": "^2.8.1" diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/app/DeploymentPipelineApp.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/app/DeploymentPipelineApp.ts index 44db4543..b473a084 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/app/DeploymentPipelineApp.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/app/DeploymentPipelineApp.ts @@ -6,7 +6,7 @@ // the CD pipeline needs no file in the user's config repo -- the same zero-touch shape as PipelineApp for // the CI side. It renders exactly one stack: the CD pipeline (see DeploymentPipeline). -import { App, Aspects, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { App, Aspects, DefaultStackSynthesizer, RemovalPolicy, Stack } from 'aws-cdk-lib'; import { AwsSolutionsChecks } from 'cdk-nag'; import { ResolvedDeploymentConfig } from '../config/types'; import { DeploymentPipeline } from '../engine/codepipeline/DeploymentPipeline'; @@ -40,7 +40,11 @@ export class DeploymentPipelineApp extends App { public readonly pipelineStack: Stack; public constructor(props: DeploymentPipelineAppProps) { - super(); + // Repo 2's pipeline stack belongs to the hub account. Its deployer image's config.qualifier must + // not leak into that stack, while an explicit CDK bootstrapQualifier context for the hub still must. + super({ + defaultStackSynthesizer: new DefaultStackSynthesizer(), + }); const name = pipelineName(props.config); this.pipelineStack = new Stack(this, name, { stackName: name, diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/app/PipelineApp.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/app/PipelineApp.ts index c284826e..95c023ef 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/app/PipelineApp.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/app/PipelineApp.ts @@ -13,7 +13,7 @@ // more than it looks, because cdk-nag's rules match resources with `instanceof` and go silently // inert across two copies (finding `qa-duplicate-aws-cdk-lib-makes-cdk-nag-inert`). -import { App, Aspects, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { App, Aspects, DefaultStackSynthesizer, RemovalPolicy, Stack } from 'aws-cdk-lib'; import { AwsSolutionsChecks } from 'cdk-nag'; import { EngineType, ResolvedCicdConfig } from '../config/types'; import { CodePipelineEngine } from '../engine/codepipeline/CodePipelineEngine'; @@ -27,7 +27,10 @@ function engineFor(config: ResolvedCicdConfig, disposable: boolean): IEngine { // an unknown value is reachable and worth naming rather than rendering a pipeline-less stack. switch (config.engine) { case EngineType.CODEPIPELINE: - return new CodePipelineEngine({ removalPolicy: disposable ? RemovalPolicy.DESTROY : undefined }); + return new CodePipelineEngine({ + buildImage: config.ci.image, + removalPolicy: disposable ? RemovalPolicy.DESTROY : undefined, + }); default: throw new Error(`cdk-cicd: unknown pipeline engine '${config.engine}' -- expected 'codepipeline'`); } @@ -57,9 +60,14 @@ export class PipelineApp extends App { public readonly pipelineStack: Stack; public constructor(props: PipelineAppProps) { - super(); - const config = props.config; + // The engine-owned pipeline stack is infrastructure in the hub account, not an application stage. + // Do not apply config.qualifier here. Leaving the synthesizer qualifier unset still lets CDK honor + // the hub app's standard @aws-cdk/core:bootstrapQualifier context contract. + super({ + defaultStackSynthesizer: new DefaultStackSynthesizer(), + }); + const name = `${config.application ?? DEFAULT_APPLICATION}-pipeline`; this.pipelineStack = new Stack(this, name, { diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/config/build-image.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/config/build-image.ts index 0d7fd7b4..fada3a4d 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/config/build-image.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/config/build-image.ts @@ -7,9 +7,71 @@ // npm deps (installed in the image), NOT `cdk.out`, so Repo 2 can synth-and-deploy it offline against any // target's config. See docs/design/v3-devops-experience.md (Level 2, two-repository split). +/** + * Validate the shared `ci.image` string contract before an engine classifies the image. + * + * Registry credentials must never be embedded in the image string: CodeBuild receives them through + * Secrets Manager, while GitHub Actions receives secret expressions in `container.credentials`. + */ +export function assertValidCiImageReference(image: string): void { + const invalidReference = (): never => { + throw new Error( + 'cdk-cicd: ci.image must be a non-empty Docker/OCI image reference without a URL scheme or whitespace.', + ); + }; + + if (image.trim() !== image || image.length === 0 || /\s/.test(image) || /^[a-z][a-z0-9+.-]*:\/\//i.test(image)) { + invalidReference(); + } + + const firstAt = image.indexOf('@'); + if (firstAt >= 0) { + const digest = image.slice(firstAt + 1); + const isDigestReference = + firstAt > 0 && + image.indexOf('@', firstAt + 1) < 0 && + /^[A-Za-z][A-Za-z0-9]*(?:[+._-][A-Za-z0-9]+)*:[A-Za-z0-9=_-]+$/.test(digest); + if (!isDigestReference) { + throw new Error( + 'cdk-cicd: ci.image must not embed registry credentials, and digest references must use ' + + "`@:`; use the engine's explicit build-registry credentials configuration.", + ); + } + } + + const imageName = firstAt >= 0 ? image.slice(0, firstAt) : image; + if (imageName.startsWith('/') || imageName.endsWith('/') || imageName.includes('//')) { + invalidReference(); + } + const finalSlash = imageName.lastIndexOf('/'); + const tagSeparator = imageName.lastIndexOf(':'); + if (tagSeparator > finalSlash && tagSeparator === imageName.length - 1) { + invalidReference(); + } +} + +/** Return the lower-cased registry hostname without changing the repository/tag/digest portion. */ +export function ciImageRegistryHost(image: string): string | undefined { + const firstSlash = image.indexOf('/'); + return firstSlash > 0 ? image.slice(0, firstSlash).toLowerCase() : undefined; +} + +/** Whether a normalized registry hostname belongs to, or is shaped like, a private ECR endpoint. */ +export function isPrivateEcrRegistryHost(registryHost: string | undefined): boolean { + return registryHost !== undefined && /\.dkr(?:\.ecr(?:-fips)?|-ecr(?:-fips)?)\./.test(registryHost); +} + +/** Whether a normalized registry hostname is the Amazon ECR Public registry. */ +export function isPublicEcrRegistryHost(registryHost: string | undefined): boolean { + return registryHost === 'public.ecr.aws'; +} + /** How the pushed image is tagged. */ export enum ImageTagStrategy { - /** Tag with the resolved source commit sha (CODEBUILD_RESOLVED_SOURCE_VERSION). The default. */ + /** + * Tag with the resolved Git commit SHA, or a deterministic SHA-256 of a non-Git source revision. + * The default. + */ GIT_SHA = 'git_sha', /** Tag `latest` only. Simplest, but not immutable -- prefer GIT_SHA for real pipelines. */ LATEST = 'latest', diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/config/default-synthesizer-role-arn.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/config/default-synthesizer-role-arn.ts new file mode 100644 index 00000000..a5b70387 --- /dev/null +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/config/default-synthesizer-role-arn.ts @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BOOTSTRAP_QUALIFIER_CONTEXT, DefaultStackSynthesizer } from 'aws-cdk-lib'; +import { IConstruct } from 'constructs'; + +const BOOTSTRAP_QUALIFIER_PATTERN = /^[A-Za-z0-9_-]{1,10}$/; + +/** + * Normalize an explicitly configured CDK bootstrap qualifier. + * + * Authoring inputs may contain surrounding whitespace, but the resolved configuration, runtime + * synthesizer, and pipeline IAM must all use the same trimmed, validated value. + */ +export function normalizeDefaultSynthesizerQualifier(qualifier: string): string { + const normalized = qualifier.trim(); + if (!BOOTSTRAP_QUALIFIER_PATTERN.test(normalized)) { + throw new Error( + 'cdk-cicd: explicit bootstrap qualifier must match the CDK contract `[A-Za-z0-9_-]{1,10}` ' + + 'after trimming surrounding whitespace.', + ); + } + return normalized; +} + +/** Target values used to specialize a DefaultStackSynthesizer role ARN. */ +export interface DefaultSynthesizerRoleArnOptions { + /** Bootstrap qualifier. Defaults to the CDK default qualifier (`hnb659fds`). */ + readonly qualifier?: string; + /** Concrete target AWS account. */ + readonly account: string; + /** Concrete target AWS Region. */ + readonly region: string; + /** + * Concrete target AWS partition. + * + * When omitted, `${AWS::Partition}` remains intact to match the role ARN emitted in a cloud assembly. + */ + readonly partition?: string; +} + +/** + * Specialize the literal placeholders accepted by CDK's DefaultStackSynthesizer role properties. + * + * CDK specializes qualifier, account, and Region while leaving the partition as + * `${AWS::Partition}` in the cloud assembly. Callers rendering an IAM policy can additionally supply + * a concrete partition. + */ +export function specializeDefaultSynthesizerRoleArn( + roleArn: string, + options: DefaultSynthesizerRoleArnOptions, +): string { + const qualifier = + options.qualifier === undefined + ? DefaultStackSynthesizer.DEFAULT_QUALIFIER + : normalizeDefaultSynthesizerQualifier(options.qualifier); + const replacements: Array = [ + ['${Qualifier}', qualifier], + ['${AWS::AccountId}', options.account], + ['${AWS::Region}', options.region], + ]; + if (options.partition !== undefined) { + replacements.push(['${AWS::Partition}', options.partition]); + } + + return replacements.reduce( + (specialized, [placeholder, value]) => specialized.split(placeholder).join(value), + roleArn, + ); +} + +/** + * Resolve the qualifier the DefaultStackSynthesizer bound below `scope` will use. + * + * An omitted property does not always mean `hnb659fds`: CDK first consults its bootstrap-qualifier + * context key. Pipeline IAM must follow the same precedence as the application synthesizer. + */ +export function resolveDefaultSynthesizerQualifier(scope: IConstruct, configuredQualifier?: string): string { + if (configuredQualifier !== undefined) return normalizeDefaultSynthesizerQualifier(configuredQualifier); + + const contextQualifier = scope.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT); + if (contextQualifier === undefined) return DefaultStackSynthesizer.DEFAULT_QUALIFIER; + if (typeof contextQualifier !== 'string' || !BOOTSTRAP_QUALIFIER_PATTERN.test(contextQualifier)) { + throw new Error( + `cdk-cicd: context '${BOOTSTRAP_QUALIFIER_CONTEXT}' must match the CDK bootstrap qualifier ` + + 'contract `[A-Za-z0-9_-]{1,10}`.', + ); + } + return contextQualifier; +} diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/config/define.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/config/define.ts index e4a86353..bf87a375 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/config/define.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/config/define.ts @@ -14,10 +14,12 @@ import { aws_codebuild as codebuild } from 'aws-cdk-lib'; import { BuildImage } from './build-image'; +import { normalizeDefaultSynthesizerQualifier } from './default-synthesizer-role-arn'; import { Repository } from './repository'; import { CiConfig, CodeArtifactConfig, + CodeBuildImageCredentials, CodePipelineRoleNames, DeployModel, DeploymentConfig, @@ -61,6 +63,8 @@ export interface CiConfigInput { readonly steps?: { [key: string]: string }; readonly synthStages?: string[] | 'all'; readonly image?: string; + /** Secrets Manager credentials for an authenticated external-registry CodeBuild image. */ + readonly codeBuildImageCredentials?: CodeBuildImageCredentials; /** Escape hatch: a CodeBuild spec fragment merged into the CI build project. See `CiConfig.partialBuildSpec`. */ readonly partialBuildSpec?: codebuild.BuildSpec; } @@ -68,6 +72,7 @@ export interface CiConfigInput { /** Proxy config as written: `noProxy`/`proxyTestUrl` are optional, defaulted by `normalizeProxy`. */ export interface ProxyConfigInput { readonly proxySecretArn: string; + readonly encryptionKeyArn?: string; readonly noProxy?: string[]; readonly proxyTestUrl?: string; } @@ -75,6 +80,7 @@ export interface ProxyConfigInput { /** What a user passes to `defineCICD`. Deliberately permissive; normalized to `ResolvedCicdConfig`. */ export interface CicdConfigProps { readonly application?: string; + /** Explicit bootstrap qualifier. Surrounding whitespace is trimmed; the result must match `[A-Za-z0-9_-]{1,10}`. */ readonly qualifier?: string; /** * CloudFormation stack name for the engine-owned self-mutating pipeline stack. See @@ -85,7 +91,12 @@ export interface CicdConfigProps { readonly repository: Repository; /** Each stage is either a bare name (`'dev'`) or a full object. */ readonly stages: Array; - readonly synthesizer?: { readonly type?: SynthesizerType }; + /** + * Application synthesizer. `APP_STAGING` remains available for direct/local deployment and Repo 1 + * image synthesis, but wrapper-generated deployment pipelines require `DEFAULT`. Direct use supports + * custom deployment and CloudFormation execution roles, but not a deploy-role ExternalId. + */ + readonly synthesizer?: { readonly type?: SynthesizerType; readonly appId?: string }; readonly engine?: EngineType; /** GitHub Actions engine configuration. Only read when `engine` is `EngineType.GITHUB_ACTIONS`. */ readonly githubActions?: GitHubActionsConfig; @@ -111,6 +122,13 @@ export interface CicdConfigProps { readonly vpc?: VpcConfig; /** Compliance/access-log destination bucket name. See `ResolvedCicdConfig.complianceLogBucketName`. */ readonly complianceLogBucketName?: string; + /** + * Create and manage `complianceLogBucketName`. Set to `false` to reference a pre-existing, + * owner-managed Blueprint compliance bucket. + * + * @default true + */ + readonly createComplianceLogBucket?: boolean; /** * CodeBuild environment overrides (privileged mode, compute type, environment variables) applied to * every CodeBuild project. See `ResolvedCicdConfig.codeBuildEnvSettings`. @@ -145,10 +163,14 @@ export interface CicdConfigProps { * only the first stage, contradicting the field's own doc. */ function normalizeCi(ci: CiConfigInput | undefined, stageNames: string[]): CiConfig { + if (ci?.codeBuildImageCredentials !== undefined && ci.image === undefined) { + throw new Error('cdk-cicd: ci.codeBuildImageCredentials requires ci.image.'); + } return { steps: ci?.steps ?? {}, synthStages: ci?.synthStages === undefined ? [] : ci.synthStages === 'all' ? [...stageNames] : ci.synthStages, image: ci?.image, + codeBuildImageCredentials: ci?.codeBuildImageCredentials, partialBuildSpec: ci?.partialBuildSpec, }; } @@ -158,6 +180,7 @@ function normalizeProxy(proxy: ProxyConfigInput | undefined): ProxyConfig | unde if (proxy === undefined) return undefined; return { proxySecretArn: proxy.proxySecretArn, + encryptionKeyArn: proxy.encryptionKeyArn, noProxy: proxy.noProxy ?? [], proxyTestUrl: proxy.proxyTestUrl ?? 'https://aws.amazon.com', }; @@ -197,8 +220,34 @@ function normalizeStage(stage: string | StageInput): ResolvedStage { export function resolveCicdConfig(props: CicdConfigProps): ResolvedCicdConfig { const application = props.application; const stages = props.stages.map(normalizeStage); - const qualifier = props.qualifier ?? (application !== undefined ? deriveQualifier(application) : undefined); + const synthesizerType = props.synthesizer?.type ?? SynthesizerType.DEFAULT; + const engine = props.engine ?? EngineType.CODEPIPELINE; + const qualifier = + props.qualifier !== undefined + ? normalizeDefaultSynthesizerQualifier(props.qualifier) + : application !== undefined + ? deriveQualifier(application) + : undefined; const warmAccountsFromSsm = props.warmAccountsFromSsm ?? false; + const createComplianceLogBucket = props.createComplianceLogBucket ?? true; + if (!createComplianceLogBucket && !props.complianceLogBucketName?.trim()) { + throw new Error( + 'cdk-cicd: createComplianceLogBucket: false requires complianceLogBucketName for the existing bucket.', + ); + } + if (synthesizerType === SynthesizerType.APP_STAGING) { + const stageWithExternalId = stages.find((stage) => { + const deployRole = stage.deployment?.deployRole?.trim(); + const externalId = (stage.deployment?.externalId ?? props.deployRoleExternalId)?.trim(); + return deployRole !== undefined && deployRole.length > 0 && externalId !== undefined && externalId.length > 0; + }); + if (stageWithExternalId !== undefined) { + throw new Error( + `cdk-cicd: SynthesizerType.APP_STAGING cannot use a deploy-role ExternalId ` + + `(stage '${stageWithExternalId.name}'). The installed alpha deployment identities do not expose it.`, + ); + } + } // Account warming scans SSM under the qualifier and grants ssm:GetParametersByPath on // `parameter//*`. Without a resolvable qualifier the grant could only widen to // `parameter/*/*` (every parameter in the account) -- so require a qualifier rather than emit an @@ -215,8 +264,11 @@ export function resolveCicdConfig(props: CicdConfigProps): ResolvedCicdConfig { pipelineStackName: props.pipelineStackName, repository: props.repository, stages, - synthesizer: { type: props.synthesizer?.type ?? SynthesizerType.DEFAULT }, - engine: props.engine ?? EngineType.CODEPIPELINE, + synthesizer: { + type: synthesizerType, + appId: props.synthesizer?.appId, + }, + engine, githubActions: props.githubActions, pipelineRoleNames: props.pipelineRoleNames, codePipelineRoleNames: props.codePipelineRoleNames, @@ -231,6 +283,7 @@ export function resolveCicdConfig(props: CicdConfigProps): ResolvedCicdConfig { warmAccountsFromSsm, vpc: props.vpc, complianceLogBucketName: props.complianceLogBucketName, + createComplianceLogBucket, codeBuildEnvSettings: props.codeBuildEnvSettings, deployModel: props.deployModel ?? DeployModel.ASSEMBLY_PROMOTION, asyncDeploy: props.asyncDeploy ?? false, @@ -261,12 +314,36 @@ export interface DeploymentTargetInput { readonly deployment?: DeploymentConfig; /** This target's deployer image (tag/digest), overriding the top-level `image` -- the per-stage version. */ readonly image?: string; + /** + * Existing compliance/access-log destination bucket for this target, overriding the deployment-wide + * default. The target must declare a concrete account and exactly one concrete Region. + */ + readonly complianceLogBucketName?: string; } /** What a user passes to `defineDeployment` (Repo 2). Deliberately permissive; normalized to resolved structs. */ export interface DeploymentProps { + /** Application name used by the deployer image. */ + readonly application?: string; + /** + * Bootstrap qualifier used by the deployer image; derived from `application` when omitted. + * Surrounding whitespace is trimmed; the result must match `[A-Za-z0-9_-]{1,10}`. + */ + readonly qualifier?: string; + /** + * Synthesizer used by the deployer image; must match its `cicd.config`. `APP_STAGING` is supported + * by direct `deploy --from-image`, not by the generated Repo 2 CodePipeline. Direct use supports + * custom deployment and CloudFormation execution roles, but not a deploy-role ExternalId. + */ + readonly synthesizer?: { readonly type?: SynthesizerType; readonly appId?: string }; /** Default deployer image (an ECR/OCI reference, tag or digest); optional if every target pins its own `image`. */ readonly image?: string; + /** + * Existing compliance/access-log destination bucket name used by targets that do not override it. + * Repo 2 does not create this bucket. Every target using it must be in the bucket's same account and + * Region, represented by a concrete account and exactly one concrete Region on that target. + */ + readonly complianceLogBucketName?: string; /** The targets to run the image against, in order. */ readonly targets: DeploymentTargetInput[]; /** @@ -279,11 +356,58 @@ export interface DeploymentProps { readonly codeArtifact?: CodeArtifactConfig; /** Generic private npm registry the CD build authenticates against before `npm ci`. */ readonly npmRegistry?: NpmRegistryConfig; + /** + * Confirms that every cross-account ECR repository referenced by `image` or a target override has + * an owner-side repository policy granting the generated Repo 2 CodeBuild role pull access. + */ + readonly crossAccountEcrRepositoryPolicyConfigured?: boolean; +} + +const AWS_ACCOUNT_ID = /^\d{12}$/; +const AWS_REGION = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+-\d+$/; +const S3_BUCKET_NAME = /^(?!\d{1,3}(?:\.\d{1,3}){3}$)(?!.*\.\.)[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])$/; + +function normalizeComplianceBucketName(value: string | undefined, context: string): string | undefined { + if (value === undefined) return undefined; + const name = value.trim(); + if (name !== value || !S3_BUCKET_NAME.test(name)) { + throw new Error( + `cdk-cicd: ${context} complianceLogBucketName must be a valid 3-63 character S3 bucket name ` + + 'with no surrounding whitespace.', + ); + } + return name; } -function normalizeTarget(target: DeploymentTargetInput): ResolvedDeploymentTarget { +function normalizeTarget( + target: DeploymentTargetInput, + defaultComplianceLogBucketName?: string, +): ResolvedDeploymentTarget { const env: StageEnvInput = target.env ?? {}; const regions = env.regions ?? (env.region !== undefined ? [env.region] : []); + const complianceLogBucketName = normalizeComplianceBucketName( + target.complianceLogBucketName ?? defaultComplianceLogBucketName, + `target '${target.stage}'`, + ); + + let complianceLogBucketAccount: string | undefined; + let complianceLogBucketRegion: string | undefined; + if (complianceLogBucketName !== undefined) { + if (env.account === undefined || !AWS_ACCOUNT_ID.test(env.account)) { + throw new Error( + `cdk-cicd: target '${target.stage}' compliance logging requires a concrete 12-digit env.account.`, + ); + } + if (regions.length !== 1 || !AWS_REGION.test(regions[0])) { + throw new Error( + `cdk-cicd: target '${target.stage}' compliance logging requires exactly one concrete AWS Region; ` + + 'S3 server access logs cannot cross Regions.', + ); + } + complianceLogBucketAccount = env.account; + complianceLogBucketRegion = regions[0]; + } + return { stage: target.stage, env: { @@ -295,6 +419,9 @@ function normalizeTarget(target: DeploymentTargetInput): ResolvedDeploymentTarge manualApproval: target.manualApproval ?? !AUTO_APPROVE_STAGES.has(target.stage), deployment: target.deployment, image: target.image, + complianceLogBucketName, + complianceLogBucketAccount, + complianceLogBucketRegion, }; } @@ -316,11 +443,54 @@ function normalizeTarget(target: DeploymentTargetInput): ResolvedDeploymentTarge * `ResolvedDeploymentConfig` is jsii-modeled. */ export function defineDeployment(props: DeploymentProps): ResolvedDeploymentConfig { + const synthesizerType = props.synthesizer?.type ?? SynthesizerType.DEFAULT; + const qualifier = + props.qualifier !== undefined + ? normalizeDefaultSynthesizerQualifier(props.qualifier) + : props.application !== undefined + ? deriveQualifier(props.application) + : undefined; + const complianceLogBucketName = normalizeComplianceBucketName(props.complianceLogBucketName, 'deployment'); + if (synthesizerType === SynthesizerType.APP_STAGING) { + const targetWithExternalId = props.targets.find((target) => { + const deployRole = target.deployment?.deployRole?.trim(); + const externalId = target.deployment?.externalId?.trim(); + return deployRole !== undefined && deployRole.length > 0 && externalId !== undefined && externalId.length > 0; + }); + if (targetWithExternalId !== undefined) { + throw new Error( + `cdk-cicd: SynthesizerType.APP_STAGING cannot use a deploy-role ExternalId ` + + `(target '${targetWithExternalId.stage}'). The installed alpha deployment identities do not expose it.`, + ); + } + } + const targets = props.targets.map((target) => normalizeTarget(target, complianceLogBucketName)); + const bucketCoordinates = new Map(); + for (const target of targets) { + if (target.complianceLogBucketName === undefined) continue; + const coordinates = `${target.complianceLogBucketAccount}/${target.complianceLogBucketRegion}`; + const previous = bucketCoordinates.get(target.complianceLogBucketName); + if (previous !== undefined && previous !== coordinates) { + throw new Error( + `cdk-cicd: compliance bucket '${target.complianceLogBucketName}' is assigned to both ${previous} ` + + `and ${coordinates}. An S3 bucket has one account and Region; use distinct bucket names.`, + ); + } + bucketCoordinates.set(target.complianceLogBucketName, coordinates); + } return { + application: props.application, + qualifier, + synthesizer: { + type: synthesizerType, + appId: props.synthesizer?.appId, + }, image: props.image, - targets: props.targets.map(normalizeTarget), + complianceLogBucketName, + targets, repository: props.repository, codeArtifact: props.codeArtifact, npmRegistry: props.npmRegistry, + crossAccountEcrRepositoryPolicyConfigured: props.crossAccountEcrRepositoryPolicyConfigured, }; } diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/config/naming.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/config/naming.ts index c58d0560..de07237d 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/config/naming.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/config/naming.ts @@ -13,9 +13,9 @@ // NAME is sufficient for a clean in-place update. // // `stageStackName` gives that control from `bin/`: a stage-qualified name for new projects, and the -// options to reproduce Blueprint's name. `stageFirst` puts the stage first (as Blueprint did); `uppercaseStage` matches -// Blueprint's DEFAULT uppercase stage ids -- if your Blueprint stages were lowercase/custom-case, pass the stage -// verbatim instead (default casing) so the name matches EXACTLY. It is a TS-authoring helper (a free +// options to reproduce Blueprint's name. `stageFirst` puts the stage first (as Blueprint did); +// `uppercaseStage` matches Blueprint's DEFAULT uppercase stage ids, while `preserveStageCase` handles +// a custom-case Blueprint stage without changing the established lowercase default. It is a TS-authoring helper (a free // function, invisible to jsii, like `defineCICD`); importing it in `bin/` is the opt-in, not the default. /** Options for {@link stageStackName}. */ @@ -35,6 +35,11 @@ export interface StageStackNameOptions { * not uppercase. */ readonly uppercaseStage?: boolean; + /** + * Preserve the stage segment's original casing. Use this only to match an existing stack whose stage + * id was custom-case; the backward-compatible default lowercases the segment. + */ + readonly preserveStageCase?: boolean; } /** @@ -45,8 +50,8 @@ export interface StageStackNameOptions { * Migrating from Blueprint without recreating resources: Blueprint prefixed the stack name with the stage id verbatim, * so with its default (uppercase) stages `stageStackName('myapp', { stageFirst: true, uppercaseStage: * true })` -> `DEV-myapp`, exactly what Blueprint deployed, and CloudFormation UPDATES it in place. If your Blueprint - * stages were lowercase/custom-case, drop `uppercaseStage` (or pass an explicit `stage`) so the casing - * matches. Always confirm with `cdk-cicd synth --stage ` + `cdk diff` before switching the pipeline. + * stages were custom-case, set `preserveStageCase: true` so the casing matches. Always confirm with + * `cdk-cicd synth --stage ` + `cdk diff` before switching the pipeline. * * TS-authoring only (a free function; jsii does not model it) -- import it in `bin/` as the opt-in. */ @@ -56,8 +61,8 @@ export function stageStackName(base: string, options: StageStackNameOptions = {} return base; } // Always `-`: CloudFormation stack names allow only [A-Za-z][A-Za-z0-9-]*, so any other separator would - // produce an invalid name. Default casing is lowercase; `uppercaseStage` opts into upper for Blueprint-default - // stage ids. - const seg = options.uppercaseStage ? stage.toUpperCase() : stage.toLowerCase(); + // produce an invalid name. Keep the established lowercase default; `uppercaseStage` is the explicit + // compatibility helper for Blueprint's built-in uppercase stage ids. + const seg = options.uppercaseStage ? stage.toUpperCase() : options.preserveStageCase ? stage : stage.toLowerCase(); return options.stageFirst ? `${seg}-${base}` : `${base}-${seg}`; } diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/config/types.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/config/types.ts index a77affb5..43c045f8 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/config/types.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/config/types.ts @@ -36,7 +36,11 @@ export enum RegionOrder { export enum SynthesizerType { /** `DefaultStackSynthesizer` -- the Autopilot default. */ DEFAULT = 'default', - /** `AppStagingSynthesizer` -- opt-in, still alpha. */ + /** + * `AppStagingSynthesizer` -- opt-in, still alpha. Supported for direct/local application + * deployments and Repo 1 image synthesis; wrapper-generated deployment pipelines reject it. + * Its staging support stack cannot honor a configured deploy or CloudFormation execution role. + */ APP_STAGING = 'app_staging', } @@ -62,6 +66,14 @@ export enum EngineType { GITHUB_ACTIONS = 'github-actions', } +/** GitHub secret names used to authenticate the Build-Synth job container to an external registry. */ +export interface GitHubBuildContainerCredentials { + /** GitHub Actions secret containing the registry username. */ + readonly usernameSecretName: string; + /** GitHub Actions secret containing the registry password or access token. */ + readonly passwordSecretName: string; +} + /** * GitHub Actions engine configuration: the OIDC role the workflow assumes plus the workflow file's own * identity (Blueprint `GitHubPipelinePluginOptions`, migrated). Only read when `engine` is `GITHUB_ACTIONS`; @@ -92,9 +104,27 @@ export interface GitHubActionsConfig { readonly workflowTriggers?: WorkflowTriggers; /** * Region the workflow assumes the OIDC role in when publishing assets (NOT the region assets publish - * to). @default "us-west-2" + * to). @default - the concrete pipeline stack region */ readonly publishAssetsAuthRegion?: string; + /** + * GitHub Actions secret names used as the Build-Synth container's registry credentials. + * + * Only external-registry `ci.image` values support this setting. Values are rendered as + * `${{ secrets.NAME }}` expressions; literal credentials are never accepted. + */ + readonly buildContainerCredentials?: GitHubBuildContainerCredentials; + /** + * Confirms that every generated GitHub Environment used by a stage with `manualApproval: true` + * has a required-reviewer protection rule configured in GitHub. + * + * The workflow file can name an environment, but GitHub does not let CDK configure that + * environment's protection rules. The engine therefore fails closed for approval-gated stages + * unless this acknowledgement is explicitly set. + * + * @default false + */ + readonly environmentProtectionConfigured?: boolean; } /** @@ -117,6 +147,14 @@ export enum DeployModel { DEPLOY_TIME_SYNTH = 'deploy-time-synth', } +/** Secrets Manager credentials used by CodeBuild to pull an authenticated external-registry image. */ +export interface CodeBuildImageCredentials { + /** Complete ARN of the Secrets Manager secret containing the registry username and password. */ + readonly secretArn: string; + /** Customer-managed KMS key encrypting `secretArn`, when one is used. */ + readonly encryptionKeyArn?: string; +} + /** Resolved CI configuration: the checks/build steps and which stages CI synthesizes for validation. */ export interface CiConfig { /** @@ -132,6 +170,13 @@ export interface CiConfig { readonly synthStages: string[]; /** Optional CodeBuild image override. */ readonly image?: string; + /** + * Secrets Manager credentials for an authenticated external-registry `image`. + * + * Supported by the CodeBuild engines only. Managed CodeBuild images and private ECR images use + * their own credential models and reject this setting. + */ + readonly codeBuildImageCredentials?: CodeBuildImageCredentials; /** * Escape hatch (Blueprint `CDKPipelineProps.ciBuildSpec`, migrated): deep-merged into the CI build project's * generated buildspec via `codebuild.mergeBuildSpecs`, augmenting rather than replacing the engine's @@ -172,6 +217,8 @@ export interface ProxyConfig { * `password`, `http_proxy_port`, `https_proxy_port` and `proxy_domain`. */ readonly proxySecretArn: string; + /** Customer-managed KMS key encrypting `proxySecretArn`, when one is used. */ + readonly encryptionKeyArn?: string; /** * Hosts that bypass the proxy. Empty means the engine adds its own region's `amazonaws.com` * endpoint, so calls to AWS APIs (e.g. a private-registry `codeartifact login`) skip the proxy. @@ -184,14 +231,17 @@ export interface ProxyConfig { /** * A generic private npm registry the pipeline's builds authenticate against with a bearer token * (Blueprint `NPMRegistryConfig`, migrated). Unlike `CodeArtifactConfig` (an `aws codeartifact login`), this covers - * any npm-compatible registry: when set, every build project writes a `.npmrc` -- scoped to `scope` when - * given, otherwise overriding the default registry -- with an auth token read from Secrets Manager. + * any npm-compatible registry: jobs that install packages use a temporary npm config outside the source + * checkout -- scoped to `scope` when given, otherwise overriding the default registry -- with an auth + * token read from Secrets Manager. */ export interface NpmRegistryConfig { /** The registry URL, e.g. `https://npm.example.com/`. */ readonly url: string; /** ARN of the Secrets Manager secret holding the bearer token (the secret's plain `SecretString`). */ readonly basicAuthSecretArn: string; + /** Customer-managed KMS key encrypting `basicAuthSecretArn`, when one is used. */ + readonly encryptionKeyArn?: string; /** npm scope to bind to the registry, e.g. `cdklabs` for `@cdklabs/*`. Omit to override the default registry. */ readonly scope?: string; } @@ -260,7 +310,10 @@ export interface StageEnvironment { /** Forced deployer / CloudFormation-execution roles for a stage. */ export interface DeploymentConfig { - /** ARN the CLI assumes to deploy (passed as `cdk deploy --role-arn`). */ + /** + * ARN CDK assumes for deployment operations. It is written to the cloud assembly as the stack's + * deployment-role assumption; it is not CloudFormation's execution `RoleARN`. + */ readonly deployRole?: string; /** ARN CloudFormation assumes to execute the change set. */ readonly cfnExecutionRole?: string; @@ -269,7 +322,9 @@ export interface DeploymentConfig { * trust policy requires). Overrides the pipeline-level `ResolvedCicdConfig.deployRoleExternalId` for * this stage. A literal, or a `resolve:secretsmanager:` reference resolved at synth time (the * same `resolve:` convention `VpcConfig.vpcId` uses). Ignored when `deployRole` is unset -- an - * ExternalId only applies to a role assumption the wrapper actually performs. + * ExternalId only applies to a role assumption the wrapper actually performs. Secret references + * currently require the Secrets Manager AWS-managed encryption key; customer-managed KMS keys need + * an additional `kms:Decrypt` grant that this config shape cannot identify. */ readonly externalId?: string; } @@ -300,9 +355,11 @@ export interface CodePipelineRoleNames { /** `RoleName` forced on the CodePipeline pipeline role. */ readonly pipeline?: string; /** - * Prefix for the per-stage CodeBuild project roles: each stage's build role is named - * `-` (plus the CI/self-update projects, `-build` / - * `-selfupdate`). Omit to keep CDK-generated names. + * Prefix for every flat-engine CodeBuild project role. The suffix is the lower-cased construct id + * with a trailing `Project` removed: `BuildProject` -> `build`, `UpdatePipeline` -> + * `updatepipeline`, and `Deploy-dev` -> `deploy-dev`. + * + * Omit to keep CDK-generated names. */ readonly buildRolePrefix?: string; } @@ -323,11 +380,16 @@ export interface ResolvedStage { export interface SynthesizerConfig { /** The synthesizer to install. */ readonly type: SynthesizerType; + /** + * Application-unique id for `APP_STAGING` resources. Defaults to `application`; the alpha + * synthesizer normalizes it to a lowercase, dash-separated value of at most 20 characters. + */ + readonly appId?: string; } /** The fully resolved pipeline configuration `defineCICD` produces. */ export interface ResolvedCicdConfig { - /** Application name; drives the bootstrap qualifier and asset naming. */ + /** Application name; drives the derived bootstrap qualifier and asset naming. */ readonly application?: string; /** Bootstrap qualifier (≤10 chars), derived from `application` when not given. */ readonly qualifier?: string; @@ -367,7 +429,8 @@ export interface ResolvedCicdConfig { /** * Pipeline-level default ExternalId presented when assuming a stage's forced `deployRole`. A stage's * own `DeploymentConfig.externalId` overrides this. A literal or a `resolve:secretsmanager:` - * reference resolved at synth time. + * reference resolved at synth time. Secret references currently require the Secrets Manager + * AWS-managed encryption key; customer-managed KMS keys are not supported without an external grant. */ readonly deployRoleExternalId?: string; /** Resolved CI configuration. */ @@ -398,6 +461,16 @@ export interface ResolvedCicdConfig { * `SupportResources.complianceLogBucket`; see there for the bucket's shape. */ readonly complianceLogBucketName?: string; + /** + * Whether the pipeline stack creates and manages `complianceLogBucketName`. + * + * Set to `false` during a Blueprint migration to reference an existing, owner-managed bucket. + * The pipeline then creates neither the bucket nor its bucket policy; the bucket owner must keep + * the encryption, TLS-enforcement, and S3 server-access-log delivery policy in place. + * + * @default true + */ + readonly createComplianceLogBucket?: boolean; /** * CodeBuild environment overrides -- privileged mode, compute type, environment variables -- applied * to every CodeBuild project the pipeline creates (Blueprint `codeBuildEnvSettings`, migrated from @@ -465,6 +538,16 @@ export interface ResolvedDeploymentTarget { * or set `int`/`prod` to the same tag to promote. When unset, the target uses the config-level `image`. */ readonly image?: string; + /** + * Compliance/access-log destination bucket for this target, after applying the deployment-wide + * default. When set, `complianceLogBucketAccount` and `complianceLogBucketRegion` are also set and + * exactly match this target's concrete, single-Region environment. + */ + readonly complianceLogBucketName?: string; + /** Account containing `complianceLogBucketName`. */ + readonly complianceLogBucketAccount?: string; + /** Region containing `complianceLogBucketName`. */ + readonly complianceLogBucketRegion?: string; } /** @@ -473,12 +556,36 @@ export interface ResolvedDeploymentTarget { * `cdk-cicd deploy --from-image` runs the image per target, synthesizing and deploying in-container. */ export interface ResolvedDeploymentConfig { + /** + * Application name baked into the deployer image. Optional for the default synthesizer; required + * (or supply `synthesizer.appId`) when the image uses `APP_STAGING` for direct `deploy --from-image`. + * The generated Repo 2 CodePipeline itself supports only `DEFAULT`. Direct APP_STAGING targets can + * configure deployment roles, but cannot attach an ExternalId to the deployment role. + */ + readonly application?: string; + /** Bootstrap qualifier used by the deployer image; derived from `application` when omitted. */ + readonly qualifier?: string; + /** + * Synthesizer used by the deployer image; must match its `cicd.config`. + * + * Optional for compatibility with pre-synthesizer Repo 2 configs; consumers must treat omission + * as `SynthesizerType.DEFAULT`. + */ + readonly synthesizer?: SynthesizerConfig; /** * The default deployer image to run targets against (an ECR/OCI reference, tag or digest). A target's * own `image` overrides this, so per-stage versions live on the targets; this is the shared fallback. * Optional only because every target may pin its own `image` -- each target must resolve to one or the other. */ readonly image?: string; + /** + * Default compliance/access-log destination bucket name for targets that do not provide their own. + * + * Repo 2 references an existing bucket; it does not create one. A target using this default must + * have a concrete account and exactly one concrete Region so S3's same-account/same-Region delivery + * requirement can be verified. Targets in other environments may override the name individually. + */ + readonly complianceLogBucketName?: string; /** The deployment targets, in order. */ readonly targets: ResolvedDeploymentTarget[]; /** @@ -498,4 +605,11 @@ export interface ResolvedDeploymentConfig { * pipeline-config `npmRegistry`. */ readonly npmRegistry?: NpmRegistryConfig; + /** + * Confirms that owner-side repository policies permit the generated pipeline role to pull every + * cross-account ECR image referenced by this deployment config. + * + * @default false + */ + readonly crossAccountEcrRepositoryPolicyConfigured?: boolean; } diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/cdkpipelines/CdkPipelinesEngine.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/cdkpipelines/CdkPipelinesEngine.ts index 23ffdcc6..8865ff79 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/cdkpipelines/CdkPipelinesEngine.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/cdkpipelines/CdkPipelinesEngine.ts @@ -13,21 +13,43 @@ // builds the app's stacks for a given stage, exactly as Blueprint's `.addStack(...)` did. So it is used from an // explicit `bin/` (the documented opt-in path), not the `deploy-ci` zero-touch flow. -import { Arn, ArnFormat, AspectPriority, Aspects, Environment, Stack, Stage } from 'aws-cdk-lib'; +import { AspectPriority, Aspects, Environment, Stack, Stage, Token } from 'aws-cdk-lib'; import * as codebuild from 'aws-cdk-lib/aws-codebuild'; +import * as ecr from 'aws-cdk-lib/aws-ecr'; import * as iam from 'aws-cdk-lib/aws-iam'; +import * as kms from 'aws-cdk-lib/aws-kms'; import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as pipelines from 'aws-cdk-lib/pipelines'; +import { RegionInfo } from 'aws-cdk-lib/region-info'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; +import { + assertValidCiImageReference, + ciImageRegistryHost, + isPrivateEcrRegistryHost, + isPublicEcrRegistryHost, +} from '../../config/build-image'; +import { resolveDefaultSynthesizerQualifier } from '../../config/default-synthesizer-role-arn'; import { Repository, RepositorySourceType } from '../../config/repository'; -import { CodeArtifactConfig, PipelineRoleNames, ProxyConfig, ResolvedCicdConfig } from '../../config/types'; +import { + CodeArtifactConfig, + CodeBuildImageCredentials, + NpmRegistryConfig, + PipelineRoleNames, + ProxyConfig, + RegionOrder, + ResolvedCicdConfig, + SynthesizerType, +} from '../../config/types'; import { AccessLogsForBucketAspect } from '../../support/AccessLogsForBucketAspect'; import { SupportResources } from '../../support/SupportResources'; import { resolveVpcNetworking } from '../../support/Vpc'; import { defaultCiCommands } from '../ci-commands'; import { resolveCodeCommitRepository } from '../codepipeline/source'; +const PRIVATE_NPM_CONFIG_PATH = '/tmp/cdk-cicd-npmrc'; + /** Context passed to the stage factory for one deployment stage. */ export interface CdkPipelinesStageContext { /** The stage name from the config (e.g. `DEVFRA`). */ @@ -87,6 +109,116 @@ function sourceFor(scope: Construct, repository: Repository): pipelines.CodePipe } } +/** Fail closed until the installed CDK Pipelines deployment path can forward deploy-role ExternalIds. */ +function assertNoDeployRoleExternalIds(config: ResolvedCicdConfig): void { + const unsupportedStages = config.stages + .filter((stage) => { + const deployRole = stage.deployment?.deployRole?.trim(); + const externalId = (stage.deployment?.externalId ?? config.deployRoleExternalId)?.trim(); + return deployRole !== undefined && deployRole.length > 0 && externalId !== undefined && externalId.length > 0; + }) + .map((stage) => stage.name); + + if (unsupportedStages.length > 0) { + throw new Error( + `cdk-cicd: CDK_PIPELINES cannot honor deploy-role ExternalIds for stage(s): ` + + `${unsupportedStages.join(', ')}. Remove the ExternalId or use the CODEPIPELINE engine.`, + ); + } +} + +/** The installed alpha synthesizer explicitly excludes CDK Pipelines and replay would cross Stage boundaries. */ +function assertSupportedSynthesizer(config: ResolvedCicdConfig): void { + if ((config.synthesizer?.type ?? SynthesizerType.DEFAULT) === SynthesizerType.APP_STAGING) { + throw new Error( + 'cdk-cicd: CDK_PIPELINES cannot use SynthesizerType.APP_STAGING: the installed ' + + '@aws-cdk/app-staging-synthesizer-alpha does not support CDK Pipelines, and Stage replay would ' + + 'create an invalid cross-Stage dependency on DefaultStagingStack. Use SynthesizerType.DEFAULT ' + + 'for generated pipelines; APP_STAGING remains available for direct local CDK deployment.', + ); + } +} + +/** + * CDK Pipelines can deploy across accounts and Regions, but a single pipeline cannot cross AWS + * partitions. Resolve every participating Region through the installed RegionInfo table so unknown + * or mixed partitions fail before placeholder ARNs are stamped with the pipeline partition. + */ +function assertSingleKnownPartition(stack: Stack, config: ResolvedCicdConfig, engine: string): string { + const pipelinePartition = partitionForRegion(stack.region, `${engine} pipeline`); + + for (const stage of config.stages) { + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + for (const region of regions) { + const targetPartition = partitionForRegion(region, `${engine} stage '${stage.name}'`); + if (targetPartition !== pipelinePartition) { + throw new Error( + `cdk-cicd: ${engine} cannot mix AWS partitions: pipeline region '${stack.region}' is in ` + + `'${pipelinePartition}', but stage '${stage.name}' region '${region}' is in '${targetPartition}'.`, + ); + } + } + } + + if (config.codeArtifact?.region !== undefined) { + const codeArtifactPartition = partitionForRegion(config.codeArtifact.region, `${engine} CodeArtifact`); + if (codeArtifactPartition !== pipelinePartition) { + throw new Error( + `cdk-cicd: ${engine} cannot use CodeArtifact region '${config.codeArtifact.region}' in partition ` + + `'${codeArtifactPartition}' from pipeline partition '${pipelinePartition}'.`, + ); + } + } + + return pipelinePartition; +} + +function partitionForRegion(region: string, context: string): string { + if (region.length === 0 || Token.isUnresolved(region)) { + throw new Error(`cdk-cicd: ${context} requires a concrete Region so its AWS partition can be verified.`); + } + const partition = RegionInfo.get(region).partition; + if (partition === undefined) { + throw new Error( + `cdk-cicd: ${context} region '${region}' is not known to this aws-cdk-lib version. ` + + 'Upgrade the wrapper/CDK before using that Region.', + ); + } + return partition; +} + +/** + * This engine provisions one destination bucket with the pipeline stack. Fail closed instead of + * inventing per-Region bucket names: S3 server access logging requires each source and destination + * bucket to be in the same account and Region. + */ +function assertSupportedComplianceLogging(stack: Stack, config: ResolvedCicdConfig): void { + if (config.complianceLogBucketName === undefined) return; + if (Token.isUnresolved(stack.account) || Token.isUnresolved(stack.region)) { + throw new Error( + 'cdk-cicd: compliance logging requires a concrete pipeline stack account and region so the ' + + 'S3 same-account/same-region requirement can be verified.', + ); + } + + for (const stage of config.stages) { + const account = stage.env.account ?? stack.account; + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + if ( + Token.isUnresolved(account) || + account !== stack.account || + regions.some((stageRegion) => Token.isUnresolved(stageRegion) || stageRegion !== stack.region) + ) { + throw new Error( + `cdk-cicd: compliance logging cannot target stage '${stage.name}' from the pipeline bucket ` + + `'${config.complianceLogBucketName}' in ${stack.account}/${stack.region}. S3 server access-log ` + + 'source and destination buckets must be in the same account and region; configure only ' + + 'co-located stages or omit complianceLogBucketName.', + ); + } + } +} + /** * A CDK Pipelines pipeline rendered from an Autopilot config + a stage factory. Reproduces the Blueprint shape: * Source -> Synth (self-mutating) -> Assets -> one wave per stage (with a pre-approval when the stage is @@ -98,11 +230,24 @@ export class CdkPipelinesEngine extends Construct { constructor(scope: Construct, id: string, props: CdkPipelinesEngineProps) { super(scope, id); const config = props.config; + assertSupportedSynthesizer(config); + assertNoDeployRoleExternalIds(config); const name = props.pipelineName ?? `${config.application ?? 'cdk-cicd'}-pipeline`; - const region = Stack.of(this).region; + const stack = Stack.of(this); + const region = stack.region; + const partition = assertSingleKnownPartition(stack, config, 'CDK_PIPELINES'); + const privateNpm = config.npmRegistry !== undefined || config.codeArtifact !== undefined; + assertSupportedComplianceLogging(stack, config); + const complianceLogBucket = + config.complianceLogBucketName !== undefined + ? new SupportResources(this, 'Support', { + complianceLogBucketName: config.complianceLogBucketName, + createComplianceLogBucket: config.createComplianceLogBucket, + }).complianceLogBucket + : undefined; - // The Synth step: install (proxy exports, then CodeArtifact login for private/pre-release deps) - // then `npm run cdk synth`, run with `CDK_CICD_MODE=pipeline` on the step env (below) so + // The Synth step: install proxy/account-warming prerequisites, then configure private npm and run + // CI plus `npm run cdk synth`, with `CDK_CICD_MODE=pipeline` on the step env (below) so // `cdk.json`'s single `cdk-cicd exec` entry renders THIS pipeline -- so CDK Pipelines self-mutation, // which reruns this step and redeploys the pipeline stack from the fresh assembly, still sees itself. // Without the mode set, that same entry synthesizes only the application stacks. The proxy's exports @@ -111,6 +256,10 @@ export class CdkPipelinesEngine extends Construct { const installCommands = [ ...(config.proxy ? proxyInstallCommands(config.proxy) : []), ...(config.warmAccountsFromSsm ? ssmWarmingCommands(config.qualifier) : []), + ]; + const privateNpmCommands = [ + ...(privateNpm ? npmConfigSetupCommands() : []), + ...(config.npmRegistry ? npmRegistryLoginCommands(config.npmRegistry) : []), ...(config.codeArtifact ? [ `aws codeartifact login --tool npm --domain ${config.codeArtifact.domain} ` + @@ -121,6 +270,25 @@ export class CdkPipelinesEngine extends Construct { : []), ]; const ciSteps = Object.values(config.ci.steps); + const synthCommands = [ + ...privateNpmCommands, + ...(ciSteps.length > 0 ? ciSteps : defaultCiCommands()), + 'npm run cdk synth', + ]; + const synthPartialBuildSpec = mergeSynthPartialBuildSpec( + config.ci.partialBuildSpec, + config.proxy, + config.npmRegistry, + privateNpm, + ); + if (config.ci.image === undefined && config.ci.codeBuildImageCredentials !== undefined) { + throw new Error('cdk-cicd: ci.codeBuildImageCredentials requires ci.image.'); + } + const synthBuildImage = + config.ci.image !== undefined + ? resolveSynthBuildImage(this, config.ci.image, partition, config.ci.codeBuildImageCredentials) + : undefined; + const lookupStatements = targetLookupStatements(stack, config, partition); // Blueprint `VPCProvider`, applied by CDK Pipelines itself to EVERY CodeBuild project it creates (synth, // self-mutation, asset publishing) -- the uniform application Blueprint had. const vpcNetworking = resolveVpcNetworking(this, config.vpc, config.proxy !== undefined); @@ -150,40 +318,79 @@ export class CdkPipelinesEngine extends Construct { // synth`, which runs `cdk.json`'s single `cdk-cicd exec` entry. `CDK_CICD_MODE=pipeline` (below) // makes that entry render THIS pipeline, so CDK Pipelines self-mutation re-renders itself; a plain // `cdk synth` without the mode set renders only the application stacks. - commands: [...(ciSteps.length > 0 ? ciSteps : defaultCiCommands()), 'npm run cdk synth'], + commands: synthCommands, env: { // Render the pipeline (not the app stacks) from the single cdk.json entry during self-mutation. CDK_CICD_MODE: 'pipeline', ...(config.qualifier ? { CDK_QUALIFIER: config.qualifier } : {}), AWS_REGION: region, + ...(privateNpm ? { NPM_CONFIG_USERCONFIG: PRIVATE_NPM_CONFIG_PATH } : {}), ...(config.proxy ? proxyEnvVariables(Stack.of(this), config.proxy) : {}), }, - // The proxy credentials/ports live in Secrets Manager, not in plain env vars. - partialBuildSpec: config.proxy - ? codebuild.BuildSpec.fromObject({ env: { 'secrets-manager': proxySecretsManagerVars(config.proxy) } }) - : undefined, + // Only the Synth project receives ci.image. The pipeline-wide defaults continue to govern + // self-mutation and asset-publishing projects. + buildEnvironment: synthBuildImage !== undefined ? { buildImage: synthBuildImage } : undefined, + // Proxy credentials/ports and the npm bearer token are resolved by CodeBuild at container + // start. Merge them with the caller's CI partial buildspec instead of replacing either side. + partialBuildSpec: synthPartialBuildSpec, // Grant the synth build the CodeArtifact/proxy-secret read permissions its // `codeartifact login`/`export`s need (the CodeBuildStep role has only logs/artifacts by // default) -- else they fail AccessDenied. rolePolicyStatements: [ ...(config.codeArtifact ? codeArtifactReadStatements(Stack.of(this), config.codeArtifact) : []), - ...(config.proxy ? proxySecretReadStatements(Stack.of(this), config.proxy) : []), + ...(config.proxy ? proxySecretReadStatements(config.proxy) : []), + ...(config.npmRegistry + ? secretReadStatements(config.npmRegistry.basicAuthSecretArn, config.npmRegistry.encryptionKeyArn) + : []), + ...codeBuildImageCredentialKeyDecryptStatements(config.ci.codeBuildImageCredentials), ...(config.warmAccountsFromSsm ? ssmWarmingReadStatements(Stack.of(this), config.qualifier) : []), + ...lookupStatements, ], }), }); - // One wave per (stage x region), in config order, wrapping the app stacks the provider builds. A gated - // stage gets a manual-approval step ahead of its FIRST region -- the fail-closed promotion gate Blueprint had. - // A multi-region stage becomes one wave per region (Blueprint deployed each region), not a single dropped one. + // Sequential multi-region stages retain one wave per region. Parallel stages put every regional + // deployment in one wave so CDK Pipelines schedules them together. A gated stage is approved once, + // before either the first sequential region or the shared parallel wave. for (const stage of config.stages) { const regions = stage.env.regions.length > 0 ? stage.env.regions : [region]; - regions.forEach((stageRegion, i) => { - const env: Environment = { account: stage.env.account, region: stageRegion }; + const appStageFor = (stageRegion: string): Stage => { + const env: Environment = { + account: + config.complianceLogBucketName !== undefined ? (stage.env.account ?? stack.account) : stage.env.account, + region: stageRegion, + }; const stageId = regions.length > 1 ? `${stage.name}-${stageRegion}` : stage.name; const appStage = new Stage(this, stageId, { env }); + if (config.complianceLogBucketName !== undefined && complianceLogBucket !== undefined) { + // CDK aspects do not cross Stage boundaries. Attach directly to every application Stage so + // its stacks receive logging, while the engine-level aspect below handles pipeline resources. + Aspects.of(appStage).add( + new AccessLogsForBucketAspect({ + complianceLogBucketName: config.complianceLogBucketName, + complianceLogBucketAccount: stack.account, + complianceLogBucketRegion: stack.region, + complianceLogBucket, + }), + { priority: AspectPriority.MUTATING }, + ); + } props.stages.stacks(appStage, { stageName: stage.name, env }); - this.pipeline.addStage(appStage, { + return appStage; + }; + + if (stage.env.regionOrder === RegionOrder.PARALLEL && regions.length > 1) { + const wave = this.pipeline.addWave(stage.name, { + pre: stage.manualApproval ? [new pipelines.ManualApprovalStep(`Approve-${stage.name}`)] : undefined, + }); + for (const stageRegion of regions) { + wave.addStage(appStageFor(stageRegion)); + } + continue; + } + + regions.forEach((stageRegion, i) => { + this.pipeline.addStage(appStageFor(stageRegion), { pre: stage.manualApproval && i === 0 ? [new pipelines.ManualApprovalStep(`Approve-${stage.name}`)] : undefined, }); @@ -203,23 +410,18 @@ export class CdkPipelinesEngine extends Construct { this.enforceRoleNames(config.pipelineRoleNames); } - // Compliance/access-log bucket, at parity with the flat CodePipelineEngine: provision it when a name - // is configured, and attach the per-region name-substituting access-logs aspect so secondary-region - // stacks log to the region-substituted bucket name (Blueprint's AccessLogsForBucketPlugin behavior). - if (config.complianceLogBucketName !== undefined) { - const support = new SupportResources(this, 'Support', { - complianceLogBucketName: config.complianceLogBucketName, - }); - // Force-read the lazy getter so the bucket is provisioned by merely configuring the name (Blueprint's - // ComplianceBucketProvider was default-on, not behind a separate opt-in). - void support.complianceLogBucket; - // MUTATING priority so the aspect sets each bucket's L1 loggingConfiguration BEFORE the readonly - // AwsSolutionsChecks (added in pipeline-assembler) visits -- otherwise AwsSolutions-S1 false-fails - // because nag sees the bucket before the logging config is applied. No S1 suppression is added. + // Attach separately to pipeline resources. Application stacks sit below cdk.Stage boundaries and + // receive their own aspect in appStageFor() above. + if (config.complianceLogBucketName !== undefined && complianceLogBucket !== undefined) { + // MUTATING priority so the aspect sets each source bucket's L1 loggingConfiguration BEFORE the + // readonly AwsSolutionsChecks visits. The destination bucket alone carries the required S1 + // suppression because S3 server access logs must not be delivered back into the same bucket. Aspects.of(this).add( new AccessLogsForBucketAspect({ complianceLogBucketName: config.complianceLogBucketName, - mainRegion: region, + complianceLogBucketAccount: stack.account, + complianceLogBucketRegion: stack.region, + complianceLogBucket, }), { priority: AspectPriority.MUTATING }, ); @@ -311,6 +513,241 @@ export class CdkPipelinesEngine extends Construct { } } +interface ParsedEcrImage { + readonly account: string; + readonly region: string; + readonly partition: string; + readonly repositoryName: string; + readonly tagOrDigest?: string; +} + +/** + * Resolve the string convenience config to the CDK image type CodeBuild expects: + * managed CodeBuild IDs use CODEBUILD credentials, private ECR images carry a repository object so + * CDK grants the Synth role pull access, and all other registries are treated as anonymous/public + * Docker registries, optionally with a Secrets Manager credential that CDK grants to the Synth role. + */ +function resolveSynthBuildImage( + scope: Construct, + image: string, + pipelinePartition: string, + credentials?: CodeBuildImageCredentials, +): codebuild.IBuildImage { + assertValidCiImageReference(image); + if (image.startsWith('aws/codebuild/')) { + assertNoCodeBuildRegistryCredentials(image, credentials, 'managed CodeBuild'); + return codebuild.LinuxBuildImage.fromCodeBuildImageId(image); + } + + if (isPublicEcrRegistryHost(ciImageRegistryHost(image))) { + assertNoCodeBuildRegistryCredentials(image, credentials, 'public ECR'); + } + + const parsedEcr = parsePrivateEcrImage(image); + if (parsedEcr !== undefined) { + assertNoCodeBuildRegistryCredentials(image, credentials, 'private ECR'); + const stack = Stack.of(scope); + if (Token.isUnresolved(stack.account) || Token.isUnresolved(stack.region)) { + throw new Error( + `cdk-cicd: private ECR ci.image '${image}' requires a concrete pipeline stack account and region.`, + ); + } + if (parsedEcr.partition !== pipelinePartition) { + throw new Error( + `cdk-cicd: private ECR ci.image '${image}' is in partition '${parsedEcr.partition}', but the ` + + `pipeline is in '${pipelinePartition}'.`, + ); + } + if (parsedEcr.account !== stack.account || parsedEcr.region !== stack.region) { + throw new Error( + `cdk-cicd: private ECR ci.image '${image}' must be in the pipeline stack account and region ` + + `(${stack.account}/${stack.region}); cross-account and cross-region CodeBuild images are not supported.`, + ); + } + const repository = ecr.Repository.fromRepositoryArn( + scope, + 'CiImageRepository', + `arn:${parsedEcr.partition}:ecr:${parsedEcr.region}:${parsedEcr.account}:repository/${parsedEcr.repositoryName}`, + ); + return codebuild.LinuxBuildImage.fromEcrRepository(repository, parsedEcr.tagOrDigest); + } + + const secret = + credentials !== undefined + ? importCodeBuildRegistrySecret(scope, 'CiImageRegistryCredentials', credentials) + : undefined; + return codebuild.LinuxBuildImage.fromDockerRegistry( + image, + secret !== undefined ? { secretsManagerCredentials: secret } : undefined, + ); +} + +function parsePrivateEcrImage(image: string): ParsedEcrImage | undefined { + const firstSlash = image.indexOf('/'); + if (firstSlash < 1) return undefined; + const normalizedRegistryHost = ciImageRegistryHost(image)!; + const repositoryAndVersion = image.slice(firstSlash + 1); + if (!isPrivateEcrRegistryHost(normalizedRegistryHost)) return undefined; + + if (/^\d{12}\.dkr(?:\.ecr-fips|-ecr-fips)\./.test(normalizedRegistryHost)) { + throw new Error( + `cdk-cicd: private ECR ci.image '${image}' uses a FIPS registry endpoint. The installed ` + + 'aws-cdk-lib CodeBuild image binding accepts an ECR repository and renders its canonical registry ' + + 'URI, so it cannot preserve a requested FIPS endpoint.', + ); + } + if (/^\d{12}\.dkr-ecr\.[a-z0-9-]+\.on\.aws$/.test(normalizedRegistryHost)) { + throw new Error( + `cdk-cicd: private ECR ci.image '${image}' uses a dual-stack registry endpoint. The installed ` + + 'aws-cdk-lib CodeBuild image binding renders the canonical dkr.ecr endpoint, and the installed ' + + 'CodeBuild contract does not expose a dual-stack build-image option.', + ); + } + + const match = normalizedRegistryHost.match(/^(\d{12})\.dkr\.ecr\.([a-z0-9-]+)\.(.+)$/); + if (match === null) { + throw new Error( + `cdk-cicd: private ECR ci.image '${image}' does not use a supported canonical registry endpoint ` + + "('.dkr.ecr..').", + ); + } + + const [, account, region, registrySuffix] = match; + const regionInfo = RegionInfo.get(region); + const expectedSuffix = regionInfo.domainSuffix; + const partition = regionInfo.partition; + if (expectedSuffix === undefined || partition === undefined) { + throw new Error( + `cdk-cicd: private ECR ci.image '${image}' uses region '${region}', whose partition/domain ` + + 'suffix is not known to this aws-cdk-lib version. Upgrade the wrapper/CDK before using this image.', + ); + } + if (registrySuffix !== expectedSuffix) { + throw new Error( + `cdk-cicd: private ECR ci.image '${image}' has registry suffix '${registrySuffix}', but region ` + + `'${region}' belongs to partition '${partition}' and requires '${expectedSuffix}'.`, + ); + } + if (repositoryAndVersion.length === 0) { + throw new Error(`cdk-cicd: private ECR ci.image '${image}' is missing a repository name.`); + } + + const digestSeparator = repositoryAndVersion.indexOf('@'); + if (digestSeparator >= 0) { + const repositoryName = repositoryAndVersion.slice(0, digestSeparator); + const tagOrDigest = repositoryAndVersion.slice(digestSeparator + 1); + if (repositoryName.length === 0 || !/^sha256:[0-9a-fA-F]{64}$/.test(tagOrDigest)) { + throw new Error(`cdk-cicd: private ECR ci.image '${image}' must use a non-empty repository and a sha256 digest.`); + } + return { + account, + region, + partition, + repositoryName, + tagOrDigest, + }; + } + + const tagSeparator = repositoryAndVersion.lastIndexOf(':'); + const finalSlash = repositoryAndVersion.lastIndexOf('/'); + if (tagSeparator > finalSlash) { + const repositoryName = repositoryAndVersion.slice(0, tagSeparator); + const tagOrDigest = repositoryAndVersion.slice(tagSeparator + 1); + if (repositoryName.length === 0 || tagOrDigest.length === 0) { + throw new Error(`cdk-cicd: private ECR ci.image '${image}' has an empty repository name or tag.`); + } + return { + account, + region, + partition, + repositoryName, + tagOrDigest, + }; + } + + return { account, region, partition, repositoryName: repositoryAndVersion }; +} + +function assertNoCodeBuildRegistryCredentials( + image: string, + credentials: CodeBuildImageCredentials | undefined, + imageKind: string, +): void { + if (credentials === undefined) return; + throw new Error( + `cdk-cicd: ci.codeBuildImageCredentials cannot be used with ${imageKind} ci.image '${image}'; ` + + 'only authenticated external registries use Secrets Manager registry credentials.', + ); +} + +function importCodeBuildRegistrySecret( + scope: Construct, + id: string, + credentials: CodeBuildImageCredentials, +): secretsmanager.ISecret { + if (credentials.secretArn.trim().length === 0) { + throw new Error('cdk-cicd: ci.codeBuildImageCredentials.secretArn must not be empty.'); + } + if (credentials.encryptionKeyArn !== undefined && credentials.encryptionKeyArn.trim().length === 0) { + throw new Error('cdk-cicd: ci.codeBuildImageCredentials.encryptionKeyArn must not be empty.'); + } + const encryptionKey = + credentials.encryptionKeyArn !== undefined + ? kms.Key.fromKeyArn(scope, `${id}EncryptionKey`, credentials.encryptionKeyArn) + : undefined; + return secretsmanager.Secret.fromSecretAttributes(scope, id, { + secretCompleteArn: credentials.secretArn, + ...(encryptionKey !== undefined ? { encryptionKey } : {}), + }); +} + +/** + * `fromDockerRegistry` grants the imported secret read. aws-cdk-lib 2.195.0 cannot add the + * `ViaServicePrincipal` statement to an imported CMK's resource policy, so place the exact decrypt + * grant on the generated Synth role through `CodeBuildStep.rolePolicyStatements`. + */ +function codeBuildImageCredentialKeyDecryptStatements( + credentials: CodeBuildImageCredentials | undefined, +): iam.PolicyStatement[] { + return credentials?.encryptionKeyArn !== undefined + ? [new iam.PolicyStatement({ actions: ['kms:Decrypt'], resources: [credentials.encryptionKeyArn] })] + : []; +} + +/** + * Let Synth resolve uncached CDK context in every target environment. The lookup role performs the + * actual read; the bootstrap version parameter is also listed explicitly to match the CLI's bootstrap + * validation contract. + */ +export function targetLookupStatements( + stack: Stack, + config: ResolvedCicdConfig, + validatedPartition?: string, +): iam.PolicyStatement[] { + const partition = validatedPartition ?? assertSingleKnownPartition(stack, config, 'self-mutating pipeline'); + const qualifier = resolveDefaultSynthesizerQualifier(stack, config.qualifier); + const roleArns = new Set(); + const versionParameters = new Set(); + + for (const stage of config.stages) { + const account = stage.env.account ?? stack.account; + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + for (const region of regions) { + roleArns.add(`arn:${partition}:iam::${account}:role/cdk-${qualifier}-lookup-role-${account}-${region}`); + versionParameters.add(`arn:${partition}:ssm:${region}:${account}:parameter/cdk-bootstrap/${qualifier}/version`); + } + } + + const statements: iam.PolicyStatement[] = []; + if (roleArns.size > 0) { + statements.push(new iam.PolicyStatement({ actions: ['sts:AssumeRole'], resources: [...roleArns] })); + } + if (versionParameters.size > 0) { + statements.push(new iam.PolicyStatement({ actions: ['ssm:GetParameter'], resources: [...versionParameters] })); + } + return statements; +} + /** The CodeArtifact read permissions a `codeartifact login` + `npm ci` need (mirrors the flat engine). */ function codeArtifactReadStatements(stack: Stack, ca: CodeArtifactConfig): iam.PolicyStatement[] { const account = ca.account ?? stack.account; @@ -339,7 +776,10 @@ function codeArtifactReadStatements(stack: Stack, ca: CodeArtifactConfig): iam.P * `codeartifact login`) bypass the proxy while `npm ci` against public npm goes through it. */ function proxyEnvVariables(stack: Stack, proxy: ProxyConfig): Record { - const noProxy = proxy.noProxy.length > 0 ? proxy.noProxy : [`${stack.region}.amazonaws.com`]; + const domainSuffix = Token.isUnresolved(stack.region) + ? stack.urlSuffix + : (RegionInfo.get(stack.region).domainSuffix ?? stack.urlSuffix); + const noProxy = proxy.noProxy.length > 0 ? proxy.noProxy : [`${stack.region}.${domainSuffix}`]; return { AWS_STS_REGIONAL_ENDPOINTS: 'regional', NO_PROXY: noProxy.join(','), @@ -358,6 +798,73 @@ function proxySecretsManagerVars(proxy: ProxyConfig): Record { }; } +/** + * Create the credential-bearing npm config outside the source workspace before either registry login. + */ +function npmConfigSetupCommands(): string[] { + return [ + `export NPM_CONFIG_USERCONFIG="${PRIVATE_NPM_CONFIG_PATH}"`, + 'rm -f "$NPM_CONFIG_USERCONFIG"', + 'umask 077 && touch "$NPM_CONFIG_USERCONFIG"', + ]; +} + +/** Remove the temporary credential file after CodeBuild's build phase. */ +function npmConfigCleanupCommands(): string[] { + return ['rm -f "$NPM_CONFIG_USERCONFIG"']; +} + +/** + * Write a generic npm-compatible registry to the temporary config. CodeBuild injects the token from + * Secrets Manager as `NPM_AUTH_TOKEN`; a following CodeArtifact login can append its scoped entries. + */ +function npmRegistryLoginCommands(npm: NpmRegistryConfig): string[] { + const host = npm.url.replace(/^https?:\/\//, ''); + const scope = npm.scope !== undefined && npm.scope.length > 0 ? npm.scope : undefined; + const scopePrefix = scope !== undefined ? `${scope.startsWith('@') ? scope : `@${scope}`}:` : ''; + return [ + `echo "${scopePrefix}registry=${npm.url}" > "$NPM_CONFIG_USERCONFIG"`, + `echo "//${host}:_authToken=$NPM_AUTH_TOKEN" >> "$NPM_CONFIG_USERCONFIG"`, + ]; +} + +/** Merge caller CI buildspec additions with wrapper-owned secret bindings for the Synth project. */ +function mergeSynthPartialBuildSpec( + partialBuildSpec: codebuild.BuildSpec | undefined, + proxy: ProxyConfig | undefined, + npmRegistry: NpmRegistryConfig | undefined, + privateNpm: boolean, +): codebuild.BuildSpec | undefined { + if (proxy === undefined && npmRegistry === undefined && !privateNpm) return partialBuildSpec; + + const wrapperBuildSpec = codebuild.BuildSpec.fromObject({ + ...(proxy !== undefined || npmRegistry !== undefined + ? { + env: { + 'secrets-manager': { + ...(proxy !== undefined ? proxySecretsManagerVars(proxy) : {}), + ...(npmRegistry !== undefined ? { NPM_AUTH_TOKEN: npmRegistry.basicAuthSecretArn } : {}), + }, + }, + } + : {}), + ...(privateNpm + ? { + phases: { + build: { + // Setup/login and CI all run in CodeBuild's build phase. Its `finally` commands execute + // even after a failed command, so credentials cannot be stranded by a failed synth. + finally: npmConfigCleanupCommands(), + }, + }, + } + : {}), + }); + return partialBuildSpec !== undefined + ? codebuild.mergeBuildSpecs(partialBuildSpec, wrapperBuildSpec) + : wrapperBuildSpec; +} + /** Export the proxy for every later shell command, then prove the tunnel works before install runs. */ function proxyInstallCommands(proxy: ProxyConfig): string[] { return [ @@ -430,23 +937,19 @@ export function ssmWarmingReadStatements(stack: Stack, qualifier?: string): iam. ]; } -/** The read grant the proxy secret needs, plus cross-account KMS decrypt when the secret lives elsewhere. */ -function proxySecretReadStatements(stack: Stack, proxy: ProxyConfig): iam.PolicyStatement[] { - const secretArn = Arn.split(proxy.proxySecretArn, ArnFormat.SLASH_RESOURCE_NAME); - const statements = [ - new iam.PolicyStatement({ actions: ['secretsmanager:GetSecretValue'], resources: [proxy.proxySecretArn] }), - ]; - if (secretArn.account !== undefined && secretArn.account !== stack.account) { - statements.push( - new iam.PolicyStatement({ - actions: ['kms:Decrypt', 'kms:DescribeKey', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], - resources: [`arn:${stack.partition}:kms:${secretArn.region}:${secretArn.account}:key/*`], - }), - ); +/** Secret read plus an exact CMK decrypt grant when the config identifies one. */ +function secretReadStatements(secretArn: string, encryptionKeyArn?: string): iam.PolicyStatement[] { + const statements = [new iam.PolicyStatement({ actions: ['secretsmanager:GetSecretValue'], resources: [secretArn] })]; + if (encryptionKeyArn !== undefined && encryptionKeyArn.trim().length > 0) { + statements.push(new iam.PolicyStatement({ actions: ['kms:Decrypt'], resources: [encryptionKeyArn] })); } return statements; } +function proxySecretReadStatements(proxy: ProxyConfig): iam.PolicyStatement[] { + return secretReadStatements(proxy.proxySecretArn, proxy.encryptionKeyArn); +} + // NOTE: the old `cdkPipelinesApp(config, factory)` explicit-factory entry has been RETIRED. The single // entry is now `cdk-cicd exec bin/app.ts` for both engines (engine chosen in cicd.config): for // CDK_PIPELINES it replays the plain bin per stage via runtime/pipeline-assembler, so no factory or diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/codepipeline/CodePipelineEngine.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/codepipeline/CodePipelineEngine.ts index 63df4375..c1fa9bc0 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/codepipeline/CodePipelineEngine.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/codepipeline/CodePipelineEngine.ts @@ -10,37 +10,61 @@ import * as path from 'path'; import { - Arn, - ArnFormat, AspectPriority, Aspects, - DefaultStackSynthesizer, Duration, RemovalPolicy, Stack, + Token, aws_lambda as lambda, aws_codebuild as codebuild, aws_codepipeline as codepipeline, aws_codepipeline_actions as actions, aws_ecr as ecr, aws_iam as iam, + aws_kms as kms, + aws_secretsmanager as secretsmanager, } from 'aws-cdk-lib'; +import { RegionInfo } from 'aws-cdk-lib/region-info'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; import { buildSourceAction } from './source'; -import { BuildImage, BuildImageKind, ImageTagStrategy } from '../../config/build-image'; +import { + assertValidCiImageReference, + BuildImage, + BuildImageKind, + ciImageRegistryHost, + ImageTagStrategy, + isPrivateEcrRegistryHost, + isPublicEcrRegistryHost, +} from '../../config/build-image'; +import { + resolveDefaultSynthesizerQualifier, + specializeDefaultSynthesizerRoleArn, +} from '../../config/default-synthesizer-role-arn'; +import { RepositorySourceType } from '../../config/repository'; import { CodeArtifactConfig, + CodeBuildImageCredentials, CodePipelineRoleNames, DeployModel, + NpmRegistryConfig, ProxyConfig, + RegionOrder, ResolvedCicdConfig, + SynthesizerType, } from '../../config/types'; +import { + COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, + COMPLIANCE_LOG_BUCKET_NAME_FLAG, + COMPLIANCE_LOG_BUCKET_REGION_FLAG, +} from '../../runtime/inject'; import { AccessLogsForBucketAspect } from '../../support/AccessLogsForBucketAspect'; import { SupportResources } from '../../support/SupportResources'; import { VpcNetworking } from '../../support/Vpc'; import { ssmWarmingCommands, ssmWarmingReadStatements } from '../cdkpipelines/CdkPipelinesEngine'; import { defaultCiCommands } from '../ci-commands'; +import { deployRoleExternalIdSecretArnsForStages } from '../external-id-secrets'; // Reuse the SSM account-warming helpers the CdkPipelines engine exports (same wiring GitHubActionsEngine // does) rather than redefining the scan/grant here -- one source of truth for both the shell and the IAM. import { EngineRenderProps, IEngine } from '../types'; @@ -57,6 +81,21 @@ const BOOTSTRAP_ROLE_KINDS = ['deploy', 'file-publishing', 'image-publishing', ' */ const NODE_RUNTIME_VERSION = 22; +/** Kept outside CODEBUILD_SRC_DIR so npm credentials can never enter a promoted source artifact. */ +const PRIVATE_NPM_CONFIG_PATH = '/tmp/cdk-cicd-npmrc'; +/** Fixed CodePipeline service quotas. */ +const MAX_ACTIONS_PER_STAGE = 100; +const MAX_ACTIONS_PER_PIPELINE = 1_000; +const MAX_STAGES_PER_PIPELINE = 50; +const CODEPIPELINE_IDENTIFIER = /^[A-Za-z0-9.@_-]{1,100}$/; +const FLAT_ENGINE_STAGE_NAMES = ['Source', 'Build', 'UpdatePipeline'] as const; + +interface ComplianceLoggingEnvironment { + readonly bucketName: string; + readonly account: string; + readonly region: string; +} + /** * The newest Node runtime the CONSUMER's `aws-cdk-lib` knows about, for the deploy-driver Lambda. * @@ -76,9 +115,169 @@ function latestNodeRuntime(): lambda.Runtime { ).reduce((best, r) => (major(r) > major(best) ? r : best), lambda.Runtime.NODEJS_22_X); } +/** + * One destination bucket is provisioned with the pipeline stack. S3 server access logging cannot + * cross accounts or Regions, so reject any application target that could not use that real bucket. + */ +function resolveComplianceLoggingEnvironment( + stack: Stack, + config: ResolvedCicdConfig, +): ComplianceLoggingEnvironment | undefined { + const bucketName = config.complianceLogBucketName; + if (bucketName === undefined) return undefined; + + if (Token.isUnresolved(stack.account) || Token.isUnresolved(stack.region)) { + throw new Error( + 'cdk-cicd: compliance logging requires a concrete pipeline stack account and region so the ' + + 'S3 same-account/same-region requirement can be verified.', + ); + } + + for (const stage of config.stages) { + const account = stage.env.account ?? stack.account; + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + if ( + Token.isUnresolved(account) || + account !== stack.account || + regions.some((region) => Token.isUnresolved(region) || region !== stack.region) + ) { + throw new Error( + `cdk-cicd: compliance logging cannot target stage '${stage.name}' from the pipeline bucket ` + + `'${bucketName}' in ${stack.account}/${stack.region}. S3 server access-log source and ` + + 'destination buckets must be in the same account and region; configure only co-located ' + + 'stages or omit complianceLogBucketName.', + ); + } + } + + return { bucketName, account: stack.account, region: stack.region }; +} + +/** Validate the exact flat-pipeline topology before constructs emit opaque service-limit errors. */ +function validateFlatPipelineTopology(config: ResolvedCicdConfig): void { + const duplicateStage = config.stages.find( + (stage, index) => config.stages.findIndex((candidate) => candidate.name === stage.name) !== index, + ); + if (duplicateStage !== undefined) { + throw new Error(`cdk-cicd: duplicate stage name '${duplicateStage.name}' is not allowed`); + } + + const renderedStages: Array<{ readonly name: string; readonly actionNames: string[] }> = FLAT_ENGINE_STAGE_NAMES.map( + (name) => ({ name, actionNames: [name] }), + ); + for (const stage of config.stages) { + validateCodePipelineIdentifier('stage', stage.name); + if ((FLAT_ENGINE_STAGE_NAMES as readonly string[]).includes(stage.name)) { + throw new Error( + `cdk-cicd: stage name '${stage.name}' is reserved by the flat CodePipeline engine. ` + + `Choose a name other than ${FLAT_ENGINE_STAGE_NAMES.join(', ')}.`, + ); + } + + const regions = stage.env.regions; + const suffixes = + stage.env.regionOrder === RegionOrder.PARALLEL && regions.length > 1 + ? regions.map((region) => `-${region}`) + : ['']; + const actionNames = [ + ...(stage.manualApproval ? [`Approve-${stage.name}`] : []), + ...suffixes.map((suffix) => `Deploy-${stage.name}${suffix}`), + ...(config.asyncDeploy ? suffixes.map((suffix) => `Await-${stage.name}${suffix}`) : []), + ]; + for (const actionName of actionNames) { + validateCodePipelineIdentifier('action', actionName); + } + const duplicateAction = actionNames.find((actionName, index) => actionNames.indexOf(actionName) !== index); + if (duplicateAction !== undefined) { + throw new Error( + `cdk-cicd: stage '${stage.name}' would contain duplicate action name '${duplicateAction}'. ` + + 'Remove duplicate parallel regions.', + ); + } + if (actionNames.length > MAX_ACTIONS_PER_STAGE) { + throw new Error( + `cdk-cicd: stage '${stage.name}' would contain ${actionNames.length} actions, exceeding the ` + + `fixed ${MAX_ACTIONS_PER_STAGE}-action CodePipeline quota. Reduce parallel regions or disable ` + + 'asyncDeploy.', + ); + } + renderedStages.push({ name: stage.name, actionNames }); + } + + if (renderedStages.length > MAX_STAGES_PER_PIPELINE) { + throw new Error( + `cdk-cicd: the flat pipeline would contain ${renderedStages.length} stages, exceeding the fixed ` + + `${MAX_STAGES_PER_PIPELINE}-stage CodePipeline quota. Reduce deployment stages or split them ` + + 'across pipelines.', + ); + } + const actionCount = renderedStages.reduce((count, stage) => count + stage.actionNames.length, 0); + if (actionCount > MAX_ACTIONS_PER_PIPELINE) { + throw new Error( + `cdk-cicd: the flat pipeline would contain ${actionCount} actions, exceeding the fixed ` + + `${MAX_ACTIONS_PER_PIPELINE}-action CodePipeline quota. Reduce stages/parallel regions or split ` + + 'the deployment across pipelines.', + ); + } +} + +function validateCodePipelineIdentifier(kind: 'stage' | 'action', name: string): void { + if (!CODEPIPELINE_IDENTIFIER.test(name)) { + throw new Error( + `cdk-cicd: generated CodePipeline ${kind} name '${name}' must match ` + + `${CODEPIPELINE_IDENTIFIER} (1-100 characters). Rename the stage or region.`, + ); + } +} + +function validateFlatPipelinePartitions(stack: Stack, config: ResolvedCicdConfig): void { + if (Token.isUnresolved(stack.region) || stack.region.length === 0) { + throw new Error( + 'cdk-cicd: the flat deployment pipeline requires a concrete pipeline stack region so its AWS ' + + 'partition and target bootstrap-role ARNs can be validated.', + ); + } + const pipelinePartition = RegionInfo.get(stack.region).partition; + if (pipelinePartition === undefined) { + throw new Error( + `cdk-cicd: pipeline region '${stack.region}' has no known AWS partition in this aws-cdk-lib ` + + 'version. Upgrade the wrapper/CDK before rendering the pipeline.', + ); + } + + for (const stage of config.stages) { + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + for (const region of regions) { + if (Token.isUnresolved(region) || region.length === 0) { + throw new Error( + `cdk-cicd: stage '${stage.name}' has an unresolved target region; the flat engine must know ` + + 'each target partition before it builds bootstrap-role ARNs.', + ); + } + const targetPartition = RegionInfo.get(region).partition; + if (targetPartition === undefined) { + throw new Error( + `cdk-cicd: stage '${stage.name}' targets region '${region}', whose AWS partition is not known ` + + 'to this aws-cdk-lib version. Upgrade the wrapper/CDK before using that region.', + ); + } + if (targetPartition !== pipelinePartition) { + throw new Error( + `cdk-cicd: stage '${stage.name}' targets partition '${targetPartition}' (${region}), but the ` + + `flat pipeline runs in '${pipelinePartition}' (${stack.region}). IAM role assumption and ` + + 'CodePipeline deployment cannot cross AWS partitions; use a pipeline in the target partition.', + ); + } + } + } +} + /** Options for the CodePipeline engine. */ export interface CodePipelineEngineProps { - /** CodeBuild image for the CI and deploy projects. Defaults to the standard Amazon Linux image. */ + /** + * CodeBuild image for the CI Build project only. Overrides `config.ci.image`; defaults to the + * standard Amazon Linux image. + */ readonly buildImage?: string; /** * Removal policy for the pipeline's own support resources (artifact bucket, encryption key). @@ -99,20 +298,25 @@ export class CodePipelineEngine implements IEngine { public render(scope: Construct, props: EngineRenderProps): void { const config = props.config; + const stack = Stack.of(scope); + const ciBuildImage = this.buildImage ?? config.ci.image; + if (config.deployerImage === undefined) { + validateFlatPipelineTopology(config); + validateFlatPipelinePartitions(stack, config); + } const sourceOutput = new codepipeline.Artifact(); const support = new SupportResources(scope, 'Support', { removalPolicy: this.removalPolicy, vpc: config.vpc, useProxy: config.proxy !== undefined, complianceLogBucketName: config.complianceLogBucketName, + createComplianceLogBucket: config.createComplianceLogBucket, }); const vpcNetworking = support.vpcNetworking; // v2 `ComplianceBucketProvider` provisioned this bucket eagerly whenever a name was configured // (default-on, not gated behind a separate opt-in); force the same here by reading the lazy // getter, so setting `complianceLogBucketName` alone is enough to get the bucket. - if (config.complianceLogBucketName !== undefined) { - void support.complianceLogBucket; - } + const complianceLogBucket = config.complianceLogBucketName !== undefined ? support.complianceLogBucket : undefined; const pipeline = new codepipeline.Pipeline(scope, 'Pipeline', { pipelineName: props.pipelineName, @@ -129,6 +333,16 @@ export class CodePipelineEngine implements IEngine { this.renderImageBuild(scope, pipeline, support, sourceOutput, config, config.deployerImage, vpcNetworking); return; } + const complianceLogging = resolveComplianceLoggingEnvironment(stack, config); + if (synthesizerType(config) === SynthesizerType.APP_STAGING) { + throw new Error( + 'cdk-cicd: APP_STAGING cannot be deployed by the flat CodePipeline engine. The pinned alpha emits ' + + 'DefaultStagingStack with BootstraplessSynthesizer, so that support stack is deployed with the ' + + "CodeBuild project's base credentials instead of the configured deployment role. Use " + + 'SynthesizerType.DEFAULT for pipeline deployment, or use container mode only to build the image ' + + 'and run its APP_STAGING deployment directly with appropriately privileged credentials.', + ); + } // The pipeline stack contains ONLY the wrapper's own plumbing -- no user resources deploy here // (those land in the per-stage app stacks the deploy actions create). AwsSolutionsChecks is live @@ -162,16 +376,31 @@ export class CodePipelineEngine implements IEngine { // in deploy-time-synth mode is reused too, not synthesized a second time by its own deploy. const assembly = synthed.length > 0 ? new codepipeline.Artifact('Assembly') : undefined; - const buildProject = this.project( - scope, - 'BuildProject', - this.ciCommands(config, synthed, promote), - config.codeArtifact, - config.proxy, - config.codeBuildEnvSettings, - assembly !== undefined, - config.ci.partialBuildSpec, + const buildProject = this.project(scope, 'BuildProject', this.ciCommands(config, synthed, promote), { + codeArtifact: config.codeArtifact, + npmRegistry: config.npmRegistry, + proxy: config.proxy, + codeBuildEnvSettings: config.codeBuildEnvSettings, + publishAssembly: assembly !== undefined, + partialBuildSpec: config.ci.partialBuildSpec, vpcNetworking, + buildImage: ciBuildImage, + buildImageCredentials: config.ci.codeBuildImageCredentials, + requiresDocker: true, + complianceLogging, + }); + this.grantLookupPermissions( + buildProject, + config.stages.map((stage) => ({ + account: stage.env.account ?? stack.account, + regions: stage.env.regions.length > 0 ? stage.env.regions : [stack.region], + })), + config.qualifier, + ); + this.grantExternalIdSecretRead( + buildProject, + config.stages.filter((stage) => synthed.includes(stage.name)), + config.deployRoleExternalId, ); // The synth build is where `ssmWarmingCommands` runs its `aws ssm get-parameters-by-path`, so its // role -- not the deploy roles -- needs the read grant. Same helper the CdkPipelines engine uses. @@ -198,30 +427,29 @@ export class CodePipelineEngine implements IEngine { // run under the new definition, so a change to the config -- a new stage, a changed gate -- takes // effect on the same push that introduced it, with no separate `deploy-ci` by hand. The target is // the pipeline's own account/region, so it needs the bootstrap roles there just like a deploy does. - const stack = Stack.of(scope); // The self-update must re-emit the SAME pipeline it is part of. A disposable pipeline that ran a // bare `deploy-ci` here would re-synth itself with the default RETAIN and quietly un-dispose its own // bucket and key on the first run -- so thread the flag through, keyed off the removal policy in hand. const deployCi = this.removalPolicy === RemovalPolicy.DESTROY ? 'npx cdk-cicd deploy-ci --disposable' : 'npx cdk-cicd deploy-ci'; - const selfUpdate = this.project( - scope, - 'UpdatePipeline', - ['npm ci', deployCi], - config.codeArtifact, - config.proxy, - config.codeBuildEnvSettings, - false, - undefined, + const selfUpdate = this.project(scope, 'UpdatePipeline', ['npm ci', deployCi], { + codeArtifact: config.codeArtifact, + npmRegistry: config.npmRegistry, + proxy: config.proxy, + codeBuildEnvSettings: config.codeBuildEnvSettings, vpcNetworking, - ); + }); + // The pipeline stack does not inherit the application's config.qualifier, but its App may select a + // hub bootstrap qualifier through CDK context. Resolve that same context for self-update IAM. this.grantDeployPermissions(selfUpdate, stack.account, [stack.region]); pipeline.addStage({ stageName: 'UpdatePipeline', actions: [new actions.CodeBuildAction({ actionName: 'SelfMutate', project: selfUpdate, input: sourceOutput })], }); - // One deploy action per stage; the region fan-out lives inside `cdk-cicd deploy`. + // Sequential stages keep one deploy action and let `cdk-cicd deploy` fan out across regions. + // PARALLEL multi-region stages use one region-scoped project/action per region in the same + // CodePipeline stage, all at the same run order. for (const stage of config.stages) { // `--from-assembly` makes the deploy use the promoted `cdk.out//` from its input // artifact instead of synthesizing. It refuses rather than falling back if the assembly is absent, @@ -246,37 +474,75 @@ export class CodePipelineEngine implements IEngine { ); } - // With asyncDeploy the build only PREPARES change sets and exits; a Lambda executes and awaits them, - // so no build minutes are billed for the CloudFormation wait (D-deploy-wait). The plan travels - // through an SSM parameter whose name is fixed at render time, so both halves can name it without - // the Lambda having to download and unzip a pipeline artifact. - const planParam = config.asyncDeploy ? `/cdk-cicd/${props.pipelineName}/${stage.name}/deploy-plan` : undefined; - const stageCmd = - planParam !== undefined ? `${deployCmd} --prepare-only --plan-parameter ${planParam}` : deployCmd; + const deployTargets: Array<{ readonly region?: string; readonly regions: string[] }> = + stage.env.regionOrder === RegionOrder.PARALLEL && regions.length > 1 + ? regions.map((region) => ({ region, regions: [region] })) + : [{ regions }]; + const deployActions: codepipeline.IAction[] = []; + const awaitActions: codepipeline.IAction[] = []; - const project = this.project( - scope, - `Deploy-${stage.name}`, - ['npm ci', stageCmd], - config.codeArtifact, - config.proxy, - config.codeBuildEnvSettings, - false, - undefined, - vpcNetworking, - ); - this.grantDeployPermissions(project, account, regions, stage.deployment?.deployRole); - if (planParam !== undefined) { - project.addToRolePolicy( - new iam.PolicyStatement({ - actions: ['ssm:PutParameter'], - resources: [`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${planParam}`], + for (const target of deployTargets) { + const suffix = target.region !== undefined ? `-${target.region}` : ''; + const regionOption = target.region !== undefined ? ` --region ${target.region}` : ''; + // With asyncDeploy the build only PREPARES change sets and exits; a Lambda executes and awaits + // them, so no build minutes are billed for the CloudFormation wait (D-deploy-wait). Parallel + // regions need distinct parameters and drivers so their plans cannot overwrite one another. + const planParam = config.asyncDeploy + ? `/cdk-cicd/${props.pipelineName}/${stage.name}${target.region ? `/${target.region}` : ''}/deploy-plan` + : undefined; + const regionalDeployCmd = `${deployCmd}${regionOption}`; + const stageCmd = + planParam !== undefined + ? `${regionalDeployCmd} --prepare-only --plan-parameter ${planParam}` + : regionalDeployCmd; + + const project = this.project(scope, `Deploy-${stage.name}${suffix}`, ['npm ci', stageCmd], { + codeArtifact: config.codeArtifact, + npmRegistry: config.npmRegistry, + proxy: config.proxy, + codeBuildEnvSettings: config.codeBuildEnvSettings, + vpcNetworking, + requiresDocker: true, + complianceLogging, + }); + this.grantDeployPermissions(project, account, target.regions, config.qualifier, stage.deployment?.deployRole); + if (!reuse) { + this.grantExternalIdSecretRead(project, [stage], config.deployRoleExternalId); + } + if (planParam !== undefined) { + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['ssm:PutParameter'], + resources: [`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${planParam}`], + }), + ); + } + + deployActions.push( + new actions.CodeBuildAction({ + actionName: `Deploy-${stage.name}${suffix}`, + project, + // Per stage, not per pipeline: a reusing stage takes the Build output (cdk.out + the few + // source files `npm ci` needs), while a stage that still synthesizes must take the RAW + // SOURCE -- the assembly artifact deliberately omits bin/ and lib/, so synthesizing from it + // would fail. + input: reuse && assembly !== undefined ? assembly : sourceOutput, + runOrder: stage.manualApproval ? 2 : 1, }), ); - } - const driver = - planParam !== undefined ? this.deployDriver(scope, stage.name, planParam, account, regions) : undefined; + if (planParam !== undefined) { + const driver = this.deployDriver(scope, `${stage.name}${suffix}`, planParam, account, target.regions); + awaitActions.push( + new actions.LambdaInvokeAction({ + actionName: `Await-${stage.name}${suffix}`, + lambda: driver, + userParameters: { planParameterName: planParam }, + runOrder: stage.manualApproval ? 3 : 2, + }), + ); + } + } // A gated stage puts its approval in the SAME pipeline stage as the deploy, ordered ahead of it, // rather than in a stage of its own: run order already sequences them, and one stage per @@ -287,27 +553,9 @@ export class CodePipelineEngine implements IEngine { ...(stage.manualApproval ? [new actions.ManualApprovalAction({ actionName: `Approve-${stage.name}`, runOrder: 1 })] : []), - new actions.CodeBuildAction({ - actionName: `Deploy-${stage.name}`, - project, - // Per stage, not per pipeline: a reusing stage takes the Build output (cdk.out + the few - // source files `npm ci` needs), while a stage that still synthesizes must take the RAW - // SOURCE -- the assembly artifact deliberately omits bin/ and lib/, so synthesizing from it - // would fail. - input: reuse && assembly !== undefined ? assembly : sourceOutput, - runOrder: stage.manualApproval ? 2 : 1, - }), - // Ordered strictly after the prepare step: it reads the plan that step writes. - ...(driver !== undefined - ? [ - new actions.LambdaInvokeAction({ - actionName: `Await-${stage.name}`, - lambda: driver, - userParameters: { planParameterName: planParam }, - runOrder: stage.manualApproval ? 3 : 2, - }), - ] - : []), + ...deployActions, + // Ordered strictly after the prepare steps: each reads its region-specific plan. + ...awaitActions, ], }); } @@ -332,15 +580,18 @@ export class CodePipelineEngine implements IEngine { true, ); - // Compliance/access-log bucket: attach the per-region name-substituting aspect when a name is - // configured (the bucket itself is force-provisioned above via `void support.complianceLogBucket`). + // Compliance/access-log bucket: attach the destination aspect when a name is configured (the + // bucket itself is force-provisioned above via `support.complianceLogBucket`). // MUTATING priority so the L1 loggingConfiguration override lands before the readonly - // AwsSolutionsChecks, else AwsSolutions-S1 false-fails. No S1 suppression is added. + // AwsSolutionsChecks. The destination bucket alone carries the required S1 suppression because + // S3 server access logs must not be delivered back into the same bucket. if (config.complianceLogBucketName !== undefined) { Aspects.of(scope).add( new AccessLogsForBucketAspect({ complianceLogBucketName: config.complianceLogBucketName, - mainRegion: Stack.of(scope).region, + complianceLogBucketAccount: stack.account, + complianceLogBucketRegion: stack.region, + complianceLogBucket, }), { priority: AspectPriority.MUTATING }, ); @@ -380,43 +631,111 @@ export class CodePipelineEngine implements IEngine { } } + /** + * Let the CI synth project perform context lookups in every target environment it resolves. The + * synthesizer's lookup role is the least-privilege path for VPC, hosted-zone, AMI and other context + * providers; the bootstrap version parameter is read by the CLI before it assumes that role. + */ + private grantLookupPermissions( + project: codebuild.PipelineProject, + targets: ReadonlyArray<{ readonly account: string; readonly regions: readonly string[] }>, + configuredQualifier?: string, + ): void { + const stack = Stack.of(project); + const qualifier = resolveDefaultSynthesizerQualifier(project, configuredQualifier); + const roleArns = new Set(); + const versionParams = new Set(); + + for (const target of targets) { + for (const region of target.regions) { + roleArns.add( + `arn:${stack.partition}:iam::${target.account}:role/cdk-${qualifier}-lookup-role-${target.account}-${region}`, + ); + versionParams.add( + `arn:${stack.partition}:ssm:${region}:${target.account}:parameter/cdk-bootstrap/${qualifier}/version`, + ); + } + } + + if (roleArns.size > 0) { + project.addToRolePolicy(new iam.PolicyStatement({ actions: ['sts:AssumeRole'], resources: [...roleArns] })); + } + if (versionParams.size > 0) { + project.addToRolePolicy( + new iam.PolicyStatement({ actions: ['ssm:GetParameter'], resources: [...versionParams] }), + ); + } + } + + /** + * Grant the stage synth process access to secret-backed deploy-role ExternalIds. A stage override + * wins over the pipeline fallback, matching the CLI, and an ExternalId is ignored unless a non-blank + * deployRole is configured because there is then no role assumption to supply it to. + */ + private grantExternalIdSecretRead( + project: codebuild.PipelineProject, + stages: ReadonlyArray, + pipelineExternalId?: string, + ): void { + const secretArns = deployRoleExternalIdSecretArnsForStages(stages, pipelineExternalId); + if (secretArns.length === 0) return; + + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: secretArns, + }), + ); + } + /** * Let a `cdk deploy` project actually deploy into `account`/`regions` -- a stage's application * deploy, or the self-update stage deploying the pipeline into its own account. `cdk deploy` does - * everything through the CDK bootstrap roles, so the project's own role needs nothing but permission - * to assume them, plus any forced deployer role passed in. Without this the project fails AccessDenied. + * everything through the CDK bootstrap roles, so the project's own role needs permission to assume + * them, plus any forced deployment role. A separate CloudFormation execution role is passed to the + * service by the assumed deployment role; that role, not this project, must have iam:PassRole. * * The bootstrap version parameter is granted for the CLI's base-credentials path only; on the * normal path the CLI reads it under the *assumed* bootstrap role, not under this project's role. - * - * The qualifier is the bootstrap default because that is what the wrapper's synthesizer uses -- - * `resolveSynthesizer` builds a plain `DefaultStackSynthesizer` and does not thread the config's - * `qualifier` through, so keying these ARNs off `config.qualifier` would point at roles that do not - * exist. A user who sets the `@aws-cdk/core:bootstrapQualifier` context in their own `cdk.json` - * does move their app's roles, and this grant does NOT follow -- see finding - * `code-review-bootstrap-qualifier-not-single-source-of-truth`. */ private grantDeployPermissions( project: codebuild.PipelineProject, account: string, regions: string[], + configuredQualifier?: string, forcedDeployRole?: string, ): void { const stack = Stack.of(project); - const qualifier = DefaultStackSynthesizer.DEFAULT_QUALIFIER; + const qualifier = resolveDefaultSynthesizerQualifier(project, configuredQualifier); + const normalizedForcedDeployRole = forcedDeployRole?.trim(); + const roleArns = new Set(); - const roleArns = regions.flatMap((region) => - BOOTSTRAP_ROLE_KINDS.map( - (kind) => `arn:${stack.partition}:iam::${account}:role/cdk-${qualifier}-${kind}-role-${account}-${region}`, - ), - ); - // Same emptiness guard the CLI applies before passing --role-arn: a blank configured role is - // "no forced role", not an empty ARN (which would make the policy document malformed). - if (forcedDeployRole !== undefined && forcedDeployRole.length > 0) { - roleArns.push(forcedDeployRole); - } + for (const region of regions) { + for (const kind of BOOTSTRAP_ROLE_KINDS) { + roleArns.add(`arn:${stack.partition}:iam::${account}:role/cdk-${qualifier}-${kind}-role-${account}-${region}`); + } - project.addToRolePolicy(new iam.PolicyStatement({ actions: ['sts:AssumeRole'], resources: roleArns })); + // A blank configured deployment role means "no forced role", not an empty ARN. CDK specializes + // these placeholders per target stack before writing the assembly, so the project's IAM grant + // must name the same concrete role for every target Region. + if (normalizedForcedDeployRole !== undefined && normalizedForcedDeployRole.length > 0) { + const partition = RegionInfo.get(region).partition; + if (partition === undefined) { + throw new Error( + `cdk-cicd: target region '${region}' has no known AWS partition in this aws-cdk-lib version.`, + ); + } + roleArns.add( + specializeDefaultSynthesizerRoleArn(normalizedForcedDeployRole, { + qualifier, + account, + region, + partition, + }), + ); + } + } + project.addToRolePolicy(new iam.PolicyStatement({ actions: ['sts:AssumeRole'], resources: [...roleArns] })); project.addToRolePolicy( new iam.PolicyStatement({ actions: ['ssm:GetParameter'], @@ -444,6 +763,7 @@ export class CodePipelineEngine implements IEngine { ): void { const stack = Stack.of(scope); const appName = config.application ?? 'cdk-cicd'; + const ciBuildImage = this.buildImage ?? config.ci.image; // Reference an existing repo by name, else provision one. Provisioned repos follow the pipeline's // removal policy (a disposable pipeline deletes its repo, and empties images so the delete succeeds). @@ -455,39 +775,68 @@ export class CodePipelineEngine implements IEngine { removalPolicy: this.removalPolicy, emptyOnDelete: this.removalPolicy === RemovalPolicy.DESTROY, imageScanOnPush: true, + imageTagMutability: + build.tagStrategy === ImageTagStrategy.GIT_SHA ? ecr.TagMutability.IMMUTABLE : ecr.TagMutability.MUTABLE, }); pipeline.addStage({ stageName: 'Source', actions: [buildSourceAction(scope, config.repository, sourceOutput)] }); - // The image tag: the resolved source commit, so the image is immutable and hash-versioned; `latest` - // when the strategy asks for it. `CODEBUILD_RESOLVED_SOURCE_VERSION` is the commit CodeBuild checked - // out. The registry URI is derived from the pipeline's own account/region at run time. - const tag = - build.tagStrategy === ImageTagStrategy.LATEST ? 'latest' : '${CODEBUILD_RESOLVED_SOURCE_VERSION:-latest}'; + // GIT_SHA repositories are immutable. A retry reuses an already-published tag, while concurrent + // builds tolerate the other build winning the immutable-tag race. Imported repositories are + // verified at runtime because this stack cannot change their mutability setting. + const immutableTag = build.tagStrategy === ImageTagStrategy.GIT_SHA; + const tag = immutableTag ? '$IMAGE_TAG' : 'latest'; const uri = `${stack.account}.dkr.ecr.${stack.region}.${stack.urlSuffix}/${repository.repositoryName}`; + const privateNpm = config.npmRegistry !== undefined || config.codeArtifact !== undefined; const commands = [ + ...(privateNpm ? npmConfigSetupCommands() : []), + ...(config.npmRegistry ? npmRegistryLoginCommands(config.npmRegistry) : []), ...(config.codeArtifact ? [codeArtifactLogin(stack, config.codeArtifact)] : []), ...defaultCiCommands(), - // Log in to ECR, build the deployer image from the source, tag by commit, push. The image payload - // is the app + deps (per the Dockerfile), NOT cdk.out -- Repo 2 synths at run time. + ...(immutableTag + ? [ + ...immutableImageTagCommands(config.repository.repositoryType), + `test "$(aws ecr describe-repositories --region ${stack.region} --repository-names ${repository.repositoryName} --query 'repositories[0].imageTagMutability' --output text)" = "IMMUTABLE" || { echo "GIT_SHA requires an immutable ECR repository: ${repository.repositoryName}"; exit 1; }`, + ] + : []), + // Log in to ECR, build the deployer image from the source, tag by immutable source revision, and + // push. The image payload is the app + deps (per the Dockerfile), NOT cdk.out -- Repo 2 synths at + // run time. `aws ecr get-login-password --region ${stack.region} | docker login --username AWS --password-stdin ${stack.account}.dkr.ecr.${stack.region}.${stack.urlSuffix}`, - `docker build -f ${build.dockerfile} -t ${uri}:${tag} .`, - `docker push ${uri}:${tag}`, + ...(immutableTag + ? [ + `if aws ecr describe-images --region ${stack.region} --repository-name ${repository.repositoryName} --image-ids imageTag="$IMAGE_TAG" >/dev/null 2>&1; then echo "Immutable image ${uri}:$IMAGE_TAG already exists; reusing it"; else docker build -f ${build.dockerfile} -t ${uri}:$IMAGE_TAG . && (docker push ${uri}:$IMAGE_TAG || { aws ecr describe-images --region ${stack.region} --repository-name ${repository.repositoryName} --image-ids imageTag="$IMAGE_TAG" >/dev/null 2>&1 && echo "Immutable image ${uri}:$IMAGE_TAG was published concurrently; reusing it"; }); fi`, + ] + : [`docker build -f ${build.dockerfile} -t ${uri}:${tag} .`, `docker push ${uri}:${tag}`]), ]; // The proxy's exports run first, in `install` -- ahead of the codeArtifact login and `npm ci`, same // ordering as `project()` (NO_PROXY is what lets the AWS-API-bound `codeartifact login` skip the // proxy while `npm ci` against public npm goes through it). const install = { - ...(this.buildImage === undefined ? { 'runtime-versions': { nodejs: NODE_RUNTIME_VERSION } } : {}), + ...(ciBuildImage === undefined && config.codeBuildEnvSettings?.buildImage === undefined + ? { 'runtime-versions': { nodejs: NODE_RUNTIME_VERSION } } + : {}), ...(config.proxy ? { commands: proxyInstallCommands(config.proxy) } : {}), }; + const buildSpecEnv = buildSpecEnvironment(stack, config.proxy, config.npmRegistry, config.codeArtifact); + const environment = withPrivateNpmConfig( + this.buildEnvironment( + scope, + 'BuildImage', + config.codeBuildEnvSettings, + ciBuildImage, + config.ci.codeBuildImageCredentials, + true, + ), + privateNpm, + ); const project = new codebuild.PipelineProject(scope, 'BuildImage', { // Docker builds need a privileged environment; runtime pinned like the deploy projects. // `codeBuildEnvSettings` still contributes computeType/environmentVariables here -- only // `privileged` is forced (Docker requires it regardless of what the config says). - environment: { ...this.buildEnvironment(config.codeBuildEnvSettings), privileged: true }, + environment: { ...environment, privileged: true }, vpc: vpcNetworking?.vpc, securityGroups: vpcNetworking?.securityGroups, subnetSelection: vpcNetworking?.subnetSelection, @@ -495,21 +844,22 @@ export class CodePipelineEngine implements IEngine { version: '0.2', phases: { ...(Object.keys(install).length > 0 ? { install } : {}), - build: { commands }, + build: { + commands, + // CodeBuild runs a phase's `finally` commands even when an earlier command in that phase + // fails. Credentials are created in this same phase, so failed login/CI/build commands + // cannot strand the temporary npm config. + ...(privateNpm ? { finally: npmConfigCleanupCommands() } : {}), + }, }, - // The proxy credentials/ports live in Secrets Manager, not in plain env vars. - ...(config.proxy - ? { - env: { - variables: proxyEnvVariables(stack, config.proxy), - 'secrets-manager': proxySecretsManagerVars(config.proxy), - }, - } - : {}), + ...(buildSpecEnv !== undefined ? { env: buildSpecEnv } : {}), }), }); + grantCodeBuildImageCredentialKeyDecrypt(project, config.ci.codeBuildImageCredentials); repository.grantPullPush(project); + if (immutableTag) repository.grantRead(project); if (config.codeArtifact) grantCodeArtifactRead(project, config.codeArtifact); + if (config.npmRegistry) grantNpmRegistrySecretRead(project, config.npmRegistry); if (config.proxy) grantProxySecretRead(project, config.proxy); // Provisioned repos derive the URI from the pipeline account; a referenced repo may be elsewhere, but // grantPullPush + ECR's token endpoint cover same-account. (Cross-account push is a later slice.) @@ -519,6 +869,24 @@ export class CodePipelineEngine implements IEngine { actions: [new actions.CodeBuildAction({ actionName: 'BuildAndPush', project, input: sourceOutput })], }); + if (config.complianceLogBucketName !== undefined) { + if (Token.isUnresolved(stack.account) || Token.isUnresolved(stack.region)) { + throw new Error( + 'cdk-cicd: compliance logging requires a concrete pipeline stack account and region so the ' + + 'S3 same-account/same-region requirement can be verified.', + ); + } + Aspects.of(scope).add( + new AccessLogsForBucketAspect({ + complianceLogBucketName: config.complianceLogBucketName, + complianceLogBucketAccount: stack.account, + complianceLogBucketRegion: stack.region, + complianceLogBucket: support.complianceLogBucket, + }), + { priority: AspectPriority.MUTATING }, + ); + } + NagSuppressions.addResourceSuppressions( pipeline, [ @@ -538,6 +906,11 @@ export class CodePipelineEngine implements IEngine { reason: 'ECR grantPullPush issues ecr:GetAuthorizationToken on "*" (the token endpoint is not resource-scopable) plus repo-scoped push actions; the CodeBuild log/report and artifact-bucket wildcards are the project\'s own, as in the deploy pipeline. When a VPC is configured this also covers the CodeBuild-managed network-interface permissions, as in the deploy pipeline\'s project() suppression.', }, + { + id: 'AwsSolutions-CB5', + reason: + 'This project must build and push the deployer container image, which requires the local Docker daemon exposed by CodeBuild privileged mode.', + }, ], true, ); @@ -591,10 +964,9 @@ export class CodePipelineEngine implements IEngine { resources: regions.map((region) => `arn:${stack.partition}:cloudformation:${region}:${account}:stack/*/*`), }), ); - // NOTE: no sts:AssumeRole for a stage's `deployRole`. That role is a CloudFormation SERVICE role - // (trusted by cloudformation.amazonaws.com), baked into the change set as its RoleARN via - // `cdk deploy --role-arn`; CloudFormation assumes it at ExecuteChangeSet time. The Lambda executes - // the change set under its OWN identity and does not -- cannot -- assume that role. + // The driver assumes neither deployment role. CDK already used `deployRole` while preparing the + // change set, and baked `cfnExecutionRole` into it as CloudFormation's RoleARN. This Lambda only + // executes that prepared change set under its own identity. NagSuppressions.addResourceSuppressions( fn, @@ -640,14 +1012,36 @@ export class CodePipelineEngine implements IEngine { scope: Construct, id: string, commands: string[], - codeArtifact?: CodeArtifactConfig, - proxy?: ProxyConfig, - codeBuildEnvSettings?: codebuild.BuildEnvironment, - publishAssembly = false, - partialBuildSpec?: codebuild.BuildSpec, - vpcNetworking?: VpcNetworking, + options: { + readonly codeArtifact?: CodeArtifactConfig; + readonly npmRegistry?: NpmRegistryConfig; + readonly proxy?: ProxyConfig; + readonly codeBuildEnvSettings?: codebuild.BuildEnvironment; + readonly publishAssembly?: boolean; + readonly partialBuildSpec?: codebuild.BuildSpec; + readonly vpcNetworking?: VpcNetworking; + readonly buildImage?: string; + readonly buildImageCredentials?: CodeBuildImageCredentials; + /** Whether this project normally needs the local Docker daemon for CDK assets or bundling. */ + readonly requiresDocker?: boolean; + /** Compliance destination exported into application synthesis performed by this project. */ + readonly complianceLogging?: ComplianceLoggingEnvironment; + } = {}, ): codebuild.PipelineProject { const stack = Stack.of(scope); + const { + codeArtifact, + npmRegistry, + proxy, + codeBuildEnvSettings, + publishAssembly = false, + partialBuildSpec, + vpcNetworking, + buildImage, + buildImageCredentials, + requiresDocker = false, + complianceLogging, + } = options; // Pin the Node runtime, but ONLY on the default (CodeBuild-managed) image. Without // `runtime-versions` the managed image's default applies, which on standard:7.0 is Node 18 -- and // `aws-cdk-lib` declares `node >= 20`, so every `npm ci` warned EBADENGINE and the app then ran on an @@ -660,36 +1054,56 @@ export class CodePipelineEngine implements IEngine { // of which need HTTP(S)_PROXY/NO_PROXY already set (NO_PROXY is what lets the AWS-API-bound // `codeartifact login` skip the proxy while `npm ci` against public npm goes through it). const install = { - ...(this.buildImage === undefined ? { 'runtime-versions': { nodejs: NODE_RUNTIME_VERSION } } : {}), + ...(buildImage === undefined && codeBuildEnvSettings?.buildImage === undefined + ? { 'runtime-versions': { nodejs: NODE_RUNTIME_VERSION } } + : {}), ...(proxy ? { commands: proxyInstallCommands(proxy) } : {}), }; - // Every project runs `npm ci`; a private-registry login has to come first, or the install resolves - // against public npm and fails on the private packages (the wrapper's own, before it is published). - const preBuildCommands = codeArtifact ? [codeArtifactLogin(stack, codeArtifact)] : []; + // Private-registry setup has to run before npm ci. Keep setup, login, CI, and cleanup in the same + // build phase: CodeBuild's phase-level `finally` is then guaranteed to run after any failed command. + // Write the generic registry first so a following CodeArtifact login can append scoped entries. + const privateNpm = npmRegistry !== undefined || codeArtifact !== undefined; + const privateNpmCommands = [ + ...(privateNpm ? npmConfigSetupCommands() : []), + ...(npmRegistry ? npmRegistryLoginCommands(npmRegistry) : []), + ...(codeArtifact ? [codeArtifactLogin(stack, codeArtifact)] : []), + ]; const phases = { ...(Object.keys(install).length > 0 ? { install } : {}), - ...(preBuildCommands.length > 0 ? { pre_build: { commands: preBuildCommands } } : {}), - build: { commands }, + build: { + commands: [...privateNpmCommands, ...commands], + ...(privateNpm ? { finally: npmConfigCleanupCommands() } : {}), + }, }; + const buildSpecEnv = buildSpecEnvironment(stack, proxy, npmRegistry, codeArtifact); const generatedBuildSpec = codebuild.BuildSpec.fromObject({ version: '0.2', phases, - // The proxy credentials/ports live in Secrets Manager, not in plain env vars. - ...(proxy - ? { env: { variables: proxyEnvVariables(stack, proxy), 'secrets-manager': proxySecretsManagerVars(proxy) } } + ...(buildSpecEnv !== undefined ? { env: buildSpecEnv } : {}), + // Publish the WHOLE source tree plus the synthesized assembly. node_modules is rebuilt downstream; + // npm credential files are excluded defensively even though generated credentials live in /tmp. + // A hardcoded source allowlist is intentionally avoided because deploy still loads cicd.config.ts + // and any files/scripts it imports. + ...(publishAssembly + ? { + artifacts: { + files: ['**/*'], + 'exclude-paths': ['node_modules/**/*', '.npmrc', '**/.npmrc'], + }, + } : {}), - // Publish the WHOLE source tree plus the synthesized assembly, excluding only node_modules (the - // deploy re-runs `npm ci`). A hardcoded file allowlist was wrong: `cdk-cicd deploy --from-assembly` - // still loads `cicd.config.ts` under ts-node, so a config that imports another file, a tsconfig it - // compiles against, or a package.json `postinstall`/`prepare` that reads `scripts/`/`patches/` -- - // all ordinary layouts -- would be missing from the artifact and fail at the deploy stage, after - // Build had already gone green. Excluding node_modules keeps the artifact from ballooning. - ...(publishAssembly ? { artifacts: { files: ['**/*'], 'exclude-paths': ['node_modules/**/*'] } } : {}), }); + const environment = withComplianceLoggingEnvironment( + withPrivateNpmConfig( + this.buildEnvironment(scope, id, codeBuildEnvSettings, buildImage, buildImageCredentials, requiresDocker), + privateNpm, + ), + complianceLogging, + ); const project = new codebuild.PipelineProject(scope, id, { - environment: this.buildEnvironment(codeBuildEnvSettings), + environment, vpc: vpcNetworking?.vpc, securityGroups: vpcNetworking?.securityGroups, subnetSelection: vpcNetworking?.subnetSelection, @@ -700,9 +1114,13 @@ export class CodePipelineEngine implements IEngine { ? codebuild.mergeBuildSpecs(generatedBuildSpec, partialBuildSpec) : generatedBuildSpec, }); + grantCodeBuildImageCredentialKeyDecrypt(project, buildImageCredentials); if (codeArtifact) { grantCodeArtifactRead(project, codeArtifact); } + if (npmRegistry) { + grantNpmRegistrySecretRead(project, npmRegistry); + } if (proxy) { grantProxySecretRead(project, proxy); } @@ -723,14 +1141,23 @@ export class CodePipelineEngine implements IEngine { 'sts:GetServiceBearerToken on Resource "*", which CodeArtifact requires and which IAM ' + 'cannot express at resource level; it is constrained instead by a condition on ' + "sts:AWSServiceName = codeartifact.amazonaws.com, which cdk-nag's IAM5 rule does not read. " + - 'When a proxy is configured this also covers the cross-account KMS grant on key/* under the ' + - "secret's own account/region -- Secrets Manager does not expose a per-key ARN to scope to. " + 'When a VPC is configured this also covers the CodeBuild-managed network-interface permissions ' + '(ec2:CreateNetworkInterface/DescribeNetworkInterfaces/DeleteNetworkInterface/DescribeSubnets/' + 'DescribeSecurityGroups/DescribeDhcpOptions/DescribeVpcs on Resource "*"), which CDK generates ' + "for every VPC-attached CodeBuild project and which EC2 cannot scope to an ENI that doesn't " + 'exist yet.', }, + ...(environment.privileged + ? [ + { + id: 'AwsSolutions-CB5', + reason: + 'CDK applications can synthesize DockerImageAsset and Docker-based bundling inputs. ' + + 'CodeBuild privileged mode supplies the local Docker daemon those standard CDK asset ' + + 'paths require; bootstrap-role IAM grants still scope publishing to the target environments.', + }, + ] + : []), ], true, ); @@ -738,20 +1165,264 @@ export class CodePipelineEngine implements IEngine { } /** - * Merge v2 `codeBuildEnvSettings` (privileged mode, compute type, environment variables -- - * `CodeBuildFactoryProvider` parity) into a project's `environment`, applied uniformly to every - * CodeBuild project like v2 did. The engine's own `buildImage` ctor prop (a Docker-registry image - * string) wins over `codeBuildEnvSettings.buildImage` (a full `IBuildImage`) when both are set -- it - * is the more specific, code-level choice. + * Merge v2 `codeBuildEnvSettings` into a project's environment. Projects that synthesize or deploy + * applications default to privileged mode so CDK Docker assets/bundling still work; plumbing-only + * projects do not. An explicit user setting wins. A project-specific image wins over the shared one. */ - private buildEnvironment(settings?: codebuild.BuildEnvironment): codebuild.BuildEnvironment | undefined { + private buildEnvironment( + scope: Construct, + projectId: string, + settings?: codebuild.BuildEnvironment, + projectBuildImage?: string, + buildImageCredentials?: CodeBuildImageCredentials, + requiresDocker = false, + ): codebuild.BuildEnvironment { + if (projectBuildImage === undefined && buildImageCredentials !== undefined) { + throw new Error('cdk-cicd: ci.codeBuildImageCredentials requires ci.image.'); + } const buildImage = - this.buildImage !== undefined - ? codebuild.LinuxBuildImage.fromDockerRegistry(this.buildImage) + projectBuildImage !== undefined + ? buildImageFromString(scope, `${projectId}BuildImageRepository`, projectBuildImage, buildImageCredentials) : settings?.buildImage; - if (settings === undefined && buildImage === undefined) return undefined; - return { ...settings, ...(buildImage !== undefined ? { buildImage } : {}) }; + return { + ...settings, + privileged: settings?.privileged ?? requiresDocker, + ...(buildImage !== undefined ? { buildImage } : {}), + }; + } +} + +interface EcrImageReference { + readonly account: string; + readonly partition: string; + readonly region: string; + readonly repositoryName: string; + readonly tagOrDigest?: string; +} + +/** + * Build-image strings cover three credential models: + * - CodeBuild-managed images are pulled by the CodeBuild service. + * - Private ECR images are bound to an IRepository so CDK grants the project role pull access. + * - Other registry strings are public images pulled with service-role credentials. + */ +function buildImageFromString( + scope: Construct, + repositoryId: string, + image: string, + credentials?: CodeBuildImageCredentials, +): codebuild.IBuildImage { + assertValidCiImageReference(image); + if (image.startsWith('aws/codebuild/')) { + assertNoCodeBuildRegistryCredentials(image, credentials, 'managed CodeBuild'); + return codebuild.LinuxBuildImage.fromCodeBuildImageId(image); + } + + if (isPublicEcrRegistryHost(ciImageRegistryHost(image))) { + assertNoCodeBuildRegistryCredentials(image, credentials, 'public ECR'); + } + + const parsed = parseEcrImageReference(image); + if (parsed === undefined) { + assertNoUnsupportedPrivateEcrEndpoint(image); + const secret = + credentials !== undefined + ? importCodeBuildRegistrySecret(scope, `${repositoryId}RegistryCredentials`, credentials) + : undefined; + return codebuild.LinuxBuildImage.fromDockerRegistry( + image, + secret !== undefined ? { secretsManagerCredentials: secret } : undefined, + ); + } + + assertNoCodeBuildRegistryCredentials(image, credentials, 'private ECR'); + const stack = Stack.of(scope); + validatePrivateEcrBuildImageEnvironment(stack, parsed, image); + const repository = ecr.Repository.fromRepositoryAttributes(scope, repositoryId, { + repositoryName: parsed.repositoryName, + repositoryArn: `arn:${parsed.partition}:ecr:${parsed.region}:${parsed.account}:repository/${parsed.repositoryName}`, + }); + return codebuild.LinuxBuildImage.fromEcrRepository(repository, parsed.tagOrDigest); +} + +function assertNoUnsupportedPrivateEcrEndpoint(image: string): void { + const registryHost = ciImageRegistryHost(image); + if (!isPrivateEcrRegistryHost(registryHost)) return; + + throw new Error( + `cdk-cicd: private ECR CodeBuild image '${image}' does not use the canonical registry form ` + + "'.dkr.ecr..'. The installed aws-cdk-lib binds ECR build " + + 'images through that canonical endpoint; use the repository URI returned by ECR.', + ); +} + +function validatePrivateEcrBuildImageEnvironment(stack: Stack, image: EcrImageReference, imageReference: string): void { + if (Token.isUnresolved(stack.account) || Token.isUnresolved(stack.region)) { + throw new Error( + `cdk-cicd: private ECR CodeBuild image '${imageReference}' requires a concrete pipeline stack ` + + 'account and region so image access can be validated. Set the pipeline stack env.', + ); + } + if (image.region !== stack.region) { + throw new Error( + `cdk-cicd: private ECR CodeBuild image '${imageReference}' is in '${image.region}', but the ` + + `CodeBuild project is in '${stack.region}'. CodeBuild custom ECR images must be in the same ` + + 'region; replicate or mirror the image into the pipeline region.', + ); + } + if (image.account !== stack.account) { + throw new Error( + `cdk-cicd: private ECR CodeBuild image '${imageReference}' is owned by account '${image.account}', ` + + `but the flat pipeline runs in '${stack.account}'. This engine cannot create or verify the ` + + 'owner-side repository policy required for a cross-account build image; mirror the image into ' + + 'the pipeline account.', + ); + } +} + +function parseEcrImageReference(image: string): EcrImageReference | undefined { + const firstSlash = image.indexOf('/'); + if (firstSlash < 1) return undefined; + const registryHost = image.slice(0, firstSlash).toLowerCase(); + const repositoryReference = image.slice(firstSlash + 1); + if (/^\d{12}\.dkr(?:\.ecr-fips|-ecr-fips)\./.test(registryHost)) { + throw new Error( + `cdk-cicd: private ECR CodeBuild image '${image}' uses a FIPS registry endpoint. The installed ` + + 'aws-cdk-lib CodeBuild image binding accepts an ECR repository and renders its canonical registry ' + + 'URI, so it cannot preserve a requested FIPS endpoint.', + ); + } + if (/^\d{12}\.dkr-ecr\.[a-z0-9-]+\.on\.aws$/.test(registryHost)) { + throw new Error( + `cdk-cicd: private ECR CodeBuild image '${image}' uses a dual-stack registry endpoint. The installed ` + + 'aws-cdk-lib CodeBuild image binding renders the canonical dkr.ecr endpoint.', + ); + } + + const match = /^(\d{12})\.dkr\.ecr\.([a-z0-9-]+)\.(.+)$/.exec(registryHost); + if (match === null) return undefined; + + const [, account, region, registrySuffix] = match; + const regionInfo = RegionInfo.get(region); + const expectedSuffix = regionInfo.domainSuffix; + const partition = regionInfo.partition; + if (expectedSuffix === undefined || partition === undefined) { + throw new Error( + `cdk-cicd: private ECR CodeBuild image '${image}' uses region '${region}', whose partition/domain ` + + 'suffix is not known to this aws-cdk-lib version. Upgrade the wrapper/CDK before using this image.', + ); + } + if (registrySuffix !== expectedSuffix) { + throw new Error( + `cdk-cicd: private ECR CodeBuild image '${image}' has registry suffix '${registrySuffix}', but ` + + `region '${region}' belongs to partition '${partition}' and requires '${expectedSuffix}'.`, + ); } + + const digestSeparator = repositoryReference.indexOf('@'); + if (digestSeparator >= 0) { + return { + account, + partition, + region, + repositoryName: repositoryReference.slice(0, digestSeparator), + tagOrDigest: repositoryReference.slice(digestSeparator + 1), + }; + } + + const tagSeparator = repositoryReference.lastIndexOf(':'); + return { + account, + partition, + region, + repositoryName: tagSeparator >= 0 ? repositoryReference.slice(0, tagSeparator) : repositoryReference, + ...(tagSeparator >= 0 ? { tagOrDigest: repositoryReference.slice(tagSeparator + 1) } : {}), + }; +} + +function assertNoCodeBuildRegistryCredentials( + image: string, + credentials: CodeBuildImageCredentials | undefined, + imageKind: string, +): void { + if (credentials === undefined) return; + throw new Error( + `cdk-cicd: ci.codeBuildImageCredentials cannot be used with ${imageKind} ci.image '${image}'; ` + + 'only authenticated external registries use Secrets Manager registry credentials.', + ); +} + +function importCodeBuildRegistrySecret( + scope: Construct, + id: string, + credentials: CodeBuildImageCredentials, +): secretsmanager.ISecret { + if (credentials.secretArn.trim().length === 0) { + throw new Error('cdk-cicd: ci.codeBuildImageCredentials.secretArn must not be empty.'); + } + if (credentials.encryptionKeyArn !== undefined && credentials.encryptionKeyArn.trim().length === 0) { + throw new Error('cdk-cicd: ci.codeBuildImageCredentials.encryptionKeyArn must not be empty.'); + } + const encryptionKey = + credentials.encryptionKeyArn !== undefined + ? kms.Key.fromKeyArn(scope, `${id}EncryptionKey`, credentials.encryptionKeyArn) + : undefined; + return secretsmanager.Secret.fromSecretAttributes(scope, id, { + secretCompleteArn: credentials.secretArn, + ...(encryptionKey !== undefined ? { encryptionKey } : {}), + }); +} + +/** + * `fromDockerRegistry` binds the secret and grants `GetSecretValue`. In aws-cdk-lib 2.195.0, + * `Secret.grantRead` expresses a CMK grant through `ViaServicePrincipal`; an imported key has no + * mutable resource policy, so add the project-role decrypt permission explicitly. + */ +function grantCodeBuildImageCredentialKeyDecrypt( + project: codebuild.PipelineProject, + credentials: CodeBuildImageCredentials | undefined, +): void { + if (credentials?.encryptionKeyArn === undefined) return; + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['kms:Decrypt'], + resources: [credentials.encryptionKeyArn], + }), + ); +} + +/** Project-level values outrank buildspec env, so force the credential file outside the source tree here too. */ +function withPrivateNpmConfig(environment: codebuild.BuildEnvironment, enabled: boolean): codebuild.BuildEnvironment { + if (!enabled) return environment; + return { + ...environment, + environmentVariables: { + ...environment.environmentVariables, + NPM_CONFIG_USERCONFIG: { value: PRIVATE_NPM_CONFIG_PATH }, + }, + }; +} + +/** Wrapper-owned values win over user project settings so application synthesis cannot bypass logging. */ +function withComplianceLoggingEnvironment( + environment: codebuild.BuildEnvironment, + complianceLogging?: ComplianceLoggingEnvironment, +): codebuild.BuildEnvironment { + if (complianceLogging === undefined) return environment; + return { + ...environment, + environmentVariables: { + ...environment.environmentVariables, + [COMPLIANCE_LOG_BUCKET_NAME_FLAG]: { value: complianceLogging.bucketName }, + [COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG]: { value: complianceLogging.account }, + [COMPLIANCE_LOG_BUCKET_REGION_FLAG]: { value: complianceLogging.region }, + }, + }; +} + +/** Older/direct JSII callers may omit the newly introduced synthesizer object. */ +function synthesizerType(config: ResolvedCicdConfig): SynthesizerType { + return config.synthesizer?.type ?? SynthesizerType.DEFAULT; } /** @@ -829,6 +1500,104 @@ function grantCodeArtifactRead(project: codebuild.PipelineProject, ca: CodeArtif ); } +/** Create the private npm config with owner-only permissions before any login mutates it. */ +function npmConfigSetupCommands(): string[] { + return [ + `export NPM_CONFIG_USERCONFIG="${PRIVATE_NPM_CONFIG_PATH}"`, + 'rm -f "$NPM_CONFIG_USERCONFIG"', + 'umask 077 && touch "$NPM_CONFIG_USERCONFIG"', + ]; +} + +/** Remove the credential-bearing file after the build, including failed build phases. */ +function npmConfigCleanupCommands(): string[] { + return ['rm -f "$NPM_CONFIG_USERCONFIG"']; +} + +/** + * Turn CodePipeline's resolved source revision into a valid, deterministic OCI tag. + * + * Git-backed source actions normally supply a full commit hash, which remains useful as-is after + * lower-casing. S3 supplies an object revision/version identity instead, and other unexpected values + * are hashed so characters such as `/`, `+`, or `=` can never produce an invalid Docker/ECR tag. + */ +function immutableImageTagCommands(sourceType: RepositorySourceType): string[] { + const preserveGitCommit = sourceType !== RepositorySourceType.S3; + const expression = preserveGitCommit + ? '/^[0-9a-f]{40,64}$/i.test(value) ? value.toLowerCase() : hash(value)' + : 'hash(value)'; + const nodeProgram = + 'const crypto = require("crypto"); ' + + 'const value = process.argv[1]; ' + + 'const hash = (input) => crypto.createHash("sha256").update(input).digest("hex"); ' + + `process.stdout.write(${expression});`; + return [ + 'export SOURCE_REVISION="${CODEBUILD_RESOLVED_SOURCE_VERSION:?CODEBUILD_RESOLVED_SOURCE_VERSION is required for GIT_SHA image tagging}"', + `export IMAGE_TAG="$(node -e '${nodeProgram}' "$SOURCE_REVISION")"`, + ]; +} + +/** + * Write the generic registry into the temporary npm config. The token itself is injected by CodeBuild + * from Secrets Manager as `NPM_AUTH_TOKEN`, so it never appears in the synthesized buildspec. + */ +function npmRegistryLoginCommands(npm: NpmRegistryConfig): string[] { + const host = npm.url.replace(/^https?:\/\//, ''); + const scope = npm.scope !== undefined && npm.scope.length > 0 ? npm.scope : undefined; + const scopePrefix = scope !== undefined ? `${scope.startsWith('@') ? scope : `@${scope}`}:` : ''; + return [ + `echo "${scopePrefix}registry=${npm.url}" > "$NPM_CONFIG_USERCONFIG"`, + `echo "//${host}:_authToken=$NPM_AUTH_TOKEN" >> "$NPM_CONFIG_USERCONFIG"`, + ]; +} + +/** Buildspec environment shared by proxy and generic npm-registry authentication. */ +function buildSpecEnvironment( + stack: Stack, + proxy?: ProxyConfig, + npmRegistry?: NpmRegistryConfig, + codeArtifact?: CodeArtifactConfig, +): + | { + readonly variables?: Record; + readonly 'secrets-manager'?: Record; + } + | undefined { + const privateNpm = npmRegistry !== undefined || codeArtifact !== undefined; + if (proxy === undefined && !privateNpm) return undefined; + const variables = { + ...(proxy !== undefined ? proxyEnvVariables(stack, proxy) : {}), + ...(privateNpm ? { NPM_CONFIG_USERCONFIG: PRIVATE_NPM_CONFIG_PATH } : {}), + }; + const secretsManager = { + ...(proxy !== undefined ? proxySecretsManagerVars(proxy) : {}), + ...(npmRegistry !== undefined ? { NPM_AUTH_TOKEN: npmRegistry.basicAuthSecretArn } : {}), + }; + return { + ...(Object.keys(variables).length > 0 ? { variables } : {}), + ...(Object.keys(secretsManager).length > 0 ? { 'secrets-manager': secretsManager } : {}), + }; +} + +/** The generic registry's bearer token and optional CMK are scoped to their exact ARNs. */ +function grantNpmRegistrySecretRead(project: codebuild.PipelineProject, npmRegistry: NpmRegistryConfig): void { + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [npmRegistry.basicAuthSecretArn], + }), + ); + const encryptionKeyArn = npmRegistry.encryptionKeyArn?.trim(); + if (encryptionKeyArn !== undefined && encryptionKeyArn.length > 0) { + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['kms:Decrypt'], + resources: [encryptionKeyArn], + }), + ); + } +} + /** * Plain (non-secret) proxy env vars every build project needs (v2 `CodeBuildFactoryProvider` parity). * An empty `noProxy` defaults to the project's own region's AWS endpoint, so AWS API calls (like @@ -836,7 +1605,10 @@ function grantCodeArtifactRead(project: codebuild.PipelineProject, ca: CodeArtif * through it. */ function proxyEnvVariables(stack: Stack, proxy: ProxyConfig): Record { - const noProxy = proxy.noProxy.length > 0 ? proxy.noProxy : [`${stack.region}.amazonaws.com`]; + const domainSuffix = Token.isUnresolved(stack.region) + ? stack.urlSuffix + : (RegionInfo.get(stack.region).domainSuffix ?? stack.urlSuffix); + const noProxy = proxy.noProxy.length > 0 ? proxy.noProxy : [`${stack.region}.${domainSuffix}`]; return { AWS_STS_REGIONAL_ENDPOINTS: 'regional', NO_PROXY: noProxy.join(','), @@ -865,22 +1637,20 @@ function proxyInstallCommands(proxy: ProxyConfig): string[] { ]; } -/** The read grant the proxy secret needs, plus cross-account KMS decrypt when the secret lives elsewhere. */ +/** The read grant the proxy secret and its optional customer-managed KMS key need. */ function grantProxySecretRead(project: codebuild.PipelineProject, proxy: ProxyConfig): void { - const stack = Stack.of(project); project.addToRolePolicy( new iam.PolicyStatement({ actions: ['secretsmanager:GetSecretValue'], resources: [proxy.proxySecretArn], }), ); - const secretAccount = Arn.split(proxy.proxySecretArn, ArnFormat.SLASH_RESOURCE_NAME).account; - if (secretAccount !== undefined && secretAccount !== stack.account) { - const secretRegion = Arn.split(proxy.proxySecretArn, ArnFormat.SLASH_RESOURCE_NAME).region; + const encryptionKeyArn = proxy.encryptionKeyArn?.trim(); + if (encryptionKeyArn !== undefined && encryptionKeyArn.length > 0) { project.addToRolePolicy( new iam.PolicyStatement({ - actions: ['kms:Decrypt', 'kms:DescribeKey', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], - resources: [`arn:${stack.partition}:kms:${secretRegion}:${secretAccount}:key/*`], + actions: ['kms:Decrypt'], + resources: [encryptionKeyArn], }), ); } diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/codepipeline/DeploymentPipeline.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/codepipeline/DeploymentPipeline.ts index a061c2e9..6dbeadff 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/codepipeline/DeploymentPipeline.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/codepipeline/DeploymentPipeline.ts @@ -3,29 +3,52 @@ // // The CD (deploy-side) CodePipeline of the container two-repo split (m6-container, Repo 2). Where the CI // pipeline (CodePipelineEngine + `deployerImage`) builds & pushes config-agnostic image(s), THIS pipeline -// consumes them: a config-only source repo (the `deploy.config.ts`, no CDK code) triggers a CodePipeline -// with one Deploy action per target. Each target deploys from ITS OWN image version -- the tag/digest lives -// on the target in deploy.config and is read at RUN time -- so bumping one stage's image and committing -// deploys only that stage. Non-gated targets deploy in parallel; a gated target waits on a manual approval. +// consumes them: a config-only source repo (the `deploy.config.ts`, no CDK code) triggers a CodePipeline. +// Sequential targets use one Deploy action; parallel multi-region targets use one action per region. Each +// target deploys from ITS OWN image version -- the tag/digest lives on the target in deploy.config and is +// read at RUN time -- so bumping one stage's image and committing deploys only that stage. Contiguous +// non-gated targets deploy in parallel; a gated target waits on one manual approval before its deployment +// action(s) and gates every target declared after it. // // Source -> Deploy (per-target privileged CodeBuild actions). Each action runs `cdk-cicd deploy --from-image // --target `, which pulls that target's image and synth-and-deploys the stage offline in-container. -import { DefaultStackSynthesizer, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { createHash } from 'crypto'; +import { Annotations, RemovalPolicy, Stack, Token } from 'aws-cdk-lib'; import * as codebuild from 'aws-cdk-lib/aws-codebuild'; import * as codepipeline from 'aws-cdk-lib/aws-codepipeline'; import * as actions from 'aws-cdk-lib/aws-codepipeline-actions'; +import * as ecr from 'aws-cdk-lib/aws-ecr'; import * as iam from 'aws-cdk-lib/aws-iam'; +import { RegionInfo } from 'aws-cdk-lib/region-info'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; import { buildSourceAction } from './source'; -import { NpmRegistryConfig, ResolvedDeploymentConfig } from '../../config/types'; +import { + resolveDefaultSynthesizerQualifier, + specializeDefaultSynthesizerRoleArn, +} from '../../config/default-synthesizer-role-arn'; +import { + NpmRegistryConfig, + RegionOrder, + ResolvedDeploymentConfig, + ResolvedDeploymentTarget, + SynthesizerType, +} from '../../config/types'; import { SupportResources } from '../../support/SupportResources'; +import { deployRoleExternalIdSecretArnsForStages } from '../external-id-secrets'; /** Node runtime for the CD build image's install phase (kept in step with the CI engine). */ const NODE_RUNTIME_VERSION = 22; +/** Kept outside CODEBUILD_SRC_DIR so registry credentials cannot enter or modify the config checkout. */ +const PRIVATE_NPM_CONFIG_PATH = '/tmp/cdk-cicd-npmrc'; /** The CDK bootstrap roles `cdk deploy` assumes (same set the CI engine grants). */ const BOOTSTRAP_ROLE_KINDS = ['deploy', 'file-publishing', 'image-publishing', 'lookup']; +/** Fixed CodePipeline service quotas that cannot be raised. */ +const MAX_ACTIONS_PER_STAGE = 100; +const MAX_ACTIONS_PER_PIPELINE = 1_000; +const MAX_STAGES_PER_PIPELINE = 50; +const CODEPIPELINE_IDENTIFIER = /^[A-Za-z0-9.@_-]{1,100}$/; /** Options for the CD deployment pipeline. */ export interface DeploymentPipelineProps { @@ -38,11 +61,14 @@ export interface DeploymentPipelineProps { } /** - * Renders the CD CodePipeline into `scope` (a Stack): Source (the config repo) -> a "Deploy" stage with one - * privileged-CodeBuild action per ungated target (parallel), then a "DeployGated" stage with the gated - * targets, each behind its own manual approval. Each action runs `cdk-cicd deploy --from-image --target - * ` -- pulling that target's own image version, read from deploy.config at run time. The CLI is - * installed from the source repo's `package.json` (`npm ci`), so the config repo carries no CDK code. + * Renders the CD CodePipeline into `scope` (a Stack): Source (the config repo) followed by ordered + * deployment stages. Each contiguous run of ungated targets shares one stage and can deploy in parallel. + * Each gated target has its own stage, with its approval at run order 1 and only that target's deploy + * action(s) at run order 2, so the gate blocks every later target without reordering the declaration. + * A sequential target uses one action for all regions; a parallel multi-region target fans out one action + * per region. Each action runs `cdk-cicd deploy --from-image --target ` -- pulling that target's own + * image version, read from deploy.config at run time. The CLI is installed from the source repo's + * `package.json` (`npm ci`), so the config repo carries no CDK code. */ export class DeploymentPipeline extends Construct { public readonly pipeline: codepipeline.Pipeline; @@ -58,6 +84,81 @@ export class DeploymentPipeline extends Construct { } const removalPolicy = props.removalPolicy; const stack = Stack.of(this); + const qualifier = resolveDefaultSynthesizerQualifier(this, config.qualifier); + + // Duplicate target stages would collide on action names and state parameters -- reject them early. + const names = config.targets.map((t) => t.stage); + const dup = names.find((s, i) => names.indexOf(s) !== i); + if (dup !== undefined) { + throw new Error(`cdk-cicd: duplicate deploy.config target stage '${dup}' -- each target needs a unique stage`); + } + const effectiveTargets = config.targets.map((target) => resolveDeploymentTargetEnvironment(stack, target)); + const synthesizer = deploymentSynthesizer(config); + if (synthesizer.type === SynthesizerType.APP_STAGING) { + throw new Error( + 'cdk-cicd: APP_STAGING cannot be deployed by the Repo 2 CodePipeline. The pinned alpha emits ' + + 'DefaultStagingStack with BootstraplessSynthesizer, so that support stack is deployed with the ' + + "CodeBuild project's base credentials instead of the configured deployment role. Use " + + 'SynthesizerType.DEFAULT for Repo 2, or run `cdk-cicd deploy --from-image` directly with ' + + 'appropriately privileged credentials.', + ); + } + const deploymentUnits = effectiveTargets.flatMap((target) => deploymentUnitsForTarget(stack, this, target)); + const deploymentStages = deploymentStagePlans(config.targets); + validateDeploymentTopology(deploymentStages, deploymentUnits); + validateDeploymentPartitions(stack, effectiveTargets); + const pipelinePartition = RegionInfo.get(stack.region).partition; + if (pipelinePartition === undefined) { + throw new Error( + `cdk-cicd: Repo 2 pipeline region '${stack.region}' has no known AWS partition in this aws-cdk-lib version.`, + ); + } + const expectedDeploymentTopology = deploymentPipelineShapeFingerprint( + config, + effectiveTargets, + pipelinePartition, + qualifier, + ); + const externalIdSecretArns = deployRoleExternalIdSecretArnsForStages( + config.targets.map((target) => ({ + name: target.stage, + env: target.env, + manualApproval: target.manualApproval, + deployment: target.deployment, + })), + ); + + // Log in to every distinct ECR registry across the targets' effective images and grant pull access to + // every distinct repository. The build verifies the synth-time pipeline shape before deploying, so + // registry/repository, role, account/region, synthesizer, or action-topology changes fail with an + // instruction to re-run `deploy-ci`; ordinary tag/version changes remain runtime deployments. + const images = config.targets.map((t) => t.image ?? config.image).filter((i): i is string => i !== undefined); + const ecrRepositories = new Map(); + const ecrHosts = new Map(); + for (const image of images) { + const repository = parseEcrRepository(image); + if (repository !== undefined) { + validateEcrRepositoryAccount( + this, + stack, + repository, + image, + config.crossAccountEcrRepositoryPolicyConfigured ?? false, + ); + ecrRepositories.set(`${repository.account}:${repository.region}:${repository.repositoryName}`, repository); + ecrHosts.set(repository.registryHost, repository.region); + } + } + const buildImage = deploymentBuildImage( + this, + stack, + props.buildImage, + config.crossAccountEcrRepositoryPolicyConfigured ?? false, + ); + const ecrLoginCommands = [...ecrHosts].map( + ([host, region]) => + `aws ecr get-login-password --region ${region} | docker login --username AWS --password-stdin ${host}`, + ); const sourceOutput = new codepipeline.Artifact(); const support = new SupportResources(this, 'Support', { removalPolicy }); @@ -65,18 +166,9 @@ export class DeploymentPipeline extends Construct { pipeline.addStage({ stageName: 'Source', actions: [buildSourceAction(this, config.repository, sourceOutput)] }); - // Log in to every distinct ECR registry across the targets' images (config default + per-target - // overrides). The registry host is stable across version bumps -- only the tag changes, and the tag is - // read from deploy.config at run time -- so logging in to the provision-time set of hosts is enough. - const images = config.targets.map((t) => t.image ?? config.image).filter((i): i is string => i !== undefined); - const ecrHosts = new Set(images.map((i) => i.split('/')[0]).filter((h) => h.includes('.dkr.ecr.'))); - const ecrLoginCommands = [...ecrHosts].map( - (h) => - `aws ecr get-login-password --region ${h.split('.')[3]} | docker login --username AWS --password-stdin ${h}`, - ); - // Optional CodeArtifact login so `npm ci` can install a pre-release wrapper CLI. const ca = config.codeArtifact; + const privateNpm = ca !== undefined || config.npmRegistry !== undefined; const codeArtifactLogin = ca ? [ `aws codeartifact login --tool npm --domain ${ca.domain} --domain-owner ${ca.account ?? stack.account} ` + @@ -94,22 +186,60 @@ export class DeploymentPipeline extends Construct { // a stage's image tag and committing deploys only that stage. Privileged for docker; creds materialized // to static env vars (CodeBuild serves them via the container-credentials endpoint) so // `deploy --from-image` can forward them into the deployer container by name. + const fingerprintCommand = + `TARGET_FINGERPRINT=$(TS_NODE_COMPILER_OPTIONS='{"module":"commonjs"}' ` + + `node -r ts-node/register/transpile-only -e ${shellQuote( + deploymentFingerprintScript(effectiveTargets, pipelinePartition, qualifier), + )})`; + const prepareParallelTarget = + `TS_NODE_COMPILER_OPTIONS='{"module":"commonjs"}' ` + + `node -r ts-node/register/transpile-only -e ${shellQuote(parallelTargetConfigScript())}`; + const deployTarget = 'npm run cdk-cicd -- deploy --from-image --target "$TARGET_STAGE" --yes'; + const readPreviousFingerprint = [ + 'if PREVIOUS_TARGET_FINGERPRINT=$(aws ssm get-parameter --name "$TARGET_STATE_PARAMETER" ' + + '--query "Parameter.Value" --output text 2>/tmp/cdk-cicd-target-state-error); then', + ' :', + 'elif grep -q "ParameterNotFound" /tmp/cdk-cicd-target-state-error; then', + ' PREVIOUS_TARGET_FINGERPRINT=""', + 'else', + ' cat /tmp/cdk-cicd-target-state-error >&2', + ' exit 1', + 'fi', + ].join('\n'); + const deployAndRecord = [ + '{', + ' if [ -n "${TARGET_REGION:-}" ]; then', + ` ${prepareParallelTarget} &&`, + ` (cd .cdk-cicd-target && ${deployTarget})`, + ' else', + ` ${deployTarget}`, + ' fi', + '} && aws ssm put-parameter --name "$TARGET_STATE_PARAMETER" --type String ' + + '--value "$TARGET_FINGERPRINT" --overwrite >/dev/null', + ].join('\n'); const commands = [ - ...ecrLoginCommands, + ...(privateNpm ? npmConfigSetupCommands() : []), ...codeArtifactLogin, ...npmRegistryLogin, 'npm ci', + ...(privateNpm ? ['rm -f "$NPM_CONFIG_USERCONFIG"', 'unset NPM_AUTH_TOKEN'] : []), + fingerprintCommand, + 'if [ "${#TARGET_FINGERPRINT}" -ne 64 ] || printf "%s" "$TARGET_FINGERPRINT" | grep -q "[^0-9a-f]"; then ' + + 'echo "cdk-cicd: could not compute a valid target fingerprint" >&2; exit 1; fi', + readPreviousFingerprint, + 'if [ "$PREVIOUS_TARGET_FINGERPRINT" = "$TARGET_FINGERPRINT" ]; then ' + + 'echo "cdk-cicd: target $TARGET_STAGE is unchanged; skipping deployment"; exit 0; fi', + ...ecrLoginCommands, 'eval "$(aws configure export-credentials --format env 2>/dev/null)" || { ' + 'CREDS=$(curl -s "http://169.254.170.2${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}"); ' + 'export AWS_ACCESS_KEY_ID=$(echo "$CREDS" | jq -r .AccessKeyId); ' + 'export AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | jq -r .SecretAccessKey); ' + 'export AWS_SESSION_TOKEN=$(echo "$CREDS" | jq -r .Token); }', - 'npx cdk-cicd deploy --from-image --target "$TARGET_STAGE" --yes', + deployAndRecord, ]; const project = new codebuild.PipelineProject(this, 'Deploy', { environment: { - buildImage: - props.buildImage !== undefined ? codebuild.LinuxBuildImage.fromDockerRegistry(props.buildImage) : undefined, + buildImage, privileged: true, }, buildSpec: codebuild.BuildSpec.fromObject({ @@ -118,43 +248,55 @@ export class DeploymentPipeline extends Construct { ...(props.buildImage === undefined ? { install: { 'runtime-versions': { nodejs: NODE_RUNTIME_VERSION } } } : {}), - build: { commands }, + build: { + commands, + ...(privateNpm ? { finally: ['rm -f "$NPM_CONFIG_USERCONFIG"'] } : {}), + }, }, - // The bearer token `npmRegistryLoginCommands` writes into .npmrc; resolved by CodeBuild at - // container start, not read via a shell `aws secretsmanager` call. + // The bearer token `npmRegistryLoginCommands` writes into the temporary npm config; resolved + // by CodeBuild at container start, not read via a shell `aws secretsmanager` call. ...(npmRegistry ? { env: { 'secrets-manager': { NPM_AUTH_TOKEN: npmRegistry.basicAuthSecretArn } } } : {}), }), }); // The deploy build runs `cdk deploy` per target, which does everything through the CDK bootstrap // roles -- so the project's role needs permission to assume them in EACH target's account/region (plus - // any forced deployer role). This mirrors the CI engine's grantDeployPermissions. The qualifier is the - // bootstrap default because that is what the wrapper's synthesizer uses; an app on a custom - // bootstrapQualifier would need its own roles granted (finding - // code-review-bootstrap-qualifier-not-single-source-of-truth). - const qualifier = DefaultStackSynthesizer.DEFAULT_QUALIFIER; + // any forced deployer role). This mirrors the CI engine's grantDeployPermissions. Repo 2 repeats + // the image's qualifier/synthesizer identity so this pipeline can name the same bootstrap and + // bootstrap roles without inspecting the image at synth time. const roleArns = new Set(); const versionParams = new Set(); - for (const target of config.targets) { - const account = target.env.account; - // A target with no explicit account deploys under the pipeline's ambient account; we cannot name its - // bootstrap roles at synth time, so the project's own identity (or a forced role) must cover it. - if (account !== undefined) { - for (const region of target.env.regions) { - for (const kind of BOOTSTRAP_ROLE_KINDS) { - roleArns.add( - `arn:${stack.partition}:iam::${account}:role/cdk-${qualifier}-${kind}-role-${account}-${region}`, + for (const effectiveTarget of effectiveTargets) { + const { target, account, regions } = effectiveTarget; + for (const region of regions) { + for (const kind of BOOTSTRAP_ROLE_KINDS) { + roleArns.add( + `arn:${stack.partition}:iam::${account}:role/cdk-${qualifier}-${kind}-role-${account}-${region}`, + ); + } + versionParams.add( + `arn:${stack.partition}:ssm:${region}:${account}:parameter/cdk-bootstrap/${qualifier}/version`, + ); + // CDK specializes configured role placeholders independently for every target stack. + const forced = target.deployment?.deployRole?.trim(); + if (forced !== undefined && forced.length > 0) { + const targetPartition = RegionInfo.get(region).partition; + if (targetPartition === undefined) { + throw new Error( + `cdk-cicd: deployment target '${target.stage}' uses region '${region}', whose AWS ` + + 'partition is not known to this aws-cdk-lib version.', ); } - versionParams.add( - `arn:${stack.partition}:ssm:${region}:${account}:parameter/cdk-bootstrap/${qualifier}/version`, + roleArns.add( + specializeDefaultSynthesizerRoleArn(forced, { + qualifier, + account, + region, + partition: targetPartition, + }), ); } } - // A stage's `deployRole` is a CloudFormation SERVICE role (passed as --role-arn); granting the - // project sts:AssumeRole on it mirrors the CI engine and covers the case where the CLI assumes it. - const forced = target.deployment?.deployRole; - if (forced !== undefined && forced.length > 0) roleArns.add(forced); } if (roleArns.size > 0) { project.addToRolePolicy(new iam.PolicyStatement({ actions: ['sts:AssumeRole'], resources: [...roleArns] })); @@ -164,6 +306,29 @@ export class DeploymentPipeline extends Construct { new iam.PolicyStatement({ actions: ['ssm:GetParameter'], resources: [...versionParams] }), ); } + if (ecrRepositories.size > 0) { + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['ecr:GetAuthorizationToken'], + // ECR does not support resource-level permissions for authorization tokens. + resources: ['*'], + }), + ); + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: [ + 'ecr:BatchCheckLayerAvailability', + 'ecr:BatchGetImage', + 'ecr:DescribeImages', + 'ecr:GetDownloadUrlForLayer', + ], + resources: [...ecrRepositories.values()].map( + (repository) => + `arn:${repository.partition}:ecr:${repository.region}:${repository.account}:repository/${repository.repositoryName}`, + ), + }), + ); + } // CodeArtifact read for the build's `npm ci` (pre-release CLI install). if (ca) { const caAccount = ca.account ?? stack.account; @@ -198,40 +363,78 @@ export class DeploymentPipeline extends Construct { resources: [npmRegistry.basicAuthSecretArn], }), ); + const encryptionKeyArn = npmRegistry.encryptionKeyArn; + if (encryptionKeyArn !== undefined && encryptionKeyArn.length > 0) { + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['kms:Decrypt'], + resources: [encryptionKeyArn], + }), + ); + } + } + // `deploy --from-image` resolves target ExternalIds before launching Docker. Only targets with a + // forced deploy role contribute a secret ARN, matching the CLI's effective-role contract. + if (externalIdSecretArns.length > 0) { + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: externalIdSecretArns, + }), + ); } - // Each target deploys from its OWN image version (read from deploy.config at run time via `--target`). - // Duplicate target stages would collide on action names -- reject them early with a clear message. - const names = config.targets.map((t) => t.stage); - const dup = names.find((s, i) => names.indexOf(s) !== i); - if (dup !== undefined) { - throw new Error(`cdk-cicd: duplicate deploy.config target stage '${dup}' -- each target needs a unique stage`); + // Sequential targets get one state parameter for the whole rollout; parallel targets get one per + // region. A successful region can therefore never mask another region's failed/missing deployment. + const stateParameterNames = new Set(deploymentUnits.map((unit) => unit.stateParameterName)); + if (stateParameterNames.size > 0) { + project.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['ssm:GetParameter', 'ssm:PutParameter'], + resources: [...stateParameterNames.values()].map( + (parameterName) => `arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${parameterName}`, + ), + }), + ); } - const deployAction = (target: (typeof config.targets)[number], runOrder?: number) => + + // Each deployment unit reads its target's OWN image version at run time. TARGET_REGION is present only + // for a RegionOrder.PARALLEL fan-out action; the build then narrows an ephemeral deploy.config to that + // region before invoking the existing CLI, whose from-image executor remains the source of truth for + // image resolution, account/role environment, and the inner `deploy --region` command. + const deployAction = (unit: DeploymentUnit, runOrder?: number) => new actions.CodeBuildAction({ - actionName: `Deploy-${target.stage}`, + actionName: unit.actionName, project, input: sourceOutput, runOrder, - environmentVariables: { TARGET_STAGE: { value: target.stage } }, + environmentVariables: { + TARGET_STAGE: { value: unit.target.stage }, + TARGET_STATE_PARAMETER: { value: unit.stateParameterName }, + EXPECTED_DEPLOYMENT_TOPOLOGY: { value: expectedDeploymentTopology }, + ...(unit.region !== undefined ? { TARGET_REGION: { value: unit.region } } : {}), + }, }); + const actionsForTarget = (target: ResolvedDeploymentTarget, runOrder?: number) => + deploymentUnits.filter((unit) => unit.target === target).map((unit) => deployAction(unit, runOrder)); - // Two stages, so a pending gated approval never blocks the ungated targets: - // - "Deploy": every ungated target, in parallel (bump one, e.g. dev, and only it redeploys). - // - "DeployGated": every gated target, each behind its own manual approval; the deploys then run in - // parallel after approval (int + prod promote together). - const ungated = config.targets.filter((t) => !t.manualApproval); - const gated = config.targets.filter((t) => t.manualApproval); - if (ungated.length > 0) { - pipeline.addStage({ stageName: 'Deploy', actions: ungated.map((t) => deployAction(t)) }); - } - if (gated.length > 0) { - const gatedActions: codepipeline.IAction[] = []; - for (const target of gated) { - gatedActions.push(new actions.ManualApprovalAction({ actionName: `Approve-${target.stage}`, runOrder: 1 })); - gatedActions.push(deployAction(target, 2)); - } - pipeline.addStage({ stageName: 'DeployGated', actions: gatedActions }); + // Render the exact plan used by quota validation. A gate always precedes every later target, while only + // adjacent ungated targets are grouped into the same parallel CodePipeline stage. + for (const deploymentStage of deploymentStages) { + const approvalTarget = deploymentStage.approvalTarget; + pipeline.addStage({ + stageName: deploymentStage.name, + actions: + approvalTarget === undefined + ? deploymentStage.targets.flatMap((target) => actionsForTarget(target)) + : [ + new actions.ManualApprovalAction({ + actionName: `Approve-${approvalTarget.stage}`, + runOrder: 1, + }), + ...actionsForTarget(approvalTarget, 2), + ], + }); } // cdk-nag suppressions, mirroring the CI engine so a real `deploy-ci` synth (which runs @@ -242,7 +445,8 @@ export class DeploymentPipeline extends Construct { { id: 'AwsSolutions-IAM5', reason: - 'CodeBuild default log/report/artifact wildcards, plus scoped sts:AssumeRole on the CDK bootstrap roles.', + 'CodeBuild default log/report/artifact wildcards, ECR authorization tokens, plus scoped ' + + 'sts:AssumeRole on the CDK bootstrap roles.', }, { id: 'AwsSolutions-CB3', @@ -270,15 +474,869 @@ export class DeploymentPipeline extends Construct { } /** - * Writes a `.npmrc` authenticating npm against a generic private registry (mirrors the CI engine's - * `npmRegistryLogin`; see there for the v2 `npm-login.sh` provenance). + * Writes generic-registry credentials to the temporary npm config. */ function npmRegistryLoginCommands(npm: NpmRegistryConfig): string[] { const host = npm.url.replace(/^https?:\/\//, ''); const scope = npm.scope !== undefined && npm.scope.length > 0 ? npm.scope : undefined; const scopePrefix = scope !== undefined ? `${scope.startsWith('@') ? scope : `@${scope}`}:` : ''; return [ - `echo "${scopePrefix}registry=${npm.url}" > ./.npmrc`, - `echo "//${host}:_authToken=$NPM_AUTH_TOKEN" >> ./.npmrc`, + `echo "${scopePrefix}registry=${npm.url}" > "$NPM_CONFIG_USERCONFIG"`, + `echo "//${host}:_authToken=$NPM_AUTH_TOKEN" >> "$NPM_CONFIG_USERCONFIG"`, ]; } + +/** Create the credential file outside the source checkout with owner-only permissions. */ +function npmConfigSetupCommands(): string[] { + return [ + `export NPM_CONFIG_USERCONFIG="${PRIVATE_NPM_CONFIG_PATH}"`, + 'rm -f "$NPM_CONFIG_USERCONFIG"', + 'umask 077 && touch "$NPM_CONFIG_USERCONFIG"', + ]; +} + +interface EcrRepository { + readonly registryHost: string; + readonly account: string; + readonly partition: string; + readonly region: string; + readonly repositoryName: string; + readonly tagOrDigest?: string; +} + +interface EffectiveDeploymentTarget { + readonly target: ResolvedDeploymentTarget; + readonly account: string; + readonly regions: string[]; + readonly regionOrder: RegionOrder; +} + +interface DeploymentUnit { + readonly target: ResolvedDeploymentTarget; + /** Present only when a PARALLEL multi-region target is narrowed to one region. */ + readonly region?: string; + readonly actionName: string; + readonly stateParameterName: string; +} + +interface DeploymentStagePlan { + readonly name: string; + readonly targets: readonly ResolvedDeploymentTarget[]; + /** Present only for a one-target stage that requires approval before its deployment action(s). */ + readonly approvalTarget?: ResolvedDeploymentTarget; +} + +/** Resolve an omitted target account/region against the pipeline stack before creating topology or IAM. */ +function resolveDeploymentTargetEnvironment(stack: Stack, target: ResolvedDeploymentTarget): EffectiveDeploymentTarget { + const account = + target.env.account === undefined + ? concretePipelineEnvironmentValue(stack.account, 'account', `deployment target '${target.stage}'`) + : concreteTargetEnvironmentValue(target.env.account, 'account', target.stage); + const regions = + target.env.regions.length === 0 + ? [concretePipelineEnvironmentValue(stack.region, 'region', `deployment target '${target.stage}'`)] + : target.env.regions.map((region) => concreteTargetEnvironmentValue(region, 'region', target.stage)); + return { + target, + account, + regions, + regionOrder: target.env.regionOrder, + }; +} + +function concretePipelineEnvironmentValue(value: string, field: 'account' | 'region', purpose: string): string { + if (value.length === 0 || Token.isUnresolved(value)) { + throw new Error( + `cdk-cicd: ${purpose} needs a concrete ${field}, but the deployment pipeline stack's ${field} ` + + `is unresolved. Set the pipeline stack env or provide target.env.${field}.`, + ); + } + return value; +} + +function concreteTargetEnvironmentValue(value: string, field: 'account' | 'region', stage: string): string { + if (value.length === 0 || Token.isUnresolved(value)) { + throw new Error( + `cdk-cicd: deployment target '${stage}' has an unresolved env.${field}; Repo 2 must know concrete ` + + `${field} values when it builds deployment actions and bootstrap IAM.`, + ); + } + return value; +} + +function validateDeploymentPartitions(stack: Stack, targets: readonly EffectiveDeploymentTarget[]): void { + const pipelineRegion = concretePipelineEnvironmentValue( + stack.region, + 'region', + 'the Repo 2 deployment pipeline partition validation', + ); + const pipelinePartition = RegionInfo.get(pipelineRegion).partition; + if (pipelinePartition === undefined) { + throw new Error( + `cdk-cicd: Repo 2 pipeline region '${pipelineRegion}' has no known AWS partition in this ` + + 'aws-cdk-lib version. Upgrade the wrapper/CDK before rendering the pipeline.', + ); + } + + for (const target of targets) { + for (const region of target.regions) { + const targetPartition = RegionInfo.get(region).partition; + if (targetPartition === undefined) { + throw new Error( + `cdk-cicd: deployment target '${target.target.stage}' uses region '${region}', whose AWS ` + + 'partition is not known to this aws-cdk-lib version. Upgrade the wrapper/CDK before using it.', + ); + } + if (targetPartition !== pipelinePartition) { + throw new Error( + `cdk-cicd: deployment target '${target.target.stage}' is in partition '${targetPartition}' ` + + `(${region}), but the Repo 2 pipeline is in '${pipelinePartition}' (${pipelineRegion}). IAM ` + + 'role assumption and ECR authentication cannot cross AWS partitions; use a pipeline in the ' + + 'target partition.', + ); + } + } + } +} + +/** Expand only PARALLEL multi-region targets; every other target remains one sequential CLI invocation. */ +function deploymentUnitsForTarget( + stack: Stack, + scope: Construct, + effectiveTarget: EffectiveDeploymentTarget, +): DeploymentUnit[] { + const { target, regions, regionOrder } = effectiveTarget; + if (regionOrder === RegionOrder.PARALLEL && regions.length > 1) { + return regions.map((region) => ({ + target, + region, + actionName: `Deploy-${target.stage}-${region}`, + stateParameterName: deploymentStateParameterName(stack, scope, target.stage, region), + })); + } + return [ + { + target, + actionName: `Deploy-${target.stage}`, + stateParameterName: deploymentStateParameterName(stack, scope, target.stage), + }, + ]; +} + +/** Preserve target declaration order while grouping only adjacent ungated targets into parallel waves. */ +function deploymentStagePlans(targets: readonly ResolvedDeploymentTarget[]): DeploymentStagePlan[] { + const stages: DeploymentStagePlan[] = []; + let ungatedTargets: ResolvedDeploymentTarget[] = []; + const appendStage = ( + stageTargets: readonly ResolvedDeploymentTarget[], + approvalTarget?: ResolvedDeploymentTarget, + ): void => { + stages.push({ + name: `Deploy-${stages.length + 1}`, + targets: [...stageTargets], + approvalTarget, + }); + }; + const flushUngatedTargets = (): void => { + if (ungatedTargets.length === 0) return; + appendStage(ungatedTargets); + ungatedTargets = []; + }; + + for (const target of targets) { + if (target.manualApproval) { + flushUngatedTargets(); + appendStage([target], target); + } else { + ungatedTargets.push(target); + } + } + flushUngatedTargets(); + return stages; +} + +/** Reject CodePipeline shapes that exceed fixed service quotas before any actions are rendered. */ +function validateDeploymentTopology( + deploymentStages: readonly DeploymentStagePlan[], + deploymentUnits: readonly DeploymentUnit[], +): void { + const unitsForTarget = (target: ResolvedDeploymentTarget) => deploymentUnits.filter((unit) => unit.target === target); + const stages = [ + { name: 'Source', actionNames: ['Source'] }, + ...deploymentStages.map((stage) => ({ + name: stage.name, + actionNames: [ + ...(stage.approvalTarget === undefined ? [] : [`Approve-${stage.approvalTarget.stage}`]), + ...stage.targets.flatMap((target) => unitsForTarget(target).map((unit) => unit.actionName)), + ], + })), + ]; + + const duplicateStage = stages.find( + (stage, index) => stages.findIndex((candidate) => candidate.name === stage.name) !== index, + ); + if (duplicateStage !== undefined) { + throw new Error(`cdk-cicd: generated duplicate CodePipeline stage name '${duplicateStage.name}'`); + } + for (const stage of stages) { + validateCodePipelineIdentifier('stage', stage.name); + for (const actionName of stage.actionNames) { + validateCodePipelineIdentifier('action', actionName); + } + const duplicateAction = stage.actionNames.find( + (actionName, index) => stage.actionNames.indexOf(actionName) !== index, + ); + if (duplicateAction !== undefined) { + throw new Error( + `cdk-cicd: CodePipeline stage '${stage.name}' would contain duplicate action name ` + + `'${duplicateAction}'. Rename the target stage or remove duplicate parallel regions.`, + ); + } + } + + const oversizedStage = stages.find((stage) => stage.actionNames.length > MAX_ACTIONS_PER_STAGE); + if (oversizedStage !== undefined) { + throw new Error( + `cdk-cicd: CodePipeline stage '${oversizedStage.name}' would contain ${oversizedStage.actionNames.length} ` + + `actions, exceeding the fixed ${MAX_ACTIONS_PER_STAGE}-action service quota. Reduce parallel regions ` + + 'or targets in that contiguous ungated wave, or split the deployment across pipelines.', + ); + } + if (stages.length > MAX_STAGES_PER_PIPELINE) { + throw new Error( + `cdk-cicd: the deployment pipeline would contain ${stages.length} stages, exceeding the fixed ` + + `${MAX_STAGES_PER_PIPELINE}-stage service quota. Reduce approval barriers/ungated waves or split the ` + + 'deployment across pipelines.', + ); + } + const totalActions = stages.reduce((count, stage) => count + stage.actionNames.length, 0); + if (totalActions > MAX_ACTIONS_PER_PIPELINE) { + throw new Error( + `cdk-cicd: the deployment pipeline would contain ${totalActions} actions, exceeding the fixed ` + + `${MAX_ACTIONS_PER_PIPELINE}-action service quota. Reduce targets/regions or split the deployment ` + + 'across pipelines.', + ); + } +} + +function validateCodePipelineIdentifier(kind: 'stage' | 'action', name: string): void { + if (!CODEPIPELINE_IDENTIFIER.test(name)) { + throw new Error( + `cdk-cicd: generated CodePipeline ${kind} name '${name}' must match ` + + `${CODEPIPELINE_IDENTIFIER} (1-100 characters). Rename the deployment target stage.`, + ); + } +} + +function deploymentSynthesizer(config: ResolvedDeploymentConfig): NonNullable { + return config.synthesizer ?? { type: SynthesizerType.DEFAULT }; +} + +function deploymentRepositoryIdentity(repository: ResolvedDeploymentConfig['repository']): unknown { + if (repository === undefined) return null; + const base = { + type: repository.repositoryType, + name: repository.name, + }; + switch (repository.repositoryType) { + case 'codecommit': + return { + ...base, + branch: repository.branch ?? 'main', + existing: repository.existing ?? false, + }; + case 'github': + case 'codestar_connection': + return { + ...base, + branch: repository.branch ?? 'main', + connectionArn: repository.connectionArn ?? null, + }; + case 's3': + return base; + default: + return { + ...base, + branch: repository.branch ?? null, + connectionArn: repository.connectionArn ?? null, + existing: repository.existing ?? null, + }; + } +} + +/** + * Hash every deploy.config field that changes the synthesized CD pipeline: action topology, IAM, + * registry login, and the deployer image's bootstrap/app-staging identity. Image tags/digests and + * application config versions remain runtime inputs and are handled by the per-target fingerprint. + */ +function deploymentPipelineShapeFingerprint( + config: ResolvedDeploymentConfig, + effectiveTargets: readonly EffectiveDeploymentTarget[], + partition: string, + qualifier: string, +): string { + const synthesizer = deploymentSynthesizer(config); + const ecrRepositoryIdentity = (image: string | undefined) => { + const repository = image === undefined ? undefined : parseEcrRepository(image); + return repository === undefined + ? null + : { + registryHost: repository.registryHost, + account: repository.account, + region: repository.region, + repositoryName: repository.repositoryName, + }; + }; + const roleIdentity = (roleArn: string | undefined, target: EffectiveDeploymentTarget): string | string[] | null => { + const normalizedRoleArn = roleArn?.trim(); + if (normalizedRoleArn === undefined || normalizedRoleArn.length === 0) return normalizedRoleArn ?? null; + return target.regions.map((region) => + specializeDefaultSynthesizerRoleArn(normalizedRoleArn, { + qualifier, + account: target.account, + region, + partition, + }), + ); + }; + const shape = { + application: config.application ?? null, + qualifier, + repository: deploymentRepositoryIdentity(config.repository), + synthesizer: { + type: synthesizer.type, + appId: synthesizer.appId ?? null, + }, + crossAccountEcrRepositoryPolicyConfigured: config.crossAccountEcrRepositoryPolicyConfigured ?? false, + codeArtifact: + config.codeArtifact === undefined + ? null + : { + domain: config.codeArtifact.domain, + repository: config.codeArtifact.repository, + account: config.codeArtifact.account ?? null, + region: config.codeArtifact.region ?? null, + npmScope: config.codeArtifact.npmScope ?? null, + }, + npmRegistry: + config.npmRegistry === undefined + ? null + : { + url: config.npmRegistry.url, + basicAuthSecretArn: config.npmRegistry.basicAuthSecretArn, + encryptionKeyArn: config.npmRegistry.encryptionKeyArn ?? null, + scope: config.npmRegistry.scope ?? null, + }, + targets: effectiveTargets.map((effectiveTarget) => { + const target = effectiveTarget.target; + return { + stage: target.stage, + manualApproval: target.manualApproval, + account: target.env.account ?? null, + regions: target.env.regions, + regionOrder: target.env.regionOrder, + deployRole: roleIdentity(target.deployment?.deployRole, effectiveTarget), + cfnExecutionRole: roleIdentity(target.deployment?.cfnExecutionRole, effectiveTarget), + externalId: target.deployment?.externalId ?? null, + ecrRepository: ecrRepositoryIdentity(target.image ?? config.image), + }; + }), + }; + return createHash('sha256') + .update(JSON.stringify({ schema: 'container-deployment-pipeline-shape-v3', ...shape })) + .digest('hex'); +} + +/** + * Parse a private ECR image reference into the fields needed by `docker login`, repository-scoped IAM, + * and CodeBuild custom-image binding. Nested repository paths and an optional tag/digest are preserved. + */ +function parseEcrRepository(image: string): EcrRepository | undefined { + const firstSlash = image.indexOf('/'); + if (firstSlash < 1) return undefined; + + const registryHost = image.slice(0, firstSlash); + const registry = /^([0-9]{12})\.dkr\.ecr(?:-fips)?\.([a-z0-9-]+)\.(.+)$/.exec(registryHost); + if (registry === null) return undefined; + const regionInfo = RegionInfo.get(registry[2]); + const expectedSuffix = regionInfo.domainSuffix; + const partition = regionInfo.partition; + if (expectedSuffix === undefined || partition === undefined) { + throw new Error( + `cdk-cicd: ECR image '${image}' uses region '${registry[2]}', whose partition/domain suffix is ` + + 'not known to this aws-cdk-lib version. Upgrade the wrapper/CDK before using this image.', + ); + } + if (registry[3] !== expectedSuffix) { + throw new Error( + `cdk-cicd: ECR image '${image}' has registry suffix '${registry[3]}', but region '${registry[2]}' ` + + `belongs to partition '${partition}' and requires '${expectedSuffix}'.`, + ); + } + + const imagePath = image.slice(firstSlash + 1); + const digestSeparator = imagePath.indexOf('@'); + const lastSlash = imagePath.lastIndexOf('/'); + const tagSeparator = imagePath.lastIndexOf(':'); + const repositoryName = + digestSeparator >= 0 + ? imagePath.slice(0, digestSeparator) + : tagSeparator > lastSlash + ? imagePath.slice(0, tagSeparator) + : imagePath; + if (repositoryName.length === 0) return undefined; + const tagOrDigest = + digestSeparator >= 0 + ? imagePath.slice(digestSeparator + 1) + : tagSeparator > lastSlash + ? imagePath.slice(tagSeparator + 1) + : undefined; + + return { + registryHost, + account: registry[1], + partition, + region: registry[2], + repositoryName, + tagOrDigest, + }; +} + +function validateEcrRepositoryAccount( + scope: Construct, + stack: Stack, + repository: EcrRepository, + image: string, + ownerPolicyAcknowledged: boolean, +): void { + const pipelineAccount = concretePipelineEnvironmentValue(stack.account, 'account', `ECR image '${image}'`); + const pipelineRegion = concretePipelineEnvironmentValue(stack.region, 'region', `ECR image '${image}'`); + const pipelinePartition = RegionInfo.get(pipelineRegion).partition; + if (pipelinePartition === undefined) { + throw new Error( + `cdk-cicd: Repo 2 pipeline region '${pipelineRegion}' has no known AWS partition in this ` + + 'aws-cdk-lib version. Upgrade the wrapper/CDK before using private ECR images.', + ); + } + if (repository.partition !== pipelinePartition) { + throw new Error( + `cdk-cicd: ECR image '${image}' is in partition '${repository.partition}', but the Repo 2 ` + + `pipeline is in '${pipelinePartition}'. ECR authentication and IAM cannot cross AWS partitions; ` + + 'mirror the image into the pipeline partition.', + ); + } + if (repository.account === pipelineAccount) return; + + if (!ownerPolicyAcknowledged) { + throw new Error( + `cdk-cicd: ECR image '${image}' belongs to account '${repository.account}', while the Repo 2 ` + + `pipeline runs in '${pipelineAccount}'. Cross-account pulls require an owner-side ECR repository ` + + 'policy that this construct cannot create or verify from an image URI. Configure that policy, then ' + + 'set crossAccountEcrRepositoryPolicyConfigured: true, or mirror the image into the pipeline account.', + ); + } + + const repositoryArn = + `arn:${repository.partition}:ecr:${repository.region}:${repository.account}:` + + `repository/${repository.repositoryName}`; + Annotations.of(scope).addWarningV2( + `cdk-cicd:cross-account-ecr-${createHash('sha256').update(repositoryArn).digest('hex').slice(0, 12)}`, + `cdk-cicd: cross-account ECR access acknowledged for ${repositoryArn}. This stack grants the Repo 2 ` + + 'CodeBuild role identity-side pull permissions only; keep the owner-account repository policy ' + + 'granting that generated role/account ecr:BatchCheckLayerAvailability, ecr:BatchGetImage, ' + + 'ecr:DescribeImages, and ecr:GetDownloadUrlForLayer.', + ); +} + +function deploymentBuildImage( + scope: Construct, + stack: Stack, + image: string | undefined, + ownerPolicyAcknowledged: boolean, +): codebuild.IBuildImage | undefined { + if (image === undefined) return undefined; + if (image.startsWith('aws/codebuild/')) { + return codebuild.LinuxBuildImage.fromCodeBuildImageId(image); + } + + const repository = parseEcrRepository(image); + if (repository === undefined) { + return codebuild.LinuxBuildImage.fromDockerRegistry(image); + } + const pipelineRegion = concretePipelineEnvironmentValue(stack.region, 'region', `ECR build image '${image}'`); + if (repository.region !== pipelineRegion) { + throw new Error( + `cdk-cicd: ECR build image '${image}' is in '${repository.region}', but the Repo 2 CodeBuild ` + + `project is in '${pipelineRegion}'. CodeBuild custom ECR images must be in the same region; ` + + 'replicate or mirror the build image into the pipeline region.', + ); + } + validateEcrRepositoryAccount(scope, stack, repository, image, ownerPolicyAcknowledged); + const importedRepository = ecr.Repository.fromRepositoryAttributes(scope, 'DeployBuildImageRepository', { + repositoryName: repository.repositoryName, + repositoryArn: + `arn:${repository.partition}:ecr:${repository.region}:${repository.account}:` + + `repository/${repository.repositoryName}`, + }); + return codebuild.LinuxBuildImage.fromEcrRepository(importedRepository, repository.tagOrDigest); +} + +/** A stable, pipeline-local SSM parameter for one sequential target or one parallel target-region. */ +function deploymentStateParameterName(stack: Stack, scope: Construct, stage: string, region?: string): string { + const stateKey = region === undefined ? stage : `${stage}\0${region}`; + const stageHash = createHash('sha256').update(stateKey).digest('hex').slice(0, 20); + return `/cdk-cicd/deployment-state/${stack.stackName}/${scope.node.addr}/${stageHash}`; +} + +/** + * JavaScript executed after `npm ci` to fingerprint only the selected target's effective deployment + * inputs plus the config repo's package manifest/lock identity. `defineDeployment` normalizes target + * property order, so JSON serialization is deterministic. The version parser intentionally mirrors the + * deploy executor: a missing file means "use the configured image reference as-is", while malformed + * JSON and invalid version values fail before the unchanged-target shortcut can hide them. + */ +function deploymentFingerprintScript( + effectiveTargets: readonly EffectiveDeploymentTarget[], + partition: string, + qualifier: string, +): string { + const targetEnvironments = Object.fromEntries( + effectiveTargets.map((target) => [ + target.target.stage, + { + account: target.account, + regions: target.regions, + }, + ]), + ); + return [ + 'const { execFileSync } = require("child_process");', + 'const crypto = require("crypto");', + 'const fs = require("fs");', + 'const path = require("path");', + 'const writeFingerprint = process.stdout.write.bind(process.stdout);', + 'process.stdout.write = process.stderr.write.bind(process.stderr);', + 'const file = ["deploy.config.ts", "deploy.config.js"]', + ' .map((name) => path.resolve(name))', + ' .find((candidate) => fs.existsSync(candidate));', + 'if (file === undefined) throw new Error("cdk-cicd: no deploy.config.ts or deploy.config.js found");', + 'const loaded = require(file);', + 'const config = loaded.default ?? loaded;', + `const EFFECTIVE_QUALIFIER = ${JSON.stringify(qualifier)};`, + `const PIPELINE_PARTITION = ${JSON.stringify(partition)};`, + `const EXPECTED_TARGET_ENVIRONMENTS = ${JSON.stringify(targetEnvironments)};`, + 'const replaceAll = (value, search, replacement) => value.split(search).join(replacement);', + 'const specializeRoleArn = (roleArn, account, region) => {', + ' let specialized = replaceAll(roleArn, "${Qualifier}", config.qualifier ?? EFFECTIVE_QUALIFIER);', + ' specialized = replaceAll(specialized, "${AWS::AccountId}", account);', + ' specialized = replaceAll(specialized, "${AWS::Region}", region);', + ' return replaceAll(specialized, "${AWS::Partition}", PIPELINE_PARTITION);', + '};', + 'const roleIdentity = (roleArn, target) => {', + ' const normalizedRoleArn = roleArn?.trim();', + ' if (normalizedRoleArn === undefined || normalizedRoleArn.length === 0) return normalizedRoleArn ?? null;', + ' const expectedEnvironment = EXPECTED_TARGET_ENVIRONMENTS[target.stage];', + ' const account = target.env.account ?? expectedEnvironment?.account;', + ' const regions = target.env.regions.length > 0 ? target.env.regions : expectedEnvironment?.regions;', + ' if (account === undefined || regions === undefined) return [];', + ' return regions.map((region) => specializeRoleArn(normalizedRoleArn, account, region));', + '};', + 'const parseEcrImage = (image) => {', + ' if (typeof image !== "string") return null;', + ' const firstSlash = image.indexOf("/");', + ' if (firstSlash < 1) return null;', + ' const registryHost = image.slice(0, firstSlash);', + ' const registry = /^([0-9]{12})\\.dkr\\.ecr(?:-fips)?\\.([a-z0-9-]+)\\..+$/.exec(registryHost);', + ' if (registry === null) return null;', + ' const imagePath = image.slice(firstSlash + 1);', + ' const digestSeparator = imagePath.indexOf("@");', + ' const lastSlash = imagePath.lastIndexOf("/");', + ' const tagSeparator = imagePath.lastIndexOf(":");', + ' const repositoryName =', + ' digestSeparator >= 0', + ' ? imagePath.slice(0, digestSeparator)', + ' : tagSeparator > lastSlash', + ' ? imagePath.slice(0, tagSeparator)', + ' : imagePath;', + ' if (repositoryName.length === 0) return null;', + ' return {', + ' registryHost,', + ' account: registry[1],', + ' region: registry[2],', + ' repositoryName,', + ' imageDigest: digestSeparator >= 0 ? imagePath.slice(digestSeparator + 1) : null,', + ' imageTag:', + ' digestSeparator < 0 && tagSeparator > lastSlash ? imagePath.slice(tagSeparator + 1) : null,', + ' };', + '};', + 'const ecrRepositoryIdentity = (image) => {', + ' const parsed = parseEcrImage(image);', + ' return parsed === null', + ' ? null', + ' : {', + ' registryHost: parsed.registryHost,', + ' account: parsed.account,', + ' region: parsed.region,', + ' repositoryName: parsed.repositoryName,', + ' };', + '};', + 'const resolveEffectiveImage = (target, version) => {', + ' if (target.image?.includes("@")) return target.image;', + ' const base = target.image ?? config.image;', + ' if (base === undefined || version === null) return base;', + ' if (base.includes("@")) {', + ' throw new Error(', + ' "cdk-cicd deploy --from-image: image " + base + " is pinned by digest and cannot be combined " +', + ' "with config/" + target.stage + ".json version " + version +', + ' "; remove the separate version or use a tag-based image",', + ' );', + ' }', + ' const lastSlash = base.lastIndexOf("/");', + ' const lastColon = base.lastIndexOf(":");', + ' const repository = lastColon > lastSlash ? base.slice(0, lastColon) : base;', + ' return repository + ":" + version;', + '};', + 'const immutableEcrImageIdentity = (image) => {', + ' const parsed = parseEcrImage(image);', + ' if (parsed === null) return null;', + ' let imageDigest = parsed.imageDigest;', + ' if (imageDigest === null) {', + ' const imageTag = parsed.imageTag ?? "latest";', + ' try {', + ' imageDigest = execFileSync(', + ' "aws",', + ' [', + ' "ecr",', + ' "describe-images",', + ' "--registry-id",', + ' parsed.account,', + ' "--repository-name",', + ' parsed.repositoryName,', + ' "--image-ids",', + ' "imageTag=" + imageTag,', + ' "--query",', + ' "imageDetails[0].imageDigest",', + ' "--output",', + ' "text",', + ' "--region",', + ' parsed.region,', + ' "--no-cli-pager",', + ' ],', + ' { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },', + ' ).trim();', + ' } catch (error) {', + ' const detail = error.stderr?.toString().trim() || error.message;', + ' throw new Error(', + ' "cdk-cicd: could not resolve immutable ECR digest for " + image + ": " + detail,', + ' );', + ' }', + ' }', + ' if (!/^sha256:[0-9a-f]{64}$/.test(imageDigest)) {', + ' throw new Error("cdk-cicd: ECR returned an invalid image digest for " + image + ": " + imageDigest);', + ' }', + ' return {', + ' registryHost: parsed.registryHost,', + ' account: parsed.account,', + ' region: parsed.region,', + ' repositoryName: parsed.repositoryName,', + ' imageDigest,', + ' };', + '};', + 'const repositoryIdentity = (repository) => {', + ' if (repository === undefined) return null;', + ' const base = { type: repository.repositoryType, name: repository.name };', + ' switch (repository.repositoryType) {', + ' case "codecommit":', + ' return { ...base, branch: repository.branch ?? "main", existing: repository.existing ?? false };', + ' case "github":', + ' case "codestar_connection":', + ' return {', + ' ...base,', + ' branch: repository.branch ?? "main",', + ' connectionArn: repository.connectionArn ?? null,', + ' };', + ' case "s3":', + ' return base;', + ' default:', + ' return {', + ' ...base,', + ' branch: repository.branch ?? null,', + ' connectionArn: repository.connectionArn ?? null,', + ' existing: repository.existing ?? null,', + ' };', + ' }', + '};', + 'const synthesizer = config.synthesizer ?? { type: "default" };', + 'const pipelineShape = {', + ' application: config.application ?? null,', + ' qualifier: config.qualifier ?? EFFECTIVE_QUALIFIER,', + ' repository: repositoryIdentity(config.repository),', + ' synthesizer: { type: synthesizer.type, appId: synthesizer.appId ?? null },', + ' crossAccountEcrRepositoryPolicyConfigured:', + ' config.crossAccountEcrRepositoryPolicyConfigured ?? false,', + ' codeArtifact:', + ' config.codeArtifact === undefined', + ' ? null', + ' : {', + ' domain: config.codeArtifact.domain,', + ' repository: config.codeArtifact.repository,', + ' account: config.codeArtifact.account ?? null,', + ' region: config.codeArtifact.region ?? null,', + ' npmScope: config.codeArtifact.npmScope ?? null,', + ' },', + ' npmRegistry:', + ' config.npmRegistry === undefined', + ' ? null', + ' : {', + ' url: config.npmRegistry.url,', + ' basicAuthSecretArn: config.npmRegistry.basicAuthSecretArn,', + ' encryptionKeyArn: config.npmRegistry.encryptionKeyArn ?? null,', + ' scope: config.npmRegistry.scope ?? null,', + ' },', + ' targets: config.targets.map((candidate) => ({', + ' stage: candidate.stage,', + ' manualApproval: candidate.manualApproval,', + ' account: candidate.env.account ?? null,', + ' regions: candidate.env.regions,', + ' regionOrder: candidate.env.regionOrder,', + ' deployRole: roleIdentity(candidate.deployment?.deployRole, candidate),', + ' cfnExecutionRole: roleIdentity(candidate.deployment?.cfnExecutionRole, candidate),', + ' externalId: candidate.deployment?.externalId ?? null,', + ' ecrRepository: ecrRepositoryIdentity(candidate.image ?? config.image),', + ' })),', + '};', + 'const currentTopology = crypto', + ' .createHash("sha256")', + ' .update(JSON.stringify({ schema: "container-deployment-pipeline-shape-v3", ...pipelineShape }))', + ' .digest("hex");', + 'if (currentTopology !== process.env.EXPECTED_DEPLOYMENT_TOPOLOGY) {', + ' throw new Error(', + ' "cdk-cicd: deploy.config changed fields that shape the CD pipeline; " +', + ' "re-run cdk-cicd deploy-ci to update its actions and permissions",', + ' );', + '}', + 'const stage = process.env.TARGET_STAGE;', + 'const target = config.targets.find((candidate) => candidate.stage === stage);', + 'if (target === undefined) throw new Error("cdk-cicd: no deployment target named " + stage);', + 'if (', + ' process.env.TARGET_REGION === undefined &&', + ' target.env.regionOrder === "parallel" &&', + ' target.env.regions.length > 1', + ') {', + ' throw new Error(', + ' "cdk-cicd: target " + stage + " now needs parallel region actions; " +', + ' "re-run cdk-cicd deploy-ci to update the pipeline topology",', + ' );', + '}', + 'const versionFile = path.resolve("config", stage + ".json");', + 'let version = null;', + 'if (fs.existsSync(versionFile)) {', + ' let document;', + ' try {', + ' document = JSON.parse(fs.readFileSync(versionFile, "utf8"));', + ' } catch (error) {', + ' throw new Error(', + ' "cdk-cicd deploy --from-image: " + versionFile + " exists but could not be read as JSON (" +', + ' error.message + ")",', + ' );', + ' }', + ' const candidate =', + ' document !== null && typeof document === "object" && !Array.isArray(document)', + ' ? document.version', + ' : undefined;', + ' if (typeof candidate !== "string" || candidate.length === 0 || candidate.trim() !== candidate) {', + ' throw new Error(', + ' "cdk-cicd deploy --from-image: " + versionFile +', + ' " must contain a non-empty string version field with no surrounding whitespace",', + ' );', + ' }', + ' version = candidate;', + '}', + 'const effectiveImage = resolveEffectiveImage(target, version);', + 'const immutableEcrImage = immutableEcrImageIdentity(effectiveImage);', + 'const toolingFiles = [', + ' "package.json",', + ' "package-lock.json",', + ' "npm-shrinkwrap.json",', + ' "yarn.lock",', + ' "pnpm-lock.yaml",', + '];', + 'const tooling = Object.fromEntries(', + ' toolingFiles', + ' .filter((name) => fs.existsSync(path.resolve(name)))', + ' .map((name) => [', + ' name,', + ' crypto.createHash("sha256").update(fs.readFileSync(path.resolve(name))).digest("hex"),', + ' ]),', + ');', + 'const fingerprintInput = {', + ' schema: "container-deployment-target-v3",', + ' target,', + ' image: target.image ?? config.image ?? null,', + ' version,', + ' ...(immutableEcrImage === null ? {} : { immutableEcrImage }),', + ' region: process.env.TARGET_REGION ?? null,', + ' tooling,', + '};', + 'writeFingerprint(', + ' crypto.createHash("sha256").update(JSON.stringify(fingerprintInput)).digest("hex"),', + ');', + ].join('\n'); +} + +/** + * Build a temporary one-target, one-region config for a PARALLEL action. The existing from-image CLI + * then performs image/version resolution and emits the same docker/inner-deploy contract as sequential + * mode; no ignored top-level `--region` flag or duplicate docker implementation is introduced here. + */ +function parallelTargetConfigScript(): string { + return [ + 'const fs = require("fs");', + 'const path = require("path");', + 'const file = ["deploy.config.ts", "deploy.config.js"]', + ' .map((name) => path.resolve(name))', + ' .find((candidate) => fs.existsSync(candidate));', + 'if (file === undefined) throw new Error("cdk-cicd: no deploy.config.ts or deploy.config.js found");', + 'const loaded = require(file);', + 'const config = loaded.default ?? loaded;', + 'const stage = process.env.TARGET_STAGE;', + 'const region = process.env.TARGET_REGION;', + 'if (region === undefined || region.length === 0) throw new Error("cdk-cicd: TARGET_REGION is empty");', + 'const target = config.targets.find((candidate) => candidate.stage === stage);', + 'if (target === undefined) throw new Error("cdk-cicd: no deployment target named " + stage);', + 'if (target.env.regionOrder !== "parallel" || !target.env.regions.includes(region)) {', + ' throw new Error(', + ' "cdk-cicd: target " + stage + " no longer defines parallel region " + region +', + ' "; re-run cdk-cicd deploy-ci to update the pipeline topology",', + ' );', + '}', + 'const output = path.resolve(".cdk-cicd-target");', + 'fs.mkdirSync(output, { recursive: true });', + 'const narrowed = {', + ' application: config.application,', + ' qualifier: config.qualifier,', + ' synthesizer: config.synthesizer,', + ' image: config.image,', + ' targets: [{ ...target, env: { ...target.env, regions: [region] } }],', + '};', + 'fs.writeFileSync(', + ' path.join(output, "deploy.config.js"),', + ' "module.exports = " + JSON.stringify(narrowed) + ";\\n",', + ');', + 'fs.writeFileSync(', + ' path.join(output, "package.json"),', + ' JSON.stringify({ private: true, scripts: { "cdk-cicd": "../node_modules/.bin/cdk-cicd" } }) + "\\n",', + ');', + 'const versionFile = path.resolve("config", stage + ".json");', + 'if (fs.existsSync(versionFile)) {', + ' const outputConfig = path.join(output, "config");', + ' fs.mkdirSync(outputConfig, { recursive: true });', + ' fs.copyFileSync(versionFile, path.join(outputConfig, stage + ".json"));', + '}', + ].join('\n'); +} + +/** Quote a value as one POSIX-shell argument. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/external-id-secrets.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/external-id-secrets.ts new file mode 100644 index 00000000..6a3296d2 --- /dev/null +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/external-id-secrets.ts @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ResolvedCicdConfig } from '../config/types'; + +const SECRET_REF_PREFIX = 'resolve:secretsmanager:'; + +/** + * Secrets Manager ARNs that must be readable while the application is synthesized. ExternalIds are + * resolved only for stages that actually configure a deploy role; per-stage values override the + * pipeline default, matching the CLI runtime contract. + */ +export function deployRoleExternalIdSecretArns(config: ResolvedCicdConfig): string[] { + return deployRoleExternalIdSecretArnsForStages(config.stages, config.deployRoleExternalId); +} + +/** Stage-scoped form used by engines that synthesize only a subset of the configured stages. */ +export function deployRoleExternalIdSecretArnsForStages( + stages: ReadonlyArray, + pipelineExternalId?: string, +): string[] { + const arns = new Set(); + for (const stage of stages) { + if (stage.deployment?.deployRole === undefined || stage.deployment.deployRole.trim().length === 0) { + continue; + } + const externalId = (stage.deployment.externalId ?? pipelineExternalId)?.trim(); + if (externalId?.startsWith(SECRET_REF_PREFIX)) { + const secretArn = externalId.slice(SECRET_REF_PREFIX.length).trim(); + if (secretArn.length === 0) { + throw new Error( + `cdk-cicd: stage '${stage.name}' has an empty resolve:secretsmanager: deploy-role externalId reference`, + ); + } + arns.add(secretArn); + } + } + return [...arns]; +} diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/github/GitHubActionsEngine.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/github/GitHubActionsEngine.ts index 4f055514..02eecd22 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/engine/github/GitHubActionsEngine.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/engine/github/GitHubActionsEngine.ts @@ -18,20 +18,35 @@ // unresolved `Aws.PARTITION` token unless the (opt-in, not assumed here) `ENABLE_PARTITION_LITERALS` // feature flag is set, which would silently break this same literal-ARN requirement. -import { Arn, Stack, Stage, Token } from 'aws-cdk-lib'; +import { Arn, AspectPriority, Aspects, DefaultStackSynthesizer, Stack, Stage, Token } from 'aws-cdk-lib'; import * as iam from 'aws-cdk-lib/aws-iam'; import { CodeBuildStep } from 'aws-cdk-lib/pipelines'; import { RegionInfo } from 'aws-cdk-lib/region-info'; import { NagSuppressions } from 'cdk-nag'; import { AwsCredentials, GitHubActionRole, GitHubWorkflow, JsonPatch } from 'cdk-pipelines-github'; import { Construct } from 'constructs'; +import { assertValidCiImageReference, ciImageRegistryHost, isPrivateEcrRegistryHost } from '../../config/build-image'; +import { + resolveDefaultSynthesizerQualifier, + specializeDefaultSynthesizerRoleArn, +} from '../../config/default-synthesizer-role-arn'; import { RepositorySourceType } from '../../config/repository'; -import { ProxyConfig, ResolvedCicdConfig } from '../../config/types'; +import { + CodeArtifactConfig, + NpmRegistryConfig, + ProxyConfig, + RegionOrder, + ResolvedCicdConfig, + SynthesizerType, +} from '../../config/types'; +import { AccessLogsForBucketAspect } from '../../support/AccessLogsForBucketAspect'; +import { SupportResources } from '../../support/SupportResources'; import { CdkPipelinesStageContext, IStageProvider, ssmWarmingCommands, ssmWarmingReadStatements, + targetLookupStatements, } from '../cdkpipelines/CdkPipelinesEngine'; import { defaultCiCommands } from '../ci-commands'; @@ -51,10 +66,9 @@ export interface GitHubActionsEngineProps { /** * A GitHub Actions workflow rendered from an Autopilot config + a stage factory. Reproduces the Blueprint shape: a - * `GitHubActionRole` the workflow assumes over OIDC, a Synth job, and one job (with a GitHub Environment, - * so an environment protection rule set up on GitHub's side gates it) per deployment stage. Manual-approval - * config is NOT translated into a CDK step here -- as in Blueprint, GitHub Environments are the gate; every stage - * gets its own environment regardless of `manualApproval`, and gating is configured in the GitHub UI. + * `GitHubActionRole` the workflow assumes over OIDC, a Synth job, and one job (with a GitHub Environment) + * per deployment stage. GitHub owns the environment protection rules, so approval-gated stages are accepted + * only when config explicitly acknowledges that required reviewers are configured on those environments. */ export class GitHubActionsEngine extends Construct { public readonly pipeline: GitHubWorkflow; @@ -70,13 +84,27 @@ export class GitHubActionsEngine extends Construct { ); } const options = config.githubActions ?? {}; + assertSupportedSynthesizer(config); + assertNoDeployRoleExternalIds(config); + assertEnvironmentProtectionConfigured(config); const stack = Stack.of(this); + assertConcretePipelineEnvironment(stack); + assertConcreteDeploymentEnvironments(stack, config); + const partition = assertSupportedWorkflowPartition(stack, config, options.publishAssetsAuthRegion); + assertSupportedComplianceLogging(stack, config); + const buildContainerCredentials = resolveBuildContainerCredentials(config); + const complianceLogBucket = + config.complianceLogBucketName !== undefined + ? new SupportResources(this, 'Support', { + complianceLogBucketName: config.complianceLogBucketName, + createComplianceLogBucket: config.createComplianceLogBucket, + }).complianceLogBucket + : undefined; const roleName = options.roleName ?? `${config.application ?? 'cdk-cicd'}-github-role`; - const publishAssetsAuthRegion = options.publishAssetsAuthRegion ?? 'us-west-2'; + const publishAssetsAuthRegion = options.publishAssetsAuthRegion ?? stack.region; // A literal ARN, not `this.gitHubActionRole.role.roleArn` (a CDK token): the workflow file is plain // text written at synth time, so only a value known BEFORE synth ends up correctly in it. - const partition = Token.isUnresolved(stack.region) ? 'aws' : (RegionInfo.get(stack.region).partition ?? 'aws'); const gitHubActionRoleArn = Arn.format({ partition, service: 'iam', @@ -104,6 +132,39 @@ export class GitHubActionsEngine extends Construct { } : {}), }); + hardenGitHubOidcAudience(this.gitHubActionRole); + for (const statement of targetLookupStatements(stack, config, partition)) { + this.gitHubActionRole.role.addToPrincipalPolicy(statement); + } + const qualifier = resolveDefaultSynthesizerQualifier(this, config.qualifier); + const forcedDeployRoles = new Set(); + for (const stage of config.stages) { + const configuredDeployRole = stage.deployment?.deployRole?.trim(); + const deployRole = + configuredDeployRole === undefined || configuredDeployRole.length === 0 + ? DefaultStackSynthesizer.DEFAULT_DEPLOY_ROLE_ARN + : configuredDeployRole; + + const account = stage.env.account ?? stack.account; + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + for (const region of regions) { + const specializedRole = specializeDefaultSynthesizerRoleArn(deployRole, { + qualifier, + account, + region, + partition, + }); + assertStableGitHubDeployRoleArn(specializedRole, stage.name); + if (configuredDeployRole !== undefined && configuredDeployRole.length > 0) { + forcedDeployRoles.add(specializedRole); + } + } + } + if (forcedDeployRoles.size > 0) { + this.gitHubActionRole.role.addToPrincipalPolicy( + new iam.PolicyStatement({ actions: ['sts:AssumeRole'], resources: [...forcedDeployRoles] }), + ); + } NagSuppressions.addResourceSuppressions( this.gitHubActionRole, [ @@ -122,9 +183,12 @@ export class GitHubActionsEngine extends Construct { // The step runs with `CDK_CICD_MODE=pipeline` (set on the step env below), so `cdk.json`'s single // `cdk-cicd exec` entry renders the pipeline -- keeping self-mutation producing the workflow the // "commit the updated workflow file" check compares. Without the mode it synthesizes only app stacks. + const privateNpm = config.npmRegistry !== undefined || config.codeArtifact !== undefined; const installCommands = [ - ...(config.proxy ? proxyInstallCommands(config.proxy) : []), + ...(privateNpm ? npmConfigSetupCommands() : []), + ...(config.proxy ? proxyInstallCommands(stack, config.proxy) : []), ...(config.warmAccountsFromSsm ? ssmWarmingCommands(config.qualifier) : []), + ...(config.npmRegistry ? npmRegistryLoginCommands(config.npmRegistry) : []), ...(config.codeArtifact ? [ `aws codeartifact login --tool npm --domain ${config.codeArtifact.domain} ` + @@ -142,6 +206,24 @@ export class GitHubActionsEngine extends Construct { workflowPath: options.workflowPath, workflowName: options.workflowName ?? props.pipelineName, workflowTriggers: options.workflowTriggers, + postBuildSteps: privateNpm + ? [ + { + name: 'Clean up npm credentials', + if: 'always()', + run: npmConfigCleanupCommand(), + }, + ] + : undefined, + // cdk-pipelines-github renders buildContainer only on the Build-Synth job, so this is the + // faithful GitHub Actions equivalent of the CI-only CodeBuild image override. + buildContainer: + config.ci.image !== undefined + ? { + image: config.ci.image, + ...(buildContainerCredentials !== undefined ? { credentials: buildContainerCredentials } : {}), + } + : undefined, synth: new CodeBuildStep('Synth', { installCommands: [], // With no ci.steps, run the default CI (its own `npm ci` first); with ci.steps, those steps ARE @@ -150,30 +232,24 @@ export class GitHubActionsEngine extends Construct { // render THIS pipeline so self-mutation keeps producing the workflow the "commit the updated // workflow file" check compares. A plain `cdk synth` without the mode renders only the app stacks. commands: [...(ciSteps.length > 0 ? ciSteps : defaultCiCommands()), 'npm run cdk synth'], - env: { CDK_CICD_MODE: 'pipeline', ...(config.qualifier ? { CDK_QUALIFIER: config.qualifier } : {}) }, + env: { + CDK_CICD_MODE: 'pipeline', + CDK_AWS_PARTITION: partition, + ...(config.qualifier ? { CDK_QUALIFIER: config.qualifier } : {}), + }, primaryOutputDirectory: 'cdk.out', }), }); - // The Synth job needs AWS credentials of its own only when it talks to an AWS API before `cdk synth` - // (a private CodeArtifact login, or a proxy secret read) -- unlike the per-stage deploy/asset-publish - // jobs, `cdk-pipelines-github` does not inject them into the Synth job automatically (see the Synth - // job's own comment in `pipeline.js`: "does not use the GitHub Action Role on its own"). Patched in - // right after the checkout step (index 1), ahead of the install/build commands that need it -- same - // fixed job/step addressing Blueprint used (the synth step is always named 'Synth', so the job is always - // `Build-Synth`, and checkout is always the first step `cdk-pipelines-github` emits). - // Inserted in order, each patch computed against the array as it will look once the ones before it - // have applied -- so the credential step (when present) always lands ahead of the login step. - const patches: JsonPatch[] = []; - let insertAt = 1; - if (config.codeArtifact !== undefined || config.proxy !== undefined || config.warmAccountsFromSsm) { - const credentialStep = AwsCredentials.fromOpenIdConnect({ - gitHubActionRoleArn, - roleSessionName: 'cdk-cicd-github-actions', - }).credentialSteps(publishAssetsAuthRegion)[0]; - patches.push(JsonPatch.add(`/jobs/Build-Synth/steps/${insertAt}`, credentialStep)); - insertAt += 1; - } + // `cdk-pipelines-github` does not authenticate Build-Synth on its own. Always assume the OIDC role + // immediately after checkout so the CDK CLI has a concrete account/region during self-mutation and + // uncached context lookups can assume the target bootstrap lookup roles. + const credentialStep = AwsCredentials.fromOpenIdConnect({ + gitHubActionRoleArn, + roleSessionName: 'cdk-cicd-github-actions', + }).credentialSteps(publishAssetsAuthRegion)[0]; + const patches: JsonPatch[] = [JsonPatch.add('/jobs/Build-Synth/steps/1', credentialStep)]; + const insertAt = 2; // The warming scan reads SSM under the qualifier; grant it on the OIDC role the Synth job assumes. // Reuse the shared helper so the grant is scoped to `parameter//*` (a resolvable // qualifier is guaranteed: resolveCicdConfig rejects warmAccountsFromSsm without one). @@ -182,6 +258,24 @@ export class GitHubActionsEngine extends Construct { this.gitHubActionRole.role.addToPrincipalPolicy(statement); } } + if (config.codeArtifact !== undefined) { + for (const statement of codeArtifactReadStatements(stack, config.codeArtifact)) { + this.gitHubActionRole.role.addToPrincipalPolicy(statement); + } + } + if (config.proxy !== undefined) { + for (const statement of secretReadStatements(config.proxy.proxySecretArn, config.proxy.encryptionKeyArn)) { + this.gitHubActionRole.role.addToPrincipalPolicy(statement); + } + } + if (config.npmRegistry !== undefined) { + for (const statement of secretReadStatements( + config.npmRegistry.basicAuthSecretArn, + config.npmRegistry.encryptionKeyArn, + )) { + this.gitHubActionRole.role.addToPrincipalPolicy(statement); + } + } if (installCommands.length > 0) { patches.push( JsonPatch.add(`/jobs/Build-Synth/steps/${insertAt}`, { name: 'Login', run: installCommands.join('\n') }), @@ -191,31 +285,446 @@ export class GitHubActionsEngine extends Construct { this.pipeline.workflowFile.patch(...patches); } - // One job per (stage x region), in config order, each gated by its own GitHub Environment (a manual - // approval, if any, is a protection rule configured on that environment in GitHub -- not a CDK step). + // One job per (stage x region), each tied to its own GitHub Environment. For manualApproval stages, + // validation above requires an explicit acknowledgement that required reviewers are configured on + // these generated environment names. Sequential regions are separate waves; parallel regions share + // a GitHub wave and therefore have no dependency edge between their deployment jobs. // Unlike `CdkPipelinesEngine`, the account always resolves to a concrete value (defaulting to the // pipeline's own account): the deploy job is a static YAML step, with no CloudFormation-side // mechanism to defer an unresolved account the way an AWS-hosted CodePipeline deploy action can. for (const stage of config.stages) { const account = stage.env.account ?? stack.account; const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; - for (const region of regions) { + const appStageFor = (region: string): { readonly appStage: Stage; readonly stageId: string } => { const stageId = regions.length > 1 ? `${stage.name}-${region}` : stage.name; const appStage = new Stage(this, stageId, { env: { account, region } }); + if (config.complianceLogBucketName !== undefined && complianceLogBucket !== undefined) { + // Aspects do not cross Stage boundaries. Attach directly so the application templates, + // rather than only this workflow-support stack, receive server-access logging. + Aspects.of(appStage).add( + new AccessLogsForBucketAspect({ + complianceLogBucketName: config.complianceLogBucketName, + complianceLogBucketAccount: stack.account, + complianceLogBucketRegion: stack.region, + complianceLogBucket, + }), + { priority: AspectPriority.MUTATING }, + ); + } const context: CdkPipelinesStageContext = { stageName: stage.name, env: { account, region } }; props.stages.stacks(appStage, context); + return { appStage, stageId }; + }; + + if (stage.env.regionOrder === RegionOrder.PARALLEL && regions.length > 1) { + const wave = this.pipeline.addGitHubWave(stage.name); + for (const region of regions) { + const { appStage, stageId } = appStageFor(region); + wave.addStageWithGitHubOptions(appStage, { gitHubEnvironment: { name: stageId } }); + } + continue; + } + + for (const region of regions) { + const { appStage, stageId } = appStageFor(region); this.pipeline.addStageWithGitHubOptions(appStage, { gitHubEnvironment: { name: stageId } }); } } + + // cdk-pipelines-github resolves deployment placeholders from the ambient CDK_AWS_PARTITION + // process variable and otherwise hard-codes "aws". Build eagerly under the validated partition, + // then restore the caller's process environment so local synths and tests remain isolated. + buildWorkflowForPartition(this.pipeline, partition); } } -/** Export the proxy for every later shell command, then prove the tunnel works before install runs. */ -function proxyInstallCommands(proxy: ProxyConfig): string[] { +/** The installed alpha synthesizer does not support either CDK Pipelines implementation. */ +function assertSupportedSynthesizer(config: ResolvedCicdConfig): void { + if ((config.synthesizer?.type ?? SynthesizerType.DEFAULT) === SynthesizerType.APP_STAGING) { + throw new Error( + 'cdk-cicd: GITHUB_ACTIONS cannot use SynthesizerType.APP_STAGING: the installed ' + + '@aws-cdk/app-staging-synthesizer-alpha does not support CDK Pipelines, and Stage replay would ' + + 'create an invalid cross-Stage dependency on DefaultStagingStack. Use SynthesizerType.DEFAULT ' + + 'for generated pipelines; APP_STAGING remains available for direct local CDK deployment.', + ); + } +} + +/** Workflow YAML needs literal account/region values; CloudFormation tokens cannot be deferred into it. */ +function assertConcretePipelineEnvironment(stack: Stack): void { + if (Token.isUnresolved(stack.account) || Token.isUnresolved(stack.region)) { + throw new Error( + 'cdk-cicd: GITHUB_ACTIONS requires a concrete pipeline stack account and region because the ' + + 'workflow renders a literal OIDC role ARN.', + ); + } + if (RegionInfo.get(stack.region).partition === undefined || RegionInfo.get(stack.region).domainSuffix === undefined) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS pipeline region '${stack.region}' is not known to this aws-cdk-lib ` + + 'version. Upgrade the wrapper/CDK before rendering a workflow for that Region.', + ); + } +} + +/** GitHub workflow YAML cannot defer target account/Region tokens to CloudFormation. */ +function assertConcreteDeploymentEnvironments(stack: Stack, config: ResolvedCicdConfig): void { + for (const stage of config.stages) { + const account = stage.env.account ?? stack.account; + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + if ( + account.length === 0 || + Token.isUnresolved(account) || + regions.some((region) => region.length === 0 || Token.isUnresolved(region)) + ) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS stage '${stage.name}' requires concrete account and region values ` + + 'because the generated workflow contains literal deployment jobs and IAM role ARNs.', + ); + } + } +} + +/** + * The installed GitHub engine has no OIDC audience option and its deployment renderer reads + * CDK_AWS_PARTITION from process state. Restrict it to the commercial partition, validate every + * participating Region, and then build under that exact partition below. + */ +function assertSupportedWorkflowPartition( + stack: Stack, + config: ResolvedCicdConfig, + configuredAuthRegion?: string, +): string { + const pipelinePartition = partitionForRegion(stack.region, 'GITHUB_ACTIONS pipeline'); + if (pipelinePartition !== 'aws') { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS does not support pipeline partition '${pipelinePartition}' with the ` + + 'installed cdk-pipelines-github release: its OIDC helper cannot configure a partition-specific ' + + "audience. Use the CDK_PIPELINES or CODEPIPELINE engine outside the commercial 'aws' partition.", + ); + } + + for (const stage of config.stages) { + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + for (const region of regions) { + const targetPartition = partitionForRegion(region, `GITHUB_ACTIONS stage '${stage.name}'`); + if (targetPartition !== pipelinePartition) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS cannot mix AWS partitions: pipeline region '${stack.region}' is in ` + + `'${pipelinePartition}', but stage '${stage.name}' region '${region}' is in '${targetPartition}'.`, + ); + } + } + } + + if (config.codeArtifact?.region !== undefined) { + const codeArtifactPartition = partitionForRegion(config.codeArtifact.region, 'GITHUB_ACTIONS CodeArtifact'); + if (codeArtifactPartition !== pipelinePartition) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS cannot use CodeArtifact region '${config.codeArtifact.region}' in partition ` + + `'${codeArtifactPartition}' from pipeline partition '${pipelinePartition}'.`, + ); + } + } + + const authRegion = configuredAuthRegion ?? stack.region; + const authPartition = partitionForRegion(authRegion, 'GITHUB_ACTIONS publishAssetsAuthRegion'); + if (authPartition !== pipelinePartition) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS publishAssetsAuthRegion '${authRegion}' is in partition ` + + `'${authPartition}', but the pipeline is in '${pipelinePartition}'.`, + ); + } + + return pipelinePartition; +} + +function partitionForRegion(region: string, context: string): string { + if (region.length === 0 || Token.isUnresolved(region)) { + throw new Error(`cdk-cicd: ${context} requires a concrete Region so its AWS partition can be verified.`); + } + const partition = RegionInfo.get(region).partition; + if (partition === undefined) { + throw new Error( + `cdk-cicd: ${context} region '${region}' is not known to this aws-cdk-lib version. ` + + 'Upgrade the wrapper/CDK before using that Region.', + ); + } + return partition; +} + +/** + * A single destination bucket is deployed with the workflow-support stack. S3 server access logging + * cannot cross accounts or Regions, so reject any application Stage that could not use that bucket. + */ +function assertSupportedComplianceLogging(stack: Stack, config: ResolvedCicdConfig): void { + if (config.complianceLogBucketName === undefined) return; + + for (const stage of config.stages) { + const account = stage.env.account ?? stack.account; + const regions = stage.env.regions.length > 0 ? stage.env.regions : [stack.region]; + if ( + Token.isUnresolved(account) || + account !== stack.account || + regions.some((region) => Token.isUnresolved(region) || region !== stack.region) + ) { + throw new Error( + `cdk-cicd: compliance logging cannot target GitHub Actions stage '${stage.name}' from bucket ` + + `'${config.complianceLogBucketName}' in ${stack.account}/${stack.region}. S3 server access-log ` + + 'source and destination buckets must be in the same account and region; configure only ' + + 'co-located stages or omit complianceLogBucketName.', + ); + } + } +} + +/** + * GitHub pulls a job container before any workflow step can authenticate to private ECR. Recognize + * canonical, FIPS, dual-stack, and malformed ECR-like hosts so none can fall through as an anonymous + * external registry. Other external registry strings remain the public/anonymous path. + */ +function resolveBuildContainerCredentials( + config: ResolvedCicdConfig, +): { readonly username: string; readonly password: string } | undefined { + if (config.ci.codeBuildImageCredentials !== undefined) { + throw new Error( + 'cdk-cicd: ci.codeBuildImageCredentials is supported only by the CodeBuild engines; ' + + 'GITHUB_ACTIONS uses githubActions.buildContainerCredentials.', + ); + } + + const image = config.ci.image; + const credentials = config.githubActions?.buildContainerCredentials; + if (image === undefined) { + if (credentials !== undefined) { + throw new Error('cdk-cicd: githubActions.buildContainerCredentials requires ci.image.'); + } + return undefined; + } + + assertValidCiImageReference(image); + if (image.startsWith('aws/codebuild/')) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS cannot use managed CodeBuild ci.image '${image}'; ` + + 'aws/codebuild/... is a CodeBuild image ID, not a pullable OCI job-container reference.', + ); + } + + const registryHost = ciImageRegistryHost(image); + if (isPrivateEcrRegistryHost(registryHost)) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS cannot use private ECR ci.image '${image}' because GitHub pulls the ` + + 'job container before the OIDC authentication step. GitHub container credentials do not ' + + 'implement the AWS ECR authorization-token exchange; use a public external image or a ' + + 'non-container runner.', + ); + } + + if (credentials === undefined) return undefined; + return { + username: githubSecretExpression(credentials.usernameSecretName, 'usernameSecretName'), + password: githubSecretExpression(credentials.passwordSecretName, 'passwordSecretName'), + }; +} + +function githubSecretExpression(secretName: string, field: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretName) || /^GITHUB_/i.test(secretName)) { + throw new Error( + `cdk-cicd: githubActions.buildContainerCredentials.${field} '${secretName}' is not a valid GitHub ` + + 'secret name. Use letters, numbers, or underscores; do not start with a number or GITHUB_.', + ); + } + return `\${{ secrets.${secretName} }}`; +} + +function buildWorkflowForPartition(pipeline: GitHubWorkflow, partition: string): void { + const previousPartition = process.env.CDK_AWS_PARTITION; + try { + process.env.CDK_AWS_PARTITION = partition; + pipeline.buildPipeline(); + } finally { + if (previousPartition === undefined) { + delete process.env.CDK_AWS_PARTITION; + } else { + process.env.CDK_AWS_PARTITION = previousPartition; + } + } +} + +/** + * The installed cdk-pipelines-github release scopes only the `sub` claim on its trust statement. + * Add the audience condition directly to that generated Allow so an imported provider with additional + * client IDs cannot use a non-STS audience to assume the workflow role. + */ +function hardenGitHubOidcAudience(gitHubActionRole: GitHubActionRole): void { + const roleResource = gitHubActionRole.role.node.defaultChild; + if (!(roleResource instanceof iam.CfnRole)) { + throw new Error('cdk-cicd: could not locate the generated GitHub Actions IAM role trust policy.'); + } + roleResource.addPropertyOverride( + 'AssumeRolePolicyDocument.Statement.0.Condition.StringEquals.token\\.actions\\.githubusercontent\\.com:aud', + 'sts.amazonaws.com', + ); +} + +/** Fail closed until cdk-pipelines-github can forward caller-provided deploy-role ExternalIds. */ +function assertNoDeployRoleExternalIds(config: ResolvedCicdConfig): void { + const unsupportedStages = config.stages + .filter((stage) => { + const deployRole = stage.deployment?.deployRole?.trim(); + const externalId = (stage.deployment?.externalId ?? config.deployRoleExternalId)?.trim(); + return deployRole !== undefined && deployRole.length > 0 && externalId !== undefined && externalId.length > 0; + }) + .map((stage) => stage.name); + + if (unsupportedStages.length > 0) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS cannot honor deploy-role ExternalIds for stage(s): ` + + `${unsupportedStages.join(', ')}; the installed engine hardcodes a different ExternalId. ` + + 'Remove the ExternalId or use the CODEPIPELINE engine.', + ); + } +} + +/** + * The installed cdk-pipelines-github credential provider derives a deployment role by replacing the + * first literal `cfn-exec` in the manifest ARN with `deploy`. A custom role containing that text + * would therefore be granted here but never assumed by the generated workflow. + */ +function assertStableGitHubDeployRoleArn(roleArn: string, stageName: string): void { + const rolePathAndName = roleArn.match(/:role\/(.+)$/)?.[1]; + if (rolePathAndName?.includes('cfn-exec')) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS deployRole for stage '${stageName}' cannot contain literal ` + + '`cfn-exec` in its role path or name: the installed cdk-pipelines-github release rewrites ' + + 'that text to `deploy` before assuming the role. Rename the role or use another engine.', + ); + } +} + +/** + * A workflow can reference GitHub Environments but cannot configure their protection rules. Requiring + * this acknowledgement prevents manualApproval from silently rendering an unprotected deployment job. + */ +function assertEnvironmentProtectionConfigured(config: ResolvedCicdConfig): void { + const gatedStages = config.stages.filter((stage) => stage.manualApproval).map((stage) => stage.name); + if (gatedStages.length > 0 && config.githubActions?.environmentProtectionConfigured !== true) { + throw new Error( + `cdk-cicd: GITHUB_ACTIONS stage(s) ${gatedStages.join(', ')} require manual approval, but GitHub ` + + 'environment protection is not acknowledged. Configure required reviewers on every generated ' + + 'GitHub Environment, then set githubActions.environmentProtectionConfigured: true.', + ); + } +} + +/** Secret read plus an exact CMK decrypt grant when the config identifies one. */ +function secretReadStatements(secretArn: string, encryptionKeyArn?: string): iam.PolicyStatement[] { + const statements = [new iam.PolicyStatement({ actions: ['secretsmanager:GetSecretValue'], resources: [secretArn] })]; + if (encryptionKeyArn !== undefined && encryptionKeyArn.trim().length > 0) { + statements.push(new iam.PolicyStatement({ actions: ['kms:Decrypt'], resources: [encryptionKeyArn] })); + } + return statements; +} + +/** The CodeArtifact read permissions a `codeartifact login` + `npm ci` need. */ +function codeArtifactReadStatements(stack: Stack, codeArtifact: CodeArtifactConfig): iam.PolicyStatement[] { + const account = codeArtifact.account ?? stack.account; + const region = codeArtifact.region ?? stack.region; + return [ + new iam.PolicyStatement({ + actions: ['codeartifact:GetAuthorizationToken'], + resources: [`arn:${stack.partition}:codeartifact:${region}:${account}:domain/${codeArtifact.domain}`], + }), + new iam.PolicyStatement({ + actions: ['codeartifact:GetRepositoryEndpoint', 'codeartifact:ReadFromRepository'], + resources: [ + `arn:${stack.partition}:codeartifact:${region}:${account}:repository/${codeArtifact.domain}/${codeArtifact.repository}`, + ], + }), + new iam.PolicyStatement({ + actions: ['sts:GetServiceBearerToken'], + resources: ['*'], + conditions: { StringEquals: { 'sts:AWSServiceName': 'codeartifact.amazonaws.com' } }, + }), + ]; +} + +/** + * Resolve proxy credentials after OIDC authentication, mask them, and persist the effective proxy + * environment for every later Build-Synth step through GitHub's environment file. + */ +function proxyInstallCommands(stack: Stack, proxy: ProxyConfig): string[] { + const secretRegion = secretsManagerRegion(proxy.proxySecretArn); + const noProxy = + proxy.noProxy.length > 0 ? proxy.noProxy : [`${stack.region}.${RegionInfo.get(stack.region).domainSuffix!}`]; return [ + `PROXY_SECRET_JSON="$(aws secretsmanager get-secret-value --secret-id ${shellQuote(proxy.proxySecretArn)}` + + `${secretRegion !== undefined ? ` --region ${shellQuote(secretRegion)}` : ''} ` + + '--query SecretString --output text)"', + 'if [ -z "$PROXY_SECRET_JSON" ] || [ "$PROXY_SECRET_JSON" = "None" ]; then echo "cdk-cicd: proxy secret is empty" >&2; exit 1; fi', + 'PROXY_USERNAME="$(printf \'%s\' "$PROXY_SECRET_JSON" | jq -er \'.username\')"', + 'PROXY_PASSWORD="$(printf \'%s\' "$PROXY_SECRET_JSON" | jq -er \'.password\')"', + 'HTTP_PROXY_PORT="$(printf \'%s\' "$PROXY_SECRET_JSON" | jq -er \'.http_proxy_port\')"', + 'HTTPS_PROXY_PORT="$(printf \'%s\' "$PROXY_SECRET_JSON" | jq -er \'.https_proxy_port\')"', + 'PROXY_DOMAIN="$(printf \'%s\' "$PROXY_SECRET_JSON" | jq -er \'.proxy_domain\')"', + 'unset PROXY_SECRET_JSON', + 'echo "::add-mask::$PROXY_USERNAME"', + 'echo "::add-mask::$PROXY_PASSWORD"', 'export HTTP_PROXY="http://$PROXY_USERNAME:$PROXY_PASSWORD@$PROXY_DOMAIN:$HTTP_PROXY_PORT"', 'export HTTPS_PROXY="https://$PROXY_USERNAME:$PROXY_PASSWORD@$PROXY_DOMAIN:$HTTPS_PROXY_PORT"', + `export NO_PROXY=${shellQuote(noProxy.join(','))}`, + 'export AWS_STS_REGIONAL_ENDPOINTS=regional', + 'echo "HTTP_PROXY=$HTTP_PROXY" >> "$GITHUB_ENV"', + 'echo "HTTPS_PROXY=$HTTPS_PROXY" >> "$GITHUB_ENV"', + 'echo "NO_PROXY=$NO_PROXY" >> "$GITHUB_ENV"', + 'echo "AWS_STS_REGIONAL_ENDPOINTS=$AWS_STS_REGIONAL_ENDPOINTS" >> "$GITHUB_ENV"', 'echo "--- Proxy Test ---"', `curl -Is --connect-timeout 5 ${proxy.proxyTestUrl} | grep "HTTP/"`, ]; } + +/** + * Resolve a generic npm-registry token only after the Synth job has assumed its OIDC role, mask it + * before any later command can log it, and persist it solely in the runner's temporary npm config. + */ +function npmRegistryLoginCommands(npm: NpmRegistryConfig): string[] { + const host = npm.url.replace(/^https?:\/\//, ''); + const scope = npm.scope !== undefined && npm.scope.length > 0 ? npm.scope : undefined; + const scopePrefix = scope !== undefined ? `${scope.startsWith('@') ? scope : `@${scope}`}:` : ''; + const secretRegion = secretsManagerRegion(npm.basicAuthSecretArn); + return [ + `NPM_AUTH_TOKEN="$(aws secretsmanager get-secret-value --secret-id ${shellQuote(npm.basicAuthSecretArn)}` + + `${secretRegion !== undefined ? ` --region ${shellQuote(secretRegion)}` : ''} ` + + '--query SecretString --output text)"', + 'if [ -z "$NPM_AUTH_TOKEN" ] || [ "$NPM_AUTH_TOKEN" = "None" ]; then echo "cdk-cicd: npm registry secret is empty" >&2; exit 1; fi', + 'echo "::add-mask::$NPM_AUTH_TOKEN"', + `echo "${scopePrefix}registry=${npm.url}" > "$NPM_CONFIG_USERCONFIG"`, + `echo "//${host}:_authToken=$NPM_AUTH_TOKEN" >> "$NPM_CONFIG_USERCONFIG"`, + 'unset NPM_AUTH_TOKEN', + ]; +} + +/** Configure npm to use an owner-only credential file outside the checked-out source workspace. */ +function npmConfigSetupCommands(): string[] { + return [ + 'export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/cdk-cicd-npmrc"', + 'rm -f "$NPM_CONFIG_USERCONFIG"', + 'umask 077 && touch "$NPM_CONFIG_USERCONFIG"', + 'echo "NPM_CONFIG_USERCONFIG=$NPM_CONFIG_USERCONFIG" >> "$GITHUB_ENV"', + ]; +} + +/** Safe even if an earlier login command failed before exporting the path. */ +function npmConfigCleanupCommand(): string { + return 'if [ -n "${NPM_CONFIG_USERCONFIG:-}" ]; then rm -f "$NPM_CONFIG_USERCONFIG"; fi'; +} + +/** Use the secret ARN's region instead of assuming it matches the workflow's OIDC-auth region. */ +function secretsManagerRegion(secretArn: string): string | undefined { + const parts = secretArn.split(':'); + return parts.length > 5 && parts[0] === 'arn' && parts[2] === 'secretsmanager' ? parts[3] : undefined; +} + +/** Quote a literal for the POSIX shell emitted into the workflow. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/index.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/index.ts index c7c3e9c7..8d967d67 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/index.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/index.ts @@ -38,11 +38,13 @@ export { BuildImage, BuildImageKind, DockerBuildProps, ImageTagStrategy } from ' export { CiConfig, CodeArtifactConfig, + CodeBuildImageCredentials, CodePipelineRoleNames, DeployModel, DeploymentConfig, EngineType, GitHubActionsConfig, + GitHubBuildContainerCredentials, ManagedVpcConfig, NpmRegistryConfig, PipelineRoleNames, @@ -65,6 +67,10 @@ export { defineDeployment } from './config/define'; // Stack-name control for `bin/` (TS-authoring, like `defineCICD`): a stage-qualified name, and the option // to reproduce Blueprint's `-` so a migration updates the existing stack in place. See naming.ts. export { stageStackName, StageStackNameOptions } from './config/naming'; +export { + DefaultSynthesizerRoleArnOptions, + specializeDefaultSynthesizerRoleArn, +} from './config/default-synthesizer-role-arn'; // The engine abstraction (m4-iengine). `IEngine`/`EngineRenderProps` are the seam CodePipeline (M4) // and later container engines implement; concrete engines will be exported here as they land. diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/inject.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/inject.ts index 5dd50499..83ce3e17 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/inject.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/inject.ts @@ -13,9 +13,23 @@ // below is that shared post-construction core. import * as path from 'path'; -import { App, Aspects, DefaultStackSynthesizer, IReusableStackSynthesizer, Tags } from 'aws-cdk-lib'; +import { AppStagingSynthesizer, BootstrapRole, DeploymentIdentities } from '@aws-cdk/app-staging-synthesizer-alpha'; +import { + AspectPriority, + Aspects, + DefaultStackSynthesizer, + IAspect, + IReusableStackSynthesizer, + Stage, + Tags, +} from 'aws-cdk-lib'; +import { BucketEncryption } from 'aws-cdk-lib/aws-s3'; +import { IConstruct } from 'constructs'; import { configPluginRefs, registeredPlugins, resolvePlugins } from './plugins'; import { AppConfig } from '../appconfig/accessor'; +import { normalizeDefaultSynthesizerQualifier } from '../config/default-synthesizer-role-arn'; +import { SynthesizerType } from '../config/types'; +import { AccessLogsForBucketAspect } from '../support/AccessLogsForBucketAspect'; /** * Environment flag that `cdk-cicd exec` (m2-exec) sets to arm the bundled-app diagnostic. It is a @@ -25,6 +39,13 @@ import { AppConfig } from '../appconfig/accessor'; */ export const EXEC_FLAG = 'CDK_CICD_EXEC'; +/** + * Construct-context key for wrapper-owned runtime configuration. Kept separate from + * {@link AppConfig.CONTEXT_KEY}: `AppConfig.of()` must return only the active stage's application + * config, never pipeline controls such as plugins, qualifier, or synthesizer. + */ +export const WRAPPER_CONFIG_CONTEXT_KEY = 'cicd:wrapper'; + /** The actionable message the diagnostic prints when the preload injected nothing. */ export const BUNDLED_DIAGNOSTIC_MESSAGE = 'cdk-cicd-wrapper: the injection preload loaded but no App passed through it, so the wrapper ' + @@ -64,11 +85,204 @@ export const DEPLOY_ROLE_FLAG = 'CDK_CICD_DEPLOY_ROLE_ARN'; export const CFN_EXEC_ROLE_FLAG = 'CDK_CICD_CFN_EXEC_ROLE_ARN'; /** ExternalId presented when assuming the forced deploy role (m-external-id). See `DeploymentConfig.externalId`. */ export const DEPLOY_ROLE_EXTERNAL_ID_FLAG = 'CDK_CICD_DEPLOY_ROLE_EXTERNAL_ID'; +/** Compliance destination name injected by a pipeline build into application synthesis. */ +export const COMPLIANCE_LOG_BUCKET_NAME_FLAG = 'CDK_CICD_COMPLIANCE_LOG_BUCKET_NAME'; +/** Account containing the compliance destination; paired with {@link COMPLIANCE_LOG_BUCKET_NAME_FLAG}. */ +export const COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG = 'CDK_CICD_COMPLIANCE_LOG_BUCKET_ACCOUNT'; +/** Region containing the compliance destination; paired with {@link COMPLIANCE_LOG_BUCKET_NAME_FLAG}. */ +export const COMPLIANCE_LOG_BUCKET_REGION_FLAG = 'CDK_CICD_COMPLIANCE_LOG_BUCKET_REGION'; function envArn(value: string | undefined): string | undefined { return value !== undefined && value.trim().length > 0 ? value.trim() : undefined; } +function stagingAppId(value: string): string { + const normalized = value + .toLocaleLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .slice(0, 20); + if (normalized.length === 0) { + throw new Error( + `cdk-cicd-wrapper: APP_STAGING app id '${value}' contains no letters, numbers, or dashes after normalization.`, + ); + } + return normalized; +} + +const WRAPPER_CONFIG_FIELDS = ['application', 'plugins', 'qualifier', 'synthesizer'] as const; +const WRAPPER_APPLIED = Symbol.for('@cdklabs/cdk-cicd-wrapper.WrapperApplied'); +const RUNTIME_COMPLIANCE_CONFIG = Symbol.for('@cdklabs/cdk-cicd-wrapper.RuntimeComplianceConfig'); +const COMPLIANCE_ASPECT_CONFIG = Symbol.for('@cdklabs/cdk-cicd-wrapper.ComplianceAspectConfig'); +const COMPLIANCE_STAGE_SYNTH_PATCH = Symbol.for('@cdklabs/cdk-cicd-wrapper.ComplianceStageSynthPatch'); + +interface WrapperCarrier { + [WRAPPER_APPLIED]?: boolean; +} + +interface RuntimeComplianceConfig { + readonly bucketName: string; + readonly account: string; + readonly region: string; +} + +interface ComplianceCarrier { + [RUNTIME_COMPLIANCE_CONFIG]?: RuntimeComplianceConfig; + [COMPLIANCE_ASPECT_CONFIG]?: string; +} + +interface StageSynthesisCarrier { + readonly assembly?: unknown; +} + +interface StageSynthPrototype { + [COMPLIANCE_STAGE_SYNTH_PATCH]?: boolean; + synth(this: IConstruct, ...args: unknown[]): unknown; +} + +/** Internal controls for applying the wrapper to a composite construct tree. */ +interface ApplyWrapperOptions { + /** + * Do not run this scope's aspects below descendants that already received their own wrapper pass. + * Used by self-mutating engines: each application Stage gets stage-specific config, while the root + * pass remains responsible for pipeline infrastructure without visiting app resources twice. + */ + readonly skipAppliedDescendants?: boolean; +} + +/** Delegates an aspect everywhere except below an independently wrapped descendant scope. */ +class SkipAppliedDescendantsAspect implements IAspect { + public constructor( + public readonly delegate: IAspect, + private readonly root: IConstruct, + ) {} + + public visit(node: IConstruct): void { + let current: IConstruct | undefined = node; + while (current !== undefined && current !== this.root) { + if ((current as WrapperCarrier)[WRAPPER_APPLIED] === true) return; + current = current.node.scope; + } + this.delegate.visit(node); + } +} + +function complianceConfigKey(config: RuntimeComplianceConfig): string { + return `${config.bucketName}\0${config.account}\0${config.region}`; +} + +function nearestRuntimeComplianceConfig(scope: IConstruct): RuntimeComplianceConfig | undefined { + let current: IConstruct | undefined = scope; + while (current !== undefined) { + const config = (current as ComplianceCarrier)[RUNTIME_COMPLIANCE_CONFIG]; + if (config !== undefined) return config; + current = current.node.scope; + } + return undefined; +} + +function attachRuntimeComplianceAspects(scope: IConstruct): void { + for (const candidate of scope.node.findAll()) { + if (!Stage.isStage(candidate)) continue; + + const config = nearestRuntimeComplianceConfig(candidate); + if (config === undefined) continue; + + const carrier = candidate as ComplianceCarrier; + const configKey = complianceConfigKey(config); + if (carrier[COMPLIANCE_ASPECT_CONFIG] === configKey) continue; + if ((candidate as unknown as StageSynthesisCarrier).assembly !== undefined) { + throw new Error( + `cdk-cicd-wrapper: Stage '${candidate.node.path}' was synthesized before compliance logging ` + + 'was attached. Its cached cloud assembly cannot be retrofitted; call CdkCicd.attach(app) before synth.', + ); + } + if (carrier[COMPLIANCE_ASPECT_CONFIG] !== undefined) { + throw new Error( + `cdk-cicd-wrapper: Stage '${candidate.node.path}' received conflicting compliance logging destinations.`, + ); + } + + Aspects.of(candidate).add( + new AccessLogsForBucketAspect({ + complianceLogBucketName: config.bucketName, + complianceLogBucketAccount: config.account, + complianceLogBucketRegion: config.region, + }), + { priority: AspectPriority.MUTATING }, + ); + carrier[COMPLIANCE_ASPECT_CONFIG] = configKey; + } +} + +/** + * CDK deliberately stops inherited Aspect traversal at child Stage boundaries and synthesizes + * nested Stages before invoking a parent Stage's Aspects. Patch the concrete CDK copy's Stage + * synthesis method once so stages created after the wrapper was applied receive the compliance + * aspect before any nested assembly is emitted. + */ +function installRuntimeComplianceTraversal(scope: IConstruct, config: RuntimeComplianceConfig): void { + (scope as ComplianceCarrier)[RUNTIME_COMPLIANCE_CONFIG] = config; + attachRuntimeComplianceAspects(scope); + + if (!Stage.isStage(scope)) return; + + let prototype: StageSynthPrototype | null = Object.getPrototypeOf(scope) as StageSynthPrototype | null; + while (prototype !== null && !Object.prototype.hasOwnProperty.call(prototype, 'synth')) { + prototype = Object.getPrototypeOf(prototype) as StageSynthPrototype | null; + } + if (prototype === null || prototype[COMPLIANCE_STAGE_SYNTH_PATCH] === true) return; + + const originalSynth = prototype.synth; + Object.defineProperty(prototype, 'synth', { + configurable: true, + writable: true, + value: function complianceAwareSynth(this: IConstruct, ...args: unknown[]): unknown { + attachRuntimeComplianceAspects(this); + return originalSynth.apply(this, args); + }, + }); + Object.defineProperty(prototype, COMPLIANCE_STAGE_SYNTH_PATCH, { value: true }); +} + +/** + * Select the serializable cicd.config fields the App-construction runtime owns. Keeping this allowlist + * in one place prevents the pipeline config from leaking into `AppConfig.of()` while still letting the + * preload and self-mutating assembler consume the same runtime shape. + */ +export function wrapperRuntimeConfig(config: Record): Record { + const selected: Record = {}; + for (const field of WRAPPER_CONFIG_FIELDS) { + if (Object.prototype.hasOwnProperty.call(config, field)) { + selected[field] = config[field]; + } + } + return selected; +} + +/** + * Build the config consumed by wrapper internals. Application fields such as tags and log retention + * remain available to plugins, but wrapper-owned fields come exclusively from the separate wrapper + * context whenever it is present. Without that context, preserve the historical single-context shape + * for callers that invoke the runtime helpers directly. + */ +export function mergeRuntimeConfig( + appConfig: Record, + wrapperConfig?: Record, +): Record { + if (wrapperConfig === undefined) { + return { ...appConfig }; + } + + const merged = { ...appConfig }; + for (const field of WRAPPER_CONFIG_FIELDS) { + delete merged[field]; + if (Object.prototype.hasOwnProperty.call(wrapperConfig, field)) { + merged[field] = wrapperConfig[field]; + } + } + return merged; +} + /** * The synthesizer the wrapper installs. `DefaultStackSynthesizer` is the Autopilot default (app-staging is * opt-in, still alpha). When the CLI has exported forced deployer / CloudFormation-execution role ARNs @@ -76,14 +290,79 @@ function envArn(value: string | undefined): string | undefined { * environment, not from config, so the wrapper stays decoupled from cicd.config parsing. A forced * deploy-role ExternalId is threaded the same way (`DefaultStackSynthesizer.deployRoleExternalId`). */ -export function resolveSynthesizer(_config: Record): IReusableStackSynthesizer { +export function resolveSynthesizer(config: Record): IReusableStackSynthesizer { + const rawSynthesizer = config.synthesizer; + if (rawSynthesizer !== undefined && !isConfigObject(rawSynthesizer)) { + throw new Error('cdk-cicd-wrapper: `synthesizer` must be an object such as { type: SynthesizerType.DEFAULT }.'); + } + const synthesizerType = isConfigObject(rawSynthesizer) + ? (rawSynthesizer.type ?? SynthesizerType.DEFAULT) + : SynthesizerType.DEFAULT; + if (synthesizerType !== SynthesizerType.DEFAULT && synthesizerType !== SynthesizerType.APP_STAGING) { + throw new Error(`cdk-cicd-wrapper: unsupported synthesizer type '${String(synthesizerType)}'.`); + } + + let qualifier: string | undefined; + if (config.qualifier !== undefined) { + if (typeof config.qualifier !== 'string') { + throw new Error('cdk-cicd-wrapper: `qualifier` must be a string.'); + } + qualifier = normalizeDefaultSynthesizerQualifier(config.qualifier); + } const deployRoleArn = envArn(process.env[DEPLOY_ROLE_FLAG]); const cloudFormationExecutionRole = envArn(process.env[CFN_EXEC_ROLE_FLAG]); - const deployRoleExternalId = envArn(process.env[DEPLOY_ROLE_EXTERNAL_ID_FLAG]); - if (deployRoleArn !== undefined || cloudFormationExecutionRole !== undefined) { - return new DefaultStackSynthesizer({ deployRoleArn, cloudFormationExecutionRole, deployRoleExternalId }); + const deployRoleExternalId = + deployRoleArn === undefined ? undefined : envArn(process.env[DEPLOY_ROLE_EXTERNAL_ID_FLAG]); + + if (synthesizerType === SynthesizerType.APP_STAGING) { + const configuredAppId = isConfigObject(rawSynthesizer) ? rawSynthesizer.appId : undefined; + if (configuredAppId !== undefined && typeof configuredAppId !== 'string') { + throw new Error('cdk-cicd-wrapper: APP_STAGING `appId` must be a string.'); + } + const rawAppId = + envArn(configuredAppId) ?? (typeof config.application === 'string' ? envArn(config.application) : undefined); + if (rawAppId === undefined) { + throw new Error( + 'cdk-cicd-wrapper: SynthesizerType.APP_STAGING requires an application-unique id; set ' + + '`application` or `synthesizer.appId` in cicd.config.', + ); + } + const appId = stagingAppId(rawAppId); + if (deployRoleExternalId !== undefined) { + throw new Error( + 'cdk-cicd-wrapper: SynthesizerType.APP_STAGING cannot use a forced deploy-role ExternalId. ' + + '@aws-cdk/app-staging-synthesizer-alpha supports custom deployment identities but does not ' + + 'expose an ExternalId for them; remove the ExternalId or use SynthesizerType.DEFAULT.', + ); + } + + const deploymentIdentities = + deployRoleArn !== undefined || cloudFormationExecutionRole !== undefined + ? DeploymentIdentities.specifyRoles({ + deploymentRole: BootstrapRole.fromRoleArn(deployRoleArn ?? AppStagingSynthesizer.DEFAULT_DEPLOY_ROLE_ARN), + cloudFormationExecutionRole: BootstrapRole.fromRoleArn( + cloudFormationExecutionRole ?? AppStagingSynthesizer.DEFAULT_CLOUDFORMATION_ROLE_ARN, + ), + // The pinned alpha's partial-role fallback accidentally references `identities.lookupRole` + // twice, so provide the standard lookup role explicitly. + lookupRole: BootstrapRole.fromRoleArn(AppStagingSynthesizer.DEFAULT_LOOKUP_ROLE_ARN), + }) + : undefined; + + return AppStagingSynthesizer.defaultResources({ + appId, + bootstrapQualifier: qualifier, + deploymentIdentities, + stagingBucketEncryption: BucketEncryption.S3_MANAGED, + }); } - return new DefaultStackSynthesizer(); + + return new DefaultStackSynthesizer({ + qualifier, + deployRoleArn, + cloudFormationExecutionRole, + deployRoleExternalId, + }); } /** @@ -91,7 +370,36 @@ export function resolveSynthesizer(_config: Record): IReusableS * from the resolved config, tree-wide. Safe to call on an already-constructed App, which * is what lets `attach()` reuse it. */ -export function applyWrapper(app: App, config: Record): void { +export function applyWrapper( + scope: IConstruct, + config: Record, + options: ApplyWrapperOptions = {}, +): void { + const wrapperContext = scope.node.tryGetContext(WRAPPER_CONFIG_CONTEXT_KEY); + const effectiveConfig = mergeRuntimeConfig(config, isConfigObject(wrapperContext) ? wrapperContext : undefined); + const pluginCarriers = Array.from(new Set([scope, ...scope.node.findAll()])); + + // Flat-engine application stacks synthesize in a separate CodeBuild process from the pipeline stack. + // The engine exports the real destination environment into that process; attach the mutating aspect + // here so it reaches the application templates rather than only the pipeline's own construct tree. + const complianceLogBucketName = envArn(process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG]); + const complianceLogBucketAccount = envArn(process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG]); + const complianceLogBucketRegion = envArn(process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG]); + const complianceValues = [complianceLogBucketName, complianceLogBucketAccount, complianceLogBucketRegion]; + if (complianceValues.some((value) => value !== undefined)) { + if (complianceValues.some((value) => value === undefined)) { + throw new Error( + 'cdk-cicd-wrapper: compliance logging runtime configuration is incomplete; bucket name, ' + + 'account, and region must be injected together.', + ); + } + installRuntimeComplianceTraversal(scope, { + bucketName: complianceLogBucketName!, + account: complianceLogBucketAccount!, + region: complianceLogBucketRegion!, + }); + } + // Resolve which security plugins (Aspects) apply from the injected config's `plugins` selection and // any custom plugins registered in bin/ via CdkCicd.addPlugin (issue #241). No `plugins` in config // means the full default-on set (cdk-nag, log retention, and the bucket/SNS/key/EC2 hardening @@ -99,26 +407,35 @@ export function applyWrapper(app: App, config: Record): void { // resolution itself is the pure `resolvePlugins` -- here we just add the result tree-wide, since // Aspects visit before template emission (no need to monkeypatch synth()). const { aspects, warnings } = resolvePlugins({ - configPlugins: configPluginRefs(config), - registered: registeredPlugins(app), - config, + configPlugins: configPluginRefs(effectiveConfig), + // During self-mutating replay a custom plugin is registered against the Stage returned in place + // of `new App()`. Search the complete tree so that registration is visible when the wrapper is + // applied once, at the real root App, after all stages have been replayed. + registered: pluginCarriers.flatMap((carrier) => registeredPlugins(carrier)), + config: effectiveConfig, }); for (const aspect of aspects) { - Aspects.of(app).add(aspect); + Aspects.of(scope).add(options.skipAppliedDescendants ? new SkipAppliedDescendantsAspect(aspect, scope) : aspect); } for (const warning of warnings) { // eslint-disable-next-line no-console console.warn(`cdk-cicd-wrapper: ${warning.message}`); } - const tags = config.tags; + const tags = effectiveConfig.tags; if (tags !== null && typeof tags === 'object' && !Array.isArray(tags)) { for (const [key, value] of Object.entries(tags as Record)) { if (value !== null && value !== undefined) { - Tags.of(app).add(key, String(value)); + Tags.of(scope).add(key, String(value)); } } } + (scope as WrapperCarrier)[WRAPPER_APPLIED] = true; +} + +/** Whether the wrapper has already been explicitly applied to this construct scope. */ +export function wrapperApplied(scope: IConstruct): boolean { + return (scope as WrapperCarrier)[WRAPPER_APPLIED] === true; } /** @@ -129,18 +446,13 @@ export function applyWrapper(app: App, config: Record): void { * simply un-configured, not an error. */ export function readInjectedConfig(props?: { context?: { [key: string]: unknown } }): Record { - const fromProps = props?.context?.[AppConfig.CONTEXT_KEY]; - if (isConfigObject(fromProps)) { - return fromProps; - } - + let envContext: Record = {}; const raw = process.env.CDK_CONTEXT_JSON; if (raw !== undefined && raw.length > 0) { try { const parsed = JSON.parse(raw); - const fromEnv = parsed?.[AppConfig.CONTEXT_KEY]; - if (isConfigObject(fromEnv)) { - return fromEnv; + if (isConfigObject(parsed)) { + envContext = parsed; } } catch { // A malformed CDK_CONTEXT_JSON is the CLI's problem, not ours -- the App constructor @@ -148,7 +460,19 @@ export function readInjectedConfig(props?: { context?: { [key: string]: unknown } } - return {}; + const fromProps = props?.context?.[AppConfig.CONTEXT_KEY]; + const fromEnv = envContext[AppConfig.CONTEXT_KEY]; + const appConfig = isConfigObject(fromProps) ? fromProps : isConfigObject(fromEnv) ? fromEnv : {}; + + const wrapperFromProps = props?.context?.[WRAPPER_CONFIG_CONTEXT_KEY]; + const wrapperFromEnv = envContext[WRAPPER_CONFIG_CONTEXT_KEY]; + const wrapperConfig = isConfigObject(wrapperFromProps) + ? wrapperFromProps + : isConfigObject(wrapperFromEnv) + ? wrapperFromEnv + : undefined; + + return mergeRuntimeConfig(appConfig, wrapperConfig); } /** A parsed value usable as a config object: a non-null, non-array object. */ diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/pipeline-assembler.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/pipeline-assembler.ts index 68dac0c5..83bee296 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/pipeline-assembler.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/pipeline-assembler.ts @@ -13,11 +13,25 @@ // re-`require` the entry -- the same App-construction seam register.ts owns for the flat engine. Not // jsii-exported: it uses dynamic require and module-level state, like register.ts. +import { readFileSync } from 'fs'; import * as path from 'path'; -import { App, Aspects, AppProps, Environment, Stack, Stage } from 'aws-cdk-lib'; -import { AwsSolutionsChecks } from 'cdk-nag'; -import { appExportTargets, assertAppModuleLayout, patchAppExports, restoreAppExports } from './inject'; -import { EngineType, ResolvedCicdConfig } from '../config/types'; +import { App, AppProps, DefaultStackSynthesizer, Environment, Stack, Stage } from 'aws-cdk-lib'; +import { + applyWrapper, + appExportTargets, + assertAppModuleLayout, + CFN_EXEC_ROLE_FLAG, + DEPLOY_ROLE_FLAG, + patchAppExports, + resolveSynthesizer, + restoreAppExports, + wrapperApplied, + wrapperRuntimeConfig, + WRAPPER_CONFIG_CONTEXT_KEY, +} from './inject'; +import { AppConfig } from '../appconfig/accessor'; +import { ConfigErrorKind } from '../appconfig/error'; +import { EngineType, ResolvedCicdConfig, SynthesizerType } from '../config/types'; import { CdkPipelinesEngine, CdkPipelinesStageContext, @@ -28,18 +42,23 @@ import { GitHubActionsEngine } from '../engine/github/GitHubActionsEngine'; /** Name used when the config names no application (mirrors PipelineApp). */ const DEFAULT_APPLICATION = 'cdk-cicd'; +interface ReplayCdkBindings { + /** Every export object whose `App` property must point at the replay stand-in. */ + readonly appExportTargets: object[]; + /** One private default-synthesizer context key per distinct aws-cdk-lib copy. */ + readonly synthesizerContextKeys: string[]; +} + /** - * Every place `App` is exported from every distinct aws-cdk-lib copy reachable from the ENTRY, this - * module, and the cwd (leaf module + every re-export -- see `appExportTargets`). We must patch the - * copy the ENTRY actually loads -- a monorepo/workspace can have a nested aws-cdk-lib next to the - * entry AND one next to the wrapper, and Node caches by resolved path, so patching only one would - * silently miss the entry's copy (its `new cdk.App()` would build a throwaway App and leave the stage - * empty). Same strategy as register.ts's distinctCdkCopies. Direct file path bypasses the package - * `exports` map (ERR_PACKAGE_PATH_NOT_EXPORTED). + * Every distinct aws-cdk-lib copy reachable from the ENTRY, this module, and the cwd. For each copy, + * collect both the App export targets and that copy's randomized private default-synthesizer context + * key. A monorepo can load Stack from one copy and Stage from another; setting every key on the replay + * Stage makes each Stack constructor observe the same stage-specific synthesizer. */ -function appLeafModules(entryResolved: string): object[] { +function replayCdkBindings(entryResolved: string): ReplayCdkBindings { const seen = new Set(); const targets: object[] = []; + const synthesizerContextKeys: string[] = []; for (const from of [path.dirname(entryResolved), __dirname, process.cwd()]) { let pkgJson: string; try { @@ -52,13 +71,77 @@ function appLeafModules(entryResolved: string): object[] { seen.add(root); // eslint-disable-next-line @typescript-eslint/no-require-imports const mod = require(path.join(root, 'core', 'lib', 'app.js')) as { App: new (props?: AppProps) => App }; - // eslint-disable-next-line @typescript-eslint/no-require-imports - assertAppModuleLayout(mod, require(pkgJson).version); + const cdkVersion = (JSON.parse(readFileSync(pkgJson, 'utf8')) as { version?: unknown }).version; + assertAppModuleLayout(mod, String(cdkVersion)); for (const target of appExportTargets(root, mod)) { if (!targets.includes(target)) targets.push(target); } + // App stores its default synthesizer under a deliberately randomized private context key. Read the + // key from this exact aws-cdk-lib copy so a Stack imported from it can find our replay override. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const privateContext = require(path.join(root, 'core', 'lib', 'private', 'private-context.js')) as { + PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER?: unknown; + }; + if (typeof privateContext.PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER !== 'string') { + throw new Error( + `cdk-cicd: aws-cdk-lib ${String(cdkVersion)} no longer exposes the private ` + + 'default-stack-synthesizer context key required for self-mutating replay.', + ); + } + synthesizerContextKeys.push(privateContext.PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER); + } + return { appExportTargets: targets, synthesizerContextKeys }; +} + +/** + * Forced-role environment for one replayed stage. The self-mutating engines preserve forced deploy + * and CloudFormation roles in the assembly, but their installed deployment paths cannot honor a custom + * deploy-role ExternalId. Reject that combination rather than synthesizing a pipeline that fails later. + */ +export function replayForcedRoleEnv(config: ResolvedCicdConfig, stageName: string): Record { + const stage = config.stages.find((candidate) => candidate.name === stageName); + const deployment = stage?.deployment; + const env: Record = {}; + if (deployment?.deployRole !== undefined && deployment.deployRole.trim().length > 0) { + env[DEPLOY_ROLE_FLAG] = deployment.deployRole.trim(); + } + if (deployment?.cfnExecutionRole !== undefined && deployment.cfnExecutionRole.trim().length > 0) { + env[CFN_EXEC_ROLE_FLAG] = deployment.cfnExecutionRole.trim(); + } + + if (env[DEPLOY_ROLE_FLAG] !== undefined) { + const externalId = deployment?.externalId ?? config.deployRoleExternalId; + if (externalId !== undefined && externalId.trim().length > 0) { + throw new Error( + `cdk-cicd: self-mutating engines cannot honor the deploy-role ExternalId configured for stage ` + + `'${stageName}'. Remove the ExternalId or use the CODEPIPELINE engine.`, + ); + } + } + return env; +} + +function setOrDeleteEnv(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +/** + * Stack construction reads a private context key created independently by each aws-cdk-lib copy. + * Install this stage's synthesizer under every discovered key before the entry creates children. + * Explicit per-Stack synthesizers still win, while ordinary stacks carry this stage's forced roles. + */ +function installReplaySynthesizer(stage: Stage, config: ResolvedCicdConfig, synthesizerContextKeys: string[]): void { + if (stage.node.children.length > 0) { + throw new Error( + `cdk-cicd: replay stage '${stage.node.id}' already has children, so its stage-specific ` + + 'synthesizer cannot be installed before stack construction.', + ); + } + const synthesizer = resolveSynthesizer(wrapperRuntimeConfig(config as unknown as Record)); + for (const key of synthesizerContextKeys) { + stage.node.setContext(key, synthesizer); } - return targets; } /** @@ -75,19 +158,24 @@ function appLeafModules(entryResolved: string): object[] { * (which flows through the pipeline App and is inherited by every stage) instead. Both are enforced/eased * elsewhere: an empty stage throws a clear error below; cdk.json context is inherited. */ -function replayEntryInto(entry: string, stage: Stage, context: CdkPipelinesStageContext): void { +function replayEntryInto( + entry: string, + stage: Stage, + context: CdkPipelinesStageContext, + config: ResolvedCicdConfig, +): void { const resolved = require.resolve(entry); - const targets = appLeafModules(resolved); - if (targets.length === 0) { + const bindings = replayCdkBindings(resolved); + if (bindings.appExportTargets.length === 0) { throw new Error(`cdk-cicd: cannot resolve aws-cdk-lib from '${entry}' to replay it into the pipeline stage.`); } const prevStage = process.env.CDK_STAGE; const prevAccount = process.env.CDK_DEFAULT_ACCOUNT; const prevRegion = process.env.CDK_DEFAULT_REGION; - process.env.CDK_STAGE = context.stageName; - if (context.env.account !== undefined) process.env.CDK_DEFAULT_ACCOUNT = context.env.account; - if (context.env.region !== undefined) process.env.CDK_DEFAULT_REGION = context.env.region; + const prevDeployRole = process.env[DEPLOY_ROLE_FLAG]; + const prevCfnExecRole = process.env[CFN_EXEC_ROLE_FLAG]; + const forcedRoleEnv = replayForcedRoleEnv(config, context.stageName); // `new cdk.App()` in the entry yields this stage. A NON-derived class may return an object from its // constructor with no super() (TS forbids that in a derived constructor -- TS2377), so no throwaway App @@ -95,7 +183,6 @@ function replayEntryInto(entry: string, stage: Stage, context: CdkPipelinesStage // pipeline synthesizes the stage. const hadOwnSynth = Object.prototype.hasOwnProperty.call(stage, 'synth'); const originalSynth = Reflect.get(stage, 'synth'); - Reflect.set(stage, 'synth', () => undefined); const ReplayApp = class { public constructor(_props?: AppProps) { return stage; @@ -115,25 +202,37 @@ function replayEntryInto(entry: string, stage: Stage, context: CdkPipelinesStage // `aws-cdk-lib/core` re-exports self-memoize into a non-writable value on first read (see // appExportTargets), and a plain assignment silently no-ops against that -- the entry's // `new cdk.App()` would then build a real, unpatched App instead of landing in `stage`. - const originals = patchAppExports(targets, ReplayApp); + let originals: Map | undefined; try { + process.env.CDK_STAGE = context.stageName; + // An omitted account/region is environment-agnostic and must retain the ambient credential + // target, matching the pre-replay behaviour. Concrete stage values override it for this replay. + if (context.env.account !== undefined) process.env.CDK_DEFAULT_ACCOUNT = context.env.account; + if (context.env.region !== undefined) process.env.CDK_DEFAULT_REGION = context.env.region; + setOrDeleteEnv(DEPLOY_ROLE_FLAG, forcedRoleEnv[DEPLOY_ROLE_FLAG]); + setOrDeleteEnv(CFN_EXEC_ROLE_FLAG, forcedRoleEnv[CFN_EXEC_ROLE_FLAG]); + installReplaySynthesizer(stage, config, bindings.synthesizerContextKeys); + Reflect.set(stage, 'synth', () => undefined); + originals = patchAppExports(bindings.appExportTargets, ReplayApp); delete require.cache[resolved]; // eslint-disable-next-line @typescript-eslint/no-require-imports require(resolved); } finally { - restoreAppExports(originals); + if (originals !== undefined) restoreAppExports(originals); if (hadOwnSynth) Reflect.set(stage, 'synth', originalSynth); else Reflect.deleteProperty(stage, 'synth'); restoreEnv('CDK_STAGE', prevStage); restoreEnv('CDK_DEFAULT_ACCOUNT', prevAccount); restoreEnv('CDK_DEFAULT_REGION', prevRegion); + restoreEnv(DEPLOY_ROLE_FLAG, prevDeployRole); + restoreEnv(CFN_EXEC_ROLE_FLAG, prevCfnExecRole); } // A stage with no stacks means the entry built nothing into it -- almost always because construction // lives in the top level of a transitively-required module (cached, runs once). Fail with a clear // pointer instead of CDK Pipelines' generic "stage should contain at least one Stack". - if (stage.node.findAll().filter((c): c is Stack => c instanceof Stack).length === 0) { + if (stage.node.findAll().filter(Stack.isStack).length === 0) { throw new Error( `cdk-cicd: replaying '${entry}' into stage '${context.stageName}' produced no stacks. Build your ` + 'stacks at the top level of the entry, or in a function the entry calls -- not in the top level of ' + @@ -148,14 +247,26 @@ function restoreEnv(key: string, value: string | undefined): void { } /** An IStageProvider that fills each pipeline stage by replaying the plain entry into it. */ -export function replayStageProvider(entry: string): IStageProvider { +export function replayStageProvider(entry: string, config: ResolvedCicdConfig): IStageProvider { return { stacks(stage: Stage, context: CdkPipelinesStageContext): void { - replayEntryInto(entry, stage, context); + replayEntryInto(entry, stage, context, config); }, }; } +/** Load the same per-stage application config as `cdk-cicd exec`, tolerating a missing file as `{}`. */ +function stageApplicationConfig(stageName: string): Record { + try { + return AppConfig.load({ stage: stageName }) as Record; + } catch (error) { + if ((error as { kind?: string }).kind === ConfigErrorKind.MISSING_FILE) { + return {}; + } + throw error; + } +} + /** * Build the self-mutating app: one pipeline stack whose stages are filled by `provider`. Split from * `assemblePipelineApp` so the pipeline structure is unit-testable with a stub provider (the replay @@ -164,7 +275,38 @@ export function replayStageProvider(entry: string): IStageProvider { * `cdk.Stage` inside one synth); `config.engine` picks which one renders the stages `provider` builds. */ export function buildPipelineApp(config: ResolvedCicdConfig, provider: IStageProvider): App { - const app = new App(); + if ((config.synthesizer?.type ?? SynthesizerType.DEFAULT) === SynthesizerType.APP_STAGING) { + throw new Error( + 'cdk-cicd: SynthesizerType.APP_STAGING is not supported by the CDK_PIPELINES or ' + + 'GITHUB_ACTIONS engines: its DefaultStagingStack is created under the root App, but an ' + + 'application stack inside a pipeline Stage cannot depend across that Stage boundary. This is ' + + 'not a bootstrap-qualifier limitation. Use SynthesizerType.DEFAULT for generated pipelines; ' + + 'APP_STAGING remains available for direct local CDK deployment.', + ); + } + const runtimeConfig = wrapperRuntimeConfig(config as unknown as Record); + // The pipeline stack does not inherit the application's config.qualifier. Its default synthesizer + // follows CDK's normal bootstrap-qualifier context fallback, then the standard default. The + // configured application synthesizer is installed independently on each replay Stage. + const app = new App({ + defaultStackSynthesizer: new DefaultStackSynthesizer(), + }); + const wrappedProvider: IStageProvider = { + stacks(stage: Stage, context: CdkPipelinesStageContext): void { + const appConfig = stageApplicationConfig(context.stageName); + // Set both contexts before the provider creates children: user code can call AppConfig.of() + // during construction, while attach()/the wrapper see pipeline-owned controls separately. + stage.node.setContext(AppConfig.CONTEXT_KEY, appConfig); + stage.node.setContext(WRAPPER_CONFIG_CONTEXT_KEY, runtimeConfig); + provider.stacks(stage, context); + // A bundled/explicit entry may already have called CdkCicd.attach(stage). Avoid applying the + // same custom plugins twice; normal replay reaches this branch and receives stage-specific tags + // and plugin configuration. + if (!wrapperApplied(stage)) { + applyWrapper(stage, appConfig); + } + }, + }; // The construct id stays `${application}-pipeline` regardless of any stack-name override: the // pipeline's child logical IDs derive from the construct node path (`/Cd/Pipeline/…`), so // changing the id would churn every child logical ID. `pipelineStackName` overrides ONLY the @@ -174,10 +316,8 @@ export function buildPipelineApp(config: ResolvedCicdConfig, provider: IStagePro const stackName = config.pipelineStackName ?? constructId; // Ambient credentials win when present (a real `deploy-ci` run, or any locally-authenticated synth). // Falling back to the first stage's env keeps the pipeline stack's account/region reproducible when - // no credentials are active -- e.g. the GitHub Actions engine's own self-mutation "Synthesize" job, - // which runs `cdk synth` before assuming any role, and must render the SAME literal account each time - // to pass cdk-pipelines-github's "commit the updated workflow file" check (a token there is never - // stable across runs). + // no credentials are active -- for example a static local render. GitHub Build-Synth authenticates + // before invoking `cdk synth`, but this fallback also keeps direct assembly deterministic. const firstStage = config.stages[0]; const stack = new Stack(app, constructId, { stackName, @@ -190,11 +330,13 @@ export function buildPipelineApp(config: ResolvedCicdConfig, provider: IStagePro // engine embeds it as a stable literal the "commit the updated workflow file" self-mutation check // compares across runs, so it must not vary with a CloudFormation stack-name override. if (config.engine === EngineType.GITHUB_ACTIONS) { - new GitHubActionsEngine(stack, 'Cd', { config, pipelineName: constructId, stages: provider }); + new GitHubActionsEngine(stack, 'Cd', { config, pipelineName: constructId, stages: wrappedProvider }); } else { - new CdkPipelinesEngine(stack, 'Cd', { config, pipelineName: constructId, stages: provider }); + new CdkPipelinesEngine(stack, 'Cd', { config, pipelineName: constructId, stages: wrappedProvider }); } - Aspects.of(app).add(new AwsSolutionsChecks({ verbose: false })); + // Apply the same configured/default wrapper plugin set as the flat-engine preload, after replay has + // populated every stage (and registered any custom plugins on its App stand-in). + applyWrapper(app, runtimeConfig, { skipAppliedDescendants: true }); return app; } @@ -204,7 +346,7 @@ export function buildPipelineApp(config: ResolvedCicdConfig, provider: IStagePro * GITHUB_ACTIONS; the entry path comes from the `CDK_CICD_ENTRY` env var the CLI sets. */ export function assemblePipelineApp(config: ResolvedCicdConfig, entry: string): App { - return buildPipelineApp(config, replayStageProvider(entry)); + return buildPipelineApp(config, replayStageProvider(entry, config)); } /** diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/register.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/register.ts index 8770fc73..ff40651d 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/register.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/runtime/register.ts @@ -26,6 +26,7 @@ import { resolveSynthesizer, shouldWarnBundled, } from './inject'; +import { resolveDefaultSynthesizerQualifier } from '../config/default-synthesizer-role-arn'; // Marks a class this hook has already wrapped, so a second load is a no-op. const WRAPPED = Symbol.for('@cdklabs/cdk-cicd-wrapper.WrappedApp'); @@ -63,6 +64,15 @@ function patchCopy(cdkRoot: string, cdkVersion: string): void { defaultStackSynthesizer: props?.defaultStackSynthesizer ?? resolveSynthesizer(config), }); + // When the wrapper owns the synthesizer and no explicit qualifier was configured, + // DefaultStackSynthesizer will resolve the CDK bootstrap-qualifier context later when a + // Stack binds to it. Validate the exact context CDK installed on this App now, before user + // code can construct a Stack, so runtime synthesis and pipeline IAM reject the same invalid + // values without duplicating CDK's AppProps/environment context precedence. + if (props?.defaultStackSynthesizer === undefined && config.qualifier === undefined) { + resolveDefaultSynthesizerQualifier(this); + } + markAppConstructed(); applyWrapper(this, config); } diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/support/AccessLogsForBucketAspect.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/support/AccessLogsForBucketAspect.ts index eeb84361..597d282b 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/support/AccessLogsForBucketAspect.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/support/AccessLogsForBucketAspect.ts @@ -5,83 +5,113 @@ // no-op unless `complianceLogBucketName` was configured (it read the name off // `PipelineBlueprintProps.deploymentDefinition` and initialized `GlobalResources.COMPLIANCE_BUCKET` // as a side effect). In Autopilot the compliance bucket (`SupportResources.complianceLogBucket`) and its -// `complianceLogBucketName` config field now exist, so this aspect takes the destination bucket name -// explicitly. It is auto-attached by the engines that provision the bucket: the flat `CodePipelineEngine` -// and the `CdkPipelinesEngine`, in both cases at `AspectPriority.MUTATING` so the L1 logging override -// lands before the readonly `AwsSolutionsChecks` (otherwise `AwsSolutions-S1` false-fails). It remains -// exported for a narrower explicit `Aspects.of(scope).add(...)` use. +// `complianceLogBucketName` config field now exist, so this aspect takes the destination bucket and +// environment explicitly. It is auto-attached by the engines that provision the bucket: the flat +// `CodePipelineEngine` and the `CdkPipelinesEngine`, in both cases at `AspectPriority.MUTATING` so the +// L1 logging override lands before the readonly `AwsSolutionsChecks` (otherwise `AwsSolutions-S1` +// false-fails). It remains exported for a narrower explicit `Aspects.of(scope).add(...)` use. -import { IAspect, Annotations, Names, Stack } from 'aws-cdk-lib'; -import { CfnBucket } from 'aws-cdk-lib/aws-s3'; +import { CfnResource, IAspect, Names, Stack, Token } from 'aws-cdk-lib'; +import { CfnBucket, CfnBucketPolicy, IBucket } from 'aws-cdk-lib/aws-s3'; import { IConstruct } from 'constructs'; /** Constructor props for {@link AccessLogsForBucketAspect}. */ export interface AccessLogsForBucketAspectProps { /** The name of the bucket every visited bucket's access logs are delivered to. */ readonly complianceLogBucketName: string; - + /** AWS account that owns the compliance bucket. S3 access-log delivery cannot cross accounts. */ + readonly complianceLogBucketAccount: string; + /** AWS Region containing the compliance bucket. S3 access-log delivery cannot cross Regions. */ + readonly complianceLogBucketRegion: string; /** - * The region the compliance log bucket lives in. When a visited bucket's stack is deployed to a - * different region, `complianceLogBucketName` is rewritten by substituting `mainRegion` for that - * stack's region -- same cross-region name convention as Blueprint. + * The concrete destination bucket when it exists in the same CDK app. Supplying it lets same-stack + * source buckets depend explicitly on the destination bucket and its policy. */ - readonly mainRegion: string; + readonly complianceLogBucket?: IBucket; } /** - * Configures S3 server access logging (destination + prefix) on every L1 `CfnBucket` it visits that - * does not already set a logging destination, matching Blueprint's default-on `AccessLogsForBucketPlugin`. + * Configures S3 server access logging on every L1 `CfnBucket` it visits. The compliance destination + * always wins; an existing user prefix is preserved, otherwise a bucket-specific prefix is generated. */ export class AccessLogsForBucketAspect implements IAspect { private readonly complianceLogBucketName: string; - private readonly mainRegion: string; + private readonly complianceLogBucketAccount: string; + + private readonly complianceLogBucketRegion: string; + + private readonly complianceLogBucket?: IBucket; public constructor(props: AccessLogsForBucketAspectProps) { this.complianceLogBucketName = props.complianceLogBucketName; - this.mainRegion = props.mainRegion; + this.complianceLogBucketAccount = props.complianceLogBucketAccount; + this.complianceLogBucketRegion = props.complianceLogBucketRegion; + this.complianceLogBucket = props.complianceLogBucket; } public visit(node: IConstruct): void { - if (!(node instanceof CfnBucket)) { + if (!isCfnResourceType(node, CfnBucket.CFN_RESOURCE_TYPE_NAME)) { return; } + const bucket = node as unknown as CfnBucket; - const stack = this.findStack(node); - if (!stack) { - throw new Error('Could not find stack for the bucket'); + const destinationResource = this.complianceLogBucket?.node.defaultChild; + if ( + bucket === destinationResource || + (bucket.bucketName !== undefined && + !Token.isUnresolved(bucket.bucketName) && + bucket.bucketName === this.complianceLogBucketName) + ) { + // A server-access-log destination must never log to itself. + return; } - let complianceLogBucketName = this.complianceLogBucketName; - if (stack.region !== this.mainRegion) { - Annotations.of(node).addWarningV2( - 'access-logs-for-bucket-aspect-cross-region-used', - 'The Access Logs For Bucket aspect is used cross region', + const stack = Stack.of(bucket); + if (Token.isUnresolved(stack.account) || Token.isUnresolved(stack.region)) { + throw new Error( + `cdk-cicd: compliance logging for bucket '${bucket.node.path}' requires a concrete source ` + + 'stack account and region so the S3 same-account/same-region requirement can be verified.', ); - complianceLogBucketName = this.complianceLogBucketName.replace(this.mainRegion, stack.region); } - - if (node.loggingConfiguration === undefined) { - node.loggingConfiguration = { - destinationBucketName: complianceLogBucketName, - logFilePrefix: Names.uniqueId(node), - }; - } else { - const currentLoggingConfig = node.loggingConfiguration as CfnBucket.LoggingConfigurationProperty; - if (currentLoggingConfig.logFilePrefix) { - node.loggingConfiguration = { - destinationBucketName: complianceLogBucketName, - logFilePrefix: currentLoggingConfig.logFilePrefix, - }; - } + if (stack.account !== this.complianceLogBucketAccount || stack.region !== this.complianceLogBucketRegion) { + throw new Error( + `cdk-cicd: bucket '${bucket.node.path}' is in ${stack.account}/${stack.region}, but compliance ` + + `bucket '${this.complianceLogBucketName}' is in ${this.complianceLogBucketAccount}/` + + `${this.complianceLogBucketRegion}. S3 server access logs require the source and destination ` + + 'buckets to be in the same account and region.', + ); } + + const currentLoggingConfig = bucket.loggingConfiguration as CfnBucket.LoggingConfigurationProperty | undefined; + bucket.loggingConfiguration = { + destinationBucketName: this.complianceLogBucketName, + logFilePrefix: currentLoggingConfig?.logFilePrefix ?? Names.uniqueId(bucket), + targetObjectKeyFormat: currentLoggingConfig?.targetObjectKeyFormat, + }; + + // The source now targets the concrete compliance bucket, so same-stack creation ordering matters. + this.addSameStackDependencies(bucket, stack); } - private findStack(node: IConstruct): Stack | undefined { - let current: IConstruct | undefined = node; - while (current && current.node.scope && !('stackName' in current)) { - current = current.node.scope; + private addSameStackDependencies(source: CfnBucket, sourceStack: Stack): void { + if (this.complianceLogBucket === undefined || Stack.of(this.complianceLogBucket) !== sourceStack) { + return; + } + + for (const dependency of this.complianceLogBucket.node.findAll()) { + if ( + dependency !== source && + (isCfnResourceType(dependency, CfnBucket.CFN_RESOURCE_TYPE_NAME) || + isCfnResourceType(dependency, CfnBucketPolicy.CFN_RESOURCE_TYPE_NAME)) + ) { + source.addDependency(dependency as CfnResource); + } } - return current as Stack | undefined; } } + +/** CDK's symbol-backed L1 guard works across separately loaded aws-cdk-lib copies; instanceof does not. */ +function isCfnResourceType(node: IConstruct, resourceType: string): boolean { + return CfnResource.isCfnResource(node) && node.cfnResourceType === resourceType; +} diff --git a/packages/@cdklabs/cdk-cicd-wrapper/src/support/SupportResources.ts b/packages/@cdklabs/cdk-cicd-wrapper/src/support/SupportResources.ts index 0e72ef53..1acabb30 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/src/support/SupportResources.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/src/support/SupportResources.ts @@ -12,8 +12,9 @@ // The remaining Blueprint support resources (compliance/log bucket, SSM parameters, VPC, proxy) slot in as // further lazy properties when a milestone needs them. -import { RemovalPolicy, aws_kms as kms, aws_s3 as s3 } from 'aws-cdk-lib'; -import { AnyPrincipal, Effect, PolicyStatement, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { RemovalPolicy, Stack, aws_kms as kms, aws_s3 as s3 } from 'aws-cdk-lib'; +import { Effect, PolicyStatement, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; import { resolveVpcNetworking, VpcNetworking } from './Vpc'; import { VpcConfig } from '../config/types'; @@ -36,10 +37,22 @@ export interface SupportResourcesProps { /** * The name of the compliance/access-log bucket -- Blueprint's `IComplianceBucket.bucketName` * (`ComplianceBucketProvider`). Required only if `complianceLogBucket` is read; an explicit, - * predictable name is what lets other buckets' S3 server-access-logging destination (and Blueprint's - * cross-region name-substitution convention for multi-region deployments) point at it. + * predictable name is what lets same-account, same-Region application buckets point their S3 + * server-access logging at it without creating CloudFormation cross-stack references. */ readonly complianceLogBucketName?: string; + /** + * Whether this construct creates and manages `complianceLogBucketName`. + * + * Set to `false` to reference a pre-existing, owner-managed Blueprint compliance bucket. Imported + * buckets synthesize no `AWS::S3::Bucket` or `AWS::S3::BucketPolicy`; the owner must maintain the + * bucket's same-account/same-Region placement, SSE-S3 encryption, TLS enforcement, public-access + * block, disabled Object Lock and Requester Pays settings, and S3 server-access-log delivery policy. + * A name-only CDK import cannot inspect or validate those live settings. + * + * @default true + */ + readonly createComplianceLogBucket?: boolean; } /** @@ -52,11 +65,12 @@ export class SupportResources extends Construct { private readonly vpcConfig?: VpcConfig; private readonly useProxy: boolean; private readonly complianceLogBucketName?: string; + private readonly createComplianceLogBucket: boolean; private _encryptionKey?: kms.Key; private _artifactBucket?: s3.Bucket; private _vpcNetworking?: VpcNetworking; private vpcResolved = false; - private _complianceLogBucket?: s3.Bucket; + private _complianceLogBucket?: s3.IBucket; public constructor(scope: Construct, id: string, props: SupportResourcesProps = {}) { super(scope, id); @@ -64,6 +78,7 @@ export class SupportResources extends Construct { this.vpcConfig = props.vpc; this.useProxy = props.useProxy ?? false; this.complianceLogBucketName = props.complianceLogBucketName; + this.createComplianceLogBucket = props.createComplianceLogBucket ?? true; } /** The customer-managed key the wrapper encrypts its own artifacts with. Created on first read. */ @@ -114,20 +129,17 @@ export class SupportResources extends Construct { * `ComplianceLogBucketStack`) -- other buckets' S3 server access logs land here. Created on first * read, same as every other property here. Requires `complianceLogBucketName`: unlike * `artifactBucket`, this bucket's name must be explicit and predictable so other buckets' logging - * configuration (and, cross-region, Blueprint's name-substitution convention) can reference it. + * configuration can reference it by name. * - * Blueprint provisioned this bucket via a custom-resource Lambda so a redeploy could tolerate the bucket - * already existing (`BucketAlreadyOwnedByYou`); Autopilot provisions it as a plain, CloudFormation-managed - * `Bucket` instead -- simpler, and the "already exists" case Blueprint tolerated doesn't arise here since - * this construct's stack owns the bucket for the life of the pipeline. + * By default Autopilot provisions a plain, CloudFormation-managed `Bucket`. For an in-place Blueprint + * migration, set `createComplianceLogBucket: false` to reference the existing bucket by name instead. + * CDK intentionally cannot mutate an imported bucket policy, so that mode leaves the bucket and policy + * entirely under their current owner's lifecycle. * - * Folds in the TLS/SSE policy fix Blueprint's Stage-1 change (`0b7ae02`) made and Autopilot must not regress: - * enforcing encryption-in-transit works with a plain `Bool` condition on `aws:SecureTransport` - * (`enforceSSL`, below) because that key is always present on every request. Enforcing encryption - * *at rest* does not: `s3:x-amz-server-side-encryption` is only present in the request context when - * the caller actually sets the header, so a `Bool` check against `"false"` never matches a request - * that omits the header entirely -- exactly the unencrypted upload this statement exists to block. - * The `Null` operator below checks for the header's *absence*, which a `Bool` check cannot. + * The bucket uses default SSE-S3 encryption. Writers, including the S3 server-access-log delivery + * service, do not need to send an `x-amz-server-side-encryption` header: S3 encrypts the object at + * rest after accepting it. A bucket-policy deny based on that header would block valid log delivery, + * so transport encryption is enforced here while at-rest encryption is enforced by bucket defaults. */ public get complianceLogBucket(): s3.IBucket { if (this._complianceLogBucket === undefined) { @@ -135,6 +147,21 @@ export class SupportResources extends Construct { throw new Error('complianceLogBucketName must be configured to read complianceLogBucket'); } + if (!this.createComplianceLogBucket) { + if (this.removalPolicy === RemovalPolicy.DESTROY) { + throw new Error( + 'createComplianceLogBucket: false cannot be combined with RemovalPolicy.DESTROY; ' + + 'the imported compliance bucket is owner-managed.', + ); + } + this._complianceLogBucket = s3.Bucket.fromBucketName( + this, + 'ImportedComplianceLogBucket', + this.complianceLogBucketName, + ); + return this._complianceLogBucket; + } + const bucket = new s3.Bucket(this, 'ComplianceLogBucket', { bucketName: this.complianceLogBucketName, encryption: s3.BucketEncryption.S3_MANAGED, @@ -143,30 +170,40 @@ export class SupportResources extends Construct { removalPolicy: this.removalPolicy, autoDeleteObjects: this.removalPolicy === RemovalPolicy.DESTROY, }); + NagSuppressions.addResourceSuppressions(bucket, [ + { + id: 'AwsSolutions-S1', + reason: + 'This bucket is the dedicated S3 server-access-log destination and must not recursively log to itself.', + }, + ]); - bucket.addToResourcePolicy( + const policyResult = bucket.addToResourcePolicy( new PolicyStatement({ sid: 'S3ServerAccessLogsPolicy', effect: Effect.ALLOW, principals: [new ServicePrincipal('logging.s3.amazonaws.com')], actions: ['s3:PutObject'], resources: [bucket.arnForObjects('*')], - }), - ); - bucket.addToResourcePolicy( - new PolicyStatement({ - sid: 'EnforceEncryptionAtRest', - effect: Effect.DENY, - principals: [new AnyPrincipal()], - actions: ['s3:PutObject'], - resources: [bucket.arnForObjects('*')], conditions: { - Null: { - 's3:x-amz-server-side-encryption': 'true', + StringEquals: { + 'aws:SourceAccount': Stack.of(this).account, + }, + ArnLike: { + // Source bucket names are application-defined and often late-bound. Constrain delivery + // to S3 buckets owned by this exact account; application/pipeline aspects enforce the + // same-account/same-region contract before they configure any source bucket. + 'aws:SourceArn': `arn:${Stack.of(this).partition}:s3:::*`, }, }, }), ); + if (!policyResult.statementAdded || bucket.policy === undefined) { + throw new Error('failed to attach the managed compliance bucket policy'); + } + // The policy is operationally part of the destination. Retaining the bucket while deleting its + // policy would stop log delivery and remove TLS enforcement; disposable stacks should delete both. + bucket.policy.applyRemovalPolicy(this.removalPolicy); this._complianceLogBucket = bucket; } diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/app/PipelineApp.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/app/PipelineApp.test.ts index b83653bb..881b76db 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/app/PipelineApp.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/app/PipelineApp.test.ts @@ -6,7 +6,7 @@ // from the application, environment taken from the ambient CDK_DEFAULT_* the CDK CLI resolves, the // disposable flag reaching the support resources, and the nag aspect being applied at all. -import { Aspects } from 'aws-cdk-lib'; +import { Aspects, BOOTSTRAP_QUALIFIER_CONTEXT } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import { AwsSolutionsChecks } from 'cdk-nag'; import { PipelineApp } from '../../src/app/PipelineApp'; @@ -58,6 +58,55 @@ describe('m4-approval-selfupdate: PipelineApp', () => { expect(stack.environment.region).toEqual(REGION); }); + test('the pipeline stack keeps the standard hub bootstrap qualifier', () => { + const resolved = defineCICD({ + application: 'shop', + qualifier: 'customq', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + }); + const app = new PipelineApp({ config: resolved }); + const artifact = app.synth().getStackArtifact(app.pipelineStack.artifactId); + + expect(artifact.assumeRoleArn).toContain('cdk-hnb659fds-deploy-role-'); + expect(artifact.cloudFormationExecutionRoleArn).toContain('cdk-hnb659fds-cfn-exec-role-'); + }); + + test('the pipeline stack and self-update IAM honor the hub bootstrap qualifier from CDK context', () => { + const previous = process.env.CDK_CONTEXT_JSON; + process.env.CDK_CONTEXT_JSON = JSON.stringify({ [BOOTSTRAP_QUALIFIER_CONTEXT]: 'hubqual' }); + try { + const app = new PipelineApp({ config: config('shop') }); + const assembly = app.synth(); + const artifact = assembly.getStackArtifact(app.pipelineStack.artifactId); + const policies = JSON.stringify(Template.fromJSON(artifact.template).findResources('AWS::IAM::Policy')); + + expect(artifact.assumeRoleArn).toContain('cdk-hubqual-deploy-role-'); + expect(artifact.cloudFormationExecutionRoleArn).toContain('cdk-hubqual-cfn-exec-role-'); + expect(policies).toContain(`cdk-hubqual-deploy-role-${ACCOUNT}-${REGION}`); + } finally { + if (previous === undefined) delete process.env.CDK_CONTEXT_JSON; + else process.env.CDK_CONTEXT_JSON = previous; + } + }); + + test('ci.image is applied only to the CI Build project', () => { + const resolved = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + ci: { image: 'public.ecr.aws/example/node:22' }, + }); + const stack = new PipelineApp({ config: resolved }).synth().stacks[0]; + const projects = Object.values(Template.fromJSON(stack.template).findResources('AWS::CodeBuild::Project')); + const customImageProjects = projects.filter( + (project) => project.Properties.Environment.Image === 'public.ecr.aws/example/node:22', + ); + + expect(customImageProjects).toHaveLength(1); + expect(JSON.stringify(customImageProjects[0].Properties.Source.BuildSpec)).toContain('cdk-cicd synth --all'); + }); + test('the nag aspect is applied to the app', () => { // Only registration is asserted: this repository resolves two copies of aws-cdk-lib, and cdk-nag's // rules match resources with `instanceof`, so the checks produce nothing here regardless of the diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/config/default-synthesizer-role-arn.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/config/default-synthesizer-role-arn.test.ts new file mode 100644 index 00000000..9d6d63a4 --- /dev/null +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/config/default-synthesizer-role-arn.test.ts @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { App, BOOTSTRAP_QUALIFIER_CONTEXT, DefaultStackSynthesizer } from 'aws-cdk-lib'; +import { + normalizeDefaultSynthesizerQualifier, + resolveDefaultSynthesizerQualifier, + specializeDefaultSynthesizerRoleArn, +} from '../../src/config/default-synthesizer-role-arn'; + +describe('default synthesizer qualifier contract', () => { + test('role specialization trims and replaces every explicit qualifier placeholder', () => { + expect( + specializeDefaultSynthesizerRoleArn( + 'arn:aws:iam::${AWS::AccountId}:role/cdk-${Qualifier}-${Qualifier}-${AWS::Region}', + { + qualifier: ' shared_1 ', + account: '111111111111', + region: 'us-west-2', + }, + ), + ).toBe('arn:aws:iam::111111111111:role/cdk-shared_1-shared_1-us-west-2'); + }); + + test('explicit qualifier normalization is shared with pipeline IAM resolution', () => { + const app = new App(); + + expect(normalizeDefaultSynthesizerQualifier(' shared_1 ')).toBe('shared_1'); + expect(resolveDefaultSynthesizerQualifier(app, ' shared_1 ')).toBe('shared_1'); + }); + + test.each(['', ' ', 'invalid qualifier', 'invalid!', '12345678901'])( + 'rejects invalid explicit qualifier %j', + (qualifier) => { + expect(() => normalizeDefaultSynthesizerQualifier(qualifier)).toThrow( + /explicit bootstrap qualifier.*\[A-Za-z0-9_-\]\{1,10\}/, + ); + expect(() => resolveDefaultSynthesizerQualifier(new App(), qualifier)).toThrow( + /explicit bootstrap qualifier.*\[A-Za-z0-9_-\]\{1,10\}/, + ); + expect(() => + specializeDefaultSynthesizerRoleArn('${Qualifier}', { + qualifier, + account: '111111111111', + region: 'us-west-2', + }), + ).toThrow(/explicit bootstrap qualifier.*\[A-Za-z0-9_-\]\{1,10\}/); + }, + ); + + test('uses the exact valid CDK context qualifier when no explicit value is configured', () => { + const app = new App({ context: { [BOOTSTRAP_QUALIFIER_CONTEXT]: 'Context_1' } }); + + expect(resolveDefaultSynthesizerQualifier(app)).toBe('Context_1'); + }); + + test.each([' context1 ', '', 'invalid!', '12345678901', 123])( + 'rejects rather than normalizing invalid CDK context qualifier %j', + (qualifier) => { + const app = new App({ context: { [BOOTSTRAP_QUALIFIER_CONTEXT]: qualifier } }); + + expect(() => resolveDefaultSynthesizerQualifier(app)).toThrow( + new RegExp(`context '${BOOTSTRAP_QUALIFIER_CONTEXT}'.*\\[A-Za-z0-9_-\\]\\{1,10\\}`), + ); + }, + ); + + test('falls back to the CDK default only when explicit and context qualifiers are absent', () => { + expect(resolveDefaultSynthesizerQualifier(new App())).toBe(DefaultStackSynthesizer.DEFAULT_QUALIFIER); + }); +}); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/config/define.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/config/define.test.ts index c4805293..a447cde1 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/config/define.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/config/define.test.ts @@ -56,13 +56,22 @@ describe('m3-config: defineCICD top-level defaults', () => { ); }); - test('an explicit qualifier wins, and no application means no derived qualifier', () => { - expect(defineCICD({ application: 'app', qualifier: 'custom', repository: REPO, stages: [] }).qualifier).toBe( - 'custom', + test('an explicit qualifier wins and is trimmed, while no application means no derived qualifier', () => { + expect(defineCICD({ application: 'app', qualifier: ' custom_1 ', repository: REPO, stages: [] }).qualifier).toBe( + 'custom_1', ); expect(defineCICD({ repository: REPO, stages: [] }).qualifier).toBeUndefined(); }); + test.each(['', ' ', 'invalid qualifier', 'invalid!', '12345678901'])( + 'rejects an invalid explicit qualifier %j', + (qualifier) => { + expect(() => defineCICD({ qualifier, repository: REPO, stages: [] })).toThrow( + /explicit bootstrap qualifier.*\[A-Za-z0-9_-\]\{1,10\}/, + ); + }, + ); + test('an application that sanitizes to nothing falls back to a valid qualifier', () => { // e.g. an all-punctuation name -> no alphanumerics left -> must not yield an empty qualifier. expect(defineCICD({ application: '!!!', repository: REPO, stages: [] }).qualifier).toBe('cdkcicd'); @@ -71,7 +80,12 @@ describe('m3-config: defineCICD top-level defaults', () => { test('engine defaults to CODEPIPELINE and ci defaults to empty (engine supplies its own steps)', () => { const cfg = defineCICD({ repository: REPO, stages: [] }); expect(cfg.engine).toBe(EngineType.CODEPIPELINE); - expect(cfg.ci).toEqual({ steps: {}, synthStages: [], image: undefined }); + expect(cfg.ci).toEqual({ + steps: {}, + synthStages: [], + image: undefined, + codeBuildImageCredentials: undefined, + }); }); test("ci.synthStages 'all' collapses to an empty list; an explicit list is kept", () => { @@ -79,23 +93,114 @@ describe('m3-config: defineCICD top-level defaults', () => { expect(defineCICD({ repository: REPO, stages: [], ci: { synthStages: ['dev'] } }).ci.synthStages).toEqual(['dev']); }); - test('ci.steps overrides and image pass through', () => { + test('ci.steps, image, and CodeBuild image credentials pass through', () => { + const codeBuildImageCredentials = { + secretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:registry-ABC123', + encryptionKeyArn: 'arn:aws:kms:us-west-2:111111111111:key/EXAMPLE_NOT_A_SECRET', + }; const cfg = defineCICD({ repository: REPO, stages: [], - ci: { steps: { lint: 'npx cdk-cicd validate' }, image: 'node:24' }, + ci: { + steps: { lint: 'npx cdk-cicd validate' }, + image: 'registry.example.com/private/node:24', + codeBuildImageCredentials, + }, }); expect(cfg.ci.steps).toEqual({ lint: 'npx cdk-cicd validate' }); - expect(cfg.ci.image).toBe('node:24'); + expect(cfg.ci.image).toBe('registry.example.com/private/node:24'); + expect(cfg.ci.codeBuildImageCredentials).toEqual(codeBuildImageCredentials); + }); + + test('CodeBuild image credentials require ci.image', () => { + expect(() => + defineCICD({ + repository: REPO, + stages: [], + ci: { + codeBuildImageCredentials: { + secretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:registry-ABC123', + }, + }, + }), + ).toThrow(/ci\.codeBuildImageCredentials requires ci\.image/); }); test('synthesizer defaults to DEFAULT and an explicit type wins', () => { expect(defineCICD({ repository: REPO, stages: [] }).synthesizer.type).toBe(SynthesizerType.DEFAULT); + const appStaging = defineCICD({ + application: 'payments', + repository: REPO, + stages: [], + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-v2' }, + }); + expect(appStaging.qualifier).toBe('payments'); + expect(appStaging.synthesizer.type).toBe(SynthesizerType.APP_STAGING); + expect(appStaging.synthesizer.appId).toBe('payments-v2'); + }); + + test('APP_STAGING preserves custom qualifiers and deployment identities for direct use', () => { + const deployment = { + deployRole: 'arn:aws:iam::111111111111:role/deploy', + cfnExecutionRole: 'arn:aws:iam::111111111111:role/cfn-exec', + }; + const config = defineCICD({ + repository: REPO, + stages: [{ name: 'prod', deployment }], + qualifier: 'custom123', + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-v2' }, + }); + + expect(config.qualifier).toBe('custom123'); + expect(config.stages[0].deployment).toEqual(deployment); expect( - defineCICD({ repository: REPO, stages: [], synthesizer: { type: SynthesizerType.APP_STAGING } }).synthesizer.type, + defineCICD({ + repository: REPO, + stages: [], + engine: EngineType.CDK_PIPELINES, + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-v2' }, + }).synthesizer.type, ).toBe(SynthesizerType.APP_STAGING); }); + test('APP_STAGING rejects deploy-role ExternalIds but permits role-only and blank identity values', () => { + expect(() => + defineCICD({ + repository: REPO, + stages: [ + { + name: 'prod', + deployment: { + deployRole: 'arn:aws:iam::111111111111:role/deploy', + externalId: 'stage-external-id', + }, + }, + ], + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-v2' }, + }), + ).toThrow(/cannot use a deploy-role ExternalId.*stage 'prod'/s); + expect(() => + resolveCicdConfig({ + repository: REPO, + deployRoleExternalId: 'pipeline-external-id', + stages: [ + { + name: 'prod', + deployment: { deployRole: 'arn:aws:iam::111111111111:role/deploy' }, + }, + ], + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-v2' }, + }), + ).toThrow(/cannot use a deploy-role ExternalId.*stage 'prod'/s); + expect(() => + defineCICD({ + repository: REPO, + stages: [{ name: 'prod', deployment: { deployRole: ' ', cfnExecutionRole: ' ', externalId: ' ' } }], + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-v2' }, + }), + ).not.toThrow(); + }); + test('codeArtifact defaults to undefined (opt-in) and an explicit config passes through unchanged', () => { expect(defineCICD({ repository: REPO, stages: [] }).codeArtifact).toBeUndefined(); const codeArtifact = { domain: 'd', repository: 'r', npmScope: 'cdklabs' }; @@ -116,12 +221,40 @@ describe('m3-config: defineCICD top-level defaults', () => { expect(defineCICD({ repository: REPO, stages: [], vpc }).vpc).toEqual(vpc); }); - test('m9-migrate-compliance-bucket: complianceLogBucketName defaults to undefined and passes through unchanged', () => { - expect(defineCICD({ repository: REPO, stages: [] }).complianceLogBucketName).toBeUndefined(); - expect( - defineCICD({ repository: REPO, stages: [], complianceLogBucketName: 'my-compliance-bucket' }) - .complianceLogBucketName, - ).toEqual('my-compliance-bucket'); + test('private dependency KMS key ARNs survive normalization', () => { + const npmRegistry = { + url: 'https://npm.example.com/', + basicAuthSecretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:npm', + encryptionKeyArn: 'arn:aws:kms:us-west-2:111111111111:key/npm-key', + }; + const proxy = { + proxySecretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:proxy', + encryptionKeyArn: 'arn:aws:kms:us-west-2:111111111111:key/proxy-key', + }; + const cfg = defineCICD({ repository: REPO, stages: [], npmRegistry, proxy }); + expect(cfg.npmRegistry).toEqual(npmRegistry); + expect(cfg.proxy?.encryptionKeyArn).toBe(proxy.encryptionKeyArn); + }); + + test('m9-migrate-compliance-bucket: managed creation defaults on and an existing bucket is explicit', () => { + const defaults = defineCICD({ repository: REPO, stages: [] }); + expect(defaults.complianceLogBucketName).toBeUndefined(); + expect(defaults.createComplianceLogBucket).toBe(true); + + const existing = defineCICD({ + repository: REPO, + stages: [], + complianceLogBucketName: 'my-compliance-bucket', + createComplianceLogBucket: false, + }); + expect(existing.complianceLogBucketName).toEqual('my-compliance-bucket'); + expect(existing.createComplianceLogBucket).toBe(false); + }); + + test('m9-migrate-compliance-bucket: existing-bucket mode requires a name', () => { + expect(() => defineCICD({ repository: REPO, stages: [], createComplianceLogBucket: false })).toThrow( + /requires complianceLogBucketName/, + ); }); test('warmAccountsFromSsm defaults to false (opt-in) and an explicit true passes through', () => { @@ -151,6 +284,91 @@ describe('m3-config: defineCICD top-level defaults', () => { }); describe('m6-container: defineDeployment target normalization (Repo 2)', () => { + test('normalizes and validates explicit qualifiers with the same contract as defineCICD', () => { + expect( + defineDeployment({ + qualifier: ' deploy_1 ', + image: 'img:tag', + targets: [{ stage: 'dev' }], + }).qualifier, + ).toBe('deploy_1'); + + for (const qualifier of ['', ' ', 'invalid qualifier', 'invalid!', '12345678901']) { + expect(() => defineDeployment({ qualifier, image: 'img:tag', targets: [{ stage: 'dev' }] })).toThrow( + /explicit bootstrap qualifier.*\[A-Za-z0-9_-\]\{1,10\}/, + ); + } + }); + + test('derives the qualifier and preserves the app-staging identity', () => { + const cfg = defineDeployment({ + application: 'Payments-Service', + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-assets' }, + image: 'img:tag', + targets: [{ stage: 'dev' }], + }); + + expect(cfg.application).toBe('Payments-Service'); + expect(cfg.qualifier).toBe('paymentsse'); + expect(cfg.synthesizer).toEqual({ + type: SynthesizerType.APP_STAGING, + appId: 'payments-assets', + }); + }); + + test('Repo 2 APP_STAGING preserves custom qualifiers and deployment identities for direct use', () => { + const deployment = { + deployRole: 'arn:aws:iam::111111111111:role/deploy', + cfnExecutionRole: 'arn:aws:iam::111111111111:role/cfn-exec', + }; + const config = defineDeployment({ + qualifier: 'custom123', + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-assets' }, + image: 'img:tag', + targets: [{ stage: 'prod', deployment }], + }); + + expect(config.qualifier).toBe('custom123'); + expect(config.targets[0].deployment).toEqual(deployment); + }); + + test('Repo 2 APP_STAGING rejects deploy-role ExternalIds but permits role-only and blank identity values', () => { + expect(() => + defineDeployment({ + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-assets' }, + image: 'img:tag', + targets: [ + { + stage: 'prod', + deployment: { + deployRole: 'arn:aws:iam::111111111111:role/deploy', + externalId: 'target-external-id', + }, + }, + ], + }), + ).toThrow(/cannot use a deploy-role ExternalId.*target 'prod'/s); + expect(() => + defineDeployment({ + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-assets' }, + image: 'img:tag', + targets: [ + { + stage: 'prod', + deployment: { cfnExecutionRole: 'arn:aws:iam::111111111111:role/cfn-exec' }, + }, + ], + }), + ).not.toThrow(); + expect(() => + defineDeployment({ + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-assets' }, + image: 'img:tag', + targets: [{ stage: 'prod', deployment: { deployRole: ' ', cfnExecutionRole: ' ', externalId: ' ' } }], + }), + ).not.toThrow(); + }); + test('the image passes through and targets keep their order', () => { const cfg = defineDeployment({ image: 'acct.dkr.ecr.eu-west-1.amazonaws.com/my-app-deployer:1.4.2', @@ -167,6 +385,9 @@ describe('m6-container: defineDeployment target normalization (Repo 2)', () => { env: { account: undefined, regions: [], regionOrder: RegionOrder.SEQUENTIAL }, manualApproval: false, deployment: undefined, + complianceLogBucketName: undefined, + complianceLogBucketAccount: undefined, + complianceLogBucketRegion: undefined, }); }); @@ -197,6 +418,16 @@ describe('m6-container: defineDeployment target normalization (Repo 2)', () => { expect(defineDeployment({ image: 'img:1', repository: repo, targets: [{ stage: 'dev' }] }).repository).toBe(repo); }); + test('cross-account ECR repository-policy acknowledgement passes through', () => { + expect( + defineDeployment({ + image: '222222222222.dkr.ecr.us-west-2.amazonaws.com/deployer:1', + targets: [{ stage: 'dev' }], + crossAccountEcrRepositoryPolicyConfigured: true, + }).crossAccountEcrRepositoryPolicyConfigured, + ).toBe(true); + }); + test('a per-target image pins that stage version; top-level image is optional (the default)', () => { const cfg = defineDeployment({ image: 'repo:base', @@ -233,4 +464,86 @@ describe('m6-container: defineDeployment target normalization (Repo 2)', () => { expect(cfg.targets[0].env.account).toBe('333333333333'); expect(cfg.targets[0].deployment).toEqual({ deployRole: 'arn:role/deploy' }); }); + + test('Repo 2 compliance logging resolves a config default and a per-target override to concrete coordinates', () => { + const cfg = defineDeployment({ + image: 'img:tag', + complianceLogBucketName: 'shared-compliance-111111111111', + targets: [ + { stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }, + { + stage: 'prod', + env: { account: '222222222222', region: 'us-east-1' }, + complianceLogBucketName: 'prod-compliance-222222222222', + }, + ], + }); + + expect(cfg.complianceLogBucketName).toBe('shared-compliance-111111111111'); + expect(cfg.targets[0]).toEqual( + expect.objectContaining({ + complianceLogBucketName: 'shared-compliance-111111111111', + complianceLogBucketAccount: '111111111111', + complianceLogBucketRegion: 'eu-west-1', + }), + ); + expect(cfg.targets[1]).toEqual( + expect.objectContaining({ + complianceLogBucketName: 'prod-compliance-222222222222', + complianceLogBucketAccount: '222222222222', + complianceLogBucketRegion: 'us-east-1', + }), + ); + }); + + test.each([ + { + name: 'an account-agnostic target', + target: { stage: 'dev', env: { region: 'eu-west-1' } }, + error: /requires a concrete 12-digit env\.account/, + }, + { + name: 'a region-agnostic target', + target: { stage: 'dev', env: { account: '111111111111' } }, + error: /requires exactly one concrete AWS Region/, + }, + { + name: 'a multi-region target', + target: { + stage: 'dev', + env: { account: '111111111111', regions: ['eu-west-1', 'us-east-1'] }, + }, + error: /requires exactly one concrete AWS Region/, + }, + { + name: 'an invalid bucket name', + target: { + stage: 'dev', + env: { account: '111111111111', region: 'eu-west-1' }, + complianceLogBucketName: 'Invalid Bucket', + }, + error: /must be a valid 3-63 character S3 bucket name/, + }, + ])('Repo 2 compliance logging rejects $name', ({ target, error }) => { + expect(() => + defineDeployment({ + image: 'img:tag', + complianceLogBucketName: target.complianceLogBucketName === undefined ? 'compliance-logs' : undefined, + targets: [target], + }), + ).toThrow(error); + }); + + test('Repo 2 rejects one bucket name assigned to different account/Region coordinates', () => { + expect(() => + defineDeployment({ + image: 'img:tag', + complianceLogBucketName: 'shared-compliance-logs', + targets: [ + { stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }, + { stage: 'prod', env: { account: '222222222222', region: 'us-east-1' } }, + ], + }), + ).toThrow(/assigned to both 111111111111\/eu-west-1 and 222222222222\/us-east-1/); + }); }); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/config/naming.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/config/naming.test.ts index 3d1d08a6..07db4dcb 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/config/naming.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/config/naming.test.ts @@ -10,7 +10,7 @@ describe('m5: stageStackName', () => { else process.env.CDK_STAGE = saved; }); - test('new-project default: base-, lowercased, reading CDK_STAGE', () => { + test('new-project default: base-, preserving configured casing, reading CDK_STAGE', () => { process.env.CDK_STAGE = 'dev'; expect(stageStackName('myapp')).toBe('myapp-dev'); }); @@ -33,12 +33,17 @@ describe('m5: stageStackName', () => { expect(stageStackName('myapp', { stage: '' })).toBe('myapp'); }); - test('default casing lowercases the stage (pins the behavior, not just the already-lowercase input)', () => { - // An Autopilot config may carry an uppercase stage id; the clean default name is lowercase. + test('default casing remains lowercase for backward compatibility', () => { expect(stageStackName('myapp', { stage: 'DEV' })).toBe('myapp-dev'); + expect(stageStackName('myapp', { stage: 'Prod', stageFirst: true })).toBe('prod-myapp'); }); test('uppercaseStage without stageFirst still applies (casing and order are independent)', () => { expect(stageStackName('myapp', { stage: 'dev', uppercaseStage: true })).toBe('myapp-DEV'); }); + + test('preserveStageCase is an explicit migration opt-in without changing the lowercase default', () => { + expect(stageStackName('myapp', { stage: 'Gamma', preserveStageCase: true })).toBe('myapp-Gamma'); + expect(stageStackName('myapp', { stage: 'Gamma', stageFirst: true, preserveStageCase: true })).toBe('Gamma-myapp'); + }); }); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/cdkpipelines/cdk-pipelines-engine.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/cdkpipelines/cdk-pipelines-engine.test.ts index 8778379b..242d1de7 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/cdkpipelines/cdk-pipelines-engine.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/cdkpipelines/cdk-pipelines-engine.test.ts @@ -5,7 +5,7 @@ // UpdatePipeline self-mutation -> Assets -> one wave per stage, with a manual-approval gate on gated stages). import * as path from 'path'; -import { App, Aspects, Stack, Stage } from 'aws-cdk-lib'; +import { App, Aspects, BOOTSTRAP_QUALIFIER_CONTEXT, Stack, Stage } from 'aws-cdk-lib'; import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import * as codebuild from 'aws-cdk-lib/aws-codebuild'; import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; @@ -14,6 +14,7 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import { AwsSolutionsChecks } from 'cdk-nag'; import { defineCICD } from '../../../src/config/define'; import { Repository } from '../../../src/config/repository'; +import { RegionOrder, ResolvedCicdConfig, SynthesizerType } from '../../../src/config/types'; import { CdkPipelinesEngine, CdkPipelinesStageContext, @@ -45,6 +46,117 @@ function render(): Template { } describe('Blueprint-compat: CdkPipelinesEngine (aws-cdk-lib/pipelines)', () => { + test('requires a concrete pipeline Region so the AWS partition is known', () => { + const stack = new Stack(new App(), 'PipelineStack'); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + }), + stages: new StubStages(), + }), + ).toThrow(/CDK_PIPELINES pipeline requires a concrete Region.*partition/); + }); + + test('rejects a pipeline Region unknown to the installed CDK region table', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'unknown-future-1' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + }), + stages: new StubStages(), + }), + ).toThrow(/CDK_PIPELINES pipeline region 'unknown-future-1' is not known/); + }); + + test('rejects target Regions in a different AWS partition', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: [ + { + name: 'isolated', + env: { account: '111111111111', region: 'us-iso-east-1' }, + }, + ], + }), + stages: new StubStages(), + }), + ).toThrow(/cannot mix AWS partitions.*'aws'.*stage 'isolated'.*'aws-iso'/); + }); + + test('rejects target Regions unknown to the installed CDK region table', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: [ + { + name: 'future', + env: { account: '111111111111', region: 'unknown-future-1' }, + }, + ], + }), + stages: new StubStages(), + }), + ).toThrow(/stage 'future' region 'unknown-future-1' is not known/); + }); + + test('supports one known non-commercial partition and emits lookup ARNs for that partition', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-gov-west-1' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + }), + stages: new StubStages(), + }); + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('arn:aws-us-gov:iam::111111111111:role/cdk-'); + expect(policies).toContain('arn:aws-us-gov:ssm:us-gov-west-1:111111111111:parameter/cdk-bootstrap/'); + }); + + test('rejects a CodeArtifact Region in a different partition', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + codeArtifact: { domain: 'packages', repository: 'npm', region: 'cn-north-1' }, + }), + stages: new StubStages(), + }), + ).toThrow(/CodeArtifact region 'cn-north-1'.*'aws-cn'.*pipeline partition 'aws'/); + }); + test('builds a self-mutating CDK Pipelines pipeline with a Source, Synth, and one wave per stage', () => { const t = render(); // Exactly one CDK Pipelines pipeline. @@ -67,6 +179,49 @@ describe('Blueprint-compat: CdkPipelinesEngine (aws-cdk-lib/pipelines)', () => { expect(actionCategories('dev')).not.toContain('Approval'); }); + test('RegionOrder.PARALLEL puts every regional deployment in one wave', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: [ + { + name: 'prod', + env: { + account: '222222222222', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: RegionOrder.PARALLEL, + }, + manualApproval: true, + }, + ], + }), + stages: new StubStages(), + }); + + const pipeline = Object.values(Template.fromStack(stack).findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const deploymentStages = (pipeline.Properties.Stages as any[]).filter((stage) => + ['prod', 'prod-eu-west-1', 'prod-us-east-1'].includes(stage.Name), + ); + + expect(deploymentStages).toHaveLength(1); + expect(deploymentStages[0].Name).toBe('prod'); + const deployActions = deploymentStages[0].Actions.filter( + (action: any) => action.ActionTypeId.Category === 'Deploy', + ); + expect(deployActions).toHaveLength(4); + expect( + deployActions.reduce((counts: Record, action: any) => { + counts[action.RunOrder] = (counts[action.RunOrder] ?? 0) + 1; + return counts; + }, {}), + ).toEqual({ 2: 2, 3: 2 }); + expect(deploymentStages[0].Actions.some((action: any) => action.ActionTypeId.Category === 'Approval')).toBe(true); + }); + test('the synth step runs npm ci + the default scripts + npm run cdk synth with CDK_CICD_MODE=pipeline', () => { const t = render(); // The Synth CodeBuild project's buildspec carries the commands. It runs `npm run cdk synth` (never @@ -133,6 +288,141 @@ describe('Blueprint-compat: CdkPipelinesEngine (aws-cdk-lib/pipelines)', () => { expect(policies).toContain('secretsmanager:GetSecretValue'); }); + test('a generic npm registry authenticates Synth before npm ci and merges secret buildspec bindings', () => { + const npmSecretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:npm-token-abc123'; + const proxySecretArn = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:proxy-abc123'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + proxy: { proxySecretArn }, + npmRegistry: { + url: 'https://npm.example.com/', + scope: 'cdklabs', + basicAuthSecretArn: npmSecretArn, + }, + codeArtifact: { domain: 'domain', repository: 'repository', npmScope: 'internal' }, + ci: { + partialBuildSpec: codebuild.BuildSpec.fromObject({ + env: { variables: { CALLER_BUILD_SPEC: 'preserved' } }, + }), + }, + }), + stages: new StubStages(), + }); + + const t = Template.fromStack(stack); + const projects = Object.values(t.findResources('AWS::CodeBuild::Project')) as any[]; + const synthProject = projects.find((project) => + (project.Properties.Environment.EnvironmentVariables ?? []).some( + (variable: { Name?: string }) => variable.Name === 'CDK_CICD_MODE', + ), + ); + expect(synthProject).toBeDefined(); + const spec = JSON.parse(synthProject.Properties.Source.BuildSpec); + const installCommands = spec.phases.install.commands as string[]; + const buildCommands = spec.phases.build.commands as string[]; + const proxyIndex = installCommands.findIndex((command) => command.includes('export HTTP_PROXY=')); + const npmRegistryIndex = buildCommands.findIndex((command) => + command.includes('@cdklabs:registry=https://npm.example.com/'), + ); + const codeArtifactIndex = buildCommands.findIndex((command) => command.includes('aws codeartifact login')); + + expect(proxyIndex).toBeGreaterThanOrEqual(0); + expect(npmRegistryIndex).toBeGreaterThanOrEqual(0); + expect(codeArtifactIndex).toBeGreaterThan(npmRegistryIndex); + expect(buildCommands).toContain('npm ci'); + expect(spec.env.variables).toEqual(expect.objectContaining({ CALLER_BUILD_SPEC: 'preserved' })); + expect(spec.env['secrets-manager']).toEqual( + expect.objectContaining({ + PROXY_USERNAME: `${proxySecretArn}:username`, + NPM_AUTH_TOKEN: npmSecretArn, + }), + ); + expect(buildCommands).toEqual( + expect.arrayContaining([ + 'export NPM_CONFIG_USERCONFIG="/tmp/cdk-cicd-npmrc"', + 'umask 077 && touch "$NPM_CONFIG_USERCONFIG"', + 'echo "@cdklabs:registry=https://npm.example.com/" > "$NPM_CONFIG_USERCONFIG"', + ]), + ); + expect(JSON.stringify(buildCommands)).not.toContain('./.npmrc'); + expect(spec.phases.build.finally).toEqual(['rm -f "$NPM_CONFIG_USERCONFIG"']); + t.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'secretsmanager:GetSecretValue', + Resource: npmSecretArn, + }), + ]), + }), + }); + }); + + test('rejects a deploy-role ExternalId because CDK Pipelines cannot forward it', () => { + const secretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:deploy-external-id-abc123'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + deployRoleExternalId: `resolve:secretsmanager:${secretArn}`, + stages: [{ name: 'prod', deployment: { deployRole: 'arn:aws:iam::222222222222:role/Deploy' } }], + }), + stages: new StubStages(), + }), + ).toThrow(/CDK_PIPELINES cannot honor deploy-role ExternalIds.*prod/); + }); + + test('rejects APP_STAGING because the installed alpha does not support CDK Pipelines', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + synthesizer: { type: SynthesizerType.APP_STAGING }, + }), + stages: new StubStages(), + }), + ).toThrow(/CDK_PIPELINES cannot use SynthesizerType\.APP_STAGING.*does not support CDK Pipelines.*cross-Stage/); + }); + + test('treats an omitted synthesizer in a legacy resolved config as DEFAULT', () => { + const resolved = defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + }); + const legacy = { ...resolved } as Partial; + Reflect.deleteProperty(legacy, 'synthesizer'); + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: legacy as ResolvedCicdConfig, + stages: new StubStages(), + }), + ).not.toThrow(); + expect(() => Template.fromStack(stack)).not.toThrow(); + }); + test('warmAccountsFromSsm scans SSM and exports ACCOUNT_ ahead of the synth build', () => { const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); const engine = new CdkPipelinesEngine(stack, 'Cd', { @@ -195,6 +485,89 @@ describe('Blueprint-compat: CdkPipelinesEngine (aws-cdk-lib/pipelines)', () => { expect(policies).not.toContain('ssm:GetParametersByPath'); }); + test('uses the configured qualifier for target lookup grants', () => { + const stack = new Stack(new App({ context: { [BOOTSTRAP_QUALIFIER_CONTEXT]: 'ctxqual' } }), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + qualifier: 'customq', + repository: Repository.codecommit('shop'), + stages: [ + { + name: 'prod', + env: { account: '222222222222', regions: ['eu-west-1', 'us-east-1'] }, + }, + ], + }), + stages: new StubStages(), + }); + + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('sts:AssumeRole'); + expect(policies).toContain('cdk-customq-lookup-role-222222222222-eu-west-1'); + expect(policies).toContain('cdk-customq-lookup-role-222222222222-us-east-1'); + expect(policies).toContain('ssm:GetParameter'); + expect(policies).toContain('parameter/cdk-bootstrap/customq/version'); + expect(policies).not.toContain('cdk-ctxqual-lookup-role-222222222222'); + expect(policies).not.toContain('cdk-hnb659fds-lookup-role-222222222222'); + }); + + test('uses the CDK bootstrap qualifier context for target lookup grants when config omits it', () => { + const stack = new Stack(new App({ context: { [BOOTSTRAP_QUALIFIER_CONTEXT]: 'ctxqual' } }), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + repository: Repository.codecommit('shop'), + stages: [ + { + name: 'prod', + env: { account: '222222222222', regions: ['eu-west-1', 'us-east-1'] }, + }, + ], + }), + stages: new StubStages(), + }); + + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('cdk-ctxqual-lookup-role-222222222222-eu-west-1'); + expect(policies).toContain('cdk-ctxqual-lookup-role-222222222222-us-east-1'); + expect(policies).toContain('parameter/cdk-bootstrap/ctxqual/version'); + expect(policies).not.toContain('cdk-hnb659fds-lookup-role-222222222222'); + }); + + test('registry secrets grant kms:Decrypt only on each configured customer-managed key', () => { + const proxyKeyArn = 'arn:aws:kms:us-west-2:111111111111:key/proxy-key'; + const npmKeyArn = 'arn:aws:kms:eu-west-1:111111111111:key/npm-key'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + proxy: { + proxySecretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:proxy', + encryptionKeyArn: proxyKeyArn, + }, + npmRegistry: { + url: 'https://npm.example.com/', + basicAuthSecretArn: 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:npm', + encryptionKeyArn: npmKeyArn, + }, + }), + stages: new StubStages(), + }); + + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('kms:Decrypt'); + expect(policies).toContain(proxyKeyArn); + expect(policies).toContain(npmKeyArn); + }); + test('codeBuildEnvSettings applies to every CodeBuild project CDK Pipelines creates (synth + self-mutation)', () => { const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); const engine = new CdkPipelinesEngine(stack, 'Cd', { @@ -227,6 +600,396 @@ describe('Blueprint-compat: CdkPipelinesEngine (aws-cdk-lib/pipelines)', () => { } }); + test('ci.image overrides only the Synth CodeBuild project, not self-mutation or asset publishing', () => { + class DockerAssetStages implements IStageProvider { + public stacks(stage: Stage, context: CdkPipelinesStageContext): void { + const stack = new Stack(stage, 'App'); + new ecr_assets.DockerImageAsset(stack, `Img-${context.stageName}`, { + directory: path.join(__dirname, 'fixtures', 'docker'), + }); + } + } + + const customImage = 'public.ecr.aws/example/ci-image:2026-09'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image: customImage }, + }), + stages: new DockerAssetStages(), + }); + + const projects = Object.values(Template.fromStack(stack).findResources('AWS::CodeBuild::Project')) as any[]; + expect(projects.length).toBeGreaterThanOrEqual(3); + const synthProject = projects.find((project) => + (project.Properties.Environment.EnvironmentVariables ?? []).some( + (variable: { Name?: string }) => variable.Name === 'CDK_CICD_MODE', + ), + ); + expect(synthProject.Properties.Environment.Image).toBe(customImage); + for (const project of projects.filter((candidate) => candidate !== synthProject)) { + expect(project.Properties.Environment.Image).not.toBe(customImage); + } + }); + + test('a managed CodeBuild image ID uses CODEBUILD image-pull credentials', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image: 'aws/codebuild/standard:7.0' }, + }), + stages: new StubStages(), + }); + + const projects = Object.values(Template.fromStack(stack).findResources('AWS::CodeBuild::Project')) as any[]; + const synthProject = projects.find((project) => + (project.Properties.Environment.EnvironmentVariables ?? []).some( + (variable: { Name?: string }) => variable.Name === 'CDK_CICD_MODE', + ), + ); + expect(synthProject.Properties.Environment).toEqual( + expect.objectContaining({ + Image: 'aws/codebuild/standard:7.0', + ImagePullCredentialsType: 'CODEBUILD', + }), + ); + }); + + test('keeps a public external registry image on CodeBuild’s anonymous pull path', () => { + const image = 'registry.example.com/public/ci:stable'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }); + + const projects = Object.values(Template.fromStack(stack).findResources('AWS::CodeBuild::Project')) as any[]; + const synthProject = projects.find((project) => + (project.Properties.Environment.EnvironmentVariables ?? []).some( + (variable: { Name?: string }) => variable.Name === 'CDK_CICD_MODE', + ), + ); + expect(synthProject.Properties.Environment).toEqual( + expect.objectContaining({ + Image: image, + ImagePullCredentialsType: 'SERVICE_ROLE', + }), + ); + expect(synthProject.Properties.Environment.RegistryCredential).toBeUndefined(); + }); + + test('authenticated external CI images render RegistryCredential and scoped secret/KMS grants', () => { + const image = 'registry.example.com/private/ci:stable'; + const secretArn = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:registry-ABC123'; + const encryptionKeyArn = 'arn:aws:kms:us-west-2:111111111111:key/EXAMPLE_NOT_A_SECRET'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { + image, + codeBuildImageCredentials: { secretArn, encryptionKeyArn }, + }, + }), + stages: new StubStages(), + }); + + const template = Template.fromStack(stack); + const projects = Object.values(template.findResources('AWS::CodeBuild::Project')) as any[]; + const synthProject = projects.find((project) => + (project.Properties.Environment.EnvironmentVariables ?? []).some( + (variable: { Name?: string }) => variable.Name === 'CDK_CICD_MODE', + ), + ); + expect(synthProject.Properties.Environment).toEqual( + expect.objectContaining({ + Image: image, + ImagePullCredentialsType: 'SERVICE_ROLE', + RegistryCredential: { + Credential: secretArn, + CredentialProvider: 'SECRETS_MANAGER', + }, + }), + ); + for (const project of projects.filter((candidate) => candidate !== synthProject)) { + expect(project.Properties.Environment.RegistryCredential).toBeUndefined(); + } + + const policies = JSON.stringify(template.findResources('AWS::IAM::Policy')); + expect(policies).toContain('secretsmanager:GetSecretValue'); + expect(policies).toContain(secretArn); + expect(policies).toContain('kms:Decrypt'); + expect(policies).toContain(encryptionKeyArn); + }); + + test.each([ + ['managed CodeBuild', 'aws/codebuild/standard:7.0'], + ['private ECR', '111111111111.dkr.ecr.us-west-2.amazonaws.com/platform/ci:stable'], + ['public ECR', 'public.ecr.aws/example/ci:stable'], + ])('rejects CodeBuild registry credentials for a %s image', (_case, image) => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { + image, + codeBuildImageCredentials: { + secretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:registry-ABC123', + }, + }, + }), + stages: new StubStages(), + }), + ).toThrow(/codeBuildImageCredentials cannot be used with (?:managed CodeBuild|private ECR|public ECR)/); + }); + + test('rejects inline registry userinfo without echoing the credential', () => { + const password = 'do-not-log-this-password'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + let failure: unknown; + try { + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image: `user:${password}@registry.example.com/private/ci:stable` }, + }), + stages: new StubStages(), + }); + } catch (error) { + failure = error; + } + expect(String(failure)).toMatch(/must not embed registry credentials/); + expect(String(failure)).not.toContain(password); + }); + + test('accepts a public shorthand image pinned by digest on CodeBuild’s anonymous pull path', () => { + const image = `ubuntu@sha256:${'a'.repeat(64)}`; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }); + + const projects = Object.values(Template.fromStack(stack).findResources('AWS::CodeBuild::Project')) as any[]; + const synthProject = projects.find((project) => + (project.Properties.Environment.EnvironmentVariables ?? []).some( + (variable: { Name?: string }) => variable.Name === 'CDK_CICD_MODE', + ), + ); + expect(synthProject.Properties.Environment).toEqual( + expect.objectContaining({ + Image: image, + ImagePullCredentialsType: 'SERVICE_ROLE', + }), + ); + expect(synthProject.Properties.Environment.RegistryCredential).toBeUndefined(); + }); + + test('a private ECR ci.image grants the Synth role permission to pull it', () => { + const image = '111111111111.dkr.ecr.us-west-2.amazonaws.com/platform/ci:stable'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }); + + const template = Template.fromStack(stack); + const projects = Object.values(template.findResources('AWS::CodeBuild::Project')) as any[]; + const synthProject = projects.find((project) => + (project.Properties.Environment.EnvironmentVariables ?? []).some( + (variable: { Name?: string }) => variable.Name === 'CDK_CICD_MODE', + ), + ); + expect(synthProject.Properties.Environment.ImagePullCredentialsType).toBe('SERVICE_ROLE'); + const renderedImage = JSON.stringify(synthProject.Properties.Environment.Image); + expect(renderedImage).toContain('111111111111.dkr.ecr.us-west-2.'); + expect(renderedImage).toContain('/platform/ci:stable'); + const policies = JSON.stringify(template.findResources('AWS::IAM::Policy')); + expect(policies).toContain('ecr:GetAuthorizationToken'); + expect(policies).toContain('ecr:BatchGetImage'); + expect(policies).toContain('repository/platform/ci'); + }); + + test('recognizes a private ECR registry host case-insensitively', () => { + const image = '111111111111.DKR.ECR.us-west-2.amazonaws.com/platform/ci:stable'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }); + + const template = Template.fromStack(stack); + const policies = JSON.stringify(template.findResources('AWS::IAM::Policy')); + expect(policies).toContain('ecr:GetAuthorizationToken'); + expect(policies).toContain('repository/platform/ci'); + }); + + test.each([ + ['aws-iso', '111111111111.dkr.ecr.us-iso-east-1.c2s.ic.gov/platform/ci:stable'], + ['aws-iso-b', '111111111111.dkr.ecr.us-isob-east-1.sc2s.sgov.gov/platform/ci:stable'], + ])('recognizes a private ECR image in the %s partition instead of treating it as Docker Hub', (_case, image) => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }), + ).toThrow(/private ECR ci\.image.*partition 'aws-iso(?:-b)?'.*pipeline is in 'aws'/); + }); + + test('rejects an ECR-shaped image for a partition unknown to the installed CDK', () => { + const image = '111111111111.dkr.ecr.us-isof-south-1.csp.hci.ic.gov/platform/ci:stable'; + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }), + ).toThrow(/private ECR ci\.image.*partition\/domain suffix is not known/); + }); + + test.each([ + ['dual-stack', '111111111111.dkr-ecr.us-west-2.on.aws/platform/ci:stable', /dual-stack registry endpoint/], + ['dual-stack FIPS', '111111111111.dkr-ecr-fips.us-west-2.on.aws/platform/ci:stable', /FIPS registry endpoint/], + [ + 'canonical FIPS', + '111111111111.dkr.ecr-fips.us-west-2.amazonaws.com/platform/ci:stable', + /FIPS registry endpoint/, + ], + [ + 'spoofed canonical suffix', + '111111111111.dkr.ecr.us-west-2.amazonaws.com.attacker.example/platform/ci:stable', + /registry suffix 'amazonaws\.com\.attacker\.example'.*requires 'amazonaws\.com'/, + ], + [ + 'spoofed dual-stack suffix', + '111111111111.dkr-ecr.us-west-2.on.aws.attacker.example/platform/ci:stable', + /does not use a supported canonical registry endpoint/, + ], + ])('rejects a %s private ECR endpoint instead of treating it as an external registry', (_case, image, error) => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }), + ).toThrow(error); + }); + + test.each([ + ['cross-account', '222222222222.dkr.ecr.us-west-2.amazonaws.com/platform/ci:stable'], + ['cross-region', '111111111111.dkr.ecr.eu-west-1.amazonaws.com/platform/ci:stable'], + ])('rejects a %s private ECR ci.image', (_case, image) => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }), + ).toThrow(/must be in the pipeline stack account and region.*cross-account and cross-region/); + }); + + test('rejects a private ECR ci.image when the pipeline stack environment is unresolved', () => { + const image = '111111111111.dkr.ecr.us-west-2.amazonaws.com/platform/ci:stable'; + const stack = new Stack(new App(), 'PipelineStack'); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + ci: { image }, + }), + stages: new StubStages(), + }), + ).toThrow(/CDK_PIPELINES pipeline requires a concrete Region.*partition/); + }); + test('without codeBuildEnvSettings every CodeBuild project keeps the CDK-managed environment default', () => { const t = render(); for (const p of Object.values(t.findResources('AWS::CodeBuild::Project')) as any[]) { @@ -382,29 +1145,99 @@ describe('Blueprint-compat: CdkPipelinesEngine (aws-cdk-lib/pipelines)', () => { }); describe('complianceLogBucketName under CDK_PIPELINES', () => { - function renderWithCompliance(regionOfStage: string): Template { - // Pipeline in us-west-2; a stage in a different region exercises the per-region name substitution. - const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); + class CapturingStages implements IStageProvider { + public appStack?: Stack; + + public stacks(stage: Stage, context: CdkPipelinesStageContext): void { + this.appStack = new Stack(stage, 'App'); + new s3.Bucket(this.appStack, `Bucket-${context.stageName}`); + } + } + + function renderWithCompliance(createComplianceLogBucket = true): { pipeline: Template; application: Template } { + const app = new App(); + const stack = new Stack(app, 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + const stages = new CapturingStages(); const engine = new CdkPipelinesEngine(stack, 'Cd', { config: defineCICD({ application: 'shop', repository: Repository.codecommit('shop'), - stages: [{ name: 'prod', env: { account: '111111111111', region: regionOfStage } }], + stages: [{ name: 'prod', env: { account: '111111111111', region: 'us-west-2' } }], complianceLogBucketName: 'compliance-log-111111111111-us-west-2', + createComplianceLogBucket, }), - stages: new StubStages(), + stages, }); void engine; - return Template.fromStack(stack); + return { + pipeline: Template.fromStack(stack), + application: Template.fromStack(stages.appStack!), + }; } test('provisions the compliance-log bucket in the pipeline stack when the name is set', () => { - const t = renderWithCompliance('us-west-2'); + const t = renderWithCompliance().pipeline; const buckets = Object.values(t.findResources('AWS::S3::Bucket')) as any[]; const names = buckets.map((b) => b.Properties.BucketName).filter((n) => typeof n === 'string'); expect(names).toContain('compliance-log-111111111111-us-west-2'); }); + test('imports an existing destination without synthesizing its bucket or bucket policy', () => { + const { pipeline, application } = renderWithCompliance(false); + const destinationName = 'compliance-log-111111111111-us-west-2'; + const buckets = Object.values(pipeline.findResources('AWS::S3::Bucket')) as any[]; + expect(buckets.some((bucket) => bucket.Properties.BucketName === destinationName)).toBe(false); + + const bucketPolicies = Object.values(pipeline.findResources('AWS::S3::BucketPolicy')) as any[]; + expect(bucketPolicies.some((policy) => JSON.stringify(policy.Properties.Bucket).includes(destinationName))).toBe( + false, + ); + application.hasResourceProperties('AWS::S3::Bucket', { + LoggingConfiguration: { + DestinationBucketName: destinationName, + }, + }); + }); + + test('applies compliance logging inside application Stage boundaries', () => { + renderWithCompliance().application.hasResourceProperties('AWS::S3::Bucket', { + LoggingConfiguration: { + DestinationBucketName: 'compliance-log-111111111111-us-west-2', + }, + }); + }); + + test('does not configure the compliance destination bucket to log to itself', () => { + const buckets = Object.values(renderWithCompliance().pipeline.findResources('AWS::S3::Bucket')) as any[]; + const destination = buckets.find( + (bucket) => bucket.Properties.BucketName === 'compliance-log-111111111111-us-west-2', + ); + expect(destination.Properties.LoggingConfiguration).toBeUndefined(); + }); + + test.each([ + ['cross-account', { account: '222222222222', region: 'us-west-2' }], + ['cross-region', { account: '111111111111', region: 'us-east-1' }], + ])('rejects a %s application target instead of fabricating a destination', (_case, env) => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new CdkPipelinesEngine(stack, 'Cd', { + config: defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: [{ name: 'prod', env }], + complianceLogBucketName: 'compliance-log-111111111111-us-west-2', + }), + stages: new StubStages(), + }), + ).toThrow(/same account and region/); + }); + test('is absent when no complianceLogBucketName is configured', () => { const t = render(); const buckets = Object.values(t.findResources('AWS::S3::Bucket')) as any[]; @@ -419,7 +1252,7 @@ describe('Blueprint-compat: CdkPipelinesEngine (aws-cdk-lib/pipelines)', () => { config: defineCICD({ application: 'shop', repository: Repository.codecommit('shop'), - stages: ['dev'], + stages: [{ name: 'dev', env: { account: '111111111111', region: 'us-west-2' } }], complianceLogBucketName: 'compliance-log-111111111111-us-west-2', }), stages: new StubStages(), diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/CodePipelineEngine.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/CodePipelineEngine.test.ts index 60c7cd46..7eefd722 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/CodePipelineEngine.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/CodePipelineEngine.test.ts @@ -5,10 +5,17 @@ import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as codebuild from 'aws-cdk-lib/aws-codebuild'; import { Runtime, RuntimeFamily } from 'aws-cdk-lib/aws-lambda'; +import { specializeDefaultSynthesizerRoleArn } from '../../../src'; +import { BuildImage, ImageTagStrategy } from '../../../src/config/build-image'; import { defineCICD } from '../../../src/config/define'; import { Repository } from '../../../src/config/repository'; -import { DeployModel } from '../../../src/config/types'; +import { DeployModel, RegionOrder, SynthesizerType } from '../../../src/config/types'; import { CodePipelineEngine } from '../../../src/engine/codepipeline/CodePipelineEngine'; +import { + COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, + COMPLIANCE_LOG_BUCKET_NAME_FLAG, + COMPLIANCE_LOG_BUCKET_REGION_FLAG, +} from '../../../src/runtime/inject'; function render(config: ReturnType): Template { const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); @@ -27,12 +34,114 @@ function arnEndingIn(suffix: string) { /** The parsed buildspec of the one CodeBuild project whose build commands contain `marker`. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function specContaining(t: Template, marker: string): any { - return Object.values(t.findResources('AWS::CodeBuild::Project')) - .map((p) => JSON.parse(p.Properties.Source.BuildSpec)) - .find((s) => JSON.stringify(s.phases.build.commands).includes(marker)); + return JSON.parse(projectContaining(t, marker).Properties.Source.BuildSpec); +} + +/** The one CodeBuild project whose generated buildspec contains `marker`. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function projectContaining(t: Template, marker: string): any { + return Object.values(t.findResources('AWS::CodeBuild::Project')).find((project) => + project.Properties.Source.BuildSpec.includes(marker), + ); } describe('m4-codepipeline: CodePipelineEngine', () => { + describe('flat CodePipeline topology validation', () => { + test.each(['Source', 'Build', 'UpdatePipeline'])("rejects reserved stage name '%s'", (name) => { + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + stages: [{ name, manualApproval: false }], + }); + + expect(() => render(config)).toThrow(new RegExp(`stage name '${name}' is reserved`)); + }); + + test('rejects duplicate stage names before constructs are emitted', () => { + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + stages: [ + { name: 'prod', manualApproval: false }, + { name: 'prod', manualApproval: false }, + ], + }); + + expect(() => render(config)).toThrow("duplicate stage name 'prod'"); + }); + + test('rejects generated action names that exceed the CodePipeline identifier contract', () => { + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + stages: [{ name: 'x'.repeat(95), manualApproval: false }], + }); + + expect(() => render(config)).toThrow(/generated CodePipeline action name/); + }); + + test('rejects more than 50 rendered stages including Source, Build, and UpdatePipeline', () => { + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + stages: Array.from({ length: 48 }, (_, index) => ({ + name: `stage-${index}`, + manualApproval: false, + })), + }); + + expect(() => render(config)).toThrow(/51 stages.*50-stage CodePipeline quota/); + }); + + test('rejects more than 100 generated actions in one parallel async stage', () => { + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + asyncDeploy: true, + stages: [ + { + name: 'dev', + manualApproval: false, + env: { + regions: Array.from({ length: 51 }, (_, index) => `test-region-${index}`), + regionOrder: RegionOrder.PARALLEL, + }, + }, + ], + }); + + expect(() => render(config)).toThrow(/102 actions.*100-action CodePipeline quota/); + }); + + test('rejects more than 1,000 actions across an otherwise valid 50-stage pipeline', () => { + const regions = Array.from({ length: 11 }, (_, index) => `test-region-${index}`); + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + asyncDeploy: true, + stages: Array.from({ length: 47 }, (_, index) => ({ + name: `stage-${index}`, + manualApproval: false, + env: { regions, regionOrder: RegionOrder.PARALLEL }, + })), + }); + + expect(() => render(config)).toThrow(/1037 actions.*1000-action CodePipeline quota/); + }); + + test('rejects targets in a different AWS partition before generating bootstrap-role ARNs', () => { + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + stages: [{ name: 'china', env: { account: '222222222222', region: 'cn-north-1' } }], + }); + + expect(() => render(config)).toThrow(/partition 'aws-cn'.*flat pipeline runs in 'aws'/); + }); + + test('rejects target regions unknown to the installed CDK region table', () => { + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + stages: [{ name: 'future', env: { account: '222222222222', region: 'moon-north-1' } }], + }); + + expect(() => render(config)).toThrow(/AWS partition is not known/); + }); + }); + test('builds ONE pipeline with a flat footprint: 1 build project + 1 project per stage', () => { const config = defineCICD({ application: 'shop', @@ -82,6 +191,116 @@ describe('m4-codepipeline: CodePipelineEngine', () => { }); }); + test('the CI synth project may assume lookup roles in every resolved target account and region', () => { + const config = defineCICD({ + application: 'shop', + qualifier: 'customq', + repository: Repository.s3('shop-src/app.zip'), + stages: [ + { name: 'dev', env: { account: '222222222222', regions: ['us-west-2', 'us-west-1'] } }, + { name: 'prod', env: { account: '333333333333', region: 'eu-west-1' } }, + ], + }); + + render(config).hasResourceProperties('AWS::IAM::Policy', { + Roles: [{ Ref: Match.stringLikeRegexp('BuildProjectRole') }], + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'sts:AssumeRole', + Resource: Match.arrayWith([ + arnEndingIn(':iam::222222222222:role/cdk-customq-lookup-role-222222222222-us-west-2'), + arnEndingIn(':iam::222222222222:role/cdk-customq-lookup-role-222222222222-us-west-1'), + arnEndingIn(':iam::333333333333:role/cdk-customq-lookup-role-333333333333-eu-west-1'), + ]), + }), + Match.objectLike({ + Action: 'ssm:GetParameter', + Resource: Match.arrayWith([ + arnEndingIn(':ssm:us-west-2:222222222222:parameter/cdk-bootstrap/customq/version'), + arnEndingIn(':ssm:us-west-1:222222222222:parameter/cdk-bootstrap/customq/version'), + arnEndingIn(':ssm:eu-west-1:333333333333:parameter/cdk-bootstrap/customq/version'), + ]), + }), + ]), + }), + }); + }); + + test('bootstrap grants fall back to the CDK default qualifier when the config has none', () => { + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + stages: [{ name: 'dev', env: { account: '222222222222', region: 'us-west-1' } }], + }); + + render(config).hasResourceProperties('AWS::IAM::Policy', { + Roles: [{ Ref: Match.stringLikeRegexp('BuildProjectRole') }], + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'sts:AssumeRole', + Resource: arnEndingIn(':iam::222222222222:role/cdk-hnb659fds-lookup-role-222222222222-us-west-1'), + }), + Match.objectLike({ + Action: 'ssm:GetParameter', + Resource: arnEndingIn(':ssm:us-west-1:222222222222:parameter/cdk-bootstrap/hnb659fds/version'), + }), + ]), + }), + }); + }); + + test('the role ARN specializer preserves the manifest partition placeholder unless one is supplied', () => { + const roleArn = + 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${Qualifier}/${Qualifier}-' + + '${AWS::AccountId}-${AWS::Region}-${AWS::Region}-${AWS::Partition}'; + + expect( + specializeDefaultSynthesizerRoleArn(roleArn, { + account: '222222222222', + region: 'us-west-1', + }), + ).toBe( + 'arn:${AWS::Partition}:iam::222222222222:role/hnb659fds/hnb659fds-' + + '222222222222-us-west-1-us-west-1-${AWS::Partition}', + ); + expect( + specializeDefaultSynthesizerRoleArn(roleArn, { + qualifier: 'shopq', + account: '222222222222', + region: 'us-west-1', + partition: 'aws', + }), + ).toBe('arn:aws:iam::222222222222:role/shopq/shopq-222222222222-us-west-1-us-west-1-aws'); + }); + + test('forced deploy-role placeholders are specialized for every concrete target region', () => { + const roleArn = + 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-deployer-${AWS::AccountId}-${AWS::Region}'; + const config = defineCICD({ + repository: Repository.s3('shop-src/app.zip'), + stages: [ + { + name: 'dev', + env: { account: '222222222222', regions: ['us-west-2', 'us-west-1'] }, + deployment: { deployRole: roleArn }, + }, + ], + }); + + const policies = render(config).findResources('AWS::IAM::Policy'); + const deployPolicy = Object.values(policies).find((policy) => + JSON.stringify(policy.Properties.Roles).includes('DeploydevRole'), + ); + const rendered = JSON.stringify(deployPolicy?.Properties.PolicyDocument); + expect(rendered).toContain('arn:aws:iam::222222222222:role/cdk-hnb659fds-deployer-222222222222-us-west-2'); + expect(rendered).toContain('arn:aws:iam::222222222222:role/cdk-hnb659fds-deployer-222222222222-us-west-1'); + expect(rendered).not.toContain('${Qualifier}'); + expect(rendered).not.toContain('${AWS::AccountId}'); + expect(rendered).not.toContain('${AWS::Region}'); + expect(rendered).not.toContain('${AWS::Partition}'); + }); + test('a multi-region stage is ONE deploy action (region fan-out is inside cdk-cicd deploy)', () => { const config = defineCICD({ application: 'shop', @@ -99,6 +318,78 @@ describe('m4-codepipeline: CodePipelineEngine', () => { }); }); + test('a PARALLEL multi-region stage renders one region-scoped deploy action per region', () => { + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: [ + { + name: 'prod', + env: { + account: '222222222222', + regions: ['us-west-2', 'us-west-1'], + regionOrder: RegionOrder.PARALLEL, + }, + }, + ], + }); + const t = render(config); + const pipeline = Object.values(t.findResources('AWS::CodePipeline::Pipeline'))[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prod = (pipeline.Properties.Stages as any[]).find((stage) => stage.Name === 'prod'); + + expect(prod.Actions.map((action: { Name: string; RunOrder: number }) => [action.Name, action.RunOrder])).toEqual([ + ['Approve-prod', 1], + ['Deploy-prod-us-west-2', 2], + ['Deploy-prod-us-west-1', 2], + ]); + + const deploySpecs = Object.values(t.findResources('AWS::CodeBuild::Project')) + .map((project) => project.Properties.Source.BuildSpec) + .filter((spec) => JSON.stringify(spec).includes('cdk-cicd deploy --stage prod')) + .map((spec) => JSON.parse(spec)); + expect(deploySpecs).toHaveLength(2); + const deployCommands = deploySpecs.flatMap((spec) => spec.phases.build.commands); + expect(deployCommands).toEqual( + expect.arrayContaining([ + 'npx cdk-cicd deploy --stage prod --yes --from-assembly --region us-west-2', + 'npx cdk-cicd deploy --stage prod --yes --from-assembly --region us-west-1', + ]), + ); + }); + + test('parallel async deploys keep region plans and await actions independent', () => { + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + asyncDeploy: true, + stages: [ + { + name: 'dev', + env: { + account: '111111111111', + regions: ['us-west-2', 'us-west-1'], + regionOrder: RegionOrder.PARALLEL, + }, + }, + ], + }); + const t = render(config); + const pipeline = Object.values(t.findResources('AWS::CodePipeline::Pipeline'))[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dev = (pipeline.Properties.Stages as any[]).find((stage) => stage.Name === 'dev'); + + expect(dev.Actions.map((action: { Name: string; RunOrder: number }) => [action.Name, action.RunOrder])).toEqual([ + ['Deploy-dev-us-west-2', 1], + ['Deploy-dev-us-west-1', 1], + ['Await-dev-us-west-2', 2], + ['Await-dev-us-west-1', 2], + ]); + const buildSpecs = JSON.stringify(t.findResources('AWS::CodeBuild::Project')); + expect(buildSpecs).toContain('/shop-pipeline/dev/us-west-2/deploy-plan'); + expect(buildSpecs).toContain('/shop-pipeline/dev/us-west-1/deploy-plan'); + }); + test('the S3 repository yields an S3 source action with the bucket and key split correctly', () => { const config = defineCICD({ application: 'shop', @@ -271,6 +562,196 @@ describe('m4-codepipeline: CodePipelineEngine', () => { expect(grantsCodeArtifact).toBe(false); }); + test('generic npm credentials stay outside promoted artifacts and are cleaned up', () => { + const secretArn = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:npm-token-abc123'; + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + npmRegistry: { + url: 'https://npm.example.com/', + scope: 'cdklabs', + basicAuthSecretArn: secretArn, + }, + codeBuildEnvSettings: { + environmentVariables: { NPM_CONFIG_USERCONFIG: { value: './.npmrc' } }, + }, + }); + const t = render(config); + + const projects = Object.values(t.findResources('AWS::CodeBuild::Project')); + expect(projects).toHaveLength(3); + for (const project of projects) { + const spec = JSON.parse(project.Properties.Source.BuildSpec); + expect(spec.phases.build.commands.slice(0, 5)).toEqual([ + 'export NPM_CONFIG_USERCONFIG="/tmp/cdk-cicd-npmrc"', + 'rm -f "$NPM_CONFIG_USERCONFIG"', + 'umask 077 && touch "$NPM_CONFIG_USERCONFIG"', + 'echo "@cdklabs:registry=https://npm.example.com/" > "$NPM_CONFIG_USERCONFIG"', + 'echo "//npm.example.com/:_authToken=$NPM_AUTH_TOKEN" >> "$NPM_CONFIG_USERCONFIG"', + ]); + expect(spec.phases.build.finally).toEqual(['rm -f "$NPM_CONFIG_USERCONFIG"']); + expect(spec.env.variables.NPM_CONFIG_USERCONFIG).toBe('/tmp/cdk-cicd-npmrc'); + expect(spec.env['secrets-manager']).toEqual({ NPM_AUTH_TOKEN: secretArn }); + expect(JSON.stringify(spec)).not.toContain('./.npmrc'); + expect(project.Properties.Environment.EnvironmentVariables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Name: 'NPM_CONFIG_USERCONFIG', + Value: '/tmp/cdk-cicd-npmrc', + }), + ]), + ); + expect(JSON.stringify(project.Properties.Environment.EnvironmentVariables)).not.toContain('./.npmrc'); + } + + const promoted = specContaining(t, 'cdk-cicd synth --all'); + expect(promoted.artifacts['exclude-paths']).toEqual(['node_modules/**/*', '.npmrc', '**/.npmrc']); + + const secretPolicies = Object.values(t.findResources('AWS::IAM::Policy')).filter((policy) => { + const document = JSON.stringify(policy.Properties.PolicyDocument); + return document.includes('secretsmanager:GetSecretValue') && document.includes(secretArn); + }); + expect(secretPolicies).toHaveLength(3); + }); + + test('generic npm registry authentication is also wired into the container image build project', () => { + const secretArn = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:npm-token-abc123'; + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + deployerImage: BuildImage.docker(), + npmRegistry: { + url: 'https://npm.example.com/', + basicAuthSecretArn: secretArn, + }, + }); + const t = render(config); + const projects = Object.values(t.findResources('AWS::CodeBuild::Project')); + + expect(projects).toHaveLength(1); + const spec = JSON.stringify(projects[0].Properties.Source.BuildSpec); + expect(spec).toContain('NPM_CONFIG_USERCONFIG'); + expect(spec).toContain('/tmp/cdk-cicd-npmrc'); + expect(spec).toContain('rm -f'); + expect(spec).toContain('umask 077 && touch'); + expect(spec).toContain('registry=https://npm.example.com/'); + expect(spec).toContain('//npm.example.com/:_authToken=$NPM_AUTH_TOKEN'); + expect(spec.match(/rm -f/g)).toHaveLength(2); + expect(spec).toContain(secretArn); + expect(spec).not.toContain('./.npmrc'); + t.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'secretsmanager:GetSecretValue', + Resource: secretArn, + }), + ]), + }), + }); + }); + + test('GIT_SHA deployer images use immutable tags and retries reuse an existing image', () => { + const t = render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + deployerImage: BuildImage.docker(), + codeBuildEnvSettings: { privileged: false }, + }), + ); + + t.hasResourceProperties('AWS::ECR::Repository', { + RepositoryName: 'shop-deployer', + ImageTagMutability: 'IMMUTABLE', + }); + const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0]; + expect(project.Properties.Environment.PrivilegedMode).toBe(true); + const rendered = JSON.stringify(project.Properties.Source.BuildSpec); + expect(rendered).toContain('CODEBUILD_RESOLVED_SOURCE_VERSION is required for GIT_SHA image tagging'); + expect(rendered).toContain('crypto.createHash'); + expect(rendered).toContain('sha256'); + expect(rendered).toContain('SOURCE_REVISION'); + expect(rendered).not.toContain(':-latest'); + expect(rendered).toContain('describe-repositories'); + expect(rendered).toContain('imageTagMutability'); + expect(rendered).toContain('describe-images'); + expect(rendered).toContain('already exists; reusing it'); + expect(rendered).toContain('was published concurrently; reusing it'); + expect(rendered.indexOf('describe-images')).toBeLessThan(rendered.indexOf('docker build')); + + const policies = JSON.stringify(t.findResources('AWS::IAM::Policy')); + expect(policies).toContain('ecr:DescribeRepositories'); + expect(policies).toContain('ecr:DescribeImages'); + }); + + test('GIT_SHA preserves full Git commit IDs but hashes unexpected Git revisions', () => { + const t = render( + defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + deployerImage: BuildImage.docker(), + }), + ); + const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0]; + const rendered = JSON.stringify(project.Properties.Source.BuildSpec); + expect(rendered).toContain('/^[0-9a-f]{40,64}$/i'); + expect(rendered).toContain('value.toLowerCase()'); + expect(rendered).toContain('hash(value)'); + }); + + test('LATEST deployer images keep a mutable repository and always push latest', () => { + const t = render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + deployerImage: BuildImage.docker({ tagStrategy: ImageTagStrategy.LATEST }), + }), + ); + + t.hasResourceProperties('AWS::ECR::Repository', { ImageTagMutability: 'MUTABLE' }); + const commands = JSON.stringify( + Object.values(t.findResources('AWS::CodeBuild::Project'))[0].Properties.Source.BuildSpec, + ); + expect(commands).toContain(':latest'); + expect(commands).not.toContain('imageTagMutability'); + expect(commands).not.toContain('Immutable image'); + }); + + test('npm and proxy CMKs grant only scoped kms:Decrypt', () => { + const npmKeyArn = 'arn:aws:kms:us-west-2:111111111111:key/npm-key'; + const proxyKeyArn = 'arn:aws:kms:us-west-2:111111111111:key/proxy-key'; + const t = render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + npmRegistry: { + url: 'https://npm.example.com/', + basicAuthSecretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:npm-token', + encryptionKeyArn: npmKeyArn, + }, + proxy: { + proxySecretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:proxy', + encryptionKeyArn: proxyKeyArn, + }, + }), + ); + + const decryptStatements = Object.values(t.findResources('AWS::IAM::Policy')).flatMap((policy) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (policy.Properties.PolicyDocument.Statement as any[]).filter((statement) => statement.Action === 'kms:Decrypt'), + ); + expect(decryptStatements.filter((statement) => statement.Resource === npmKeyArn)).toHaveLength(3); + expect(decryptStatements.filter((statement) => statement.Resource === proxyKeyArn)).toHaveLength(3); + expect(decryptStatements.every((statement) => !JSON.stringify(statement.Resource).includes('key/*'))).toBe(true); + }); + test('a proxy config exports HTTP(S)_PROXY and curls the test URL before every build runs', () => { const config = defineCICD({ application: 'shop', @@ -374,7 +855,7 @@ describe('m4-codepipeline: CodePipelineEngine', () => { } }); - test('a Docker-registry buildImage on the engine still wins over codeBuildEnvSettings.buildImage', () => { + test('a Docker-registry buildImage on the engine applies only to the CI Build project', () => { const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); new CodePipelineEngine({ buildImage: 'public.ecr.aws/example/node:22' }).render(stack, { config: defineCICD({ @@ -387,21 +868,254 @@ describe('m4-codepipeline: CodePipelineEngine', () => { }); const t = Template.fromStack(stack); - for (const p of Object.values(t.findResources('AWS::CodeBuild::Project'))) { - // The ctor's Docker image is used, not overridden by the (unset) codeBuildEnvSettings.buildImage. - expect(p.Properties.Environment.Image).toBe('public.ecr.aws/example/node:22'); - // But the config's other settings still apply alongside it. + const projects = Object.values(t.findResources('AWS::CodeBuild::Project')); + const ciProjects = projects.filter((p) => + JSON.stringify(p.Properties.Source.BuildSpec).includes('cdk-cicd synth --all'), + ); + expect(ciProjects).toHaveLength(1); + expect(ciProjects[0].Properties.Environment.Image).toBe('public.ecr.aws/example/node:22'); + expect(ciProjects[0].Properties.Environment.ImagePullCredentialsType).toBe('SERVICE_ROLE'); + expect(ciProjects[0].Properties.Environment.RegistryCredential).toBeUndefined(); + for (const p of projects) { expect(p.Properties.Environment.ComputeType).toBe('BUILD_GENERAL1_MEDIUM'); } + for (const p of projects.filter((candidate) => candidate !== ciProjects[0])) { + expect(p.Properties.Environment.Image).not.toBe('public.ecr.aws/example/node:22'); + } + }); + + test('an AWS-managed CI image uses CodeBuild pull credentials', () => { + const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); + new CodePipelineEngine({ buildImage: 'aws/codebuild/standard:7.0' }).render(stack, { + config: defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + }), + pipelineName: 'shop-pipeline', + }); + + const ci = projectContaining(Template.fromStack(stack), 'cdk-cicd synth --all'); + expect(ci.Properties.Environment).toEqual( + expect.objectContaining({ + Image: 'aws/codebuild/standard:7.0', + ImagePullCredentialsType: 'CODEBUILD', + }), + ); + }); + + test('authenticated external CI images render RegistryCredential and scoped secret/KMS grants', () => { + const image = 'registry.example.com/private/node:22'; + const secretArn = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:registry-ABC123'; + const encryptionKeyArn = 'arn:aws:kms:us-west-2:111111111111:key/EXAMPLE_NOT_A_SECRET'; + const t = render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + ci: { + image, + codeBuildImageCredentials: { secretArn, encryptionKeyArn }, + }, + }), + ); + + const ci = projectContaining(t, 'cdk-cicd synth --all'); + expect(ci.Properties.Environment).toEqual( + expect.objectContaining({ + Image: image, + ImagePullCredentialsType: 'SERVICE_ROLE', + RegistryCredential: { + Credential: secretArn, + CredentialProvider: 'SECRETS_MANAGER', + }, + }), + ); + for (const project of Object.values(t.findResources('AWS::CodeBuild::Project')).filter( + (candidate) => candidate !== ci, + )) { + expect(project.Properties.Environment.RegistryCredential).toBeUndefined(); + } + + const policies = JSON.stringify(t.findResources('AWS::IAM::Policy')); + expect(policies).toContain('secretsmanager:GetSecretValue'); + expect(policies).toContain(secretArn); + expect(policies).toContain('kms:Decrypt'); + expect(policies).toContain(encryptionKeyArn); + }); + + test.each([ + ['managed CodeBuild', 'aws/codebuild/standard:7.0'], + ['private ECR', '111111111111.dkr.ecr.us-west-2.amazonaws.com/tooling/node:22'], + ['public ECR', 'public.ecr.aws/example/node:22'], + ])('rejects CodeBuild registry credentials for a %s image', (_case, image) => { + expect(() => + render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + ci: { + image, + codeBuildImageCredentials: { + secretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:registry-ABC123', + }, + }, + }), + ), + ).toThrow(/codeBuildImageCredentials cannot be used with (?:managed CodeBuild|private ECR|public ECR)/); + }); + + test('rejects inline registry userinfo without echoing the credential', () => { + const password = 'do-not-log-this-password'; + let failure: unknown; + try { + render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + ci: { image: `user:${password}@registry.example.com/private/node:22` }, + }), + ); + } catch (error) { + failure = error; + } + expect(String(failure)).toMatch(/must not embed registry credentials/); + expect(String(failure)).not.toContain(password); + }); + + test('rejects URL-style inline registry credentials without echoing them', () => { + const password = 'do-not-log-this-url-password'; + let failure: unknown; + try { + render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + ci: { image: `https://user:${password}@registry.example.com/private/node:22` }, + }), + ); + } catch (error) { + failure = error; + } + expect(String(failure)).toMatch(/without a URL scheme/); + expect(String(failure)).not.toContain(password); }); - test('without codeBuildEnvSettings every build project keeps the CDK-managed environment default', () => { + test('rejects malformed digest references before CodeBuild receives them', () => { + expect(() => + render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + ci: { image: 'registry.example.com/private/node@not-a-digest' }, + }), + ), + ).toThrow(/digest references must use/); + }); + + test('a private ECR CI image grants the build role pull access', () => { + const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); + new CodePipelineEngine({ + buildImage: '111111111111.dkr.ecr.us-west-2.amazonaws.com/tooling/node:22', + }).render(stack, { + config: defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + }), + pipelineName: 'shop-pipeline', + }); + + const t = Template.fromStack(stack); + const ci = projectContaining(t, 'cdk-cicd synth --all'); + expect(ci.Properties.Environment.ImagePullCredentialsType).toBe('SERVICE_ROLE'); + expect(JSON.stringify(ci.Properties.Environment.Image)).toContain('111111111111.dkr.ecr.us-west-2.'); + expect(JSON.stringify(ci.Properties.Environment.Image)).toContain('/tooling/node:22'); + const buildPolicy = Object.values(t.findResources('AWS::IAM::Policy')).find((policy) => + JSON.stringify(policy.Properties.Roles).includes('BuildProjectRole'), + ); + expect(JSON.stringify(buildPolicy?.Properties.PolicyDocument)).toContain('ecr:BatchGetImage'); + expect(JSON.stringify(buildPolicy?.Properties.PolicyDocument)).toContain( + ':ecr:us-west-2:111111111111:repository/tooling/node', + ); + }); + + test('recognizes a mixed-case private ECR registry host and grants repository pull access', () => { + const t = render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + ci: { image: '111111111111.DKR.ECR.us-west-2.amazonaws.com/tooling/node:22' }, + }), + ); + + const policies = JSON.stringify(t.findResources('AWS::IAM::Policy')); + expect(policies).toContain('ecr:BatchGetImage'); + expect(policies).toContain(':ecr:us-west-2:111111111111:repository/tooling/node'); + }); + + test('a private ECR CI image must be in the flat pipeline account and region', () => { + const renderWithImage = (buildImage: string) => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new CodePipelineEngine({ buildImage }).render(stack, { + config: defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + }), + pipelineName: 'shop-pipeline', + }); + }; + + expect(() => renderWithImage('111111111111.dkr.ecr.eu-west-1.amazonaws.com/tooling/node:22')).toThrow( + /must be in the same region/, + ); + expect(() => renderWithImage('999999999999.dkr.ecr.us-west-2.amazonaws.com/tooling/node:22')).toThrow( + /owner-side repository policy.*mirror the image into the pipeline account/, + ); + expect(() => renderWithImage('111111111111.dkr.ecr.us-iso-east-1.c2s.ic.gov/tooling/node:22')).toThrow( + /must be in the same region/, + ); + expect(() => renderWithImage('111111111111.dkr.ecr.us-west-2.evil.example/tooling/node:22')).toThrow( + /registry suffix 'evil\.example'.*requires 'amazonaws\.com'/, + ); + expect(() => renderWithImage('111111111111.dkr-ecr.us-west-2.on.aws/tooling/node:22')).toThrow( + /dual-stack registry endpoint/, + ); + expect(() => renderWithImage('111111111111.dkr.ecr-fips.us-west-2.amazonaws.com/tooling/node:22')).toThrow( + /FIPS registry endpoint/, + ); + expect(() => renderWithImage('111111111111.dkr.ecr.us-isof-south-1.csp.hci.ic.gov/tooling/node:22')).toThrow( + /partition\/domain suffix is not known/, + ); + }); + + test('only projects that synthesize/deploy assets default to privileged mode', () => { const config = defineCICD({ application: 'shop', repository: Repository.s3('shop-src/app.zip'), stages: ['dev'] }); const t = render(config); - for (const p of Object.values(t.findResources('AWS::CodeBuild::Project'))) { + expect(projectContaining(t, 'cdk-cicd synth --all').Properties.Environment.PrivilegedMode).toBe(true); + expect(projectContaining(t, 'cdk-cicd deploy --stage dev').Properties.Environment.PrivilegedMode).toBe(true); + expect(projectContaining(t, 'cdk-cicd deploy-ci').Properties.Environment.PrivilegedMode).toBe(false); + }); + + test('an explicit privileged setting is preserved instead of being overwritten by the Docker default', () => { + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + codeBuildEnvSettings: { privileged: false }, + }); + + for (const p of Object.values(render(config).findResources('AWS::CodeBuild::Project'))) { expect(p.Properties.Environment.PrivilegedMode).toBe(false); - expect(p.Properties.Environment.EnvironmentVariables).toBeUndefined(); } }); @@ -448,6 +1162,106 @@ describe('m4-codepipeline: CodePipelineEngine', () => { t.hasResourceProperties('AWS::S3::Bucket', { BucketName: 'shop-compliance-log-bucket' }); }); + test('an existing Blueprint compliance bucket is referenced without CloudFormation adopting it', () => { + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + complianceLogBucketName: 'existing-blueprint-compliance-bucket', + createComplianceLogBucket: false, + }); + const t = render(config); + const buckets = Object.values(t.findResources('AWS::S3::Bucket')); + + expect(buckets).toHaveLength(1); + expect(buckets[0].Properties.BucketName).toBeUndefined(); + expect(buckets[0].Properties.LoggingConfiguration).toEqual( + expect.objectContaining({ DestinationBucketName: 'existing-blueprint-compliance-bucket' }), + ); + expect(JSON.stringify(t.findResources('AWS::S3::BucketPolicy'))).not.toContain( + 'existing-blueprint-compliance-bucket', + ); + }); + + test('does not configure the compliance destination bucket to log to itself', () => { + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + complianceLogBucketName: 'shop-compliance-log-bucket', + }); + const buckets = Object.values(render(config).findResources('AWS::S3::Bucket')); + const destination = buckets.find((bucket) => bucket.Properties.BucketName === 'shop-compliance-log-bucket'); + + expect(destination?.Properties.LoggingConfiguration).toBeUndefined(); + }); + + test('container image-build mode still logs its pipeline artifact bucket', () => { + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + deployerImage: BuildImage.docker(), + complianceLogBucketName: 'shop-compliance-log-bucket', + }); + const buckets = Object.values(render(config).findResources('AWS::S3::Bucket')); + const artifactBucket = buckets.find((bucket) => bucket.Properties.BucketName === undefined); + const destination = buckets.find((bucket) => bucket.Properties.BucketName === 'shop-compliance-log-bucket'); + + expect(artifactBucket?.Properties.LoggingConfiguration).toEqual( + expect.objectContaining({ DestinationBucketName: 'shop-compliance-log-bucket' }), + ); + expect(destination?.Properties.LoggingConfiguration).toBeUndefined(); + }); + + test('injects the real destination environment into projects that synthesize application stacks', () => { + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + complianceLogBucketName: 'shop-compliance-log-bucket', + }); + const projects = Object.values(render(config).findResources('AWS::CodeBuild::Project')); + const applicationProjects = projects.filter((project) => { + const buildSpec = project.Properties.Source.BuildSpec; + return buildSpec.includes('cdk-cicd synth') || buildSpec.includes('cdk-cicd deploy --stage'); + }); + + expect(applicationProjects).toHaveLength(2); + for (const project of applicationProjects) { + expect(project.Properties.Environment.EnvironmentVariables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Name: COMPLIANCE_LOG_BUCKET_NAME_FLAG, + Value: 'shop-compliance-log-bucket', + }), + expect.objectContaining({ + Name: COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, + Value: '111111111111', + }), + expect.objectContaining({ + Name: COMPLIANCE_LOG_BUCKET_REGION_FLAG, + Value: 'us-west-2', + }), + ]), + ); + } + }); + + test.each([ + ['cross-account', { account: '222222222222', region: 'us-west-2' }], + ['cross-region', { account: '111111111111', region: 'us-east-1' }], + ])('rejects a %s application target instead of fabricating a destination', (_case, env) => { + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: [{ name: 'dev', env }], + complianceLogBucketName: 'shop-compliance-log-bucket', + }); + + expect(() => render(config)).toThrow(/same account and region/); + }); + test('without complianceLogBucketName no compliance bucket is created', () => { const config = defineCICD({ application: 'shop', @@ -481,7 +1295,7 @@ describe('m4-codepipeline: CodePipelineEngine', () => { } }); - test('a user-supplied buildImage gets NO runtime-versions pin', () => { + test('a user-supplied CI buildImage removes the runtime pin only from the CI Build project', () => { const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); new CodePipelineEngine({ buildImage: 'public.ecr.aws/example/node:18' }).render(stack, { config: defineCICD({ application: 'shop', repository: Repository.s3('shop-src/app.zip'), stages: ['dev'] }), @@ -492,9 +1306,14 @@ describe('m4-codepipeline: CodePipelineEngine', () => { // fixed set of Node versions. Emitting the pin for a custom image (or standard:5.0/6.0, where nodejs // 22 does not exist) turns a working pipeline into a hard YAML_FILE_ERROR in the install phase, so a // user who brings their own image owns its Node version. - for (const p of Object.values(Template.fromStack(stack).findResources('AWS::CodeBuild::Project'))) { + const projects = Object.values(Template.fromStack(stack).findResources('AWS::CodeBuild::Project')); + for (const p of projects) { const spec = JSON.parse(p.Properties.Source.BuildSpec); - expect(spec.phases.install).toBeUndefined(); + if (JSON.stringify(spec.phases.build.commands).includes('cdk-cicd synth --all')) { + expect(spec.phases.install).toBeUndefined(); + } else { + expect(spec.phases.install['runtime-versions'].nodejs).toBeGreaterThanOrEqual(20); + } } }); @@ -573,18 +1392,18 @@ describe('m4-codepipeline: CodePipelineEngine', () => { Match.objectLike({ Action: 'sts:AssumeRole', Resource: Match.arrayWith([ - arnEndingIn(':iam::222222222222:role/cdk-hnb659fds-deploy-role-222222222222-us-west-2'), - arnEndingIn(':iam::222222222222:role/cdk-hnb659fds-file-publishing-role-222222222222-us-west-2'), - arnEndingIn(':iam::222222222222:role/cdk-hnb659fds-image-publishing-role-222222222222-us-west-2'), - arnEndingIn(':iam::222222222222:role/cdk-hnb659fds-lookup-role-222222222222-us-west-2'), - arnEndingIn(':iam::222222222222:role/cdk-hnb659fds-deploy-role-222222222222-us-west-1'), + arnEndingIn(':iam::222222222222:role/cdk-shop-deploy-role-222222222222-us-west-2'), + arnEndingIn(':iam::222222222222:role/cdk-shop-file-publishing-role-222222222222-us-west-2'), + arnEndingIn(':iam::222222222222:role/cdk-shop-image-publishing-role-222222222222-us-west-2'), + arnEndingIn(':iam::222222222222:role/cdk-shop-lookup-role-222222222222-us-west-2'), + arnEndingIn(':iam::222222222222:role/cdk-shop-deploy-role-222222222222-us-west-1'), ]), }), Match.objectLike({ Action: 'ssm:GetParameter', Resource: Match.arrayWith([ - arnEndingIn(':ssm:us-west-2:222222222222:parameter/cdk-bootstrap/hnb659fds/version'), - arnEndingIn(':ssm:us-west-1:222222222222:parameter/cdk-bootstrap/hnb659fds/version'), + arnEndingIn(':ssm:us-west-2:222222222222:parameter/cdk-bootstrap/shop/version'), + arnEndingIn(':ssm:us-west-1:222222222222:parameter/cdk-bootstrap/shop/version'), ]), }), ]), @@ -592,6 +1411,33 @@ describe('m4-codepipeline: CodePipelineEngine', () => { }); }); + test('APP_STAGING deployment fails closed because its support stack bypasses the deploy role', () => { + expect(() => + render( + defineCICD({ + application: 'shop', + synthesizer: { type: SynthesizerType.APP_STAGING }, + repository: Repository.s3('shop-src/app.zip'), + stages: [{ name: 'dev', env: { account: '111111111111', region: 'us-west-2' } }], + }), + ), + ).toThrow(/DefaultStagingStack with BootstraplessSynthesizer.*CodeBuild project's base credentials/); + }); + + test('a missing runtime synthesizer object defaults to DEFAULT', () => { + const config = { + ...defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: ['dev'], + }), + synthesizer: undefined, + } as unknown as ReturnType; + + expect(() => render(config)).not.toThrow(); + expect(JSON.stringify(render(config).findResources('AWS::IAM::Policy'))).not.toContain('-file-role-'); + }); + test("a stage's forced deploy role is assumable too", () => { const config = defineCICD({ application: 'shop', @@ -617,6 +1463,29 @@ describe('m4-codepipeline: CodePipelineEngine', () => { }); }); + test('a CloudFormation execution role is neither assumed nor passed by the project role', () => { + const executionRole = 'arn:aws:iam::222222222222:role/cloudformation-execution'; + const t = render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: [ + { + name: 'dev', + env: { account: '222222222222', region: 'us-west-2' }, + deployment: { cfnExecutionRole: executionRole }, + }, + ], + }), + ); + const deployPolicy = Object.values(t.findResources('AWS::IAM::Policy')).find((policy) => + JSON.stringify(policy.Properties.Roles).includes('DeploydevRole'), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const statements = deployPolicy?.Properties.PolicyDocument.Statement as any[]; + expect(JSON.stringify(statements)).not.toContain(executionRole); + }); + test('a blank configured deploy role is no forced role, not an empty ARN', () => { const config = defineCICD({ application: 'shop', @@ -635,6 +1504,110 @@ describe('m4-codepipeline: CodePipelineEngine', () => { expect(resources).not.toContain(''); }); + test('the CI synth role reads only effective secret-backed deploy-role ExternalIds', () => { + const fallbackSecret = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:fallback-external'; + const overrideSecret = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:prod-external'; + const ignoredSecret = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:ignored-external'; + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + deployRoleExternalId: `resolve:secretsmanager:${fallbackSecret}`, + stages: [ + { + name: 'dev', + deployment: { deployRole: 'arn:aws:iam::111111111111:role/dev-deployer' }, + }, + { + name: 'prod', + deployment: { + deployRole: 'arn:aws:iam::111111111111:role/prod-deployer', + externalId: `resolve:secretsmanager:${overrideSecret}`, + }, + }, + { + name: 'qa', + deployment: { externalId: `resolve:secretsmanager:${ignoredSecret}` }, + }, + { + name: 'res', + deployment: { + deployRole: 'arn:aws:iam::111111111111:role/res-deployer', + externalId: 'literal-external-id', + }, + }, + ], + }); + const t = render(config); + + t.hasResourceProperties('AWS::IAM::Policy', { + Roles: [{ Ref: Match.stringLikeRegexp('BuildProjectRole') }], + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'secretsmanager:GetSecretValue', + Resource: Match.arrayWith([fallbackSecret, overrideSecret]), + }), + ]), + }), + }); + const secretPolicies = Object.values(t.findResources('AWS::IAM::Policy')).filter((policy) => + JSON.stringify(policy.Properties.PolicyDocument).includes('secretsmanager:GetSecretValue'), + ); + // Assembly promotion resolves every stage ExternalId in CI; deploy projects consume that assembly + // and must not receive the secret again. + expect(secretPolicies).toHaveLength(1); + expect(JSON.stringify(secretPolicies[0].Properties.PolicyDocument)).not.toContain(ignoredSecret); + expect(JSON.stringify(secretPolicies[0].Properties.PolicyDocument)).not.toContain('literal-external-id'); + }); + + test('deploy-time synth grants the effective ExternalId secret only to projects that synth that stage', () => { + const fallbackSecret = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:dev-external'; + const prodSecret = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:prod-external'; + const config = defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + deployModel: DeployModel.DEPLOY_TIME_SYNTH, + deployRoleExternalId: `resolve:secretsmanager:${fallbackSecret}`, + stages: [ + { + name: 'dev', + env: { account: '111111111111', region: 'us-west-2' }, + deployment: { deployRole: 'arn:aws:iam::111111111111:role/dev-deployer' }, + }, + { + name: 'prod', + env: { + account: '222222222222', + regions: ['us-west-2', 'us-west-1'], + regionOrder: RegionOrder.PARALLEL, + }, + deployment: { + deployRole: 'arn:aws:iam::222222222222:role/prod-deployer', + externalId: `resolve:secretsmanager:${prodSecret}`, + }, + }, + ], + }); + const t = render(config); + const policies = Object.values(t.findResources('AWS::IAM::Policy')); + const policyForRole = (rolePattern: string) => + policies.find((policy) => JSON.stringify(policy.Properties.Roles).includes(rolePattern)); + + expect(JSON.stringify(policyForRole('BuildProjectRole')?.Properties.PolicyDocument)).toContain(fallbackSecret); + // dev is synthesized in CI and promoted, so its deploy project does not resolve the secret. + expect(JSON.stringify(policyForRole('DeploydevRole')?.Properties.PolicyDocument)).not.toContain( + 'secretsmanager:GetSecretValue', + ); + // prod is not synthesized in CI; each parallel regional deploy project synthesizes it independently. + for (const role of ['Deployproduswest2Role', 'Deployproduswest1Role']) { + const policy = policyForRole(role); + expect(policy).toBeDefined(); + expect(JSON.stringify(policy?.Properties.PolicyDocument)).toContain('secretsmanager:GetSecretValue'); + expect(JSON.stringify(policy?.Properties.PolicyDocument)).toContain(prodSecret); + } + expect(JSON.stringify(policyForRole('BuildProjectRole')?.Properties.PolicyDocument)).not.toContain(prodSecret); + }); + test('a gated stage gets a manual approval action ordered ahead of its deploy', () => { const config = defineCICD({ application: 'shop', @@ -746,14 +1719,14 @@ describe('m4-codepipeline: CodePipelineEngine', () => { test('promotion is the DEFAULT: Build publishes cdk.out and deploys consume it without synthing', () => { const t = render(cfg()); - // The Build project publishes the WHOLE source tree plus cdk.out, minus node_modules. A hardcoded + // The Build project publishes the WHOLE source tree plus cdk.out, minus node_modules and any + // credential-bearing npm config. A hardcoded // allowlist broke multi-file configs, tsconfig-compiled configs, and postinstall inputs -- all of // which `cdk-cicd deploy --from-assembly` still needs because it loads cicd.config.ts under ts-node - // and runs `npm ci`. Assert the whole-tree publish AND the node_modules exclusion, since dropping - // either silently breaks a promoted deploy while leaving Build green. + // and runs `npm ci`. Assert the whole-tree publish and defensive exclusions. const build = specContaining(t, 'cdk-cicd synth --all'); expect(build.artifacts.files).toEqual(['**/*']); - expect(build.artifacts['exclude-paths']).toEqual(['node_modules/**/*']); + expect(build.artifacts['exclude-paths']).toEqual(['node_modules/**/*', '.npmrc', '**/.npmrc']); // ...and each deploy consumes it rather than synthesizing. for (const stage of ['dev', 'prod']) { @@ -933,9 +1906,8 @@ describe('m4-codepipeline: CodePipelineEngine', () => { }); test('the driver Lambda is NOT granted sts:AssumeRole, even when the stage forces a deploy role', () => { - // A stage's deployRole is a CloudFormation SERVICE role baked into the change set via --role-arn; - // the Lambda executes under its own identity and must not try to assume it (that role does not - // trust the Lambda). Regression guard for the two commits that disagreed on what deployRole means. + // CDK assumes deployRole while preparing the change set. The driver only executes the prepared + // change set, whose separate cfnExecutionRole is already recorded as CloudFormation's RoleARN. const t = render( defineCICD({ application: 'shop', @@ -1046,7 +2018,7 @@ describe('m4-codepipeline: CodePipelineEngine', () => { Action: 'sts:AssumeRole', // account and region both come from the pipeline stack when the stage omits them. Resource: Match.arrayWith([ - arnEndingIn(':iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-west-2'), + arnEndingIn(':iam::111111111111:role/cdk-shop-deploy-role-111111111111-us-west-2'), ]), }), ]), diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/container-mode.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/container-mode.test.ts index 2709609c..e17497ac 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/container-mode.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/container-mode.test.ts @@ -10,6 +10,7 @@ import * as codebuild from 'aws-cdk-lib/aws-codebuild'; import { BuildImage, ImageTagStrategy } from '../../../src/config/build-image'; import { defineCICD } from '../../../src/config/define'; import { Repository } from '../../../src/config/repository'; +import { SynthesizerType } from '../../../src/config/types'; import { CodePipelineEngine } from '../../../src/engine/codepipeline/CodePipelineEngine'; function render(config: ReturnType, removalPolicy?: RemovalPolicy): Template { @@ -42,6 +43,20 @@ describe('m6-container: image-build pipeline', () => { t.resourceCountIs('AWS::CodeBuild::Project', 1); }); + test('container-only image synthesis may preserve APP_STAGING for a later direct deployment', () => { + expect(() => + render( + defineCICD({ + application: 'shop', + repository: Repository.s3('shop-src/app.zip'), + stages: [{ name: 'prod', env: { account: '222222222222', region: 'us-west-2' } }], + synthesizer: { type: SynthesizerType.APP_STAGING }, + deployerImage: BuildImage.docker(), + }), + ), + ).not.toThrow(); + }); + test('provisions an ECR repo named -deployer and the build logs in, builds and pushes', () => { const t = render(cfg(BuildImage.docker())); t.hasResourceProperties('AWS::ECR::Repository', { RepositoryName: 'shop-deployer' }); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/deployment-pipeline.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/deployment-pipeline.test.ts index d06dca86..abc0fe4f 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/deployment-pipeline.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/codepipeline/deployment-pipeline.test.ts @@ -4,16 +4,33 @@ // Container mode (Repo 2): the CD pipeline consumes the pushed image and deploys each target -- Source // (the config repo) -> Deploy (one privileged CodeBuild that ECR-logs-in and runs deploy --from-image). +import { spawnSync } from 'child_process'; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import * as path from 'path'; import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; -import { Match, Template } from 'aws-cdk-lib/assertions'; +import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import { defineDeployment } from '../../../src/config/define'; import { Repository } from '../../../src/config/repository'; +import { RegionOrder, SynthesizerType } from '../../../src/config/types'; import { DeploymentPipeline } from '../../../src/engine/codepipeline/DeploymentPipeline'; -function render(config: ReturnType, removalPolicy?: RemovalPolicy): Template { +function deploymentStack( + config: ReturnType, + removalPolicy?: RemovalPolicy, + buildImage?: string, +): Stack { const stack = new Stack(new App(), 'CdStack', { env: { account: '111111111111', region: 'eu-west-1' } }); - new DeploymentPipeline(stack, 'Cd', { config, removalPolicy }); - return Template.fromStack(stack); + new DeploymentPipeline(stack, 'Cd', { config, removalPolicy, buildImage }); + return stack; +} + +function render( + config: ReturnType, + removalPolicy?: RemovalPolicy, + buildImage?: string, +): Template { + return Template.fromStack(deploymentStack(config, removalPolicy, buildImage)); } const cfg = () => @@ -30,11 +47,20 @@ const cfg = () => ], }); +function extractEmbeddedNodeScript(command: string): string { + const start = command.indexOf("-e '"); + if (start < 0) throw new Error(`missing embedded Node script in command: ${command}`); + const scriptStart = start + 4; + const end = command.indexOf("'", scriptStart); + if (end <= scriptStart) throw new Error(`unterminated embedded Node script in command: ${command}`); + return command.slice(scriptStart, end).replace(/'"'"'/g, "'"); +} + describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { - test('renders Source -> Deploy (ungated) -> DeployGated (gated) with a privileged CodeBuild project', () => { + test('renders Source followed by ordered deployment waves', () => { const t = render(cfg()); // cfg: dev (ungated) + prod (gated) const pipeline = Object.values(t.findResources('AWS::CodePipeline::Pipeline'))[0] as any; - expect((pipeline.Properties.Stages as any[]).map((s) => s.Name)).toEqual(['Source', 'Deploy', 'DeployGated']); + expect((pipeline.Properties.Stages as any[]).map((s) => s.Name)).toEqual(['Source', 'Deploy-1', 'Deploy-2']); t.hasResourceProperties( 'AWS::CodeBuild::Project', Match.objectLike({ Environment: Match.objectLike({ PrivilegedMode: true }) }), @@ -48,7 +74,8 @@ describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { expect(spec).toContain('docker login'); expect(spec).toContain('get-login-password'); // Each action deploys ONE target (its own image version), selected by the TARGET_STAGE env var. - expect(spec).toContain('cdk-cicd deploy --from-image --target'); + expect(spec.match(/npm run cdk-cicd -- deploy --from-image --target/g)).toHaveLength(2); + expect(spec).not.toContain('npx cdk-cicd'); expect(spec).toContain('TARGET_STAGE'); expect(spec).toContain('npm ci'); // CodeBuild serves creds via the container-credentials endpoint; they must be materialized to static @@ -57,20 +84,675 @@ describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { expect(project.Properties.Environment.PrivilegedMode).toBe(true); }); - test("logs in to the image's OWN ECR registry/region, not the pipeline account", () => { - // image in account 999999999999 / us-east-2, pipeline in 111111111111 / eu-west-1 - const crossAccount = defineDeployment({ - image: '999999999999.dkr.ecr.us-east-2.amazonaws.com/app:1', + test('skips an unchanged target and records its fingerprint only after a successful deployment', () => { + const t = render(cfg()); + const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0] as any; + const spec = JSON.stringify(project.Properties.Source.BuildSpec); + + // The fingerprint is target-specific: it loads the selected normalized target, its effective image, + // and the version field whose stage-local file controls promotion. + expect(spec).toContain('container-deployment-target-v3'); + expect(spec).toContain('TARGET_STAGE'); + expect(spec).toContain('target.image ?? config.image'); + expect(spec).toContain('.json'); + expect(spec).toContain('version'); + expect(spec).toContain('package.json'); + expect(spec).toContain('package-lock.json'); + + // A missing state value means first deployment. Other SSM failures remain fatal rather than silently + // skipping or redeploying, and an exact match exits before the image is pulled/run. + expect(spec).toContain('ssm get-parameter'); + expect(spec).toContain('ParameterNotFound'); + expect(spec).toContain('target $TARGET_STAGE is unchanged; skipping deployment'); + expect(spec).toContain('exit 1'); + + const deploy = spec.indexOf('cdk-cicd deploy --from-image'); + const record = spec.indexOf('ssm put-parameter'); + expect(deploy).toBeGreaterThan(-1); + expect(record).toBeGreaterThan(deploy); + expect(spec).toContain('} && aws ssm put-parameter'); + + const policies = JSON.stringify(t.findResources('AWS::IAM::Policy')); + expect(policies).toContain('ssm:GetParameter'); + expect(policies).toContain('ssm:PutParameter'); + expect(policies).toContain('parameter/cdk-cicd/deployment-state/CdStack/'); + }); + + test('resolves mutable ECR tags before the skip comparison with repository-scoped IAM', () => { + const t = render(cfg()); + const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0] as any; + const commands = JSON.parse(project.Properties.Source.BuildSpec).phases.build.commands as string[]; + const fingerprintIndex = commands.findIndex((command) => command.startsWith('TARGET_FINGERPRINT=')); + const stateReadIndex = commands.findIndex((command) => command.includes('ssm get-parameter')); + + expect(fingerprintIndex).toBeGreaterThan(-1); + expect(commands[fingerprintIndex]).toContain('describe-images'); + expect(commands[fingerprintIndex]).toContain('imageDetails[0].imageDigest'); + expect(commands[fingerprintIndex]).toContain('--registry-id'); + expect(commands[fingerprintIndex]).toContain('registryHost'); + expect(fingerprintIndex).toBeLessThan(stateReadIndex); + + const policies = Object.values(t.findResources('AWS::IAM::Policy')) as any[]; + const statements = policies.flatMap((policy) => policy.Properties.PolicyDocument.Statement as any[]); + const statementActions = (statement: any): string[] => + Array.isArray(statement.Action) ? statement.Action : [statement.Action]; + const describeStatement = statements.find((statement) => + statementActions(statement).includes('ecr:DescribeImages'), + ); + const describeResources = Array.isArray(describeStatement.Resource) + ? describeStatement.Resource + : [describeStatement.Resource]; + + expect(statementActions(describeStatement)).toEqual( + expect.arrayContaining([ + 'ecr:BatchCheckLayerAvailability', + 'ecr:BatchGetImage', + 'ecr:DescribeImages', + 'ecr:GetDownloadUrlForLayer', + ]), + ); + expect(describeResources).toHaveLength(1); + expect(JSON.stringify(describeResources)).toContain(':ecr:eu-west-1:111111111111:repository/my-app-deployer'); + expect(describeResources).not.toContain('*'); + const authorizationStatement = statements.find((statement) => + statementActions(statement).includes('ecr:GetAuthorizationToken'), + ); + expect(authorizationStatement.Resource).toBe('*'); + expect(statementActions(authorizationStatement)).not.toContain('ecr:DescribeImages'); + }); + + test('preserves non-ECR fingerprinting without invoking AWS or adding ECR permissions', () => { + const nonEcr = defineDeployment({ + image: 'registry.example.com/team/app:base', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'dev', + env: { account: '111111111111', region: 'eu-west-1' }, + manualApproval: false, + }, + ], + }); + const t = render(nonEcr); + const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0] as any; + const commands = JSON.parse(project.Properties.Source.BuildSpec).phases.build.commands as string[]; + const fingerprintScript = extractEmbeddedNodeScript( + commands.find((command) => command.startsWith('TARGET_FINGERPRINT='))!, + ); + const pipeline = Object.values(t.findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const action = pipeline.Properties.Stages[1].Actions[0]; + const actionEnvironment = Object.fromEntries( + JSON.parse(action.Configuration.EnvironmentVariables).map((entry: any) => [entry.name, entry.value]), + ); + expect(JSON.stringify(t.findResources('AWS::IAM::Policy'))).not.toContain('ecr:DescribeImages'); + + const cwd = mkdtempSync(path.join(tmpdir(), 'deployment-non-ecr-')); + try { + writeFileSync( + path.join(cwd, 'deploy.config.js'), + 'module.exports = ' + + JSON.stringify({ + application: nonEcr.application, + qualifier: nonEcr.qualifier, + synthesizer: nonEcr.synthesizer, + image: nonEcr.image, + repository: nonEcr.repository, + targets: nonEcr.targets, + }), + ); + mkdirSync(path.join(cwd, 'config')); + writeFileSync(path.join(cwd, 'config', 'dev.json'), JSON.stringify({ version: '1.0.0' })); + writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { cli: '1.0.0' } })); + const bin = path.join(cwd, 'bin'); + mkdirSync(bin); + writeFileSync(path.join(bin, 'aws'), '#!/bin/sh\nexit 87\n'); + chmodSync(path.join(bin, 'aws'), 0o755); + const runFingerprint = () => + spawnSync(process.execPath, ['-e', fingerprintScript], { + cwd, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ''}`, + TARGET_STAGE: 'dev', + EXPECTED_DEPLOYMENT_TOPOLOGY: actionEnvironment.EXPECTED_DEPLOYMENT_TOPOLOGY, + }, + }); + + const first = runFingerprint(); + expect(first.status).toBe(0); + expect(first.stdout).toMatch(/^[0-9a-f]{64}$/); + writeFileSync(path.join(cwd, 'config', 'dev.json'), JSON.stringify({ version: '2.0.0' })); + const second = runFingerprint(); + expect(second.status).toBe(0); + expect(second.stdout).not.toBe(first.stdout); + + writeFileSync(path.join(cwd, 'config', 'dev.json'), '{'); + const malformed = runFingerprint(); + expect(malformed.status).not.toBe(0); + expect(malformed.stderr).toContain('exists but could not be read as JSON'); + + writeFileSync(path.join(cwd, 'config', 'dev.json'), JSON.stringify({ version: ' 2.0.0 ' })); + const invalid = runFingerprint(); + expect(invalid.status).not.toBe(0); + expect(invalid.stderr).toContain('must contain a non-empty string version field with no surrounding whitespace'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('the executable deploy-and-record command writes state only after a successful deploy', () => { + const t = render(cfg()); + const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0] as any; + const commands = JSON.parse(project.Properties.Source.BuildSpec).phases.build.commands as string[]; + const deployAndRecord = commands.find((command) => command.includes('} && aws ssm put-parameter'))!; + expect(deployAndRecord).toBeDefined(); + + const cwd = mkdtempSync(path.join(tmpdir(), 'deployment-state-write-')); + try { + const bin = path.join(cwd, 'bin'); + mkdirSync(bin); + const fakeCdkCicd = path.join(bin, 'fake-cdk-cicd'); + const aws = path.join(bin, 'aws'); + writeFileSync( + path.join(cwd, 'package.json'), + JSON.stringify({ private: true, scripts: { 'cdk-cicd': './bin/fake-cdk-cicd' } }), + ); + writeFileSync(fakeCdkCicd, '#!/bin/sh\nprintf "deploy\\n" >> "$TRACE_FILE"\nexit "${DEPLOY_EXIT:-0}"\n'); + writeFileSync(aws, '#!/bin/sh\nprintf "%s\\n" "$*" >> "$TRACE_FILE"\n'); + chmodSync(fakeCdkCicd, 0o755); + chmodSync(aws, 0o755); + + const run = (deployExit: number, traceFile: string) => + spawnSync('/bin/sh', ['-c', deployAndRecord], { + cwd, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ''}`, + DEPLOY_EXIT: String(deployExit), + TARGET_STAGE: 'dev', + TARGET_STATE_PARAMETER: '/cdk-cicd/test/dev', + TARGET_FINGERPRINT: 'a'.repeat(64), + TRACE_FILE: traceFile, + }, + }); + + const failedTrace = path.join(cwd, 'failed.trace'); + const failed = run(7, failedTrace); + expect(failed.status).toBe(7); + expect(readFileSync(failedTrace, 'utf8')).toBe('deploy\n'); + + const successfulTrace = path.join(cwd, 'successful.trace'); + const successful = run(0, successfulTrace); + expect(successful.status).toBe(0); + expect(readFileSync(successfulTrace, 'utf8')).toContain('ssm put-parameter'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('assigns each target a distinct stable SSM state parameter', () => { + const pipeline = Object.values(render(cfg()).findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const deployActions = (pipeline.Properties.Stages as any[]) + .flatMap((stage) => stage.Actions as any[]) + .filter((action) => action.Name.startsWith('Deploy-')); + const parameterFor = (actionName: string) => { + const action = deployActions.find((candidate) => candidate.Name === actionName); + const environment = JSON.parse(action.Configuration.EnvironmentVariables); + return environment.find((entry: any) => entry.name === 'TARGET_STATE_PARAMETER').value; + }; + + const dev = parameterFor('Deploy-dev'); + const prod = parameterFor('Deploy-prod'); + expect(dev).toContain('/cdk-cicd/deployment-state/CdStack/'); + expect(prod).toContain('/cdk-cicd/deployment-state/CdStack/'); + expect(dev).not.toEqual(prod); + }); + + test('keeps a sequential multi-region target in one action with no region override', () => { + const sequential = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'dev', + env: { + account: '111111111111', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: RegionOrder.SEQUENTIAL, + }, + }, + ], + }); + const pipeline = Object.values(render(sequential).findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const deploy = (pipeline.Properties.Stages as any[]).find((stage) => stage.Name === 'Deploy-1'); + expect((deploy.Actions as any[]).map((action) => action.Name)).toEqual(['Deploy-dev']); + + const environment = JSON.parse(deploy.Actions[0].Configuration.EnvironmentVariables); + expect(environment.find((entry: any) => entry.name === 'TARGET_REGION')).toBeUndefined(); + }); + + test('fans out a parallel multi-region target into independent same-stage actions', () => { + const parallel = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'dev', + env: { + account: '111111111111', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: RegionOrder.PARALLEL, + }, + }, + ], + }); + const t = render(parallel); + const pipeline = Object.values(t.findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const deploy = (pipeline.Properties.Stages as any[]).find((stage) => stage.Name === 'Deploy-1'); + const actions = deploy.Actions as any[]; + expect(actions.map((action) => action.Name)).toEqual(['Deploy-dev-eu-west-1', 'Deploy-dev-us-east-1']); + expect(actions.map((action) => action.RunOrder ?? 1)).toEqual([1, 1]); + + const environmentFor = (action: any) => + Object.fromEntries( + JSON.parse(action.Configuration.EnvironmentVariables).map((entry: any) => [entry.name, entry.value]), + ); + const first = environmentFor(actions[0]); + const second = environmentFor(actions[1]); + expect(first.TARGET_REGION).toBe('eu-west-1'); + expect(second.TARGET_REGION).toBe('us-east-1'); + expect(first.TARGET_STATE_PARAMETER).not.toEqual(second.TARGET_STATE_PARAMETER); + expect(first.EXPECTED_DEPLOYMENT_TOPOLOGY).toMatch(/^[0-9a-f]{64}$/); + expect(first.EXPECTED_DEPLOYMENT_TOPOLOGY).toBe(second.EXPECTED_DEPLOYMENT_TOPOLOGY); + + const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0] as any; + const spec = JSON.stringify(project.Properties.Source.BuildSpec); + // The current from-image CLI does not consume its outer --region option. Each action instead writes a + // temporary one-region config and then uses the normal CLI path, which emits the inner --region command. + expect(spec).toContain('.cdk-cicd-target'); + expect(spec).toContain('regions: [region]'); + expect(spec).toContain('(cd .cdk-cicd-target && npm run cdk-cicd -- deploy --from-image'); + expect(spec).toContain('re-run cdk-cicd deploy-ci to update the pipeline topology'); + expect(spec).not.toContain('--yes --region "$TARGET_REGION"'); + }); + + test('embedded fingerprint and parallel-config scripts execute against the current CLI config shape', () => { + const parallel = { + ...defineDeployment({ + application: 'shop', + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'dev', + env: { + account: '111111111111', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: RegionOrder.PARALLEL, + }, + }, + ], + }), + // A custom standard-bootstrap qualifier is part of the pipeline-shape fingerprint. + qualifier: 'shopqual', + }; + const t = render(parallel); + const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0] as any; + const commands = JSON.parse(project.Properties.Source.BuildSpec).phases.build.commands as string[]; + const pipeline = Object.values(t.findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const deploy = (pipeline.Properties.Stages as any[]).find((stage) => stage.Name === 'Deploy-1'); + const actionEnvironment = Object.fromEntries( + JSON.parse(deploy.Actions[0].Configuration.EnvironmentVariables).map((entry: any) => [entry.name, entry.value]), + ); + const fingerprintScript = extractEmbeddedNodeScript( + commands.find((command) => command.startsWith('TARGET_FINGERPRINT='))!, + ); + const parallelConfigScript = extractEmbeddedNodeScript( + commands.find((command) => command.includes('.cdk-cicd-target'))!, + ); + + const cwd = mkdtempSync(path.join(tmpdir(), 'deployment-pipeline-')); + try { + const writeDeploymentConfig = (targets: unknown[], overrides: Record = {}) => + writeFileSync( + path.join(cwd, 'deploy.config.js'), + 'module.exports = ' + + JSON.stringify({ + application: parallel.application, + qualifier: parallel.qualifier, + synthesizer: parallel.synthesizer, + image: parallel.image, + repository: parallel.repository, + crossAccountEcrRepositoryPolicyConfigured: parallel.crossAccountEcrRepositoryPolicyConfigured, + targets, + ...overrides, + }), + ); + writeDeploymentConfig(parallel.targets); + mkdirSync(path.join(cwd, 'config')); + writeFileSync(path.join(cwd, 'config', 'dev.json'), JSON.stringify({ version: '1.2.3' })); + const packageJson = path.join(cwd, 'package.json'); + const packageLock = path.join(cwd, 'package-lock.json'); + writeFileSync(packageJson, JSON.stringify({ dependencies: { '@cdklabs/cdk-cicd-wrapper-cli': '1.0.0' } })); + writeFileSync(packageLock, JSON.stringify({ lockfileVersion: 3, packages: { '': { version: '1.0.0' } } })); + + const bin = path.join(cwd, 'bin'); + const aws = path.join(bin, 'aws'); + const ecrTrace = path.join(cwd, 'ecr.trace'); + const firstDigest = `sha256:${'a'.repeat(64)}`; + const secondDigest = `sha256:${'b'.repeat(64)}`; + mkdirSync(bin); + writeFileSync( + aws, + '#!/bin/sh\n' + + 'if [ "${ECR_FAIL_IF_CALLED:-}" = "1" ]; then exit 91; fi\n' + + 'printf "%s\\n" "$*" >> "$ECR_TRACE_FILE"\n' + + 'printf "%s\\n" "$ECR_DIGEST"\n', + ); + chmodSync(aws, 0o755); + const env = { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ''}`, + ECR_DIGEST: firstDigest, + ECR_TRACE_FILE: ecrTrace, + TARGET_STAGE: 'dev', + TARGET_REGION: 'eu-west-1', + EXPECTED_DEPLOYMENT_TOPOLOGY: actionEnvironment.EXPECTED_DEPLOYMENT_TOPOLOGY, + }; + const runFingerprint = (overrides: NodeJS.ProcessEnv = {}) => + spawnSync(process.execPath, ['-e', fingerprintScript], { + cwd, + env: { ...env, ...overrides }, + encoding: 'utf8', + }); + const fingerprint = runFingerprint(); + expect(fingerprint.status).toBe(0); + expect(fingerprint.stdout).toMatch(/^[0-9a-f]{64}$/); + expect(readFileSync(ecrTrace, 'utf8')).toContain( + 'ecr describe-images --registry-id 111111111111 --repository-name app ' + + '--image-ids imageTag=1.2.3 --query imageDetails[0].imageDigest --output text ' + + '--region eu-west-1 --no-cli-pager', + ); + + const retagged = runFingerprint({ ECR_DIGEST: secondDigest }); + expect(retagged.status).toBe(0); + expect(retagged.stdout).not.toBe(fingerprint.stdout); + + writeFileSync(ecrTrace, ''); + writeDeploymentConfig([ + { + ...parallel.targets[0], + image: `111111111111.dkr.ecr.eu-west-1.amazonaws.com/app@${firstDigest}`, + }, + ]); + const digestPinned = runFingerprint({ ECR_FAIL_IF_CALLED: '1' }); + expect(digestPinned.status).toBe(0); + expect(readFileSync(ecrTrace, 'utf8')).toBe(''); + writeDeploymentConfig(parallel.targets); + + writeFileSync(packageJson, JSON.stringify({ dependencies: { '@cdklabs/cdk-cicd-wrapper-cli': '1.0.1' } })); + const manifestChanged = runFingerprint(); + expect(manifestChanged.status).toBe(0); + expect(manifestChanged.stdout).not.toBe(fingerprint.stdout); + + writeFileSync(packageJson, JSON.stringify({ dependencies: { '@cdklabs/cdk-cicd-wrapper-cli': '1.0.0' } })); + writeFileSync(packageLock, JSON.stringify({ lockfileVersion: 3, packages: { '': { version: '1.0.1' } } })); + const lockChanged = runFingerprint(); + expect(lockChanged.status).toBe(0); + expect(lockChanged.stdout).not.toBe(fingerprint.stdout); + + writeDeploymentConfig(parallel.targets, { qualifier: 'changedq' }); + const identityChanged = runFingerprint(); + expect(identityChanged.status).not.toBe(0); + expect(identityChanged.stderr).toContain('re-run cdk-cicd deploy-ci to update its actions and permissions'); + writeDeploymentConfig(parallel.targets); + + writeDeploymentConfig(parallel.targets, { + repository: { ...parallel.repository, branch: 'release' }, + }); + const repositoryChanged = runFingerprint(); + expect(repositoryChanged.status).not.toBe(0); + expect(repositoryChanged.stderr).toContain('re-run cdk-cicd deploy-ci to update its actions and permissions'); + writeDeploymentConfig(parallel.targets); + + const narrow = spawnSync(process.execPath, ['-e', parallelConfigScript], { + cwd, + env, + encoding: 'utf8', + }); + expect(narrow.status).toBe(0); + const generatedSource = readFileSync(path.join(cwd, '.cdk-cicd-target', 'deploy.config.js'), 'utf8'); + const generated = JSON.parse(generatedSource.match(/^module\.exports = (.*);\n$/s)![1]); + expect(generated.application).toBe('shop'); + expect(generated.qualifier).toBe('shopqual'); + expect(generated.synthesizer).toEqual({ type: SynthesizerType.DEFAULT }); + expect(generated.targets[0].env.regions).toEqual(['eu-west-1']); + expect(JSON.parse(readFileSync(path.join(cwd, '.cdk-cicd-target', 'package.json'), 'utf8'))).toEqual({ + private: true, + scripts: { 'cdk-cicd': '../node_modules/.bin/cdk-cicd' }, + }); + expect(readFileSync(path.join(cwd, '.cdk-cicd-target', 'config', 'dev.json'), 'utf8')).toContain('1.2.3'); + + const staleRegion = spawnSync(process.execPath, ['-e', parallelConfigScript], { + cwd, + env: { ...env, TARGET_REGION: 'ap-southeast-2' }, + encoding: 'utf8', + }); + expect(staleRegion.status).not.toBe(0); + expect(staleRegion.stderr).toContain('no longer defines parallel region ap-southeast-2'); + + const staleSequentialActionEnv: NodeJS.ProcessEnv = { ...env }; + delete staleSequentialActionEnv.TARGET_REGION; + const staleSequentialAction = spawnSync(process.execPath, ['-e', fingerprintScript], { + cwd, + env: staleSequentialActionEnv, + encoding: 'utf8', + }); + expect(staleSequentialAction.status).not.toBe(0); + expect(staleSequentialAction.stderr).toContain('now needs parallel region actions'); + + writeDeploymentConfig([ + { + ...parallel.targets[0], + env: { ...parallel.targets[0].env, regions: [...parallel.targets[0].env.regions, 'ap-southeast-2'] }, + }, + ]); + const addedParallelRegion = spawnSync(process.execPath, ['-e', fingerprintScript], { + cwd, + env, + encoding: 'utf8', + }); + expect(addedParallelRegion.status).not.toBe(0); + expect(addedParallelRegion.stderr).toContain('re-run cdk-cicd deploy-ci'); + + writeDeploymentConfig([ + ...parallel.targets, + { + stage: 'prod', + env: { regions: ['eu-west-1'], regionOrder: RegionOrder.SEQUENTIAL }, + manualApproval: true, + }, + ]); + const addedTarget = spawnSync(process.execPath, ['-e', fingerprintScript], { + cwd, + env, + encoding: 'utf8', + }); + expect(addedTarget.status).not.toBe(0); + expect(addedTarget.stderr).toContain('re-run cdk-cicd deploy-ci'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('uses one approval before all parallel region actions for a gated target', () => { + const gatedParallel = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'prod', + env: { + account: '111111111111', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: RegionOrder.PARALLEL, + }, + manualApproval: true, + }, + ], + }); + const pipeline = Object.values(render(gatedParallel).findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const gated = (pipeline.Properties.Stages as any[]).find((stage) => stage.Name === 'Deploy-1'); + const actions = gated.Actions as any[]; + + expect(actions.map((action) => action.Name)).toEqual([ + 'Approve-prod', + 'Deploy-prod-eu-west-1', + 'Deploy-prod-us-east-1', + ]); + expect(actions[0].RunOrder).toBe(1); + expect(actions.slice(1).map((action) => action.RunOrder)).toEqual([2, 2]); + }); + + test("logs in to the image's own ECR region rather than the pipeline region", () => { + const crossRegion = defineDeployment({ + image: '111111111111.dkr.ecr.us-east-2.amazonaws.com/app:1', repository: Repository.codecommit('cfg'), targets: [{ stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }], }); - const t = render(crossAccount); + const t = render(crossRegion); const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0] as any; const spec = JSON.stringify(project.Properties.Source.BuildSpec); - expect(spec).toContain('999999999999.dkr.ecr.us-east-2.amazonaws.com'); + expect(spec).toContain('111111111111.dkr.ecr.us-east-2.amazonaws.com'); expect(spec).toContain('--region us-east-2'); }); + test('grants ECR authorization and repository-scoped pull permissions for each distinct image repository', () => { + const perRepository = defineDeployment({ + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'dev', + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/team/apps/deployer:dev-42', + }, + { + stage: 'res', + // Same repository with another tag must not duplicate the IAM resource. + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/team/apps/deployer:res-42', + }, + { + stage: 'prod', + image: '111111111111.dkr.ecr.us-east-2.amazonaws.com/platform/prod/deployer@sha256:abcdef', + }, + ], + }); + const policies = JSON.stringify(render(perRepository).findResources('AWS::IAM::Policy')); + const devRepository = ':ecr:eu-west-1:111111111111:repository/team/apps/deployer'; + const prodRepository = ':ecr:us-east-2:111111111111:repository/platform/prod/deployer'; + + expect(policies).toContain('ecr:GetAuthorizationToken'); + expect(policies).toContain('ecr:BatchCheckLayerAvailability'); + expect(policies).toContain('ecr:BatchGetImage'); + expect(policies).toContain('ecr:GetDownloadUrlForLayer'); + expect(policies).toContain(devRepository); + expect(policies).toContain(prodRepository); + expect(policies.split(devRepository)).toHaveLength(2); + expect(policies).not.toContain('repository/team/apps/deployer:dev-42'); + expect(policies).not.toContain('repository/platform/prod/deployer@sha256'); + }); + + test('rejects cross-account ECR images unless the owner-side repository policy is acknowledged', () => { + const crossAccount = defineDeployment({ + image: '999999999999.dkr.ecr.us-east-2.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }], + }); + + expect(() => render(crossAccount)).toThrow(/crossAccountEcrRepositoryPolicyConfigured: true/); + }); + + test('rejects cross-partition or malformed ECR registry hosts', () => { + const deploymentWithImage = (image: string) => + defineDeployment({ + image, + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }], + }); + + expect(() => render(deploymentWithImage('111111111111.dkr.ecr.us-iso-east-1.c2s.ic.gov/app:1'))).toThrow( + /ECR authentication and IAM cannot cross AWS partitions/, + ); + expect(() => render(deploymentWithImage('111111111111.dkr.ecr.eu-west-1.evil.example/app:1'))).toThrow( + /registry suffix 'evil\.example'.*requires 'amazonaws\.com'/, + ); + expect(() => render(deploymentWithImage('111111111111.dkr.ecr.us-isof-south-1.csp.hci.ic.gov/app:1'))).toThrow( + /partition\/domain suffix is not known/, + ); + }); + + test('allows acknowledged cross-account ECR images with identity grants and an owner-policy warning', () => { + const crossAccount = defineDeployment({ + image: '999999999999.dkr.ecr.us-east-2.amazonaws.com/platform/deployer:1', + repository: Repository.codecommit('cfg'), + crossAccountEcrRepositoryPolicyConfigured: true, + targets: [{ stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }], + }); + const stack = deploymentStack(crossAccount); + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + + expect(policies).toContain('ecr:BatchCheckLayerAvailability'); + expect(policies).toContain(':ecr:us-east-2:999999999999:repository/platform/deployer'); + expect( + Annotations.fromStack(stack).findWarning( + '*', + Match.stringLikeRegexp('identity-side pull permissions only.*owner-account repository policy'), + ), + ).toHaveLength(1); + }); + + test('uses CodeBuild credentials for managed build images and repository grants for private ECR images', () => { + const managedProject = Object.values( + render(cfg(), undefined, 'aws/codebuild/standard:7.0').findResources('AWS::CodeBuild::Project'), + )[0] as any; + expect(managedProject.Properties.Environment.ImagePullCredentialsType).toBe('CODEBUILD'); + + const privateImage = '111111111111.dkr.ecr.eu-west-1.amazonaws.com/build/deployer@sha256:abcdef'; + const privateTemplate = render(cfg(), undefined, privateImage); + const privateProject = Object.values(privateTemplate.findResources('AWS::CodeBuild::Project'))[0] as any; + expect(privateProject.Properties.Environment.ImagePullCredentialsType).toBe('SERVICE_ROLE'); + expect(JSON.stringify(privateProject.Properties.Environment.Image)).toContain('build/deployer@sha256:abcdef'); + expect(JSON.stringify(privateTemplate.findResources('AWS::IAM::Policy'))).toContain( + ':ecr:eu-west-1:111111111111:repository/build/deployer', + ); + }); + + test('requires an ECR build image to be in the Repo 2 pipeline region', () => { + expect(() => render(cfg(), undefined, '111111111111.dkr.ecr.us-west-2.amazonaws.com/build/deployer:1')).toThrow( + /CodeBuild custom ECR images must be in the same region/, + ); + }); + + test('allows a same-region cross-account ECR build image only after owner-policy acknowledgement', () => { + const image = '999999999999.dkr.ecr.eu-west-1.amazonaws.com/build/deployer:1'; + expect(() => render(cfg(), undefined, image)).toThrow(/crossAccountEcrRepositoryPolicyConfigured: true/); + + const acknowledged = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + crossAccountEcrRepositoryPolicyConfigured: true, + targets: [{ stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }], + }); + const stack = deploymentStack(acknowledged, undefined, image); + const template = Template.fromStack(stack); + const project = Object.values(template.findResources('AWS::CodeBuild::Project'))[0] as any; + expect(project.Properties.Environment.ImagePullCredentialsType).toBe('SERVICE_ROLE'); + expect(JSON.stringify(template.findResources('AWS::IAM::Policy'))).toContain( + ':ecr:eu-west-1:999999999999:repository/build/deployer', + ); + }); + test('grants sts:AssumeRole on the CDK bootstrap roles for each target account/region', () => { const policies = JSON.stringify(render(cfg()).findResources('AWS::IAM::Policy')); // bootstrap deploy + publishing roles for the dev target (111111111111 / eu-west-1) @@ -78,6 +760,89 @@ describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { expect(policies).toContain('role/cdk-hnb659fds-file-publishing-role-111111111111-eu-west-1'); }); + test('resolves environment-agnostic targets to the concrete pipeline account and region for IAM', () => { + const environmentAgnostic = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'dev' }], + }); + const policies = JSON.stringify(render(environmentAgnostic).findResources('AWS::IAM::Policy')); + + expect(policies).toContain('role/cdk-hnb659fds-deploy-role-111111111111-eu-west-1'); + expect(policies).toContain(':ssm:eu-west-1:111111111111:parameter/cdk-bootstrap/hnb659fds/version'); + }); + + test('fails early when an environment-agnostic target cannot resolve the pipeline account or region', () => { + const unresolvedAccountStack = new Stack(new App(), 'UnresolvedAccount'); + const noEnvironment = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'dev' }], + }); + expect(() => new DeploymentPipeline(unresolvedAccountStack, 'Cd', { config: noEnvironment })).toThrow( + /pipeline stack's account is unresolved/, + ); + + const unresolvedRegionStack = new Stack(new App(), 'UnresolvedRegion'); + const accountOnly = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'dev', env: { account: '111111111111' } }], + }); + expect(() => new DeploymentPipeline(unresolvedRegionStack, 'Cd', { config: accountOnly })).toThrow( + /pipeline stack's region is unresolved/, + ); + }); + + test('rejects deployment targets in a different or unknown AWS partition', () => { + const targetInChina = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'china', env: { account: '222222222222', region: 'cn-north-1' } }], + }); + expect(() => render(targetInChina)).toThrow(/partition 'aws-cn'.*Repo 2 pipeline is in 'aws'/); + + const unknownTargetRegion = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'future', env: { account: '222222222222', region: 'moon-north-1' } }], + }); + expect(() => render(unknownTargetRegion)).toThrow(/AWS partition is not known/); + }); + + test('treats a missing legacy synthesizer as the default synthesizer', () => { + const legacy = { + ...defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }], + }), + synthesizer: undefined, + }; + const template = render(legacy); + const policies = JSON.stringify(template.findResources('AWS::IAM::Policy')); + const project = Object.values(template.findResources('AWS::CodeBuild::Project'))[0] as any; + + expect(policies).toContain('role/cdk-hnb659fds-deploy-role-111111111111-eu-west-1'); + expect(policies).not.toContain('-file-role-eu-west-1'); + expect(project.Properties.Source.BuildSpec).toContain('const synthesizer = config.synthesizer ??'); + expect(project.Properties.Source.BuildSpec).toContain('default'); + }); + + test('rejects APP_STAGING because its bootstrapless support stack bypasses the deployment role', () => { + const appStaging = defineDeployment({ + application: 'payments', + synthesizer: { type: SynthesizerType.APP_STAGING }, + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [{ stage: 'prod', env: { account: '111111111111', region: 'eu-west-1' } }], + }); + + expect(() => render(appStaging)).toThrow( + /DefaultStagingStack with BootstraplessSynthesizer.*CodeBuild project's base credentials/, + ); + }); + test('grants sts:AssumeRole for any forced target deploy roles', () => { const t = render(cfg()); // the prod target's deployRole must be assumable by the deploy project role. CDK renders a single @@ -87,6 +852,164 @@ describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { expect(policies).toContain('arn:aws:iam::222222222222:role/deployer'); }); + test('specializes forced deploy-role placeholders for every target region', () => { + const roleArn = + 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-deployer-${AWS::AccountId}-${AWS::Region}'; + const config = defineDeployment({ + qualifier: 'shopq', + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'prod', + env: { + account: '222222222222', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: RegionOrder.SEQUENTIAL, + }, + deployment: { deployRole: roleArn }, + }, + ], + }); + + const policies = JSON.stringify(render(config).findResources('AWS::IAM::Policy')); + expect(policies).toContain('arn:aws:iam::222222222222:role/cdk-shopq-deployer-222222222222-eu-west-1'); + expect(policies).toContain('arn:aws:iam::222222222222:role/cdk-shopq-deployer-222222222222-us-east-1'); + expect(policies).not.toContain('${Qualifier}'); + expect(policies).not.toContain('${AWS::AccountId}'); + expect(policies).not.toContain('${AWS::Region}'); + expect(policies).not.toContain('${AWS::Partition}'); + }); + + test('pipeline role comparisons canonicalize placeholder and concrete ARNs', () => { + const placeholderConfig = defineDeployment({ + qualifier: 'shopq', + image: 'example.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'prod', + env: { account: '222222222222', region: 'eu-west-1' }, + deployment: { + deployRole: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-deployer-${AWS::Region}', + cfnExecutionRole: + 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-cfn-exec-${AWS::Region}', + }, + }, + ], + }); + const concreteConfig = defineDeployment({ + qualifier: 'shopq', + image: 'example.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'prod', + env: { account: '222222222222', region: 'eu-west-1' }, + deployment: { + deployRole: 'arn:aws:iam::222222222222:role/cdk-shopq-deployer-eu-west-1', + cfnExecutionRole: 'arn:aws:iam::222222222222:role/cdk-shopq-cfn-exec-eu-west-1', + }, + }, + ], + }); + const expectedTopology = (template: Template): string => { + const pipeline = Object.values(template.findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const deployAction = (pipeline.Properties.Stages as any[]) + .flatMap((stage) => stage.Actions as any[]) + .find((action) => action.Name === 'Deploy-prod'); + const environment = JSON.parse(deployAction.Configuration.EnvironmentVariables) as Array<{ + name: string; + value: string; + }>; + return environment.find((entry) => entry.name === 'EXPECTED_DEPLOYMENT_TOPOLOGY')!.value; + }; + + const placeholderTemplate = render(placeholderConfig); + const expected = expectedTopology(placeholderTemplate); + expect(expected).toBe(expectedTopology(render(concreteConfig))); + + const project = Object.values(placeholderTemplate.findResources('AWS::CodeBuild::Project'))[0] as any; + const commands = JSON.parse(project.Properties.Source.BuildSpec).phases.build.commands as string[]; + const fingerprintScript = extractEmbeddedNodeScript( + commands.find((command) => command.startsWith('TARGET_FINGERPRINT='))!, + ); + const cwd = mkdtempSync(path.join(tmpdir(), 'deployment-role-specialization-')); + try { + writeFileSync(path.join(cwd, 'deploy.config.js'), `module.exports = ${JSON.stringify(concreteConfig)};\n`); + const result = spawnSync(process.execPath, ['-e', fingerprintScript], { + cwd, + env: { + ...process.env, + TARGET_STAGE: 'prod', + EXPECTED_DEPLOYMENT_TOPOLOGY: expected, + }, + encoding: 'utf8', + }); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/^[0-9a-f]{64}$/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('leaves custom CloudFormation execution-role passing to the assumed deployment role', () => { + const cfnExecutionRole = 'arn:aws:iam::111111111111:role/cfn-execution'; + const withExecutionRole = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'dev', + env: { account: '111111111111', region: 'eu-west-1' }, + deployment: { + deployRole: 'arn:aws:iam::111111111111:role/deployer', + cfnExecutionRole, + }, + }, + ], + }); + const policies = Object.values(render(withExecutionRole).findResources('AWS::IAM::Policy')) as any[]; + const statements = policies.flatMap((policy) => policy.Properties.PolicyDocument.Statement as any[]); + expect(JSON.stringify(statements)).not.toContain(cfnExecutionRole); + }); + + test('grants Secrets Manager read for effective target ExternalId references only', () => { + const externalIdSecret = 'arn:aws:secretsmanager:us-east-1:222222222222:secret:repo2-external'; + const ignoredSecret = 'arn:aws:secretsmanager:us-east-1:222222222222:secret:ignored-without-role'; + const withExternalIds = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'dev', + deployment: { + deployRole: 'arn:aws:iam::222222222222:role/deployer', + cfnExecutionRole: 'arn:aws:iam::222222222222:role/cfn-exec', + externalId: `resolve:secretsmanager:${externalIdSecret}`, + }, + }, + { + stage: 'res', + deployment: { + deployRole: 'arn:aws:iam::222222222222:role/res-deployer', + externalId: 'literal-external-id', + }, + }, + { + stage: 'prod', + deployment: { externalId: `resolve:secretsmanager:${ignoredSecret}` }, + }, + ], + }); + + const policies = JSON.stringify(render(withExternalIds).findResources('AWS::IAM::Policy')); + expect(policies).toContain('secretsmanager:GetSecretValue'); + expect(policies).toContain(externalIdSecret); + expect(policies).not.toContain(ignoredSecret); + expect(policies).not.toContain('literal-external-id'); + }); + test('a disposable pipeline empties/destroys its own artifact bucket', () => { const t = render(cfg(), RemovalPolicy.DESTROY); t.hasResource('AWS::S3::Bucket', Match.objectLike({ DeletionPolicy: 'Delete' })); @@ -97,22 +1020,89 @@ describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { expect(() => render(noRepo)).toThrow(/needs a `repository`/); }); - test('ungated targets deploy in the parallel Deploy stage; gated ones in DeployGated behind an approval', () => { + test('an ungated wave stays before the following gated target', () => { const t = render(cfg()); // dev (ungated), prod (gated) const pipeline = Object.values(t.findResources('AWS::CodePipeline::Pipeline'))[0] as any; const stage = (n: string) => (pipeline.Properties.Stages as any[]).find((s) => s.Name === n); - // dev (ungated) is in Deploy, NOT blocked by prod's approval. - expect((stage('Deploy').Actions as any[]).map((a) => a.Name)).toEqual(['Deploy-dev']); - // prod (gated) is in DeployGated: approve (runOrder 1) then deploy (runOrder 2). - const gated = stage('DeployGated'); + expect((stage('Deploy-1').Actions as any[]).map((a) => a.Name)).toEqual(['Deploy-dev']); + const gated = stage('Deploy-2'); const byName = (n: string) => (gated.Actions as any[]).find((a) => a.Name === n); + // The native approval necessarily queues before CodeBuild can perform its fingerprint check. An + // unchanged gated target therefore still needs approval, after which Deploy-prod exits as a no-op. expect(byName('Approve-prod').RunOrder).toBe(1); expect(byName('Deploy-prod').RunOrder).toBe(2); // each deploy action selects its target via TARGET_STAGE expect(JSON.stringify(byName('Deploy-prod').Configuration.EnvironmentVariables)).toContain('prod'); }); - test('two gated targets deploy in parallel in DeployGated (int + prod, each approved)', () => { + test('a gated-first target blocks the contiguous ungated wave declared after it', () => { + const gatedFirst = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'prod', + env: { account: '111111111111', region: 'eu-west-1' }, + manualApproval: true, + }, + { + stage: 'smoke', + env: { account: '111111111111', region: 'eu-west-1' }, + manualApproval: false, + }, + { + stage: 'verify', + env: { account: '111111111111', region: 'eu-west-1' }, + manualApproval: false, + }, + ], + }); + const pipeline = Object.values(render(gatedFirst).findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const stages = pipeline.Properties.Stages as any[]; + + expect(stages.map((stage) => stage.Name)).toEqual(['Source', 'Deploy-1', 'Deploy-2']); + expect(stages[1].Actions.map((action: any) => [action.Name, action.RunOrder])).toEqual([ + ['Approve-prod', 1], + ['Deploy-prod', 2], + ]); + expect(stages[2].Actions.map((action: any) => action.Name)).toEqual(['Deploy-smoke', 'Deploy-verify']); + }); + + test('interleaved gates preserve target order and split only contiguous ungated waves', () => { + const interleaved = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: false }, + { stage: 'qa', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: false }, + { stage: 'preprod', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: true }, + { stage: 'smoke', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: false }, + { stage: 'canary', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: false }, + { stage: 'prod', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: true }, + { stage: 'verify', env: { account: '111111111111', region: 'eu-west-1' }, manualApproval: false }, + ], + }); + const pipeline = Object.values(render(interleaved).findResources('AWS::CodePipeline::Pipeline'))[0] as any; + const stages = pipeline.Properties.Stages as any[]; + + expect(stages.map((stage) => stage.Name)).toEqual([ + 'Source', + 'Deploy-1', + 'Deploy-2', + 'Deploy-3', + 'Deploy-4', + 'Deploy-5', + ]); + expect(stages.slice(1).map((stage) => stage.Actions.map((action: any) => action.Name))).toEqual([ + ['Deploy-dev', 'Deploy-qa'], + ['Approve-preprod', 'Deploy-preprod'], + ['Deploy-smoke', 'Deploy-canary'], + ['Approve-prod', 'Deploy-prod'], + ['Deploy-verify'], + ]); + }); + + test('two gated targets get independent approval/deploy pairs in declared order', () => { const twoGated = defineDeployment({ image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', repository: Repository.codecommit('cfg'), @@ -123,11 +1113,18 @@ describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { }); const pipeline = Object.values(render(twoGated).findResources('AWS::CodePipeline::Pipeline'))[0] as any; const names = (pipeline.Properties.Stages as any[]).map((s) => s.Name); - expect(names).toEqual(['Source', 'DeployGated']); // no ungated -> no Deploy stage - const gated = (pipeline.Properties.Stages as any[]).find((s) => s.Name === 'DeployGated'); - const deploys = (gated.Actions as any[]).filter((a) => a.Name.startsWith('Deploy-')); - // both gated deploys share runOrder 2 -> they run in parallel after their approvals - expect(deploys.map((a) => a.RunOrder)).toEqual([2, 2]); + expect(names).toEqual(['Source', 'Deploy-1', 'Deploy-2']); + + const pairedActions = (stageName: string) => + (pipeline.Properties.Stages as any[]).find((stage) => stage.Name === stageName).Actions as any[]; + expect(pairedActions('Deploy-1').map((action) => [action.Name, action.RunOrder])).toEqual([ + ['Approve-int', 1], + ['Deploy-int', 2], + ]); + expect(pairedActions('Deploy-2').map((action) => [action.Name, action.RunOrder])).toEqual([ + ['Approve-prod', 1], + ['Deploy-prod', 2], + ]); }); test('distinct per-target image registries are each logged in to', () => { @@ -142,7 +1139,7 @@ describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { { stage: 'prod', env: { account: '222222222222', region: 'us-east-1' }, - image: '222222222222.dkr.ecr.us-east-2.amazonaws.com/app:prod-7', + image: '111111111111.dkr.ecr.us-east-2.amazonaws.com/app:prod-7', }, ], }); @@ -150,26 +1147,128 @@ describe('m6-container: CD DeploymentPipeline (Repo 2)', () => { const spec = JSON.stringify(project.Properties.Source.BuildSpec); // both distinct registries get a docker login, each in its own region expect(spec).toContain('111111111111.dkr.ecr.eu-west-1.amazonaws.com'); - expect(spec).toContain('222222222222.dkr.ecr.us-east-2.amazonaws.com'); + expect(spec).toContain('111111111111.dkr.ecr.us-east-2.amazonaws.com'); expect(spec).toContain('--region eu-west-1'); expect(spec).toContain('--region us-east-2'); }); - test('a npmRegistry config writes a scoped .npmrc before npm ci and grants secret read', () => { + test('a npmRegistry config uses a temporary npmrc, cleans it, and grants secret/KMS access', () => { + const encryptionKeyArn = 'arn:aws:kms:eu-west-1:111111111111:key/EXAMPLE_NOT_A_SECRET'; const withRegistry = defineDeployment({ image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/my-app-deployer:1.2.3', repository: Repository.codecommit('my-deploy-config'), - npmRegistry: { url: 'https://npm.example.com/', basicAuthSecretArn: 'arn:npm-secret', scope: 'cdklabs' }, + npmRegistry: { + url: 'https://npm.example.com/', + basicAuthSecretArn: 'arn:npm-secret', + encryptionKeyArn, + scope: 'cdklabs', + }, targets: [{ stage: 'dev', env: { account: '111111111111', region: 'eu-west-1' } }], }); const t = render(withRegistry); const project = Object.values(t.findResources('AWS::CodeBuild::Project'))[0] as any; - const spec = JSON.stringify(project.Properties.Source.BuildSpec); + const buildSpec = JSON.parse(project.Properties.Source.BuildSpec); + const spec = JSON.stringify(buildSpec); + expect(spec).toContain('/tmp/cdk-cicd-npmrc'); expect(spec).toContain('@cdklabs:registry=https://npm.example.com/'); expect(spec).toContain('//npm.example.com/:_authToken=$NPM_AUTH_TOKEN'); + expect(buildSpec.phases.build.commands).toContain('rm -f "$NPM_CONFIG_USERCONFIG"'); + expect(buildSpec.phases.build.finally).toContain('rm -f "$NPM_CONFIG_USERCONFIG"'); + expect(spec).not.toContain('> ./.npmrc'); const policies = JSON.stringify(t.findResources('AWS::IAM::Policy')); expect(policies).toContain('secretsmanager:GetSecretValue'); expect(policies).toContain('arn:npm-secret'); + expect(policies).toContain('kms:Decrypt'); + expect(policies).toContain(encryptionKeyArn); + }); + + test('rejects a rendered stage that would exceed the 100-action CodePipeline quota', () => { + const tooManyRegions = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'prod', + env: { + account: '111111111111', + regions: Array.from({ length: 100 }, (_, index) => `test-region-${index}`), + regionOrder: RegionOrder.PARALLEL, + }, + manualApproval: true, + }, + ], + }); + + expect(() => render(tooManyRegions)).toThrow(/stage 'Deploy-1' would contain 101 actions.*100-action/); + }); + + test('rejects topologies that exceed total action or stage quotas', () => { + const tooManyActions = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: Array.from({ length: 20 }, (_, targetIndex) => ({ + stage: `stage-${targetIndex}`, + env: { + account: '111111111111', + regions: Array.from({ length: 50 }, (_unused, regionIndex) => `test-${targetIndex}-${regionIndex}`), + regionOrder: RegionOrder.PARALLEL, + }, + manualApproval: true, + })), + }); + expect(() => render(tooManyActions)).toThrow(/would contain 1021 actions.*1000-action/); + + const tooManyStages = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: Array.from({ length: 50 }, (_, index) => ({ + stage: `stage-${index}`, + env: { account: '111111111111', region: 'eu-west-1' }, + manualApproval: true, + })), + }); + expect(() => render(tooManyStages)).toThrow(/would contain 51 stages.*50-stage/); + }); + + test('rejects generated CodePipeline names that exceed the 100-character identifier limit', () => { + const longStage = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'a'.repeat(95), + env: { account: '111111111111', region: 'eu-west-1' }, + manualApproval: true, + }, + ], + }); + + expect(() => render(longStage)).toThrow(/generated CodePipeline action name.*1-100 characters/); + }); + + test('rejects duplicate generated actions in the shared ungated stage', () => { + const collision = defineDeployment({ + image: '111111111111.dkr.ecr.eu-west-1.amazonaws.com/app:1', + repository: Repository.codecommit('cfg'), + targets: [ + { + stage: 'dev-eu-west-1', + env: { account: '111111111111', region: 'eu-west-1' }, + manualApproval: false, + }, + { + stage: 'dev', + env: { + account: '111111111111', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: RegionOrder.PARALLEL, + }, + manualApproval: false, + }, + ], + }); + + expect(() => render(collision)).toThrow(/duplicate action name 'Deploy-dev-eu-west-1'/); }); test('rejects duplicate target stage names (they would collide on action names)', () => { diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/github/GitHubActionsEngine.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/github/GitHubActionsEngine.test.ts index dde41138..91bab7dc 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/engine/github/GitHubActionsEngine.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/engine/github/GitHubActionsEngine.test.ts @@ -9,13 +9,14 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { App, Aspects, Stack, Stage } from 'aws-cdk-lib'; +import { App, Aspects, Aws, BOOTSTRAP_QUALIFIER_CONTEXT, Stack, Stage } from 'aws-cdk-lib'; import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { AwsSolutionsChecks } from 'cdk-nag'; +import { parse } from 'yaml'; import { defineCICD } from '../../../src/config/define'; import { Repository } from '../../../src/config/repository'; -import { GitHubActionsConfig, ResolvedCicdConfig } from '../../../src/config/types'; +import { GitHubActionsConfig, RegionOrder, ResolvedCicdConfig, SynthesizerType } from '../../../src/config/types'; import { CdkPipelinesStageContext, IStageProvider } from '../../../src/engine/cdkpipelines/CdkPipelinesEngine'; import { GitHubActionsEngine } from '../../../src/engine/github/GitHubActionsEngine'; @@ -41,20 +42,30 @@ function workflowPath(): string { } function config(overrides: Partial[0]> = {}): ResolvedCicdConfig { + const githubActions: GitHubActionsConfig = { + workflowPath: workflowPath(), + environmentProtectionConfigured: true, + ...(overrides.githubActions ?? {}), + }; return defineCICD({ application: 'shop', repository: Repository.github('org/shop'), stages: ['dev', { name: 'prod', env: { account: '222222222222', region: 'us-east-1' }, manualApproval: true }], - githubActions: { workflowPath: workflowPath() }, ...overrides, + githubActions, }); } -function render(overrides: Partial[0]> = {}): { +function render( + overrides: Partial[0]> = {}, + context: Record = {}, +): { stack: Stack; engine: GitHubActionsEngine; } { - const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); + const stack = new Stack(new App({ context }), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); const engine = new GitHubActionsEngine(stack, 'Cd', { config: config(overrides), stages: new StubStages() }); // `doBuildPipeline()` (which populates `workflowFile`, incl. applying the JsonPatch calls) runs lazily // at synth time -- force it now so `engine.pipeline.workflowFile.toYaml()` reflects the real content. @@ -74,6 +85,216 @@ describe('GitHubActionsEngine', () => { ).toThrow(/Repository\.github/); }); + test('rejects APP_STAGING because the installed alpha does not support CDK Pipelines', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new GitHubActionsEngine(stack, 'Cd', { + config: config({ synthesizer: { type: SynthesizerType.APP_STAGING } }), + stages: new StubStages(), + }), + ).toThrow(/GITHUB_ACTIONS cannot use SynthesizerType\.APP_STAGING.*does not support CDK Pipelines.*cross-Stage/); + }); + + test('requires a concrete pipeline account and region for the literal OIDC role ARN', () => { + const stack = new Stack(new App(), 'PipelineStack'); + expect( + () => + new GitHubActionsEngine(stack, 'Cd', { + config: config({ stages: ['dev'] }), + stages: new StubStages(), + }), + ).toThrow(/requires a concrete pipeline stack account and region.*literal OIDC role ARN/); + }); + + test('rejects a concrete pipeline region unknown to the installed CDK region table', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'unknown-future-1' }, + }); + expect( + () => + new GitHubActionsEngine(stack, 'Cd', { + config: config({ stages: ['dev'] }), + stages: new StubStages(), + }), + ).toThrow(/pipeline region 'unknown-future-1' is not known/); + }); + + test('rejects a non-commercial pipeline partition unsupported by the installed GitHub OIDC helper', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-gov-west-1' }, + }); + expect( + () => + new GitHubActionsEngine(stack, 'Cd', { + config: config({ stages: ['dev'] }), + stages: new StubStages(), + }), + ).toThrow(/does not support pipeline partition 'aws-us-gov'.*OIDC helper.*audience/); + }); + + test('requires concrete target account and region values for literal workflow jobs', () => { + expect(() => + render({ + stages: [ + { + name: 'dev', + env: { account: Aws.ACCOUNT_ID, region: Aws.REGION }, + }, + ], + }), + ).toThrow(/stage 'dev' requires concrete account and region values.*literal deployment jobs/); + }); + + test('rejects target Regions in a different AWS partition', () => { + expect(() => + render({ + stages: [ + { + name: 'isolated', + env: { account: '111111111111', region: 'us-iso-east-1' }, + }, + ], + }), + ).toThrow(/cannot mix AWS partitions.*'aws'.*stage 'isolated'.*'aws-iso'/); + }); + + test('rejects target Regions unknown to the installed CDK region table', () => { + expect(() => + render({ + stages: [ + { + name: 'future', + env: { account: '111111111111', region: 'unknown-future-1' }, + }, + ], + }), + ).toThrow(/stage 'future' region 'unknown-future-1' is not known/); + }); + + test('rejects a CodeArtifact Region in another partition', () => { + expect(() => + render({ + stages: ['dev'], + codeArtifact: { domain: 'packages', repository: 'npm', region: 'cn-north-1' }, + }), + ).toThrow(/CodeArtifact region 'cn-north-1'.*'aws-cn'.*pipeline partition 'aws'/); + }); + + test('rejects a CodeArtifact Region unknown to the installed CDK region table', () => { + expect(() => + render({ + stages: ['dev'], + codeArtifact: { domain: 'packages', repository: 'npm', region: 'unknown-future-1' }, + }), + ).toThrow(/CodeArtifact region 'unknown-future-1' is not known/); + }); + + test('rejects a publish-assets authentication Region in another partition', () => { + expect(() => + render({ + stages: ['dev'], + githubActions: { + workflowPath: workflowPath(), + environmentProtectionConfigured: true, + publishAssetsAuthRegion: 'cn-north-1', + }, + }), + ).toThrow(/publishAssetsAuthRegion 'cn-north-1'.*'aws-cn'.*pipeline is in 'aws'/); + }); + + test('provisions compliance logging and applies it inside application Stage boundaries', () => { + const { stack, engine } = render({ + stages: ['dev'], + complianceLogBucketName: 'shop-compliance-log-bucket', + }); + const pipelineTemplate = Template.fromStack(stack); + const destination = Object.values(pipelineTemplate.findResources('AWS::S3::Bucket')).find( + (bucket: any) => bucket.Properties.BucketName === 'shop-compliance-log-bucket', + ) as any; + expect(destination).toBeDefined(); + expect(destination.Properties.LoggingConfiguration).toBeUndefined(); + + const applicationStack = engine.node.findAll().find((construct): construct is Stack => construct instanceof Stack); + expect(applicationStack).toBeDefined(); + Template.fromStack(applicationStack!).hasResourceProperties('AWS::S3::Bucket', { + LoggingConfiguration: { + DestinationBucketName: 'shop-compliance-log-bucket', + }, + }); + }); + + test('imports an existing compliance destination without synthesizing its bucket or policy', () => { + const { stack, engine } = render({ + stages: ['dev'], + complianceLogBucketName: 'shop-existing-compliance-log-bucket', + createComplianceLogBucket: false, + }); + const pipelineTemplate = Template.fromStack(stack); + pipelineTemplate.resourceCountIs('AWS::S3::Bucket', 0); + pipelineTemplate.resourceCountIs('AWS::S3::BucketPolicy', 0); + + const applicationStack = engine.node.findAll().find((construct): construct is Stack => construct instanceof Stack); + expect(applicationStack).toBeDefined(); + Template.fromStack(applicationStack!).hasResourceProperties('AWS::S3::Bucket', { + LoggingConfiguration: { + DestinationBucketName: 'shop-existing-compliance-log-bucket', + }, + }); + }); + + test('rejects compliance logging for a cross-account or cross-region application stage', () => { + expect(() => + render({ + complianceLogBucketName: 'shop-compliance-log-bucket', + stages: [ + { + name: 'prod', + env: { account: '222222222222', region: 'us-east-1' }, + }, + ], + }), + ).toThrow(/compliance logging cannot target GitHub Actions stage 'prod'.*same account and region/); + }); + + test('treats an omitted synthesizer in a legacy resolved config as DEFAULT', () => { + const resolved = config({ stages: ['dev'] }); + const legacy = { ...resolved } as Partial; + Reflect.deleteProperty(legacy, 'synthesizer'); + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + + expect( + () => + new GitHubActionsEngine(stack, 'Cd', { + config: legacy as ResolvedCicdConfig, + stages: new StubStages(), + }), + ).not.toThrow(); + expect(() => Template.fromStack(stack)).not.toThrow(); + }); + + test('fails closed when manualApproval is configured without acknowledging GitHub environment protection', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + expect( + () => + new GitHubActionsEngine(stack, 'Cd', { + config: config({ + githubActions: { + workflowPath: workflowPath(), + environmentProtectionConfigured: false, + }, + }), + stages: new StubStages(), + }), + ).toThrow(/Configure required reviewers.*environmentProtectionConfigured: true/); + }); + test('creates a GitHubActionRole with a literal name and trust scoped to the configured repository', () => { const { stack } = render(); const t = Template.fromStack(stack); @@ -82,7 +303,10 @@ describe('GitHubActionsEngine', () => { AssumeRolePolicyDocument: Match.objectLike({ Statement: Match.arrayWith([ Match.objectLike({ - Condition: { StringLike: { 'token.actions.githubusercontent.com:sub': ['repo:org/shop:*'] } }, + Condition: { + StringLike: { 'token.actions.githubusercontent.com:sub': ['repo:org/shop:*'] }, + StringEquals: { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com' }, + }, }), ]), }), @@ -136,6 +360,9 @@ describe('GitHubActionsEngine', () => { Principal: { Federated: 'arn:aws:iam::111111111111:oidc-provider/token.actions.githubusercontent.com', }, + Condition: Match.objectLike({ + StringEquals: { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com' }, + }), }), ]), }), @@ -149,6 +376,39 @@ describe('GitHubActionsEngine', () => { expect(yaml).not.toContain('Token['); }); + test('defaults OIDC authentication to the pipeline Region instead of us-west-2', () => { + const stack = new Stack(new App(), 'PipelineStack', { + env: { account: '111111111111', region: 'eu-central-1' }, + }); + const engine = new GitHubActionsEngine(stack, 'Cd', { + config: config({ stages: ['dev'] }), + stages: new StubStages(), + }); + const workflow = parse(engine.pipeline.workflowFile.toYaml()) as { + jobs: Record }> }>; + }; + const auth = workflow.jobs['Build-Synth'].steps.find((step) => step.name === 'Authenticate Via OIDC Role'); + expect(auth?.with?.['aws-region']).toBe('eu-central-1'); + }); + + test('builds deployment placeholders under the validated partition and restores ambient process state', () => { + const previousPartition = process.env.CDK_AWS_PARTITION; + process.env.CDK_AWS_PARTITION = 'aws-cn'; + try { + const { engine } = render({ stages: ['dev'] }); + const yaml = engine.pipeline.workflowFile.toYaml(); + expect(process.env.CDK_AWS_PARTITION).toBe('aws-cn'); + expect(yaml).toContain('CDK_AWS_PARTITION: aws'); + expect(yaml).not.toContain('arn:aws-cn:'); + } finally { + if (previousPartition === undefined) { + delete process.env.CDK_AWS_PARTITION; + } else { + process.env.CDK_AWS_PARTITION = previousPartition; + } + } + }); + test('the Synth job runs npm ci + the default scripts + npm run cdk synth with CDK_CICD_MODE=pipeline', () => { const { engine } = render(); const yaml = engine.pipeline.workflowFile.toYaml(); @@ -162,13 +422,178 @@ describe('GitHubActionsEngine', () => { expect(yaml).toContain('CDK_CICD_MODE'); }); - test('each stage gets its own GitHub Environment named after the stage', () => { + test('ci.image becomes the Build-Synth container and does not affect deployment jobs', () => { + const image = 'public.ecr.aws/example/ci-image:2026-09'; + const { engine } = render({ ci: { image } }); + const workflow = parse(engine.pipeline.workflowFile.toYaml()) as { + jobs: Record; + }; + + expect(workflow.jobs['Build-Synth'].container).toEqual({ image }); + for (const [jobName, job] of Object.entries(workflow.jobs)) { + if (jobName !== 'Build-Synth') expect(job.container).toBeUndefined(); + } + }); + + test('keeps a public external-registry Build-Synth container on the anonymous pull path', () => { + const image = 'registry.example.com/public/ci-image:stable'; + const { engine } = render({ ci: { image } }); + const workflow = parse(engine.pipeline.workflowFile.toYaml()) as { + jobs: Record; + }; + expect(workflow.jobs['Build-Synth'].container).toEqual({ image }); + }); + + test('renders external-registry credentials as GitHub secret expressions', () => { + const image = 'registry.example.com/private/ci-image:stable'; + const { engine } = render({ + ci: { image }, + githubActions: { + buildContainerCredentials: { + usernameSecretName: 'REGISTRY_USERNAME', + passwordSecretName: 'REGISTRY_PASSWORD', + }, + }, + }); + const workflow = parse(engine.pipeline.workflowFile.toYaml()) as { + jobs: Record< + string, + { + container?: { + image?: string; + credentials?: { username?: string; password?: string }; + }; + } + >; + }; + + expect(workflow.jobs['Build-Synth'].container).toEqual({ + image, + credentials: { + username: '${{ secrets.REGISTRY_USERNAME }}', + password: '${{ secrets.REGISTRY_PASSWORD }}', + }, + }); + }); + + test('rejects GitHub container credentials without ci.image', () => { + expect(() => + render({ + githubActions: { + buildContainerCredentials: { + usernameSecretName: 'REGISTRY_USERNAME', + passwordSecretName: 'REGISTRY_PASSWORD', + }, + }, + }), + ).toThrow(/buildContainerCredentials requires ci\.image/); + }); + + test.each([ + ['usernameSecretName', '1STARTS_WITH_NUMBER'], + ['usernameSecretName', 'GITHUB_TOKEN'], + ['passwordSecretName', 'contains-dash'], + ['passwordSecretName', ''], + ])('rejects invalid GitHub registry %s %p', (field, secretName) => { + expect(() => + render({ + ci: { image: 'registry.example.com/private/ci-image:stable' }, + githubActions: { + buildContainerCredentials: { + usernameSecretName: field === 'usernameSecretName' ? secretName : 'REGISTRY_USERNAME', + passwordSecretName: field === 'passwordSecretName' ? secretName : 'REGISTRY_PASSWORD', + }, + }, + }), + ).toThrow(new RegExp(`buildContainerCredentials\\.${field}.*not a valid GitHub secret name`)); + }); + + test('rejects CodeBuild registry credentials in the GitHub Actions engine', () => { + expect(() => + render({ + ci: { + image: 'registry.example.com/private/ci-image:stable', + codeBuildImageCredentials: { + secretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:registry-ABC123', + }, + }, + }), + ).toThrow(/codeBuildImageCredentials is supported only by the CodeBuild engines/); + }); + + test('accepts a public shorthand image pinned by digest', () => { + const image = `ubuntu@sha256:${'a'.repeat(64)}`; + const { engine } = render({ ci: { image } }); + const workflow = parse(engine.pipeline.workflowFile.toYaml()) as { + jobs: Record; + }; + expect(workflow.jobs['Build-Synth'].container).toEqual({ image }); + }); + + test('rejects a managed CodeBuild image ID because it is not a pullable GitHub job container', () => { + expect(() => render({ ci: { image: 'aws/codebuild/standard:7.0' } })).toThrow( + /managed CodeBuild ci\.image.*not a pullable OCI job-container reference/, + ); + }); + + test('rejects a private ECR Build-Synth container because it is pulled before OIDC authentication', () => { + expect(() => + render({ + ci: { image: '111111111111.dkr.ecr.us-west-2.amazonaws.com/platform/ci:stable' }, + }), + ).toThrow(/cannot use private ECR ci\.image.*before the OIDC authentication step/); + }); + + test('rejects GitHub username/password credentials for private ECR images', () => { + expect(() => + render({ + ci: { image: '111111111111.dkr.ecr.us-west-2.amazonaws.com/platform/ci:stable' }, + githubActions: { + buildContainerCredentials: { + usernameSecretName: 'REGISTRY_USERNAME', + passwordSecretName: 'REGISTRY_PASSWORD', + }, + }, + }), + ).toThrow(/cannot use private ECR ci\.image.*do not implement the AWS ECR authorization-token exchange/); + }); + + test('rejects private ECR Build-Synth containers in isolated partitions before OIDC authentication', () => { + expect(() => + render({ + ci: { image: '111111111111.dkr.ecr.us-iso-east-1.c2s.ic.gov/platform/ci:stable' }, + }), + ).toThrow(/cannot use private ECR ci\.image.*before the OIDC authentication step/); + }); + + test.each([ + ['dual-stack', '111111111111.dkr-ecr.us-west-2.on.aws/platform/ci:stable'], + ['FIPS', '111111111111.dkr.ecr-fips.us-west-2.amazonaws.com/platform/ci:stable'], + ['mixed-case host', '111111111111.DKR.ECR.us-west-2.amazonaws.com/platform/ci:stable'], + ['spoofed suffix', '111111111111.dkr.ecr.us-west-2.amazonaws.com.attacker.example/platform/ci:stable'], + ])('rejects a %s private ECR Build-Synth endpoint instead of treating it as public', (_case, image) => { + expect(() => render({ ci: { image } })).toThrow( + /cannot use private ECR ci\.image.*before the OIDC authentication step/, + ); + }); + + test('rejects inline registry userinfo without echoing the credential', () => { + const password = 'do-not-log-this-password'; + let failure: unknown; + try { + render({ ci: { image: `user:${password}@registry.example.com/private/ci-image:stable` } }); + } catch (error) { + failure = error; + } + expect(String(failure)).toMatch(/must not embed registry credentials/); + expect(String(failure)).not.toContain(password); + }); + + test('acknowledged approval stages continue to use their generated GitHub Environments', () => { const { engine } = render(); const yaml = engine.pipeline.workflowFile.toYaml(); expect(yaml).toContain('environment: dev'); expect(yaml).toContain('environment: prod'); - // The gated ('prod') and ungated ('dev') stage are otherwise rendered the same way -- GitHub - // Environments (configured on GitHub's side), not a CDK ManualApprovalStep, are the gate. }); test('a multi-region stage becomes one job per region, each its own GitHub Environment', () => { @@ -187,6 +612,38 @@ describe('GitHubActionsEngine', () => { expect(yaml).toContain('environment: prod-us-east-1'); }); + test('RegionOrder.PARALLEL removes dependencies between a stage’s regional deploy jobs', () => { + const stack = new Stack(new App(), 'PipelineStack', { env: { account: '111111111111', region: 'us-west-2' } }); + const engine = new GitHubActionsEngine(stack, 'Cd', { + config: config({ + stages: [ + { + name: 'prod', + env: { + account: '111111111111', + regions: ['eu-west-1', 'us-east-1'], + regionOrder: RegionOrder.PARALLEL, + }, + }, + ], + }), + stages: new StubStages(), + }); + Template.fromStack(stack); + const yaml = engine.pipeline.workflowFile.toYaml(); + const jobs = (parse(yaml) as { jobs: Record }).jobs; + const [euJobName, euJob] = Object.entries(jobs).find(([, job]) => job.environment === 'prod-eu-west-1') ?? []; + const [usJobName, usJob] = Object.entries(jobs).find(([, job]) => job.environment === 'prod-us-east-1') ?? []; + const needs = (job: { needs?: string | string[] } | undefined): string[] => + job?.needs === undefined ? [] : Array.isArray(job.needs) ? job.needs : [job.needs]; + + expect(euJobName).toBeDefined(); + expect(usJobName).toBeDefined(); + expect(needs(euJob)).not.toContain(usJobName); + expect(needs(usJob)).not.toContain(euJobName); + expect(needs(euJob)).toEqual(needs(usJob)); + }); + test('a stage with no explicit account defaults to the pipeline account, not env-agnostic', () => { // cdk-pipelines-github needs a concrete account/region per stage (a static YAML step, unlike an // AWS-hosted CodePipeline deploy action) -- an agnostic 'dev' stage must not make it throw. @@ -194,8 +651,10 @@ describe('GitHubActionsEngine', () => { expect(() => Template.fromStack(stack)).not.toThrow(); }); - test('a codeArtifact config logs in ahead of the build, with credentials configured first', () => { - const { engine } = render({ codeArtifact: { domain: 'd', repository: 'r', npmScope: 'cdklabs' } }); + test('a codeArtifact config logs in after OIDC auth and grants the role the required read permissions', () => { + const { stack, engine } = render({ + codeArtifact: { domain: 'd', repository: 'r', npmScope: 'cdklabs' }, + }); const yaml = engine.pipeline.workflowFile.toYaml(); const loginIdx = yaml.indexOf('aws codeartifact login'); const credsIdx = yaml.indexOf('Authenticate Via OIDC Role'); @@ -203,25 +662,233 @@ describe('GitHubActionsEngine', () => { expect(credsIdx).toBeGreaterThan(-1); expect(credsIdx).toBeLessThan(loginIdx); expect(yaml).toContain('--namespace cdklabs'); + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('codeartifact:GetAuthorizationToken'); + expect(policies).toContain('codeartifact:GetRepositoryEndpoint'); + expect(policies).toContain('codeartifact:ReadFromRepository'); + expect(policies).toContain('sts:GetServiceBearerToken'); }); - test('a proxy config exports HTTP(S)_PROXY ahead of the build', () => { - const { engine } = render({ - proxy: { proxySecretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:proxy-abc123' }, + test('a proxy config loads and masks its secret after OIDC auth, then persists the proxy environment', () => { + const proxySecretArn = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:proxy-abc123'; + const { stack, engine } = render({ + proxy: { proxySecretArn }, }); - const yaml = engine.pipeline.workflowFile.toYaml(); - expect(yaml).toContain('export HTTP_PROXY='); - expect(yaml).toContain('curl -Is --connect-timeout 5 https://aws.amazon.com'); + const workflow = parse(engine.pipeline.workflowFile.toYaml()) as { + jobs: Record }>; + }; + const steps = workflow.jobs['Build-Synth'].steps; + const credentialsIndex = steps.findIndex((step) => step.name === 'Authenticate Via OIDC Role'); + const loginIndex = steps.findIndex((step) => step.name === 'Login'); + const buildIndex = steps.findIndex((step) => step.name === 'Build'); + const login = steps[loginIndex].run ?? ''; + + expect(credentialsIndex).toBeGreaterThanOrEqual(0); + expect(loginIndex).toBeGreaterThan(credentialsIndex); + expect(buildIndex).toBeGreaterThan(loginIndex); + expect(login).toContain(`--secret-id '${proxySecretArn}' --region 'us-west-2'`); + expect(login).toContain("jq -er '.username'"); + expect(login).toContain('::add-mask::$PROXY_PASSWORD'); + expect(login).toContain('echo "HTTP_PROXY=$HTTP_PROXY" >> "$GITHUB_ENV"'); + expect(login).toContain('export NO_PROXY='); + expect(login).toContain('curl -Is --connect-timeout 5 https://aws.amazon.com'); + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('secretsmanager:GetSecretValue'); + expect(policies).toContain(proxySecretArn); + }); + + test('a generic npm registry fetches and masks its token after OIDC auth, then grants exact secret read', () => { + const secretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:npm-token-abc123'; + const proxySecretArn = 'arn:aws:secretsmanager:us-west-2:111111111111:secret:proxy-abc123'; + const { stack, engine } = render({ + proxy: { proxySecretArn }, + npmRegistry: { + url: 'https://npm.example.com/', + scope: 'cdklabs', + basicAuthSecretArn: secretArn, + }, + codeArtifact: { domain: 'domain', repository: 'repository', npmScope: 'internal' }, + }); + const workflow = parse(engine.pipeline.workflowFile.toYaml()) as { + jobs: Record }>; + }; + const steps = workflow.jobs['Build-Synth'].steps; + const credentialsIndex = steps.findIndex((step) => step.name === 'Authenticate Via OIDC Role'); + const loginIndex = steps.findIndex((step) => step.name === 'Login'); + const buildIndex = steps.findIndex((step) => step.name === 'Build'); + const login = steps[loginIndex].run ?? ''; + const proxyFetchIndex = login.indexOf(`--secret-id '${proxySecretArn}'`); + const proxyIndex = login.indexOf('export HTTP_PROXY='); + const fetchIndex = login.indexOf(`--secret-id '${secretArn}'`); + const maskIndex = login.indexOf('::add-mask::$NPM_AUTH_TOKEN'); + const npmrcIndex = login.indexOf('@cdklabs:registry=https://npm.example.com/'); + const codeArtifactIndex = login.indexOf('aws codeartifact login'); + + expect(credentialsIndex).toBeGreaterThanOrEqual(0); + expect(loginIndex).toBeGreaterThan(credentialsIndex); + expect(buildIndex).toBeGreaterThan(loginIndex); + expect(proxyFetchIndex).toBeGreaterThanOrEqual(0); + expect(proxyIndex).toBeGreaterThan(proxyFetchIndex); + expect(fetchIndex).toBeGreaterThan(proxyIndex); + expect(maskIndex).toBeGreaterThan(fetchIndex); + expect(npmrcIndex).toBeGreaterThan(maskIndex); + expect(codeArtifactIndex).toBeGreaterThan(npmrcIndex); + expect(login).toContain(`--secret-id '${secretArn}' --region 'eu-west-1'`); + expect(login).toContain('//npm.example.com/:_authToken=$NPM_AUTH_TOKEN'); + expect(login).toContain('export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/cdk-cicd-npmrc"'); + expect(login).toContain('echo "NPM_CONFIG_USERCONFIG=$NPM_CONFIG_USERCONFIG" >> "$GITHUB_ENV"'); + expect(login).toContain('> "$NPM_CONFIG_USERCONFIG"'); + expect(login).not.toContain('./.npmrc'); + const cleanup = steps.find((step) => step.name === 'Clean up npm credentials'); + expect(cleanup).toEqual( + expect.objectContaining({ + if: 'always()', + run: 'if [ -n "${NPM_CONFIG_USERCONFIG:-}" ]; then rm -f "$NPM_CONFIG_USERCONFIG"; fi', + }), + ); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'secretsmanager:GetSecretValue', + Resource: secretArn, + }), + ]), + }), + }); + }); + + test('rejects a deploy-role ExternalId because the installed GitHub engine cannot forward it', () => { + const secretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:deploy-external-id-abc123'; + expect(() => + render({ + deployRoleExternalId: `resolve:secretsmanager:${secretArn}`, + stages: [{ name: 'prod', deployment: { deployRole: 'arn:aws:iam::222222222222:role/Deploy' } }], + }), + ).toThrow(/GITHUB_ACTIONS cannot honor deploy-role ExternalIds.*prod/); + }); + + test('trims and specializes configured deployment-role placeholders before granting AssumeRole', () => { + const deployRole = + ' arn:${AWS::Partition}:iam::${AWS::AccountId}:role/Custom-${Qualifier}-${AWS::AccountId}-${AWS::Region} '; + const { stack } = render({ + qualifier: 'customq', + stages: [ + { + name: 'prod', + env: { account: '222222222222', regions: ['eu-west-1', 'us-east-1'] }, + deployment: { deployRole }, + }, + ], + }); + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('sts:AssumeRole'); + expect(policies).toContain('arn:aws:iam::222222222222:role/Custom-customq-222222222222-eu-west-1'); + expect(policies).toContain('arn:aws:iam::222222222222:role/Custom-customq-222222222222-us-east-1'); + expect(policies).not.toContain('${Qualifier}'); + expect(policies).not.toContain('${AWS::AccountId}'); + expect(policies).not.toContain('${AWS::Region}'); + }); + + test('uses the CDK bootstrap qualifier context when no config qualifier is resolved', () => { + const { stack } = render( + { + application: undefined, + stages: [ + { + name: 'prod', + env: { account: '222222222222', region: 'eu-west-1' }, + deployment: { + deployRole: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/Custom-${Qualifier}-${AWS::Region}', + }, + }, + ], + }, + { [BOOTSTRAP_QUALIFIER_CONTEXT]: 'ctxqual' }, + ); + + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('arn:aws:iam::222222222222:role/Custom-ctxqual-eu-west-1'); + expect(policies).not.toContain('Custom-hnb659fds-eu-west-1'); }); - test('without codeArtifact/proxy the Synth job needs no extra credential step', () => { + test('rejects a custom deployment-role name containing cfn-exec because the dependency rewrites it', () => { + expect(() => + render({ + stages: [ + { + name: 'prod', + env: { account: '222222222222', region: 'eu-west-1' }, + deployment: { deployRole: 'arn:aws:iam::222222222222:role/Custom-cfn-exec-Role' }, + }, + ], + }), + ).toThrow(/GITHUB_ACTIONS deployRole.*prod.*cannot contain literal `cfn-exec`.*rewrites.*`deploy`/); + }); + + test('rejects cfn-exec introduced by role placeholder specialization', () => { + expect(() => + render({ + qualifier: 'cfn-exec', + stages: [ + { + name: 'prod', + env: { account: '222222222222', region: 'eu-west-1' }, + deployment: { + deployRole: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/Custom-${Qualifier}-Role', + }, + }, + ], + }), + ).toThrow(/GITHUB_ACTIONS deployRole.*prod.*cannot contain literal `cfn-exec`.*rewrites.*`deploy`/); + }); + + test('rejects cfn-exec in the effective qualifier when using the default deploy role', () => { + expect(() => + render({ + qualifier: 'cfn-exec', + stages: [{ name: 'prod', env: { account: '222222222222', region: 'eu-west-1' } }], + }), + ).toThrow(/GITHUB_ACTIONS deployRole.*prod.*cannot contain literal `cfn-exec`.*rewrites.*`deploy`/); + }); + + test('Build-Synth always authenticates even without AWS-backed install features', () => { const { engine } = render(); - const yaml = engine.pipeline.workflowFile.toYaml(); - // Exactly one "Authenticate Via OIDC Role" step in the Synth job: the one cdk-pipelines-github's own - // asset-publish/deploy jobs already add, not a second one this engine patched in. - const synthJob = yaml.slice(yaml.indexOf('Build-Synth:'), yaml.indexOf('Assets-')); - expect(synthJob).not.toContain('Authenticate Via OIDC Role'); - expect(synthJob).not.toContain('Login'); + const workflow = parse(engine.pipeline.workflowFile.toYaml()) as { + jobs: Record }> }>; + }; + const credentialSteps = workflow.jobs['Build-Synth'].steps.filter( + (step) => step.name === 'Authenticate Via OIDC Role', + ); + expect(credentialSteps).toHaveLength(1); + expect(credentialSteps[0].with).toEqual( + expect.objectContaining({ + 'aws-region': 'us-west-2', + 'role-to-assume': 'arn:aws:iam::111111111111:role/shop-github-role', + }), + ); + }); + + test('registry secrets grant the OIDC role kms:Decrypt only on configured customer-managed keys', () => { + const proxyKeyArn = 'arn:aws:kms:us-west-2:111111111111:key/proxy-key'; + const npmKeyArn = 'arn:aws:kms:eu-west-1:111111111111:key/npm-key'; + const { stack } = render({ + proxy: { + proxySecretArn: 'arn:aws:secretsmanager:us-west-2:111111111111:secret:proxy', + encryptionKeyArn: proxyKeyArn, + }, + npmRegistry: { + url: 'https://npm.example.com/', + basicAuthSecretArn: 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:npm', + encryptionKeyArn: npmKeyArn, + }, + }); + + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('kms:Decrypt'); + expect(policies).toContain(proxyKeyArn); + expect(policies).toContain(npmKeyArn); }); test('warmAccountsFromSsm scans SSM in the Login step and exports the ACCOUNT_ loop', () => { @@ -256,6 +923,24 @@ describe('GitHubActionsEngine', () => { }); }); + test('grants the OIDC role target bootstrap lookup AssumeRole and version-parameter access', () => { + const { stack } = render({ + qualifier: 'customq', + stages: [ + { + name: 'prod', + env: { account: '222222222222', regions: ['eu-west-1', 'us-east-1'] }, + }, + ], + }); + const policies = JSON.stringify(Template.fromStack(stack).findResources('AWS::IAM::Policy')); + expect(policies).toContain('sts:AssumeRole'); + expect(policies).toContain('cdk-customq-lookup-role-222222222222-eu-west-1'); + expect(policies).toContain('cdk-customq-lookup-role-222222222222-us-east-1'); + expect(policies).toContain('ssm:GetParameter'); + expect(policies).toContain('parameter/cdk-bootstrap/customq/version'); + }); + test('without warmAccountsFromSsm neither the SSM scan nor the ssm:GetParametersByPath statement is present', () => { const { stack, engine } = render(); const yaml = engine.pipeline.workflowFile.toYaml(); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/attach.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/attach.test.ts index fe413f12..dcdbeb7b 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/attach.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/attach.test.ts @@ -4,14 +4,23 @@ // attach.test.ts deliberately does NOT import register.ts -- it exercises the explicit escape // hatch on a STOCK, unpatched App, which is the bundled/ESM situation attach exists for. -import { App, Aspects, IAspect, Stack } from 'aws-cdk-lib'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { App, Aspects, IAspect, Stack, Stage } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import * as logs from 'aws-cdk-lib/aws-logs'; +import * as s3 from 'aws-cdk-lib/aws-s3'; import * as ssm from 'aws-cdk-lib/aws-ssm'; import { AwsSolutionsChecks } from 'cdk-nag'; import { IConstruct } from 'constructs'; import { AppConfig, CdkCicd } from '../../src'; -import { appsConstructed } from '../../src/runtime/inject'; +import { + appsConstructed, + COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, + COMPLIANCE_LOG_BUCKET_NAME_FLAG, + COMPLIANCE_LOG_BUCKET_REGION_FLAG, +} from '../../src/runtime/inject'; import { DEFAULT_LOG_RETENTION_DAYS } from '../../src/support/LogRetentionAspect'; describe('m2-attach: CdkCicd.attach', () => { @@ -84,6 +93,121 @@ describe('m2-attach: CdkCicd.attach', () => { Template.fromStack(stack).hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 30 }); }); + test('applies pipeline-injected compliance logging to application stacks', () => { + const previous = { + name: process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG], + account: process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG], + region: process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG], + }; + process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG] = 'compliance-bucket'; + process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG] = '111111111111'; + process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG] = 'us-west-2'; + try { + const app = new App(); + CdkCicd.attach(app); + const stack = new Stack(app, 'ApplicationStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + new s3.Bucket(stack, 'ApplicationBucket'); + + Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', { + LoggingConfiguration: { + DestinationBucketName: 'compliance-bucket', + }, + }); + } finally { + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_NAME_FLAG, previous.name); + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, previous.account); + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_REGION_FLAG, previous.region); + } + }); + + test('applies compliance logging across Stage boundaries created after attach', () => { + const previous = { + name: process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG], + account: process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG], + region: process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG], + }; + process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG] = 'compliance-bucket'; + process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG] = '111111111111'; + process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG] = 'us-west-2'; + try { + const app = new App(); + CdkCicd.attach(app); + const outer = new Stage(app, 'OuterStage', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + const inner = new Stage(outer, 'InnerStage', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + const stack = new Stack(inner, 'NestedApplicationStack'); + new s3.Bucket(stack, 'NestedApplicationBucket'); + + Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', { + LoggingConfiguration: { + DestinationBucketName: 'compliance-bucket', + }, + }); + } finally { + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_NAME_FLAG, previous.name); + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, previous.account); + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_REGION_FLAG, previous.region); + } + }); + + test('fails closed when compliance is attached after a nested Stage has already synthesized', () => { + const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'late-compliance-')); + const app = new App({ outdir }); + const stage = new Stage(app, 'AlreadySynthesized', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + const stack = new Stack(stage, 'ApplicationStack'); + new s3.Bucket(stack, 'ApplicationBucket'); + stage.synth(); + + const previous = { + name: process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG], + account: process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG], + region: process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG], + }; + process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG] = 'compliance-bucket'; + process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG] = '111111111111'; + process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG] = 'us-west-2'; + try { + expect(() => CdkCicd.attach(app)).toThrow(/cached cloud assembly cannot be retrofitted/); + } finally { + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_NAME_FLAG, previous.name); + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, previous.account); + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_REGION_FLAG, previous.region); + fs.rmSync(outdir, { recursive: true, force: true }); + } + }); + + test('fails closed when an application stack cannot use the injected compliance destination', () => { + const previous = { + name: process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG], + account: process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG], + region: process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG], + }; + process.env[COMPLIANCE_LOG_BUCKET_NAME_FLAG] = 'compliance-bucket'; + process.env[COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG] = '111111111111'; + process.env[COMPLIANCE_LOG_BUCKET_REGION_FLAG] = 'us-west-2'; + try { + const app = new App(); + CdkCicd.attach(app); + const stack = new Stack(app, 'CrossRegionApplicationStack', { + env: { account: '111111111111', region: 'us-east-1' }, + }); + new s3.Bucket(stack, 'ApplicationBucket'); + + expect(() => Template.fromStack(stack)).toThrow(/same account and region/); + } finally { + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_NAME_FLAG, previous.name); + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_ACCOUNT_FLAG, previous.account); + setOrDeleteEnv(COMPLIANCE_LOG_BUCKET_REGION_FLAG, previous.region); + } + }); + test('skipDefaults opts out of every plugin (no cdk-nag)', () => { const app = new App(); CdkCicd.attach(app, { skipDefaults: true }); @@ -135,3 +259,8 @@ describe('m2-attach: CdkCicd.attach', () => { expect(() => CdkCicd.attach(app)).toThrow(/addPlugin/); }); }); + +function setOrDeleteEnv(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/fixtures/cdkp-runner.js b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/fixtures/cdkp-runner.js index c98a272a..5e9df031 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/fixtures/cdkp-runner.js +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/fixtures/cdkp-runner.js @@ -12,7 +12,18 @@ const cdk = require('aws-cdk-lib'); const config = defineCICD({ application: 'shop', repository: Repository.codecommit('shop'), - stages: ['dev', { name: 'prod', env: { account: '222222222222', region: 'us-east-1' }, manualApproval: true }], + stages: [ + 'dev', + { + name: 'prod', + env: { account: '222222222222', region: 'us-east-1' }, + manualApproval: true, + deployment: { + deployRole: 'arn:aws:iam::222222222222:role/ForcedDeploy', + cfnExecutionRole: 'arn:aws:iam::222222222222:role/ForcedCfn', + }, + }, + ], }); const app = assemblePipelineApp(config, path.join(__dirname, 'plain-bin.js')); @@ -24,6 +35,13 @@ const result = stages.map((s) => { const bucketIds = Object.keys(resources).filter((k) => resources[k].Type === 'AWS::S3::Bucket'); // stack.environment is `aws:///` -- proves the per-stage env pin took effect. const account = stack && stack.environment ? stack.environment.account : undefined; - return { stage: s.node.id, buckets: bucketIds.length, bucketIds, account }; + return { + stage: s.node.id, + buckets: bucketIds.length, + bucketIds, + account, + assumeRoleArn: stack && stack.assumeRoleArn, + cfnRoleArn: stack && stack.cloudFormationExecutionRoleArn, + }; }); process.stdout.write('RESULT=' + JSON.stringify(result) + '\n'); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/pipeline-assembler.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/pipeline-assembler.test.ts index 37eae7bd..31e50b7a 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/pipeline-assembler.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/pipeline-assembler.test.ts @@ -10,18 +10,31 @@ // require.cache (same reason bundled-diagnostic runs the compiled preload out of process). import { execFileSync } from 'child_process'; -import { existsSync, mkdtempSync, rmSync } from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { App, Stack, Stage } from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; +import { + App, + Aspects, + BOOTSTRAP_QUALIFIER_CONTEXT, + CfnResource, + DefaultStackSynthesizer, + IAspect, + Stack, + Stage, +} from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import * as logs from 'aws-cdk-lib/aws-logs'; import * as s3 from 'aws-cdk-lib/aws-s3'; +import { IConstruct } from 'constructs'; +import { AppConfig } from '../../src/appconfig/accessor'; import { defineCICD } from '../../src/config/define'; import { Repository } from '../../src/config/repository'; -import { EngineType } from '../../src/config/types'; +import { EngineType, ResolvedCicdConfig, SynthesizerType } from '../../src/config/types'; import { CdkPipelinesStageContext, IStageProvider } from '../../src/engine/cdkpipelines/CdkPipelinesEngine'; import { GitHubActionsEngine } from '../../src/engine/github/GitHubActionsEngine'; -import { buildPipelineApp } from '../../src/runtime/pipeline-assembler'; +import { buildPipelineApp, replayForcedRoleEnv } from '../../src/runtime/pipeline-assembler'; +import { registerPlugin } from '../../src/runtime/plugins'; function config() { return defineCICD({ @@ -71,6 +84,218 @@ describe('CDK Pipelines assembler: pipeline structure (stub provider)', () => { expect(categories('prod')).toContain('Approval'); expect(categories('dev')).not.toContain('Approval'); }); + + test('applies the full default wrapper aspect set to the self-mutating app', () => { + const app = buildPipelineApp(config(), new StubProvider()); + const aspectNames = Aspects.of(app).all.map((aspect) => { + const delegated = (aspect as { delegate?: IAspect }).delegate; + return (delegated ?? aspect).constructor.name; + }); + expect(aspectNames).toEqual( + expect.arrayContaining([ + 'AwsSolutionsChecks', + 'LogRetentionAspect', + 'EncryptBucketOnTransitAspect', + 'EncryptSNSTopicOnTransitAspect', + 'RotateEncryptionKeysAspect', + 'DisablePublicIPAssignmentForEC2Aspect', + ]), + ); + }); + + test('root pipeline aspects do not revisit resources in an independently wrapped application stage', () => { + const counter = new (class implements IAspect { + public bucketVisits = 0; + + public visit(node: IConstruct): void { + if (CfnResource.isCfnResource(node) && node.cfnResourceType === 'AWS::S3::Bucket') { + this.bucketVisits += 1; + } + } + })(); + let applicationStack: Stack | undefined; + const provider: IStageProvider = { + stacks(stage: Stage, context: CdkPipelinesStageContext): void { + registerPlugin(stage, { + ref: { name: 'CountBuckets', version: '1' }, + aspect: counter, + }); + applicationStack = new Stack(stage, 'shop-app', { env: context.env }); + new s3.Bucket(applicationStack, 'Data'); + }, + }; + buildPipelineApp( + defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + plugins: [{ name: 'CountBuckets', version: '1' }], + }), + provider, + ); + + Template.fromStack(applicationStack!); + expect(counter.bucketVisits).toBe(1); + }); + + test('honours plugins: [] for the self-mutating app', () => { + const app = buildPipelineApp( + defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + plugins: [], + }), + new StubProvider(), + ); + expect(Aspects.of(app).all).toEqual([]); + }); + + test('loads per-stage application config before replay and applies its tags/plugin settings to that stage', () => { + const originalCwd = process.cwd(); + const cwd = mkdtempSync(path.join(os.tmpdir(), 'cdk-cicd-stage-config-')); + mkdirSync(path.join(cwd, 'config')); + writeFileSync( + path.join(cwd, 'config', 'dev.json'), + JSON.stringify({ tags: { StageConfig: 'dev' }, logRetentionInDays: 14 }), + ); + + let appConfig: Record | undefined; + let applicationStack: Stack | undefined; + const provider: IStageProvider = { + stacks(stage: Stage, context: CdkPipelinesStageContext): void { + appConfig = AppConfig.of(stage) as Record; + applicationStack = new Stack(stage, 'shop-app', { env: context.env }); + new s3.Bucket(applicationStack, 'Data'); + new logs.CfnLogGroup(applicationStack, 'Logs'); + }, + }; + + try { + process.chdir(cwd); + buildPipelineApp( + defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + }), + provider, + ); + + expect(appConfig).toMatchObject({ tags: { StageConfig: 'dev' }, logRetentionInDays: 14 }); + const template = Template.fromStack(applicationStack!); + template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 14 }); + template.hasResourceProperties('AWS::S3::Bucket', { + Tags: Match.arrayWith([{ Key: 'StageConfig', Value: 'dev' }]), + }); + } finally { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('rejects APP_STAGING for its cross-Stage support-stack dependency, not its qualifier', () => { + const resolved = defineCICD({ + application: 'shop', + qualifier: 'customq', + repository: Repository.codecommit('shop'), + stages: ['dev'], + synthesizer: { type: SynthesizerType.APP_STAGING }, + }); + expect(() => buildPipelineApp({ ...resolved, engine: EngineType.CDK_PIPELINES }, new StubProvider())).toThrow( + /DefaultStagingStack.*cannot depend across that Stage boundary.*not a bootstrap-qualifier limitation/, + ); + }); + + test('the engine-owned pipeline stack follows CDK bootstrap-qualifier context, not the application qualifier', () => { + const previousContext = process.env.CDK_CONTEXT_JSON; + process.env.CDK_CONTEXT_JSON = JSON.stringify({ [BOOTSTRAP_QUALIFIER_CONTEXT]: 'ctxqual' }); + try { + const app = buildPipelineApp( + defineCICD({ + application: 'shop', + qualifier: 'appqual', + repository: Repository.codecommit('shop'), + stages: ['dev'], + }), + new StubProvider(), + ); + const stack = app.node.findChild('shop-pipeline') as Stack; + expect((stack.synthesizer as DefaultStackSynthesizer).bootstrapQualifier).toBe('ctxqual'); + } finally { + if (previousContext === undefined) { + delete process.env.CDK_CONTEXT_JSON; + } else { + process.env.CDK_CONTEXT_JSON = previousContext; + } + } + }); + + test('treats an omitted synthesizer in a legacy resolved config as DEFAULT', () => { + const resolved = defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: ['dev'], + }); + const legacy = { ...resolved } as Partial; + Reflect.deleteProperty(legacy, 'synthesizer'); + + const app = buildPipelineApp(legacy as ResolvedCicdConfig, new StubProvider()); + const stack = app.node.findChild('shop-pipeline') as Stack; + expect((stack.synthesizer as DefaultStackSynthesizer).bootstrapQualifier).toBe( + DefaultStackSynthesizer.DEFAULT_QUALIFIER, + ); + }); +}); + +describe('self-mutating assembler: stage forced-role contract', () => { + test('exports deploy and CFN execution role values for replay synthesis', () => { + const cicd = defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: [ + { + name: 'prod', + deployment: { + deployRole: 'arn:aws:iam::222222222222:role/ForcedDeploy', + cfnExecutionRole: 'arn:aws:iam::222222222222:role/ForcedCfn', + }, + }, + ], + }); + expect(replayForcedRoleEnv(cicd, 'prod')).toEqual({ + CDK_CICD_DEPLOY_ROLE_ARN: 'arn:aws:iam::222222222222:role/ForcedDeploy', + CDK_CICD_CFN_EXEC_ROLE_ARN: 'arn:aws:iam::222222222222:role/ForcedCfn', + }); + }); + + test('rejects a pipeline ExternalId fallback when a deployRole is configured', () => { + const cicd = defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + deployRoleExternalId: 'pipeline-external', + stages: [ + { name: 'dev', deployment: { deployRole: 'arn:dev' } }, + { name: 'qa', deployment: { cfnExecutionRole: 'arn:cfn' } }, + ], + }); + expect(() => replayForcedRoleEnv(cicd, 'dev')).toThrow(/cannot honor the deploy-role ExternalId.*dev/); + expect(replayForcedRoleEnv(cicd, 'qa')).toEqual({ CDK_CICD_CFN_EXEC_ROLE_ARN: 'arn:cfn' }); + }); + + test('rejects a stage-level ExternalId, including a Secrets Manager reference', () => { + const cicd = defineCICD({ + application: 'shop', + repository: Repository.codecommit('shop'), + stages: [ + { + name: 'prod', + deployment: { deployRole: 'arn:prod', externalId: 'resolve:secretsmanager:prod-external' }, + }, + ], + }); + expect(() => replayForcedRoleEnv(cicd, 'prod')).toThrow(/cannot honor the deploy-role ExternalId.*prod/); + }); }); describe('CDK Pipelines assembler: pipelineStackName override', () => { @@ -205,6 +430,8 @@ describe('CDK Pipelines assembler: real per-stage replay (subprocess)', () => { buckets: number; bucketIds: string[]; account?: string; + assumeRoleArn?: string; + cfnRoleArn?: string; }>; const byStage = Object.fromEntries(result.map((r) => [r.stage, r])); // Each replayed stage got exactly the plain bin's one bucket. @@ -214,6 +441,8 @@ describe('CDK Pipelines assembler: real per-stage replay (subprocess)', () => { // ambient hub account (111…) -- so the replay set CDK_DEFAULT_ACCOUNT per stage, not once. expect(byStage.dev.account).toBe('111111111111'); expect(byStage.prod.account).toBe('222222222222'); + expect(byStage.prod.assumeRoleArn).toBe('arn:aws:iam::222222222222:role/ForcedDeploy'); + expect(byStage.prod.cfnRoleArn).toBe('arn:aws:iam::222222222222:role/ForcedCfn'); // CDK_STAGE was pinned per stage: the bucket logical id (Data-${CDK_STAGE}) differs across stages. expect(byStage.dev.bucketIds[0]).not.toEqual(byStage.prod.bucketIds[0]); }); @@ -264,6 +493,26 @@ describe('CDK Pipelines assembler: ReplayApp inherits App statics (App.of)', () expect(new (Fixed as unknown as new () => object)()).toBe(stage); }); + test('Stack.isStack recognizes a stack created by a distinct aws-cdk-lib copy', () => { + // Jest's moduleNameMapper intentionally aliases every aws-cdk-lib import to one copy, so exercise + // the real Node resolution boundary in a subprocess: monorepo-root CDK versus package-local CDK. + const rootCdk = path.resolve(__dirname, '../../../../../node_modules/aws-cdk-lib'); + const packageCdk = path.resolve(__dirname, '../../node_modules/aws-cdk-lib'); + const script = [ + `const root = require(${JSON.stringify(rootCdk)});`, + `const other = require(${JSON.stringify(packageCdk)});`, + "const stack = new other.Stack(new other.App(), 'OtherCopy');", + 'process.stdout.write(JSON.stringify({ instanceOfRoot: stack instanceof root.Stack, isStack: root.Stack.isStack(stack) }));', + ].join('\n'); + const result = JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf-8' })) as { + instanceOfRoot: boolean; + isStack: boolean; + }; + + expect(result.instanceOfRoot).toBe(false); + expect(result.isStack).toBe(true); + }); + // Pin the supported range explicitly. The App-export injection hook is documented (inject.ts) as // verified from aws-cdk-lib 2.195.0 upward -- that is the wrapper's declared peer floor // (`^2.195.0`), so this fix widens NOTHING below it; it repairs a crash WITHIN the range. This guard diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/register.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/register.test.ts index e226b151..144f3d6b 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/register.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/register.test.ts @@ -6,14 +6,26 @@ // contained to this file. The pure-guard tests deliberately import from inject.ts (no // side effect) rather than register.ts. -import { App, Aspects, DefaultStackSynthesizer, IReusableStackSynthesizer, Stack } from 'aws-cdk-lib'; +import { + App, + Aspects, + BOOTSTRAP_QUALIFIER_CONTEXT, + DefaultStackSynthesizer, + IReusableStackSynthesizer, + Stack, +} from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as ssm from 'aws-cdk-lib/aws-ssm'; import { AwsSolutionsChecks } from 'cdk-nag'; import { AppConfig } from '../../src/appconfig'; import * as inject from '../../src/runtime/inject'; -import { appsConstructed, assertAppModuleLayout } from '../../src/runtime/inject'; +import { + appsConstructed, + assertAppModuleLayout, + readInjectedConfig, + WRAPPER_CONFIG_CONTEXT_KEY, +} from '../../src/runtime/inject'; import { DEFAULT_LOG_RETENTION_DAYS } from '../../src/support/LogRetentionAspect'; // Side-effecting import: patches App. Must come after the other imports so the assertions // below observe the patched module. @@ -69,6 +81,38 @@ describe('m2-register: the App patch', () => { } }); + test('a valid CDK bootstrap-qualifier context controls the wrapper-owned synthesizer', () => { + const app = new App({ context: { [BOOTSTRAP_QUALIFIER_CONTEXT]: 'context_1' } }); + const stack = new Stack(app, 'ContextQualifierStack'); + + expect(app.synth().getStackArtifact(stack.artifactId).assumeRoleArn).toContain('cdk-context_1-deploy-role-'); + }); + + test.each([' context1 ', '', 'invalid!', '12345678901', 123])( + 'rejects invalid CDK bootstrap-qualifier context before user stacks are constructed: %j', + (qualifier) => { + expect(() => new App({ context: { [BOOTSTRAP_QUALIFIER_CONTEXT]: qualifier } })).toThrow( + new RegExp(`context '${BOOTSTRAP_QUALIFIER_CONTEXT}'.*\\[A-Za-z0-9_-\\]\\{1,10\\}`), + ); + }, + ); + + test('validates bootstrap-qualifier context loaded through CDK_CONTEXT_JSON', () => { + const previous = process.env.CDK_CONTEXT_JSON; + process.env.CDK_CONTEXT_JSON = JSON.stringify({ [BOOTSTRAP_QUALIFIER_CONTEXT]: ' invalid ' }); + try { + expect(() => new App()).toThrow( + new RegExp(`context '${BOOTSTRAP_QUALIFIER_CONTEXT}'.*\\[A-Za-z0-9_-\\]\\{1,10\\}`), + ); + } finally { + if (previous === undefined) { + delete process.env.CDK_CONTEXT_JSON; + } else { + process.env.CDK_CONTEXT_JSON = previous; + } + } + }); + test('the resolver is NOT consulted when the user supplies a synthesizer', () => { // The `?? resolveSynthesizer` short-circuit must leave a user choice untouched -- proven here // by the resolver never being called, complementing the qualifier-survival test below. @@ -109,6 +153,43 @@ describe('m2-register: the App patch', () => { } }); + test('wrapper config is separate from the stage application config', () => { + const appConfig = { qualifier: 'business-value', feature: 'checkout' }; + const app = new App({ + context: { + [AppConfig.CONTEXT_KEY]: appConfig, + [WRAPPER_CONFIG_CONTEXT_KEY]: { qualifier: 'runtime01', plugins: [] }, + }, + }); + const stack = new Stack(app, 'SeparatedConfigStack'); + + expect(AppConfig.of(stack)).toEqual(appConfig); + expect(hasNagAspect(app)).toBe(false); + const artifact = app.synth().getStackArtifact(stack.artifactId); + expect(artifact.assumeRoleArn).toContain('runtime01'); + expect(artifact.assumeRoleArn).not.toContain('business-value'); + }); + + test('readInjectedConfig strips wrapper-owned fields from app config when wrapper context is present', () => { + expect( + readInjectedConfig({ + context: { + [AppConfig.CONTEXT_KEY]: { + tags: { Owner: 'platform' }, + plugins: [{ name: 'application-data', version: '9' }], + qualifier: 'application-data', + }, + [WRAPPER_CONFIG_CONTEXT_KEY]: { plugins: [], qualifier: 'runtime01', synthesizer: { type: 'default' } }, + }, + }), + ).toEqual({ + tags: { Owner: 'platform' }, + plugins: [], + qualifier: 'runtime01', + synthesizer: { type: 'default' }, + }); + }); + test('a wrapped App forces the default log retention with no injected config', () => { const stack = new Stack(new App(), 'DefaultRetentionStack'); new logs.CfnLogGroup(stack, 'Logs'); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/synthesizer.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/synthesizer.test.ts index 48380608..9097ec0e 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/synthesizer.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/runtime/synthesizer.test.ts @@ -6,6 +6,8 @@ // the synthesized stack's roles, read from the environment (never from cicd.config). import { App, Stack } from 'aws-cdk-lib'; +import { resolveDefaultSynthesizerQualifier } from '../../src/config/default-synthesizer-role-arn'; +import { SynthesizerType } from '../../src/config/types'; import { CFN_EXEC_ROLE_FLAG, DEPLOY_ROLE_EXTERNAL_ID_FLAG, @@ -17,10 +19,16 @@ const DEPLOY_ARN = 'arn:aws:iam::111111111111:role/ForcedDeploy'; const CFN_ARN = 'arn:aws:iam::111111111111:role/ForcedCfnExec'; /** Synthesize a stack whose synthesizer is resolveSynthesizer() under the given role env, return its roles. */ -function synthWithRoleEnv(env: { deploy?: string; cfn?: string; externalId?: string }): { +function synthWithRoleEnv( + env: { deploy?: string; cfn?: string; externalId?: string }, + config: Record = {}, +): { assumeRoleArn?: string; cfnRoleArn?: string; assumeRoleExternalId?: string; + supportAssumeRoleArn?: string; + supportCfnRoleArn?: string; + stackNames: string[]; } { const prev = { d: process.env[DEPLOY_ROLE_FLAG], @@ -35,14 +43,19 @@ function synthWithRoleEnv(env: { deploy?: string; cfn?: string; externalId?: str try { const app = new App(); const stack = new Stack(app, 'S', { - synthesizer: resolveSynthesizer({}), + synthesizer: resolveSynthesizer(config), env: { account: '111111111111', region: 'us-west-2' }, }); - const artifact = app.synth().getStackArtifact(stack.artifactId); + const assembly = app.synth(); + const artifact = assembly.getStackArtifact(stack.artifactId); + const supportArtifact = assembly.stacks.find((candidate) => candidate.stackName.startsWith('StagingStack-')); return { assumeRoleArn: artifact.assumeRoleArn, cfnRoleArn: artifact.cloudFormationExecutionRoleArn, assumeRoleExternalId: artifact.assumeRoleExternalId, + supportAssumeRoleArn: supportArtifact?.assumeRoleArn, + supportCfnRoleArn: supportArtifact?.cloudFormationExecutionRoleArn, + stackNames: assembly.stacks.map((candidate) => candidate.stackName), }; } finally { set(DEPLOY_ROLE_FLAG, prev.d); @@ -80,4 +93,104 @@ describe('m3-forced-roles: resolveSynthesizer', () => { expect(assumeRoleArn).toBe(DEPLOY_ARN); expect(cfnRoleArn).toBe(CFN_ARN); }); + + test('the configured qualifier controls the default bootstrap role names', () => { + const configuredQualifier = ' shop123 '; + const expectedQualifier = resolveDefaultSynthesizerQualifier(new App(), configuredQualifier); + const { assumeRoleArn, cfnRoleArn } = synthWithRoleEnv({}, { qualifier: configuredQualifier }); + expect(assumeRoleArn).toContain(`cdk-${expectedQualifier}-deploy-role-`); + expect(cfnRoleArn).toContain(`cdk-${expectedQualifier}-cfn-exec-role-`); + }); + + test.each(['', ' ', 'invalid qualifier', 'invalid!', '12345678901'])( + 'rejects an invalid qualifier in manually constructed resolved config %j', + (qualifier) => { + expect(() => resolveSynthesizer({ qualifier })).toThrow(/explicit bootstrap qualifier.*\[A-Za-z0-9_-\]\{1,10\}/); + }, + ); + + test('APP_STAGING uses the alpha default qualifier when none is configured', () => { + const result = synthWithRoleEnv( + {}, + { + application: 'payments-platform', + synthesizer: { type: SynthesizerType.APP_STAGING }, + }, + ); + expect(result.assumeRoleArn).toContain('cdk-hnb659fds-deploy-role-'); + expect(result.cfnRoleArn).toContain('cdk-hnb659fds-cfn-exec-role-'); + expect(result.stackNames).toContain('StagingStack-payments-platform'); + }); + + test('APP_STAGING accepts an explicit appId override', () => { + const result = synthWithRoleEnv( + {}, + { + application: 'payments-platform', + synthesizer: { type: SynthesizerType.APP_STAGING, appId: 'payments-v2' }, + }, + ); + expect(result.stackNames).toContain('StagingStack-payments-v2'); + }); + + test.each([ + { name: 'deploy role', env: { deploy: DEPLOY_ARN } }, + { name: 'CloudFormation execution role', env: { cfn: CFN_ARN } }, + { name: 'both roles', env: { deploy: DEPLOY_ARN, cfn: CFN_ARN } }, + ] as Array<{ name: string; env: { deploy?: string; cfn?: string } }>)( + 'APP_STAGING threads a forced $name into application deployment identities', + ({ env }) => { + const result = synthWithRoleEnv(env, { + application: 'payments', + synthesizer: { type: SynthesizerType.APP_STAGING }, + }); + + if (env.deploy !== undefined) expect(result.assumeRoleArn).toBe(DEPLOY_ARN); + else expect(result.assumeRoleArn).toContain('cdk-hnb659fds-deploy-role-'); + if (env.cfn !== undefined) expect(result.cfnRoleArn).toBe(CFN_ARN); + else expect(result.cfnRoleArn).toContain('cdk-hnb659fds-cfn-exec-role-'); + expect(result.supportAssumeRoleArn).toContain('cdk-hnb659fds-deploy-role-'); + expect(result.supportCfnRoleArn).toContain('cdk-hnb659fds-cfn-exec-role-'); + expect(result.supportAssumeRoleArn).not.toBe(DEPLOY_ARN); + expect(result.supportCfnRoleArn).not.toBe(CFN_ARN); + }, + ); + + test('APP_STAGING fails fast when no application identity is available', () => { + expect(() => resolveSynthesizer({ synthesizer: { type: SynthesizerType.APP_STAGING } })).toThrow( + /requires an application-unique id/, + ); + }); + + test('APP_STAGING applies a custom bootstrap qualifier to its deployment identities', () => { + const result = synthWithRoleEnv( + {}, + { + application: 'payments', + qualifier: 'shop123', + synthesizer: { type: SynthesizerType.APP_STAGING }, + }, + ); + expect(result.assumeRoleArn).toContain('cdk-shop123-deploy-role-'); + expect(result.cfnRoleArn).toContain('cdk-shop123-cfn-exec-role-'); + expect(result.stackNames).toContain('StagingStack-payments'); + }); + + test('APP_STAGING fails fast for a forced deploy-role ExternalId', () => { + expect(() => + synthWithRoleEnv( + { deploy: DEPLOY_ARN, externalId: 'external-123' }, + { + application: 'payments', + synthesizer: { type: SynthesizerType.APP_STAGING }, + }, + ), + ).toThrow(/cannot use a forced deploy-role ExternalId/); + }); + + test('an unknown synthesizer type fails explicitly', () => { + expect(() => resolveSynthesizer({ synthesizer: { type: 'future' } })).toThrow( + /unsupported synthesizer type 'future'/, + ); + }); }); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/support/AccessLogsForBucketAspect.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/support/AccessLogsForBucketAspect.test.ts index ee540c44..f4838deb 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/support/AccessLogsForBucketAspect.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/support/AccessLogsForBucketAspect.test.ts @@ -3,19 +3,27 @@ import { App, Aspects, Stack } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; +import { PolicyStatement, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { AccessLogsForBucketAspect } from '../../src/support/AccessLogsForBucketAspect'; -function stack(region = 'us-west-2'): Stack { - return new Stack(new App(), 'BucketStack', { env: { region } }); +function stack(region = 'us-west-2', account = '111111111111'): Stack { + return new Stack(new App(), 'BucketStack', { env: { account, region } }); +} + +function aspect(destinationBucket?: s3.IBucket): AccessLogsForBucketAspect { + return new AccessLogsForBucketAspect({ + complianceLogBucketName: 'compliance-bucket', + complianceLogBucketAccount: '111111111111', + complianceLogBucketRegion: 'us-west-2', + complianceLogBucket: destinationBucket, + }); } describe('m9-migrate-security-plugins: AccessLogsForBucketAspect', () => { test('configures logging to the compliance bucket on a bucket with none set', () => { const s = stack(); - Aspects.of(s).add( - new AccessLogsForBucketAspect({ complianceLogBucketName: 'compliance-bucket', mainRegion: 'us-west-2' }), - ); + Aspects.of(s).add(aspect()); new s3.Bucket(s, 'Bucket'); Template.fromStack(s).hasResourceProperties('AWS::S3::Bucket', { @@ -25,28 +33,34 @@ describe('m9-migrate-security-plugins: AccessLogsForBucketAspect', () => { }); }); - test('rewrites the bucket name for a stack deployed to a different region', () => { + test('fails closed for a source bucket in a different region', () => { const s = stack('us-west-1'); - Aspects.of(s).add( - new AccessLogsForBucketAspect({ - complianceLogBucketName: 'compliance-bucket-us-west-2', - mainRegion: 'us-west-2', - }), - ); + Aspects.of(s).add(aspect()); new s3.Bucket(s, 'Bucket'); - Template.fromStack(s).hasResourceProperties('AWS::S3::Bucket', { - LoggingConfiguration: { - DestinationBucketName: 'compliance-bucket-us-west-1', - }, - }); + expect(() => Template.fromStack(s)).toThrow(/same account and region/); + }); + + test('fails closed for a source bucket in a different account', () => { + const s = stack('us-west-2', '222222222222'); + Aspects.of(s).add(aspect()); + new s3.Bucket(s, 'Bucket'); + + expect(() => Template.fromStack(s)).toThrow(/same account and region/); + }); + + test('never configures the compliance destination bucket to log to itself', () => { + const s = stack(); + Aspects.of(s).add(aspect()); + new s3.Bucket(s, 'Compliance', { bucketName: 'compliance-bucket' }); + + const bucket = Object.values(Template.fromStack(s).findResources('AWS::S3::Bucket'))[0]; + expect(bucket.Properties.LoggingConfiguration).toBeUndefined(); }); test('preserves an already-set log file prefix but still redirects the destination bucket', () => { const s = stack(); - Aspects.of(s).add( - new AccessLogsForBucketAspect({ complianceLogBucketName: 'compliance-bucket', mainRegion: 'us-west-2' }), - ); + Aspects.of(s).add(aspect()); const bucket = new s3.CfnBucket(s, 'Bucket', { loggingConfiguration: { destinationBucketName: 'some-other-bucket', logFilePrefix: 'my-prefix/' }, }); @@ -60,29 +74,76 @@ describe('m9-migrate-security-plugins: AccessLogsForBucketAspect', () => { expect(bucket).toBeDefined(); }); - test('leaves a bucket that sets a logging destination but no prefix untouched', () => { + test('redirects an existing logging destination without a prefix to the compliance bucket', () => { const s = stack(); - Aspects.of(s).add( - new AccessLogsForBucketAspect({ complianceLogBucketName: 'compliance-bucket', mainRegion: 'us-west-2' }), - ); + Aspects.of(s).add(aspect()); new s3.CfnBucket(s, 'Bucket', { loggingConfiguration: { destinationBucketName: 'some-other-bucket' }, }); - Template.fromStack(s).hasResourceProperties('AWS::S3::Bucket', { - LoggingConfiguration: { - DestinationBucketName: 'some-other-bucket', - }, + const bucket = Object.values(Template.fromStack(s).findResources('AWS::S3::Bucket'))[0]; + expect(bucket.Properties.LoggingConfiguration).toEqual({ + DestinationBucketName: 'compliance-bucket', + LogFilePrefix: expect.any(String), }); }); test('ignores non-bucket constructs', () => { const s = stack(); - expect(() => - Aspects.of(s).add( - new AccessLogsForBucketAspect({ complianceLogBucketName: 'compliance-bucket', mainRegion: 'us-west-2' }), - ), - ).not.toThrow(); + expect(() => Aspects.of(s).add(aspect())).not.toThrow(); Template.fromStack(s).resourceCountIs('AWS::S3::Bucket', 0); }); + + test('adds same-stack dependencies on the concrete destination bucket and policy', () => { + const s = stack(); + const destination = new s3.Bucket(s, 'Compliance', { bucketName: 'compliance-bucket' }); + destination.addToResourcePolicy( + new PolicyStatement({ + actions: ['s3:PutObject'], + resources: [destination.arnForObjects('*')], + principals: [new ServicePrincipal('logging.s3.amazonaws.com')], + }), + ); + Aspects.of(s).add(aspect(destination)); + new s3.CfnBucket(s, 'Source', { + loggingConfiguration: { destinationBucketName: 'some-other-bucket' }, + }); + + const resources = Template.fromStack(s).findResources('AWS::S3::Bucket'); + const source = Object.entries(resources).find(([, resource]) => resource.Properties.BucketName === undefined); + const compliance = Object.entries(resources).find( + ([, resource]) => resource.Properties.BucketName === 'compliance-bucket', + ); + expect(source?.[1].DependsOn).toEqual( + expect.arrayContaining([expect.stringMatching(/Compliance/), expect.stringMatching(/Policy/)]), + ); + expect(source?.[1].Properties.LoggingConfiguration.DestinationBucketName).toBe('compliance-bucket'); + expect(compliance?.[1].DependsOn).toBeUndefined(); + }); + + test('configures a CfnBucket built from a second independently loaded aws-cdk-lib copy', () => { + let otherBucket!: s3.CfnBucket; + let otherTemplate!: Template; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const otherCdk = require('aws-cdk-lib'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const otherS3 = require('aws-cdk-lib/aws-s3'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const otherAssertions = require('aws-cdk-lib/assertions'); + const otherStack = new otherCdk.Stack(new otherCdk.App(), 'OtherCopyStack', { + env: { account: '111111111111', region: 'us-west-2' }, + }); + otherBucket = new otherS3.CfnBucket(otherStack, 'Bucket'); + aspect().visit(otherBucket); + otherTemplate = otherAssertions.Template.fromStack(otherStack); + }); + + expect(otherBucket instanceof s3.CfnBucket).toBe(false); + otherTemplate.hasResourceProperties('AWS::S3::Bucket', { + LoggingConfiguration: { + DestinationBucketName: 'compliance-bucket', + }, + }); + }); }); diff --git a/packages/@cdklabs/cdk-cicd-wrapper/test/support/SupportResources.test.ts b/packages/@cdklabs/cdk-cicd-wrapper/test/support/SupportResources.test.ts index 798383d1..0fcdd3dc 100644 --- a/packages/@cdklabs/cdk-cicd-wrapper/test/support/SupportResources.test.ts +++ b/packages/@cdklabs/cdk-cicd-wrapper/test/support/SupportResources.test.ts @@ -1,8 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; -import { Match, Template } from 'aws-cdk-lib/assertions'; +import { App, Aspects, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; +import { AwsSolutionsChecks } from 'cdk-nag'; import { SupportResources } from '../../src/support/SupportResources'; function stack(): Stack { @@ -140,6 +141,15 @@ describe('m4-support-resources: SupportResources', () => { t.resourceCountIs('AWS::S3::Bucket', 1); t.hasResourceProperties('AWS::S3::Bucket', { BucketName: 'my-compliance-bucket', + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { + SSEAlgorithm: 'AES256', + }, + }, + ], + }, PublicAccessBlockConfiguration: { BlockPublicAcls: true, BlockPublicPolicy: true, @@ -149,6 +159,16 @@ describe('m4-support-resources: SupportResources', () => { }); }); + test('suppresses recursive server-access logging on the dedicated destination bucket', () => { + const s = stack(); + Aspects.of(s).add(new AwsSolutionsChecks()); + const support = new SupportResources(s, 'Support', { complianceLogBucketName: 'my-compliance-bucket' }); + expect(support.complianceLogBucket).toBeDefined(); + + Template.fromStack(s); + expect(Annotations.fromStack(s).findError('*', Match.stringLikeRegexp('AwsSolutions-S1'))).toHaveLength(0); + }); + test('grants the S3 log-delivery service principal write access', () => { const s = stack(); const support = new SupportResources(s, 'Support', { complianceLogBucketName: 'my-compliance-bucket' }); @@ -162,12 +182,81 @@ describe('m4-support-resources: SupportResources', () => { Effect: 'Allow', Principal: { Service: 'logging.s3.amazonaws.com' }, Action: 's3:PutObject', + Condition: { + StringEquals: { + 'aws:SourceAccount': '111111111111', + }, + ArnLike: { + 'aws:SourceArn': Match.anyValue(), + }, + }, }), ]), }), }); }); + test('RETAIN applies coherently to the managed bucket and its generated policy', () => { + const s = stack(); + const support = new SupportResources(s, 'Support', { complianceLogBucketName: 'my-compliance-bucket' }); + expect(support.complianceLogBucket).toBeDefined(); + + const t = Template.fromStack(s); + t.hasResource('AWS::S3::Bucket', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + t.hasResource('AWS::S3::BucketPolicy', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + }); + + test('DESTROY applies coherently to the managed bucket and its generated policy', () => { + const s = stack(); + const support = new SupportResources(s, 'Support', { + complianceLogBucketName: 'my-compliance-bucket', + removalPolicy: RemovalPolicy.DESTROY, + }); + expect(support.complianceLogBucket).toBeDefined(); + + const t = Template.fromStack(s); + t.hasResource('AWS::S3::Bucket', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + t.hasResource('AWS::S3::BucketPolicy', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + t.resourceCountIs('Custom::S3AutoDeleteObjects', 1); + }); + + test('existing-bucket mode imports by name without synthesizing a bucket or policy', () => { + const s = stack(); + const support = new SupportResources(s, 'Support', { + complianceLogBucketName: 'blueprint-compliance-bucket', + createComplianceLogBucket: false, + }); + + expect(support.complianceLogBucket.bucketName).toBe('blueprint-compliance-bucket'); + const t = Template.fromStack(s); + t.resourceCountIs('AWS::S3::Bucket', 0); + t.resourceCountIs('AWS::S3::BucketPolicy', 0); + t.resourceCountIs('Custom::S3AutoDeleteObjects', 0); + }); + + test('existing-bucket mode rejects DESTROY because the owner controls its lifecycle', () => { + const s = stack(); + const support = new SupportResources(s, 'Support', { + complianceLogBucketName: 'blueprint-compliance-bucket', + createComplianceLogBucket: false, + removalPolicy: RemovalPolicy.DESTROY, + }); + + expect(() => support.complianceLogBucket).toThrow(/cannot be combined with RemovalPolicy\.DESTROY/); + }); + test('denies non-TLS access -- the TLS half of the 0b7ae02 fix', () => { const s = stack(); const support = new SupportResources(s, 'Support', { complianceLogBucketName: 'my-compliance-bucket' }); @@ -186,29 +275,16 @@ describe('m4-support-resources: SupportResources', () => { }); }); - test( - 'denies PutObject with no encryption header at all -- the SSE correctness the 0b7ae02 fix made ' + - '(a Bool condition on a header that is absent from the request context never matches, so it must ' + - 'use Null instead)', - () => { - const s = stack(); - const support = new SupportResources(s, 'Support', { complianceLogBucketName: 'my-compliance-bucket' }); - expect(support.complianceLogBucket).toBeDefined(); - - Template.fromStack(s).hasResourceProperties('AWS::S3::BucketPolicy', { - PolicyDocument: Match.objectLike({ - Statement: Match.arrayWith([ - Match.objectLike({ - Sid: 'EnforceEncryptionAtRest', - Effect: 'Deny', - Action: 's3:PutObject', - Condition: { Null: { 's3:x-amz-server-side-encryption': 'true' } }, - }), - ]), - }), - }); - }, - ); + test('does not require an SSE request header that would block S3 log delivery', () => { + const s = stack(); + const support = new SupportResources(s, 'Support', { complianceLogBucketName: 'my-compliance-bucket' }); + expect(support.complianceLogBucket).toBeDefined(); + + const policies = Object.values(Template.fromStack(s).findResources('AWS::S3::BucketPolicy')) as any[]; + const statements = policies.flatMap((policy) => policy.Properties.PolicyDocument.Statement); + expect(statements.some((statement) => statement.Sid === 'EnforceEncryptionAtRest')).toBe(false); + expect(JSON.stringify(statements)).not.toContain('s3:x-amz-server-side-encryption'); + }); test('repeated reads return the same bucket rather than a second one', () => { const s = stack(); diff --git a/projenrc/CLIConfig.ts b/projenrc/CLIConfig.ts index 662210a6..ddb82d6c 100644 --- a/projenrc/CLIConfig.ts +++ b/projenrc/CLIConfig.ts @@ -31,6 +31,12 @@ export class CLIConfig extends yarn.TypeScriptWorkspace { 'csv', '@aws-sdk/client-s3', '@aws-sdk/credential-providers', + // Deployment-plan/drift traversal consumes cloud assemblies as an external protocol. Validate + // every on-disk manifest against the installed schema instead of partially interpreting JSON. + // Match the schema range consumed by the repository's CDK CLI. Older manifests remain + // backwards-compatible, while assemblies emitted at schema 42-48 are no longer rejected by + // our pre-deploy parser even though the installed `cdk deploy` supports them. + '@aws-cdk/cloud-assembly-schema@^48.12.0', 'tslog', // Autopilot `cdk-cicd exec` resolves the register preload and reuses the config loader from the // constructs package. Kept a workspace dependency, NOT folded into the jsii package (D5). diff --git a/projenrc/PipelineConfig.ts b/projenrc/PipelineConfig.ts index eab18f55..8c56f58a 100644 --- a/projenrc/PipelineConfig.ts +++ b/projenrc/PipelineConfig.ts @@ -41,7 +41,14 @@ export class PipelineConfig extends yarn.TypeScriptWorkspace { '@cloudcomponents/cdk-pull-request-check', 'yaml', ], - deps: ['@cloudcomponents/cdk-pull-request-approval-rule', '@cloudcomponents/cdk-pull-request-check', 'yaml'], + deps: [ + // SynthesizerType.APP_STAGING is a runtime option, so ship the alpha module whose version + // exactly matches aws-cdk-lib. Alpha CDK modules must not float independently of the core. + `@aws-cdk/app-staging-synthesizer-alpha@${root.cdkVersion}-alpha.0`, + '@cloudcomponents/cdk-pull-request-approval-rule', + '@cloudcomponents/cdk-pull-request-check', + 'yaml', + ], jestOptions: { jestConfig: { // Force a SINGLE aws-cdk-lib copy in tests. This package bundles deps, which nests its own @@ -135,7 +142,10 @@ export class PipelineConfig extends yarn.TypeScriptWorkspace { // `cdk-pipelines-github` to the monorepo root, so copy them local FIRST -- prepended so it runs // before the docgen step, otherwise docgen fails with `Unable to locate assembly for dependency`. postCompile.prependExec( - 'for DEP in cdk-nag cdk-pipelines-github; do cp -rf ../../../node_modules/$DEP ./node_modules/ 2>/dev/null; done;', + 'for DEP in cdk-nag cdk-pipelines-github; do cp -rf ../../../node_modules/$DEP ./node_modules/ 2>/dev/null; done; ' + + 'if [ -d ../../../node_modules/@aws-cdk/app-staging-synthesizer-alpha ]; then ' + + 'mkdir -p ./node_modules/@aws-cdk; ' + + 'cp -rf ../../../node_modules/@aws-cdk/app-staging-synthesizer-alpha ./node_modules/@aws-cdk/; fi;', ); } } diff --git a/yarn.lock b/yarn.lock index d4053705..115ccc71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,6 +10,11 @@ ansi-styles "^6.2.1" is-fullwidth-code-point "^5.0.0" +"@aws-cdk/app-staging-synthesizer-alpha@2.195.0-alpha.0": + version "2.195.0-alpha.0" + resolved "https://registry.npmjs.org/@aws-cdk/app-staging-synthesizer-alpha/-/app-staging-synthesizer-alpha-2.195.0-alpha.0.tgz" + integrity sha512-7FHZ8n6BDjbDWgAPZO9vJJYRDlGrIooE/kLyzoxQ0dBwzx3iHrBRC03MflMvnbrsDRyxG+kXHTnMk8Hx3E3cKw== + "@aws-cdk/asset-awscli-v1@^2.2.229": version "2.2.255" resolved "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.255.tgz" @@ -36,6 +41,14 @@ jsonschema "~1.4.1" semver "^7.7.1" +"@aws-cdk/cloud-assembly-schema@^48.12.0": + version "48.20.0" + resolved "https://registry.yarnpkg.com/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-48.20.0.tgz#a2b60373cfbe228f901f62f0d7e2c5e2fe40702d" + integrity sha512-+eeiav9LY4wbF/EFuCt/vfvi/Zoxo8bf94PW5clbMraChEliq83w4TbRVy0jB9jE0v1ooFTtIjSQkowSPkfISg== + dependencies: + jsonschema "~1.4.1" + semver "^7.7.2" + "@aws-cdk/integ-runner@^2.186.0-alpha.0": version "2.190.8" resolved "https://registry.npmjs.org/@aws-cdk/integ-runner/-/integ-runner-2.190.8.tgz"