297 lines
12 KiB
TypeScript
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)
|
|
}
|