Skip to content

Commit f3f8802

Browse files
authored
chore(toolkit): new and improved stack selection (#32928)
### Reason for this change New unified, public stack selector interface. This was previously a mix of `StackSelector` and options. It was also very hard to follow. ### Summary of existing usage ``` ## Stacks for list: diff and defineEnvironments ALL+ => ALL, error when none exists - fail if no stacks at all - Always ALL stacks ## listStacks.ts: Not Selector ALL+, MATCH => ALL, or match by pattern with upstream - fail if no stacks at all - if patterns that, otherwise ALL - Always extend Upstream ## Single: Not a Selector, used by metadata MUST_MATCH_ONE => Exactly 1, matched by pattern, errors for various out of bounds - fail if no stacks at all - single pattern - no extend, no default - Custom: Exception if more than 1 - Via stack collection: will fail if not at least 1 ## Destroy: Selector, used by: Destroy in extension Deploy From deploy: Exactly 1 matched by pattern, exclusive MUST_MATCH_ONE From destroy: many is fine ALL+, MUST_MATCH, ONLY_ONE Bug: if no top level stacks and --all cdk destroy --all vs cdk destroy "*/*" No stack found in the main cloud assembly. Use "list" to print manifest Are you sure you want to delete: LocalStage/NetworkStack, DevStage/NetworkStack (y/n)? n Bug: cdk destroy unknown Are you sure you want to delete: (y/n)? Selected: | MAIN (not empty, extended) | MATCH (can be empty, extended) | EXACTLY ONE - fail if no stack at all - maybe MAIN, with throw - otherwise patterns - otherwise exactly 1 - extends both - can be matching none ## Diff: Selector, used by: Diff, Synth Wants: at least 1 Possible bug: synth/diff if no top-level and no patterns Auto: | MATCH (extended, must match) | MAIN (not empty, not extended) - fail if no stack at all - never all top-level - pattern if provided (extended) - otherwise Main (not extended) - Custom: Not none if patterns ### Diff ALL+, MUST_MATCH, ONLY_ONE Wants Exactly 1 of template path Bug?: When top-level is empty cdk diff "*/*" -> finds both cdk diff "*" -> finds none cdk diff -> 0 stacks with differences (bug!!!) ### Synth ALL+, MUST_MATCH More than one doesn't really matter Bug if top-level is empty Supply a stack id () to display its template. -> list in () is empty ## Deploy: Selector, used by: Deploy, Rollback, Import - Custom: Not none if patterns ### Deploy ALL+, MAIN+, MUST_MATCH, ONLY_ONE Bug: Empty top level and "cdk deploy --all" Wants: at least 1 - OPTION: fail if no stack at all - OPTION: Extends - selector: all or patterns or exactly 1 - Custom: Not none ### Rollback ALL+, MAIN+, MUST_MATCH, ONLY_ONE Wants: at least 1 - fail if no stack at all - selector: all or patterns or exactly 1 - no extend - Custom: Not none ### Import MUST_MATCH_ONE, ONLY_ONE Wants: Exactly 1 - fail if no stack at all - selector: all or patterns or exactly 1 - no extend - Custom: more then one ``` ### 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*
1 parent 613a874 commit f3f8802

File tree

4 files changed

+158
-49
lines changed

4 files changed

+158
-49
lines changed
Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as cxapi from '@aws-cdk/cx-api';
2-
import { CloudAssembly } from 'aws-cdk/lib/api/cxapp/cloud-assembly';
2+
import { CloudAssembly, sanitizePatterns, StackCollection, ExtendedStackSelection as CliExtendedStackSelection } from 'aws-cdk/lib/api/cxapp/cloud-assembly';
3+
import { major } from 'semver';
34
import { ICloudAssemblySource } from './types';
5+
import { ExtendedStackSelection, StackSelectionStrategy, StackSelector } from '../../types';
6+
import { ToolkitError } from '../errors';
47

58
/**
69
* A single Cloud Assembly wrapped to provide additional stack operations.
@@ -9,4 +12,99 @@ export class StackAssembly extends CloudAssembly implements ICloudAssemblySource
912
public async produce(): Promise<cxapi.CloudAssembly> {
1013
return this.assembly;
1114
}
15+
16+
/**
17+
* Improved stack selection interface with a single selector
18+
* @returns
19+
* @throws when the assembly does not contain any stacks, unless `selector.failOnEmpty` is `false`
20+
* @throws when individual selection strategies are not satisfied
21+
*/
22+
public selectStacksV2(selector: StackSelector): StackCollection {
23+
const asm = this.assembly;
24+
const topLevelStacks = asm.stacks;
25+
const allStacks = major(asm.version) < 10 ? asm.stacks : asm.stacksRecursively;
26+
27+
if (allStacks.length === 0 && (selector.failOnEmpty ?? true)) {
28+
throw new ToolkitError('This app contains no stacks');
29+
}
30+
31+
const extend = convertExtend(selector.extend);
32+
const patterns = sanitizePatterns(selector.patterns ?? []);
33+
34+
switch (selector.strategy) {
35+
case StackSelectionStrategy.ALL_STACKS:
36+
return new StackCollection(this, allStacks);
37+
case StackSelectionStrategy.MAIN_ASSEMBLY:
38+
if (topLevelStacks.length < 1) {
39+
//@todo text should probably be handled in io host
40+
throw new ToolkitError('No stack found in the main cloud assembly. Use "list" to print manifest');
41+
}
42+
return this.extendStacks(topLevelStacks, allStacks, extend);
43+
case StackSelectionStrategy.ONLY_SINGLE:
44+
if (topLevelStacks.length !== 1) {
45+
//@todo text should probably be handled in io host
46+
throw new ToolkitError('Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`\n' +
47+
`Stacks: ${allStacks.map(x => x.hierarchicalId).join(' · ')}`);
48+
}
49+
return new StackCollection(this, topLevelStacks);
50+
default:
51+
const matched = this.selectMatchingStacks(allStacks, patterns, extend);
52+
if (
53+
selector.strategy === StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE
54+
&& matched.stackCount !== 1
55+
) {
56+
//@todo text should probably be handled in io host
57+
throw new ToolkitError(
58+
`Stack selection is ambiguous, please choose a specific stack for import [${allStacks.map(x => x.hierarchicalId).join(',')}]`,
59+
);
60+
}
61+
if (
62+
selector.strategy === StackSelectionStrategy.PATTERN_MUST_MATCH
63+
&& matched.stackCount < 1
64+
) {
65+
//@todo text should probably be handled in io host
66+
throw new ToolkitError(
67+
`Stack selection is ambiguous, please choose a specific stack for import [${allStacks.map(x => x.hierarchicalId).join(',')}]`,
68+
);
69+
}
70+
71+
return matched;
72+
}
73+
}
74+
75+
/**
76+
* Select all stacks.
77+
*
78+
* This method never throws and can safely be used as a basis for other calculations.
79+
*
80+
* @returns a `StackCollection` of all stacks
81+
*/
82+
public selectAllStacks() {
83+
const allStacks = major(this.assembly.version) < 10 ? this.assembly.stacks : this.assembly.stacksRecursively;
84+
return new StackCollection(this, allStacks);
85+
}
86+
87+
/**
88+
* Select all stacks that have the validateOnSynth flag et.
89+
*
90+
* @param assembly
91+
* @returns a `StackCollection` of all stacks that needs to be validated
92+
*/
93+
public selectStacksForValidation() {
94+
const allStacks = this.selectAllStacks();
95+
return allStacks.filter((art) => art.validateOnSynth ?? false);
96+
}
97+
}
98+
99+
function convertExtend(extend?: ExtendedStackSelection): CliExtendedStackSelection | undefined {
100+
switch (extend) {
101+
case ExtendedStackSelection.DOWNSTREAM:
102+
return CliExtendedStackSelection.Downstream;
103+
case ExtendedStackSelection.UPSTREAM:
104+
return CliExtendedStackSelection.Upstream;
105+
case ExtendedStackSelection.NONE:
106+
return CliExtendedStackSelection.None;
107+
default:
108+
return undefined;
109+
}
12110
}

