diff --git a/.prototools b/.prototools new file mode 100644 index 0000000..5bcd790 --- /dev/null +++ b/.prototools @@ -0,0 +1 @@ +bun = "1.4.2" diff --git a/OS_img/Debian_linux.png b/OS_img/Debian_linux.png new file mode 100644 index 0000000..f6a2455 Binary files /dev/null and b/OS_img/Debian_linux.png differ diff --git a/OS_img/Ubuntu_Linux.png b/OS_img/Ubuntu_Linux.png new file mode 100644 index 0000000..83e6f62 Binary files /dev/null and b/OS_img/Ubuntu_Linux.png differ diff --git a/OS_img/amazon_Linux.png b/OS_img/amazon_Linux.png new file mode 100644 index 0000000..e51deef Binary files /dev/null and b/OS_img/amazon_Linux.png differ diff --git a/OS_img/red-hat-logo.png b/OS_img/red-hat-logo.png new file mode 100644 index 0000000..edc1c16 Binary files /dev/null and b/OS_img/red-hat-logo.png differ diff --git a/README.md b/README.md index 0e18209..e257c28 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | No | No | | Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | +| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No | | Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No | | Integration | SES Mailbox / Email | Yes (list, inspect) | No | No | | Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) | diff --git a/Screenshot 2026-09-25 at 2.06.15 PM.png b/Screenshot 2026-09-25 at 2.06.15 PM.png new file mode 100644 index 0000000..044a7df Binary files /dev/null and b/Screenshot 2026-09-25 at 2.06.15 PM.png differ diff --git a/docker/Dockerfile b/docker/Dockerfile index 162a75b..fd396f2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app COPY packages/frontend/package.json ./ RUN npm install COPY packages/frontend/ . +COPY OS_img/ /OS_img/ RUN npm run build # Stage 2 — compile API to a self-contained binary diff --git a/packages/api/package.json b/packages/api/package.json index f20e511..f6a9736 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -14,8 +14,8 @@ "@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-cloudformation": "^3.1111.0", "@aws-sdk/client-cloudwatch-logs": "^3.1096.0", - "@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-dynamodb": "^3.1088.0", + "@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-eks": "^3.1076.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-eventbridge": "^3.1076.0", @@ -26,6 +26,7 @@ "@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-secrets-manager": "^3.1076.0", "@aws-sdk/client-sfn": "^3.1096.0", + "@aws-sdk/client-sns": "^3.1140.0", "@aws-sdk/client-sqs": "^3.1076.0", "@aws-sdk/client-ssm": "^3.1096.0", "@aws-sdk/util-dynamodb": "^3.996.7", diff --git a/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts b/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts index 9700ff3..597b463 100644 --- a/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts @@ -112,12 +112,16 @@ describe('AwsComputeAdapter', () => { keyName: 'my-key', subnetId: 'subnet-abc', securityGroupIds: 'sg-111, sg-222', + rootVolumeSize: '30', + rootVolumeType: 'gp3', }}) expect(launched).toHaveLength(1) expect(launched[0].name).toBe('test-box') expect(launched[0].imageId).toBe('ami-0abcdef123') expect(launched[0].instanceType).toBe('t3.micro') expect(launched[0].securityGroupIds).toEqual(['sg-111', 'sg-222']) + expect(launched[0].rootVolumeSize).toBe(30) + expect(launched[0].rootVolumeType).toBe('gp3') expect(result.type).toBe('instance') expect(result.cloud).toBe('aws') }) diff --git a/packages/api/src/adapter-aws/AwsComputeAdapter.ts b/packages/api/src/adapter-aws/AwsComputeAdapter.ts index 13b472b..3e4663b 100644 --- a/packages/api/src/adapter-aws/AwsComputeAdapter.ts +++ b/packages/api/src/adapter-aws/AwsComputeAdapter.ts @@ -1,4 +1,5 @@ import {awsComputeSchema} from '../cloud-spi/computeSchema' +import {ValidationError} from '../cloud-spi/errors' import type { CloudResource, CloudServiceAdapter, @@ -51,6 +52,7 @@ export class AwsComputeAdapter implements CloudServiceAdapter { const v = input.values const sgRaw = typeof v.securityGroupIds === 'string' ? v.securityGroupIds : '' const securityGroupIds = sgRaw ? sgRaw.split(',').map((s: string) => s.trim()).filter(Boolean) : undefined + const rootVolumeSize = positiveInteger(v.rootVolumeSize) const runInput: RunInstanceInput = { name: String(v.name ?? ''), imageId: String(v.imageId ?? ''), @@ -58,6 +60,8 @@ export class AwsComputeAdapter implements CloudServiceAdapter { keyName: v.keyName ? String(v.keyName) : undefined, subnetId: v.subnetId ? String(v.subnetId) : undefined, securityGroupIds, + rootVolumeSize, + rootVolumeType: v.rootVolumeType ? String(v.rootVolumeType) : undefined, } const instance = await this.service_.runInstance(runInput) return instanceToResource(instance) @@ -68,6 +72,13 @@ export class AwsComputeAdapter implements CloudServiceAdapter { } } +function positiveInteger(value: unknown): number | undefined { + if (value === undefined || value === null || String(value).trim() === '') return undefined + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) throw new ValidationError('rootVolumeSize must be a positive whole number') + return parsed +} + function instanceToResource(instance: Ec2Instance): CloudResource { return { id: instance.instanceId, diff --git a/packages/api/src/adapter-aws/AwsEventBridgeAdapter.test.ts b/packages/api/src/adapter-aws/AwsEventBridgeAdapter.test.ts index f82c296..43c3a9b 100644 --- a/packages/api/src/adapter-aws/AwsEventBridgeAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsEventBridgeAdapter.test.ts @@ -2,10 +2,17 @@ import {describe, expect, test} from 'bun:test' import { CreateEventBusCommand, DeleteEventBusCommand, + DeleteRuleCommand, DescribeEventBusCommand, ListEventBusesCommand, + ListRulesCommand, + ListTargetsByRuleCommand, + PutRuleCommand, + PutTargetsCommand, + RemoveTargetsCommand, type EventBridgeClient, } from '@aws-sdk/client-eventbridge' +import {AddPermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' import {AwsEventBridgeAdapter} from './AwsEventBridgeAdapter' type SendHandler = (command: unknown) => Promise @@ -74,8 +81,64 @@ describe('AwsEventBridgeAdapter', () => { expect(commands[1]).toBeInstanceOf(DeleteEventBusCommand) expect((commands[1] as DeleteEventBusCommand).input.Name).toBe('orders') }) + + test('lists every rule and its targets for an event bus', async () => { + const adapter = createAdapter(async (command) => { + if (command instanceof ListRulesCommand) { + expect(command.input.EventBusName).toBe('orders') + return command.input.NextToken + ? {Rules: [{Name: 'nightly', Arn: 'arn:aws:events:us-east-1:000000000000:rule/orders/nightly', EventPattern: '{"source":["billing"]}', State: 'DISABLED'}]} + : {Rules: [{Name: 'hourly', Arn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly', ScheduleExpression: 'rate(1 hour)'}], NextToken: 'next'} + } + if (command instanceof ListTargetsByRuleCommand) { + return {Targets: [{Id: 'worker', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]} + } + return {} + }) + + await expect(adapter.listEventBridgeRules('event-bus:orders')).resolves.toEqual([ + {name: 'hourly', busName: 'orders', busId: 'event-bus:orders', arn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly', scheduleExpression: 'rate(1 hour)', state: 'ENABLED', targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]}, + {name: 'nightly', busName: 'orders', busId: 'event-bus:orders', arn: 'arn:aws:events:us-east-1:000000000000:rule/orders/nightly', eventPattern: '{"source":["billing"]}', state: 'DISABLED', targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]}, + ]) + }) + + test('creates a rule, attaches targets, and permits Lambda invocation', async () => { + const commands: unknown[] = [] + const adapter = createAdapter(async (command) => { + commands.push(command) + if (command instanceof PutRuleCommand) return {RuleArn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly'} + return {} + }) + + await expect(adapter.createEventBridgeRule('event-bus:orders', { + name: 'hourly', scheduleExpression: 'rate(1 hour)', state: 'DISABLED', description: 'Run the worker', + targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}], + })).resolves.toMatchObject({name: 'hourly', busName: 'orders', state: 'DISABLED'}) + + expect((commands[0] as PutRuleCommand).input).toEqual({Name: 'hourly', EventBusName: 'orders', ScheduleExpression: 'rate(1 hour)', State: 'DISABLED', Description: 'Run the worker'}) + expect((commands[1] as PutTargetsCommand).input).toEqual({Rule: 'hourly', EventBusName: 'orders', Targets: [{Id: 'worker', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]}) + expect((commands[2] as AddPermissionCommand).input).toMatchObject({FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:worker', Principal: 'events.amazonaws.com', SourceArn: 'arn:aws:events:us-east-1:000000000000:rule/orders/hourly'}) + }) + + test('removes targets before deleting a rule', async () => { + const commands: unknown[] = [] + const adapter = createAdapter(async (command) => { + commands.push(command) + if (command instanceof ListTargetsByRuleCommand) return {Targets: [{Id: 'worker', Arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]} + return {} + }) + + await adapter.deleteEventBridgeRule('event-bus:orders', 'hourly') + + expect(commands[0]).toBeInstanceOf(ListTargetsByRuleCommand) + expect((commands[1] as RemoveTargetsCommand).input).toEqual({Rule: 'hourly', EventBusName: 'orders', Ids: ['worker']}) + expect((commands[2] as DeleteRuleCommand).input).toEqual({Name: 'hourly', EventBusName: 'orders'}) + }) }) function createAdapter(handler: SendHandler = async () => ({EventBuses: []})): AwsEventBridgeAdapter { - return new AwsEventBridgeAdapter({send: handler} as unknown as EventBridgeClient) + return new AwsEventBridgeAdapter( + {send: handler} as unknown as EventBridgeClient, + {send: handler} as unknown as LambdaClient, + ) } diff --git a/packages/api/src/adapter-aws/AwsEventBridgeAdapter.ts b/packages/api/src/adapter-aws/AwsEventBridgeAdapter.ts index acbace5..088761c 100644 --- a/packages/api/src/adapter-aws/AwsEventBridgeAdapter.ts +++ b/packages/api/src/adapter-aws/AwsEventBridgeAdapter.ts @@ -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 { 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)})) + } + + 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 { @@ -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 { + 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) +} diff --git a/packages/api/src/adapter-aws/AwsSnsAdapter.test.ts b/packages/api/src/adapter-aws/AwsSnsAdapter.test.ts new file mode 100644 index 0000000..ffafb35 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsSnsAdapter.test.ts @@ -0,0 +1,138 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateTopicCommand, + DeleteTopicCommand, + GetSubscriptionAttributesCommand, + GetTopicAttributesCommand, + ListSubscriptionsByTopicCommand, + ListTopicsCommand, + PublishCommand, + SubscribeCommand, + type SNSClient, + UnsubscribeCommand, +} from '@aws-sdk/client-sns' +import {AwsSnsAdapter} from './AwsSnsAdapter' +import {ValidationError} from '../cloud-spi/errors' + +const ORDERS_ARN = 'arn:aws:sns:us-east-1:000000000000:orders' +const ALERTS_ARN = 'arn:aws:sns:us-east-1:000000000000:alerts' + +function stubSns() { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + if (command instanceof ListTopicsCommand) return {Topics: [{TopicArn: ORDERS_ARN}, {TopicArn: ALERTS_ARN}]} + if (command instanceof GetTopicAttributesCommand) { + return {Attributes: {DisplayName: 'Order notifications', FifoTopic: 'false'}} + } + if (command instanceof CreateTopicCommand) { + return {TopicArn: `arn:aws:sns:us-east-1:000000000000:${command.input.Name}`} + } + if (command instanceof PublishCommand) return {MessageId: 'e5a98c5b-cd94-4c38-a9ac-7260ec3e575f'} + if (command instanceof ListSubscriptionsByTopicCommand) return {Subscriptions: [{ + SubscriptionArn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-1', + Protocol: 'sqs', + Endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders', + }]} + if (command instanceof GetSubscriptionAttributesCommand) return {Attributes: {FilterPolicy: '{"eventType":["created"]}'}} + if (command instanceof SubscribeCommand) return {SubscriptionArn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-2'} + return {} + }, + } as unknown as SNSClient + return {client, sent} +} + +describe('AwsSnsAdapter', () => { + test('lists SNS topic ARNs as topic resources', async () => { + const {client} = stubSns() + const [topic] = await new AwsSnsAdapter(client).list() + + expect(topic).toMatchObject({id: ORDERS_ARN, name: 'orders', service: 'notifications', type: 'topic'}) + expect(topic?.metadata.arn).toBe(ORDERS_ARN) + }) + + test('gets a topic with its SNS attributes', async () => { + const {client, sent} = stubSns() + const topic = await new AwsSnsAdapter(client).get(ORDERS_ARN) + + expect(sent[0]).toBeInstanceOf(GetTopicAttributesCommand) + expect(topic?.metadata.displayName).toBe('Order notifications') + }) + + test('creates and reads back an SNS topic', async () => { + const {client, sent} = stubSns() + const topic = await new AwsSnsAdapter(client).create({values: {name: 'orders'}}) + + expect(sent[0]).toBeInstanceOf(CreateTopicCommand) + expect((sent[0] as CreateTopicCommand).input.Name).toBe('orders') + expect(sent[1]).toBeInstanceOf(GetTopicAttributesCommand) + expect(topic.id).toBe(ORDERS_ARN) + }) + + test('deletes a topic by ARN', async () => { + const {client, sent} = stubSns() + await new AwsSnsAdapter(client).delete(ORDERS_ARN) + + expect((sent[0] as DeleteTopicCommand).input.TopicArn).toBe(ORDERS_ARN) + }) + + test('publishes a message with optional subject and string message attributes', async () => { + const {client, sent} = stubSns() + const result = await new AwsSnsAdapter(client).publish(ORDERS_ARN, { + message: 'Order placed', + subject: 'Order update', + messageAttributes: {orderId: '1234'}, + }) + + expect(result.messageId).toBe('e5a98c5b-cd94-4c38-a9ac-7260ec3e575f') + expect((sent[0] as PublishCommand).input).toEqual({ + TopicArn: ORDERS_ARN, + Message: 'Order placed', + Subject: 'Order update', + MessageAttributes: {orderId: {DataType: 'String', StringValue: '1234'}}, + }) + }) + + test('manages SNS subscriptions with direct SDK commands and exposes their attributes', async () => { + const {client, sent} = stubSns() + const adapter = new AwsSnsAdapter(client) + + const created = await adapter.createSnsSubscription(ORDERS_ARN, { + protocol: 'sqs', + endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders', + }) + const subscriptions = await adapter.listSnsSubscriptions(ORDERS_ARN) + await adapter.deleteSnsSubscription('arn:aws:sns:us-east-1:000000000000:orders:sub-1') + + expect((sent[0] as SubscribeCommand).input).toEqual({ + TopicArn: ORDERS_ARN, + Protocol: 'sqs', + Endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders', + }) + expect((sent[1] as ListSubscriptionsByTopicCommand).input).toEqual({TopicArn: ORDERS_ARN}) + expect((sent[2] as GetSubscriptionAttributesCommand).input).toEqual({ + SubscriptionArn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-1', + }) + expect((sent[3] as UnsubscribeCommand).input).toEqual({ + SubscriptionArn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-1', + }) + expect(created).toMatchObject({arn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-2', protocol: 'sqs', status: 'Confirmed'}) + expect(subscriptions).toEqual([{ + arn: 'arn:aws:sns:us-east-1:000000000000:orders:sub-1', + protocol: 'sqs', + endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders', + status: 'Confirmed', + filterPolicy: '{"eventType":["created"]}', + }]) + }) + + test('rejects invalid topic names and blank messages before calling SNS', async () => { + const {client, sent} = stubSns() + const adapter = new AwsSnsAdapter(client) + + await expect(adapter.create({values: {name: 'has space'}})).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.publish(ORDERS_ARN, {message: ''})).rejects.toBeInstanceOf(ValidationError) + expect(sent).toEqual([]) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsSnsAdapter.ts b/packages/api/src/adapter-aws/AwsSnsAdapter.ts new file mode 100644 index 0000000..e310588 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsSnsAdapter.ts @@ -0,0 +1,200 @@ +import { + CreateTopicCommand, + DeleteTopicCommand, + GetSubscriptionAttributesCommand, + GetTopicAttributesCommand, + ListSubscriptionsByTopicCommand, + ListTopicsCommand, + PublishCommand, + SubscribeCommand, + type SNSClient, + type Subscription, + type Topic, + UnsubscribeCommand, +} from '@aws-sdk/client-sns' +import {ValidationError} from '../cloud-spi/errors' +import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + PublishNotificationInput, + PublishNotificationResult, + ResourceQuery, + ServiceSchema, + SnsSubscriptionInput, + SnsSubscriptionOverview, +} from '../cloud-spi/types' + +/** Amazon SNS topics through the provider-neutral Notifications category. */ +export class AwsSnsAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'notifications' as const + + constructor(private readonly sns: SNSClient) {} + + schema(): ServiceSchema { + return awsNotificationsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const topics: Topic[] = [] + let nextToken: string | undefined + do { + const response = await this.sns.send(new ListTopicsCommand(nextToken ? {NextToken: nextToken} : {})) + topics.push(...(response.Topics ?? [])) + nextToken = response.NextToken + } while (nextToken) + + return filterBySearch(topics.map((topic) => topicResource(topic)), query.search) + } + + async get(id: string): Promise { + try { + const response = await this.sns.send(new GetTopicAttributesCommand({TopicArn: id})) + return topicResource({TopicArn: id}, response.Attributes) + } catch (error) { + if (isMissing(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = requiredName(input.values.name) + const response = await this.sns.send(new CreateTopicCommand({Name: name})) + const arn = response.TopicArn + if (!arn) throw new ValidationError(`SNS did not return an ARN for ${name}`) + return (await this.get(arn)) ?? topicResource({TopicArn: arn}) + } + + async delete(id: string): Promise { + await this.sns.send(new DeleteTopicCommand({TopicArn: id})) + } + + async publish(id: string, input: PublishNotificationInput): Promise { + const message = requiredMessage(input.message) + const subject = optionalSubject(input.subject) + const MessageAttributes = toMessageAttributes(input.messageAttributes) + const response = await this.sns.send(new PublishCommand({ + TopicArn: id, + Message: message, + ...(subject ? {Subject: subject} : {}), + ...(MessageAttributes ? {MessageAttributes} : {}), + })) + return {messageId: response.MessageId ?? null} + } + + async createSnsSubscription(topicArn: string, input: SnsSubscriptionInput): Promise { + const protocol = requiredSubscriptionValue(input.protocol, 'protocol') + const endpoint = requiredSubscriptionValue(input.endpoint, 'endpoint') + const response = await this.sns.send(new SubscribeCommand({ + TopicArn: topicArn, + Protocol: protocol, + Endpoint: endpoint, + })) + return { + arn: response.SubscriptionArn ?? null, + protocol, + endpoint, + status: response.SubscriptionArn === 'PendingConfirmation' ? 'PendingConfirmation' : 'Confirmed', + filterPolicy: null, + } + } + + async listSnsSubscriptions(topicArn: string): Promise { + const subscriptions: Subscription[] = [] + let nextToken: string | undefined + do { + const response = await this.sns.send(new ListSubscriptionsByTopicCommand({ + TopicArn: topicArn, + ...(nextToken ? {NextToken: nextToken} : {}), + })) + subscriptions.push(...(response.Subscriptions ?? [])) + nextToken = response.NextToken + } while (nextToken) + + return Promise.all(subscriptions.map((subscription) => this.subscriptionOverview(subscription))) + } + + async deleteSnsSubscription(subscriptionArn: string): Promise { + await this.sns.send(new UnsubscribeCommand({SubscriptionArn: subscriptionArn})) + } + + async health(): Promise { + await this.sns.send(new ListTopicsCommand({})) + } + + private async subscriptionOverview(subscription: Subscription): Promise { + const arn = subscription.SubscriptionArn ?? null + const pending = arn === 'PendingConfirmation' + const attributes = !arn || pending + ? undefined + : await this.sns.send(new GetSubscriptionAttributesCommand({SubscriptionArn: arn})) + return { + arn, + protocol: subscription.Protocol ?? '', + endpoint: subscription.Endpoint ?? '', + status: pending ? 'PendingConfirmation' : 'Confirmed', + filterPolicy: attributes?.Attributes?.FilterPolicy ?? null, + } + } +} + +function topicResource(topic: Topic, attributes: Record = {}): CloudResource { + const arn = topic.TopicArn ?? '' + return { + id: arn, + name: topicName(arn), + cloud: 'aws', + service: 'notifications', + type: attributes.FifoTopic === 'true' ? 'fifo-topic' : 'topic', + region: arn.split(':')[3] || null, + createdAt: null, + metadata: {arn, fifoTopic: attributes.FifoTopic === 'true', displayName: attributes.DisplayName}, + } +} + +function topicName(arn: string): string { + return arn.split(':').pop() ?? arn +} + +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 requiredName(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) throw new ValidationError('name is required') + const name = value.trim() + if (!/^[A-Za-z0-9_-]{1,256}$/.test(name) && !/^[A-Za-z0-9_-]{1,251}\.fifo$/.test(name)) { + throw new ValidationError('name must be up to 256 letters, numbers, hyphens, underscores, or end in .fifo') + } + return name +} + +function requiredMessage(value: unknown): string { + if (typeof value !== 'string' || !value) throw new ValidationError('message is required') + return value +} + +function requiredSubscriptionValue(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) throw new ValidationError(`${field} is required`) + return value.trim() +} + +function optionalSubject(value: unknown): string | undefined { + if (value === undefined || value === '') return undefined + if (typeof value !== 'string') throw new ValidationError('subject must be a string') + if (value.length > 100) throw new ValidationError('subject must be 100 characters or fewer') + return value +} + +function toMessageAttributes(values: Record | undefined) { + if (!values || Object.keys(values).length === 0) return undefined + return Object.fromEntries(Object.entries(values).map(([Name, Value]) => [Name, {DataType: 'String', StringValue: Value}])) +} + +function isMissing(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as {name?: string}).name === 'NotFound' +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index 97a57d4..beed793 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -16,6 +16,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { APIGatewayClient } from "@aws-sdk/client-api-gateway"; import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; import { AppConfigClient } from "@aws-sdk/client-appconfig"; +import { SNSClient } from "@aws-sdk/client-sns"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -63,6 +64,7 @@ export type AwsClients = { apiGateway: APIGatewayClient; cloudformation: CloudFormationClient; appConfig: AppConfigClient; + sns: SNSClient; }; export type AwsClientName = keyof AwsClients; @@ -94,6 +96,7 @@ function buildClients(accountId: string): AwsClients { apiGateway: new APIGatewayClient(base), cloudformation: new CloudFormationClient(base), appConfig: new AppConfigClient(base), + sns: new SNSClient(base), }; } diff --git a/packages/api/src/cloud-spi/notificationsSchema.ts b/packages/api/src/cloud-spi/notificationsSchema.ts new file mode 100644 index 0000000..2d2a390 --- /dev/null +++ b/packages/api/src/cloud-spi/notificationsSchema.ts @@ -0,0 +1,33 @@ +import type {ServiceSchema} from './types' + +export function awsNotificationsSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'notifications', + displayName: 'Amazon SNS', + fields: [ + { + name: 'name', + label: 'Topic Name', + type: 'text', + required: true, + description: 'Up to 256 letters, numbers, hyphens, and underscores.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: [{name: 'search', label: 'Search', type: 'text', required: false}], + columns: [ + {name: 'name', label: 'Name'}, + {name: 'arn', label: 'Topic ARN', path: 'metadata.arn', format: 'code'}, + {name: 'type', label: 'Type'}, + ], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List topics', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create topic', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete topic', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect topic', enabled: true, status: 'available', runtimeRequired: false}, + ], + }, + } +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index bed5d96..b4c0114 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -111,6 +111,13 @@ export const SERVICE_CATALOG = { group: 'Integration', order: 10, }, + notifications: { + displayName: 'Notifications', + displayNameByCloud: {aws: 'SNS'}, + iconKey: 'notifications', + group: 'Integration', + order: 11, + }, events: { displayName: 'Events', displayNameByCloud: {aws: 'EventBridge'}, diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 8607e19..19f3dd6 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -450,6 +450,29 @@ export interface ServerlessInvokeResult { executionDuration?: number } +export interface PublishNotificationInput { + message: string + subject?: string + messageAttributes?: Record +} + +export interface PublishNotificationResult { + messageId: string | null +} + +export interface SnsSubscriptionInput { + protocol: string + endpoint: string +} + +export interface SnsSubscriptionOverview { + arn: string | null + protocol: string + endpoint: string + status: 'Confirmed' | 'PendingConfirmation' + filterPolicy: string | null +} + export interface ApiGatewayLambdaRouteInput { path: string method: string @@ -530,6 +553,27 @@ export interface EventBridgeLambdaTriggerOverview { state: string | null } +export interface EventBridgeRuleTarget { + id: string + arn: string +} + +export interface EventBridgeRuleInput { + name: string + scheduleExpression?: string + eventPattern?: string + state?: 'ENABLED' | 'DISABLED' + description?: string + targets: EventBridgeRuleTarget[] +} + +export interface EventBridgeRuleOverview extends EventBridgeRuleInput { + busName: string + busId: string + arn: string | null + state: 'ENABLED' | 'DISABLED' +} + export interface SqsLambdaEventSourceMappingInput { functionName: string queueArn: string @@ -608,6 +652,10 @@ export interface CloudServiceAdapter { createDatabaseSnapshot?(input: CreateDatabaseSnapshotInput): Promise listDatabaseOrderableInstanceClasses?(engine?: string): Promise invoke?(id: string, payload: string): Promise + publish?(id: string, input: PublishNotificationInput): Promise + createSnsSubscription?(topicArn: string, input: SnsSubscriptionInput): Promise + listSnsSubscriptions?(topicArn: string): Promise + deleteSnsSubscription?(subscriptionArn: string): Promise createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise listApiGatewayRoutes?(apiId: string): Promise @@ -618,6 +666,9 @@ export interface CloudServiceAdapter { createEventBridgeLambdaTrigger?(input: EventBridgeLambdaTriggerInput): Promise listEventBridgeLambdaTriggers?(functionName: string): Promise deleteEventBridgeLambdaTrigger?(functionName: string, triggerId: string): Promise + listEventBridgeRules?(eventBusId: string): Promise + createEventBridgeRule?(eventBusId: string, input: EventBridgeRuleInput): Promise + deleteEventBridgeRule?(eventBusId: string, ruleName: string): Promise createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise listSqsLambdaEventSourceMappings?(functionName: string): Promise deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index a709d0a..08e53da 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -36,6 +36,7 @@ import {AwsCloudFormationAdapter} from './adapter-aws/AwsCloudFormationAdapter' import {AwsSecretsAdapter} from './adapter-aws/AwsSecretsAdapter' import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter' import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter' +import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -67,13 +68,14 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsKmsAdapter(clients.kms), new AwsStepFunctionsAdapter(clients.sfn), new AwsLoadBalancingAdapter(clients.elbv2), - new AwsEventBridgeAdapter(clients.eventbridge), + new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda), new AwsIamAdapter(clients.iam), new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)), new AwsCloudFormationAdapter(clients.cloudformation), new AwsSecretsAdapter(clients.secretsManager), new AwsSesAdapter(), new AwsAppConfigAdapter(clients.appConfig), + new AwsSnsAdapter(clients.sns), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 70e73ae..23e1b30 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -17,6 +17,8 @@ import {awsKmsSchema} from '../cloud-spi/kmsSchema' import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema' import {awsSesEmailSchema} from '../cloud-spi/emailSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' +import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' +import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema' import type { AppConfigConfigurationProfile, AppConfigDeployment, @@ -99,6 +101,88 @@ function appWithRoutes( return app } +describe('EventBridge rule routes', () => { + test('routes rule lifecycle requests through the Events adapter using the event bus resource id', async () => { + const calls: Array<{operation: string; args: unknown[]}> = [] + const adapter = mockAdapter('aws', { + service: 'events', + schema: awsEventBridgeSchema, + listEventBridgeRules: async (...args) => { + calls.push({operation: 'list', args}) + return [] + }, + createEventBridgeRule: async (...args) => { + calls.push({operation: 'create', args}) + return { + name: 'hourly', busName: 'orders', busId: 'event-bus:orders', arn: null, + scheduleExpression: 'rate(1 hour)', state: 'ENABLED', targets: [], + } + }, + deleteEventBridgeRule: async (...args) => { + calls.push({operation: 'delete', args}) + }, + }) + const app = appWithRoutes([adapter]) + + const create = await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + name: 'hourly', scheduleExpression: 'rate(1 hour)', + targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}], + }), + }) + expect(create.status).toBe(201) + await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules') + const remove = await app.request('/api/clouds/aws/services/events/resources/event-bus:orders/rules/hourly', {method: 'DELETE'}) + + expect(remove.status).toBe(200) + expect(calls).toEqual([ + {operation: 'create', args: ['event-bus:orders', {name: 'hourly', scheduleExpression: 'rate(1 hour)', eventPattern: undefined, state: undefined, description: undefined, targets: [{id: 'worker', arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}]}]}, + {operation: 'list', args: ['event-bus:orders']}, + {operation: 'delete', args: ['event-bus:orders', 'hourly']}, + ]) + }) +}) + +describe('SNS subscription routes', () => { + test('routes subscription lifecycle requests through the Notifications adapter', async () => { + const calls: Array<{operation: string; args: unknown[]}> = [] + const adapter = mockAdapter('aws', { + service: 'notifications', + schema: awsNotificationsSchema, + createSnsSubscription: async (...args) => { + calls.push({operation: 'create', args}) + return {arn: 'subscription-1', protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders', status: 'Confirmed', filterPolicy: null} + }, + listSnsSubscriptions: async (...args) => { + calls.push({operation: 'list', args}) + return [] + }, + deleteSnsSubscription: async (...args) => { + calls.push({operation: 'delete', args}) + }, + }) + const app = appWithRoutes([adapter]) + + const create = await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders'}), + }) + expect(create.status).toBe(201) + await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions') + const remove = await app.request('/api/clouds/aws/services/notifications/resources/orders/subscriptions/subscription-1', {method: 'DELETE'}) + + expect(remove.status).toBe(200) + expect(calls).toEqual([ + {operation: 'create', args: ['orders', {protocol: 'sqs', endpoint: 'arn:aws:sqs:us-east-1:000000000000:orders'}]}, + {operation: 'list', args: ['orders']}, + {operation: 'delete', args: ['subscription-1']}, + ]) + }) +}) + const databaseSnapshot: DatabaseSnapshot = { id: 'orders-db-snapshot-1', name: 'orders-db-snapshot-1', diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index ce07a77..6b26598 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -620,6 +620,42 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.get('/:cloud/services/events/resources/:id/rules', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => c.json(await svc(c).listEventBridgeRules(cloud, c.req.param('id')))) + }) + + app.post('/:cloud/services/events/resources/:id/rules', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await c.req.json>() + const rule = await svc(c).createEventBridgeRule(cloud, c.req.param('id'), { + name: typeof input.name === 'string' ? input.name : '', + scheduleExpression: typeof input.scheduleExpression === 'string' ? input.scheduleExpression : undefined, + eventPattern: typeof input.eventPattern === 'string' ? input.eventPattern : undefined, + state: input.state === 'DISABLED' ? 'DISABLED' : input.state === 'ENABLED' ? 'ENABLED' : undefined, + description: typeof input.description === 'string' ? input.description : undefined, + targets: Array.isArray(input.targets) ? input.targets.flatMap((target) => { + if (!target || typeof target !== 'object' || Array.isArray(target)) return [] + const value = target as {id?: unknown; arn?: unknown} + return typeof value.id === 'string' && typeof value.arn === 'string' ? [{id: value.id, arn: value.arn}] : [] + }) : [], + }) + return c.json(rule, 201) + }) + }) + + app.delete('/:cloud/services/events/resources/:id/rules/:ruleName', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + await svc(c).deleteEventBridgeRule(cloud, c.req.param('id'), c.req.param('ruleName')) + return c.json({ok: true}) + }) + }) + app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) @@ -867,6 +903,60 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.post('/:cloud/services/notifications/resources/:id/publish', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await jsonBody<{message?: unknown; subject?: unknown; messageAttributes?: unknown}>(c) + if (typeof body.message !== 'string' || !body.message) { + throw new ValidationError('message is required') + } + if (body.subject !== undefined && typeof body.subject !== 'string') { + throw new ValidationError('subject must be a string') + } + if (body.messageAttributes !== undefined && !isStringRecord(body.messageAttributes)) { + throw new ValidationError('messageAttributes must be an object of string values') + } + const result = await svc(c).publishNotification(cloud, c.req.param('id'), { + message: body.message, + ...(body.subject ? {subject: body.subject} : {}), + ...(body.messageAttributes ? {messageAttributes: body.messageAttributes} : {}), + }) + return c.json(result) + }) + }) + + app.get('/:cloud/services/notifications/resources/:id/subscriptions', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + return withRuntime(c, async () => c.json(await svc(c).listSnsSubscriptions(cloud, c.req.param('id')))) + }) + + app.post('/:cloud/services/notifications/resources/:id/subscriptions', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + return withRuntime(c, async () => { + const body = await jsonBody<{protocol?: unknown; endpoint?: unknown}>(c) + if (typeof body.protocol !== 'string' || !body.protocol.trim()) throw new ValidationError('protocol is required') + if (typeof body.endpoint !== 'string' || !body.endpoint.trim()) throw new ValidationError('endpoint is required') + const subscription = await svc(c).createSnsSubscription(cloud, c.req.param('id'), { + protocol: body.protocol, + endpoint: body.endpoint, + }) + return c.json(subscription, 201) + }) + }) + + app.delete('/:cloud/services/notifications/resources/:id/subscriptions/:subscriptionArn', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + return withRuntime(c, async () => { + await svc(c).deleteSnsSubscription(cloud, c.req.param('subscriptionArn')) + return c.json({ok: true}) + }) + }) + app.post('/:cloud/services/kms/resources/:id/encrypt', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -984,6 +1074,13 @@ function encryptionContext(value: unknown): Record | undefined { return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record } +function isStringRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.values(value).every((item) => typeof item === 'string') +} + function stringHeaders(value: unknown): Record { if (value === undefined) return {} if (!value || typeof value !== 'object' || Array.isArray(value)) { diff --git a/packages/api/src/routes/ec2.test.ts b/packages/api/src/routes/ec2.test.ts index 84eb3af..ccff367 100644 --- a/packages/api/src/routes/ec2.test.ts +++ b/packages/api/src/routes/ec2.test.ts @@ -12,6 +12,7 @@ import type { Ec2IpPermission, Ec2KeyPair, Ec2KeyPairMaterial, + Ec2KeyPairType, Ec2NatGateway, Ec2RouteTable, Ec2SecurityGroup, @@ -75,7 +76,7 @@ const fakeService = { getConsoleOutput: async (instanceId: string): Promise => ({instanceId, output: 'boot log'}), listAmis: async (): Promise => [], listKeyPairs: async (): Promise => [], - createKeyPair: async (name: string): Promise => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}), + createKeyPair: async (name: string, _keyType: Ec2KeyPairType = 'rsa'): Promise => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}), deleteKeyPair: async () => {}, listSecurityGroups: async (): Promise => [], createSecurityGroup: async () => ({groupId: 'sg-0a1b2c3d4e5f6a7b'}), @@ -257,6 +258,46 @@ describe('POST /key-pairs', () => { expect(body.keyName).toBe('my-key') expect(body.keyMaterial).toBe('---PEM---') }) + + test('accepts ed25519 key type', async () => { + let receivedKeyType: Ec2KeyPairType | undefined + const router = createEc2Router({...fakeService, createKeyPair: async (name: string, keyType: Ec2KeyPairType) => { + receivedKeyType = keyType + return {keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'} + }}) + const res = await router.request('/key-pairs', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'my-ed25519-key', keyType: 'ed25519'}), + }) + expect(res.status).toBe(200) + expect(receivedKeyType).toBe('ed25519') + }) + + test('defaults key type to rsa', async () => { + let receivedKeyType: Ec2KeyPairType | undefined + const router = createEc2Router({...fakeService, createKeyPair: async (name: string, keyType: Ec2KeyPairType) => { + receivedKeyType = keyType + return {keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'} + }}) + const res = await router.request('/key-pairs', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'my-rsa-key'}), + }) + expect(res.status).toBe(200) + expect(receivedKeyType).toBe('rsa') + }) + + test('rejects unsupported key types', async () => { + const res = await app.request('/key-pairs', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'my-key', keyType: 'dsa'}), + }) + expect(res.status).toBe(400) + expect((await res.json() as {error: string}).error).toBe('keyType must be rsa or ed25519') + }) }) describe('GET /internet-gateways', () => { diff --git a/packages/api/src/routes/ec2.ts b/packages/api/src/routes/ec2.ts index 7000fb6..d381792 100644 --- a/packages/api/src/routes/ec2.ts +++ b/packages/api/src/routes/ec2.ts @@ -4,6 +4,7 @@ import type { CreateAmiInput, CreateNatGatewayInput, CreateRouteInput, + Ec2KeyPairType, Ec2Tag, IpPermissionInput, RunInstanceInput, @@ -12,6 +13,11 @@ import type { import {ec2Service} from '../services/ec2' type Ec2Service = typeof ec2Service +type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType} + +function isEc2KeyPairType(value: unknown): value is Ec2KeyPairType { + return value === 'rsa' || value === 'ed25519' +} function handleError(c: Context, error: unknown) { const msg = error instanceof Error ? error.message : 'Unknown error' @@ -124,8 +130,11 @@ export function createEc2Router(svc: Ec2Service = ec2Service) { app.post('/key-pairs', async (c) => { try { - const {name} = await c.req.json<{name: string}>() - return c.json(await svc.createKeyPair(name)) + const {name, keyType = 'rsa'} = await c.req.json() + if (!isEc2KeyPairType(keyType)) { + return c.json({error: 'keyType must be rsa or ed25519'}, 400) + } + return c.json(await svc.createKeyPair(name, keyType)) } catch (error) { return handleError(c, error) } diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index bb3e1f4..4229810 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -30,6 +30,8 @@ import type { S3LambdaTriggerOverview, EventBridgeLambdaTriggerInput, EventBridgeLambdaTriggerOverview, + EventBridgeRuleInput, + EventBridgeRuleOverview, SqsLambdaEventSourceMappingInput, SqsLambdaEventSourceMappingOverview, DatabaseSnapshot, @@ -42,6 +44,10 @@ import type { KmsEncryptInput, KmsEncryptResult, NoSqlItem, + PublishNotificationInput, + PublishNotificationResult, + SnsSubscriptionInput, + SnsSubscriptionOverview, ResourceQuery, ServerlessInvokeResult, SqlConnectionInput, @@ -310,6 +316,24 @@ export class CloudProxyService { await adapter.deleteEventBridgeLambdaTrigger(functionName, triggerId) } + async listEventBridgeRules(cloud: CloudProvider, eventBusId: string): Promise { + const adapter = this.requireAdapter(cloud, 'events') + if (!adapter.listEventBridgeRules) throw new NotSupportedError(`EventBridge rules are not supported for ${cloud}`) + return adapter.listEventBridgeRules(eventBusId) + } + + async createEventBridgeRule(cloud: CloudProvider, eventBusId: string, input: EventBridgeRuleInput): Promise { + const adapter = this.requireAdapter(cloud, 'events') + if (!adapter.createEventBridgeRule) throw new NotSupportedError(`EventBridge rules are not supported for ${cloud}`) + return adapter.createEventBridgeRule(eventBusId, input) + } + + async deleteEventBridgeRule(cloud: CloudProvider, eventBusId: string, ruleName: string): Promise { + const adapter = this.requireAdapter(cloud, 'events') + if (!adapter.deleteEventBridgeRule) throw new NotSupportedError(`EventBridge rules are not supported for ${cloud}`) + await adapter.deleteEventBridgeRule(eventBusId, ruleName) + } + async createSqsLambdaEventSourceMapping(cloud: CloudProvider, input: SqsLambdaEventSourceMappingInput): Promise { const adapter = this.requireAdapter(cloud, 'serverless') if (!adapter.createSqsLambdaEventSourceMapping) throw new NotSupportedError(`SQS Lambda event source mappings are not supported for ${cloud}`) @@ -360,6 +384,29 @@ export class CloudProxyService { if (!adapter.invoke) throw new NotSupportedError(`${cloud}/${service} invoke is not supported`) return adapter.invoke(id, payload) } + + async publishNotification(cloud: CloudProvider, topicArn: string, input: PublishNotificationInput): Promise { + const adapter = this.requireAdapter(cloud, 'notifications') + if (!adapter.publish) throw new NotSupportedError(`Publishing notifications is not supported for ${cloud}`) + return adapter.publish(topicArn, input) + } + async createSnsSubscription(cloud: CloudProvider, topicArn: string, input: SnsSubscriptionInput): Promise { + const adapter = this.requireAdapter(cloud, 'notifications') + if (!adapter.createSnsSubscription) throw new NotSupportedError(`SNS subscriptions are not supported for ${cloud}`) + return adapter.createSnsSubscription(topicArn, input) + } + + async listSnsSubscriptions(cloud: CloudProvider, topicArn: string): Promise { + const adapter = this.requireAdapter(cloud, 'notifications') + if (!adapter.listSnsSubscriptions) throw new NotSupportedError(`SNS subscriptions are not supported for ${cloud}`) + return adapter.listSnsSubscriptions(topicArn) + } + + async deleteSnsSubscription(cloud: CloudProvider, subscriptionArn: string): Promise { + const adapter = this.requireAdapter(cloud, 'notifications') + if (!adapter.deleteSnsSubscription) throw new NotSupportedError(`SNS subscriptions are not supported for ${cloud}`) + await adapter.deleteSnsSubscription(subscriptionArn) + } async listObjects(cloud: CloudProvider, service: CloudServiceType, resourceId: string, prefix?: string): Promise { const adapter = this.requireAdapter(cloud, service) if (!adapter.listObjects) throw new NotSupportedError(`Object listing is not supported for ${cloud}/${service}`) diff --git a/packages/api/src/services/ec2.test.ts b/packages/api/src/services/ec2.test.ts index 7ca5b0d..017fbde 100644 --- a/packages/api/src/services/ec2.test.ts +++ b/packages/api/src/services/ec2.test.ts @@ -214,7 +214,7 @@ describe('ec2Service key pairs', () => { expect(result).toEqual([]) }) - test('createKeyPair sends CreateKeyPairCommand and returns material', async () => { + test('createKeyPair sends CreateKeyPairCommand with the requested key type and returns material', async () => { const sent: unknown[] = [] const client: EC2Client = { send: async (cmd: unknown) => { @@ -222,12 +222,25 @@ describe('ec2Service key pairs', () => { return {KeyPairId: 'kp-1', KeyName: 'my-key', KeyMaterial: '-----BEGIN RSA-----'} }, } as unknown as EC2Client - const result = await createEc2Service(client).createKeyPair('my-key') + const result = await createEc2Service(client).createKeyPair('my-key', 'ed25519') expect(sent[0]).toBeInstanceOf(CreateKeyPairCommand) + expect((sent[0] as CreateKeyPairCommand).input).toMatchObject({KeyName: 'my-key', KeyType: 'ed25519'}) expect(result.keyName).toBe('my-key') expect(result.keyMaterial).toBe('-----BEGIN RSA-----') }) + test('createKeyPair defaults to rsa', async () => { + const sent: CreateKeyPairCommand[] = [] + const client: EC2Client = { + send: async (cmd: CreateKeyPairCommand) => { + sent.push(cmd) + return {} + }, + } as unknown as EC2Client + await createEc2Service(client).createKeyPair('my-key') + expect(sent[0].input.KeyType).toBe('rsa') + }) + test('deleteKeyPair sends DeleteKeyPairCommand', async () => { const sent: unknown[] = [] const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client diff --git a/packages/api/src/services/ec2.ts b/packages/api/src/services/ec2.ts index 4085b10..417fb59 100644 --- a/packages/api/src/services/ec2.ts +++ b/packages/api/src/services/ec2.ts @@ -142,6 +142,8 @@ export type Ec2KeyPairMaterial = { keyMaterial: string } +export type Ec2KeyPairType = 'rsa' | 'ed25519' + export type Ec2Vpc = { vpcId: string cidrBlock: string @@ -588,8 +590,8 @@ export function createEc2Service(client: EC2Client = awsClients.ec2) { } }, - async createKeyPair(name: string): Promise { - const res = await client.send(new CreateKeyPairCommand({KeyName: name})) + async createKeyPair(name: string, keyType: Ec2KeyPairType = 'rsa'): Promise { + const res = await client.send(new CreateKeyPairCommand({KeyName: name, KeyType: keyType})) return { keyPairId: res.KeyPairId ?? '', keyName: res.KeyName ?? name, diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 9823c24..265137d 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -22,6 +22,10 @@ export const apiEndpointKeys = { update: "clouds.services.resources.update", delete: "clouds.services.resources.delete", invoke: "clouds.services.resources.invoke", + publish: "clouds.services.resources.notifications.publish", + createSnsSubscription: "clouds.services.resources.notifications.subscription.create", + listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list", + deleteSnsSubscription: "clouds.services.resources.notifications.subscription.delete", encrypt: "clouds.services.resources.encrypt", decrypt: "clouds.services.resources.decrypt", createApiGatewayRoute: "clouds.services.resources.apigateway.route.create", @@ -33,7 +37,10 @@ export const apiEndpointKeys = { deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete", createEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.create", listEventBridgeLambdaTriggers: "clouds.services.resources.serverless.eventbridge-triggers.list", - deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete", + deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete", + createEventBridgeRule: "clouds.services.resources.events.rule.create", + listEventBridgeRules: "clouds.services.resources.events.rules.list", + deleteEventBridgeRule: "clouds.services.resources.events.rule.delete", createSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.create", listSqsLambdaEventSourceMappings: "clouds.services.resources.serverless.sqs-event-source-mappings.list", deleteSqsLambdaEventSourceMapping: "clouds.services.resources.serverless.sqs-event-source-mapping.delete", @@ -297,7 +304,7 @@ export const endpointRegistry: EndpointRegistry = new Map([ }, ], [ - apiEndpointKeys.clouds.resources.invoke, + apiEndpointKeys.clouds.resources.invoke, { path: "/clouds/:cloud/services/:service/resources/:id/invoke", method: "POST", @@ -312,6 +319,38 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "apigateway" }, }, ], + [ + apiEndpointKeys.clouds.resources.publish, + { + path: "/clouds/:cloud/services/notifications/resources/:id/publish", + method: "POST", + telemetry: { service: "notifications" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.createSnsSubscription, + { + path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions", + method: "POST", + telemetry: { service: "notifications" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.listSnsSubscriptions, + { + path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions", + method: "GET", + telemetry: { service: "notifications" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.deleteSnsSubscription, + { + path: "/clouds/:cloud/services/notifications/resources/:id/subscriptions/:subscriptionArn", + method: "DELETE", + telemetry: { service: "notifications" }, + }, + ], [ apiEndpointKeys.clouds.resources.listApiGatewayRoutes, { @@ -384,6 +423,30 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "serverless" }, }, ], + [ + apiEndpointKeys.clouds.resources.createEventBridgeRule, + { + path: "/clouds/:cloud/services/events/resources/:id/rules", + method: "POST", + telemetry: { service: "events" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.listEventBridgeRules, + { + path: "/clouds/:cloud/services/events/resources/:id/rules", + method: "GET", + telemetry: { service: "events" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.deleteEventBridgeRule, + { + path: "/clouds/:cloud/services/events/resources/:id/rules/:ruleName", + method: "DELETE", + telemetry: { service: "events" }, + }, + ], [ apiEndpointKeys.clouds.resources.createSqsLambdaEventSourceMapping, { diff --git a/packages/frontend/src/api/aws/ec2.api.ts b/packages/frontend/src/api/aws/ec2.api.ts index 60fadd2..68665ef 100644 --- a/packages/frontend/src/api/aws/ec2.api.ts +++ b/packages/frontend/src/api/aws/ec2.api.ts @@ -77,6 +77,8 @@ export type Ec2KeyPairMaterial = { keyMaterial: string; }; +export type Ec2KeyPairType = "rsa" | "ed25519"; + export type Ec2Vpc = { vpcId: string; cidrBlock: string; @@ -344,10 +346,10 @@ export async function listEc2KeyPairs(signal?: AbortSignal): Promise { - const res = await apiClient.call( +export async function createEc2KeyPair(name: string, keyType: Ec2KeyPairType = "rsa"): Promise { + const res = await apiClient.call( apiEndpointKeys.aws.ec2.keyPairs.create, - { body: JSON.stringify({ name }), headers: { "content-type": "application/json" } }, + { body: { name, keyType } }, ); return res.data; } diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index d2cd720..575efa0 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -204,6 +204,16 @@ export interface ServerlessInvokeResult { executionDuration?: number; } +export interface PublishNotificationInput { + message: string; + subject?: string; + messageAttributes?: Record; +} + +export interface PublishNotificationResult { + messageId: string | null; +} + export interface CreateApiGatewayLambdaRouteInput { path: string; method: string; @@ -268,6 +278,19 @@ export interface S3LambdaTriggerOverview { suffix: string | null; } +export interface CreateSnsSubscriptionInput { + protocol: string; + endpoint: string; +} + +export interface SnsSubscriptionOverview { + arn: string | null; + protocol: string; + endpoint: string; + status: "Confirmed" | "PendingConfirmation"; + filterPolicy: string | null; +} + export interface CreateEventBridgeLambdaTriggerInput { ruleName: string; scheduleExpression: string; @@ -283,6 +306,27 @@ export interface EventBridgeLambdaTriggerOverview { state: string | null; } +export interface EventBridgeRuleTarget { + id: string; + arn: string; +} + +export interface CreateEventBridgeRuleInput { + name: string; + scheduleExpression?: string; + eventPattern?: string; + state?: "ENABLED" | "DISABLED"; + description?: string; + targets: EventBridgeRuleTarget[]; +} + +export interface EventBridgeRuleOverview extends CreateEventBridgeRuleInput { + busName: string; + busId: string; + arn: string | null; + state: "ENABLED" | "DISABLED"; +} + export interface CreateSqsLambdaEventSourceMappingInput { queueArn: string; batchSize?: number; @@ -393,6 +437,46 @@ export async function deleteS3LambdaTrigger( ); } +export async function createSnsSubscription( + cloud: CloudProvider, + topicArn: string, + input: CreateSnsSubscriptionInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.createSnsSubscription, + requestOptions(cloud, "notifications", {signal, body: input}), + {cloud, id: topicArn}, + ); + return res.data; +} + +export async function listSnsSubscriptions( + cloud: CloudProvider, + topicArn: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.listSnsSubscriptions, + requestOptions(cloud, "notifications", {signal}), + {cloud, id: topicArn}, + ); + return res.data; +} + +export async function deleteSnsSubscription( + cloud: CloudProvider, + topicArn: string, + subscriptionArn: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.deleteSnsSubscription, + requestOptions(cloud, "notifications", {signal}), + {cloud, id: topicArn, subscriptionArn}, + ); +} + export async function createEventBridgeLambdaTrigger( cloud: CloudProvider, functionName: string, @@ -433,6 +517,46 @@ export async function deleteEventBridgeLambdaTrigger( ); } +export async function createEventBridgeRule( + cloud: CloudProvider, + eventBusId: string, + input: CreateEventBridgeRuleInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.createEventBridgeRule, + requestOptions(cloud, "events", {signal, body: input}), + {cloud, id: eventBusId}, + ); + return res.data; +} + +export async function listEventBridgeRules( + cloud: CloudProvider, + eventBusId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.listEventBridgeRules, + requestOptions(cloud, "events", {signal}), + {cloud, id: eventBusId}, + ); + return res.data; +} + +export async function deleteEventBridgeRule( + cloud: CloudProvider, + eventBusId: string, + ruleName: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.deleteEventBridgeRule, + requestOptions(cloud, "events", {signal}), + {cloud, id: eventBusId, ruleName}, + ); +} + export async function createSqsLambdaEventSourceMapping( cloud: CloudProvider, functionName: string, @@ -525,6 +649,20 @@ export async function invokeCloudResource( return res.data; } +export async function publishNotification( + cloud: CloudProvider, + topicArn: string, + input: PublishNotificationInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.publish, + requestOptions(cloud, "notifications", { signal, body: input }), + { cloud, id: topicArn }, + ); + return res.data; +} + export async function encryptKmsResource( cloud: CloudProvider, id: string, diff --git a/packages/frontend/src/components/ComputePanel.tsx b/packages/frontend/src/components/ComputePanel.tsx index 7166389..1d925a4 100644 --- a/packages/frontend/src/components/ComputePanel.tsx +++ b/packages/frontend/src/components/ComputePanel.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, useState } from 'react' +import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AlertTriangle, @@ -10,6 +10,7 @@ import { RefreshCw, RotateCcw, Save, + Search, Terminal, Trash2, X, @@ -17,6 +18,7 @@ import { import { authorizeEc2SecurityGroupIngress, createEc2Ami, + createEc2KeyPair, createEc2SecurityGroup, deregisterEc2Ami, describeEc2Instance, @@ -42,6 +44,10 @@ import { import { createCloudResource } from '@/api/cloudProxyClient' import type { CloudProvider } from '@/types/cloud' import type { CloudResource } from '@/types/resource' +import amazonLinuxImage from '../../../../OS_img/amazon_Linux.png' +import debianImage from '../../../../OS_img/Debian_linux.png' +import redHatImage from '../../../../OS_img/red-hat-logo.png' +import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png' // ─── Props ──────────────────────────────────────────────────────────────────── @@ -343,11 +349,16 @@ function InstancePanel({ instanceId }: { instanceId: string }) { const query = useQuery({ queryKey: ['ec2', 'instance', instanceId], queryFn: ({ signal }) => describeEc2Instance(instanceId, signal), + refetchInterval: (query) => { + const state = query.state.data?.state + return state === 'pending' || state === 'stopping' || state === 'shutting-down' ? 1_500 : false + }, }) const invalidate = () => { void qc.invalidateQueries({ queryKey: ['ec2', 'instance', instanceId] }) void qc.invalidateQueries({ queryKey: ['ec2', 'instances'] }) + void qc.invalidateQueries({ queryKey: ['cloud-resources', 'aws', 'compute'] }) } const startMut = useMutation({ @@ -678,6 +689,32 @@ export function ComputePanel({ cloud, resource, runtimeReachable }: ComputePanel type InboundRule = { id: string; protocol: string; portInput: string; cidr: string } +type LaunchStep = 1 | 2 | 3 | 4 + +type AmiFamily = 'amazon-linux' | 'fedora' | 'ubuntu' | 'red-hat' | 'debian' | 'suse' | 'other' + +type AmiClassification = { family: AmiFamily; label: string; mark: string; color: string } + +function classifyAmi(ami: Ec2Image): AmiClassification { + const metadata = `${ami.name} ${ami.description ?? ''} ${ami.platform ?? ''}`.toLowerCase() + if (metadata.includes('ubuntu')) return { family: 'ubuntu', label: 'Ubuntu', mark: 'U', color: '#e95420' } + if (metadata.includes('amazon linux') || metadata.includes('amzn')) return { family: 'amazon-linux', label: 'Amazon Linux', mark: 'A', color: '#ff9900' } + if (metadata.includes('fedora')) return { family: 'fedora', label: 'Fedora', mark: 'F', color: '#51a2da' } + if (metadata.includes('red hat') || metadata.includes('rhel')) return { family: 'red-hat', label: 'Red Hat', mark: 'R', color: '#ee0000' } + if (metadata.includes('debian')) return { family: 'debian', label: 'Debian', mark: 'D', color: '#a80030' } + if (metadata.includes('suse')) return { family: 'suse', label: 'SUSE', mark: 'S', color: '#30ba78' } + return { family: 'other', label: ami.platform || 'Other', mark: '?', color: 'var(--text-2)' } +} + +const QUICK_START_AMIS: Array<{ family: Exclude; label: string; image: string; preferredImageId?: string }> = [ + { family: 'amazon-linux', label: 'Amazon Linux 2023', image: amazonLinuxImage, preferredImageId: 'ami-0abcdef1234567891' }, + { family: 'fedora', label: 'Fedora', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2351a2da"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="34" font-weight="bold" fill="white"%3EF%3C/text%3E%3C/svg%3E' }, + { family: 'ubuntu', label: 'Ubuntu', image: ubuntuImage }, + { family: 'suse', label: 'openSUSE', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2330ba78"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="30" font-weight="bold" fill="white"%3ES%3C/text%3E%3C/svg%3E' }, + { family: 'red-hat', label: 'Red Hat', image: redHatImage }, + { family: 'debian', label: 'Debian', image: debianImage }, +] + const SG_PRESETS: Array<{ label: string; protocol: string; portInput: string; cidr: string }> = [ { label: 'SSH', protocol: 'tcp', portInput: '22', cidr: '0.0.0.0/0' }, { label: 'HTTP', protocol: 'tcp', portInput: '80', cidr: '0.0.0.0/0' }, @@ -714,18 +751,23 @@ export function LaunchInstanceForm({ onCancel: () => void }) { const qc = useQueryClient() - - // Instance fields + const [step, setStep] = useState(1) + const [stepError, setStepError] = useState('') const [name, setName] = useState('') const [imageId, setImageId] = useState(selectedResource?.type === 'image' ? selectedResource.id : '') + const [architectureFilter, setArchitectureFilter] = useState('') + const [amiSearch, setAmiSearch] = useState('') + const [showAmiBrowser, setShowAmiBrowser] = useState(false) const [instanceType, setInstanceType] = useState('t3.micro') - - // Networking fields const [keyName, setKeyName] = useState('') + const [keyPairMode, setKeyPairMode] = useState<'existing' | 'new'>('existing') + const [newKeyName, setNewKeyName] = useState('') + const [newKeyType, setNewKeyType] = useState<'rsa' | 'ed25519'>('rsa') + const [createdKeyMaterial, setCreatedKeyMaterial] = useState<{ name: string; material: string }>() + const [rootVolumeSize, setRootVolumeSize] = useState('20') + const [rootVolumeType, setRootVolumeType] = useState('gp3') const [subnetId, setSubnetId] = useState('') const [selectedSgs, setSelectedSgs] = useState([]) - - // Inline SG creation const [showCreateSg, setShowCreateSg] = useState(false) const [newSgName, setNewSgName] = useState('') const [newSgDesc, setNewSgDesc] = useState('') @@ -756,6 +798,15 @@ export function LaunchInstanceForm({ }, }) + const createKeyMut = useMutation({ + mutationFn: () => createEc2KeyPair(newKeyName.trim(), newKeyType), + onSuccess: (keyPair) => { + setKeyName(keyPair.keyName) + setCreatedKeyMaterial({ name: keyPair.keyName, material: keyPair.keyMaterial }) + void qc.invalidateQueries({ queryKey: ['ec2', 'key-pairs'] }) + }, + }) + const createSgMut = useMutation({ mutationFn: async (vars: {name: string; desc: string; vpc: string; rules: InboundRule[]}) => { const { groupId } = await createEc2SecurityGroup(vars.name, vars.desc || vars.name, vars.vpc || undefined) @@ -793,8 +844,7 @@ export function LaunchInstanceForm({ setSelectedSgs((prev) => checked ? [...prev, groupId] : prev.filter((id) => id !== groupId)) } - function submit(e: FormEvent) { - e.preventDefault() + function launch() { createMut.mutate({ name, imageId, @@ -802,9 +852,34 @@ export function LaunchInstanceForm({ keyName: keyName || undefined, subnetId: subnetId || undefined, securityGroupIds: selectedSgs.length ? selectedSgs.join(',') : undefined, + rootVolumeSize, + rootVolumeType, }) } + function nextStep() { + if (step === 1 && (!name.trim() || !imageId)) { + setStepError('Enter an instance name and select an AMI to continue.') + return + } + if (step === 2 && keyPairMode === 'new' && !keyName) { + setStepError('Create the new key pair before continuing, or choose an existing key pair.') + return + } + setStepError('') + setStep((current) => Math.min(4, current + 1) as LaunchStep) + } + + function downloadPrivateKey() { + if (!createdKeyMaterial) return + const href = URL.createObjectURL(new Blob([createdKeyMaterial.material], { type: 'application/x-pem-file' })) + const link = document.createElement('a') + link.href = href + link.download = `${createdKeyMaterial.name}.pem` + link.click() + URL.revokeObjectURL(href) + } + const vpcs = vpcsQ.data ?? [] const subnets = subsQ.data ?? [] const groupByVpc = vpcs.length > 2 @@ -814,75 +889,148 @@ export function LaunchInstanceForm({ return tag ? `${tag} (${vpc.vpcId})` : vpc.vpcId } + const amis = amisQ.data ?? [] + const architectures = [...new Set(amis.map((ami) => ami.architecture).filter((architecture): architecture is string => Boolean(architecture)))].sort() + const filteredAmis = amis.filter((ami) => !architectureFilter || ami.architecture === architectureFilter) + const quickStartAmis = QUICK_START_AMIS.map((catalogAmi) => ({ + ...catalogAmi, + ami: filteredAmis.find((candidate) => candidate.imageId === catalogAmi.preferredImageId) + ?? filteredAmis.find((candidate) => classifyAmi(candidate).family === catalogAmi.family), + })) + const quickStartImageIds = new Set(quickStartAmis.flatMap((catalogAmi) => catalogAmi.ami ? [catalogAmi.ami.imageId] : [])) + const normalizedAmiSearch = amiSearch.trim().toLowerCase() + const visibleQuickStartAmis = quickStartAmis.filter((catalogAmi) => { + const searchable = `${catalogAmi.label} ${catalogAmi.ami?.imageId ?? ''} ${catalogAmi.ami?.name ?? ''} ${catalogAmi.ami?.description ?? ''}`.toLowerCase() + return !normalizedAmiSearch || searchable.includes(normalizedAmiSearch) + }) + const browsableAmis = filteredAmis.filter((ami) => { + const searchable = `${ami.imageId} ${ami.name} ${ami.description ?? ''} ${classifyAmi(ami).label}`.toLowerCase() + return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch)) + }) + const selectedAmi = amis.find((ami) => ami.imageId === imageId) + const selectedSubnet = subnets.find((subnet) => subnet.subnetId === subnetId) + const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId)) + const steps: Array<{ number: LaunchStep; label: string }> = [ + { number: 1, label: 'Name and AMI' }, + { number: 2, label: 'Instance and key pair' }, + { number: 3, label: 'Network and security' }, + { number: 4, label: 'Storage and review' }, + ] + return ( -
- {/* ── Required ──────────────────────────────────────────── */} -
Required
+ { event.preventDefault(); if (step === 4) launch() }}> +
+ {steps.map((item) => ( + + ))} +
- + {step === 1 && <> +
Name and AMI
+ + +
+ Amazon Machine Image * +
+
+
+ {amisQ.isLoading && Loading AMIs…} + {!amisQ.isLoading && <> +
Quick Start
+
+ {visibleQuickStartAmis.map((catalogAmi) => ( + + ))} +
+ } + {!amisQ.isLoading && amis.length === 0 && No AMIs are available.} + {!amisQ.isLoading && amis.length > 0 && <> + + {showAmiBrowser &&
+ {browsableAmis.map((ami) => { + const distro = classifyAmi(ami) + return + })} + {browsableAmis.length === 0 && No additional AMIs match this search and architecture.} +
} + } +
+
+ } - + {step === 2 && <> +
Instance type and key pair
+ +
+ Key pair +
+ + +
+ {keyPairMode === 'existing' ? ( + + ) :
+ { setNewKeyName(e.target.value); setKeyName('') }} placeholder="Key pair name" /> + + + {createdKeyMaterial && } +
} + {createKeyMut.error instanceof Error &&

{createKeyMut.error.message}

} + {createdKeyMaterial &&

The private key is available only from this creation response. Download it before leaving this wizard.

} +
+ } - - - {/* ── Networking — optional ─────────────────────────────── */} -
Networking — optional
- - - - - - {/* ── Security Groups ───────────────────────────────────── */} -
+ {step === 3 && <> +
Network and security
+ +
Security Groups
+
+ } - {createMut.error instanceof Error && ( -
{createMut.error.message}
- )} + {step === 4 && <> +
Storage and review
+ + +
+ Review: fields sent to RunInstances +
+
Name{name}
+
AMI{selectedAmi?.name || imageId} ({imageId})
+
Instance type{instanceType}
+
Key pair{keyName || 'Not sent'}
+
Subnet{selectedSubnet ? `${selectedSubnet.availabilityZone} (${subnetId})` : 'Not sent'}
+
Security groups{selectedGroups.length ? selectedGroups.map((group) => group.groupName || group.groupId).join(', ') : 'Not sent'}
+
Root volume{rootVolumeSize} GiB {rootVolumeType}, delete on termination
+
+
+ } -
- - + {stepError &&
{stepError}
} + {createMut.error instanceof Error &&
{createMut.error.message}
} +
+ + {step < 4 ? : }
) diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index c84dfc6..a3030f4 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -48,9 +48,11 @@ import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel"; import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel"; import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel"; import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel"; +import { EventBridgeRulePanel } from "@/components/EventBridgeRulePanel"; import { LambdaInvocationHistoryPanel } from "@/components/LambdaInvocationHistoryPanel"; import { LambdaCreatePanel } from "@/components/LambdaCreatePanel"; import { bucketDeleteWarning } from "@/lib/bucketDeletion"; +import { SnsTopicPanel } from "@/components/SnsTopicPanel"; interface DynamicResourceViewProps { cloud: CloudProvider; @@ -87,6 +89,12 @@ export function DynamicResourceView({ const [bulkDeleteError, setBulkDeleteError] = useState(null); const [deleteWarning, setDeleteWarning] = useState(null); + useEffect(() => { + if (cloud === "aws" && service === "compute") { + setCreateOpen(true); + } + }, [cloud, service]); + const handleDatabaseTabChange = (tab: "instances" | "snapshots") => { setDatabaseTab(tab); @@ -766,7 +774,7 @@ export function DynamicResourceView({ )}
)} - {renderResourceSurface({ + {!(cloud === "aws" && service === "compute" && createOpen) && renderResourceSurface({ schema, resources, selectedId: activeSelected?.id, @@ -872,6 +880,13 @@ export function DynamicResourceView({ runtimeReachable={canUseRuntime} /> )} + {service === "events" && ( + + )} {service === "configuration" && cloud === "aws" && ( )} + {service === "notifications" && ( + + )} {editingResource && schema.updateFields && (
({ + createEventBridgeRule: vi.fn(), + deleteEventBridgeRule: vi.fn(), + listCloudResources: vi.fn(), + listEventBridgeRules: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => apiMocks) + +const eventBus: CloudResource = {id: 'event-bus:orders', name: 'orders', cloud: 'aws', service: 'events', type: 'event-bus', region: null, createdAt: null, metadata: {}} +const lambda: CloudResource = {id: 'worker', name: 'worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null, metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:worker'}} +const topic: CloudResource = {id: 'orders-topic', name: 'orders-topic', cloud: 'aws', service: 'notifications', type: 'topic', region: null, createdAt: null, metadata: {arn: 'arn:aws:sns:us-east-1:000000000000:orders-topic'}} + +function renderPanel() { + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + render() +} + +test('creates a scheduled EventBridge rule with the selected resource ARN', async () => { + apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'serverless' ? [lambda] : [])) + apiMocks.listEventBridgeRules.mockResolvedValue([]) + apiMocks.createEventBridgeRule.mockResolvedValue({name: 'hourly-report'}) + const user = userEvent.setup() + renderPanel() + + await screen.findByRole('option', {name: 'worker'}) + await user.type(screen.getByLabelText(/rule name/i), 'hourly-report') + await user.selectOptions(screen.getByLabelText(/target resource/i), String(lambda.metadata.arn)) + expect(screen.getByText(/No Lambda, SQS queue, SNS topic, or Step Functions resources will be created/i)).toBeVisible() + await user.click(screen.getByRole('button', {name: 'Create rule'})) + + await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', 'event-bus:orders', { + name: 'hourly-report', scheduleExpression: 'rate(5 minutes)', state: 'ENABLED', targets: [{id: 'worker', arn: lambda.metadata.arn}], + })) +}) + +test('creates an EventBridge rule with a selected SNS topic target', async () => { + apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'notifications' ? [topic] : [])) + apiMocks.listEventBridgeRules.mockResolvedValue([]) + apiMocks.createEventBridgeRule.mockResolvedValue({name: 'topic-notifications'}) + const user = userEvent.setup() + renderPanel() + + await screen.findByRole('option', {name: 'orders-topic'}) + await user.selectOptions(screen.getByLabelText(/target service/i), 'sns') + await user.type(screen.getByLabelText(/rule name/i), 'topic-notifications') + await user.selectOptions(screen.getByLabelText(/target resource/i), String(topic.metadata.arn)) + await user.click(screen.getByRole('button', {name: 'Create rule'})) + + await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, { + name: 'topic-notifications', scheduleExpression: 'rate(5 minutes)', state: 'ENABLED', targets: [{id: 'orders-topic', arn: topic.metadata.arn}], + })) +}) + +test('creates an event-pattern rule and confirms before deletion', async () => { + apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'serverless' ? [lambda] : [])) + apiMocks.listEventBridgeRules.mockResolvedValue([{name: 'billing-events', busName: 'orders', busId: eventBus.id, arn: null, eventPattern: '{"source":["billing"]}', state: 'DISABLED', targets: [{id: 'worker', arn: String(lambda.metadata.arn)}]}]) + apiMocks.createEventBridgeRule.mockResolvedValue({name: 'billing-events'}) + apiMocks.deleteEventBridgeRule.mockResolvedValue(undefined) + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByLabelText(/mode/i)) + await user.selectOptions(screen.getByLabelText(/mode/i), 'event-pattern') + await user.type(screen.getByLabelText(/rule name/i), 'billing-events') + await user.selectOptions(screen.getByLabelText(/target resource/i), String(lambda.metadata.arn)) + await user.click(screen.getByRole('button', {name: 'Create rule'})) + await waitFor(() => expect(apiMocks.createEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, expect.objectContaining({eventPattern: '{\n "source": ["orders"]\n}'}))) + + await user.click(await screen.findByRole('button', {name: 'Delete rule billing-events'})) + expect(screen.getByText('Delete rule?')).toBeVisible() + await user.click(screen.getByRole('button', {name: 'Delete'})) + await waitFor(() => expect(apiMocks.deleteEventBridgeRule).toHaveBeenCalledWith('aws', eventBus.id, 'billing-events')) +}) diff --git a/packages/frontend/src/components/EventBridgeRulePanel.tsx b/packages/frontend/src/components/EventBridgeRulePanel.tsx new file mode 100644 index 0000000..3159c81 --- /dev/null +++ b/packages/frontend/src/components/EventBridgeRulePanel.tsx @@ -0,0 +1,170 @@ +import {useEffect, useState} from 'react' +import {CalendarClock, Loader2, Plus, Radio, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {createEventBridgeRule, deleteEventBridgeRule, listCloudResources, listEventBridgeRules, type EventBridgeRuleOverview} from '@/api/cloudProxyClient' +import type {CloudProvider, CloudServiceType} from '@/types/cloud' +import type {CloudResource} from '@/types/resource' + +interface EventBridgeRulePanelProps { + cloud: CloudProvider + resource?: CloudResource + runtimeReachable: boolean +} + +type TargetService = 'lambda' | 'sqs' | 'sns' | 'step-functions' + +const targetServiceDetails: Record boolean}> = { + lambda: {label: 'Lambda', service: 'serverless', matches: (resource) => resource.type === 'lambda'}, + sqs: {label: 'SQS', service: 'messaging', matches: (resource) => resource.type === 'queue' || resource.type === 'fifo-queue'}, + sns: {label: 'SNS', service: 'notifications', matches: (resource) => resource.type === 'topic'}, + 'step-functions': {label: 'Step Functions', service: 'workflows', matches: (resource) => resource.type === 'state-machine'}, +} + +export function EventBridgeRulePanel({cloud, resource, runtimeReachable}: EventBridgeRulePanelProps) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [mode, setMode] = useState<'schedule' | 'event-pattern'>('schedule') + const [scheduleExpression, setScheduleExpression] = useState('rate(5 minutes)') + const [eventPattern, setEventPattern] = useState('{\n "source": ["orders"]\n}') + const [enabled, setEnabled] = useState(true) + const [targetService, setTargetService] = useState('lambda') + const [targetArn, setTargetArn] = useState('') + const [deleteTarget, setDeleteTarget] = useState(null) + const queryClient = useQueryClient() + const targetQueries = { + serverless: useQuery({ + queryKey: ['cloud-resources', cloud, 'serverless'], + queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal), + enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable, + }), + messaging: useQuery({ + queryKey: ['cloud-resources', cloud, 'messaging'], + queryFn: ({signal}) => listCloudResources(cloud, 'messaging', undefined, signal), + enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable, + }), + notifications: useQuery({ + queryKey: ['cloud-resources', cloud, 'notifications'], + queryFn: ({signal}) => listCloudResources(cloud, 'notifications', undefined, signal), + enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable, + }), + workflows: useQuery({ + queryKey: ['cloud-resources', cloud, 'workflows'], + queryFn: ({signal}) => listCloudResources(cloud, 'workflows', undefined, signal), + enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable, + }), + } + const rulesQuery = useQuery({ + queryKey: ['eventbridge-rules', cloud, resource?.id], + queryFn: ({signal}) => listEventBridgeRules(cloud, resource!.id, signal), + enabled: cloud === 'aws' && resource?.service === 'events' && resource.type === 'event-bus' && runtimeReachable, + }) + + useEffect(() => { + setName('') + setDescription('') + setMode('schedule') + setScheduleExpression('rate(5 minutes)') + setEventPattern('{\n "source": ["orders"]\n}') + setEnabled(true) + setTargetService('lambda') + setTargetArn('') + setDeleteTarget(null) + }, [resource?.id]) + + const resourcesByTargetService = (Object.keys(targetServiceDetails) as TargetService[]).reduce>((resources, service) => { + const details = targetServiceDetails[service] + const sourceResources = details.service === 'serverless' + ? targetQueries.serverless.data ?? [] + : details.service === 'messaging' + ? targetQueries.messaging.data ?? [] + : details.service === 'notifications' + ? targetQueries.notifications.data ?? [] + : targetQueries.workflows.data ?? [] + resources[service] = sourceResources.filter((candidate) => details.matches(candidate) && typeof candidate.metadata.arn === 'string' && candidate.metadata.arn.length > 0) + return resources + }, {lambda: [], sqs: [], sns: [], 'step-functions': []}) + const availableTargetServices = (Object.keys(targetServiceDetails) as TargetService[]).filter((service) => resourcesByTargetService[service].length > 0) + const targetResources = resourcesByTargetService[targetService] + const selectedTarget = targetResources.find((candidate) => candidate.metadata.arn === targetArn) + const patternValid = mode !== 'event-pattern' || isJsonObject(eventPattern) + + useEffect(() => { + if (availableTargetServices.length > 0 && !availableTargetServices.includes(targetService)) { + setTargetService(availableTargetServices[0]) + setTargetArn('') + } + }, [availableTargetServices, targetService]) + + const createMutation = useMutation({ + mutationFn: () => createEventBridgeRule(cloud, resource!.id, { + name: name.trim(), + ...(mode === 'schedule' ? {scheduleExpression: scheduleExpression.trim()} : {eventPattern: eventPattern.trim()}), + state: enabled ? 'ENABLED' : 'DISABLED', + ...(description.trim() ? {description: description.trim()} : {}), + targets: [{id: targetId(selectedTarget!), arn: targetArn}], + }), + onSuccess: () => { + setName('') + setDescription('') + setTargetArn('') + void queryClient.invalidateQueries({queryKey: ['eventbridge-rules', cloud, resource?.id]}) + }, + }) + const canCreate = Boolean(name.trim() && targetArn && (mode === 'schedule' ? scheduleExpression.trim() : eventPattern.trim()) && patternValid) && !createMutation.isPending + const deleteMutation = useMutation({ + mutationFn: (rule: EventBridgeRuleOverview) => deleteEventBridgeRule(cloud, resource!.id, rule.name), + onSuccess: () => { + setDeleteTarget(null) + void queryClient.invalidateQueries({queryKey: ['eventbridge-rules', cloud, resource?.id]}) + }, + }) + + if (!resource || resource.service !== 'events' || resource.type !== 'event-bus') return null + + return
+
+
+

EventBridge Rules

+

Add rule to {resource.name}

+

Route a schedule or matching events from this event bus to one existing target.

+
+
+
+
{ + event.preventDefault() + if (canCreate) createMutation.mutate() + }}> + + + + {mode === 'schedule' ? :