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): RemovalPolicies.of(scope) #32283

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
137 changes: 137 additions & 0 deletions packages/aws-cdk-lib/core/lib/removal-policys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { IConstruct } from 'constructs';
import { Aspects, IAspect } from './aspect';
import { CfnResource } from './cfn-resource';
import { RemovalPolicy } from './removal-policy';

/**
* Properties for applying a removal policy
*/
export interface RemovalPolicyProps {
/**
* Apply the removal policy only to specific resource types.
* You can specify either the CloudFormation resource type (e.g., 'AWS::S3::Bucket')
* or the CDK resource class (e.g., CfnBucket).
* @default - apply to all resources
*/
readonly applyToResourceTypes?: Array<string | { new (...args: any[]): CfnResource }>;

/**
* Exclude specific resource types from the removal policy.
* You can specify either the CloudFormation resource type (e.g., 'AWS::S3::Bucket')
* or the CDK resource class (e.g., CfnBucket).
* @default - no exclusions
*/
readonly excludeResourceTypes?: Array<string | { new (...args: any[]): CfnResource }>;
}

/**
* The RemovalPolicyAspect handles applying a removal policy to resources
*/
class RemovalPolicyAspect implements IAspect {
constructor(
private readonly policy: RemovalPolicy,
private readonly props: RemovalPolicyProps = {},
) {}

private getResourceTypeFromClass(resourceClass: { new (...args: any[]): CfnResource }): string {
// Create a prototype instance to get the type without instantiating
const prototype = resourceClass.prototype;
if ('cfnResourceType' in prototype) {
return prototype.cfnResourceType;
}
// Fallback to checking constructor properties
const instance = Object.create(prototype);
return instance.constructor.CFN_RESOURCE_TYPE_NAME ?? '';
}

private matchesResourceType(resourceType: string, pattern: string | { new (...args: any[]): CfnResource }): boolean {
if (typeof pattern === 'string') {
return resourceType === pattern;
}
return resourceType === this.getResourceTypeFromClass(pattern);
}

public visit(node: IConstruct): void {
if (!CfnResource.isCfnResource(node)) {
return;
}

const cfnResource = node as CfnResource;
const resourceType = cfnResource.cfnResourceType;

// Skip if resource type is excluded
if (this.props.excludeResourceTypes?.some(pattern => this.matchesResourceType(resourceType, pattern))) {
return;
}

// Skip if specific resource types are specified and this one isn't included
if (this.props.applyToResourceTypes?.length &&
!this.props.applyToResourceTypes.some(pattern => this.matchesResourceType(resourceType, pattern))) {
return;
}

// Apply the removal policy
cfnResource.applyRemovalPolicy(this.policy);
}
}

/**
* Manages removal policies for all resources within a construct scope
*/
export class RemovalPolicys {
watany-dev marked this conversation as resolved.
Show resolved Hide resolved
/**
* Returns the removal policies API for the given scope
* @param scope The scope
*/
public static of(scope: IConstruct): RemovalPolicys {
return new RemovalPolicys(scope);
}

private constructor(private readonly scope: IConstruct) {}

/**
* Apply a removal policy to all resources within this scope
*
* @param policy The removal policy to apply
* @param props Configuration options
*/
public apply(policy: RemovalPolicy, props: RemovalPolicyProps = {}) {
Aspects.of(this.scope).add(new RemovalPolicyAspect(policy, props));
}

/**
* Apply DESTROY removal policy to all resources within this scope
*
* @param props Configuration options
*/
public destroy(props: RemovalPolicyProps = {}) {
this.apply(RemovalPolicy.DESTROY, props);
}

/**
* Apply RETAIN removal policy to all resources within this scope
*
* @param props Configuration options
*/
public retain(props: RemovalPolicyProps = {}) {
this.apply(RemovalPolicy.RETAIN, props);
}

/**
* Apply SNAPSHOT removal policy to all resources within this scope
*
* @param props Configuration options
*/
public snapshot(props: RemovalPolicyProps = {}) {
this.apply(RemovalPolicy.SNAPSHOT, props);
}

/**
* Apply RETAIN_ON_UPDATE_OR_DELETE removal policy to all resources within this scope
*
* @param props Configuration options
*/
public retainOnUpdateOrDelete(props: RemovalPolicyProps = {}) {
this.apply(RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE, props);
}
}
207 changes: 207 additions & 0 deletions packages/aws-cdk-lib/core/test/removal-policys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { Construct } from 'constructs';
import { CfnResource, RemovalPolicy, Stack, Aspects } from '../lib';
import { synthesize } from '../lib/private/synthesis';
import { RemovalPolicys } from '../lib/removal-policys';

