Skip to content
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
11 changes: 9 additions & 2 deletions API.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 30 additions & 3 deletions src/aurora.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { join } from 'path';
import {
Annotations,
Aspects,
aws_ec2,
aws_iam,
aws_kms,
aws_lambda,
aws_lambda_nodejs,
aws_logs,
aws_rds,
CfnResource,
// CfnMapping,
CfnOutput,
custom_resources,
CustomResource,
Duration,
IAspect,
RemovalPolicy,
Stack,
} from 'aws-cdk-lib';
import { Construct, IDependable } from 'constructs';
import { Construct, IDependable, IConstruct } from 'constructs';
import { Namer } from 'multi-convention-namer';

import {} from './aurora.provision-database';
Expand All @@ -31,6 +34,20 @@ declare global {
interface ReadableStream {}
}

/**
* Aspect that applies a RemovalPolicy to all CloudFormation resources in a construct tree.
* This ensures consistent deletion behavior across all resources when the stack is deleted.
*/
class ApplyRemovalPolicyAspect implements IAspect {
constructor(private readonly policy: RemovalPolicy) {}

visit(node: IConstruct): void {
if (node instanceof CfnResource) {
node.applyRemovalPolicy(this.policy);
}
}
}

export interface AuroraProps {
/**
* Turn on the Activity Stream feature of the Aurora cluster.
Expand Down Expand Up @@ -96,7 +113,14 @@ export interface AuroraProps {
*/
readonly proxySecurityGroups?: aws_ec2.ISecurityGroup[];
/**
* @default - passthrough
* The removal policy to apply to all resources in this construct.
*
* Controls what happens to resources when the CloudFormation stack is deleted:
* - RemovalPolicy.RETAIN: Resources are orphaned (recommended for production databases)
* - RemovalPolicy.DESTROY: Resources are deleted with the stack
* - RemovalPolicy.SNAPSHOT: Resources are snapshotted before deletion (where supported)
*
* @default RemovalPolicy.RETAIN - All resources are retained for safety
*/
readonly removalPolicy?: RemovalPolicy;
/**
Expand Down Expand Up @@ -380,7 +404,6 @@ export class Aurora extends Construct {
),
parameterGroup: props.parameterGroup,
parameters: parameters,
removalPolicy: props.removalPolicy,
storageEncryptionKey: encryptionKey,
});

Expand Down Expand Up @@ -603,5 +626,9 @@ export class Aurora extends Construct {
rdsUser.node.addDependency(this.cluster);
});
}

// Apply removal policy to all resources (defaults to RETAIN for safety)
const removalPolicy = props.removalPolicy ?? RemovalPolicy.RETAIN;
Aspects.of(this).add(new ApplyRemovalPolicyAspect(removalPolicy));
}
}
77 changes: 73 additions & 4 deletions test/aurora.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,29 @@ describe('Aurora', () => {
it('proxyName', () => {
template.hasResourceProperties('AWS::RDS::DBProxy', { DBProxyName: 'Test' });
});
it('removalPolicy', () => {
template.hasResource('AWS::RDS::DBCluster', {
UpdateReplacePolicy: 'Snapshot',
DeletionPolicy: 'Snapshot',
it('removalPolicy defaults to RETAIN for all resources', () => {
// Get all resources from the template
const templateJson = template.toJSON();
const resources = templateJson.Resources || {};

// Check specific resource types that support DeletionPolicy
const supportedTypes = [
'AWS::RDS::DBCluster',
'AWS::RDS::DBInstance',
'AWS::SecretsManager::Secret',
'AWS::RDS::DBProxy',
];

const resourcesWithPolicy = Object.entries(resources).filter(([_, resource]: [string, any]) =>
supportedTypes.includes(resource.Type),
);

// Verify we found some resources
expect(resourcesWithPolicy.length).toBeGreaterThan(0);

// Check that these resources have DeletionPolicy: Retain by default
resourcesWithPolicy.forEach(([, resource]: [string, any]) => {
expect(resource.DeletionPolicy).toBe('Retain');
});
});
it('retention', () => {
Expand Down Expand Up @@ -428,5 +447,55 @@ describe('Aurora', () => {
Parameters: { test: 'rds' },
});
});

describe('removalPolicy aspect', () => {
test.each([
{
removalPolicy: RemovalPolicy.RETAIN,
expectedPolicy: 'Retain',
description: 'applies RETAIN policy to all resources',
},
{
removalPolicy: RemovalPolicy.DESTROY,
expectedPolicy: 'Delete',
description: 'applies DESTROY policy to all resources',
},
{
removalPolicy: RemovalPolicy.SNAPSHOT,
expectedPolicy: 'Snapshot',
description: 'applies SNAPSHOT policy to snapshot-capable resources',
},
])('$description when explicitly set to $removalPolicy', ({ removalPolicy, expectedPolicy }) => {
createAurora({ ...defaultAuroraProps, removalPolicy });

const templateJson = template.toJSON();
const resources = templateJson.Resources || {};

// Check specific resource types that support DeletionPolicy
const supportedTypes = [
'AWS::RDS::DBCluster',
'AWS::RDS::DBInstance',
'AWS::SecretsManager::Secret',
'AWS::RDS::DBProxy',
];

const resourcesWithPolicy = Object.entries(resources).filter(([_, resource]: [string, any]) =>
supportedTypes.includes(resource.Type),
);

// Verify we found some resources
expect(resourcesWithPolicy.length).toBeGreaterThan(0);

// Check that these resources have the expected DeletionPolicy
resourcesWithPolicy.forEach(([, resource]: [string, any]) => {
if (removalPolicy === RemovalPolicy.DESTROY) {
// DESTROY maps to CloudFormation's Delete (or undefined which defaults to Delete)
expect(['Delete', undefined]).toContain(resource.DeletionPolicy);
} else {
expect(resource.DeletionPolicy).toBe(expectedPolicy);
}
});
});
});
});
});