diff --git a/README.md b/README.md index a7b58d2..b552699 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,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 | EKS / AKS / GKE | Yes (list, inspect) | No | Yes (list, create, inspect, delete) | +| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | 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) | | Databases | Database | Yes (list, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | @@ -106,15 +106,17 @@ Current gaps:
k8s Engine -AWS only, through the unified shell. +All three clouds, through the unified shell. -- EKS clusters can be listed and inspected. -- Cluster metadata, node groups, and related details are surfaced when returned by Floci AWS Core. +- AWS EKS and Azure AKS clusters can be listed and inspected. +- GCP GKE clusters can additionally be created and deleted. +- Cluster metadata, node groups, and related details are surfaced when returned by the runtime. Current gaps: -- No AKS or GKE adapter yet. -- No generic cluster creation flow in Cloud Explorer. +- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice: + the shipped floci-az config runs AKS unmocked with no Docker socket to start k3s + with, so a created cluster never leaves `provisioningState: Failed`.
diff --git a/packages/api/src/adapter-azure/AzureAksAdapter.test.ts b/packages/api/src/adapter-azure/AzureAksAdapter.test.ts new file mode 100644 index 0000000..82ea47c --- /dev/null +++ b/packages/api/src/adapter-azure/AzureAksAdapter.test.ts @@ -0,0 +1,176 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {AzureAksAdapter} from './AzureAksAdapter' +import {AzureRestRuntimeClient} from '../azure' +import {NotSupportedError, 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(): AzureAksAdapter { + return new AzureAksAdapter(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 cluster(name: string, rg = 'rg-app', provisioningState = 'Succeeded') { + return { + id: `/subscriptions/${SUB}/resourceGroups/${rg}/providers/Microsoft.ContainerService/managedClusters/${name}`, + name, + type: 'Microsoft.ContainerService/managedClusters', + location: 'eastus', + properties: { + provisioningState, + kubernetesVersion: '1.29.0', + currentKubernetesVersion: '1.29.0', + dnsPrefix: `${name}dns`, + fqdn: `${name}dns.hcp.eastus.azmk8s.io`, + enableRBAC: true, + nodeResourceGroup: `MC_${rg}_${name}_eastus`, + agentPoolProfiles: [ + {name: 'nodepool1', count: 2, vmSize: 'Standard_D2s_v3', osType: 'Linux', mode: 'System'}, + ], + }, + } +} + +function runtimeStub(clusters: unknown[] = [cluster('prod')]) { + return stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + if (url.includes('/managedClusters/')) return json(clusters[0] ?? {}) + return json({value: clusters}) + }) +} + +describe('AzureAksAdapter', () => { + test('identifies itself as the Azure k8s adapter', () => { + const a = adapter() + + expect(a.cloud).toBe('azure') + expect(a.service).toBe('k8s') + expect(a.schema().displayName).toBe('Azure AKS') + }) + + test('lists clusters and maps the ARM shape onto the shared columns', async () => { + runtimeStub() + const [resource] = await adapter().list() + + expect(resource).toMatchObject({ + // Same addressing as Azure VMs: ARM cannot reach a cluster without its + // resource group, and the generic route passes one id. + id: 'rg-app/prod', + name: 'prod', + cloud: 'azure', + service: 'k8s', + type: 'cluster', + region: 'eastus', + status: 'Succeeded', + version: '1.29.0', + }) + expect(resource?.metadata).toMatchObject({ + resourceGroup: 'rg-app', + fqdn: 'proddns.hcp.eastus.azmk8s.io', + nodeResourceGroup: 'MC_rg-app_prod_eastus', + enableRBAC: true, + nodeCount: 2, + }) + }) + + test('reports a failed provisioning state rather than hiding it', async () => { + // The local runtime never actually provisions a cluster: every create + // settles at Failed. Showing that is the whole point of the column. + runtimeStub([cluster('broken', 'rg-app', 'Failed')]) + const [resource] = await adapter().list() + + expect(resource?.status).toBe('Failed') + }) + + test('summarises the agent pools', async () => { + runtimeStub() + const [resource] = await adapter().list() + + expect(resource?.metadata.agentPools).toEqual([ + {name: 'nodepool1', count: 2, vmSize: 'Standard_D2s_v3', osType: 'Linux', mode: 'System'}, + ]) + }) + + test('filters the list by search term', async () => { + runtimeStub([cluster('prod'), cluster('staging')]) + const a = adapter() + + await expect(a.list({search: 'prod'})).resolves.toHaveLength(1) + await expect(a.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a cluster addressed by resource group and name', async () => { + const calls = runtimeStub() + const resource = await adapter().get('rg-app/prod') + + const call = calls.find((c) => c.url.includes('/managedClusters/prod')) + expect(call?.url).toContain(`/resourceGroups/rg-app/providers/Microsoft.ContainerService/managedClusters/prod`) + expect(resource?.id).toBe('rg-app/prod') + }) + + test('rejects an id that does not name a resource group', async () => { + await expect(adapter().get('prod')).rejects.toThrow(ValidationError) + }) + + test('returns null when a cluster 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('refuses create and delete because this runtime cannot provision a cluster', async () => { + // Deliberately a runtime-specific reason, not a category-wide one: GCP's + // GKE adapter does support create and delete, so "k8s is read-only + // everywhere" is false. What holds here is narrower and verified — the + // shipped floci-az config runs AKS unmocked with no Docker socket to + // start k3s with, so a created cluster sits at provisioningState Failed + // forever. Advertising create would be advertising a dead end. + const a = adapter() + + await expect(a.create({values: {name: 'prod'}})).rejects.toThrow(NotSupportedError) + await expect(a.delete('rg-app/prod')).rejects.toThrow(NotSupportedError) + }) + + test('sends the accept header the other Azure adapters send', async () => { + // azureJson and cosmosJson both set it, and some ARM endpoints are + // Accept-sensitive; diverging here makes error handling inconsistent. + const calls = runtimeStub() + await adapter().list() + + const headers = calls[0]?.init?.headers as Record | undefined + expect(headers?.accept).toBe('application/json') + }) + + test('treats an empty ARM response as no cluster rather than a parse error', async () => { + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + return new Response(null, {status: 204}) + }) + + await expect(adapter().get('rg-app/prod')).resolves.toBeNull() + }) + + test('advertises only list and inspect', () => { + expect(adapter().schema().actions).toEqual(['list', 'inspect']) + }) +}) diff --git a/packages/api/src/adapter-azure/AzureAksAdapter.ts b/packages/api/src/adapter-azure/AzureAksAdapter.ts new file mode 100644 index 0000000..e3b71f8 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureAksAdapter.ts @@ -0,0 +1,195 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {NotSupportedError, RuntimeError, ValidationError} from '../cloud-spi/errors' +import {azureAksSchema} from '../cloud-spi/eksSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to the Floci-AZ runtime's ARM surface for + * Microsoft.ContainerService/managedClusters — verified against `floci/floci-az` + * 0.9.0: + * + * list GET /subscriptions/{s}/providers/Microsoft.ContainerService/managedClusters + * get GET /subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.ContainerService/managedClusters/{n} + * + * Read-only, and for a reason specific to this runtime rather than to the + * category: GCP's GKE adapter does support create and delete, so "k8s is + * read-only everywhere" is not true. What is true here is that a cluster created + * against floci-az settles at `provisioningState: Failed` and stays there, and + * `docker ps` shows nothing starts — unlike GKE on floci-gcp, which really + * launches k3s. Advertising create would advertise something that cannot + * succeed. + * + * `DELETE` does work on the runtime (202, and the cluster disappears), but a + * delete-only surface in a category that cannot create is worse than none. + */ +const API_VERSION = '2023-10-01' + +interface ArmList { + value?: T[] +} + +interface AgentPoolProfile { + name?: string + count?: number + vmSize?: string + osType?: string + mode?: string +} + +interface AksCluster { + id?: string + name?: string + location?: string + tags?: Record + properties?: { + provisioningState?: string + kubernetesVersion?: string + currentKubernetesVersion?: string + dnsPrefix?: string + fqdn?: string + enableRBAC?: boolean + nodeResourceGroup?: string + agentPoolProfiles?: AgentPoolProfile[] + } +} + +export class AzureAksAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'k8s' as const + + private subscriptionId: string | null = null + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureAksSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const subscription = await this.subscription() + const body = await this.json>( + `/subscriptions/${subscription}/providers/Microsoft.ContainerService/managedClusters?api-version=${API_VERSION}`, + ) + return filterBySearch((body?.value ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const {resourceGroup, name} = parseId(id) + const subscription = await this.subscription() + const path = `/subscriptions/${subscription}/resourceGroups/${encodeURIComponent(resourceGroup)}/providers/Microsoft.ContainerService/managedClusters/${encodeURIComponent(name)}` + + const cluster = await this.json( + `${path}?api-version=${API_VERSION}`, + {}, + {emptyOnNotFound: true}, + ) + return cluster ? toResource(cluster) : null + } + + async create(_input: CreateResourceInput): Promise { + throw new NotSupportedError('AKS cluster creation is not supported from the dynamic Cloud Explorer.') + } + + async delete(_id: string): Promise { + throw new NotSupportedError('AKS cluster deletion is not supported from the dynamic Cloud Explorer.') + } + + /** 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 cluster requests to') + + this.subscriptionId = id + return id + } + + /** + * Mirrors the `azureJson`/`cosmosJson` helpers the other Azure adapters use: + * ARM endpoints can be Accept-sensitive, and a 204 carries no body to parse. + * + * Several near-identical copies of this now exist under adapter-azure/. Worth + * lifting onto AzureRuntimeClient once the open Azure PRs have merged — doing + * it in parallel branches would only 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(cluster: AksCluster): CloudResource { + const properties = cluster.properties ?? {} + const name = cluster.name ?? '' + const resourceGroup = resourceGroupOf(cluster.id ?? '') + const pools = properties.agentPoolProfiles ?? [] + + return { + id: resourceGroup ? `${resourceGroup}/${name}` : name, + name, + cloud: 'azure', + service: 'k8s', + type: 'cluster', + region: cluster.location ?? null, + // ARM does not return a creation time for a managed cluster, and inventing + // one would be fake data. + createdAt: null, + status: properties.provisioningState ?? null, + version: properties.currentKubernetesVersion ?? properties.kubernetesVersion ?? null, + metadata: { + provider: 'azure', + resourceId: cluster.id, + resourceGroup, + dnsPrefix: properties.dnsPrefix, + fqdn: properties.fqdn, + enableRBAC: properties.enableRBAC, + nodeResourceGroup: properties.nodeResourceGroup, + agentPools: pools, + nodeCount: pools.reduce((total, pool) => total + (pool.count ?? 0), 0), + tags: cluster.tags, + }, + } +} + +/** ARM cannot address a cluster 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(`cluster 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)) +} diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 6240686..bd4f281 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 {AzureAksAdapter} from './adapter-azure/AzureAksAdapter' 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 AzureAksAdapter(), new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), new GcpCloudSqlAdapter(),