diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts index 6a423e37d3a51..a9588163beab7 100644 --- a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts @@ -1,6 +1,9 @@ import * as cxapi from '@aws-cdk/cx-api'; -import { CloudAssembly } from 'aws-cdk/lib/api/cxapp/cloud-assembly'; +import { CloudAssembly, sanitizePatterns, StackCollection, ExtendedStackSelection as CliExtendedStackSelection } from 'aws-cdk/lib/api/cxapp/cloud-assembly'; +import { major } from 'semver'; import { ICloudAssemblySource } from './types'; +import { ExtendedStackSelection, StackSelectionStrategy, StackSelector } from '../../types'; +import { ToolkitError } from '../errors'; /** * A single Cloud Assembly wrapped to provide additional stack operations. @@ -9,4 +12,99 @@ export class StackAssembly extends CloudAssembly implements ICloudAssemblySource public async produce(): Promise { return this.assembly; } + + /** + * Improved stack selection interface with a single selector + * @returns + * @throws when the assembly does not contain any stacks, unless `selector.failOnEmpty` is `false` + * @throws when individual selection strategies are not satisfied + */ + public selectStacksV2(selector: StackSelector): StackCollection { + const asm = this.assembly; + const topLevelStacks = asm.stacks; + const allStacks = major(asm.version) < 10 ? asm.stacks : asm.stacksRecursively; + + if (allStacks.length === 0 && (selector.failOnEmpty ?? true)) { + throw new ToolkitError('This app contains no stacks'); + } + + const extend = convertExtend(selector.extend); + const patterns = sanitizePatterns(selector.patterns ?? []); + + switch (selector.strategy) { + case StackSelectionStrategy.ALL_STACKS: + return new StackCollection(this, allStacks); + case StackSelectionStrategy.MAIN_ASSEMBLY: + if (topLevelStacks.length < 1) { + //@todo text should probably be handled in io host + throw new ToolkitError('No stack found in the main cloud assembly. Use "list" to print manifest'); + } + return this.extendStacks(topLevelStacks, allStacks, extend); + case StackSelectionStrategy.ONLY_SINGLE: + if (topLevelStacks.length !== 1) { + //@todo text should probably be handled in io host + throw new ToolkitError('Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`\n' + + `Stacks: ${allStacks.map(x => x.hierarchicalId).join(' · ')}`); + } + return new StackCollection(this, topLevelStacks); + default: + const matched = this.selectMatchingStacks(allStacks, patterns, extend); + if ( + selector.strategy === StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE + && matched.stackCount !== 1 + ) { + //@todo text should probably be handled in io host + throw new ToolkitError( + `Stack selection is ambiguous, please choose a specific stack for import [${allStacks.map(x => x.hierarchicalId).join(',')}]`, + ); + } + if ( + selector.strategy === StackSelectionStrategy.PATTERN_MUST_MATCH + && matched.stackCount < 1 + ) { + //@todo text should probably be handled in io host + throw new ToolkitError( + `Stack selection is ambiguous, please choose a specific stack for import [${allStacks.map(x => x.hierarchicalId).join(',')}]`, + ); + } + + return matched; + } + } + + /** + * Select all stacks. + * + * This method never throws and can safely be used as a basis for other calculations. + * + * @returns a `StackCollection` of all stacks + */ + public selectAllStacks() { + const allStacks = major(this.assembly.version) < 10 ? this.assembly.stacks : this.assembly.stacksRecursively; + return new StackCollection(this, allStacks); + } + + /** + * Select all stacks that have the validateOnSynth flag et. + * + * @param assembly + * @returns a `StackCollection` of all stacks that needs to be validated + */ + public selectStacksForValidation() { + const allStacks = this.selectAllStacks(); + return allStacks.filter((art) => art.validateOnSynth ?? false); + } +} + +function convertExtend(extend?: ExtendedStackSelection): CliExtendedStackSelection | undefined { + switch (extend) { + case ExtendedStackSelection.DOWNSTREAM: + return CliExtendedStackSelection.Downstream; + case ExtendedStackSelection.UPSTREAM: + return CliExtendedStackSelection.Upstream; + case ExtendedStackSelection.NONE: + return CliExtendedStackSelection.None; + default: + return undefined; + } } diff --git a/packages/@aws-cdk/toolkit/lib/toolkit.ts b/packages/@aws-cdk/toolkit/lib/toolkit.ts index 61d1ad38d95c9..c1b4c7b2d96d8 100644 --- a/packages/@aws-cdk/toolkit/lib/toolkit.ts +++ b/packages/@aws-cdk/toolkit/lib/toolkit.ts @@ -83,9 +83,9 @@ export class Toolkit { public async synth(cx: ICloudAssemblySource, options: SynthOptions): Promise { const ioHost = withAction(this.ioHost, 'synth'); const assembly = await this.assemblyFromSource(cx); - const stacks = await assembly.selectStacks(options.stacks, false); - const autoValidateStacks = options.validateStacks ? await this.selectStacksForValidation(assembly) : new StackCollection(assembly, []); - this.processStackMessages(stacks.concat(autoValidateStacks)); + const stacks = assembly.selectStacksV2(options.stacks); + const autoValidateStacks = options.validateStacks ? [assembly.selectStacksForValidation()] : []; + this.processStackMessages(stacks.concat(...autoValidateStacks)); // if we have a single stack, print it to STDOUT if (stacks.stackCount === 1) { @@ -123,7 +123,7 @@ export class Toolkit { public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise { const ioHost = withAction(this.ioHost, 'diff'); const assembly = await this.assemblyFromSource(cx); - const stacks = await assembly.selectStacks(options.stacks, {}); + const stacks = await assembly.selectStacksV2(options.stacks); throw new Error('Not implemented yet'); } @@ -134,7 +134,7 @@ export class Toolkit { const ioHost = withAction(this.ioHost, 'deploy'); const timer = Timer.start(); const assembly = await this.assemblyFromSource(cx); - const stackCollection = await assembly.selectStacks(options.stacks, {}); + const stackCollection = assembly.selectStacksV2(options.stacks); const synthTime = timer.end(); await ioHost.notify(info(`\n✨ Synthesis time: ${synthTime.asSec}s\n`, 'CDK_TOOLKIT_I5001', { @@ -219,11 +219,8 @@ export class Toolkit { } else { await ioHost.notify(warning(`${chalk.bold(stack.displayName)}: stack has no resources, deleting existing stack.`)); await this._destroy(assembly, 'deploy', { - selector: { patterns: [stack.hierarchicalId] }, - exclusively: true, - force: true, + stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE }, roleArn: options.roleArn, - fromDeploy: true, ci: options.ci, }); } @@ -321,7 +318,7 @@ export class Toolkit { } // Perform a rollback - await this.rollback({ + await this.rollback(cx, { selector: { patterns: [stack.hierarchicalId] }, toolkitStackName: options.toolkitStackName, force: options.force, @@ -454,7 +451,7 @@ export class Toolkit { * * Rolls back the selected stacks. */ - public async rollback(_cx: ICloudAssemblySource, _options: WatchOptions): Promise { + public async rollback(_cx: ICloudAssemblySource, _options: any): Promise { const ioHost = withAction(this.ioHost, 'rollback'); throw new Error('Not implemented yet'); } @@ -474,7 +471,7 @@ export class Toolkit { */ private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise { const ioHost = withAction(this.ioHost, action); - let stacks = await assembly.selectStacks(options.stacks, false); + let stacks = await assembly.selectStacksV2(options.stacks); // The stacks will have been ordered for deployment, so reverse them for deletion. stacks = stacks.reversed(); @@ -523,8 +520,6 @@ export class Toolkit { /** * Create a deployments class - * @param action C - * @returns */ private async deploymentsForAction(action: ToolkitAction): Promise { return new Deployments({ @@ -533,17 +528,6 @@ export class Toolkit { }); } - /** - * Select all stacks that have the validateOnSynth flag et. - * - * @param assembly - * @returns a `StackCollection` of all stacks that needs to be validated - */ - private async selectStacksForValidation(assembly: StackAssembly) { - const allStacks = await assembly.selectStacks({ strategy: StackSelectionStrategy.ALL_STACKS }, false); - return allStacks.filter((art) => art.validateOnSynth ?? false); - } - /** * Validate the stacks for errors and warnings according to the CLI's current settings * @deprecated remove and use directly in synth diff --git a/packages/@aws-cdk/toolkit/lib/types.ts b/packages/@aws-cdk/toolkit/lib/types.ts index dbccb1da39fa6..891be972a7ea3 100644 --- a/packages/@aws-cdk/toolkit/lib/types.ts +++ b/packages/@aws-cdk/toolkit/lib/types.ts @@ -13,33 +13,41 @@ export type ToolkitAction = export enum StackSelectionStrategy { /** - * Returns an empty selection in case there are no stacks. + * Returns all stacks in the app regardless of patterns, + * including stacks inside nested assemblies. */ - NONE = 'none', + ALL_STACKS = 'ALL_STACKS', /** - * Return matched stacks. If no patterns are provided, return the single stack in the app. - * If the app has more than one stack, an error is thrown. - * - * This is the default strategy used by "deploy" and "destroy". + * Returns all stacks in the main (top level) assembly only. */ - MATCH_OR_SINGLE = 'match-or-single', + MAIN_ASSEMBLY = 'MAIN_ASSEMBLY', /** - * Throws an exception if the selector doesn't match at least one stack in the app. + * If the assembly includes a single stack, returns it. + * Otherwise throws an exception. */ - MUST_MATCH_PATTERN = 'must-match-pattern', + ONLY_SINGLE = 'ONLY_SINGLE', /** - * Returns all stacks in the main (top level) assembly only. + * @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` */ - MAIN_ASSEMBLY = 'main', + PATTERN_MATCH = 'PATTERN_MATCH', /** - * If no selectors are provided, returns all stacks in the app, - * including stacks inside nested assemblies. + * Return stacks matched by patterns. + * Throws an exception if the patterns don't match at least one stack in the assembly. */ - ALL_STACKS = 'all', + PATTERN_MUST_MATCH = 'PATTERN_MUST_MATCH', + + /** + * Returns if exactly one stack is matched by the pattern(s). + * Throws an exception if no stack, or more than exactly one stack are matched. + */ + PATTERN_MUST_MATCH_SINGLE = 'PATTERN_MUST_MATCH_SINGLE', } /** @@ -60,25 +68,44 @@ export enum ExtendedStackSelection { * Include stacks that depend on this stack */ DOWNSTREAM = 'downstream', + + /** + * @TODO + * Include both directions. + * I.e. stacks that this stack depends on, and stacks that depend on this stack. + */ + // FULL = 'full', } /** * A specification of which stacks should be selected */ export interface StackSelector { + /** + * The behavior if if no selectors are provided. + */ + strategy: StackSelectionStrategy; + /** * A list of patterns to match the stack hierarchical ids + * Only used with `PATTERN_*` selection strategies. */ patterns?: string[]; /** - * Extend the selection to upstream/downstream stacks - * @default ExtendedStackSelection.None only select the specified stacks. + * Extend the selection to upstream/downstream stacks. + * @default ExtendedStackSelection.None only select the specified/matched stacks */ extend?: ExtendedStackSelection; /** - * The behavior if if no selectors are provided. + * By default, we throw an exception if the assembly contains no stacks. + * Set to `false`, to halt execution for empty assemblies without error. + * + * Note that actions can still throw if a stack selection result is empty, + * but the assembly contains stacks in principle. + * + * @default true */ - strategy: StackSelectionStrategy; + failOnEmpty?: boolean; } diff --git a/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts b/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts index 4f0fa296ec123..d4b0ec18380d6 100644 --- a/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts +++ b/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts @@ -134,7 +134,7 @@ export class CloudAssembly { } } - private selectMatchingStacks( + protected selectMatchingStacks( stacks: cxapi.CloudFormationStackArtifact[], patterns: string[], extend: ExtendedStackSelection = ExtendedStackSelection.None, @@ -170,7 +170,7 @@ export class CloudAssembly { } } - private extendStacks( + protected extendStacks( matched: cxapi.CloudFormationStackArtifact[], all: cxapi.CloudFormationStackArtifact[], extend: ExtendedStackSelection = ExtendedStackSelection.None, @@ -241,8 +241,8 @@ export class StackCollection { return new StackCollection(this.assembly, this.stackArtifacts.filter(predicate)); } - public concat(other: StackCollection): StackCollection { - return new StackCollection(this.assembly, this.stackArtifacts.concat(other.stackArtifacts)); + public concat(...others: StackCollection[]): StackCollection { + return new StackCollection(this.assembly, this.stackArtifacts.concat(...others.map(o => o.stackArtifacts))); } /** @@ -380,7 +380,7 @@ function includeUpstreamStacks( } } -function sanitizePatterns(patterns: string[]): string[] { +export function sanitizePatterns(patterns: string[]): string[] { let sanitized = patterns.filter(s => s != null); // filter null/undefined sanitized = [...new Set(sanitized)]; // make them unique return sanitized;