Skip to content

Commit da52491

Browse files
Added cleanup scripts for old stacks and proxygen instances
1 parent b957221 commit da52491

10 files changed

Lines changed: 1507 additions & 407 deletions

File tree

package-lock.json

Lines changed: 411 additions & 384 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cdkConstructs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"type": "module",
2222
"dependencies": {
2323
"@aws-sdk/client-cloudformation": "^3.975.0",
24+
"@aws-sdk/client-route-53": "^3.975.0",
2425
"@aws-sdk/client-s3": "^3.975.0",
2526
"aws-cdk": "^2.1102.0",
2627
"aws-cdk-lib": "^2.236.0",

packages/cdkConstructs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from "./constructs/PythonLambdaFunction.js"
44
export * from "./apps/createApp.js"
55
export * from "./config/index.js"
66
export * from "./utils/helpers.js"
7+
export * from "./stacks/deleteUnusedStacks.js"
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import {
2+
CloudFormationClient,
3+
DeleteStackCommand,
4+
ListStacksCommand,
5+
StackSummary
6+
} from "@aws-sdk/client-cloudformation"
7+
import {ChangeResourceRecordSetsCommand, ListHostedZonesByNameCommand, Route53Client} from "@aws-sdk/client-route-53"
8+
9+
const CNAME_HOSTED_ZONE_NAME = "dev.eps.national.nhs.uk."
10+
11+
/**
12+
* Deletes unused CloudFormation stacks and their associated Route 53 CNAME records.
13+
*
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).
17+
*
18+
* @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.
21+
* @returns A promise that resolves when all eligible stacks have been processed.
22+
*/
23+
export async function deleteUnusedStacks(baseStackName: string, repoName: string, basePath: string): Promise<void> {
24+
const cloudFormationClient = new CloudFormationClient({})
25+
const route53Client = new Route53Client({})
26+
const hostedZoneId = await getHostedZoneId(route53Client)
27+
const activeVersions = await getActiveVersions(basePath)
28+
29+
console.log("checking cloudformation stacks")
30+
31+
const allStacks = await listAllStacks(cloudFormationClient)
32+
const activeVersionDeployed = allStacks.find(stack => {
33+
const versionInfo = getVersion(stack.StackName!, baseStackName)
34+
if (!versionInfo) {
35+
return false
36+
}
37+
const {version, isSandbox} = versionInfo
38+
return !isSandbox && version === activeVersions.baseEnvVersion?.replaceAll(".", "-")
39+
})?.CreationTime
40+
const keepAllNonPRStacks = isEmbargoed(activeVersionDeployed)
41+
42+
for (const stack of allStacks) {
43+
if (stack.StackStatus === "DELETE_COMPLETE" || !stack.StackName) {
44+
continue
45+
}
46+
47+
const stackName = stack.StackName
48+
const deleteSuperseded = !keepAllNonPRStacks && isSupersededVersion(stack, baseStackName, activeVersions)
49+
if (!deleteSuperseded && !(await isClosedPullRequest(stackName, baseStackName, repoName))) {
50+
continue
51+
}
52+
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+
)
75+
76+
console.log(`CNAME record ${recordName} deleted`)
77+
}
78+
}
79+
80+
async function listAllStacks(cloudFormationClient: CloudFormationClient): Promise<Array<StackSummary>> {
81+
const stacks: Array<StackSummary> = []
82+
let nextToken: string | undefined
83+
84+
do {
85+
const response = await cloudFormationClient.send(new ListStacksCommand({NextToken: nextToken}))
86+
87+
if (response.StackSummaries) {
88+
stacks.push(...response.StackSummaries)
89+
}
90+
91+
nextToken = response.NextToken
92+
} while (nextToken)
93+
94+
return stacks
95+
}
96+
97+
async function getHostedZoneId(route53Client: Route53Client): Promise<string | undefined> {
98+
const response = await route53Client.send(
99+
new ListHostedZonesByNameCommand({
100+
DNSName: CNAME_HOSTED_ZONE_NAME
101+
})
102+
)
103+
104+
return response.HostedZones?.[0]?.Id
105+
}
106+
107+
async function isClosedPullRequest(stackName: string, baseStackName: string, repoName: string): Promise<boolean> {
108+
const match = new RegExp(String.raw`^${baseStackName}-pr-(?<pullRequestId>\d+)(-sandbox)?$`).exec(stackName)
109+
if (!match?.groups?.pullRequestId) {
110+
return false
111+
}
112+
113+
const pullRequestId = match.groups.pullRequestId
114+
console.log(`Checking pull request id ${pullRequestId}`)
115+
const url = `https://api.github.com/repos/NHSDigital/${repoName}/pulls/${pullRequestId}`
116+
117+
const headers: Record<string, string> = {
118+
Accept: "application/vnd.github+json",
119+
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
120+
}
121+
122+
const response = await fetch(url, {headers})
123+
if (!response.ok) {
124+
console.log(`Failed to fetch PR ${pullRequestId}: ${response.status} ${await response.text()}`)
125+
return false
126+
}
127+
128+
const data = (await response.json()) as {state?: string}
129+
if (data.state !== "closed") {
130+
console.log(`not going to delete stack ${stackName} as PR state is ${data.state}`)
131+
return false
132+
}
133+
134+
console.log(`** going to delete stack ${stackName} as PR state is ${data.state} **`)
135+
return true
136+
}
137+
138+
type ActiveVersions = {
139+
baseEnvVersion: string
140+
sandboxEnvVersion: string | null
141+
}
142+
143+
async function getActiveVersions(basePath: string): Promise<ActiveVersions> {
144+
let apigeeEnv = process.env.APIGEE_ENVIRONMENT!
145+
const baseEnvVersion = await getActiveVersion(apigeeEnv, basePath)
146+
let sandboxEnvVersion: string | null = null
147+
try {
148+
if (apigeeEnv === "int") {
149+
sandboxEnvVersion = await getActiveVersion("sandbox", basePath)
150+
} else if (apigeeEnv === "internal-dev") {
151+
sandboxEnvVersion = await getActiveVersion("internal-dev-sandbox", basePath)
152+
}
153+
} catch (error) {
154+
console.log(`Failed to get active version for sandbox environment: ${(error as Error).message}`)
155+
}
156+
return {baseEnvVersion, sandboxEnvVersion}
157+
}
158+
159+
async function getActiveVersion(apimDomain: string, basePath: string): Promise<string> {
160+
const headers: Record<string, string> = {
161+
Accept: "application/json",
162+
apikey: `${process.env.APIM_STATUS_API_KEY}`
163+
}
164+
const url = `https://${apimDomain}/${basePath}/_status`
165+
console.log(`Checking live api status endpoint at ${url} for active version`)
166+
const response = await fetch(url, {headers})
167+
if (!response.ok) {
168+
throw new Error(`Failed to fetch active version from ${url}: ${response.status} ${await response.text()}`)
169+
}
170+
171+
const data = (await response.json()) as {checks: {healthcheck: {outcome: {versionNumber: string}}}}
172+
return data.checks.healthcheck.outcome.versionNumber
173+
}
174+
175+
function getVersion(stackName: string, baseStackName: string): {version: string, isSandbox: boolean} | null {
176+
const pattern = String.raw`^${baseStackName}(?<sandbox>-sandbox)?-(?<version>v[\da-z-]+)?$`
177+
const match = new RegExp(pattern).exec(stackName)
178+
if (!match?.groups?.version) {
179+
return null
180+
}
181+
return {version: match.groups.version, isSandbox: match.groups.sandbox === "-sandbox"}
182+
}
183+
184+
function isEmbargoed(deployDate: Date | undefined): boolean {
185+
return !!deployDate && Date.now() - deployDate.getTime() < 24 * 60 * 60 * 1000
186+
}
187+
188+
function isSupersededVersion(
189+
stack: StackSummary,
190+
baseStackName: string,
191+
activeVersions: ActiveVersions
192+
): boolean {
193+
const versionInfo = getVersion(stack.StackName!, baseStackName)
194+
if (!versionInfo) {
195+
return false
196+
}
197+
if (isEmbargoed(stack.CreationTime)) {
198+
console.log(`Stack ${stack.StackName} created less than 24 hours ago, keeping for potential rollback`)
199+
return false
200+
}
201+
const {version, isSandbox} = versionInfo
202+
const currentVersion = isSandbox ? activeVersions.sandboxEnvVersion : activeVersions.baseEnvVersion
203+
if (!currentVersion) {
204+
return false
205+
}
206+
return version !== currentVersion.replaceAll(".", "-")
207+
}

0 commit comments

Comments
 (0)