Skip to content
Draft
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
108 changes: 76 additions & 32 deletions packages/aws-cdk/lib/cli/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ import {
deserializeStructure,
formatErrorMessage,
formatTime,
obscureTemplate,
partition,
serializeStructure,
validateSnsTopicArn,
Expand Down Expand Up @@ -1113,32 +1112,64 @@ export class CdkToolkit {
autoValidate?: boolean,
json?: boolean,
): Promise<any> {
const stacks = await this.selectStacksForDiff(stackNames, exclusively, autoValidate);
// Stack selection stays CLI-side to preserve the historical semantics and error
// messages exactly: the MainAssembly default (including the empty selection for
// stage-only apps, which toolkit-lib's MAIN_ASSEMBLY strategy rejects), and
// 'NoStacksMatched' with the autoValidate nuance. The CloudExecutable caches the
// assembly, so the app synthesizes once here and the toolkit-lib call below
// reuses the cached result.
const assembly = await this.assembly();
const selected = await this.selectStacksByPattern(assembly, stackNames, exclusively);
const autoValidateStacks = autoValidate
? (await this.selectStacksForList([])).filter((art) => art.validateOnSynth ?? false)
: new StackCollection(assembly, []);
this.validateStacksSelected(selected.concat(autoValidateStacks), stackNames);

// if we have a single stack, print it to STDOUT
if (stacks.stackCount === 1) {
if (!quiet) {
await printSerializedObject(this.ioHost.asIoHelper(), obscureTemplate(stacks.firstStack.template), json ?? false);
}
// Validation and result emission run through toolkit-lib; listeners map its
// messages onto the historical `cdk synth` output (cf. list/metadata/destroy).
this.ioHost.once(IO.CDK_TOOLKIT_I1001, () => ({ preventDefault: true }));
this.ioHost.once(IO.CDK_TOOLKIT_I1000, () => ({ preventDefault: true }));
// Single stack: the historical output is the (already obscured) template on
// stdout — or nothing at all when quiet — never the success message.
this.ioHost.once(IO.CDK_TOOLKIT_I1901, (msg) => quiet
? { preventDefault: true }
: { message: (json ?? false) ? msg.data.stack.stringifiedJson : msg.data.stack.stringifiedYaml });
// The multi-stack success line was historically `info` (stderr in non-CI), not `result`.
this.ioHost.once(IO.CDK_TOOLKIT_I1902, () => ({ level: 'info' }));

let cx;
try {
cx = await this.toolkit.synth(this.props.cloudExecutable, {
// Selection already happened above; pass the exact hierarchical ids through
// (escaped, because toolkit-lib re-matches patterns as globs and ids are
// unrestricted strings) so toolkit-lib renders precisely the CLI-selected
// set. Expansion, if any, is already applied, and an empty id list yields
// the historical empty selection for stage-only apps.
stacks: {
strategy: StackSelectionStrategy.PATTERN_MATCH,
patterns: selected.hierarchicalIds.map(escapeGlobMetaCharacters),
expand: ExpandStackSelection.NONE,
},
validateStacks: autoValidate ?? false,
});
} finally {
this.ioHost.removeAllListeners();
}

try {
// In CI mode, non-error messages go to stdout. When we just printed the
// template to stdout, skip the flags message to preserve the contract that
// `cdk synth` output is valid YAML. When quiet (no template printed) or
// non-CI (flags go to stderr), it's safe to show.
if (quiet || !this.ioHost.isCI) {
if (selected.stackCount !== 1 || quiet || !this.ioHost.isCI) {
await displayFlagsMessage(this.ioHost.asIoHelper(), this.toolkit, this.props.cloudExecutable);
}
return undefined;
} finally {
// Toolkit.synth's caller owns the returned assembly (disposal is a no-op for
// the CLI's borrowed assembly, but required by the contract).
await cx.dispose();
}

// not outputting template to stdout, let's explain things to the user a little bit...
await this.ioHost.asIoHelper().defaults.info(chalk.green(`Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`));
await this.ioHost.asIoHelper().defaults.info(
`Supply a stack id (${stacks.stackArtifacts.map((s) => chalk.green(s.hierarchicalId)).join(', ')}) to display its template.`,
);

await displayFlagsMessage(this.ioHost.asIoHelper(), this.toolkit, this.props.cloudExecutable);
return undefined;
}

/**
Expand Down Expand Up @@ -1408,27 +1439,35 @@ export class CdkToolkit {
private async selectStacksForDiff(
stackNames: string[],
exclusively?: boolean,
autoValidate?: boolean,
): Promise<StackCollection> {
const assembly = await this.assembly();

const selectedForDiff = await assembly.selectStacks(
const selectedForDiff = await this.selectStacksByPattern(assembly, stackNames, exclusively);

this.validateStacksSelected(selectedForDiff, stackNames);
await this.validateStacks(assembly, selectedForDiff);

return selectedForDiff;
}

/**
* Select stacks with the historical diff/synth semantics: patterns match
* hierarchical ids, no patterns select the top-level (main assembly) stacks
* — an empty collection for stage-only apps — and non-exclusive selection
* expands to upstream dependencies.
*/
private selectStacksByPattern(
assembly: CloudAssembly,
stackNames: string[],
exclusively?: boolean,
): Promise<StackCollection> {
return assembly.selectStacks(
{ patterns: stackNames },
{
extend: exclusively ? ExtendedStackSelection.None : ExtendedStackSelection.Upstream,
defaultBehavior: DefaultSelection.MainAssembly,
},
);

const allStacks = await this.selectStacksForList([]);
const autoValidateStacks = autoValidate
? allStacks.filter((art) => art.validateOnSynth ?? false)
: new StackCollection(assembly, []);

this.validateStacksSelected(selectedForDiff.concat(autoValidateStacks), stackNames);
await this.validateStacks(assembly, selectedForDiff.concat(autoValidateStacks));

return selectedForDiff;
}

/**
Expand Down Expand Up @@ -1509,10 +1548,15 @@ export class CdkToolkit {
}

/**
* Print a serialized object (YAML or JSON) to stdout.
* Escape glob metacharacters so picomatch treats the string as a literal.
*
* Needed when handing already-resolved stack ids back to toolkit-lib as
* selection patterns: hierarchical ids are unrestricted strings, and an
* unescaped id containing e.g. `[1]` would be re-interpreted as a glob
* that can also match other stacks.
*/
async function printSerializedObject(ioHelper: IoHelper, obj: any, json: boolean) {
await ioHelper.defaults.result(serializeStructure(obj, json));
function escapeGlobMetaCharacters(str: string): string {
return str.replace(/[\\*?[\](){}!+@|^$]/g, '\\$&');
}

/**
Expand Down
75 changes: 48 additions & 27 deletions packages/aws-cdk/test/cli/cdk-toolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2425,44 +2425,56 @@ describe('synth', () => {
const toolkit = defaultToolkitSetup();
await toolkit.synth([], false, false);

// Separate tests as colorizing hampers detection
expect(notifySpy.mock.calls[1][0].message).toMatch('Test-Stack-A-Display-Name');
expect(notifySpy.mock.calls[1][0].message).toMatch('Test-Stack-B');
// The "Supply a stack id (...)" line lists all selected stacks.
// Separate assertions as colorizing hampers detection.
expect(notifySpy).toHaveBeenCalledWith(expectIoMsg(expect.stringContaining('Test-Stack-A-Display-Name')));
expect(notifySpy).toHaveBeenCalledWith(expectIoMsg(expect.stringContaining('Test-Stack-B')));
});

test('with no stdout option', async () => {
// GIVE
const toolkit = defaultToolkitSetup();

// THEN
// THEN: resolves. The user-visible output (no template when quiet) is pinned
// by the NDJSON snapshots in test/commands/synth.test.ts; the raw notify spy
// cannot observe the suppression because it runs before the output listeners.
await toolkit.synth(['Test-Stack-A-Display-Name'], false, true);
expect(notifySpy.mock.calls.length).toEqual(0);
});

test('single stack synth in CI mode does not pollute stdout with flags message', async () => {
// GIVEN
ioHost.isCI = true;
const toolkit = defaultToolkitSetup();
describe('flags message gating in CI mode', () => {
let flagsSpy: jest.SpyInstance;

// WHEN - single stack, quiet=false (template printed to stdout)
await toolkit.synth(['Test-Stack-A-Display-Name'], false, false);
beforeEach(() => {
ioHost.isCI = true;
flagsSpy = jest.spyOn(Toolkit.prototype, 'flags').mockResolvedValue([]);
});

// THEN - only the template result should be emitted, no warn-level flags message
const warnMessages = notifySpy.mock.calls.filter(([msg]) => msg.level === 'warn');
expect(warnMessages).toEqual([]);
});
afterEach(() => {
// The suite-level `jest.resetAllMocks()` would leave this prototype spy in
// place with no implementation, breaking every later test that shows the
// flags message; restore the real method instead.
flagsSpy.mockRestore();
});

test('single stack synth in CI mode with quiet shows flags message', async () => {
// GIVEN
ioHost.isCI = true;
const toolkit = defaultToolkitSetup();
test('single stack synth in CI mode does not pollute stdout with flags message', async () => {
const toolkit = defaultToolkitSetup();

// WHEN - single stack, quiet=true (no template printed)
await toolkit.synth(['Test-Stack-A-Display-Name'], false, true);
// WHEN - single stack, quiet=false (template printed to stdout)
await toolkit.synth(['Test-Stack-A-Display-Name'], false, false);

// THEN - flags message is allowed since stdout is not occupied by the template
// (it may or may not appear depending on flag state, but it's not suppressed)
// We just verify the synth completes without error
// THEN - the template occupies stdout in CI mode, so the flags message is skipped entirely
expect(flagsSpy).not.toHaveBeenCalled();
});

test('single stack synth in CI mode with quiet shows flags message', async () => {
const toolkit = defaultToolkitSetup();

// WHEN - single stack, quiet=true (no template printed)
await toolkit.synth(['Test-Stack-A-Display-Name'], false, true);

// THEN - flags message is allowed since stdout is not occupied by the template
expect(flagsSpy).toHaveBeenCalled();
});
});

describe('stack with error and flagged for validation', () => {
Expand Down Expand Up @@ -2491,8 +2503,9 @@ describe('synth', () => {
test('causes synth to succeed if autoValidate=false', async () => {
const toolkit = defaultToolkitSetup();
const autoValidate = false;
// Resolves despite the error annotation on the (unselected) nested stack.
// The quiet output contract is pinned by the snapshots in test/commands/synth.test.ts.
await toolkit.synth([], false, true, autoValidate);
expect(notifySpy.mock.calls.filter(([msg]) => msg.level === 'result').length).toBe(0);
});
});

Expand Down Expand Up @@ -2545,8 +2558,16 @@ describe('synth', () => {

await toolkit.synth([MockStack.MOCK_STACK_D.stackName], true, false);

expect(notifySpy.mock.calls.length).toEqual(1);
expect(notifySpy.mock.calls[0][0]).toBeDefined();
// The single-stack result (the template) was emitted; exclusively=true means
// the dependency was not pulled in, so we did not take the multi-stack path.
expect(notifySpy).toHaveBeenCalledWith(expectIoMsg(expect.stringContaining('Successfully synthesized'), 'result'));
});

test('fails with the historical error message when no stacks match', async () => {
const toolkit = defaultToolkitSetup();

await expect(toolkit.synth(['NoSuchStack'], true, false))
.rejects.toThrow(/No stacks match the name\(s\) NoSuchStack/);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"result","code":null,"message":"Resources:\n TemplateName: Test-Stack-A\n"}
{"seq":2,"type":"notify","action":"synth","level":"result","code":"CDK_TOOLKIT_I1901","message":"Resources:\n TemplateName: Test-Stack-A\n"}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":null,"message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":"CDK_TOOLKIT_I1902","message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":3,"type":"notify","action":"synth","level":"info","code":null,"message":"Supply a stack id () to display its template."}
{"seq":4,"type":"notify","action":"synth","level":"warn","code":null,"message":"1 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more."}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":null,"message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":"CDK_TOOLKIT_I1902","message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":3,"type":"notify","action":"synth","level":"info","code":null,"message":"Supply a stack id (Test-Stack-A-Display-Name, Test-Stack-B) to display its template."}
{"seq":4,"type":"notify","action":"synth","level":"warn","code":null,"message":"1 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more."}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":null,"message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":"CDK_TOOLKIT_I1902","message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":3,"type":"notify","action":"synth","level":"info","code":null,"message":"Supply a stack id (Test-Stack-A-Display-Name, Test-Stack-B) to display its template."}
{"seq":4,"type":"notify","action":"synth","level":"warn","code":null,"message":"1 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more."}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":null,"message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":"CDK_TOOLKIT_I1902","message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":3,"type":"notify","action":"synth","level":"info","code":null,"message":"Supply a stack id (Test-Stack-A-Display-Name, Test-Stack-A/nested) to display its template."}
{"seq":4,"type":"notify","action":"synth","level":"warn","code":null,"message":"1 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more."}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"info","code":"CDK_TOOLKIT_I1002","message":"Including dependency stacks: Test-Stack-B"}
{"seq":3,"type":"notify","action":"synth","level":"info","code":null,"message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":3,"type":"notify","action":"synth","level":"info","code":"CDK_TOOLKIT_I1902","message":"Successfully synthesized to <TMP>/cdk.out<RANDOM>"}
{"seq":4,"type":"notify","action":"synth","level":"info","code":null,"message":"Supply a stack id (Test-Stack-B, Test-Stack-D) to display its template."}
{"seq":5,"type":"notify","action":"synth","level":"warn","code":null,"message":"1 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more."}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"result","code":null,"message":"Resources:\n TemplateName: Test-Stack-A\n"}
{"seq":2,"type":"notify","action":"synth","level":"result","code":"CDK_TOOLKIT_I1901","message":"Resources:\n TemplateName: Test-Stack-A\n"}
{"seq":3,"type":"notify","action":"synth","level":"warn","code":null,"message":"1 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more."}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"result","code":null,"message":"{\n \"Resources\": {\n \"TemplateName\": \"Test-Stack-A\"\n }\n}"}
{"seq":2,"type":"notify","action":"synth","level":"result","code":"CDK_TOOLKIT_I1901","message":"{\n \"Resources\": {\n \"TemplateName\": \"Test-Stack-A\"\n }\n}"}
{"seq":3,"type":"notify","action":"synth","level":"warn","code":null,"message":"1 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more."}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"seq":0,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."}
{"seq":1,"type":"notify","action":"synth","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: <DURATION>\n"}
{"seq":2,"type":"notify","action":"synth","level":"result","code":null,"message":"Resources:\n TemplateName: Data[prod]\n"}
{"seq":2,"type":"notify","action":"synth","level":"result","code":"CDK_TOOLKIT_I1901","message":"Resources:\n TemplateName: Data[prod]\n"}
{"seq":3,"type":"notify","action":"synth","level":"warn","code":null,"message":"1 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more."}
Loading