class TestResource extends CfnResource {
public static readonly CFN_RESOURCE_TYPE_NAME = 'AWS::Test::Resource';

constructor(scope: Construct, id: string) {
super(scope, id, {
type: TestResource.CFN_RESOURCE_TYPE_NAME,
});
}
}

class TestBucketResource extends CfnResource {
public static readonly CFN_RESOURCE_TYPE_NAME = 'AWS::S3::Bucket';

constructor(scope: Construct, id: string) {
super(scope, id, {
type: TestBucketResource.CFN_RESOURCE_TYPE_NAME,
});
}
}

class TestTableResource extends CfnResource {
public static readonly CFN_RESOURCE_TYPE_NAME = 'AWS::DynamoDB::Table';

constructor(scope: Construct, id: string) {
super(scope, id, {
type: TestTableResource.CFN_RESOURCE_TYPE_NAME,
});
}
}

describe('removal-policys', () => {
test('applies removal policy to all resources in scope', () => {
// GIVEN
const stack = new Stack();
const parent = new Construct(stack, 'Parent');
const resource1 = new TestResource(parent, 'Resource1');
const resource2 = new TestResource(parent, 'Resource2');

// WHEN
RemovalPolicys.of(parent).destroy();

// THEN
synthesize(stack);
expect(resource1.cfnOptions.deletionPolicy).toBe('Delete');
expect(resource2.cfnOptions.deletionPolicy).toBe('Delete');
});

test('applies removal policy only to specified resource types using strings', () => {
// GIVEN
const stack = new Stack();
const parent = new Construct(stack, 'Parent');
const bucket = new TestBucketResource(parent, 'Bucket');
const table = new TestTableResource(parent, 'Table');
const resource = new TestResource(parent, 'Resource');

// WHEN
RemovalPolicys.of(parent).retain({
applyToResourceTypes: ['AWS::S3::Bucket', 'AWS::DynamoDB::Table'],
});

// THEN
synthesize(stack);
expect(bucket.cfnOptions.deletionPolicy).toBe('Retain');
expect(table.cfnOptions.deletionPolicy).toBe('Retain');
expect(resource.cfnOptions.deletionPolicy).toBeUndefined();
});

test('applies removal policy only to specified resource types using classes', () => {
// GIVEN
const stack = new Stack();
const parent = new Construct(stack, 'Parent');
const bucket = new TestBucketResource(parent, 'Bucket');
const table = new TestTableResource(parent, 'Table');
const resource = new TestResource(parent, 'Resource');

// WHEN
RemovalPolicys.of(parent).retain({
applyToResourceTypes: [TestBucketResource, TestTableResource],
});

// THEN
synthesize(stack);
expect(bucket.cfnOptions.deletionPolicy).toBe('Retain');
expect(table.cfnOptions.deletionPolicy).toBe('Retain');
expect(resource.cfnOptions.deletionPolicy).toBeUndefined();
});

test('excludes specified resource types using strings', () => {
// GIVEN
const stack = new Stack();
const parent = new Construct(stack, 'Parent');
const bucket = new TestBucketResource(parent, 'Bucket');
const table = new TestTableResource(parent, 'Table');
const resource = new TestResource(parent, 'Resource');

// WHEN
RemovalPolicys.of(parent).snapshot({
excludeResourceTypes: ['AWS::Test::Resource'],
});

// THEN
synthesize(stack);
expect(bucket.cfnOptions.deletionPolicy).toBe('Snapshot');
expect(table.cfnOptions.deletionPolicy).toBe('Snapshot');
expect(resource.cfnOptions.deletionPolicy).toBeUndefined();
});

test('excludes specified resource types using classes', () => {
// GIVEN
const stack = new Stack();
const parent = new Construct(stack, 'Parent');
const bucket = new TestBucketResource(parent, 'Bucket');
const table = new TestTableResource(parent, 'Table');
const resource = new TestResource(parent, 'Resource');

// WHEN
RemovalPolicys.of(parent).snapshot({
excludeResourceTypes: [TestResource],
});

// THEN
synthesize(stack);
expect(bucket.cfnOptions.deletionPolicy).toBe('Snapshot');
expect(table.cfnOptions.deletionPolicy).toBe('Snapshot');
expect(resource.cfnOptions.deletionPolicy).toBeUndefined();
});

test('applies different removal policies', () => {
// GIVEN
const stack = new Stack();
const destroy = new TestResource(stack, 'DestroyResource');
const retain = new TestResource(stack, 'RetainResource');
const snapshot = new TestResource(stack, 'SnapshotResource');
const retainOnUpdate = new TestResource(stack, 'RetainOnUpdateResource');

// WHEN
RemovalPolicys.of(destroy).destroy();
RemovalPolicys.of(retain).retain();
RemovalPolicys.of(snapshot).snapshot();
RemovalPolicys.of(retainOnUpdate).retainOnUpdateOrDelete();

// THEN
synthesize(stack);
expect(destroy.cfnOptions.deletionPolicy).toBe('Delete');
expect(retain.cfnOptions.deletionPolicy).toBe('Retain');
expect(snapshot.cfnOptions.deletionPolicy).toBe('Snapshot');
expect(retainOnUpdate.cfnOptions.deletionPolicy).toBe('RetainExceptOnCreate');
});

test('last applied removal policy takes precedence', () => {
// GIVEN
const stack = new Stack();
const resource = new TestResource(stack, 'Resource');

// WHEN
RemovalPolicys.of(resource).destroy();
RemovalPolicys.of(resource).retain();
RemovalPolicys.of(resource).snapshot();

// THEN
synthesize(stack);
expect(resource.cfnOptions.deletionPolicy).toBe('Snapshot');
});

test('child scope can override parent scope removal policy', () => {
// GIVEN
const stack = new Stack();
const parent = new Construct(stack, 'Parent');
const child = new Construct(parent, 'Child');
const parentResource = new TestResource(parent, 'ParentResource');
const childResource = new TestResource(child, 'ChildResource');

// WHEN
RemovalPolicys.of(parent).destroy();
RemovalPolicys.of(child).retain();

// THEN
synthesize(stack);
expect(parentResource.cfnOptions.deletionPolicy).toBe('Delete');
expect(childResource.cfnOptions.deletionPolicy).toBe('Retain');
});

test('can mix string and class resource type specifications', () => {
// GIVEN
const stack = new Stack();
const parent = new Construct(stack, 'Parent');
const bucket = new TestBucketResource(parent, 'Bucket');
const table = new TestTableResource(parent, 'Table');
const resource = new TestResource(parent, 'Resource');

// WHEN
RemovalPolicys.of(parent).retain({
applyToResourceTypes: [TestBucketResource, 'AWS::DynamoDB::Table'],
});

// THEN
synthesize(stack);
expect(bucket.cfnOptions.deletionPolicy).toBe('Retain');
expect(table.cfnOptions.deletionPolicy).toBe('Retain');
expect(resource.cfnOptions.deletionPolicy).toBeUndefined();
});
});
Loading