Files
floci-ui/packages/api/src/adapter-azure/AzureComputeAdapter.ts
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

330 lines
13 KiB
TypeScript

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<T> {
value?: T[]
}
interface AzureVm {
id?: string
name?: string
location?: string
tags?: Record<string, string>
properties?: {
hardwareProfile?: {vmSize?: string}
storageProfile?: {
imageReference?: {publisher?: string; offer?: string; sku?: string; version?: string}
osDisk?: Record<string, unknown>
}
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<CloudResource[]> {
const subscription = await this.subscription()
const body = await this.json<ArmList<AzureVm>>(
`/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<CloudResource | null> {
const {resourceGroup, name} = parseId(id)
const path = await this.vmPath(resourceGroup, name)
const machine = await this.json<AzureVm>(`${path}?api-version=${API_VERSION}`, {}, {emptyOnNotFound: true})
if (!machine) return null
return toResource(machine, await this.powerState(machine.id ?? ''))
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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 requestedGroup = 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<keyof typeof AZURE_VM_IMAGES>,
'image',
)
const location = optionalOneOf(input.values.location, AZURE_LOCATIONS, 'location') ?? 'eastus'
const adminUsername = optionalString(input.values.adminUsername, 'adminUsername') ?? 'azureuser'
const resourceGroup = await this.resolveResourceGroup(requestedGroup)
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<void> {
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<void> {
await this.power(id, 'start')
}
/** ARM calls it powerOff; `deallocate` also exists but releases the compute. */
async stop(id: string): Promise<void> {
await this.power(id, 'powerOff')
}
async reboot(id: string): Promise<void> {
await this.power(id, 'restart')
}
private async power(id: string, action: 'start' | 'powerOff' | 'restart'): Promise<void> {
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<string> {
if (this.subscriptionId) return this.subscriptionId
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
const id = body?.value?.[0]?.subscriptionId
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope compute requests to')
this.subscriptionId = id
return id
}
private async vmPath(resourceGroup: string, name: string): Promise<string> {
const subscription = await this.subscription()
return `/subscriptions/${subscription}/resourceGroups/${encodeURIComponent(resourceGroup)}/providers/Microsoft.Compute/virtualMachines/${encodeURIComponent(name)}`
}
/**
* 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 resolveResourceGroup(resourceGroup: string): Promise<string> {
const subscription = await this.subscription()
const body = await this.json<ArmList<{name?: string}>>(
`/subscriptions/${subscription}/resourceGroups?api-version=${RESOURCE_API_VERSION}`,
)
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
}
/**
* 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<PowerLookup> {
if (!resourceId) return {state: null}
try {
const view = await this.json<AzureInstanceView>(`${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<T>(path: string, init: RequestInit = {}, options = {}): Promise<T | null> {
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<T extends readonly string[]>(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<T extends readonly string[]>(
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]
}