first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
@@ -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(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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
}