first commit
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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<T> {
|
||||
value?: T[]
|
||||
}
|
||||
|
||||
interface AgentPoolProfile {
|
||||
name?: string
|
||||
count?: number
|
||||
vmSize?: string
|
||||
osType?: string
|
||||
mode?: string
|
||||
}
|
||||
|
||||
interface AksCluster {
|
||||
id?: string
|
||||
name?: string
|
||||
location?: string
|
||||
tags?: Record<string, string>
|
||||
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<CloudResource[]> {
|
||||
const subscription = await this.subscription()
|
||||
const body = await this.json<ArmList<AksCluster>>(
|
||||
`/subscriptions/${subscription}/providers/Microsoft.ContainerService/managedClusters?api-version=${API_VERSION}`,
|
||||
)
|
||||
return filterBySearch((body?.value ?? []).map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
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<AksCluster>(
|
||||
`${path}?api-version=${API_VERSION}`,
|
||||
{},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return cluster ? toResource(cluster) : null
|
||||
}
|
||||
|
||||
async create(_input: CreateResourceInput): Promise<CloudResource> {
|
||||
throw new NotSupportedError('AKS cluster creation is not supported from the dynamic Cloud Explorer.')
|
||||
}
|
||||
|
||||
async delete(_id: string): Promise<void> {
|
||||
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<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('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<T>(path: string, init: RequestInit = {}, options = {}): Promise<T | null> {
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {AzureComputeAdapter} from './AzureComputeAdapter'
|
||||
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(): AzureComputeAdapter {
|
||||
return new AzureComputeAdapter(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 vm(name: string, rg = 'rg-app', overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `/subscriptions/${SUB}/resourceGroups/${rg}/providers/Microsoft.Compute/virtualMachines/${name}`,
|
||||
name,
|
||||
type: 'Microsoft.Compute/virtualMachines',
|
||||
location: 'eastus',
|
||||
properties: {
|
||||
hardwareProfile: {vmSize: 'Standard_B1s'},
|
||||
storageProfile: {
|
||||
imageReference: {publisher: 'Canonical', offer: 'UbuntuServer', sku: '20.04-LTS', version: 'latest'},
|
||||
},
|
||||
osProfile: {computerName: name, adminUsername: 'azureuser'},
|
||||
vmId: '4190719c-60b8-4a37-9346-6bf7be1730e0',
|
||||
provisioningState: 'Succeeded',
|
||||
timeCreated: '2026-07-28T09:37:46.091123168Z',
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function instanceView(power = 'PowerState/running') {
|
||||
return {
|
||||
computerName: 'vm',
|
||||
osName: 'Linux',
|
||||
statuses: [
|
||||
{code: 'ProvisioningState/succeeded', displayStatus: 'Provisioning succeeded', level: 'Info'},
|
||||
{code: power, displayStatus: 'VM running', level: 'Info'},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes each request to what the runtime actually answers for it. */
|
||||
function runtimeStub(vms: unknown[] = [vm('web')], power = 'PowerState/running') {
|
||||
return stubFetch((url) => {
|
||||
if (url.includes('/subscriptions') && url.endsWith('/subscriptions')) {
|
||||
return json({value: [{subscriptionId: SUB, displayName: 'floci-az local', state: 'Enabled'}]})
|
||||
}
|
||||
if (url.includes('/instanceView')) return json(instanceView(power))
|
||||
if (url.includes('/virtualMachines/')) return json(vms[0] ?? {})
|
||||
if (url.includes('/resourceGroups') && !url.includes('/providers')) {
|
||||
return json({value: [{name: 'rg-app', id: `/subscriptions/${SUB}/resourceGroups/rg-app`}]})
|
||||
}
|
||||
return json({value: vms})
|
||||
})
|
||||
}
|
||||
|
||||
describe('AzureComputeAdapter', () => {
|
||||
test('identifies itself as the Azure compute adapter', () => {
|
||||
const a = adapter()
|
||||
|
||||
expect(a.cloud).toBe('azure')
|
||||
expect(a.service).toBe('compute')
|
||||
expect(a.schema().displayName).toBe('Azure Virtual Machines')
|
||||
})
|
||||
|
||||
test('lists VMs and maps the ARM resource shape', async () => {
|
||||
runtimeStub()
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(resource).toMatchObject({
|
||||
// The id carries the resource group, because ARM cannot address a VM
|
||||
// without it and the generic route only passes one id.
|
||||
id: 'rg-app/web',
|
||||
name: 'web',
|
||||
cloud: 'azure',
|
||||
service: 'compute',
|
||||
type: 'instance',
|
||||
region: 'eastus',
|
||||
instanceClass: 'Standard_B1s',
|
||||
createdAt: '2026-07-28T09:37:46.091123168Z',
|
||||
})
|
||||
expect(resource?.metadata).toMatchObject({
|
||||
resourceGroup: 'rg-app',
|
||||
provisioningState: 'Succeeded',
|
||||
adminUsername: 'azureuser',
|
||||
image: 'Canonical:UbuntuServer:20.04-LTS',
|
||||
})
|
||||
})
|
||||
|
||||
test('reports the power state from the instance view', async () => {
|
||||
runtimeStub([vm('web')], 'PowerState/stopped')
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(resource?.status).toBe('stopped')
|
||||
})
|
||||
|
||||
test('still lists VMs when the instance view fails', async () => {
|
||||
// Power state is enrichment fanned out per VM. An unisolated failure would
|
||||
// reject the whole list and show nothing.
|
||||
stubFetch((url) => {
|
||||
if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]})
|
||||
if (url.includes('/instanceView')) return json({error: {message: 'boom'}}, 500)
|
||||
return json({value: [vm('web')]})
|
||||
})
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(resource?.id).toBe('rg-app/web')
|
||||
expect(resource?.status).toBeNull()
|
||||
expect(resource?.metadata.powerStateUnavailable).toBe(true)
|
||||
})
|
||||
|
||||
test('filters the list by search term', async () => {
|
||||
runtimeStub([vm('web'), vm('api')])
|
||||
const a = adapter()
|
||||
|
||||
await expect(a.list({search: 'we'})).resolves.toHaveLength(1)
|
||||
await expect(a.list({search: 'nope'})).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
test('inspects a VM addressed by resource group and name', async () => {
|
||||
const calls = runtimeStub()
|
||||
const resource = await adapter().get('rg-app/web')
|
||||
|
||||
const vmCall = calls.find((c) => c.url.includes('/virtualMachines/web'))
|
||||
expect(vmCall?.url).toContain(`/subscriptions/${SUB}/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/web`)
|
||||
expect(resource?.id).toBe('rg-app/web')
|
||||
})
|
||||
|
||||
test('rejects an id that does not name a resource group', async () => {
|
||||
await expect(adapter().get('web')).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('returns null when a VM does not exist', async () => {
|
||||
stubFetch((url) => {
|
||||
if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]})
|
||||
return json({error: {message: 'not found', code: 'ResourceNotFound'}}, 404)
|
||||
})
|
||||
await expect(adapter().get('rg-app/nope')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('creates a VM with the image reference the label maps to', async () => {
|
||||
const calls = runtimeStub()
|
||||
await adapter().create({
|
||||
values: {name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s', image: 'Ubuntu 22.04 LTS'},
|
||||
})
|
||||
|
||||
const put = calls.find((c) => c.init?.method === 'PUT')
|
||||
expect(put?.url).toContain('/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/web')
|
||||
const body = JSON.parse(String(put?.init?.body))
|
||||
expect(body.location).toBe('eastus')
|
||||
expect(body.properties.hardwareProfile.vmSize).toBe('Standard_B1s')
|
||||
expect(body.properties.storageProfile.imageReference).toEqual({
|
||||
publisher: 'Canonical',
|
||||
offer: '0001-com-ubuntu-server-jammy',
|
||||
sku: '22_04-lts',
|
||||
version: 'latest',
|
||||
})
|
||||
expect(body.properties.osProfile.adminUsername).toBe('azureuser')
|
||||
})
|
||||
|
||||
test('verifies the resource group exists, because the runtime does not', async () => {
|
||||
// The local runtime happily creates a VM in a resource group that does not
|
||||
// exist and returns 201. Real Azure answers ResourceGroupNotFound, so the
|
||||
// check belongs here or the console would create unreachable resources.
|
||||
stubFetch((url) => {
|
||||
if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]})
|
||||
if (url.includes('/resourceGroups') && !url.includes('/providers')) return json({value: []})
|
||||
return json(vm('web'))
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter().create({
|
||||
values: {name: 'web', resourceGroup: 'rg-missing', vmSize: 'Standard_B1s', image: 'Debian 11'},
|
||||
}),
|
||||
).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('accepts a resource group whose case differs from the runtime', async () => {
|
||||
// Azure treats resource group names as case-insensitive, so rejecting
|
||||
// "RG-App" when "rg-app" exists would block a create that should succeed.
|
||||
const calls = runtimeStub()
|
||||
await adapter().create({
|
||||
values: {name: 'web', resourceGroup: 'RG-App', vmSize: 'Standard_B1s', image: 'Debian 11'},
|
||||
})
|
||||
|
||||
expect(calls.some((c) => c.init?.method === 'PUT')).toBe(true)
|
||||
})
|
||||
|
||||
test("uses the runtime's spelling of the resource group, not the caller's", async () => {
|
||||
// The id is `resourceGroup/name`, so echoing the caller's casing would emit
|
||||
// an id that does not match what list() returns and inspect would 404.
|
||||
const calls = runtimeStub()
|
||||
const resource = await adapter().create({
|
||||
values: {name: 'web', resourceGroup: 'RG-App', vmSize: 'Standard_B1s', image: 'Debian 11'},
|
||||
})
|
||||
|
||||
const put = calls.find((c) => c.init?.method === 'PUT')
|
||||
expect(put?.url).toContain('/resourceGroups/rg-app/')
|
||||
expect(put?.url).not.toContain('RG-App')
|
||||
expect(resource.id).toBe('rg-app/web')
|
||||
})
|
||||
|
||||
test('requires the fields the schema marks required', async () => {
|
||||
runtimeStub()
|
||||
const a = adapter()
|
||||
|
||||
const cases: Array<[Record<string, unknown>, string]> = [
|
||||
[{}, 'name is required'],
|
||||
[{name: 'web'}, 'resourceGroup is required'],
|
||||
[{name: 'web', resourceGroup: 'rg-app'}, 'vmSize is required'],
|
||||
[{name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s'}, 'image is required'],
|
||||
]
|
||||
|
||||
for (const [values, message] of cases) {
|
||||
await expect(a.create({values})).rejects.toThrow(new ValidationError(message))
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects values the schema does not offer', async () => {
|
||||
runtimeStub()
|
||||
const a = adapter()
|
||||
const base = {name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s', image: 'Debian 11'}
|
||||
|
||||
await expect(a.create({values: {...base, vmSize: 'Standard_MEGA'}})).rejects.toThrow(ValidationError)
|
||||
await expect(a.create({values: {...base, image: 'TempleOS'}})).rejects.toThrow(ValidationError)
|
||||
await expect(a.create({values: {...base, location: 'mars1'}})).rejects.toThrow(ValidationError)
|
||||
await expect(a.create({values: {...base, name: 'bad name!'}})).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('deletes a VM', async () => {
|
||||
const calls = runtimeStub()
|
||||
await adapter().delete('rg-app/web')
|
||||
|
||||
const del = calls.find((c) => c.init?.method === 'DELETE')
|
||||
expect(del?.url).toContain('/virtualMachines/web')
|
||||
})
|
||||
|
||||
describe('lifecycle verbs map to the ARM actions', () => {
|
||||
const cases: Array<['start' | 'stop' | 'reboot', string]> = [
|
||||
['start', '/start'],
|
||||
['stop', '/powerOff'],
|
||||
['reboot', '/restart'],
|
||||
]
|
||||
|
||||
for (const [verb, suffix] of cases) {
|
||||
test(`${verb} posts to ${suffix}`, async () => {
|
||||
const calls = runtimeStub()
|
||||
await adapter()[verb]('rg-app/web')
|
||||
|
||||
const post = calls.find((c) => c.init?.method === 'POST')
|
||||
expect(post?.url).toContain(`/virtualMachines/web${suffix}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
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('Floci-AZ 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]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import type {
|
||||
CloudResource,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
SqlConnectionInput,
|
||||
SqlDatabase,
|
||||
SqlQueryResult,
|
||||
SqlTable,
|
||||
} from '../cloud-spi/types'
|
||||
import {AzureDatabaseAdapter} from './AzureDatabaseAdapter'
|
||||
|
||||
describe('AzureDatabaseAdapter', () => {
|
||||
test('combines engines and dispatches create and data operations', async () => {
|
||||
const calls: string[] = []
|
||||
const sql = engineAdapter('sql-server', 'azure-sql', calls)
|
||||
const postgres = engineAdapter('postgres-flexible-server', 'postgresql', calls)
|
||||
const adapter = new AzureDatabaseAdapter(sql, postgres)
|
||||
|
||||
await expect(adapter.list()).resolves.toHaveLength(2)
|
||||
await expect(adapter.create({values: {engine: 'postgresql'}})).resolves.toMatchObject({engine: 'postgresql'})
|
||||
await adapter.querySql('postgres-server', {
|
||||
username: 'pgadmin',
|
||||
password: 'password',
|
||||
engine: 'postgresql',
|
||||
}, 'SELECT 1')
|
||||
|
||||
expect(calls).toEqual([
|
||||
'azure-sql:list',
|
||||
'postgresql:list',
|
||||
'postgresql:create',
|
||||
'postgresql:query',
|
||||
])
|
||||
})
|
||||
|
||||
test('still lists one engine when the other is unavailable', async () => {
|
||||
const calls: string[] = []
|
||||
const sql = engineAdapter('sql-server', 'azure-sql', calls)
|
||||
const postgres = engineAdapter('postgres-flexible-server', 'postgresql', calls)
|
||||
postgres.list = async () => {
|
||||
throw new Error('PostgreSQL is not configured on this runtime')
|
||||
}
|
||||
|
||||
const resources = await new AzureDatabaseAdapter(sql, postgres).list()
|
||||
|
||||
expect(resources.map((resource) => resource.type)).toEqual(['sql-server'])
|
||||
})
|
||||
|
||||
test('surfaces the failure when both engines are unavailable', async () => {
|
||||
const calls: string[] = []
|
||||
const sql = engineAdapter('sql-server', 'azure-sql', calls)
|
||||
const postgres = engineAdapter('postgres-flexible-server', 'postgresql', calls)
|
||||
sql.list = async () => {
|
||||
throw new Error('Cannot reach Floci-AZ')
|
||||
}
|
||||
postgres.list = async () => {
|
||||
throw new Error('PostgreSQL is not configured on this runtime')
|
||||
}
|
||||
|
||||
await expect(new AzureDatabaseAdapter(sql, postgres).list()).rejects.toThrow('Cannot reach Floci-AZ')
|
||||
})
|
||||
})
|
||||
|
||||
function engineAdapter(
|
||||
type: 'sql-server' | 'postgres-flexible-server',
|
||||
engine: 'azure-sql' | 'postgresql',
|
||||
calls: string[],
|
||||
) {
|
||||
const resource: CloudResource = {
|
||||
id: `${type}:server`,
|
||||
name: 'server',
|
||||
cloud: 'azure',
|
||||
service: 'database',
|
||||
type,
|
||||
region: 'eastus',
|
||||
createdAt: null,
|
||||
engine,
|
||||
metadata: {},
|
||||
}
|
||||
return {
|
||||
async list(_query?: ResourceQuery): Promise<CloudResource[]> {
|
||||
calls.push(`${engine}:list`)
|
||||
return [resource]
|
||||
},
|
||||
async get(): Promise<CloudResource> {
|
||||
return resource
|
||||
},
|
||||
async create(_input: CreateResourceInput): Promise<CloudResource> {
|
||||
calls.push(`${engine}:create`)
|
||||
return resource
|
||||
},
|
||||
async delete(): Promise<void> {},
|
||||
async listSqlDatabases(_serverId: string, _connection: SqlConnectionInput): Promise<SqlDatabase[]> {
|
||||
return []
|
||||
},
|
||||
async listSqlTables(_serverId: string, _connection: SqlConnectionInput): Promise<SqlTable[]> {
|
||||
return []
|
||||
},
|
||||
async querySql(_serverId: string, _connection: SqlConnectionInput, _query: string): Promise<SqlQueryResult> {
|
||||
calls.push(`${engine}:query`)
|
||||
return {resultSets: [], rowsAffected: [], durationMs: 0}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {azureDatabaseSchema} from '../cloud-spi/databaseSchema'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
SqlConnectionInput,
|
||||
SqlDatabase,
|
||||
SqlQueryResult,
|
||||
SqlTable,
|
||||
} from '../cloud-spi/types'
|
||||
import {AzurePostgresAdapter, isPostgresServerResourceId} from './AzurePostgresAdapter'
|
||||
import {AzureSqlAdapter, isSqlServerResourceId} from './AzureSqlAdapter'
|
||||
|
||||
interface AzureDatabaseEngineAdapter {
|
||||
list(query?: ResourceQuery): Promise<CloudResource[]>
|
||||
get(id: string): Promise<CloudResource | null>
|
||||
create(input: CreateResourceInput): Promise<CloudResource>
|
||||
delete(id: string): Promise<void>
|
||||
listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]>
|
||||
listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]>
|
||||
querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult>
|
||||
}
|
||||
|
||||
export class AzureDatabaseAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'azure' as const
|
||||
readonly service = 'database' as const
|
||||
|
||||
constructor(
|
||||
private readonly sql: AzureDatabaseEngineAdapter = new AzureSqlAdapter(),
|
||||
private readonly postgres: AzureDatabaseEngineAdapter = new AzurePostgresAdapter(),
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return azureDatabaseSchema()
|
||||
}
|
||||
|
||||
// The two engines are independent resource groups. A deployment that runs only one of
|
||||
// them must still list that one, so a single engine failing is tolerated; both failing
|
||||
// still surfaces, and so does any failure once the other engine has answered nothing.
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const [sqlServers, postgresServers] = await Promise.allSettled([
|
||||
this.sql.list(query),
|
||||
this.postgres.list(query),
|
||||
])
|
||||
|
||||
if (sqlServers.status === 'rejected' && postgresServers.status === 'rejected') {
|
||||
throw sqlServers.reason
|
||||
}
|
||||
|
||||
return [
|
||||
...(sqlServers.status === 'fulfilled' ? sqlServers.value : []),
|
||||
...(postgresServers.status === 'fulfilled' ? postgresServers.value : []),
|
||||
]
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
if (isPostgresServerResourceId(id)) return this.postgres.get(id)
|
||||
if (isSqlServerResourceId(id)) return this.sql.get(id)
|
||||
const sqlServer = await this.sql.get(id)
|
||||
return sqlServer ?? this.postgres.get(id)
|
||||
}
|
||||
|
||||
create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
return input.values.engine === 'postgresql'
|
||||
? this.postgres.create(input)
|
||||
: this.sql.create(input)
|
||||
}
|
||||
|
||||
delete(id: string): Promise<void> {
|
||||
return isPostgresServerResourceId(id)
|
||||
? this.postgres.delete(id)
|
||||
: this.sql.delete(id)
|
||||
}
|
||||
|
||||
listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
|
||||
return this.dataAdapter(serverId, connection).listSqlDatabases(serverId, connection)
|
||||
}
|
||||
|
||||
listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]> {
|
||||
return this.dataAdapter(serverId, connection).listSqlTables(serverId, connection)
|
||||
}
|
||||
|
||||
querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult> {
|
||||
return this.dataAdapter(serverId, connection).querySql(serverId, connection, query)
|
||||
}
|
||||
|
||||
private dataAdapter(serverId: string, connection: SqlConnectionInput): AzureDatabaseEngineAdapter {
|
||||
return connection.engine === 'postgresql' || isPostgresServerResourceId(serverId)
|
||||
? this.postgres
|
||||
: this.sql
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {AzureKeyVaultAdapter} from './AzureKeyVaultAdapter'
|
||||
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'
|
||||
|
||||
interface RecordedCall {
|
||||
path: string
|
||||
init: RequestInit
|
||||
options: AzureRuntimeFetchOptions
|
||||
}
|
||||
|
||||
describe('AzureKeyVaultAdapter', () => {
|
||||
test('lists and filters Key Vault secrets without exposing values', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const adapter = new AzureKeyVaultAdapter(testClient({
|
||||
'/devstoreaccount1-keyvault/secrets?api-version=7.4': {
|
||||
value: [listedSecretRecord('api-token')],
|
||||
nextLink: 'https://devstoreaccount1.vault.azure.net/secrets?api-version=7.4&next=page-2',
|
||||
},
|
||||
'/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2': {
|
||||
value: [listedSecretRecord('database-password', {env: 'test'})],
|
||||
nextLink: null,
|
||||
},
|
||||
}, calls))
|
||||
|
||||
await expect(adapter.list({search: 'database'})).resolves.toEqual([{
|
||||
id: 'database-password',
|
||||
name: 'database-password',
|
||||
cloud: 'azure',
|
||||
service: 'secrets',
|
||||
type: 'secret',
|
||||
region: null,
|
||||
createdAt: '2026-05-20T20:00:00.000Z',
|
||||
status: 'enabled',
|
||||
version: null,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
secretsService: 'key-vault',
|
||||
vaultAccount: 'devstoreaccount1',
|
||||
contentType: 'text/plain',
|
||||
updatedAt: '2026-05-20T20:00:01.000Z',
|
||||
expiresAt: null,
|
||||
notBefore: null,
|
||||
recoveryLevel: 'Purgeable',
|
||||
recoverableDays: 7,
|
||||
tags: [{key: 'env', value: 'test'}],
|
||||
},
|
||||
}])
|
||||
expect(calls.map((call) => call.path)).toEqual([
|
||||
'/devstoreaccount1-keyvault/secrets?api-version=7.4',
|
||||
'/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2',
|
||||
])
|
||||
expect(calls.every((call) => call.options.includeStorageApiVersion === false)).toBe(true)
|
||||
})
|
||||
|
||||
test('stops paging when the runtime echoes the current page as its nextLink', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const adapter = new AzureKeyVaultAdapter(testClient({
|
||||
'/devstoreaccount1-keyvault/secrets?api-version=7.4': {
|
||||
value: [listedSecretRecord('api-token')],
|
||||
nextLink: 'https://devstoreaccount1.vault.azure.net/secrets?api-version=7.4',
|
||||
},
|
||||
}, calls))
|
||||
|
||||
await expect(adapter.list()).resolves.toHaveLength(1)
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('gets a secret and omits its value from normalized metadata', async () => {
|
||||
const adapter = new AzureKeyVaultAdapter(testClient({
|
||||
'/devstoreaccount1-keyvault/secrets/database-password?api-version=7.4': {
|
||||
...secretRecord('database-password', 'v1'),
|
||||
value: 'super-secret',
|
||||
},
|
||||
}))
|
||||
|
||||
const resource = await adapter.get('database-password')
|
||||
|
||||
expect(resource).toMatchObject({id: 'database-password', version: 'v1'})
|
||||
expect(resource?.metadata).not.toHaveProperty('value')
|
||||
})
|
||||
|
||||
test('creates secrets through the Azure Key Vault data-plane contract', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const adapter = new AzureKeyVaultAdapter(testClient({
|
||||
'/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {
|
||||
...secretRecord('api-key', 'created-version'),
|
||||
value: 'abc123',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
}, calls))
|
||||
|
||||
const created = await adapter.create({values: {
|
||||
secretName: 'api-key',
|
||||
secretValue: 'abc123',
|
||||
contentType: 'application/json',
|
||||
}})
|
||||
|
||||
expect(created).toMatchObject({id: 'api-key', version: 'created-version'})
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].path).toBe('/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4')
|
||||
expect(calls[0].init.method).toBe('PUT')
|
||||
expect(calls[0].init.headers).toMatchObject({
|
||||
authorization: 'Bearer floci-ui',
|
||||
'content-type': 'application/json',
|
||||
})
|
||||
expect(calls[0].options.includeStorageApiVersion).toBe(false)
|
||||
expect(JSON.parse(String(calls[0].init.body))).toEqual({
|
||||
value: 'abc123',
|
||||
contentType: 'application/json',
|
||||
})
|
||||
})
|
||||
|
||||
test('deletes secrets using soft-delete endpoint', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const adapter = new AzureKeyVaultAdapter(testClient({
|
||||
'/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {},
|
||||
}, calls))
|
||||
|
||||
await adapter.delete('api-key')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].init.method).toBe('DELETE')
|
||||
expect(calls[0].init.headers).toMatchObject({authorization: 'Bearer floci-ui'})
|
||||
expect(calls[0].options.includeStorageApiVersion).toBe(false)
|
||||
expect(calls[0].options.emptyOnNotFound).toBeUndefined()
|
||||
})
|
||||
|
||||
test('surfaces a delete of a secret that does not exist', async () => {
|
||||
const adapter = new AzureKeyVaultAdapter({
|
||||
endpoint: 'http://localhost:4577',
|
||||
accountName: 'devstoreaccount1',
|
||||
async fetch(path: string) {
|
||||
throw new Error(`Azure runtime request failed: HTTP 404 ${path}`)
|
||||
},
|
||||
})
|
||||
|
||||
await expect(adapter.delete('missing')).rejects.toThrow('HTTP 404')
|
||||
})
|
||||
|
||||
test('normalizes missing secrets to null and missing lists to empty', async () => {
|
||||
const adapter = new AzureKeyVaultAdapter(testClient({}))
|
||||
|
||||
await expect(adapter.get('missing')).resolves.toBeNull()
|
||||
await expect(adapter.list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('validates required inputs before calling the runtime', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const adapter = new AzureKeyVaultAdapter(testClient({}, calls))
|
||||
|
||||
await expect(adapter.create({values: {secretName: 'not valid', secretValue: 'value'}}))
|
||||
.rejects.toThrow('Use a valid Key Vault secret name')
|
||||
await expect(adapter.create({values: {secretName: 'valid-name', secretValue: ''}}))
|
||||
.rejects.toThrow('secretValue is required')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('exposes Azure Key Vault CRUD capabilities in its schema', () => {
|
||||
const schema = new AzureKeyVaultAdapter(testClient({})).schema()
|
||||
|
||||
expect(schema.service).toBe('secrets')
|
||||
expect(schema.actions).toEqual(['list', 'create', 'delete', 'inspect'])
|
||||
expect(schema.fields.find((field) => field.name === 'secretValue')?.type).toBe('password')
|
||||
const namePattern = schema.fields.find((field) => field.name === 'secretName')?.validation?.pattern
|
||||
expect(() => new RegExp(namePattern ?? '', 'v')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// The list endpoint returns base identifiers; only a single-secret read carries a version.
|
||||
function listedSecretRecord(name: string, tags: Record<string, string> = {}) {
|
||||
return secretRecord(name, null, tags)
|
||||
}
|
||||
|
||||
function secretRecord(name: string, version: string | null, tags: Record<string, string> = {}) {
|
||||
return {
|
||||
id: `https://devstoreaccount1.vault.azure.net/secrets/${name}${version ? `/${version}` : ''}`,
|
||||
attributes: {
|
||||
enabled: true,
|
||||
created: 1779307200,
|
||||
updated: 1779307201,
|
||||
exp: null,
|
||||
nbf: null,
|
||||
recoveryLevel: 'Purgeable',
|
||||
recoverableDays: 7,
|
||||
},
|
||||
contentType: 'text/plain',
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
function testClient(
|
||||
responses: Record<string, unknown>,
|
||||
calls: RecordedCall[] = [],
|
||||
): AzureRuntimeClient {
|
||||
return {
|
||||
endpoint: 'http://localhost:4577',
|
||||
accountName: 'devstoreaccount1',
|
||||
async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) {
|
||||
calls.push({path, init, options})
|
||||
if (!(path in responses)) {
|
||||
if (options.emptyOnNotFound) return null
|
||||
throw new Error(`Unexpected Key Vault path: ${path}`)
|
||||
}
|
||||
return new Response(JSON.stringify(responses[path]), {
|
||||
status: 200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import {azure, type AzureRuntimeClient} from '../azure'
|
||||
import {azureSecretsSchema, SECRET_NAME_MESSAGE, SECRET_NAME_PATTERN} from '../cloud-spi/secretsSchema'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
const API_VERSION = '7.4'
|
||||
|
||||
interface KeyVaultSecretAttributes {
|
||||
enabled?: boolean
|
||||
created?: number
|
||||
updated?: number
|
||||
exp?: number | null
|
||||
nbf?: number | null
|
||||
recoveryLevel?: string
|
||||
recoverableDays?: number
|
||||
}
|
||||
|
||||
interface KeyVaultSecretRecord {
|
||||
id?: string
|
||||
value?: string
|
||||
attributes?: KeyVaultSecretAttributes
|
||||
contentType?: string
|
||||
tags?: Record<string, string>
|
||||
}
|
||||
|
||||
interface KeyVaultSecretListResponse {
|
||||
value?: KeyVaultSecretRecord[]
|
||||
nextLink?: string | null
|
||||
}
|
||||
|
||||
export class AzureKeyVaultAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'azure' as const
|
||||
readonly service = 'secrets' as const
|
||||
|
||||
constructor(private readonly client: AzureRuntimeClient = azure) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return azureSecretsSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const records: KeyVaultSecretRecord[] = []
|
||||
const visited = new Set<string>()
|
||||
let path: string | null = `/secrets?api-version=${API_VERSION}`
|
||||
|
||||
// A runtime that echoes the current page as its own nextLink would otherwise
|
||||
// loop forever, so stop as soon as a page repeats.
|
||||
while (path && !visited.has(path)) {
|
||||
visited.add(path)
|
||||
const body: KeyVaultSecretListResponse | null = await this.keyVaultJson<KeyVaultSecretListResponse>(
|
||||
path,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
records.push(...(body?.value ?? []))
|
||||
path = body?.nextLink ? keyVaultNextPath(body.nextLink) : null
|
||||
}
|
||||
|
||||
return filterBySearch(records.map((record) => toSecretResource(record)), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const body = await this.keyVaultJson<KeyVaultSecretRecord>(
|
||||
`/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
|
||||
return body ? toSecretResource(body, id) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const secretName = stringValue(input.values.secretName ?? input.values.name)
|
||||
const secretValue = stringValue(input.values.secretValue ?? input.values.value, false)
|
||||
const contentType = stringValue(input.values.contentType)
|
||||
|
||||
if (!secretName) throw new ValidationError('secretName is required')
|
||||
if (!new RegExp(SECRET_NAME_PATTERN).test(secretName)) throw new ValidationError(SECRET_NAME_MESSAGE)
|
||||
if (!secretValue) throw new ValidationError('secretValue is required')
|
||||
|
||||
const body = await this.keyVaultJson<KeyVaultSecretRecord>(
|
||||
`/secrets/${encodeURIComponent(secretName)}?api-version=${API_VERSION}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
value: secretValue,
|
||||
...(contentType ? {contentType} : {}),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!body) throw new Error('Azure Key Vault create returned an empty response')
|
||||
return toSecretResource(body, secretName)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(
|
||||
this.keyVaultPath(`/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`),
|
||||
{method: 'DELETE', headers: keyVaultHeaders()},
|
||||
{includeStorageApiVersion: false},
|
||||
)
|
||||
}
|
||||
|
||||
private async keyVaultJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
options?: {emptyOnNotFound?: boolean},
|
||||
): Promise<T | null> {
|
||||
const res = await this.client.fetch(
|
||||
this.keyVaultPath(path),
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
...keyVaultHeaders(),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
},
|
||||
{...options, includeStorageApiVersion: false},
|
||||
)
|
||||
|
||||
if (!res || res.status === 204) return null
|
||||
return await res.json() as T
|
||||
}
|
||||
|
||||
private keyVaultPath(path: string): string {
|
||||
return `/${encodeURIComponent(this.client.accountName)}-keyvault${path}`
|
||||
}
|
||||
}
|
||||
|
||||
function keyVaultNextPath(nextLink: string): string {
|
||||
try {
|
||||
const url = new URL(nextLink)
|
||||
return `${url.pathname}${url.search}`
|
||||
} catch {
|
||||
return nextLink.startsWith('/') ? nextLink : `/${nextLink}`
|
||||
}
|
||||
}
|
||||
|
||||
function keyVaultHeaders(): Record<string, string> {
|
||||
return {
|
||||
accept: 'application/json',
|
||||
authorization: 'Bearer floci-ui',
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
function toSecretResource(record: KeyVaultSecretRecord, fallbackName = ''): CloudResource {
|
||||
const parsed = parseSecretId(record.id)
|
||||
const name = parsed.name || fallbackName
|
||||
const attributes = record.attributes ?? {}
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'azure',
|
||||
service: 'secrets',
|
||||
type: 'secret',
|
||||
region: null,
|
||||
createdAt: epochDate(attributes.created),
|
||||
status: attributes.enabled === false ? 'disabled' : 'enabled',
|
||||
version: parsed.version,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
secretsService: 'key-vault',
|
||||
vaultAccount: parsed.account,
|
||||
contentType: record.contentType || null,
|
||||
updatedAt: epochDate(attributes.updated),
|
||||
expiresAt: epochDate(attributes.exp),
|
||||
notBefore: epochDate(attributes.nbf),
|
||||
recoveryLevel: attributes.recoveryLevel,
|
||||
recoverableDays: attributes.recoverableDays,
|
||||
tags: Object.entries(record.tags ?? {}).map(([key, value]) => ({key, value})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseSecretId(id?: string): {account: string | null; name: string; version: string | null} {
|
||||
if (!id) return {account: null, name: '', version: null}
|
||||
try {
|
||||
const url = new URL(id)
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
return {
|
||||
account: url.hostname.split('.')[0] || null,
|
||||
name: decodeURIComponent(parts[1] ?? ''),
|
||||
version: parts[2] ? decodeURIComponent(parts[2]) : null,
|
||||
}
|
||||
} catch {
|
||||
const parts = id.split('/').filter(Boolean)
|
||||
const secretsIndex = parts.lastIndexOf('secrets')
|
||||
return {
|
||||
account: null,
|
||||
name: decodeURIComponent(parts[secretsIndex + 1] ?? ''),
|
||||
version: parts[secretsIndex + 2] ? decodeURIComponent(parts[secretsIndex + 2]) : null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function epochDate(value?: number | null): string | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
||||
return new Date(value * 1000).toISOString()
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, trim = true): string {
|
||||
if (typeof value !== 'string') return ''
|
||||
return trim ? value.trim() : value
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {AzureNoSqlAdapter} from './AzureNoSqlAdapter'
|
||||
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'
|
||||
|
||||
describe('AzureNoSqlAdapter', () => {
|
||||
test('normalizes Cosmos databases as cloud NoSQL resources', async () => {
|
||||
const adapter = new AzureNoSqlAdapter(testClient({
|
||||
'/dbs': {
|
||||
_count: 1,
|
||||
Databases: [{id: 'appdb', _rid: 'db-rid', _etag: '"etag"', _ts: 1779307200}],
|
||||
},
|
||||
}))
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual([{
|
||||
id: 'appdb',
|
||||
name: 'appdb',
|
||||
cloud: 'azure',
|
||||
service: 'nosql',
|
||||
type: 'cosmos-database',
|
||||
region: null,
|
||||
createdAt: '2026-05-20T20:00:00.000Z',
|
||||
status: 'available',
|
||||
engine: 'cosmos-nosql',
|
||||
version: 'NoSQL API',
|
||||
instanceClass: null,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
databaseService: 'cosmos',
|
||||
api: 'nosql',
|
||||
resourceId: 'db-rid',
|
||||
self: undefined,
|
||||
etag: 'etag',
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
test('lists containers and documents from the selected database', async () => {
|
||||
const adapter = new AzureNoSqlAdapter(testClient({
|
||||
'/dbs/appdb/colls': {
|
||||
_count: 1,
|
||||
DocumentCollections: [{
|
||||
id: 'items',
|
||||
_rid: 'coll-rid',
|
||||
_etag: '"coll-etag"',
|
||||
_ts: 1779307200,
|
||||
partitionKey: {paths: ['/category'], kind: 'Hash'},
|
||||
}],
|
||||
},
|
||||
'/dbs/appdb/colls/items': {
|
||||
id: 'items',
|
||||
partitionKey: {paths: ['/category'], kind: 'Hash'},
|
||||
},
|
||||
'/dbs/appdb/colls/items/docs': {
|
||||
_count: 1,
|
||||
Documents: [{id: 'item-1', category: 'demo', _etag: '"doc-etag"', _ts: 1779307201}],
|
||||
},
|
||||
}))
|
||||
|
||||
await expect(adapter.listCosmosContainers('appdb')).resolves.toMatchObject([{
|
||||
id: 'items',
|
||||
databaseId: 'appdb',
|
||||
partitionKeyPath: '/category',
|
||||
}])
|
||||
await expect(adapter.listCosmosItems('appdb', 'items')).resolves.toMatchObject([{
|
||||
id: 'item-1',
|
||||
databaseId: 'appdb',
|
||||
containerId: 'items',
|
||||
partitionKey: 'demo',
|
||||
etag: 'doc-etag',
|
||||
document: {id: 'item-1', category: 'demo', _etag: '"doc-etag"', _ts: 1779307201},
|
||||
}])
|
||||
})
|
||||
|
||||
test('sends document partition key when deleting an item', async () => {
|
||||
const calls: Array<{path: string; init: RequestInit}> = []
|
||||
const adapter = new AzureNoSqlAdapter(testClient({
|
||||
'/dbs/appdb/colls/items/docs/item-1': null,
|
||||
}, calls))
|
||||
|
||||
await adapter.deleteCosmosItem('appdb', 'items', 'item-1', 'demo')
|
||||
|
||||
expect(calls[0].path).toBe('/dbs/appdb/colls/items/docs/item-1')
|
||||
expect(calls[0].init.method).toBe('DELETE')
|
||||
expect(calls[0].init.headers).toMatchObject({'x-ms-documentdb-partitionkey': '["demo"]'})
|
||||
})
|
||||
|
||||
test('queries documents using the Cosmos SQL query endpoint', async () => {
|
||||
const calls: Array<{path: string; init: RequestInit}> = []
|
||||
const adapter = new AzureNoSqlAdapter(testClient({
|
||||
'/dbs/appdb/colls/items/docs': {
|
||||
_count: 1,
|
||||
Documents: [{id: 'item-1'}],
|
||||
},
|
||||
}, calls))
|
||||
|
||||
await expect(adapter.queryCosmosItems('appdb', 'items', 'SELECT * FROM c')).resolves.toEqual({
|
||||
count: 1,
|
||||
items: [{id: 'item-1'}],
|
||||
})
|
||||
expect(calls[0].path).toBe('/dbs/appdb/colls/items/docs')
|
||||
expect(calls[0].init.method).toBe('POST')
|
||||
expect(calls[0].init.headers).toMatchObject({'x-ms-documentdb-isquery': 'True'})
|
||||
})
|
||||
|
||||
test('falls back to account-suffixed path when root routing is not implemented', async () => {
|
||||
const calls: Array<{path: string; init: RequestInit}> = []
|
||||
const adapter = new AzureNoSqlAdapter(testClient({
|
||||
'/dbs': 'not-implemented',
|
||||
'/devstoreaccount1-cosmos/dbs': {
|
||||
_count: 1,
|
||||
Databases: [{id: 'fallbackdb'}],
|
||||
},
|
||||
}, calls))
|
||||
|
||||
await expect(adapter.list()).resolves.toMatchObject([{id: 'fallbackdb'}])
|
||||
expect(calls.map((call) => call.path).filter((path) => path.endsWith('/dbs'))).toEqual(['/dbs', '/devstoreaccount1-cosmos/dbs'])
|
||||
})
|
||||
|
||||
test('falls back to named NoSQL engine path after root and default account routes fail', async () => {
|
||||
const calls: Array<{path: string; init: RequestInit}> = []
|
||||
const adapter = new AzureNoSqlAdapter(testClient({
|
||||
'/dbs': 'not-implemented',
|
||||
'/devstoreaccount1-cosmos/dbs': 'not-implemented',
|
||||
'/devstoreaccount1-cosmos-nosql/dbs': {
|
||||
_count: 1,
|
||||
Databases: [{id: 'nosqldb'}],
|
||||
},
|
||||
}, calls))
|
||||
|
||||
await expect(adapter.list()).resolves.toMatchObject([{id: 'nosqldb'}])
|
||||
expect(calls.map((call) => call.path).filter((path) => path.endsWith('/dbs'))).toEqual([
|
||||
'/dbs',
|
||||
'/devstoreaccount1-cosmos/dbs',
|
||||
'/devstoreaccount1-cosmos-nosql/dbs',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function testClient(responses: Record<string, unknown>, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient {
|
||||
return {
|
||||
endpoint: 'http://localhost:4577',
|
||||
accountName: 'devstoreaccount1',
|
||||
async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) {
|
||||
calls.push({path, init})
|
||||
if (responses[path] === 'not-implemented') {
|
||||
throw new Error('Azure runtime request failed: HTTP 501')
|
||||
}
|
||||
if (!(path in responses)) {
|
||||
if (options.emptyOnNotFound) return null
|
||||
return new Response('Not Found', {status: 404})
|
||||
}
|
||||
return new Response(JSON.stringify(responses[path]), {status: 200, headers: {'content-type': 'application/json'}})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {azure, type AzureRuntimeClient} from '../azure'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {CosmosNoSqlUnavailableError} from './CosmosNoSqlUnavailableError'
|
||||
import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CosmosContainer,
|
||||
CosmosItem,
|
||||
CosmosQueryResult,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
interface CosmosListResponse<T> {
|
||||
_count?: number
|
||||
Databases?: T[]
|
||||
DocumentCollections?: T[]
|
||||
Documents?: T[]
|
||||
}
|
||||
|
||||
type CosmosRecord = Record<string, unknown>
|
||||
|
||||
export class AzureNoSqlAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'azure' as const
|
||||
readonly service = 'nosql' as const
|
||||
|
||||
constructor(private readonly client: AzureRuntimeClient = azure) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return azureNoSqlSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.cosmosJson<CosmosListResponse<CosmosRecord>>('/dbs', {method: 'GET'}, {emptyOnNotFound: true})
|
||||
const databases = body?.Databases ?? []
|
||||
return filterBySearch(databases.map(toDatabaseResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const body = await this.cosmosJson<CosmosRecord>(`/dbs/${encodeSegment(id)}`, {method: 'GET'}, {emptyOnNotFound: true})
|
||||
return body ? toDatabaseResource(body) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const databaseName = stringValue(input.values.databaseName)
|
||||
if (!databaseName) throw new ValidationError('databaseName is required')
|
||||
if (!isValidCosmosId(databaseName)) throw new ValidationError('Use a valid Cosmos database name.')
|
||||
|
||||
const body = await this.cosmosJson<CosmosRecord>('/dbs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({id: databaseName}),
|
||||
})
|
||||
if (!body) throw new RuntimeError('Cosmos database creation returned an empty response')
|
||||
return toDatabaseResource(body)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.cosmosFetch(`/dbs/${encodeSegment(id)}`, {method: 'DELETE'})
|
||||
}
|
||||
|
||||
async listCosmosContainers(databaseId: string): Promise<CosmosContainer[]> {
|
||||
const body = await this.cosmosJson<CosmosListResponse<CosmosRecord>>(
|
||||
`/dbs/${encodeSegment(databaseId)}/colls`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return (body?.DocumentCollections ?? []).map((container) => toContainer(databaseId, container))
|
||||
}
|
||||
|
||||
async createCosmosContainer(databaseId: string, input: CreateResourceInput): Promise<CosmosContainer> {
|
||||
const containerName = stringValue(input.values.containerName)
|
||||
const partitionKeyPath = normalizePartitionKeyPath(stringValue(input.values.partitionKeyPath) || '/id')
|
||||
if (!containerName) throw new ValidationError('containerName is required')
|
||||
if (!isValidCosmosId(containerName)) throw new ValidationError('Use a valid Cosmos container name.')
|
||||
|
||||
const body = await this.cosmosJson<CosmosRecord>(`/dbs/${encodeSegment(databaseId)}/colls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: containerName,
|
||||
partitionKey: {paths: [partitionKeyPath], kind: 'Hash'},
|
||||
}),
|
||||
})
|
||||
if (!body) throw new RuntimeError('Cosmos container creation returned an empty response')
|
||||
return toContainer(databaseId, body)
|
||||
}
|
||||
|
||||
async deleteCosmosContainer(databaseId: string, containerId: string): Promise<void> {
|
||||
await this.cosmosFetch(`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`, {method: 'DELETE'})
|
||||
}
|
||||
|
||||
async listCosmosItems(databaseId: string, containerId: string): Promise<CosmosItem[]> {
|
||||
const [container, body] = await Promise.all([
|
||||
this.getCosmosContainer(databaseId, containerId),
|
||||
this.cosmosJson<CosmosListResponse<CosmosRecord>>(
|
||||
`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
),
|
||||
])
|
||||
const pkPath = container ? partitionKeyPath(container) : '/id'
|
||||
return (body?.Documents ?? []).map((document) => toItem(databaseId, containerId, document, pkPath))
|
||||
}
|
||||
|
||||
async upsertCosmosItem(databaseId: string, containerId: string, document: Record<string, unknown>): Promise<CosmosItem> {
|
||||
if (!isRecord(document)) throw new ValidationError('document must be a JSON object')
|
||||
const id = stringValue(document.id)
|
||||
if (!id) throw new ValidationError('Cosmos document id is required')
|
||||
|
||||
const container = await this.getCosmosContainer(databaseId, containerId)
|
||||
const pkPath = container ? partitionKeyPath(container) : '/id'
|
||||
const body = await this.cosmosJson<CosmosRecord>(
|
||||
`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(document),
|
||||
headers: {
|
||||
'x-ms-documentdb-is-upsert': 'True',
|
||||
...partitionKeyHeader(partitionKeyValue(document, pkPath)),
|
||||
},
|
||||
},
|
||||
)
|
||||
if (!body) throw new RuntimeError('Cosmos document upsert returned an empty response')
|
||||
return toItem(databaseId, containerId, body, pkPath)
|
||||
}
|
||||
|
||||
async deleteCosmosItem(databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise<void> {
|
||||
await this.cosmosFetch(
|
||||
`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs/${encodeSegment(itemId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: partitionKeyHeader(partitionKey ?? null),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async queryCosmosItems(databaseId: string, containerId: string, query: string): Promise<CosmosQueryResult> {
|
||||
const sql = query.trim() || 'SELECT * FROM c'
|
||||
const body = await this.cosmosJson<CosmosListResponse<Record<string, unknown> | string | number | boolean | null>>(
|
||||
`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({query: sql, parameters: []}),
|
||||
headers: {
|
||||
'content-type': 'application/query+json',
|
||||
'x-ms-documentdb-isquery': 'True',
|
||||
'x-ms-documentdb-query-enablecrosspartition': 'True',
|
||||
},
|
||||
},
|
||||
)
|
||||
if (!body) throw new RuntimeError('Cosmos query returned an empty response')
|
||||
const items = body.Documents ?? []
|
||||
return {items, count: body._count ?? items.length}
|
||||
}
|
||||
|
||||
private getCosmosContainer(databaseId: string, containerId: string): Promise<CosmosRecord | null> {
|
||||
return this.cosmosJson<CosmosRecord>(
|
||||
`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
}
|
||||
|
||||
private cosmosFetch(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise<Response | null> {
|
||||
const request: RequestInit = {
|
||||
...init,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
}
|
||||
return this.fetchWithFallbacks(path, request, options)
|
||||
}
|
||||
|
||||
private async cosmosJson<T>(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise<T | null> {
|
||||
const res = await this.cosmosFetch(path, init, options)
|
||||
if (!res) return null
|
||||
if (res.status === 204) return null
|
||||
return await res.json() as T
|
||||
}
|
||||
|
||||
private async fetchWithFallbacks(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise<Response | null> {
|
||||
const attempts = [
|
||||
path,
|
||||
`${cosmosAccountPath(this.client)}${path}`,
|
||||
`${cosmosNoSqlAccountPath(this.client)}${path}`,
|
||||
]
|
||||
const failures: string[] = []
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
return await this.client.fetch(attempt, init, options)
|
||||
} catch (error) {
|
||||
if (!isRetriableRoutingError(error)) throw error
|
||||
failures.push(`${attempt}: ${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
throw new CosmosNoSqlUnavailableError(`Cosmos NoSQL request failed on all known routes. ${failures.join(' | ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function cosmosAccountPath(client: AzureRuntimeClient): string {
|
||||
return `/${encodeSegment(`${client.accountName}-cosmos`)}`
|
||||
}
|
||||
|
||||
function cosmosNoSqlAccountPath(client: AzureRuntimeClient): string {
|
||||
return `/${encodeSegment(`${client.accountName}-cosmos-nosql`)}`
|
||||
}
|
||||
|
||||
function toDatabaseResource(database: CosmosRecord): CloudResource {
|
||||
const name = stringValue(database.id)
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'azure',
|
||||
service: 'nosql',
|
||||
type: 'cosmos-database',
|
||||
region: null,
|
||||
createdAt: timestampToIso(database._ts),
|
||||
status: 'available',
|
||||
engine: 'cosmos-nosql',
|
||||
version: 'NoSQL API',
|
||||
instanceClass: null,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
databaseService: 'cosmos',
|
||||
api: 'nosql',
|
||||
resourceId: database._rid,
|
||||
self: database._self,
|
||||
etag: unquote(stringValue(database._etag)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toContainer(databaseId: string, container: CosmosRecord): CosmosContainer {
|
||||
return {
|
||||
id: stringValue(container.id),
|
||||
name: stringValue(container.id),
|
||||
databaseId,
|
||||
partitionKeyPath: partitionKeyPath(container),
|
||||
createdAt: timestampToIso(container._ts),
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
databaseService: 'cosmos',
|
||||
api: 'nosql',
|
||||
resourceId: container._rid,
|
||||
self: container._self,
|
||||
etag: unquote(stringValue(container._etag)),
|
||||
indexingPolicy: container.indexingPolicy,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toItem(databaseId: string, containerId: string, document: CosmosRecord, pkPath = '/id'): CosmosItem {
|
||||
return {
|
||||
id: stringValue(document.id),
|
||||
databaseId,
|
||||
containerId,
|
||||
partitionKey: partitionKeyValue(document, pkPath),
|
||||
etag: unquote(stringValue(document._etag)),
|
||||
timestamp: timestampToIso(document._ts),
|
||||
document,
|
||||
}
|
||||
}
|
||||
|
||||
function partitionKeyValue(document: CosmosRecord, pkPath: string): string | null {
|
||||
const segments = normalizePartitionKeyPath(pkPath).slice(1).split('/').filter(Boolean)
|
||||
let current: unknown = document
|
||||
for (const segment of segments) {
|
||||
if (!isRecord(current)) return null
|
||||
current = current[segment]
|
||||
}
|
||||
if (current === undefined || current === null) return null
|
||||
if (typeof current === 'string') return current
|
||||
if (typeof current === 'number' || typeof current === 'boolean') return String(current)
|
||||
return JSON.stringify(current)
|
||||
}
|
||||
|
||||
function partitionKeyHeader(partitionKey: string | null): Record<string, string> {
|
||||
return partitionKey === null ? {} : {'x-ms-documentdb-partitionkey': JSON.stringify([partitionKey])}
|
||||
}
|
||||
|
||||
function partitionKeyPath(container: CosmosRecord): string {
|
||||
const pk = container.partitionKey
|
||||
if (!isRecord(pk) || !Array.isArray(pk.paths) || typeof pk.paths[0] !== 'string') return '/id'
|
||||
return pk.paths[0]
|
||||
}
|
||||
|
||||
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 stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
function timestampToIso(value: unknown): string | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
||||
return new Date(value * 1000).toISOString()
|
||||
}
|
||||
|
||||
function normalizePartitionKeyPath(value: string): string {
|
||||
const trimmed = value.trim() || '/id'
|
||||
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
}
|
||||
|
||||
function isValidCosmosId(value: string): boolean {
|
||||
return value.length > 0 && value.length <= 255 && /^[A-Za-z0-9._-]+$/.test(value)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function unquote(value: string): string | null {
|
||||
if (!value) return null
|
||||
return value.replace(/^"|"$/g, '')
|
||||
}
|
||||
|
||||
function encodeSegment(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
function isRetriableRoutingError(error: unknown): boolean {
|
||||
return error instanceof Error && (error.message.includes('HTTP 404') || error.message.includes('HTTP 501'))
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'
|
||||
import {toHttpError} from '../cloud-spi/errors'
|
||||
import {AzurePostgresAdapter} from './AzurePostgresAdapter'
|
||||
import type {SqlDataClient, SqlDataConnection} from './MssqlDataClient'
|
||||
|
||||
const SERVERS_PATH = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.DBforPostgreSQL/flexibleServers'
|
||||
const API_VERSION = '?api-version=2025-08-01'
|
||||
|
||||
describe('AzurePostgresAdapter', () => {
|
||||
test('normalizes PostgreSQL flexible servers', async () => {
|
||||
const adapter = new AzurePostgresAdapter(testClient({
|
||||
[`${SERVERS_PATH}${API_VERSION}`]: {
|
||||
value: [postgresRecord()],
|
||||
},
|
||||
}))
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual([{
|
||||
id: 'postgres-flexible-server:app-postgres',
|
||||
name: 'app-postgres',
|
||||
cloud: 'azure',
|
||||
service: 'database',
|
||||
type: 'postgres-flexible-server',
|
||||
region: 'eastus',
|
||||
createdAt: null,
|
||||
status: 'Ready',
|
||||
engine: 'postgresql',
|
||||
version: '17',
|
||||
instanceClass: 'Standard_B1ms',
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
databaseService: 'postgresql',
|
||||
resourceKind: 'flexible-server',
|
||||
armId: `${SERVERS_PATH}/app-postgres`,
|
||||
serverName: 'app-postgres',
|
||||
administratorLogin: 'pgadmin',
|
||||
fullyQualifiedDomainName: 'app-postgres.local',
|
||||
publicNetworkAccess: 'Enabled',
|
||||
storageSizeGB: 32,
|
||||
endpoint: {address: 'app-postgres.local', port: 5432},
|
||||
tags: [{key: 'environment', value: 'test'}],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
test('creates a PostgreSQL flexible server through the ARM contract', async () => {
|
||||
const calls: Array<{path: string; init: RequestInit}> = []
|
||||
const serverPath = `${SERVERS_PATH}/app-postgres${API_VERSION}`
|
||||
const adapter = new AzurePostgresAdapter(testClient({
|
||||
[serverPath]: postgresRecord(),
|
||||
}, calls))
|
||||
|
||||
await expect(adapter.create({values: {
|
||||
serverName: 'app-postgres',
|
||||
location: 'eastus',
|
||||
administratorLogin: 'pgadmin',
|
||||
administratorLoginPassword: 'StrongPassw0rd!',
|
||||
}})).resolves.toMatchObject({
|
||||
id: 'postgres-flexible-server:app-postgres',
|
||||
engine: 'postgresql',
|
||||
})
|
||||
|
||||
expect(calls[0].path).toBe(serverPath)
|
||||
expect(calls[0].init.method).toBe('PUT')
|
||||
expect(JSON.parse(String(calls[0].init.body))).toEqual({
|
||||
location: 'eastus',
|
||||
sku: {name: 'Standard_B1ms', tier: 'Burstable'},
|
||||
properties: {
|
||||
administratorLogin: 'pgadmin',
|
||||
administratorLoginPassword: 'StrongPassw0rd!',
|
||||
version: '17',
|
||||
storage: {storageSizeGB: 32},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('browses databases and tables and runs PostgreSQL SQL', async () => {
|
||||
const serverPath = `${SERVERS_PATH}/app-postgres${API_VERSION}`
|
||||
const dataCalls: Array<{connection: SqlDataConnection; query: string}> = []
|
||||
const dataClient: SqlDataClient = {
|
||||
async query(connection, query) {
|
||||
dataCalls.push({connection, query})
|
||||
if (query.includes('FROM pg_database')) {
|
||||
return sqlResult([{name: 'postgres', state: 'ONLINE', createdAt: null, isSystem: true}])
|
||||
}
|
||||
if (query.includes('FROM pg_class')) {
|
||||
return sqlResult([{schemaName: 'public', name: 'orders', objectType: 'table', rowCount: '3'}])
|
||||
}
|
||||
return sqlResult([{order_id: 1042}], [1])
|
||||
},
|
||||
}
|
||||
const adapter = new AzurePostgresAdapter(testClient({
|
||||
[serverPath]: postgresRecord(),
|
||||
}), dataClient)
|
||||
const connection = {
|
||||
username: 'pgadmin',
|
||||
password: 'StrongPassw0rd!',
|
||||
engine: 'postgresql' as const,
|
||||
}
|
||||
|
||||
await expect(adapter.listSqlDatabases('app-postgres', connection)).resolves.toEqual([{
|
||||
name: 'postgres',
|
||||
state: 'ONLINE',
|
||||
createdAt: null,
|
||||
isSystem: true,
|
||||
}])
|
||||
await expect(adapter.listSqlTables('app-postgres', {...connection, database: 'appdb'})).resolves.toEqual([{
|
||||
schema: 'public',
|
||||
name: 'orders',
|
||||
type: 'table',
|
||||
rowCount: 3,
|
||||
}])
|
||||
await expect(adapter.querySql('app-postgres', {...connection, database: 'appdb'}, 'SELECT * FROM orders')).resolves.toEqual(
|
||||
sqlResult([{order_id: 1042}], [1]),
|
||||
)
|
||||
|
||||
expect(dataCalls[0].connection).toEqual({
|
||||
server: 'app-postgres.local',
|
||||
port: 5432,
|
||||
database: 'postgres',
|
||||
username: 'pgadmin',
|
||||
password: 'StrongPassw0rd!',
|
||||
tlsMode: 'disable',
|
||||
})
|
||||
expect(dataCalls[1].connection.database).toBe('appdb')
|
||||
expect(dataCalls[2].query).toBe('SELECT * FROM orders')
|
||||
})
|
||||
|
||||
test('reports bad input as 400 and a missing server as 404', async () => {
|
||||
const existing = new AzurePostgresAdapter(testClient({
|
||||
[`${SERVERS_PATH}/app-postgres${API_VERSION}`]: postgresRecord(),
|
||||
}))
|
||||
const missing = new AzurePostgresAdapter(testClient({}))
|
||||
|
||||
expect(await httpStatusOf(() => existing.create({values: {}}))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.create({values: {serverName: 'App_Postgres'}}))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.create({values: {serverName: 'app-postgres'}}))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.querySql('app-postgres', {username: 'pgadmin', password: 'x'}, ''))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.querySql('app-postgres', {username: '', password: ''}, 'SELECT 1'))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.get(''))).toBe(400)
|
||||
|
||||
expect(await httpStatusOf(() => missing.querySql('gone', {username: 'pgadmin', password: 'x'}, 'SELECT 1'))).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
async function httpStatusOf(operation: () => Promise<unknown>): Promise<number> {
|
||||
const failure = await operation().then(() => null, (err: unknown) => err)
|
||||
expect(failure).not.toBeNull()
|
||||
return toHttpError(failure).status
|
||||
}
|
||||
|
||||
function postgresRecord() {
|
||||
return {
|
||||
id: `${SERVERS_PATH}/app-postgres`,
|
||||
name: 'app-postgres',
|
||||
type: 'Microsoft.DBforPostgreSQL/flexibleServers',
|
||||
location: 'eastus',
|
||||
tags: {environment: 'test'},
|
||||
sku: {name: 'Standard_B1ms', tier: 'Burstable'},
|
||||
properties: {
|
||||
administratorLogin: 'pgadmin',
|
||||
version: '17',
|
||||
state: 'Ready',
|
||||
fullyQualifiedDomainName: 'app-postgres.local',
|
||||
localPort: 5432,
|
||||
storage: {storageSizeGB: 32},
|
||||
network: {publicNetworkAccess: 'Enabled'},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sqlResult(rows: Array<Record<string, unknown>>, rowsAffected: number[] = []) {
|
||||
return {
|
||||
resultSets: [{
|
||||
columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'text'})),
|
||||
rows,
|
||||
truncated: false,
|
||||
}],
|
||||
rowsAffected,
|
||||
durationMs: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function testClient(responses: Record<string, unknown>, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient {
|
||||
return {
|
||||
endpoint: 'http://localhost:4577',
|
||||
accountName: 'devstoreaccount1',
|
||||
async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) {
|
||||
calls.push({path, init})
|
||||
if (!(path in responses)) {
|
||||
if (options.emptyOnNotFound) return null
|
||||
throw new Error(`Unexpected Azure runtime request: ${path}`)
|
||||
}
|
||||
return new Response(JSON.stringify(responses[path]), {status: 200, headers: {'content-type': 'application/json'}})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
azure,
|
||||
azureResourceGroup,
|
||||
type AzureRuntimeClient,
|
||||
azureSubscriptionId,
|
||||
} from '../azure'
|
||||
import {azureDatabaseSchema} from '../cloud-spi/databaseSchema'
|
||||
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
SqlConnectionInput,
|
||||
SqlDatabase,
|
||||
SqlQueryResult,
|
||||
SqlTable,
|
||||
} from '../cloud-spi/types'
|
||||
import type {SqlDataClient, SqlDataConnection} from './MssqlDataClient'
|
||||
import {PostgresDataClient} from './PostgresDataClient'
|
||||
import {postgresTlsMode} from './sqlTransport'
|
||||
import {
|
||||
filterBySearch,
|
||||
isValidServerName,
|
||||
numberValue,
|
||||
rawStringValue,
|
||||
recordValue,
|
||||
stringValue,
|
||||
tagList,
|
||||
withApiVersion,
|
||||
} from './sqlAdapterUtils'
|
||||
|
||||
const API_VERSION = '2025-08-01'
|
||||
const RESOURCE_ID_PREFIX = 'postgres-flexible-server:'
|
||||
const LIST_DATABASES_QUERY = `
|
||||
SELECT
|
||||
datname AS name,
|
||||
CASE WHEN datallowconn THEN 'ONLINE' ELSE 'OFFLINE' END AS state,
|
||||
NULL::text AS "createdAt",
|
||||
datname IN ('postgres', 'template0', 'template1') AS "isSystem"
|
||||
FROM pg_database
|
||||
WHERE datallowconn
|
||||
ORDER BY "isSystem" DESC, datname`
|
||||
const LIST_TABLES_QUERY = `
|
||||
SELECT
|
||||
namespace.nspname AS "schemaName",
|
||||
relation.relname AS name,
|
||||
CASE WHEN relation.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END AS "objectType",
|
||||
CASE
|
||||
WHEN relation.relkind IN ('r', 'p') AND relation.reltuples >= 0
|
||||
THEN relation.reltuples::bigint
|
||||
ELSE NULL
|
||||
END AS "rowCount"
|
||||
FROM pg_class AS relation
|
||||
INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace
|
||||
WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
AND namespace.nspname NOT LIKE 'pg_toast%'
|
||||
AND relation.relkind IN ('r', 'p', 'v', 'm')
|
||||
ORDER BY namespace.nspname, relation.relname`
|
||||
|
||||
interface AzurePostgresListResponse {
|
||||
value?: AzurePostgresRecord[]
|
||||
}
|
||||
|
||||
interface AzurePostgresRecord {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
location?: string
|
||||
tags?: Record<string, string>
|
||||
sku?: Record<string, unknown>
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export class AzurePostgresAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'azure' as const
|
||||
readonly service = 'database' as const
|
||||
|
||||
constructor(
|
||||
private readonly client: AzureRuntimeClient = azure,
|
||||
private readonly dataClient: SqlDataClient = new PostgresDataClient(),
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return azureDatabaseSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.postgresJson<AzurePostgresListResponse>(postgresServersPath(), {method: 'GET'}, true)
|
||||
return filterBySearch((body?.value ?? []).map(toPostgresResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const serverName = postgresServerName(id)
|
||||
const body = await this.postgresJson<AzurePostgresRecord>(postgresServerPath(serverName), {method: 'GET'}, true)
|
||||
return body ? toPostgresResource(body) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const serverName = stringValue(input.values.serverName)
|
||||
const location = stringValue(input.values.location) || 'eastus'
|
||||
const administratorLogin = stringValue(input.values.administratorLogin)
|
||||
const administratorLoginPassword = stringValue(input.values.administratorLoginPassword)
|
||||
|
||||
if (!serverName) throw new ValidationError('serverName is required')
|
||||
if (!isValidServerName(serverName)) throw new ValidationError('Use a valid PostgreSQL flexible server name.')
|
||||
if (!administratorLogin) throw new ValidationError('administratorLogin is required')
|
||||
if (!administratorLoginPassword) throw new ValidationError('administratorLoginPassword is required')
|
||||
|
||||
const body = await this.postgresJson<AzurePostgresRecord>(postgresServerPath(serverName), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
location,
|
||||
sku: {name: 'Standard_B1ms', tier: 'Burstable'},
|
||||
properties: {
|
||||
administratorLogin,
|
||||
administratorLoginPassword,
|
||||
version: '17',
|
||||
storage: {storageSizeGB: 32},
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!body) throw new RuntimeError('PostgreSQL flexible server creation returned an empty response')
|
||||
return toPostgresResource(body)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.postgresFetch(postgresServerPath(postgresServerName(id)), {method: 'DELETE'})
|
||||
}
|
||||
|
||||
async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
|
||||
const result = await this.querySql(serverId, {...connection, database: 'postgres'}, LIST_DATABASES_QUERY)
|
||||
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
|
||||
const name = stringValue(row.name)
|
||||
if (!name) return []
|
||||
return [{
|
||||
name,
|
||||
state: stringValue(row.state) || 'UNKNOWN',
|
||||
createdAt: stringValue(row.createdAt) || null,
|
||||
isSystem: row.isSystem === true,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]> {
|
||||
const result = await this.querySql(serverId, connection, LIST_TABLES_QUERY)
|
||||
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
|
||||
const schema = stringValue(row.schemaName)
|
||||
const name = stringValue(row.name)
|
||||
if (!schema || !name) return []
|
||||
return [{
|
||||
schema,
|
||||
name,
|
||||
type: row.objectType === 'view' ? 'view' as const : 'table' as const,
|
||||
rowCount: integerValue(row.rowCount),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult> {
|
||||
if (!query.trim()) throw new ValidationError('SQL query is required')
|
||||
return this.dataClient.query(await this.dataConnection(serverId, connection), query)
|
||||
}
|
||||
|
||||
private postgresFetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise<Response | null> {
|
||||
return this.client.fetch(withApiVersion(path, API_VERSION), {
|
||||
...init,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
}, {emptyOnNotFound})
|
||||
}
|
||||
|
||||
private async postgresJson<T>(path: string, init: RequestInit, emptyOnNotFound = false): Promise<T | null> {
|
||||
const response = await this.postgresFetch(path, init, emptyOnNotFound)
|
||||
if (!response || response.status === 204) return null
|
||||
return await response.json() as T
|
||||
}
|
||||
|
||||
private async dataConnection(serverId: string, input: SqlConnectionInput): Promise<SqlDataConnection> {
|
||||
const resource = await this.get(serverId)
|
||||
if (!resource) throw new NotFoundError('PostgreSQL flexible server not found')
|
||||
|
||||
const endpoint = recordValue(resource.metadata.endpoint)
|
||||
const server = stringValue(endpoint?.address)
|
||||
const port = numberValue(endpoint?.port)
|
||||
const username = rawStringValue(input.username)
|
||||
const password = rawStringValue(input.password)
|
||||
const database = stringValue(input.database) || 'postgres'
|
||||
const hasLocalPort = endpoint?.port !== undefined && endpoint.port !== null
|
||||
|
||||
// The endpoint comes from the resource the runtime returned, so a missing
|
||||
// address is a runtime problem rather than something the caller sent.
|
||||
if (!server || port === null) throw new RuntimeError('PostgreSQL server endpoint is not available')
|
||||
if (!username) throw new ValidationError('PostgreSQL username is required')
|
||||
if (!password) throw new ValidationError('PostgreSQL password is required')
|
||||
|
||||
return {
|
||||
server,
|
||||
port,
|
||||
database,
|
||||
username,
|
||||
password,
|
||||
tlsMode: postgresTlsMode(server, hasLocalPort),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isPostgresServerResourceId(id: string): boolean {
|
||||
return id.startsWith(RESOURCE_ID_PREFIX)
|
||||
}
|
||||
|
||||
function postgresServersPath(): string {
|
||||
return `/subscriptions/${encodeURIComponent(azureSubscriptionId())}/resourceGroups/${encodeURIComponent(azureResourceGroup())}/providers/Microsoft.DBforPostgreSQL/flexibleServers`
|
||||
}
|
||||
|
||||
function postgresServerPath(serverName: string): string {
|
||||
return `${postgresServersPath()}/${encodeURIComponent(serverName)}`
|
||||
}
|
||||
|
||||
function postgresServerName(id: string): string {
|
||||
const name = isPostgresServerResourceId(id) ? id.slice(RESOURCE_ID_PREFIX.length) : id
|
||||
if (!name) throw new ValidationError('PostgreSQL flexible server id is required')
|
||||
return name
|
||||
}
|
||||
|
||||
function toPostgresResource(server: AzurePostgresRecord): CloudResource {
|
||||
const name = stringValue(server.name)
|
||||
const properties = server.properties ?? {}
|
||||
const sku = recordValue(server.sku)
|
||||
const network = recordValue(properties.network)
|
||||
const storage = recordValue(properties.storage)
|
||||
return {
|
||||
id: `${RESOURCE_ID_PREFIX}${name}`,
|
||||
name,
|
||||
cloud: 'azure',
|
||||
service: 'database',
|
||||
type: 'postgres-flexible-server',
|
||||
region: stringValue(server.location) || null,
|
||||
createdAt: null,
|
||||
status: stringValue(properties.state) || stringValue(properties.provisioningState) || null,
|
||||
engine: 'postgresql',
|
||||
version: stringValue(properties.version) || null,
|
||||
instanceClass: stringValue(sku?.name) || null,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
databaseService: 'postgresql',
|
||||
resourceKind: 'flexible-server',
|
||||
armId: server.id,
|
||||
serverName: name,
|
||||
administratorLogin: properties.administratorLogin,
|
||||
fullyQualifiedDomainName: properties.fullyQualifiedDomainName,
|
||||
publicNetworkAccess: network?.publicNetworkAccess,
|
||||
storageSizeGB: storage?.storageSizeGB,
|
||||
endpoint: {
|
||||
address: properties.fullyQualifiedDomainName,
|
||||
port: numberValue(properties.localPort),
|
||||
},
|
||||
tags: tagList(server.tags),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function integerValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value
|
||||
if (typeof value !== 'string' || !/^-?\d+$/.test(value)) return null
|
||||
const parsed = Number(value)
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { AzureRuntimeClient, AzureRuntimeFetchOptions } from '../azure'
|
||||
import { AzureServerlessAdapter } from './AzureServerlessAdapter'
|
||||
|
||||
interface RecordedCall {
|
||||
path: string
|
||||
init: RequestInit
|
||||
options?: AzureRuntimeFetchOptions
|
||||
}
|
||||
|
||||
function azureFunction(name: string) {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
kind: 'functionapp',
|
||||
location: 'centralus',
|
||||
type: 'Microsoft.Web/sites/functions',
|
||||
properties: {
|
||||
state: 'Running',
|
||||
runtime: 'node',
|
||||
functionAppName: 'floci-functions',
|
||||
lastModifiedTimeUtc: '2026-06-22T05:29:13Z',
|
||||
scriptHref: `http://localhost:4577/functions/${name}/script`,
|
||||
invokeUrlTemplate: `http://localhost:4577/functions/${name}/invoke`,
|
||||
config: {
|
||||
bindings: [
|
||||
{
|
||||
type: 'httpTrigger',
|
||||
direction: 'in',
|
||||
name: 'req',
|
||||
},
|
||||
],
|
||||
},
|
||||
files: {
|
||||
'index.js': 'module.exports = async () => ({statusCode: 200})',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function testClient(
|
||||
handler: (
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
options?: AzureRuntimeFetchOptions,
|
||||
) => Promise<Response | null>,
|
||||
): AzureRuntimeClient {
|
||||
return {
|
||||
endpoint: 'http://localhost:4577',
|
||||
accountName: 'devstoreaccount1',
|
||||
fetch: handler,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AzureServerlessAdapter', () => {
|
||||
test('lists functions and maps the Azure resource shape', async () => {
|
||||
const client = testClient(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value: [azureFunction('hello')],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
|
||||
const resources = await new AzureServerlessAdapter(client).list()
|
||||
|
||||
expect(resources).toEqual([
|
||||
{
|
||||
id: 'hello',
|
||||
name: 'hello',
|
||||
cloud: 'azure',
|
||||
service: 'serverless',
|
||||
type: 'azure-function',
|
||||
region: 'centralus',
|
||||
createdAt: '2026-06-22T05:29:13Z',
|
||||
status: 'Running',
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
serverlessService: 'functions',
|
||||
kind: 'functionapp',
|
||||
resourceType: 'Microsoft.Web/sites/functions',
|
||||
runtime: 'node',
|
||||
functionAppName: 'floci-functions',
|
||||
lastModified: '2026-06-22T05:29:13Z',
|
||||
triggerType: 'httpTrigger',
|
||||
scriptHref: 'http://localhost:4577/functions/hello/script',
|
||||
invokeUrlTemplate: 'http://localhost:4577/functions/hello/invoke',
|
||||
config: {
|
||||
bindings: [
|
||||
{
|
||||
type: 'httpTrigger',
|
||||
direction: 'in',
|
||||
name: 'req',
|
||||
},
|
||||
],
|
||||
},
|
||||
files: {
|
||||
'index.js':
|
||||
'module.exports = async () => ({statusCode: 200})',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('handles missing trigger metadata gracefully', async () => {
|
||||
const functionWithoutBindings = azureFunction('hello')
|
||||
functionWithoutBindings.properties.config = {
|
||||
bindings: [],
|
||||
}
|
||||
|
||||
const client = testClient(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value: [functionWithoutBindings],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
|
||||
const resources = await new AzureServerlessAdapter(client).list()
|
||||
|
||||
expect(resources[0].metadata.lastModified).toBe(
|
||||
'2026-06-22T05:29:13Z',
|
||||
)
|
||||
expect(resources[0].metadata.triggerType).toBeUndefined()
|
||||
})
|
||||
|
||||
test('normalizes a missing list endpoint to an empty list', async () => {
|
||||
const client = testClient(async (_path, _init, options) => {
|
||||
if (options?.emptyOnNotFound) return null
|
||||
return new Response('Not Found', { status: 404 })
|
||||
})
|
||||
|
||||
await expect(new AzureServerlessAdapter(client).list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('filters listed functions by search term', async () => {
|
||||
const client = testClient(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value: [azureFunction('alpha'), azureFunction('beta')],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
|
||||
const resources = await new AzureServerlessAdapter(client).list({
|
||||
search: 'bet',
|
||||
})
|
||||
|
||||
expect(resources.map((resource) => resource.name)).toEqual(['beta'])
|
||||
})
|
||||
|
||||
test('gets and maps a single function', async () => {
|
||||
const client = testClient(async () =>
|
||||
new Response(JSON.stringify(azureFunction('hello')), { status: 200 }),
|
||||
)
|
||||
|
||||
const resource = await new AzureServerlessAdapter(client).get('hello')
|
||||
|
||||
expect(resource?.id).toBe('hello')
|
||||
expect(resource?.status).toBe('Running')
|
||||
expect(resource?.metadata.runtime).toBe('node')
|
||||
})
|
||||
|
||||
test('get returns null when the function is missing', async () => {
|
||||
const client = testClient(async (_path, _init, options) => {
|
||||
if (options?.emptyOnNotFound) return null
|
||||
return new Response('Not Found', { status: 404 })
|
||||
})
|
||||
|
||||
await expect(
|
||||
new AzureServerlessAdapter(client).get('missing'),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('create posts the expected payload and maps the response', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
|
||||
const client = testClient(async (path, init, options) => {
|
||||
calls.push({ path, init, options })
|
||||
return new Response(JSON.stringify(azureFunction('hello')), {
|
||||
status: 201,
|
||||
})
|
||||
})
|
||||
|
||||
const resource = await new AzureServerlessAdapter(client).create({
|
||||
values: {
|
||||
functionName: 'hello',
|
||||
runtime: 'node',
|
||||
handler: 'index.handler',
|
||||
code: 'module.exports.handler = async () => ({statusCode: 200})',
|
||||
location: 'centralus',
|
||||
functionAppName: 'floci-functions',
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].path).toBe('/functions')
|
||||
expect(calls[0].init.method).toBe('POST')
|
||||
expect(calls[0].init.headers).toEqual({
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
})
|
||||
expect(JSON.parse(String(calls[0].init.body))).toEqual({
|
||||
name: 'hello',
|
||||
runtime: 'node',
|
||||
handler: 'index.handler',
|
||||
code: 'module.exports.handler = async () => ({statusCode: 200})',
|
||||
location: 'centralus',
|
||||
functionAppName: 'floci-functions',
|
||||
})
|
||||
expect(resource.id).toBe('hello')
|
||||
})
|
||||
|
||||
test('create defaults runtime to node', async () => {
|
||||
let sentBody = ''
|
||||
|
||||
const client = testClient(async (_path, init) => {
|
||||
sentBody = String(init.body)
|
||||
return new Response(JSON.stringify(azureFunction('hello')), {
|
||||
status: 201,
|
||||
})
|
||||
})
|
||||
|
||||
await new AzureServerlessAdapter(client).create({
|
||||
values: {
|
||||
functionName: 'hello',
|
||||
},
|
||||
})
|
||||
|
||||
expect(JSON.parse(sentBody)).toEqual({
|
||||
name: 'hello',
|
||||
runtime: 'node',
|
||||
})
|
||||
})
|
||||
|
||||
test('create rejects when functionName is missing', async () => {
|
||||
const client = testClient(async () =>
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
)
|
||||
|
||||
await expect(
|
||||
new AzureServerlessAdapter(client).create({
|
||||
values: {},
|
||||
}),
|
||||
).rejects.toThrow('functionName is required')
|
||||
})
|
||||
|
||||
test('create rejects when the runtime returns an empty response', async () => {
|
||||
const client = testClient(async () =>
|
||||
new Response(null, { status: 204 }),
|
||||
)
|
||||
|
||||
await expect(
|
||||
new AzureServerlessAdapter(client).create({
|
||||
values: {
|
||||
functionName: 'hello',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Azure Functions create returned an empty response')
|
||||
})
|
||||
|
||||
test('delete issues a DELETE request against the function path', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
|
||||
const client = testClient(async (path, init, options) => {
|
||||
calls.push({ path, init, options })
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
|
||||
await new AzureServerlessAdapter(client).delete('hello')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].path).toBe('/functions/hello')
|
||||
expect(calls[0].init.method).toBe('DELETE')
|
||||
expect(calls[0].options).toEqual({
|
||||
emptyOnNotFound: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('invoke posts the payload and maps the response', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
|
||||
const client = testClient(async (path, init, options) => {
|
||||
calls.push({ path, init, options })
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
statusCode: 202,
|
||||
payload: {
|
||||
message: 'accepted',
|
||||
},
|
||||
functionError: 'Handled',
|
||||
logResult: 'execution log',
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
})
|
||||
|
||||
const result = await new AzureServerlessAdapter(client).invoke(
|
||||
'hello',
|
||||
'{"name":"Hajira"}',
|
||||
)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].path).toBe('/functions/hello/invoke')
|
||||
expect(calls[0].init.method).toBe('POST')
|
||||
expect(JSON.parse(String(calls[0].init.body))).toEqual({
|
||||
payload: '{"name":"Hajira"}',
|
||||
})
|
||||
expect(result.statusCode).toBe(202)
|
||||
expect(result.payload).toBe('{"message":"accepted"}')
|
||||
expect(result.functionError).toBe('Handled')
|
||||
expect(result.logResult).toBe('execution log')
|
||||
expect(result.executionDuration).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('invoke uses an empty JSON object when payload is blank', async () => {
|
||||
let sentBody = ''
|
||||
|
||||
const client = testClient(async (_path, init) => {
|
||||
sentBody = String(init.body)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
statusCode: 200,
|
||||
body: 'ok',
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
})
|
||||
|
||||
const result = await new AzureServerlessAdapter(client).invoke(
|
||||
'hello',
|
||||
' ',
|
||||
)
|
||||
|
||||
expect(JSON.parse(sentBody)).toEqual({
|
||||
payload: '{}',
|
||||
})
|
||||
expect(result.payload).toBe('ok')
|
||||
})
|
||||
|
||||
test('lists functions from a direct array response', async () => {
|
||||
const client = testClient(async () =>
|
||||
new Response(
|
||||
JSON.stringify([azureFunction('hello')]),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
|
||||
const resources = await new AzureServerlessAdapter(client).list()
|
||||
|
||||
expect(resources).toHaveLength(1)
|
||||
expect(resources[0].id).toBe('hello')
|
||||
expect(resources[0].type).toBe('azure-function')
|
||||
})
|
||||
test('propagates runtime errors from the Azure client', async () => {
|
||||
const client = testClient(async () => {
|
||||
throw new Error('Azure Functions request failed: HTTP 500')
|
||||
})
|
||||
|
||||
await expect(
|
||||
new AzureServerlessAdapter(client).list(),
|
||||
).rejects.toThrow('Azure Functions request failed: HTTP 500')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import { azure, type AzureRuntimeClient } from '../azure'
|
||||
import { azureServerlessSchema } from '../cloud-spi/serverlessSchema'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CloudServiceDescriptorOverride,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServerlessInvokeResult,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
interface AzureFunctionRecord {
|
||||
id?: string
|
||||
name?: string
|
||||
kind?: string
|
||||
location?: string
|
||||
type?: string
|
||||
properties?: {
|
||||
state?: string
|
||||
status?: string
|
||||
runtime?: string
|
||||
functionAppName?: string
|
||||
lastModifiedTimeUtc?: string
|
||||
scriptHref?: string
|
||||
invokeUrlTemplate?: string
|
||||
config?: Record<string, unknown>
|
||||
files?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
interface AzureFunctionListResponse {
|
||||
value?: AzureFunctionRecord[]
|
||||
}
|
||||
|
||||
export class AzureServerlessAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'azure' as const
|
||||
readonly service = 'serverless' as const
|
||||
|
||||
constructor(private readonly client: AzureRuntimeClient = azure) { }
|
||||
|
||||
/**
|
||||
* floci-az answers 501 NotImplemented on /functions, so this adapter cannot
|
||||
* currently serve a request even though it is registered. Reporting
|
||||
* coming_soon keeps the nav honest; remove this once the runtime ships the
|
||||
* endpoint and the adapter's own tests exercise it against a real response.
|
||||
*/
|
||||
descriptorOverride(): CloudServiceDescriptorOverride {
|
||||
return {
|
||||
availability: 'coming_soon',
|
||||
reason: 'The Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.',
|
||||
}
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return azureServerlessSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.azureJson<AzureFunctionListResponse | AzureFunctionRecord[]>(
|
||||
'/functions',
|
||||
{ method: 'GET' },
|
||||
{ emptyOnNotFound: true },
|
||||
)
|
||||
|
||||
const records = Array.isArray(body) ? body : body?.value ?? []
|
||||
return filterBySearch(records.map(toFunctionResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const body = await this.azureJson<AzureFunctionRecord>(
|
||||
`/functions/${encodeURIComponent(id)}`,
|
||||
{ method: 'GET' },
|
||||
{ emptyOnNotFound: true },
|
||||
)
|
||||
|
||||
return body ? toFunctionResource(body) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const functionName = stringValue(input.values.functionName ?? input.values.name)
|
||||
const runtime = stringValue(input.values.runtime) || 'node'
|
||||
const handler = stringValue(input.values.handler)
|
||||
const code = stringValue(input.values.code)
|
||||
const location = stringValue(input.values.location)
|
||||
const functionAppName = stringValue(input.values.functionAppName)
|
||||
|
||||
if (!functionName) throw new ValidationError('functionName is required')
|
||||
|
||||
const body = await this.azureJson<AzureFunctionRecord>(
|
||||
'/functions',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: functionName,
|
||||
runtime,
|
||||
handler: handler || undefined,
|
||||
code: code || undefined,
|
||||
location: location || undefined,
|
||||
functionAppName: functionAppName || undefined,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!body) throw new RuntimeError('Azure Functions create returned an empty response')
|
||||
return toFunctionResource(body)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(
|
||||
`/functions/${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' },
|
||||
{ emptyOnNotFound: true },
|
||||
)
|
||||
}
|
||||
async invoke(id: string, payload: string): Promise<ServerlessInvokeResult> {
|
||||
const startedAt = performance.now()
|
||||
|
||||
const body = await this.azureJson<{
|
||||
statusCode?: number
|
||||
payload?: unknown
|
||||
body?: unknown
|
||||
error?: string
|
||||
functionError?: string
|
||||
logResult?: string
|
||||
}>(
|
||||
`/functions/${encodeURIComponent(id)}/invoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
payload: payload?.trim() ? payload : '{}',
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
statusCode: body?.statusCode ?? 200,
|
||||
payload: stringifyPayload(body?.payload ?? body?.body ?? ''),
|
||||
functionError: body?.functionError ?? body?.error,
|
||||
logResult: body?.logResult,
|
||||
executionDuration: Math.round(performance.now() - startedAt),
|
||||
}
|
||||
}
|
||||
|
||||
private async azureJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
options?: { emptyOnNotFound?: boolean },
|
||||
): Promise<T | null> {
|
||||
const res = await this.client.fetch(
|
||||
path,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
},
|
||||
options,
|
||||
)
|
||||
|
||||
if (!res || res.status === 204) return null
|
||||
return await res.json() as T
|
||||
}
|
||||
}
|
||||
|
||||
function toFunctionResource(record: AzureFunctionRecord): CloudResource {
|
||||
const name = stringValue(record.name ?? record.id)
|
||||
const props = record.properties ?? {}
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'azure',
|
||||
service: 'serverless',
|
||||
type: 'azure-function',
|
||||
region: record.location ?? null,
|
||||
createdAt: props.lastModifiedTimeUtc ?? null,
|
||||
status: props.state ?? props.status ?? null,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
serverlessService: 'functions',
|
||||
kind: record.kind,
|
||||
resourceType: record.type,
|
||||
runtime: props.runtime,
|
||||
functionAppName: props.functionAppName,
|
||||
lastModified: props.lastModifiedTimeUtc,
|
||||
triggerType: getTriggerType(props.config),
|
||||
scriptHref: props.scriptHref,
|
||||
invokeUrlTemplate: props.invokeUrlTemplate,
|
||||
config: props.config,
|
||||
files: props.files,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function stringifyPayload(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (value === undefined || value === null) return ''
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function getTriggerType(config?: Record<string, unknown>): string | undefined {
|
||||
const bindings = config?.bindings
|
||||
if (!Array.isArray(bindings)) return undefined
|
||||
|
||||
const trigger = bindings.find((binding) => {
|
||||
if (!binding || typeof binding !== 'object') return false
|
||||
|
||||
const type = (binding as Record<string, unknown>).type
|
||||
return typeof type === 'string' && type.toLowerCase().endsWith('trigger')
|
||||
})
|
||||
|
||||
if (!trigger || typeof trigger !== 'object') return undefined
|
||||
|
||||
const type = (trigger as Record<string, unknown>).type
|
||||
return typeof type === 'string' ? type : undefined
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'
|
||||
import {ConflictError, toHttpError, ValidationError} from '../cloud-spi/errors'
|
||||
import {AzureServiceBusAdapter} from './AzureServiceBusAdapter'
|
||||
|
||||
interface RecordedCall {
|
||||
path: string
|
||||
init: RequestInit
|
||||
options?: AzureRuntimeFetchOptions
|
||||
}
|
||||
|
||||
describe('AzureServiceBusAdapter', () => {
|
||||
test('lists and normalizes Service Bus namespaces', async () => {
|
||||
const client = testClient(async () => new Response(JSON.stringify({
|
||||
namespaces: [{
|
||||
name: 'orders-bus',
|
||||
amqpPort: 5672,
|
||||
amqpsPort: 5671,
|
||||
tlsCertPem: 'certificate',
|
||||
mocked: false,
|
||||
}],
|
||||
})))
|
||||
|
||||
await expect(new AzureServiceBusAdapter(client).list()).resolves.toEqual([{
|
||||
id: 'orders-bus',
|
||||
name: 'orders-bus',
|
||||
cloud: 'azure',
|
||||
service: 'messaging',
|
||||
type: 'servicebus-namespace',
|
||||
region: null,
|
||||
createdAt: null,
|
||||
status: 'Running',
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
messagingService: 'service-bus',
|
||||
amqpPort: 5672,
|
||||
amqpsPort: 5671,
|
||||
mocked: false,
|
||||
tlsEnabled: true,
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
test('normalizes a missing namespace list endpoint to an empty list', async () => {
|
||||
const client = testClient(async (_path, _init, options) => {
|
||||
if (options?.emptyOnNotFound) return null
|
||||
return new Response(null, {status: 404})
|
||||
})
|
||||
|
||||
await expect(new AzureServiceBusAdapter(client).list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('filters namespaces by search term', async () => {
|
||||
const client = testClient(async () => new Response(JSON.stringify({
|
||||
namespaces: [{name: 'orders-bus', mocked: false}, {name: 'billing-bus', mocked: true}],
|
||||
})))
|
||||
|
||||
const resources = await new AzureServiceBusAdapter(client).list({search: 'bill'})
|
||||
|
||||
expect(resources.map((resource) => resource.name)).toEqual(['billing-bus'])
|
||||
expect(resources[0].status).toBe('Mocked')
|
||||
})
|
||||
|
||||
test('gets a namespace by name', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const client = testClient(async (path, init, options) => {
|
||||
calls.push({path, init, options})
|
||||
return new Response(JSON.stringify({name: 'orders-bus', mocked: false}))
|
||||
})
|
||||
|
||||
const resource = await new AzureServiceBusAdapter(client).get('orders-bus')
|
||||
|
||||
expect(calls[0].path).toBe('/devstoreaccount1-servicebus/namespaces/orders-bus')
|
||||
expect(calls[0].init.method).toBe('GET')
|
||||
expect(calls[0].options).toEqual({emptyOnNotFound: true})
|
||||
expect(resource?.id).toBe('orders-bus')
|
||||
})
|
||||
|
||||
test('creates a namespace through the emulator management contract', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const client = testClient(async (path, init, options) => {
|
||||
calls.push({path, init, options})
|
||||
if (init.method === 'GET') return null
|
||||
return new Response(JSON.stringify({name: 'orders-bus', amqpPort: 5672, mocked: false}), {status: 201})
|
||||
})
|
||||
|
||||
const resource = await new AzureServiceBusAdapter(client).create({values: {namespaceName: 'orders-bus'}})
|
||||
|
||||
const put = calls[calls.length - 1]
|
||||
expect(put.path).toBe('/devstoreaccount1-servicebus/namespaces/orders-bus')
|
||||
expect(put.init.method).toBe('PUT')
|
||||
expect(put.init.body).toBe('{}')
|
||||
expect(put.init.headers).toEqual({accept: 'application/json', 'content-type': 'application/json'})
|
||||
expect(resource.id).toBe('orders-bus')
|
||||
})
|
||||
|
||||
test('rejects creating a namespace that already exists', async () => {
|
||||
const methods: (string | undefined)[] = []
|
||||
const client = testClient(async (_path, init) => {
|
||||
methods.push(init.method)
|
||||
return new Response(JSON.stringify({name: 'orders-bus', mocked: false}))
|
||||
})
|
||||
|
||||
const failure = await new AzureServiceBusAdapter(client)
|
||||
.create({values: {namespaceName: 'orders-bus'}})
|
||||
.catch((err: unknown) => err)
|
||||
expect(failure).toBeInstanceOf(ConflictError)
|
||||
expect((failure as ConflictError).message).toBe('Service Bus namespace orders-bus already exists')
|
||||
expect(toHttpError(failure).status).toBe(409)
|
||||
expect(methods).toEqual(['GET'])
|
||||
})
|
||||
|
||||
test('surfaces Service Bus create errors', async () => {
|
||||
const client = testClient(async (path, init) => {
|
||||
if (init.method === 'GET') return null
|
||||
throw new Error(`Azure runtime request failed: HTTP 500 ${path} - namespace store unavailable`)
|
||||
})
|
||||
|
||||
await expect(new AzureServiceBusAdapter(client).create({values: {namespaceName: 'orders-bus'}}))
|
||||
.rejects.toThrow('HTTP 500 /devstoreaccount1-servicebus/namespaces/orders-bus - namespace store unavailable')
|
||||
})
|
||||
|
||||
test('rejects invalid namespace names as 400s', async () => {
|
||||
const adapter = new AzureServiceBusAdapter(testClient(async () => new Response()))
|
||||
|
||||
for (const namespaceName of ['', 'short', 'orders-sb']) {
|
||||
const failure = await adapter.create({values: {namespaceName}}).catch((err: unknown) => err)
|
||||
expect(failure).toBeInstanceOf(ValidationError)
|
||||
expect(toHttpError(failure).status).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
test('deletes a namespace through the emulator management contract', async () => {
|
||||
const calls: RecordedCall[] = []
|
||||
const client = testClient(async (path, init, options) => {
|
||||
calls.push({path, init, options})
|
||||
return new Response(null, {status: 204})
|
||||
})
|
||||
|
||||
await new AzureServiceBusAdapter(client).delete('orders-bus')
|
||||
|
||||
expect(calls).toEqual([{
|
||||
path: '/devstoreaccount1-servicebus/namespaces/orders-bus',
|
||||
init: {method: 'DELETE'},
|
||||
options: undefined,
|
||||
}])
|
||||
})
|
||||
|
||||
test('surfaces a delete of a namespace that does not exist', async () => {
|
||||
const client = testClient(async (path) => {
|
||||
throw new Error(`Azure runtime request failed: HTTP 404 ${path}`)
|
||||
})
|
||||
|
||||
await expect(new AzureServiceBusAdapter(client).delete('missing')).rejects.toThrow('HTTP 404')
|
||||
})
|
||||
})
|
||||
|
||||
function testClient(
|
||||
handler: (path: string, init: RequestInit, options?: AzureRuntimeFetchOptions) => Promise<Response | null>,
|
||||
): AzureRuntimeClient {
|
||||
return {
|
||||
endpoint: 'http://localhost:4577',
|
||||
accountName: 'devstoreaccount1',
|
||||
fetch: handler,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {azure, type AzureRuntimeClient} from '../azure'
|
||||
import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {azureServiceBusSchema} from '../cloud-spi/servicebusSchema'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CloudServiceDescriptorOverride,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
interface ServiceBusNamespaceRecord {
|
||||
name?: string
|
||||
amqpPort?: number
|
||||
amqpsPort?: number
|
||||
tlsCertPem?: string
|
||||
mocked?: boolean
|
||||
}
|
||||
|
||||
interface ServiceBusNamespaceListResponse {
|
||||
namespaces?: ServiceBusNamespaceRecord[]
|
||||
}
|
||||
|
||||
export class AzureServiceBusAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'azure' as const
|
||||
readonly service = 'messaging' as const
|
||||
|
||||
constructor(private readonly client: AzureRuntimeClient = azure) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return azureServiceBusSchema()
|
||||
}
|
||||
|
||||
// The catalog category is the cloud-neutral 'Messaging'; on Azure it is Service Bus.
|
||||
descriptorOverride(): CloudServiceDescriptorOverride {
|
||||
return {displayName: 'Service Bus'}
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.azureJson<ServiceBusNamespaceListResponse>(
|
||||
namespacePath(this.client),
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
|
||||
const resources = (body?.namespaces ?? [])
|
||||
.map(toNamespaceResource)
|
||||
.filter((resource): resource is CloudResource => resource !== null)
|
||||
return filterBySearch(resources, query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const body = await this.azureJson<ServiceBusNamespaceRecord>(
|
||||
namespaceResourcePath(this.client, id),
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
|
||||
return body ? toNamespaceResource(body) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const namespaceName = stringValue(input.values.namespaceName)
|
||||
if (!namespaceName) throw new ValidationError('namespaceName is required')
|
||||
if (!isValidNamespaceName(namespaceName)) {
|
||||
throw new ValidationError('Use a valid Service Bus namespace: 6-50 letters, numbers, or hyphens; start with a letter, end with a letter or number, and do not end with -sb or -mgmt.')
|
||||
}
|
||||
|
||||
// The runtime treats PUT as idempotent and answers 200 for a namespace that already
|
||||
// exists, so a duplicate would otherwise be reported to the user as a fresh create.
|
||||
if (await this.get(namespaceName)) {
|
||||
throw new ConflictError(`Service Bus namespace ${namespaceName} already exists`)
|
||||
}
|
||||
|
||||
const body = await this.azureJson<ServiceBusNamespaceRecord>(
|
||||
namespaceResourcePath(this.client, namespaceName),
|
||||
{method: 'PUT', body: '{}'},
|
||||
)
|
||||
if (!body) throw new RuntimeError('Azure Service Bus create returned an empty response')
|
||||
const resource = toNamespaceResource(body)
|
||||
if (!resource) throw new RuntimeError('Azure Service Bus create returned a namespace without a name')
|
||||
return resource
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(namespaceResourcePath(this.client, id), {method: 'DELETE'})
|
||||
}
|
||||
|
||||
private async azureJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
options?: {emptyOnNotFound?: boolean},
|
||||
): Promise<T | null> {
|
||||
const res = await this.client.fetch(
|
||||
path,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
},
|
||||
options,
|
||||
)
|
||||
|
||||
if (!res || res.status === 204) return null
|
||||
return await res.json() as T
|
||||
}
|
||||
}
|
||||
|
||||
function namespacePath(client: AzureRuntimeClient): string {
|
||||
return `/${encodeURIComponent(client.accountName)}-servicebus/namespaces`
|
||||
}
|
||||
|
||||
function namespaceResourcePath(client: AzureRuntimeClient, namespaceName: string): string {
|
||||
return `${namespacePath(client)}/${encodeURIComponent(namespaceName)}`
|
||||
}
|
||||
|
||||
function toNamespaceResource(record: ServiceBusNamespaceRecord): CloudResource | null {
|
||||
const name = stringValue(record.name)
|
||||
if (!name) return null
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'azure',
|
||||
service: 'messaging',
|
||||
type: 'servicebus-namespace',
|
||||
region: null,
|
||||
createdAt: null,
|
||||
status: record.mocked ? 'Mocked' : 'Running',
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
messagingService: 'service-bus',
|
||||
amqpPort: record.amqpPort,
|
||||
amqpsPort: record.amqpsPort,
|
||||
mocked: record.mocked,
|
||||
tlsEnabled: Boolean(record.tlsCertPem),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
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 isValidNamespaceName(value: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$/.test(value)
|
||||
&& !value.toLowerCase().endsWith('-sb')
|
||||
&& !value.toLowerCase().endsWith('-mgmt')
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'
|
||||
import {toHttpError} from '../cloud-spi/errors'
|
||||
import {AzureSqlAdapter} from './AzureSqlAdapter'
|
||||
import type {SqlDataClient, SqlDataConnection} from './MssqlDataClient'
|
||||
|
||||
const SERVERS_PATH = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers'
|
||||
const API_VERSION = '?api-version=2021-11-01'
|
||||
|
||||
describe('AzureSqlAdapter', () => {
|
||||
test('normalizes Azure SQL servers as cloud database resources', async () => {
|
||||
const adapter = new AzureSqlAdapter(testClient({
|
||||
[`${SERVERS_PATH}${API_VERSION}`]: {
|
||||
value: [{
|
||||
id: `${SERVERS_PATH}/app-sql`,
|
||||
name: 'app-sql',
|
||||
type: 'Microsoft.Sql/servers',
|
||||
location: 'eastus',
|
||||
kind: 'v12.0',
|
||||
tags: {environment: 'test'},
|
||||
properties: {
|
||||
administratorLogin: 'sa',
|
||||
version: '12.0',
|
||||
state: 'Ready',
|
||||
fullyQualifiedDomainName: 'localhost',
|
||||
localPort: 14330,
|
||||
publicNetworkAccess: 'Enabled',
|
||||
},
|
||||
}],
|
||||
},
|
||||
}))
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual([{
|
||||
id: 'sql-server:app-sql',
|
||||
name: 'app-sql',
|
||||
cloud: 'azure',
|
||||
service: 'database',
|
||||
type: 'sql-server',
|
||||
region: 'eastus',
|
||||
createdAt: null,
|
||||
status: 'Ready',
|
||||
engine: 'azure-sql',
|
||||
version: '12.0',
|
||||
instanceClass: 'v12.0',
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
databaseService: 'sql',
|
||||
resourceKind: 'server',
|
||||
armId: `${SERVERS_PATH}/app-sql`,
|
||||
serverName: 'app-sql',
|
||||
administratorLogin: 'sa',
|
||||
fullyQualifiedDomainName: 'localhost',
|
||||
publicNetworkAccess: 'Enabled',
|
||||
minimalTlsVersion: undefined,
|
||||
endpoint: {address: 'localhost', port: 14330},
|
||||
tags: [{key: 'environment', value: 'test'}],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
test('creates an Azure SQL server through the ARM contract', async () => {
|
||||
const calls: Array<{path: string; init: RequestInit}> = []
|
||||
const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}`
|
||||
const adapter = new AzureSqlAdapter(testClient({
|
||||
[serverPath]: {
|
||||
id: `${SERVERS_PATH}/app-sql`,
|
||||
name: 'app-sql',
|
||||
location: 'uksouth',
|
||||
properties: {state: 'Ready', version: '12.0'},
|
||||
},
|
||||
}, calls))
|
||||
|
||||
const resource = await adapter.create({values: {
|
||||
serverName: 'app-sql',
|
||||
location: 'uksouth',
|
||||
administratorLogin: 'sqladmin',
|
||||
administratorLoginPassword: 'StrongPassw0rd!',
|
||||
}})
|
||||
|
||||
expect(resource.id).toBe('sql-server:app-sql')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].path).toBe(serverPath)
|
||||
expect(calls[0].init.method).toBe('PUT')
|
||||
expect(JSON.parse(String(calls[0].init.body))).toEqual({
|
||||
location: 'uksouth',
|
||||
properties: {
|
||||
administratorLogin: 'sqladmin',
|
||||
administratorLoginPassword: 'StrongPassw0rd!',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('gets and deletes a SQL server by its normalized id', async () => {
|
||||
const calls: Array<{path: string; init: RequestInit}> = []
|
||||
const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}`
|
||||
const adapter = new AzureSqlAdapter(testClient({
|
||||
[serverPath]: {name: 'app-sql', location: 'eastus', properties: {state: 'Ready'}},
|
||||
}, calls))
|
||||
|
||||
await expect(adapter.get('sql-server:app-sql')).resolves.toMatchObject({name: 'app-sql', type: 'sql-server'})
|
||||
await adapter.delete('sql-server:app-sql')
|
||||
|
||||
expect(calls.map((call) => [call.init.method, call.path])).toEqual([
|
||||
['GET', serverPath],
|
||||
['DELETE', serverPath],
|
||||
])
|
||||
})
|
||||
|
||||
test('returns null when an Azure SQL server is not found', async () => {
|
||||
const adapter = new AzureSqlAdapter(testClient({}))
|
||||
await expect(adapter.get('sql-server:missing')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('browses databases and tables and runs T-SQL through the server endpoint', async () => {
|
||||
const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}`
|
||||
const dataCalls: Array<{connection: SqlDataConnection; query: string}> = []
|
||||
const dataClient: SqlDataClient = {
|
||||
async query(connection, query) {
|
||||
dataCalls.push({connection, query})
|
||||
if (query.includes('FROM sys.databases')) {
|
||||
return sqlResult([{name: 'master', state: 'ONLINE', createdAt: '2026-01-01T00:00:00.000Z', isSystem: true}])
|
||||
}
|
||||
if (query.includes('FROM sys.tables')) {
|
||||
return sqlResult([{schemaName: 'dbo', name: 'orders', objectType: 'table', rowCount: '42'}])
|
||||
}
|
||||
return sqlResult([{orderId: 1042}], [1])
|
||||
},
|
||||
}
|
||||
const adapter = new AzureSqlAdapter(testClient({
|
||||
[serverPath]: {
|
||||
name: 'app-sql',
|
||||
properties: {
|
||||
fullyQualifiedDomainName: 'app-sql.local',
|
||||
localPort: 1433,
|
||||
minimalTlsVersion: 'None',
|
||||
},
|
||||
},
|
||||
}), dataClient)
|
||||
const credentials = {username: 'sa', password: 'LocalDev!2026'}
|
||||
|
||||
await expect(adapter.listSqlDatabases('sql-server:app-sql', credentials)).resolves.toEqual([{
|
||||
name: 'master',
|
||||
state: 'ONLINE',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
isSystem: true,
|
||||
}])
|
||||
await expect(adapter.listSqlTables('sql-server:app-sql', {...credentials, database: 'ordersdb'})).resolves.toEqual([{
|
||||
schema: 'dbo',
|
||||
name: 'orders',
|
||||
type: 'table',
|
||||
rowCount: 42,
|
||||
}])
|
||||
await expect(adapter.querySql('sql-server:app-sql', {...credentials, database: 'ordersdb'}, 'SELECT * FROM dbo.orders')).resolves.toEqual(
|
||||
sqlResult([{orderId: 1042}], [1]),
|
||||
)
|
||||
|
||||
expect(dataCalls).toHaveLength(3)
|
||||
expect(dataCalls[0].connection).toEqual({
|
||||
server: 'app-sql.local',
|
||||
port: 1433,
|
||||
database: 'master',
|
||||
username: 'sa',
|
||||
password: 'LocalDev!2026',
|
||||
tlsMode: 'trust-server-certificate',
|
||||
})
|
||||
expect(dataCalls[1].connection.database).toBe('ordersdb')
|
||||
expect(dataCalls[2].query).toBe('SELECT * FROM dbo.orders')
|
||||
})
|
||||
|
||||
test('rejects a blank query before looking the server up', async () => {
|
||||
const calls: Array<{path: string; init: RequestInit}> = []
|
||||
const adapter = new AzureSqlAdapter(testClient({}, calls))
|
||||
|
||||
await expect(adapter.querySql('sql-server:missing', {username: 'sa', password: 'LocalDev!2026'}, ' '))
|
||||
.rejects.toThrow('SQL query is required')
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
test('reports bad input as 400 and a missing server as 404', async () => {
|
||||
const existing = new AzureSqlAdapter(testClient({
|
||||
[`${SERVERS_PATH}/app-sql${API_VERSION}`]: {
|
||||
name: 'app-sql',
|
||||
properties: {fullyQualifiedDomainName: 'app-sql.local', localPort: 1433},
|
||||
},
|
||||
}))
|
||||
const missing = new AzureSqlAdapter(testClient({}))
|
||||
|
||||
expect(await httpStatusOf(() => existing.create({values: {}}))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.create({values: {serverName: 'App_SQL'}}))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.create({values: {serverName: 'app-sql'}}))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.querySql('sql-server:app-sql', {username: 'sa', password: 'x'}, ''))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.querySql('sql-server:app-sql', {username: '', password: ''}, 'SELECT 1'))).toBe(400)
|
||||
expect(await httpStatusOf(() => existing.get(''))).toBe(400)
|
||||
|
||||
expect(await httpStatusOf(() => missing.querySql('sql-server:gone', {username: 'sa', password: 'x'}, 'SELECT 1'))).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
async function httpStatusOf(operation: () => Promise<unknown>): Promise<number> {
|
||||
const failure = await operation().then(() => null, (err: unknown) => err)
|
||||
expect(failure).not.toBeNull()
|
||||
return toHttpError(failure).status
|
||||
}
|
||||
|
||||
function sqlResult(rows: Array<Record<string, unknown>>, rowsAffected: number[] = []) {
|
||||
return {
|
||||
resultSets: [{
|
||||
columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'nvarchar'})),
|
||||
rows,
|
||||
truncated: false,
|
||||
}],
|
||||
rowsAffected,
|
||||
durationMs: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function testClient(responses: Record<string, unknown>, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient {
|
||||
return {
|
||||
endpoint: 'http://localhost:4577',
|
||||
accountName: 'devstoreaccount1',
|
||||
async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) {
|
||||
calls.push({path, init})
|
||||
if (!(path in responses)) {
|
||||
if (options.emptyOnNotFound) return null
|
||||
throw new Error(`Unexpected Azure runtime request: ${path}`)
|
||||
}
|
||||
return new Response(JSON.stringify(responses[path]), {status: 200, headers: {'content-type': 'application/json'}})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
azure,
|
||||
azureResourceGroup,
|
||||
type AzureRuntimeClient,
|
||||
azureSubscriptionId,
|
||||
} from '../azure'
|
||||
import {azureDatabaseSchema} from '../cloud-spi/databaseSchema'
|
||||
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
SqlConnectionInput,
|
||||
SqlDatabase,
|
||||
SqlQueryResult,
|
||||
SqlTable,
|
||||
} from '../cloud-spi/types'
|
||||
import {MssqlDataClient, type SqlDataClient, type SqlDataConnection} from './MssqlDataClient'
|
||||
import {azureSqlTlsMode} from './sqlTransport'
|
||||
import {
|
||||
filterBySearch,
|
||||
isValidServerName,
|
||||
numberValue,
|
||||
rawStringValue,
|
||||
recordValue,
|
||||
stringValue,
|
||||
tagList,
|
||||
withApiVersion,
|
||||
} from './sqlAdapterUtils'
|
||||
|
||||
const API_VERSION = '2021-11-01'
|
||||
const RESOURCE_ID_PREFIX = 'sql-server:'
|
||||
const LIST_DATABASES_QUERY = `
|
||||
SELECT
|
||||
name,
|
||||
state_desc AS state,
|
||||
create_date AS createdAt,
|
||||
CAST(CASE WHEN database_id <= 4 THEN 1 ELSE 0 END AS bit) AS isSystem
|
||||
FROM sys.databases
|
||||
ORDER BY database_id`
|
||||
const LIST_TABLES_QUERY = `
|
||||
SELECT
|
||||
schemas.name AS [schemaName],
|
||||
tables.name,
|
||||
'table' AS [objectType],
|
||||
SUM(partitions.rows) AS [rowCount]
|
||||
FROM sys.tables AS tables
|
||||
INNER JOIN sys.schemas AS schemas ON schemas.schema_id = tables.schema_id
|
||||
LEFT JOIN sys.partitions AS partitions
|
||||
ON partitions.object_id = tables.object_id AND partitions.index_id IN (0, 1)
|
||||
GROUP BY schemas.name, tables.name
|
||||
UNION ALL
|
||||
SELECT schemas.name, views.name, 'view', NULL
|
||||
FROM sys.views AS views
|
||||
INNER JOIN sys.schemas AS schemas ON schemas.schema_id = views.schema_id
|
||||
ORDER BY [schemaName], name`
|
||||
|
||||
interface AzureSqlListResponse {
|
||||
value?: AzureSqlRecord[]
|
||||
}
|
||||
|
||||
interface AzureSqlRecord {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
location?: string
|
||||
kind?: string
|
||||
tags?: Record<string, string>
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export class AzureSqlAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'azure' as const
|
||||
readonly service = 'database' as const
|
||||
|
||||
constructor(
|
||||
private readonly client: AzureRuntimeClient = azure,
|
||||
private readonly dataClient: SqlDataClient = new MssqlDataClient(),
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return azureDatabaseSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.sqlJson<AzureSqlListResponse>(sqlServersPath(), {method: 'GET'}, true)
|
||||
return filterBySearch((body?.value ?? []).map(toSqlServerResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const serverName = sqlServerName(id)
|
||||
const body = await this.sqlJson<AzureSqlRecord>(sqlServerPath(serverName), {method: 'GET'}, true)
|
||||
return body ? toSqlServerResource(body) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const serverName = stringValue(input.values.serverName)
|
||||
const location = stringValue(input.values.location) || 'eastus'
|
||||
const administratorLogin = stringValue(input.values.administratorLogin)
|
||||
const administratorLoginPassword = stringValue(input.values.administratorLoginPassword)
|
||||
|
||||
if (!serverName) throw new ValidationError('serverName is required')
|
||||
if (!isValidServerName(serverName)) throw new ValidationError('Use a valid Azure SQL server name.')
|
||||
if (!administratorLogin) throw new ValidationError('administratorLogin is required')
|
||||
if (!administratorLoginPassword) throw new ValidationError('administratorLoginPassword is required')
|
||||
|
||||
const body = await this.sqlJson<AzureSqlRecord>(sqlServerPath(serverName), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
location,
|
||||
properties: {administratorLogin, administratorLoginPassword},
|
||||
}),
|
||||
})
|
||||
if (!body) throw new RuntimeError('Azure SQL server creation returned an empty response')
|
||||
return toSqlServerResource(body)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.sqlFetch(sqlServerPath(sqlServerName(id)), {method: 'DELETE'})
|
||||
}
|
||||
|
||||
async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
|
||||
const result = await this.querySql(serverId, {...connection, database: 'master'}, LIST_DATABASES_QUERY)
|
||||
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
|
||||
const name = stringValue(row.name)
|
||||
if (!name) return []
|
||||
return [{
|
||||
name,
|
||||
state: stringValue(row.state) || 'UNKNOWN',
|
||||
createdAt: stringValue(row.createdAt) || null,
|
||||
isSystem: row.isSystem === true || row.isSystem === 1,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]> {
|
||||
const result = await this.querySql(serverId, connection, LIST_TABLES_QUERY)
|
||||
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
|
||||
const schema = stringValue(row.schemaName)
|
||||
const name = stringValue(row.name)
|
||||
if (!schema || !name) return []
|
||||
return [{
|
||||
schema,
|
||||
name,
|
||||
type: row.objectType === 'view' ? 'view' as const : 'table' as const,
|
||||
rowCount: integerValue(row.rowCount),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult> {
|
||||
if (!query.trim()) throw new ValidationError('SQL query is required')
|
||||
return this.dataClient.query(await this.dataConnection(serverId, connection), query)
|
||||
}
|
||||
|
||||
private sqlFetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise<Response | null> {
|
||||
return this.client.fetch(withApiVersion(path, API_VERSION), {
|
||||
...init,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
}, {emptyOnNotFound})
|
||||
}
|
||||
|
||||
private async sqlJson<T>(path: string, init: RequestInit, emptyOnNotFound = false): Promise<T | null> {
|
||||
const response = await this.sqlFetch(path, init, emptyOnNotFound)
|
||||
if (!response || response.status === 204) return null
|
||||
return await response.json() as T
|
||||
}
|
||||
|
||||
private async dataConnection(serverId: string, input: SqlConnectionInput): Promise<SqlDataConnection> {
|
||||
const resource = await this.get(serverId)
|
||||
if (!resource) throw new NotFoundError('Azure SQL server not found')
|
||||
|
||||
const endpoint = recordValue(resource.metadata.endpoint)
|
||||
const server = stringValue(endpoint?.address)
|
||||
const port = numberValue(endpoint?.port)
|
||||
const username = rawStringValue(input.username)
|
||||
const password = rawStringValue(input.password)
|
||||
const database = stringValue(input.database) || 'master'
|
||||
const minimalTlsVersion = stringValue(resource.metadata.minimalTlsVersion) || null
|
||||
const hasLocalPort = endpoint?.port !== undefined && endpoint.port !== null
|
||||
|
||||
// The endpoint comes from the resource the runtime returned, so a missing
|
||||
// address is a runtime problem rather than something the caller sent.
|
||||
if (!server || port === null) throw new RuntimeError('Azure SQL server endpoint is not available')
|
||||
if (!username) throw new ValidationError('SQL username is required')
|
||||
if (!password) throw new ValidationError('SQL password is required')
|
||||
|
||||
return {
|
||||
server,
|
||||
port,
|
||||
database,
|
||||
username,
|
||||
password,
|
||||
tlsMode: azureSqlTlsMode(server, minimalTlsVersion, hasLocalPort),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isSqlServerResourceId(id: string): boolean {
|
||||
return id.startsWith(RESOURCE_ID_PREFIX)
|
||||
}
|
||||
|
||||
function sqlServersPath(): string {
|
||||
return `/subscriptions/${encodeURIComponent(azureSubscriptionId())}/resourceGroups/${encodeURIComponent(azureResourceGroup())}/providers/Microsoft.Sql/servers`
|
||||
}
|
||||
|
||||
function sqlServerPath(serverName: string): string {
|
||||
return `${sqlServersPath()}/${encodeURIComponent(serverName)}`
|
||||
}
|
||||
|
||||
function sqlServerName(id: string): string {
|
||||
const name = isSqlServerResourceId(id) ? id.slice(RESOURCE_ID_PREFIX.length) : id
|
||||
if (!name) throw new ValidationError('Azure SQL server id is required')
|
||||
return name
|
||||
}
|
||||
|
||||
function toSqlServerResource(server: AzureSqlRecord): CloudResource {
|
||||
const name = stringValue(server.name)
|
||||
const properties = server.properties ?? {}
|
||||
return {
|
||||
id: `${RESOURCE_ID_PREFIX}${name}`,
|
||||
name,
|
||||
cloud: 'azure',
|
||||
service: 'database',
|
||||
type: 'sql-server',
|
||||
region: stringValue(server.location) || null,
|
||||
createdAt: null,
|
||||
status: stringValue(properties.state) || null,
|
||||
engine: 'azure-sql',
|
||||
version: stringValue(properties.version) || null,
|
||||
instanceClass: stringValue(server.kind) || null,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
databaseService: 'sql',
|
||||
resourceKind: 'server',
|
||||
armId: server.id,
|
||||
serverName: name,
|
||||
administratorLogin: properties.administratorLogin,
|
||||
fullyQualifiedDomainName: properties.fullyQualifiedDomainName,
|
||||
publicNetworkAccess: properties.publicNetworkAccess,
|
||||
minimalTlsVersion: properties.minimalTlsVersion,
|
||||
endpoint: {
|
||||
address: properties.fullyQualifiedDomainName,
|
||||
port: numberValue(properties.localPort),
|
||||
},
|
||||
tags: tagList(server.tags),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function integerValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null
|
||||
const parsed = Number(value)
|
||||
return Number.isSafeInteger(parsed) ? parsed : null
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {AzureStorageAdapter} from './AzureStorageAdapter'
|
||||
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('AzureStorageAdapter', () => {
|
||||
test('normalizes missing container list endpoint to an empty list', async () => {
|
||||
globalThis.fetch = (async () => new Response('Not Found', {status: 404})) as unknown as typeof fetch
|
||||
|
||||
const adapter = new AzureStorageAdapter(testClient())
|
||||
await expect(adapter.list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('normalizes missing blob list endpoint to an empty list', async () => {
|
||||
globalThis.fetch = (async () => new Response('Not Found', {status: 404})) as unknown as typeof fetch
|
||||
|
||||
const adapter = new AzureStorageAdapter(testClient())
|
||||
await expect(adapter.listObjects('container')).resolves.toEqual({prefix: '', objects: []})
|
||||
})
|
||||
|
||||
test('creates folder placeholders with an internal marker blob', async () => {
|
||||
const paths: string[] = []
|
||||
globalThis.fetch = (async (path: RequestInfo | URL) => {
|
||||
paths.push(String(path))
|
||||
return new Response('', {status: 201})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const adapter = new AzureStorageAdapter(testClient())
|
||||
await adapter.putObject('container', 'photos/', new Uint8Array(), 'application/x-directory')
|
||||
|
||||
expect(paths).toEqual(['/devstoreaccount1/container/photos/.floci-folder'])
|
||||
})
|
||||
|
||||
test('renders internal marker blobs as folders', async () => {
|
||||
globalThis.fetch = (async () => new Response(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<EnumerationResults>
|
||||
<Blobs>
|
||||
<Blob>
|
||||
<Name>test/.floci-folder</Name>
|
||||
<Properties>
|
||||
<Last-Modified>Wed, 20 May 2026 04:16:27 GMT</Last-Modified>
|
||||
<Content-Length>0</Content-Length>
|
||||
</Properties>
|
||||
</Blob>
|
||||
<Blob>
|
||||
<Name>background.png</Name>
|
||||
<Properties>
|
||||
<Last-Modified>Wed, 20 May 2026 04:18:00 GMT</Last-Modified>
|
||||
<Content-Length>120</Content-Length>
|
||||
</Properties>
|
||||
</Blob>
|
||||
</Blobs>
|
||||
</EnumerationResults>
|
||||
`, {status: 200})) as unknown as typeof fetch
|
||||
|
||||
const adapter = new AzureStorageAdapter(testClient())
|
||||
await expect(adapter.listObjects('container')).resolves.toEqual({
|
||||
prefix: '',
|
||||
objects: [
|
||||
{
|
||||
key: 'test/',
|
||||
name: 'test',
|
||||
type: 'folder',
|
||||
size: null,
|
||||
lastModified: 'Wed, 20 May 2026 04:16:27 GMT',
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
storageService: 'blob',
|
||||
prefix: 'test/',
|
||||
marker: '.floci-folder',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'background.png',
|
||||
name: 'background.png',
|
||||
type: 'object',
|
||||
size: 120,
|
||||
lastModified: 'Wed, 20 May 2026 04:18:00 GMT',
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
storageService: 'blob',
|
||||
accessTier: undefined,
|
||||
blobType: undefined,
|
||||
contentType: undefined,
|
||||
etag: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function testClient(): AzureRuntimeClient {
|
||||
return {
|
||||
endpoint: 'http://localhost:4577',
|
||||
accountName: 'devstoreaccount1',
|
||||
async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) {
|
||||
const res = await globalThis.fetch(path, init)
|
||||
if (options.emptyOnNotFound && res.status === 404) return null
|
||||
if (!res.ok) throw new Error(`Azure Blob request failed: HTTP ${res.status}`)
|
||||
return res
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import {azureStorageSchema} from '../cloud-spi/storageSchema'
|
||||
import {azure, type AzureRuntimeClient} from '../azure'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
StorageObject,
|
||||
StorageObjectDownload,
|
||||
StorageObjectList,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
const FOLDER_MARKER = '.floci-folder'
|
||||
|
||||
export class AzureStorageAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'azure' as const
|
||||
readonly service = 'storage' as const
|
||||
|
||||
constructor(private readonly client: AzureRuntimeClient = azure) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return azureStorageSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const res = await this.client.fetch(`${accountPath(this.client)}?comp=list`, {method: 'GET'}, {emptyOnNotFound: true})
|
||||
if (!res) return []
|
||||
|
||||
const xml = await res.text()
|
||||
return filterBySearch(parseContainers(xml), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const resources = await this.list()
|
||||
return resources.find((resource) => resource.id === id) ?? null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const containerName = stringValue(input.values.containerName)
|
||||
if (!containerName) throw new ValidationError('containerName is required')
|
||||
if (!isValidContainerName(containerName)) {
|
||||
throw new ValidationError('Use a valid Azure container name: 3-63 lowercase letters, numbers, or single hyphens.')
|
||||
}
|
||||
|
||||
await this.client.fetch(`${containerPath(this.client, containerName)}?restype=container`, {method: 'PUT'})
|
||||
return normalizeContainer(containerName, null)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(`${containerPath(this.client, id)}?restype=container`, {method: 'DELETE'})
|
||||
}
|
||||
|
||||
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
|
||||
const qs = new URLSearchParams({restype: 'container', comp: 'list', delimiter: '/'})
|
||||
if (prefix) qs.set('prefix', prefix)
|
||||
const res = await this.client.fetch(`${containerPath(this.client, resourceId)}?${qs}`, {method: 'GET'}, {emptyOnNotFound: true})
|
||||
if (!res) return {prefix, objects: []}
|
||||
|
||||
const xml = await res.text()
|
||||
return {prefix, objects: parseBlobs(xml, prefix)}
|
||||
}
|
||||
|
||||
async putObject(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise<void> {
|
||||
const blobKey = azureBlobKey(key, contentType)
|
||||
await this.client.fetch(`${containerPath(this.client, resourceId)}/${encodePath(blobKey)}`, {
|
||||
method: 'PUT',
|
||||
body: copyBytes(body),
|
||||
headers: {
|
||||
'content-type': contentType,
|
||||
'x-ms-blob-type': 'BlockBlob',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async getObject(resourceId: string, key: string): Promise<StorageObjectDownload> {
|
||||
const res = await this.client.fetch(`${containerPath(this.client, resourceId)}/${encodePath(key)}`, {method: 'GET'})
|
||||
if (!res) throw new NotFoundError('Azure blob not found')
|
||||
return {
|
||||
body: await res.arrayBuffer(),
|
||||
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
|
||||
contentLength: numberHeader(res.headers.get('content-length')),
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObject(resourceId: string, key: string): Promise<void> {
|
||||
await this.client.fetch(`${containerPath(this.client, resourceId)}/${encodePath(key)}`, {method: 'DELETE'})
|
||||
}
|
||||
|
||||
async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise<void> {
|
||||
const destContainer = destResourceId ?? srcResourceId
|
||||
const srcUrl = `${this.client.endpoint}${containerPath(this.client, srcResourceId)}/${encodePath(srcKey)}`
|
||||
await this.client.fetch(`${containerPath(this.client, destContainer)}/${encodePath(destKey)}`, {
|
||||
method: 'PUT',
|
||||
headers: {'x-ms-copy-source': srcUrl},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function accountPath(client: AzureRuntimeClient): string {
|
||||
return `/${encodeURIComponent(client.accountName)}`
|
||||
}
|
||||
|
||||
function containerPath(client: AzureRuntimeClient, containerName: string): string {
|
||||
return `${accountPath(client)}/${encodeURIComponent(containerName)}`
|
||||
}
|
||||
|
||||
function parseContainers(xml: string): CloudResource[] {
|
||||
const matches = xml.matchAll(/<Container>\s*<Name>([^<]+)<\/Name>[\s\S]*?(?:<Last-Modified>([^<]+)<\/Last-Modified>)?[\s\S]*?<\/Container>/g)
|
||||
return [...matches].map((match) => normalizeContainer(decodeXml(match[1]), match[2] ? decodeXml(match[2]) : null))
|
||||
}
|
||||
|
||||
function parseBlobs(xml: string, prefix: string) {
|
||||
const folderKeys = new Set<string>()
|
||||
const prefixes: StorageObject[] = [...xml.matchAll(/<BlobPrefix>\s*<Name>([^<]+)<\/Name>\s*<\/BlobPrefix>/g)].map((match) => {
|
||||
const key = decodeXml(match[1])
|
||||
folderKeys.add(key)
|
||||
return {
|
||||
key,
|
||||
name: objectName(key, prefix),
|
||||
type: 'folder' as const,
|
||||
size: null,
|
||||
lastModified: null,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
storageService: 'blob',
|
||||
prefix: key,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const blobs: StorageObject[] = []
|
||||
for (const match of xml.matchAll(/<Blob>\s*<Name>([^<]+)<\/Name>[\s\S]*?<\/Blob>/g)) {
|
||||
const key = decodeXml(match[1])
|
||||
const blobXml = match[0]
|
||||
const lastModified = xmlValue(blobXml, 'Last-Modified') ?? null
|
||||
const contentLength = numberHeader(xmlValue(blobXml, 'Content-Length') ?? null)
|
||||
const markerFolderKey = markerFolderPrefix(key)
|
||||
if (markerFolderKey) {
|
||||
if (!folderKeys.has(markerFolderKey)) {
|
||||
folderKeys.add(markerFolderKey)
|
||||
prefixes.push({
|
||||
key: markerFolderKey,
|
||||
name: objectName(markerFolderKey, prefix),
|
||||
type: 'folder' as const,
|
||||
size: null,
|
||||
lastModified,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
storageService: 'blob',
|
||||
prefix: markerFolderKey,
|
||||
marker: FOLDER_MARKER,
|
||||
},
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
blobs.push({
|
||||
key,
|
||||
name: objectName(key, prefix),
|
||||
type: 'object' as const,
|
||||
size: contentLength,
|
||||
lastModified,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
storageService: 'blob',
|
||||
etag: xmlValue(blobXml, 'Etag') ?? xmlValue(blobXml, 'ETag'),
|
||||
contentType: xmlValue(blobXml, 'Content-Type'),
|
||||
blobType: xmlValue(blobXml, 'BlobType'),
|
||||
accessTier: xmlValue(blobXml, 'AccessTier'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return [...prefixes, ...blobs]
|
||||
}
|
||||
|
||||
function normalizeContainer(name: string, createdAt: string | null): CloudResource {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'azure',
|
||||
service: 'storage',
|
||||
type: 'container',
|
||||
region: null,
|
||||
createdAt,
|
||||
metadata: {
|
||||
provider: 'azure',
|
||||
storageService: 'blob',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
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 decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
function xmlValue(xml: string, tagName: string): string | undefined {
|
||||
const match = xml.match(new RegExp(`<${escapeRegExp(tagName)}>([^<]+)<\\/${escapeRegExp(tagName)}>`, 'i'))
|
||||
return match?.[1] ? decodeXml(match[1]) : undefined
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function objectName(key: string, prefix: string): string {
|
||||
const relative = key.startsWith(prefix) ? key.slice(prefix.length) : key
|
||||
return relative.replace(/\/$/, '') || key
|
||||
}
|
||||
|
||||
function encodePath(key: string): string {
|
||||
return key.split('/').map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
function numberHeader(value: string | null): number | null {
|
||||
if (!value) return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function copyBytes(bytes: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(bytes.byteLength)
|
||||
copy.set(bytes)
|
||||
return copy.buffer
|
||||
}
|
||||
|
||||
function azureBlobKey(key: string, contentType: string): string {
|
||||
if (key.endsWith('/') && contentType === 'application/x-directory') return `${key}${FOLDER_MARKER}`
|
||||
return key
|
||||
}
|
||||
|
||||
function markerFolderPrefix(key: string): string | null {
|
||||
if (!key.endsWith(`/${FOLDER_MARKER}`)) return null
|
||||
return key.slice(0, -FOLDER_MARKER.length)
|
||||
}
|
||||
|
||||
function isValidContainerName(value: string): boolean {
|
||||
return /^[a-z0-9](?:[a-z0-9]|-(?!-)){1,61}[a-z0-9]$/.test(value)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {CloudError, type CloudErrorStatus} from '../cloud-spi/errors'
|
||||
|
||||
/**
|
||||
* Raised when none of the known Cosmos NoSQL route shapes answered on the
|
||||
* Floci-AZ runtime. Keeps its own wire code so the frontend can keep telling
|
||||
* "Cosmos is not enabled on this runtime" apart from a generic runtime failure.
|
||||
*/
|
||||
export class CosmosNoSqlUnavailableError extends CloudError {
|
||||
readonly status: CloudErrorStatus = 502
|
||||
readonly code = 'cosmos_nosql_unavailable' as const
|
||||
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Floci-AZ runtime'
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import sql from 'mssql'
|
||||
import type {config as MssqlConfig, IRecordSet} from 'mssql'
|
||||
import type {SqlColumn, SqlQueryResult, SqlResultSet} from '../cloud-spi/types'
|
||||
import {mapSqlDataError, type SqlDataPhase} from './sqlDataErrors'
|
||||
import type {SqlTlsMode} from './sqlTransport'
|
||||
import {normalizeSqlRow} from './sqlValues'
|
||||
|
||||
const MAX_ROWS_PER_RESULT_SET = 500
|
||||
|
||||
export interface SqlDataConnection {
|
||||
server: string
|
||||
port: number
|
||||
database: string
|
||||
username: string
|
||||
password: string
|
||||
tlsMode: SqlTlsMode
|
||||
}
|
||||
|
||||
export interface SqlDataClient {
|
||||
query(connection: SqlDataConnection, query: string): Promise<SqlQueryResult>
|
||||
}
|
||||
|
||||
export class MssqlDataClient implements SqlDataClient {
|
||||
async query(connection: SqlDataConnection, query: string): Promise<SqlQueryResult> {
|
||||
const pool = new sql.ConnectionPool(connectionConfig(connection))
|
||||
const startedAt = performance.now()
|
||||
let phase: SqlDataPhase = 'connect'
|
||||
|
||||
try {
|
||||
await pool.connect()
|
||||
phase = 'query'
|
||||
const result = await pool.request().query<Record<string, unknown>>(query)
|
||||
phase = 'result'
|
||||
return {
|
||||
resultSets: result.recordsets.map(toResultSet),
|
||||
rowsAffected: result.rowsAffected,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}
|
||||
} catch (error) {
|
||||
throw mapSqlDataError('Azure SQL', phase, error)
|
||||
} finally {
|
||||
await pool.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectionConfig(connection: SqlDataConnection): MssqlConfig {
|
||||
return {
|
||||
user: connection.username,
|
||||
password: connection.password,
|
||||
server: connection.server,
|
||||
port: connection.port,
|
||||
database: connection.database,
|
||||
connectionTimeout: 10_000,
|
||||
requestTimeout: 30_000,
|
||||
pool: {min: 0, max: 1, idleTimeoutMillis: 5_000},
|
||||
options: {
|
||||
encrypt: connection.tlsMode !== 'disable',
|
||||
trustServerCertificate: connection.tlsMode !== 'verify',
|
||||
appName: 'Floci UI',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toResultSet(recordset: IRecordSet<Record<string, unknown>>): SqlResultSet {
|
||||
return {
|
||||
columns: Object.values(recordset.columns)
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((column): SqlColumn => ({
|
||||
name: column.name,
|
||||
type: sqlTypeName(column.type),
|
||||
})),
|
||||
rows: recordset.slice(0, MAX_ROWS_PER_RESULT_SET).map(normalizeSqlRow),
|
||||
truncated: recordset.length > MAX_ROWS_PER_RESULT_SET,
|
||||
}
|
||||
}
|
||||
|
||||
function sqlTypeName(type: unknown): string {
|
||||
if (type && (typeof type === 'object' || typeof type === 'function') && 'declaration' in type) {
|
||||
const declaration = (type as {declaration?: unknown}).declaration
|
||||
if (typeof declaration === 'string') return declaration
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {Client, types} from 'pg'
|
||||
import type {QueryResult} from 'pg'
|
||||
import type {SqlColumn, SqlQueryResult, SqlResultSet} from '../cloud-spi/types'
|
||||
import type {SqlDataConnection, SqlDataClient} from './MssqlDataClient'
|
||||
import {mapSqlDataError, type SqlDataPhase} from './sqlDataErrors'
|
||||
import {normalizeSqlRow} from './sqlValues'
|
||||
|
||||
const MAX_ROWS_PER_RESULT_SET = 500
|
||||
const TYPE_NAMES = new Map(
|
||||
Object.entries(types.builtins)
|
||||
.filter((entry): entry is [string, number] => typeof entry[1] === 'number')
|
||||
.map(([name, oid]) => [oid, name.toLowerCase()]),
|
||||
)
|
||||
|
||||
export class PostgresDataClient implements SqlDataClient {
|
||||
async query(connection: SqlDataConnection, query: string): Promise<SqlQueryResult> {
|
||||
const client = new Client({
|
||||
host: connection.server,
|
||||
port: connection.port,
|
||||
database: connection.database,
|
||||
user: connection.username,
|
||||
password: connection.password,
|
||||
ssl: connection.tlsMode === 'disable'
|
||||
? false
|
||||
: {rejectUnauthorized: connection.tlsMode === 'verify'},
|
||||
connectionTimeoutMillis: 10_000,
|
||||
query_timeout: 30_000,
|
||||
statement_timeout: 30_000,
|
||||
application_name: 'Floci UI',
|
||||
})
|
||||
const startedAt = performance.now()
|
||||
let phase: SqlDataPhase = 'connect'
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
phase = 'query'
|
||||
const rawResult = await client.query<Record<string, unknown>>(query)
|
||||
phase = 'result'
|
||||
const results = (Array.isArray(rawResult) ? rawResult : [rawResult]) as QueryResult<Record<string, unknown>>[]
|
||||
return {
|
||||
resultSets: results.filter((result) => result.fields.length > 0).map(toResultSet),
|
||||
rowsAffected: results.map((result) => result.rowCount ?? 0),
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}
|
||||
} catch (error) {
|
||||
throw mapSqlDataError('PostgreSQL', phase, error)
|
||||
} finally {
|
||||
await client.end().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toResultSet(result: QueryResult<Record<string, unknown>>): SqlResultSet {
|
||||
return {
|
||||
columns: result.fields.map((field): SqlColumn => ({
|
||||
name: field.name,
|
||||
type: TYPE_NAMES.get(field.dataTypeID) ?? `oid:${field.dataTypeID}`,
|
||||
})),
|
||||
rows: result.rows.slice(0, MAX_ROWS_PER_RESULT_SET).map(normalizeSqlRow),
|
||||
truncated: result.rows.length > MAX_ROWS_PER_RESULT_SET,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {CloudResource} from '../cloud-spi/types'
|
||||
|
||||
export function withApiVersion(path: string, apiVersion: string): string {
|
||||
const separator = path.includes('?') ? '&' : '?'
|
||||
return `${path}${separator}api-version=${apiVersion}`
|
||||
}
|
||||
|
||||
export 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))
|
||||
}
|
||||
|
||||
export function tagList(tags?: Record<string, string>): Array<{key: string; value: string}> {
|
||||
return Object.entries(tags ?? {}).map(([key, value]) => ({key, value}))
|
||||
}
|
||||
|
||||
export function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export function rawStringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
export function numberValue(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function isValidServerName(value: string): boolean {
|
||||
return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {toHttpError} from '../cloud-spi/errors'
|
||||
import {mapSqlDataError} from './sqlDataErrors'
|
||||
|
||||
describe('mapSqlDataError', () => {
|
||||
test('maps connection failures to runtime unavailable', () => {
|
||||
expect(toHttpError(mapSqlDataError('Azure SQL', 'connect', new Error('login failed')))).toMatchObject({
|
||||
status: 503,
|
||||
body: {code: 'runtime_unavailable'},
|
||||
})
|
||||
})
|
||||
|
||||
test('maps query errors to invalid requests', () => {
|
||||
expect(toHttpError(mapSqlDataError('PostgreSQL', 'query', new Error('syntax error')))).toMatchObject({
|
||||
status: 400,
|
||||
body: {code: 'invalid_request'},
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps transport failures unavailable during queries', () => {
|
||||
const error = Object.assign(new Error('connection reset'), {code: 'ECONNRESET'})
|
||||
|
||||
expect(toHttpError(mapSqlDataError('PostgreSQL', 'query', error))).toMatchObject({
|
||||
status: 503,
|
||||
body: {code: 'runtime_unavailable'},
|
||||
})
|
||||
})
|
||||
|
||||
test('maps result-processing failures to runtime errors', () => {
|
||||
expect(toHttpError(mapSqlDataError('Azure SQL', 'result', new Error('invalid result')))).toMatchObject({
|
||||
status: 502,
|
||||
body: {code: 'runtime_error'},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type CloudError,
|
||||
isUnreachableCause,
|
||||
RuntimeError,
|
||||
RuntimeUnavailableError,
|
||||
ValidationError,
|
||||
} from '../cloud-spi/errors'
|
||||
|
||||
export type SqlDataPhase = 'connect' | 'query' | 'result'
|
||||
|
||||
export function mapSqlDataError(service: string, phase: SqlDataPhase, error: unknown): CloudError {
|
||||
const detail = error instanceof Error ? error.message : `Unknown ${service} error`
|
||||
const message = `${service} data request failed: ${detail}`
|
||||
const options = {cause: error}
|
||||
|
||||
if (phase === 'connect' || isUnreachableCause(error)) {
|
||||
return new RuntimeUnavailableError(message, options)
|
||||
}
|
||||
if (phase === 'query' && error instanceof Error) {
|
||||
return new ValidationError(message, options)
|
||||
}
|
||||
return new RuntimeError(message, options)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {azureSqlTlsMode, isLoopbackSqlHost, postgresTlsMode} from './sqlTransport'
|
||||
|
||||
describe('isLoopbackSqlHost', () => {
|
||||
test('treats loopback addresses as local', () => {
|
||||
for (const host of ['localhost', 'LOCALHOST', ' 127.0.0.1 ', '127.1.2.3', '::1', '[::1]']) {
|
||||
expect(isLoopbackSqlHost(host)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('treats every other host as remote so TLS stays enforced', () => {
|
||||
for (const host of [
|
||||
'db.example.com',
|
||||
'10.0.0.5',
|
||||
'192.168.1.10',
|
||||
'0.0.0.0',
|
||||
'localhost.evil.com',
|
||||
'127.0.0.1.evil.com',
|
||||
'2600::1',
|
||||
]) {
|
||||
expect(isLoopbackSqlHost(host)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('azureSqlTlsMode', () => {
|
||||
test('trusts the emulator certificate for container-network endpoints', () => {
|
||||
expect(azureSqlTlsMode('floci-az-sql-app', 'None', true)).toBe('trust-server-certificate')
|
||||
})
|
||||
|
||||
test('keeps certificate verification for remote endpoints without the emulator hint', () => {
|
||||
expect(azureSqlTlsMode('db.example.com', 'None', false)).toBe('verify')
|
||||
expect(azureSqlTlsMode('db.example.com', '1.2', true)).toBe('verify')
|
||||
expect(azureSqlTlsMode('db.example.com', null, false)).toBe('verify')
|
||||
})
|
||||
})
|
||||
|
||||
describe('postgresTlsMode', () => {
|
||||
test('disables TLS for the emulator local-port extension', () => {
|
||||
expect(postgresTlsMode('floci-az-pg-app', true)).toBe('disable')
|
||||
})
|
||||
|
||||
test('keeps certificate verification for remote endpoints without the emulator hint', () => {
|
||||
expect(postgresTlsMode('db.example.com', false)).toBe('verify')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '::1', '[::1]'])
|
||||
|
||||
export type SqlTlsMode = 'disable' | 'trust-server-certificate' | 'verify'
|
||||
|
||||
export function isLoopbackSqlHost(host: string): boolean {
|
||||
const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, '')
|
||||
if (LOOPBACK_HOSTNAMES.has(normalized) || LOOPBACK_HOSTNAMES.has(`[${normalized}]`)) return true
|
||||
// The whole 127.0.0.0/8 block is loopback, not just 127.0.0.1.
|
||||
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized)
|
||||
}
|
||||
|
||||
export function azureSqlTlsMode(
|
||||
host: string,
|
||||
minimalTlsVersion: string | null,
|
||||
hasLocalPort: boolean,
|
||||
): SqlTlsMode {
|
||||
if (isLoopbackSqlHost(host)) return 'disable'
|
||||
if (hasLocalPort && minimalTlsVersion?.trim().toLowerCase() === 'none') return 'trust-server-certificate'
|
||||
return 'verify'
|
||||
}
|
||||
|
||||
export function postgresTlsMode(host: string, hasLocalPort: boolean): SqlTlsMode {
|
||||
if (isLoopbackSqlHost(host) || hasLocalPort) return 'disable'
|
||||
return 'verify'
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function normalizeSqlRow(row: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeSqlValue(value)]))
|
||||
}
|
||||
|
||||
function normalizeSqlValue(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'bigint') return value.toString()
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
if (Buffer.isBuffer(value)) return value.toString('base64')
|
||||
if (Array.isArray(value)) return value.map(normalizeSqlValue)
|
||||
if (typeof value === 'object') return normalizeSqlRow(value as Record<string, unknown>)
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user