Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): stack termination protection #7610

Merged
merged 10 commits into from
May 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shivlaks do you mind giving this a quick review?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on it

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;
}
}