Files
floci-ui/packages/api/src/adapter-aws/AwsEventBridgeAdapter.ts
T
2026-09-30 17:43:42 -04:00

297 lines
12 KiB
TypeScript

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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.eventbridge.send(new DeleteEventBusCommand({Name: eventBusName(id)}))
}
async listEventBridgeRules(eventBusId: string): Promise<EventBridgeRuleOverview[]> {
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<EventBridgeRuleOverview> {
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<void> {
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<EventBridgePutEventResult> {
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<Target[]> {
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<EventBridgeRuleOverview, 'scheduleExpression' | 'eventPattern' | 'state' | 'description'> {
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)
}