packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/stack-refresh.ts (119 lines of code) (raw):

import type { ParameterDeclaration } from '@aws-sdk/client-cloudformation'; import type { ICloudFormationClient } from '../aws-auth/private'; import { IO, type IoHelper } from '../io/private'; import { ToolkitError } from '../toolkit-error'; export class ActiveAssetCache { private readonly stacks: Set<string> = new Set(); public rememberStack(stackTemplate: string) { this.stacks.add(stackTemplate); } public contains(asset: string): boolean { for (const stack of this.stacks) { if (stack.includes(asset)) { return true; } } return false; } } async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | undefined>) { let finished = false; let nextToken: string | undefined; while (!finished) { nextToken = await cb(nextToken); if (nextToken === undefined) { finished = true; } } } /** * Fetches all relevant stack templates from CloudFormation. It ignores the following stacks: * - stacks in DELETE_COMPLETE or DELETE_IN_PROGRESS stage * - stacks that are using a different bootstrap qualifier */ async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHelper, qualifier?: string) { const stackNames: string[] = []; await paginateSdkCall(async (nextToken) => { const stacks = await cfn.listStacks({ NextToken: nextToken }); // We ignore stacks with these statuses because their assets are no longer live const ignoredStatues = ['CREATE_FAILED', 'DELETE_COMPLETE', 'DELETE_IN_PROGRESS', 'DELETE_FAILED', 'REVIEW_IN_PROGRESS']; stackNames.push( ...(stacks.StackSummaries ?? []) .filter((s: any) => !ignoredStatues.includes(s.StackStatus)) .map((s: any) => s.StackId ?? s.StackName), ); return stacks.NextToken; }); await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Parsing through ${stackNames.length} stacks`)); const templates: string[] = []; for (const stack of stackNames) { let summary; summary = await cfn.getTemplateSummary({ StackName: stack, }); if (bootstrapFilter(summary.Parameters, qualifier)) { // This stack is definitely bootstrapped to a different qualifier so we can safely ignore it continue; } else { const template = await cfn.getTemplate({ StackName: stack, }); templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters)); } } await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg('Done parsing through stacks')); return templates; } /** * Filter out stacks that we KNOW are using a different bootstrap qualifier * This is mostly necessary for the integration tests that can run the same app (with the same assets) * under different qualifiers. * This is necessary because a stack under a different bootstrap could coincidentally reference the same hash * and cause a false negative (cause an asset to be preserved when its isolated) * This is intentionally done in a way where we ONLY filter out stacks that are meant for a different qualifier * because we are okay with false positives. */ function bootstrapFilter(parameters?: ParameterDeclaration[], qualifier?: string) { const bootstrapVersion = parameters?.find((p) => p.ParameterKey === 'BootstrapVersion'); const splitBootstrapVersion = bootstrapVersion?.DefaultValue?.split('/'); // We find the qualifier in a specific part of the bootstrap version parameter return (qualifier && splitBootstrapVersion && splitBootstrapVersion.length == 4 && splitBootstrapVersion[2] != qualifier); } export async function refreshStacks(cfn: ICloudFormationClient, ioHelper: IoHelper, activeAssets: ActiveAssetCache, qualifier?: string) { try { const stacks = await fetchAllStackTemplates(cfn, ioHelper, qualifier); for (const stack of stacks) { activeAssets.rememberStack(stack); } } catch (err) { throw new ToolkitError(`Error refreshing stacks: ${err}`); } } /** * Background Stack Refresh properties */ export interface BackgroundStackRefreshProps { /** * The CFN SDK handler */ readonly cfn: ICloudFormationClient; /** * Used to send messages. */ readonly ioHelper: IoHelper; /** * Active Asset storage */ readonly activeAssets: ActiveAssetCache; /** * Stack bootstrap qualifier */ readonly qualifier?: string; } /** * Class that controls scheduling of the background stack refresh */ export class BackgroundStackRefresh { private timeout?: NodeJS.Timeout; private lastRefreshTime: number; private queuedPromises: Array<(value: unknown) => void> = []; constructor(private readonly props: BackgroundStackRefreshProps) { this.lastRefreshTime = Date.now(); } public start() { // Since start is going to be called right after the first invocation of refreshStacks, // lets wait some time before beginning the background refresh. this.timeout = setTimeout(() => this.refresh(), 300_000); // 5 minutes } private async refresh() { const startTime = Date.now(); await refreshStacks(this.props.cfn, this.props.ioHelper, this.props.activeAssets, this.props.qualifier); this.justRefreshedStacks(); // If the last invocation of refreshStacks takes <5 minutes, the next invocation starts 5 minutes after the last one started. // If the last invocation of refreshStacks takes >5 minutes, the next invocation starts immediately. this.timeout = setTimeout(() => this.refresh(), Math.max(startTime + 300_000 - Date.now(), 0)); } private justRefreshedStacks() { this.lastRefreshTime = Date.now(); for (const p of this.queuedPromises.splice(0, this.queuedPromises.length)) { p(undefined); } } /** * Checks if the last successful background refresh happened within the specified time frame. * If the last refresh is older than the specified time frame, it returns a Promise that resolves * when the next background refresh completes or rejects if the refresh takes too long. */ public noOlderThan(ms: number) { const horizon = Date.now() - ms; // The last refresh happened within the time frame if (this.lastRefreshTime >= horizon) { return Promise.resolve(); } // The last refresh happened earlier than the time frame // We will wait for the latest refresh to land or reject if it takes too long return Promise.race([ new Promise(resolve => this.queuedPromises.push(resolve)), new Promise((_, reject) => setTimeout(() => reject(new ToolkitError('refreshStacks took too long; the background thread likely threw an error')), ms)), ]); } public stop() { clearTimeout(this.timeout); } }