diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index d8fd70981..d39bf70b1 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -1,10 +1,9 @@ import '../../../private/dispose-polyfill'; import type * as cxapi from '@aws-cdk/cloud-assembly-api'; import chalk from 'chalk'; -import { isMatch as picomatch } from 'picomatch'; +import picomatch from 'picomatch'; import { major } from 'semver'; import { ToolkitError } from '../../../toolkit/toolkit-error'; -import { flatten } from '../../../util'; import type { IoHelper } from '../../io/private'; import { IO } from '../../io/private'; import { StackCollection } from '../stack-collection'; @@ -194,7 +193,7 @@ export class StackAssembly implements IReadableCloudAssembly { return { stacks: matched, - suggestions: options.suggestPatternMatches ? this.suggestionsForPatterns(patterns, matched) : undefined, + suggestions: options.suggestPatternMatches ? this.suggestionsForPatterns(patterns) : undefined, }; } } @@ -205,14 +204,21 @@ export class StackAssembly implements IReadableCloudAssembly { * at least one stack are omitted; the array is empty when there is no close * match. Pure computation, never throws, emits no output. */ - private suggestionsForPatterns(patterns: string[], matched: StackCollection): Record { + private suggestionsForPatterns(patterns: string[]): Record { const suggestions: Record = {}; for (const pattern of patterns) { - if (matched.stackArtifacts.some((stack) => picomatch(stack.hierarchicalId, pattern))) { + const exact = picomatch(pattern, undefined, true); + const loose = picomatch(pattern.toLowerCase()); + // The stacks the pattern is about; for a negation, the stacks it excludes + const refersTo = exact.state.negated + ? (matches: picomatch.Matcher, id: string) => !matches(id) + : (matches: picomatch.Matcher, id: string) => matches(id); + + if (this.allStacks.some((stack) => refersTo(exact, stack.hierarchicalId))) { continue; } suggestions[pattern] = this.allStacks - .filter((stack) => picomatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) + .filter((stack) => refersTo(loose, stack.hierarchicalId.toLowerCase())) .map((stack) => stack.hierarchicalId); } return suggestions; @@ -233,8 +239,8 @@ export class StackAssembly implements IReadableCloudAssembly { patterns: string[], extend: ExpandStackSelection = ExpandStackSelection.NONE, ): Promise { - const matchingPattern = (pattern: string) => (stack: cxapi.CloudFormationStackArtifact) => picomatch(stack.hierarchicalId, pattern); - const matchedStacks = flatten(patterns.map(pattern => stacks.filter(matchingPattern(pattern)))); + const selects = matcherFor(patterns); + const matchedStacks = stacks.filter(stack => selects(stack.hierarchicalId)); return this.extendStacks(matchedStacks, stacks, extend); } @@ -267,6 +273,33 @@ export class StackAssembly implements IReadableCloudAssembly { } } +/** + * Match the stacks a set of patterns selects: OR over the selecting patterns, + * AND over the negated ones. Every pattern is compiled exactly as picomatch + * defines it - a negated matcher accepts the stacks that survive it - and + * its parse state's `negated` decides which group a pattern belongs to. + * + * Negations on their own start from every stack, so `!Stack` selects every + * stack but that one. No patterns at all still selects nothing. + */ +function matcherFor(patterns: string[]): (hierarchicalId: string) => boolean { + const positives: picomatch.Matcher[] = []; + const negatives: picomatch.Matcher[] = []; + for (const pattern of patterns) { + const parsed = picomatch(pattern, undefined, true); + (parsed.state.negated ? negatives : positives).push(parsed); + } + + if (positives.length === 0 && negatives.length === 0) { + return () => false; + } + + return (hierarchicalId) => { + const included = positives.length === 0 || positives.some(matches => matches(hierarchicalId)); + return included && negatives.every(matches => matches(hierarchicalId)); + }; +} + function indexByHierarchicalId(stacks: cxapi.CloudFormationStackArtifact[]): Map { const result = new Map(); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-selector.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-selector.ts index 60daad5f4..a165dfce4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-selector.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-selector.ts @@ -78,6 +78,14 @@ export interface StackSelector { /** * A list of patterns to match the stack hierarchical ids * Only used with `PATTERN_*` selection strategies. + * + * A pattern starting with `!` excludes the stacks it matches. The selection is + * the union of the other patterns, minus everything the excluding ones match; + * exclusions on their own start from every stack. `!(...)` is extglob syntax + * and is matched as a regular pattern. + * + * - `['!Prod/Canary']` selects every stack except `Prod/Canary` + * - `['Prod/**', '!Prod/Canary']` selects every stack under `Prod` but that one */ patterns?: string[]; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts index eaae88baa..5abcfa440 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts @@ -212,6 +212,36 @@ describe('destroy', () => { expect(mockDestroyStack).not.toHaveBeenCalled(); }); + test('suggests a closely matching stack when an exclusion does not exist', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['!stack1'] }, + }); + + // THEN: an exclusion was compared against the stacks it left behind, which + // always matches, so nothing was ever reported for it + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('!stack1')} does not exist. Do you mean`), + })); + }); + + test('a stack cancelled out by an exclusion is not reported as missing', async () => { + // WHEN: the exclusion empties the selection, but both patterns match existing stacks + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stack1', '!Stack1'] }, + }); + + // THEN: no "does not exist" for a stack that exists, only the empty-selection warning + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ code: 'CDK_TOOLKIT_W7010' })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ code: 'CDK_TOOLKIT_W7011' })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + test('warns about a missing name but still destroys the matching stacks', async () => { // WHEN: one name matches (Stack1), the other does not const cx = await builderFixture(toolkit, 'two-empty-stacks'); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts new file mode 100644 index 000000000..a5b1c44bf --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/stack-selection-exclusion.test.ts @@ -0,0 +1,111 @@ +import { ExpandStackSelection, StackSelectionStrategy } from '../../../lib/api/cloud-assembly'; +import { Toolkit } from '../../../lib/toolkit'; +import { TestIoHost } from '../../_helpers'; +import type { TestStackArtifact } from '../../_helpers/test-cloud-assembly-source'; +import { TestCloudAssemblySource } from '../../_helpers/test-cloud-assembly-source'; + +const ioHost = new TestIoHost(); +const toolkit = new Toolkit({ ioHost }); + +beforeEach(() => { + ioHost.notifySpy.mockClear(); +}); + +// Patterns used to be matched independently and unioned, so `['!A', '!B']` +// contributed "not A" and "not B" and returned every stack there is. + +const STACKS: TestStackArtifact[] = [ + { stackName: 'StackA', displayName: 'Prod/StackA' }, + { stackName: 'StackB', displayName: 'Prod/StackB' }, + { stackName: 'Canary', displayName: 'Prod/Canary' }, + { stackName: 'DevStack', displayName: 'Dev/DevStack' }, +]; + +async function select(patterns: string[], expand = ExpandStackSelection.NONE) { + const cx = new TestCloudAssemblySource({ stacks: STACKS }); + const stacks = await toolkit.list(cx, { + stacks: { patterns, strategy: StackSelectionStrategy.PATTERN_MATCH, expand }, + }); + return stacks.map(s => s.id).sort(); +} + +describe('exclusion patterns', () => { + test('a single exclusion selects everything else', async () => { + expect(await select(['!Prod/StackB'])).toEqual(['Dev/DevStack', 'Prod/Canary', 'Prod/StackA']); + }); + + test('multiple exclusions remove all of the stacks they match', async () => { + // Used to return every stack, including the two that were excluded + expect(await select(['!Prod/StackB', '!Prod/Canary'])).toEqual(['Dev/DevStack', 'Prod/StackA']); + }); + + test('an exclusion narrows down the stacks matched by the other patterns', async () => { + // Used to return every stack but Canary, because 'Prod/StackA' and + // '!Prod/Canary' were unioned instead of subtracted + expect(await select(['Prod/StackA', '!Prod/Canary'])).toEqual(['Prod/StackA']); + }); + + test('`!(...)` is extglob syntax, not an exclusion', async () => { + // Taken as an exclusion, `!(StackA)` would leave `(StackA)` to exclude and + // select `Prod/StackA` too. As an extglob it does not cross a `/`. + const cx = new TestCloudAssemblySource({ + stacks: [{ stackName: 'StackA' }, { stackName: 'StackB' }, { stackName: 'Nested', displayName: 'Prod/StackA' }], + }); + const stacks = await toolkit.list(cx, { + stacks: { patterns: ['!(StackA)'], strategy: StackSelectionStrategy.PATTERN_MATCH, expand: ExpandStackSelection.NONE }, + }); + + expect(stacks.map(s => s.id).sort()).toEqual(['StackB']); + }); + + test('a doubled `!` cancels out, the way picomatch reads it', async () => { + // Not an exclusion: picomatch matches `!!Prod/StackA` as a positive + expect(await select(['!!Prod/StackA'])).toEqual(['Prod/StackA']); + }); + + test('patterns without an exclusion are unaffected', async () => { + expect(await select(['Prod/StackA', 'Dev/*'])).toEqual(['Dev/DevStack', 'Prod/StackA']); + }); + + test('a bare `!` excludes nothing and does not crash', async () => { + expect(await select(['!'])).toEqual(['Dev/DevStack', 'Prod/Canary', 'Prod/StackA', 'Prod/StackB']); + }); + + test('an empty pattern list still selects nothing', async () => { + expect(await select([])).toEqual([]); + }); +}); + +describe('exclusion patterns and dependency expansion', () => { + const DEPENDENCY: TestStackArtifact = { stackName: 'DependencyStack' }; + const DEPENDENT: TestStackArtifact = { stackName: 'DependentStack', depends: ['DependencyStack'] }; + + test('an excluded stack is still pulled back in as a dependency', async () => { + // Deploying the dependent stack without its dependency is not something the + // toolkit can do, so expansion wins over the exclusion. `--exclusively` + // (ExpandStackSelection.NONE) is how you keep the dependency out. + const cx = new TestCloudAssemblySource({ stacks: [DEPENDENCY, DEPENDENT] }); + const stacks = await toolkit.list(cx, { + stacks: { + patterns: ['DependentStack', '!DependencyStack'], + strategy: StackSelectionStrategy.PATTERN_MATCH, + expand: ExpandStackSelection.UPSTREAM, + }, + }); + + expect(stacks.map(s => s.id).sort()).toEqual(['DependencyStack', 'DependentStack']); + }); + + test('without expansion the exclusion holds', async () => { + const cx = new TestCloudAssemblySource({ stacks: [DEPENDENCY, DEPENDENT] }); + const stacks = await toolkit.list(cx, { + stacks: { + patterns: ['DependentStack', '!DependencyStack'], + strategy: StackSelectionStrategy.PATTERN_MATCH, + expand: ExpandStackSelection.NONE, + }, + }); + + expect(stacks.map(s => s.id).sort()).toEqual(['DependentStack']); + }); +}); diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index e44a565a9..58017509d 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -291,7 +291,43 @@ You can have multiple stacks in a cdk app. An example can be found in [how to cr In order to deploy them, you can list the stacks you want to deploy. If your application contains pipeline stacks, the `cdk list` command will show stack names as paths, showing where they are in the pipeline hierarchy (e.g., `PipelineStack`, `PipelineStack/Prod`, `PipelineStack/Prod/MyService` etc). -If you want to deploy all of them, you can use the flag `--all` or the wildcard `*` to deploy all stacks in an app. Please note that, if you have a hierarchy of stacks as described above, `--all` and `*` will only match the stacks on the top level. If you want to match all the stacks in the hierarchy, use `**`. You can also combine these patterns. For example, if you want to deploy all stacks in the `Prod` stage, you can use `cdk deploy PipelineStack/Prod/**`. +To deploy every stack in the app, use the `--all` flag or the wildcard `*`. +If your app has stacks inside stages, keep in mind how far each wildcard reaches: + +- `*` matches one level, so it only picks up top-level stacks (same as `--all`) +- `**` matches any number of levels, so it picks up stacks at every depth + +You can also combine these patterns. +For example, to deploy everything inside the `Prod` stage of a pipeline: + +```console +cdk deploy 'PipelineStack/Prod/**' +``` + +##### Excluding stacks + +Put a `!` in front of a pattern to leave those stacks out. +Wrap the pattern in quotes so your shell leaves the `!` alone. + +Every stack except `NlbStack`: + +``` +cdk deploy '!NlbStack' +``` + +Everything under `Prod`, except the canary: + +``` +cdk deploy 'PipelineStack/Prod/**' '!PipelineStack/Prod/Canary' +``` + +CDK picks the stacks your normal patterns match, then drops the ones your `!` patterns match. +If you only pass `!` patterns, they apply to every stack in the app. +`--all` doesn't work together with patterns, so use `**` instead. +An excluded stack still deploys if a selected stack needs it — add `--exclusively` (`-e`) to skip dependencies. +And `!(...)` is shell syntax, not a CDK exclusion. + +##### Deploying stacks in parallel `--concurrency N` allows deploying multiple stacks in parallel while respecting inter-stack dependencies to speed up deployments. It does not protect against CloudFormation and other AWS account rate limiting.