Skip to content

Commit

Permalink
feat(core): stack termination protection (#7610)
Browse files Browse the repository at this point in the history
Add a `terminationProtection` prop to `StackProps` to enable stack termination
protection.

This does not require extra IAM permission for existing CDK stacks
(`cloudformation:UpdateTerminationProtection`).

The logic to evaluate if we can skip deploy is now moved to a separate
function.

Closes #1682
  • Loading branch information
jogold committed May 4, 2020
1 parent 0ef9883 commit 7ed60b8
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 20 deletions.
16 changes: 16 additions & 0 deletions packages/@aws-cdk/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,3 +650,19 @@ new CfnInclude(this, 'ID', {
},
});
```

### Termination Protection
You can prevent a stack from being accidentally deleted by enabling termination
protection on the stack. If a user attempts to delete a stack with termination
protection enabled, the deletion fails and the stack--including its status--remains
unchanged. Enabling or disabling termination protection on a stack sets it for any
nested stacks belonging to that stack as well. You can enable termination protection
on a stack by setting the `terminationProtection` prop to `true`.

```ts
const stack = new Stack(app, 'StackName', {
terminationProtection: true,
});
```

By default, termination protection is disabled.
14 changes: 14 additions & 0 deletions packages/@aws-cdk/core/lib/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export interface StackProps {
* @default {}
*/
readonly tags?: { [key: string]: string };

/**
* Whether to enable termination protection for this stack.
*
* @default false
*/
readonly terminationProtection?: boolean;
}

/**
Expand Down Expand Up @@ -181,6 +188,11 @@ export class Stack extends Construct implements ITaggable {
*/
public readonly environment: string;

/**
* Whether termination protection is enabled for this stack.
*/
public readonly terminationProtection?: boolean;

/**
* If this is a nested stack, this represents its `AWS::CloudFormation::Stack`
* resource. `undefined` for top-level (non-nested) stacks.
Expand Down Expand Up @@ -254,6 +266,7 @@ export class Stack extends Construct implements ITaggable {
this.account = account;
this.region = region;
this.environment = environment;
this.terminationProtection = props.terminationProtection;

if (props.description !== undefined) {
// Max length 1024 bytes
Expand Down Expand Up @@ -778,6 +791,7 @@ export class Stack extends Construct implements ITaggable {

const properties: cxapi.AwsCloudFormationStackProperties = {
templateFile: this.templateFile,
terminationProtection: this.terminationProtection,
...stackNameProperty,
};

Expand Down
7 changes: 7 additions & 0 deletions packages/@aws-cdk/cx-api/lib/cloud-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export interface AwsCloudFormationStackProperties {
* @default - name derived from artifact ID
*/
readonly stackName?: string;

/**
* Whether to enable termination protection for this stack.
*
* @default false
*/
readonly terminationProtection?: boolean;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export class CloudFormationStackArtifact extends CloudArtifact {
*/
public readonly environment: Environment;

/**
* Whether termination protection is enabled for this stack.
*/
public readonly terminationProtection?: boolean;

constructor(assembly: CloudAssembly, artifactId: string, artifact: cxschema.ArtifactManifest) {
super(assembly, artifactId, artifact);

Expand All @@ -67,6 +72,7 @@ export class CloudFormationStackArtifact extends CloudArtifact {
const properties = (this.manifest.properties || {}) as AwsCloudFormationStackProperties;
this.templateFile = properties.templateFile;
this.parameters = properties.parameters || { };
this.terminationProtection = properties.terminationProtection;

this.stackName = properties.stackName || artifactId;
this.template = JSON.parse(fs.readFileSync(path.join(this.assembly.directory, this.templateFile), 'utf-8'));
Expand Down
85 changes: 68 additions & 17 deletions packages/aws-cdk/lib/api/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,22 +149,16 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
const deployName = options.deployName || stackArtifact.stackName;
let cloudFormationStack = await CloudFormationStack.lookup(cfn, deployName);

if (!options.force && cloudFormationStack.exists) {
// bail out if the current template is exactly the same as the one we are about to deploy
// in cdk-land, this means nothing changed because assets (and therefore nested stacks) are immutable.
debug('checking if we can skip this stack based on the currently deployed template and tags (use --force to override)');
const tagsIdentical = compareTags(cloudFormationStack.tags, options.tags ?? []);
if (JSON.stringify(stackArtifact.template) === JSON.stringify(await cloudFormationStack.template()) && tagsIdentical) {
debug(`${deployName}: no change in template and tags, skipping (use --force to override)`);
return {
noOp: true,
outputs: cloudFormationStack.outputs,
stackArn: cloudFormationStack.stackId,
stackArtifact,
};
} else {
debug(`${deployName}: template changed, deploying...`);
}
if (await canSkipDeploy(options, cloudFormationStack)) {
debug(`${deployName}: skipping deployment (use --force to override)`);
return {
noOp: true,
outputs: cloudFormationStack.outputs,
stackArn: cloudFormationStack.stackId,
stackArtifact,
};
} else {
debug(`${deployName}: deploying...`);
}

// Detect "legacy" assets (which remain in the metadata) and publish them via
Expand Down Expand Up @@ -239,6 +233,18 @@ export async function deployStack(options: DeployStackOptions): Promise<DeploySt
} else {
print('Changeset %s created and waiting in review for manual execution (--no-execute)', changeSetName);
}

// Update termination protection only if it has changed.
const terminationProtection = stackArtifact.terminationProtection ?? false;
if (cloudFormationStack.terminationProtection !== terminationProtection) {
debug('Updating termination protection from %s to %s for stack %s', cloudFormationStack.terminationProtection, terminationProtection, deployName);
await cfn.updateTerminationProtection({
StackName: deployName,
EnableTerminationProtection: terminationProtection,
}).promise();
debug('Termination protection updated to %s for stack %s', terminationProtection, deployName);
}

return { noOp: false, outputs: cloudFormationStack.outputs, stackArn: changeSet.StackId!, stackArtifact };
}

Expand Down Expand Up @@ -326,6 +332,51 @@ export async function destroyStack(options: DestroyStackOptions) {
}
}

/**
* Checks whether we can skip deployment
*/
async function canSkipDeploy(deployStackOptions: DeployStackOptions, cloudFormationStack: CloudFormationStack): Promise<boolean> {
const deployName = deployStackOptions.deployName || deployStackOptions.stack.stackName;
debug(`${deployName}: checking if we can skip deploy`);

// Forced deploy
if (deployStackOptions.force) {
debug(`${deployName}: forced deployment`);
return false;
}

// No existing stack
if (!cloudFormationStack.exists) {
debug(`${deployName}: no existing stack`);
return false;
}

// Template has changed (assets taken into account here)
if (JSON.stringify(deployStackOptions.stack.template) !== JSON.stringify(await cloudFormationStack.template())) {
debug(`${deployName}: template has changed`);
return false;
}

// Tags have changed
if (!compareTags(cloudFormationStack.tags, deployStackOptions.tags ?? [])) {
debug(`${deployName}: tags have changed`);
return false;
}

// Termination protection has been updated
const terminationProtection = deployStackOptions.stack.terminationProtection ?? false; // cast to boolean for comparison
if (terminationProtection !== cloudFormationStack.terminationProtection) {
debug(`${deployName}: termination protection has been updated`);
return false;
}

// We can skip deploy
return true;
}

/**
* Compares two list of tags, returns true if identical.
*/
function compareTags(a: Tag[], b: Tag[]): boolean {
if (a.length !== b.length) {
return false;
Expand All @@ -340,4 +391,4 @@ function compareTags(a: Tag[], b: Tag[]): boolean {
}

return true;
}
}
9 changes: 8 additions & 1 deletion packages/aws-cdk/lib/api/util/cloudformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ export class CloudFormationStack {
return this.exists ? (this.stack!.Parameters || []).map(p => p.ParameterKey!) : [];
}

/**
* Return the termination protection of the stack
*/
public get terminationProtection(): boolean | undefined {
return this.stack?.EnableTerminationProtection;
}

private assertExists() {
if (!this.exists) {
throw new Error(`No stack named '${this.stackName}'`);
Expand Down Expand Up @@ -293,4 +300,4 @@ export class TemplateParameters {
return ret;

}
}
}
1 change: 1 addition & 0 deletions packages/aws-cdk/test/api/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ beforeEach(() => {
{
StackStatus: 'CREATE_COMPLETE',
StackStatusReason: 'It is magic',
EnableTerminationProtection: false,
},
] })),
createChangeSet: jest.fn((info: CreateChangeSetInput) => {
Expand Down
59 changes: 58 additions & 1 deletion packages/aws-cdk/test/api/deploy-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ const FAKE_STACK = testStack({
template: FAKE_TEMPLATE,
});

const FAKE_STACK_TERMINATION_PROTECTION = testStack({
stackName: 'termination-protection',
template: FAKE_TEMPLATE,
terminationProtection: true,
});

let sdk: MockSdk;
let sdkProvider: MockSdkProvider;
let cfnMocks: MockedObject<SyncHandlerSubsetOf<AWS.CloudFormation>>;
Expand All @@ -25,6 +31,7 @@ beforeEach(() => {
{
StackStatus: 'CREATE_COMPLETE',
StackStatusReason: 'It is magic',
EnableTerminationProtection: false,
},
] })),
createChangeSet: jest.fn((_o) => ({})),
Expand All @@ -34,6 +41,7 @@ beforeEach(() => {
})),
executeChangeSet: jest.fn((_o) => ({})),
getTemplate: jest.fn((_o) => ({ TemplateBody: JSON.stringify(FAKE_TEMPLATE) })),
updateTerminationProtection: jest.fn((_o) => ({ StackId: 'stack-id' })),
};
sdk.stubCloudFormation(cfnMocks as any);
});
Expand Down Expand Up @@ -286,6 +294,54 @@ test('changeset is updated when stack exists in CREATE_COMPLETE status', async (
expect(cfnMocks.executeChangeSet).not.toHaveBeenCalled();
});

test('deploy with termination protection enabled', async () => {
// WHEN
await deployStack({
stack: FAKE_STACK_TERMINATION_PROTECTION,
sdk,
sdkProvider,
resolvedEnvironment: mockResolvedEnvironment(),
});

// THEN
expect(cfnMocks.updateTerminationProtection).toHaveBeenCalledWith(expect.objectContaining({
EnableTerminationProtection: true,
}));
});

test('updateTerminationProtection not called when termination protection is undefined', async () => {
// WHEN
await deployStack({
stack: FAKE_STACK,
sdk,
sdkProvider,
resolvedEnvironment: mockResolvedEnvironment(),
});

// THEN
expect(cfnMocks.updateTerminationProtection).not.toHaveBeenCalled();
});

test('updateTerminationProtection called when termination protection is undefined and stack has termination protection', async () => {
// GIVEN
givenStackExists({
EnableTerminationProtection: true,
});

// WHEN
await deployStack({
stack: FAKE_STACK,
sdk,
sdkProvider,
resolvedEnvironment: mockResolvedEnvironment(),
});

// THEN
expect(cfnMocks.updateTerminationProtection).toHaveBeenCalledWith(expect.objectContaining({
EnableTerminationProtection: false,
}));
});

/**
* Set up the mocks so that it looks like the stack exists to start with
*/
Expand All @@ -298,8 +354,9 @@ function givenStackExists(overrides: Partial<AWS.CloudFormation.Stack> = {}) {
StackId: 'mock-stack-id',
CreationTime: new Date(),
StackStatus: 'CREATE_COMPLETE',
EnableTerminationProtection: false,
...overrides,
},
],
}));
}
}
4 changes: 4 additions & 0 deletions packages/aws-cdk/test/integ/cli/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,8 @@ new ConditionalResourceStack(app, `${stackPrefix}-conditional-resource`)
new StackWithNestedStack(app, `${stackPrefix}-with-nested-stack`);
new StackWithNestedStackUsingParameters(app, `${stackPrefix}-with-nested-stack-using-parameters`);

new YourStack(app, `${stackPrefix}-termination-protection`, {
terminationProtection: true,
});

app.synth();
1 change: 1 addition & 0 deletions packages/aws-cdk/test/integ/cli/common.bash
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function cleanup() {
cleanup_stack ${STACK_NAME_PREFIX}-with-nested-stack
cleanup_stack ${STACK_NAME_PREFIX}-outputs-test-1
cleanup_stack ${STACK_NAME_PREFIX}-outputs-test-2
cleanup_stack ${STACK_NAME_PREFIX}-termination-protection
}

function setup() {
Expand Down
26 changes: 26 additions & 0 deletions packages/aws-cdk/test/integ/cli/test-cdk-termination-protection.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
scriptdir=$(cd $(dirname $0) && pwd)
source ${scriptdir}/common.bash
# ----------------------------------------------------------

setup

stack="${STACK_NAME_PREFIX}-termination-protection"

stack_arn=$(cdk deploy -v ${stack} --require-approval=never)
echo "Stack deployed successfully"

# try to destroy
destroyed=1
cdk destroy -f ${stack} 2>&1 || destroyed=0

if [ $destroyed -eq 1 ]; then
fail 'cdk destroy succeeded on a stack with termination protection enabled'
fi

# disable termination protection and destroy stack
aws cloudformation update-termination-protection --no-enable-termination-protection --stack-name ${stack}
cdk destroy -f ${stack}

echo "✅ success"
4 changes: 3 additions & 1 deletion packages/aws-cdk/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface TestStackArtifact {
depends?: string[];
metadata?: cxapi.StackMetadata;
assets?: cxschema.AssetMetadataEntry[];
terminationProtection?: boolean;
}

export interface TestAssembly {
Expand Down Expand Up @@ -71,6 +72,7 @@ export function testAssembly(assembly: TestAssembly): cxapi.CloudAssembly {
metadata,
properties: {
templateFile,
terminationProtection: stack.terminationProtection,
},
});
}
Expand Down Expand Up @@ -125,4 +127,4 @@ export function classMockOf<A>(ctr: new (...args: any[]) => A): jest.Mocked<A> {
ret[methodName] = jest.fn();
}
return ret;
}
}

0 comments on commit 7ed60b8

Please sign in to comment.