Skip to content

Commit e3c5f55

Browse files
authored
refactor(toolkit): pull out interesting parts of cxapp (#329)
There is some code in the cxapp api that is needed in both CLI and toolkit-lib. This PR breaks the shared code out and moves it into the temp helper package. The remaining part of cxapp is "downgraded" from being a reusable api to being CLI only. Also includes some more hard copies of legacy exports, to make future life easier. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 7f8c07e commit e3c5f55

37 files changed

+880
-781
lines changed

packages/@aws-cdk/cli-lib-alpha/lib/cli.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { SharedOptions, DeployOptions, DestroyOptions, BootstrapOptions, SynthOptions, ListOptions } from './commands';
22
import { StackActivityProgress, HotswapMode } from './commands';
33
import { exec as runCli } from '../../../aws-cdk/lib';
4-
import { createAssembly, prepareContext, prepareDefaultEnvironment } from '../../../aws-cdk/lib/api/cxapp/exec';
4+
import { prepareContext, prepareDefaultEnvironment } from '../../../aws-cdk/lib/api/cloud-assembly';
5+
import { createAssembly } from '../../../aws-cdk/lib/cxapp';
56
import { debug } from '../../../aws-cdk/lib/legacy-exports';
67

78
const debugFn = async (msg: string) => void debug(msg);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import * as path from 'path';
2+
import { format } from 'util';
3+
import * as cxapi from '@aws-cdk/cx-api';
4+
import * as fs from 'fs-extra';
5+
import type { SdkProvider } from '../aws-auth';
6+
import type { Settings } from '../settings';
7+
8+
/**
9+
* If we don't have region/account defined in context, we fall back to the default SDK behavior
10+
* where region is retrieved from ~/.aws/config and account is based on default credentials provider
11+
* chain and then STS is queried.
12+
*
13+
* This is done opportunistically: for example, if we can't access STS for some reason or the region
14+
* is not configured, the context value will be 'null' and there could failures down the line. In
15+
* some cases, synthesis does not require region/account information at all, so that might be perfectly
16+
* fine in certain scenarios.
17+
*
18+
* @param context The context key/value bash.
19+
*/
20+
export async function prepareDefaultEnvironment(
21+
aws: SdkProvider,
22+
debugFn: (msg: string) => Promise<void>,
23+
): Promise<{ [key: string]: string }> {
24+
const env: { [key: string]: string } = { };
25+
26+
env[cxapi.DEFAULT_REGION_ENV] = aws.defaultRegion;
27+
await debugFn(`Setting "${cxapi.DEFAULT_REGION_ENV}" environment variable to ${env[cxapi.DEFAULT_REGION_ENV]}`);
28+
29+
const accountId = (await aws.defaultAccount())?.accountId;
30+
if (accountId) {
31+
env[cxapi.DEFAULT_ACCOUNT_ENV] = accountId;
32+
await debugFn(`Setting "${cxapi.DEFAULT_ACCOUNT_ENV}" environment variable to ${env[cxapi.DEFAULT_ACCOUNT_ENV]}`);
33+
}
34+
35+
return env;
36+
}
37+
38+
/**
39+
* Settings related to synthesis are read from context.
40+
* The merging of various configuration sources like cli args or cdk.json has already happened.
41+
* We now need to set the final values to the context.
42+
*/
43+
export async function prepareContext(
44+
settings: Settings,
45+
context: {[key: string]: any},
46+
env: { [key: string]: string | undefined},
47+
debugFn: (msg: string) => Promise<void>,
48+
) {
49+
const debugMode: boolean = settings.get(['debug']) ?? true;
50+
if (debugMode) {
51+
env.CDK_DEBUG = 'true';
52+
}
53+
54+
const pathMetadata: boolean = settings.get(['pathMetadata']) ?? true;
55+
if (pathMetadata) {
56+
context[cxapi.PATH_METADATA_ENABLE_CONTEXT] = true;
57+
}
58+
59+
const assetMetadata: boolean = settings.get(['assetMetadata']) ?? true;
60+
if (assetMetadata) {
61+
context[cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT] = true;
62+
}
63+
64+
const versionReporting: boolean = settings.get(['versionReporting']) ?? true;
65+
if (versionReporting) {
66+
context[cxapi.ANALYTICS_REPORTING_ENABLED_CONTEXT] = true;
67+
}
68+
// We need to keep on doing this for framework version from before this flag was deprecated.
69+
if (!versionReporting) {
70+
context['aws:cdk:disable-version-reporting'] = true;
71+
}
72+
73+
const stagingEnabled = settings.get(['staging']) ?? true;
74+
if (!stagingEnabled) {
75+
context[cxapi.DISABLE_ASSET_STAGING_CONTEXT] = true;
76+
}
77+
78+
const bundlingStacks = settings.get(['bundlingStacks']) ?? ['**'];
79+
context[cxapi.BUNDLING_STACKS] = bundlingStacks;
80+
81+
await debugFn(format('context:', context));
82+
83+
return context;
84+
}
85+
86+
export function spaceAvailableForContext(env: { [key: string]: string }, limit: number) {
87+
const size = (value: string) => value != null ? Buffer.byteLength(value) : 0;
88+
89+
const usedSpace = Object.entries(env)
90+
.map(([k, v]) => k === cxapi.CONTEXT_ENV ? size(k) : size(k) + size(v))
91+
.reduce((a, b) => a + b, 0);
92+
93+
return Math.max(0, limit - usedSpace);
94+
}
95+
96+
/**
97+
* Guess the executable from the command-line argument
98+
*
99+
* Only do this if the file is NOT marked as executable. If it is,
100+
* we'll defer to the shebang inside the file itself.
101+
*
102+
* If we're on Windows, we ALWAYS take the handler, since it's hard to
103+
* verify if registry associations have or have not been set up for this
104+
* file type, so we'll assume the worst and take control.
105+
*/
106+
export async function guessExecutable(app: string, debugFn: (msg: string) => Promise<void>) {
107+
const commandLine = appToArray(app);
108+
if (commandLine.length === 1) {
109+
let fstat;
110+
111+
try {
112+
fstat = await fs.stat(commandLine[0]);
113+
} catch {
114+
await debugFn(`Not a file: '${commandLine[0]}'. Using '${commandLine}' as command-line`);
115+
return commandLine;
116+
}
117+
118+
// eslint-disable-next-line no-bitwise
119+
const isExecutable = (fstat.mode & fs.constants.X_OK) !== 0;
120+
const isWindows = process.platform === 'win32';
121+
122+
const handler = EXTENSION_MAP.get(path.extname(commandLine[0]));
123+
if (handler && (!isExecutable || isWindows)) {
124+
return handler(commandLine[0]);
125+
}
126+
}
127+
return commandLine;
128+
}
129+
130+
/**
131+
* Mapping of extensions to command-line generators
132+
*/
133+
const EXTENSION_MAP = new Map<string, CommandGenerator>([
134+
['.js', executeNode],
135+
]);
136+
137+
type CommandGenerator = (file: string) => string[];
138+
139+
/**
140+
* Execute the given file with the same 'node' process as is running the current process
141+
*/
142+
function executeNode(scriptFile: string): string[] {
143+
return [process.execPath, scriptFile];
144+
}
145+
146+
/**
147+
* Make sure the 'app' is an array
148+
*
149+
* If it's a string, split on spaces as a trivial way of tokenizing the command line.
150+
*/
151+
function appToArray(app: any) {
152+
return typeof app === 'string' ? app.split(' ') : app;
153+
}
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
export * from './environment';
2+
export * from './stack-assembly';
3+
export * from './stack-collection';
14
export * from './stack-selector';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import type * as cxapi from '@aws-cdk/cx-api';
2+
import * as chalk from 'chalk';
3+
import { minimatch } from 'minimatch';
4+
import { StackCollection } from './stack-collection';
5+
import { flatten } from '../../util';
6+
import { IO } from '../io/private';
7+
import type { IoHelper } from '../io/private/io-helper';
8+
9+
export interface IStackAssembly {
10+
/**
11+
* The directory this CloudAssembly was read from
12+
*/
13+
directory: string;
14+
15+
/**
16+
* Select a single stack by its ID
17+
*/
18+
stackById(stackId: string): StackCollection;
19+
}
20+
21+
/**
22+
* When selecting stacks, what other stacks to include because of dependencies
23+
*/
24+
export enum ExtendedStackSelection {
25+
/**
26+
* Don't select any extra stacks
27+
*/
28+
None,
29+
30+
/**
31+
* Include stacks that this stack depends on
32+
*/
33+
Upstream,
34+
35+
/**
36+
* Include stacks that depend on this stack
37+
*/
38+
Downstream,
39+
}
40+
41+
/**
42+
* A single Cloud Assembly and the operations we do on it to deploy the artifacts inside
43+
*/
44+
export abstract class BaseStackAssembly implements IStackAssembly {
45+
/**
46+
* Sanitize a list of stack match patterns
47+
*/
48+
protected static sanitizePatterns(patterns: string[]): string[] {
49+
let sanitized = patterns.filter(s => s != null); // filter null/undefined
50+
sanitized = [...new Set(sanitized)]; // make them unique
51+
return sanitized;
52+
}
53+
54+
/**
55+
* The directory this CloudAssembly was read from
56+
*/
57+
public readonly directory: string;
58+
59+
/**
60+
* The IoHelper used for messaging
61+
*/
62+
protected readonly ioHelper: IoHelper;
63+
64+
constructor(public readonly assembly: cxapi.CloudAssembly, ioHelper: IoHelper) {
65+
this.directory = assembly.directory;
66+
this.ioHelper = ioHelper;
67+
}
68+
69+
/**
70+
* Select a single stack by its ID
71+
*/
72+
public stackById(stackId: string) {
73+
return new StackCollection(this, [this.assembly.getStackArtifact(stackId)]);
74+
}
75+
76+
protected async selectMatchingStacks(
77+
stacks: cxapi.CloudFormationStackArtifact[],
78+
patterns: string[],
79+
extend: ExtendedStackSelection = ExtendedStackSelection.None,
80+
): Promise<StackCollection> {
81+
const matchingPattern = (pattern: string) => (stack: cxapi.CloudFormationStackArtifact) => minimatch(stack.hierarchicalId, pattern);
82+
const matchedStacks = flatten(patterns.map(pattern => stacks.filter(matchingPattern(pattern))));
83+
84+
return this.extendStacks(matchedStacks, stacks, extend);
85+
}
86+
87+
protected async extendStacks(
88+
matched: cxapi.CloudFormationStackArtifact[],
89+
all: cxapi.CloudFormationStackArtifact[],
90+
extend: ExtendedStackSelection = ExtendedStackSelection.None,
91+
) {
92+
const allStacks = new Map<string, cxapi.CloudFormationStackArtifact>();
93+
for (const stack of all) {
94+
allStacks.set(stack.hierarchicalId, stack);
95+
}
96+
97+
const index = indexByHierarchicalId(matched);
98+
99+
switch (extend) {
100+
case ExtendedStackSelection.Downstream:
101+
await includeDownstreamStacks(this.ioHelper, index, allStacks);
102+
break;
103+
case ExtendedStackSelection.Upstream:
104+
await includeUpstreamStacks(this.ioHelper, index, allStacks);
105+
break;
106+
}
107+
108+
// Filter original array because it is in the right order
109+
const selectedList = all.filter(s => index.has(s.hierarchicalId));
110+
111+
return new StackCollection(this, selectedList);
112+
}
113+
}
114+
115+
function indexByHierarchicalId(stacks: cxapi.CloudFormationStackArtifact[]): Map<string, cxapi.CloudFormationStackArtifact> {
116+
const result = new Map<string, cxapi.CloudFormationStackArtifact>();
117+
118+
for (const stack of stacks) {
119+
result.set(stack.hierarchicalId, stack);
120+
}
121+
122+
return result;
123+
}
124+
125+
/**
126+
* Calculate the transitive closure of stack dependents.
127+
*
128+
* Modifies `selectedStacks` in-place.
129+
*/
130+
async function includeDownstreamStacks(
131+
ioHelper: IoHelper,
132+
selectedStacks: Map<string, cxapi.CloudFormationStackArtifact>,
133+
allStacks: Map<string, cxapi.CloudFormationStackArtifact>,
134+
) {
135+
const added = new Array<string>();
136+
137+
let madeProgress;
138+
do {
139+
madeProgress = false;
140+
141+
for (const [id, stack] of allStacks) {
142+
// Select this stack if it's not selected yet AND it depends on a stack that's in the selected set
143+
if (!selectedStacks.has(id) && (stack.dependencies || []).some(dep => selectedStacks.has(dep.id))) {
144+
selectedStacks.set(id, stack);
145+
added.push(id);
146+
madeProgress = true;
147+
}
148+
}
149+
} while (madeProgress);
150+
151+
if (added.length > 0) {
152+
await ioHelper.notify(IO.DEFAULT_ASSEMBLY_INFO.msg(`Including depending stacks: ${chalk.bold(added.join(', '))}`));
153+
}
154+
}
155+
156+
/**
157+
* Calculate the transitive closure of stack dependencies.
158+
*
159+
* Modifies `selectedStacks` in-place.
160+
*/
161+
async function includeUpstreamStacks(
162+
ioHelper: IoHelper,
163+
selectedStacks: Map<string, cxapi.CloudFormationStackArtifact>,
164+
allStacks: Map<string, cxapi.CloudFormationStackArtifact>,
165+
) {
166+
const added = new Array<string>();
167+
let madeProgress = true;
168+
while (madeProgress) {
169+
madeProgress = false;
170+
171+
for (const stack of selectedStacks.values()) {
172+
// Select an additional stack if it's not selected yet and a dependency of a selected stack (and exists, obviously)
173+
for (const dependencyId of stack.dependencies.map(x => x.manifest.displayName ?? x.id)) {
174+
if (!selectedStacks.has(dependencyId) && allStacks.has(dependencyId)) {
175+
added.push(dependencyId);
176+
selectedStacks.set(dependencyId, allStacks.get(dependencyId)!);
177+
madeProgress = true;
178+
}
179+
}
180+
}
181+
}
182+
183+
if (added.length > 0) {
184+
await ioHelper.notify(IO.DEFAULT_ASSEMBLY_INFO.msg(`Including dependency stacks: ${chalk.bold(added.join(', '))}`));
185+
}
186+
}

0 commit comments

Comments
 (0)