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(),