packages/@aws-cdk/toolkit-lib/lib/api/stack-events/stack-activity-monitor.ts (153 lines of code) (raw):

import * as util from 'util'; import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; import * as uuid from 'uuid'; import { StackEventPoller } from './stack-event-poller'; import { StackProgressMonitor } from './stack-progress-monitor'; import type { StackActivity } from '../../payloads/stack-activity'; import { stackEventHasErrorMessage } from '../../util'; import type { ICloudFormationClient } from '../aws-auth/private'; import { IO, type IoHelper } from '../io/private'; import { resourceMetadata } from '../resource-metadata/resource-metadata'; export interface StackActivityMonitorProps { /** * The CloudFormation client */ readonly cfn: ICloudFormationClient; /** * The IoHelper used for messaging */ readonly ioHelper: IoHelper; /** * The stack artifact that is getting deployed */ readonly stack: CloudFormationStackArtifact; /** * The name of the Stack that is getting deployed */ readonly stackName: string; /** * Total number of resources to update * * Used to calculate a progress bar. * * @default - No progress reporting. */ readonly resourcesTotal?: number; /** * Creation time of the change set * * This will be used to filter events, only showing those from after the change * set creation time. * * It is recommended to use this, otherwise the filtering will be subject * to clock drift between local and cloud machines. * * @default - local machine's current time */ readonly changeSetCreationTime?: Date; /** * Time to wait between fetching new activities. * * Must wait a reasonable amount of time between polls, since we need to consider CloudFormation API limits * * @default 2_000 */ readonly pollingInterval?: number; } export class StackActivityMonitor { /** * The poller used to read stack events */ private readonly poller: StackEventPoller; /** * Fetch new activity every 1 second * Printers can decide to update a view less frequently if desired */ private readonly pollingInterval: number; public readonly errors: string[] = []; private monitorId?: string; private readonly progressMonitor: StackProgressMonitor; /** * Current tick timer */ private tickTimer?: ReturnType<typeof setTimeout>; /** * Set to the activity of reading the current events */ private readPromise?: Promise<any>; private readonly ioHelper: IoHelper; private readonly stackName: string; private readonly stack: CloudFormationStackArtifact; constructor({ cfn, ioHelper, stack, stackName, resourcesTotal, changeSetCreationTime, pollingInterval = 2_000, }: StackActivityMonitorProps) { this.ioHelper = ioHelper; this.stack = stack; this.stackName = stackName; this.progressMonitor = new StackProgressMonitor(resourcesTotal); this.pollingInterval = pollingInterval; this.poller = new StackEventPoller(cfn, { stackName, startTime: changeSetCreationTime?.getTime() ?? Date.now(), }); } public async start() { this.monitorId = uuid.v4(); await this.ioHelper.notify(IO.CDK_TOOLKIT_I5501.msg(`Deploying ${this.stackName}`, { deployment: this.monitorId, stack: this.stack, stackName: this.stackName, resourcesTotal: this.progressMonitor.total, })); this.scheduleNextTick(); return this; } public async stop() { const oldMonitorId = this.monitorId!; this.monitorId = undefined; if (this.tickTimer) { clearTimeout(this.tickTimer); } // Do a final poll for all events. This is to handle the situation where DescribeStackStatus // already returned an error, but the monitor hasn't seen all the events yet and we'd end // up not printing the failure reason to users. await this.finalPollToEnd(oldMonitorId); await this.ioHelper.notify(IO.CDK_TOOLKIT_I5503.msg(`Completed ${this.stackName}`, { deployment: oldMonitorId, stack: this.stack, stackName: this.stackName, resourcesTotal: this.progressMonitor.total, })); } private scheduleNextTick() { if (!this.monitorId) { return; } this.tickTimer = setTimeout(() => void this.tick(), this.pollingInterval); } private async tick() { if (!this.monitorId) { return; } try { this.readPromise = this.readNewEvents(this.monitorId); await this.readPromise; this.readPromise = undefined; // We might have been stop()ped while the network call was in progress. if (!this.monitorId) { return; } } catch (e) { await this.ioHelper.notify(IO.CDK_TOOLKIT_E5500.msg( util.format('Error occurred while monitoring stack: %s', e), { error: e as any }, )); } this.scheduleNextTick(); } private findMetadataFor(logicalId: string | undefined) { const metadata = this.stack.manifest?.metadata; if (!logicalId || !metadata) { return undefined; } return resourceMetadata(this.stack, logicalId); } /** * Reads all new events from the stack history * * The events are returned in reverse chronological order; we continue to the next page if we * see a next page and the last event in the page is new to us (and within the time window). * haven't seen the final event */ private async readNewEvents(monitorId: string): Promise<void> { const pollEvents = await this.poller.poll(); for (const resourceEvent of pollEvents) { this.progressMonitor.process(resourceEvent.event); const activity: StackActivity = { deployment: monitorId, event: resourceEvent.event, metadata: this.findMetadataFor(resourceEvent.event.LogicalResourceId), progress: this.progressMonitor.progress, }; this.checkForErrors(activity); await this.ioHelper.notify(IO.CDK_TOOLKIT_I5502.msg(this.formatActivity(activity, true), activity)); } } /** * Perform a final poll to the end and flush out all events to the printer * * Finish any poll currently in progress, then do a final one until we've * reached the last page. */ private async finalPollToEnd(monitorId: string) { // If we were doing a poll, finish that first. It was started before // the moment we were sure we weren't going to get any new events anymore // so we need to do a new one anyway. Need to wait for this one though // because our state is single-threaded. if (this.readPromise) { await this.readPromise; } await this.readNewEvents(monitorId); } /** * Formats a stack activity into a basic string */ private formatActivity(activity: StackActivity, progress: boolean): string { const event = activity.event; const metadata = activity.metadata; const resourceName = metadata ? metadata.constructPath : event.LogicalResourceId || ''; const logicalId = resourceName !== event.LogicalResourceId ? `(${event.LogicalResourceId}) ` : ''; return util.format( '%s | %s%s | %s | %s | %s %s%s%s', event.StackName, progress !== false ? `${activity.progress.formatted} | ` : '', new Date(event.Timestamp!).toLocaleTimeString(), event.ResourceStatus || '', event.ResourceType, resourceName, logicalId, event.ResourceStatusReason ? event.ResourceStatusReason : '', metadata?.entry.trace ? `\n\t${metadata.entry.trace.join('\n\t\\_ ')}` : '', ); } private checkForErrors(activity: StackActivity) { if (stackEventHasErrorMessage(activity.event.ResourceStatus ?? '')) { const isCancelled = (activity.event.ResourceStatusReason ?? '').indexOf('cancelled') > -1; // Cancelled is not an interesting failure reason, nor is the stack message (stack // message will just say something like "stack failed to update") if (!isCancelled && activity.event.StackName !== activity.event.LogicalResourceId) { this.errors.push(activity.event.ResourceStatusReason ?? ''); } } } }