From 25d5d60fd0ed852b1817d749b65c68d5279b38a3 Mon Sep 17 00:00:00 2001 From: Rico Hermans Date: Tue, 16 May 2023 14:10:50 +0200 Subject: [PATCH] feat(cli): assets can now depend on stacks (#25536) Introduce a work graph, in which building assets, publishing assets, and deploying stacks are nodes. Each can have their own sets of dependencies which will be respected in a parallel deployment. This change supports an upcoming change for asset publishing. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/cx-api/jest.config.js | 2 +- .../cloud-assembly-schema/lib/manifest.ts | 10 + .../lib/artifacts/cloudformation-artifact.ts | 35 ++ .../nested-cloud-assembly-artifact.ts | 37 +- .../lib/artifacts/tree-cloud-artifact.ts | 37 +- .../aws-cdk-lib/cx-api/lib/cloud-assembly.ts | 16 +- .../cx-api/test/cloud-assembly.test.ts | 7 + .../test/fixtures/asset-depends/manifest.json | 29 ++ .../test/fixtures/asset-depends/template.json | 7 + packages/aws-cdk/THIRD_PARTY_LICENSES | 80 ---- .../aws-cdk/lib/api/aws-auth/sdk-provider.ts | 1 + packages/aws-cdk/lib/api/cxapp/exec.ts | 5 +- ...ormation-deployments.ts => deployments.ts} | 355 ++++++++++------ .../lib/api/logs/find-cloudwatch-logs.ts | 4 +- packages/aws-cdk/lib/cdk-toolkit.ts | 101 +++-- packages/aws-cdk/lib/cli.ts | 6 +- packages/aws-cdk/lib/deploy.ts | 69 ---- packages/aws-cdk/lib/import.ts | 4 +- packages/aws-cdk/lib/util/asset-publishing.ts | 6 +- .../aws-cdk/lib/util/work-graph-builder.ts | 145 +++++++ packages/aws-cdk/lib/util/work-graph-types.ts | 54 +++ packages/aws-cdk/lib/util/work-graph.ts | 298 ++++++++++++++ packages/aws-cdk/package.json | 1 - .../api/cloudformation-deployments.test.ts | 151 +------ packages/aws-cdk/test/cdk-toolkit.test.ts | 93 +---- packages/aws-cdk/test/deploy.test.ts | 200 --------- packages/aws-cdk/test/diff.test.ts | 12 +- packages/aws-cdk/test/import.test.ts | 8 +- .../aws-cdk/test/stage-manifest/manifest.json | 24 ++ .../aws-cdk/test/work-graph-builder.test.ts | 189 +++++++++ packages/aws-cdk/test/work-graph.test.ts | 380 ++++++++++++++++++ packages/aws-cdk/tsconfig.json | 1 + packages/cdk-assets/jest.config.js | 1 + .../cdk-assets/lib/private/asset-handler.ts | 5 + .../lib/private/handlers/container-images.ts | 5 + .../cdk-assets/lib/private/handlers/files.ts | 17 + packages/cdk-assets/lib/publishing.ts | 78 +++- 37 files changed, 1695 insertions(+), 778 deletions(-) create mode 100644 packages/aws-cdk-lib/cx-api/test/fixtures/asset-depends/manifest.json create mode 100644 packages/aws-cdk-lib/cx-api/test/fixtures/asset-depends/template.json rename packages/aws-cdk/lib/api/{cloudformation-deployments.ts => deployments.ts} (57%) delete mode 100644 packages/aws-cdk/lib/deploy.ts create mode 100644 packages/aws-cdk/lib/util/work-graph-builder.ts create mode 100644 packages/aws-cdk/lib/util/work-graph-types.ts create mode 100644 packages/aws-cdk/lib/util/work-graph.ts delete mode 100644 packages/aws-cdk/test/deploy.test.ts create mode 100644 packages/aws-cdk/test/stage-manifest/manifest.json create mode 100644 packages/aws-cdk/test/work-graph-builder.test.ts create mode 100644 packages/aws-cdk/test/work-graph.test.ts diff --git a/packages/@aws-cdk/cx-api/jest.config.js b/packages/@aws-cdk/cx-api/jest.config.js index 095efaa522407..751c263a6e75c 100644 --- a/packages/@aws-cdk/cx-api/jest.config.js +++ b/packages/@aws-cdk/cx-api/jest.config.js @@ -4,7 +4,7 @@ module.exports = { coverageThreshold: { global: { ...baseConfig.coverageThreshold.global, - branches: 75, + branches: 70, }, }, }; diff --git a/packages/aws-cdk-lib/cloud-assembly-schema/lib/manifest.ts b/packages/aws-cdk-lib/cloud-assembly-schema/lib/manifest.ts index 61af3b69ddb24..76069e0187d4c 100644 --- a/packages/aws-cdk-lib/cloud-assembly-schema/lib/manifest.ts +++ b/packages/aws-cdk-lib/cloud-assembly-schema/lib/manifest.ts @@ -48,6 +48,16 @@ export interface LoadManifestOptions { * @default false */ readonly skipEnumCheck?: boolean; + + /** + * Topologically sort all artifacts + * + * This parameter is only respected by the constructor of `CloudAssembly`. The + * property lives here for backwards compatibility reasons. + * + * @default true + */ + readonly topoSort?: boolean; } /** diff --git a/packages/aws-cdk-lib/cx-api/lib/artifacts/cloudformation-artifact.ts b/packages/aws-cdk-lib/cx-api/lib/artifacts/cloudformation-artifact.ts index 0ca21e10ea772..7cf279c96d924 100644 --- a/packages/aws-cdk-lib/cx-api/lib/artifacts/cloudformation-artifact.ts +++ b/packages/aws-cdk-lib/cx-api/lib/artifacts/cloudformation-artifact.ts @@ -5,7 +5,30 @@ import { CloudArtifact } from '../cloud-artifact'; import type { CloudAssembly } from '../cloud-assembly'; import { Environment, EnvironmentUtils } from '../environment'; +const CLOUDFORMATION_STACK_ARTIFACT_SYM = Symbol.for('@aws-cdk/cx-api.CloudFormationStackArtifact'); + export class CloudFormationStackArtifact extends CloudArtifact { + /** + * Checks if `art` is an instance of this class. + * + * Use this method instead of `instanceof` to properly detect `CloudFormationStackArtifact` + * instances, even when the construct library is symlinked. + * + * Explanation: in JavaScript, multiple copies of the `cx-api` library on + * disk are seen as independent, completely different libraries. As a + * consequence, the class `CloudFormationStackArtifact` in each copy of the `cx-api` library + * is seen as a different class, and an instance of one class will not test as + * `instanceof` the other class. `npm install` will not create installations + * like this, but users may manually symlink construct libraries together or + * use a monorepo tool: in those cases, multiple copies of the `cx-api` + * library can be accidentally installed, and `instanceof` will behave + * unpredictably. It is safest to avoid using `instanceof`, and using + * this type-testing method instead. + */ + public static isCloudFormationStackArtifact(art: any): art is CloudFormationStackArtifact { + return art && typeof art === 'object' && art[CLOUDFORMATION_STACK_ARTIFACT_SYM]; + } + /** * The file name of the template. */ @@ -183,3 +206,15 @@ export class CloudFormationStackArtifact extends CloudArtifact { return ret; } } + +/** + * Mark all instances of 'CloudFormationStackArtifact' + * + * Why not put this in the constructor? Because this is a class property, + * not an instance property. It applies to all instances of the class. + */ +Object.defineProperty(CloudFormationStackArtifact.prototype, CLOUDFORMATION_STACK_ARTIFACT_SYM, { + value: true, + enumerable: false, + writable: false, +}); diff --git a/packages/aws-cdk-lib/cx-api/lib/artifacts/nested-cloud-assembly-artifact.ts b/packages/aws-cdk-lib/cx-api/lib/artifacts/nested-cloud-assembly-artifact.ts index c8e6a266ac337..421caafb9acb2 100644 --- a/packages/aws-cdk-lib/cx-api/lib/artifacts/nested-cloud-assembly-artifact.ts +++ b/packages/aws-cdk-lib/cx-api/lib/artifacts/nested-cloud-assembly-artifact.ts @@ -3,10 +3,33 @@ import * as cxschema from '../../../cloud-assembly-schema'; import { CloudArtifact } from '../cloud-artifact'; import type { CloudAssembly } from '../cloud-assembly'; +const NESTED_CLOUD_ASSEMBLY_SYM = Symbol.for('@aws-cdk/cx-api.NestedCloudAssemblyArtifact'); + /** * Asset manifest is a description of a set of assets which need to be built and published */ export class NestedCloudAssemblyArtifact extends CloudArtifact { + /** + * Checks if `art` is an instance of this class. + * + * Use this method instead of `instanceof` to properly detect `NestedCloudAssemblyArtifact` + * instances, even when the construct library is symlinked. + * + * Explanation: in JavaScript, multiple copies of the `cx-api` library on + * disk are seen as independent, completely different libraries. As a + * consequence, the class `NestedCloudAssemblyArtifact` in each copy of the `cx-api` library + * is seen as a different class, and an instance of one class will not test as + * `instanceof` the other class. `npm install` will not create installations + * like this, but users may manually symlink construct libraries together or + * use a monorepo tool: in those cases, multiple copies of the `cx-api` + * library can be accidentally installed, and `instanceof` will behave + * unpredictably. It is safest to avoid using `instanceof`, and using + * this type-testing method instead. + */ + public static isNestedCloudAssemblyArtifact(art: any): art is NestedCloudAssemblyArtifact { + return art && typeof art === 'object' && art[NESTED_CLOUD_ASSEMBLY_SYM]; + } + /** * The relative directory name of the asset manifest */ @@ -40,4 +63,16 @@ export interface NestedCloudAssemblyArtifact { readonly nestedAssembly: CloudAssembly; // Declared in a different file -} \ No newline at end of file +} + +/** + * Mark all instances of 'NestedCloudAssemblyArtifact' + * + * Why not put this in the constructor? Because this is a class property, + * not an instance property. It applies to all instances of the class. + */ +Object.defineProperty(NestedCloudAssemblyArtifact.prototype, NESTED_CLOUD_ASSEMBLY_SYM, { + value: true, + enumerable: false, + writable: false, +}); \ No newline at end of file diff --git a/packages/aws-cdk-lib/cx-api/lib/artifacts/tree-cloud-artifact.ts b/packages/aws-cdk-lib/cx-api/lib/artifacts/tree-cloud-artifact.ts index 84f7c2474d94b..53226ce28c306 100644 --- a/packages/aws-cdk-lib/cx-api/lib/artifacts/tree-cloud-artifact.ts +++ b/packages/aws-cdk-lib/cx-api/lib/artifacts/tree-cloud-artifact.ts @@ -2,7 +2,30 @@ import * as cxschema from '../../../cloud-assembly-schema'; import { CloudArtifact } from '../cloud-artifact'; import { CloudAssembly } from '../cloud-assembly'; +const TREE_CLOUD_ARTIFACT_SYM = Symbol.for('@aws-cdk/cx-api.TreeCloudArtifact'); + export class TreeCloudArtifact extends CloudArtifact { + /** + * Checks if `art` is an instance of this class. + * + * Use this method instead of `instanceof` to properly detect `TreeCloudArtifact` + * instances, even when the construct library is symlinked. + * + * Explanation: in JavaScript, multiple copies of the `cx-api` library on + * disk are seen as independent, completely different libraries. As a + * consequence, the class `TreeCloudArtifact` in each copy of the `cx-api` library + * is seen as a different class, and an instance of one class will not test as + * `instanceof` the other class. `npm install` will not create installations + * like this, but users may manually symlink construct libraries together or + * use a monorepo tool: in those cases, multiple copies of the `cx-api` + * library can be accidentally installed, and `instanceof` will behave + * unpredictably. It is safest to avoid using `instanceof`, and using + * this type-testing method instead. + */ + public static isTreeCloudArtifact(art: any): art is TreeCloudArtifact { + return art && typeof art === 'object' && art[TREE_CLOUD_ARTIFACT_SYM]; + } + public readonly file: string; constructor(assembly: CloudAssembly, name: string, artifact: cxschema.ArtifactManifest) { @@ -14,4 +37,16 @@ export class TreeCloudArtifact extends CloudArtifact { } this.file = properties.file; } -} \ No newline at end of file +} + +/** + * Mark all instances of 'TreeCloudArtifact' + * + * Why not put this in the constructor? Because this is a class property, + * not an instance property. It applies to all instances of the class. + */ +Object.defineProperty(TreeCloudArtifact.prototype, TREE_CLOUD_ARTIFACT_SYM, { + value: true, + enumerable: false, + writable: false, +}); \ No newline at end of file diff --git a/packages/aws-cdk-lib/cx-api/lib/cloud-assembly.ts b/packages/aws-cdk-lib/cx-api/lib/cloud-assembly.ts index a55b6a368d345..7309cbb01f80d 100644 --- a/packages/aws-cdk-lib/cx-api/lib/cloud-assembly.ts +++ b/packages/aws-cdk-lib/cx-api/lib/cloud-assembly.ts @@ -6,7 +6,6 @@ import { NestedCloudAssemblyArtifact } from './artifacts/nested-cloud-assembly-a import { TreeCloudArtifact } from './artifacts/tree-cloud-artifact'; import { CloudArtifact } from './cloud-artifact'; import { topologicalSort } from './toposort'; -import { LoadManifestOptions } from '../../cloud-assembly-schema'; import * as cxschema from '../../cloud-assembly-schema'; /** @@ -47,12 +46,12 @@ export class CloudAssembly { * Reads a cloud assembly from the specified directory. * @param directory The root directory of the assembly. */ - constructor(directory: string, loadOptions?: LoadManifestOptions) { + constructor(directory: string, loadOptions?: cxschema.LoadManifestOptions) { this.directory = directory; this.manifest = cxschema.Manifest.loadAssemblyManifest(path.join(directory, MANIFEST_FILE), loadOptions); this.version = this.manifest.version; - this.artifacts = this.renderArtifacts(); + this.artifacts = this.renderArtifacts(loadOptions?.topoSort ?? true); this.runtime = this.manifest.runtime || { libraries: { } }; // force validation of deps by accessing 'depends' on all artifacts @@ -219,7 +218,7 @@ export class CloudAssembly { } } - private renderArtifacts() { + private renderArtifacts(topoSort: boolean) { const result = new Array(); for (const [name, artifact] of Object.entries(this.manifest.artifacts || { })) { const cloudartifact = CloudArtifact.fromManifest(this, name, artifact); @@ -228,7 +227,7 @@ export class CloudAssembly { } } - return topologicalSort(result, x => x.id, x => x._dependencyIDs); + return topoSort ? topologicalSort(result, x => x.id, x => x._dependencyIDs) : result; } } @@ -357,6 +356,13 @@ export class CloudAssemblyBuilder { parentBuilder: this, }); } + + /** + * Delete the cloud assembly directory + */ + public delete() { + fs.rmSync(this.outdir, { recursive: true, force: true }); + } } /** diff --git a/packages/aws-cdk-lib/cx-api/test/cloud-assembly.test.ts b/packages/aws-cdk-lib/cx-api/test/cloud-assembly.test.ts index 804d1e43e16b6..b3965026a98fb 100644 --- a/packages/aws-cdk-lib/cx-api/test/cloud-assembly.test.ts +++ b/packages/aws-cdk-lib/cx-api/test/cloud-assembly.test.ts @@ -168,6 +168,13 @@ test('can read assembly with asset manifest', () => { expect(assembly.artifacts).toHaveLength(2); }); +test('can toposort assembly with asset dependency', () => { + const assembly = new CloudAssembly(path.join(FIXTURES, 'asset-depends')); + expect(assembly.stacks).toHaveLength(2); + expect(assembly.artifacts).toHaveLength(3); + expect(assembly.artifacts[0].id).toEqual('StagingStack'); +}); + test('getStackArtifact retrieves a stack by artifact id from a nested assembly', () => { const assembly = new CloudAssembly(path.join(FIXTURES, 'nested-assemblies')); diff --git a/packages/aws-cdk-lib/cx-api/test/fixtures/asset-depends/manifest.json b/packages/aws-cdk-lib/cx-api/test/fixtures/asset-depends/manifest.json new file mode 100644 index 0000000000000..3873aca91ac7b --- /dev/null +++ b/packages/aws-cdk-lib/cx-api/test/fixtures/asset-depends/manifest.json @@ -0,0 +1,29 @@ +{ + "version": "0.0.0", + "artifacts": { + "MyStackName": { + "type": "aws:cloudformation:stack", + "environment": "aws://37736633/us-region-1", + "properties": { + "templateFile": "template.json" + }, + "dependencies": ["AssetManifest"], + "metadata": { + } + }, + "AssetManifest": { + "type": "cdk:asset-manifest", + "properties": { + "file": "asset.json" + }, + "dependencies": ["StagingStack"] + }, + "StagingStack": { + "type": "aws:cloudformation:stack", + "environment": "aws://1111/us-region-1", + "properties": { + "templateFile": "template.json" + } + } + } +} diff --git a/packages/aws-cdk-lib/cx-api/test/fixtures/asset-depends/template.json b/packages/aws-cdk-lib/cx-api/test/fixtures/asset-depends/template.json new file mode 100644 index 0000000000000..284fd64cffc21 --- /dev/null +++ b/packages/aws-cdk-lib/cx-api/test/fixtures/asset-depends/template.json @@ -0,0 +1,7 @@ +{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket" + } + } +} \ No newline at end of file diff --git a/packages/aws-cdk/THIRD_PARTY_LICENSES b/packages/aws-cdk/THIRD_PARTY_LICENSES index 307780fcf5773..3369d5bb77f0c 100644 --- a/packages/aws-cdk/THIRD_PARTY_LICENSES +++ b/packages/aws-cdk/THIRD_PARTY_LICENSES @@ -1143,32 +1143,6 @@ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------- - -** eventemitter3@4.0.7 - https://www.npmjs.com/package/eventemitter3/v/4.0.7 | MIT -The MIT License (MIT) - -Copyright (c) 2014 Arnout Kazemier - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------- ** fast-deep-equal@3.1.3 - https://www.npmjs.com/package/fast-deep-equal/v/3.1.3 | MIT @@ -2300,60 +2274,6 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----------------- - -** p-finally@1.0.0 - https://www.npmjs.com/package/p-finally/v/1.0.0 | MIT -The MIT License (MIT) - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ----------------- - -** p-queue@6.6.2 - https://www.npmjs.com/package/p-queue/v/6.6.2 | MIT -MIT License - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ----------------- - -** p-timeout@3.2.0 - https://www.npmjs.com/package/p-timeout/v/3.2.0 | MIT -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------- ** pac-proxy-agent@5.0.0 - https://www.npmjs.com/package/pac-proxy-agent/v/5.0.0 | MIT diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts b/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts index 277c8f6b70c2e..e77fefa61083b 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts @@ -176,6 +176,7 @@ export class SdkProvider { options?: CredentialsOptions, ): Promise { const env = await this.resolveEnvironment(environment); + const baseCreds = await this.obtainBaseCredentials(env.account, mode); // At this point, we need at least SOME credentials diff --git a/packages/aws-cdk/lib/api/cxapp/exec.ts b/packages/aws-cdk/lib/api/cxapp/exec.ts index 7a7e2105b7210..757e03653d0e0 100644 --- a/packages/aws-cdk/lib/api/cxapp/exec.ts +++ b/packages/aws-cdk/lib/api/cxapp/exec.ts @@ -130,7 +130,10 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom */ export function createAssembly(appDir: string) { try { - return new cxapi.CloudAssembly(appDir); + return new cxapi.CloudAssembly(appDir, { + // We sort as we deploy + topoSort: false, + }); } catch (error: any) { if (error.message.includes(cxschema.VERSION_MISMATCH)) { // this means the CLI version is too old. diff --git a/packages/aws-cdk/lib/api/cloudformation-deployments.ts b/packages/aws-cdk/lib/api/deployments.ts similarity index 57% rename from packages/aws-cdk/lib/api/cloudformation-deployments.ts rename to packages/aws-cdk/lib/api/deployments.ts index 58c98179164da..4a71367d9ecbc 100644 --- a/packages/aws-cdk/lib/api/cloudformation-deployments.ts +++ b/packages/aws-cdk/lib/api/deployments.ts @@ -1,8 +1,9 @@ import * as cxapi from '@aws-cdk/cx-api'; -import { AssetManifest } from 'cdk-assets'; +import * as cdk_assets from 'cdk-assets'; +import { AssetManifest, IManifestEntry } from 'cdk-assets'; import { Mode } from './aws-auth/credentials'; import { ISDK } from './aws-auth/sdk'; -import { SdkProvider } from './aws-auth/sdk-provider'; +import { CredentialsOptions, SdkForEnvironment, SdkProvider } from './aws-auth/sdk-provider'; import { deployStack, DeployStackResult, destroyStack, makeBodyParameterAndUpload, DeploymentMethod } from './deploy-stack'; import { HotswapMode } from './hotswap/common'; import { loadCurrentTemplateWithNestedStacks, loadCurrentTemplate } from './nested-stack-helpers'; @@ -12,7 +13,7 @@ import { StackActivityProgress } from './util/cloudformation/stack-activity-moni import { replaceEnvPlaceholders } from './util/placeholders'; import { Tag } from '../cdk-toolkit'; import { debug, warning } from '../logging'; -import { buildAssets, publishAssets, BuildAssetsOptions, PublishAssetsOptions } from '../util/asset-publishing'; +import { buildAssets, publishAssets, BuildAssetsOptions, PublishAssetsOptions, PublishingAws, EVENT_TO_LOGGER } from '../util/asset-publishing'; /** * SDK obtained by assuming the lookup role @@ -39,68 +40,6 @@ export interface PreparedSdkWithLookupRoleForEnvironment { readonly didAssumeRole: boolean; } -/** - * Try to use the bootstrap lookupRole. There are two scenarios that are handled here - * 1. The lookup role may not exist (it was added in bootstrap stack version 7) - * 2. The lookup role may not have the correct permissions (ReadOnlyAccess was added in - * bootstrap stack version 8) - * - * In the case of 1 (lookup role doesn't exist) `forEnvironment` will either: - * 1. Return the default credentials if the default credentials are for the stack account - * 2. Throw an error if the default credentials are not for the stack account. - * - * If we successfully assume the lookup role we then proceed to 2 and check whether the bootstrap - * stack version is valid. If it is not we throw an error which should be handled in the calling - * function (and fallback to use a different role, etc) - * - * If we do not successfully assume the lookup role, but do get back the default credentials - * then return those and note that we are returning the default credentials. The calling - * function can then decide to use them or fallback to another role. - */ -export async function prepareSdkWithLookupRoleFor( - sdkProvider: SdkProvider, - stack: cxapi.CloudFormationStackArtifact, -): Promise { - const resolvedEnvironment = await sdkProvider.resolveEnvironment(stack.environment); - - // Substitute any placeholders with information about the current environment - const arns = await replaceEnvPlaceholders({ - lookupRoleArn: stack.lookupRole?.arn, - }, resolvedEnvironment, sdkProvider); - - // try to assume the lookup role - const warningMessage = `Could not assume ${arns.lookupRoleArn}, proceeding anyway.`; - const upgradeMessage = `(To get rid of this warning, please upgrade to bootstrap version >= ${stack.lookupRole?.requiresBootstrapStackVersion})`; - try { - const stackSdk = await sdkProvider.forEnvironment(resolvedEnvironment, Mode.ForReading, { - assumeRoleArn: arns.lookupRoleArn, - assumeRoleExternalId: stack.lookupRole?.assumeRoleExternalId, - }); - - // if we succeed in assuming the lookup role, make sure we have the correct bootstrap stack version - if (stackSdk.didAssumeRole && stack.lookupRole?.bootstrapStackVersionSsmParameter && stack.lookupRole.requiresBootstrapStackVersion) { - const version = await ToolkitInfo.versionFromSsmParameter(stackSdk.sdk, stack.lookupRole.bootstrapStackVersionSsmParameter); - if (version < stack.lookupRole.requiresBootstrapStackVersion) { - throw new Error(`Bootstrap stack version '${stack.lookupRole.requiresBootstrapStackVersion}' is required, found version '${version}'.`); - } - // we may not have assumed the lookup role because one was not provided - // if that is the case then don't print the upgrade warning - } else if (!stackSdk.didAssumeRole && stack.lookupRole?.requiresBootstrapStackVersion) { - warning(upgradeMessage); - } - return { ...stackSdk, resolvedEnvironment }; - } catch (e: any) { - debug(e); - // only print out the warnings if the lookupRole exists AND there is a required - // bootstrap version, otherwise the warnings will print `undefined` - if (stack.lookupRole && stack.lookupRole.requiresBootstrapStackVersion) { - warning(warningMessage); - warning(upgradeMessage); - } - throw (e); - } -} - export interface DeployStackOptions { /** * Stack to deploy @@ -248,13 +187,6 @@ export interface DeployStackOptions { */ readonly overrideTemplate?: any; - /** - * Whether to build assets before publishing. - * - * @default true To remain backward compatible. - */ - readonly buildAssets?: boolean; - /** * Whether to build/publish assets in parallel * @@ -263,7 +195,7 @@ export interface DeployStackOptions { readonly assetParallelism?: boolean; } -export interface BuildStackAssetsOptions { +interface AssetOptions { /** * Stack with assets to build. */ @@ -282,25 +214,30 @@ export interface BuildStackAssetsOptions { * @default - Current role */ readonly roleArn?: string; +} +export interface BuildStackAssetsOptions extends AssetOptions { /** * Options to pass on to `buildAssets()` function */ readonly buildOptions?: BuildAssetsOptions; -} -interface PublishStackAssetsOptions { /** - * Whether to build assets before publishing. - * - * @default true To remain backward compatible. + * Stack name this asset is for */ - readonly buildAssets?: boolean; + readonly stackName?: string; +} +interface PublishStackAssetsOptions extends AssetOptions { /** * Options to pass on to `publishAsests()` function */ readonly publishOptions?: Omit; + + /** + * Stack name this asset is for + */ + readonly stackName?: string; } export interface DestroyStackOptions { @@ -317,8 +254,9 @@ export interface StackExistsOptions { deployName?: string; } -export interface ProvisionerProps { +export interface DeploymentsProps { sdkProvider: SdkProvider; + readonly quiet?: boolean; } /** @@ -345,15 +283,17 @@ export interface PreparedSdkForEnvironment { } /** - * Helper class for CloudFormation deployments + * Scope for a single set of deployments from a set of Cloud Assembly Artifacts * - * Looks us the right SDK and Bootstrap stack to deploy a given - * stack artifact. + * Manages lookup of SDKs, Bootstrap stacks, etc. */ -export class CloudFormationDeployments { +export class Deployments { private readonly sdkProvider: SdkProvider; + private readonly toolkitInfoCache = new Map(); + private readonly sdkCache = new Map(); + private readonly publisherCache = new Map(); - constructor(props: ProvisionerProps) { + constructor(private readonly props: DeploymentsProps) { this.sdkProvider = props.sdkProvider; } @@ -381,7 +321,7 @@ export class CloudFormationDeployments { const { stackSdk, resolvedEnvironment } = await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading); const cfn = stackSdk.cloudFormation(); - const toolkitInfo = await ToolkitInfo.lookup(resolvedEnvironment, stackSdk, toolkitStackName); + const toolkitInfo = await this.lookupToolkit(resolvedEnvironment, stackSdk, toolkitStackName); // Upload the template, if necessary, before passing it to CFN const cfnParam = await makeBodyParameterAndUpload( @@ -411,19 +351,9 @@ export class CloudFormationDeployments { }; } - const { stackSdk, resolvedEnvironment, cloudFormationRoleArn } = await this.prepareSdkFor(options.stack, options.roleArn); + const { stackSdk, resolvedEnvironment, cloudFormationRoleArn } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); - const toolkitInfo = await ToolkitInfo.lookup(resolvedEnvironment, stackSdk, options.toolkitStackName); - - // 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, { - buildAssets: options.buildAssets ?? true, - publishOptions: { - parallel: options.assetParallelism, - }, - }); - } + const toolkitInfo = await this.lookupToolkit(resolvedEnvironment, stackSdk, options.toolkitStackName); // Do a verification of the bootstrap stack version await this.validateBootstrapStackVersion( @@ -460,7 +390,7 @@ export class CloudFormationDeployments { } public async destroyStack(options: DestroyStackOptions): Promise { - const { stackSdk, cloudFormationRoleArn: roleArn } = await this.prepareSdkFor(options.stack, options.roleArn); + const { stackSdk, cloudFormationRoleArn: roleArn } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); return destroyStack({ sdk: stackSdk, @@ -481,7 +411,7 @@ export class CloudFormationDeployments { private async prepareSdkWithLookupOrDeployRole(stackArtifact: cxapi.CloudFormationStackArtifact): Promise { // try to assume the lookup role try { - const result = await prepareSdkWithLookupRoleFor(this.sdkProvider, stackArtifact); + const result = await this.prepareSdkWithLookupRoleFor(stackArtifact); if (result.didAssumeRole) { return { resolvedEnvironment: result.resolvedEnvironment, @@ -504,8 +434,8 @@ export class CloudFormationDeployments { */ private async prepareSdkFor( stack: cxapi.CloudFormationStackArtifact, - roleArn?: string, - mode = Mode.ForWriting, + roleArn: string | undefined, + mode: Mode, ): Promise { if (!stack.environment) { throw new Error(`The stack ${stack.displayName} does not have an environment`); @@ -521,7 +451,7 @@ export class CloudFormationDeployments { cloudFormationRoleArn: roleArn ?? stack.cloudFormationExecutionRoleArn, }, resolvedEnvironment, this.sdkProvider); - const stackSdk = await this.sdkProvider.forEnvironment(resolvedEnvironment, mode, { + const stackSdk = await this.cachedSdkForEnvironment(resolvedEnvironment, mode, { assumeRoleArn: arns.assumeRoleArn, assumeRoleExternalId: stack.assumeRoleExternalId, }); @@ -534,47 +464,152 @@ export class CloudFormationDeployments { } /** - * Build a stack's assets. + * Try to use the bootstrap lookupRole. There are two scenarios that are handled here + * 1. The lookup role may not exist (it was added in bootstrap stack version 7) + * 2. The lookup role may not have the correct permissions (ReadOnlyAccess was added in + * bootstrap stack version 8) + * + * In the case of 1 (lookup role doesn't exist) `forEnvironment` will either: + * 1. Return the default credentials if the default credentials are for the stack account + * 2. Throw an error if the default credentials are not for the stack account. + * + * If we successfully assume the lookup role we then proceed to 2 and check whether the bootstrap + * stack version is valid. If it is not we throw an error which should be handled in the calling + * function (and fallback to use a different role, etc) + * + * If we do not successfully assume the lookup role, but do get back the default credentials + * then return those and note that we are returning the default credentials. The calling + * function can then decide to use them or fallback to another role. + */ + public async prepareSdkWithLookupRoleFor( + stack: cxapi.CloudFormationStackArtifact, + ): Promise { + const resolvedEnvironment = await this.sdkProvider.resolveEnvironment(stack.environment); + + // Substitute any placeholders with information about the current environment + const arns = await replaceEnvPlaceholders({ + lookupRoleArn: stack.lookupRole?.arn, + }, resolvedEnvironment, this.sdkProvider); + + // try to assume the lookup role + const warningMessage = `Could not assume ${arns.lookupRoleArn}, proceeding anyway.`; + const upgradeMessage = `(To get rid of this warning, please upgrade to bootstrap version >= ${stack.lookupRole?.requiresBootstrapStackVersion})`; + try { + const stackSdk = await this.cachedSdkForEnvironment(resolvedEnvironment, Mode.ForReading, { + assumeRoleArn: arns.lookupRoleArn, + assumeRoleExternalId: stack.lookupRole?.assumeRoleExternalId, + }); + + // if we succeed in assuming the lookup role, make sure we have the correct bootstrap stack version + if (stackSdk.didAssumeRole && stack.lookupRole?.bootstrapStackVersionSsmParameter && stack.lookupRole.requiresBootstrapStackVersion) { + const version = await ToolkitInfo.versionFromSsmParameter(stackSdk.sdk, stack.lookupRole.bootstrapStackVersionSsmParameter); + if (version < stack.lookupRole.requiresBootstrapStackVersion) { + throw new Error(`Bootstrap stack version '${stack.lookupRole.requiresBootstrapStackVersion}' is required, found version '${version}'.`); + } + // we may not have assumed the lookup role because one was not provided + // if that is the case then don't print the upgrade warning + } else if (!stackSdk.didAssumeRole && stack.lookupRole?.requiresBootstrapStackVersion) { + warning(upgradeMessage); + } + return { ...stackSdk, resolvedEnvironment }; + } catch (e: any) { + debug(e); + // only print out the warnings if the lookupRole exists AND there is a required + // bootstrap version, otherwise the warnings will print `undefined` + if (stack.lookupRole && stack.lookupRole.requiresBootstrapStackVersion) { + warning(warningMessage); + warning(upgradeMessage); + } + throw (e); + } + } + + /** + * Look up the toolkit for a given environment, using a given SDK */ - public async buildStackAssets(options: BuildStackAssetsOptions) { - const { stackSdk, resolvedEnvironment } = await this.prepareSdkFor(options.stack, options.roleArn); - const toolkitInfo = await ToolkitInfo.lookup(resolvedEnvironment, stackSdk, options.toolkitStackName); + public async lookupToolkit(resolvedEnvironment: cxapi.Environment, sdk: ISDK, toolkitStackName?: string) { + const key = `${resolvedEnvironment.account}:${resolvedEnvironment.region}:${toolkitStackName}`; + const existing = this.toolkitInfoCache.get(key); + if (existing) { + return existing; + } + const ret = await ToolkitInfo.lookup(resolvedEnvironment, sdk, toolkitStackName); + this.toolkitInfoCache.set(key, ret); + return ret; + } + private async prepareAndValidateAssets(asset: cxapi.AssetManifestArtifact, options: AssetOptions) { + const { stackSdk, resolvedEnvironment } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); + const toolkitInfo = await this.lookupToolkit(resolvedEnvironment, stackSdk, options.toolkitStackName); const stackEnv = await this.sdkProvider.resolveEnvironment(options.stack.environment); - const assetArtifacts = options.stack.dependencies.filter(cxapi.AssetManifestArtifact.isAssetManifestArtifact); + await this.validateBootstrapStackVersion( + options.stack.stackName, + asset.requiresBootstrapStackVersion, + asset.bootstrapStackVersionSsmParameter, + toolkitInfo); - for (const assetArtifact of assetArtifacts) { - await this.validateBootstrapStackVersion( - options.stack.stackName, - assetArtifact.requiresBootstrapStackVersion, - assetArtifact.bootstrapStackVersionSsmParameter, - toolkitInfo); + const manifest = AssetManifest.fromFile(asset.file); - const manifest = AssetManifest.fromFile(assetArtifact.file); - await buildAssets(manifest, this.sdkProvider, stackEnv, options.buildOptions); - } + return { manifest, stackEnv }; } /** - * Publish all asset manifests that are referenced by the given stack + * Build all assets in a manifest + * + * @deprecated Use `buildSingleAsset` instead */ - 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); + public async buildAssets(asset: cxapi.AssetManifestArtifact, options: BuildStackAssetsOptions) { + const { manifest, stackEnv } = await this.prepareAndValidateAssets(asset, options); + await buildAssets(manifest, this.sdkProvider, stackEnv, options.buildOptions); + } - for (const assetArtifact of assetArtifacts) { - await this.validateBootstrapStackVersion( - stack.stackName, - assetArtifact.requiresBootstrapStackVersion, - assetArtifact.bootstrapStackVersionSsmParameter, - toolkitInfo); + /** + * Publish all assets in a manifest + * + * @deprecated Use `publishSingleAsset` instead + */ + public async publishAssets(asset: cxapi.AssetManifestArtifact, options: PublishStackAssetsOptions) { + const { manifest, stackEnv } = await this.prepareAndValidateAssets(asset, options); + await publishAssets(manifest, this.sdkProvider, stackEnv, options.publishOptions); + } - const manifest = AssetManifest.fromFile(assetArtifact.file); - await publishAssets(manifest, this.sdkProvider, stackEnv, { - ...options.publishOptions, - buildAssets: options.buildAssets ?? true, - }); - } + /** + * Build a single asset from an asset manifest + */ + // eslint-disable-next-line max-len + public async buildSingleAsset(assetArtifact: cxapi.AssetManifestArtifact, assetManifest: AssetManifest, asset: IManifestEntry, options: BuildStackAssetsOptions) { + const { stackSdk, resolvedEnvironment: stackEnv } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); + const toolkitInfo = await this.lookupToolkit(stackEnv, stackSdk, options.toolkitStackName); + + await this.validateBootstrapStackVersion( + options.stack.stackName, + assetArtifact.requiresBootstrapStackVersion, + assetArtifact.bootstrapStackVersionSsmParameter, + toolkitInfo); + + const publisher = this.cachedPublisher(assetManifest, stackEnv, options.stackName); + await publisher.buildEntry(asset); + } + + /** + * Publish a single asset from an asset manifest + */ + // eslint-disable-next-line max-len + public async publishSingleAsset(assetManifest: AssetManifest, asset: IManifestEntry, options: PublishStackAssetsOptions) { + const { resolvedEnvironment: stackEnv } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); + + // No need to validate anymore, we already did that during build + const publisher = this.cachedPublisher(assetManifest, stackEnv, options.stackName); + await publisher.publishEntry(asset); + } + + /** + * Return whether a single asset has been published already + */ + public async isSingleAssetPublished(assetManifest: AssetManifest, asset: IManifestEntry, options: PublishStackAssetsOptions) { + const { resolvedEnvironment: stackEnv } = await this.prepareSdkFor(options.stack, options.roleArn, Mode.ForWriting); + const publisher = this.cachedPublisher(assetManifest, stackEnv, options.stackName); + return publisher.isEntryPublished(asset); } /** @@ -594,4 +629,58 @@ export class CloudFormationDeployments { throw new Error(`${stackName}: ${e.message}`); } } + + private async cachedSdkForEnvironment( + environment: cxapi.Environment, + mode: Mode, + options?: CredentialsOptions, + ) { + const cacheKey = [ + environment.account, + environment.region, + `${mode}`, + options?.assumeRoleArn ?? '', + options?.assumeRoleExternalId ?? '', + ].join(':'); + const existing = this.sdkCache.get(cacheKey); + if (existing) { + return existing; + } + const ret = await this.sdkProvider.forEnvironment(environment, mode, options); + this.sdkCache.set(cacheKey, ret); + return ret; + } + + private cachedPublisher(assetManifest: cdk_assets.AssetManifest, env: cxapi.Environment, stackName?: string) { + const existing = this.publisherCache.get(assetManifest); + if (existing) { + return existing; + } + const prefix = stackName ? `${stackName}: ` : ''; + const publisher = new cdk_assets.AssetPublishing(assetManifest, { + aws: new PublishingAws(this.sdkProvider, env), + progressListener: new ParallelSafeAssetProgress(prefix, this.props.quiet ?? false), + }); + this.publisherCache.set(assetManifest, publisher); + return publisher; + } +} + +/** + * Asset progress that doesn't do anything with percentages (currently) + */ +class ParallelSafeAssetProgress implements cdk_assets.IPublishProgressListener { + constructor(private readonly prefix: string, private readonly quiet: boolean) { + } + + public onPublishEvent(type: cdk_assets.EventType, event: cdk_assets.IPublishProgress): void { + const handler = this.quiet && type !== 'fail' ? debug : EVENT_TO_LOGGER[type]; + handler(`${this.prefix} ${type}: ${event.message}`); + } +} + +/** + * @deprecated Use 'Deployments' instead + */ +export class CloudFormationDeployments extends Deployments { } diff --git a/packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts b/packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts index e1aa80860356b..a54daabc9a0ae 100644 --- a/packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts +++ b/packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts @@ -1,7 +1,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import { CloudFormation } from 'aws-sdk'; import { Mode, SdkProvider, ISDK } from '../aws-auth'; -import { prepareSdkWithLookupRoleFor } from '../cloudformation-deployments'; +import { Deployments } from '../deployments'; import { EvaluateCloudFormationTemplate, LazyListStackResources } from '../evaluate-cloudformation-template'; // resource types that have associated CloudWatch Log Groups that should _not_ be monitored @@ -49,7 +49,7 @@ export async function findCloudWatchLogGroups( const resolvedEnv = await sdkProvider.resolveEnvironment(stackArtifact.environment); // try to assume the lookup role and fallback to the default credentials try { - sdk = (await prepareSdkWithLookupRoleFor(sdkProvider, stackArtifact)).sdk; + sdk = (await new Deployments({ sdkProvider }).prepareSdkWithLookupRoleFor(stackArtifact)).sdk; } catch { sdk = (await sdkProvider.forEnvironment(resolvedEnv, Mode.ForReading)).sdk; } diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 7f2024b71736d..8e56480e9f4c5 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -8,15 +8,13 @@ import * as promptly from 'promptly'; import { DeploymentMethod } from './api'; import { SdkProvider } from './api/aws-auth'; import { Bootstrapper, BootstrapEnvironmentOptions } from './api/bootstrap'; -import { CloudFormationDeployments } from './api/cloudformation-deployments'; import { CloudAssembly, DefaultSelection, ExtendedStackSelection, StackCollection, StackSelector } from './api/cxapp/cloud-assembly'; import { CloudExecutable } from './api/cxapp/cloud-executable'; +import { Deployments } from './api/deployments'; import { HotswapMode } from './api/hotswap/common'; 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 { deployStacks } from './deploy'; import { printSecurityDiff, printStackDiff, RequireApproval } from './diff'; import { ResourceImporter } from './import'; import { data, debug, error, highlight, print, success, warning } from './logging'; @@ -24,6 +22,9 @@ import { deserializeStructure, serializeStructure } from './serialize'; import { Configuration, PROJECT_CONFIG } from './settings'; import { numberFromBool, partition } from './util'; import { validateSnsTopicArn } from './util/validate-notification-arn'; +import { Concurrency, WorkGraph } from './util/work-graph'; +import { WorkGraphBuilder } from './util/work-graph-builder'; +import { AssetBuildNode, AssetPublishNode, StackNode } from './util/work-graph-types'; import { environmentsFromDescriptors, globEnvironmentsFromStacks, looksLikeGlob } from '../lib/api/cxapp/environments'; export interface CdkToolkitProps { @@ -36,7 +37,7 @@ export interface CdkToolkitProps { /** * The provisioning engine used to apply changes to the cloud */ - cloudFormation: CloudFormationDeployments; + deployments: Deployments; /** * Whether to be verbose @@ -135,7 +136,7 @@ export class CdkToolkit { // Compare N stacks against deployed templates for (const stack of stacks.stackArtifacts) { stream.write(format('Stack %s\n', chalk.bold(stack.displayName))); - const currentTemplate = await this.props.cloudFormation.readCurrentTemplateWithNestedStacks(stack, options.compareAgainstProcessedTemplate); + const currentTemplate = await this.props.deployments.readCurrentTemplateWithNestedStacks(stack, options.compareAgainstProcessedTemplate); diffs += options.securityOnly ? numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening)) : printStackDiff(currentTemplate, stack, strict, contextLines, stream); @@ -186,24 +187,30 @@ export class CdkToolkit { } const stacks = stackCollection.stackArtifacts; - const assetBuildTime = options.assetBuildTime ?? AssetBuildTime.ALL_BEFORE_DEPLOY; const stackOutputs: { [key: string]: any } = { }; const outputsFile = options.outputsFile; - if (assetBuildTime === AssetBuildTime.ALL_BEFORE_DEPLOY) { - // Prebuild all assets - try { - await buildAllStackAssets(stackCollection.stackArtifacts, { - buildStackAssets: (a) => this.buildAllAssetsForSingleStack(a, options), - }); - } catch (e) { - error('\n ❌ Building assets failed: %s', e); - throw e; - } - } + const buildAsset = async (assetNode: AssetBuildNode) => { + await this.props.deployments.buildSingleAsset(assetNode.assetManifestArtifact, assetNode.assetManifest, assetNode.asset, { + stack: assetNode.parentStack, + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName, + stackName: assetNode.parentStack.stackName, + }); + }; + + const publishAsset = async (assetNode: AssetPublishNode) => { + await this.props.deployments.publishSingleAsset(assetNode.assetManifest, assetNode.asset, { + stack: assetNode.parentStack, + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName, + stackName: assetNode.parentStack.stackName, + }); + }; - const deployStack = async (stack: cxapi.CloudFormationStackArtifact) => { + const deployStack = async (assetNode: StackNode) => { + const stack = assetNode.stack; if (stackCollection.stackCount !== 1) { highlight(stack.displayName); } if (!stack.environment) { @@ -212,7 +219,7 @@ export class CdkToolkit { } if (Object.keys(stack.template.Resources || {}).length === 0) { // The generated stack has no resources - if (!await this.props.cloudFormation.stackExists({ stack })) { + if (!await this.props.deployments.stackExists({ stack })) { warning('%s: stack has no resources, skipping deployment.', chalk.bold(stack.displayName)); } else { warning('%s: stack has no resources, deleting existing stack.', chalk.bold(stack.displayName)); @@ -229,7 +236,7 @@ export class CdkToolkit { } if (requireApproval !== RequireApproval.Never) { - const currentTemplate = await this.props.cloudFormation.readCurrentTemplate(stack); + const currentTemplate = await this.props.deployments.readCurrentTemplate(stack); if (printSecurityDiff(currentTemplate, stack, requireApproval)) { // only talk to user if STDIN is a terminal (otherwise, fail) @@ -262,7 +269,7 @@ export class CdkToolkit { let elapsedDeployTime = 0; try { - const result = await this.props.cloudFormation.deployStack({ + const result = await this.props.deployments.deployStack({ stack, deployName: stack.stackName, roleArn: options.roleArn, @@ -281,7 +288,6 @@ export class CdkToolkit { rollback: options.rollback, hotswap: options.hotswap, extraUserAgent: options.extraUserAgent, - buildAssets: assetBuildTime !== AssetBuildTime.ALL_BEFORE_DEPLOY, assetParallelism: options.assetParallelism, }); @@ -329,6 +335,8 @@ export class CdkToolkit { print('\n✨ Total time: %ss\n', formatTime(elapsedSynthTime + elapsedDeployTime)); }; + const assetBuildTime = options.assetBuildTime ?? AssetBuildTime.ALL_BEFORE_DEPLOY; + const prebuildAssets = assetBuildTime === AssetBuildTime.ALL_BEFORE_DEPLOY; const concurrency = options.concurrency || 1; const progress = concurrency > 1 ? StackActivityProgress.EVENTS : options.progress; if (concurrency > 1 && options.progress && options.progress != StackActivityProgress.EVENTS) { @@ -336,7 +344,28 @@ export class CdkToolkit { } try { - await deployStacks(stacks, { concurrency, deployStack }); + const stacksAndTheirAssetManifests = stacks.flatMap(stack => [ + stack, + ...stack.dependencies.filter(cxapi.AssetManifestArtifact.isAssetManifestArtifact), + ]); + const workGraph = new WorkGraphBuilder(prebuildAssets).build(stacksAndTheirAssetManifests); + + // Unless we are running with '--force', skip already published assets + if (!options.force) { + await this.removePublishedAssets(workGraph, options); + } + + const graphConcurrency: Concurrency = { + 'stack': concurrency, + 'asset-build': 1, // This will be CPU-bound/memory bound, mostly matters for Docker builds + 'asset-publish': options.assetParallelism ? 8 : 1, // This will be I/O-bound, 8 in parallel seems reasonable + }; + + await workGraph.doParallel(graphConcurrency, { + deployStack, + buildAsset, + publishAsset, + }); } catch (e) { error('\n ❌ Deployment failed: %s', e); throw e; @@ -449,7 +478,7 @@ export class CdkToolkit { highlight(stack.displayName); - const resourceImporter = new ResourceImporter(stack, this.props.cloudFormation, { + const resourceImporter = new ResourceImporter(stack, this.props.deployments, { toolkitStackName: options.toolkitStackName, }); const { additions, hasNonAdditions } = await resourceImporter.discoverImportableResources(options.force); @@ -527,7 +556,7 @@ export class CdkToolkit { for (const [index, stack] of stacks.stackArtifacts.entries()) { success('%s: destroying... [%s/%s]', chalk.blue(stack.displayName), index+1, stacks.stackCount); try { - await this.props.cloudFormation.destroyStack({ + await this.props.deployments.destroyStack({ stack, deployName: stack.stackName, roleArn: options.roleArn, @@ -783,22 +812,16 @@ export class CdkToolkit { } } - private async buildAllAssetsForSingleStack(stack: cxapi.CloudFormationStackArtifact, options: Pick): Promise { - // 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, + /** + * Remove the asset publishing and building from the work graph for assets that are already in place + */ + private async removePublishedAssets(graph: WorkGraph, options: DeployOptions) { + await graph.removeUnnecessaryAssets(assetNode => this.props.deployments.isSingleAssetPublished(assetNode.assetManifest, assetNode.asset, { + stack: assetNode.parentStack, roleArn: options.roleArn, toolkitStackName: options.toolkitStackName, - buildOptions: { - parallel: options.assetParallelism, - }, - }); - print('\n%s: assets built\n', chalk.bold(stack.displayName)); + stackName: assetNode.parentStack.stackName, + })); } } diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index c51a3f735104e..f24f42231beb5 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -11,10 +11,10 @@ import { checkForPlatformWarnings } from './platform-warnings'; import { enableTracing } from './util/tracing'; import { SdkProvider } from '../lib/api/aws-auth'; import { BootstrapSource, Bootstrapper } from '../lib/api/bootstrap'; -import { CloudFormationDeployments } from '../lib/api/cloudformation-deployments'; import { StackSelector } from '../lib/api/cxapp/cloud-assembly'; import { CloudExecutable, Synthesizer } from '../lib/api/cxapp/cloud-executable'; import { execProgram } from '../lib/api/cxapp/exec'; +import { Deployments } from '../lib/api/deployments'; import { PluginHost } from '../lib/api/plugin'; import { ToolkitInfo } from '../lib/api/toolkit-info'; import { StackActivityProgress } from '../lib/api/util/cloudformation/stack-activity-monitor'; @@ -348,7 +348,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise 0, ignoreErrors: argv['ignore-errors'], strict: argv.strict, diff --git a/packages/aws-cdk/lib/deploy.ts b/packages/aws-cdk/lib/deploy.ts deleted file mode 100644 index 76bd1b579664f..0000000000000 --- a/packages/aws-cdk/lib/deploy.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as cxapi from '@aws-cdk/cx-api'; -import PQueue from 'p-queue'; - -type Options = { - concurrency: number; - deployStack: (stack: cxapi.CloudFormationStackArtifact) => Promise; -}; - -type DeploymentState = 'pending' | 'queued' | 'deploying' | 'completed' | 'failed' | 'skipped'; - -export const deployStacks = async (stacks: cxapi.CloudFormationStackArtifact[], { concurrency, deployStack }: Options): Promise => { - const queue = new PQueue({ concurrency }); - const deploymentStates = stacks.reduce((acc, stack) => ({ ...acc, [stack.id]: 'pending' as const }), {} as Record); - - const isStackUnblocked = (stack: cxapi.CloudFormationStackArtifact) => - stack.dependencies - .map(({ id }) => id) - .filter((id) => !id.endsWith('.assets')) - .every((id) => !deploymentStates[id] || deploymentStates[id] === 'completed'); // Dependency not selected or already finished - - const hasAnyStackFailed = (states: Record) => Object.values(states).includes('failed'); - - const deploymentErrors: Error[] = []; - - const enqueueStackDeploys = () => { - stacks.forEach(async (stack) => { - if (deploymentStates[stack.id] === 'pending' && isStackUnblocked(stack)) { - deploymentStates[stack.id] = 'queued'; - - await queue.add(async () => { - // Do not start new deployments if any has already failed - if (hasAnyStackFailed(deploymentStates)) { - deploymentStates[stack.id] = 'skipped'; - return; - } - - deploymentStates[stack.id] = 'deploying'; - - await deployStack(stack).catch((err) => { - // By recording the failure immediately as the queued task exits, we prevent the next - // queued task from starting (its 'hasAnyStackFailed' will return 'true'). - deploymentStates[stack.id] = 'failed'; - throw err; - }); - - deploymentStates[stack.id] = 'completed'; - enqueueStackDeploys(); - }).catch((err) => { - deploymentStates[stack.id] = 'failed'; - deploymentErrors.push(err); - }); - } - }); - }; - - enqueueStackDeploys(); - - await queue.onIdle(); - - if (deploymentErrors.length) { - throw Error(`Stack Deployments Failed: ${deploymentErrors}`); - } - - // We shouldn't be able to get here, but check it anyway - const neverUnblocked = Object.entries(deploymentStates).filter(([_, s]) => s === 'pending').map(([n, _]) => n); - if (neverUnblocked.length > 0) { - throw new Error(`The following stacks never became unblocked: ${neverUnblocked.join(', ')}. Please report this at https://github.com/aws/aws-cdk/issues`); - } -}; diff --git a/packages/aws-cdk/lib/import.ts b/packages/aws-cdk/lib/import.ts index aab93048ec7ce..d2df1e2f65c12 100644 --- a/packages/aws-cdk/lib/import.ts +++ b/packages/aws-cdk/lib/import.ts @@ -4,7 +4,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import * as fs from 'fs-extra'; import * as promptly from 'promptly'; -import { CloudFormationDeployments, DeployStackOptions } from './api/cloudformation-deployments'; +import { Deployments, DeployStackOptions } from './api/deployments'; import { ResourceIdentifierProperties, ResourcesToImport } from './api/util/cloudformation'; import { error, print, success, warning } from './logging'; @@ -63,7 +63,7 @@ export class ResourceImporter { constructor( private readonly stack: cxapi.CloudFormationStackArtifact, - private readonly cfn: CloudFormationDeployments, + private readonly cfn: Deployments, private readonly options: ResourceImporterOptions = {}) { } /** diff --git a/packages/aws-cdk/lib/util/asset-publishing.ts b/packages/aws-cdk/lib/util/asset-publishing.ts index af53828106069..c94c9bab94a94 100644 --- a/packages/aws-cdk/lib/util/asset-publishing.ts +++ b/packages/aws-cdk/lib/util/asset-publishing.ts @@ -109,7 +109,7 @@ export async function buildAssets( } } -class PublishingAws implements cdk_assets.IAws { +export class PublishingAws implements cdk_assets.IAws { private sdkCache: Map = new Map(); constructor( @@ -186,7 +186,7 @@ class PublishingAws implements cdk_assets.IAws { } } -const EVENT_TO_LOGGER: Record void> = { +export const EVENT_TO_LOGGER: Record void> = { build: debug, cached: debug, check: debug, @@ -206,4 +206,4 @@ class PublishingProgressListener implements cdk_assets.IPublishProgressListener const handler = this.quiet && type !== 'fail' ? debug : EVENT_TO_LOGGER[type]; handler(`[${event.percentComplete}%] ${type}: ${event.message}`); } -} +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/util/work-graph-builder.ts b/packages/aws-cdk/lib/util/work-graph-builder.ts new file mode 100644 index 0000000000000..ac3bd04a2d401 --- /dev/null +++ b/packages/aws-cdk/lib/util/work-graph-builder.ts @@ -0,0 +1,145 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { AssetManifest, IManifestEntry } from 'cdk-assets'; +import { WorkGraph } from './work-graph'; +import { DeploymentState, AssetBuildNode, WorkNode } from './work-graph-types'; + + +export class WorkGraphBuilder { + /** + * Default priorities for nodes + * + * Assets builds have higher priority than the other two operations, to make good on our promise that + * '--prebuild-assets' will actually do assets before stacks (if it can). Unfortunately it is the + * default :( + * + * But between stack dependencies and publish dependencies, stack dependencies go first + */ + public static PRIORITIES: Record = { + 'asset-build': 10, + 'asset-publish': 0, + 'stack': 5, + }; + private readonly graph = new WorkGraph(); + private readonly assetBuildNodes = new Map; + + constructor(private readonly prebuildAssets: boolean, private readonly idPrefix = '') { } + + private addStack(artifact: cxapi.CloudFormationStackArtifact) { + this.graph.addNodes({ + type: 'stack', + id: `${this.idPrefix}${artifact.id}`, + dependencies: new Set(this.getDepIds(artifact.dependencies)), + stack: artifact, + deploymentState: DeploymentState.PENDING, + priority: WorkGraphBuilder.PRIORITIES.stack, + }); + } + + /** + * Oof, see this parameter list + */ + // eslint-disable-next-line max-len + private addAsset(parentStack: cxapi.CloudFormationStackArtifact, assetArtifact: cxapi.AssetManifestArtifact, assetManifest: AssetManifest, asset: IManifestEntry) { + const buildId = `${this.idPrefix}${asset.id}-build`; + + // Add the build node, but only one per "source" + // The genericSource includes a relative path we could make absolute to do more effective deduplication of build steps. Not doing that right now. + const assetBuildNodeKey = JSON.stringify(asset.genericSource); + if (!this.assetBuildNodes.has(assetBuildNodeKey)) { + const node: AssetBuildNode = { + type: 'asset-build', + id: buildId, + dependencies: new Set([ + ...this.getDepIds(assetArtifact.dependencies), + // If we disable prebuild, then assets inherit dependencies from their parent stack + ...!this.prebuildAssets ? this.getDepIds(parentStack.dependencies) : [], + ]), + parentStack, + assetManifestArtifact: assetArtifact, + assetManifest, + asset, + deploymentState: DeploymentState.PENDING, + priority: WorkGraphBuilder.PRIORITIES['asset-build'], + }; + this.assetBuildNodes.set(assetBuildNodeKey, node); + this.graph.addNodes(node); + } + + // Always add the publish + const publishNodeId = `${this.idPrefix}${asset.id}-publish`; + this.graph.addNodes({ + type: 'asset-publish', + id: publishNodeId, + dependencies: new Set([ + buildId, + // The asset publish step also depends on the stacks that the parent depends on. + // This is purely cosmetic: if we don't do this, the progress printing of asset publishing + // is going to interfere with the progress bar of the stack deployment. We could remove this + // for overall faster deployments if we ever have a better method of progress displaying. + ...this.getDepIds(parentStack.dependencies), + ]), + parentStack, + assetManifestArtifact: assetArtifact, + assetManifest, + asset, + deploymentState: DeploymentState.PENDING, + priority: WorkGraphBuilder.PRIORITIES['asset-publish'], + }); + // This will work whether the stack node has been added yet or not + this.graph.addDependency(`${this.idPrefix}${parentStack.id}`, publishNodeId); + } + + public build(artifacts: cxapi.CloudArtifact[]): WorkGraph { + const parentStacks = stacksFromAssets(artifacts); + + for (const artifact of artifacts) { + if (cxapi.CloudFormationStackArtifact.isCloudFormationStackArtifact(artifact)) { + this.addStack(artifact); + } else if (cxapi.AssetManifestArtifact.isAssetManifestArtifact(artifact)) { + const manifest = AssetManifest.fromFile(artifact.file); + + for (const entry of manifest.entries) { + const parentStack = parentStacks.get(artifact); + if (parentStack === undefined) { + throw new Error('Found an asset manifest that is not associated with a stack'); + } + this.addAsset(parentStack, artifact, manifest, entry); + } + } else if (cxapi.NestedCloudAssemblyArtifact.isNestedCloudAssemblyArtifact(artifact)) { + const assembly = new cxapi.CloudAssembly(artifact.fullPath, { topoSort: false }); + const nestedGraph = new WorkGraphBuilder(this.prebuildAssets, `${this.idPrefix}${artifact.id}.`).build(assembly.artifacts); + this.graph.absorb(nestedGraph); + } else { + // Ignore whatever else + } + } + + this.graph.removeUnavailableDependencies(); + return this.graph; + } + + private getDepIds(deps: cxapi.CloudArtifact[]): string[] { + const ids = []; + for (const artifact of deps) { + if (cxapi.AssetManifestArtifact.isAssetManifestArtifact(artifact)) { + // Depend on only the publish step. The publish step will depend on the build step on its own. + ids.push(`${this.idPrefix}${artifact.id}-publish`); + } else { + ids.push(`${this.idPrefix}${artifact.id}`); + } + } + return ids; + } +} + +function stacksFromAssets(artifacts: cxapi.CloudArtifact[]) { + const ret = new Map(); + for (const stack of artifacts.filter(cxapi.CloudFormationStackArtifact.isCloudFormationStackArtifact)) { + const assetArtifacts = stack.dependencies.filter(cxapi.AssetManifestArtifact.isAssetManifestArtifact); + for (const art of assetArtifacts) { + ret.set(art, stack); + } + } + + return ret; +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/util/work-graph-types.ts b/packages/aws-cdk/lib/util/work-graph-types.ts new file mode 100644 index 0000000000000..9da5c59d85ec0 --- /dev/null +++ b/packages/aws-cdk/lib/util/work-graph-types.ts @@ -0,0 +1,54 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { AssetManifest, IManifestEntry } from 'cdk-assets'; + +export enum DeploymentState { + PENDING = 'pending', + QUEUED = 'queued', + DEPLOYING = 'deploying', + COMPLETED = 'completed', + FAILED = 'failed', + SKIPPED = 'skipped', +}; + +export type WorkNode = StackNode | AssetBuildNode | AssetPublishNode; + +export interface WorkNodeCommon { + readonly id: string; + readonly dependencies: Set; + deploymentState: DeploymentState; +} + +export interface StackNode extends WorkNodeCommon { + readonly type: 'stack'; + readonly stack: cxapi.CloudFormationStackArtifact; + /** Sort by priority when picking up work, higher is earlier */ + readonly priority?: number; +} + +export interface AssetBuildNode extends WorkNodeCommon { + readonly type: 'asset-build'; + /** The asset manifest this asset resides in (artifact) */ + readonly assetManifestArtifact: cxapi.AssetManifestArtifact; + /** The asset manifest this asset resides in */ + readonly assetManifest: AssetManifest; + /** The stack this asset was defined in (used for environment settings) */ + readonly parentStack: cxapi.CloudFormationStackArtifact; + /** The asset that needs to be built */ + readonly asset: IManifestEntry; + /** Sort by priority when picking up work, higher is earlier */ + readonly priority?: number; +} + +export interface AssetPublishNode extends WorkNodeCommon { + readonly type: 'asset-publish'; + /** The asset manifest this asset resides in (artifact) */ + readonly assetManifestArtifact: cxapi.AssetManifestArtifact; + /** The asset manifest this asset resides in */ + readonly assetManifest: AssetManifest; + /** The stack this asset was defined in (used for environment settings) */ + readonly parentStack: cxapi.CloudFormationStackArtifact; + /** The asset that needs to be published */ + readonly asset: IManifestEntry; + /** Sort by priority when picking up work, higher is earlier */ + readonly priority?: number; +} diff --git a/packages/aws-cdk/lib/util/work-graph.ts b/packages/aws-cdk/lib/util/work-graph.ts new file mode 100644 index 0000000000000..792b858a4d168 --- /dev/null +++ b/packages/aws-cdk/lib/util/work-graph.ts @@ -0,0 +1,298 @@ +import { WorkNode, DeploymentState, StackNode, AssetBuildNode, AssetPublishNode } from './work-graph-types'; + +export type Concurrency = number | Record; + +export class WorkGraph { + public readonly nodes: Record; + private readonly readyPool: Array = []; + private readonly lazyDependencies = new Map(); + public error?: Error; + + public constructor(nodes: Record = {}) { + this.nodes = { ...nodes }; + } + + public addNodes(...nodes: WorkNode[]) { + for (const node of nodes) { + const ld = this.lazyDependencies.get(node.id); + if (ld) { + for (const x of ld) { + node.dependencies.add(x); + } + this.lazyDependencies.delete(node.id); + } + + this.nodes[node.id] = node; + } + } + + public removeNode(nodeId: string | WorkNode) { + const id = typeof nodeId === 'string' ? nodeId : nodeId.id; + const removedNode = this.nodes[id]; + + this.lazyDependencies.delete(id); + delete this.nodes[id]; + + if (removedNode) { + for (const node of Object.values(this.nodes)) { + node.dependencies.delete(removedNode.id); + } + } + } + + /** + * Return all nodes of a given type + */ + public nodesOfType(type: T): Extract[] { + return Object.values(this.nodes).filter(n => n.type === type) as any; + } + + /** + * Return all nodes that depend on a given node + */ + public dependees(nodeId: string | WorkNode) { + const id = typeof nodeId === 'string' ? nodeId : nodeId.id; + return Object.values(this.nodes).filter(n => n.dependencies.has(id)); + } + + /** + * Add a dependency, that may come before or after the nodes involved + */ + public addDependency(fromId: string, toId: string) { + const node = this.nodes[fromId]; + if (node) { + node.dependencies.add(toId); + return; + } + let lazyDeps = this.lazyDependencies.get(fromId); + if (!lazyDeps) { + lazyDeps = []; + this.lazyDependencies.set(fromId, lazyDeps); + } + lazyDeps.push(toId); + } + + public node(id: string) { + const ret = this.nodes[id]; + if (!ret) { + throw new Error(`No node with id ${id} among ${Object.keys(this.nodes)}`); + } + return ret; + } + + public absorb(graph: WorkGraph) { + this.addNodes(...Object.values(graph.nodes)); + } + + private hasFailed(): boolean { + return Object.values(this.nodes).some((n) => n.deploymentState === DeploymentState.FAILED); + } + + public doParallel(concurrency: Concurrency, actions: WorkGraphActions) { + return this.forAllArtifacts(concurrency, async (x: WorkNode) => { + switch (x.type) { + case 'stack': + await actions.deployStack(x); + break; + case 'asset-build': + await actions.buildAsset(x); + break; + case 'asset-publish': + await actions.publishAsset(x); + break; + } + }); + } + + /** + * Return the set of unblocked nodes + */ + public ready(): ReadonlyArray { + this.updateReadyPool(); + return this.readyPool; + } + + private forAllArtifacts(n: Concurrency, fn: (x: WorkNode) => Promise): Promise { + const graph = this; + + // If 'n' is a number, we limit all concurrency equally (effectively we will be using totalMax) + // If 'n' is a record, we limit each job independently (effectively we will be using max) + const max: Record = typeof n === 'number' ? + { + 'asset-build': n, + 'asset-publish': n, + 'stack': n, + } : n; + const totalMax = typeof n === 'number' ? n : sum(Object.values(n)); + + return new Promise((ok, fail) => { + let active: Record = { + 'asset-build': 0, + 'asset-publish': 0, + 'stack': 0, + }; + function totalActive() { + return sum(Object.values(active)); + } + + start(); + + function start() { + graph.updateReadyPool(); + + for (let i = 0; i < graph.readyPool.length; ) { + const node = graph.readyPool[i]; + + if (active[node.type] < max[node.type] && totalActive() < totalMax) { + graph.readyPool.splice(i, 1); + startOne(node); + } else { + i += 1; + } + } + + if (totalActive() === 0) { + if (graph.done()) { + ok(); + } + // wait for other active deploys to finish before failing + if (graph.hasFailed()) { + fail(graph.error); + } + } + } + + function startOne(x: WorkNode) { + x.deploymentState = DeploymentState.DEPLOYING; + active[x.type]++; + void fn(x).then(() => { + active[x.type]--; + graph.deployed(x); + start(); + }).catch((err) => { + active[x.type]--; + // By recording the failure immediately as the queued task exits, we prevent the next + // queued task from starting. + graph.failed(x, err); + start(); + }); + } + }); + } + + private done(): boolean { + return Object.values(this.nodes).every((n) => DeploymentState.COMPLETED === n.deploymentState); + } + + private deployed(node: WorkNode) { + node.deploymentState = DeploymentState.COMPLETED; + } + + private failed(node: WorkNode, error?: Error) { + this.error = error; + node.deploymentState = DeploymentState.FAILED; + this.skipRest(); + this.readyPool.splice(0); + } + + public toString() { + return Object.entries(this.nodes).map(([id, node]) => + `${id} := ${node.deploymentState} ${node.type} ${node.dependencies.size > 0 ? `(${Array.from(node.dependencies)})` : ''}`.trim(), + ).join(', '); + } + + /** + * Ensure all dependencies actually exist. This protects against scenarios such as the following: + * StackA depends on StackB, but StackB is not selected to deploy. The dependency is redundant + * and will be dropped. + * This assumes the manifest comes uncorrupted so we will not fail if a dependency is not found. + */ + public removeUnavailableDependencies() { + for (const node of Object.values(this.nodes)) { + const removeDeps = []; + for (const dep of node.dependencies) { + if (this.nodes[dep] === undefined) { + removeDeps.push(dep); + } + } + removeDeps.forEach((d) => { + node.dependencies.delete(d); + }); + } + } + + /** + * Remove all asset publishing steps for assets that are already published, and then build + * that aren't used anymore. + */ + public async removeUnnecessaryAssets(isUnnecessary: (x: AssetPublishNode) => Promise) { + const publishes = this.nodesOfType('asset-publish'); + for (const assetNode of publishes) { + const unnecessary = await isUnnecessary(assetNode); + if (unnecessary) { + this.removeNode(assetNode); + } + } + + // Now also remove any asset build steps that don't have any dependencies on them anymore + const unusedBuilds = this.nodesOfType('asset-build').filter(build => this.dependees(build).length === 0); + for (const unusedBuild of unusedBuilds) { + this.removeNode(unusedBuild); + } + } + + private updateReadyPool() { + let activeCount = 0; + let pendingCount = 0; + for (const node of Object.values(this.nodes)) { + switch (node.deploymentState) { + case DeploymentState.DEPLOYING: + activeCount += 1; + break; + case DeploymentState.PENDING: + pendingCount += 1; + if (Array.from(node.dependencies).every((id) => this.node(id).deploymentState === DeploymentState.COMPLETED)) { + node.deploymentState = DeploymentState.QUEUED; + this.readyPool.push(node); + } + break; + } + } + + for (let i = 0; i < this.readyPool.length; i++) { + const node = this.readyPool[i]; + if (node.deploymentState !== DeploymentState.QUEUED) { + this.readyPool.splice(i, 1); + } + } + + // Sort by reverse priority + this.readyPool.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + + if (this.readyPool.length === 0 && activeCount === 0 && pendingCount > 0) { + throw new Error(`Unable to make progress anymore among: ${this}`); + } + } + + private skipRest() { + for (const node of Object.values(this.nodes)) { + if ([DeploymentState.QUEUED, DeploymentState.PENDING].includes(node.deploymentState)) { + node.deploymentState = DeploymentState.SKIPPED; + } + } + } +} + +export interface WorkGraphActions { + deployStack: (stackNode: StackNode) => Promise; + buildAsset: (assetNode: AssetBuildNode) => Promise; + publishAsset: (assetNode: AssetPublishNode) => Promise; +} + +function sum(xs: number[]) { + let ret = 0; + for (const x of xs) { + ret += x; + } + return ret; +} \ No newline at end of file diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index 8a83b97d876d7..530e6e42bb1fb 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -109,7 +109,6 @@ "glob": "^7.2.3", "json-diff": "^0.10.0", "minimatch": "^9.0.0", - "p-queue": "^6.6.2", "promptly": "^3.2.0", "proxy-agent": "^5.0.0", "semver": "^7.5.0", diff --git a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts index baae814a09aef..95a7a767c3f9a 100644 --- a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts +++ b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts @@ -2,32 +2,25 @@ 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 { FakeCloudformationStack } from './fake-cloudformation-stack'; -import { DEFAULT_BOOTSTRAP_VARIANT } from '../../lib'; -import { CloudFormationDeployments } from '../../lib/api/cloudformation-deployments'; +import { Deployments } from '../../lib/api/deployments'; import { deployStack } from '../../lib/api/deploy-stack'; import { HotswapMode } from '../../lib/api/hotswap/common'; -import { EcrRepositoryInfo, ToolkitInfo } from '../../lib/api/toolkit-info'; +import { 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'; let sdkProvider: MockSdkProvider; -let deployments: CloudFormationDeployments; +let deployments: Deployments; let mockToolkitInfoLookup: jest.Mock; let currentCfnStackResources: { [key: string]: CloudFormation.StackResourceSummary[] }; let numberOfTimesListStackResourcesWasCalled: number; beforeEach(() => { jest.resetAllMocks(); sdkProvider = new MockSdkProvider(); - deployments = new CloudFormationDeployments({ sdkProvider }); + deployments = new Deployments({ sdkProvider }); numberOfTimesListStackResourcesWasCalled = 0; currentCfnStackResources = {}; @@ -65,37 +58,6 @@ 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({ @@ -441,7 +403,7 @@ test('readCurrentTemplateWithNestedStacks() with a 3-level nested + sibling stru break; default: - throw new Error('unknown stack name ' + stackName + ' found in cloudformation-deployments.test.ts'); + throw new Error('unknown stack name ' + stackName + ' found in deployments.test.ts'); } return cfnStack; @@ -884,32 +846,6 @@ 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, undefined); -}); - function pushStackResourceSummaries(stackName: string, ...items: CloudFormation.StackResourceSummary[]) { if (!currentCfnStackResources[stackName]) { currentCfnStackResources[stackName] = []; @@ -927,80 +863,3 @@ 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 variant: string = DEFAULT_BOOTSTRAP_VARIANT; - 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'); -} diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts index aa5d65c75b964..c87a796b085fc 100644 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cdk-toolkit.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable import/order */ // We need to mock the chokidar library, used by 'cdk watch' const mockChokidarWatcherOn = jest.fn(); const fakeChokidarWatcher = { @@ -59,14 +58,14 @@ import * as path from 'path'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { Manifest } from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; -import { instanceMockFrom, MockCloudExecutable, TestStackArtifact, withMocked } from './util'; +import { instanceMockFrom, MockCloudExecutable, TestStackArtifact } from './util'; import { MockSdkProvider } from './util/mock-sdk'; import { Bootstrapper } from '../lib/api/bootstrap'; -import { CloudFormationDeployments, DeployStackOptions, DestroyStackOptions } from '../lib/api/cloudformation-deployments'; import { DeployStackResult } from '../lib/api/deploy-stack'; +import { Deployments, DeployStackOptions, DestroyStackOptions } from '../lib/api/deployments'; import { HotswapMode } from '../lib/api/hotswap/common'; import { Template } from '../lib/api/util/cloudformation'; -import { CdkToolkit, Tag, AssetBuildTime } from '../lib/cdk-toolkit'; +import { CdkToolkit, Tag } from '../lib/cdk-toolkit'; import { RequireApproval } from '../lib/diff'; import { flatten } from '../lib/util'; @@ -101,7 +100,7 @@ function defaultToolkitSetup() { cloudExecutable, configuration: cloudExecutable.configuration, sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: new FakeCloudFormation({ + deployments: new FakeCloudFormation({ 'Test-Stack-A': { Foo: 'Bar' }, 'Test-Stack-B': { Baz: 'Zinga!' }, 'Test-Stack-C': { Baz: 'Zinga!' }, @@ -148,7 +147,9 @@ describe('readCurrentTemplate', () => { }, ], }); - mockForEnvironment = jest.fn().mockImplementation(() => { return { sdk: mockCloudExecutable.sdkProvider.sdk, didAssumeRole: true }; }); + mockForEnvironment = jest.fn().mockImplementation(() => { + return { sdk: mockCloudExecutable.sdkProvider.sdk, didAssumeRole: true }; + }); mockCloudExecutable.sdkProvider.forEnvironment = mockForEnvironment; mockCloudExecutable.sdkProvider.stubCloudFormation({ getTemplate() { @@ -192,7 +193,7 @@ describe('readCurrentTemplate', () => { cloudExecutable: mockCloudExecutable, configuration: mockCloudExecutable.configuration, sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), + deployments: new Deployments({ sdkProvider: mockCloudExecutable.sdkProvider }), }); // WHEN @@ -226,7 +227,7 @@ describe('readCurrentTemplate', () => { cloudExecutable: mockCloudExecutable, configuration: mockCloudExecutable.configuration, sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), + deployments: new Deployments({ sdkProvider: mockCloudExecutable.sdkProvider }), }); // WHEN @@ -261,7 +262,7 @@ describe('readCurrentTemplate', () => { cloudExecutable: mockCloudExecutable, configuration: mockCloudExecutable.configuration, sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), + deployments: new Deployments({ sdkProvider: mockCloudExecutable.sdkProvider }), }); // WHEN @@ -299,7 +300,7 @@ describe('readCurrentTemplate', () => { cloudExecutable: mockCloudExecutable, configuration: mockCloudExecutable.configuration, sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), + deployments: new Deployments({ sdkProvider: mockCloudExecutable.sdkProvider }), }); // WHEN @@ -331,7 +332,7 @@ describe('readCurrentTemplate', () => { cloudExecutable: mockCloudExecutable, configuration: mockCloudExecutable.configuration, sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), + deployments: new Deployments({ sdkProvider: mockCloudExecutable.sdkProvider }), }); mockCloudExecutable.sdkProvider.stubSSM({ getParameter() { @@ -370,7 +371,7 @@ describe('readCurrentTemplate', () => { cloudExecutable: mockCloudExecutable, configuration: mockCloudExecutable.configuration, sdkProvider: mockCloudExecutable.sdkProvider, - cloudFormation: new CloudFormationDeployments({ sdkProvider: mockCloudExecutable.sdkProvider }), + deployments: new Deployments({ sdkProvider: mockCloudExecutable.sdkProvider }), }); // WHEN @@ -408,7 +409,7 @@ describe('deploy', () => { describe('with hotswap deployment', () => { test("passes through the 'hotswap' option to CloudFormationDeployments.deployStack()", async () => { // GIVEN - const mockCfnDeployments = instanceMockFrom(CloudFormationDeployments); + const mockCfnDeployments = instanceMockFrom(Deployments); mockCfnDeployments.deployStack.mockReturnValue(Promise.resolve({ noOp: false, outputs: {}, @@ -419,7 +420,7 @@ describe('deploy', () => { cloudExecutable, configuration: cloudExecutable.configuration, sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: mockCfnDeployments, + deployments: mockCfnDeployments, }); // WHEN @@ -492,7 +493,7 @@ describe('deploy', () => { cloudExecutable, configuration: cloudExecutable.configuration, sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: new FakeCloudFormation({ + deployments: new FakeCloudFormation({ 'Test-Stack-A': { Foo: 'Bar' }, 'Test-Stack-B': { Baz: 'Zinga!' }, }, notificationArns), @@ -513,7 +514,7 @@ describe('deploy', () => { cloudExecutable, configuration: cloudExecutable.configuration, sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: new FakeCloudFormation({ + deployments: new FakeCloudFormation({ 'Test-Stack-A': { Foo: 'Bar' }, }, notificationArns), }); @@ -584,64 +585,6 @@ describe('deploy', () => { expect(cloudExecutable.hasApp).toEqual(false); expect(mockSynthesize).not.toHaveBeenCalled(); }); - - test('can disable asset parallelism', async () => { - // GIVEN - cloudExecutable = new MockCloudExecutable({ - stacks: [MockStack.MOCK_STACK_WITH_ASSET], - }); - const fakeCloudFormation = new FakeCloudFormation({}); - - const toolkit = new CdkToolkit({ - cloudExecutable, - configuration: cloudExecutable.configuration, - sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: fakeCloudFormation, - }); - - // WHEN - // Not the best test but following this through to the asset publishing library fails - await withMocked(fakeCloudFormation, 'buildStackAssets', async (mockBuildStackAssets) => { - await toolkit.deploy({ - selector: { patterns: ['Test-Stack-Asset'] }, - assetParallelism: false, - hotswap: HotswapMode.FULL_DEPLOYMENT, - }); - - expect(mockBuildStackAssets).toHaveBeenCalledWith(expect.objectContaining({ - buildOptions: expect.objectContaining({ - parallel: false, - }), - })); - }); - }); - - test('can disable asset prebuild', async () => { - // GIVEN - cloudExecutable = new MockCloudExecutable({ - stacks: [MockStack.MOCK_STACK_WITH_ASSET], - }); - const fakeCloudFormation = new FakeCloudFormation({}); - - const toolkit = new CdkToolkit({ - cloudExecutable, - configuration: cloudExecutable.configuration, - sdkProvider: cloudExecutable.sdkProvider, - cloudFormation: fakeCloudFormation, - }); - - // WHEN - // Not the best test but following this through to the asset publishing library fails - await withMocked(fakeCloudFormation, 'buildStackAssets', async (mockBuildStackAssets) => { - await toolkit.deploy({ - selector: { patterns: ['Test-Stack-Asset'] }, - assetBuildTime: AssetBuildTime.JUST_IN_TIME, - hotswap: HotswapMode.FULL_DEPLOYMENT, - }); - - expect(mockBuildStackAssets).not.toHaveBeenCalled(); - }); - }); }); }); @@ -1096,7 +1039,7 @@ class MockStack { } } -class FakeCloudFormation extends CloudFormationDeployments { +class FakeCloudFormation extends Deployments { private readonly expectedTags: { [stackName: string]: Tag[] } = {}; private readonly expectedNotificationArns?: string[]; diff --git a/packages/aws-cdk/test/deploy.test.ts b/packages/aws-cdk/test/deploy.test.ts deleted file mode 100644 index a161867b8db48..0000000000000 --- a/packages/aws-cdk/test/deploy.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* eslint-disable import/order */ -import * as cxapi from '@aws-cdk/cx-api'; -import { deployStacks } from '../lib/deploy'; - -type Stack = cxapi.CloudFormationStackArtifact; - -const sleep = async (duration: number) => new Promise((resolve) => setTimeout(() => resolve(), duration)); - -// Not great to have actual sleeps in the tests, but they mostly just exist to give 'p-queue' -// a chance to start new tasks. -const SLOW = 200; - -/** - * Repurposing unused stack attributes to create specific test scenarios - * - stack.name = deployment duration - * - stack.displayName = error message - */ -describe('DeployStacks', () => { - const deployedStacks: string[] = []; - const deployStack = async ({ id, displayName, name }: Stack) => { - const errorMessage = displayName; - const timeout = Number(name) || 0; - - await sleep(timeout); - - if (errorMessage) { - throw Error(errorMessage); - } - - deployedStacks.push(id); - }; - - beforeEach(() => { - deployedStacks.splice(0); - }); - - // Success - test.each([ - // Concurrency 1 - { scenario: 'No Stacks', concurrency: 1, toDeploy: [], expected: [] }, - { scenario: 'A', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [] }], expected: ['A'] }, - { scenario: 'A, B', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [] }], expected: ['A', 'B'] }, - { scenario: 'A -> B', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [{ id: 'A' }] }], expected: ['A', 'B'] }, - { scenario: '[unsorted] A -> B', concurrency: 1, toDeploy: [{ id: 'B', dependencies: [{ id: 'A' }] }, { id: 'A', dependencies: [] }], expected: ['A', 'B'] }, - { scenario: 'A -> B -> C', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [{ id: 'A' }] }, { id: 'C', dependencies: [{ id: 'B' }] }], expected: ['A', 'B', 'C'] }, - { scenario: 'A -> B, A -> C', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [{ id: 'A' }] }, { id: 'C', dependencies: [{ id: 'A' }] }], expected: ['A', 'B', 'C'] }, - { - scenario: 'A (slow), B', - concurrency: 1, - toDeploy: [ - { id: 'A', dependencies: [], name: SLOW }, - { id: 'B', dependencies: [] }, - ], - expected: ['A', 'B'], - }, - { - scenario: 'A -> B, C -> D', - concurrency: 1, - toDeploy: [ - { id: 'A', dependencies: [] }, - { id: 'B', dependencies: [{ id: 'A' }] }, - { id: 'C', dependencies: [] }, - { id: 'D', dependencies: [{ id: 'C' }] }, - ], - expected: ['A', 'C', 'B', 'D'], - }, - { - scenario: 'A (slow) -> B, C -> D', - concurrency: 1, - toDeploy: [ - { id: 'A', dependencies: [], name: SLOW }, - { id: 'B', dependencies: [{ id: 'A' }] }, - { id: 'C', dependencies: [] }, - { id: 'D', dependencies: [{ id: 'C' }] }, - ], - expected: ['A', 'C', 'B', 'D'], - }, - - // Concurrency 2 - { scenario: 'No Stacks', concurrency: 2, toDeploy: [], expected: [] }, - { scenario: 'A', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [] }], expected: ['A'] }, - { scenario: 'A, B', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [] }], expected: ['A', 'B'] }, - { scenario: 'A -> B', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [{ id: 'A' }] }], expected: ['A', 'B'] }, - { scenario: '[unsorted] A -> B', concurrency: 2, toDeploy: [{ id: 'B', dependencies: [{ id: 'A' }] }, { id: 'A', dependencies: [] }], expected: ['A', 'B'] }, - { scenario: 'A -> B -> C', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [{ id: 'A' }] }, { id: 'C', dependencies: [{ id: 'B' }] }], expected: ['A', 'B', 'C'] }, - { scenario: 'A -> B, A -> C', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [{ id: 'A' }] }, { id: 'C', dependencies: [{ id: 'A' }] }], expected: ['A', 'B', 'C'] }, - { - scenario: 'A, B', - concurrency: 2, - toDeploy: [ - { id: 'A', dependencies: [], name: SLOW }, - { id: 'B', dependencies: [] }, - ], - expected: ['B', 'A'], - }, - { - scenario: 'A -> B, C -> D', - concurrency: 2, - toDeploy: [ - { id: 'A', dependencies: [] }, - { id: 'B', dependencies: [{ id: 'A' }] }, - { id: 'C', dependencies: [] }, - { id: 'D', dependencies: [{ id: 'C' }] }, - ], - expected: ['A', 'C', 'B', 'D'], - }, - { - scenario: 'A (slow) -> B, C -> D', - concurrency: 2, - toDeploy: [ - { id: 'A', dependencies: [], name: SLOW }, - { id: 'B', dependencies: [{ id: 'A' }] }, - { id: 'C', dependencies: [] }, - { id: 'D', dependencies: [{ id: 'C' }] }, - ], - expected: ['C', 'D', 'A', 'B'], - }, - { - scenario: 'A -> B, A not selected', - concurrency: 1, - toDeploy: [ - { id: 'B', dependencies: [{ id: 'A' }] }, - ], - expected: ['B'], - }, - ])('Success - Concurrency: $concurrency - $scenario', async ({ concurrency, expected, toDeploy }) => { - await expect(deployStacks(toDeploy as unknown as Stack[], { concurrency, deployStack })).resolves.toBeUndefined(); - - expect(deployedStacks).toStrictEqual(expected); - }); - - // Failure - test.each([ - // Concurrency 1 - { scenario: 'A (error)', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [], displayName: 'A' }], expectedError: 'A', expectedStacks: [] }, - { scenario: 'A (error), B', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [], displayName: 'A' }, { id: 'B', dependencies: [] }], expectedError: 'A', expectedStacks: [] }, - { scenario: 'A, B (error)', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [], displayName: 'B' }], expectedError: 'B', expectedStacks: ['A'] }, - { scenario: 'A (error) -> B', concurrency: 1, toDeploy: [{ id: 'A', dependencies: [], displayName: 'A' }, { id: 'B', dependencies: [{ id: 'A' }] }], expectedError: 'A', expectedStacks: [] }, - { scenario: '[unsorted] A (error) -> B', concurrency: 1, toDeploy: [{ id: 'B', dependencies: [{ id: 'A' }] }, { id: 'A', dependencies: [], displayName: 'A' }], expectedError: 'A', expectedStacks: [] }, - { - scenario: 'A (error) -> B, C -> D', - concurrency: 1, - toDeploy: [ - { id: 'A', dependencies: [], displayName: 'A' }, - { id: 'B', dependencies: [{ id: 'A' }] }, - { id: 'C', dependencies: [] }, - { id: 'D', dependencies: [{ id: 'C' }] }, - ], - expectedError: 'A', - expectedStacks: [], - }, - { - scenario: 'A -> B, C (error) -> D', - concurrency: 1, - toDeploy: [ - { id: 'A', dependencies: [] }, - { id: 'B', dependencies: [{ id: 'A' }] }, - { id: 'C', dependencies: [], displayName: 'C', name: SLOW }, - { id: 'D', dependencies: [{ id: 'C' }] }, - ], - expectedError: 'C', - expectedStacks: ['A'], - }, - - // Concurrency 2 - { scenario: 'A (error)', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [], displayName: 'A' }], expectedError: 'A', expectedStacks: [] }, - { scenario: 'A (error), B', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [], displayName: 'A' }, { id: 'B', dependencies: [] }], expectedError: 'A', expectedStacks: ['B'] }, - { scenario: 'A, B (error)', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [] }, { id: 'B', dependencies: [], displayName: 'B' }], expectedError: 'B', expectedStacks: ['A'] }, - { scenario: 'A (error) -> B', concurrency: 2, toDeploy: [{ id: 'A', dependencies: [], displayName: 'A' }, { id: 'B', dependencies: [{ id: 'A' }] }], expectedError: 'A', expectedStacks: [] }, - { scenario: '[unsorted] A (error) -> B', concurrency: 2, toDeploy: [{ id: 'B', dependencies: [{ id: 'A' }] }, { id: 'A', dependencies: [], displayName: 'A' }], expectedError: 'A', expectedStacks: [] }, - { - scenario: 'A (error) -> B, C -> D', - concurrency: 2, - toDeploy: [ - { id: 'A', dependencies: [], displayName: 'A' }, - { id: 'B', dependencies: [{ id: 'A' }] }, - { id: 'C', dependencies: [] }, - { id: 'D', dependencies: [{ id: 'C' }] }, - ], - expectedError: 'A', - expectedStacks: ['C'], - }, - { - scenario: 'A -> B, C (error) -> D', - concurrency: 2, - toDeploy: [ - { id: 'A', dependencies: [] }, - { id: 'B', dependencies: [{ id: 'A' }] }, - { id: 'C', dependencies: [], displayName: 'C', name: SLOW }, - { id: 'D', dependencies: [{ id: 'C' }] }, - ], - expectedError: 'C', - expectedStacks: ['A', 'B'], - }, - ])('Failure - Concurrency: $concurrency - $scenario', async ({ concurrency, expectedError, toDeploy, expectedStacks }) => { - await expect(deployStacks(toDeploy as unknown as Stack[], { concurrency, deployStack })).rejects.toThrowError(expectedError); - - expect(deployedStacks).toStrictEqual(expectedStacks); - }); -}); \ No newline at end of file diff --git a/packages/aws-cdk/test/diff.test.ts b/packages/aws-cdk/test/diff.test.ts index c93d91dd85aef..128c933dad245 100644 --- a/packages/aws-cdk/test/diff.test.ts +++ b/packages/aws-cdk/test/diff.test.ts @@ -4,11 +4,11 @@ import { StringDecoder } from 'string_decoder'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; import { instanceMockFrom, MockCloudExecutable } from './util'; -import { CloudFormationDeployments } from '../lib/api/cloudformation-deployments'; +import { Deployments } from '../lib/api/deployments'; import { CdkToolkit } from '../lib/cdk-toolkit'; let cloudExecutable: MockCloudExecutable; -let cloudFormation: jest.Mocked; +let cloudFormation: jest.Mocked; let toolkit: CdkToolkit; describe('non-nested stacks', () => { @@ -42,11 +42,11 @@ describe('non-nested stacks', () => { }], }); - cloudFormation = instanceMockFrom(CloudFormationDeployments); + cloudFormation = instanceMockFrom(Deployments); toolkit = new CdkToolkit({ cloudExecutable, - cloudFormation, + deployments: cloudFormation, configuration: cloudExecutable.configuration, sdkProvider: cloudExecutable.sdkProvider, }); @@ -144,11 +144,11 @@ describe('nested stacks', () => { }], }); - cloudFormation = instanceMockFrom(CloudFormationDeployments); + cloudFormation = instanceMockFrom(Deployments); toolkit = new CdkToolkit({ cloudExecutable, - cloudFormation, + deployments: cloudFormation, configuration: cloudExecutable.configuration, sdkProvider: cloudExecutable.sdkProvider, }); diff --git a/packages/aws-cdk/test/import.test.ts b/packages/aws-cdk/test/import.test.ts index 19d97ed38285d..0d73cef3949cd 100644 --- a/packages/aws-cdk/test/import.test.ts +++ b/packages/aws-cdk/test/import.test.ts @@ -10,7 +10,7 @@ jest.mock('promptly', () => { import * as promptly from 'promptly'; import { testStack } from './util'; import { MockSdkProvider } from './util/mock-sdk'; -import { CloudFormationDeployments } from '../lib/api/cloudformation-deployments'; +import { Deployments } from '../lib/api/deployments'; import { ResourceImporter, ImportMap } from '../lib/import'; const promptlyConfirm = promptly.confirm as jest.Mock; @@ -67,11 +67,11 @@ function stackWithKeySigningKey(props: Record) { } let sdkProvider: MockSdkProvider; -let deployments: CloudFormationDeployments; +let deployments: Deployments; beforeEach(() => { jest.resetAllMocks(); sdkProvider = new MockSdkProvider({ realSdk: false }); - deployments = new CloudFormationDeployments({ sdkProvider }); + deployments = new Deployments({ sdkProvider }); createChangeSetInput = undefined; }); @@ -351,4 +351,4 @@ function givenCurrentStack(stackName: string, template: any) { return {}; }, }); -} \ No newline at end of file +} diff --git a/packages/aws-cdk/test/stage-manifest/manifest.json b/packages/aws-cdk/test/stage-manifest/manifest.json new file mode 100644 index 0000000000000..4eb5c5743979e --- /dev/null +++ b/packages/aws-cdk/test/stage-manifest/manifest.json @@ -0,0 +1,24 @@ +{ + "version": "31.0.0", + "artifacts": { + "StageA.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "StageA.assets.json" + } + }, + "NestedStageA": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "StageA.template.json", + "additionalDependencies": [ + "StageA.assets" + ] + }, + "dependencies": [ + "StageA.assets" + ] + } + } +} \ No newline at end of file diff --git a/packages/aws-cdk/test/work-graph-builder.test.ts b/packages/aws-cdk/test/work-graph-builder.test.ts new file mode 100644 index 0000000000000..4df8b12baa1db --- /dev/null +++ b/packages/aws-cdk/test/work-graph-builder.test.ts @@ -0,0 +1,189 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import * as cxapi from '@aws-cdk/cx-api'; +import { CloudAssemblyBuilder } from '@aws-cdk/cx-api'; +import { WorkGraphBuilder } from '../lib/util/work-graph-builder'; +import { AssetBuildNode, AssetPublishNode, StackNode, WorkNode } from '../lib/util/work-graph-types'; + +let rootBuilder: CloudAssemblyBuilder; +beforeEach(() => { + rootBuilder = new CloudAssemblyBuilder(); +}); + +afterEach(() => { + rootBuilder.delete(); +}); + +describe('with some stacks and assets', () => { + let assembly: cxapi.CloudAssembly; + beforeEach(() => { + addSomeStacksAndAssets(rootBuilder); + assembly = rootBuilder.buildAssembly(); + }); + + test('stack depends on the asset publishing step', () => { + const graph = new WorkGraphBuilder(true).build(assembly.artifacts); + + expect(assertableNode(graph.node('stack2'))).toEqual(expect.objectContaining({ + type: 'stack', + dependencies: expect.arrayContaining(['F1:D1-publish']), + } as StackNode)); + }); + + test('asset publishing step depends on asset building step', () => { + const graph = new WorkGraphBuilder(true).build(assembly.artifacts); + + expect(graph.node('F1:D1-publish')).toEqual(expect.objectContaining({ + type: 'asset-publish', + dependencies: new Set(['F1:D1-build']), + } as Partial)); + }); + + test('with prebuild off, asset building inherits dependencies from their parent stack', () => { + const graph = new WorkGraphBuilder(false).build(assembly.artifacts); + + expect(graph.node('F1:D1-build')).toEqual(expect.objectContaining({ + type: 'asset-build', + dependencies: new Set(['stack0', 'stack1']), + } as Partial)); + }); + + test('with prebuild on, assets only have their own dependencies', () => { + const graph = new WorkGraphBuilder(true).build(assembly.artifacts); + + expect(graph.node('F1:D1-build')).toEqual(expect.objectContaining({ + type: 'asset-build', + dependencies: new Set(['stack0']), + } as Partial)); + }); +}); + +test('tree metadata is ignored', async () => { + rootBuilder.addArtifact('tree', { + type: cxschema.ArtifactType.CDK_TREE, + properties: { + file: 'doesnotexist.json', + } as cxschema.TreeArtifactProperties, + }); + + const assembly = rootBuilder.buildAssembly(); + + const graph = new WorkGraphBuilder(true).build(assembly.artifacts); + expect(graph.ready().length).toEqual(0); +}); + +test('can handle nested assemblies', async () => { + addSomeStacksAndAssets(rootBuilder); + const nested = rootBuilder.createNestedAssembly('nested', 'Nested Assembly'); + addSomeStacksAndAssets(nested); + nested.buildAssembly(); + + const assembly = rootBuilder.buildAssembly(); + + let workDone = 0; + const graph = new WorkGraphBuilder(true).build(assembly.artifacts); + await graph.doParallel(10, { + deployStack: async () => { workDone += 1; }, + buildAsset: async () => { }, + publishAsset: async () => { workDone += 1; }, + }); + + expect(workDone).toEqual(8); +}); + +test('dependencies on unselected artifacts are silently ignored', async () => { + addStack(rootBuilder, 'stackA', { + environment: 'aws://222222/us-east-1', + }); + addStack(rootBuilder, 'stackB', { + dependencies: ['stackA'], + environment: 'aws://222222/us-east-1', + }); + + const asm = rootBuilder.buildAssembly(); + const graph = new WorkGraphBuilder(true).build([asm.getStackArtifact('stackB')]); + expect(graph.ready()[0]).toEqual(expect.objectContaining({ + id: 'stackB', + dependencies: new Set(), + })); +}); + +/** + * Write an asset manifest file and add it to the assembly builder + */ +function addAssets( + builder: CloudAssemblyBuilder, + artifactId: string, + options: { files: Record, dependencies?: string[] }, +) { + const manifestFile = `${artifactId}.json`; + const outPath = path.join(builder.outdir, manifestFile); + + const manifest: cxschema.AssetManifest = { + version: cxschema.Manifest.version(), + files: options.files, + }; + + fs.writeFileSync(outPath, JSON.stringify(manifest, undefined, 2)); + + builder.addArtifact(artifactId, { + type: cxschema.ArtifactType.ASSET_MANIFEST, + dependencies: options.dependencies, + properties: { + file: manifestFile, + } as cxschema.AssetManifestProperties, + }); +} + +/** + * Add a stack to the cloud assembly + */ +function addStack(builder: CloudAssemblyBuilder, stackId: string, options: { environment: string, dependencies?: string[] }) { + const templateFile = `${stackId}.template.json`; + const outPath = path.join(builder.outdir, templateFile); + fs.writeFileSync(outPath, JSON.stringify({}, undefined, 2)); + + builder.addArtifact(stackId, { + type: cxschema.ArtifactType.AWS_CLOUDFORMATION_STACK, + dependencies: options.dependencies, + environment: options.environment, + properties: { + templateFile, + }, + }); +} + +function addSomeStacksAndAssets(builder: CloudAssemblyBuilder) { + addStack(builder, 'stack0', { + environment: 'aws://11111/us-east-1', + }); + addAssets(builder, 'stack2assets', { + dependencies: ['stack0'], + files: { + F1: { + source: { path: 'xyz' }, + destinations: { + D1: { bucketName: 'bucket', objectKey: 'key' }, + }, + }, + }, + }); + addStack(builder, 'stack1', { + environment: 'aws://11111/us-east-1', + }); + addStack(builder, 'stack2', { + environment: 'aws://11111/us-east-1', + dependencies: ['stack2assets', 'stack1'], + }); +} + +/** + * We can't do arrayContaining on the set that a Node has, so convert it to an array for asserting + */ +function assertableNode(x: A) { + return { + ...x, + dependencies: Array.from(x.dependencies), + }; +} \ No newline at end of file diff --git a/packages/aws-cdk/test/work-graph.test.ts b/packages/aws-cdk/test/work-graph.test.ts new file mode 100644 index 0000000000000..07d8c2405a328 --- /dev/null +++ b/packages/aws-cdk/test/work-graph.test.ts @@ -0,0 +1,380 @@ +import { WorkGraph } from '../lib/util/work-graph'; +import { AssetBuildNode, AssetPublishNode, DeploymentState, StackNode } from '../lib/util/work-graph-types'; + +const DUMMY: any = 'DUMMY'; + +const sleep = async (duration: number) => new Promise((resolve) => setTimeout(() => resolve(), duration)); + +// Not great to have actual sleeps in the tests, but they mostly just exist to give the async workflow +// a chance to start new tasks. +const SLOW = 200; + +/** + * Repurposing unused stack attributes to create specific test scenarios + * - stack.name = deployment duration + * - stack.displayName = error message + */ +describe('WorkGraph', () => { + const actionedAssets: string[] = []; + const callbacks = { + deployStack: async (x: StackNode) => { + const errorMessage = x.stack.displayName; + const timeout = Number(x.stack.stackName) || 0; + + await sleep(timeout); + + // Special case for testing NestedCloudAssemblyArtifacts + if (errorMessage && !errorMessage.startsWith('Nested')) { + throw Error(errorMessage); + } + + actionedAssets.push(x.id); + }, + buildAsset: async({ id }: AssetBuildNode) => { + actionedAssets.push(id); + }, + publishAsset: async({ id }: AssetPublishNode) => { + actionedAssets.push(id); + }, + }; + + beforeEach(() => { + actionedAssets.splice(0); + }); + + // Success + test.each([ + // Concurrency 1 + { scenario: 'No Stacks', concurrency: 1, toDeploy: [], expected: [] }, + { scenario: 'A', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }]), expected: ['A'] }, + { scenario: 'A, B', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack' }]), expected: ['A', 'B'] }, + { scenario: 'A -> B', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack', stackDependencies: ['A'] }]), expected: ['A', 'B'] }, + { scenario: '[unsorted] A -> B', concurrency: 1, toDeploy: createArtifacts([{ id: 'B', type: 'stack', stackDependencies: ['A'] }, { id: 'A', type: 'stack' }]), expected: ['A', 'B'] }, + { scenario: 'A -> B -> C', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack', stackDependencies: ['A'] }, { id: 'C', type: 'stack', stackDependencies: ['B'] }]), expected: ['A', 'B', 'C'] }, + { scenario: 'A -> B, A -> C', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack', stackDependencies: ['A'] }, { id: 'C', type: 'stack', stackDependencies: ['A'] }]), expected: ['A', 'B', 'C'] }, + { + scenario: 'A (slow), B', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', name: SLOW }, + { id: 'B', type: 'stack' }, + ]), + expected: ['A', 'B'], + }, + { + scenario: 'A -> B, C -> D', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack' }, + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + { id: 'C', type: 'stack' }, + { id: 'D', type: 'stack', stackDependencies: ['C'] }, + ]), + expected: ['A', 'C', 'B', 'D'], + }, + { + scenario: 'A (slow) -> B, C -> D', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', name: SLOW }, + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + { id: 'C', type: 'stack' }, + { id: 'D', type: 'stack', stackDependencies: ['C'] }, + ]), + expected: ['A', 'C', 'B', 'D'], + }, + // With Assets + { + scenario: 'A -> a', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', assetDependencies: ['a'] }, + { id: 'a', type: 'asset' }, + ]), + expected: ['a-build', 'a-publish', 'A'], + }, + { + scenario: 'A -> [a, B]', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', stackDependencies: ['B'], assetDependencies: ['a'] }, + { id: 'B', type: 'stack' }, + { id: 'a', type: 'asset', name: SLOW }, + ]), + expected: ['B', 'a-build', 'a-publish', 'A'], + }, + { + scenario: 'A -> a, B -> b', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', assetDependencies: ['a'] }, + { id: 'B', type: 'stack', assetDependencies: ['b'] }, + { id: 'a', type: 'asset' }, + { id: 'b', type: 'asset' }, + ]), + expected: ['a-build', 'b-build', 'a-publish', 'b-publish', 'A', 'B'], + }, + { + scenario: 'A, B -> b -> A', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack' }, + { id: 'B', type: 'stack', assetDependencies: ['b'] }, + { id: 'b', type: 'asset', stackDependencies: ['A'] }, + ]), + expected: ['A', 'b-build', 'b-publish', 'B'], + }, + + // Concurrency 2 + { scenario: 'No Stacks', concurrency: 2, toDeploy: [], expected: [] }, + { scenario: 'A', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }]), expected: ['A'] }, + { scenario: 'A, B', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack' }]), expected: ['A', 'B'] }, + { scenario: 'A -> B', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack', stackDependencies: ['A'] }]), expected: ['A', 'B'] }, + { scenario: '[unsorted] A -> B', concurrency: 2, toDeploy: createArtifacts([{ id: 'B', type: 'stack', stackDependencies: ['A'] }, { id: 'A', type: 'stack' }]), expected: ['A', 'B'] }, + { scenario: 'A -> B -> C', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack', stackDependencies: ['A'] }, { id: 'C', type: 'stack', stackDependencies: ['B'] }]), expected: ['A', 'B', 'C'] }, + { scenario: 'A -> B, A -> C', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack', stackDependencies: ['A'] }, { id: 'C', type: 'stack', stackDependencies: ['A'] }]), expected: ['A', 'B', 'C'] }, + { + scenario: 'A, B', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', name: SLOW }, + { id: 'B', type: 'stack' }, + ]), + expected: ['B', 'A'], + }, + { + scenario: 'A -> B, C -> D', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack' }, + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + { id: 'C', type: 'stack' }, + { id: 'D', type: 'stack', stackDependencies: ['C'] }, + ]), + expected: ['A', 'C', 'B', 'D'], + }, + { + scenario: 'A (slow) -> B, C -> D', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', name: SLOW }, + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + { id: 'C', type: 'stack' }, + { id: 'D', type: 'stack', stackDependencies: ['C'] }, + ]), + expected: ['C', 'D', 'A', 'B'], + }, + { + scenario: 'A -> B, A not selected', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + ]), + expected: ['B'], + }, + // With Assets + { + scenario: 'A -> a', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', assetDependencies: ['a'] }, + { id: 'a', type: 'asset' }, + ]), + expected: ['a-build', 'a-publish', 'A'], + }, + { + scenario: 'A -> [a, B]', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', stackDependencies: ['B'], assetDependencies: ['a'] }, + { id: 'B', type: 'stack', name: SLOW }, + { id: 'a', type: 'asset' }, + ]), + expected: ['a-build', 'a-publish', 'B', 'A'], + }, + { + scenario: 'A -> a, B -> b', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', assetDependencies: ['a'] }, + { id: 'B', type: 'stack', assetDependencies: ['b'] }, + { id: 'a', type: 'asset' }, + { id: 'b', type: 'asset' }, + ]), + expected: ['a-build', 'b-build', 'a-publish', 'b-publish', 'A', 'B'], + }, + { + scenario: 'A, B -> b -> A', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack' }, + { id: 'B', type: 'stack', assetDependencies: ['b'] }, + { id: 'b', type: 'asset', stackDependencies: ['A'] }, + ]), + expected: ['A', 'b-build', 'b-publish', 'B'], + }, + { + scenario: 'A, B -> [b, c], b -> A', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', name: SLOW }, + { id: 'B', type: 'stack', assetDependencies: ['b', 'c'] }, + { id: 'b', type: 'asset', stackDependencies: ['A'] }, + { id: 'c', type: 'asset' }, + ]), + expected: ['c-build', 'c-publish', 'A', 'b-build', 'b-publish', 'B'], + }, + ])('Success - Concurrency: $concurrency - $scenario', async ({ concurrency, expected, toDeploy }) => { + const graph = new WorkGraph(); + addTestArtifactsToGraph(toDeploy, graph); + + await graph.doParallel(concurrency, callbacks); + + expect(actionedAssets).toStrictEqual(expected); + }); + + test('can remove unnecessary assets', async () => { + const graph = new WorkGraph(); + addTestArtifactsToGraph([ + { id: 'a', type: 'asset' }, + { id: 'b', type: 'asset' }, + { id: 'A', type: 'stack', assetDependencies: ['a', 'b'] }, + ], graph); + + // Remove 'b' from the graph + await graph.removeUnnecessaryAssets(node => Promise.resolve(node.id.startsWith('b'))); + await graph.doParallel(1, callbacks); + + // We expect to only see 'a' and 'A' + expect(actionedAssets).toEqual(['a-build', 'a-publish', 'A']); + }); + + + // Failure + test.each([ + // Concurrency 1 + { scenario: 'A (error)', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack', displayName: 'A' }]), expectedError: 'A', expectedStacks: [] }, + { scenario: 'A (error), B', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack', displayName: 'A' }, { id: 'B', type: 'stack' }]), expectedError: 'A', expectedStacks: [] }, + { scenario: 'A, B (error)', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack', displayName: 'B' }]), expectedError: 'B', expectedStacks: ['A'] }, + { scenario: 'A (error) -> B', concurrency: 1, toDeploy: createArtifacts([{ id: 'A', type: 'stack', displayName: 'A' }, { id: 'B', type: 'stack', stackDependencies: ['A'] }]), expectedError: 'A', expectedStacks: [] }, + { scenario: '[unsorted] A (error) -> B', concurrency: 1, toDeploy: createArtifacts([{ id: 'B', type: 'stack', stackDependencies: ['A'] }, { id: 'A', type: 'stack', displayName: 'A' }]), expectedError: 'A', expectedStacks: [] }, + { + scenario: 'A (error) -> B, C -> D', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', displayName: 'A' }, + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + { id: 'C', type: 'stack' }, + { id: 'D', type: 'stack', stackDependencies: ['C'] }, + ]), + expectedError: 'A', + expectedStacks: [], + }, + { + scenario: 'A -> B, C (error) -> D', + concurrency: 1, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack' }, + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + { id: 'C', type: 'stack', displayName: 'C', name: SLOW }, + { id: 'D', type: 'stack', stackDependencies: ['C'] }, + ]), + expectedError: 'C', + expectedStacks: ['A'], + }, + + // Concurrency 2 + { scenario: 'A (error)', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack', displayName: 'A' }]), expectedError: 'A', expectedStacks: [] }, + { scenario: 'A (error), B', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack', displayName: 'A' }, { id: 'B', type: 'stack' }]), expectedError: 'A', expectedStacks: ['B'] }, + { scenario: 'A, B (error)', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack' }, { id: 'B', type: 'stack', displayName: 'B' }]), expectedError: 'B', expectedStacks: ['A'] }, + { scenario: 'A (error) -> B', concurrency: 2, toDeploy: createArtifacts([{ id: 'A', type: 'stack', displayName: 'A' }, { id: 'B', type: 'stack', stackDependencies: ['A'] }]), expectedError: 'A', expectedStacks: [] }, + { scenario: '[unsorted] A (error) -> B', concurrency: 2, toDeploy: createArtifacts([{ id: 'B', type: 'stack', stackDependencies: ['A'] }, { id: 'A', type: 'stack', displayName: 'A' }]), expectedError: 'A', expectedStacks: [] }, + { + scenario: 'A (error) -> B, C -> D', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack', displayName: 'A' }, + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + { id: 'C', type: 'stack' }, + { id: 'D', type: 'stack', stackDependencies: ['C'] }, + ]), + expectedError: 'A', + expectedStacks: ['C'], + }, + { + scenario: 'A -> B, C (error) -> D', + concurrency: 2, + toDeploy: createArtifacts([ + { id: 'A', type: 'stack' }, + { id: 'B', type: 'stack', stackDependencies: ['A'] }, + { id: 'C', type: 'stack', displayName: 'C', name: SLOW }, + { id: 'D', type: 'stack', stackDependencies: ['C'] }, + ]), + expectedError: 'C', + expectedStacks: ['A', 'B'], + }, + ])('Failure - Concurrency: $concurrency - $scenario', async ({ concurrency, expectedError, toDeploy, expectedStacks }) => { + const graph = new WorkGraph(); + addTestArtifactsToGraph(toDeploy, graph); + + await expect(graph.doParallel(concurrency, callbacks)).rejects.toThrowError(expectedError); + + expect(actionedAssets).toStrictEqual(expectedStacks); + }); +}); + +interface TestArtifact { + stackDependencies?: string[]; + assetDependencies?: string[]; + id: string; + type: 'stack' | 'asset' | 'tree'| 'nested'; + name?: number; + displayName?: string; +} + +function createArtifacts(artifacts: TestArtifact[]) { + return artifacts; +} + +function addTestArtifactsToGraph(toDeploy: TestArtifact[], graph: WorkGraph) { + for (const node of toDeploy) { + switch (node.type) { + case 'stack': + graph.addNodes({ + type: 'stack', + id: node.id, + deploymentState: DeploymentState.PENDING, + stack: { + // We're smuggling information here so that the set of callbacks can do some appropriate action + stackName: node.name, // Used to smuggle sleep duration + displayName: node.displayName, // Used to smuggle exception triggers + } as any, + dependencies: new Set([...node.stackDependencies ?? [], ...(node.assetDependencies ?? []).map(x => `${x}-publish`)]), + }); + break; + case 'asset': + graph.addNodes({ + type: 'asset-build', + id: `${node.id}-build`, + deploymentState: DeploymentState.PENDING, + asset: DUMMY, + assetManifest: DUMMY, + assetManifestArtifact: DUMMY, + parentStack: DUMMY, + dependencies: new Set([...node.stackDependencies ?? [], ...(node.assetDependencies ?? []).map(x => `${x}-publish`)]), + }); + graph.addNodes({ + type: 'asset-publish', + id: `${node.id}-publish`, + deploymentState: DeploymentState.PENDING, + asset: DUMMY, + assetManifest: DUMMY, + assetManifestArtifact: DUMMY, + parentStack: DUMMY, + dependencies: new Set([`${node.id}-build`]), + }); + break; + } + } + graph.removeUnavailableDependencies(); +} \ No newline at end of file diff --git a/packages/aws-cdk/tsconfig.json b/packages/aws-cdk/tsconfig.json index 8b434f8faee95..75033560001a0 100644 --- a/packages/aws-cdk/tsconfig.json +++ b/packages/aws-cdk/tsconfig.json @@ -5,6 +5,7 @@ "lib": ["es2019", "es2020", "dom"], "strict": true, "alwaysStrict": true, + "skipLibCheck": true, "declaration": true, "inlineSourceMap": true, "inlineSources": true, diff --git a/packages/cdk-assets/jest.config.js b/packages/cdk-assets/jest.config.js index d052cbb29f05d..4147a830a714b 100644 --- a/packages/cdk-assets/jest.config.js +++ b/packages/cdk-assets/jest.config.js @@ -4,6 +4,7 @@ module.exports = { coverageThreshold: { global: { ...baseConfig.coverageThreshold.global, + statements: 75, branches: 60, }, }, diff --git a/packages/cdk-assets/lib/private/asset-handler.ts b/packages/cdk-assets/lib/private/asset-handler.ts index 28b10357bf17d..5baa32d9d52f4 100644 --- a/packages/cdk-assets/lib/private/asset-handler.ts +++ b/packages/cdk-assets/lib/private/asset-handler.ts @@ -15,6 +15,11 @@ export interface IAssetHandler { * Publish the asset. */ publish(): Promise; + + /** + * Return whether the asset already exists + */ + isPublished(): Promise; } export interface IHandlerHost { diff --git a/packages/cdk-assets/lib/private/handlers/container-images.ts b/packages/cdk-assets/lib/private/handlers/container-images.ts index f14363c12a5fa..0537c788970c9 100644 --- a/packages/cdk-assets/lib/private/handlers/container-images.ts +++ b/packages/cdk-assets/lib/private/handlers/container-images.ts @@ -45,6 +45,11 @@ export class ContainerImageAssetHandler implements IAssetHandler { await dockerForBuilding.tag(localTagName, initOnce.imageUri); } + public async isPublished(): Promise { + const initOnce = await this.initOnce(); + return initOnce.destinationAlreadyExists; + } + public async publish(): Promise { const initOnce = await this.initOnce(); diff --git a/packages/cdk-assets/lib/private/handlers/files.ts b/packages/cdk-assets/lib/private/handlers/files.ts index da6fbc56c061c..edc2addd61ada 100644 --- a/packages/cdk-assets/lib/private/handlers/files.ts +++ b/packages/cdk-assets/lib/private/handlers/files.ts @@ -29,6 +29,23 @@ export class FileAssetHandler implements IAssetHandler { public async build(): Promise {} + public async isPublished(): Promise { + const destination = await replaceAwsPlaceholders(this.asset.destination, this.host.aws); + const s3Url = `s3://${destination.bucketName}/${destination.objectKey}`; + try { + const s3 = await this.host.aws.s3Client(destination); + this.host.emitMessage(EventType.CHECK, `Check ${s3Url}`); + + if (await objectExists(s3, destination.bucketName, destination.objectKey)) { + this.host.emitMessage(EventType.FOUND, `Found ${s3Url}`); + return true; + } + } catch (e: any) { + this.host.emitMessage(EventType.DEBUG, `${e.message}`); + } + return false; + } + 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 28da539a1486f..c35a0254e55ed 100644 --- a/packages/cdk-assets/lib/publishing.ts +++ b/packages/cdk-assets/lib/publishing.ts @@ -1,6 +1,6 @@ import { AssetManifest, IManifestEntry } from './asset-manifest'; import { IAws } from './aws'; -import { IHandlerHost } from './private/asset-handler'; +import { IAssetHandler, IHandlerHost } from './private/asset-handler'; import { DockerFactory } from './private/docker'; import { makeAssetHandler } from './private/handlers'; import { EventType, IPublishProgress, IPublishProgressListener } from './progress'; @@ -26,21 +26,21 @@ export interface AssetPublishingOptions { readonly throwOnError?: boolean; /** - * Whether to publish in parallel + * Whether to publish in parallel, when 'publish()' is called * * @default false */ readonly publishInParallel?: boolean; /** - * Whether to build assets + * Whether to build assets, when 'publish()' is called * * @default true */ readonly buildAssets?: boolean; /** - * Whether to publish assets + * Whether to publish assets, when 'publish()' is called * * @default true */ @@ -85,6 +85,7 @@ export class AssetPublishing implements IPublishProgress { private readonly startMessagePrefix: string; private readonly successMessagePrefix: string; private readonly errorMessagePrefix: string; + private readonly handlerCache = new Map(); constructor(private readonly manifest: AssetManifest, private readonly options: AssetPublishingOptions) { this.assets = manifest.entries; @@ -150,7 +151,62 @@ export class AssetPublishing implements IPublishProgress { } /** - * Publish an asset. + * Build a single asset from the manifest + */ + public async buildEntry(asset: IManifestEntry) { + try { + if (this.progressEvent(EventType.START, `${this.startMessagePrefix} ${asset.id}`)) { return false; } + + const handler = this.assetHandler(asset); + await handler.build(); + + if (this.aborted) { + throw new Error('Aborted'); + } + } catch (e: any) { + this.failures.push({ asset, error: e }); + this.completedOperations++; + if (this.progressEvent(EventType.FAIL, e.message)) { return false; } + } + + return true; + } + + /** + * Publish a single asset from the manifest + */ + public async publishEntry(asset: IManifestEntry) { + try { + if (this.progressEvent(EventType.UPLOAD, `${this.startMessagePrefix} ${asset.id}`)) { return false; } + + const handler = this.assetHandler(asset); + await handler.publish(); + + if (this.aborted) { + throw new Error('Aborted'); + } + + this.completedOperations++; + if (this.progressEvent(EventType.SUCCESS, `${this.successMessagePrefix} ${asset.id}`)) { return false; } + } catch (e: any) { + this.failures.push({ asset, error: e }); + this.completedOperations++; + if (this.progressEvent(EventType.FAIL, e.message)) { return false; } + } + + return true; + } + + /** + * Return whether a single asset is published + */ + public isEntryPublished(asset: IManifestEntry) { + const handler = this.assetHandler(asset); + return handler.isPublished(); + } + + /** + * publish an asset (used by 'publish()') * @param asset The asset to publish * @returns false when publishing should stop */ @@ -158,7 +214,7 @@ export class AssetPublishing implements IPublishProgress { try { if (this.progressEvent(EventType.START, `${this.startMessagePrefix} ${asset.id}`)) { return false; } - const handler = makeAssetHandler(this.manifest, asset, this.handlerHost); + const handler = this.assetHandler(asset); if (this.buildAssets) { await handler.build(); @@ -206,4 +262,14 @@ export class AssetPublishing implements IPublishProgress { if (this.options.progressListener) { this.options.progressListener.onPublishEvent(event, this); } return this.aborted; } + + private assetHandler(asset: IManifestEntry) { + const existing = this.handlerCache.get(asset); + if (existing) { + return existing; + } + const ret = makeAssetHandler(this.manifest, asset, this.handlerHost); + this.handlerCache.set(asset, ret); + return ret; + } }