diff --git a/packages/@aws-cdk/pipelines/README.md b/packages/@aws-cdk/pipelines/README.md index aa4d22627967c..515c3eb5c5e9e 100644 --- a/packages/@aws-cdk/pipelines/README.md +++ b/packages/@aws-cdk/pipelines/README.md @@ -584,6 +584,45 @@ const pipeline = new CdkPipeline(this, 'Pipeline', { }); ``` +## Docker Registry Credentials + +You can specify credentials to use for authenticating to Docker registries as part of the +pipeline definition. This can be useful if any Docker image assets — in the pipeline or +any of the application stages — require authentication, either due to being in a +different environment (e.g., ECR repo) or to avoid throttling (e.g., DockerHub). + +```ts +const dockerHubSecret = secretsmanager.Secret.fromSecretCompleteArn(this, 'DHSecret', 'arn:aws:...'); +const customRegSecret = secretsmanager.Secret.fromSecretCompleteArn(this, 'CRSecret', 'arn:aws:...'); +const repo1 = ecr.Repository.fromRepositoryArn(stack, 'Repo', 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo1'); +const repo2 = ecr.Repository.fromRepositoryArn(stack, 'Repo', 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo2'); + +const pipeline = new CdkPipeline(this, 'Pipeline', { + dockerCredentials: [ + DockerCredential.dockerHub(dockerHubSecret), + DockerCredential.customRegistry('dockerregistry.example.com', customRegSecret), + DockerCredential.ecr([repo1, repo2]); + ], + ... +}); +``` + +You can authenticate to DockerHub, or any other Docker registry, by specifying a secret +with the username and secret/password to pass to `docker login`. The names of the fields +within the secret to use for the username and password can be customized. Authentication +to ECR repostories is done using the execution role of the relevant CodeBuild job. Both +types of credentials can be provided with an optional role to assume before requesting +the credentials. + +By default, the Docker credentials provided to the pipeline will be available to the +Synth/Build, Self-Update, and Asset Publishing actions within the pipeline. The scope of +the credentials can be limited via the `DockerCredentialUsage` option. + +```ts +const dockerHubSecret = secretsmanager.Secret.fromSecretCompleteArn(this, 'DHSecret', 'arn:aws:...'); +// Only the image asset publishing actions will be granted read access to the secret. +const creds = DockerCredential.dockerHub(dockerHubSecret, { usages: [DockerCredentialUsage.ASSET_PUBLISHING] }); +``` ## CDK Environment Bootstrapping diff --git a/packages/@aws-cdk/pipelines/lib/actions/update-pipeline-action.ts b/packages/@aws-cdk/pipelines/lib/actions/update-pipeline-action.ts index 7a6aba1c41bd5..42b3c51c1da3a 100644 --- a/packages/@aws-cdk/pipelines/lib/actions/update-pipeline-action.ts +++ b/packages/@aws-cdk/pipelines/lib/actions/update-pipeline-action.ts @@ -5,6 +5,7 @@ import * as events from '@aws-cdk/aws-events'; import * as iam from '@aws-cdk/aws-iam'; import { Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; +import { dockerCredentialsInstallCommands, DockerCredential, DockerCredentialUsage } from '../docker-credentials'; import { embeddedAsmPath } from '../private/construct-internals'; // v2 - keep this import as a separate section to reduce merge conflict when forward merging with the v2 branch. @@ -53,6 +54,14 @@ export interface UpdatePipelineActionProps { * @default - false */ readonly privileged?: boolean + + /** + * Docker registries and associated credentials necessary during the pipeline + * self-update stage. + * + * @default [] + */ + readonly dockerCredentials?: DockerCredential[]; } /** @@ -83,7 +92,10 @@ export class UpdatePipelineAction extends CoreConstruct implements codepipeline. version: '0.2', phases: { install: { - commands: `npm install -g aws-cdk${installSuffix}`, + commands: [ + `npm install -g aws-cdk${installSuffix}`, + ...dockerCredentialsInstallCommands(DockerCredentialUsage.SELF_UPDATE, props.dockerCredentials), + ], }, build: { commands: [ @@ -114,6 +126,8 @@ export class UpdatePipelineAction extends CoreConstruct implements codepipeline. actions: ['s3:ListBucket'], resources: ['*'], })); + (props.dockerCredentials ?? []).forEach(reg => reg.grantRead(selfMutationProject, DockerCredentialUsage.SELF_UPDATE)); + this.action = new cpactions.CodeBuildAction({ actionName: 'SelfMutate', input: props.cloudAssemblyInput, diff --git a/packages/@aws-cdk/pipelines/lib/docker-credentials.ts b/packages/@aws-cdk/pipelines/lib/docker-credentials.ts new file mode 100644 index 0000000000000..a2a5b2ca39d64 --- /dev/null +++ b/packages/@aws-cdk/pipelines/lib/docker-credentials.ts @@ -0,0 +1,230 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as ecr from '@aws-cdk/aws-ecr'; +import * as iam from '@aws-cdk/aws-iam'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import { Fn } from '@aws-cdk/core'; + +/** + * Represents credentials used to access a Docker registry. + */ +export abstract class DockerCredential { + /** + * Creates a DockerCredential for DockerHub. + * Convenience method for `fromCustomRegistry('index.docker.io', opts)`. + */ + public static dockerHub(secret: secretsmanager.ISecret, opts: ExternalDockerCredentialOptions = {}): DockerCredential { + return new ExternalDockerCredential('index.docker.io', secret, opts); + } + + /** + * Creates a DockerCredential for a registry, based on its domain name (e.g., 'www.example.com'). + */ + public static customRegistry( + registryDomain: string, + secret: secretsmanager.ISecret, + opts: ExternalDockerCredentialOptions = {}): DockerCredential { + return new ExternalDockerCredential(registryDomain, secret, opts); + } + + /** + * Creates a DockerCredential for one or more ECR repositories. + * + * NOTE - All ECR repositories in the same account and region share a domain name + * (e.g., 0123456789012.dkr.ecr.eu-west-1.amazonaws.com), and can only have one associated + * set of credentials (and DockerCredential). Attempting to associate one set of credentials + * with one ECR repo and another with another ECR repo in the same account and region will + * result in failures when using these credentials in the pipeline. + */ + public static ecr(repositories: ecr.IRepository[], opts?: EcrDockerCredentialOptions): DockerCredential { + return new EcrDockerCredential(repositories, opts ?? {}); + } + + constructor(protected readonly usages?: DockerCredentialUsage[]) { } + + /** + * Determines if this credential is relevant to the input usage. + * @internal + */ + public _applicableForUsage(usage: DockerCredentialUsage) { + return !this.usages || this.usages.includes(usage); + } + + /** + * Grant read-only access to the registry credentials. + * This grants read access to any secrets, and pull access to any repositories. + */ + public abstract grantRead(grantee: iam.IGrantable, usage: DockerCredentialUsage): void; + + /** + * Creates and returns the credential configuration, to be used by `cdk-assets` + * to support the `docker-credential-cdk-assets` tool for `docker login`. + * @internal + */ + public abstract _renderCdkAssetsConfig(): DockerCredentialCredentialSource +} + +/** Options for defining credentials for a Docker Credential */ +export interface ExternalDockerCredentialOptions { + /** + * The name of the JSON field of the secret which contains the user/login name. + * @default 'username' + */ + readonly secretUsernameField?: string; + /** + * The name of the JSON field of the secret which contains the secret/password. + * @default 'secret' + */ + readonly secretPasswordField?: string; + /** + * An IAM role to assume prior to accessing the secret. + * @default - none. The current execution role will be used. + */ + readonly assumeRole?: iam.IRole + /** + * Defines which stages of the pipeline should be granted access to these credentials. + * @default - all relevant stages (synth, self-update, asset publishing) are granted access. + */ + readonly usages?: DockerCredentialUsage[]; +} + +/** Options for defining access for a Docker Credential composed of ECR repos */ +export interface EcrDockerCredentialOptions { + /** + * An IAM role to assume prior to accessing the secret. + * @default - none. The current execution role will be used. + */ + readonly assumeRole?: iam.IRole + /** + * Defines which stages of the pipeline should be granted access to these credentials. + * @default - all relevant stages (synth, self-update, asset publishing) are granted access. + */ + readonly usages?: DockerCredentialUsage[]; +} + +/** Defines which stages of a pipeline require the specified credentials */ +export enum DockerCredentialUsage { + /** Synth/Build */ + SYNTH, + /** Self-update */ + SELF_UPDATE, + /** Asset publishing */ + ASSET_PUBLISHING, +}; + +/** DockerCredential defined by registry domain and a secret */ +class ExternalDockerCredential extends DockerCredential { + constructor( + private readonly registryDomain: string, + private readonly secret: secretsmanager.ISecret, + private readonly opts: ExternalDockerCredentialOptions) { + super(opts.usages); + } + + public grantRead(grantee: iam.IGrantable, usage: DockerCredentialUsage) { + if (!this._applicableForUsage(usage)) { return; } + + if (this.opts.assumeRole) { + grantee.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['sts:AssumeRole'], + resources: [this.opts.assumeRole.roleArn], + })); + } + const role = this.opts.assumeRole ?? grantee; + this.secret.grantRead(role); + } + + public _renderCdkAssetsConfig(): DockerCredentialCredentialSource { + return { + [this.registryDomain]: { + secretsManagerSecretId: this.secret.secretArn, + secretsUsernameField: this.opts.secretUsernameField, + secretsPasswordField: this.opts.secretPasswordField, + assumeRoleArn: this.opts.assumeRole?.roleArn, + }, + }; + } +} + +/** DockerCredential defined by a set of ECR repositories in the same account & region */ +class EcrDockerCredential extends DockerCredential { + public readonly registryDomain: string; + + constructor(private readonly repositories: ecr.IRepository[], private readonly opts: EcrDockerCredentialOptions) { + super(opts.usages); + + if (repositories.length === 0) { + throw new Error('must supply at least one `ecr.IRepository` to create an `EcrDockerCredential`'); + } + this.registryDomain = Fn.select(0, Fn.split('/', repositories[0].repositoryUri)); + } + + public grantRead(grantee: iam.IGrantable, usage: DockerCredentialUsage) { + if (!this._applicableForUsage(usage)) { return; } + + if (this.opts.assumeRole) { + grantee.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['sts:AssumeRole'], + resources: [this.opts.assumeRole.roleArn], + })); + } + const role = this.opts.assumeRole ?? grantee; + this.repositories.forEach(repo => repo.grantPull(role)); + } + + public _renderCdkAssetsConfig(): DockerCredentialCredentialSource { + return { + [this.registryDomain]: { + ecrRepository: true, + assumeRoleArn: this.opts.assumeRole?.roleArn, + }, + }; + } +} + +/** Format for the CDK assets config. See the cdk-assets `DockerDomainCredentialSource` */ +interface DockerCredentialCredentialSource { + readonly secretsManagerSecretId?: string; + readonly secretsUsernameField?: string; + readonly secretsPasswordField?: string; + readonly ecrRepository?: boolean; + readonly assumeRoleArn?: string; +} + +/** + * Creates a set of OS-specific buildspec installation commands for setting up the given + * registries and associated credentials. + * + * @param registries - Registries to configure credentials for. It is an error to provide + * multiple registries for the same domain. + * @param osType - (optional) Defaults to Linux. + * @returns An array of commands to configure cdk-assets to use these credentials. + */ +export function dockerCredentialsInstallCommands( + usage: DockerCredentialUsage, + registries?: DockerCredential[], + osType?: ec2.OperatingSystemType): string[] { + + const relevantRegistries = (registries ?? []).filter(reg => reg._applicableForUsage(usage)); + if (!relevantRegistries || relevantRegistries.length === 0) { return []; } + + const domainCredentials = relevantRegistries.reduce(function (map: Record, registry) { + Object.assign(map, registry._renderCdkAssetsConfig()); + return map; + }, {}); + const cdkAssetsConfigFile = { + version: '1.0', + domainCredentials, + }; + + if (osType === ec2.OperatingSystemType.WINDOWS) { + return [ + 'mkdir %USERPROFILE%\\.cdk', + `echo '${JSON.stringify(cdkAssetsConfigFile)}' > %USERPROFILE%\\.cdk\\cdk-docker-creds.json`, + ]; + } else { + return [ + 'mkdir $HOME/.cdk', + `echo '${JSON.stringify(cdkAssetsConfigFile)}' > $HOME/.cdk/cdk-docker-creds.json`, + ]; + } +} diff --git a/packages/@aws-cdk/pipelines/lib/index.ts b/packages/@aws-cdk/pipelines/lib/index.ts index dbe8a73291c23..2e63ee1d083a9 100644 --- a/packages/@aws-cdk/pipelines/lib/index.ts +++ b/packages/@aws-cdk/pipelines/lib/index.ts @@ -2,4 +2,5 @@ export * from './pipeline'; export * from './stage'; export * from './synths'; export * from './actions'; -export * from './validation'; \ No newline at end of file +export * from './docker-credentials'; +export * from './validation'; diff --git a/packages/@aws-cdk/pipelines/lib/pipeline.ts b/packages/@aws-cdk/pipelines/lib/pipeline.ts index 2a655279030b8..2d6d9e33c3ef9 100644 --- a/packages/@aws-cdk/pipelines/lib/pipeline.ts +++ b/packages/@aws-cdk/pipelines/lib/pipeline.ts @@ -5,12 +5,14 @@ import * as iam from '@aws-cdk/aws-iam'; import { Annotations, App, Aws, CfnOutput, Fn, Lazy, PhysicalName, Stack, Stage } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { AssetType, DeployCdkStackAction, PublishAssetsAction, UpdatePipelineAction } from './actions'; +import { dockerCredentialsInstallCommands, DockerCredential, DockerCredentialUsage } from './docker-credentials'; import { appOf, assemblyBuilderOf } from './private/construct-internals'; import { AddStageOptions, AssetPublishingCommand, CdkStage, StackOutput } from './stage'; // v2 - keep this import as a separate section to reduce merge conflict when forward merging with the v2 branch. // eslint-disable-next-line import { Construct as CoreConstruct } from '@aws-cdk/core'; +import { SimpleSynthAction } from './synths'; const CODE_BUILD_LENGTH_LIMIT = 100; /** @@ -151,6 +153,15 @@ export interface CdkPipelineProps { * @default - false */ readonly supportDockerAssets?: boolean; + + /** + * A list of credentials used to authenticate to Docker registries. + * + * Specify any credentials necessary within the pipeline to build, synth, update, or publish assets. + * + * @default [] + */ + readonly dockerCredentials?: DockerCredential[]; } /** @@ -171,6 +182,7 @@ export class CdkPipeline extends CoreConstruct { private readonly _stages: CdkStage[] = []; private readonly _outputArtifacts: Record = {}; private readonly _cloudAssemblyArtifact: codepipeline.Artifact; + private readonly _dockerCredentials: DockerCredential[]; constructor(scope: Construct, id: string, props: CdkPipelineProps) { super(scope, id); @@ -180,6 +192,7 @@ export class CdkPipeline extends CoreConstruct { } this._cloudAssemblyArtifact = props.cloudAssemblyArtifact; + this._dockerCredentials = props.dockerCredentials ?? []; const pipelineStack = Stack.of(this); if (props.codePipeline) { @@ -218,6 +231,10 @@ export class CdkPipeline extends CoreConstruct { } if (props.synthAction) { + if (props.synthAction instanceof SimpleSynthAction && this._dockerCredentials.length > 0) { + props.synthAction._addDockerCredentials(this._dockerCredentials); + } + this._pipeline.addStage({ stageName: 'Build', actions: [props.synthAction], @@ -233,6 +250,7 @@ export class CdkPipeline extends CoreConstruct { cdkCliVersion: props.cdkCliVersion, projectName: maybeSuffix(props.pipelineName, '-selfupdate'), privileged: props.supportDockerAssets, + dockerCredentials: this._dockerCredentials, })], }); } @@ -246,6 +264,7 @@ export class CdkPipeline extends CoreConstruct { subnetSelection: props.subnetSelection, singlePublisherPerType: props.singlePublisherPerType, preInstallCommands: props.assetPreInstallCommands, + dockerCredentials: this._dockerCredentials, }); } @@ -397,6 +416,7 @@ interface AssetPublishingProps { readonly subnetSelection?: ec2.SubnetSelection; readonly singlePublisherPerType?: boolean; readonly preInstallCommands?: string[]; + readonly dockerCredentials: DockerCredential[]; } /** @@ -414,6 +434,7 @@ class AssetPublishing extends CoreConstruct { private readonly lastStageBeforePublishing?: codepipeline.IStage; private readonly stages: codepipeline.IStage[] = []; private readonly pipeline: codepipeline.Pipeline; + private readonly dockerCredentials: DockerCredential[]; private _fileAssetCtr = 0; private _dockerAssetCtr = 0; @@ -427,6 +448,8 @@ class AssetPublishing extends CoreConstruct { const stages: codepipeline.IStage[] = (this.props.pipeline as any)._stages; // Any asset publishing stages will be added directly after the last stage that currently exists. this.lastStageBeforePublishing = stages.slice(-1)[0]; + + this.dockerCredentials = props.dockerCredentials; } /** @@ -484,6 +507,8 @@ class AssetPublishing extends CoreConstruct { command.assetType === AssetType.FILE ? 'FileAsset' : 'DockerAsset' : command.assetType === AssetType.FILE ? `FileAsset${++this._fileAssetCtr}` : `DockerAsset${++this._dockerAssetCtr}`; + const credsInstallCommands = dockerCredentialsInstallCommands(DockerCredentialUsage.ASSET_PUBLISHING, this.dockerCredentials); + // NOTE: It's important that asset changes don't force a pipeline self-mutation. // This can cause an infinite loop of updates (see https://github.com/aws/aws-cdk/issues/9080). // For that reason, we use the id as the actionName below, rather than the asset hash. @@ -496,7 +521,7 @@ class AssetPublishing extends CoreConstruct { vpc: this.props.vpc, subnetSelection: this.props.subnetSelection, createBuildspecFile: this.props.singlePublisherPerType, - preInstallCommands: this.props.preInstallCommands, + preInstallCommands: [...(this.props.preInstallCommands ?? []), ...credsInstallCommands], }); this.stages[stageIndex].addAction(action); } @@ -566,6 +591,11 @@ class AssetPublishing extends CoreConstruct { resources: Lazy.list({ produce: () => [...this.assetPublishingRoles[assetType]].map(arn => Fn.sub(arn)) }), })); + // Grant pull access for any ECR registries and secrets that exist + if (assetType === AssetType.DOCKER_IMAGE) { + this.dockerCredentials.forEach(reg => reg.grantRead(assetRole, DockerCredentialUsage.ASSET_PUBLISHING)); + } + // Artifact access this.pipeline.artifactBucket.grantRead(assetRole); diff --git a/packages/@aws-cdk/pipelines/lib/synths/simple-synth-action.ts b/packages/@aws-cdk/pipelines/lib/synths/simple-synth-action.ts index 9713b4da43076..8380a9c859698 100644 --- a/packages/@aws-cdk/pipelines/lib/synths/simple-synth-action.ts +++ b/packages/@aws-cdk/pipelines/lib/synths/simple-synth-action.ts @@ -7,6 +7,7 @@ import * as ec2 from '@aws-cdk/aws-ec2'; import * as events from '@aws-cdk/aws-events'; import * as iam from '@aws-cdk/aws-iam'; import { Stack } from '@aws-cdk/core'; +import { dockerCredentialsInstallCommands, DockerCredential, DockerCredentialUsage } from '../docker-credentials'; import { toPosixPath } from '../private/fs'; import { copyEnvironmentVariables, filterEmpty } from './_util'; @@ -259,6 +260,7 @@ export class SimpleSynthAction implements codepipeline.IAction, iam.IGrantable { private _action?: codepipeline_actions.CodeBuildAction; private _actionProperties: codepipeline.ActionProperties; private _project?: codebuild.IProject; + private _dockerCredentials?: DockerCredential[]; constructor(private readonly props: SimpleSynthActionProps) { // A number of actionProperties get read before bind() is even called (so before we @@ -326,6 +328,11 @@ export class SimpleSynthAction implements codepipeline.IAction, iam.IGrantable { const testCommands = this.props.testCommands ?? []; const synthCommand = this.props.synthCommand; + const environment = { buildImage: codebuild.LinuxBuildImage.STANDARD_5_0, ...this.props.environment }; + const osType = (environment.buildImage instanceof codebuild.WindowsBuildImage) + ? ec2.OperatingSystemType.WINDOWS + : ec2.OperatingSystemType.LINUX; + const buildSpec = codebuild.BuildSpec.fromObject({ version: '0.2', phases: { @@ -333,6 +340,7 @@ export class SimpleSynthAction implements codepipeline.IAction, iam.IGrantable { commands: filterEmpty([ this.props.subdirectory ? `cd ${this.props.subdirectory}` : '', ...installCommands, + ...dockerCredentialsInstallCommands(DockerCredentialUsage.SYNTH, this._dockerCredentials, osType), ]), }, build: { @@ -346,8 +354,6 @@ export class SimpleSynthAction implements codepipeline.IAction, iam.IGrantable { artifacts: renderArtifacts(this), }); - const environment = { buildImage: codebuild.LinuxBuildImage.STANDARD_5_0, ...this.props.environment }; - const environmentVariables = { ...copyEnvironmentVariables(...this.props.copyEnvironmentVariables || []), }; @@ -379,6 +385,8 @@ export class SimpleSynthAction implements codepipeline.IAction, iam.IGrantable { this._project = project; + this._dockerCredentials?.forEach(reg => reg.grantRead(project.grantPrincipal, DockerCredentialUsage.SYNTH)); + this._action = new codepipeline_actions.CodeBuildAction({ actionName: this.actionProperties.actionName, input: this.props.sourceArtifact, @@ -448,6 +456,16 @@ export class SimpleSynthAction implements codepipeline.IAction, iam.IGrantable { return this._action.onStateChange(name, target, options); } + + /** + * Associate one or more Docker registries and associated credentials with the synth action. + * This will be used to inject installation commands to set up `cdk-assets`, + * and grant read access to the credentials. + * @internal + */ + public _addDockerCredentials(dockerCredentials: DockerCredential[]) { + this._dockerCredentials = dockerCredentials; + } } /** diff --git a/packages/@aws-cdk/pipelines/package.json b/packages/@aws-cdk/pipelines/package.json index 4bceafa19bca6..7c24bb5b83505 100644 --- a/packages/@aws-cdk/pipelines/package.json +++ b/packages/@aws-cdk/pipelines/package.json @@ -38,7 +38,6 @@ "cfn2ts": "0.0.0", "pkglint": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", - "@aws-cdk/aws-ecr": "0.0.0", "@aws-cdk/aws-ecr-assets": "0.0.0", "@aws-cdk/assert-internal": "0.0.0" }, @@ -48,9 +47,11 @@ "@aws-cdk/aws-codebuild": "0.0.0", "@aws-cdk/aws-codepipeline": "0.0.0", "@aws-cdk/aws-codepipeline-actions": "0.0.0", + "@aws-cdk/aws-ecr": "0.0.0", "@aws-cdk/aws-events": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/cloud-assembly-schema": "0.0.0", "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/cx-api": "0.0.0" @@ -62,9 +63,11 @@ "@aws-cdk/aws-codepipeline": "0.0.0", "@aws-cdk/aws-codepipeline-actions": "0.0.0", "@aws-cdk/cloud-assembly-schema": "0.0.0", + "@aws-cdk/aws-ecr": "0.0.0", "@aws-cdk/aws-events": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/cx-api": "0.0.0" }, diff --git a/packages/@aws-cdk/pipelines/test/docker-credentials.test.ts b/packages/@aws-cdk/pipelines/test/docker-credentials.test.ts new file mode 100644 index 0000000000000..de9d7efa54852 --- /dev/null +++ b/packages/@aws-cdk/pipelines/test/docker-credentials.test.ts @@ -0,0 +1,381 @@ +import { arrayWith } from '@aws-cdk/assert-internal'; +import '@aws-cdk/assert-internal/jest'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as ecr from '@aws-cdk/aws-ecr'; +import * as iam from '@aws-cdk/aws-iam'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import * as cdk from '@aws-cdk/core'; +import * as cdkp from '../lib'; + +let app: cdk.App; +let stack: cdk.Stack; + +beforeEach(() => { + app = new cdk.App(); + stack = new cdk.Stack(app, 'Stack', { + env: { account: '0123456789012', region: 'eu-west-1' }, + }); +}); + +describe('ExternalDockerCredential', () => { + + let secret: secretsmanager.ISecret; + + beforeEach(() => { + secret = new secretsmanager.Secret(stack, 'Secret'); + }); + + test('dockerHub defaults registry domain', () => { + const creds = cdkp.DockerCredential.dockerHub(secret); + + expect(Object.keys(creds._renderCdkAssetsConfig())).toEqual(['index.docker.io']); + }); + + test('minimal example only renders secret', () => { + const creds = cdkp.DockerCredential.customRegistry('example.com', secret); + + const config = creds._renderCdkAssetsConfig(); + expect(stack.resolve(config)).toEqual({ + 'example.com': { + secretsManagerSecretId: { Ref: 'SecretA720EF05' }, + }, + }); + }); + + test('maximmum example includes all expected properties', () => { + const roleArn = 'arn:aws:iam::0123456789012:role/MyRole'; + const creds = cdkp.DockerCredential.customRegistry('example.com', secret, { + secretUsernameField: 'login', + secretPasswordField: 'pass', + assumeRole: iam.Role.fromRoleArn(stack, 'Role', roleArn), + }); + + const config = creds._renderCdkAssetsConfig(); + expect(stack.resolve(config)).toEqual({ + 'example.com': { + secretsManagerSecretId: { Ref: 'SecretA720EF05' }, + secretsUsernameField: 'login', + secretsPasswordField: 'pass', + assumeRoleArn: roleArn, + }, + }); + }); + + describe('grantRead', () => { + + test('grants read access to the secret', () => { + const creds = cdkp.DockerCredential.customRegistry('example.com', secret); + + const user = new iam.User(stack, 'User'); + creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], + Effect: 'Allow', + Resource: { Ref: 'SecretA720EF05' }, + }, + ], + Version: '2012-10-17', + }, + Users: [{ Ref: 'User00B015A1' }], + }); + }); + + test('grants read access to the secret to the assumeRole if provided', () => { + const assumeRole = iam.Role.fromRoleArn(stack, 'Role', 'arn:aws:iam::0123456789012:role/MyRole'); + const creds = cdkp.DockerCredential.customRegistry('example.com', secret, { assumeRole }); + + const user = new iam.User(stack, 'User'); + creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], + Effect: 'Allow', + Resource: { Ref: 'SecretA720EF05' }, + }], + Version: '2012-10-17', + }, + Roles: ['MyRole'], + }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Resource: 'arn:aws:iam::0123456789012:role/MyRole', + }], + Version: '2012-10-17', + }, + Users: [{ Ref: 'User00B015A1' }], + }); + }); + + test('does not grant any access if the usage does not match', () => { + const assumeRole = iam.Role.fromRoleArn(stack, 'Role', 'arn:aws:iam::0123456789012:role/MyRole'); + const creds = cdkp.DockerCredential.customRegistry('example.com', secret, { + assumeRole, + usages: [cdkp.DockerCredentialUsage.ASSET_PUBLISHING], + }); + + const user = new iam.User(stack, 'User'); + creds.grantRead(user, cdkp.DockerCredentialUsage.SELF_UPDATE); + + expect(stack).not.toHaveResource('AWS::IAM::Policy'); + }); + + }); + +}); + +describe('EcrDockerCredential', () => { + + let repo: ecr.IRepository; + + beforeEach(() => { + repo = ecr.Repository.fromRepositoryArn(stack, 'Repo', 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo'); + }); + + test('minimal example only renders ecrRepository', () => { + const creds = cdkp.DockerCredential.ecr([repo]); + + const config = creds._renderCdkAssetsConfig(); + + expect(stack.resolve(Object.keys(config))).toEqual([{ + 'Fn::Select': [ + 0, { + 'Fn::Split': ['/', { + 'Fn::Join': ['', ['0123456789012.dkr.ecr.eu-west-1.', { Ref: 'AWS::URLSuffix' }, '/Repo']], + }], + }, + ], + }]); + expect(Object.values(config)).toEqual([{ + ecrRepository: true, + }]); + }); + + test('maximum example renders all fields', () => { + const roleArn = 'arn:aws:iam::0123456789012:role/MyRole'; + const creds = cdkp.DockerCredential.ecr([repo], { + assumeRole: iam.Role.fromRoleArn(stack, 'Role', roleArn), + usages: [cdkp.DockerCredentialUsage.SYNTH], + }); + + const config = creds._renderCdkAssetsConfig(); + + expect(stack.resolve(Object.keys(config))).toEqual([{ + 'Fn::Select': [ + 0, { + 'Fn::Split': ['/', { + 'Fn::Join': ['', ['0123456789012.dkr.ecr.eu-west-1.', { Ref: 'AWS::URLSuffix' }, '/Repo']], + }], + }, + ], + }]); + expect(Object.values(config)).toEqual([{ + assumeRoleArn: roleArn, + ecrRepository: true, + }]); + }); + + describe('grantRead', () => { + + test('grants pull access to the repo', () => { + const creds = cdkp.DockerCredential.ecr([repo]); + + const user = new iam.User(stack, 'User'); + creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Action: [ + 'ecr:BatchCheckLayerAvailability', + 'ecr:GetDownloadUrlForLayer', + 'ecr:BatchGetImage', + ], + Effect: 'Allow', + Resource: 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo', + }, + { + Action: 'ecr:GetAuthorizationToken', + Effect: 'Allow', + Resource: '*', + }], + Version: '2012-10-17', + }, + Users: [{ Ref: 'User00B015A1' }], + }); + }); + + test('grants pull access to the assumed role', () => { + const assumeRole = iam.Role.fromRoleArn(stack, 'Role', 'arn:aws:iam::0123456789012:role/MyRole'); + const creds = cdkp.DockerCredential.ecr([repo], { assumeRole }); + + const user = new iam.User(stack, 'User'); + creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Action: [ + 'ecr:BatchCheckLayerAvailability', + 'ecr:GetDownloadUrlForLayer', + 'ecr:BatchGetImage', + ], + Effect: 'Allow', + Resource: 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo', + }, + { + Action: 'ecr:GetAuthorizationToken', + Effect: 'Allow', + Resource: '*', + }], + Version: '2012-10-17', + }, + Roles: ['MyRole'], + }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Resource: 'arn:aws:iam::0123456789012:role/MyRole', + }], + Version: '2012-10-17', + }, + Users: [{ Ref: 'User00B015A1' }], + }); + }); + + test('grants pull access to multiple repos if provided', () => { + const repo2 = ecr.Repository.fromRepositoryArn(stack, 'Repo2', 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo2'); + const creds = cdkp.DockerCredential.ecr([repo, repo2]); + + const user = new iam.User(stack, 'User'); + creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: [ + 'ecr:BatchCheckLayerAvailability', + 'ecr:GetDownloadUrlForLayer', + 'ecr:BatchGetImage', + ], + Effect: 'Allow', + Resource: 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo', + }, + { + Action: [ + 'ecr:BatchCheckLayerAvailability', + 'ecr:GetDownloadUrlForLayer', + 'ecr:BatchGetImage', + ], + Effect: 'Allow', + Resource: 'arn:aws:ecr:eu-west-1:0123456789012:repository/Repo2', + }, + { + Action: 'ecr:GetAuthorizationToken', + Effect: 'Allow', + Resource: '*', + }), + Version: '2012-10-17', + }, + Users: [{ Ref: 'User00B015A1' }], + }); + }); + + test('does not grant any access if the usage does not match', () => { + const creds = cdkp.DockerCredential.ecr([repo], { usages: [cdkp.DockerCredentialUsage.SYNTH] }); + + const user = new iam.User(stack, 'User'); + creds.grantRead(user, cdkp.DockerCredentialUsage.ASSET_PUBLISHING); + + expect(stack).not.toHaveResource('AWS::IAM::Policy'); + }); + + }); + +}); + +describe('dockerCredentialsInstallCommands', () => { + const secretArn = 'arn:aws:secretsmanager:eu-west-1:0123456789012:secret:mySecret-012345'; + let secret: secretsmanager.ISecret; + + beforeEach(() => { + secret = secretsmanager.Secret.fromSecretCompleteArn(stack, 'Secret', secretArn); + }); + + test('returns a blank array for empty inputs', () => { + expect(cdkp.dockerCredentialsInstallCommands(cdkp.DockerCredentialUsage.SYNTH)).toEqual([]); + expect(cdkp.dockerCredentialsInstallCommands(cdkp.DockerCredentialUsage.SYNTH, [])).toEqual([]); + }); + + test('returns only credentials relevant to the current usage', () => { + const synthCreds = cdkp.DockerCredential.customRegistry('synth.example.com', secret, { + usages: [cdkp.DockerCredentialUsage.SYNTH], + }); + const selfUpdateCreds = cdkp.DockerCredential.customRegistry('selfupdate.example.com', secret, { + usages: [cdkp.DockerCredentialUsage.SELF_UPDATE], + }); + const assetPublishingCreds = cdkp.DockerCredential.customRegistry('assetpublishing.example.com', secret, { + usages: [cdkp.DockerCredentialUsage.ASSET_PUBLISHING], + }); + + const commands = cdkp.dockerCredentialsInstallCommands(cdkp.DockerCredentialUsage.SYNTH, + [synthCreds, selfUpdateCreds, assetPublishingCreds]).join('|'); + + expect(commands.includes('synth')).toBeTruthy(); + expect(commands.includes('selfupdate')).toBeFalsy(); + expect(commands.includes('assetpublishing')).toBeFalsy(); + }); + + test('defaults to Linux-style commands', () => { + const creds = cdkp.DockerCredential.dockerHub(secret); + + const defaultCommands = cdkp.dockerCredentialsInstallCommands(cdkp.DockerCredentialUsage.SYNTH, [creds]); + const linuxCommands = cdkp.dockerCredentialsInstallCommands(cdkp.DockerCredentialUsage.SYNTH, [creds], ec2.OperatingSystemType.LINUX); + + expect(defaultCommands).toEqual(linuxCommands); + }); + + test('Linux commands', () => { + const creds = cdkp.DockerCredential.customRegistry('example.com', secret); + const expectedCredsFile = JSON.stringify({ + version: '1.0', + domainCredentials: { + 'example.com': { secretsManagerSecretId: secretArn }, + }, + }); + + const commands = cdkp.dockerCredentialsInstallCommands(cdkp.DockerCredentialUsage.SYNTH, [creds], ec2.OperatingSystemType.LINUX); + + expect(commands).toEqual([ + 'mkdir $HOME/.cdk', + `echo '${expectedCredsFile}' > $HOME/.cdk/cdk-docker-creds.json`, + ]); + }); + + test('Windows commands', () => { + const creds = cdkp.DockerCredential.customRegistry('example.com', secret); + const expectedCredsFile = JSON.stringify({ + version: '1.0', + domainCredentials: { + 'example.com': { secretsManagerSecretId: secretArn }, + }, + }); + + const commands = cdkp.dockerCredentialsInstallCommands(cdkp.DockerCredentialUsage.SYNTH, [creds], ec2.OperatingSystemType.WINDOWS); + + expect(commands).toEqual([ + 'mkdir %USERPROFILE%\\.cdk', + `echo '${expectedCredsFile}' > %USERPROFILE%\\.cdk\\cdk-docker-creds.json`, + ]); + }); +}); diff --git a/packages/@aws-cdk/pipelines/test/integ.pipeline-with-assets-single-upload.expected.json b/packages/@aws-cdk/pipelines/test/integ.pipeline-with-assets-single-upload.expected.json index 2e3fcf1729a57..2160f260dcd9e 100644 --- a/packages/@aws-cdk/pipelines/test/integ.pipeline-with-assets-single-upload.expected.json +++ b/packages/@aws-cdk/pipelines/test/integ.pipeline-with-assets-single-upload.expected.json @@ -1324,7 +1324,7 @@ ] }, "Source": { - "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": \"npm install -g aws-cdk\"\n },\n \"build\": {\n \"commands\": [\n \"cdk -a . deploy PipelineStack --require-approval=never --verbose\"\n ]\n }\n }\n}", + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": [\n \"npm install -g aws-cdk\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"cdk -a . deploy PipelineStack --require-approval=never --verbose\"\n ]\n }\n }\n}", "Type": "CODEPIPELINE" }, "EncryptionKey": { diff --git a/packages/@aws-cdk/pipelines/test/integ.pipeline-with-assets.expected.json b/packages/@aws-cdk/pipelines/test/integ.pipeline-with-assets.expected.json index 848768d86e2d1..c2e4cddc58aef 100644 --- a/packages/@aws-cdk/pipelines/test/integ.pipeline-with-assets.expected.json +++ b/packages/@aws-cdk/pipelines/test/integ.pipeline-with-assets.expected.json @@ -1350,7 +1350,7 @@ ] }, "Source": { - "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": \"npm install -g aws-cdk\"\n },\n \"build\": {\n \"commands\": [\n \"cdk -a . deploy PipelineStack --require-approval=never --verbose\"\n ]\n }\n }\n}", + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": [\n \"npm install -g aws-cdk\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"cdk -a . deploy PipelineStack --require-approval=never --verbose\"\n ]\n }\n }\n}", "Type": "CODEPIPELINE" }, "EncryptionKey": { @@ -1529,7 +1529,7 @@ ] }, "Source": { - "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": \"npm install -g cdk-assets\"\n },\n \"build\": {\n \"commands\": [\n \"cdk-assets --path \\\"assembly-PipelineStack-PreProd/PipelineStackPreProdStack65A0AD1F.assets.json\\\" --verbose publish \\\"8289faf53c7da377bb2b90615999171adef5e1d8f6b88810e5fef75e6ca09ba5:12345678-test-region\\\"\"\n ]\n }\n }\n}", + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": [\n \"npm install -g cdk-assets\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"cdk-assets --path \\\"assembly-PipelineStack-PreProd/PipelineStackPreProdStack65A0AD1F.assets.json\\\" --verbose publish \\\"8289faf53c7da377bb2b90615999171adef5e1d8f6b88810e5fef75e6ca09ba5:12345678-test-region\\\"\"\n ]\n }\n }\n}", "Type": "CODEPIPELINE" }, "EncryptionKey": { @@ -1560,7 +1560,7 @@ ] }, "Source": { - "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": \"npm install -g cdk-assets\"\n },\n \"build\": {\n \"commands\": [\n \"cdk-assets --path \\\"assembly-PipelineStack-PreProd/PipelineStackPreProdStack65A0AD1F.assets.json\\\" --verbose publish \\\"ac76997971c3f6ddf37120660003f1ced72b4fc58c498dfd99c78fa77e721e0e:12345678-test-region\\\"\"\n ]\n }\n }\n}", + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": [\n \"npm install -g cdk-assets\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"cdk-assets --path \\\"assembly-PipelineStack-PreProd/PipelineStackPreProdStack65A0AD1F.assets.json\\\" --verbose publish \\\"ac76997971c3f6ddf37120660003f1ced72b4fc58c498dfd99c78fa77e721e0e:12345678-test-region\\\"\"\n ]\n }\n }\n}", "Type": "CODEPIPELINE" }, "EncryptionKey": { diff --git a/packages/@aws-cdk/pipelines/test/integ.pipeline.expected.json b/packages/@aws-cdk/pipelines/test/integ.pipeline.expected.json index d0c3a1c622c49..32a0a50bd90d5 100644 --- a/packages/@aws-cdk/pipelines/test/integ.pipeline.expected.json +++ b/packages/@aws-cdk/pipelines/test/integ.pipeline.expected.json @@ -1283,7 +1283,7 @@ ] }, "Source": { - "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": \"npm install -g aws-cdk\"\n },\n \"build\": {\n \"commands\": [\n \"cdk -a . deploy PipelineStack --require-approval=never --verbose\"\n ]\n }\n }\n}", + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"install\": {\n \"commands\": [\n \"npm install -g aws-cdk\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"cdk -a . deploy PipelineStack --require-approval=never --verbose\"\n ]\n }\n }\n}", "Type": "CODEPIPELINE" }, "EncryptionKey": { diff --git a/packages/@aws-cdk/pipelines/test/pipeline-assets.test.ts b/packages/@aws-cdk/pipelines/test/pipeline-assets.test.ts index 07525ec32af19..970e097733530 100644 --- a/packages/@aws-cdk/pipelines/test/pipeline-assets.test.ts +++ b/packages/@aws-cdk/pipelines/test/pipeline-assets.test.ts @@ -2,10 +2,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { arrayWith, deepObjectLike, encodedJson, notMatching, objectLike, stringLike, SynthUtils } from '@aws-cdk/assert-internal'; import '@aws-cdk/assert-internal/jest'; +import * as cb from '@aws-cdk/aws-codebuild'; import * as cp from '@aws-cdk/aws-codepipeline'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecr_assets from '@aws-cdk/aws-ecr-assets'; import * as s3_assets from '@aws-cdk/aws-s3-assets'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; import { Stack, Stage, StageProps } from '@aws-cdk/core'; import { Construct } from 'constructs'; import * as cdkp from '../lib'; @@ -291,7 +293,7 @@ describe('basic pipeline', () => { BuildSpec: encodedJson(deepObjectLike({ phases: { install: { - commands: 'npm install -g cdk-assets@1.2.3', + commands: ['npm install -g cdk-assets@1.2.3'], }, }, })), @@ -522,6 +524,209 @@ describe('pipeline with single asset publisher', () => { }); }); +describe('pipeline with Docker credentials', () => { + const secretSynthArn = 'arn:aws:secretsmanager:eu-west-1:0123456789012:secret:synth-012345'; + const secretUpdateArn = 'arn:aws:secretsmanager:eu-west-1:0123456789012:secret:update-012345'; + const secretPublishArn = 'arn:aws:secretsmanager:eu-west-1:0123456789012:secret:publish-012345'; + let secretSynth: secretsmanager.ISecret; + let secretUpdate: secretsmanager.ISecret; + let secretPublish: secretsmanager.ISecret; + + beforeEach(() => { + app = new TestApp(); + pipelineStack = new Stack(app, 'PipelineStack', { env: PIPELINE_ENV }); + + secretSynth = secretsmanager.Secret.fromSecretCompleteArn(pipelineStack, 'Secret1', secretSynthArn); + secretUpdate = secretsmanager.Secret.fromSecretCompleteArn(pipelineStack, 'Secret2', secretUpdateArn); + secretPublish = secretsmanager.Secret.fromSecretCompleteArn(pipelineStack, 'Secret3', secretPublishArn); + pipeline = new TestGitHubNpmPipeline(pipelineStack, 'Cdk', { + dockerCredentials: [ + cdkp.DockerCredential.customRegistry('synth.example.com', secretSynth, { + usages: [cdkp.DockerCredentialUsage.SYNTH], + }), + cdkp.DockerCredential.customRegistry('selfupdate.example.com', secretUpdate, { + usages: [cdkp.DockerCredentialUsage.SELF_UPDATE], + }), + cdkp.DockerCredential.customRegistry('publish.example.com', secretPublish, { + usages: [cdkp.DockerCredentialUsage.ASSET_PUBLISHING], + }), + ], + }); + }); + + behavior('synth action receives install commands and access to relevant credentials', (suite) => { + suite.legacy(() => { + pipeline.addApplicationStage(new DockerAssetApp(app, 'App1')); + + const expectedCredsConfig = JSON.stringify({ + version: '1.0', + domainCredentials: { 'synth.example.com': { secretsManagerSecretId: secretSynthArn } }, + }); + + expect(pipelineStack).toHaveResourceLike('AWS::CodeBuild::Project', { + Environment: { Image: 'aws/codebuild/standard:5.0' }, + // Prove we're looking at the Synth project + ServiceRole: { 'Fn::GetAtt': ['CdkPipelineBuildSynthCdkBuildProjectRole5E173C62', 'Arn'] }, + Source: { + BuildSpec: encodedJson(deepObjectLike({ + phases: { + pre_build: { + commands: [ + 'npm ci', + 'mkdir $HOME/.cdk', + `echo '${expectedCredsConfig}' > $HOME/.cdk/cdk-docker-creds.json`, + ], + }, + }, + })), + }, + }); + expect(pipelineStack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], + Effect: 'Allow', + Resource: secretSynthArn, + }), + Version: '2012-10-17', + }, + Roles: [{ Ref: 'CdkPipelineBuildSynthCdkBuildProjectRole5E173C62' }], + }); + }); + }); + + behavior('synth action receives Windows install commands if a Windows image is detected', (suite) => { + suite.legacy(() => { + pipeline = new TestGitHubNpmPipeline(pipelineStack, 'Cdk2', { + dockerCredentials: [ + cdkp.DockerCredential.customRegistry('synth.example.com', secretSynth, { + usages: [cdkp.DockerCredentialUsage.SYNTH], + }), + cdkp.DockerCredential.customRegistry('selfupdate.example.com', secretUpdate, { + usages: [cdkp.DockerCredentialUsage.SELF_UPDATE], + }), + cdkp.DockerCredential.customRegistry('publish.example.com', secretPublish, { + usages: [cdkp.DockerCredentialUsage.ASSET_PUBLISHING], + }), + ], + npmSynthOptions: { + environment: { + buildImage: cb.WindowsBuildImage.WINDOWS_BASE_2_0, + }, + }, + }); + pipeline.addApplicationStage(new DockerAssetApp(app, 'App1')); + + const expectedCredsConfig = JSON.stringify({ + version: '1.0', + domainCredentials: { 'synth.example.com': { secretsManagerSecretId: secretSynthArn } }, + }); + + expect(pipelineStack).toHaveResourceLike('AWS::CodeBuild::Project', { + Environment: { Image: 'aws/codebuild/windows-base:2.0' }, + // Prove we're looking at the Synth project + ServiceRole: { 'Fn::GetAtt': ['Cdk2PipelineBuildSynthCdkBuildProjectRole9869128F', 'Arn'] }, + Source: { + BuildSpec: encodedJson(deepObjectLike({ + phases: { + pre_build: { + commands: [ + 'npm ci', + 'mkdir %USERPROFILE%\\.cdk', + `echo '${expectedCredsConfig}' > %USERPROFILE%\\.cdk\\cdk-docker-creds.json`, + ], + }, + }, + })), + }, + }); + }); + }); + + behavior('self-update receives install commands and access to relevant credentials', (suite) => { + suite.legacy(() => { + pipeline.addApplicationStage(new DockerAssetApp(app, 'App1')); + + const expectedCredsConfig = JSON.stringify({ + version: '1.0', + domainCredentials: { 'selfupdate.example.com': { secretsManagerSecretId: secretUpdateArn } }, + }); + + expect(pipelineStack).toHaveResourceLike('AWS::CodeBuild::Project', { + Environment: { Image: 'aws/codebuild/standard:5.0' }, + // Prove we're looking at the SelfMutate project + ServiceRole: { 'Fn::GetAtt': ['CdkUpdatePipelineSelfMutationRoleAAF1B580', 'Arn'] }, + Source: { + BuildSpec: encodedJson(deepObjectLike({ + phases: { + install: { + commands: [ + 'npm install -g aws-cdk', + 'mkdir $HOME/.cdk', + `echo '${expectedCredsConfig}' > $HOME/.cdk/cdk-docker-creds.json`, + ], + }, + }, + })), + }, + }); + expect(pipelineStack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], + Effect: 'Allow', + Resource: secretUpdateArn, + }), + Version: '2012-10-17', + }, + Roles: [{ Ref: 'CdkUpdatePipelineSelfMutationRoleAAF1B580' }], + }); + }); + }); + + behavior('asset publishing receives install commands and access to relevant credentials', (suite) => { + suite.legacy(() => { + pipeline.addApplicationStage(new DockerAssetApp(app, 'App1')); + + const expectedCredsConfig = JSON.stringify({ + version: '1.0', + domainCredentials: { 'publish.example.com': { secretsManagerSecretId: secretPublishArn } }, + }); + + expect(pipelineStack).toHaveResourceLike('AWS::CodeBuild::Project', { + Environment: { Image: 'aws/codebuild/standard:5.0' }, + // Prove we're looking at the Publishing project + ServiceRole: { 'Fn::GetAtt': ['CdkAssetsDockerRole484B6DD3', 'Arn'] }, + Source: { + BuildSpec: encodedJson(deepObjectLike({ + phases: { + install: { + commands: [ + 'mkdir $HOME/.cdk', + `echo '${expectedCredsConfig}' > $HOME/.cdk/cdk-docker-creds.json`, + 'npm install -g cdk-assets', + ], + }, + }, + })), + }, + }); + expect(pipelineStack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], + Effect: 'Allow', + Resource: secretPublishArn, + }), + Version: '2012-10-17', + }, + Roles: [{ Ref: 'CdkAssetsDockerRole484B6DD3' }], + }); + }); + }); + +}); + class PlainStackApp extends Stage { constructor(scope: Construct, id: string, props?: StageProps) { super(scope, id, props); diff --git a/packages/@aws-cdk/pipelines/test/pipeline.test.ts b/packages/@aws-cdk/pipelines/test/pipeline.test.ts index d23b3932a84de..fdb20d19ae396 100644 --- a/packages/@aws-cdk/pipelines/test/pipeline.test.ts +++ b/packages/@aws-cdk/pipelines/test/pipeline.test.ts @@ -328,7 +328,7 @@ behavior('pipeline has self-mutation stage', (suite) => { BuildSpec: encodedJson(deepObjectLike({ phases: { install: { - commands: 'npm install -g aws-cdk', + commands: ['npm install -g aws-cdk'], }, build: { commands: arrayWith('cdk -a . deploy PipelineStack --require-approval=never --verbose'), @@ -449,7 +449,7 @@ behavior('can control fix/CLI version used in pipeline selfupdate', (suite) => { BuildSpec: encodedJson(deepObjectLike({ phases: { install: { - commands: 'npm install -g aws-cdk@1.2.3', + commands: ['npm install -g aws-cdk@1.2.3'], }, }, })), diff --git a/packages/@aws-cdk/pipelines/test/testutil.ts b/packages/@aws-cdk/pipelines/test/testutil.ts index 26d7f686399af..e654299d85182 100644 --- a/packages/@aws-cdk/pipelines/test/testutil.ts +++ b/packages/@aws-cdk/pipelines/test/testutil.ts @@ -32,11 +32,16 @@ export class TestApp extends App { } } +export interface TestGitHubNpmPipelineExtraProps { + readonly sourceArtifact?: codepipeline.Artifact; + readonly npmSynthOptions?: Partial; +} + export class TestGitHubNpmPipeline extends cdkp.CdkPipeline { public readonly sourceArtifact: codepipeline.Artifact; public readonly cloudAssemblyArtifact: codepipeline.Artifact; - constructor(scope: Construct, id: string, props?: Partial & { readonly sourceArtifact?: codepipeline.Artifact } ) { + constructor(scope: Construct, id: string, props?: Partial & TestGitHubNpmPipelineExtraProps ) { const sourceArtifact = props?.sourceArtifact ?? new codepipeline.Artifact(); const cloudAssemblyArtifact = props?.cloudAssemblyArtifact ?? new codepipeline.Artifact(); @@ -45,6 +50,7 @@ export class TestGitHubNpmPipeline extends cdkp.CdkPipeline { synthAction: cdkp.SimpleSynthAction.standardNpmSynth({ sourceArtifact, cloudAssemblyArtifact, + ...props?.npmSynthOptions, }), cloudAssemblyArtifact, ...props, @@ -127,4 +133,4 @@ export function stringNoLongerThan(length: number): PropertyMatcher { return true; }); -} \ No newline at end of file +}