Skip to content

Commit 34c547c

Browse files
authoredMar 7, 2025··
feat(core): RemovalPolicies.of(scope) (#32283)
## Issue # (if applicable) N/A - New feature proposal ## Reason for this change Currently, applying removal policies to multiple resources requires setting them individually or using Tags as a workaround. This change introduces a new RemovalPolicies module that provides a more intuitive and type-safe way to manage removal policies across multiple resources, similar to the existing Tags API. ## Description of changes Added a new RemovalPolicies module that provides: - A similar interface to Tags.of() for managing removal policies - Type-safe resource type specifications using CloudFormation resource type strings - Ability to include or exclude specific resource types - Convenient methods for common removal policies (destroy, retain, snapshot, retainOnUpdateOrDelete) Example usage: ```ts // Using CloudFormation resource type strings RemovalPolicies.of(scope).retain({ applyToResourceTypes: ['AWS::S3::Bucket', 'AWS::DynamoDB::Table'] }); const bucket = new s3.Bucket(scope, 'bucket') // Using CDK resource classes (type-safe) RemovalPolicies.of(scope).retain({ applyToResourceTypes: [ bucket.cfnResourceType, CfnTable.CFN_RESOURCE_TYPE_NAME, ] }); // Mixed usage RemovalPolicies.of(scope).retain({ applyToResourceTypes: [bucket.cfnResourceType, 'AWS::DynamoDB::Table'] }); ``` ## Description of how you validated changes TBD ## Checklist [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 3ed7c4d commit 34c547c

14 files changed

+1665
-1
lines changed
 

‎packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.assets.json

+19
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.template.json

+36
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/TestStack.assets.json

+19
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
{
2+
"Resources": {
3+
"TestBucket560B80BC": {
4+
"Type": "AWS::S3::Bucket",
5+
"UpdateReplacePolicy": "Retain",
6+
"DeletionPolicy": "Retain"
7+
},
8+
"TestTable5769773A": {
9+
"Type": "AWS::DynamoDB::Table",
10+
"Properties": {
11+
"AttributeDefinitions": [
12+
{
13+
"AttributeName": "id",
14+
"AttributeType": "S"
15+
}
16+
],
17+
"KeySchema": [
18+
{
19+
"AttributeName": "id",
20+
"KeyType": "HASH"
21+
}
22+
],
23+
"ProvisionedThroughput": {
24+
"ReadCapacityUnits": 5,
25+
"WriteCapacityUnits": 5
26+
}
27+
},
28+
"UpdateReplacePolicy": "Retain",
29+
"DeletionPolicy": "Retain"
30+
},
31+
"TestUser6A619381": {
32+
"Type": "AWS::IAM::User",
33+
"UpdateReplacePolicy": "Retain",
34+
"DeletionPolicy": "Retain"
35+
},
36+
"DestroyBucket924C7F03": {
37+
"Type": "AWS::S3::Bucket",
38+
"UpdateReplacePolicy": "Delete",
39+
"DeletionPolicy": "Delete"
40+
},
41+
"MissingPoliciesTestPreConfigured993B6B53": {
42+
"Type": "AWS::S3::Bucket",
43+
"UpdateReplacePolicy": "Retain",
44+
"DeletionPolicy": "Retain"
45+
},
46+
"MissingPoliciesTestNotConfiguredECEB0D31": {
47+
"Type": "AWS::S3::Bucket",
48+
"UpdateReplacePolicy": "Retain",
49+
"DeletionPolicy": "Retain"
50+
},
51+
"FilteredMissingPoliciesTestBucketToRetainB723E6AC": {
52+
"Type": "AWS::S3::Bucket",
53+
"UpdateReplacePolicy": "Retain",
54+
"DeletionPolicy": "Retain"
55+
},
56+
"FilteredMissingPoliciesTestTableToSkip835B5C39": {
57+
"Type": "AWS::DynamoDB::Table",
58+
"Properties": {
59+
"AttributeDefinitions": [
60+
{
61+
"AttributeName": "id",
62+
"AttributeType": "S"
63+
}
64+
],
65+
"KeySchema": [
66+
{
67+
"AttributeName": "id",
68+
"KeyType": "HASH"
69+
}
70+
],
71+
"ProvisionedThroughput": {
72+
"ReadCapacityUnits": 5,
73+
"WriteCapacityUnits": 5
74+
}
75+
},
76+
"UpdateReplacePolicy": "Retain",
77+
"DeletionPolicy": "Retain"
78+
}
79+
},
80+
"Parameters": {
81+
"BootstrapVersion": {
82+
"Type": "AWS::SSM::Parameter::Value<String>",
83+
"Default": "/cdk-bootstrap/hnb659fds/version",
84+
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
85+
}
86+
},
87+
"Rules": {
88+
"CheckBootstrapVersion": {
89+
"Assertions": [
90+
{
91+
"Assert": {
92+
"Fn::Not": [
93+
{
94+
"Fn::Contains": [
95+
[
96+
"1",
97+
"2",
98+
"3",
99+
"4",
100+
"5"
101+
],
102+
{
103+
"Ref": "BootstrapVersion"
104+
}
105+
]
106+
}
107+
]
108+
},
109+
"AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI."
110+
}
111+
]
112+
}
113+
}
114+
}

‎packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/cdk.out

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/integ.json

+12
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/tree.json

+399
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { App, MissingRemovalPolicies, RemovalPolicies, RemovalPolicy, Stack } from 'aws-cdk-lib';
2+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
3+
import * as iam from 'aws-cdk-lib/aws-iam';
4+
import * as s3 from 'aws-cdk-lib/aws-s3';
5+
import * as integ from '@aws-cdk/integ-tests-alpha';
6+
import { Construct } from 'constructs';
7+
8+
const app = new App();
9+
const stack = new Stack(app, 'TestStack');
10+
11+
new s3.Bucket(stack, 'TestBucket');
12+
13+
new dynamodb.Table(stack, 'TestTable', {
14+
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
15+
});
16+
17+
new iam.User(stack, 'TestUser');
18+
19+
const destroyBucket = new Construct(stack, 'DestroyBucket');
20+
new s3.Bucket(destroyBucket, 'Default');
21+
22+
RemovalPolicies.of(stack).retain({
23+
priority: 50,
24+
});
25+
RemovalPolicies.of(destroyBucket).destroy({
26+
priority: 100,
27+
});
28+
29+
// Missing Policies
30+
const missingPoliciesTest = new Construct(stack, 'MissingPoliciesTest');
31+
new s3.Bucket(missingPoliciesTest, 'PreConfigured', {
32+
removalPolicy: RemovalPolicy.DESTROY,
33+
});
34+
35+
new s3.Bucket(missingPoliciesTest, 'NotConfigured');
36+
37+
MissingRemovalPolicies.of(missingPoliciesTest).retain();
38+
39+
const filteredTest = new Construct(stack, 'FilteredMissingPoliciesTest');
40+
new s3.Bucket(filteredTest, 'BucketToRetain');
41+
new dynamodb.Table(filteredTest, 'TableToSkip', {
42+
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
43+
});
44+
45+
MissingRemovalPolicies.of(filteredTest).snapshot({
46+
applyToResourceTypes: [
47+
's3.CfnBucket',
48+
],
49+
});
50+
51+
new integ.IntegTest(app, 'RemovalPoliciesTest', {
52+
testCases: [stack],
53+
});

‎packages/aws-cdk-lib/core/README.md

+68
Original file line numberDiff line numberDiff line change
@@ -1800,4 +1800,72 @@ warning by the `id`.
18001800
Annotations.of(this).acknowledgeWarning('IAM:Group:MaxPoliciesExceeded', 'Account has quota increased to 20');
18011801
```
18021802

1803+
## RemovalPolicies
1804+
1805+
The `RemovalPolicies` class provides a convenient way to manage removal policies for AWS CDK resources within a construct scope. It allows you to apply removal policies to multiple resources at once, with options to include or exclude specific resource types.
1806+
1807+
### Usage
1808+
1809+
```typescript
1810+
import { RemovalPolicies, MissingRemovalPolicies } from 'aws-cdk-lib';
1811+
1812+
// Apply DESTROY policy to all resources in a scope
1813+
RemovalPolicies.of(scope).destroy();
1814+
1815+
// Apply RETAIN policy to all resources in a scope
1816+
RemovalPolicies.of(scope).retain();
1817+
1818+
// Apply SNAPSHOT policy to all resources in a scope
1819+
RemovalPolicies.of(scope).snapshot();
1820+
1821+
// Apply RETAIN_ON_UPDATE_OR_DELETE policy to all resources in a scope
1822+
RemovalPolicies.of(scope).retainOnUpdateOrDelete();
1823+
1824+
// Apply RETAIN policy only to specific resource types
1825+
RemovalPolicies.of(parent).retain({
1826+
applyToResourceTypes: [
1827+
'AWS::DynamoDB::Table',
1828+
bucket.cfnResourceType, // 'AWS::S3::Bucket'
1829+
CfnDBInstance.CFN_RESOURCE_TYPE_NAME, // 'AWS::RDS::DBInstance'
1830+
],
1831+
});
1832+
1833+
// Apply SNAPSHOT policy excluding specific resource types
1834+
RemovalPolicies.of(scope).snapshot({
1835+
excludeResourceTypes: ['AWS::Test::Resource'],
1836+
});
1837+
```
1838+
1839+
### RemovalPolicies vs MissingRemovalPolicies
1840+
1841+
CDK provides two different classes for managing removal policies:
1842+
1843+
- RemovalPolicies: Always applies the specified removal policy, overriding any existing policies.
1844+
- MissingRemovalPolicies: Applies the removal policy only to resources that don't already have a policy set.
1845+
1846+
```typescript
1847+
// Override any existing policies
1848+
RemovalPolicies.of(scope).retain();
1849+
1850+
// Only apply to resources without existing policies
1851+
MissingRemovalPolicies.of(scope).retain();
1852+
```
1853+
1854+
### Aspect Priority
1855+
1856+
Both RemovalPolicies and MissingRemovalPolicies are implemented as Aspects. You can control the order in which they're applied using the priority parameter:
1857+
1858+
```typescript
1859+
// Apply in a specific order based on priority
1860+
RemovalPolicies.of(stack).retain({ priority: 100 });
1861+
RemovalPolicies.of(stack).destroy({ priority: 200 }); // This will override the RETAIN policy
1862+
```
1863+
1864+
For RemovalPolicies, the policies are applied in order of aspect execution, with the last applied policy overriding previous ones. The priority only affects the order in which aspects are applied during synthesis.
1865+
1866+
#### Note
1867+
1868+
When using MissingRemovalPolicies with priority, a warning will be issued as this can lead to unexpected behavior. This is because MissingRemovalPolicies only applies to resources without existing policies, making priority less relevant.
1869+
1870+
18031871
<!--END CORE DOCUMENTATION-->

‎packages/aws-cdk-lib/core/lib/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export * from './cfn-dynamic-reference';
2929
export * from './cfn-tag';
3030
export * from './cfn-json';
3131
export * from './removal-policy';
32+
export * from './removal-policies';
3233
export * from './arn';
3334
export * from './duration';
3435
export * from './expiration';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { IConstruct } from 'constructs';
2+
import { Annotations } from './annotations';
3+
import { Aspects, IAspect, AspectPriority } from './aspect';
4+
import { CfnResource } from './cfn-resource';
5+
import { RemovalPolicy } from './removal-policy';
6+
7+
/**
8+
* Properties for applying a removal policy
9+
*/
10+
export interface RemovalPolicyProps {
11+
/**
12+
* Apply the removal policy only to specific resource types.
13+
* Can be a CloudFormation resource type string (e.g., 'AWS::S3::Bucket').
14+
* @default - apply to all resources
15+
*/
16+
readonly applyToResourceTypes?: string[];
17+
18+
/**
19+
* Exclude specific resource types from the removal policy.
20+
* Can be a CloudFormation resource type string (e.g., 'AWS::S3::Bucket').
21+
* @default - no exclusions
22+
*/
23+
readonly excludeResourceTypes?: string[];
24+
25+
/**
26+
* The priority to use when applying this policy.
27+
*
28+
* The priority affects only the order in which aspects are applied during synthesis.
29+
* For RemovalPolicies, the last applied policy will override previous ones.
30+
*
31+
* NOTE: Priority does NOT determine which policy "wins" when there are conflicts.
32+
* The order of application determines the final policy, with later policies
33+
* overriding earlier ones.
34+
*
35+
* @default - AspectPriority.MUTATING
36+
*/
37+
readonly priority?: number;
38+
}
39+
40+
/**
41+
* Base class for removal policy aspects
42+
*/
43+
abstract class BaseRemovalPolicyAspect implements IAspect {
44+
constructor(
45+
protected readonly policy: RemovalPolicy,
46+
protected readonly props: RemovalPolicyProps = {},
47+
) {}
48+
49+
/**
50+
* Checks if the given resource type matches any of the patterns
51+
*/
52+
protected resourceTypeMatchesPatterns(resourceType: string, patterns?: string[]): boolean {
53+
if (!patterns || patterns.length === 0) {
54+
return false;
55+
}
56+
return patterns.includes(resourceType);
57+
}
58+
59+
/**
60+
* Determines if the removal policy should be applied to the given resource
61+
*/
62+
protected abstract shouldApplyPolicy(cfnResource: CfnResource): boolean;
63+
64+
public visit(node: IConstruct): void {
65+
if (!CfnResource.isCfnResource(node)) {
66+
return;
67+
}
68+
69+
const cfnResource = node as CfnResource;
70+
const resourceType = cfnResource.cfnResourceType;
71+
72+
if (this.resourceTypeMatchesPatterns(resourceType, this.props.excludeResourceTypes)) {
73+
return;
74+
}
75+
76+
if (
77+
this.props.applyToResourceTypes?.length &&
78+
!this.resourceTypeMatchesPatterns(resourceType, this.props.applyToResourceTypes)
79+
) {
80+
return;
81+
}
82+
83+
if (this.shouldApplyPolicy(cfnResource)) {
84+
// Apply the removal policy
85+
cfnResource.applyRemovalPolicy(this.policy);
86+
}
87+
}
88+
}
89+
90+
/**
91+
* The RemovalPolicyAspect handles applying a removal policy to resources,
92+
* overriding any existing policies
93+
*/
94+
class RemovalPolicyAspect extends BaseRemovalPolicyAspect {
95+
protected shouldApplyPolicy(_cfnResource: CfnResource): boolean {
96+
// RemovalPolicyAspect always applies the policy, regardless of existing policies
97+
return true;
98+
}
99+
}
100+
101+
/**
102+
* The MissingRemovalPolicyAspect handles applying a removal policy only to resources
103+
* that don't already have a policy set
104+
*/
105+
class MissingRemovalPolicyAspect extends BaseRemovalPolicyAspect {
106+
protected shouldApplyPolicy(cfnResource: CfnResource): boolean {
107+
// For MissingRemovalPolicies, we only apply the policy if one doesn't already exist
108+
const userAlreadySetPolicy =
109+
cfnResource.cfnOptions.deletionPolicy !== undefined ||
110+
cfnResource.cfnOptions.updateReplacePolicy !== undefined;
111+
112+
return !userAlreadySetPolicy;
113+
}
114+
}
115+
116+
/**
117+
* Manages removal policies for all resources within a construct scope,
118+
* overriding any existing policies by default
119+
*/
120+
export class RemovalPolicies {
121+
/**
122+
* Returns the removal policies API for the given scope
123+
* @param scope The scope
124+
*/
125+
public static of(scope: IConstruct): RemovalPolicies {
126+
return new RemovalPolicies(scope);
127+
}
128+
129+
private constructor(private readonly scope: IConstruct) {}
130+
131+
/**
132+
* Apply a removal policy to all resources within this scope,
133+
* overriding any existing policies
134+
*
135+
* @param policy The removal policy to apply
136+
* @param props Configuration options
137+
*/
138+
public apply(policy: RemovalPolicy, props: RemovalPolicyProps = {}) {
139+
Aspects.of(this.scope).add(new RemovalPolicyAspect(policy, props), {
140+
priority: props.priority ?? AspectPriority.MUTATING,
141+
});
142+
}
143+
144+
/**
145+
* Apply DESTROY removal policy to all resources within this scope
146+
*
147+
* @param props Configuration options
148+
*/
149+
public destroy(props: RemovalPolicyProps = {}) {
150+
this.apply(RemovalPolicy.DESTROY, props);
151+
}
152+
153+
/**
154+
* Apply RETAIN removal policy to all resources within this scope
155+
*
156+
* @param props Configuration options
157+
*/
158+
public retain(props: RemovalPolicyProps = {}) {
159+
this.apply(RemovalPolicy.RETAIN, props);
160+
}
161+
162+
/**
163+
* Apply SNAPSHOT removal policy to all resources within this scope
164+
*
165+
* @param props Configuration options
166+
*/
167+
public snapshot(props: RemovalPolicyProps = {}) {
168+
this.apply(RemovalPolicy.SNAPSHOT, props);
169+
}
170+
171+
/**
172+
* Apply RETAIN_ON_UPDATE_OR_DELETE removal policy to all resources within this scope
173+
*
174+
* @param props Configuration options
175+
*/
176+
public retainOnUpdateOrDelete(props: RemovalPolicyProps = {}) {
177+
this.apply(RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE, props);
178+
}
179+
}
180+
181+
/**
182+
* Manages removal policies for resources without existing policies within a construct scope
183+
*/
184+
export class MissingRemovalPolicies {
185+
/**
186+
* Returns the missing removal policies API for the given scope
187+
* @param scope The scope
188+
*/
189+
public static of(scope: IConstruct): MissingRemovalPolicies {
190+
return new MissingRemovalPolicies(scope);
191+
}
192+
193+
private constructor(private readonly scope: IConstruct) {}
194+
195+
/**
196+
* Apply a removal policy only to resources without existing policies within this scope
197+
*
198+
* @param policy The removal policy to apply
199+
* @param props Configuration options
200+
*/
201+
public apply(policy: RemovalPolicy, props: RemovalPolicyProps = {}) {
202+
Aspects.of(this.scope).add(new MissingRemovalPolicyAspect(policy, props), {
203+
priority: props.priority ?? AspectPriority.MUTATING,
204+
});
205+
206+
if (props.priority !== undefined) {
207+
Annotations.of(this.scope).addWarningV2(
208+
`Warning MissingRemovalPolicies with priority in ${this.scope.node.path}`,
209+
'Applying a MissingRemovalPolicy with `priority` can lead to unexpected behavior since it only applies to resources without existing policies. Please refer to the documentation for more details.',
210+
);
211+
}
212+
}
213+
214+
/**
215+
* Apply DESTROY removal policy only to resources without existing policies within this scope
216+
*
217+
* @param props Configuration options
218+
*/
219+
public destroy(props: RemovalPolicyProps = {}) {
220+
this.apply(RemovalPolicy.DESTROY, props);
221+
}
222+
223+
/**
224+
* Apply RETAIN removal policy only to resources without existing policies within this scope
225+
*
226+
* @param props Configuration options
227+
*/
228+
public retain(props: RemovalPolicyProps = {}) {
229+
this.apply(RemovalPolicy.RETAIN, props);
230+
}
231+
232+
/**
233+
* Apply SNAPSHOT removal policy only to resources without existing policies within this scope
234+
*
235+
* @param props Configuration options
236+
*/
237+
public snapshot(props: RemovalPolicyProps = {}) {
238+
this.apply(RemovalPolicy.SNAPSHOT, props);
239+
}
240+
241+
/**
242+
* Apply RETAIN_ON_UPDATE_OR_DELETE removal policy only to resources without existing policies within this scope
243+
*
244+
* @param props Configuration options
245+
*/
246+
public retainOnUpdateOrDelete(props: RemovalPolicyProps = {}) {
247+
this.apply(RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE, props);
248+
}
249+
}

‎packages/aws-cdk-lib/core/test/aspect.test.ts

+114-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { Construct, IConstruct } from 'constructs';
2+
import { getWarnings } from './util';
23
import { Template } from '../../assertions';
34
import { Bucket, CfnBucket } from '../../aws-s3';
45
import * as cxschema from '../../cloud-assembly-schema';
56
import { App, CfnResource, Stack, Tag, Tags } from '../lib';
67
import { IAspect, Aspects, AspectPriority } from '../lib/aspect';
8+
import { MissingRemovalPolicies, RemovalPolicies } from '../lib/removal-policies';
9+
import { RemovalPolicy } from '../lib/removal-policy';
10+
711
class MyConstruct extends Construct {
812
public static IsMyConstruct(x: any): x is MyConstruct {
913
return x.visitCounter !== undefined;
@@ -300,7 +304,7 @@ describe('aspect', () => {
300304
test('Infinitely recursing Aspect is caught with error', () => {
301305
const app = new App({ context: { '@aws-cdk/core:aspectStabilization': true } });
302306
const root = new Stack(app, 'My-Stack');
303-
const child = new MyConstruct(root, 'MyConstruct');
307+
new MyConstruct(root, 'MyConstruct');
304308

305309
Aspects.of(root).add(new InfiniteAspect());
306310

@@ -343,4 +347,113 @@ describe('aspect', () => {
343347
app.synth();
344348
}).not.toThrow();
345349
});
350+
351+
test('RemovalPolicy: higher priority wins', () => {
352+
const app = new App();
353+
const stack = new Stack(app, 'My-Stack');
354+
new Bucket(stack, 'my-bucket', {
355+
removalPolicy: RemovalPolicy.RETAIN,
356+
});
357+
358+
RemovalPolicies.of(stack).apply(RemovalPolicy.DESTROY, {
359+
priority: 100,
360+
});
361+
362+
RemovalPolicies.of(stack).apply(RemovalPolicy.RETAIN, {
363+
priority: 200,
364+
});
365+
366+
Template.fromStack(stack).hasResource('AWS::S3::Bucket', {
367+
UpdateReplacePolicy: 'Retain',
368+
DeletionPolicy: 'Retain',
369+
});
370+
});
371+
372+
test('RemovalPolicy: last one wins when priorities are equal', () => {
373+
const app = new App();
374+
const stack = new Stack(app, 'My-Stack');
375+
new Bucket(stack, 'my-bucket', {
376+
removalPolicy: RemovalPolicy.RETAIN,
377+
});
378+
379+
RemovalPolicies.of(stack).apply(RemovalPolicy.DESTROY, {
380+
priority: 100,
381+
});
382+
383+
RemovalPolicies.of(stack).apply(RemovalPolicy.RETAIN, {
384+
priority: 100,
385+
});
386+
387+
Template.fromStack(stack).hasResource('AWS::S3::Bucket', {
388+
UpdateReplacePolicy: 'Retain',
389+
DeletionPolicy: 'Retain',
390+
});
391+
});
392+
393+
test('MissingRemovalPolicy: default removal policy is respected', () => {
394+
const app = new App();
395+
const stack = new Stack(app, 'My-Stack');
396+
new Bucket(stack, 'my-bucket', {
397+
removalPolicy: RemovalPolicy.RETAIN,
398+
});
399+
400+
MissingRemovalPolicies.of(stack).apply(RemovalPolicy.DESTROY, {
401+
priority: 100,
402+
});
403+
404+
Template.fromStack(stack).hasResource('AWS::S3::Bucket', {
405+
UpdateReplacePolicy: 'Retain',
406+
DeletionPolicy: 'Retain',
407+
});
408+
});
409+
410+
test('RemovalPolicy: multiple aspects in chain', () => {
411+
const app = new App();
412+
const stack = new Stack(app, 'My-Stack');
413+
new Bucket(stack, 'my-bucket', {
414+
removalPolicy: RemovalPolicy.RETAIN,
415+
});
416+
417+
RemovalPolicies.of(stack).apply(RemovalPolicy.DESTROY, {
418+
priority: 100,
419+
});
420+
421+
RemovalPolicies.of(stack).apply(RemovalPolicy.RETAIN, {
422+
priority: 200,
423+
});
424+
425+
RemovalPolicies.of(stack).apply(RemovalPolicy.SNAPSHOT, {
426+
priority: 300,
427+
});
428+
429+
Template.fromStack(stack).hasResource('AWS::S3::Bucket', {
430+
UpdateReplacePolicy: 'Snapshot',
431+
DeletionPolicy: 'Snapshot',
432+
});
433+
});
434+
435+
test('RemovalPolicy: different resource type', () => {
436+
const app = new App();
437+
const stack = new Stack(app, 'My-Stack');
438+
new CfnResource(stack, 'my-resource', {
439+
type: 'AWS::EC2::Instance',
440+
properties: {
441+
ImageId: 'ami-1234567890abcdef0',
442+
InstanceType: 't2.micro',
443+
},
444+
}).applyRemovalPolicy(RemovalPolicy.DESTROY);
445+
446+
RemovalPolicies.of(stack).apply(RemovalPolicy.RETAIN, {
447+
priority: 100,
448+
});
449+
450+
Template.fromStack(stack).hasResource('AWS::EC2::Instance', {
451+
Properties: {
452+
ImageId: 'ami-1234567890abcdef0',
453+
InstanceType: 't2.micro',
454+
},
455+
UpdateReplacePolicy: 'Retain',
456+
DeletionPolicy: 'Retain',
457+
});
458+
});
346459
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
import { Construct } from 'constructs';
2+
import { getWarnings } from './util';
3+
import { App, CfnResource, Stack } from '../lib';
4+
import { synthesize } from '../lib/private/synthesis';
5+
import { RemovalPolicies, MissingRemovalPolicies } from '../lib/removal-policies';
6+
import { RemovalPolicy } from '../lib/removal-policy';
7+
8+
class TestResource extends CfnResource {
9+
public static readonly CFN_RESOURCE_TYPE_NAME = 'AWS::Test::Resource';
10+
11+
constructor(scope: Construct, id: string) {
12+
super(scope, id, {
13+
type: TestResource.CFN_RESOURCE_TYPE_NAME,
14+
});
15+
}
16+
}
17+
18+
class TestBucketResource extends CfnResource {
19+
public static readonly CFN_RESOURCE_TYPE_NAME = 'AWS::S3::Bucket';
20+
21+
constructor(scope: Construct, id: string) {
22+
super(scope, id, {
23+
type: TestBucketResource.CFN_RESOURCE_TYPE_NAME,
24+
});
25+
}
26+
}
27+
28+
class TestTableResource extends CfnResource {
29+
public static readonly CFN_RESOURCE_TYPE_NAME = 'AWS::DynamoDB::Table';
30+
31+
constructor(scope: Construct, id: string) {
32+
super(scope, id, {
33+
type: TestTableResource.CFN_RESOURCE_TYPE_NAME,
34+
});
35+
}
36+
}
37+
38+
describe('removal-policies', () => {
39+
test('applies removal policy to all resources in scope', () => {
40+
// GIVEN
41+
const stack = new Stack();
42+
const parent = new Construct(stack, 'Parent');
43+
const resource1 = new TestResource(parent, 'Resource1');
44+
const resource2 = new TestResource(parent, 'Resource2');
45+
46+
// WHEN
47+
RemovalPolicies.of(parent).destroy();
48+
49+
// THEN
50+
synthesize(stack);
51+
expect(resource1.cfnOptions.deletionPolicy).toBe('Delete');
52+
expect(resource2.cfnOptions.deletionPolicy).toBe('Delete');
53+
});
54+
55+
test('applies removal policy only to specified resource types', () => {
56+
// GIVEN
57+
const stack = new Stack();
58+
const parent = new Construct(stack, 'Parent');
59+
const bucket = new TestBucketResource(parent, 'Bucket');
60+
const table = new TestTableResource(parent, 'Table');
61+
const resource = new TestResource(parent, 'Resource');
62+
63+
// WHEN
64+
RemovalPolicies.of(parent).retain({
65+
applyToResourceTypes: [
66+
bucket.cfnResourceType, // 'AWS::S3::Bucket'
67+
TestTableResource.CFN_RESOURCE_TYPE_NAME, // 'AWS::DynamoDB::Table'
68+
],
69+
});
70+
71+
// THEN
72+
synthesize(stack);
73+
expect(bucket.cfnOptions.deletionPolicy).toBe('Retain');
74+
expect(table.cfnOptions.deletionPolicy).toBe('Retain');
75+
expect(resource.cfnOptions.deletionPolicy).toBeUndefined();
76+
});
77+
78+
test('excludes specified resource types', () => {
79+
// GIVEN
80+
const stack = new Stack();
81+
const parent = new Construct(stack, 'Parent');
82+
const bucket = new TestBucketResource(parent, 'Bucket');
83+
const table = new TestTableResource(parent, 'Table');
84+
const resource = new TestResource(parent, 'Resource');
85+
86+
// WHEN
87+
RemovalPolicies.of(parent).snapshot({
88+
excludeResourceTypes: [
89+
TestResource.CFN_RESOURCE_TYPE_NAME, // 'AWS::Test::Resource'
90+
],
91+
});
92+
93+
// THEN
94+
synthesize(stack);
95+
expect(bucket.cfnOptions.deletionPolicy).toBe('Snapshot');
96+
expect(table.cfnOptions.deletionPolicy).toBe('Snapshot');
97+
expect(resource.cfnOptions.deletionPolicy).toBeUndefined();
98+
});
99+
100+
test('applies different removal policies', () => {
101+
// GIVEN
102+
const stack = new Stack();
103+
const destroy = new TestResource(stack, 'DestroyResource');
104+
const retain = new TestResource(stack, 'RetainResource');
105+
const snapshot = new TestResource(stack, 'SnapshotResource');
106+
const retainOnUpdate = new TestResource(stack, 'RetainOnUpdateResource');
107+
108+
// WHEN
109+
RemovalPolicies.of(destroy).destroy();
110+
RemovalPolicies.of(retain).retain();
111+
RemovalPolicies.of(snapshot).snapshot();
112+
RemovalPolicies.of(retainOnUpdate).retainOnUpdateOrDelete();
113+
114+
// THEN
115+
synthesize(stack);
116+
expect(destroy.cfnOptions.deletionPolicy).toBe('Delete');
117+
expect(retain.cfnOptions.deletionPolicy).toBe('Retain');
118+
expect(snapshot.cfnOptions.deletionPolicy).toBe('Snapshot');
119+
expect(retainOnUpdate.cfnOptions.deletionPolicy).toBe('RetainExceptOnCreate');
120+
});
121+
122+
test('RemovalPolicies overrides existing policies by default', () => {
123+
// GIVEN
124+
const stack = new Stack();
125+
const resource = new TestResource(stack, 'Resource');
126+
127+
// WHEN
128+
RemovalPolicies.of(resource).destroy();
129+
synthesize(stack);
130+
expect(resource.cfnOptions.deletionPolicy).toBe('Delete');
131+
132+
RemovalPolicies.of(resource).retain();
133+
synthesize(stack);
134+
expect(resource.cfnOptions.deletionPolicy).toBe('Retain');
135+
136+
RemovalPolicies.of(resource).snapshot();
137+
synthesize(stack);
138+
expect(resource.cfnOptions.deletionPolicy).toBe('Snapshot');
139+
});
140+
141+
test('child scope can override parent scope removal policy by default', () => {
142+
// GIVEN
143+
const stack = new Stack();
144+
const parent = new Construct(stack, 'Parent');
145+
const child = new Construct(parent, 'Child');
146+
const parentResource = new TestResource(parent, 'ParentResource');
147+
const childResource = new TestResource(child, 'ChildResource');
148+
149+
// WHEN
150+
RemovalPolicies.of(parent).destroy();
151+
RemovalPolicies.of(child).retain();
152+
153+
// THEN
154+
synthesize(stack);
155+
expect(parentResource.cfnOptions.deletionPolicy).toBe('Delete');
156+
expect(childResource.cfnOptions.deletionPolicy).toBe('Retain');
157+
});
158+
159+
test('RemovalPolicies applies policies in order, with the last one overriding previous ones regardless of priority', () => {
160+
// GIVEN
161+
const stack = new Stack();
162+
const resource = new TestResource(stack, 'PriorityResource');
163+
164+
// WHEN - despite higher priority (10), destroy is applied first and gets overridden by retainOnUpdateOrDelete
165+
RemovalPolicies.of(stack).destroy({ priority: 10 });
166+
RemovalPolicies.of(stack).retainOnUpdateOrDelete({ priority: 250 });
167+
168+
// THEN
169+
synthesize(stack);
170+
expect(resource.cfnOptions.deletionPolicy).toBe('RetainExceptOnCreate');
171+
});
172+
173+
test('RemovalPolicies application order determines the final policy, not priority', () => {
174+
// GIVEN
175+
const stack = new Stack();
176+
const resource = new TestResource(stack, 'PriorityResource');
177+
178+
// WHEN
179+
RemovalPolicies.of(stack).retainOnUpdateOrDelete({ priority: 10 });
180+
RemovalPolicies.of(stack).destroy({ priority: 250 });
181+
182+
// THEN
183+
synthesize(stack);
184+
expect(resource.cfnOptions.deletionPolicy).toBe('Delete');
185+
});
186+
});
187+
188+
describe('missing-removal-policies', () => {
189+
test('applies removal policy only to resources without existing policies', () => {
190+
// GIVEN
191+
const stack = new Stack();
192+
const parent = new Construct(stack, 'Parent');
193+
const resource1 = new TestResource(parent, 'Resource1');
194+
const resource2 = new TestResource(parent, 'Resource2');
195+
196+
// Set a policy on resource1
197+
resource1.applyRemovalPolicy(RemovalPolicy.RETAIN);
198+
199+
// WHEN
200+
MissingRemovalPolicies.of(parent).destroy();
201+
// THEN
202+
synthesize(stack);
203+
expect(resource1.cfnOptions.deletionPolicy).toBe('Retain'); // Unchanged
204+
expect(resource2.cfnOptions.deletionPolicy).toBe('Delete'); // Applied
205+
});
206+
207+
test('applies removal policy only to specified resource types without existing policies', () => {
208+
// GIVEN
209+
const stack = new Stack();
210+
const parent = new Construct(stack, 'Parent');
211+
const bucket1 = new TestBucketResource(parent, 'Bucket1');
212+
const bucket2 = new TestBucketResource(parent, 'Bucket2');
213+
const table = new TestTableResource(parent, 'Table');
214+
215+
// Set a policy on bucket1
216+
bucket1.applyRemovalPolicy(RemovalPolicy.RETAIN);
217+
218+
// WHEN
219+
MissingRemovalPolicies.of(parent).snapshot({
220+
applyToResourceTypes: [
221+
TestBucketResource.CFN_RESOURCE_TYPE_NAME, // 'AWS::S3::Bucket'
222+
],
223+
});
224+
// THEN
225+
synthesize(stack);
226+
expect(bucket1.cfnOptions.deletionPolicy).toBe('Retain'); // Unchanged
227+
expect(bucket2.cfnOptions.deletionPolicy).toBe('Snapshot'); // Applied
228+
expect(table.cfnOptions.deletionPolicy).toBeUndefined(); // Not applied (wrong type)
229+
});
230+
231+
test('excludes specified resource types from missing removal policies', () => {
232+
// GIVEN
233+
const stack = new Stack();
234+
const parent = new Construct(stack, 'Parent');
235+
const bucket = new TestBucketResource(parent, 'Bucket');
236+
const table = new TestTableResource(parent, 'Table');
237+
const resource = new TestResource(parent, 'Resource');
238+
// WHEN
239+
MissingRemovalPolicies.of(parent).retain({
240+
excludeResourceTypes: [
241+
TestTableResource.CFN_RESOURCE_TYPE_NAME, // 'AWS::DynamoDB::Table'
242+
],
243+
});
244+
245+
// THEN
246+
synthesize(stack);
247+
expect(bucket.cfnOptions.deletionPolicy).toBe('Retain');
248+
expect(table.cfnOptions.deletionPolicy).toBeUndefined();
249+
expect(resource.cfnOptions.deletionPolicy).toBe('Retain');
250+
});
251+
252+
test('applies different missing removal policies', () => {
253+
// GIVEN
254+
const stack = new Stack();
255+
const destroy = new TestResource(stack, 'DestroyResource');
256+
const retain = new TestResource(stack, 'RetainResource');
257+
const snapshot = new TestResource(stack, 'SnapshotResource');
258+
const retainOnUpdate = new TestResource(stack, 'RetainOnUpdateResource');
259+
// WHEN
260+
MissingRemovalPolicies.of(destroy).destroy();
261+
MissingRemovalPolicies.of(retain).retain();
262+
MissingRemovalPolicies.of(snapshot).snapshot();
263+
MissingRemovalPolicies.of(retainOnUpdate).retainOnUpdateOrDelete();
264+
265+
// THEN
266+
synthesize(stack);
267+
expect(destroy.cfnOptions.deletionPolicy).toBe('Delete');
268+
expect(retain.cfnOptions.deletionPolicy).toBe('Retain');
269+
expect(snapshot.cfnOptions.deletionPolicy).toBe('Snapshot');
270+
expect(retainOnUpdate.cfnOptions.deletionPolicy).toBe('RetainExceptOnCreate');
271+
});
272+
273+
test('MissingRemovalPolicies does not override existing policies', () => {
274+
// GIVEN
275+
const stack = new Stack();
276+
const resource = new TestResource(stack, 'Resource');
277+
// WHEN
278+
resource.applyRemovalPolicy(RemovalPolicy.RETAIN);
279+
synthesize(stack);
280+
expect(resource.cfnOptions.deletionPolicy).toBe('Retain');
281+
282+
MissingRemovalPolicies.of(resource).destroy();
283+
synthesize(stack);
284+
expect(resource.cfnOptions.deletionPolicy).toBe('Retain'); // Unchanged
285+
286+
MissingRemovalPolicies.of(resource).snapshot();
287+
synthesize(stack);
288+
expect(resource.cfnOptions.deletionPolicy).toBe('Retain'); // Still unchanged
289+
});
290+
291+
test('child scope MissingRemovalPolicies does not override parent scope RemovalPolicies', () => {
292+
// GIVEN
293+
const stack = new Stack();
294+
const parent = new Construct(stack, 'Parent');
295+
const child = new Construct(parent, 'Child');
296+
const childResource = new TestResource(child, 'ChildResource');
297+
// WHEN
298+
RemovalPolicies.of(parent).destroy();
299+
MissingRemovalPolicies.of(child).retain();
300+
301+
// THEN
302+
synthesize(stack);
303+
expect(childResource.cfnOptions.deletionPolicy).toBe('Delete'); // Parent policy applied
304+
});
305+
306+
test('parent scope MissingRemovalPolicies does not override child scope RemovalPolicies', () => {
307+
// GIVEN
308+
const stack = new Stack();
309+
const parent = new Construct(stack, 'Parent');
310+
const child = new Construct(parent, 'Child');
311+
const childResource = new TestResource(child, 'ChildResource');
312+
// WHEN
313+
MissingRemovalPolicies.of(parent).destroy();
314+
RemovalPolicies.of(child).retain();
315+
316+
// THEN
317+
synthesize(stack);
318+
expect(childResource.cfnOptions.deletionPolicy).toBe('Retain'); // Child policy applied
319+
});
320+
321+
test('RemovalPolicy aspect overrides where MissingRemovalPolicy does not', () => {
322+
// GIVEN
323+
const stack = new Stack();
324+
const bucket = new TestBucketResource(stack, 'Bucket');
325+
// WHEN - this is the example from the discussion
326+
// const stack = new Stack(app);
327+
// new MyThirdPartyBucket(stack, 'Bucket');
328+
// RemovalPolicies.of(stack).apply(RemovalPolicy.RETAIN);
329+
// Simulate the bucket already having a policy (as if set by MyThirdPartyBucket)
330+
bucket.applyRemovalPolicy(RemovalPolicy.DESTROY);
331+
332+
// Apply the policy using RemovalPolicies (overrides)
333+
RemovalPolicies.of(stack).retain();
334+
335+
// THEN
336+
synthesize(stack);
337+
expect(bucket.cfnOptions.deletionPolicy).toBe('Retain'); // Overridden
338+
339+
// WHEN - reset and try with MissingRemovalPolicies
340+
const stack2 = new Stack();
341+
const bucket2 = new TestBucketResource(stack2, 'Bucket');
342+
343+
// Simulate the bucket already having a policy (as if set by MyThirdPartyBucket)
344+
bucket2.applyRemovalPolicy(RemovalPolicy.DESTROY);
345+
346+
// Apply the policy using MissingRemovalPolicies (doesn't override)
347+
MissingRemovalPolicies.of(stack2).retain();
348+
349+
// THEN
350+
synthesize(stack2);
351+
expect(bucket2.cfnOptions.deletionPolicy).toBe('Delete'); // Not overridden
352+
});
353+
});

0 commit comments

Comments
 (0)
Please sign in to comment.