import { CreateEventBusCommand, DeleteEventBusCommand, DescribeEventBusCommand, DeleteRuleCommand, ListEventBusesCommand, ListRulesCommand, ListTargetsByRuleCommand, PutRuleCommand, PutEventsCommand, PutTargetsCommand, RemoveTargetsCommand, type EventBridgeClient, type EventBus, type Rule, type Target, } from '@aws-sdk/client-eventbridge' import {AddPermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' import {ValidationError} from '../cloud-spi/errors' import type { CloudResource, CloudServiceAdapter, CloudServiceDescriptorOverride, CreateResourceInput, EventBridgeRuleInput, EventBridgeRuleOverview, EventBridgeRuleTarget, EventBridgePutEventInput, EventBridgePutEventResult, ResourceQuery, ServiceSchema, } from '../cloud-spi/types' const EVENT_BUS_PREFIX = 'event-bus:' /** AWS EventBridge event buses through the provider-neutral Events category. */ export class AwsEventBridgeAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'events' as const constructor( private readonly eventbridge: EventBridgeClient, private readonly lambda: LambdaClient, ) {} schema(): ServiceSchema { return awsEventBridgeSchema() } descriptorOverride(): CloudServiceDescriptorOverride { return {displayName: 'EventBridge'} } async list(query: ResourceQuery = {}): Promise { const eventBuses: EventBus[] = [] let nextToken: string | undefined do { const response = await this.eventbridge.send(new ListEventBusesCommand({NextToken: nextToken})) eventBuses.push(...(response.EventBuses ?? [])) nextToken = response.NextToken } while (nextToken) return filterBySearch(eventBuses.map(eventBusResource), query.search) } async get(id: string): Promise { const name = eventBusName(id) try { return eventBusResource(await this.eventbridge.send(new DescribeEventBusCommand({Name: name}))) } catch (error) { if (isNotFound(error)) return null throw error } } async create(input: CreateResourceInput): Promise { const name = requiredValue(input.values.name, 'name') const description = stringValue(input.values.description) || undefined const response = await this.eventbridge.send(new CreateEventBusCommand({Name: name, Description: description})) return eventBusResource({Name: name, Arn: response.EventBusArn, Description: description}) } async delete(id: string): Promise { await this.eventbridge.send(new DeleteEventBusCommand({Name: eventBusName(id)})) } async listEventBridgeRules(eventBusId: string): Promise { const busName = eventBusName(eventBusId) const rules: Rule[] = [] let nextToken: string | undefined do { const response = await this.eventbridge.send(new ListRulesCommand({ ...eventBusInput(busName), NextToken: nextToken, })) rules.push(...(response.Rules ?? [])) nextToken = response.NextToken } while (nextToken) return Promise.all(rules.filter((rule) => Boolean(rule.Name)).map(async (rule) => { const name = rule.Name as string return ruleOverview(rule, busName, await this.listTargets(name, busName)) })) } async createEventBridgeRule(eventBusId: string, input: EventBridgeRuleInput): Promise { const busName = eventBusName(eventBusId) const name = requiredValue(input.name, 'name') const targetInput = ruleInput(input) const response = await this.eventbridge.send(new PutRuleCommand({ Name: name, ...eventBusInput(busName), ...(targetInput.scheduleExpression ? {ScheduleExpression: targetInput.scheduleExpression} : {}), ...(targetInput.eventPattern ? {EventPattern: targetInput.eventPattern} : {}), State: targetInput.state, ...(targetInput.description ? {Description: targetInput.description} : {}), })) const arn = response.RuleArn if (!arn) throw new ValidationError(`EventBridge rule ${name} did not return an ARN`) await this.eventbridge.send(new PutTargetsCommand({ Rule: name, ...eventBusInput(busName), Targets: input.targets.map((target) => ({Id: target.id, Arn: target.arn})), })) await Promise.all(input.targets.filter((target) => isLambdaArn(target.arn)).map((target) => this.lambda.send(new AddPermissionCommand({ FunctionName: target.arn, StatementId: permissionStatementId(busName, name, target.id), Action: 'lambda:InvokeFunction', Principal: 'events.amazonaws.com', SourceArn: arn, })), )) return { name, busName, busId: `${EVENT_BUS_PREFIX}${busName}`, arn, ...targetInput, targets: input.targets, } } async deleteEventBridgeRule(eventBusId: string, ruleName: string): Promise { const busName = eventBusName(eventBusId) const name = requiredValue(ruleName, 'ruleName') const targets = await this.listTargets(name, busName) const targetIds = targets.map((target) => target.Id).filter((id): id is string => Boolean(id)) if (targetIds.length > 0) { await this.eventbridge.send(new RemoveTargetsCommand({Rule: name, ...eventBusInput(busName), Ids: targetIds})) } await this.eventbridge.send(new DeleteRuleCommand({Name: name, ...eventBusInput(busName)})) } async putEventBridgeEvent(eventBusId: string, input: EventBridgePutEventInput): Promise { const source = requiredValue(input.source, 'source') const detailType = requiredValue(input.detailType, 'detailType') const detail = requiredValue(input.detail, 'detail') try { const parsed: unknown = JSON.parse(detail) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object') } catch { throw new ValidationError('detail must be a JSON object') } const response = await this.eventbridge.send(new PutEventsCommand({Entries: [{ EventBusName: eventBusName(eventBusId), Source: source, DetailType: detailType, Detail: detail, ...(input.resources?.length ? {Resources: input.resources} : {}), }]})) const entry = response.Entries?.[0] return {eventId: entry?.EventId ?? null, errorCode: entry?.ErrorCode ?? null, errorMessage: entry?.ErrorMessage ?? null} } private async listTargets(ruleName: string, busName: string): Promise { const targets: Target[] = [] let nextToken: string | undefined do { const response = await this.eventbridge.send(new ListTargetsByRuleCommand({ Rule: ruleName, ...eventBusInput(busName), NextToken: nextToken, })) targets.push(...(response.Targets ?? [])) nextToken = response.NextToken } while (nextToken) return targets } } function eventBusResource(eventBus: EventBus): CloudResource { const name = eventBus.Name ?? '' return { id: `${EVENT_BUS_PREFIX}${name}`, name, cloud: 'aws', service: 'events', type: 'event-bus', region: regionFromArn(eventBus.Arn), createdAt: eventBus.CreationTime?.toISOString() ?? null, status: null, metadata: { provider: 'aws', eventsService: 'eventbridge', arn: eventBus.Arn, description: eventBus.Description, policy: eventBus.Policy, lastModifiedAt: eventBus.LastModifiedTime?.toISOString() ?? null, }, } } function eventBusName(id: string): string { const name = id.startsWith(EVENT_BUS_PREFIX) ? id.slice(EVENT_BUS_PREFIX.length) : '' if (!name) throw new ValidationError(`Unknown EventBridge event bus id: ${id}`) return name } function requiredValue(value: unknown, field: string): string { const result = stringValue(value) if (!result) throw new ValidationError(`${field} is required`) return result } function stringValue(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { const normalized = search?.trim().toLowerCase() if (!normalized) return resources return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) } function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null } function isNotFound(error: unknown): boolean { if (!error || typeof error !== 'object') return false const value = error as {name?: string; $metadata?: {httpStatusCode?: number}} return value.name === 'ResourceNotFoundException' || value.$metadata?.httpStatusCode === 404 } function eventBusInput(busName: string): {EventBusName?: string} { return busName === 'default' ? {} : {EventBusName: busName} } function ruleInput(input: EventBridgeRuleInput): Pick { const scheduleExpression = stringValue(input.scheduleExpression) const eventPattern = stringValue(input.eventPattern) if (Boolean(scheduleExpression) === Boolean(eventPattern)) { throw new ValidationError('exactly one of scheduleExpression or eventPattern is required') } if (eventPattern) { try { JSON.parse(eventPattern) } catch { throw new ValidationError('eventPattern must be a JSON string') } } if (!Array.isArray(input.targets) || input.targets.length === 0) throw new ValidationError('at least one target is required') const targetsValid = input.targets.every((target) => requiredValue(target.id, 'target.id') && requiredValue(target.arn, 'target.arn')) if (!targetsValid) throw new ValidationError('target id and arn are required') const state = input.state ?? 'ENABLED' if (state !== 'ENABLED' && state !== 'DISABLED') throw new ValidationError('state must be ENABLED or DISABLED') return { ...(scheduleExpression ? {scheduleExpression} : {}), ...(eventPattern ? {eventPattern} : {}), state, ...(stringValue(input.description) ? {description: stringValue(input.description)} : {}), } } function ruleOverview(rule: Rule, busName: string, targets: Target[]): EventBridgeRuleOverview { return { name: rule.Name ?? '', busName, busId: `${EVENT_BUS_PREFIX}${busName}`, arn: rule.Arn ?? null, ...(rule.ScheduleExpression ? {scheduleExpression: rule.ScheduleExpression} : {}), ...(rule.EventPattern ? {eventPattern: rule.EventPattern} : {}), state: rule.State === 'DISABLED' ? 'DISABLED' : 'ENABLED', ...(rule.Description ? {description: rule.Description} : {}), targets: targets.flatMap((target) => target.Id && target.Arn ? [{id: target.Id, arn: target.Arn}] : []), } } function isLambdaArn(arn: string): boolean { return arn.split(':')[2] === 'lambda' } function permissionStatementId(busName: string, ruleName: string, targetId: string): string { return `floci-events-${busName}-${ruleName}-${targetId}`.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 100) }