feat: EC2 UI polished + storage persisting
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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<Ec2KeyPair[
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2KeyPair(name: string): Promise<Ec2KeyPairMaterial> {
|
||||
const res = await apiClient.call<Ec2KeyPairMaterial>(
|
||||
export async function createEc2KeyPair(name: string, keyType: Ec2KeyPairType = "rsa"): Promise<Ec2KeyPairMaterial> {
|
||||
const res = await apiClient.call<Ec2KeyPairMaterial, {name: string; keyType: Ec2KeyPairType}>(
|
||||
apiEndpointKeys.aws.ec2.keyPairs.create,
|
||||
{ body: JSON.stringify({ name }), headers: { "content-type": "application/json" } },
|
||||
{ body: { name, keyType } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,16 @@ export interface ServerlessInvokeResult {
|
||||
executionDuration?: number;
|
||||
}
|
||||
|
||||
export interface PublishNotificationInput {
|
||||
message: string;
|
||||
subject?: string;
|
||||
messageAttributes?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<SnsSubscriptionOverview> {
|
||||
const res = await apiClient.call<SnsSubscriptionOverview, CreateSnsSubscriptionInput>(
|
||||
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<SnsSubscriptionOverview[]> {
|
||||
const res = await apiClient.call<SnsSubscriptionOverview[]>(
|
||||
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<void> {
|
||||
await apiClient.call<void>(
|
||||
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<EventBridgeRuleOverview> {
|
||||
const res = await apiClient.call<EventBridgeRuleOverview, CreateEventBridgeRuleInput>(
|
||||
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<EventBridgeRuleOverview[]> {
|
||||
const res = await apiClient.call<EventBridgeRuleOverview[]>(
|
||||
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<void> {
|
||||
await apiClient.call<void>(
|
||||
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<PublishNotificationResult> {
|
||||
const res = await apiClient.call<PublishNotificationResult, PublishNotificationInput>(
|
||||
apiEndpointKeys.clouds.resources.publish,
|
||||
requestOptions(cloud, "notifications", { signal, body: input }),
|
||||
{ cloud, id: topicArn },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function encryptKmsResource(
|
||||
cloud: CloudProvider,
|
||||
id: string,
|
||||
|
||||
@@ -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<AmiFamily, 'other'>; 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<LaunchStep>(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<string[]>([])
|
||||
|
||||
// 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 (
|
||||
<form className="launch-form" onSubmit={submit}>
|
||||
{/* ── Required ──────────────────────────────────────────── */}
|
||||
<div className="launch-form-group">Required</div>
|
||||
<form className="launch-form" aria-label="Launch instance wizard" onSubmit={(event) => { event.preventDefault(); if (step === 4) launch() }}>
|
||||
<div className="sns-tabs" aria-label="Launch steps" style={{ gridColumn: '1 / -1', overflowX: 'auto' }}>
|
||||
{steps.map((item) => (
|
||||
<button key={item.number} type="button" className={`sns-tab${step === item.number ? ' active' : ''}`} onClick={() => item.number < step && setStep(item.number)} disabled={item.number > step}>
|
||||
{item.number}. {item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>Instance Name <em className="field-required">*</em></span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g. my-web-server"/>
|
||||
</label>
|
||||
{step === 1 && <>
|
||||
<div className="launch-form-group">Name and AMI</div>
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>Instance name <em className="field-required">*</em></span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. my-web-server" />
|
||||
</label>
|
||||
<label className="launch-field">
|
||||
<span>Architecture</span>
|
||||
<select className="input" value={architectureFilter} onChange={(e) => { setArchitectureFilter(e.target.value); setImageId('') }}>
|
||||
<option value="">All architectures</option>
|
||||
{architectures.map((architecture) => <option key={architecture} value={architecture}>{architecture}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Amazon Machine Image <em className="field-required">*</em></span>
|
||||
<div className="ami-catalog">
|
||||
<div className="ami-search">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input className="input" value={amiSearch} onChange={(e) => { setAmiSearch(e.target.value); setShowAmiBrowser(Boolean(e.target.value)) }} placeholder="Search AMIs" aria-label="Search available AMIs" />
|
||||
</div>
|
||||
{amisQ.isLoading && <span className="muted">Loading AMIs…</span>}
|
||||
{!amisQ.isLoading && <>
|
||||
<div className="ami-catalog-heading">Quick Start</div>
|
||||
<div className="ami-quick-start-grid">
|
||||
{visibleQuickStartAmis.map((catalogAmi) => (
|
||||
<button
|
||||
key={catalogAmi.family}
|
||||
type="button"
|
||||
className={`ami-quick-start-tile${imageId === catalogAmi.ami?.imageId ? ' selected' : ''}${catalogAmi.ami ? '' : ' unavailable'}`}
|
||||
disabled={!catalogAmi.ami}
|
||||
onClick={() => { if (catalogAmi.ami) { setImageId(catalogAmi.ami.imageId); setArchitectureFilter(catalogAmi.ami.architecture ?? '') } }}
|
||||
title={catalogAmi.ami ? catalogAmi.ami.name || catalogAmi.ami.imageId : `No local ${catalogAmi.label} AMI is available`}
|
||||
>
|
||||
<img src={catalogAmi.image} alt="" />
|
||||
<span>{catalogAmi.label}</span>
|
||||
<small>{catalogAmi.ami?.architecture || 'No local AMI'}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
{!amisQ.isLoading && amis.length === 0 && <span className="muted">No AMIs are available.</span>}
|
||||
{!amisQ.isLoading && amis.length > 0 && <>
|
||||
<button type="button" className="ami-browser-toggle" onClick={() => setShowAmiBrowser((visible) => !visible)}>
|
||||
{showAmiBrowser ? 'Hide available AMIs' : 'Browse available AMIs'}
|
||||
</button>
|
||||
{showAmiBrowser && <div className="ami-browser-list">
|
||||
{browsableAmis.map((ami) => {
|
||||
const distro = classifyAmi(ami)
|
||||
return <label key={ami.imageId} className="ami-browser-item">
|
||||
<input type="radio" name="ami" checked={imageId === ami.imageId} onChange={() => { setImageId(ami.imageId); setArchitectureFilter(ami.architecture ?? '') }} />
|
||||
<span><b style={{ background: distro.color }}>{distro.mark}</b>{ami.name || ami.imageId}</span>
|
||||
<code>{ami.imageId} · {distro.label}</code>
|
||||
</label>
|
||||
})}
|
||||
{browsableAmis.length === 0 && <span className="muted">No additional AMIs match this search and architecture.</span>}
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<label className="launch-field">
|
||||
<span>AMI <em className="field-required">*</em></span>
|
||||
<select className="input" value={imageId} onChange={(e) => setImageId(e.target.value)} required>
|
||||
<option value="">Select an AMI…</option>
|
||||
{(amisQ.data ?? []).map((ami: Ec2Image) => (
|
||||
<option key={ami.imageId} value={ami.imageId}>{ami.name || ami.imageId}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{step === 2 && <>
|
||||
<div className="launch-form-group">Instance type and key pair</div>
|
||||
<label className="launch-field">
|
||||
<span>Instance type <em className="field-required">*</em></span>
|
||||
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}>
|
||||
{INSTANCE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Key pair</span>
|
||||
<div className="field-row" style={{ marginTop: 6 }}>
|
||||
<label><input type="radio" checked={keyPairMode === 'existing'} onChange={() => setKeyPairMode('existing')} /> Choose existing</label>
|
||||
<label><input type="radio" checked={keyPairMode === 'new'} onChange={() => { setKeyPairMode('new'); setKeyName('') }} /> Create new</label>
|
||||
</div>
|
||||
{keyPairMode === 'existing' ? (
|
||||
<select className="input" style={{ marginTop: 8 }} value={keyName} onChange={(e) => setKeyName(e.target.value)}>
|
||||
<option value="">No key pair</option>
|
||||
{(keysQ.data ?? []).map((keyPair: Ec2KeyPair) => <option key={keyPair.keyPairId} value={keyPair.keyName}>{keyPair.keyName}</option>)}
|
||||
</select>
|
||||
) : <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
<input className="input" value={newKeyName} onChange={(e) => { setNewKeyName(e.target.value); setKeyName('') }} placeholder="Key pair name" />
|
||||
<select className="input" value={newKeyType} onChange={(e) => setNewKeyType(e.target.value as 'rsa' | 'ed25519')}><option value="rsa">RSA</option><option value="ed25519">ED25519</option></select>
|
||||
<button type="button" className="button" disabled={!newKeyName.trim() || createKeyMut.isPending} onClick={() => createKeyMut.mutate()}>{createKeyMut.isPending ? 'Creating…' : 'Create key pair'}</button>
|
||||
{createdKeyMaterial && <button type="button" className="button primary" onClick={downloadPrivateKey}>Download {createdKeyMaterial.name}.pem</button>}
|
||||
</div>}
|
||||
{createKeyMut.error instanceof Error && <p className="form-error" style={{ marginTop: 8 }}>{createKeyMut.error.message}</p>}
|
||||
{createdKeyMaterial && <p className="muted" style={{ margin: '8px 0 0' }}>The private key is available only from this creation response. Download it before leaving this wizard.</p>}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Instance Type <em className="field-required">*</em></span>
|
||||
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)} required>
|
||||
{INSTANCE_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* ── Networking — optional ─────────────────────────────── */}
|
||||
<div className="launch-form-group">Networking — optional</div>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Key Pair</span>
|
||||
<select className="input" value={keyName} onChange={(e) => setKeyName(e.target.value)}>
|
||||
<option value="">None</option>
|
||||
{(keysQ.data ?? []).map((kp: Ec2KeyPair) => (
|
||||
<option key={kp.keyPairId} value={kp.keyName}>{kp.keyName}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Subnet</span>
|
||||
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
|
||||
<option value="">Default</option>
|
||||
{groupByVpc
|
||||
? vpcs.map((vpc: Ec2Vpc) => {
|
||||
const vpcSubnets = subnets.filter((s: Ec2Subnet) => s.vpcId === vpc.vpcId)
|
||||
if (!vpcSubnets.length) return null
|
||||
return (
|
||||
<optgroup key={vpc.vpcId} label={vpcLabel(vpc)}>
|
||||
{vpcSubnets.map((s: Ec2Subnet) => (
|
||||
<option key={s.subnetId} value={s.subnetId}>
|
||||
{s.availabilityZone} — {s.cidrBlock}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)
|
||||
})
|
||||
: subnets.map((s: Ec2Subnet) => (
|
||||
<option key={s.subnetId} value={s.subnetId}>
|
||||
{s.availabilityZone} — {s.cidrBlock}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* ── Security Groups ───────────────────────────────────── */}
|
||||
<div className="launch-field launch-field--span">
|
||||
{step === 3 && <>
|
||||
<div className="launch-form-group">Network and security</div>
|
||||
<label className="launch-field launch-field--span">
|
||||
<span>Subnet</span>
|
||||
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
|
||||
<option value="">Default subnet selection</option>
|
||||
{groupByVpc ? vpcs.map((vpc: Ec2Vpc) => {
|
||||
const vpcSubnets = subnets.filter((subnet: Ec2Subnet) => subnet.vpcId === vpc.vpcId)
|
||||
return vpcSubnets.length ? <optgroup key={vpc.vpcId} label={vpcLabel(vpc)}>{vpcSubnets.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.availabilityZone} · {subnet.cidrBlock}</option>)}</optgroup> : null
|
||||
}) : subnets.map((subnet: Ec2Subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.availabilityZone} · {subnet.cidrBlock}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<div className="sg-header">
|
||||
<span>Security Groups</span>
|
||||
<button
|
||||
@@ -934,7 +1082,6 @@ export function LaunchInstanceForm({
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Inbound Rules */}
|
||||
<div className="sg-rules-section">
|
||||
<div className="sg-rules-header">
|
||||
<span className="sg-rules-label">Inbound Rules</span>
|
||||
@@ -1008,18 +1155,45 @@ export function LaunchInstanceForm({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{createMut.error instanceof Error && (
|
||||
<div className="form-error launch-field--span">{createMut.error.message}</div>
|
||||
)}
|
||||
{step === 4 && <>
|
||||
<div className="launch-form-group">Storage and review</div>
|
||||
<label className="launch-field">
|
||||
<span>Root volume size (GiB)</span>
|
||||
<input className="input" type="number" min="1" value={rootVolumeSize} onChange={(event) => setRootVolumeSize(event.target.value)} />
|
||||
<small>Creates the root EBS volume at this size.</small>
|
||||
</label>
|
||||
<label className="launch-field">
|
||||
<span>Root volume type</span>
|
||||
<select className="input" value={rootVolumeType} onChange={(event) => setRootVolumeType(event.target.value)}>
|
||||
<option value="gp3">General Purpose SSD (gp3)</option>
|
||||
<option value="gp2">General Purpose SSD (gp2)</option>
|
||||
<option value="io1">Provisioned IOPS SSD (io1)</option>
|
||||
<option value="st1">Throughput Optimized HDD (st1)</option>
|
||||
<option value="sc1">Cold HDD (sc1)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Review: fields sent to RunInstances</span>
|
||||
<div className="meta-grid" style={{ marginTop: 8 }}>
|
||||
<div className="meta-row"><span className="meta-label">Name</span><span className="meta-value">{name}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">AMI</span><span className="meta-value">{selectedAmi?.name || imageId} ({imageId})</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Instance type</span><span className="meta-value">{instanceType}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Key pair</span><span className="meta-value">{keyName || 'Not sent'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Subnet</span><span className="meta-value">{selectedSubnet ? `${selectedSubnet.availabilityZone} (${subnetId})` : 'Not sent'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Security groups</span><span className="meta-value">{selectedGroups.length ? selectedGroups.map((group) => group.groupName || group.groupId).join(', ') : 'Not sent'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Root volume</span><span className="meta-value">{rootVolumeSize} GiB {rootVolumeType}, delete on termination</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<div className="launch-form-actions">
|
||||
<button type="button" className="button" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="button primary" disabled={createMut.isPending}>
|
||||
<Plus size={14}/>
|
||||
{createMut.isPending ? 'Launching…' : 'Launch instance'}
|
||||
</button>
|
||||
{stepError && <div className="form-error launch-field--span">{stepError}</div>}
|
||||
{createMut.error instanceof Error && <div className="form-error launch-field--span">{createMut.error.message}</div>}
|
||||
<div className="launch-form-actions" style={{ gridColumn: '1 / -1' }}>
|
||||
<button type="button" className="button" onClick={step === 1 ? onCancel : () => { setStep((current) => (current - 1) as LaunchStep); setStepError('') }}>{step === 1 ? 'Cancel' : 'Back'}</button>
|
||||
{step < 4 ? <button type="button" className="button primary" onClick={nextStep}>Next</button> : <button type="button" className="button primary" disabled={createMut.isPending} onClick={launch}><Plus size={14}/>{createMut.isPending ? 'Launching…' : 'Launch instance'}</button>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [deleteWarning, setDeleteWarning] = useState<string | null>(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({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderResourceSurface({
|
||||
{!(cloud === "aws" && service === "compute" && createOpen) && renderResourceSurface({
|
||||
schema,
|
||||
resources,
|
||||
selectedId: activeSelected?.id,
|
||||
@@ -872,6 +880,13 @@ export function DynamicResourceView({
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{service === "events" && (
|
||||
<EventBridgeRulePanel
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{service === "configuration" && cloud === "aws" && (
|
||||
<AppConfigPanel
|
||||
cloud={cloud}
|
||||
@@ -886,6 +901,13 @@ export function DynamicResourceView({
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{service === "notifications" && (
|
||||
<SnsTopicPanel
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
/>
|
||||
)}
|
||||
{editingResource && schema.updateFields && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {EventBridgeRulePanel} from './EventBridgeRulePanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
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(<QueryClientProvider client={queryClient}><EventBridgeRulePanel cloud="aws" resource={eventBus} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
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'))
|
||||
})
|
||||
@@ -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<TargetService, {label: string; service: CloudServiceType; matches: (resource: CloudResource) => 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<TargetService>('lambda')
|
||||
const [targetArn, setTargetArn] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<EventBridgeRuleOverview | null>(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<Record<TargetService, CloudResource[]>>((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 <section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">EventBridge Rules</p>
|
||||
<h3><Radio size={15}/> Add rule to {resource.name}</h3>
|
||||
<p className="muted compact-text">Route a schedule or matching events from this event bus to one existing target.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canCreate) createMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Rule name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)} placeholder="hourly-report" required/></label>
|
||||
<label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Generate the hourly report"/></label>
|
||||
<label className="dynamic-field"><span>Mode</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as 'schedule' | 'event-pattern')}><option value="schedule">Schedule</option><option value="event-pattern">Event pattern</option></select></label>
|
||||
{mode === 'schedule' ? <label className="dynamic-field"><span>Schedule expression</span><input className="input" value={scheduleExpression} onChange={(event) => setScheduleExpression(event.target.value)} placeholder="rate(5 minutes)" required/><small>Use a rate or cron expression.</small></label> : <label className="dynamic-field"><span>Event pattern (JSON)</span><textarea className="textarea code-textarea small" value={eventPattern} onChange={(event) => setEventPattern(event.target.value)} spellCheck={false} required/>{!patternValid && <small className="error-text">Event pattern must be a JSON object.</small>}</label>}
|
||||
<label className="dynamic-field"><span>Target service</span><select className="input" value={targetService} onChange={(event) => { setTargetService(event.target.value as TargetService); setTargetArn('') }}>{availableTargetServices.length === 0 ? <option value={targetService}>{targetServiceDetails[targetService].label}</option> : availableTargetServices.map((service) => <option key={service} value={service}>{targetServiceDetails[service].label}</option>)}</select><small>{availableTargetServices.length === 0 ? 'No supported EventBridge targets are available.' : 'Only services with discoverable resources are listed.'}</small></label>
|
||||
<label className="dynamic-field"><span>Target resource</span><select className="input" value={targetArn} onChange={(event) => setTargetArn(event.target.value)} required><option value="">Select a {targetServiceDetails[targetService].label} resource</option>{targetResources.map((candidate) => <option key={candidate.id} value={String(candidate.metadata.arn)}>{candidate.name}</option>)}</select><small>{targetResources.length === 0 ? `No ${targetServiceDetails[targetService].label} resources with an ARN are available.` : 'Uses the resource ARN returned by the cloud API.'}</small></label>
|
||||
<label className="dynamic-field"><span>State</span><span><input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)}/> Enabled</span></label>
|
||||
<div className="apigateway-route-explorer"><p className="eyebrow">Reviewed creation plan</p><p className="muted compact-text">Create EventBridge rule <code>{name.trim() || 'your-rule'}</code> on bus <code>{resource.name}</code> with {mode === 'schedule' ? <code>{scheduleExpression.trim() || 'your schedule'}</code> : 'the supplied event pattern'}, then attach existing {targetServiceDetails[targetService].label} target <code>{selectedTarget?.name || 'your-target'}</code>. No Lambda, SQS queue, SNS topic, or Step Functions resources will be created.</p></div>
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Creating rule' : 'Create rule'}</button>
|
||||
</form>
|
||||
{createMutation.isError && <p className="error-text compact-text">{createMutation.error instanceof Error ? createMutation.error.message : 'Unable to create EventBridge rule.'}</p>}
|
||||
</div>
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Configured Rules</p><h3><CalendarClock size={15}/> Rules and targets</h3></div></div>
|
||||
{rulesQuery.isLoading && <p className="muted compact-text">Loading EventBridge rules...</p>}
|
||||
{rulesQuery.isError && <p className="error-text compact-text">{rulesQuery.error instanceof Error ? rulesQuery.error.message : 'Unable to load EventBridge rules.'}</p>}
|
||||
{rulesQuery.data?.length === 0 && <p className="muted compact-text">No rules configured for this event bus.</p>}
|
||||
{rulesQuery.data?.map((rule) => <div className="apigateway-route-item" key={rule.name}><div className="apigateway-route-select"><span className="apigateway-route-method">{rule.state}</span><code>{rule.name}</code><small>{rule.scheduleExpression ?? rule.eventPattern ?? 'No pattern'}{rule.description ? ` · ${rule.description}` : ''}</small>{rule.targets.map((target) => <small key={target.id}>Target {target.id}: <code>{target.arn}</code></small>)}</div>{deleteTarget?.name === rule.name ? <div className="apigateway-route-confirm"><span>Delete rule?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(rule)}>Delete</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Delete rule ${rule.name}`} onClick={() => setDeleteTarget(rule)}><Trash2 size={13}/></button>}</div>)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function isJsonObject(value: string): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function targetId(resource: CloudResource): string {
|
||||
return resource.id.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 64) || 'target'
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {fireEvent, render, screen, waitFor} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {beforeEach, expect, test, vi} from "vitest";
|
||||
import {SnsTopicPanel} from "./SnsTopicPanel";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
createSnsSubscription: vi.fn(),
|
||||
deleteSnsSubscription: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
listSnsSubscriptions: vi.fn(),
|
||||
publishNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/cloudProxyClient", () => apiMocks);
|
||||
|
||||
const topic: CloudResource = {
|
||||
id: "arn:aws:sns:us-east-1:000000000000:orders",
|
||||
name: "orders",
|
||||
cloud: "aws",
|
||||
service: "notifications",
|
||||
type: "topic",
|
||||
region: null,
|
||||
createdAt: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
function renderPanel() {
|
||||
const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SnsTopicPanel cloud="aws" resource={topic} runtimeReachable />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.values(apiMocks).forEach((mock) => mock.mockReset());
|
||||
apiMocks.listCloudResources.mockResolvedValue([]);
|
||||
apiMocks.listSnsSubscriptions.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
test("creates a Lambda subscription from the selected resource ARN", async () => {
|
||||
const lambda: CloudResource = {...topic, id: "worker", name: "worker", service: "serverless", type: "lambda", metadata: {arn: "arn:aws:lambda:us-east-1:000000000000:function:worker"}};
|
||||
apiMocks.listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === "serverless" ? [lambda] : []));
|
||||
apiMocks.createSnsSubscription.mockResolvedValue({arn: "subscription-1"});
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await screen.findByRole("option", {name: "worker"});
|
||||
await user.selectOptions(screen.getByLabelText(/lambda endpoint/i), String(lambda.metadata.arn));
|
||||
await user.click(screen.getByRole("button", {name: "Subscribe"}));
|
||||
|
||||
await waitFor(() => expect(apiMocks.createSnsSubscription).toHaveBeenCalledWith("aws", topic.id, {
|
||||
protocol: "lambda", endpoint: lambda.metadata.arn,
|
||||
}));
|
||||
});
|
||||
|
||||
test("creates a manual HTTPS subscription and explains confirmation", async () => {
|
||||
apiMocks.createSnsSubscription.mockResolvedValue({arn: "subscription-2"});
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Protocol"), "https");
|
||||
await user.type(screen.getByLabelText(/endpoint/i), "https://example.com/hooks");
|
||||
expect(screen.getByText("SNS may require endpoint confirmation before delivery can begin.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", {name: "Subscribe"}));
|
||||
|
||||
await waitFor(() => expect(apiMocks.createSnsSubscription).toHaveBeenCalledWith("aws", topic.id, {
|
||||
protocol: "https", endpoint: "https://example.com/hooks",
|
||||
}));
|
||||
});
|
||||
|
||||
test("shows returned subscription confirmation and filter policy, then confirms unsubscribe", async () => {
|
||||
const subscription = {arn: "arn:aws:sns:us-east-1:000000000000:orders:sub-1", protocol: "https", endpoint: "https://example.com/hooks", status: "PendingConfirmation", filterPolicy: '{"eventType":["created"]}'};
|
||||
apiMocks.listSnsSubscriptions.mockResolvedValue([subscription]);
|
||||
apiMocks.deleteSnsSubscription.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText("Confirmation: PendingConfirmation")).toBeVisible();
|
||||
expect(screen.getByText(/Filter policy:/i)).toHaveTextContent(subscription.filterPolicy);
|
||||
await user.click(screen.getByRole("button", {name: /unsubscribe https/i}));
|
||||
expect(screen.getByText("Unsubscribe this endpoint?")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", {name: "Unsubscribe"}));
|
||||
|
||||
await waitFor(() => expect(apiMocks.deleteSnsSubscription).toHaveBeenCalledWith("aws", topic.id, subscription.arn));
|
||||
});
|
||||
|
||||
test("publishes a topic message with optional subject and attributes", async () => {
|
||||
apiMocks.publishNotification.mockResolvedValue({messageId: "message-123"});
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.type(screen.getByLabelText(/subject/i), "Order created");
|
||||
await user.type(screen.getByLabelText(/^message$/i), "New order");
|
||||
fireEvent.change(screen.getByLabelText(/message attributes/i), {target: {value: '{"eventType":"order.created"}'}});
|
||||
await user.click(screen.getByRole("button", {name: "Publish"}));
|
||||
|
||||
await waitFor(() => expect(apiMocks.publishNotification).toHaveBeenCalledWith("aws", topic.id, {
|
||||
subject: "Order created",
|
||||
message: "New order",
|
||||
messageAttributes: {eventType: "order.created"},
|
||||
}));
|
||||
expect(await screen.findByText("message-123")).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects message attributes that are not string-valued JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPanel();
|
||||
|
||||
await user.type(screen.getByLabelText(/^message$/i), "New order");
|
||||
fireEvent.change(screen.getByLabelText(/message attributes/i), {target: {value: '{"attempt":1}'}});
|
||||
await user.click(screen.getByRole("button", {name: "Publish"}));
|
||||
|
||||
expect(screen.getByText("Message attribute values must be strings.")).toBeVisible();
|
||||
expect(apiMocks.publishNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {BellRing, Loader2, Plus, Send, Trash2} from "lucide-react";
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {createSnsSubscription, deleteSnsSubscription, listCloudResources, listSnsSubscriptions, publishNotification, type PublishNotificationResult, type SnsSubscriptionOverview} from "@/api/cloudProxyClient";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
|
||||
interface SnsTopicPanelProps {
|
||||
cloud: CloudProvider;
|
||||
resource?: CloudResource;
|
||||
runtimeReachable: boolean;
|
||||
}
|
||||
|
||||
type SubscriptionProtocol = "lambda" | "sqs" | "http" | "https" | "email" | "email-json" | "sms";
|
||||
|
||||
const resourceProtocols: Record<"lambda" | "sqs", {label: string; service: "serverless" | "messaging"; matches: (candidate: CloudResource) => boolean}> = {
|
||||
lambda: {label: "Lambda", service: "serverless", matches: (candidate) => candidate.type === "lambda"},
|
||||
sqs: {label: "SQS", service: "messaging", matches: (candidate) => candidate.type === "queue" || candidate.type === "fifo-queue"},
|
||||
};
|
||||
|
||||
export function SnsTopicPanel({cloud, resource, runtimeReachable}: SnsTopicPanelProps) {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [attributesText, setAttributesText] = useState("");
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<PublishNotificationResult | null>(null);
|
||||
const [protocol, setProtocol] = useState<SubscriptionProtocol>("lambda");
|
||||
const [endpoint, setEndpoint] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<SnsSubscriptionOverview | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const isTopic = resource?.service === "notifications" && resource.type === "topic";
|
||||
const subscriptionsEnabled = Boolean(isTopic && cloud === "aws" && runtimeReachable);
|
||||
const lambdaResourcesQuery = useQuery({
|
||||
queryKey: ["cloud-resources", cloud, "serverless"],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, "serverless", undefined, signal),
|
||||
enabled: subscriptionsEnabled,
|
||||
});
|
||||
const sqsResourcesQuery = useQuery({
|
||||
queryKey: ["cloud-resources", cloud, "messaging"],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, "messaging", undefined, signal),
|
||||
enabled: subscriptionsEnabled,
|
||||
});
|
||||
const subscriptionsQuery = useQuery({
|
||||
queryKey: ["sns-subscriptions", cloud, resource?.id],
|
||||
queryFn: ({signal}) => listSnsSubscriptions(cloud, resource!.id, signal),
|
||||
enabled: subscriptionsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSubject("");
|
||||
setMessage("");
|
||||
setAttributesText("");
|
||||
setValidationError(null);
|
||||
setResult(null);
|
||||
setProtocol("lambda");
|
||||
setEndpoint("");
|
||||
setDeleteTarget(null);
|
||||
}, [resource?.id]);
|
||||
|
||||
const canPublish = Boolean(isTopic && runtimeReachable && message.trim());
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (input: {message: string; subject?: string; messageAttributes?: Record<string, string>}) =>
|
||||
publishNotification(cloud, resource!.id, input),
|
||||
onSuccess: setResult,
|
||||
});
|
||||
const createSubscriptionMutation = useMutation({
|
||||
mutationFn: () => createSnsSubscription(cloud, resource!.id, {protocol, endpoint}),
|
||||
onSuccess: () => {
|
||||
setEndpoint("");
|
||||
void queryClient.invalidateQueries({queryKey: ["sns-subscriptions", cloud, resource?.id]});
|
||||
},
|
||||
});
|
||||
const deleteSubscriptionMutation = useMutation({
|
||||
mutationFn: (subscription: SnsSubscriptionOverview) => deleteSnsSubscription(cloud, resource!.id, subscription.arn!),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
void queryClient.invalidateQueries({queryKey: ["sns-subscriptions", cloud, resource?.id]});
|
||||
},
|
||||
});
|
||||
|
||||
const publish = () => {
|
||||
if (!canPublish) return;
|
||||
|
||||
try {
|
||||
const messageAttributes = parseMessageAttributes(attributesText);
|
||||
setValidationError(null);
|
||||
setResult(null);
|
||||
publishMutation.mutate({
|
||||
message,
|
||||
...(subject.trim() ? {subject: subject.trim()} : {}),
|
||||
...(messageAttributes ? {messageAttributes} : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : "Message attributes are invalid.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isTopic) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select an SNS topic</h3><p>Select a topic to publish a notification or manage subscriptions.</p></div></section>;
|
||||
}
|
||||
|
||||
const endpointResources = protocol === "lambda"
|
||||
? (lambdaResourcesQuery.data ?? []).filter((candidate) => resourceProtocols.lambda.matches(candidate) && typeof candidate.metadata.arn === "string" && candidate.metadata.arn.length > 0)
|
||||
: protocol === "sqs"
|
||||
? (sqsResourcesQuery.data ?? []).filter((candidate) => resourceProtocols.sqs.matches(candidate) && typeof candidate.metadata.arn === "string" && candidate.metadata.arn.length > 0)
|
||||
: [];
|
||||
const needsResourceSelector = protocol === "lambda" || protocol === "sqs";
|
||||
const resourcesLoading = protocol === "lambda" ? lambdaResourcesQuery.isLoading : sqsResourcesQuery.isLoading;
|
||||
const canCreateSubscription = subscriptionsEnabled && Boolean(endpoint) && !createSubscriptionMutation.isPending;
|
||||
|
||||
return <div className="sns-topic-workspace">
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div><p className="eyebrow">SNS Actions</p><h3><Send size={15} /> Publish message</h3><p className="muted compact-text">Publish directly to {resource.name || resource.id}.</p></div>
|
||||
<span className={`runtime-state ${runtimeReachable ? "ready" : "pending"}`}>{runtimeReachable ? "Ready" : "Runtime unavailable"}</span>
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
<div className="sns-topic-summary"><span>Topic</span><code>{resource.name || resource.id}</code></div>
|
||||
<form className="inspector-action-form apigateway-route-form sns-publish-form" onSubmit={(event) => { event.preventDefault(); publish(); }}>
|
||||
<label className="dynamic-field" htmlFor="sns-publish-subject"><span>Subject (optional)</span><input id="sns-publish-subject" className="input" value={subject} onChange={(event) => setSubject(event.target.value)} /></label>
|
||||
<label className="dynamic-field dynamic-field--span" htmlFor="sns-publish-message"><span>Message</span><textarea id="sns-publish-message" className="textarea code-textarea" value={message} onChange={(event) => setMessage(event.target.value)} required spellCheck={false} placeholder="Write the notification body" /></label>
|
||||
<label className="dynamic-field dynamic-field--span" htmlFor="sns-publish-attributes"><span>Message attributes (optional JSON)</span><textarea id="sns-publish-attributes" className="textarea code-textarea small" value={attributesText} onChange={(event) => setAttributesText(event.target.value)} placeholder={'{"eventType":"order.created"}'} spellCheck={false} /><small>Use a JSON object with string values.</small></label>
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canPublish || publishMutation.isPending}>{publishMutation.isPending ? <Loader2 size={13} className="spin" /> : <Send size={13} />}{publishMutation.isPending ? "Publishing" : "Publish"}</button>
|
||||
</form>
|
||||
{validationError && <p className="error-text compact-text">{validationError}</p>}
|
||||
{publishMutation.isError && <p className="error-text compact-text">{publishMutation.error instanceof Error ? publishMutation.error.message : "Unable to publish message."}</p>}
|
||||
{result && <div className="inspector-section"><p className="metric-label">MessageId</p><code>{result.messageId ?? "No MessageId returned"}</code></div>}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{cloud === "aws" && <section className="table-panel sns-subscriptions-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Subscriptions</p><h3><BellRing size={15} /> Subscribe an endpoint</h3></div></div>
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => { event.preventDefault(); if (canCreateSubscription) createSubscriptionMutation.mutate(); }}>
|
||||
<label className="dynamic-field" htmlFor="sns-subscription-protocol"><span>Protocol</span><select id="sns-subscription-protocol" className="input" value={protocol} onChange={(event) => { setProtocol(event.target.value as SubscriptionProtocol); setEndpoint(""); }}><option value="lambda">Lambda</option><option value="sqs">SQS</option><option value="http">HTTP</option><option value="https">HTTPS</option><option value="email">Email</option><option value="email-json">Email JSON</option><option value="sms">SMS</option></select></label>
|
||||
{needsResourceSelector ? <label className="dynamic-field" htmlFor="sns-subscription-resource-endpoint"><span>{resourceProtocols[protocol].label} endpoint</span><select id="sns-subscription-resource-endpoint" className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} required disabled={!subscriptionsEnabled}><option value="">Select a {resourceProtocols[protocol].label} resource</option>{endpointResources.map((candidate) => <option key={candidate.id} value={String(candidate.metadata.arn)}>{candidate.name}</option>)}</select><small>{resourcesLoading ? `Loading ${resourceProtocols[protocol].label} resources...` : endpointResources.length === 0 ? `No ${resourceProtocols[protocol].label} resources with an ARN are available.` : "Uses the resource ARN returned by the cloud API."}</small></label> : <label className="dynamic-field" htmlFor="sns-subscription-endpoint"><span>Endpoint</span><input id="sns-subscription-endpoint" className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} placeholder={manualEndpointPlaceholder(protocol)} required disabled={!subscriptionsEnabled} /><small>SNS may require endpoint confirmation before delivery can begin.</small></label>}
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreateSubscription}>{createSubscriptionMutation.isPending ? <Loader2 size={13} className="spin" /> : <Plus size={13} />}{createSubscriptionMutation.isPending ? "Subscribing" : "Subscribe"}</button>
|
||||
</form>
|
||||
{createSubscriptionMutation.isError && <p className="error-text compact-text">{createSubscriptionMutation.error instanceof Error ? createSubscriptionMutation.error.message : "Unable to create subscription."}</p>}
|
||||
{subscriptionsQuery.isLoading && <p className="muted compact-text">Loading subscriptions...</p>}
|
||||
{subscriptionsQuery.isError && <p className="error-text compact-text">{subscriptionsQuery.error instanceof Error ? subscriptionsQuery.error.message : "Unable to load subscriptions."}</p>}
|
||||
{subscriptionsQuery.data?.length === 0 && <p className="muted compact-text">No subscriptions configured for this topic.</p>}
|
||||
{subscriptionsQuery.data?.map((subscription) => <div className="apigateway-route-item" key={subscription.arn ?? `${subscription.protocol}:${subscription.endpoint}`}><div className="apigateway-route-select"><span className="apigateway-route-method">{subscription.protocol}</span><code>{subscription.endpoint}</code><small>Confirmation: {subscription.status}</small>{subscription.filterPolicy !== null && <small>Filter policy: <code>{subscription.filterPolicy}</code></small>}</div>{subscription.arn && (deleteTarget?.arn === subscription.arn ? <div className="apigateway-route-confirm"><span>Unsubscribe this endpoint?</span><button className="button danger compact" type="button" disabled={deleteSubscriptionMutation.isPending} onClick={() => deleteSubscriptionMutation.mutate(subscription)}>Unsubscribe</button><button className="button compact" type="button" disabled={deleteSubscriptionMutation.isPending} onClick={() => setDeleteTarget(null)}>Cancel</button></div> : <button className="icon-btn" type="button" aria-label={`Unsubscribe ${subscription.endpoint}`} onClick={() => setDeleteTarget(subscription)}><Trash2 size={13} /></button>)}</div>)}
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function manualEndpointPlaceholder(protocol: Exclude<SubscriptionProtocol, "lambda" | "sqs">): string {
|
||||
if (protocol === "email" || protocol === "email-json") return "alerts@example.com";
|
||||
if (protocol === "sms") return "+15551234567";
|
||||
return "https://example.com/notifications";
|
||||
}
|
||||
|
||||
function parseMessageAttributes(raw: string): Record<string, string> | undefined {
|
||||
if (!raw.trim()) return undefined;
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Message attributes must be valid JSON.");
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Message attributes must be a JSON object of string values.");
|
||||
}
|
||||
if (Object.values(value).some((entry) => typeof entry !== "string")) {
|
||||
throw new Error("Message attribute values must be strings.");
|
||||
}
|
||||
return Object.keys(value).length > 0 ? value as Record<string, string> : undefined;
|
||||
}
|
||||
@@ -2284,6 +2284,185 @@ pre,
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ami-catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.ami-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ami-search svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.ami-search .input {
|
||||
width: 100%;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.ami-catalog-heading {
|
||||
color: var(--text-1);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ami-quick-start-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile {
|
||||
display: grid;
|
||||
grid-template-rows: 42px auto auto;
|
||||
justify-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 12px 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
color: var(--text-1);
|
||||
background: var(--raised);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile:hover {
|
||||
border-color: var(--link);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.ami-quick-start-tile.selected {
|
||||
border-color: var(--link);
|
||||
box-shadow: inset 0 0 0 1px var(--link);
|
||||
}
|
||||
|
||||
.ami-quick-start-tile.unavailable {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile.unavailable:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--raised);
|
||||
}
|
||||
|
||||
.ami-quick-start-tile img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile span {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ami-quick-start-tile small {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ami-browser-toggle {
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ami-browser-list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ami-browser-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 2px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ami-browser-item input {
|
||||
accent-color: var(--link);
|
||||
}
|
||||
|
||||
.ami-browser-item > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ami-browser-item b {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ami-browser-item code {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ami-quick-start-grid {
|
||||
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.ami-catalog {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ami-quick-start-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ami-browser-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ami-browser-item code {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.sg-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2581,6 +2760,43 @@ pre,
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.sns-topic-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sns-topic-workspace .table-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sns-topic-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sns-publish-form .dynamic-field--span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sns-publish-form .code-textarea {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.sns-publish-form .code-textarea.small {
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sns-topic-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.apigateway-route-form .dynamic-field {
|
||||
grid-template-rows: 14px 30px minmax(15px, auto);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type KnownCloudServiceType =
|
||||
| 'networking'
|
||||
| 'serverless'
|
||||
| 'messaging'
|
||||
| 'notifications'
|
||||
| 'events'
|
||||
| 'secrets'
|
||||
| 'logs'
|
||||
|
||||
Reference in New Issue
Block a user