Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
136 changes: 136 additions & 0 deletions packages/api/src/adapter-azure/AzureServiceBusAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response | null>,
): AzureRuntimeClient {
return {
endpoint: 'http://localhost:4577',
accountName: 'devstoreaccount1',
fetch: handler,
}
}
154 changes: 154 additions & 0 deletions packages/api/src/adapter-azure/AzureServiceBusAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<CloudResource[]> {
const body = await this.azureJson<ServiceBusNamespaceListResponse>(
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<CloudResource | null> {
const body = await this.azureJson<ServiceBusNamespaceRecord>(
namespaceResourcePath(this.client, id),
{method: 'GET'},
{emptyOnNotFound: true},
)

return body ? toNamespaceResource(body) : null
}

async create(input: CreateResourceInput): Promise<CloudResource> {
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<ServiceBusNamespaceRecord>(
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<void> {
await this.client.fetch(
namespaceResourcePath(this.client, id),
{method: 'DELETE'},
{emptyOnNotFound: true},
)
}

private async azureJson<T>(
path: string,
init: RequestInit,
options?: {emptyOnNotFound?: boolean},
): Promise<T | null> {
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')
}
43 changes: 43 additions & 0 deletions packages/api/src/cloud-spi/servicebusSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {CapabilitySchema, CloudProvider, ResourceActionName, ServiceSchema} from './types'

const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{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
}
4 changes: 2 additions & 2 deletions packages/api/src/cloud-spi/types.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/cloudProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(),
Expand Down
Loading