Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
};
}
}
Expand All @@ -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<string, string[]> {
private suggestionsForPatterns(patterns: string[]): Record<string, string[]> {
const suggestions: Record<string, string[]> = {};
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;
Expand All @@ -233,8 +239,8 @@ export class StackAssembly implements IReadableCloudAssembly {
patterns: string[],
extend: ExpandStackSelection = ExpandStackSelection.NONE,
): Promise<StackCollection> {
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);
}
Expand Down Expand Up @@ -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<string, cxapi.CloudFormationStackArtifact> {
const result = new Map<string, cxapi.CloudFormationStackArtifact>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down
30 changes: 30 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
});
38 changes: 37 additions & 1 deletion packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading