Skip to content

Commit 0c37297

Browse files
Separated main stack cleanup from PR cleanup
1 parent da52491 commit 0c37297

2 files changed

Lines changed: 551 additions & 442 deletions

File tree

packages/cdkConstructs/src/stacks/deleteUnusedStacks.ts

Lines changed: 97 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,26 @@ import {
66
} from "@aws-sdk/client-cloudformation"
77
import {ChangeResourceRecordSetsCommand, ListHostedZonesByNameCommand, Route53Client} from "@aws-sdk/client-route-53"
88

9-
const CNAME_HOSTED_ZONE_NAME = "dev.eps.national.nhs.uk."
10-
119
/**
1210
* Deletes unused CloudFormation stacks and their associated Route 53 CNAME records.
1311
*
14-
* A stack is considered unused if:
15-
* - it represents a pull request deployment whose PR has been closed; or
16-
* - it is a superseded version of the base stack (and is not within the 24‑hour embargo window).
12+
* A stack is considered unused if it is a superseded version of the base stack
13+
* (and is not within the 24‑hour embargo window).
1714
*
1815
* @param baseStackName - Base name/prefix of the CloudFormation stacks to evaluate.
19-
* @param repoName - GitHub repository name used to look up pull request state.
20-
* @param basePath - Base path of the API used to determine the currently active version.
16+
* @param hostedZoneName - Hosted zone name used to look up Route 53 records.
17+
* @param getActiveVersions - Function to get the currently active versions.
2118
* @returns A promise that resolves when all eligible stacks have been processed.
2219
*/
23-
export async function deleteUnusedStacks(baseStackName: string, repoName: string, basePath: string): Promise<void> {
20+
export async function deleteUnusedMainStacks(
21+
baseStackName: string,
22+
hostedZoneName: string,
23+
getActiveVersions: () => Promise<ActiveVersions>
24+
): Promise<void> {
2425
const cloudFormationClient = new CloudFormationClient({})
2526
const route53Client = new Route53Client({})
26-
const hostedZoneId = await getHostedZoneId(route53Client)
27-
const activeVersions = await getActiveVersions(basePath)
28-
27+
const hostedZoneId = await getHostedZoneId(route53Client, hostedZoneName)
28+
const activeVersions = await getActiveVersions()
2929
console.log("checking cloudformation stacks")
3030

3131
const allStacks = await listAllStacks(cloudFormationClient)
@@ -37,46 +37,96 @@ export async function deleteUnusedStacks(baseStackName: string, repoName: string
3737
const {version, isSandbox} = versionInfo
3838
return !isSandbox && version === activeVersions.baseEnvVersion?.replaceAll(".", "-")
3939
})?.CreationTime
40-
const keepAllNonPRStacks = isEmbargoed(activeVersionDeployed)
40+
if (isEmbargoed(activeVersionDeployed)) {
41+
console.log(
42+
`Active version ${activeVersions.baseEnvVersion} deployed less than 24 hours ago,` +
43+
"skipping deletion of superseded stacks")
44+
return
45+
}
4146

4247
for (const stack of allStacks) {
4348
if (stack.StackStatus === "DELETE_COMPLETE" || !stack.StackName) {
4449
continue
4550
}
4651

4752
const stackName = stack.StackName
48-
const deleteSuperseded = !keepAllNonPRStacks && isSupersededVersion(stack, baseStackName, activeVersions)
49-
if (!deleteSuperseded && !(await isClosedPullRequest(stackName, baseStackName, repoName))) {
53+
if (!isSupersededVersion(stack, baseStackName, activeVersions)) {
5054
continue
5155
}
5256

53-
await cloudFormationClient.send(new DeleteStackCommand({StackName: stackName}))
54-
console.log("** Sleeping for 60 seconds to avoid 429 on delete stack **")
55-
await new Promise((resolve) => setTimeout(resolve, 60_000))
56-
57-
const recordName = `${stackName}.${CNAME_HOSTED_ZONE_NAME}`
58-
console.log(`** going to delete CNAME record ${recordName} **`)
59-
await route53Client.send(
60-
new ChangeResourceRecordSetsCommand({
61-
HostedZoneId: hostedZoneId,
62-
ChangeBatch: {
63-
Changes: [
64-
{
65-
Action: "DELETE",
66-
ResourceRecordSet: {
67-
Name: recordName,
68-
Type: "CNAME"
69-
}
70-
}
71-
]
72-
}
73-
})
74-
)
57+
await deleteStack(cloudFormationClient, route53Client, hostedZoneId, hostedZoneName, stackName)
58+
}
59+
}
60+
61+
/**
62+
* Deletes unused CloudFormation stacks and their associated Route 53 CNAME records.
63+
*
64+
* A stack is considered unused if it represents a pull request deployment whose PR has been closed.
65+
*
66+
* @param baseStackName - Base name/prefix of the CloudFormation stacks to evaluate.
67+
* @param hostedZoneName - Hosted zone name used to look up Route 53 records.
68+
* @param repoName - GitHub repository name used to look up pull request state.
69+
* @returns A promise that resolves when all eligible stacks have been processed.
70+
*/
71+
export async function deleteUnusedPrStacks(
72+
baseStackName: string,
73+
hostedZoneName: string,
74+
repoName: string): Promise<void> {
75+
const cloudFormationClient = new CloudFormationClient({})
76+
const route53Client = new Route53Client({})
77+
const hostedZoneId = await getHostedZoneId(route53Client, hostedZoneName)
7578

76-
console.log(`CNAME record ${recordName} deleted`)
79+
console.log("checking cloudformation stacks")
80+
81+
const allStacks = await listAllStacks(cloudFormationClient)
82+
83+
for (const stack of allStacks) {
84+
if (stack.StackStatus === "DELETE_COMPLETE" || !stack.StackName) {
85+
continue
86+
}
87+
88+
const stackName = stack.StackName
89+
if (!(await isClosedPullRequest(stackName, baseStackName, repoName))) {
90+
continue
91+
}
92+
93+
await deleteStack(cloudFormationClient, route53Client, hostedZoneId, hostedZoneName, stackName)
7794
}
7895
}
7996

97+
async function deleteStack(
98+
cloudFormationClient: CloudFormationClient,
99+
route53Client: Route53Client,
100+
hostedZoneId: string | undefined,
101+
hostedZoneName: string,
102+
stackName: string
103+
): Promise<void> {
104+
await cloudFormationClient.send(new DeleteStackCommand({StackName: stackName}))
105+
console.log("** Sleeping for 60 seconds to avoid 429 on delete stack **")
106+
await new Promise((resolve) => setTimeout(resolve, 60_000))
107+
108+
const recordName = `${stackName}.${hostedZoneName}`
109+
console.log(`** going to delete CNAME record ${recordName} **`)
110+
await route53Client.send(
111+
new ChangeResourceRecordSetsCommand({
112+
HostedZoneId: hostedZoneId,
113+
ChangeBatch: {
114+
Changes: [
115+
{
116+
Action: "DELETE",
117+
ResourceRecordSet: {
118+
Name: recordName,
119+
Type: "CNAME"
120+
}
121+
}
122+
]
123+
}
124+
})
125+
)
126+
127+
console.log(`CNAME record ${recordName} deleted`)
128+
}
129+
80130
async function listAllStacks(cloudFormationClient: CloudFormationClient): Promise<Array<StackSummary>> {
81131
const stacks: Array<StackSummary> = []
82132
let nextToken: string | undefined
@@ -94,10 +144,10 @@ async function listAllStacks(cloudFormationClient: CloudFormationClient): Promis
94144
return stacks
95145
}
96146

97-
async function getHostedZoneId(route53Client: Route53Client): Promise<string | undefined> {
147+
async function getHostedZoneId(route53Client: Route53Client, hostedZoneName: string): Promise<string | undefined> {
98148
const response = await route53Client.send(
99149
new ListHostedZonesByNameCommand({
100-
DNSName: CNAME_HOSTED_ZONE_NAME
150+
DNSName: hostedZoneName
101151
})
102152
)
103153

@@ -135,28 +185,28 @@ async function isClosedPullRequest(stackName: string, baseStackName: string, rep
135185
return true
136186
}
137187

138-
type ActiveVersions = {
188+
export type ActiveVersions = {
139189
baseEnvVersion: string
140190
sandboxEnvVersion: string | null
141191
}
142192

143-
async function getActiveVersions(basePath: string): Promise<ActiveVersions> {
193+
export async function getActiveApiVersions(basePath: string): Promise<ActiveVersions> {
144194
let apigeeEnv = process.env.APIGEE_ENVIRONMENT!
145-
const baseEnvVersion = await getActiveVersion(apigeeEnv, basePath)
195+
const baseEnvVersion = await getActiveApiVersion(apigeeEnv, basePath)
146196
let sandboxEnvVersion: string | null = null
147197
try {
148198
if (apigeeEnv === "int") {
149-
sandboxEnvVersion = await getActiveVersion("sandbox", basePath)
199+
sandboxEnvVersion = await getActiveApiVersion("sandbox", basePath)
150200
} else if (apigeeEnv === "internal-dev") {
151-
sandboxEnvVersion = await getActiveVersion("internal-dev-sandbox", basePath)
201+
sandboxEnvVersion = await getActiveApiVersion("internal-dev-sandbox", basePath)
152202
}
153203
} catch (error) {
154204
console.log(`Failed to get active version for sandbox environment: ${(error as Error).message}`)
155205
}
156206
return {baseEnvVersion, sandboxEnvVersion}
157207
}
158208

159-
async function getActiveVersion(apimDomain: string, basePath: string): Promise<string> {
209+
async function getActiveApiVersion(apimDomain: string, basePath: string): Promise<string> {
160210
const headers: Record<string, string> = {
161211
Accept: "application/json",
162212
apikey: `${process.env.APIM_STATUS_API_KEY}`
@@ -173,9 +223,9 @@ async function getActiveVersion(apimDomain: string, basePath: string): Promise<s
173223
}
174224

175225
function getVersion(stackName: string, baseStackName: string): {version: string, isSandbox: boolean} | null {
176-
const pattern = String.raw`^${baseStackName}(?<sandbox>-sandbox)?-(?<version>v[\da-z-]+)?$`
226+
const pattern = String.raw`^${baseStackName}(?<sandbox>-sandbox)?-(?<version>[\da-z-]+)?$`
177227
const match = new RegExp(pattern).exec(stackName)
178-
if (!match?.groups?.version) {
228+
if (!match?.groups?.version || match.groups.version.startsWith("pr-")) {
179229
return null
180230
}
181231
return {version: match.groups.version, isSandbox: match.groups.sandbox === "-sandbox"}

0 commit comments

Comments
 (0)