From aa400ce1f44753367d686b49bdf0a4522d593146 Mon Sep 17 00:00:00 2001 From: TheSaifZaman Date: Tue, 28 Jul 2026 15:45:51 +0600 Subject: [PATCH 1/4] feat(compute): add an Azure Virtual Machines adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing `compute` category to Azure. No catalog row and no new CloudResource type — `compute` and `instance` already exist — so this adds an adapter, a schema function and one registry line, and does not touch the shared SPI surface. This corrects a recorded assumption that floci-az answers 501 for everything beyond blob storage and Cosmos. That holds for the legacy endpoints (/functions, /Tables) but not for ARM: the Microsoft.Compute provider paths are real handlers. Confirmed by checking that a bogus provider 404s with "Unsupported Microsoft.Compute path" rather than returning an empty list, so the 200s are not a catch-all stub. Verified against the runtime: create returns 201, delete 204 and the VM is gone, instanceView reports a real PowerState, and powerOff/start/ restart genuinely move a VM between running, stopped and deallocated. Decisions that follow from probing: - Resources are addressed as `resourceGroup/name`. ARM cannot address a VM without its resource group and the generic route passes one id; the id survives the route because HttpClient encodes path params. - create verifies the resource group exists first. The runtime creates a VM in a nonexistent group and returns 201, while real Azure answers ResourceGroupNotFound, so the check has to live in the adapter. - The subscription is discovered from /subscriptions rather than hardcoded, but is never presented as a scope: the runtime returns the same resources for any subscription id, so a subscription selector would be fake data for the same reason the region selector was. - start/stop/reboot are implemented and work, but are advertised as coming_soon with a reason, because there is no generic resource actions route yet — only invoke and the object routes. Advertising a control the console cannot call would break the schema-is-a-promise contract. - Power state is a per-VM instanceView fan-out, so a failure degrades the row with metadata.powerStateUnavailable instead of failing the list. Verified end to end through the route: nav entry now available for Azure, create, list, inspect by encoded id, delete, the resource-group rejection returning 400, and the schema reporting the three lifecycle verbs as coming_soon. --- .../adapter-azure/AzureComputeAdapter.test.ts | 250 ++++++++++++++ .../src/adapter-azure/AzureComputeAdapter.ts | 319 ++++++++++++++++++ packages/api/src/cloud-spi/computeSchema.ts | 147 ++++++++ packages/api/src/cloudProxy.ts | 2 + 4 files changed, 718 insertions(+) create mode 100644 packages/api/src/adapter-azure/AzureComputeAdapter.test.ts create mode 100644 packages/api/src/adapter-azure/AzureComputeAdapter.ts diff --git a/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts b/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts new file mode 100644 index 0000000..d5ff40e --- /dev/null +++ b/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts @@ -0,0 +1,250 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {AzureComputeAdapter} from './AzureComputeAdapter' +import {AzureRestRuntimeClient} from '../azure' +import {ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4577' +const SUB = '00000000-0000-0000-0000-000000000001' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): AzureComputeAdapter { + return new AzureComputeAdapter(new AzureRestRuntimeClient(ENDPOINT, 'devstoreaccount1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), {status}) +} + +function vm(name: string, rg = 'rg-app', overrides: Record = {}) { + return { + id: `/subscriptions/${SUB}/resourceGroups/${rg}/providers/Microsoft.Compute/virtualMachines/${name}`, + name, + type: 'Microsoft.Compute/virtualMachines', + location: 'eastus', + properties: { + hardwareProfile: {vmSize: 'Standard_B1s'}, + storageProfile: { + imageReference: {publisher: 'Canonical', offer: 'UbuntuServer', sku: '20.04-LTS', version: 'latest'}, + }, + osProfile: {computerName: name, adminUsername: 'azureuser'}, + vmId: '4190719c-60b8-4a37-9346-6bf7be1730e0', + provisioningState: 'Succeeded', + timeCreated: '2026-07-28T09:37:46.091123168Z', + ...overrides, + }, + } +} + +function instanceView(power = 'PowerState/running') { + return { + computerName: 'vm', + osName: 'Linux', + statuses: [ + {code: 'ProvisioningState/succeeded', displayStatus: 'Provisioning succeeded', level: 'Info'}, + {code: power, displayStatus: 'VM running', level: 'Info'}, + ], + } +} + +/** Routes each request to what the runtime actually answers for it. */ +function runtimeStub(vms: unknown[] = [vm('web')], power = 'PowerState/running') { + return stubFetch((url) => { + if (url.includes('/subscriptions') && url.endsWith('/subscriptions')) { + return json({value: [{subscriptionId: SUB, displayName: 'floci-az local', state: 'Enabled'}]}) + } + if (url.includes('/instanceView')) return json(instanceView(power)) + if (url.includes('/virtualMachines/')) return json(vms[0] ?? {}) + if (url.includes('/resourceGroups') && !url.includes('/providers')) { + return json({value: [{name: 'rg-app', id: `/subscriptions/${SUB}/resourceGroups/rg-app`}]}) + } + return json({value: vms}) + }) +} + +describe('AzureComputeAdapter', () => { + test('identifies itself as the Azure compute adapter', () => { + const a = adapter() + + expect(a.cloud).toBe('azure') + expect(a.service).toBe('compute') + expect(a.schema().displayName).toBe('Azure Virtual Machines') + }) + + test('lists VMs and maps the ARM resource shape', async () => { + runtimeStub() + const [resource] = await adapter().list() + + expect(resource).toMatchObject({ + // The id carries the resource group, because ARM cannot address a VM + // without it and the generic route only passes one id. + id: 'rg-app/web', + name: 'web', + cloud: 'azure', + service: 'compute', + type: 'instance', + region: 'eastus', + instanceClass: 'Standard_B1s', + createdAt: '2026-07-28T09:37:46.091123168Z', + }) + expect(resource?.metadata).toMatchObject({ + resourceGroup: 'rg-app', + provisioningState: 'Succeeded', + adminUsername: 'azureuser', + image: 'Canonical:UbuntuServer:20.04-LTS', + }) + }) + + test('reports the power state from the instance view', async () => { + runtimeStub([vm('web')], 'PowerState/stopped') + const [resource] = await adapter().list() + + expect(resource?.status).toBe('stopped') + }) + + test('still lists VMs when the instance view fails', async () => { + // Power state is enrichment fanned out per VM. An unisolated failure would + // reject the whole list and show nothing. + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + if (url.includes('/instanceView')) return json({error: {message: 'boom'}}, 500) + return json({value: [vm('web')]}) + }) + const [resource] = await adapter().list() + + expect(resource?.id).toBe('rg-app/web') + expect(resource?.status).toBeNull() + expect(resource?.metadata.powerStateUnavailable).toBe(true) + }) + + test('filters the list by search term', async () => { + runtimeStub([vm('web'), vm('api')]) + const a = adapter() + + await expect(a.list({search: 'we'})).resolves.toHaveLength(1) + await expect(a.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a VM addressed by resource group and name', async () => { + const calls = runtimeStub() + const resource = await adapter().get('rg-app/web') + + const vmCall = calls.find((c) => c.url.includes('/virtualMachines/web')) + expect(vmCall?.url).toContain(`/subscriptions/${SUB}/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/web`) + expect(resource?.id).toBe('rg-app/web') + }) + + test('rejects an id that does not name a resource group', async () => { + await expect(adapter().get('web')).rejects.toThrow(ValidationError) + }) + + test('returns null when a VM does not exist', async () => { + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + return json({error: {message: 'not found', code: 'ResourceNotFound'}}, 404) + }) + await expect(adapter().get('rg-app/nope')).resolves.toBeNull() + }) + + test('creates a VM with the image reference the label maps to', async () => { + const calls = runtimeStub() + await adapter().create({ + values: {name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s', image: 'Ubuntu 22.04 LTS'}, + }) + + const put = calls.find((c) => c.init?.method === 'PUT') + expect(put?.url).toContain('/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/web') + const body = JSON.parse(String(put?.init?.body)) + expect(body.location).toBe('eastus') + expect(body.properties.hardwareProfile.vmSize).toBe('Standard_B1s') + expect(body.properties.storageProfile.imageReference).toEqual({ + publisher: 'Canonical', + offer: '0001-com-ubuntu-server-jammy', + sku: '22_04-lts', + version: 'latest', + }) + expect(body.properties.osProfile.adminUsername).toBe('azureuser') + }) + + test('verifies the resource group exists, because the runtime does not', async () => { + // The local runtime happily creates a VM in a resource group that does not + // exist and returns 201. Real Azure answers ResourceGroupNotFound, so the + // check belongs here or the console would create unreachable resources. + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + if (url.includes('/resourceGroups') && !url.includes('/providers')) return json({value: []}) + return json(vm('web')) + }) + + await expect( + adapter().create({ + values: {name: 'web', resourceGroup: 'rg-missing', vmSize: 'Standard_B1s', image: 'Debian 11'}, + }), + ).rejects.toThrow(ValidationError) + }) + + test('requires the fields the schema marks required', async () => { + runtimeStub() + const a = adapter() + + const cases: Array<[Record, string]> = [ + [{}, 'name is required'], + [{name: 'web'}, 'resourceGroup is required'], + [{name: 'web', resourceGroup: 'rg-app'}, 'vmSize is required'], + [{name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s'}, 'image is required'], + ] + + for (const [values, message] of cases) { + await expect(a.create({values})).rejects.toThrow(new ValidationError(message)) + } + }) + + test('rejects values the schema does not offer', async () => { + runtimeStub() + const a = adapter() + const base = {name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s', image: 'Debian 11'} + + await expect(a.create({values: {...base, vmSize: 'Standard_MEGA'}})).rejects.toThrow(ValidationError) + await expect(a.create({values: {...base, image: 'TempleOS'}})).rejects.toThrow(ValidationError) + await expect(a.create({values: {...base, location: 'mars1'}})).rejects.toThrow(ValidationError) + await expect(a.create({values: {...base, name: 'bad name!'}})).rejects.toThrow(ValidationError) + }) + + test('deletes a VM', async () => { + const calls = runtimeStub() + await adapter().delete('rg-app/web') + + const del = calls.find((c) => c.init?.method === 'DELETE') + expect(del?.url).toContain('/virtualMachines/web') + }) + + describe('lifecycle verbs map to the ARM actions', () => { + const cases: Array<['start' | 'stop' | 'reboot', string]> = [ + ['start', '/start'], + ['stop', '/powerOff'], + ['reboot', '/restart'], + ] + + for (const [verb, suffix] of cases) { + test(`${verb} posts to ${suffix}`, async () => { + const calls = runtimeStub() + await adapter()[verb]('rg-app/web') + + const post = calls.find((c) => c.init?.method === 'POST') + expect(post?.url).toContain(`/virtualMachines/web${suffix}`) + }) + } + }) +}) diff --git a/packages/api/src/adapter-azure/AzureComputeAdapter.ts b/packages/api/src/adapter-azure/AzureComputeAdapter.ts new file mode 100644 index 0000000..64629c4 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureComputeAdapter.ts @@ -0,0 +1,319 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import { + AZURE_LOCATIONS, + AZURE_VM_IMAGES, + AZURE_VM_NAME_PATTERN, + AZURE_VM_SIZES, + azureComputeSchema, +} from '../cloud-spi/computeSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to the Floci-AZ runtime's ARM surface for Microsoft.Compute — verified + * against `floci/floci-az` 0.9.0: + * + * list GET /subscriptions/{s}/providers/Microsoft.Compute/virtualMachines + * get GET /subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{n} + * create PUT (same path as get) -> 201 + * delete DELETE (same path as get) -> 204 + * power GET (get path)/instanceView -> statuses[] + * start POST (get path)/start -> 202 + * stop POST (get path)/powerOff -> 202 + * reboot POST (get path)/restart -> 202 + * + * This corrects an earlier assumption that floci-az answers 501 for everything + * beyond blob and Cosmos. That is true of the *legacy* endpoints (`/functions`, + * `/Tables`) but not of ARM: the provider paths are real handlers, confirmed + * because a bogus provider 404s with `Unsupported Microsoft.Compute path`. + * + * Notes from probing the runtime: + * - It does **not** partition by subscription: any subscription id returns the + * same resources. The subscription is therefore discovered from + * `/subscriptions` and used as a fixed scope; it is never presented as a + * selectable scope, which would be fake data. + * - Lifecycle verbs genuinely change power state, so they are advertised. + * - `create` succeeds with 201 even when the resource group does not exist. Real + * Azure answers ResourceGroupNotFound, so this adapter checks first. + * - Resource group DELETE returns 200 and does nothing, so this adapter does not + * manage resource groups at all. + */ +const API_VERSION = '2023-03-01' +const RESOURCE_API_VERSION = '2021-04-01' + +interface ArmList { + value?: T[] +} + +interface AzureVm { + id?: string + name?: string + location?: string + tags?: Record + properties?: { + hardwareProfile?: {vmSize?: string} + storageProfile?: { + imageReference?: {publisher?: string; offer?: string; sku?: string; version?: string} + osDisk?: Record + } + osProfile?: {computerName?: string; adminUsername?: string} + vmId?: string + provisioningState?: string + timeCreated?: string + } +} + +interface AzureInstanceView { + computerName?: string + osName?: string + statuses?: Array<{code?: string; displayStatus?: string; level?: string}> +} + +/** Power state plus whether the lookup failed, so the two stay distinguishable. */ +type PowerLookup = {state: string | null; unavailable?: true} + +export class AzureComputeAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'compute' as const + + private subscriptionId: string | null = null + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureComputeSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const subscription = await this.subscription() + const body = await this.json>( + `/subscriptions/${subscription}/providers/Microsoft.Compute/virtualMachines?api-version=${API_VERSION}`, + ) + + const resources = await Promise.all( + (body?.value ?? []).map(async (machine) => toResource(machine, await this.powerState(machine.id ?? ''))), + ) + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + const {resourceGroup, name} = parseId(id) + const path = await this.vmPath(resourceGroup, name) + const machine = await this.json(`${path}?api-version=${API_VERSION}`, {}, {emptyOnNotFound: true}) + if (!machine) return null + return toResource(machine, await this.powerState(machine.id ?? '')) + } + + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (!new RegExp(AZURE_VM_NAME_PATTERN).test(name)) { + throw new ValidationError( + 'name must start with a letter or digit and contain only letters, digits, hyphens, underscores and periods', + ) + } + const resourceGroup = requiredString(input.values.resourceGroup, 'resourceGroup') + const vmSize = requiredOneOf(input.values.vmSize, AZURE_VM_SIZES, 'vmSize') + const imageLabel = requiredOneOf( + input.values.image, + Object.keys(AZURE_VM_IMAGES) as Array, + 'image', + ) + const location = optionalOneOf(input.values.location, AZURE_LOCATIONS, 'location') ?? 'eastus' + const adminUsername = optionalString(input.values.adminUsername, 'adminUsername') ?? 'azureuser' + + await this.assertResourceGroupExists(resourceGroup) + + const path = await this.vmPath(resourceGroup, name) + await this.json(`${path}?api-version=${API_VERSION}`, { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + location, + properties: { + hardwareProfile: {vmSize}, + storageProfile: {imageReference: AZURE_VM_IMAGES[imageLabel]}, + osProfile: {computerName: name, adminUsername}, + }, + }), + }) + + const created = await this.get(`${resourceGroup}/${name}`) + if (!created) throw new RuntimeError(`VM ${name} was created but could not be read back`) + return created + } + + async delete(id: string): Promise { + const {resourceGroup, name} = parseId(id) + const path = await this.vmPath(resourceGroup, name) + await this.client.fetch(`${path}?api-version=${API_VERSION}`, {method: 'DELETE'}, {emptyOnNotFound: true}) + } + + async start(id: string): Promise { + await this.power(id, 'start') + } + + /** ARM calls it powerOff; `deallocate` also exists but releases the compute. */ + async stop(id: string): Promise { + await this.power(id, 'powerOff') + } + + async reboot(id: string): Promise { + await this.power(id, 'restart') + } + + private async power(id: string, action: 'start' | 'powerOff' | 'restart'): Promise { + const {resourceGroup, name} = parseId(id) + const path = await this.vmPath(resourceGroup, name) + await this.client.fetch(`${path}/${action}?api-version=${API_VERSION}`, {method: 'POST'}) + } + + /** + * The runtime ignores the subscription id, but a real one keeps the emitted + * resource ids honest, so it is discovered once rather than hardcoded. + */ + private async subscription(): Promise { + if (this.subscriptionId) return this.subscriptionId + + const body = await this.json>('/subscriptions') + const id = body?.value?.[0]?.subscriptionId + if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope compute requests to') + + this.subscriptionId = id + return id + } + + private async vmPath(resourceGroup: string, name: string): Promise { + const subscription = await this.subscription() + return `/subscriptions/${subscription}/resourceGroups/${encodeURIComponent(resourceGroup)}/providers/Microsoft.Compute/virtualMachines/${encodeURIComponent(name)}` + } + + /** + * The runtime creates a VM in a nonexistent resource group and returns 201, + * while real Azure answers ResourceGroupNotFound. Checking here stops the + * console creating a resource that could never exist against a real provider. + */ + private async assertResourceGroupExists(resourceGroup: string): Promise { + const subscription = await this.subscription() + const body = await this.json>( + `/subscriptions/${subscription}/resourceGroups?api-version=${RESOURCE_API_VERSION}`, + ) + const exists = (body?.value ?? []).some((group) => group.name === resourceGroup) + if (!exists) { + throw new ValidationError(`resource group ${resourceGroup} does not exist; create it before adding a VM`) + } + } + + /** + * Power state comes from a per-VM instanceView call, so a failure degrades the + * row rather than the request — `list` fans these out through `Promise.all`. + */ + private async powerState(resourceId: string): Promise { + if (!resourceId) return {state: null} + + try { + const view = await this.json(`${resourceId}/instanceView?api-version=${API_VERSION}`) + const power = (view?.statuses ?? []).find((status) => status.code?.startsWith('PowerState/')) + return {state: power?.code?.slice('PowerState/'.length) ?? null} + } catch { + return {state: null, unavailable: true} + } + } + + private async json(path: string, init: RequestInit = {}, options = {}): Promise { + const res = await this.client.fetch(path, init, options) + if (!res) return null + + const text = await res.text() + if (!text) return null + return JSON.parse(text) as T + } +} + +function toResource(machine: AzureVm, power: PowerLookup): CloudResource { + const properties = machine.properties ?? {} + const name = machine.name ?? '' + const resourceGroup = resourceGroupOf(machine.id ?? '') + const image = properties.storageProfile?.imageReference + + return { + id: resourceGroup ? `${resourceGroup}/${name}` : name, + name, + cloud: 'azure', + service: 'compute', + type: 'instance', + region: machine.location ?? null, + createdAt: properties.timeCreated ?? null, + status: power.state, + instanceClass: properties.hardwareProfile?.vmSize ?? null, + metadata: { + provider: 'azure', + resourceId: machine.id, + resourceGroup, + vmId: properties.vmId, + provisioningState: properties.provisioningState, + computerName: properties.osProfile?.computerName, + adminUsername: properties.osProfile?.adminUsername, + image: image ? `${image.publisher}:${image.offer}:${image.sku}` : undefined, + imageVersion: image?.version, + osDisk: properties.storageProfile?.osDisk, + tags: machine.tags, + ...(power.unavailable ? {powerStateUnavailable: true} : {}), + }, + } +} + +/** ARM cannot address a VM without its resource group, so the id carries both. */ +function parseId(id: string): {resourceGroup: string; name: string} { + const [resourceGroup, ...rest] = id.split('/') + const name = rest.join('/') + if (!resourceGroup || !name) { + throw new ValidationError(`VM id must be "resourceGroup/name", got "${id}"`) + } + return {resourceGroup, name} +} + +function resourceGroupOf(resourceId: string): string { + const match = resourceId.match(/resourceGroups\/([^/]+)/i) + return match ? match[1] : '' +} + +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 requiredString(value: unknown, field: string): string { + const raw = optionalString(value, field) + if (raw === undefined) throw new ValidationError(`${field} is required`) + return raw +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + return value.trim() || undefined +} + +function requiredOneOf(value: unknown, allowed: T, field: string): T[number] { + const raw = requiredString(value, field) + if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + return raw as T[number] +} + +function optionalOneOf( + value: unknown, + allowed: T, + field: string, +): T[number] | undefined { + const raw = optionalString(value, field) + if (raw === undefined) return undefined + if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + return raw as T[number] +} diff --git a/packages/api/src/cloud-spi/computeSchema.ts b/packages/api/src/cloud-spi/computeSchema.ts index f77cefa..78941cd 100644 --- a/packages/api/src/cloud-spi/computeSchema.ts +++ b/packages/api/src/cloud-spi/computeSchema.ts @@ -80,6 +80,153 @@ const computeFields: FieldSchema[] = [ }, ] +/** + * Azure VM images, keyed by the label offered in the create form. + * + * The ARM API wants a four-part `imageReference`; asking a user for four fields to + * launch a VM is worse than a short list of known-good images. Shared with the + * adapter so the form and the request cannot drift. + */ +export const AZURE_VM_IMAGES = { + 'Ubuntu 20.04 LTS': {publisher: 'Canonical', offer: 'UbuntuServer', sku: '20.04-LTS', version: 'latest'}, + 'Ubuntu 22.04 LTS': { + publisher: 'Canonical', + offer: '0001-com-ubuntu-server-jammy', + sku: '22_04-lts', + version: 'latest', + }, + 'Debian 11': {publisher: 'Debian', offer: 'debian-11', sku: '11', version: 'latest'}, + 'Windows Server 2022': { + publisher: 'MicrosoftWindowsServer', + offer: 'WindowsServer', + sku: '2022-datacenter', + version: 'latest', + }, +} as const + +export const AZURE_VM_SIZES = [ + 'Standard_B1s', + 'Standard_B2s', + 'Standard_D2s_v3', + 'Standard_D4s_v3', + 'Standard_F2s_v2', +] as const + +export const AZURE_LOCATIONS = ['eastus', 'eastus2', 'westus', 'westus2', 'westeurope', 'northeurope'] as const + +/** Azure VM names: 1-64 chars, letters, digits, hyphens, underscores and periods. */ +export const AZURE_VM_NAME_PATTERN = '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$' + +export function azureComputeSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'compute', + displayName: 'Azure Virtual Machines', + fields: [ + { + name: 'name', + label: 'VM Name', + type: 'text', + required: true, + span: true, + group: 'Required', + validation: { + pattern: AZURE_VM_NAME_PATTERN, + minLength: 1, + maxLength: 64, + message: 'Start with a letter or digit; letters, digits, hyphens, underscores and periods only.', + }, + }, + { + name: 'resourceGroup', + label: 'Resource Group', + type: 'text', + required: true, + group: 'Required', + description: 'Must already exist. Every Azure resource lives in a resource group.', + }, + { + name: 'vmSize', + label: 'VM Size', + type: 'select', + required: true, + options: AZURE_VM_SIZES.map((value) => ({label: value, value})), + }, + { + name: 'image', + label: 'Image', + type: 'select', + required: true, + options: Object.keys(AZURE_VM_IMAGES).map((value) => ({label: value, value})), + }, + { + name: 'location', + label: 'Location', + type: 'select', + required: false, + group: 'Optional', + description: "The VM's own location. Defaults to eastus.", + options: AZURE_LOCATIONS.map((value) => ({label: value, value})), + }, + { + name: 'adminUsername', + label: 'Admin Username', + type: 'text', + required: false, + group: 'Optional', + description: 'Defaults to azureuser.', + }, + ], + actions: ['list', 'inspect', 'create', 'delete'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create VM', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete VM', enabled: true, status: 'available', runtimeRequired: true}, + // The runtime and the adapter both do these — verified against + // floci-az, where powerOff/start/restart really change power state. + // There is no generic actions route yet (only invoke/objects), so + // the console cannot reach them; saying `coming_soon` with the + // reason beats advertising a control that cannot be called. + { + name: 'start', + label: 'Start', + enabled: false, + status: 'coming_soon', + reason: 'The adapter implements start, but the generic resource actions route is not wired yet.', + runtimeRequired: true, + }, + { + name: 'stop', + label: 'Stop', + enabled: false, + status: 'coming_soon', + reason: 'The adapter implements stop, but the generic resource actions route is not wired yet.', + runtimeRequired: true, + }, + { + name: 'reboot', + label: 'Restart', + enabled: false, + status: 'coming_soon', + reason: 'The adapter implements reboot, but the generic resource actions route is not wired yet.', + runtimeRequired: true, + }, + ], + }, + filters: computeFilters, + columns: [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Power State'}, + {name: 'instanceClass', label: 'Size'}, + {name: 'region', label: 'Location'}, + {name: 'resourceGroup', label: 'Resource Group', path: 'metadata.resourceGroup'}, + {name: 'createdAt', label: 'Created', format: 'datetime'}, + ], + } +} + export function awsComputeSchema(): ServiceSchema { return { cloud: 'aws', diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 6240686..40b56c6 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -6,6 +6,7 @@ import {AwsEksAdapter} from './adapter-aws/AwsEksAdapter' import {AwsStorageAdapter} from './adapter-aws/AwsStorageAdapter' import {AzureDatabaseAdapter} from './adapter-azure/AzureDatabaseAdapter' import {AzureStorageAdapter} from './adapter-azure/AzureStorageAdapter' +import {AzureComputeAdapter} from './adapter-azure/AzureComputeAdapter' import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter' import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter' import {GcpCloudSqlAdapter} from './adapter-gcp/GcpCloudSqlAdapter' @@ -41,6 +42,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsServerlessAdapter(clients.lambda), new AzureStorageAdapter(), new AzureDatabaseAdapter(), + new AzureComputeAdapter(), new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), new GcpCloudSqlAdapter(), From e49c8680f93171e9e783ad14302e950d50858c32 Mon Sep 17 00:00:00 2001 From: TheSaifZaman Date: Tue, 28 Jul 2026 15:51:19 +0600 Subject: [PATCH 2/4] fix(compute): match Azure resource groups case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure treats resource group names as case-insensitive, so comparing them with strict equality rejected a create whose form value differed only in case from the listed group — blocking a VM creation that should succeed. The check now resolves the caller's value to the spelling the runtime uses, rather than only comparing case-insensitively. The resource id is `resourceGroup/name`, so echoing the caller's casing back would emit an id that does not match the one list() reports, and inspect or delete against it would 404. Verified through the route: creating with "RG-VM" against a runtime holding "rg-vm" returns id "rg-vm/vm-case", that id inspects 200, and a genuinely missing group still returns 400. --- .../adapter-azure/AzureComputeAdapter.test.ts | 25 ++++++++++++++++++ .../src/adapter-azure/AzureComputeAdapter.ts | 26 +++++++++++++------ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts b/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts index d5ff40e..2639ad0 100644 --- a/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts +++ b/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts @@ -195,6 +195,31 @@ describe('AzureComputeAdapter', () => { ).rejects.toThrow(ValidationError) }) + test('accepts a resource group whose case differs from the runtime', async () => { + // Azure treats resource group names as case-insensitive, so rejecting + // "RG-App" when "rg-app" exists would block a create that should succeed. + const calls = runtimeStub() + await adapter().create({ + values: {name: 'web', resourceGroup: 'RG-App', vmSize: 'Standard_B1s', image: 'Debian 11'}, + }) + + expect(calls.some((c) => c.init?.method === 'PUT')).toBe(true) + }) + + test("uses the runtime's spelling of the resource group, not the caller's", async () => { + // The id is `resourceGroup/name`, so echoing the caller's casing would emit + // an id that does not match what list() returns and inspect would 404. + const calls = runtimeStub() + const resource = await adapter().create({ + values: {name: 'web', resourceGroup: 'RG-App', vmSize: 'Standard_B1s', image: 'Debian 11'}, + }) + + const put = calls.find((c) => c.init?.method === 'PUT') + expect(put?.url).toContain('/resourceGroups/rg-app/') + expect(put?.url).not.toContain('RG-App') + expect(resource.id).toBe('rg-app/web') + }) + test('requires the fields the schema marks required', async () => { runtimeStub() const a = adapter() diff --git a/packages/api/src/adapter-azure/AzureComputeAdapter.ts b/packages/api/src/adapter-azure/AzureComputeAdapter.ts index 64629c4..ad745af 100644 --- a/packages/api/src/adapter-azure/AzureComputeAdapter.ts +++ b/packages/api/src/adapter-azure/AzureComputeAdapter.ts @@ -117,7 +117,7 @@ export class AzureComputeAdapter implements CloudServiceAdapter { 'name must start with a letter or digit and contain only letters, digits, hyphens, underscores and periods', ) } - const resourceGroup = requiredString(input.values.resourceGroup, 'resourceGroup') + const requestedGroup = requiredString(input.values.resourceGroup, 'resourceGroup') const vmSize = requiredOneOf(input.values.vmSize, AZURE_VM_SIZES, 'vmSize') const imageLabel = requiredOneOf( input.values.image, @@ -127,7 +127,7 @@ export class AzureComputeAdapter implements CloudServiceAdapter { const location = optionalOneOf(input.values.location, AZURE_LOCATIONS, 'location') ?? 'eastus' const adminUsername = optionalString(input.values.adminUsername, 'adminUsername') ?? 'azureuser' - await this.assertResourceGroupExists(resourceGroup) + const resourceGroup = await this.resolveResourceGroup(requestedGroup) const path = await this.vmPath(resourceGroup, name) await this.json(`${path}?api-version=${API_VERSION}`, { @@ -194,19 +194,29 @@ export class AzureComputeAdapter implements CloudServiceAdapter { } /** - * The runtime creates a VM in a nonexistent resource group and returns 201, - * while real Azure answers ResourceGroupNotFound. Checking here stops the - * console creating a resource that could never exist against a real provider. + * Resolve a caller-supplied resource group to the spelling the runtime uses, + * failing if it does not exist. + * + * Two reasons this is not a plain equality check. The runtime creates a VM in a + * nonexistent resource group and returns 201 while real Azure answers + * ResourceGroupNotFound, so the existence check has to live here. And Azure + * treats these names case-insensitively, so `RG-App` must match `rg-app` — but + * the *runtime's* casing is what gets used, because the resource id is + * `resourceGroup/name` and echoing the caller's casing would emit an id that + * does not match the one `list()` reports. */ - private async assertResourceGroupExists(resourceGroup: string): Promise { + private async resolveResourceGroup(resourceGroup: string): Promise { const subscription = await this.subscription() const body = await this.json>( `/subscriptions/${subscription}/resourceGroups?api-version=${RESOURCE_API_VERSION}`, ) - const exists = (body?.value ?? []).some((group) => group.name === resourceGroup) - if (!exists) { + const match = (body?.value ?? []).find( + (group) => group.name?.toLowerCase() === resourceGroup.toLowerCase(), + ) + if (!match?.name) { throw new ValidationError(`resource group ${resourceGroup} does not exist; create it before adding a VM`) } + return match.name } /** From 6170111d9f980cf3419ddb0db5c12dba8a147b79 Mon Sep 17 00:00:00 2001 From: TheSaifZaman Date: Wed, 29 Jul 2026 08:59:52 +0600 Subject: [PATCH 3/4] docs: regenerate the service matrix after rebasing onto main --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7b58d2..fe75cfb 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Group | Service | AWS | Azure | GCP | |---|---|---|---|---| -| Compute | Compute | Yes (list, inspect, create, delete) | No | No | +| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No | | Compute | EKS / AKS / GKE | Yes (list, inspect) | No | Yes (list, create, inspect, delete) | | Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | | Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | From 68d59a75edc2624997bc5f8c5b5d4fbef87a2e8c Mon Sep 17 00:00:00 2001 From: TheSaifZaman Date: Wed, 29 Jul 2026 09:25:47 +0600 Subject: [PATCH 4/4] fix(frontend): gate the EC2 launch form to AWS DynamicResourceView rendered LaunchInstanceForm for service === 'compute' on every cloud, so Azure's Create button opened an EC2 form whose imageId/instanceType payload the Azure adapter correctly rejects. Azure now falls through to DynamicFormRenderer, which builds the form from the adapter's own schema. --- .../frontend/src/components/DynamicResourceView.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 8770a45..14ef80f 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -240,7 +240,15 @@ export function DynamicResourceView({ {canCreate && createOpen && (
- {service === "compute" ? ( + {/* + * AWS only. LaunchInstanceForm is an EC2 form: it asks for an AMI id, + * populates its dropdowns from the legacy /api/ec2 routes, and submits + * imageId/instanceType. On any other cloud that is the wrong form + * entirely — the Azure adapter rejects it with "resourceGroup is + * required". Every other cloud falls through to DynamicFormRenderer, + * which builds the right form from the adapter's own schema. + */} + {service === "compute" && cloud === "aws" ? (