feat: EC2 UI polished + storage persisting

This commit is contained in:
Zakaria
2026-09-25 18:18:33 -04:00
parent 1b129e9dcf
commit 093afb9b2b
40 changed files with 2586 additions and 102 deletions
@@ -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')
})
@@ -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,
@@ -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<unknown>
@@ -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,
)
}
@@ -2,10 +2,19 @@ import {
CreateEventBusCommand,
DeleteEventBusCommand,
DescribeEventBusCommand,
DeleteRuleCommand,
ListEventBusesCommand,
ListRulesCommand,
ListTargetsByRuleCommand,
PutRuleCommand,
PutTargetsCommand,
RemoveTargetsCommand,
type EventBridgeClient,
type EventBus,
type Rule,
type Target,
} from '@aws-sdk/client-eventbridge'
import {AddPermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {ValidationError} from '../cloud-spi/errors'
import type {
@@ -13,6 +22,9 @@ import type {
CloudServiceAdapter,
CloudServiceDescriptorOverride,
CreateResourceInput,
EventBridgeRuleInput,
EventBridgeRuleOverview,
EventBridgeRuleTarget,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
@@ -24,7 +36,10 @@ export class AwsEventBridgeAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'events' as const
constructor(private readonly eventbridge: EventBridgeClient) {}
constructor(
private readonly eventbridge: EventBridgeClient,
private readonly lambda: LambdaClient,
) {}
schema(): ServiceSchema {
return awsEventBridgeSchema()
@@ -67,6 +82,90 @@ export class AwsEventBridgeAdapter implements CloudServiceAdapter {
async delete(id: string): Promise<void> {
await this.eventbridge.send(new DeleteEventBusCommand({Name: eventBusName(id)}))
}
async listEventBridgeRules(eventBusId: string): Promise<EventBridgeRuleOverview[]> {
const busName = eventBusName(eventBusId)
const rules: Rule[] = []
let nextToken: string | undefined
do {
const response = await this.eventbridge.send(new ListRulesCommand({
...eventBusInput(busName),
NextToken: nextToken,
}))
rules.push(...(response.Rules ?? []))
nextToken = response.NextToken
} while (nextToken)
return Promise.all(rules.filter((rule) => Boolean(rule.Name)).map(async (rule) => {
const name = rule.Name as string
return ruleOverview(rule, busName, await this.listTargets(name, busName))
}))
}
async createEventBridgeRule(eventBusId: string, input: EventBridgeRuleInput): Promise<EventBridgeRuleOverview> {
const busName = eventBusName(eventBusId)
const name = requiredValue(input.name, 'name')
const targetInput = ruleInput(input)
const response = await this.eventbridge.send(new PutRuleCommand({
Name: name,
...eventBusInput(busName),
...(targetInput.scheduleExpression ? {ScheduleExpression: targetInput.scheduleExpression} : {}),
...(targetInput.eventPattern ? {EventPattern: targetInput.eventPattern} : {}),
State: targetInput.state,
...(targetInput.description ? {Description: targetInput.description} : {}),
}))
const arn = response.RuleArn
if (!arn) throw new ValidationError(`EventBridge rule ${name} did not return an ARN`)
await this.eventbridge.send(new PutTargetsCommand({
Rule: name,
...eventBusInput(busName),
Targets: input.targets.map((target) => ({Id: target.id, Arn: target.arn})),
}))
await Promise.all(input.targets.filter((target) => isLambdaArn(target.arn)).map((target) =>
this.lambda.send(new AddPermissionCommand({
FunctionName: target.arn,
StatementId: permissionStatementId(busName, name, target.id),
Action: 'lambda:InvokeFunction',
Principal: 'events.amazonaws.com',
SourceArn: arn,
})),
))
return {
name,
busName,
busId: `${EVENT_BUS_PREFIX}${busName}`,
arn,
...targetInput,
targets: input.targets,
}
}
async deleteEventBridgeRule(eventBusId: string, ruleName: string): Promise<void> {
const busName = eventBusName(eventBusId)
const name = requiredValue(ruleName, 'ruleName')
const targets = await this.listTargets(name, busName)
const targetIds = targets.map((target) => target.Id).filter((id): id is string => Boolean(id))
if (targetIds.length > 0) {
await this.eventbridge.send(new RemoveTargetsCommand({Rule: name, ...eventBusInput(busName), Ids: targetIds}))
}
await this.eventbridge.send(new DeleteRuleCommand({Name: name, ...eventBusInput(busName)}))
}
private async listTargets(ruleName: string, busName: string): Promise<Target[]> {
const targets: Target[] = []
let nextToken: string | undefined
do {
const response = await this.eventbridge.send(new ListTargetsByRuleCommand({
Rule: ruleName,
...eventBusInput(busName),
NextToken: nextToken,
}))
targets.push(...(response.Targets ?? []))
nextToken = response.NextToken
} while (nextToken)
return targets
}
}
function eventBusResource(eventBus: EventBus): CloudResource {
@@ -122,3 +221,55 @@ function isNotFound(error: unknown): boolean {
const value = error as {name?: string; $metadata?: {httpStatusCode?: number}}
return value.name === 'ResourceNotFoundException' || value.$metadata?.httpStatusCode === 404
}
function eventBusInput(busName: string): {EventBusName?: string} {
return busName === 'default' ? {} : {EventBusName: busName}
}
function ruleInput(input: EventBridgeRuleInput): Pick<EventBridgeRuleOverview, 'scheduleExpression' | 'eventPattern' | 'state' | 'description'> {
const scheduleExpression = stringValue(input.scheduleExpression)
const eventPattern = stringValue(input.eventPattern)
if (Boolean(scheduleExpression) === Boolean(eventPattern)) {
throw new ValidationError('exactly one of scheduleExpression or eventPattern is required')
}
if (eventPattern) {
try {
JSON.parse(eventPattern)
} catch {
throw new ValidationError('eventPattern must be a JSON string')
}
}
if (!Array.isArray(input.targets) || input.targets.length === 0) throw new ValidationError('at least one target is required')
const targetsValid = input.targets.every((target) => requiredValue(target.id, 'target.id') && requiredValue(target.arn, 'target.arn'))
if (!targetsValid) throw new ValidationError('target id and arn are required')
const state = input.state ?? 'ENABLED'
if (state !== 'ENABLED' && state !== 'DISABLED') throw new ValidationError('state must be ENABLED or DISABLED')
return {
...(scheduleExpression ? {scheduleExpression} : {}),
...(eventPattern ? {eventPattern} : {}),
state,
...(stringValue(input.description) ? {description: stringValue(input.description)} : {}),
}
}
function ruleOverview(rule: Rule, busName: string, targets: Target[]): EventBridgeRuleOverview {
return {
name: rule.Name ?? '',
busName,
busId: `${EVENT_BUS_PREFIX}${busName}`,
arn: rule.Arn ?? null,
...(rule.ScheduleExpression ? {scheduleExpression: rule.ScheduleExpression} : {}),
...(rule.EventPattern ? {eventPattern: rule.EventPattern} : {}),
state: rule.State === 'DISABLED' ? 'DISABLED' : 'ENABLED',
...(rule.Description ? {description: rule.Description} : {}),
targets: targets.flatMap((target) => target.Id && target.Arn ? [{id: target.Id, arn: target.Arn}] : []),
}
}
function isLambdaArn(arn: string): boolean {
return arn.split(':')[2] === 'lambda'
}
function permissionStatementId(busName: string, ruleName: string, targetId: string): string {
return `floci-events-${busName}-${ruleName}-${targetId}`.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 100)
}
@@ -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([])
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.sns.send(new DeleteTopicCommand({TopicArn: id}))
}
async publish(id: string, input: PublishNotificationInput): Promise<PublishNotificationResult> {
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<SnsSubscriptionOverview> {
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<SnsSubscriptionOverview[]> {
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<void> {
await this.sns.send(new UnsubscribeCommand({SubscriptionArn: subscriptionArn}))
}
async health(): Promise<void> {
await this.sns.send(new ListTopicsCommand({}))
}
private async subscriptionOverview(subscription: Subscription): Promise<SnsSubscriptionOverview> {
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<string, string> = {}): 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<string, string> | 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'
}
+3
View File
@@ -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),
};
}
@@ -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},
],
},
}
}
@@ -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'},
+51
View File
@@ -450,6 +450,29 @@ export interface ServerlessInvokeResult {
executionDuration?: number
}
export interface PublishNotificationInput {
message: string
subject?: string
messageAttributes?: Record<string, string>
}
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<DatabaseSnapshot>
listDatabaseOrderableInstanceClasses?(engine?: string): Promise<string[]>
invoke?(id: string, payload: string): Promise<ServerlessInvokeResult>
publish?(id: string, input: PublishNotificationInput): Promise<PublishNotificationResult>
createSnsSubscription?(topicArn: string, input: SnsSubscriptionInput): Promise<SnsSubscriptionOverview>
listSnsSubscriptions?(topicArn: string): Promise<SnsSubscriptionOverview[]>
deleteSnsSubscription?(subscriptionArn: string): Promise<void>
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
@@ -618,6 +666,9 @@ export interface CloudServiceAdapter {
createEventBridgeLambdaTrigger?(input: EventBridgeLambdaTriggerInput): Promise<EventBridgeLambdaTriggerOverview>
listEventBridgeLambdaTriggers?(functionName: string): Promise<EventBridgeLambdaTriggerOverview[]>
deleteEventBridgeLambdaTrigger?(functionName: string, triggerId: string): Promise<void>
listEventBridgeRules?(eventBusId: string): Promise<EventBridgeRuleOverview[]>
createEventBridgeRule?(eventBusId: string, input: EventBridgeRuleInput): Promise<EventBridgeRuleOverview>
deleteEventBridgeRule?(eventBusId: string, ruleName: string): Promise<void>
createSqsLambdaEventSourceMapping?(input: SqsLambdaEventSourceMappingInput): Promise<SqsLambdaEventSourceMappingOverview>
listSqsLambdaEventSourceMappings?(functionName: string): Promise<SqsLambdaEventSourceMappingOverview[]>
deleteSqsLambdaEventSourceMapping?(functionName: string, mappingId: string): Promise<void>
+3 -1
View File
@@ -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(),
+84
View File
@@ -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',
+97
View File
@@ -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<Record<string, unknown>>()
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<string, string> | undefined {
return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record<string, string>
}
function isStringRecord(value: unknown): value is Record<string, string> {
return typeof value === 'object'
&& value !== null
&& !Array.isArray(value)
&& Object.values(value).every((item) => typeof item === 'string')
}
function stringHeaders(value: unknown): Record<string, string> {
if (value === undefined) return {}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
+42 -1
View File
@@ -12,6 +12,7 @@ import type {
Ec2IpPermission,
Ec2KeyPair,
Ec2KeyPairMaterial,
Ec2KeyPairType,
Ec2NatGateway,
Ec2RouteTable,
Ec2SecurityGroup,
@@ -75,7 +76,7 @@ const fakeService = {
getConsoleOutput: async (instanceId: string): Promise<Ec2ConsoleOutput> => ({instanceId, output: 'boot log'}),
listAmis: async (): Promise<Ec2Image[]> => [],
listKeyPairs: async (): Promise<Ec2KeyPair[]> => [],
createKeyPair: async (name: string): Promise<Ec2KeyPairMaterial> => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}),
createKeyPair: async (name: string, _keyType: Ec2KeyPairType = 'rsa'): Promise<Ec2KeyPairMaterial> => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}),
deleteKeyPair: async () => {},
listSecurityGroups: async (): Promise<Ec2SecurityGroup[]> => [],
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', () => {
+11 -2
View File
@@ -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<CreateKeyPairInput>()
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)
}
@@ -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<EventBridgeRuleOverview[]> {
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<EventBridgeRuleOverview> {
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<void> {
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<SqsLambdaEventSourceMappingOverview> {
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<PublishNotificationResult> {
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<SnsSubscriptionOverview> {
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<SnsSubscriptionOverview[]> {
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<void> {
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<StorageObjectList> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.listObjects) throw new NotSupportedError(`Object listing is not supported for ${cloud}/${service}`)
+15 -2
View File
@@ -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
+4 -2
View File
@@ -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<Ec2KeyPairMaterial> {
const res = await client.send(new CreateKeyPairCommand({KeyName: name}))
async createKeyPair(name: string, keyType: Ec2KeyPairType = 'rsa'): Promise<Ec2KeyPairMaterial> {
const res = await client.send(new CreateKeyPairCommand({KeyName: name, KeyType: keyType}))
return {
keyPairId: res.KeyPairId ?? '',
keyName: res.KeyName ?? name,