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), }); 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/_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(); }; 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({ 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/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/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)); 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:'), + ); + }); +}); 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); });