|
| 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