Skip to content

Commit

Permalink
fix(cli): option --all selects stacks in nested assemblies (#15046)
Browse files Browse the repository at this point in the history
The option `-all` should select all stacks at the top level assembly, regardless of their `hierarchicalId`. For example, if we have a stack at the top level assembly, but nested inside another construct, like this:

```typescript
const construct = new Construct(app, 'construct');
new Stack(construct, 'includeme');

const stage = new Stage(app, 'stage');
new Stack(stage, 'donotincludeme')

```
then, the first stack should be deployed, even though it's `hierarchicalId` will be `"construct/includeme"`.

With this change, we are decoupling `--all` from the glob pattern `*`. 

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
otaviomacedo authored Jun 9, 2021
1 parent d3eb60f commit 0d00e50
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 85 deletions.
11 changes: 8 additions & 3 deletions packages/aws-cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as yargs from 'yargs';
import { SdkProvider } from '../lib/api/aws-auth';
import { BootstrapSource, Bootstrapper } from '../lib/api/bootstrap';
import { CloudFormationDeployments } from '../lib/api/cloudformation-deployments';
import { StackSelector } from '../lib/api/cxapp/cloud-assembly';
import { CloudExecutable } from '../lib/api/cxapp/cloud-executable';
import { execProgram } from '../lib/api/cxapp/exec';
import { ToolkitInfo } from '../lib/api/toolkit-info';
Expand Down Expand Up @@ -230,7 +231,11 @@ async function initCommandLine() {
args.STACKS = args.STACKS || [];
args.ENVIRONMENTS = args.ENVIRONMENTS || [];

const stacks = (args.all) ? ['*'] : args.STACKS;
const selector: StackSelector = {
allTopLevel: args.all,
patterns: args.STACKS,
};

const cli = new CdkToolkit({
cloudExecutable,
cloudFormation,
Expand Down Expand Up @@ -294,7 +299,7 @@ async function initCommandLine() {
}
}
return cli.deploy({
stackNames: stacks,
selector,
exclusively: args.exclusively,
toolkitStackName,
roleArn: args.roleArn,
Expand All @@ -314,7 +319,7 @@ async function initCommandLine() {

case 'destroy':
return cli.destroy({
stackNames: stacks,
selector,
exclusively: args.exclusively,
force: args.force,
roleArn: args.roleArn,
Expand Down
153 changes: 104 additions & 49 deletions packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ export enum ExtendedStackSelection {
Downstream
}

/**
* A specification of which stacks should be selected
*/
export interface StackSelector {
/**
* Whether all stacks at the top level assembly should
* be selected and nothing else
*/
allTopLevel?: boolean,

/**
* A list of patterns to match the stack hierarchical ids
*/
patterns: string[],
}

/**
* A single Cloud Assembly and the operations we do on it to deploy the artifacts inside
*/
Expand All @@ -75,77 +91,100 @@ export class CloudAssembly {
this.directory = assembly.directory;
}

public async selectStacks(selectors: string[], options: SelectStacksOptions): Promise<StackCollection> {
selectors = selectors.filter(s => s != null); // filter null/undefined
selectors = [...new Set(selectors)]; // make them unique

public async selectStacks(selector: StackSelector, options: SelectStacksOptions): Promise<StackCollection> {
const asm = this.assembly;
const topLevelStacks = asm.stacks;
const stacks = semver.major(asm.version) < 10 ? asm.stacks : asm.stacksRecursively;
const allTopLevel = selector.allTopLevel ?? false;
const patterns = sanitizePatterns(selector.patterns);

if (stacks.length === 0) {
throw new Error('This app contains no stacks');
}

if (selectors.length === 0) {
const topLevelStacks = this.assembly.stacks;
switch (options.defaultBehavior) {
case DefaultSelection.MainAssembly:
return new StackCollection(this, topLevelStacks);
case DefaultSelection.AllStacks:
return new StackCollection(this, stacks);
case DefaultSelection.None:
return new StackCollection(this, []);
case DefaultSelection.OnlySingle:
if (topLevelStacks.length === 1) {
return new StackCollection(this, topLevelStacks);
} else {
throw new Error('Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`\n' +
`Stacks: ${stacks.map(x => x.id).join(' ')}`);
}
default:
throw new Error(`invalid default behavior: ${options.defaultBehavior}`);
}
if (allTopLevel) {
return this.selectTopLevelStacks(stacks, topLevelStacks, options.extend);
} else if (patterns.length > 0) {
return this.selectMatchingStacks(stacks, patterns, options.extend);
} else {
return this.selectDefaultStacks(stacks, topLevelStacks, options.defaultBehavior);
}
}

const allStacks = new Map<string, cxapi.CloudFormationStackArtifact>();
for (const stack of stacks) {
allStacks.set(stack.hierarchicalId, stack);
private selectTopLevelStacks(stacks: cxapi.CloudFormationStackArtifact[],
topLevelStacks: cxapi.CloudFormationStackArtifact[],
extend: ExtendedStackSelection = ExtendedStackSelection.None): StackCollection {
if (topLevelStacks.length > 0) {
return this.extendStacks(topLevelStacks, stacks, extend);
} else {
throw new Error('No stack found in the main cloud assembly. Use "list" to print manifest');
}
}

// For every selector argument, pick stacks from the list.
const selectedStacks = new Map<string, cxapi.CloudFormationStackArtifact>();
for (const pattern of selectors) {
let found = false;

for (const stack of stacks) {
const hierarchicalId = stack.hierarchicalId;
if (minimatch(hierarchicalId, pattern) && !selectedStacks.has(hierarchicalId)) {
selectedStacks.set(hierarchicalId, stack);
found = true;
} else if (minimatch(stack.id, pattern) && !selectedStacks.has(hierarchicalId) && semver.major(versionNumber()) < 2) {
warning('Selecting stack by identifier "%s". This identifier is deprecated and will be removed in v2. Please use "%s" instead.', colors.bold(stack.id), colors.bold(stack.hierarchicalId));
warning('Run "cdk ls" to see a list of all stack identifiers');
selectedStacks.set(hierarchicalId, stack);
found = true;
}
private selectMatchingStacks(stacks: cxapi.CloudFormationStackArtifact[],
patterns: string[],
extend: ExtendedStackSelection = ExtendedStackSelection.None): StackCollection {
const matchingPattern = (pattern: string) => (stack: cxapi.CloudFormationStackArtifact) => {
if (minimatch(stack.hierarchicalId, pattern)) {
return true;
} else if (stack.id === pattern && semver.major(versionNumber()) < 2) {
warning('Selecting stack by identifier "%s". This identifier is deprecated and will be removed in v2. Please use "%s" instead.', colors.bold(stack.id), colors.bold(stack.hierarchicalId));
warning('Run "cdk ls" to see a list of all stack identifiers');
return true;
}
return false;
};

if (!found) {
throw new Error(`No stack found matching '${pattern}'. Use "list" to print manifest`);
}
const matchedStacks = patterns
.map(pattern => stacks.find(matchingPattern(pattern)))
.filter(s => s != null) as cxapi.CloudFormationStackArtifact[];

return this.extendStacks(matchedStacks, stacks, extend);
}

private selectDefaultStacks(stacks: cxapi.CloudFormationStackArtifact[],
topLevelStacks: cxapi.CloudFormationStackArtifact[],
defaultSelection: DefaultSelection) {
switch (defaultSelection) {
case DefaultSelection.MainAssembly:
return new StackCollection(this, topLevelStacks);
case DefaultSelection.AllStacks:
return new StackCollection(this, stacks);
case DefaultSelection.None:
return new StackCollection(this, []);
case DefaultSelection.OnlySingle:
if (topLevelStacks.length === 1) {
return new StackCollection(this, topLevelStacks);
} else {
throw new Error('Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`\n' +
`Stacks: ${stacks.map(x => x.id).join(' ')}`);
}
default:
throw new Error(`invalid default behavior: ${defaultSelection}`);
}
}

private extendStacks(matched: cxapi.CloudFormationStackArtifact[],
all: cxapi.CloudFormationStackArtifact[],
extend: ExtendedStackSelection = ExtendedStackSelection.None) {
const allStacks = new Map<string, cxapi.CloudFormationStackArtifact>();
for (const stack of all) {
allStacks.set(stack.hierarchicalId, stack);
}

const extend = options.extend || ExtendedStackSelection.None;
const index = indexByHierarchicalId(matched);

switch (extend) {
case ExtendedStackSelection.Downstream:
includeDownstreamStacks(selectedStacks, allStacks);
includeDownstreamStacks(index, allStacks);
break;
case ExtendedStackSelection.Upstream:
includeUpstreamStacks(selectedStacks, allStacks);
includeUpstreamStacks(index, allStacks);
break;
}

// Filter original array because it is in the right order
const selectedList = stacks.filter(s => selectedStacks.has(s.hierarchicalId));
const selectedList = all.filter(s => index.has(s.hierarchicalId));

return new StackCollection(this, selectedList);
}
Expand Down Expand Up @@ -264,6 +303,16 @@ export interface MetadataMessageOptions {
strict?: boolean;
}

function indexByHierarchicalId(stacks: cxapi.CloudFormationStackArtifact[]): Map<string, cxapi.CloudFormationStackArtifact> {
const result = new Map<string, cxapi.CloudFormationStackArtifact>();

for (const stack of stacks) {
result.set(stack.hierarchicalId, stack);
}

return result;
}

/**
* Calculate the transitive closure of stack dependents.
*
Expand Down Expand Up @@ -321,4 +370,10 @@ function includeUpstreamStacks(
if (added.length > 0) {
print('Including dependency stacks: %s', colors.bold(added.join(', ')));
}
}

function sanitizePatterns(patterns: string[]): string[] {
let sanitized = patterns.filter(s => s != null); // filter null/undefined
sanitized = [...new Set(sanitized)]; // make them unique
return sanitized;
}
32 changes: 16 additions & 16 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { environmentsFromDescriptors, globEnvironmentsFromStacks, looksLikeGlob
import { SdkProvider } from './api/aws-auth';
import { Bootstrapper, BootstrapEnvironmentOptions } from './api/bootstrap';
import { CloudFormationDeployments } from './api/cloudformation-deployments';
import { CloudAssembly, DefaultSelection, ExtendedStackSelection, StackCollection } from './api/cxapp/cloud-assembly';
import { CloudAssembly, DefaultSelection, ExtendedStackSelection, StackCollection, StackSelector } from './api/cxapp/cloud-assembly';
import { CloudExecutable } from './api/cxapp/cloud-executable';
import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor';
import { printSecurityDiff, printStackDiff, RequireApproval } from './diff';
Expand Down Expand Up @@ -108,7 +108,7 @@ export class CdkToolkit {
}

public async deploy(options: DeployOptions) {
const stacks = await this.selectStacksForDeploy(options.stackNames, options.exclusively);
const stacks = await this.selectStacksForDeploy(options.selector, options.exclusively);

const requireApproval = options.requireApproval ?? RequireApproval.Broadening;

Expand Down Expand Up @@ -143,7 +143,7 @@ export class CdkToolkit {
} else {
warning('%s: stack has no resources, deleting existing stack.', colors.bold(stack.displayName));
await this.destroy({
stackNames: [stack.stackName],
selector: { patterns: [stack.stackName] },
exclusively: true,
force: true,
roleArn: options.roleArn,
Expand Down Expand Up @@ -233,7 +233,7 @@ export class CdkToolkit {
}

public async destroy(options: DestroyOptions) {
let stacks = await this.selectStacksForDestroy(options.stackNames, options.exclusively);
let stacks = await this.selectStacksForDestroy(options.selector, options.exclusively);

// The stacks will have been ordered for deployment, so reverse them for deletion.
stacks = stacks.reversed();
Expand Down Expand Up @@ -371,18 +371,18 @@ export class CdkToolkit {
}));
}

private async selectStacksForList(selectors: string[]) {
private async selectStacksForList(patterns: string[]) {
const assembly = await this.assembly();
const stacks = await assembly.selectStacks(selectors, { defaultBehavior: DefaultSelection.AllStacks });
const stacks = await assembly.selectStacks({ patterns }, { defaultBehavior: DefaultSelection.AllStacks });

// No validation

return stacks;
}

private async selectStacksForDeploy(stackNames: string[], exclusively?: boolean) {
private async selectStacksForDeploy(selector: StackSelector, exclusively?: boolean) {
const assembly = await this.assembly();
const stacks = await assembly.selectStacks(stackNames, {
const stacks = await assembly.selectStacks(selector, {
extend: exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream,
defaultBehavior: DefaultSelection.OnlySingle,
});
Expand All @@ -395,7 +395,7 @@ export class CdkToolkit {
private async selectStacksForDiff(stackNames: string[], exclusively?: boolean) {
const assembly = await this.assembly();

const selectedForDiff = await assembly.selectStacks(stackNames, {
const selectedForDiff = await assembly.selectStacks({ patterns: stackNames }, {
extend: exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream,
defaultBehavior: DefaultSelection.MainAssembly,
});
Expand All @@ -408,9 +408,9 @@ export class CdkToolkit {
return selectedForDiff;
}

private async selectStacksForDestroy(stackNames: string[], exclusively?: boolean) {
private async selectStacksForDestroy(selector: StackSelector, exclusively?: boolean) {
const assembly = await this.assembly();
const stacks = await assembly.selectStacks(stackNames, {
const stacks = await assembly.selectStacks(selector, {
extend: exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Downstream,
defaultBehavior: DefaultSelection.OnlySingle,
});
Expand All @@ -437,7 +437,7 @@ export class CdkToolkit {
private async selectSingleStackByName(stackName: string) {
const assembly = await this.assembly();

const stacks = await assembly.selectStacks([stackName], {
const stacks = await assembly.selectStacks({ patterns: [stackName] }, {
extend: ExtendedStackSelection.None,
defaultBehavior: DefaultSelection.None,
});
Expand Down Expand Up @@ -507,9 +507,9 @@ export interface DiffOptions {

export interface DeployOptions {
/**
* Stack names to deploy
* Criteria for selecting stacks to deploy
*/
stackNames: string[];
selector: StackSelector;

/**
* Only select the given stack
Expand Down Expand Up @@ -610,9 +610,9 @@ export interface DeployOptions {

export interface DestroyOptions {
/**
* The names of the stacks to delete
* Criteria for selecting stacks to deploy
*/
stackNames: string[];
selector: StackSelector;

/**
* Whether to exclude stacks that depend on the stacks to be deleted
Expand Down
9 changes: 8 additions & 1 deletion packages/aws-cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,14 @@
"node": ">= 10.13.0 <13 || >=13.7.0"
},
"nozem": {
"ostools": ["git", "date", "cat", "dotnet", "mvn", "npm"],
"ostools": [
"git",
"date",
"cat",
"dotnet",
"mvn",
"npm"
],
"env": {
"CODEBUILD_RESOLVED_SOURCE_VERSION": "|nzm-build"
}
Expand Down
Loading

0 comments on commit 0d00e50

Please sign in to comment.