diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.assets.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.assets.json new file mode 100644 index 0000000000000..1f9c654c913b5 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.assets.json @@ -0,0 +1,19 @@ +{ + "version": "38.0.1", + "files": { + "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": { + "source": { + "path": "RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.template.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.template.json new file mode 100644 index 0000000000000..ad9d0fb73d1dd --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.template.json @@ -0,0 +1,36 @@ +{ + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/TestStack.assets.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/TestStack.assets.json new file mode 100644 index 0000000000000..329238c1350be --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/TestStack.assets.json @@ -0,0 +1,19 @@ +{ + "version": "38.0.1", + "files": { + "f317d1d8eb0f6b3d1c8990bc82107ef6959bc53324190e036a7734ea3b5189a8": { + "source": { + "path": "TestStack.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "f317d1d8eb0f6b3d1c8990bc82107ef6959bc53324190e036a7734ea3b5189a8.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/TestStack.template.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/TestStack.template.json new file mode 100644 index 0000000000000..7e3859e62e83b --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/TestStack.template.json @@ -0,0 +1,76 @@ +{ + "Resources": { + "TestBucket560B80BC": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "TestTable5769773A": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "TestUser6A619381": { + "Type": "AWS::IAM::User", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "DestroyBucket924C7F03": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/cdk.out b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/cdk.out new file mode 100644 index 0000000000000..c6e612584e352 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"38.0.1"} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/integ.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/integ.json new file mode 100644 index 0000000000000..4075b7e151eac --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/integ.json @@ -0,0 +1,12 @@ +{ + "version": "38.0.1", + "testCases": { + "RemovalPoliciesTest/DefaultTest": { + "stacks": [ + "TestStack" + ], + "assertionStack": "RemovalPoliciesTest/DefaultTest/DeployAssert", + "assertionStackName": "RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/manifest.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/manifest.json new file mode 100644 index 0000000000000..79f3737f773e8 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/manifest.json @@ -0,0 +1,131 @@ +{ + "version": "38.0.1", + "artifacts": { + "TestStack.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "TestStack.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "TestStack": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "TestStack.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/f317d1d8eb0f6b3d1c8990bc82107ef6959bc53324190e036a7734ea3b5189a8.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "TestStack.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "TestStack.assets" + ], + "metadata": { + "/TestStack/TestBucket/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "TestBucket560B80BC" + } + ], + "/TestStack/TestTable/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "TestTable5769773A" + } + ], + "/TestStack/TestUser/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "TestUser6A619381" + } + ], + "/TestStack/DestroyBucket/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "DestroyBucket924C7F03" + } + ], + "/TestStack/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/TestStack/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "TestStack" + }, + "RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "RemovalPoliciesTestDefaultTestDeployAssertF0ACFC0A.assets" + ], + "metadata": { + "/RemovalPoliciesTest/DefaultTest/DeployAssert/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/RemovalPoliciesTest/DefaultTest/DeployAssert/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "RemovalPoliciesTest/DefaultTest/DeployAssert" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/tree.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/tree.json new file mode 100644 index 0000000000000..d46895344354b --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.js.snapshot/tree.json @@ -0,0 +1,214 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "TestStack": { + "id": "TestStack", + "path": "TestStack", + "children": { + "TestBucket": { + "id": "TestBucket", + "path": "TestStack/TestBucket", + "children": { + "Resource": { + "id": "Resource", + "path": "TestStack/TestBucket/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3::Bucket", + "aws:cdk:cloudformation:props": {} + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.CfnBucket", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.Bucket", + "version": "0.0.0" + } + }, + "TestTable": { + "id": "TestTable", + "path": "TestStack/TestTable", + "children": { + "Resource": { + "id": "Resource", + "path": "TestStack/TestTable/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::DynamoDB::Table", + "aws:cdk:cloudformation:props": { + "attributeDefinitions": [ + { + "attributeName": "id", + "attributeType": "S" + } + ], + "keySchema": [ + { + "attributeName": "id", + "keyType": "HASH" + } + ], + "provisionedThroughput": { + "readCapacityUnits": 5, + "writeCapacityUnits": 5 + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_dynamodb.CfnTable", + "version": "0.0.0" + } + }, + "ScalingRole": { + "id": "ScalingRole", + "path": "TestStack/TestTable/ScalingRole", + "constructInfo": { + "fqn": "aws-cdk-lib.Resource", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_dynamodb.Table", + "version": "0.0.0" + } + }, + "TestUser": { + "id": "TestUser", + "path": "TestStack/TestUser", + "children": { + "Resource": { + "id": "Resource", + "path": "TestStack/TestUser/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::User", + "aws:cdk:cloudformation:props": {} + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.CfnUser", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.User", + "version": "0.0.0" + } + }, + "DestroyBucket": { + "id": "DestroyBucket", + "path": "TestStack/DestroyBucket", + "children": { + "Resource": { + "id": "Resource", + "path": "TestStack/DestroyBucket/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3::Bucket", + "aws:cdk:cloudformation:props": {} + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.CfnBucket", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.Bucket", + "version": "0.0.0" + } + }, + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "TestStack/BootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "TestStack/CheckBootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + }, + "RemovalPoliciesTest": { + "id": "RemovalPoliciesTest", + "path": "RemovalPoliciesTest", + "children": { + "DefaultTest": { + "id": "DefaultTest", + "path": "RemovalPoliciesTest/DefaultTest", + "children": { + "Default": { + "id": "Default", + "path": "RemovalPoliciesTest/DefaultTest/Default", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.4.2" + } + }, + "DeployAssert": { + "id": "DeployAssert", + "path": "RemovalPoliciesTest/DefaultTest/DeployAssert", + "children": { + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "RemovalPoliciesTest/DefaultTest/DeployAssert/BootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "RemovalPoliciesTest/DefaultTest/DeployAssert/CheckBootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests-alpha.IntegTestCase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests-alpha.IntegTest", + "version": "0.0.0" + } + }, + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.4.2" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.App", + "version": "0.0.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.ts b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.ts new file mode 100644 index 0000000000000..cd167ccf1971b --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.removal-policies.ts @@ -0,0 +1,27 @@ +import { App, RemovalPolicy, RemovalPolicies, Stack } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as integ from '@aws-cdk/integ-tests-alpha'; + +const app = new App(); +const stack = new Stack(app, 'TestStack'); + +new s3.Bucket(stack, 'TestBucket'); + +new dynamodb.Table(stack, 'TestTable', { + partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }, +}); + +const user = new iam.User(stack, 'TestUser'); +user.applyRemovalPolicy(RemovalPolicy.RETAIN); + +new s3.Bucket(stack, 'DestroyBucket', { + removalPolicy: RemovalPolicy.DESTROY, +}); + +RemovalPolicies.of(stack).destroy(); + +new integ.IntegTest(app, 'RemovalPoliciesTest', { + testCases: [stack], +}); diff --git a/packages/aws-cdk-lib/core/README.md b/packages/aws-cdk-lib/core/README.md index 17e65307db2ce..14f8bfa646eb4 100644 --- a/packages/aws-cdk-lib/core/README.md +++ b/packages/aws-cdk-lib/core/README.md @@ -1792,4 +1792,49 @@ warning by the `id`. Annotations.of(this).acknowledgeWarning('IAM:Group:MaxPoliciesExceeded', 'Account has quota increased to 20'); ``` +## RemovalPolicies + +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. + +### Usage + +Creates a new instance of RemovalPolicies for the given scope. + +```typescript +import { RemovalPolicies } from 'aws-cdk-lib'; + +// Apply DESTROY policy to all resources in a scope +RemovalPolicies.of(scope).destroy(); + +// Apply DESTROY policy (overwrited) +RemovalPolicies.of(scope).snapshot(); +RemovalPolicies.of(scope).destroy({ overwrite: true })); + +// Apply DESTROY policy (priority) +RemovalPolicies.of(stack).retainOnUpdateOrDelete({ priority: 250 }); +RemovalPolicies.of(stack).destroy({ priority: 10 }); + +// Apply RETAIN policy only to specific resource types +RemovalPolicies.of(parent).retain({ + applyToResourceTypes: [ + 'AWS::DynamoDB::Table', + bucket.cfnResourceType, // 'AWS::S3::Bucket' + CfnDBInstance.CFN_RESOURCE_TYPE_NAME, // 'AWS::RDS::DBInstance' + ], +}); + +// Apply SNAPSHOT policy excluding specific resource types +RemovalPolicies.of(scope).snapshot({ + excludeResourceTypes: ['AWS::Test::Resource'], +}); +``` + +#### Behavior Summary + +- When `overwrite` is `false` (default): + - Existing `removalPolicy` set by the user is preserved. The aspect will skip applying the policy to such resources. + +- When `overwrite` is `true`: + - The existing `removalPolicy` is ignored, and the specified policy is applied unconditionally. + diff --git a/packages/aws-cdk-lib/core/lib/index.ts b/packages/aws-cdk-lib/core/lib/index.ts index 9fb4041a136a0..0b5156bac243a 100644 --- a/packages/aws-cdk-lib/core/lib/index.ts +++ b/packages/aws-cdk-lib/core/lib/index.ts @@ -29,6 +29,7 @@ export * from './cfn-dynamic-reference'; export * from './cfn-tag'; export * from './cfn-json'; export * from './removal-policy'; +export * from './removal-policies'; export * from './arn'; export * from './duration'; export * from './expiration'; diff --git a/packages/aws-cdk-lib/core/lib/removal-policies.ts b/packages/aws-cdk-lib/core/lib/removal-policies.ts new file mode 100644 index 0000000000000..013ee66959a8e --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/removal-policies.ts @@ -0,0 +1,153 @@ +import { IConstruct } from 'constructs'; +import { Aspects, IAspect, AspectPriority } 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. + * Can be a CloudFormation resource type string (e.g., 'AWS::S3::Bucket'). + * @default - apply to all resources + */ + readonly applyToResourceTypes?: string[]; + + /** + * Exclude specific resource types from the removal policy. + * Can be a CloudFormation resource type string (e.g., 'AWS::S3::Bucket'). + * @default - no exclusions + */ + readonly excludeResourceTypes?: string[]; + + /** + * If true, overwrite any user-specified removal policy that has been previously set. + * This means even if the user has already called `applyRemovalPolicy()` on the resource, + * this aspect will override it. + * @default false - do not overwrite user-specified policies + */ + readonly overwrite?: boolean; + + /** + * The priority to use when applying this aspect. + * If multiple aspects apply conflicting settings, the one with the higher priority wins. + * + * @default - AspectPriority.MUTATING + */ + readonly priority?: number; +} + +/** + * The RemovalPolicyAspect handles applying a removal policy to resources + */ +class RemovalPolicyAspect implements IAspect { + constructor( + private readonly policy: RemovalPolicy, + private readonly props: RemovalPolicyProps = {}, + ) {} + + /** + * Checks if the given resource type matches any of the patterns + */ + private resourceTypeMatchesPatterns(resourceType: string, patterns?: string[]): boolean { + if (!patterns || patterns.length === 0) { + return false; + } + return patterns.includes(resourceType); + } + + public visit(node: IConstruct): void { + if (!CfnResource.isCfnResource(node)) { + return; + } + + const cfnResource = node as CfnResource; + const resourceType = cfnResource.cfnResourceType; + + const userAlreadySetPolicy = + cfnResource.cfnOptions.deletionPolicy !== undefined || + cfnResource.cfnOptions.updateReplacePolicy !== undefined; + + if (!this.props.overwrite && userAlreadySetPolicy) { + return; + } + + if (this.resourceTypeMatchesPatterns(resourceType, this.props.excludeResourceTypes)) { + return; + } + + if ( + this.props.applyToResourceTypes?.length && + !this.resourceTypeMatchesPatterns(resourceType, this.props.applyToResourceTypes) + ) { + return; + } + + // Apply the removal policy + cfnResource.applyRemovalPolicy(this.policy); + } +} + +/** + * Manages removal policies for all resources within a construct scope + */ +export class RemovalPolicies { + /** + * Returns the removal policies API for the given scope + * @param scope The scope + */ + public static of(scope: IConstruct): RemovalPolicies { + return new RemovalPolicies(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), { + priority: props.priority ?? AspectPriority.MUTATING, + }); + } + + /** + * 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); + } +} diff --git a/packages/aws-cdk-lib/core/test/removal-policies.test.ts b/packages/aws-cdk-lib/core/test/removal-policies.test.ts new file mode 100644 index 0000000000000..d9f3e7fde2a34 --- /dev/null +++ b/packages/aws-cdk-lib/core/test/removal-policies.test.ts @@ -0,0 +1,252 @@ +import { Construct } from 'constructs'; +import { CfnResource, CfnDeletionPolicy, Stack } from '../lib'; +import { synthesize } from '../lib/private/synthesis'; +import { RemovalPolicies } from '../lib/removal-policies'; + +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 + RemovalPolicies.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 + RemovalPolicies.of(parent).retain({ + applyToResourceTypes: [ + bucket.cfnResourceType, // 'AWS::S3::Bucket' + TestTableResource.CFN_RESOURCE_TYPE_NAME, // '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 + RemovalPolicies.of(parent).retain({ + applyToResourceTypes: [ + TestBucketResource.CFN_RESOURCE_TYPE_NAME, // 'AWS::S3::Bucket' + table.cfnResourceType, // 'AWS::DynamoDB::Table' + ], + }); + + // 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 + RemovalPolicies.of(parent).snapshot({ + excludeResourceTypes: [ + TestResource.CFN_RESOURCE_TYPE_NAME, // '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 + RemovalPolicies.of(parent).snapshot({ + excludeResourceTypes: [ + resource.cfnResourceType, + ], + }); + + // 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 + RemovalPolicies.of(destroy).destroy(); + RemovalPolicies.of(retain).retain(); + RemovalPolicies.of(snapshot).snapshot(); + RemovalPolicies.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 + RemovalPolicies.of(resource).destroy(); + synthesize(stack); + expect(resource.cfnOptions.deletionPolicy).toBe('Delete'); + + RemovalPolicies.of(resource).retain(); + synthesize(stack); + expect(resource.cfnOptions.deletionPolicy).toBe('Delete'); + + RemovalPolicies.of(resource).snapshot({ overwrite: true }); + 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 + RemovalPolicies.of(parent).destroy(); + RemovalPolicies.of(child).retain(); + + // THEN + synthesize(stack); + expect(parentResource.cfnOptions.deletionPolicy).toBe('Delete'); + expect(childResource.cfnOptions.deletionPolicy).toBe('Delete'); + + RemovalPolicies.of(child).retain({ overwrite: true }); + synthesize(stack); + expect(childResource.cfnOptions.deletionPolicy).toBe('Retain'); + }); + + test('exist removalPolicy', () => { + // GIVEN + const stack = new Stack(); + const parent = new Construct(stack, 'Parent'); + const bucket = new TestBucketResource(parent, 'Bucket'); + bucket.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN; + + synthesize(stack); + expect(bucket.cfnOptions.deletionPolicy).toBe('Retain'); + + const table = new TestTableResource(parent, 'Table'); + RemovalPolicies.of(parent).retainOnUpdateOrDelete(); + + synthesize(stack); + expect(bucket.cfnOptions.deletionPolicy).toBe('Retain'); + expect(table.cfnOptions.deletionPolicy).toBe('RetainExceptOnCreate'); + }); + + test('higher priority removal policy overrides lower priority removal policy', () => { + // GIVEN + const stack = new Stack(); + const resource = new TestResource(stack, 'PriorityResource'); + + // WHEN + RemovalPolicies.of(stack).retainOnUpdateOrDelete({ priority: 250 }); + RemovalPolicies.of(stack).destroy({ priority: 10 }); + + // THEN + synthesize(stack); + expect(resource.cfnOptions.deletionPolicy).toBe('Delete'); + }); + + test('higher priority removal policy with overwrite set to true', () => { + // GIVEN + const stack = new Stack(); + const resource = new TestResource(stack, 'PriorityResource'); + + // WHEN + RemovalPolicies.of(stack).retainOnUpdateOrDelete({ priority: 10 }); + RemovalPolicies.of(stack).destroy({ priority: 250, overwrite: true }); + + // THEN + synthesize(stack); + expect(resource.cfnOptions.deletionPolicy).toBe('Delete'); + }); + +});