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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Available constructs and helpers include:
- `TypescriptLambdaFunction` – A reusable construct for TypeScript Lambda functions
- `createApp` – Helper for creating a CDK `App` pre-configured with standard EPS tags and stack props
- `deleteUnusedStacks` – Helper functions for cleaning up superseded or PR-based CloudFormation stacks and their Route 53 records
- `checkDestructiveChangeSet` – Describes a CloudFormation change set, filters out replacements and removals (optionally applying time-bound waivers) and throws if anything destructive remains.

### CDK app bootstrap (`createApp`)

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

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

### Check destructive change sets
This is used for stateful stack deployments where we want to make sure we do not automatically deploy potentially destructive changes.
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:

```ts
import {checkDestructiveChangeSet} from "@nhsdigital/eps-cdk-constructs"

await checkDestructiveChangeSet(
process.env.CDK_CHANGE_SET_NAME,
process.env.STACK_NAME,
process.env.AWS_REGION,
[
{
LogicalResourceId: "MyAlarm",
PhysicalResourceId: "monitoring-alarm",
ResourceType: "AWS::CloudWatch::Alarm",
StackName: "monitoring",
ExpiryDate: "2026-03-01T00:00:00Z",
AllowedReason: "Pending rename rollout"
}
]
)
```

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.


## Deployment utilities (`packages/deploymentUtils`)

The [packages/deploymentUtils](packages/deploymentUtils) package contains utilities for working with OpenAPI specifications and Proxygen-based API deployments.
Expand Down
154 changes: 154 additions & 0 deletions packages/cdkConstructs/src/changesets/checkDestructiveChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
CloudFormationClient,
DescribeChangeSetCommand,
DescribeChangeSetCommandOutput,
Change as CloudFormationChange
} from "@aws-sdk/client-cloudformation"

export type ChangeRequiringAttention = {
logicalId: string;
physicalId: string;
resourceType: string;
reason: string;
}

export type AllowedDestructiveChange = {
LogicalResourceId: string;
PhysicalResourceId: string;
ResourceType: string;
ExpiryDate: string | Date;
StackName: string;
AllowedReason: string;
}

const requiresReplacement = (replacement: unknown): boolean => {
if (replacement === undefined || replacement === null) {
return false
}

const normalized = String(replacement)
return normalized === "True" || normalized === "Conditional"
}

const toDate = (value: Date | string | number | undefined | null): Date | undefined => {
if (value === undefined || value === null) {
return undefined
}

const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date
}

/**
* Extracts the subset of CloudFormation changes that either require replacement or remove resources.
*
* @param changeSet - Raw change-set details returned from `DescribeChangeSet`.
* @returns Array of changes that need operator attention.
*/
export function checkDestructiveChanges(
changeSet: DescribeChangeSetCommandOutput | undefined | null
): Array<ChangeRequiringAttention> {
if (!changeSet || typeof changeSet !== "object") {
throw new Error("A change set object must be provided")
}

const {Changes} = changeSet
const changes = Array.isArray(Changes) ? (Changes as Array<CloudFormationChange>) : []

return changes
.map((change: CloudFormationChange) => {
const resourceChange = change?.ResourceChange
if (!resourceChange) {
return undefined
}

const replacementNeeded = requiresReplacement(resourceChange.Replacement)
const action = resourceChange.Action
const isRemoval = action === "Remove"

if (!replacementNeeded && !isRemoval) {
return undefined
}

return {
logicalId: resourceChange.LogicalResourceId ?? "<unknown logical id>",
physicalId: resourceChange.PhysicalResourceId ?? "<unknown physical id>",
resourceType: resourceChange.ResourceType ?? "<unknown type>",
reason: replacementNeeded
? `Replacement: ${String(resourceChange.Replacement)}`
: `Action: ${action ?? "<unknown action>"}`
}
})
.filter((change): change is ChangeRequiringAttention => Boolean(change))
}

/**
* Describes a CloudFormation change set, applies waiver logic, and throws if destructive changes remain.
*
* @param changeSetName - Name or ARN of the change set.
* @param stackName - Name or ARN of the stack that owns the change set.
* @param region - AWS region where the stack resides.
* @param allowedChanges - Optional waivers that temporarily allow specific destructive changes.
*/
export async function checkDestructiveChangeSet(
changeSetName: string,
stackName: string,
region: string,
allowedChanges: Array<AllowedDestructiveChange> = []): Promise<void> {
if (!changeSetName || !stackName || !region) {
throw new Error("Change set name, stack name, and region are required")
}

const client = new CloudFormationClient({region})
const command = new DescribeChangeSetCommand({
ChangeSetName: changeSetName,
StackName: stackName
})

const response: DescribeChangeSetCommandOutput = await client.send(command)
const destructiveChanges = checkDestructiveChanges(response)
const creationTime = toDate(response.CreationTime)
const changeSetStackName = response.StackName

const remainingChanges = destructiveChanges.filter(change => {
const waiver = allowedChanges.find(allowed =>
allowed.LogicalResourceId === change.logicalId &&
allowed.PhysicalResourceId === change.physicalId &&
allowed.ResourceType === change.resourceType
)

if (!waiver || !creationTime || !changeSetStackName || waiver.StackName !== changeSetStackName) {
return true
}

const expiryDate = toDate(waiver.ExpiryDate)
if (!expiryDate) {
return true
}

if (expiryDate.getTime() > creationTime.getTime()) {

console.log(
// eslint-disable-next-line max-len
`Allowing destructive change ${change.logicalId} (${change.resourceType}) until ${expiryDate.toISOString()} - ${waiver.AllowedReason}`
)
return false
}

console.error(
`Waiver for ${change.logicalId} (${change.resourceType}) expired on ${expiryDate.toISOString()}`
)
return true
})

if (remainingChanges.length === 0) {
console.log(`Change set ${changeSetName} for stack ${stackName} has no destructive changes that are not waived.`)
return
}

console.error("Resources that require attention:")
remainingChanges.forEach(({logicalId, physicalId, resourceType, reason}) => {
console.error(`- LogicalId: ${logicalId}, PhysicalId: ${physicalId}, Type: ${resourceType}, Reason: ${reason}`)
})
throw new Error(`Change set ${changeSetName} contains destructive changes`)
}
1 change: 1 addition & 0 deletions packages/cdkConstructs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from "./config/index.js"
export * from "./utils/helpers.js"
export * from "./stacks/deleteUnusedStacks.js"
export * from "./nag/pack/epsNagPack.js"
export * from "./changesets/checkDestructiveChanges"
Loading