Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ It exposes the following main entry points via [packages/deploymentUtils/src/ind
- `writeSchemas` – Writes JSON Schemas to disk, collapsing `examples` arrays into a single `example` value to be compatible with OAS.
- `deleteProxygenDeployments` – Removes Proxygen PTL instances that correspond to closed GitHub pull requests for a given API.
- Config helpers from `config/index` – used to resolve configuration and CloudFormation export values.
- `checkDestructiveChangeSet` – Describes a CloudFormation change set, filters out replacements and removals (optionally applying time-bound waivers) and throws if anything destructive remains.

`checkDestructiveChangeSet(changeSetName, stackName, region, allowedChanges?)` is useful in CI pipelines for blocking deployments that would recreate or delete infrastructure. The optional `allowedChanges` array lets you provide short-lived waivers, for example:

```ts
import {checkDestructiveChangeSet} from "@nhsdigital/eps-deployment-utils"

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.

Typical usage pattern (pseudo-code):

Expand Down
154 changes: 154 additions & 0 deletions packages/deploymentUtils/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.`)
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/deploymentUtils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./specifications/deployApi"
export * from "./specifications/writeSchemas"
export * from "./specifications/deleteProxygenDeployments"
export * from "./config/index"
export * from "./changesets/checkDestructiveChanges"
Loading