Files
floci-ui/packages/api/src/adapter-azure/AzurePostgresAdapter.ts
T
2026-09-22 16:13:21 -04:00

273 lines
11 KiB
TypeScript

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
}