diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk.ts b/packages/aws-cdk/lib/api/aws-auth/sdk.ts index 25a04aac6ef0a..5dc345226824f 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk.ts @@ -51,7 +51,9 @@ import { DescribeResourceScanCommand, type DescribeResourceScanCommandInput, type DescribeResourceScanCommandOutput, + DescribeStackEventsCommand, type DescribeStackEventsCommandInput, + DescribeStackEventsCommandOutput, DescribeStackResourcesCommand, DescribeStackResourcesCommandInput, DescribeStackResourcesCommandOutput, @@ -86,12 +88,10 @@ import { ListStacksCommand, ListStacksCommandInput, ListStacksCommandOutput, - paginateDescribeStackEvents, paginateListStackResources, RollbackStackCommand, RollbackStackCommandInput, RollbackStackCommandOutput, - StackEvent, StackResourceSummary, StartResourceScanCommand, type StartResourceScanCommandInput, @@ -404,7 +404,7 @@ export interface ICloudFormationClient { input: UpdateTerminationProtectionCommandInput, ): Promise; // Pagination functions - describeStackEvents(input: DescribeStackEventsCommandInput): Promise; + describeStackEvents(input: DescribeStackEventsCommandInput): Promise; listStackResources(input: ListStackResourcesCommandInput): Promise; } @@ -664,13 +664,8 @@ export class SDK { input: UpdateTerminationProtectionCommandInput, ): Promise => client.send(new UpdateTerminationProtectionCommand(input)), - describeStackEvents: async (input: DescribeStackEventsCommandInput): Promise => { - const stackEvents = Array(); - const paginator = paginateDescribeStackEvents({ client }, input); - for await (const page of paginator) { - stackEvents.push(...(page?.StackEvents || [])); - } - return stackEvents; + describeStackEvents: (input: DescribeStackEventsCommandInput): Promise => { + return client.send(new DescribeStackEventsCommand(input)); }, listStackResources: async (input: ListStackResourcesCommandInput): Promise => { const stackResources = Array(); diff --git a/packages/aws-cdk/lib/api/util/cloudformation/stack-event-poller.ts b/packages/aws-cdk/lib/api/util/cloudformation/stack-event-poller.ts index 9fa192fea1ce2..efc66da8ef3b0 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation/stack-event-poller.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation/stack-event-poller.ts @@ -88,65 +88,64 @@ export class StackEventPoller { private async doPoll(): Promise { const events: ResourceEvent[] = []; try { - const eventList = await this.cfn.describeStackEvents({ - StackName: this.props.stackName, - }); - for (const event of eventList) { - // 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; + 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; + } } - // 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 ?? '']); + nextToken = page?.NextToken; + if (nextToken === undefined) { + finished = true; } - if (isParentStackEvent && isStackTerminalState(event.ResourceStatus)) { - this.complete = true; - } } } catch (e: any) { if (!(e.name === 'ValidationError' && e.message === `Stack [${this.props.stackName}] does not exist`)) { throw e; } } - // // 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; } diff --git a/packages/aws-cdk/test/api/util/cloudformation/stack-event-poller.test.ts b/packages/aws-cdk/test/api/util/cloudformation/stack-event-poller.test.ts new file mode 100644 index 0000000000000..6ff65e4eba58f --- /dev/null +++ b/packages/aws-cdk/test/api/util/cloudformation/stack-event-poller.test.ts @@ -0,0 +1,83 @@ +import { DescribeStackEventsCommand, DescribeStackEventsCommandInput, StackEvent } from '@aws-sdk/client-cloudformation'; +import { StackEventPoller } from '../../../../lib/api/util/cloudformation/stack-event-poller'; +import { MockSdk, mockCloudFormationClient } from '../../../util/mock-sdk'; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('poll', () => { + + test('polls all necessary pages', async () => { + + const deployTime = Date.now(); + + const postDeployEvent1: StackEvent = { + Timestamp: new Date(deployTime + 1000), + EventId: 'event-1', + StackId: 'stack-id', + StackName: 'stack', + }; + + const postDeployEvent2: StackEvent = { + Timestamp: new Date(deployTime + 2000), + EventId: 'event-2', + StackId: 'stack-id', + StackName: 'stack', + }; + + const sdk = new MockSdk(); + mockCloudFormationClient.on(DescribeStackEventsCommand).callsFake((input: DescribeStackEventsCommandInput) => { + const result = { + StackEvents: input.NextToken === 'token' ? [postDeployEvent2] : [postDeployEvent1], + NextToken: input.NextToken === 'token' ? undefined : 'token', // simulate a two page event stream. + }; + + return result; + }); + + const poller = new StackEventPoller(sdk.cloudFormation(), { + stackName: 'stack', + startTime: new Date().getTime(), + }); + + const events = await poller.poll(); + expect(events.length).toEqual(2); + + }); + + test('does not poll unnecessary pages', async () => { + + const deployTime = Date.now(); + + const preDeployTimeEvent: StackEvent = { + Timestamp: new Date(deployTime - 1000), + EventId: 'event-1', + StackId: 'stack-id', + StackName: 'stack', + }; + + const sdk = new MockSdk(); + mockCloudFormationClient.on(DescribeStackEventsCommand).callsFake((input: DescribeStackEventsCommandInput) => { + + // the first event we return should stop the polling. we therefore + // do not expect a second page to be polled. + expect(input.NextToken).toBe(undefined); + + return { + StackEvents: [preDeployTimeEvent], + NextToken: input.NextToken === 'token' ? undefined : 'token', // simulate a two page event stream. + }; + + }); + + const poller = new StackEventPoller(sdk.cloudFormation(), { + stackName: 'stack', + startTime: new Date().getTime(), + }); + + await poller.poll(); + + }); + +});