diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts index d8c132f65..9891f6dd2 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts @@ -1,3 +1,4 @@ +import * as util from 'node:util'; import chalk from 'chalk'; import type { IIoHost, IoMessage, IoMessageLevel, IoRequest } from '../api/io'; import { isMessageRelevantForLevel } from '../api/io/private'; @@ -120,12 +121,20 @@ export class NonInteractiveIoHost implements IIoHost { /** * Notifies the host of a message that requires a response. * - * If the host does not return a response the suggested - * default response from the input message will be used. + * The non-interactive IoHost cannot prompt the user, so it always returns + * the request's `defaultResponse`. To make this visible, the message is + * annotated to indicate the auto-response — same convention as the CLI's + * `--yes` mode. */ public async requestResponse(msg: IoRequest): Promise { - // in the non-interactive IoHost, no requests are promptable - await this.notify(msg); + const annotation = typeof msg.defaultResponse === 'boolean' + ? (msg.defaultResponse ? '(auto-confirmed)' : '(auto-denied)') + : `(auto-responded with default: ${util.format(msg.defaultResponse)})`; + + await this.notify({ + ...msg, + message: `${msg.message} ${annotation}`, + }); return msg.defaultResponse; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index fc20df2ef..ff67e8b5b 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -926,14 +926,14 @@ export class Toolkit extends CloudAssemblySourceBuilder { const stackDiff = formatter.formatStackDiff(); // Send a request response with the diff as part of the message, - // and the template diff as data - // (IoHost decides whether to print depending on permissionChangeType) + // and the template diff as data. The IoHost decides how to handle the + // request — interactively prompt the user, auto-confirm, or suppress. const hasSecurityChanges = securityDiff.permissionChangeType !== PermissionChangeType.NONE; const deployMotivation = hasSecurityChanges - ? '"--require-approval" is enabled and stack includes security-sensitive updates.' - : '"--require-approval" is enabled and stack includes updates.'; + ? 'Stack includes security-sensitive updates' + : 'Stack includes updates'; const diffOutput = hasSecurityChanges ? securityDiff.formattedDiff : stackDiff.formattedDiff; - const deployQuestion = `${diffOutput}\n\n${deployMotivation}\nDo you wish to deploy these changes`; + const deployQuestion = `${diffOutput}\n\n${deployMotivation}. Do you wish to deploy these changes?`; const deployConfirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5060.req(deployQuestion, { motivation: deployMotivation, concurrency, diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts index 22565806a..2e462eb2f 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts @@ -83,9 +83,9 @@ IAM Statement Changes action: 'deploy', level: 'info', code: 'CDK_TOOLKIT_I5060', - message: expect.stringContaining('Do you wish to deploy these changes'), + message: expect.stringContaining('Do you wish to deploy these changes?'), data: expect.objectContaining({ - motivation: expect.stringContaining('stack includes security-sensitive updates.'), + motivation: 'Stack includes security-sensitive updates', permissionChangeType: 'broadening', templateDiffs: expect.objectContaining({ Stack1: expect.objectContaining({ @@ -115,12 +115,12 @@ IAM Statement Changes // Message includes formatted stack diff (not just security diff) expect(request).toContain('AWS::S3::Bucket'); - expect(request).toContain('Do you wish to deploy these changes'); + expect(request).toContain('Do you wish to deploy these changes?'); expect(ioHost.requestSpy).toHaveBeenCalledWith(expect.objectContaining({ code: 'CDK_TOOLKIT_I5060', data: expect.objectContaining({ - motivation: expect.stringContaining('stack includes updates.'), + motivation: 'Stack includes updates', permissionChangeType: 'none', }), })); diff --git a/packages/@aws-cdk/toolkit-lib/test/toolkit/non-interactive-io-host.test.ts b/packages/@aws-cdk/toolkit-lib/test/toolkit/non-interactive-io-host.test.ts new file mode 100644 index 000000000..cde134131 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/toolkit/non-interactive-io-host.test.ts @@ -0,0 +1,54 @@ +import type { IoRequest } from '../../lib/api/io'; +import { NonInteractiveIoHost } from '../../lib/toolkit/non-interactive-io-host'; + +// In non-CI mode the host writes non-error output to stderr. +let stderrMock: jest.SpyInstance; + +beforeEach(() => { + stderrMock = jest.spyOn(process.stderr, 'write').mockReturnValue(true); +}); + +afterEach(() => { + stderrMock.mockRestore(); +}); + +function request(defaultResponse: U): IoRequest { + return { + time: new Date(), + level: 'info', + action: 'deploy', + code: 'CDK_TOOLKIT_I5060', + message: 'Do you wish to deploy these changes?', + data: undefined, + defaultResponse, + }; +} + +describe('requestResponse auto-answers with the default and surfaces it', () => { + test('a true default is annotated as auto-confirmed', async () => { + const host = new NonInteractiveIoHost({ isCI: false }); + + const response = await host.requestResponse(request(true)); + + expect(response).toBe(true); + expect(stderrMock).toHaveBeenCalledWith(expect.stringContaining('Do you wish to deploy these changes? (auto-confirmed)')); + }); + + test('a false default is annotated as auto-denied', async () => { + const host = new NonInteractiveIoHost({ isCI: false }); + + const response = await host.requestResponse(request(false)); + + expect(response).toBe(false); + expect(stderrMock).toHaveBeenCalledWith(expect.stringContaining('Do you wish to deploy these changes? (auto-denied)')); + }); + + test('a non-boolean default reports the value it responded with', async () => { + const host = new NonInteractiveIoHost({ isCI: false }); + + const response = await host.requestResponse(request('the-default')); + + expect(response).toBe('the-default'); + expect(stderrMock).toHaveBeenCalledWith(expect.stringContaining('Do you wish to deploy these changes? (auto-responded with default: the-default)')); + }); +}); diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 68c415a9b..8e84a32dc 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -434,136 +434,150 @@ export class CdkToolkit { const requireApproval = options.requireApproval ?? RequireApproval.BROADENING; this.ioHost.requireDeployApproval = requireApproval; - // execute-change-set is a new flow that we can just delegate to toolkit-lib - if (options.deploymentMethod?.method === 'execute-change-set') { - await this.toolkit.deploy(this.props.cloudExecutable, { - deploymentMethod: options.deploymentMethod, - stacks: { - patterns: options.selector.patterns, - strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, - expand: ExpandStackSelection.NONE, - }, - roleArn: options.roleArn, - forceDeployment: options.force, - rollback: options.rollback, - reuseAssets: options.reuseAssets, - concurrency: options.concurrency, - traceLogs: options.traceLogs, - notificationArns: options.notificationArns, - tags: options.tags, - outputsFile: options.outputsFile, - assetParallelism: options.assetParallelism, - assetBuildConcurrency: options.assetBuildConcurrency, - assetBuildTime: options.assetBuildTime, - parameters: undefined, // parameters are only set during change set creation, so this is explicitly unset because change set already exists - }); - return; - } + // toolkit-lib emits the approval request (I5060) without mentioning + // `--require-approval`. The CLI owns that flag, so it adds the framing here. + // Both deploy paths resolve through this host, so one listener covers both. + this.ioHost.rewrite(IO.CDK_TOOLKIT_I5060, (msg) => { + const updateTypeText = msg.data.permissionChangeType !== PermissionChangeType.NONE + ? 'security-sensitive updates' + : 'updates'; + return `Stack includes ${updateTypeText} and "--require-approval" is set to '${requireApproval}'.\nDo you wish to deploy these changes?`; + }); - const startSynthTime = new Date().getTime(); - const stackCollection = await this.selectStacksForDeploy( - options.selector, - options.exclusively, - options.cacheCloudAssembly, - options.ignoreNoStacks, - ); - const elapsedSynthTime = new Date().getTime() - startSynthTime; - await this.ioHost.asIoHelper().defaults.info(`\n✨ Synthesis time: ${formatTime(elapsedSynthTime)}s\n`); + try { + // execute-change-set is a new flow that we can just delegate to toolkit-lib + if (options.deploymentMethod?.method === 'execute-change-set') { + await this.toolkit.deploy(this.props.cloudExecutable, { + deploymentMethod: options.deploymentMethod, + stacks: { + patterns: options.selector.patterns, + strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, + expand: ExpandStackSelection.NONE, + }, + roleArn: options.roleArn, + forceDeployment: options.force, + rollback: options.rollback, + reuseAssets: options.reuseAssets, + concurrency: options.concurrency, + traceLogs: options.traceLogs, + notificationArns: options.notificationArns, + tags: options.tags, + outputsFile: options.outputsFile, + assetParallelism: options.assetParallelism, + assetBuildConcurrency: options.assetBuildConcurrency, + assetBuildTime: options.assetBuildTime, + parameters: undefined, // parameters are only set during change set creation, so this is explicitly unset because change set already exists + }); + return; + } - if (stackCollection.stackCount === 0) { - await this.ioHost.asIoHelper().defaults.error('This app contains no stacks'); - return; - } + const startSynthTime = new Date().getTime(); + const stackCollection = await this.selectStacksForDeploy( + options.selector, + options.exclusively, + options.cacheCloudAssembly, + options.ignoreNoStacks, + ); + const elapsedSynthTime = new Date().getTime() - startSynthTime; + await this.ioHost.asIoHelper().defaults.info(`\n✨ Synthesis time: ${formatTime(elapsedSynthTime)}s\n`); - const migrator = new ResourceMigrator({ - deployments: this.props.deployments, - ioHelper: asIoHelper(this.ioHost, 'deploy'), - }); - await migrator.tryMigrateResources(stackCollection, { - toolkitStackName: this.toolkitStackName, - ...options, - }); + if (stackCollection.stackCount === 0) { + await this.ioHost.asIoHelper().defaults.error('This app contains no stacks'); + return; + } - if (options.deploymentMethod?.method === 'hotswap') { - await this.ioHost.asIoHelper().defaults.warn( - '⚠️ The --hotswap and --hotswap-fallback flags deliberately introduce CloudFormation drift to speed up deployments', - ); - await this.ioHost.asIoHelper().defaults.warn('⚠️ They should only be used for development - never use them for your production Stacks!\n'); - } + const migrator = new ResourceMigrator({ + deployments: this.props.deployments, + ioHelper: asIoHelper(this.ioHost, 'deploy'), + }); + await migrator.tryMigrateResources(stackCollection, { + toolkitStackName: this.toolkitStackName, + ...options, + }); + + if (options.deploymentMethod?.method === 'hotswap') { + await this.ioHost.asIoHelper().defaults.warn( + '⚠️ The --hotswap and --hotswap-fallback flags deliberately introduce CloudFormation drift to speed up deployments', + ); + await this.ioHost.asIoHelper().defaults.warn('⚠️ They should only be used for development - never use them for your production Stacks!\n'); + } - const stacks = stackCollection.stackArtifacts; + const stacks = stackCollection.stackArtifacts; - const assetBuildTime = options.assetBuildTime ?? AssetBuildTime.ALL_BEFORE_DEPLOY; - const prebuildAssets = assetBuildTime === AssetBuildTime.ALL_BEFORE_DEPLOY; - const concurrency = options.concurrency || 1; - if (concurrency > 1) { - // always force "events" progress output when we have concurrency - this.ioHost.stackProgress = StackActivityProgress.EVENTS; + const assetBuildTime = options.assetBuildTime ?? AssetBuildTime.ALL_BEFORE_DEPLOY; + const prebuildAssets = assetBuildTime === AssetBuildTime.ALL_BEFORE_DEPLOY; + const concurrency = options.concurrency || 1; + if (concurrency > 1) { + // always force "events" progress output when we have concurrency + this.ioHost.stackProgress = StackActivityProgress.EVENTS; - // ...but only warn if the user explicitly requested "bar" progress - if (options.progress && options.progress != StackActivityProgress.EVENTS) { - await this.ioHost.asIoHelper().defaults.warn('⚠️ The --concurrency flag only supports --progress "events". Switching to "events".'); + // ...but only warn if the user explicitly requested "bar" progress + if (options.progress && options.progress != StackActivityProgress.EVENTS) { + await this.ioHost.asIoHelper().defaults.warn('⚠️ The --concurrency flag only supports --progress "events". Switching to "events".'); + } } - } - const stacksAndTheirAssetManifests = stacks.flatMap((stack) => [ - stack, - ...stack.dependencies.filter(x => cxapi.AssetManifestArtifact.isAssetManifestArtifact(x)), - ]); - const workGraph = new WorkGraphBuilder( - asIoHelper(this.ioHost, 'deploy'), - prebuildAssets, - ).build(stacksAndTheirAssetManifests); - - // Unless we are running with '--force', skip already published assets - if (!options.force) { - await this.removePublishedAssets(workGraph, options); - } + const stacksAndTheirAssetManifests = stacks.flatMap((stack) => [ + stack, + ...stack.dependencies.filter(x => cxapi.AssetManifestArtifact.isAssetManifestArtifact(x)), + ]); + const workGraph = new WorkGraphBuilder( + asIoHelper(this.ioHost, 'deploy'), + prebuildAssets, + ).build(stacksAndTheirAssetManifests); + + // Unless we are running with '--force', skip already published assets + if (!options.force) { + await this.removePublishedAssets(workGraph, options); + } - const graphConcurrency: Concurrency = { - 'stack': concurrency, - 'asset-build': (options.assetParallelism ?? true) ? options.assetBuildConcurrency ?? 1 : 1, // This will be CPU-bound/memory bound, mostly matters for Docker builds - 'asset-publish': (options.assetParallelism ?? true) ? 8 : 1, // This will be I/O-bound, 8 in parallel seems reasonable - 'marker': 1, - }; + const graphConcurrency: Concurrency = { + 'stack': concurrency, + 'asset-build': (options.assetParallelism ?? true) ? options.assetBuildConcurrency ?? 1 : 1, // This will be CPU-bound/memory bound, mostly matters for Docker builds + 'asset-publish': (options.assetParallelism ?? true) ? 8 : 1, // This will be I/O-bound, 8 in parallel seems reasonable + 'marker': 1, + }; - const deploymentActions = new WorkGraphDeploymentActions(this.props.deployments, this.ioHost, this, { - roleArn: options.roleArn, - force: options.force, - stackCount: stackCollection.stackCount, - notificationArns: options.notificationArns, - deploymentMethod: options.deploymentMethod, - toolkitStackName: this.toolkitStackName, - reuseAssets: options.reuseAssets, - tags: options.tags, - parameters: options.parameters, - usePreviousParameters: options.usePreviousParameters, - rollback: options.rollback, - concurrency, - requireApproval, - assetParallelism: options.assetParallelism, - extraUserAgent: options.extraUserAgent, - cloudWatchLogMonitor: options.cloudWatchLogMonitor, - sdkProvider: this.props.sdkProvider, - express: options.express, - }); + const deploymentActions = new WorkGraphDeploymentActions(this.props.deployments, this.ioHost, this, { + roleArn: options.roleArn, + force: options.force, + stackCount: stackCollection.stackCount, + notificationArns: options.notificationArns, + deploymentMethod: options.deploymentMethod, + toolkitStackName: this.toolkitStackName, + reuseAssets: options.reuseAssets, + tags: options.tags, + parameters: options.parameters, + usePreviousParameters: options.usePreviousParameters, + rollback: options.rollback, + concurrency, + requireApproval, + assetParallelism: options.assetParallelism, + extraUserAgent: options.extraUserAgent, + cloudWatchLogMonitor: options.cloudWatchLogMonitor, + sdkProvider: this.props.sdkProvider, + express: options.express, + }); - const startDeployTime = Date.now(); + const startDeployTime = Date.now(); - await workGraph.doParallel(graphConcurrency, deploymentActions); + await workGraph.doParallel(graphConcurrency, deploymentActions); - if (options.outputsFile) { - // If an outputs file has been specified, create the file path and write stack outputs to it once. - // Outputs are written after all stacks have been deployed. If a stack deployment fails, - // all of the outputs from successfully deployed stacks before the failure will still be written. - await deploymentActions.writeOutputs(options.outputsFile); - } + if (options.outputsFile) { + // If an outputs file has been specified, create the file path and write stack outputs to it once. + // Outputs are written after all stacks have been deployed. If a stack deployment fails, + // all of the outputs from successfully deployed stacks before the failure will still be written. + await deploymentActions.writeOutputs(options.outputsFile); + } - // Add a timer on the COMMAND span for the full deployment wait time (not the same as the sum of all DEPLOY - // spans because of parallelism). - this.ioHost.telemetry?.commandSpan?.addTimer('totalDeployTime', Date.now() - startDeployTime); + // Add a timer on the COMMAND span for the full deployment wait time (not the same as the sum of all DEPLOY + // spans because of parallelism). + this.ioHost.telemetry?.commandSpan?.addTimer('totalDeployTime', Date.now() - startDeployTime); - await this.ioHost.asIoHelper().defaults.info(`\n✨ Total time: ${formatTime(Date.now() - startSynthTime)}s\n`); + await this.ioHost.asIoHelper().defaults.info(`\n✨ Total time: ${formatTime(Date.now() - startSynthTime)}s\n`); + } finally { + this.ioHost.removeAllListeners(); + } } /** @@ -2312,16 +2326,18 @@ class WorkGraphDeploymentActions implements WorkGraphActions { const securityDiff = formatter.formatSecurityDiff(); if (requiresApproval(this.options.requireApproval, securityDiff.permissionChangeType)) { const hasSecurityChanges = securityDiff.permissionChangeType !== PermissionChangeType.NONE; + // Bare-fact motivation. The I5060 listener registered in `deploy()` adds + // the `--require-approval` framing for terminal users. const motivation = hasSecurityChanges - ? '"--require-approval" is enabled and stack includes security-sensitive updates' - : `"--require-approval" is set to '${RequireApproval.ANYCHANGE}'`; + ? 'Stack includes security-sensitive updates' + : 'Stack includes updates'; const diffOutput = hasSecurityChanges ? securityDiff.formattedDiff : formatter.formatStackDiff().formattedDiff; await this.ioHost.asIoHelper().defaults.info(diffOutput); try { await askUserConfirmation( this.ioHost, - IO.CDK_TOOLKIT_I5060.req(`${motivation}: Do you wish to deploy these changes?`, { + IO.CDK_TOOLKIT_I5060.req(`${motivation}. Do you wish to deploy these changes?`, { motivation, concurrency: this.options.concurrency, permissionChangeType: securityDiff.permissionChangeType, diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index 3952fc2e6..b39402608 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -318,8 +318,9 @@ describe('deploy', () => { // The confirmation prompt should use the non-security motivation and report no permission changes expect(requestSpy).toHaveBeenCalledWith(expect.objectContaining({ code: 'CDK_TOOLKIT_I5060', - message: expect.stringContaining("'any-change'"), + message: expect.stringContaining('Stack includes updates'), data: expect.objectContaining({ + motivation: 'Stack includes updates', permissionChangeType: 'none', }), })); diff --git a/packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts b/packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts index 871fca8c2..5b98f4cca 100644 --- a/packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts +++ b/packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts @@ -1382,6 +1382,9 @@ describe('CliIoHost', () => { defaultResponse: true, }); + // The prompt appears (approval is required). Without the deploy listener + // the host prints the message as-is; the `--require-approval` framing is + // covered below. expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('test message') + ' (y/n) '); expect(response).toEqual(true); }); @@ -1403,6 +1406,96 @@ describe('CliIoHost', () => { expect(mockStdout).not.toHaveBeenCalledWith(chalk.cyan('test message') + ' (y/n) '); expect(response).toEqual(true); }); + + describe('CLI reframes the library motivation with --require-approval framing', () => { + // The deploy command registers a rewrite listener on I5060 that adds the + // CLI's `--require-approval` framing to the library's flag-free question. + // Register the same listener here so the reframed prompt can be asserted + // in isolation, keyed off the message payload and the configured flag. + beforeEach(() => { + ioHost.rewrite(IO.CDK_TOOLKIT_I5060, (msg) => { + const updateTypeText = msg.data.permissionChangeType !== 'none' + ? 'security-sensitive updates' + : 'updates'; + return `Stack includes ${updateTypeText} and "--require-approval" is set to '${ioHost.requireDeployApproval}'.\nDo you wish to deploy these changes?`; + }); + }); + + afterEach(() => { + ioHost.removeAllListeners(); + }); + + test('BROADENING + has-security adds the --require-approval suffix and breaks the question to a new line', async () => { + ioHost.requireDeployApproval = RequireApproval.BROADENING; + await requestResponse('y', { + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I5060', + message: 'test message', + data: { + permissionChangeType: 'broadening', + }, + defaultResponse: true, + }); + + expect(mockStdout).toHaveBeenCalledWith( + chalk.cyan('Stack includes security-sensitive updates and "--require-approval" is set to \'broadening\'.\nDo you wish to deploy these changes?') + ' (y/n) ', + ); + }); + + test('ANYCHANGE + has-security renders the any-change variant', async () => { + ioHost.requireDeployApproval = RequireApproval.ANYCHANGE; + await requestResponse('y', { + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I5060', + message: 'test message', + data: { + permissionChangeType: 'broadening', + }, + defaultResponse: true, + }); + + expect(mockStdout).toHaveBeenCalledWith( + chalk.cyan('Stack includes security-sensitive updates and "--require-approval" is set to \'any-change\'.\nDo you wish to deploy these changes?') + ' (y/n) ', + ); + }); + + test('ANYCHANGE + non-security renders the non-security any-change variant', async () => { + ioHost.requireDeployApproval = RequireApproval.ANYCHANGE; + await requestResponse('y', { + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I5060', + message: 'test message', + data: { + permissionChangeType: 'none', + }, + defaultResponse: true, + }); + + expect(mockStdout).toHaveBeenCalledWith( + chalk.cyan('Stack includes updates and "--require-approval" is set to \'any-change\'.\nDo you wish to deploy these changes?') + ' (y/n) ', + ); + }); + + test('non-I5060 messages pass through unchanged', async () => { + ioHost.requireDeployApproval = RequireApproval.ANYCHANGE; + await requestResponse('y', plainMessage({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I5050', + message: 'some other prompt', + defaultResponse: true, + })); + + expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('some other prompt') + ' (y/n) '); + }); + }); }); }); }); diff --git a/packages/aws-cdk/test/commands/__io_snapshots__/deploy/require-approval_the_approval_question_is_reframed_with_the_CLI_--require-approval_flag.ndjson b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/require-approval_the_approval_question_is_reframed_with_the_CLI_--require-approval_flag.ndjson new file mode 100644 index 000000000..32f54e9ff --- /dev/null +++ b/packages/aws-cdk/test/commands/__io_snapshots__/deploy/require-approval_the_approval_question_is_reframed_with_the_CLI_--require-approval_flag.ndjson @@ -0,0 +1,15 @@ +{"seq":0,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1000","message":"Starting Synthesis ..."} +{"seq":1,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I1001","message":"\n✨ Synthesis time: \n"} +{"seq":2,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✨ Synthesis time: \n"} +{"seq":3,"type":"notify","action":"deploy","level":"debug","code":null,"message":"Checking for previously published assets"} +{"seq":4,"type":"notify","action":"deploy","level":"debug","code":null,"message":"0 total assets, 0 still need to be published"} +{"seq":5,"type":"notify","action":"deploy","level":"info","code":null,"message":"Stack Test-Stack-A-Display-Name\nResources\n[+] AWS::CDK::Test TemplateName\n\n"} +{"seq":6,"type":"request","action":"deploy","level":"info","code":"CDK_TOOLKIT_I5060","message":"Stack includes updates and \"--require-approval\" is set to 'any-change'.\nDo you wish to deploy these changes?","response":true} +{"seq":7,"type":"notify","action":"deploy","level":"info","code":null,"message":"Test-Stack-A-Display-Name: deploying... [1/1]"} +{"seq":8,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I3000","message":"Starting Deploy ..."} +{"seq":9,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✅ Test-Stack-A-Display-Name"} +{"seq":10,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✨ Deployment time: \n"} +{"seq":11,"type":"notify","action":"deploy","level":"info","code":null,"message":"Stack ARN:"} +{"seq":12,"type":"notify","action":"deploy","level":"result","code":null,"message":"arn:aws:cloudformation:bermuda-triangle-1:123456789012:stack/Test-Stack-A/abcd"} +{"seq":13,"type":"notify","action":"deploy","level":"trace","code":"CDK_CLI_I3001","message":"\n✨ Deploy time: \n"} +{"seq":14,"type":"notify","action":"deploy","level":"info","code":null,"message":"\n✨ Total time: \n"} diff --git a/packages/aws-cdk/test/commands/deploy.test.ts b/packages/aws-cdk/test/commands/deploy.test.ts index 8b7a2d293..5a67d407a 100644 --- a/packages/aws-cdk/test/commands/deploy.test.ts +++ b/packages/aws-cdk/test/commands/deploy.test.ts @@ -148,6 +148,28 @@ describe('require-approval', () => { expect(cloudFormation.deployStack).not.toHaveBeenCalled(); expect(cloudFormation.cleanupChangeSet).toHaveBeenCalledWith(expect.anything(), 'cdk-deploy-change-set'); }); + + test('the approval question is reframed with the CLI `--require-approval` flag', async () => { + // Answer "yes" but keep the question visible (suppressQuestion=false) so the + // recorder captures the effective, listener-reframed prompt text. The default + // mock stack has no security-sensitive changes (permissionChangeType: none). + ioHost.respondOnce(IO.CDK_TOOLKIT_I5060, true, false); + + await toolkit.deploy({ + selector: { patterns: ['Test-Stack-A-Display-Name'] }, + exclusively: true, + deploymentMethod: { method: 'change-set' }, + requireApproval: RequireApproval.ANYCHANGE, + }); + + // toolkit-lib emits a flag-free motivation; the CLI's deploy listener adds + // the `--require-approval` framing before the question reaches the user. + const question = recorder.entries().find((e) => e.code === 'CDK_TOOLKIT_I5060'); + expect(question).toBeDefined(); + expect(stripAnsi(question!.message)).toBe( + 'Stack includes updates and "--require-approval" is set to \'any-change\'.\nDo you wish to deploy these changes?', + ); + }); }); describe('deployment method', () => {