Skip to content

Commit 5d8917b

Browse files
authored
New: [AEA-0000] - add function to scan destructive changes (#531)
## Summary - Routine Change ### Details - add function to scan for destructive changesets
1 parent 81f327f commit 5d8917b

File tree

6 files changed

+7628
-0
lines changed

6 files changed

+7628
-0
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Available constructs and helpers include:
2626
- `TypescriptLambdaFunction` – A reusable construct for TypeScript Lambda functions
2727
- `createApp` – Helper for creating a CDK `App` pre-configured with standard EPS tags and stack props
2828
- `deleteUnusedStacks` – Helper functions for cleaning up superseded or PR-based CloudFormation stacks and their Route 53 records
29+
- `checkDestructiveChangeSet` – Describes a CloudFormation change set, filters out replacements and removals (optionally applying time-bound waivers) and throws if anything destructive remains.
2930

3031
### CDK app bootstrap (`createApp`)
3132

@@ -63,6 +64,33 @@ These functions are designed to be invoked from scheduled jobs (for example, a n
6364

6465
Refer to [packages/cdkConstructs/tests/stacks/deleteUnusedStacks.test.ts](packages/cdkConstructs/tests/stacks/deleteUnusedStacks.test.ts) for example scenarios.
6566

67+
### Check destructive change sets
68+
This is used for stateful stack deployments where we want to make sure we do not automatically deploy potentially destructive changes.
69+
In a CI pipeline for stateful stacks, we should create a changeset initially, then pass the changeset details to checkDestructiveChangeSet, and an optional array of short-lived waivers, for example:
70+
71+
```ts
72+
import {checkDestructiveChangeSet} from "@nhsdigital/eps-cdk-constructs"
73+
74+
await checkDestructiveChangeSet(
75+
process.env.CDK_CHANGE_SET_NAME,
76+
process.env.STACK_NAME,
77+
process.env.AWS_REGION,
78+
[
79+
{
80+
LogicalResourceId: "MyAlarm",
81+
PhysicalResourceId: "monitoring-alarm",
82+
ResourceType: "AWS::CloudWatch::Alarm",
83+
StackName: "monitoring",
84+
ExpiryDate: "2026-03-01T00:00:00Z",
85+
AllowedReason: "Pending rename rollout"
86+
}
87+
]
88+
)
89+
```
90+
91+
Each waiver is effective only when the stack name, logical ID, physical ID, and resource type all match and the waiver’s `ExpiryDate` is later than the change set’s `CreationTime`. When no destructive changes remain, the helper logs a confirmation message; otherwise it prints the problematic resources and throws.
92+
93+
6694
## Deployment utilities (`packages/deploymentUtils`)
6795

6896
The [packages/deploymentUtils](packages/deploymentUtils) package contains utilities for working with OpenAPI specifications and Proxygen-based API deployments.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import {
2+
CloudFormationClient,
3+
DescribeChangeSetCommand,
4+
DescribeChangeSetCommandOutput,
5+
Change as CloudFormationChange
6+
} from "@aws-sdk/client-cloudformation"
7+
8+
export type ChangeRequiringAttention = {
9+
logicalId: string;
10+
physicalId: string;
11+
resourceType: string;
12+
reason: string;
13+
}
14+
15+
export type AllowedDestructiveChange = {
16+
LogicalResourceId: string;
17+
PhysicalResourceId: string;
18+
ResourceType: string;
19+
ExpiryDate: string | Date;
20+
StackName: string;
21+
AllowedReason: string;
22+
}
23+
24+
const requiresReplacement = (replacement: unknown): boolean => {
25+
if (replacement === undefined || replacement === null) {
26+
return false
27+
}
28+
29+
const normalized = String(replacement)
30+
return normalized === "True" || normalized === "Conditional"
31+
}
32+
33+
const toDate = (value: Date | string | number | undefined | null): Date | undefined => {
34+
if (value === undefined || value === null) {
35+
return undefined
36+
}
37+
38+
const date = value instanceof Date ? value : new Date(value)
39+
return Number.isNaN(date.getTime()) ? undefined : date
40+
}
41+
42+
/**
43+
* Extracts the subset of CloudFormation changes that either require replacement or remove resources.
44+
*
45+
* @param changeSet - Raw change-set details returned from `DescribeChangeSet`.
46+
* @returns Array of changes that need operator attention.
47+
*/
48+
export function checkDestructiveChanges(
49+
changeSet: DescribeChangeSetCommandOutput | undefined | null
50+
): Array<ChangeRequiringAttention> {
51+
if (!changeSet || typeof changeSet !== "object") {
52+
throw new Error("A change set object must be provided")
53+
}
54+
55+
const {Changes} = changeSet
56+
const changes = Array.isArray(Changes) ? (Changes as Array<CloudFormationChange>) : []
57+
58+
return changes
59+
.map((change: CloudFormationChange) => {
60+
const resourceChange = change?.ResourceChange
61+
if (!resourceChange) {
62+
return undefined
63+
}
64+
65+
const replacementNeeded = requiresReplacement(resourceChange.Replacement)
66+
const action = resourceChange.Action
67+
const isRemoval = action === "Remove"
68+
69+
if (!replacementNeeded && !isRemoval) {
70+
return undefined
71+
}
72+
73+
return {
74+
logicalId: resourceChange.LogicalResourceId ?? "<unknown logical id>",
75+
physicalId: resourceChange.PhysicalResourceId ?? "<unknown physical id>",
76+
resourceType: resourceChange.ResourceType ?? "<unknown type>",
77+
reason: replacementNeeded
78+
? `Replacement: ${String(resourceChange.Replacement)}`
79+
: `Action: ${action ?? "<unknown action>"}`
80+
}
81+
})
82+
.filter((change): change is ChangeRequiringAttention => Boolean(change))
83+
}
84+
85+
/**
86+
* Describes a CloudFormation change set, applies waiver logic, and throws if destructive changes remain.
87+
*
88+
* @param changeSetName - Name or ARN of the change set.
89+
* @param stackName - Name or ARN of the stack that owns the change set.
90+
* @param region - AWS region where the stack resides.
91+
* @param allowedChanges - Optional waivers that temporarily allow specific destructive changes.
92+
*/
93+
export async function checkDestructiveChangeSet(
94+
changeSetName: string,
95+
stackName: string,
96+
region: string,
97+
allowedChanges: Array<AllowedDestructiveChange> = []): Promise<void> {
98+
if (!changeSetName || !stackName || !region) {
99+
throw new Error("Change set name, stack name, and region are required")
100+
}
101+
102+
const client = new CloudFormationClient({region})
103+
const command = new DescribeChangeSetCommand({
104+
ChangeSetName: changeSetName,
105+
StackName: stackName
106+
})
107+
108+
const response: DescribeChangeSetCommandOutput = await client.send(command)
109+
const destructiveChanges = checkDestructiveChanges(response)
110+
const creationTime = toDate(response.CreationTime)
111+
const changeSetStackName = response.StackName
112+
113+
const remainingChanges = destructiveChanges.filter(change => {
114+
const waiver = allowedChanges.find(allowed =>
115+
allowed.LogicalResourceId === change.logicalId &&
116+
allowed.PhysicalResourceId === change.physicalId &&
117+
allowed.ResourceType === change.resourceType
118+
)
119+
120+
if (!waiver || !creationTime || !changeSetStackName || waiver.StackName !== changeSetStackName) {
121+
return true
122+
}
123+
124+
const expiryDate = toDate(waiver.ExpiryDate)
125+
if (!expiryDate) {
126+
return true
127+
}
128+
129+
if (expiryDate.getTime() > creationTime.getTime()) {
130+
131+
console.log(
132+
// eslint-disable-next-line max-len
133+
`Allowing destructive change ${change.logicalId} (${change.resourceType}) until ${expiryDate.toISOString()} - ${waiver.AllowedReason}`
134+
)
135+
return false
136+
}
137+
138+
console.error(
139+
`Waiver for ${change.logicalId} (${change.resourceType}) expired on ${expiryDate.toISOString()}`
140+
)
141+
return true
142+
})
143+
144+
if (remainingChanges.length === 0) {
145+
console.log(`Change set ${changeSetName} for stack ${stackName} has no destructive changes that are not waived.`)
146+
return
147+
}
148+
149+
console.error("Resources that require attention:")
150+
remainingChanges.forEach(({logicalId, physicalId, resourceType, reason}) => {
151+
console.error(`- LogicalId: ${logicalId}, PhysicalId: ${physicalId}, Type: ${resourceType}, Reason: ${reason}`)
152+
})
153+
throw new Error(`Change set ${changeSetName} contains destructive changes`)
154+
}

packages/cdkConstructs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from "./config/index.js"
66
export * from "./utils/helpers.js"
77
export * from "./stacks/deleteUnusedStacks.js"
88
export * from "./nag/pack/epsNagPack.js"
9+
export * from "./changesets/checkDestructiveChanges"

0 commit comments

Comments
 (0)