diff --git a/README.md b/README.md index a7b58d2..1750243 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ cd packages/api && bun run scripts/service-matrix.ts | 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) | | Databases | Database | Yes (list, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | -| Networking | Networking | Yes (list) | No | No | +| Networking | Networking | Yes (list) | Yes (list, inspect, create, delete) | No | | Security | Secrets Manager / Key Vault | Yes (legacy page) | Yes (list, create, delete, inspect) | No | Console Home is available for all three clouds. diff --git a/packages/api/src/adapter-azure/AzureNetworkingAdapter.test.ts b/packages/api/src/adapter-azure/AzureNetworkingAdapter.test.ts new file mode 100644 index 0000000..b5cda3e --- /dev/null +++ b/packages/api/src/adapter-azure/AzureNetworkingAdapter.test.ts @@ -0,0 +1,335 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {AzureNetworkingAdapter} from './AzureNetworkingAdapter' +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(): AzureNetworkingAdapter { + return new AzureNetworkingAdapter(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 vnet(name: string, rg = 'rg-app', subnets = [{name: 'default', properties: {addressPrefix: '10.0.1.0/24'}}]) { + return { + id: `/subscriptions/${SUB}/resourceGroups/${rg}/providers/Microsoft.Network/virtualNetworks/${name}`, + name, + type: 'Microsoft.Network/virtualNetworks', + location: 'eastus', + properties: { + addressSpace: {addressPrefixes: ['10.0.0.0/16']}, + subnets, + provisioningState: 'Succeeded', + }, + } +} + +/** + * Mirrors floci-az rather than being permissive: only the **group-scoped** + * collection reports VNets, and the subscription-scoped path answers 200 with an + * empty value. A catch-all that served both made the adapter's original + * subscription-scoped list look correct while it returned nothing against the + * real runtime. + */ +function runtimeStub(vnets: Array> = [vnet('core')], groups = [{name: 'rg-app'}]) { + return stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + if (url.includes('/virtualNetworks/')) return json(vnets[0] ?? {}) + if (url.includes('/resourceGroups?') || url.match(/\/resourceGroups$/)) return json({value: groups}) + + const group = url.match(/resourceGroups\/([^/?]+)\/providers\/Microsoft\.Network\/virtualNetworks/)?.[1] + if (!group) return json({value: []}) + return json({value: vnets.filter((candidate) => candidate.id.includes(`/resourceGroups/${group}/`))}) + }) +} + +const validValues = {name: 'core', resourceGroup: 'rg-app', addressPrefix: '10.0.0.0/16'} + +describe('AzureNetworkingAdapter', () => { + test('identifies itself as the Azure networking adapter', () => { + const a = adapter() + + expect(a.cloud).toBe('azure') + expect(a.service).toBe('networking') + expect(a.schema().displayName).toBe('Azure Virtual Networks') + }) + + test('lists VNets and maps the ARM shape', async () => { + runtimeStub() + const [resource] = await adapter().list() + + expect(resource).toMatchObject({ + id: 'rg-app/core', + name: 'core', + cloud: 'azure', + service: 'networking', + // Reuses the existing vpc type: a VNet is Azure's VPC, and the shared + // column stays meaningful without widening the union. + type: 'vpc', + region: 'eastus', + status: 'Succeeded', + }) + expect(resource?.metadata).toMatchObject({ + resourceGroup: 'rg-app', + cidrBlock: '10.0.0.0/16', + addressPrefixes: ['10.0.0.0/16'], + subnetCount: 1, + }) + }) + + test('summarises subnets with their prefixes', async () => { + runtimeStub([ + vnet('core', 'rg-app', [ + {name: 'web', properties: {addressPrefix: '10.0.1.0/24'}}, + {name: 'db', properties: {addressPrefix: '10.0.2.0/24'}}, + ]), + ]) + const [resource] = await adapter().list() + + expect(resource?.metadata.subnets).toEqual([ + {name: 'web', addressPrefix: '10.0.1.0/24'}, + {name: 'db', addressPrefix: '10.0.2.0/24'}, + ]) + expect(resource?.metadata.subnetCount).toBe(2) + }) + + test('handles a VNet with no subnets', async () => { + runtimeStub([vnet('empty', 'rg-app', [])]) + const [resource] = await adapter().list() + + expect(resource?.metadata.subnetCount).toBe(0) + expect(resource?.metadata.subnets).toEqual([]) + }) + + /** + * Regression for the empty-table bug: the adapter listed at subscription + * scope, which floci-az answers 200-with-nothing, so a created VNet never + * appeared. Verified against the runtime on 2026-07-29. + */ + test('lists per resource group rather than at subscription scope', async () => { + const calls = runtimeStub() + + await adapter().list() + + const collectionCalls = calls.filter( + (call) => call.url.includes('/virtualNetworks?') || call.url.endsWith('/virtualNetworks'), + ) + expect(collectionCalls.length).toBeGreaterThan(0) + for (const call of collectionCalls) { + expect(call.url).toContain('/resourceGroups/rg-app/providers/Microsoft.Network/virtualNetworks') + } + }) + + test('aggregates VNets across every resource group', async () => { + runtimeStub( + [vnet('core', 'rg-app'), vnet('edge', 'rg-net')], + [{name: 'rg-app'}, {name: 'rg-net'}], + ) + + const resources = await adapter().list() + + expect(resources.map((resource) => resource.id).sort()).toEqual(['rg-app/core', 'rg-net/edge']) + }) + + test('filters the list by search term', async () => { + runtimeStub([vnet('core'), vnet('edge')]) + const a = adapter() + + await expect(a.list({search: 'cor'})).resolves.toHaveLength(1) + await expect(a.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a VNet addressed by resource group and name', async () => { + const calls = runtimeStub() + const resource = await adapter().get('rg-app/core') + + const call = calls.find((c) => c.url.includes('/virtualNetworks/core')) + expect(call?.url).toContain('/resourceGroups/rg-app/providers/Microsoft.Network/virtualNetworks/core') + expect(resource?.id).toBe('rg-app/core') + }) + + test('rejects an id that does not name a resource group', async () => { + await expect(adapter().get('core')).rejects.toThrow(ValidationError) + }) + + test('returns null when a VNet does not exist', async () => { + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + return json({error: {code: 'ResourceNotFound', message: 'not found'}}, 404) + }) + await expect(adapter().get('rg-app/nope')).resolves.toBeNull() + }) + + test('creates a VNet with its address space', async () => { + const calls = runtimeStub() + const resource = await adapter().create({values: validValues}) + + const put = calls.find((c) => c.init?.method === 'PUT') + expect(put?.url).toContain('/resourceGroups/rg-app/providers/Microsoft.Network/virtualNetworks/core') + const body = JSON.parse(String(put?.init?.body)) + expect(body.location).toBe('eastus') + expect(body.properties.addressSpace.addressPrefixes).toEqual(['10.0.0.0/16']) + expect(body.properties.subnets).toBeUndefined() + expect(resource.id).toBe('rg-app/core') + }) + + test('creates the first subnet when both subnet fields are given', async () => { + const calls = runtimeStub() + await adapter().create({ + values: {...validValues, subnetName: 'web', subnetPrefix: '10.0.1.0/24'}, + }) + + const body = JSON.parse(String(calls.find((c) => c.init?.method === 'PUT')?.init?.body)) + expect(body.properties.subnets).toEqual([{name: 'web', properties: {addressPrefix: '10.0.1.0/24'}}]) + }) + + test('requires both subnet fields or neither', async () => { + // Half a subnet is not something ARM can act on, and silently dropping the + // one value the user did supply would be worse than saying so. + runtimeStub() + const a = adapter() + + await expect(a.create({values: {...validValues, subnetName: 'web'}})).rejects.toThrow( + new ValidationError('subnetPrefix is required when subnetName is given'), + ) + await expect(a.create({values: {...validValues, subnetPrefix: '10.0.1.0/24'}})).rejects.toThrow( + new ValidationError('subnetName is required when subnetPrefix is given'), + ) + }) + + test('accepts a resource group whose case differs from the runtime', async () => { + // Azure resource group names are case-insensitive, and the id must carry the + // runtime's spelling or inspect against the returned id would 404. + const calls = runtimeStub() + const resource = await adapter().create({values: {...validValues, resourceGroup: 'RG-App'}}) + + const put = calls.find((c) => c.init?.method === 'PUT') + expect(put?.url).toContain('/resourceGroups/rg-app/') + expect(resource.id).toBe('rg-app/core') + }) + + test('rejects a resource group that does not exist', async () => { + runtimeStub([vnet('core')], []) + + await expect(adapter().create({values: validValues})).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: 'core'}, 'resourceGroup is required'], + [{name: 'core', resourceGroup: 'rg-app'}, 'addressPrefix is required'], + ] + + for (const [values, message] of cases) { + await expect(a.create({values})).rejects.toThrow(new ValidationError(message)) + } + }) + + test('rejects an address space that is not a CIDR', async () => { + runtimeStub() + const a = adapter() + + await expect(a.create({values: {...validValues, addressPrefix: '10.0.0.0'}})).rejects.toThrow(ValidationError) + await expect(a.create({values: {...validValues, addressPrefix: 'not-a-cidr'}})).rejects.toThrow(ValidationError) + }) + + test('rejects a CIDR whose octets or prefix length are out of range', async () => { + // A digits-and-slashes pattern accepts 999.999.999.999/99, which then fails + // at ARM with an opaque runtime error instead of a clear ValidationError. + runtimeStub() + const a = adapter() + + for (const cidr of ['999.999.999.999/99', '256.0.0.0/16', '10.0.0.0/33', '10.0.0/16', '10.0.0.0/']) { + await expect( + a.create({values: {...validValues, addressPrefix: cidr}}), + `${cidr} must be rejected`, + ).rejects.toThrow(ValidationError) + } + }) + + test('accepts CIDRs at the edges of the valid range', async () => { + runtimeStub() + const a = adapter() + + for (const cidr of ['0.0.0.0/0', '255.255.255.255/32', '10.0.0.0/16', '192.168.1.0/24']) { + await expect(a.create({values: {...validValues, addressPrefix: cidr}}), `${cidr} must be accepted`) + .resolves.toBeDefined() + } + }) + + test('rejects a subnet CIDR that is out of range too', async () => { + runtimeStub() + + await expect( + adapter().create({values: {...validValues, subnetName: 'web', subnetPrefix: '10.0.1.0/33'}}), + ).rejects.toThrow(ValidationError) + }) + + test('enforces the Azure VNet name rules', async () => { + // Azure requires 2-64 characters, starting with a letter or digit and + // ending with a letter, digit or underscore. A one-character name or a + // trailing hyphen only fails at the ARM PUT otherwise. + runtimeStub() + const a = adapter() + + for (const name of ['a', 'core-', 'core.', '-core', '.core']) { + await expect(a.create({values: {...validValues, name}}), `${name} must be rejected`).rejects.toThrow( + ValidationError, + ) + } + for (const name of ['ab', 'core_net', 'core-net', 'core.net', 'a1']) { + await expect(a.create({values: {...validValues, name}}), `${name} must be accepted`).resolves.toBeDefined() + } + }) + + test('sends the accept header and tolerates an empty ARM body', async () => { + // Matches the azureJson/cosmosJson helpers the other Azure adapters use: + // ARM endpoints can be Accept-sensitive, and a 204 has no body to parse. + const calls = stubFetch((url, init) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + if (init?.method === 'DELETE') return new Response(null, {status: 204}) + return json({value: [vnet('core')]}) + }) + + await expect(adapter().delete('rg-app/core')).resolves.toBeUndefined() + + const headers = calls[0]?.init?.headers as Record | undefined + expect(headers?.accept).toBe('application/json') + }) + + test('rejects a location the schema does not offer', async () => { + runtimeStub() + + await expect(adapter().create({values: {...validValues, location: 'mars1'}})).rejects.toThrow(ValidationError) + }) + + test('deletes a VNet', async () => { + const calls = runtimeStub() + await adapter().delete('rg-app/core') + + const del = calls.find((c) => c.init?.method === 'DELETE') + expect(del?.url).toContain('/virtualNetworks/core') + }) +}) diff --git a/packages/api/src/adapter-azure/AzureNetworkingAdapter.ts b/packages/api/src/adapter-azure/AzureNetworkingAdapter.ts new file mode 100644 index 0000000..b1f66ce --- /dev/null +++ b/packages/api/src/adapter-azure/AzureNetworkingAdapter.ts @@ -0,0 +1,330 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import { + AZURE_VNET_LOCATIONS, + AZURE_VNET_NAME_PATTERN, + CIDR_PATTERN, + azureNetworkingSchema, +} from '../cloud-spi/networkingSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to the Floci-AZ runtime's ARM surface for Microsoft.Network — verified + * against `floci/floci-az` 0.9.0: + * + * list GET /subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks + * (per group, aggregated — the subscription-scoped path answers + * 200 with an empty value and never reports a real VNet) + * get GET /subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{n} + * create PUT (same path as get) -> 200, provisioningState Succeeded + * delete DELETE (same path as get) -> 200, and the VNet is gone + * + * Unlike the AWS side of this category, create and delete are advertised as + * available. A VNet needs only a name, a location and an address prefix, which a + * flat form expresses fine — there are none of the dependent selectors that pushed + * VPC creation into the AWS Networking panel. Both verbs were verified end to end + * rather than assumed from the AWS shape. + */ +const API_VERSION = '2023-09-01' +const RESOURCE_API_VERSION = '2021-04-01' + +interface ArmList { + value?: T[] +} + +interface AzureSubnet { + name?: string + properties?: {addressPrefix?: string} +} + +interface AzureVnet { + id?: string + name?: string + location?: string + tags?: Record + properties?: { + addressSpace?: {addressPrefixes?: string[]} + subnets?: AzureSubnet[] + provisioningState?: string + } +} + +export class AzureNetworkingAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'networking' as const + + private subscriptionId: string | null = null + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureNetworkingSchema() + } + + /** + * Enumerates resource groups and aggregates the per-group VNet lists. + * + * The subscription-scoped path exists in ARM and returns HTTP 200 here, but + * floci-az only implements the resource-group-scoped list: it routes the + * subscription path into its network service with a placeholder group, the + * strict match fails, and the response is a clean `{"value": []}`. So the + * call succeeds and the table stays empty even after a create that worked — + * a silent failure rather than an error. Verified 2026-07-29: a VNet created + * in `rg-probe` is absent from the subscription scope and present in the + * group scope. + */ + async list(query: ResourceQuery = {}): Promise { + const groups = await this.resourceGroupNames() + const perGroup = await Promise.all( + groups.map(async (group) => { + const body = await this.json>( + `${await this.vnetCollectionPath(group)}?api-version=${API_VERSION}`, + {}, + // One unreadable group must not blank the whole table. + {emptyOnNotFound: true}, + ) + return body?.value ?? [] + }), + ) + return filterBySearch(perGroup.flat().map(toResource), query.search) + } + + async get(id: string): Promise { + const {resourceGroup, name} = parseId(id) + const path = await this.vnetPath(resourceGroup, name) + const network = await this.json( + `${path}?api-version=${API_VERSION}`, + {}, + {emptyOnNotFound: true}, + ) + return network ? toResource(network) : null + } + + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (!new RegExp(AZURE_VNET_NAME_PATTERN).test(name)) { + throw new ValidationError( + 'name must be 2-64 characters, start with a letter or digit and end with a letter, digit or underscore', + ) + } + const requestedGroup = requiredString(input.values.resourceGroup, 'resourceGroup') + const addressPrefix = requiredCidr(input.values.addressPrefix, 'addressPrefix') + const location = optionalOneOf(input.values.location, AZURE_VNET_LOCATIONS, 'location') ?? 'eastus' + const subnet = optionalSubnet(input.values) + + const resourceGroup = await this.resolveResourceGroup(requestedGroup) + const path = await this.vnetPath(resourceGroup, name) + + await this.json(`${path}?api-version=${API_VERSION}`, { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + location, + properties: { + addressSpace: {addressPrefixes: [addressPrefix]}, + ...(subnet ? {subnets: [{name: subnet.name, properties: {addressPrefix: subnet.prefix}}]} : {}), + }, + }), + }) + + const created = await this.get(`${resourceGroup}/${name}`) + if (!created) throw new RuntimeError(`VNet ${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.vnetPath(resourceGroup, name) + await this.client.fetch(`${path}?api-version=${API_VERSION}`, {method: 'DELETE'}, {emptyOnNotFound: true}) + } + + /** The runtime ignores the subscription id, but a real one keeps ids honest. */ + 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 network requests to') + + this.subscriptionId = id + return id + } + + private async vnetPath(resourceGroup: string, name: string): Promise { + const subscription = await this.subscription() + return `/subscriptions/${subscription}/resourceGroups/${encodeURIComponent(resourceGroup)}/providers/Microsoft.Network/virtualNetworks/${encodeURIComponent(name)}` + } + + /** Every resource group in the subscription, in the runtime's own casing. */ + private async resourceGroupNames(): Promise { + const subscription = await this.subscription() + const body = await this.json>( + `/subscriptions/${subscription}/resourceGroups?api-version=${RESOURCE_API_VERSION}`, + ) + return (body?.value ?? []).map((group) => group.name).filter((name): name is string => Boolean(name)) + } + + /** The group-scoped VNet collection path — the only list scope floci-az serves. */ + private async vnetCollectionPath(resourceGroup: string): Promise { + const subscription = await this.subscription() + return `/subscriptions/${subscription}/resourceGroups/${encodeURIComponent(resourceGroup)}/providers/Microsoft.Network/virtualNetworks` + } + + /** + * Resolve the caller's resource group to the spelling the runtime uses. + * + * Azure treats these names case-insensitively, so `RG-App` must match `rg-app` + * — but the runtime's casing is what gets used, because the 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 { + const match = (await this.resourceGroupNames()).find( + (group) => group.toLowerCase() === resourceGroup.toLowerCase(), + ) + if (!match) { + throw new ValidationError( + `resource group ${resourceGroup} does not exist; create it before adding a VNet`, + ) + } + return match + } + + /** + * Mirrors the `azureJson`/`cosmosJson` helpers the other Azure adapters use: + * ARM endpoints can be Accept-sensitive, and a 204 carries no body to parse. + * + * Five copies of this now exist across adapter-azure/. Worth lifting onto + * AzureRuntimeClient once the open Azure PRs have merged — doing it in three + * parallel branches would just conflict. + */ + private async json(path: string, init: RequestInit = {}, options = {}): Promise { + const res = await this.client.fetch( + path, + { + ...init, + headers: { + accept: 'application/json', + ...(init.headers ?? {}), + }, + }, + options, + ) + if (!res || res.status === 204) return null + + const text = await res.text() + if (!text) return null + return JSON.parse(text) as T + } +} + +function toResource(network: AzureVnet): CloudResource { + const properties = network.properties ?? {} + const name = network.name ?? '' + const resourceGroup = resourceGroupOf(network.id ?? '') + const prefixes = properties.addressSpace?.addressPrefixes ?? [] + const subnets = (properties.subnets ?? []).map((subnet) => ({ + name: subnet.name ?? '', + addressPrefix: subnet.properties?.addressPrefix ?? '', + })) + + return { + id: resourceGroup ? `${resourceGroup}/${name}` : name, + name, + cloud: 'azure', + service: 'networking', + // A VNet is Azure's VPC; reusing the existing type keeps the shared column + // meaningful without widening the union. + type: 'vpc', + region: network.location ?? null, + // ARM returns no creation time for a virtual network. + createdAt: null, + status: properties.provisioningState ?? null, + metadata: { + provider: 'azure', + resourceId: network.id, + resourceGroup, + /** Shared key so the networking column renders for AWS and Azure alike. */ + cidrBlock: prefixes[0], + addressPrefixes: prefixes, + subnets, + subnetCount: subnets.length, + tags: network.tags, + }, + } +} + +/** ARM cannot address a VNet 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(`VNet 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)) +} + +/** Both subnet fields or neither: half a subnet is not something ARM can act on. */ +function optionalSubnet(values: Record): {name: string; prefix: string} | undefined { + const name = optionalString(values.subnetName, 'subnetName') + const prefix = optionalString(values.subnetPrefix, 'subnetPrefix') + + if (name === undefined && prefix === undefined) return undefined + if (name === undefined) throw new ValidationError('subnetName is required when subnetPrefix is given') + if (prefix === undefined) throw new ValidationError('subnetPrefix is required when subnetName is given') + + assertCidr(prefix, 'subnetPrefix') + return {name, prefix} +} + +function requiredCidr(value: unknown, field: string): string { + const raw = requiredString(value, field) + assertCidr(raw, field) + return raw +} + +function assertCidr(value: string, field: string): void { + if (!new RegExp(CIDR_PATTERN).test(value)) { + throw new ValidationError(`${field} must be an IPv4 CIDR such as 10.0.0.0/16`) + } +} + +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 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/networkingSchema.ts b/packages/api/src/cloud-spi/networkingSchema.ts index 82a0a03..f9450f3 100644 --- a/packages/api/src/cloud-spi/networkingSchema.ts +++ b/packages/api/src/cloud-spi/networkingSchema.ts @@ -11,6 +11,120 @@ const networkingFilters: FieldSchema[] = [ {name: 'search', label: 'Search', type: 'text', required: false}, ] +export const AZURE_VNET_LOCATIONS = ['eastus', 'eastus2', 'westus', 'westus2', 'westeurope', 'northeurope'] as const + +/** + * IPv4 CIDR with real bounds, e.g. 10.0.0.0/16. + * + * Octets are limited to 0-255 and the prefix length to 0-32. A looser + * digits-and-slashes pattern accepts `999.999.999.999/99`, which then fails at ARM + * with an opaque runtime error instead of a clear ValidationError. + */ +const OCTET = '(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)' +export const CIDR_PATTERN = `^(${OCTET}\\.){3}${OCTET}/(3[0-2]|[12]?\\d)$` + +/** + * Azure virtual network names: 2-64 characters, starting with a letter or digit + * and ending with a letter, digit or underscore. + * + * The provider's rule, not the runtime's — floci-az accepts names real Azure + * rejects, so a one-character name or a trailing hyphen would only fail on the + * ARM PUT. + */ +export const AZURE_VNET_NAME_PATTERN = '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}[a-zA-Z0-9_]$' + +/** + * Azure offers real create and delete, unlike AWS in this category. + * + * A VNet needs only a name, a location and an address prefix, which a flat form + * expresses fine — there are no dependent selectors of the kind that pushed VPC + * creation into the AWS Networking panel. Verified against the runtime: create + * returns Succeeded and delete removes the VNet. + */ +export function azureNetworkingSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'networking', + displayName: 'Azure Virtual Networks', + fields: [ + { + name: 'name', + label: 'VNet Name', + type: 'text', + required: true, + group: 'Required', + validation: { + pattern: AZURE_VNET_NAME_PATTERN, + minLength: 2, + maxLength: 64, + message: + '2-64 characters, starting with a letter or digit and ending with a letter, digit or underscore.', + }, + }, + { + name: 'resourceGroup', + label: 'Resource Group', + type: 'text', + required: true, + group: 'Required', + description: 'Must already exist.', + }, + { + name: 'addressPrefix', + label: 'Address Space (CIDR)', + type: 'text', + required: true, + group: 'Required', + description: 'e.g. 10.0.0.0/16', + validation: {pattern: CIDR_PATTERN, message: 'Use an IPv4 CIDR such as 10.0.0.0/16.'}, + }, + { + name: 'location', + label: 'Location', + type: 'select', + required: false, + group: 'Optional', + description: 'Defaults to eastus.', + options: AZURE_VNET_LOCATIONS.map((value) => ({label: value, value})), + }, + { + name: 'subnetName', + label: 'First Subnet Name', + type: 'text', + required: false, + group: 'First subnet — optional', + description: 'Give both subnet fields or neither.', + }, + { + name: 'subnetPrefix', + label: 'First Subnet CIDR', + type: 'text', + required: false, + group: 'First subnet — optional', + description: 'Must sit inside the address space, e.g. 10.0.1.0/24', + validation: {pattern: CIDR_PATTERN, message: 'Use an IPv4 CIDR such as 10.0.1.0/24.'}, + }, + ], + actions: ['list', 'inspect', 'create', 'delete'], + filters: networkingFilters, + columns: [ + {name: 'name', label: 'Name'}, + {name: 'version', label: 'Address Space', path: 'metadata.cidrBlock', format: 'code'}, + {name: 'status', label: 'State', format: 'badge'}, + {name: 'region', label: 'Location'}, + {name: 'subnetCount', label: 'Subnets', path: 'metadata.subnetCount'}, + ], + capabilities: { + resourceActions: [ + {name: 'list', label: 'VNets', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create VNet', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete VNet', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + } +} + export function awsNetworkingSchema(): ServiceSchema { return { cloud: 'aws', diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 6240686..cf0c569 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 {AzureNetworkingAdapter} from './adapter-azure/AzureNetworkingAdapter' 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 AzureNetworkingAdapter(), new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), new GcpCloudSqlAdapter(), diff --git a/packages/frontend/src/components/NetworkingPanel.tsx b/packages/frontend/src/components/NetworkingPanel.tsx index 233720c..d965eec 100644 --- a/packages/frontend/src/components/NetworkingPanel.tsx +++ b/packages/frontend/src/components/NetworkingPanel.tsx @@ -1563,7 +1563,17 @@ export function NetworkingPanel({cloud, resource, runtimeReachable}: NetworkingP

Networking

-

Networking management coming soon for {cloud.toUpperCase()}.

+ {/* + * Azure has a working VNet adapter, so the table above this panel is + * live — saying "coming soon" under it would contradict what the user + * can already see. What is genuinely AWS-only is this panel's extra + * workflows (subnets, security groups, gateways, route tables, EIPs). + */} +

+ {cloud === 'azure' + ? 'Virtual networks are managed in the table above. Subnets, security groups, gateways and route tables are AWS-only today.' + : `Networking management coming soon for ${cloud.toUpperCase()}.`} +

)