From f162c19601c9650c4239311423272d6fd2c8a387 Mon Sep 17 00:00:00 2001 From: Rico Hermans Date: Wed, 2 Jul 2025 12:52:48 +0200 Subject: [PATCH 1/6] fix: CCAPI provider does not do pagination (#678) The CCAPI provider stops after the first page of results. This may cause for example EC2 Prefix Lists that exist to not be found, if they don't occur in the first page of results. Make the provider retrieve all pages of results. Also in this PR: - New SDK mocks had been added without them being added to all the places where they needed to be added to be reset properly. Instead, put them all into an object so we can do a reliable `for` loop that will never go out of date again. - Up the `MaxResults` to 100, so we need to do fewer paginations and the chances of the error message finding too many elements has a reasonable indicator of the number of resources actually found. Fixes https://github.com/aws/aws-cdk-cli/issues/587 --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --- .../lib/context-providers/cc-api-provider.ts | 41 +++++--- .../toolkit-lib/test/_helpers/mock-sdk.ts | 97 +++++++++---------- .../context-providers/cc-api-provider.test.ts | 44 +++++++++ 3 files changed, 118 insertions(+), 64 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/context-providers/cc-api-provider.ts b/packages/@aws-cdk/toolkit-lib/lib/context-providers/cc-api-provider.ts index c0cc1c805..d380f58da 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/context-providers/cc-api-provider.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/context-providers/cc-api-provider.ts @@ -101,24 +101,39 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin { expectedMatchCount?: CcApiContextQuery['expectedMatchCount'], ): Promise { try { - const result = await cc.listResources({ - TypeName: typeName, - }); - const found = (result.ResourceDescriptions ?? []) - .map(foundResourceFromCcApi) - .filter((r) => { - return Object.entries(propertyMatch).every(([propPath, expected]) => { - const actual = findJsonValue(r.properties, propPath); - return propertyMatchesFilter(actual, expected); - }); + const found: FoundResource[] = []; + let nextToken: string | undefined = undefined; + + do { + const result = await cc.listResources({ + TypeName: typeName, + MaxResults: 100, + ...nextToken ? { NextToken: nextToken } : {}, }); + found.push( + ...(result.ResourceDescriptions ?? []) + .map(foundResourceFromCcApi) + .filter((r) => { + return Object.entries(propertyMatch).every(([propPath, expected]) => { + const actual = findJsonValue(r.properties, propPath); + return propertyMatchesFilter(actual, expected); + }); + }), + ); + + nextToken = result.NextToken; + + // This allows us to error out early, before we have consumed all pages. + if ((expectedMatchCount === 'at-most-one' || expectedMatchCount === 'exactly-one') && found.length > 1) { + const atLeast = nextToken ? 'at least ' : ''; + throw new ContextProviderError(`Found ${atLeast}${found.length} resources matching ${JSON.stringify(propertyMatch)}; expected ${expectedMatchCountText(expectedMatchCount)}. Please narrow the search criteria`); + } + } while (nextToken); + if ((expectedMatchCount === 'at-least-one' || expectedMatchCount === 'exactly-one') && found.length === 0) { throw new NoResultsFoundError(`Could not find any resources matching ${JSON.stringify(propertyMatch)}; expected ${expectedMatchCountText(expectedMatchCount)}.`); } - if ((expectedMatchCount === 'at-most-one' || expectedMatchCount === 'exactly-one') && found.length > 1) { - throw new ContextProviderError(`Found ${found.length} resources matching ${JSON.stringify(propertyMatch)}; expected ${expectedMatchCountText(expectedMatchCount)}. Please narrow the search criteria`); - } return found; } catch (err: any) { diff --git a/packages/@aws-cdk/toolkit-lib/test/_helpers/mock-sdk.ts b/packages/@aws-cdk/toolkit-lib/test/_helpers/mock-sdk.ts index 0ee9c09e5..184b53e7f 100644 --- a/packages/@aws-cdk/toolkit-lib/test/_helpers/mock-sdk.ts +++ b/packages/@aws-cdk/toolkit-lib/test/_helpers/mock-sdk.ts @@ -36,24 +36,46 @@ export const FAKE_CREDENTIALS: SDKv3CompatibleCredentials = { export const FAKE_CREDENTIAL_CHAIN = createCredentialChain(() => Promise.resolve(FAKE_CREDENTIALS)); // Default implementations -export const mockAppSyncClient = mockClient(AppSyncClient); -export const mockCloudControlClient = mockClient(CloudControlClient); -export const mockCloudFormationClient = mockClient(CloudFormationClient); -export const mockCloudWatchClient = mockClient(CloudWatchLogsClient); -export const mockCodeBuildClient = mockClient(CodeBuildClient); -export const mockEC2Client = mockClient(EC2Client); -export const mockECRClient = mockClient(ECRClient); -export const mockECSClient = mockClient(ECSClient); -export const mockElasticLoadBalancingV2Client = mockClient(ElasticLoadBalancingV2Client); -export const mockIAMClient = mockClient(IAMClient); -export const mockKMSClient = mockClient(KMSClient); -export const mockLambdaClient = mockClient(LambdaClient); -export const mockRoute53Client = mockClient(Route53Client); -export const mockS3Client = mockClient(S3Client); -export const mockSecretsManagerClient = mockClient(SecretsManagerClient); -export const mockSSMClient = mockClient(SSMClient); -export const mockStepFunctionsClient = mockClient(SFNClient); -export const mockSTSClient = mockClient(STSClient); +export const awsMock = { + appSync: mockClient(AppSyncClient), + cloudControl: mockClient(CloudControlClient), + cloudFormation: mockClient(CloudFormationClient), + cloudWatch: mockClient(CloudWatchLogsClient), + codeBuild: mockClient(CodeBuildClient), + ec2: mockClient(EC2Client), + ecr: mockClient(ECRClient), + ecs: mockClient(ECSClient), + elasticLoadBalancingV2: mockClient(ElasticLoadBalancingV2Client), + iAM: mockClient(IAMClient), + kMS: mockClient(KMSClient), + lambda: mockClient(LambdaClient), + route53: mockClient(Route53Client), + s3: mockClient(S3Client), + sSM: mockClient(SSMClient), + sTS: mockClient(STSClient), + secretsManager: mockClient(SecretsManagerClient), + stepFunctions: mockClient(SFNClient), +}; + +// Global aliases for the mock clients for backwards compatibility +export const mockAppSyncClient = awsMock.appSync; +export const mockCloudControlClient = awsMock.cloudControl; +export const mockCloudFormationClient = awsMock.cloudFormation; +export const mockCloudWatchClient = awsMock.cloudWatch; +export const mockCodeBuildClient = awsMock.codeBuild; +export const mockEC2Client = awsMock.ec2; +export const mockECRClient = awsMock.ecr; +export const mockECSClient = awsMock.ecs; +export const mockElasticLoadBalancingV2Client = awsMock.elasticLoadBalancingV2; +export const mockIAMClient = awsMock.iAM; +export const mockKMSClient = awsMock.kMS; +export const mockLambdaClient = awsMock.lambda; +export const mockRoute53Client = awsMock.route53; +export const mockS3Client = awsMock.s3; +export const mockSSMClient = awsMock.sSM; +export const mockSTSClient = awsMock.sTS; +export const mockSecretsManagerClient = awsMock.secretsManager; +export const mockStepFunctionsClient = awsMock.stepFunctions; /** * Resets clients back to defaults and resets the history @@ -67,22 +89,9 @@ export const mockSTSClient = mockClient(STSClient); export const restoreSdkMocksToDefault = () => { applyToAllMocks('reset'); - mockAppSyncClient.onAnyCommand().resolves({}); - mockCloudControlClient.onAnyCommand().resolves({}); - mockCloudFormationClient.onAnyCommand().resolves({}); - mockCloudWatchClient.onAnyCommand().resolves({}); - mockCodeBuildClient.onAnyCommand().resolves({}); - mockEC2Client.onAnyCommand().resolves({}); - mockECRClient.onAnyCommand().resolves({}); - mockECSClient.onAnyCommand().resolves({}); - mockElasticLoadBalancingV2Client.onAnyCommand().resolves({}); - mockIAMClient.onAnyCommand().resolves({}); - mockKMSClient.onAnyCommand().resolves({}); - mockLambdaClient.onAnyCommand().resolves({}); - mockRoute53Client.onAnyCommand().resolves({}); - mockS3Client.onAnyCommand().resolves({}); - mockSecretsManagerClient.onAnyCommand().resolves({}); - mockSSMClient.onAnyCommand().resolves({}); + for (const mock of Object.values(awsMock)) { + (mock as any).onAnyCommand().resolves({}); + } }; /** @@ -102,23 +111,9 @@ export function undoAllSdkMocks() { } function applyToAllMocks(meth: 'reset' | 'restore') { - mockAppSyncClient[meth](); - mockCloudFormationClient[meth](); - mockCloudWatchClient[meth](); - mockCodeBuildClient[meth](); - mockEC2Client[meth](); - mockECRClient[meth](); - mockECSClient[meth](); - mockElasticLoadBalancingV2Client[meth](); - mockIAMClient[meth](); - mockKMSClient[meth](); - mockLambdaClient[meth](); - mockRoute53Client[meth](); - mockS3Client[meth](); - mockSecretsManagerClient[meth](); - mockSSMClient[meth](); - mockStepFunctionsClient[meth](); - mockSTSClient[meth](); + for (const mock of Object.values(awsMock)) { + mock[meth](); + } } export const setDefaultSTSMocks = () => { diff --git a/packages/@aws-cdk/toolkit-lib/test/context-providers/cc-api-provider.test.ts b/packages/@aws-cdk/toolkit-lib/test/context-providers/cc-api-provider.test.ts index f6ea606bb..276a112bf 100644 --- a/packages/@aws-cdk/toolkit-lib/test/context-providers/cc-api-provider.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/context-providers/cc-api-provider.test.ts @@ -290,6 +290,50 @@ test('error by specifying both exactIdentifier and propertyMatch', async () => { ).rejects.toThrow('specify either exactIdentifier or propertyMatch, but not both'); // THEN }); + +test('CCAPI provider paginates results of listResources', async () => { + // GIVEN + mockCloudControlClient.on(ListResourcesCommand) + .callsFake((input) => { + switch (input.NextToken) { + case undefined: + return { + ResourceDescriptions: [ + { Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' }, + { Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' }, + ], + NextToken: 'next-token', + }; + case 'next-token': + return { + ResourceDescriptions: [ + { Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' }, + ], + }; + default: + throw new Error('Unrecognized token'); + } + }); + + // WHEN + await expect( + // WHEN + provider.getValue({ + account: '123456789012', + region: 'us-east-1', + typeName: 'AWS::EC2::PrefixList', + propertiesToReturn: ['PrefixListId'], + propertyMatch: {}, + }), + ).resolves.toEqual([ + { PrefixListId: 'pl-xxxx', Identifier: 'pl-xxxx' }, + { PrefixListId: 'pl-yyyy', Identifier: 'pl-yyyy' }, + { PrefixListId: 'pl-zzzz', Identifier: 'pl-zzzz' }, + ]); + + expect(mockCloudControlClient).toHaveReceivedCommandTimes(ListResourcesCommand, 2); +}); + test('error by specifying neither exactIdentifier or propertyMatch', async () => { // GIVEN mockCloudControlClient.on(GetResourceCommand).resolves({ From 6a4d423025335978cbae836dbb5468a6d44c21a6 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy <36202692+kaizencc@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:24:07 -0400 Subject: [PATCH 2/6] chore(cli): diff tests should not depend on message order (#680) This turns into an error every time we add a new message to the ioHost. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --- packages/aws-cdk/test/commands/diff.test.ts | 75 +++++++++++---------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/packages/aws-cdk/test/commands/diff.test.ts b/packages/aws-cdk/test/commands/diff.test.ts index 92d4fa2a1..b4713c48a 100644 --- a/packages/aws-cdk/test/commands/diff.test.ts +++ b/packages/aws-cdk/test/commands/diff.test.ts @@ -20,6 +20,10 @@ let tmpDir: string; let ioHost = CliIoHost.instance(); let notifySpy: jest.SpyInstance>; +function output() { + return notifySpy.mock.calls.map(x => x[0].message).join('\n').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); +} + beforeAll(() => { // The toolkit writes and checks for temporary files in the current directory, // so run these tests in a tempdir so they don't interfere with each other @@ -96,7 +100,7 @@ describe('fixed template', () => { }); // THEN - const plainTextOutput = notifySpy.mock.calls.map(x => x[0].message).join('\n').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + const plainTextOutput = output(); expect(plainTextOutput).toContain(`Resources [~] AWS::SomeService::SomeResource SomeResource └─ [~] Something @@ -191,8 +195,8 @@ describe('import existing resources', () => { expect(createDiffChangeSet).toHaveBeenCalled(); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutput.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')).toContain(` + const plainTextOutput = output(); + expect(plainTextOutput).toContain(` Resources [-] AWS::DynamoDB::Table MyTable orphan [←] AWS::DynamoDB::GlobalTable MyGlobalTable import @@ -229,8 +233,8 @@ Resources expect(createDiffChangeSet).toHaveBeenCalled(); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutput.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')).toContain(` + const plainTextOutput = output(); + expect(plainTextOutput).toContain(` Resources [-] AWS::DynamoDB::Table MyTable orphan [+] AWS::DynamoDB::GlobalTable MyGlobalTable @@ -267,8 +271,8 @@ Resources expect(createDiffChangeSet).not.toHaveBeenCalled(); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutput.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')).toContain(` + const plainTextOutput = output(); + expect(plainTextOutput).toContain(` Resources [-] AWS::DynamoDB::Table MyTable orphan [+] AWS::DynamoDB::GlobalTable MyGlobalTable @@ -406,7 +410,7 @@ describe('imports', () => { }); // THEN - const plainTextOutput = notifySpy.mock.calls[1][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + const plainTextOutput = output(); expect(createDiffChangeSet).not.toHaveBeenCalled(); expect(plainTextOutput).toContain(`Stack A Parameters and rules created during migration do not affect resource configuration. @@ -433,7 +437,7 @@ Resources }); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + const plainTextOutput = output(); expect(createDiffChangeSet).toHaveBeenCalled(); expect(plainTextOutput).toContain(`Stack A Parameters and rules created during migration do not affect resource configuration. @@ -525,10 +529,9 @@ describe('non-nested stacks', () => { }); // THEN - const plainTextOutputA = notifySpy.mock.calls[1][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutputA).toContain('Stack A'); - const plainTextOutputB = notifySpy.mock.calls[2][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutputB).toContain('Stack B'); + const plainTextOutput = output(); + expect(plainTextOutput).toContain('Stack A'); + expect(plainTextOutput).toContain('Stack B'); expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('✨ Number of stacks with differences: 2'), @@ -564,10 +567,9 @@ describe('non-nested stacks', () => { }); // THEN - const plainTextOutputA = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutputA).toContain('Stack A'); - const plainTextOutputB = notifySpy.mock.calls[1][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutputB).toContain('Stack B'); + const plainTextOutput = output(); + expect(plainTextOutput).toContain('Stack A'); + expect(plainTextOutput).toContain('Stack B'); expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('✨ Number of stacks with differences: 2'), @@ -630,7 +632,7 @@ describe('non-nested stacks', () => { }); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + const plainTextOutput = output(); expect(plainTextOutput).not.toContain('Stack D'); expect(plainTextOutput).not.toContain('There were no differences'); expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ @@ -648,7 +650,7 @@ describe('non-nested stacks', () => { }); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + const plainTextOutput = output(); expect(plainTextOutput).toContain('Stack A'); expect(plainTextOutput).not.toContain('There were no differences'); expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ @@ -1056,8 +1058,8 @@ describe('nested stacks', () => { }); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').replace(/[ \t]+$/gm, ''); - expect(plainTextOutput.trim()).toEqual(`Stack Parent + const plainTextOutput = output().replace(/[ \t]+$/gm, ''); + expect(plainTextOutput.trim()).toContain(`Stack Parent Resources [~] AWS::CloudFormation::Stack AdditionChild └─ [~] TemplateURL @@ -1120,8 +1122,8 @@ There were no differences`); }); // THEN - const plainTextOutput = notifySpy.mock.calls[1][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').replace(/[ \t]+$/gm, ''); - expect(plainTextOutput.trim()).toEqual(`Stack Parent + const plainTextOutput = output().replace(/[ \t]+$/gm, ''); + expect(plainTextOutput.trim()).toContain(`Stack Parent Resources [~] AWS::CloudFormation::Stack AdditionChild └─ [~] TemplateURL @@ -1183,7 +1185,7 @@ There were no differences`); }); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').replace(/[ \t]+$/gm, ''); + const plainTextOutput = output().replace(/[ \t]+$/gm, ''); expect(plainTextOutput).not.toContain('Stack UnchangedParent'); expect(plainTextOutput).not.toContain('There were no differences'); expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ @@ -1201,7 +1203,7 @@ There were no differences`); }); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').replace(/[ \t]+$/gm, ''); + const plainTextOutput = output().replace(/[ \t]+$/gm, ''); expect(plainTextOutput).toContain(`Stack Parent Resources [~] AWS::CloudFormation::Stack AdditionChild @@ -1311,8 +1313,8 @@ describe('--strict', () => { }); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutput.trim()).toEqual(`Stack A + const plainTextOutput = output(); + expect(plainTextOutput.trim()).toContain(`Stack A Resources [+] AWS::CDK::Metadata MetadataResource [+] AWS::Something::Amazing SomeOtherResource @@ -1333,8 +1335,8 @@ Other Changes }); // THEN - const plainTextOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - expect(plainTextOutput.trim()).toEqual(`Stack A + const plainTextOutput = output(); + expect(plainTextOutput.trim()).toContain(`Stack A Resources [+] AWS::Something::Amazing SomeOtherResource`); @@ -1384,15 +1386,14 @@ describe('stack display names', () => { }); // THEN - const parentOutput = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - const childOutput = notifySpy.mock.calls[1][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + const plainTextOutput = output(); // Verify that the display name (path) is shown instead of the logical ID - expect(parentOutput).toContain('Stack Parent/NestedStack/MyChild'); - expect(parentOutput).not.toContain('Stack MyChild'); + expect(plainTextOutput).toContain('Stack Parent/NestedStack/MyChild'); + expect(plainTextOutput).not.toContain('Stack MyChild'); - expect(childOutput).toContain('Stack Parent/NestedStack'); - expect(childOutput).not.toContain('Stack MyParent'); + expect(plainTextOutput).toContain('Stack Parent/NestedStack'); + expect(plainTextOutput).not.toContain('Stack MyParent'); expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('✨ Number of stacks with differences: 2'), @@ -1425,10 +1426,10 @@ describe('stack display names', () => { }); // THEN - const output = notifySpy.mock.calls[0][0].message.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + const plainTextOutput = output(); // Verify that the logical ID is shown when display name is not available - expect(output).toContain('Stack NoDisplayNameStack'); + expect(plainTextOutput).toContain('Stack NoDisplayNameStack'); expect(exitCode).toBe(0); }); From 4c1422048a4b1467adc4403ca6b1ef76750b1a9f Mon Sep 17 00:00:00 2001 From: Kaizen Conroy <36202692+kaizencc@users.noreply.github.com> Date: Wed, 2 Jul 2025 22:28:44 -0400 Subject: [PATCH 3/6] chore(cli): telemetry sink (#585) Introduces a generic telemetry sink to the cli. it is tested but not used anywhere. There are 3 base types of telemetry sinks: - `FileSink` -> gathers events and writes them to a file - `IoHostSink` -> gathers events and sends them to the IoHost (for writing to stdout/err) - `EndpointSink` -> gathers events and sends them to an external endpoint, batching at intervals of 30 seconds. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --- .../lib/cli/telemetry/endpoint-sink.ts | 141 +++++++ .../aws-cdk/lib/cli/telemetry/file-sink.ts | 67 ++++ .../aws-cdk/lib/cli/telemetry/io-host-sink.ts | 48 +++ packages/aws-cdk/lib/cli/telemetry/schema.ts | 63 +++ .../lib/cli/telemetry/sink-interface.ts | 20 + .../test/cli/telemetry/endpoint-sink.test.ts | 358 ++++++++++++++++++ .../test/cli/telemetry/file-sink.test.ts | 186 +++++++++ .../test/cli/telemetry/io-host-sink.test.ts | 160 ++++++++ 8 files changed, 1043 insertions(+) create mode 100644 packages/aws-cdk/lib/cli/telemetry/endpoint-sink.ts create mode 100644 packages/aws-cdk/lib/cli/telemetry/file-sink.ts create mode 100644 packages/aws-cdk/lib/cli/telemetry/io-host-sink.ts create mode 100644 packages/aws-cdk/lib/cli/telemetry/schema.ts create mode 100644 packages/aws-cdk/lib/cli/telemetry/sink-interface.ts create mode 100644 packages/aws-cdk/test/cli/telemetry/endpoint-sink.test.ts create mode 100644 packages/aws-cdk/test/cli/telemetry/file-sink.test.ts create mode 100644 packages/aws-cdk/test/cli/telemetry/io-host-sink.test.ts diff --git a/packages/aws-cdk/lib/cli/telemetry/endpoint-sink.ts b/packages/aws-cdk/lib/cli/telemetry/endpoint-sink.ts new file mode 100644 index 000000000..7593b85c6 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/endpoint-sink.ts @@ -0,0 +1,141 @@ +import type { IncomingMessage } from 'http'; +import type { Agent } from 'https'; +import { request } from 'https'; +import type { UrlWithStringQuery } from 'url'; +import { ToolkitError } from '@aws-cdk/toolkit-lib'; +import { IoHelper } from '../../api-private'; +import type { IIoHost } from '../io-host'; +import type { TelemetrySchema } from './schema'; +import type { ITelemetrySink } from './sink-interface'; + +const REQUEST_ATTEMPT_TIMEOUT_MS = 500; + +/** + * Properties for the Endpoint Telemetry Client + */ +export interface EndpointTelemetrySinkProps { + /** + * The external endpoint to hit + */ + readonly endpoint: UrlWithStringQuery; + + /** + * Where messages are going to be sent + */ + readonly ioHost: IIoHost; + + /** + * The agent responsible for making the network requests. + * + * Use this to set up a proxy connection. + * + * @default - Uses the shared global node agent + */ + readonly agent?: Agent; +} + +/** + * The telemetry client that hits an external endpoint. + */ +export class EndpointTelemetrySink implements ITelemetrySink { + private events: TelemetrySchema[] = []; + private endpoint: UrlWithStringQuery; + private ioHelper: IoHelper; + private agent?: Agent; + + public constructor(props: EndpointTelemetrySinkProps) { + this.endpoint = props.endpoint; + this.ioHelper = IoHelper.fromActionAwareIoHost(props.ioHost); + this.agent = props.agent; + + // Batch events every 30 seconds + setInterval(() => this.flush(), 30000).unref(); + } + + /** + * Add an event to the collection. + */ + public async emit(event: TelemetrySchema): Promise { + try { + this.events.push(event); + } catch (e: any) { + // Never throw errors, just log them via ioHost + await this.ioHelper.defaults.trace(`Failed to add telemetry event: ${e.message}`); + } + } + + public async flush(): Promise { + try { + if (this.events.length === 0) { + return; + } + + const res = await this.https(this.endpoint, this.events); + + // Clear the events array after successful output + if (res) { + this.events = []; + } + } catch (e: any) { + // Never throw errors, just log them via ioHost + await this.ioHelper.defaults.trace(`Failed to add telemetry event: ${e.message}`); + } + } + + /** + * Returns true if telemetry successfully posted, false otherwise. + */ + private async https( + url: UrlWithStringQuery, + body: TelemetrySchema[], + ): Promise { + try { + const res = await doRequest(url, body, this.agent); + + // Successfully posted + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + return true; + } + + await this.ioHelper.defaults.trace(`Telemetry Unsuccessful: POST ${url.hostname}${url.pathname}: ${res.statusCode}:${res.statusMessage}`); + + return false; + } catch (e: any) { + await this.ioHelper.defaults.trace(`Telemetry Error: POST ${url.hostname}${url.pathname}: ${JSON.stringify(e)}`); + return false; + } + } +} + +/** + * A Promisified version of `https.request()` + */ +function doRequest( + url: UrlWithStringQuery, + data: TelemetrySchema[], + agent?: Agent, +) { + return new Promise((ok, ko) => { + const payload: string = JSON.stringify(data); + const req = request({ + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': payload.length, + }, + agent, + timeout: REQUEST_ATTEMPT_TIMEOUT_MS, + }, ok); + + req.on('error', ko); + req.on('timeout', () => { + const error = new ToolkitError(`Timeout after ${REQUEST_ATTEMPT_TIMEOUT_MS}ms, aborting request`); + req.destroy(error); + }); + + req.end(payload); + }); +} diff --git a/packages/aws-cdk/lib/cli/telemetry/file-sink.ts b/packages/aws-cdk/lib/cli/telemetry/file-sink.ts new file mode 100644 index 000000000..5dfadc500 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/file-sink.ts @@ -0,0 +1,67 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ToolkitError, type IIoHost } from '@aws-cdk/toolkit-lib'; +import type { TelemetrySchema } from './schema'; +import type { ITelemetrySink } from './sink-interface'; +import { IoHelper } from '../../api-private'; + +/** + * Properties for the FileTelemetryClient + */ +export interface FileTelemetrySinkProps { + /** + * Where messages are going to be sent + */ + readonly ioHost: IIoHost; + + /** + * The local file to log telemetry data to. + */ + readonly logFilePath: string; +} + +/** + * A telemetry client that collects events writes them to a file + */ +export class FileTelemetrySink implements ITelemetrySink { + private ioHelper: IoHelper; + private logFilePath: string; + + /** + * Create a new FileTelemetryClient + */ + constructor(props: FileTelemetrySinkProps) { + this.ioHelper = IoHelper.fromActionAwareIoHost(props.ioHost); + this.logFilePath = props.logFilePath; + + if (fs.existsSync(this.logFilePath)) { + throw new ToolkitError(`Telemetry file already exists at ${this.logFilePath}`); + } + + // Create the file if necessary + const directory = path.dirname(this.logFilePath); + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + } + + /** + * Emit an event. + */ + public async emit(event: TelemetrySchema): Promise { + try { + // Format the events as a JSON string with pretty printing + const output = JSON.stringify(event, null, 2) + '\n'; + + // Write to file + fs.appendFileSync(this.logFilePath, output); + } catch (e: any) { + // Never throw errors, just log them via ioHost + await this.ioHelper.defaults.trace(`Failed to add telemetry event: ${e.message}`); + } + } + + public async flush(): Promise { + return; + } +} diff --git a/packages/aws-cdk/lib/cli/telemetry/io-host-sink.ts b/packages/aws-cdk/lib/cli/telemetry/io-host-sink.ts new file mode 100644 index 000000000..12d044295 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/io-host-sink.ts @@ -0,0 +1,48 @@ +import type { IIoHost } from '@aws-cdk/toolkit-lib'; +import type { TelemetrySchema } from './schema'; +import type { ITelemetrySink } from './sink-interface'; +import { IoHelper } from '../../api-private'; + +/** + * Properties for the StdoutTelemetryClient + */ +export interface IoHostTelemetrySinkProps { + /** + * Where messages are going to be sent + */ + readonly ioHost: IIoHost; +} + +/** + * A telemetry client that collects events and flushes them to stdout. + */ +export class IoHostTelemetrySink implements ITelemetrySink { + private ioHelper: IoHelper; + + /** + * Create a new StdoutTelemetryClient + */ + constructor(props: IoHostTelemetrySinkProps) { + this.ioHelper = IoHelper.fromActionAwareIoHost(props.ioHost); + } + + /** + * Emit an event + */ + public async emit(event: TelemetrySchema): Promise { + try { + // Format the events as a JSON string with pretty printing + const output = JSON.stringify(event, null, 2); + + // Write to IoHost + await this.ioHelper.defaults.trace(`--- TELEMETRY EVENT ---\n${output}\n-----------------------\n`); + } catch (e: any) { + // Never throw errors, just log them via ioHost + await this.ioHelper.defaults.trace(`Failed to add telemetry event: ${e.message}`); + } + } + + public async flush(): Promise { + return; + } +} diff --git a/packages/aws-cdk/lib/cli/telemetry/schema.ts b/packages/aws-cdk/lib/cli/telemetry/schema.ts new file mode 100644 index 000000000..e1d2036d4 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/schema.ts @@ -0,0 +1,63 @@ +interface Identifiers { + readonly cdkCliVersion: string; + readonly cdkLibraryVersion?: string; + readonly telemetryVersion: string; + readonly sessionId: string; + readonly eventId: string; + readonly installationId: string; + readonly timestamp: string; + readonly accountId?: string; + readonly region?: string; +} + +interface Event { + readonly state: 'ABORTED' | 'FAILED' | 'SUCCEEDED'; + readonly eventType: string; + readonly command: { + readonly path: string[]; + readonly parameters: string[]; + readonly config: { [key: string]: any }; + }; +} + +interface Environment { + readonly os: { + readonly platform: string; + readonly release: string; + }; + readonly ci: boolean; + readonly nodeVersion: string; +} + +interface Duration { + readonly total: number; + readonly components?: { [key: string]: number }; +} + +type Counters = { [key: string]: number }; + +interface Error { + readonly name: string; + readonly message?: string; // anonymized stack message + readonly trace?: string; // anonymized stack trace + readonly logs?: string; // anonymized stack logs +} + +interface Dependency { + readonly name: string; + readonly version: string; +} + +interface Project { + readonly dependencies?: Dependency[]; +} + +export interface TelemetrySchema { + readonly identifiers: Identifiers; + readonly event: Event; + readonly environment: Environment; + readonly project: Project; + readonly duration: Duration; + readonly counters?: Counters; + readonly error?: Error; +} diff --git a/packages/aws-cdk/lib/cli/telemetry/sink-interface.ts b/packages/aws-cdk/lib/cli/telemetry/sink-interface.ts new file mode 100644 index 000000000..c45ff4893 --- /dev/null +++ b/packages/aws-cdk/lib/cli/telemetry/sink-interface.ts @@ -0,0 +1,20 @@ +import type { TelemetrySchema } from './schema'; + +/** + * All Telemetry Clients are Sinks. + * + * A telemtry client receives event data via 'emit' + * and sends batched events via 'flush' + */ +export interface ITelemetrySink { + /** + * Recieve an event + */ + emit(event: TelemetrySchema): Promise; + + /** + * If the implementer of ITelemetrySink batches events, + * flush sends the data and clears the cache. + */ + flush(): Promise; +} diff --git a/packages/aws-cdk/test/cli/telemetry/endpoint-sink.test.ts b/packages/aws-cdk/test/cli/telemetry/endpoint-sink.test.ts new file mode 100644 index 000000000..375d29df0 --- /dev/null +++ b/packages/aws-cdk/test/cli/telemetry/endpoint-sink.test.ts @@ -0,0 +1,358 @@ +import * as https from 'https'; +import { parse } from 'url'; +import { IoHelper } from '../../../lib/api-private'; +import { CliIoHost } from '../../../lib/cli/io-host'; +import { EndpointTelemetrySink } from '../../../lib/cli/telemetry/endpoint-sink'; +import type { TelemetrySchema } from '../../../lib/cli/telemetry/schema'; + +// Mock the https module +jest.mock('https', () => ({ + request: jest.fn(), +})); + +// Helper function to create a test event +function createTestEvent(eventType: string, properties: Record = {}): TelemetrySchema { + return { + identifiers: { + cdkCliVersion: '1.0.0', + telemetryVersion: '1.0.0', + sessionId: 'test-session', + eventId: `test-event-${eventType}`, + installationId: 'test-installation', + timestamp: new Date().toISOString(), + }, + event: { + state: 'SUCCEEDED', + eventType, + command: { + path: ['test'], + parameters: [], + config: properties, + }, + }, + environment: { + os: { + platform: 'test', + release: 'test', + }, + ci: false, + nodeVersion: process.version, + }, + project: {}, + duration: { + total: 0, + }, + }; +} + +describe('EndpointTelemetrySink', () => { + let ioHost: CliIoHost; + + beforeEach(() => { + jest.resetAllMocks(); + + ioHost = CliIoHost.instance(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Helper to create a mock request object with the necessary event handlers + function setupMockRequest() { + // Create a mock response object with a successful status code + const mockResponse = { + statusCode: 200, + statusMessage: 'OK', + }; + + // Create the mock request object + const mockRequest = { + on: jest.fn(), + end: jest.fn(), + setTimeout: jest.fn(), + }; + + // Mock the https.request to return our mockRequest + (https.request as jest.Mock).mockImplementation((_, callback) => { + // If a callback was provided, call it with our mock response + if (callback) { + setTimeout(() => callback(mockResponse), 0); + } + return mockRequest; + }); + + return mockRequest; + } + + test('makes a POST request to the specified endpoint', async () => { + // GIVEN + const mockRequest = setupMockRequest(); + const endpoint = parse('https://example.com/telemetry'); + const testEvent = createTestEvent('test', { foo: 'bar' }); + const client = new EndpointTelemetrySink({ endpoint, ioHost }); + + // WHEN + await client.emit(testEvent); + await client.flush(); + + // THEN + const expectedPayload = JSON.stringify([testEvent]); + expect(https.request).toHaveBeenCalledWith({ + hostname: 'example.com', + port: null, + path: '/telemetry', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': expectedPayload.length, + }, + agent: undefined, + timeout: 500, + }, expect.anything()); + + expect(mockRequest.end).toHaveBeenCalledWith(expectedPayload); + }); + + test('silently catches request errors', async () => { + // GIVEN + const mockRequest = setupMockRequest(); + const endpoint = parse('https://example.com/telemetry'); + const testEvent = createTestEvent('test'); + const client = new EndpointTelemetrySink({ endpoint, ioHost }); + + mockRequest.on.mockImplementation((event, callback) => { + if (event === 'error') { + callback(new Error('Network error')); + } + return mockRequest; + }); + + await client.emit(testEvent); + + // THEN + await expect(client.flush()).resolves.not.toThrow(); + }); + + test('multiple events sent as one', async () => { + // GIVEN + const mockRequest = setupMockRequest(); + const endpoint = parse('https://example.com/telemetry'); + const testEvent1 = createTestEvent('test1', { foo: 'bar' }); + const testEvent2 = createTestEvent('test2', { foo: 'bazoo' }); + const client = new EndpointTelemetrySink({ endpoint, ioHost }); + + // WHEN + await client.emit(testEvent1); + await client.emit(testEvent2); + await client.flush(); + + // THEN + const expectedPayload = JSON.stringify([testEvent1, testEvent2]); + expect(https.request).toHaveBeenCalledTimes(1); + expect(https.request).toHaveBeenCalledWith({ + hostname: 'example.com', + port: null, + path: '/telemetry', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': expectedPayload.length, + }, + agent: undefined, + timeout: 500, + }, expect.anything()); + + expect(mockRequest.end).toHaveBeenCalledWith(expectedPayload); + }); + + test('successful flush clears events cache', async () => { + // GIVEN + setupMockRequest(); + const endpoint = parse('https://example.com/telemetry'); + const testEvent1 = createTestEvent('test1', { foo: 'bar' }); + const testEvent2 = createTestEvent('test2', { foo: 'bazoo' }); + const client = new EndpointTelemetrySink({ endpoint, ioHost }); + + // WHEN + await client.emit(testEvent1); + await client.flush(); + await client.emit(testEvent2); + await client.flush(); + + // THEN + const expectedPayload1 = JSON.stringify([testEvent1]); + expect(https.request).toHaveBeenCalledTimes(2); + expect(https.request).toHaveBeenCalledWith({ + hostname: 'example.com', + port: null, + path: '/telemetry', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': expectedPayload1.length, + }, + agent: undefined, + timeout: 500, + }, expect.anything()); + + const expectedPayload2 = JSON.stringify([testEvent2]); + expect(https.request).toHaveBeenCalledWith({ + hostname: 'example.com', + port: null, + path: '/telemetry', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': expectedPayload2.length, + }, + agent: undefined, + timeout: 500, + }, expect.anything()); + }); + + test('failed flush does not clear events cache', async () => { + // GIVEN + const mockRequest = { + on: jest.fn(), + end: jest.fn(), + setTimeout: jest.fn(), + }; + // Mock the https.request to return the first response as 503 + (https.request as jest.Mock).mockImplementationOnce((_, callback) => { + // If a callback was provided, call it with our mock response + if (callback) { + setTimeout(() => callback({ + statusCode: 503, + statusMessage: 'Service Unavailable', + }), 0); + } + return mockRequest; + }).mockImplementation((_, callback) => { + if (callback) { + setTimeout(() => callback({ + statusCode: 200, + statusMessage: 'Success', + }), 0); + } + return mockRequest; + }); + + const endpoint = parse('https://example.com/telemetry'); + const testEvent1 = createTestEvent('test1', { foo: 'bar' }); + const testEvent2 = createTestEvent('test2', { foo: 'bazoo' }); + const client = new EndpointTelemetrySink({ endpoint, ioHost }); + + // WHEN + await client.emit(testEvent1); + + // mocked to fail + await client.flush(); + + await client.emit(testEvent2); + + // mocked to succeed + await client.flush(); + + // THEN + const expectedPayload1 = JSON.stringify([testEvent1]); + expect(https.request).toHaveBeenCalledTimes(2); + expect(https.request).toHaveBeenCalledWith({ + hostname: 'example.com', + port: null, + path: '/telemetry', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': expectedPayload1.length, + }, + agent: undefined, + timeout: 500, + }, expect.anything()); + + const expectedPayload2 = JSON.stringify([testEvent2]); + expect(https.request).toHaveBeenCalledWith({ + hostname: 'example.com', + port: null, + path: '/telemetry', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': expectedPayload1.length + expectedPayload2.length - 1, + }, + agent: undefined, + timeout: 500, + }, expect.anything()); + }); + + test('flush is called every 30 seconds', async () => { + // GIVEN + jest.useFakeTimers(); + setupMockRequest(); // Setup the mock request but we don't need the return value + const endpoint = parse('https://example.com/telemetry'); + + // Create a spy on setInterval + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + // Create the client + const client = new EndpointTelemetrySink({ endpoint, ioHost }); + + // Create a spy on the flush method + const flushSpy = jest.spyOn(client, 'flush'); + + // WHEN + // Advance the timer by 30 seconds + jest.advanceTimersByTime(30000); + + // THEN + // Verify setInterval was called with the correct interval + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 30000); + + // Verify flush was called + expect(flushSpy).toHaveBeenCalledTimes(1); + + // Advance the timer by another 30 seconds + jest.advanceTimersByTime(30000); + + // Verify flush was called again + expect(flushSpy).toHaveBeenCalledTimes(2); + + // Clean up + jest.useRealTimers(); + setIntervalSpy.mockRestore(); + }); + + test('handles errors gracefully and logs to trace without throwing', async () => { + // GIVEN + const testEvent = createTestEvent('test'); + + // Create a mock IoHelper with trace spy + const traceSpy = jest.fn(); + const mockIoHelper = { + defaults: { + trace: traceSpy, + }, + }; + + // Mock IoHelper.fromActionAwareIoHost to return our mock + jest.spyOn(IoHelper, 'fromActionAwareIoHost').mockReturnValue(mockIoHelper as any); + + const endpoint = parse('https://example.com/telemetry'); + const client = new EndpointTelemetrySink({ endpoint, ioHost }); + + // Mock https.request to throw an error + (https.request as jest.Mock).mockImplementation(() => { + throw new Error('Network error'); + }); + + await client.emit(testEvent); + + // WHEN & THEN - flush should not throw even when https.request fails + await expect(client.flush()).resolves.not.toThrow(); + + // Verify that the error was logged to trace + expect(traceSpy).toHaveBeenCalledWith( + expect.stringContaining('Telemetry Error: POST example.com/telemetry:'), + ); + }); +}); diff --git a/packages/aws-cdk/test/cli/telemetry/file-sink.test.ts b/packages/aws-cdk/test/cli/telemetry/file-sink.test.ts new file mode 100644 index 000000000..e9982603a --- /dev/null +++ b/packages/aws-cdk/test/cli/telemetry/file-sink.test.ts @@ -0,0 +1,186 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { IoHelper } from '../../../lib/api-private'; +import { CliIoHost } from '../../../lib/cli/io-host'; +import { FileTelemetrySink } from '../../../lib/cli/telemetry/file-sink'; +import type { TelemetrySchema } from '../../../lib/cli/telemetry/schema'; + +describe('FileTelemetrySink', () => { + let tempDir: string; + let logFilePath: string; + let ioHost: CliIoHost; + + beforeEach(() => { + // Create a fresh temp directory for each test + tempDir = path.join(os.tmpdir(), `telemetry-test-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`); + fs.mkdirSync(tempDir, { recursive: true }); + logFilePath = path.join(tempDir, 'telemetry.json'); + + ioHost = CliIoHost.instance(); + }); + + afterEach(() => { + // Clean up temp directory after each test + if (fs.existsSync(tempDir)) { + fs.rmdirSync(tempDir, { recursive: true }); + } + + // Restore all mocks + jest.restoreAllMocks(); + }); + + test('saves data to a file', async () => { + // GIVEN + const testEvent: TelemetrySchema = { + identifiers: { + cdkCliVersion: '1.0.0', + telemetryVersion: '1.0.0', + sessionId: 'test-session', + eventId: 'test-event', + installationId: 'test-installation', + timestamp: new Date().toISOString(), + }, + event: { + state: 'SUCCEEDED', + eventType: 'test', + command: { + path: ['test'], + parameters: [], + config: { foo: 'bar' }, + }, + }, + environment: { + os: { + platform: 'test', + release: 'test', + }, + ci: false, + nodeVersion: process.version, + }, + project: {}, + duration: { + total: 0, + }, + }; + const client = new FileTelemetrySink({ logFilePath, ioHost }); + + // WHEN + await client.emit(testEvent); + + // THEN + expect(fs.existsSync(logFilePath)).toBe(true); + const fileContent = fs.readFileSync(logFilePath, 'utf8'); + const parsedContent = JSON.parse(fileContent); + expect(parsedContent).toEqual(testEvent); + }); + + test('appends data to a file', async () => { + // GIVEN + const testEvent: TelemetrySchema = { + identifiers: { + cdkCliVersion: '1.0.0', + telemetryVersion: '1.0.0', + sessionId: 'test-session', + eventId: 'test-event', + installationId: 'test-installation', + timestamp: new Date().toISOString(), + }, + event: { + state: 'SUCCEEDED', + eventType: 'test', + command: { + path: ['test'], + parameters: [], + config: { foo: 'bar' }, + }, + }, + environment: { + os: { + platform: 'test', + release: 'test', + }, + ci: false, + nodeVersion: process.version, + }, + project: {}, + duration: { + total: 0, + }, + }; + const client = new FileTelemetrySink({ logFilePath, ioHost }); + + // WHEN + await client.emit(testEvent); + await client.emit(testEvent); + + // THEN + expect(fs.existsSync(logFilePath)).toBe(true); + const fileContent = fs.readFileSync(logFilePath, 'utf8'); + + // The file should contain two JSON objects, each pretty-printed with a newline + const expectedSingleEvent = JSON.stringify(testEvent, null, 2) + '\n'; + expect(fileContent).toBe(expectedSingleEvent + expectedSingleEvent); + }); + + test('handles errors gracefully and logs to trace without throwing', async () => { + // GIVEN + const testEvent: TelemetrySchema = { + identifiers: { + cdkCliVersion: '1.0.0', + telemetryVersion: '1.0.0', + sessionId: 'test-session', + eventId: 'test-event', + installationId: 'test-installation', + timestamp: new Date().toISOString(), + }, + event: { + state: 'SUCCEEDED', + eventType: 'test', + command: { + path: ['test'], + parameters: [], + config: { foo: 'bar' }, + }, + }, + environment: { + os: { + platform: 'test', + release: 'test', + }, + ci: false, + nodeVersion: process.version, + }, + project: {}, + duration: { + total: 0, + }, + }; + + // Create a mock IoHelper with trace spy + const traceSpy = jest.fn(); + const mockIoHelper = { + defaults: { + trace: traceSpy, + }, + }; + + // Mock IoHelper.fromActionAwareIoHost to return our mock + jest.spyOn(IoHelper, 'fromActionAwareIoHost').mockReturnValue(mockIoHelper as any); + + const client = new FileTelemetrySink({ logFilePath, ioHost }); + + // Mock fs.appendFileSync to throw an error + jest.spyOn(fs, 'appendFileSync').mockImplementation(() => { + throw new Error('File write error'); + }); + + // WHEN & THEN + await expect(client.emit(testEvent)).resolves.not.toThrow(); + + // Verify that the error was logged to trace + expect(traceSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to add telemetry event:'), + ); + }); +}); diff --git a/packages/aws-cdk/test/cli/telemetry/io-host-sink.test.ts b/packages/aws-cdk/test/cli/telemetry/io-host-sink.test.ts new file mode 100644 index 000000000..363654cc6 --- /dev/null +++ b/packages/aws-cdk/test/cli/telemetry/io-host-sink.test.ts @@ -0,0 +1,160 @@ +import { PassThrough } from 'stream'; +import { IoHelper } from '../../../lib/api-private'; +import { CliIoHost } from '../../../lib/cli/io-host'; +import { IoHostTelemetrySink } from '../../../lib/cli/telemetry/io-host-sink'; +import type { TelemetrySchema } from '../../../lib/cli/telemetry/schema'; + +let passThrough: PassThrough; + +// Mess with the 'process' global so we can replace its 'process.stdin' member +global.process = { ...process }; + +describe('IoHostTelemetrySink', () => { + let mockStdout: jest.Mock; + let mockStderr: jest.Mock; + let ioHost: CliIoHost; + + beforeEach(() => { + mockStdout = jest.fn(); + mockStderr = jest.fn(); + + ioHost = CliIoHost.instance({ + isCI: false, + }); + + (process as any).stdin = passThrough = new PassThrough(); + jest.spyOn(process.stdout, 'write').mockImplementation((str: any, encoding?: any, cb?: any) => { + mockStdout(str.toString()); + const callback = typeof encoding === 'function' ? encoding : cb; + if (callback) callback(); + passThrough.write('\n'); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation((str: any, encoding?: any, cb?: any) => { + mockStderr(str.toString()); + const callback = typeof encoding === 'function' ? encoding : cb; + if (callback) callback(); + return true; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('adds events to collection', async () => { + // GIVEN + const testEvent: TelemetrySchema = { + identifiers: { + cdkCliVersion: '1.0.0', + telemetryVersion: '1.0.0', + sessionId: 'test-session', + eventId: 'test-event', + installationId: 'test-installation', + timestamp: new Date().toISOString(), + }, + event: { + state: 'SUCCEEDED', + eventType: 'test', + command: { + path: ['test'], + parameters: [], + config: { foo: 'bar' }, + }, + }, + environment: { + os: { + platform: 'test', + release: 'test', + }, + ci: false, + nodeVersion: process.version, + }, + project: {}, + duration: { + total: 0, + }, + }; + + // Create a mock IoHelper that writes to stderr like the original + const mockIoHelper = { + defaults: { + trace: async (message: string) => { + mockStderr(message); + }, + }, + }; + + // Mock IoHelper.fromActionAwareIoHost to return our mock + jest.spyOn(IoHelper, 'fromActionAwareIoHost').mockReturnValue(mockIoHelper as any); + + const client = new IoHostTelemetrySink({ ioHost }); + + // WHEN + await client.emit(testEvent); + + // THEN + expect(mockStderr).toHaveBeenCalledWith(expect.stringContaining('--- TELEMETRY EVENT ---')); + }); + + test('handles errors gracefully and logs to trace without throwing', async () => { + // GIVEN + const testEvent: TelemetrySchema = { + identifiers: { + cdkCliVersion: '1.0.0', + telemetryVersion: '1.0.0', + sessionId: 'test-session', + eventId: 'test-event', + installationId: 'test-installation', + timestamp: new Date().toISOString(), + }, + event: { + state: 'SUCCEEDED', + eventType: 'test', + command: { + path: ['test'], + parameters: [], + config: { foo: 'bar' }, + }, + }, + environment: { + os: { + platform: 'test', + release: 'test', + }, + ci: false, + nodeVersion: process.version, + }, + project: {}, + duration: { + total: 0, + }, + }; + + // Create a mock IoHelper with trace spy + const traceSpy = jest.fn(); + const mockIoHelper = { + defaults: { + trace: traceSpy, + }, + }; + + // Mock IoHelper.fromActionAwareIoHost to return our mock + jest.spyOn(IoHelper, 'fromActionAwareIoHost').mockReturnValue(mockIoHelper as any); + + const client = new IoHostTelemetrySink({ ioHost }); + + // Mock JSON.stringify to throw an error + jest.spyOn(JSON, 'stringify').mockImplementation(() => { + throw new Error('JSON stringify error'); + }); + + // WHEN & THEN + await expect(client.emit(testEvent)).resolves.not.toThrow(); + + // Verify that the error was logged to trace + expect(traceSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to add telemetry event:'), + ); + }); +}); From 64d88d9b8358aaa52e4250f94e78fc02631621cc Mon Sep 17 00:00:00 2001 From: Kaizen Conroy <36202692+kaizencc@users.noreply.github.com> Date: Wed, 2 Jul 2025 22:52:19 -0400 Subject: [PATCH 4/6] chore(cli): cli type registry json (#682) Pulling out of #631. This is relevant for telemetry but also helpful in general to compile the [dynamic] source of truth in `cli-config.ts` into a static json for our viewing pleasure. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --- .../aws-cdk/lib/cli/cli-type-registry.json | 942 ++++++++++++++++++ packages/aws-cdk/scripts/user-input-gen.ts | 3 +- 2 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 packages/aws-cdk/lib/cli/cli-type-registry.json diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json new file mode 100644 index 000000000..8d397ac86 --- /dev/null +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -0,0 +1,942 @@ +{ + "globalOptions": { + "app": { + "type": "string", + "alias": "a", + "desc": "REQUIRED WHEN RUNNING APP: command-line for executing your app or a cloud assembly directory (e.g. \"node bin/my-app.js\"). Can also be specified in cdk.json or ~/.cdk.json", + "requiresArg": true + }, + "build": { + "type": "string", + "desc": "Command-line for a pre-synth build" + }, + "context": { + "type": "array", + "alias": "c", + "desc": "Add contextual string parameter (KEY=VALUE)" + }, + "plugin": { + "type": "array", + "alias": "p", + "desc": "Name or path of a node package that extend the CDK features. Can be specified multiple times" + }, + "trace": { + "type": "boolean", + "desc": "Print trace for stack warnings" + }, + "strict": { + "type": "boolean", + "desc": "Do not construct stacks with warnings" + }, + "lookups": { + "type": "boolean", + "desc": "Perform context lookups (synthesis fails if this is disabled and context lookups need to be performed)", + "default": true + }, + "ignore-errors": { + "type": "boolean", + "default": false, + "desc": "Ignores synthesis errors, which will likely produce an invalid output" + }, + "json": { + "type": "boolean", + "alias": "j", + "desc": "Use JSON output instead of YAML when templates are printed to STDOUT", + "default": false + }, + "verbose": { + "type": "boolean", + "alias": "v", + "desc": "Show debug logs (specify multiple times to increase verbosity)", + "default": false, + "count": true + }, + "debug": { + "type": "boolean", + "desc": "Debug the CDK app. Log additional information during synthesis, such as creation stack traces of tokens (sets CDK_DEBUG, will slow down synthesis)", + "default": false + }, + "profile": { + "type": "string", + "desc": "Use the indicated AWS profile as the default environment", + "requiresArg": true + }, + "proxy": { + "type": "string", + "desc": "Use the indicated proxy. Will read from HTTPS_PROXY environment variable if not specified", + "requiresArg": true + }, + "ca-bundle-path": { + "type": "string", + "desc": "Path to CA certificate to use when validating HTTPS requests. Will read from AWS_CA_BUNDLE environment variable if not specified", + "requiresArg": true + }, + "ec2creds": { + "type": "boolean", + "alias": "i", + "desc": "Force trying to fetch EC2 instance credentials. Default: guess EC2 instance status" + }, + "version-reporting": { + "type": "boolean", + "desc": "Include the \"AWS::CDK::Metadata\" resource in synthesized templates (enabled by default)" + }, + "path-metadata": { + "type": "boolean", + "desc": "Include \"aws:cdk:path\" CloudFormation metadata for each resource (enabled by default)" + }, + "asset-metadata": { + "type": "boolean", + "desc": "Include \"aws:asset:*\" CloudFormation metadata for resources that uses assets (enabled by default)" + }, + "role-arn": { + "type": "string", + "alias": "r", + "desc": "ARN of Role to use when invoking CloudFormation", + "requiresArg": true + }, + "staging": { + "type": "boolean", + "desc": "Copy assets to the output directory (use --no-staging to disable the copy of assets which allows local debugging via the SAM CLI to reference the original source files)", + "default": true + }, + "output": { + "type": "string", + "alias": "o", + "desc": "Emits the synthesized cloud assembly into a directory (default: cdk.out)", + "requiresArg": true + }, + "notices": { + "type": "boolean", + "desc": "Show relevant notices" + }, + "no-color": { + "type": "boolean", + "desc": "Removes colors and other style from console output", + "default": false + }, + "ci": { + "type": "boolean", + "desc": "Force CI detection. If CI=true then logs will be sent to stdout instead of stderr" + }, + "unstable": { + "type": "array", + "desc": "Opt in to unstable features. The flag indicates that the scope and API of a feature might still change. Otherwise the feature is generally production ready and fully supported. Can be specified multiple times.", + "default": [] + } + }, + "commands": { + "list": { + "arg": { + "name": "STACKS", + "variadic": true + }, + "aliases": [ + "ls" + ], + "description": "Lists all stacks in the app", + "options": { + "long": { + "type": "boolean", + "default": false, + "alias": "l", + "desc": "Display environment information for each stack" + }, + "show-dependencies": { + "type": "boolean", + "default": false, + "alias": "d", + "desc": "Display stack dependency information for each stack" + } + } + }, + "synth": { + "arg": { + "name": "STACKS", + "variadic": true + }, + "aliases": [ + "synthesize" + ], + "description": "Synthesizes and prints the CloudFormation template for this stack", + "options": { + "exclusively": { + "type": "boolean", + "alias": "e", + "desc": "Only synthesize requested stacks, don't include dependencies" + }, + "validation": { + "type": "boolean", + "desc": "After synthesis, validate stacks with the \"validateOnSynth\" attribute set (can also be controlled with CDK_VALIDATION)", + "default": true + }, + "quiet": { + "type": "boolean", + "alias": "q", + "desc": "Do not output CloudFormation Template to stdout", + "default": false + } + } + }, + "bootstrap": { + "arg": { + "name": "ENVIRONMENTS", + "variadic": true + }, + "description": "Deploys the CDK toolkit stack into an AWS environment", + "options": { + "bootstrap-bucket-name": { + "type": "string", + "alias": [ + "b", + "toolkit-bucket-name" + ], + "desc": "The name of the CDK toolkit bucket; bucket will be created and must not exist" + }, + "bootstrap-kms-key-id": { + "type": "string", + "desc": "AWS KMS master key ID used for the SSE-KMS encryption (specify AWS_MANAGED_KEY to use an AWS-managed key)", + "conflicts": "bootstrap-customer-key" + }, + "example-permissions-boundary": { + "type": "boolean", + "alias": "epb", + "desc": "Use the example permissions boundary.", + "conflicts": "custom-permissions-boundary" + }, + "custom-permissions-boundary": { + "type": "string", + "alias": "cpb", + "desc": "Use the permissions boundary specified by name.", + "conflicts": "example-permissions-boundary" + }, + "bootstrap-customer-key": { + "type": "boolean", + "desc": "Create a Customer Master Key (CMK) for the bootstrap bucket (you will be charged but can customize permissions, modern bootstrapping only)", + "conflicts": "bootstrap-kms-key-id" + }, + "qualifier": { + "type": "string", + "desc": "String which must be unique for each bootstrap stack. You must configure it on your CDK app if you change this from the default." + }, + "public-access-block-configuration": { + "type": "boolean", + "desc": "Block public access configuration on CDK toolkit bucket (enabled by default) " + }, + "tags": { + "type": "array", + "alias": "t", + "desc": "Tags to add for the stack (KEY=VALUE)", + "default": [] + }, + "execute": { + "type": "boolean", + "desc": "Whether to execute ChangeSet (--no-execute will NOT execute the ChangeSet)", + "default": true + }, + "trust": { + "type": "array", + "desc": "The AWS account IDs that should be trusted to perform deployments into this environment (may be repeated, modern bootstrapping only)", + "default": [] + }, + "trust-for-lookup": { + "type": "array", + "desc": "The AWS account IDs that should be trusted to look up values in this environment (may be repeated, modern bootstrapping only)", + "default": [] + }, + "untrust": { + "type": "array", + "desc": "The AWS account IDs that should not be trusted by this environment (may be repeated, modern bootstrapping only)", + "default": [] + }, + "cloudformation-execution-policies": { + "type": "array", + "desc": "The Managed Policy ARNs that should be attached to the role performing deployments into this environment (may be repeated, modern bootstrapping only)", + "default": [] + }, + "force": { + "alias": "f", + "type": "boolean", + "desc": "Always bootstrap even if it would downgrade template version", + "default": false + }, + "termination-protection": { + "type": "boolean", + "desc": "Toggle CloudFormation termination protection on the bootstrap stacks" + }, + "show-template": { + "type": "boolean", + "desc": "Instead of actual bootstrapping, print the current CLI's bootstrapping template to stdout for customization", + "default": false + }, + "toolkit-stack-name": { + "type": "string", + "desc": "The name of the CDK toolkit stack to create", + "requiresArg": true + }, + "template": { + "type": "string", + "requiresArg": true, + "desc": "Use the template from the given file instead of the built-in one (use --show-template to obtain an example)" + }, + "previous-parameters": { + "type": "boolean", + "default": true, + "desc": "Use previous values for existing parameters (you must specify all parameters on every deployment if this is disabled)" + } + } + }, + "gc": { + "description": "Garbage collect assets. Options detailed here: https://github.com/aws/aws-cdk-cli/tree/main/packages/aws-cdk#cdk-gc", + "arg": { + "name": "ENVIRONMENTS", + "variadic": true + }, + "options": { + "action": { + "type": "string", + "desc": "The action (or sub-action) you want to perform. Valid entires are \"print\", \"tag\", \"delete-tagged\", \"full\".", + "default": "full" + }, + "type": { + "type": "string", + "desc": "Specify either ecr, s3, or all", + "default": "all" + }, + "rollback-buffer-days": { + "type": "number", + "desc": "Delete assets that have been marked as isolated for this many days", + "default": 0 + }, + "created-buffer-days": { + "type": "number", + "desc": "Never delete assets younger than this (in days)", + "default": 1 + }, + "confirm": { + "type": "boolean", + "desc": "Confirm via manual prompt before deletion", + "default": true + }, + "bootstrap-stack-name": { + "type": "string", + "desc": "The name of the CDK toolkit stack, if different from the default \"CDKToolkit\"", + "requiresArg": true + } + } + }, + "deploy": { + "description": "Deploys the stack(s) named STACKS into your AWS account", + "options": { + "all": { + "type": "boolean", + "desc": "Deploy all available stacks", + "default": false + }, + "build-exclude": { + "type": "array", + "alias": "E", + "desc": "Do not rebuild asset with the given ID. Can be specified multiple times", + "default": [] + }, + "exclusively": { + "type": "boolean", + "alias": "e", + "desc": "Only deploy requested stacks, don't include dependencies" + }, + "require-approval": { + "type": "string", + "choices": [ + "never", + "any-change", + "broadening" + ], + "desc": "What security-sensitive changes need manual approval" + }, + "notification-arns": { + "type": "array", + "desc": "ARNs of SNS topics that CloudFormation will notify with stack related events. These will be added to ARNs specified with the 'notificationArns' stack property." + }, + "tags": { + "type": "array", + "alias": "t", + "desc": "Tags to add to the stack (KEY=VALUE), overrides tags from Cloud Assembly (deprecated)" + }, + "execute": { + "type": "boolean", + "desc": "Whether to execute ChangeSet (--no-execute will NOT execute the ChangeSet) (deprecated)", + "deprecated": true + }, + "change-set-name": { + "type": "string", + "desc": "Name of the CloudFormation change set to create (only if method is not direct)" + }, + "method": { + "alias": "m", + "type": "string", + "choices": [ + "direct", + "change-set", + "prepare-change-set" + ], + "requiresArg": true, + "desc": "How to perform the deployment. Direct is a bit faster but lacks progress information" + }, + "import-existing-resources": { + "type": "boolean", + "desc": "Indicates if the stack set imports resources that already exist.", + "default": false + }, + "force": { + "alias": "f", + "type": "boolean", + "desc": "Always deploy stack even if templates are identical", + "default": false + }, + "parameters": { + "type": "array", + "desc": "Additional parameters passed to CloudFormation at deploy time (STACK:KEY=VALUE)", + "default": {} + }, + "outputs-file": { + "type": "string", + "alias": "O", + "desc": "Path to file where stack outputs will be written as JSON", + "requiresArg": true + }, + "previous-parameters": { + "type": "boolean", + "default": true, + "desc": "Use previous values for existing parameters (you must specify all parameters on every deployment if this is disabled)" + }, + "toolkit-stack-name": { + "type": "string", + "desc": "The name of the existing CDK toolkit stack (only used for app using legacy synthesis)", + "requiresArg": true + }, + "progress": { + "type": "string", + "choices": [ + "bar", + "events" + ], + "desc": "Display mode for stack activity events" + }, + "rollback": { + "type": "boolean", + "desc": "Rollback stack to stable state on failure. Defaults to 'true', iterate more rapidly with --no-rollback or -R. Note: do **not** disable this flag for deployments with resource replacements, as that will always fail", + "negativeAlias": "R" + }, + "hotswap": { + "type": "boolean", + "desc": "Attempts to perform a 'hotswap' deployment, but does not fall back to a full deployment if that is not possible. Instead, changes to any non-hotswappable properties are ignored.Do not use this in production environments" + }, + "hotswap-fallback": { + "type": "boolean", + "desc": "Attempts to perform a 'hotswap' deployment, which skips CloudFormation and updates the resources directly, and falls back to a full deployment if that is not possible. Do not use this in production environments" + }, + "hotswap-ecs-minimum-healthy-percent": { + "type": "string", + "desc": "Lower limit on the number of your service's tasks that must remain in the RUNNING state during a deployment, as a percentage of the desiredCount" + }, + "hotswap-ecs-maximum-healthy-percent": { + "type": "string", + "desc": "Upper limit on the number of your service's tasks that are allowed in the RUNNING or PENDING state during a deployment, as a percentage of the desiredCount" + }, + "hotswap-ecs-stabilization-timeout-seconds": { + "type": "string", + "desc": "Number of seconds to wait for a single service to reach stable state, where the desiredCount is equal to the runningCount" + }, + "watch": { + "type": "boolean", + "desc": "Continuously observe the project files, and deploy the given stack(s) automatically when changes are detected. Implies --hotswap by default" + }, + "logs": { + "type": "boolean", + "default": true, + "desc": "Show CloudWatch log events from all resources in the selected Stacks in the terminal. 'true' by default, use --no-logs to turn off. Only in effect if specified alongside the '--watch' option" + }, + "concurrency": { + "type": "number", + "desc": "Maximum number of simultaneous deployments (dependency permitting) to execute.", + "default": 1, + "requiresArg": true + }, + "asset-parallelism": { + "type": "boolean", + "desc": "Whether to build/publish assets in parallel" + }, + "asset-prebuild": { + "type": "boolean", + "desc": "Whether to build all assets before deploying the first stack (useful for failing Docker builds)", + "default": true + }, + "ignore-no-stacks": { + "type": "boolean", + "desc": "Whether to deploy if the app contains no stacks", + "default": false + } + }, + "arg": { + "name": "STACKS", + "variadic": true + } + }, + "rollback": { + "description": "Rolls back the stack(s) named STACKS to their last stable state", + "arg": { + "name": "STACKS", + "variadic": true + }, + "options": { + "all": { + "type": "boolean", + "default": false, + "desc": "Roll back all available stacks" + }, + "toolkit-stack-name": { + "type": "string", + "desc": "The name of the CDK toolkit stack the environment is bootstrapped with", + "requiresArg": true + }, + "force": { + "alias": "f", + "type": "boolean", + "desc": "Orphan all resources for which the rollback operation fails." + }, + "validate-bootstrap-version": { + "type": "boolean", + "desc": "Whether to validate the bootstrap stack version. Defaults to 'true', disable with --no-validate-bootstrap-version." + }, + "orphan": { + "type": "array", + "desc": "Orphan the given resources, identified by their logical ID (can be specified multiple times)", + "default": [] + } + } + }, + "import": { + "description": "Import existing resource(s) into the given STACK", + "arg": { + "name": "STACK", + "variadic": false + }, + "options": { + "execute": { + "type": "boolean", + "desc": "Whether to execute ChangeSet (--no-execute will NOT execute the ChangeSet)", + "default": true + }, + "change-set-name": { + "type": "string", + "desc": "Name of the CloudFormation change set to create" + }, + "toolkit-stack-name": { + "type": "string", + "desc": "The name of the CDK toolkit stack to create", + "requiresArg": true + }, + "rollback": { + "type": "boolean", + "desc": "Rollback stack to stable state on failure. Defaults to 'true', iterate more rapidly with --no-rollback or -R. Note: do **not** disable this flag for deployments with resource replacements, as that will always fail" + }, + "force": { + "alias": "f", + "type": "boolean", + "desc": "Do not abort if the template diff includes updates or deletes. This is probably safe but we're not sure, let us know how it goes." + }, + "record-resource-mapping": { + "type": "string", + "alias": "r", + "requiresArg": true, + "desc": "If specified, CDK will generate a mapping of existing physical resources to CDK resources to be imported as. The mapping will be written in the given file path. No actual import operation will be performed" + }, + "resource-mapping": { + "type": "string", + "alias": "m", + "requiresArg": true, + "desc": "If specified, CDK will use the given file to map physical resources to CDK resources for import, instead of interactively asking the user. Can be run from scripts" + } + } + }, + "watch": { + "description": "Shortcut for 'deploy --watch'", + "arg": { + "name": "STACKS", + "variadic": true + }, + "options": { + "build-exclude": { + "type": "array", + "alias": "E", + "desc": "Do not rebuild asset with the given ID. Can be specified multiple times", + "default": [] + }, + "exclusively": { + "type": "boolean", + "alias": "e", + "desc": "Only deploy requested stacks, don't include dependencies" + }, + "change-set-name": { + "type": "string", + "desc": "Name of the CloudFormation change set to create" + }, + "force": { + "alias": "f", + "type": "boolean", + "desc": "Always deploy stack even if templates are identical", + "default": false + }, + "toolkit-stack-name": { + "type": "string", + "desc": "The name of the existing CDK toolkit stack (only used for app using legacy synthesis)", + "requiresArg": true + }, + "progress": { + "type": "string", + "choices": [ + "bar", + "events" + ], + "desc": "Display mode for stack activity events" + }, + "rollback": { + "type": "boolean", + "desc": "Rollback stack to stable state on failure. Defaults to 'true', iterate more rapidly with --no-rollback or -R. Note: do **not** disable this flag for deployments with resource replacements, as that will always fail", + "negativeAlias": "R" + }, + "hotswap": { + "type": "boolean", + "desc": "Attempts to perform a 'hotswap' deployment, but does not fall back to a full deployment if that is not possible. Instead, changes to any non-hotswappable properties are ignored.'true' by default, use --no-hotswap to turn off" + }, + "hotswap-fallback": { + "type": "boolean", + "desc": "Attempts to perform a 'hotswap' deployment, which skips CloudFormation and updates the resources directly, and falls back to a full deployment if that is not possible." + }, + "hotswap-ecs-minimum-healthy-percent": { + "type": "string", + "desc": "Lower limit on the number of your service's tasks that must remain in the RUNNING state during a deployment, as a percentage of the desiredCount" + }, + "hotswap-ecs-maximum-healthy-percent": { + "type": "string", + "desc": "Upper limit on the number of your service's tasks that are allowed in the RUNNING or PENDING state during a deployment, as a percentage of the desiredCount" + }, + "hotswap-ecs-stabilization-timeout-seconds": { + "type": "string", + "desc": "Number of seconds to wait for a single service to reach stable state, where the desiredCount is equal to the runningCount" + }, + "logs": { + "type": "boolean", + "default": true, + "desc": "Show CloudWatch log events from all resources in the selected Stacks in the terminal. 'true' by default, use --no-logs to turn off" + }, + "concurrency": { + "type": "number", + "desc": "Maximum number of simultaneous deployments (dependency permitting) to execute.", + "default": 1, + "requiresArg": true + } + } + }, + "destroy": { + "description": "Destroy the stack(s) named STACKS", + "arg": { + "name": "STACKS", + "variadic": true + }, + "options": { + "all": { + "type": "boolean", + "default": false, + "desc": "Destroy all available stacks" + }, + "exclusively": { + "type": "boolean", + "alias": "e", + "desc": "Only destroy requested stacks, don't include dependees" + }, + "force": { + "type": "boolean", + "alias": "f", + "desc": "Do not ask for confirmation before destroying the stacks" + } + } + }, + "diff": { + "description": "Compares the specified stack with the deployed stack or a local template file, and returns with status 1 if any difference is found", + "arg": { + "name": "STACKS", + "variadic": true + }, + "options": { + "exclusively": { + "type": "boolean", + "alias": "e", + "desc": "Only diff requested stacks, don't include dependencies" + }, + "context-lines": { + "type": "number", + "desc": "Number of context lines to include in arbitrary JSON diff rendering", + "default": 3, + "requiresArg": true + }, + "template": { + "type": "string", + "desc": "The path to the CloudFormation template to compare with", + "requiresArg": true + }, + "strict": { + "type": "boolean", + "desc": "Do not filter out AWS::CDK::Metadata resources, mangled non-ASCII characters, or the CheckBootstrapVersionRule", + "default": false + }, + "security-only": { + "type": "boolean", + "desc": "Only diff for broadened security changes", + "default": false + }, + "fail": { + "type": "boolean", + "desc": "Fail with exit code 1 in case of diff" + }, + "processed": { + "type": "boolean", + "desc": "Whether to compare against the template with Transforms already processed", + "default": false + }, + "quiet": { + "type": "boolean", + "alias": "q", + "desc": "Do not print stack name and default message when there is no diff to stdout", + "default": false + }, + "change-set": { + "type": "boolean", + "alias": "changeset", + "desc": "Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.", + "default": true + }, + "import-existing-resources": { + "type": "boolean", + "desc": "Whether or not the change set imports resources that already exist", + "default": false + } + } + }, + "drift": { + "description": "Detect drifts in the given CloudFormation stack(s)", + "arg": { + "name": "STACKS", + "variadic": true + }, + "options": { + "fail": { + "type": "boolean", + "desc": "Fail with exit code 1 if drift is detected" + } + } + }, + "metadata": { + "description": "Returns all metadata associated with this stack", + "arg": { + "name": "STACK", + "variadic": false + } + }, + "acknowledge": { + "aliases": [ + "ack" + ], + "description": "Acknowledge a notice so that it does not show up anymore", + "arg": { + "name": "ID", + "variadic": false + } + }, + "notices": { + "description": "Returns a list of relevant notices", + "options": { + "unacknowledged": { + "type": "boolean", + "alias": "u", + "default": false, + "desc": "Returns a list of unacknowledged notices" + } + } + }, + "init": { + "description": "Create a new, empty CDK project from a template.", + "arg": { + "name": "TEMPLATE", + "variadic": false + }, + "options": { + "language": { + "type": "string", + "alias": "l", + "desc": "The language to be used for the new project (default can be configured in ~/.cdk.json)", + "choices": [ + "csharp", + "fsharp", + "go", + "java", + "javascript", + "python", + "typescript" + ] + }, + "list": { + "type": "boolean", + "desc": "List the available templates" + }, + "generate-only": { + "type": "boolean", + "default": false, + "desc": "If true, only generates project files, without executing additional operations such as setting up a git repo, installing dependencies or compiling the project" + }, + "lib-version": { + "type": "string", + "alias": "V", + "desc": "The version of the CDK library (aws-cdk-lib) to initialize the project with. Defaults to the version that was current when this CLI was built." + } + } + }, + "migrate": { + "description": "Migrate existing AWS resources into a CDK app", + "options": { + "stack-name": { + "type": "string", + "alias": "n", + "desc": "The name assigned to the stack created in the new project. The name of the app will be based off this name as well.", + "requiresArg": true + }, + "language": { + "type": "string", + "default": "typescript", + "alias": "l", + "desc": "The language to be used for the new project", + "choices": [ + "typescript", + "go", + "java", + "python", + "csharp" + ] + }, + "account": { + "type": "string", + "desc": "The account to retrieve the CloudFormation stack template from" + }, + "region": { + "type": "string", + "desc": "The region to retrieve the CloudFormation stack template from" + }, + "from-path": { + "type": "string", + "desc": "The path to the CloudFormation template to migrate. Use this for locally stored templates" + }, + "from-stack": { + "type": "boolean", + "desc": "Use this flag to retrieve the template for an existing CloudFormation stack" + }, + "output-path": { + "type": "string", + "desc": "The output path for the migrated CDK app" + }, + "from-scan": { + "type": "string", + "desc": "Determines if a new scan should be created, or the last successful existing scan should be used \n options are \"new\" or \"most-recent\"" + }, + "filter": { + "type": "array", + "desc": "Filters the resource scan based on the provided criteria in the following format: \"key1=value1,key2=value2\"\n This field can be passed multiple times for OR style filtering: \n filtering options: \n resource-identifier: A key-value pair that identifies the target resource. i.e. {\"ClusterName\", \"myCluster\"}\n resource-type-prefix: A string that represents a type-name prefix. i.e. \"AWS::DynamoDB::\"\n tag-key: a string that matches resources with at least one tag with the provided key. i.e. \"myTagKey\"\n tag-value: a string that matches resources with at least one tag with the provided value. i.e. \"myTagValue\"" + }, + "compress": { + "type": "boolean", + "desc": "Use this flag to zip the generated CDK app" + } + } + }, + "context": { + "description": "Manage cached context values", + "options": { + "reset": { + "alias": "e", + "desc": "The context key (or its index) to reset", + "type": "string", + "requiresArg": true + }, + "force": { + "alias": "f", + "desc": "Ignore missing key error", + "type": "boolean", + "default": false + }, + "clear": { + "desc": "Clear all context", + "type": "boolean", + "default": false + } + } + }, + "docs": { + "aliases": [ + "doc" + ], + "description": "Opens the reference documentation in a browser", + "options": { + "browser": { + "alias": "b", + "desc": "the command to use to open the browser, using %u as a placeholder for the path of the file to open", + "type": "string" + } + } + }, + "doctor": { + "description": "Check your set-up for potential problems" + }, + "refactor": { + "description": "Moves resources between stacks or within the same stack", + "arg": { + "name": "STACKS", + "variadic": true + }, + "options": { + "dry-run": { + "type": "boolean", + "desc": "Do not perform any changes, just show what would be done", + "default": false + }, + "exclude-file": { + "type": "string", + "requiresArg": true, + "desc": "If specified, CDK will use the given file to exclude resources from the refactor" + }, + "mapping-file": { + "type": "string", + "requiresArg": true, + "desc": "A file that declares an explicit mapping to be applied. If provided, the command will use it instead of computing the mapping." + }, + "revert": { + "type": "boolean", + "default": false, + "desc": "If specified, the command will revert the refactor operation. This is only valid if a mapping file was provided." + } + } + }, + "cli-telemetry": { + "description": "Enable or disable anonymous telemetry", + "options": { + "enable": { + "type": "boolean", + "desc": "Enable anonymous telemetry", + "conflicts": "disable" + }, + "disable": { + "type": "boolean", + "desc": "Disable anonymous telemetry", + "conflicts": "enable" + } + } + } + } +} diff --git a/packages/aws-cdk/scripts/user-input-gen.ts b/packages/aws-cdk/scripts/user-input-gen.ts index a446b3eea..5d9cc9b4a 100644 --- a/packages/aws-cdk/scripts/user-input-gen.ts +++ b/packages/aws-cdk/scripts/user-input-gen.ts @@ -1,10 +1,11 @@ -import * as fs from 'fs'; +import * as fs from 'fs-extra'; // eslint-disable-next-line import/no-extraneous-dependencies import { renderYargs, renderUserInputType, renderUserInputFuncs } from '@aws-cdk/user-input-gen'; import { makeConfig, YARGS_HELPERS } from '../lib/cli/cli-config'; async function main() { const config = await makeConfig(); + fs.writeJSONSync('./lib/cli/cli-type-registry.json', config, { spaces: 2 }); fs.writeFileSync('./lib/cli/parse-command-line-arguments.ts', await renderYargs(config, YARGS_HELPERS)); fs.writeFileSync('./lib/cli/user-input.ts', await renderUserInputType(config)); fs.writeFileSync('./lib/cli/convert-to-user-input.ts', await renderUserInputFuncs(config)); From 7b10fee79d252a0d61fd8581bca1e38b58d1c598 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:53:17 +0100 Subject: [PATCH 5/6] chore: make the environment deterministic for test stack (#684) --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --- .../toolkit-lib/test/_fixtures/stack-with-bucket/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-bucket/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-bucket/index.ts index bae858d71..214c732dd 100644 --- a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-bucket/index.ts +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-bucket/index.ts @@ -3,7 +3,12 @@ import * as core from 'aws-cdk-lib/core'; export default async () => { const app = new core.App({ autoSynth: false }); - const stack = new core.Stack(app, 'Stack1'); + const stack = new core.Stack(app, 'Stack1', { + env: { + account: '123456789012', + region: 'us-east-1', + }, + }); new s3.Bucket(stack, 'MyBucket'); return app.synth(); }; From cf35f57908d3ccb71b27945daea2fea327de5143 Mon Sep 17 00:00:00 2001 From: Rico Hermans Date: Thu, 3 Jul 2025 20:26:37 +0200 Subject: [PATCH 6/6] fix(integ-runner): snapshots involving AZs are different if `enableLookups: false` (#685) In https://github.com/aws/aws-cdk-cli/pull/666 we used `synthFast()` to always generate a snapshot in the same was as was used for validating (which was also using `synthFast()`). The difference being, that the context we load depends on the `enableLookups: false|true` flag that's passed to `new IntegTest()` in the test case itself. So when writing the snapshot we have to take that same field into account. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --- packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts b/packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts index c7a267142..25a106d36 100644 --- a/packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts +++ b/packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts @@ -326,11 +326,13 @@ export abstract class IntegRunner { fs.removeSync(this.snapshotDir); } + const actualTestSuite = await this.actualTestSuite(); + // if lookups are enabled then we need to synth again // using dummy context and save that as the snapshot await this.cdk.synthFast({ execCmd: this.cdkApp.split(' '), - context: this.getContext(DEFAULT_SYNTH_OPTIONS.context), + context: this.getContext(actualTestSuite.enableLookups ? DEFAULT_SYNTH_OPTIONS.context : {}), env: DEFAULT_SYNTH_OPTIONS.env, output: path.relative(this.directory, this.snapshotDir), });