Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -106,15 +106,17 @@ Current gaps:
<details>
<summary><strong>k8s Engine</strong></summary>

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`.

</details>

Expand Down
176 changes: 176 additions & 0 deletions packages/api/src/adapter-azure/AzureAksAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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'])
})
})
Loading