From 873233b6af6952fca62dfc9cc2a5e92351b0d2d6 Mon Sep 17 00:00:00 2001 From: Momo Kornher Date: Fri, 7 Feb 2025 12:00:07 +0000 Subject: [PATCH] chore(toolkit): list action (#33298) ### Issue #33179 Closes #33179 ### Description of changes Adds the list action. Converts the existing dependency calculation code into a generic feature on StackCollection. ### Describe any new or updated permissions being added n/a ### Description of how you validated changes Unit tests and integ test pipeline ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../toolkit/lib/actions/list/index.ts | 2 +- packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts | 2 +- .../lib/api/cloud-assembly/stack-selector.ts | 3 +- .../toolkit/lib/api/io/private/codes.ts | 21 +- .../@aws-cdk/toolkit/lib/toolkit/toolkit.ts | 24 +- .../test/_fixtures/console-output/app.js | 2 +- .../test/_fixtures/external-context/app.js | 2 +- .../test/_fixtures/external-context/index.ts | 2 +- .../test/_fixtures/stack-with-bucket/index.js | 11 - .../test/_fixtures/stack-with-bucket/index.ts | 2 +- .../stack-with-notification-arns/index.ts | 2 +- .../test/_fixtures/stack-with-role/index.ts | 2 +- .../test/_fixtures/two-empty-stacks/app.js | 2 +- .../test/_fixtures/two-empty-stacks/index.js | 10 - .../test/_fixtures/two-empty-stacks/index.ts | 2 +- .../test/_fixtures/validation-error/app.js | 2 +- .../@aws-cdk/toolkit/test/_helpers/index.ts | 1 + .../_helpers/test-cloud-assembly-source.ts | 183 +++++++ .../toolkit/test/actions/list.test.ts | 507 ++++++++++++++++++ .../aws-cdk/lib/api/cxapp/cloud-assembly.ts | 66 ++- packages/aws-cdk/lib/list-stacks.ts | 66 +-- 21 files changed, 801 insertions(+), 113 deletions(-) delete mode 100644 packages/@aws-cdk/toolkit/test/_fixtures/stack-with-bucket/index.js delete mode 100644 packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/index.js create mode 100644 packages/@aws-cdk/toolkit/test/_helpers/test-cloud-assembly-source.ts create mode 100644 packages/@aws-cdk/toolkit/test/actions/list.test.ts diff --git a/packages/@aws-cdk/toolkit/lib/actions/list/index.ts b/packages/@aws-cdk/toolkit/lib/actions/list/index.ts index 7fb2d665f63c4..2882b97398e83 100644 --- a/packages/@aws-cdk/toolkit/lib/actions/list/index.ts +++ b/packages/@aws-cdk/toolkit/lib/actions/list/index.ts @@ -4,5 +4,5 @@ export interface ListOptions { /** * Select the stacks */ - readonly stacks: StackSelector; + readonly stacks?: StackSelector; } diff --git a/packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts b/packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts index 481df5318a072..6c3ed8e986fb4 100644 --- a/packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts +++ b/packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts @@ -33,7 +33,7 @@ export { findCloudWatchLogGroups } from '../../../../aws-cdk/lib/api/logs/find-c export { HotswapPropertyOverrides, EcsHotswapProperties } from '../../../../aws-cdk/lib/api/hotswap/common'; // @todo Cloud Assembly and Executable - this is a messy API right now -export { CloudAssembly, sanitizePatterns, StackCollection, ExtendedStackSelection } from '../../../../aws-cdk/lib/api/cxapp/cloud-assembly'; +export { CloudAssembly, sanitizePatterns, type StackDetails, StackCollection, ExtendedStackSelection } from '../../../../aws-cdk/lib/api/cxapp/cloud-assembly'; export { prepareDefaultEnvironment, prepareContext, spaceAvailableForContext } from '../../../../aws-cdk/lib/api/cxapp/exec'; export { guessExecutable } from '../../../../aws-cdk/lib/api/cxapp/exec'; diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-selector.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-selector.ts index 44b84e7649335..e1573481a28b9 100644 --- a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-selector.ts +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-selector.ts @@ -20,10 +20,11 @@ export enum StackSelectionStrategy { ONLY_SINGLE = 'ONLY_SINGLE', /** - * @todo not currently publicly exposed * Return stacks matched by patterns. * If no stacks are found, execution is halted successfully. * Most likely you don't want to use this but `StackSelectionStrategy.MUST_MATCH_PATTERN` + * + * @todo not currently publicly exposed in cli, but available in toolkit */ PATTERN_MATCH = 'PATTERN_MATCH', diff --git a/packages/@aws-cdk/toolkit/lib/api/io/private/codes.ts b/packages/@aws-cdk/toolkit/lib/api/io/private/codes.ts index 0e4af1c85aa37..77e96c17f7fdb 100644 --- a/packages/@aws-cdk/toolkit/lib/api/io/private/codes.ts +++ b/packages/@aws-cdk/toolkit/lib/api/io/private/codes.ts @@ -1,12 +1,23 @@ import { IoMessageCode } from '../io-message'; +/** + * We have a rough system by which we assign message codes: + * - First digit groups messages by action, e.g. synth or deploy + * - X000-X009 are reserved for timings + * - X900-X999 are reserved for results + */ export const CODES = { - // Synth + // 1: Synth CDK_TOOLKIT_I1000: 'Provides synthesis times', CDK_TOOLKIT_I1901: 'Provides stack data', CDK_TOOLKIT_I1902: 'Successfully deployed stacks', - // Deploy + // 2: List + CDK_TOOLKIT_I2901: 'Provides details on the selected stacks and their dependencies', + + // 4: Diff + + // 5: Deploy CDK_TOOLKIT_I5000: 'Provides deployment times', CDK_TOOLKIT_I5001: 'Provides total time in deploy action, including synth and rollback', CDK_TOOLKIT_I5031: 'Informs about any log groups that are traced as part of the deployment', @@ -16,19 +27,21 @@ export const CODES = { CDK_TOOLKIT_E5001: 'No stacks found', - // Rollback + // 6: Rollback CDK_TOOLKIT_I6000: 'Provides rollback times', CDK_TOOLKIT_E6001: 'No stacks found', CDK_TOOLKIT_E6900: 'Rollback failed', - // Destroy + // 7: Destroy CDK_TOOLKIT_I7000: 'Provides destroy times', CDK_TOOLKIT_I7010: 'Confirm destroy stacks', CDK_TOOLKIT_E7010: 'Action was aborted due to negative confirmation of request', CDK_TOOLKIT_E7900: 'Stack deletion failed', + // 9: Bootstrap + // Assembly codes CDK_ASSEMBLY_I0042: 'Writing updated context', CDK_ASSEMBLY_I0241: 'Fetching missing context', diff --git a/packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts index 9f4d6e5ce140b..37b45065b2a64 100644 --- a/packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts @@ -14,7 +14,7 @@ import { type RollbackOptions } from '../actions/rollback'; import { type SynthOptions } from '../actions/synth'; import { patternsArrayForWatch, WatchOptions } from '../actions/watch'; import { type SdkOptions } from '../api/aws-auth'; -import { DEFAULT_TOOLKIT_STACK_NAME, SdkProvider, SuccessfulDeployStackResult, StackCollection, Deployments, HotswapMode, StackActivityProgress, ResourceMigrator, obscureTemplate, serializeStructure, tagsForStack, CliIoHost, validateSnsTopicArn, Concurrency, WorkGraphBuilder, AssetBuildNode, AssetPublishNode, StackNode, formatErrorMessage, CloudWatchLogEventMonitor, findCloudWatchLogGroups, formatTime } from '../api/aws-cdk'; +import { DEFAULT_TOOLKIT_STACK_NAME, SdkProvider, SuccessfulDeployStackResult, StackCollection, Deployments, HotswapMode, StackActivityProgress, ResourceMigrator, obscureTemplate, serializeStructure, tagsForStack, CliIoHost, validateSnsTopicArn, Concurrency, WorkGraphBuilder, AssetBuildNode, AssetPublishNode, StackNode, formatErrorMessage, CloudWatchLogEventMonitor, findCloudWatchLogGroups, formatTime, StackDetails } from '../api/aws-cdk'; import { CachedCloudAssemblySource, IdentityCloudAssemblySource, StackAssembly, ICloudAssemblySource, StackSelectionStrategy } from '../api/cloud-assembly'; import { ALL_STACKS, CloudAssemblySourceBuilder } from '../api/cloud-assembly/private'; import { ToolkitError } from '../api/errors'; @@ -199,19 +199,25 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab /** * List Action * - * List out selected stacks + * List selected stacks and their dependencies */ - public async list(cx: ICloudAssemblySource, _options: ListOptions): Promise { + public async list(cx: ICloudAssemblySource, options: ListOptions = {}): Promise { const ioHost = withAction(this.ioHost, 'list'); + const synthTimer = Timer.start(); const assembly = await this.assemblyFromSource(cx); - ioHost; - assembly; - // temporary - // eslint-disable-next-line @cdklabs/no-throw-default-error - throw new Error('Not implemented yet'); + const stackCollection = await assembly.selectStacksV2(options.stacks ?? ALL_STACKS); + await synthTimer.endAs(ioHost, 'synth'); + + const stacks = stackCollection.withDependencies(); + const message = stacks.map(s => s.id).join('\n'); + + await ioHost.notify(result(message, 'CDK_TOOLKIT_I2901', { stacks })); + return stacks; } /** + * Diff Action + * * Compares the specified stack with the deployed stack or a local template file and returns a structured diff. */ public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise { @@ -225,6 +231,8 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab } /** + * Deploy Action + * * Deploys the selected stacks into an AWS account */ public async deploy(cx: ICloudAssemblySource, options: DeployOptions = {}): Promise { diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/console-output/app.js b/packages/@aws-cdk/toolkit/test/_fixtures/console-output/app.js index f303df84fc268..8b8191a14b6ad 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/console-output/app.js +++ b/packages/@aws-cdk/toolkit/test/_fixtures/console-output/app.js @@ -1,7 +1,7 @@ import * as cdk from 'aws-cdk-lib/core'; console.log('line one'); -const app = new cdk.App(); +const app = new cdk.App({ autoSynth: false }); console.log('line two'); new cdk.Stack(app, 'Stack1'); console.log('line three'); diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/external-context/app.js b/packages/@aws-cdk/toolkit/test/_fixtures/external-context/app.js index fdc4cb2a166f5..9d906885111c5 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/external-context/app.js +++ b/packages/@aws-cdk/toolkit/test/_fixtures/external-context/app.js @@ -1,7 +1,7 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as core from 'aws-cdk-lib/core'; -const app = new core.App(); +const app = new core.App({ autoSynth: false }); const stack = new core.Stack(app, 'Stack1'); new s3.Bucket(stack, 'MyBucket', { bucketName: app.node.tryGetContext('externally-provided-bucket-name') diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/external-context/index.ts b/packages/@aws-cdk/toolkit/test/_fixtures/external-context/index.ts index a676b442d3fa6..247cbd88ae1f0 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/external-context/index.ts +++ b/packages/@aws-cdk/toolkit/test/_fixtures/external-context/index.ts @@ -2,7 +2,7 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as core from 'aws-cdk-lib/core'; export default async () => { - const app = new core.App(); + const app = new core.App({ autoSynth: false }); const stack = new core.Stack(app, 'Stack1'); new s3.Bucket(stack, 'MyBucket', { bucketName: app.node.tryGetContext('externally-provided-bucket-name'), diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-bucket/index.js b/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-bucket/index.js deleted file mode 100644 index 1d74c329f4920..0000000000000 --- a/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-bucket/index.js +++ /dev/null @@ -1,11 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const s3 = require("aws-cdk-lib/aws-s3"); -const core = require("aws-cdk-lib/core"); -exports.default = async () => { - const app = new core.App(); - const stack = new core.Stack(app, 'Stack1'); - new s3.Bucket(stack, 'MyBucket'); - return app.synth(); -}; -//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJpbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOztBQUFBLHlDQUF5QztBQUN6Qyx5Q0FBeUM7QUFFekMsa0JBQWUsS0FBSyxJQUFJLEVBQUU7SUFDeEIsTUFBTSxHQUFHLEdBQUcsSUFBSSxJQUFJLENBQUMsR0FBRyxFQUFFLENBQUM7SUFDM0IsTUFBTSxLQUFLLEdBQUcsSUFBSSxJQUFJLENBQUMsS0FBSyxDQUFDLEdBQUcsRUFBRSxRQUFRLENBQUMsQ0FBQztJQUM1QyxJQUFJLEVBQUUsQ0FBQyxNQUFNLENBQUMsS0FBSyxFQUFFLFVBQVUsQ0FBQyxDQUFDO0lBQ2pDLE9BQU8sR0FBRyxDQUFDLEtBQUssRUFBRSxDQUFDO0FBQ3JCLENBQUMsQ0FBQyJ9 \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-bucket/index.ts b/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-bucket/index.ts index bf3ebfa486d9b..bae858d71273b 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-bucket/index.ts +++ b/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-bucket/index.ts @@ -2,7 +2,7 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as core from 'aws-cdk-lib/core'; export default async () => { - const app = new core.App(); + const app = new core.App({ autoSynth: false }); const stack = new core.Stack(app, 'Stack1'); new s3.Bucket(stack, 'MyBucket'); return app.synth(); diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-notification-arns/index.ts b/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-notification-arns/index.ts index 055db902b49e5..3ae5d105ccea3 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-notification-arns/index.ts +++ b/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-notification-arns/index.ts @@ -1,7 +1,7 @@ import * as core from 'aws-cdk-lib/core'; export default async() => { - const app = new core.App(); + const app = new core.App({ autoSynth: false }); new core.Stack(app, 'Stack1', { notificationArns: [ 'arn:aws:sns:us-east-1:1111111111:resource', diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-role/index.ts b/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-role/index.ts index 38c3f675b7549..565ac98f630e0 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-role/index.ts +++ b/packages/@aws-cdk/toolkit/test/_fixtures/stack-with-role/index.ts @@ -2,7 +2,7 @@ import * as iam from 'aws-cdk-lib/aws-iam'; import * as core from 'aws-cdk-lib/core'; export default async() => { - const app = new core.App(); + const app = new core.App({ autoSynth: false }); const stack = new core.Stack(app, 'Stack1'); new iam.Role(stack, 'Role', { assumedBy: new iam.ArnPrincipal('arn'), diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/app.js b/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/app.js index 9272f9609083a..1931232cbd2ca 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/app.js +++ b/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/app.js @@ -1,6 +1,6 @@ import * as cdk from 'aws-cdk-lib/core'; -const app = new cdk.App(); +const app = new cdk.App({ autoSynth: false }); new cdk.Stack(app, 'Stack1'); new cdk.Stack(app, 'Stack2'); diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/index.js b/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/index.js deleted file mode 100644 index edd0b40c17620..0000000000000 --- a/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/index.js +++ /dev/null @@ -1,10 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const core = require("aws-cdk-lib/core"); -exports.default = async () => { - const app = new core.App(); - new core.Stack(app, 'Stack1'); - new core.Stack(app, 'Stack2'); - return app.synth(); -}; -//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJpbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOztBQUFBLHlDQUF5QztBQUV6QyxrQkFBZSxLQUFLLElBQUksRUFBRTtJQUN4QixNQUFNLEdBQUcsR0FBRyxJQUFJLElBQUksQ0FBQyxHQUFHLEVBQUUsQ0FBQztJQUMzQixJQUFJLElBQUksQ0FBQyxLQUFLLENBQUMsR0FBRyxFQUFFLFFBQVEsQ0FBQyxDQUFDO0lBQzlCLElBQUksSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLEVBQUUsUUFBUSxDQUFDLENBQUM7SUFFOUIsT0FBTyxHQUFHLENBQUMsS0FBSyxFQUFFLENBQUM7QUFDckIsQ0FBQyxDQUFDIn0= \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/index.ts b/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/index.ts index 9d4c1df80e12c..4bf5e73d747d2 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/index.ts +++ b/packages/@aws-cdk/toolkit/test/_fixtures/two-empty-stacks/index.ts @@ -1,7 +1,7 @@ import * as core from 'aws-cdk-lib/core'; export default async () => { - const app = new core.App(); + const app = new core.App({ autoSynth: false }); new core.Stack(app, 'Stack1'); new core.Stack(app, 'Stack2'); diff --git a/packages/@aws-cdk/toolkit/test/_fixtures/validation-error/app.js b/packages/@aws-cdk/toolkit/test/_fixtures/validation-error/app.js index 5724853be65a7..e2ff9972922cd 100644 --- a/packages/@aws-cdk/toolkit/test/_fixtures/validation-error/app.js +++ b/packages/@aws-cdk/toolkit/test/_fixtures/validation-error/app.js @@ -1,7 +1,7 @@ import * as cdk from 'aws-cdk-lib/core'; import * as sqs from 'aws-cdk-lib/aws-sqs'; -const app = new cdk.App(); +const app = new cdk.App({ autoSynth: false }); const stack = new cdk.Stack(app, 'Stack1'); new sqs.Queue(stack, 'Queue1', { queueName: "Queue1", diff --git a/packages/@aws-cdk/toolkit/test/_helpers/index.ts b/packages/@aws-cdk/toolkit/test/_helpers/index.ts index 7d908af311a8c..3004514572868 100644 --- a/packages/@aws-cdk/toolkit/test/_helpers/index.ts +++ b/packages/@aws-cdk/toolkit/test/_helpers/index.ts @@ -4,6 +4,7 @@ import { Toolkit, ToolkitError } from '../../lib'; import { determineOutputDirectory } from '../../lib/api/cloud-assembly/private'; export * from './test-io-host'; +export * from './test-cloud-assembly-source'; function fixturePath(...parts: string[]): string { return path.normalize(path.join(__dirname, '..', '_fixtures', ...parts)); diff --git a/packages/@aws-cdk/toolkit/test/_helpers/test-cloud-assembly-source.ts b/packages/@aws-cdk/toolkit/test/_helpers/test-cloud-assembly-source.ts new file mode 100644 index 0000000000000..8d1819aa02929 --- /dev/null +++ b/packages/@aws-cdk/toolkit/test/_helpers/test-cloud-assembly-source.ts @@ -0,0 +1,183 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ArtifactType, ArtifactMetadataEntryType, AssetManifest, AssetMetadataEntry, AwsCloudFormationStackProperties, MetadataEntry, MissingContext } from '@aws-cdk/cloud-assembly-schema'; +import * as cxapi from '@aws-cdk/cx-api'; +import { ICloudAssemblySource } from '../../lib'; + +const DEFAULT_FAKE_TEMPLATE = { No: 'Resources' }; +const SOME_RECENT_SCHEMA_VERSION = '30.0.0'; + +export interface TestStackArtifact { + stackName: string; + template?: any; + env?: string; + depends?: string[]; + metadata?: cxapi.StackMetadata; + notificationArns?: string[]; + + /** Old-style assets */ + assets?: AssetMetadataEntry[]; + properties?: Partial; + terminationProtection?: boolean; + displayName?: string; + + /** New-style assets */ + assetManifest?: AssetManifest; +} + +export interface TestAssembly { + stacks: TestStackArtifact[]; + missing?: MissingContext[]; + nestedAssemblies?: TestAssembly[]; + schemaVersion?: string; +} + +export class TestCloudAssemblySource implements ICloudAssemblySource { + mock: TestAssembly; + + constructor(mock: TestAssembly) { + this.mock = mock; + } + + public async produce(): Promise { + return testAssembly(this.mock); + } +} + +function testAssembly(assembly: TestAssembly): cxapi.CloudAssembly { + const builder = new cxapi.CloudAssemblyBuilder(); + addAttributes(assembly, builder); + + if (assembly.nestedAssemblies != null && assembly.nestedAssemblies.length > 0) { + assembly.nestedAssemblies?.forEach((nestedAssembly: TestAssembly, i: number) => { + const nestedAssemblyBuilder = builder.createNestedAssembly(`nested${i}`, `nested${i}`); + addAttributes(nestedAssembly, nestedAssemblyBuilder); + nestedAssemblyBuilder.buildAssembly(); + }); + } + + const asm = builder.buildAssembly(); + return cxapiAssemblyWithForcedVersion(asm, assembly.schemaVersion ?? SOME_RECENT_SCHEMA_VERSION); +} + +function addAttributes(assembly: TestAssembly, builder: cxapi.CloudAssemblyBuilder) { + for (const stack of assembly.stacks) { + const templateFile = `${stack.stackName}.template.json`; + const template = stack.template ?? DEFAULT_FAKE_TEMPLATE; + fs.writeFileSync(path.join(builder.outdir, templateFile), JSON.stringify(template, undefined, 2)); + addNestedStacks(templateFile, builder.outdir, template); + + // we call patchStackTags here to simulate the tags formatter + // that is used when building real manifest files. + const metadata: { [path: string]: MetadataEntry[] } = patchStackTags({ ...stack.metadata }); + for (const asset of stack.assets || []) { + metadata[asset.id] = [{ type: ArtifactMetadataEntryType.ASSET, data: asset }]; + } + + for (const missing of assembly.missing || []) { + builder.addMissing(missing); + } + + const dependencies = [...(stack.depends ?? [])]; + + if (stack.assetManifest) { + const manifestFile = `${stack.stackName}.assets.json`; + fs.writeFileSync(path.join(builder.outdir, manifestFile), JSON.stringify(stack.assetManifest, undefined, 2)); + dependencies.push(`${stack.stackName}.assets`); + builder.addArtifact(`${stack.stackName}.assets`, { + type: ArtifactType.ASSET_MANIFEST, + environment: stack.env || 'aws://123456789012/here', + properties: { + file: manifestFile, + }, + }); + } + + builder.addArtifact(stack.stackName, { + type: ArtifactType.AWS_CLOUDFORMATION_STACK, + environment: stack.env || 'aws://123456789012/here', + + dependencies, + metadata, + properties: { + ...stack.properties, + templateFile, + terminationProtection: stack.terminationProtection, + notificationArns: stack.notificationArns, + }, + displayName: stack.displayName, + }); + } +} + +function addNestedStacks(templatePath: string, outdir: string, rootStackTemplate?: any) { + let template = rootStackTemplate; + + if (!template) { + const templatePathWithDir = path.join('nested-stack-templates', templatePath); + template = JSON.parse(fs.readFileSync(path.join(__dirname, templatePathWithDir)).toString()); + fs.writeFileSync(path.join(outdir, templatePath), JSON.stringify(template, undefined, 2)); + } + + for (const logicalId in template.Resources) { + if (template.Resources[logicalId].Type === 'AWS::CloudFormation::Stack') { + if (template.Resources[logicalId].Metadata && template.Resources[logicalId].Metadata['aws:asset:path']) { + const nestedTemplatePath = template.Resources[logicalId].Metadata['aws:asset:path']; + addNestedStacks(nestedTemplatePath, outdir); + } + } + } +} + +/** + * Transform stack tags from how they are declared in source code (lower cased) + * to how they are stored on disk (upper cased). In real synthesis this is done + * by a special tags formatter. + * + * @see aws-cdk-lib/lib/stack.ts + */ +function patchStackTags(metadata: { [path: string]: MetadataEntry[] }): { + [path: string]: MetadataEntry[]; +} { + const cloned = clone(metadata) as { [path: string]: MetadataEntry[] }; + + for (const metadataEntries of Object.values(cloned)) { + for (const metadataEntry of metadataEntries) { + if (metadataEntry.type === ArtifactMetadataEntryType.STACK_TAGS && metadataEntry.data) { + const metadataAny = metadataEntry as any; + + metadataAny.data = metadataAny.data.map((t: any) => { + return { Key: t.key, Value: t.value }; + }); + } + } + } + return cloned; +} + +function clone(obj: any) { + return JSON.parse(JSON.stringify(obj)); +} + +/** + * The cloud-assembly-schema in the new monorepo will use its own package version as the schema version, which is always `0.0.0` when tests are running. + * + * If we want to test the CLI's behavior when presented with specific schema versions, we will have to + * mutate `manifest.json` on disk after writing it, and write the schema version that we want to test for in there. + * + * After we raise the schema version in the file on disk from `0.0.0` to + * `30.0.0`, `cx-api` will refuse to load `manifest.json` back, because the + * version is higher than its own package version ("Maximum schema version + * supported is 0.x.x, but found 30.0.0"), so we have to turn on `skipVersionCheck`. + */ +function cxapiAssemblyWithForcedVersion(asm: cxapi.CloudAssembly, version: string) { + rewriteManifestVersion(asm.directory, version); + return new cxapi.CloudAssembly(asm.directory, { skipVersionCheck: true }); +} + +function rewriteManifestVersion(directory: string, version: string) { + const manifestFile = `${directory}/manifest.json`; + const contents = JSON.parse(fs.readFileSync(`${directory}/manifest.json`, 'utf-8')); + contents.version = version; + fs.writeFileSync(manifestFile, JSON.stringify(contents, undefined, 2)); +} diff --git a/packages/@aws-cdk/toolkit/test/actions/list.test.ts b/packages/@aws-cdk/toolkit/test/actions/list.test.ts new file mode 100644 index 0000000000000..bfb96d283418a --- /dev/null +++ b/packages/@aws-cdk/toolkit/test/actions/list.test.ts @@ -0,0 +1,507 @@ +import { ArtifactMetadataEntryType } from '@aws-cdk/cloud-assembly-schema'; +import { StackSelectionStrategy } from '../../lib'; +import { Toolkit } from '../../lib/toolkit'; +import { TestIoHost } from '../_helpers'; +import { TestCloudAssemblySource, TestStackArtifact } from '../_helpers/test-cloud-assembly-source'; + +const ioHost = new TestIoHost(); +const toolkit = new Toolkit({ ioHost }); + +beforeEach(() => { + ioHost.notifySpy.mockClear(); + ioHost.requestSpy.mockClear(); +}); + +describe('list', () => { + test('defaults to listing all stacks', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [MOCK_STACK_A, MOCK_STACK_B, MOCK_STACK_C], + }); + + // WHEN + const stacks = await toolkit.list(cx); + + // THEN + const expected = [ + expect.objectContaining({ id: 'Test-Stack-A' }), + expect.objectContaining({ id: 'Test-Stack-B' }), + expect.objectContaining({ id: 'Test-Stack-C' }), + ]; + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'list', + level: 'result', + code: 'CDK_TOOLKIT_I2901', + message: [ + 'Test-Stack-A', + 'Test-Stack-B', + 'Test-Stack-C', + ].join('\n'), + data: { stacks: expected }, + })); + expect(stacks).toEqual(expected); + }); + + test('lists only matched stacks', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [MOCK_STACK_A, MOCK_STACK_B, MOCK_STACK_C], + }); + + // WHEN + const stacks = await toolkit.list(cx, { + stacks: { + patterns: ['Test-Stack-A', 'Test-Stack-C'], + strategy: StackSelectionStrategy.PATTERN_MATCH, + }, + }); + + // THEN + const expected = [ + expect.objectContaining({ id: 'Test-Stack-A' }), + expect.objectContaining({ id: 'Test-Stack-C' }), + ]; + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'list', + level: 'result', + code: 'CDK_TOOLKIT_I2901', + message: ['Test-Stack-A', 'Test-Stack-C'].join('\n'), + data: { stacks: expected }, + })); + expect(stacks).toEqual(expected); + }); + + test('stacks with no dependencies', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [MOCK_STACK_A, MOCK_STACK_B], + }); + + // WHEN + const stacks = await toolkit.list(cx, { + stacks: { + patterns: ['Test-Stack-A', 'Test-Stack-B'], + strategy: StackSelectionStrategy.PATTERN_MATCH, + }, + }); + + // THEN + const expected = [{ + id: 'Test-Stack-A', + name: 'Test-Stack-A', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [], + }, + { + id: 'Test-Stack-B', + name: 'Test-Stack-B', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [], + }]; + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'list', + level: 'result', + code: 'CDK_TOOLKIT_I2901', + message: ['Test-Stack-A', 'Test-Stack-B'].join('\n'), + data: { stacks: expected }, + })); + expect(stacks).toEqual(expected); + }); + + test('stacks with dependent stacks', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [ + MOCK_STACK_A, + { + ...MOCK_STACK_B, + depends: ['Test-Stack-A'], + }, + ], + }); + + // WHEN + const stacks = await toolkit.list(cx); + + // THEN + const expected = [{ + id: 'Test-Stack-A', + name: 'Test-Stack-A', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [], + }, + { + id: 'Test-Stack-B', + name: 'Test-Stack-B', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [{ + id: 'Test-Stack-A', + dependencies: [], + }], + }]; + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'list', + level: 'result', + code: 'CDK_TOOLKIT_I2901', + message: ['Test-Stack-A', 'Test-Stack-B'].join('\n'), + data: { stacks: expected }, + })); + expect(stacks).toEqual(expected); + }); + + // In the context where we have a display name set to hieraricalId/stackName + // we would need to pass in the displayName to list the stacks. + test('stacks with dependent stacks and have display name set to hieraricalId/stackName', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [ + MOCK_STACK_A, + { + ...MOCK_STACK_B, + depends: ['Test-Stack-A'], + displayName: 'Test-Stack-A/Test-Stack-B', + }, + ], + }); + + // WHEN + const stacks = await toolkit.list(cx); + + // THEN + const expected = [{ + id: 'Test-Stack-A', + name: 'Test-Stack-A', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [], + }, + { + id: 'Test-Stack-A/Test-Stack-B', + name: 'Test-Stack-B', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [{ + id: 'Test-Stack-A', + dependencies: [], + }], + }]; + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'list', + level: 'result', + code: 'CDK_TOOLKIT_I2901', + message: [ + 'Test-Stack-A', + 'Test-Stack-A/Test-Stack-B', + ].join('\n'), + data: { stacks: expected }, + })); + expect(stacks).toEqual(expected); + }); + + test('stacks with display names and have nested dependencies', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [ + MOCK_STACK_A, + { + ...MOCK_STACK_B, + depends: ['Test-Stack-A'], + displayName: 'Test-Stack-A/Test-Stack-B', + }, + { + ...MOCK_STACK_C, + depends: ['Test-Stack-B'], + displayName: 'Test-Stack-A/Test-Stack-B/Test-Stack-C', + }, + ], + }); + + // WHEN + const stacks = await toolkit.list(cx); + + // THEN + const expected = [{ + id: 'Test-Stack-A', + name: 'Test-Stack-A', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [], + }, + { + id: 'Test-Stack-A/Test-Stack-B', + name: 'Test-Stack-B', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [{ + id: 'Test-Stack-A', + dependencies: [], + }], + }, + { + id: 'Test-Stack-A/Test-Stack-B/Test-Stack-C', + name: 'Test-Stack-C', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [{ + id: 'Test-Stack-A/Test-Stack-B', + dependencies: [{ + id: 'Test-Stack-A', + dependencies: [], + }], + }], + }]; + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'list', + level: 'result', + code: 'CDK_TOOLKIT_I2901', + message: [ + 'Test-Stack-A', + 'Test-Stack-A/Test-Stack-B', + 'Test-Stack-A/Test-Stack-B/Test-Stack-C', + ].join('\n'), + data: { stacks: expected }, + })); + expect(stacks).toEqual(expected); + }); + + test('stacks with nested dependencies', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [ + MOCK_STACK_A, + { + ...MOCK_STACK_B, + depends: [MOCK_STACK_A.stackName], + }, + { + ...MOCK_STACK_C, + depends: [MOCK_STACK_B.stackName], + }, + ], + }); + + // WHEN + const stacks = await toolkit.list(cx); + + // THEN + const expected = [{ + id: 'Test-Stack-A', + name: 'Test-Stack-A', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [], + }, + { + id: 'Test-Stack-B', + name: 'Test-Stack-B', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [{ + id: 'Test-Stack-A', + dependencies: [], + }], + }, + { + id: 'Test-Stack-C', + name: 'Test-Stack-C', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [{ + id: 'Test-Stack-B', + dependencies: [{ + id: 'Test-Stack-A', + dependencies: [], + }], + }], + }]; + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'list', + level: 'result', + code: 'CDK_TOOLKIT_I2901', + message: [ + 'Test-Stack-A', + 'Test-Stack-B', + 'Test-Stack-C', + ].join('\n'), + data: { stacks: expected }, + })); + expect(stacks).toEqual(expected); + }); + + // In the context of stacks with cross-stack or cross-region references, + // the dependency mechanism is responsible for appropriately applying dependencies at the correct hierarchy level, + // typically at the top-level stacks. + // This involves handling the establishment of cross-references between stacks or nested stacks + // and generating assets for nested stack templates as necessary. + test('stacks with cross stack referencing', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [ + { + ...MOCK_STACK_A, + depends: [MOCK_STACK_C.stackName], + template: { + Resources: { + MyBucket1Reference: { + Type: 'AWS::CloudFormation::Stack', + Properties: { + TemplateURL: 'XXXXXXXXXXXXXXXXXXXXXXXXX', + Parameters: { + BucketName: { 'Fn::GetAtt': ['MyBucket1', 'Arn'] }, + }, + }, + }, + }, + }, + }, + MOCK_STACK_C, + ], + }); + + // WHEN + const stacks = await toolkit.list(cx); + + // THEN + const expected = [{ + id: 'Test-Stack-C', + name: 'Test-Stack-C', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [], + }, + { + id: 'Test-Stack-A', + name: 'Test-Stack-A', + environment: { + account: '123456789012', + region: 'bermuda-triangle-1', + name: 'aws://123456789012/bermuda-triangle-1', + }, + dependencies: [{ + id: 'Test-Stack-C', + dependencies: [], + }], + }]; + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'list', + level: 'result', + code: 'CDK_TOOLKIT_I2901', + message: [ + 'Test-Stack-C', + 'Test-Stack-A', + ].join('\n'), + data: { stacks: expected }, + })); + expect(stacks).toEqual(expected); + }); + + test('stacks with circular dependencies should error out', async () => { + // GIVEN + const cx = new TestCloudAssemblySource({ + stacks: [ + { + ...MOCK_STACK_A, + depends: [MOCK_STACK_B.stackName], + }, + { + ...MOCK_STACK_B, + depends: [MOCK_STACK_A.stackName], + }, + ], + }); + + // THEN + await expect(() => toolkit.list(cx)).rejects.toThrow('Could not determine ordering'); + expect(ioHost.notifySpy).not.toHaveBeenCalled(); + }); +}); + +const MOCK_STACK_A: TestStackArtifact = { + stackName: 'Test-Stack-A', + template: { Resources: { TemplateName: 'Test-Stack-A' } }, + env: 'aws://123456789012/bermuda-triangle-1', + metadata: { + '/Test-Stack-A': [ + { + type: ArtifactMetadataEntryType.STACK_TAGS, + }, + ], + }, +}; +const MOCK_STACK_B: TestStackArtifact = { + stackName: 'Test-Stack-B', + template: { Resources: { TemplateName: 'Test-Stack-B' } }, + env: 'aws://123456789012/bermuda-triangle-1', + metadata: { + '/Test-Stack-B': [ + { + type: ArtifactMetadataEntryType.STACK_TAGS, + }, + ], + }, +}; +const MOCK_STACK_C: TestStackArtifact = { + stackName: 'Test-Stack-C', + template: { + Resources: { + MyBucket1: { + Type: 'AWS::S3::Bucket', + Properties: { + AccessControl: 'PublicRead', + }, + DeletionPolicy: 'Retain', + }, + }, + }, + env: 'aws://123456789012/bermuda-triangle-1', + metadata: { + '/Test-Stack-C': [ + { + type: ArtifactMetadataEntryType.STACK_TAGS, + }, + ], + }, +}; diff --git a/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts b/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts index 3bc37117162d6..70dc4ae0183b0 100644 --- a/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts +++ b/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts @@ -1,4 +1,5 @@ -import * as cxapi from '@aws-cdk/cx-api'; +import type * as cxapi from '@aws-cdk/cx-api'; +import { SynthesisMessageLevel } from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import { minimatch } from 'minimatch'; import * as semver from 'semver'; @@ -205,6 +206,24 @@ export class CloudAssembly { } } +/** + * The dependencies of a stack. + */ +export type StackDependency = { + id: string; + dependencies: StackDependency[]; +}; + +/** + * Details of a stack. + */ +export type StackDetails = { + id: string; + name: string; + environment: cxapi.Environment; + dependencies: StackDependency[]; +}; + /** * A collection of stacks and related artifacts * @@ -235,6 +254,45 @@ export class StackCollection { return this.stackArtifacts.map(s => s.hierarchicalId); } + public withDependencies(): StackDetails[] { + const allData: StackDetails[] = []; + + for (const stack of this.stackArtifacts) { + const data: StackDetails = { + id: stack.displayName ?? stack.id, + name: stack.stackName, + environment: stack.environment, + dependencies: [], + }; + + for (const dependencyId of stack.dependencies.map(x => x.id)) { + if (dependencyId.includes('.assets')) { + continue; + } + + const depStack = this.assembly.stackById(dependencyId); + + if (depStack.firstStack.dependencies.filter((dep) => !(dep.id).includes('.assets')).length > 0) { + for (const stackDetail of depStack.withDependencies()) { + data.dependencies.push({ + id: stackDetail.id, + dependencies: stackDetail.dependencies, + }); + } + } else { + data.dependencies.push({ + id: depStack.firstStack.displayName ?? depStack.firstStack.id, + dependencies: [], + }); + } + } + + allData.push(data); + } + + return allData; + } + public reversed() { const arts = [...this.stackArtifacts]; arts.reverse(); @@ -262,15 +320,15 @@ export class StackCollection { for (const stack of this.stackArtifacts) { for (const message of stack.messages) { switch (message.level) { - case cxapi.SynthesisMessageLevel.WARNING: + case SynthesisMessageLevel.WARNING: warnings = true; await logger('warn', message); break; - case cxapi.SynthesisMessageLevel.ERROR: + case SynthesisMessageLevel.ERROR: errors = true; await logger('error', message); break; - case cxapi.SynthesisMessageLevel.INFO: + case SynthesisMessageLevel.INFO: await logger('info', message); break; } diff --git a/packages/aws-cdk/lib/list-stacks.ts b/packages/aws-cdk/lib/list-stacks.ts index df9b4cea7f68b..7f02726374fb1 100644 --- a/packages/aws-cdk/lib/list-stacks.ts +++ b/packages/aws-cdk/lib/list-stacks.ts @@ -1,7 +1,6 @@ import '@jsii/check-node/run'; -import { Environment } from '@aws-cdk/cx-api'; -import { DefaultSelection, ExtendedStackSelection, StackCollection } from './api/cxapp/cloud-assembly'; +import { DefaultSelection, ExtendedStackSelection, type StackDetails } from './api/cxapp/cloud-assembly'; import { CdkToolkit } from './cli/cdk-toolkit'; /** @@ -16,24 +15,6 @@ export interface ListStacksOptions { readonly selectors: string[]; } -/** - * Type to store stack dependencies recursively - */ -export type DependencyDetails = { - id: string; - dependencies: DependencyDetails[]; -}; - -/** - * Type to store stack and their dependencies - */ -export type StackDetails = { - id: string; - name: string; - environment: Environment; - dependencies: DependencyDetails[]; -}; - /** * List Stacks * @@ -51,48 +32,5 @@ export async function listStacks(toolkit: CdkToolkit, options: ListStacksOptions defaultBehavior: DefaultSelection.AllStacks, }); - function calculateStackDependencies(collectionOfStacks: StackCollection): StackDetails[] { - const allData: StackDetails[] = []; - - for (const stack of collectionOfStacks.stackArtifacts) { - const data: StackDetails = { - id: stack.displayName ?? stack.id, - name: stack.stackName, - environment: stack.environment, - dependencies: [], - }; - - for (const dependencyId of stack.dependencies.map(x => x.id)) { - if (dependencyId.includes('.assets')) { - continue; - } - - const depStack = assembly.stackById(dependencyId); - - if (depStack.stackArtifacts[0].dependencies.length > 0 && - depStack.stackArtifacts[0].dependencies.filter((dep) => !(dep.id).includes('.assets')).length > 0) { - - const stackWithDeps = calculateStackDependencies(depStack); - - for (const stackDetail of stackWithDeps) { - data.dependencies.push({ - id: stackDetail.id, - dependencies: stackDetail.dependencies, - }); - } - } else { - data.dependencies.push({ - id: depStack.stackArtifacts[0].displayName ?? depStack.stackArtifacts[0].id, - dependencies: [], - }); - } - } - - allData.push(data); - } - - return allData; - } - - return calculateStackDependencies(stacks); + return stacks.withDependencies(); }