packages/@aws-cdk/toolkit/lib/toolkit.ts

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,9 @@ export class Toolkit {
8383
public async synth(cx: ICloudAssemblySource, options: SynthOptions): Promise<ICloudAssemblySource> {
8484
const ioHost = withAction(this.ioHost, 'synth');
8585
const assembly = await this.assemblyFromSource(cx);
86-
const stacks = await assembly.selectStacks(options.stacks, false);
87-
const autoValidateStacks = options.validateStacks ? await this.selectStacksForValidation(assembly) : new StackCollection(assembly, []);
88-
this.processStackMessages(stacks.concat(autoValidateStacks));
86+
const stacks = assembly.selectStacksV2(options.stacks);
87+
const autoValidateStacks = options.validateStacks ? [assembly.selectStacksForValidation()] : [];
88+
this.processStackMessages(stacks.concat(...autoValidateStacks));
8989

9090
// if we have a single stack, print it to STDOUT
9191
if (stacks.stackCount === 1) {
@@ -123,7 +123,7 @@ export class Toolkit {
123123
public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise<boolean> {
124124
const ioHost = withAction(this.ioHost, 'diff');
125125
const assembly = await this.assemblyFromSource(cx);
126-
const stacks = await assembly.selectStacks(options.stacks, {});
126+
const stacks = await assembly.selectStacksV2(options.stacks);
127127
throw new Error('Not implemented yet');
128128
}
129129

@@ -134,7 +134,7 @@ export class Toolkit {
134134
const ioHost = withAction(this.ioHost, 'deploy');
135135
const timer = Timer.start();
136136
const assembly = await this.assemblyFromSource(cx);
137-
const stackCollection = await assembly.selectStacks(options.stacks, {});
137+
const stackCollection = assembly.selectStacksV2(options.stacks);
138138

139139
const synthTime = timer.end();
140140
await ioHost.notify(info(`\n✨ Synthesis time: ${synthTime.asSec}s\n`, 'CDK_TOOLKIT_I5001', {
@@ -219,11 +219,8 @@ export class Toolkit {
219219
} else {
220220
await ioHost.notify(warning(`${chalk.bold(stack.displayName)}: stack has no resources, deleting existing stack.`));
221221
await this._destroy(assembly, 'deploy', {
222-
selector: { patterns: [stack.hierarchicalId] },
223-
exclusively: true,
224-
force: true,
222+
stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE },
225223
roleArn: options.roleArn,
226-
fromDeploy: true,
227224
ci: options.ci,
228225
});
229226
}
@@ -321,7 +318,7 @@ export class Toolkit {
321318
}
322319

323320
// Perform a rollback
324-
await this.rollback({
321+
await this.rollback(cx, {
325322
selector: { patterns: [stack.hierarchicalId] },
326323
toolkitStackName: options.toolkitStackName,
327324
force: options.force,
@@ -454,7 +451,7 @@ export class Toolkit {
454451
*
455452
* Rolls back the selected stacks.
456453
*/
457-
public async rollback(_cx: ICloudAssemblySource, _options: WatchOptions): Promise<void> {
454+
public async rollback(_cx: ICloudAssemblySource, _options: any): Promise<void> {
458455
const ioHost = withAction(this.ioHost, 'rollback');
459456
throw new Error('Not implemented yet');
460457
}
@@ -474,7 +471,7 @@ export class Toolkit {
474471
*/
475472
private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise<void> {
476473
const ioHost = withAction(this.ioHost, action);
477-
let stacks = await assembly.selectStacks(options.stacks, false);
474+
let stacks = await assembly.selectStacksV2(options.stacks);
478475

479476
// The stacks will have been ordered for deployment, so reverse them for deletion.
480477
stacks = stacks.reversed();
@@ -523,8 +520,6 @@ export class Toolkit {
523520

524521
/**
525522
* Create a deployments class
526-
* @param action C
527-
* @returns
528523
*/
529524
private async deploymentsForAction(action: ToolkitAction): Promise<Deployments> {
530525
return new Deployments({
@@ -533,17 +528,6 @@ export class Toolkit {
533528
});
534529
}
535530

536-
/**
537-
* Select all stacks that have the validateOnSynth flag et.
538-
*
539-
* @param assembly
540-
* @returns a `StackCollection` of all stacks that needs to be validated
541-
*/
542-
private async selectStacksForValidation(assembly: StackAssembly) {
543-
const allStacks = await assembly.selectStacks({ strategy: StackSelectionStrategy.ALL_STACKS }, false);
544-
return allStacks.filter((art) => art.validateOnSynth ?? false);
545-
}
546-
547531
/**
548532
* Validate the stacks for errors and warnings according to the CLI's current settings
549533
* @deprecated remove and use directly in synth

packages/@aws-cdk/toolkit/lib/types.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,41 @@ export type ToolkitAction =
1313

1414
export enum StackSelectionStrategy {
1515
/**
16-
* Returns an empty selection in case there are no stacks.
16+
* Returns all stacks in the app regardless of patterns,
17+
* including stacks inside nested assemblies.
1718
*/
18-
NONE = 'none',
19+
ALL_STACKS = 'ALL_STACKS',
1920

2021
/**
21-
* Return matched stacks. If no patterns are provided, return the single stack in the app.
22-
* If the app has more than one stack, an error is thrown.
23-
*
24-
* This is the default strategy used by "deploy" and "destroy".
22+
* Returns all stacks in the main (top level) assembly only.
2523
*/
26-
MATCH_OR_SINGLE = 'match-or-single',
24+
MAIN_ASSEMBLY = 'MAIN_ASSEMBLY',
2725

2826
/**
29-
* Throws an exception if the selector doesn't match at least one stack in the app.
27+
* If the assembly includes a single stack, returns it.
28+
* Otherwise throws an exception.
3029
*/
31-
MUST_MATCH_PATTERN = 'must-match-pattern',
30+
ONLY_SINGLE = 'ONLY_SINGLE',
3231

3332
/**
34-
* Returns all stacks in the main (top level) assembly only.
33+
* @todo not currently publicly exposed
34+
* Return stacks matched by patterns.
35+
* If no stacks are found, execution is halted successfully.
36+
* Most likely you don't want to use this but `StackSelectionStrategy.MUST_MATCH_PATTERN`
3537
*/
36-
MAIN_ASSEMBLY = 'main',
38+
PATTERN_MATCH = 'PATTERN_MATCH',
3739

3840
/**
39-
* If no selectors are provided, returns all stacks in the app,
40-
* including stacks inside nested assemblies.
41+
* Return stacks matched by patterns.
42+
* Throws an exception if the patterns don't match at least one stack in the assembly.
4143
*/
42-
ALL_STACKS = 'all',
44+
PATTERN_MUST_MATCH = 'PATTERN_MUST_MATCH',
45+
46+
/**
47+
* Returns if exactly one stack is matched by the pattern(s).
48+
* Throws an exception if no stack, or more than exactly one stack are matched.
49+
*/
50+
PATTERN_MUST_MATCH_SINGLE = 'PATTERN_MUST_MATCH_SINGLE',
4351
}
4452

4553
/**
@@ -60,25 +68,44 @@ export enum ExtendedStackSelection {
6068
* Include stacks that depend on this stack
6169
*/
6270
DOWNSTREAM = 'downstream',
71+
72+
/**
73+
* @TODO
74+
* Include both directions.
75+
* I.e. stacks that this stack depends on, and stacks that depend on this stack.
76+
*/
77+
// FULL = 'full',
6378
}
6479

6580
/**
6681
* A specification of which stacks should be selected
6782
*/
6883
export interface StackSelector {
84+
/**
85+
* The behavior if if no selectors are provided.
86+
*/
87+
strategy: StackSelectionStrategy;
88+
6989
/**
7090
* A list of patterns to match the stack hierarchical ids
91+
* Only used with `PATTERN_*` selection strategies.
7192
*/
7293
patterns?: string[];
7394

7495
/**
75-
* Extend the selection to upstream/downstream stacks
76-
* @default ExtendedStackSelection.None only select the specified stacks.
96+
* Extend the selection to upstream/downstream stacks.
97+
* @default ExtendedStackSelection.None only select the specified/matched stacks
7798
*/
7899
extend?: ExtendedStackSelection;
79100

80101
/**
81-
* The behavior if if no selectors are provided.
102+
* By default, we throw an exception if the assembly contains no stacks.
103+
* Set to `false`, to halt execution for empty assemblies without error.
104+
*
105+
* Note that actions can still throw if a stack selection result is empty,
106+
* but the assembly contains stacks in principle.
107+
*
108+
* @default true
82109
*/
83-
strategy: StackSelectionStrategy;
110+
failOnEmpty?: boolean;
84111
}

packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export class CloudAssembly {
134134
}
135135
}
136136

137-
private selectMatchingStacks(
137+
protected selectMatchingStacks(
138138
stacks: cxapi.CloudFormationStackArtifact[],
139139
patterns: string[],
140140
extend: ExtendedStackSelection = ExtendedStackSelection.None,
@@ -170,7 +170,7 @@ export class CloudAssembly {
170170
}
171171
}
172172

173-
private extendStacks(
173+
protected extendStacks(
174174
matched: cxapi.CloudFormationStackArtifact[],
175175
all: cxapi.CloudFormationStackArtifact[],
176176
extend: ExtendedStackSelection = ExtendedStackSelection.None,
@@ -241,8 +241,8 @@ export class StackCollection {
241241
return new StackCollection(this.assembly, this.stackArtifacts.filter(predicate));
242242
}
243243

244-
public concat(other: StackCollection): StackCollection {
245-
return new StackCollection(this.assembly, this.stackArtifacts.concat(other.stackArtifacts));
244+
public concat(...others: StackCollection[]): StackCollection {
245+
return new StackCollection(this.assembly, this.stackArtifacts.concat(...others.map(o => o.stackArtifacts)));
246246
}
247247

248248
/**
@@ -380,7 +380,7 @@ function includeUpstreamStacks(
380380
}
381381
}
382382

383-
function sanitizePatterns(patterns: string[]): string[] {
383+
export function sanitizePatterns(patterns: string[]): string[] {
384384
let sanitized = patterns.filter(s => s != null); // filter null/undefined
385385
sanitized = [...new Set(sanitized)]; // make them unique
386386
return sanitized;

0 commit comments

Comments
 (0)