diff --git a/src/handlers/StackHandler.ts b/src/handlers/StackHandler.ts index 1321af56..330342af 100644 --- a/src/handlers/StackHandler.ts +++ b/src/handlers/StackHandler.ts @@ -18,6 +18,7 @@ import { parseGetStackEventsParams, parseClearStackEventsParams, parseDescribeStackParams, + parseDescribeEventsParams, } from '../stacks/actions/StackActionParser'; import { TemplateUri, @@ -48,6 +49,8 @@ import { DescribeStackResult, DescribeChangeSetParams, DescribeChangeSetResult, + DescribeEventsParams, + DescribeEventsResult, } from '../stacks/StackRequestType'; import { TelemetryService } from '../telemetry/TelemetryService'; import { EventType } from '../usageTracker/UsageTracker'; @@ -444,3 +447,28 @@ export function describeStackHandler( } }; } + +export function describeEventsHandler( + components: ServerComponents, +): RequestHandler { + return async (rawParams): Promise => { + try { + const params = parseWithPrettyError(parseDescribeEventsParams, rawParams); + + const response = await components.cfnService.describeEvents({ + StackName: params.stackName, + ChangeSetName: params.changeSetName, + OperationId: params.operationId, + FailedEventsOnly: params.failedEventsOnly, + NextToken: params.nextToken, + }); + + return { + events: response.OperationEvents ?? [], + nextToken: response.NextToken, + }; + } catch (error) { + handleLspError(error, 'Failed to describe events'); + } + }; +} diff --git a/src/protocol/LspStackHandlers.ts b/src/protocol/LspStackHandlers.ts index bc10020f..b93e0004 100644 --- a/src/protocol/LspStackHandlers.ts +++ b/src/protocol/LspStackHandlers.ts @@ -53,6 +53,9 @@ import { DescribeChangeSetParams, DescribeChangeSetResult, DescribeChangeSetRequest, + DescribeEventsParams, + DescribeEventsResult, + DescribeEventsRequest, } from '../stacks/StackRequestType'; import { Identifiable } from './LspTypes'; @@ -142,4 +145,8 @@ export class LspStackHandlers { onDescribeStack(handler: RequestHandler) { this.connection.onRequest(DescribeStackRequest.method, handler); } + + onDescribeEvents(handler: RequestHandler) { + this.connection.onRequest(DescribeEventsRequest.method, handler); + } } diff --git a/src/server/CfnServer.ts b/src/server/CfnServer.ts index f7a2d47d..1edbb0ea 100644 --- a/src/server/CfnServer.ts +++ b/src/server/CfnServer.ts @@ -48,6 +48,7 @@ import { clearStackEventsHandler, describeStackHandler, describeChangeSetHandler, + describeEventsHandler, } from '../handlers/StackHandler'; import { LspComponents } from '../protocol/LspComponents'; import { LoggerFactory } from '../telemetry/LoggerFactory'; @@ -228,6 +229,12 @@ export class CfnServer { withOnlineGuard(this.components.onlineFeatureGuard, describeStackHandler(this.components)), ), ); + this.lsp.stackHandlers.onDescribeEvents( + withTelemetryContext( + 'Stack.Describe.Events', + withOnlineGuard(this.components.onlineFeatureGuard, describeEventsHandler(this.components)), + ), + ); this.lsp.cfnEnvironmentHandlers.onParseCfnEnvironmentFiles( withTelemetryContext('Cfn.Environment.Parse', parseCfnEnvironmentFilesHandler()), diff --git a/src/stacks/StackRequestType.ts b/src/stacks/StackRequestType.ts index dd6d5000..e122b776 100644 --- a/src/stacks/StackRequestType.ts +++ b/src/stacks/StackRequestType.ts @@ -1,4 +1,11 @@ -import { StackSummary, StackStatus, StackResourceSummary, StackEvent, Stack } from '@aws-sdk/client-cloudformation'; +import { + StackSummary, + StackStatus, + StackResourceSummary, + StackEvent, + Stack, + OperationEvent, +} from '@aws-sdk/client-cloudformation'; import { RequestType } from 'vscode-languageserver-protocol'; import { ChangeSetReference, DeploymentMode, StackChange } from './actions/StackActionRequestType'; @@ -111,3 +118,20 @@ export const DescribeStackRequest = new RequestType( 'aws/cfn/stack/changeSet/describe', ); + +export type DescribeEventsParams = { + stackName?: string; + changeSetName?: string; + operationId?: string; + failedEventsOnly?: boolean; + nextToken?: string; +}; + +export type DescribeEventsResult = { + events: OperationEvent[]; + nextToken?: string; +}; + +export const DescribeEventsRequest = new RequestType( + 'aws/cfn/stack/events/describe', +); diff --git a/src/stacks/actions/StackActionParser.ts b/src/stacks/actions/StackActionParser.ts index dfe5f839..ebdfcad6 100644 --- a/src/stacks/actions/StackActionParser.ts +++ b/src/stacks/actions/StackActionParser.ts @@ -7,6 +7,7 @@ import { ClearStackEventsParams, DescribeStackParams, DescribeChangeSetParams, + DescribeEventsParams, } from '../StackRequestType'; import { CreateDeploymentParams, @@ -100,6 +101,18 @@ const DescribeStackParamsSchema = z.object({ stackName: CfnNameZodString, }); +const DescribeEventsParamsSchema = z + .object({ + stackName: CfnNameZodString.optional(), + changeSetName: CfnNameZodString.optional(), + operationId: z.string().optional(), + failedEventsOnly: z.boolean().optional(), + nextToken: z.string().optional(), + }) + .refine((data) => data.stackName ?? data.changeSetName ?? data.operationId, { + message: 'At least one of stackName, changeSetName, or operationId must be provided', + }); + export function parseCreateValidationParams(input: unknown): CreateValidationParams { return CreateValidationParamsSchema.parse(input); } @@ -135,3 +148,7 @@ export function parseDescribeStackParams(input: unknown): DescribeStackParams { export function parseDescribeChangeSetParams(input: unknown): DescribeChangeSetParams { return DescribeChangeSetParamsSchema.parse(input); } + +export function parseDescribeEventsParams(input: unknown): DescribeEventsParams { + return DescribeEventsParamsSchema.parse(input); +} diff --git a/tst/unit/handlers/StackHandler.test.ts b/tst/unit/handlers/StackHandler.test.ts index 5b53285c..5c7d358b 100644 --- a/tst/unit/handlers/StackHandler.test.ts +++ b/tst/unit/handlers/StackHandler.test.ts @@ -32,6 +32,7 @@ import { getChangeSetDeletionStatusHandler, describeStackHandler, describeChangeSetHandler, + describeEventsHandler, } from '../../../src/handlers/StackHandler'; import { analyzeCapabilities } from '../../../src/stacks/actions/CapabilityAnalyzer'; import { mapChangesToStackChanges } from '../../../src/stacks/actions/StackActionOperations'; @@ -51,6 +52,7 @@ import { DescribeStackResult, DescribeChangeSetParams, DescribeChangeSetResult, + DescribeEventsResult, } from '../../../src/stacks/StackRequestType'; import { createMockComponents, @@ -82,6 +84,7 @@ vi.mock('../../../src/stacks/actions/StackActionParser', () => ({ parseListStackResourcesParams: vi.fn((input) => input), parseDescribeStackParams: vi.fn((input) => input), parseDescribeChangeSetParams: vi.fn((input) => input), + parseDescribeEventsParams: vi.fn((input) => input), })); vi.mock('../../../src/utils/ZodErrorWrapper', () => ({ @@ -922,4 +925,58 @@ describe('StackActionHandler', () => { await expect(handler(params, {} as any)).rejects.toThrow('ChangeSet not found'); }); }); + + describe('describeEventsHandler', () => { + it('should return flat events from API', async () => { + mockComponents.cfnService.describeEvents.resolves({ + OperationEvents: [ + { EventId: '1', OperationId: 'op1', Timestamp: new Date('2024-01-01') }, + { EventId: '2', OperationId: 'op1', Timestamp: new Date('2024-01-02') }, + { EventId: '3', OperationId: 'op2', Timestamp: new Date('2024-01-03') }, + ], + $metadata: {}, + }); + + const handler = describeEventsHandler(mockComponents); + const result = (await handler({ stackName: 'test-stack' }, CancellationToken.None)) as DescribeEventsResult; + + expect(result.events).toHaveLength(3); + expect(result.events[0].EventId).toBe('1'); + }); + + it('should pass all parameters to API', async () => { + mockComponents.cfnService.describeEvents.resolves({ OperationEvents: [], $metadata: {} }); + + const handler = describeEventsHandler(mockComponents); + await handler( + { + stackName: 'test-stack', + changeSetName: 'cs', + operationId: 'op', + failedEventsOnly: true, + nextToken: 'token', + }, + CancellationToken.None, + ); + + expect( + mockComponents.cfnService.describeEvents.calledWith({ + StackName: 'test-stack', + ChangeSetName: 'cs', + OperationId: 'op', + FailedEventsOnly: true, + NextToken: 'token', + }), + ).toBe(true); + }); + + it('should handle service errors', async () => { + const serviceError = new Error('Service error'); + mockComponents.cfnService.describeEvents.rejects(serviceError); + + const handler = describeEventsHandler(mockComponents); + + await expect(handler({ stackName: 'test-stack' }, CancellationToken.None)).rejects.toThrow(); + }); + }); }); diff --git a/tst/unit/stackActions/StackActionParser.test.ts b/tst/unit/stackActions/StackActionParser.test.ts index 5ec3481c..a420e487 100644 --- a/tst/unit/stackActions/StackActionParser.test.ts +++ b/tst/unit/stackActions/StackActionParser.test.ts @@ -5,6 +5,7 @@ import { parseCreateValidationParams, parseTemplateUriParams, parseDescribeChangeSetParams, + parseDescribeEventsParams, } from '../../../src/stacks/actions/StackActionParser'; describe('StackActionParser', () => { @@ -259,4 +260,66 @@ describe('StackActionParser', () => { expect(() => parseDescribeChangeSetParams(undefined)).toThrow(ZodError); }); }); + + describe('parseDescribeEventsParams', () => { + it('should parse valid params with stackName', () => { + const result = parseDescribeEventsParams({ stackName: 'test-stack' }); + expect(result.stackName).toBe('test-stack'); + }); + + it('should parse valid params with changeSetName', () => { + const result = parseDescribeEventsParams({ changeSetName: 'test-changeset' }); + expect(result.changeSetName).toBe('test-changeset'); + }); + + it('should parse valid params with operationId', () => { + const result = parseDescribeEventsParams({ operationId: 'op-123' }); + expect(result.operationId).toBe('op-123'); + }); + + it('should parse all optional parameters', () => { + const result = parseDescribeEventsParams({ + stackName: 'test-stack', + changeSetName: 'test-changeset', + operationId: 'op-123', + failedEventsOnly: true, + nextToken: 'token123', + }); + + expect(result.stackName).toBe('test-stack'); + expect(result.changeSetName).toBe('test-changeset'); + expect(result.operationId).toBe('op-123'); + expect(result.failedEventsOnly).toBe(true); + expect(result.nextToken).toBe('token123'); + }); + + it('should throw error when no identifier provided', () => { + expect(() => parseDescribeEventsParams({})).toThrow(); + }); + + it('should throw error when only optional params provided', () => { + expect(() => parseDescribeEventsParams({ failedEventsOnly: true })).toThrow(); + }); + + it('should throw error for invalid stackName', () => { + expect(() => parseDescribeEventsParams({ stackName: '' })).toThrow(); + expect(() => parseDescribeEventsParams({ stackName: 123 as any })).toThrow(); + }); + + it('should throw error for invalid types', () => { + expect(() => parseDescribeEventsParams({ stackName: 'test', failedEventsOnly: 'true' as any })).toThrow(); + }); + + it('should accept undefined optional parameters', () => { + const result = parseDescribeEventsParams({ + stackName: 'test-stack', + failedEventsOnly: undefined, + nextToken: undefined, + }); + + expect(result.stackName).toBe('test-stack'); + expect(result.failedEventsOnly).toBeUndefined(); + expect(result.nextToken).toBeUndefined(); + }); + }); });