diff --git a/README.md b/README.md index 1c4bc79..406141e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ This table is the source of truth for the current UI surface. | Cloud Explorer / Compute | Yes | Placeholder | Placeholder | AWS EC2 and AMI workflows. | | Cloud Explorer / Networking | Yes | Placeholder | Placeholder | AWS VPC/networking workflows. | | Cloud Explorer / Serverless | Yes | Not exposed in navigation | Not exposed in navigation | AWS Lambda flows through the unified shell. | +| Cloud Explorer / Messaging | Placeholder | Yes | Placeholder | Azure Service Bus namespace list/create/delete/inspect workflows. | | Dedicated page / Secrets Manager | Yes | No | No | AWS-only page outside Cloud Explorer. | Visible placeholders in the current sidebar: diff --git a/packages/api/src/adapter-azure/AzureServiceBusAdapter.test.ts b/packages/api/src/adapter-azure/AzureServiceBusAdapter.test.ts new file mode 100644 index 0000000..55f627e --- /dev/null +++ b/packages/api/src/adapter-azure/AzureServiceBusAdapter.test.ts @@ -0,0 +1,136 @@ +import {describe, expect, test} from 'bun:test' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' +import {AzureServiceBusAdapter} from './AzureServiceBusAdapter' + +interface RecordedCall { + path: string + init: RequestInit + options?: AzureRuntimeFetchOptions +} + +describe('AzureServiceBusAdapter', () => { + test('lists and normalizes Service Bus namespaces', async () => { + const client = testClient(async () => new Response(JSON.stringify({ + namespaces: [{ + name: 'orders-bus', + amqpPort: 5672, + amqpsPort: 5671, + tlsCertPem: 'certificate', + mocked: false, + }], + }))) + + await expect(new AzureServiceBusAdapter(client).list()).resolves.toEqual([{ + id: 'orders-bus', + name: 'orders-bus', + cloud: 'azure', + service: 'messaging', + type: 'servicebus-namespace', + region: null, + createdAt: null, + status: 'Running', + metadata: { + provider: 'azure', + messagingService: 'service-bus', + amqpPort: 5672, + amqpsPort: 5671, + mocked: false, + tlsEnabled: true, + }, + }]) + }) + + test('normalizes a missing namespace list endpoint to an empty list', async () => { + const client = testClient(async (_path, _init, options) => { + if (options?.emptyOnNotFound) return null + return new Response(null, {status: 404}) + }) + + await expect(new AzureServiceBusAdapter(client).list()).resolves.toEqual([]) + }) + + test('filters namespaces by search term', async () => { + const client = testClient(async () => new Response(JSON.stringify({ + namespaces: [{name: 'orders-bus', mocked: false}, {name: 'billing-bus', mocked: true}], + }))) + + const resources = await new AzureServiceBusAdapter(client).list({search: 'bill'}) + + expect(resources.map((resource) => resource.name)).toEqual(['billing-bus']) + expect(resources[0].status).toBe('Mocked') + }) + + test('gets a namespace by name', async () => { + const calls: RecordedCall[] = [] + const client = testClient(async (path, init, options) => { + calls.push({path, init, options}) + return new Response(JSON.stringify({name: 'orders-bus', mocked: false})) + }) + + const resource = await new AzureServiceBusAdapter(client).get('orders-bus') + + expect(calls[0].path).toBe('/devstoreaccount1-servicebus/namespaces/orders-bus') + expect(calls[0].init.method).toBe('GET') + expect(calls[0].options).toEqual({emptyOnNotFound: true}) + expect(resource?.id).toBe('orders-bus') + }) + + test('creates a namespace through the emulator management contract', async () => { + const calls: RecordedCall[] = [] + const client = testClient(async (path, init, options) => { + calls.push({path, init, options}) + return new Response(JSON.stringify({name: 'orders-bus', amqpPort: 5672, mocked: false}), {status: 201}) + }) + + const resource = await new AzureServiceBusAdapter(client).create({values: {namespaceName: 'orders-bus'}}) + + expect(calls[0].path).toBe('/devstoreaccount1-servicebus/namespaces/orders-bus') + expect(calls[0].init.method).toBe('PUT') + expect(calls[0].init.body).toBe('{}') + expect(calls[0].init.headers).toEqual({accept: 'application/json', 'content-type': 'application/json'}) + expect(resource.id).toBe('orders-bus') + }) + + test('surfaces Service Bus create errors', async () => { + const client = testClient(async () => new Response( + JSON.stringify({message: 'Namespace already exists'}), + {status: 409}, + )) + + await expect(new AzureServiceBusAdapter(client).create({values: {namespaceName: 'orders-bus'}})) + .rejects.toThrow('HTTP 409 - {"message":"Namespace already exists"}') + }) + + test('rejects invalid namespace names', async () => { + const adapter = new AzureServiceBusAdapter(testClient(async () => new Response())) + + await expect(adapter.create({values: {namespaceName: 'short'}})).rejects.toThrow('Use a valid Service Bus namespace') + await expect(adapter.create({values: {namespaceName: 'orders-sb'}})).rejects.toThrow('Use a valid Service Bus namespace') + }) + + test('deletes a namespace through the emulator management contract', async () => { + const calls: RecordedCall[] = [] + const client = testClient(async (path, init, options) => { + calls.push({path, init, options}) + return new Response(null, {status: 204}) + }) + + await new AzureServiceBusAdapter(client).delete('orders-bus') + + expect(calls).toEqual([{ + path: '/devstoreaccount1-servicebus/namespaces/orders-bus', + init: {method: 'DELETE'}, + options: {emptyOnNotFound: true}, + }]) + }) +}) + +function testClient( + handler: (path: string, init: RequestInit, options?: AzureRuntimeFetchOptions) => Promise, +): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + fetch: handler, + } +} diff --git a/packages/api/src/adapter-azure/AzureServiceBusAdapter.ts b/packages/api/src/adapter-azure/AzureServiceBusAdapter.ts new file mode 100644 index 0000000..4343b84 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureServiceBusAdapter.ts @@ -0,0 +1,154 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {azureServiceBusSchema} from '../cloud-spi/servicebusSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +interface ServiceBusNamespaceRecord { + name?: string + amqpPort?: number + amqpsPort?: number + tlsCertPem?: string + mocked?: boolean +} + +interface ServiceBusNamespaceListResponse { + namespaces?: ServiceBusNamespaceRecord[] +} + +export class AzureServiceBusAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'messaging' as const + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureServiceBusSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.azureJson( + namespacePath(this.client), + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + + const resources = (body?.namespaces ?? []) + .map(toNamespaceResource) + .filter((resource): resource is CloudResource => resource !== null) + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + const body = await this.azureJson( + namespaceResourcePath(this.client, id), + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + + return body ? toNamespaceResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + const namespaceName = stringValue(input.values.namespaceName) + if (!namespaceName) throw new Error('namespaceName is required') + if (!isValidNamespaceName(namespaceName)) { + throw new Error('Use a valid Service Bus namespace: 6-50 letters, numbers, or hyphens; start with a letter and end with a letter or number.') + } + + const body = await this.azureJson( + namespaceResourcePath(this.client, namespaceName), + {method: 'PUT', body: '{}'}, + ) + if (!body) throw new Error('Azure Service Bus create returned an empty response') + const resource = toNamespaceResource(body) + if (!resource) throw new Error('Azure Service Bus create returned a namespace without a name') + return resource + } + + async delete(id: string): Promise { + await this.client.fetch( + namespaceResourcePath(this.client, id), + {method: 'DELETE'}, + {emptyOnNotFound: true}, + ) + } + + private async azureJson( + path: string, + init: RequestInit, + options?: {emptyOnNotFound?: boolean}, + ): Promise { + const res = await this.client.fetch( + path, + { + ...init, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + }, + options, + ) + + if (!res || res.status === 204) return null + if (!res.ok) { + const detail = (await res.text()).trim().slice(0, 500) + throw new Error(`Azure Service Bus request failed: HTTP ${res.status}${detail ? ` - ${detail}` : ''}`) + } + return await res.json() as T + } +} + +function namespacePath(client: AzureRuntimeClient): string { + return `/${encodeURIComponent(client.accountName)}-servicebus/namespaces` +} + +function namespaceResourcePath(client: AzureRuntimeClient, namespaceName: string): string { + return `${namespacePath(client)}/${encodeURIComponent(namespaceName)}` +} + +function toNamespaceResource(record: ServiceBusNamespaceRecord): CloudResource | null { + const name = stringValue(record.name) + if (!name) return null + + return { + id: name, + name, + cloud: 'azure', + service: 'messaging', + type: 'servicebus-namespace', + region: null, + createdAt: null, + status: record.mocked ? 'Mocked' : 'Running', + metadata: { + provider: 'azure', + messagingService: 'service-bus', + amqpPort: record.amqpPort, + amqpsPort: record.amqpsPort, + mocked: record.mocked, + tlsEnabled: Boolean(record.tlsCertPem), + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isValidNamespaceName(value: string): boolean { + return /^[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$/.test(value) + && !value.toLowerCase().endsWith('-sb') + && !value.toLowerCase().endsWith('-mgmt') +} diff --git a/packages/api/src/cloud-spi/servicebusSchema.ts b/packages/api/src/cloud-spi/servicebusSchema.ts new file mode 100644 index 0000000..91d9c5e --- /dev/null +++ b/packages/api/src/cloud-spi/servicebusSchema.ts @@ -0,0 +1,43 @@ +import type {CapabilitySchema, CloudProvider, ResourceActionName, ServiceSchema} from './types' + +const resourceActions: CapabilitySchema[] = [ + {name: 'list', label: 'List namespaces', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create namespace', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete namespace', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect namespace', enabled: true, status: 'available', runtimeRequired: false}, +] + +export function azureServiceBusSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'messaging', + displayName: 'Service Bus', + fields: [ + { + name: 'namespaceName', + label: 'Namespace Name', + type: 'text', + required: true, + description: '6-50 characters. Letters, numbers, and hyphens; starts and ends with a letter or number.', + validation: { + pattern: '^(?!.*-(?:[sS][bB]|[mM][gG][mM][tT])$)[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$', + minLength: 6, + maxLength: 50, + message: 'Use a valid Service Bus namespace: 6-50 letters, numbers, or hyphens; start with a letter and end with a letter or number.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: {resourceActions}, + filters: [{name: 'search', label: 'Search', type: 'text', required: false}], + columns: [ + {name: 'name', label: 'Namespace'}, + {name: 'status', label: 'Status'}, + {name: 'type', label: 'Type'}, + ], + } +} + +export function servicebusSchemaFor(cloud: CloudProvider): ServiceSchema | null { + return cloud === 'azure' ? azureServiceBusSchema() : null +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index bf9da0d..3a2c6d8 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -1,6 +1,6 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' +export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' | 'messaging' export type CloudAvailability = 'available' | 'coming_soon' @@ -83,7 +83,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'servicebus-namespace' region: string | null createdAt: string | null status?: string | null diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index b9740c0..02f4678 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -5,6 +5,7 @@ import {AwsDatabaseAdapter} from './adapter-aws/AwsDatabaseAdapter' import {AwsEksAdapter} from './adapter-aws/AwsEksAdapter' import {AwsStorageAdapter} from './adapter-aws/AwsStorageAdapter' import {AzureDatabaseAdapter} from './adapter-azure/AzureDatabaseAdapter' +import {AzureServiceBusAdapter} from './adapter-azure/AzureServiceBusAdapter' import {AzureStorageAdapter} from './adapter-azure/AzureStorageAdapter' import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter' import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter' @@ -34,6 +35,7 @@ export function createCloudProxyService(accountId?: string | null): CloudProxySe new AwsNetworkingAdapter(ec2Service), new AwsServerlessAdapter(clients.lambda), new AzureStorageAdapter(), + new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 49296be..5bdbe3a 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -2,6 +2,7 @@ import {describe, expect, test} from 'bun:test' import {Hono} from 'hono' import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' import {awsStorageSchema, azureStorageSchema} from '../cloud-spi/storageSchema' +import {azureServiceBusSchema} from '../cloud-spi/servicebusSchema' import type {CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput} from '../cloud-spi/types' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {CloudProxyService} from '../service/CloudProxyService' @@ -82,6 +83,20 @@ describe('cloud schema routes', () => { expect(body.displayName).toBe('Cosmos DB') }) + test('returns Azure Service Bus schema when the messaging adapter is registered', async () => { + const app = appWithRoutes([mockAdapter('azure', { + service: 'messaging', + schema: azureServiceBusSchema, + })]) + const res = await app.request('/api/clouds/azure/services/messaging/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.cloud).toBe('azure') + expect(body.service).toBe('messaging') + expect(body.displayName).toBe('Service Bus') + }) + test('returns GCP storage schema', async () => { const res = await appWithRoutes().request('/api/clouds/gcp/services/storage/schema') const body = await res.json() diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 93cb831..fe1902d 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -261,7 +261,7 @@ function isCloudProvider(value: string): value is CloudProvider { } function isServiceType(value: string): value is CloudServiceType { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' + return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' || value === 'messaging' } async function withRuntime(c: Context, handler: () => Promise): Promise { diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 826bf12..7597138 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -20,6 +20,7 @@ import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {serverlessSchemaFor} from '../cloud-spi/serverlessSchema' import {k8sSchemaFor} from '../cloud-spi/eksSchema' import {databaseSchemaFor} from '../cloud-spi/databaseSchema' +import {servicebusSchemaFor} from '../cloud-spi/servicebusSchema' import {azureEndpoint} from '../azure' import {checkGcpRuntime, gcpEndpoint} from '../gcp' @@ -73,6 +74,12 @@ export class CloudProxyService { displayName: 'Networking', availability: this.registry.get(cloud, 'networking') ? 'available' : 'coming_soon', }) + services.push({ + cloud, + service: 'messaging', + displayName: cloud === 'azure' ? 'Service Bus' : 'Messaging', + availability: this.registry.get(cloud, 'messaging') ? 'available' : 'coming_soon', + }) return services } @@ -83,6 +90,7 @@ export class CloudProxyService { if (service === 'k8s') return k8sSchemaFor(cloud) if (service === 'database') return databaseSchemaFor(cloud) if (service === 'serverless') return serverlessSchemaFor(cloud) + if (service === 'messaging') return servicebusSchemaFor(cloud) return null } diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 706b77b..bd356d3 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -355,6 +355,8 @@ function resourceCreateLabel(schema: ServiceSchema): string { return "Create container"; if (schema.cloud === "azure" && schema.service === "database") return "Create database"; + if (schema.cloud === "azure" && schema.service === "messaging") + return "Create namespace"; return "Create resource"; } diff --git a/packages/frontend/src/components/Layout.tsx b/packages/frontend/src/components/Layout.tsx index 31a1891..ced0c69 100644 --- a/packages/frontend/src/components/Layout.tsx +++ b/packages/frontend/src/components/Layout.tsx @@ -40,6 +40,7 @@ const CLOUD_SERVICE_ICONS = { compute: Server, networking: Network, serverless: Zap, + messaging: MessageSquare, } satisfies Record type CloudSidebarService = keyof typeof CLOUD_SERVICE_ICONS @@ -52,6 +53,7 @@ const CLOUD_SERVICE_ITEMS: Array<{name: CloudSidebarService; label: string; rout {name: 'networking', label: 'Networking', route: 'networking'}, {name: 'secretsmanager', label: 'Secrets Manager', route: '/secretsmanager'}, {name: 'serverless', label: 'Serverless', route: 'serverless'}, + {name: 'messaging', label: 'Messaging', route: 'messaging'}, {name: 'queue', label: 'Queue'}, {name: 'function', label: 'Function'}, ] @@ -71,6 +73,7 @@ function CloudServiceNav() { || (service.name === 'database' && (cloud === 'aws' || cloud === 'azure')) || ((service.name === 'k8s' || service.name === 'compute' || service.name === 'networking') && cloud === 'aws') || (service.name === 'serverless' && (cloud === 'aws' || cloud === 'azure')) + || (service.name === 'messaging' && cloud === 'azure') if (service.route && available) { const target = service.route.startsWith('/') ? service.route : `/cloud-explorer/${cloud}/${service.route}` return diff --git a/packages/frontend/src/pages/CloudExplorerPage.tsx b/packages/frontend/src/pages/CloudExplorerPage.tsx index 37efef0..899dca6 100644 --- a/packages/frontend/src/pages/CloudExplorerPage.tsx +++ b/packages/frontend/src/pages/CloudExplorerPage.tsx @@ -102,7 +102,7 @@ function normalizeCloud(value?: string): CloudProvider | null { } function normalizeService(value?: string): CloudServiceType | null { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'compute' || value === 'networking' || value === 'serverless' ? value : null + return value === 'storage' || value === 'k8s' || value === 'database' || value === 'compute' || value === 'networking' || value === 'serverless' || value === 'messaging' ? value : null } function ServiceInfoDialog({ @@ -256,6 +256,7 @@ function connectionValue(status?: CloudStatus, loading?: boolean): string { function serviceLabel(service: CloudServiceType): string { if (service === 'k8s') return 'k8s Engine' if (service === 'serverless') return 'Serverless' + if (service === 'messaging') return 'Messaging' return service.charAt(0).toUpperCase() + service.slice(1) } diff --git a/packages/frontend/src/types/cloud.ts b/packages/frontend/src/types/cloud.ts index 668a147..720e3aa 100644 --- a/packages/frontend/src/types/cloud.ts +++ b/packages/frontend/src/types/cloud.ts @@ -1,6 +1,6 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' export type CloudAvailability = 'available' | 'coming_soon' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'compute' | 'networking' | 'serverless' +export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'compute' | 'networking' | 'serverless' | 'messaging' export interface CloudDescriptor { id: CloudProvider diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index 71f464f..586a152 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -5,7 +5,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function'; + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'servicebus-namespace'; region: string | null createdAt: string | null status?: string | null