feat: EC2 UI polished + storage persisting

This commit is contained in:
Zakaria
2026-09-25 18:18:33 -04:00
parent 1b129e9dcf
commit 093afb9b2b
40 changed files with 2586 additions and 102 deletions
@@ -2,10 +2,19 @@ import {
CreateEventBusCommand,
DeleteEventBusCommand,
DescribeEventBusCommand,
DeleteRuleCommand,
ListEventBusesCommand,
ListRulesCommand,
ListTargetsByRuleCommand,
PutRuleCommand,
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 {
@@ -13,6 +22,9 @@ import type {
CloudServiceAdapter,
CloudServiceDescriptorOverride,
CreateResourceInput,
EventBridgeRuleInput,
EventBridgeRuleOverview,
EventBridgeRuleTarget,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
@@ -24,7 +36,10 @@ export class AwsEventBridgeAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'events' as const
constructor(private readonly eventbridge: EventBridgeClient) {}
constructor(
private readonly eventbridge: EventBridgeClient,
private readonly lambda: LambdaClient,
) {}
schema(): ServiceSchema {
return awsEventBridgeSchema()
@@ -67,6 +82,90 @@ export class AwsEventBridgeAdapter implements CloudServiceAdapter {
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)}))
}
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 {
@@ -122,3 +221,55 @@ function isNotFound(error: unknown): boolean {
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)
}