From 5cc0d3514dd6c1bedd8233ec48074257b003fed0 Mon Sep 17 00:00:00 2001 From: Josh Kellendonk Date: Fri, 19 Aug 2022 06:35:18 -0600 Subject: [PATCH] fix(cli): build assets before deploying any stacks (#21513) Changes the CDK CLI to build assets before deploying any stacks. This allows the CDK CLI to catch docker build errors, such as from rate limiting, before any stacks are deployed. Moving asset builds this early prevents these build failures from interrupting multi-stack deployments part way through. Fixes #21511 ---- ### All Submissions: * [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../lib/api/cloudformation-deployments.ts | 71 ++++++++- packages/aws-cdk/lib/build.ts | 23 +++ packages/aws-cdk/lib/cdk-toolkit.ts | 24 +++ packages/aws-cdk/lib/util/asset-publishing.ts | 52 ++++++- .../api/cloudformation-deployments.test.ts | 142 +++++++++++++++++- packages/aws-cdk/test/build.test.ts | 38 +++++ .../cdk-assets/lib/private/asset-handler.ts | 11 ++ .../lib/private/handlers/container-images.ts | 82 +++++++--- .../cdk-assets/lib/private/handlers/files.ts | 2 + packages/cdk-assets/lib/publishing.ts | 64 +++++++- .../cdk-assets/test/docker-images.test.ts | 60 +++++++- 11 files changed, 535 insertions(+), 34 deletions(-) create mode 100644 packages/aws-cdk/lib/build.ts create mode 100644 packages/aws-cdk/test/build.test.ts diff --git a/packages/aws-cdk/lib/api/cloudformation-deployments.ts b/packages/aws-cdk/lib/api/cloudformation-deployments.ts index ce81ab8d78a4d..6ca75c57bf5af 100644 --- a/packages/aws-cdk/lib/api/cloudformation-deployments.ts +++ b/packages/aws-cdk/lib/api/cloudformation-deployments.ts @@ -2,7 +2,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import { AssetManifest } from 'cdk-assets'; import { Tag } from '../cdk-toolkit'; import { debug, warning } from '../logging'; -import { publishAssets } from '../util/asset-publishing'; +import { buildAssets, publishAssets } from '../util/asset-publishing'; import { Mode } from './aws-auth/credentials'; import { ISDK } from './aws-auth/sdk'; import { SdkProvider } from './aws-auth/sdk-provider'; @@ -236,6 +236,43 @@ export interface DeployStackOptions { * @default - Use the stored template */ readonly overrideTemplate?: any; + + /** + * Whether to build assets before publishing. + * + * @default true To remain backward compatible. + */ + readonly buildAssets?: boolean; +} + +export interface BuildStackAssetsOptions { + /** + * Stack with assets to build. + */ + readonly stack: cxapi.CloudFormationStackArtifact; + + /** + * Name of the toolkit stack, if not the default name. + * + * @default 'CDKToolkit' + */ + readonly toolkitStackName?: string; + + /** + * Execution role for the building. + * + * @default - Current role + */ + readonly roleArn?: string; +} + +interface PublishStackAssetsOptions { + /** + * Whether to build assets before publishing. + * + * @default true To remain backward compatible. + */ + readonly buildAssets?: boolean; } export interface DestroyStackOptions { @@ -340,7 +377,9 @@ export class CloudFormationDeployments { // Publish any assets before doing the actual deploy (do not publish any assets on import operation) if (options.resourcesToImport === undefined) { - await this.publishStackAssets(options.stack, toolkitInfo); + await this.publishStackAssets(options.stack, toolkitInfo, { + buildAssets: options.buildAssets ?? true, + }); } // Do a verification of the bootstrap stack version @@ -451,10 +490,32 @@ export class CloudFormationDeployments { }; } + /** + * Build a stack's assets. + */ + public async buildStackAssets(options: BuildStackAssetsOptions) { + const { stackSdk, resolvedEnvironment } = await this.prepareSdkFor(options.stack, options.roleArn); + const toolkitInfo = await ToolkitInfo.lookup(resolvedEnvironment, stackSdk, options.toolkitStackName); + + const stackEnv = await this.sdkProvider.resolveEnvironment(options.stack.environment); + const assetArtifacts = options.stack.dependencies.filter(cxapi.AssetManifestArtifact.isAssetManifestArtifact); + + for (const assetArtifact of assetArtifacts) { + await this.validateBootstrapStackVersion( + options.stack.stackName, + assetArtifact.requiresBootstrapStackVersion, + assetArtifact.bootstrapStackVersionSsmParameter, + toolkitInfo); + + const manifest = AssetManifest.fromFile(assetArtifact.file); + await buildAssets(manifest, this.sdkProvider, stackEnv); + } + } + /** * Publish all asset manifests that are referenced by the given stack */ - private async publishStackAssets(stack: cxapi.CloudFormationStackArtifact, toolkitInfo: ToolkitInfo) { + private async publishStackAssets(stack: cxapi.CloudFormationStackArtifact, toolkitInfo: ToolkitInfo, options: PublishStackAssetsOptions = {}) { const stackEnv = await this.sdkProvider.resolveEnvironment(stack.environment); const assetArtifacts = stack.dependencies.filter(cxapi.AssetManifestArtifact.isAssetManifestArtifact); @@ -466,7 +527,9 @@ export class CloudFormationDeployments { toolkitInfo); const manifest = AssetManifest.fromFile(assetArtifact.file); - await publishAssets(manifest, this.sdkProvider, stackEnv); + await publishAssets(manifest, this.sdkProvider, stackEnv, { + buildAssets: options.buildAssets ?? true, + }); } } diff --git a/packages/aws-cdk/lib/build.ts b/packages/aws-cdk/lib/build.ts new file mode 100644 index 0000000000000..1fbadae36ae09 --- /dev/null +++ b/packages/aws-cdk/lib/build.ts @@ -0,0 +1,23 @@ +import * as cxapi from '@aws-cdk/cx-api'; + +type Options = { + buildStackAssets: (stack: cxapi.CloudFormationStackArtifact) => Promise; +}; + +export async function buildAllStackAssets(stacks: cxapi.CloudFormationStackArtifact[], options: Options): Promise { + const { buildStackAssets } = options; + + const buildingErrors: Error[] = []; + + for (const stack of stacks) { + try { + await buildStackAssets(stack); + } catch (err) { + buildingErrors.push(err); + } + } + + if (buildingErrors.length) { + throw Error(`Building Assets Failed: ${buildingErrors.join(', ')}`); + } +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 6652c5feda6e0..debd5a32fa998 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -14,6 +14,7 @@ import { CloudExecutable } from './api/cxapp/cloud-executable'; import { findCloudWatchLogGroups } from './api/logs/find-cloudwatch-logs'; import { CloudWatchLogEventMonitor } from './api/logs/logs-monitor'; import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor'; +import { buildAllStackAssets } from './build'; import { printSecurityDiff, printStackDiff, RequireApproval } from './diff'; import { ResourceImporter } from './import'; import { data, debug, error, highlight, print, success, warning } from './logging'; @@ -166,6 +167,28 @@ export class CdkToolkit { const stackOutputs: { [key: string]: any } = { }; const outputsFile = options.outputsFile; + const buildStackAssets = async (stack: cxapi.CloudFormationStackArtifact) => { + // Check whether the stack has an asset manifest before trying to build and publish. + if (!stack.dependencies.some(cxapi.AssetManifestArtifact.isAssetManifestArtifact)) { + return; + } + + print('%s: building assets...\n', chalk.bold(stack.displayName)); + await this.props.cloudFormation.buildStackAssets({ + stack, + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName, + }); + print('\n%s: assets built\n', chalk.bold(stack.displayName)); + }; + + try { + await buildAllStackAssets(stacks.stackArtifacts, { buildStackAssets }); + } catch (e) { + error('\n ❌ Building assets failed: %s', e); + throw e; + } + for (const stack of stacks.stackArtifacts) { if (stacks.stackCount !== 1) { highlight(stack.displayName); } if (!stack.environment) { @@ -234,6 +257,7 @@ export class CdkToolkit { rollback: options.rollback, hotswap: options.hotswap, extraUserAgent: options.extraUserAgent, + buildAssets: false, }); const message = result.noOp diff --git a/packages/aws-cdk/lib/util/asset-publishing.ts b/packages/aws-cdk/lib/util/asset-publishing.ts index bfa204530ed08..b4dbe49ceae0d 100644 --- a/packages/aws-cdk/lib/util/asset-publishing.ts +++ b/packages/aws-cdk/lib/util/asset-publishing.ts @@ -11,6 +11,13 @@ export interface PublishAssetsOptions { * Print progress at 'debug' level */ readonly quiet?: boolean; + + /** + * Whether to build assets before publishing. + * + * @default true To remain backward compatible. + */ + readonly buildAssets?: boolean; } /** @@ -30,7 +37,7 @@ export async function publishAssets( targetEnv.region === undefined || targetEnv.account === cxapi.UNKNOWN_REGION ) { - throw new Error(`Asset publishing requires resolved account and region, got ${JSON.stringify( targetEnv)}`); + throw new Error(`Asset publishing requires resolved account and region, got ${JSON.stringify(targetEnv)}`); } const publisher = new cdk_assets.AssetPublishing(manifest, { @@ -38,6 +45,8 @@ export async function publishAssets( progressListener: new PublishingProgressListener(options.quiet ?? false), throwOnError: false, publishInParallel: true, + buildAssets: options.buildAssets ?? true, + publishAssets: true, }); await publisher.publish(); if (publisher.hasFailures) { @@ -45,6 +54,47 @@ export async function publishAssets( } } +export interface BuildAssetsOptions { + /** + * Print progress at 'debug' level + */ + readonly quiet?: boolean; +} + +/** + * Use cdk-assets to build all assets in the given manifest. + */ +export async function buildAssets( + manifest: cdk_assets.AssetManifest, + sdk: SdkProvider, + targetEnv: cxapi.Environment, + options: BuildAssetsOptions = {}, +) { + // This shouldn't really happen (it's a programming error), but we don't have + // the types here to guide us. Do an runtime validation to be super super sure. + if ( + targetEnv.account === undefined || + targetEnv.account === cxapi.UNKNOWN_ACCOUNT || + targetEnv.region === undefined || + targetEnv.account === cxapi.UNKNOWN_REGION + ) { + throw new Error(`Asset building requires resolved account and region, got ${JSON.stringify(targetEnv)}`); + } + + const publisher = new cdk_assets.AssetPublishing(manifest, { + aws: new PublishingAws(sdk, targetEnv), + progressListener: new PublishingProgressListener(options.quiet ?? false), + throwOnError: false, + publishInParallel: true, + buildAssets: true, + publishAssets: false, + }); + await publisher.publish(); + if (publisher.hasFailures) { + throw new Error('Failed to build one or more assets. See the error messages above for more information.'); + } +} + class PublishingAws implements cdk_assets.IAws { private sdkCache: Map = new Map(); diff --git a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts index d34563d1e0970..26fb0f8bc5d7b 100644 --- a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts +++ b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts @@ -1,10 +1,17 @@ jest.mock('../../lib/api/deploy-stack'); +jest.mock('../../lib/util/asset-publishing'); +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import * as cxapi from '@aws-cdk/cx-api'; import { CloudFormation } from 'aws-sdk'; import { CloudFormationDeployments } from '../../lib/api/cloudformation-deployments'; import { deployStack } from '../../lib/api/deploy-stack'; -import { ToolkitInfo } from '../../lib/api/toolkit-info'; +import { EcrRepositoryInfo, ToolkitInfo } from '../../lib/api/toolkit-info'; import { CloudFormationStack } from '../../lib/api/util/cloudformation'; +import { buildAssets, publishAssets } from '../../lib/util/asset-publishing'; import { testStack } from '../util'; import { mockBootstrapStack, MockSdkProvider } from '../util/mock-sdk'; import { FakeCloudformationStack } from './fake-cloudformation-stack'; @@ -55,6 +62,37 @@ function mockSuccessfulBootstrapStackLookup(props?: Record) { mockToolkitInfoLookup.mockResolvedValue(ToolkitInfo.fromStack(fakeStack, sdkProvider.sdk)); } +test('deployStack builds assets by default for backward compatibility', async () => { + const stack = testStackWithAssetManifest(); + + // WHEN + await deployments.deployStack({ + stack, + }); + + // THEN + const expectedOptions = expect.objectContaining({ + buildAssets: true, + }); + expect(publishAssets).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expectedOptions); +}); + +test('deployStack can disable asset building for prebuilds', async () => { + const stack = testStackWithAssetManifest(); + + // WHEN + await deployments.deployStack({ + stack, + buildAssets: false, + }); + + // THEN + const expectedOptions = expect.objectContaining({ + buildAssets: false, + }); + expect(publishAssets).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expectedOptions); +}); + test('passes through hotswap=true to deployStack()', async () => { // WHEN await deployments.deployStack({ @@ -843,6 +881,32 @@ test('readCurrentTemplateWithNestedStacks() succesfully ignores stacks without m }); }); +test('building assets', async () => { + // GIVEN + const stack = testStackWithAssetManifest(); + + // WHEN + await deployments.buildStackAssets({ + stack, + }); + + // THEN + const expectedAssetManifest = expect.objectContaining({ + directory: stack.assembly.directory, + manifest: expect.objectContaining({ + files: expect.objectContaining({ + fake: expect.anything(), + }), + }), + }); + const expectedEnvironment = expect.objectContaining({ + account: 'account', + name: 'aws://account/region', + region: 'region', + }); + expect(buildAssets).toBeCalledWith(expectedAssetManifest, sdkProvider, expectedEnvironment); +}); + function pushStackResourceSummaries(stackName: string, ...items: CloudFormation.StackResourceSummary[]) { if (!currentCfnStackResources[stackName]) { currentCfnStackResources[stackName] = []; @@ -860,3 +924,79 @@ function stackSummaryOf(logicalId: string, resourceType: string, physicalResourc LastUpdatedTimestamp: new Date(), }; } + +function testStackWithAssetManifest() { + const toolkitInfo = new class extends ToolkitInfo { + public found: boolean = true; + public bucketUrl: string = 's3://fake/here'; + public bucketName: string = 'fake'; + public version: number = 1234; + public get bootstrapStack(): CloudFormationStack { + throw new Error('This should never happen'); + }; + + constructor() { + super(sdkProvider.sdk); + } + + public validateVersion(): Promise { + return Promise.resolve(); + } + + public prepareEcrRepository(): Promise { + return Promise.resolve({ + repositoryUri: 'fake', + }); + } + }; + + ToolkitInfo.lookup = mockToolkitInfoLookup = jest.fn().mockResolvedValue(toolkitInfo); + + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk.out.')); + fs.writeFileSync(path.join(outDir, 'assets.json'), JSON.stringify({ + version: '15.0.0', + files: { + fake: { + source: { + path: 'fake.json', + packaging: 'file', + }, + destinations: { + 'current_account-current_region': { + bucketName: 'fake-bucket', + objectKey: 'fake.json', + assumeRoleArn: 'arn:fake', + }, + }, + }, + }, + dockerImages: {}, + })); + fs.writeFileSync(path.join(outDir, 'template.json'), JSON.stringify({ + Resources: { + No: { Type: 'Resource' }, + }, + })); + + const builder = new cxapi.CloudAssemblyBuilder(outDir); + + builder.addArtifact('assets', { + type: cxschema.ArtifactType.ASSET_MANIFEST, + properties: { + file: 'assets.json', + }, + environment: 'aws://account/region', + }); + + builder.addArtifact('stack', { + type: cxschema.ArtifactType.AWS_CLOUDFORMATION_STACK, + properties: { + templateFile: 'template.json', + }, + environment: 'aws://account/region', + dependencies: ['assets'], + }); + + const assembly = builder.buildAssembly(); + return assembly.getStackArtifact('stack'); +} \ No newline at end of file diff --git a/packages/aws-cdk/test/build.test.ts b/packages/aws-cdk/test/build.test.ts new file mode 100644 index 0000000000000..526307cfc7719 --- /dev/null +++ b/packages/aws-cdk/test/build.test.ts @@ -0,0 +1,38 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { buildAllStackAssets } from '../lib/build'; + +type Stack = cxapi.CloudFormationStackArtifact; + +describe('buildAllStackAssets', () => { + const A = { id: 'A' }; + const B = { id: 'B' }; + const C = { id: 'C' }; + const toPublish = [A, B, C] as unknown as Stack[]; + + const sleep = async (duration: number) => new Promise((resolve) => setTimeout(() => resolve(), duration)); + + test('build', async () => { + // GIVEN + const buildStackAssets = jest.fn(() => sleep(1)); + + // WHEN/THEN + await expect(buildAllStackAssets(toPublish, { buildStackAssets })) + .resolves + .toBeUndefined(); + + expect(buildStackAssets).toBeCalledTimes(3); + expect(buildStackAssets).toBeCalledWith(A); + expect(buildStackAssets).toBeCalledWith(B); + expect(buildStackAssets).toBeCalledWith(C); + }); + + test('errors', async () => { + // GIVEN + const buildStackAssets = async () => { throw new Error('Message'); }; + + // WHEN/THEN + await expect(buildAllStackAssets(toPublish, { buildStackAssets })) + .rejects + .toThrow('Building Assets Failed: Error: Message, Error: Message, Error: Message'); + }); +}); diff --git a/packages/cdk-assets/lib/private/asset-handler.ts b/packages/cdk-assets/lib/private/asset-handler.ts index 9b4eb4fa305c7..b403a2bfab000 100644 --- a/packages/cdk-assets/lib/private/asset-handler.ts +++ b/packages/cdk-assets/lib/private/asset-handler.ts @@ -2,7 +2,18 @@ import { IAws } from '../aws'; import { EventType } from '../progress'; import { DockerFactory } from './docker'; +/** + * Handler for asset building and publishing. + */ export interface IAssetHandler { + /** + * Build the asset. + */ + build(): Promise; + + /** + * Publish the asset. + */ publish(): Promise; } diff --git a/packages/cdk-assets/lib/private/handlers/container-images.ts b/packages/cdk-assets/lib/private/handlers/container-images.ts index 88b56bf11e00a..1d9e6ac46f53c 100644 --- a/packages/cdk-assets/lib/private/handlers/container-images.ts +++ b/packages/cdk-assets/lib/private/handlers/container-images.ts @@ -1,5 +1,6 @@ import * as path from 'path'; import { DockerImageDestination } from '@aws-cdk/cloud-assembly-schema'; +import type * as AWS from 'aws-sdk'; import { DockerImageManifestEntry } from '../../asset-manifest'; import { EventType } from '../../progress'; import { IAssetHandler, IHandlerHost } from '../asset-handler'; @@ -7,50 +8,85 @@ import { Docker } from '../docker'; import { replaceAwsPlaceholders } from '../placeholders'; import { shell } from '../shell'; +interface ContainerImageAssetHandlerInit { + readonly ecr: AWS.ECR; + readonly repoUri: string; + readonly imageUri: string; + readonly destinationAlreadyExists: boolean; +} + export class ContainerImageAssetHandler implements IAssetHandler { + private init?: ContainerImageAssetHandlerInit; + constructor( private readonly workDir: string, private readonly asset: DockerImageManifestEntry, private readonly host: IHandlerHost) { } + public async build(): Promise { + const initOnce = await this.initOnce(); + + if (initOnce.destinationAlreadyExists) { return; } + if (this.host.aborted) { return; } + + const dockerForBuilding = await this.host.dockerFactory.forBuild({ + repoUri: initOnce.repoUri, + logger: (m: string) => this.host.emitMessage(EventType.DEBUG, m), + ecr: initOnce.ecr, + }); + + const builder = new ContainerImageBuilder(dockerForBuilding, this.workDir, this.asset, this.host); + const localTagName = await builder.build(); + + if (localTagName === undefined || this.host.aborted) { return; } + if (this.host.aborted) { return; } + + await dockerForBuilding.tag(localTagName, initOnce.imageUri); + } + public async publish(): Promise { + const initOnce = await this.initOnce(); + + if (initOnce.destinationAlreadyExists) { return; } + if (this.host.aborted) { return; } + + const dockerForPushing = await this.host.dockerFactory.forEcrPush({ + repoUri: initOnce.repoUri, + logger: (m: string) => this.host.emitMessage(EventType.DEBUG, m), + ecr: initOnce.ecr, + }); + + if (this.host.aborted) { return; } + + this.host.emitMessage(EventType.UPLOAD, `Push ${initOnce.imageUri}`); + await dockerForPushing.push(initOnce.imageUri); + } + + private async initOnce(): Promise { + if (this.init) { + return this.init; + } + const destination = await replaceAwsPlaceholders(this.asset.destination, this.host.aws); const ecr = await this.host.aws.ecrClient(destination); const account = async () => (await this.host.aws.discoverCurrentAccount())?.accountId; - const repoUri = await repositoryUri(ecr, destination.repositoryName); + const repoUri = await repositoryUri(ecr, destination.repositoryName); if (!repoUri) { throw new Error(`No ECR repository named '${destination.repositoryName}' in account ${await account()}. Is this account bootstrapped?`); } const imageUri = `${repoUri}:${destination.imageTag}`; - if (await this.destinationAlreadyExists(ecr, destination, imageUri)) { return; } - if (this.host.aborted) { return; } - - const containerImageDockerOptions = { - repoUri, - logger: (m: string) => this.host.emitMessage(EventType.DEBUG, m), + this.init = { + imageUri, ecr, + repoUri, + destinationAlreadyExists: await this.destinationAlreadyExists(ecr, destination, imageUri), }; - const dockerForBuilding = await this.host.dockerFactory.forBuild(containerImageDockerOptions); - - const builder = new ContainerImageBuilder(dockerForBuilding, this.workDir, this.asset, this.host); - const localTagName = await builder.build(); - - if (localTagName === undefined || this.host.aborted) { - return; - } - - this.host.emitMessage(EventType.UPLOAD, `Push ${imageUri}`); - if (this.host.aborted) { return; } - - await dockerForBuilding.tag(localTagName, imageUri); - - const dockerForPushing = await this.host.dockerFactory.forEcrPush(containerImageDockerOptions); - await dockerForPushing.push(imageUri); + return this.init; } /** diff --git a/packages/cdk-assets/lib/private/handlers/files.ts b/packages/cdk-assets/lib/private/handlers/files.ts index cf77eda43dddc..e04c44721ce4d 100644 --- a/packages/cdk-assets/lib/private/handlers/files.ts +++ b/packages/cdk-assets/lib/private/handlers/files.ts @@ -27,6 +27,8 @@ export class FileAssetHandler implements IAssetHandler { this.fileCacheRoot = path.join(workDir, '.cache'); } + public async build(): Promise {} + public async publish(): Promise { const destination = await replaceAwsPlaceholders(this.asset.destination, this.host.aws); const s3Url = `s3://${destination.bucketName}/${destination.objectKey}`; diff --git a/packages/cdk-assets/lib/publishing.ts b/packages/cdk-assets/lib/publishing.ts index a4eb709df0efd..3c57942315a3b 100644 --- a/packages/cdk-assets/lib/publishing.ts +++ b/packages/cdk-assets/lib/publishing.ts @@ -31,6 +31,20 @@ export interface AssetPublishingOptions { * @default false */ readonly publishInParallel?: boolean; + + /** + * Whether to build assets + * + * @default true + */ + readonly buildAssets?: boolean; + + /** + * Whether to publish assets + * + * @default true + */ + readonly publishAssets?: boolean; } /** @@ -66,11 +80,46 @@ export class AssetPublishing implements IPublishProgress { private aborted = false; private readonly handlerHost: IHandlerHost; private readonly publishInParallel: boolean; + private readonly buildAssets: boolean; + private readonly publishAssets: boolean; + private readonly startMessagePrefix: string; + private readonly successMessagePrefix: string; + private readonly errorMessagePrefix: string; constructor(private readonly manifest: AssetManifest, private readonly options: AssetPublishingOptions) { this.assets = manifest.entries; this.totalOperations = this.assets.length; this.publishInParallel = options.publishInParallel ?? false; + this.buildAssets = options.buildAssets ?? true; + this.publishAssets = options.publishAssets ?? true; + + const getMessages = () => { + if (this.buildAssets && this.publishAssets) { + return { + startMessagePrefix: 'Building and publishing', + successMessagePrefix: 'Built and published', + errorMessagePrefix: 'Error building and publishing', + }; + } else if (this.buildAssets) { + return { + startMessagePrefix: 'Building', + successMessagePrefix: 'Built', + errorMessagePrefix: 'Error building', + }; + } else { + return { + startMessagePrefix: 'Publishing', + successMessagePrefix: 'Published', + errorMessagePrefix: 'Error publishing', + }; + } + }; + + const messages = getMessages(); + + this.startMessagePrefix = messages.startMessagePrefix; + this.successMessagePrefix = messages.successMessagePrefix; + this.errorMessagePrefix = messages.errorMessagePrefix; const self = this; this.handlerHost = { @@ -96,7 +145,7 @@ export class AssetPublishing implements IPublishProgress { } if ((this.options.throwOnError ?? true) && this.failures.length > 0) { - throw new Error(`Error publishing: ${this.failures.map(e => e.error.message)}`); + throw new Error(`${this.errorMessagePrefix}: ${this.failures.map(e => e.error.message)}`); } } @@ -107,17 +156,24 @@ export class AssetPublishing implements IPublishProgress { */ private async publishAsset(asset: IManifestEntry) { try { - if (this.progressEvent(EventType.START, `Publishing ${asset.id}`)) { return false; } + if (this.progressEvent(EventType.START, `${this.startMessagePrefix} ${asset.id}`)) { return false; } const handler = makeAssetHandler(this.manifest, asset, this.handlerHost); - await handler.publish(); + + if (this.buildAssets) { + await handler.build(); + } + + if (this.publishAssets) { + await handler.publish(); + } if (this.aborted) { throw new Error('Aborted'); } this.completedOperations++; - if (this.progressEvent(EventType.SUCCESS, `Published ${asset.id}`)) { return false; } + if (this.progressEvent(EventType.SUCCESS, `${this.successMessagePrefix} ${asset.id}`)) { return false; } } catch (e) { this.failures.push({ asset, error: e }); this.completedOperations++; diff --git a/packages/cdk-assets/test/docker-images.test.ts b/packages/cdk-assets/test/docker-images.test.ts index b6fa692dd24a4..19662fff8f055 100644 --- a/packages/cdk-assets/test/docker-images.test.ts +++ b/packages/cdk-assets/test/docker-images.test.ts @@ -184,7 +184,7 @@ describe('with a complete manifest', () => { test('Displays an error if the ECR repository cannot be found', async () => { aws.mockEcr.describeImages = mockedApiFailure('RepositoryNotFoundException', 'Repository not Found'); - await expect(pub.publish()).rejects.toThrow('Error publishing: Repository not Found'); + await expect(pub.publish()).rejects.toThrow('Error building and publishing: Repository not Found'); }); test('successful run does not need to query account ID', async () => { @@ -439,3 +439,61 @@ test('logging in twice for two repository domains (containing account id & regio expectAllSpawns(); expect(true).toBeTruthy(); // Expect no exception, satisfy linter }); + +test('building only', async () => { + const pub = new AssetPublishing(AssetManifest.fromPath('/multi/cdk.out'), { + aws, + throwOnError: false, + buildAssets: true, + publishAssets: false, + }); + + aws.mockEcr.describeImages = mockedApiFailure('ImageNotFoundException', 'File does not exist'); + aws.mockEcr.getAuthorizationToken = mockedApiResult({ + authorizationData: [ + { authorizationToken: 'dXNlcjpwYXNz', proxyEndpoint: 'https://proxy.com/' }, + ], + }); + + const expectAllSpawns = mockSpawn( + { commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'https://proxy.com/'] }, + { commandLine: ['docker', 'inspect', 'cdkasset-theasset1'], exitCode: 1 }, + { commandLine: ['docker', 'build', '--tag', 'cdkasset-theasset1', '.'], cwd: '/multi/cdk.out/dockerdir' }, + { commandLine: ['docker', 'tag', 'cdkasset-theasset1', '12345.amazonaws.com/repo:theAsset1'] }, + { commandLine: ['docker', 'inspect', 'cdkasset-theasset2'], exitCode: 1 }, + { commandLine: ['docker', 'build', '--tag', 'cdkasset-theasset2', '.'], cwd: '/multi/cdk.out/dockerdir' }, + { commandLine: ['docker', 'tag', 'cdkasset-theasset2', '12345.amazonaws.com/repo:theAsset2'] }, + ); + + await pub.publish(); + + expectAllSpawns(); + expect(true).toBeTruthy(); // Expect no exception, satisfy linter +}); + +test('publishing only', async () => { + const pub = new AssetPublishing(AssetManifest.fromPath('/multi/cdk.out'), { + aws, + throwOnError: false, + buildAssets: false, + publishAssets: true, + }); + + aws.mockEcr.describeImages = mockedApiFailure('ImageNotFoundException', 'File does not exist'); + aws.mockEcr.getAuthorizationToken = mockedApiResult({ + authorizationData: [ + { authorizationToken: 'dXNlcjpwYXNz', proxyEndpoint: 'https://proxy.com/' }, + ], + }); + + const expectAllSpawns = mockSpawn( + { commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'https://proxy.com/'] }, + { commandLine: ['docker', 'push', '12345.amazonaws.com/aws-cdk/assets:theAsset1'] }, + { commandLine: ['docker', 'push', '12345.amazonaws.com/aws-cdk/assets:theAsset2'] }, + ); + + await pub.publish(); + + expectAllSpawns(); + expect(true).toBeTruthy(); // Expect no exception, satisfy linter +});