@@ -6,26 +6,26 @@ import {
66} from "@aws-sdk/client-cloudformation"
77import { 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+
80130async 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
175225function 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