-
Notifications
You must be signed in to change notification settings - Fork 1
New: [AEA-6028] - Added cleanup scripts for old stacks and proxygen instances #490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+1,839
−332
Merged
Changes from 2 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
da52491
Added cleanup scripts for old stacks and proxygen instances
MatthewPopat-NHS 6c0491e
Merge branch 'main' into AEA-6028
MatthewPopat-NHS 0c37297
Separated main stack cleanup from PR cleanup
MatthewPopat-NHS b4f58b8
Merge branch 'AEA-6028' of https://github.com/NHSDigital/eps-cdk-util…
MatthewPopat-NHS 8edef57
Merge branch 'main' into AEA-6028
MatthewPopat-NHS 9a3c0b5
Fixed cleanup of CNAMEs in deleteUnusedStacks
MatthewPopat-NHS 8652203
Updated readme and JSDOCs
MatthewPopat-NHS File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
207 changes: 207 additions & 0 deletions
207
packages/cdkConstructs/src/stacks/deleteUnusedStacks.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| import { | ||
| CloudFormationClient, | ||
| DeleteStackCommand, | ||
| ListStacksCommand, | ||
| StackSummary | ||
| } from "@aws-sdk/client-cloudformation" | ||
| import {ChangeResourceRecordSetsCommand, ListHostedZonesByNameCommand, Route53Client} from "@aws-sdk/client-route-53" | ||
|
|
||
| const CNAME_HOSTED_ZONE_NAME = "dev.eps.national.nhs.uk." | ||
|
|
||
| /** | ||
| * Deletes unused CloudFormation stacks and their associated Route 53 CNAME records. | ||
| * | ||
| * A stack is considered unused if: | ||
| * - it represents a pull request deployment whose PR has been closed; or | ||
| * - it is a superseded version of the base stack (and is not within the 24‑hour embargo window). | ||
| * | ||
| * @param baseStackName - Base name/prefix of the CloudFormation stacks to evaluate. | ||
| * @param repoName - GitHub repository name used to look up pull request state. | ||
| * @param basePath - Base path of the API used to determine the currently active version. | ||
| * @returns A promise that resolves when all eligible stacks have been processed. | ||
| */ | ||
| export async function deleteUnusedStacks(baseStackName: string, repoName: string, basePath: string): Promise<void> { | ||
| const cloudFormationClient = new CloudFormationClient({}) | ||
| const route53Client = new Route53Client({}) | ||
| const hostedZoneId = await getHostedZoneId(route53Client) | ||
|
MatthewPopat-NHS marked this conversation as resolved.
Outdated
|
||
| const activeVersions = await getActiveVersions(basePath) | ||
|
|
||
| console.log("checking cloudformation stacks") | ||
|
|
||
| const allStacks = await listAllStacks(cloudFormationClient) | ||
| const activeVersionDeployed = allStacks.find(stack => { | ||
| const versionInfo = getVersion(stack.StackName!, baseStackName) | ||
| if (!versionInfo) { | ||
| return false | ||
| } | ||
| const {version, isSandbox} = versionInfo | ||
| return !isSandbox && version === activeVersions.baseEnvVersion?.replaceAll(".", "-") | ||
| })?.CreationTime | ||
| const keepAllNonPRStacks = isEmbargoed(activeVersionDeployed) | ||
|
|
||
| for (const stack of allStacks) { | ||
| if (stack.StackStatus === "DELETE_COMPLETE" || !stack.StackName) { | ||
| continue | ||
| } | ||
|
|
||
| const stackName = stack.StackName | ||
| const deleteSuperseded = !keepAllNonPRStacks && isSupersededVersion(stack, baseStackName, activeVersions) | ||
| if (!deleteSuperseded && !(await isClosedPullRequest(stackName, baseStackName, repoName))) { | ||
| continue | ||
| } | ||
|
|
||
| await cloudFormationClient.send(new DeleteStackCommand({StackName: stackName})) | ||
| console.log("** Sleeping for 60 seconds to avoid 429 on delete stack **") | ||
| await new Promise((resolve) => setTimeout(resolve, 60_000)) | ||
|
|
||
| const recordName = `${stackName}.${CNAME_HOSTED_ZONE_NAME}` | ||
| console.log(`** going to delete CNAME record ${recordName} **`) | ||
| await route53Client.send( | ||
| new ChangeResourceRecordSetsCommand({ | ||
| HostedZoneId: hostedZoneId, | ||
| ChangeBatch: { | ||
| Changes: [ | ||
| { | ||
| Action: "DELETE", | ||
| ResourceRecordSet: { | ||
| Name: recordName, | ||
| Type: "CNAME" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }) | ||
| ) | ||
|
|
||
| console.log(`CNAME record ${recordName} deleted`) | ||
| } | ||
| } | ||
|
|
||
| async function listAllStacks(cloudFormationClient: CloudFormationClient): Promise<Array<StackSummary>> { | ||
| const stacks: Array<StackSummary> = [] | ||
| let nextToken: string | undefined | ||
|
|
||
| do { | ||
| const response = await cloudFormationClient.send(new ListStacksCommand({NextToken: nextToken})) | ||
|
|
||
| if (response.StackSummaries) { | ||
| stacks.push(...response.StackSummaries) | ||
| } | ||
|
|
||
| nextToken = response.NextToken | ||
| } while (nextToken) | ||
|
|
||
| return stacks | ||
| } | ||
|
|
||
| async function getHostedZoneId(route53Client: Route53Client): Promise<string | undefined> { | ||
| const response = await route53Client.send( | ||
| new ListHostedZonesByNameCommand({ | ||
| DNSName: CNAME_HOSTED_ZONE_NAME | ||
| }) | ||
| ) | ||
|
|
||
| return response.HostedZones?.[0]?.Id | ||
| } | ||
|
|
||
| async function isClosedPullRequest(stackName: string, baseStackName: string, repoName: string): Promise<boolean> { | ||
| const match = new RegExp(String.raw`^${baseStackName}-pr-(?<pullRequestId>\d+)(-sandbox)?$`).exec(stackName) | ||
| if (!match?.groups?.pullRequestId) { | ||
| return false | ||
| } | ||
|
|
||
| const pullRequestId = match.groups.pullRequestId | ||
| console.log(`Checking pull request id ${pullRequestId}`) | ||
| const url = `https://api.github.com/repos/NHSDigital/${repoName}/pulls/${pullRequestId}` | ||
|
|
||
| const headers: Record<string, string> = { | ||
| Accept: "application/vnd.github+json", | ||
| Authorization: `Bearer ${process.env.GITHUB_TOKEN}` | ||
| } | ||
|
|
||
| const response = await fetch(url, {headers}) | ||
| if (!response.ok) { | ||
| console.log(`Failed to fetch PR ${pullRequestId}: ${response.status} ${await response.text()}`) | ||
| return false | ||
| } | ||
|
|
||
| const data = (await response.json()) as {state?: string} | ||
| if (data.state !== "closed") { | ||
| console.log(`not going to delete stack ${stackName} as PR state is ${data.state}`) | ||
| return false | ||
| } | ||
|
|
||
| console.log(`** going to delete stack ${stackName} as PR state is ${data.state} **`) | ||
| return true | ||
| } | ||
|
|
||
| type ActiveVersions = { | ||
| baseEnvVersion: string | ||
| sandboxEnvVersion: string | null | ||
| } | ||
|
|
||
| async function getActiveVersions(basePath: string): Promise<ActiveVersions> { | ||
| let apigeeEnv = process.env.APIGEE_ENVIRONMENT! | ||
| const baseEnvVersion = await getActiveVersion(apigeeEnv, basePath) | ||
| let sandboxEnvVersion: string | null = null | ||
| try { | ||
| if (apigeeEnv === "int") { | ||
| sandboxEnvVersion = await getActiveVersion("sandbox", basePath) | ||
| } else if (apigeeEnv === "internal-dev") { | ||
| sandboxEnvVersion = await getActiveVersion("internal-dev-sandbox", basePath) | ||
| } | ||
| } catch (error) { | ||
| console.log(`Failed to get active version for sandbox environment: ${(error as Error).message}`) | ||
| } | ||
| return {baseEnvVersion, sandboxEnvVersion} | ||
| } | ||
|
|
||
| async function getActiveVersion(apimDomain: string, basePath: string): Promise<string> { | ||
| const headers: Record<string, string> = { | ||
| Accept: "application/json", | ||
| apikey: `${process.env.APIM_STATUS_API_KEY}` | ||
| } | ||
| const url = `https://${apimDomain}/${basePath}/_status` | ||
| console.log(`Checking live api status endpoint at ${url} for active version`) | ||
| const response = await fetch(url, {headers}) | ||
| if (!response.ok) { | ||
| throw new Error(`Failed to fetch active version from ${url}: ${response.status} ${await response.text()}`) | ||
| } | ||
|
|
||
| const data = (await response.json()) as {checks: {healthcheck: {outcome: {versionNumber: string}}}} | ||
| return data.checks.healthcheck.outcome.versionNumber | ||
| } | ||
|
|
||
| function getVersion(stackName: string, baseStackName: string): {version: string, isSandbox: boolean} | null { | ||
| const pattern = String.raw`^${baseStackName}(?<sandbox>-sandbox)?-(?<version>v[\da-z-]+)?$` | ||
|
MatthewPopat-NHS marked this conversation as resolved.
Outdated
|
||
| const match = new RegExp(pattern).exec(stackName) | ||
| if (!match?.groups?.version) { | ||
| return null | ||
| } | ||
| return {version: match.groups.version, isSandbox: match.groups.sandbox === "-sandbox"} | ||
| } | ||
|
|
||
| function isEmbargoed(deployDate: Date | undefined): boolean { | ||
| return !!deployDate && Date.now() - deployDate.getTime() < 24 * 60 * 60 * 1000 | ||
| } | ||
|
|
||
| function isSupersededVersion( | ||
| stack: StackSummary, | ||
| baseStackName: string, | ||
| activeVersions: ActiveVersions | ||
| ): boolean { | ||
| const versionInfo = getVersion(stack.StackName!, baseStackName) | ||
| if (!versionInfo) { | ||
| return false | ||
| } | ||
| if (isEmbargoed(stack.CreationTime)) { | ||
| console.log(`Stack ${stack.StackName} created less than 24 hours ago, keeping for potential rollback`) | ||
| return false | ||
| } | ||
| const {version, isSandbox} = versionInfo | ||
| const currentVersion = isSandbox ? activeVersions.sandboxEnvVersion : activeVersions.baseEnvVersion | ||
| if (!currentVersion) { | ||
| return false | ||
| } | ||
| return version !== currentVersion.replaceAll(".", "-") | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.