Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DestroyStackResult> {
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,
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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}`));
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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
Expand All @@ -570,7 +571,7 @@ export class Deployments {
);
}

public async destroyStack(options: DestroyStackOptions): Promise<void> {
public async destroyStack(options: DestroyStackOptions) {
const env = await this.envs.accessStackForMutableStackOperations(options.stack);
const executionRoleArn = await env.replacePlaceholders(options.roleArn ?? options.stack.cloudFormationExecutionRoleArn);

Expand Down
50 changes: 42 additions & 8 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -796,7 +796,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
*
* Rolls back the selected stacks.
*/
public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise<void> {
public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise<RollbackResult> {
const ioHelper = asIoHelper(this.ioHost, 'rollback');
const assembly = await assemblyFromSource(ioHelper, cx);
return this._rollback(assembly, 'rollback', options);
Expand All @@ -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<void> {
private async _rollback(assembly: StackAssembly, action: 'rollback' | 'deploy' | 'watch', options: RollbackOptions): Promise<RollbackResult> {
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;
Expand All @@ -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)');
Expand All @@ -847,14 +861,16 @@ export class Toolkit extends CloudAssemblySourceBuilder {
if (!anyRollbackable) {
throw new ToolkitError('No stacks were in a state that could be rolled back');
}

return ret;
}

/**
* Destroy Action
*
* Destroys the selected Stacks.
*/
public async destroy(cx: ICloudAssemblySource, options: DestroyOptions): Promise<void> {
public async destroy(cx: ICloudAssemblySource, options: DestroyOptions): Promise<DestroyResult> {
const ioHelper = asIoHelper(this.ioHost, 'destroy');
const assembly = await assemblyFromSource(ioHelper, cx);
return this._destroy(assembly, 'destroy', options);
Expand All @@ -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<void> {
private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise<DestroyResult> {
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({
Expand All @@ -890,18 +911,31 @@ 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) {
await ioHelper.notify(IO.CDK_TOOLKIT_E7900.msg(`\n ❌ ${chalk.blue(stack.displayName)}: ${action} failed ${e}`, { error: e }));
throw e;
}
}

return ret;
} finally {
await destroySpan.end();
}
Expand Down
125 changes: 119 additions & 6 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,35 @@ export interface DeployResult {
}

/**
* Information about a deployed stack
* Properties that describe a physically deployed stack
*/
export interface DeployedStack {
export interface PhysicalStack<Arn extends 'arnRequired' | 'arnOptional' = 'arnRequired'> {
/**
* 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
* been specialized.
*/
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
*
Expand All @@ -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
Expand All @@ -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';
Loading