diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/stack-helpers.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/stack-helpers.ts index 3d9f9139b..80c58f308 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/stack-helpers.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloudformation/stack-helpers.ts @@ -94,7 +94,7 @@ export class CloudFormationStack { } /** - * The stack's ID + * The stack's ID (which is the same as its ARN) * * Throws if the stack doesn't exist. */ diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deploy-stack.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deploy-stack.ts index df09c26ed..4876d3d54 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deploy-stack.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deploy-stack.ts @@ -663,13 +663,23 @@ export interface DestroyStackOptions { deployName?: string; } -export async function destroyStack(options: DestroyStackOptions, ioHelper: IoHelper) { +export interface DestroyStackResult { + /** + * The ARN of the stack that was destroyed, if any. + * + * If the stack didn't exist to begin with, the operation will succeed + * but this value will be undefined. + */ + readonly stackArn?: string; +} + +export async function destroyStack(options: DestroyStackOptions, ioHelper: IoHelper): Promise { const deployName = options.deployName || options.stack.stackName; const cfn = options.sdk.cloudFormation(); const currentStack = await CloudFormationStack.lookup(cfn, deployName); if (!currentStack.exists) { - return; + return {}; } const monitor = new StackActivityMonitor({ cfn, @@ -685,6 +695,8 @@ export async function destroyStack(options: DestroyStackOptions, ioHelper: IoHel if (destroyedStack && destroyedStack.stackStatus.name !== 'DELETE_COMPLETE') { throw new ToolkitError(`Failed to destroy ${deployName}: ${destroyedStack.stackStatus}`); } + + return { stackArn: currentStack.stackId }; } catch (e: any) { throw new ToolkitError(suffixWithErrors(formatErrorMessage(e), monitor.errors)); } finally { diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deployments.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deployments.ts index 612b09321..5acc631dd 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deployments.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deployments.ts @@ -229,10 +229,10 @@ export interface RollbackStackOptions { readonly validateBootstrapStackVersion?: boolean; } -export interface RollbackStackResult { - readonly notInRollbackableState?: boolean; - readonly success?: boolean; -} +export type RollbackStackResult = { readonly stackArn: string } & ( + | { readonly notInRollbackableState: true } + | { readonly success: true; notInRollbackableState?: undefined } +); interface AssetOptions { /** @@ -467,14 +467,15 @@ export class Deployments { // We loop in case of `--force` and the stack ends up in `CONTINUE_UPDATE_ROLLBACK`. let maxLoops = 10; while (maxLoops--) { - let cloudFormationStack = await CloudFormationStack.lookup(cfn, deployName); + const cloudFormationStack = await CloudFormationStack.lookup(cfn, deployName); + const stackArn = cloudFormationStack.stackId; const executionRoleArn = await env.replacePlaceholders(options.roleArn ?? options.stack.cloudFormationExecutionRoleArn); switch (cloudFormationStack.stackStatus.rollbackChoice) { case RollbackChoice.NONE: await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(`Stack ${deployName} does not need a rollback: ${cloudFormationStack.stackStatus}`)); - return { notInRollbackableState: true }; + return { stackArn: cloudFormationStack.stackId, notInRollbackableState: true }; case RollbackChoice.START_ROLLBACK: await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Initiating rollback of stack ${deployName}`)); @@ -516,7 +517,7 @@ export class Deployments { await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg( `Stack ${deployName} failed creation and rollback. This state cannot be rolled back. You can recreate this stack by running 'cdk deploy'.`, )); - return { notInRollbackableState: true }; + return { stackArn, notInRollbackableState: true }; default: throw new ToolkitError(`Unexpected rollback choice: ${cloudFormationStack.stackStatus.rollbackChoice}`); @@ -552,7 +553,7 @@ export class Deployments { } if (finalStackState.stackStatus.isRollbackSuccess || !stackErrorMessage) { - return { success: true }; + return { stackArn, success: true }; } // Either we need to ignore some resources to continue the rollback, or something went wrong @@ -570,7 +571,7 @@ export class Deployments { ); } - public async destroyStack(options: DestroyStackOptions): Promise { + public async destroyStack(options: DestroyStackOptions) { const env = await this.envs.accessStackForMutableStackOperations(options.stack); const executionRoleArn = await env.replacePlaceholders(options.roleArn ?? options.stack.cloudFormationExecutionRoleArn); diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 4180ec5bc..846d3266f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -6,7 +6,7 @@ import * as fs from 'fs-extra'; import { NonInteractiveIoHost } from './non-interactive-io-host'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; -import type { DeployResult } from './types'; +import type { DeployResult, DestroyResult, RollbackResult } from './types'; import type { BootstrapEnvironments, BootstrapOptions, BootstrapResult, EnvironmentBootstrapResult } from '../actions/bootstrap'; import { BootstrapSource } from '../actions/bootstrap'; import { AssetBuildTime, type DeployOptions } from '../actions/deploy'; @@ -796,7 +796,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { * * Rolls back the selected stacks. */ - public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise { + public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise { const ioHelper = asIoHelper(this.ioHost, 'rollback'); const assembly = await assemblyFromSource(ioHelper, cx); return this._rollback(assembly, 'rollback', options); @@ -805,16 +805,20 @@ export class Toolkit extends CloudAssemblySourceBuilder { /** * Helper to allow rollback being called as part of the deploy or watch action. */ - private async _rollback(assembly: StackAssembly, action: 'rollback' | 'deploy' | 'watch', options: RollbackOptions): Promise { + private async _rollback(assembly: StackAssembly, action: 'rollback' | 'deploy' | 'watch', options: RollbackOptions): Promise { const ioHelper = asIoHelper(this.ioHost, action); const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: options.stacks }); const stacks = await assembly.selectStacksV2(options.stacks); await this.validateStacksMetadata(stacks, ioHelper); await synthSpan.end(); + const ret: RollbackResult = { + stacks: [], + }; + if (stacks.stackCount === 0) { await ioHelper.notify(IO.CDK_TOOLKIT_E6001.msg('No stacks selected')); - return; + return ret; } let anyRollbackable = false; @@ -839,6 +843,16 @@ export class Toolkit extends CloudAssemblySourceBuilder { anyRollbackable = true; } await rollbackSpan.end(); + + ret.stacks.push({ + environment: { + account: stack.environment.account, + region: stack.environment.region, + }, + stackName: stack.stackName, + stackArn: stackResult.stackArn, + result: stackResult.notInRollbackableState ? 'already-stable' : 'rolled-back', + }); } catch (e: any) { await ioHelper.notify(IO.CDK_TOOLKIT_E6900.msg(`\n ❌ ${chalk.bold(stack.displayName)} failed: ${formatErrorMessage(e)}`, { error: e })); throw new ToolkitError('Rollback failed (use --force to orphan failing resources)'); @@ -847,6 +861,8 @@ export class Toolkit extends CloudAssemblySourceBuilder { if (!anyRollbackable) { throw new ToolkitError('No stacks were in a state that could be rolled back'); } + + return ret; } /** @@ -854,7 +870,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { * * Destroys the selected Stacks. */ - public async destroy(cx: ICloudAssemblySource, options: DestroyOptions): Promise { + public async destroy(cx: ICloudAssemblySource, options: DestroyOptions): Promise { const ioHelper = asIoHelper(this.ioHost, 'destroy'); const assembly = await assemblyFromSource(ioHelper, cx); return this._destroy(assembly, 'destroy', options); @@ -863,18 +879,23 @@ export class Toolkit extends CloudAssemblySourceBuilder { /** * Helper to allow destroy being called as part of the deploy action. */ - private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise { + private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise { const ioHelper = asIoHelper(this.ioHost, action); const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: options.stacks }); // The stacks will have been ordered for deployment, so reverse them for deletion. const stacks = (await assembly.selectStacksV2(options.stacks)).reversed(); await synthSpan.end(); + const ret: DestroyResult = { + stacks: [], + }; + const motivation = 'Destroying stacks is an irreversible action'; const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); if (!confirmed) { - return ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); + await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); + return ret; } const destroySpan = await ioHelper.span(SPAN.DESTROY_ACTION).begin({ @@ -890,11 +911,22 @@ export class Toolkit extends CloudAssemblySourceBuilder { stack, }); const deployments = await this.deploymentsForAction(action); - await deployments.destroyStack({ + const result = await deployments.destroyStack({ stack, deployName: stack.stackName, roleArn: options.roleArn, }); + + ret.stacks.push({ + environment: { + account: stack.environment.account, + region: stack.environment.region, + }, + stackName: stack.stackName, + stackArn: result.stackArn, + stackExisted: result.stackArn !== undefined, + }); + await ioHelper.notify(IO.CDK_TOOLKIT_I7900.msg(chalk.green(`\n ✅ ${chalk.blue(stack.displayName)}: ${action}ed`), stack)); await singleDestroySpan.end(); } catch (e: any) { @@ -902,6 +934,8 @@ export class Toolkit extends CloudAssemblySourceBuilder { throw e; } } + + return ret; } finally { await destroySpan.end(); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts index 7fa2e4f1f..48e9cc7fe 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts @@ -9,18 +9,18 @@ export interface DeployResult { } /** - * Information about a deployed stack + * Properties that describe a physically deployed stack */ -export interface DeployedStack { +export interface PhysicalStack { /** - * The name of the deployed stack + * The name of the stack * * A stack name is unique inside its environment, but not unique globally. */ readonly stackName: string; /** - * The environment where the stack was deployed + * The environment of the stack * * This environment is always concrete, because even though the CDK app's * stack may be region-agnostic, in order to be deployed it will have to have @@ -28,6 +28,16 @@ export interface DeployedStack { */ readonly environment: Environment; + /** + * The ARN of the stack + */ + readonly stackArn: Arn extends 'arnOptional' ? string | undefined : string; +} + +/** + * Information about a deployed stack + */ +export interface DeployedStack extends PhysicalStack { /** * Hierarchical identifier * @@ -39,9 +49,49 @@ export interface DeployedStack { readonly hierarchicalId: string; /** - * The ARN of the deployed stack + * The outputs of the deployed CloudFormation stack + */ + readonly outputs: { [key: string]: string }; +} + +/** + * An environment, which is an (account, region) pair + */ +export interface Environment { + /** + * The account number + */ + readonly account: string; + + /** + * The region number + */ + readonly region: string; +} + +/** + * Result interface for toolkit.deploy operation + */ +export interface DeployResult { + /** + * List of stacks deployed by this operation + */ + readonly stacks: DeployedStack[]; +} + +/** + * Information about a deployed stack + */ +export interface DeployedStack extends PhysicalStack { + /** + * Hierarchical identifier + * + * This uniquely identifies the stack inside the CDK app. + * + * In practice this will be the stack's construct path, but unfortunately the + * Cloud Assembly contract doesn't require or guarantee that. */ - readonly stackArn: string; + readonly hierarchicalId: string; /** * The outputs of the deployed CloudFormation stack @@ -63,3 +113,66 @@ export interface Environment { */ readonly region: string; } + +/** + * Result interface for toolkit.destroy operation + */ +export interface DestroyResult { + /** + * List of stacks destroyed by this operation + */ + readonly stacks: DestroyedStack[]; +} + +/** + * A stack targeted by a destroy operation + */ +export interface DestroyedStack extends PhysicalStack<'arnOptional'> { + /** + * Whether the stack existed to begin with + * + * If `!stackExisted`, the stack didn't exist, wasn't deleted, and `stackArn` + * will be `undefined`. + */ + readonly stackExisted: boolean; +} + +/** + * Result interface for toolkit.rollback operation + */ +export interface RollbackResult { + /** + * List of stacks rolled back by this operation + */ + readonly stacks: RolledBackStack[]; +} + +/** + * A stack targeted by a rollback operation + */ +export interface RolledBackStack extends PhysicalStack { + /** + * What operation we did for this stack + * + * Either: we did roll it back, or we didn't need to roll it back because + * it was already stable. + */ + readonly result: StackRollbackResult; +} + +/** + * An environment, which is an (account, region) pair + */ +export interface Environment { + /** + * The account number + */ + readonly account: string; + + /** + * The region number + */ + readonly region: string; +} + +export type StackRollbackResult = 'rolled-back' | 'already-stable'; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/rollback.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/rollback.test.ts index ecb09a16a..8c439208f 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/rollback.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/rollback.test.ts @@ -22,6 +22,7 @@ beforeEach(() => { mockRollbackStack.mockResolvedValue({ notInRollbackableState: false, success: true, + stackArn: 'arn:stack', }); }); @@ -29,10 +30,33 @@ describe('rollback', () => { test('successful rollback', async () => { // WHEN const cx = await builderFixture(toolkit, 'two-empty-stacks'); - await toolkit.rollback(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + const result = await toolkit.rollback(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); // THEN successfulRollback(); + + expect(result).toEqual({ + stacks: [ + { + environment: { + account: 'unknown-account', + region: 'unknown-region', + }, + result: 'rolled-back', + stackArn: 'arn:stack', + stackName: 'Stack1', + }, + { + environment: { + account: 'unknown-account', + region: 'unknown-region', + }, + result: 'rolled-back', + stackArn: 'arn:stack', + stackName: 'Stack2', + }, + ], + }); }); test('rollback not in rollbackable state', async () => {