packages/@aws-cdk/toolkit-lib/lib/api/stack-events/stack-event-poller.ts (114 lines of code) (raw):
import type { StackEvent } from '@aws-sdk/client-cloudformation';
import { formatErrorMessage } from '../../util';
import type { ICloudFormationClient } from '../aws-auth/private';
export interface StackEventPollerProps {
/**
* The stack to poll
*/
readonly stackName: string;
/**
* IDs of parent stacks of this resource, in case of resources in nested stacks
*/
readonly parentStackLogicalIds?: string[];
/**
* Timestamp for the oldest event we're interested in
*
* @default - Read all events
*/
readonly startTime?: number;
/**
* Stop reading when we see the stack entering this status
*
* Should be something like `CREATE_IN_PROGRESS`, `UPDATE_IN_PROGRESS`,
* `DELETE_IN_PROGRESS, `ROLLBACK_IN_PROGRESS`.
*
* @default - Read all events
*/
readonly stackStatuses?: string[];
}
export interface ResourceEvent {
/**
* The Stack Event as received from CloudFormation
*/
readonly event: StackEvent;
/**
* IDs of parent stacks of the resource, in case of resources in nested stacks
*/
readonly parentStackLogicalIds: string[];
/**
* Whether this event regards the root stack
*
* @default false
*/
readonly isStackEvent?: boolean;
}
export class StackEventPoller {
public readonly events: ResourceEvent[] = [];
public complete: boolean = false;
private readonly eventIds = new Set<string>();
private readonly nestedStackPollers: Record<string, StackEventPoller> = {};
constructor(
private readonly cfn: ICloudFormationClient,
private readonly props: StackEventPollerProps,
) {
}
/**
* From all accumulated events, return only the errors
*/
public get resourceErrors(): ResourceEvent[] {
return this.events.filter((e) => e.event.ResourceStatus?.endsWith('_FAILED') && !e.isStackEvent);
}
/**
* Poll for new stack events
*
* Will not return events older than events indicated by the constructor filters.
*
* Recurses into nested stacks, and returns events old-to-new.
*/
public async poll(): Promise<ResourceEvent[]> {
const events: ResourceEvent[] = await this.doPoll();
// Also poll all nested stacks we're currently tracking
for (const [logicalId, poller] of Object.entries(this.nestedStackPollers)) {
events.push(...(await poller.poll()));
if (poller.complete) {
delete this.nestedStackPollers[logicalId];
}
}
// Return what we have so far
events.sort((a, b) => a.event.Timestamp!.valueOf() - b.event.Timestamp!.valueOf());
this.events.push(...events);
return events;
}
private async doPoll(): Promise<ResourceEvent[]> {
const events: ResourceEvent[] = [];
try {
let nextToken: string | undefined;
let finished = false;
while (!finished) {
const page = await this.cfn.describeStackEvents({ StackName: this.props.stackName, NextToken: nextToken });
for (const event of page?.StackEvents ?? []) {
// Event from before we were interested in 'em
if (this.props.startTime !== undefined && event.Timestamp!.valueOf() < this.props.startTime) {
return events;
}
// Already seen this one
if (this.eventIds.has(event.EventId!)) {
return events;
}
this.eventIds.add(event.EventId!);
// The events for the stack itself are also included next to events about resources; we can test for them in this way.
const isParentStackEvent = event.PhysicalResourceId === event.StackId;
if (isParentStackEvent && this.props.stackStatuses?.includes(event.ResourceStatus ?? '')) {
return events;
}
// Fresh event
const resEvent: ResourceEvent = {
event: event,
parentStackLogicalIds: this.props.parentStackLogicalIds ?? [],
isStackEvent: isParentStackEvent,
};
events.push(resEvent);
if (
!isParentStackEvent &&
event.ResourceType === 'AWS::CloudFormation::Stack' &&
isStackBeginOperationState(event.ResourceStatus)
) {
// If the event is not for `this` stack and has a physical resource Id, recursively call for events in the nested stack
this.trackNestedStack(event, [...(this.props.parentStackLogicalIds ?? []), event.LogicalResourceId ?? '']);
}
if (isParentStackEvent && isStackTerminalState(event.ResourceStatus)) {
this.complete = true;
}
}
nextToken = page?.NextToken;
if (nextToken === undefined) {
finished = true;
}
}
} catch (e: any) {
if (!(e.name === 'ValidationError' && formatErrorMessage(e) === `Stack [${this.props.stackName}] does not exist`)) {
throw e;
}
}
return events;
}
/**
* On the CREATE_IN_PROGRESS, UPDATE_IN_PROGRESS, DELETE_IN_PROGRESS event of a nested stack, poll the nested stack updates
*/
private trackNestedStack(event: StackEvent, parentStackLogicalIds: string[]) {
const logicalId = event.LogicalResourceId;
const physicalResourceId = event.PhysicalResourceId;
// The CREATE_IN_PROGRESS event for a Nested Stack is emitted twice; first without a PhysicalResourceId
// and then with. Ignore this event if we don't have that property yet.
//
// (At this point, I also don't trust that logicalId is always going to be there so validate that as well)
if (!logicalId || !physicalResourceId) {
return;
}
if (!this.nestedStackPollers[logicalId]) {
this.nestedStackPollers[logicalId] = new StackEventPoller(this.cfn, {
stackName: physicalResourceId,
parentStackLogicalIds: parentStackLogicalIds,
startTime: event.Timestamp!.valueOf(),
});
}
}
}
function isStackBeginOperationState(state: string | undefined) {
return [
'CREATE_IN_PROGRESS',
'UPDATE_IN_PROGRESS',
'DELETE_IN_PROGRESS',
'UPDATE_ROLLBACK_IN_PROGRESS',
'ROLLBACK_IN_PROGRESS',
].includes(state ?? '');
}
function isStackTerminalState(state: string | undefined) {
return !(state ?? '').endsWith('_IN_PROGRESS');
}