diff --git a/.env.example b/.env.example index 5074891..91b5317 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ FLOCI_ENDPOINT=http://localhost:4566 FLOCI_AZURE_ENDPOINT=http://localhost:4577 FLOCI_AZURE_ACCOUNT_NAME=devstoreaccount1 +FLOCI_AZURE_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 +FLOCI_AZURE_RESOURCE_GROUP=floci-local VITE_MOCK_MODE=false AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=test diff --git a/README.md b/README.md index 1c4bc79..dbec9ba 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ This table is the source of truth for the current UI surface. | Console Home | Yes | Yes | Yes | Cloud-aware overview page with runtime status and service cards. | | Cloud Explorer / Storage | Yes | Yes | Yes | Unified storage view with resource table, inspector, object browser, and schema-driven actions. | | Cloud Explorer / k8s Engine | Yes | Placeholder | Placeholder | AWS EKS list/inspect is wired. | -| Cloud Explorer / Database | Yes | Yes | Placeholder | AWS RDS list/inspect and Azure Cosmos DB NoSQL workflows. | +| Cloud Explorer / Database | Yes | Yes | Placeholder | AWS RDS list/inspect; Azure SQL and PostgreSQL server, explorer, and query workflows. | +| Cloud Explorer / NoSQL | Placeholder | Yes | Placeholder | Azure Cosmos DB NoSQL database, container, document, and query workflows. | | Cloud Explorer / Compute | Yes | Placeholder | Placeholder | AWS EC2 and AMI workflows. | | Cloud Explorer / Networking | Yes | Placeholder | Placeholder | AWS VPC/networking workflows. | | Cloud Explorer / Serverless | Yes | Not exposed in navigation | Not exposed in navigation | AWS Lambda flows through the unified shell. | @@ -105,23 +106,32 @@ Current gaps:
Database -Two different database models are currently exposed under one category: +Relational database services use this category: - AWS RDS: list and inspect oriented. -- Azure Cosmos DB NoSQL: database, container, and document workflows. +- Azure SQL Database: server lifecycle, database/table explorer, row previews, and T-SQL execution. +- Azure Database for PostgreSQL Flexible Server: server lifecycle, database/schema/table explorer, row previews, and PostgreSQL SQL execution. -Cosmos DB currently includes: +Current gaps: + +- No unified cross-provider relational database contract beyond the shared category shell. +- No GCP database adapter yet. + +
+ +
+NoSQL + +Azure Cosmos DB NoSQL is exposed separately from relational database services: - List, create, and delete databases. - List, create, and delete containers. - Create, edit, and delete documents/items. -- SQL query editor for documents. +- SQL query editor for Cosmos DB NoSQL documents. Current gaps: -- No unified cross-provider database contract beyond the shared category shell. -- No GCP database adapter yet. -- AWS DynamoDB is not rebuilt into the new Cloud Explorer model yet. +- AWS DynamoDB and Google Firestore are placeholders in the new Cloud Explorer model.
diff --git a/packages/api/package.json b/packages/api/package.json index 4c60859..0ee1753 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -17,11 +17,15 @@ "@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-secrets-manager": "^3.1076.0", "dotenv": "^17.4.2", - "hono": "^4.12.27" + "hono": "^4.12.27", + "mssql": "^12.7.0", + "pg": "^8.22.0" }, "devDependencies": { "@types/bun": "^1.3.2", + "@types/mssql": "^12.3.0", "@types/node": "^25.9.4", + "@types/pg": "^8.20.0", "typescript": "^6.0.3" } } diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts index a3fd45d..a6745b6 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts @@ -1,155 +1,77 @@ import {describe, expect, test} from 'bun:test' +import type { + CloudResource, + CreateResourceInput, + ResourceQuery, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from '../cloud-spi/types' import {AzureDatabaseAdapter} from './AzureDatabaseAdapter' -import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' describe('AzureDatabaseAdapter', () => { - test('normalizes Cosmos databases as cloud database resources', async () => { - const adapter = new AzureDatabaseAdapter(testClient({ - '/dbs': { - _count: 1, - Databases: [{id: 'appdb', _rid: 'db-rid', _etag: '"etag"', _ts: 1779307200}], - }, - })) + 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.toEqual([{ - id: 'appdb', - name: 'appdb', - cloud: 'azure', - service: 'database', - 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 AzureDatabaseAdapter(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 AzureDatabaseAdapter(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 AzureDatabaseAdapter(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'}) - }) + 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') - test('falls back to account-suffixed path when root routing is not implemented', async () => { - const calls: Array<{path: string; init: RequestInit}> = [] - const adapter = new AzureDatabaseAdapter(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)).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 AzureDatabaseAdapter(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)).toEqual([ - '/dbs', - '/devstoreaccount1-cosmos/dbs', - '/devstoreaccount1-cosmos-nosql/dbs', + expect(calls).toEqual([ + 'azure-sql:list', + 'postgresql:list', + 'postgresql:create', + 'postgresql:query', ]) }) }) -function testClient(responses: Record, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient { +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 { - 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'}}) + async list(_query?: ResourceQuery): Promise { + calls.push(`${engine}:list`) + return [resource] + }, + async get(): Promise { + return resource + }, + async create(_input: CreateResourceInput): Promise { + calls.push(`${engine}:create`) + return resource + }, + async delete(): Promise {}, + async listSqlDatabases(_serverId: string, _connection: SqlConnectionInput): Promise { + return [] + }, + async listSqlTables(_serverId: string, _connection: SqlConnectionInput): Promise { + return [] + }, + async querySql(_serverId: string, _connection: SqlConnectionInput, _query: string): Promise { + calls.push(`${engine}:query`) + return {resultSets: [], rowsAffected: [], durationMs: 0} }, } } diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts index 0b5cd05..10a2171 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts @@ -1,333 +1,83 @@ -import {azure, type AzureRuntimeClient} from '../azure' import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' import type { CloudResource, CloudServiceAdapter, - CosmosContainer, - CosmosItem, - CosmosQueryResult, CreateResourceInput, ResourceQuery, ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, } from '../cloud-spi/types' +import {AzurePostgresAdapter, isPostgresServerResourceId} from './AzurePostgresAdapter' +import {AzureSqlAdapter, isSqlServerResourceId} from './AzureSqlAdapter' -interface CosmosListResponse { - _count?: number - Databases?: T[] - DocumentCollections?: T[] - Documents?: T[] +interface AzureDatabaseEngineAdapter { + list(query?: ResourceQuery): Promise + get(id: string): Promise + create(input: CreateResourceInput): Promise + delete(id: string): Promise + listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise + listSqlTables(serverId: string, connection: SqlConnectionInput): Promise + querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise } -type CosmosRecord = Record - export class AzureDatabaseAdapter implements CloudServiceAdapter { readonly cloud = 'azure' as const readonly service = 'database' as const - constructor(private readonly client: AzureRuntimeClient = azure) {} + constructor( + private readonly sql: AzureDatabaseEngineAdapter = new AzureSqlAdapter(), + private readonly postgres: AzureDatabaseEngineAdapter = new AzurePostgresAdapter(), + ) {} schema(): ServiceSchema { return azureDatabaseSchema() } async list(query: ResourceQuery = {}): Promise { - const body = await this.cosmosJson>('/dbs', {method: 'GET'}, {emptyOnNotFound: true}) - const databases = body?.Databases ?? [] - return filterBySearch(databases.map(toDatabaseResource), query.search) - } - - async get(id: string): Promise { - const body = await this.cosmosJson(`/dbs/${encodeSegment(id)}`, {method: 'GET'}, {emptyOnNotFound: true}) - return body ? toDatabaseResource(body) : null - } - - async create(input: CreateResourceInput): Promise { - const databaseName = stringValue(input.values.databaseName) - if (!databaseName) throw new Error('databaseName is required') - if (!isValidCosmosId(databaseName)) throw new Error('Use a valid Cosmos database name.') - - const body = await this.cosmosJson('/dbs', { - method: 'POST', - body: JSON.stringify({id: databaseName}), - }) - if (!body) throw new Error('Cosmos database creation returned an empty response') - return toDatabaseResource(body) - } - - async delete(id: string): Promise { - await this.cosmosFetch(`/dbs/${encodeSegment(id)}`, {method: 'DELETE'}) - } - - async listCosmosContainers(databaseId: string): Promise { - const body = await this.cosmosJson>( - `/dbs/${encodeSegment(databaseId)}/colls`, - {method: 'GET'}, - {emptyOnNotFound: true}, - ) - return (body?.DocumentCollections ?? []).map((container) => toContainer(databaseId, container)) - } - - async createCosmosContainer(databaseId: string, input: CreateResourceInput): Promise { - const containerName = stringValue(input.values.containerName) - const partitionKeyPath = normalizePartitionKeyPath(stringValue(input.values.partitionKeyPath) || '/id') - if (!containerName) throw new Error('containerName is required') - if (!isValidCosmosId(containerName)) throw new Error('Use a valid Cosmos container name.') - - const body = await this.cosmosJson(`/dbs/${encodeSegment(databaseId)}/colls`, { - method: 'POST', - body: JSON.stringify({ - id: containerName, - partitionKey: {paths: [partitionKeyPath], kind: 'Hash'}, - }), - }) - if (!body) throw new Error('Cosmos container creation returned an empty response') - return toContainer(databaseId, body) - } - - async deleteCosmosContainer(databaseId: string, containerId: string): Promise { - await this.cosmosFetch(`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`, {method: 'DELETE'}) - } - - async listCosmosItems(databaseId: string, containerId: string): Promise { - const [container, body] = await Promise.all([ - this.getCosmosContainer(databaseId, containerId), - this.cosmosJson>( - `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs`, - {method: 'GET'}, - {emptyOnNotFound: true}, - ), + const [sqlServers, postgresServers] = await Promise.all([ + this.sql.list(query), + this.postgres.list(query), ]) - const pkPath = container ? partitionKeyPath(container) : '/id' - return (body?.Documents ?? []).map((document) => toItem(databaseId, containerId, document, pkPath)) - } - - async upsertCosmosItem(databaseId: string, containerId: string, document: Record): Promise { - if (!isRecord(document)) throw new Error('document must be a JSON object') - const id = stringValue(document.id) - if (!id) throw new Error('Cosmos document id is required') - - const container = await this.getCosmosContainer(databaseId, containerId) - const pkPath = container ? partitionKeyPath(container) : '/id' - const body = await this.cosmosJson( - `/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 Error('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 { - await this.cosmosFetch( - `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs/${encodeSegment(itemId)}`, - { - method: 'DELETE', - headers: partitionKeyHeader(partitionKey ?? null), - }, - ) + return [...sqlServers, ...postgresServers] } - async queryCosmosItems(databaseId: string, containerId: string, query: string): Promise { - const sql = query.trim() || 'SELECT * FROM c' - const body = await this.cosmosJson | 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 Error('Cosmos query returned an empty response') - const items = body.Documents ?? [] - return {items, count: body._count ?? items.length} - } - - private getCosmosContainer(databaseId: string, containerId: string): Promise { - return this.cosmosJson( - `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`, - {method: 'GET'}, - {emptyOnNotFound: true}, - ) + async get(id: string): Promise { + 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) } - private cosmosFetch(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise { - const request: RequestInit = { - ...init, - headers: { - 'accept': 'application/json', - 'content-type': 'application/json', - ...(init.headers ?? {}), - }, - } - return this.fetchWithFallbacks(path, request, options) + create(input: CreateResourceInput): Promise { + return input.values.engine === 'postgresql' + ? this.postgres.create(input) + : this.sql.create(input) } - private async cosmosJson(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise { - const res = await this.cosmosFetch(path, init, options) - if (!res) return null - if (res.status === 204) return null - return await res.json() as T + delete(id: string): Promise { + return isPostgresServerResourceId(id) + ? this.postgres.delete(id) + : this.sql.delete(id) } - private async fetchWithFallbacks(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise { - 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 Error(`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: 'database', - 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)), - }, + listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { + return this.dataAdapter(serverId, connection).listSqlDatabases(serverId, connection) } -} -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, - }, + listSqlTables(serverId: string, connection: SqlConnectionInput): Promise { + return this.dataAdapter(serverId, connection).listSqlTables(serverId, connection) } -} -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, + querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise { + return this.dataAdapter(serverId, connection).querySql(serverId, connection, query) } -} -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] + private dataAdapter(serverId: string, connection: SqlConnectionInput): AzureDatabaseEngineAdapter { + return connection.engine === 'postgresql' || isPostgresServerResourceId(serverId) + ? this.postgres + : this.sql } - 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 { - 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 { - 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) } diff --git a/packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts b/packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts new file mode 100644 index 0000000..f2be243 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts @@ -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, 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'}}) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureNoSqlAdapter.ts b/packages/api/src/adapter-azure/AzureNoSqlAdapter.ts new file mode 100644 index 0000000..7e777da --- /dev/null +++ b/packages/api/src/adapter-azure/AzureNoSqlAdapter.ts @@ -0,0 +1,333 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema' +import type { + CloudResource, + CloudServiceAdapter, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +interface CosmosListResponse { + _count?: number + Databases?: T[] + DocumentCollections?: T[] + Documents?: T[] +} + +type CosmosRecord = Record + +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 { + const body = await this.cosmosJson>('/dbs', {method: 'GET'}, {emptyOnNotFound: true}) + const databases = body?.Databases ?? [] + return filterBySearch(databases.map(toDatabaseResource), query.search) + } + + async get(id: string): Promise { + const body = await this.cosmosJson(`/dbs/${encodeSegment(id)}`, {method: 'GET'}, {emptyOnNotFound: true}) + return body ? toDatabaseResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + const databaseName = stringValue(input.values.databaseName) + if (!databaseName) throw new Error('databaseName is required') + if (!isValidCosmosId(databaseName)) throw new Error('Use a valid Cosmos database name.') + + const body = await this.cosmosJson('/dbs', { + method: 'POST', + body: JSON.stringify({id: databaseName}), + }) + if (!body) throw new Error('Cosmos database creation returned an empty response') + return toDatabaseResource(body) + } + + async delete(id: string): Promise { + await this.cosmosFetch(`/dbs/${encodeSegment(id)}`, {method: 'DELETE'}) + } + + async listCosmosContainers(databaseId: string): Promise { + const body = await this.cosmosJson>( + `/dbs/${encodeSegment(databaseId)}/colls`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return (body?.DocumentCollections ?? []).map((container) => toContainer(databaseId, container)) + } + + async createCosmosContainer(databaseId: string, input: CreateResourceInput): Promise { + const containerName = stringValue(input.values.containerName) + const partitionKeyPath = normalizePartitionKeyPath(stringValue(input.values.partitionKeyPath) || '/id') + if (!containerName) throw new Error('containerName is required') + if (!isValidCosmosId(containerName)) throw new Error('Use a valid Cosmos container name.') + + const body = await this.cosmosJson(`/dbs/${encodeSegment(databaseId)}/colls`, { + method: 'POST', + body: JSON.stringify({ + id: containerName, + partitionKey: {paths: [partitionKeyPath], kind: 'Hash'}, + }), + }) + if (!body) throw new Error('Cosmos container creation returned an empty response') + return toContainer(databaseId, body) + } + + async deleteCosmosContainer(databaseId: string, containerId: string): Promise { + await this.cosmosFetch(`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`, {method: 'DELETE'}) + } + + async listCosmosItems(databaseId: string, containerId: string): Promise { + const [container, body] = await Promise.all([ + this.getCosmosContainer(databaseId, containerId), + this.cosmosJson>( + `/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): Promise { + if (!isRecord(document)) throw new Error('document must be a JSON object') + const id = stringValue(document.id) + if (!id) throw new Error('Cosmos document id is required') + + const container = await this.getCosmosContainer(databaseId, containerId) + const pkPath = container ? partitionKeyPath(container) : '/id' + const body = await this.cosmosJson( + `/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 Error('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 { + 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 { + const sql = query.trim() || 'SELECT * FROM c' + const body = await this.cosmosJson | 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 Error('Cosmos query returned an empty response') + const items = body.Documents ?? [] + return {items, count: body._count ?? items.length} + } + + private getCosmosContainer(databaseId: string, containerId: string): Promise { + return this.cosmosJson( + `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + } + + private cosmosFetch(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise { + const request: RequestInit = { + ...init, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + } + return this.fetchWithFallbacks(path, request, options) + } + + private async cosmosJson(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise { + 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 { + 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 Error(`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 { + 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 { + 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) +} diff --git a/packages/api/src/adapter-azure/AzurePostgresAdapter.test.ts b/packages/api/src/adapter-azure/AzurePostgresAdapter.test.ts new file mode 100644 index 0000000..1f766c8 --- /dev/null +++ b/packages/api/src/adapter-azure/AzurePostgresAdapter.test.ts @@ -0,0 +1,173 @@ +import {describe, expect, test} from 'bun:test' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' +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!', + }) + expect(dataCalls[1].connection.database).toBe('appdb') + expect(dataCalls[2].query).toBe('SELECT * FROM orders') + }) +}) + +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>, rowsAffected: number[] = []) { + return { + resultSets: [{ + columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'text'})), + rows, + truncated: false, + }], + rowsAffected, + durationMs: 1, + } +} + +function testClient(responses: Record, 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'}}) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzurePostgresAdapter.ts b/packages/api/src/adapter-azure/AzurePostgresAdapter.ts new file mode 100644 index 0000000..a4b9dd2 --- /dev/null +++ b/packages/api/src/adapter-azure/AzurePostgresAdapter.ts @@ -0,0 +1,287 @@ +import { + azure, + azureResourceGroup, + type AzureRuntimeClient, + azureSubscriptionId, +} from '../azure' +import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +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' + +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 + sku?: Record + properties?: Record +} + +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 { + const body = await this.postgresJson(postgresServersPath(), {method: 'GET'}, true) + return filterBySearch((body?.value ?? []).map(toPostgresResource), query.search) + } + + async get(id: string): Promise { + const serverName = postgresServerName(id) + const body = await this.postgresJson(postgresServerPath(serverName), {method: 'GET'}, true) + return body ? toPostgresResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + 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 Error('serverName is required') + if (!isValidServerName(serverName)) throw new Error('Use a valid PostgreSQL flexible server name.') + if (!administratorLogin) throw new Error('administratorLogin is required') + if (!administratorLoginPassword) throw new Error('administratorLoginPassword is required') + + const body = await this.postgresJson(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 Error('PostgreSQL flexible server creation returned an empty response') + return toPostgresResource(body) + } + + async delete(id: string): Promise { + await this.postgresFetch(postgresServerPath(postgresServerName(id)), {method: 'DELETE'}) + } + + async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { + 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 { + 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 { + if (!query.trim()) throw new Error('SQL query is required') + return this.dataClient.query(await this.dataConnection(serverId, connection), query) + } + + private postgresFetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + return this.client.fetch(withApiVersion(path), { + ...init, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + }, {emptyOnNotFound}) + } + + private async postgresJson(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + 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 { + const resource = await this.get(serverId) + if (!resource) throw new Error('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' + + if (!server || port === null) throw new Error('PostgreSQL server endpoint is not available') + if (!username) throw new Error('PostgreSQL username is required') + if (!password) throw new Error('PostgreSQL password is required') + + return {server, port, database, username, password} + } +} + +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 withApiVersion(path: string): string { + const separator = path.includes('?') ? '&' : '?' + return `${path}${separator}api-version=${API_VERSION}` +} + +function postgresServerName(id: string): string { + const name = isPostgresServerResourceId(id) ? id.slice(RESOURCE_ID_PREFIX.length) : id + if (!name) throw new Error('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 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 tagList(tags?: Record): Array<{key: string; value: string}> { + return Object.entries(tags ?? {}).map(([key, value]) => ({key, value})) +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function rawStringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function recordValue(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function numberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +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 +} + +function isValidServerName(value: string): boolean { + return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) +} diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts new file mode 100644 index 0000000..5cb85dc --- /dev/null +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts @@ -0,0 +1,193 @@ +import {describe, expect, test} from 'bun:test' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' +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, + }, + }, + }), 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', + }) + expect(dataCalls[1].connection.database).toBe('ordersdb') + expect(dataCalls[2].query).toBe('SELECT * FROM dbo.orders') + }) +}) + +function sqlResult(rows: Array>, rowsAffected: number[] = []) { + return { + resultSets: [{ + columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'nvarchar'})), + rows, + truncated: false, + }], + rowsAffected, + durationMs: 1, + } +} + +function testClient(responses: Record, 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'}}) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.ts new file mode 100644 index 0000000..ab4b42d --- /dev/null +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.ts @@ -0,0 +1,277 @@ +import { + azure, + azureResourceGroup, + type AzureRuntimeClient, + azureSubscriptionId, +} from '../azure' +import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from '../cloud-spi/types' +import {MssqlDataClient, type SqlDataClient, type SqlDataConnection} from './MssqlDataClient' + +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 + properties?: Record +} + +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 { + const body = await this.sqlJson(sqlServersPath(), {method: 'GET'}, true) + return filterBySearch((body?.value ?? []).map(toSqlServerResource), query.search) + } + + async get(id: string): Promise { + const serverName = sqlServerName(id) + const body = await this.sqlJson(sqlServerPath(serverName), {method: 'GET'}, true) + return body ? toSqlServerResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + 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 Error('serverName is required') + if (!isValidServerName(serverName)) throw new Error('Use a valid Azure SQL server name.') + if (!administratorLogin) throw new Error('administratorLogin is required') + if (!administratorLoginPassword) throw new Error('administratorLoginPassword is required') + + const body = await this.sqlJson(sqlServerPath(serverName), { + method: 'PUT', + body: JSON.stringify({ + location, + properties: {administratorLogin, administratorLoginPassword}, + }), + }) + if (!body) throw new Error('Azure SQL server creation returned an empty response') + return toSqlServerResource(body) + } + + async delete(id: string): Promise { + await this.sqlFetch(sqlServerPath(sqlServerName(id)), {method: 'DELETE'}) + } + + async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { + 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 { + 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 { + const dataConnection = await this.dataConnection(serverId, connection) + if (!query.trim()) throw new Error('SQL query is required') + return this.dataClient.query(dataConnection, query) + } + + private sqlFetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + return this.client.fetch(withApiVersion(path), { + ...init, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + }, {emptyOnNotFound}) + } + + private async sqlJson(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + 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 { + const resource = await this.get(serverId) + if (!resource) throw new Error('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' + + if (!server || port === null) throw new Error('Azure SQL server endpoint is not available') + if (!username) throw new Error('SQL username is required') + if (!password) throw new Error('SQL password is required') + + return {server, port, database, username, password} + } +} + +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 withApiVersion(path: string): string { + const separator = path.includes('?') ? '&' : '?' + return `${path}${separator}api-version=${API_VERSION}` +} + +function sqlServerName(id: string): string { + const name = isSqlServerResourceId(id) ? id.slice(RESOURCE_ID_PREFIX.length) : id + if (!name) throw new Error('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 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 tagList(tags?: Record): Array<{key: string; value: string}> { + return Object.entries(tags ?? {}).map(([key, value]) => ({key, value})) +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function rawStringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function recordValue(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function numberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +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 +} + +function isValidServerName(value: string): boolean { + return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) +} diff --git a/packages/api/src/adapter-azure/MssqlDataClient.ts b/packages/api/src/adapter-azure/MssqlDataClient.ts new file mode 100644 index 0000000..aa063e9 --- /dev/null +++ b/packages/api/src/adapter-azure/MssqlDataClient.ts @@ -0,0 +1,79 @@ +import sql from 'mssql' +import type {config as MssqlConfig, IRecordSet} from 'mssql' +import type {SqlColumn, SqlQueryResult, SqlResultSet} from '../cloud-spi/types' +import {normalizeSqlRow} from './sqlValues' + +const MAX_ROWS_PER_RESULT_SET = 500 + +export interface SqlDataConnection { + server: string + port: number + database: string + username: string + password: string +} + +export interface SqlDataClient { + query(connection: SqlDataConnection, query: string): Promise +} + +export class MssqlDataClient implements SqlDataClient { + async query(connection: SqlDataConnection, query: string): Promise { + const pool = new sql.ConnectionPool(connectionConfig(connection)) + const startedAt = performance.now() + + try { + await pool.connect() + const result = await pool.request().query>(query) + return { + resultSets: result.recordsets.map(toResultSet), + rowsAffected: result.rowsAffected, + durationMs: Math.round(performance.now() - startedAt), + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown SQL Server error' + throw new Error(`Azure SQL data request failed: ${message}`, {cause: 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: false, + trustServerCertificate: true, + appName: 'Floci UI', + }, + } +} + +function toResultSet(recordset: IRecordSet>): 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' +} diff --git a/packages/api/src/adapter-azure/PostgresDataClient.ts b/packages/api/src/adapter-azure/PostgresDataClient.ts new file mode 100644 index 0000000..3c209fb --- /dev/null +++ b/packages/api/src/adapter-azure/PostgresDataClient.ts @@ -0,0 +1,57 @@ +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 {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 { + const client = new Client({ + host: connection.server, + port: connection.port, + database: connection.database, + user: connection.username, + password: connection.password, + ssl: false, + connectionTimeoutMillis: 10_000, + query_timeout: 30_000, + statement_timeout: 30_000, + application_name: 'Floci UI', + }) + const startedAt = performance.now() + + try { + await client.connect() + const rawResult = await client.query>(query) + const results = (Array.isArray(rawResult) ? rawResult : [rawResult]) as QueryResult>[] + 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) { + const message = error instanceof Error ? error.message : 'Unknown PostgreSQL error' + throw new Error(`PostgreSQL data request failed: ${message}`, {cause: error}) + } finally { + await client.end().catch(() => undefined) + } + } +} + +function toResultSet(result: QueryResult>): 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, + } +} diff --git a/packages/api/src/adapter-azure/sqlValues.ts b/packages/api/src/adapter-azure/sqlValues.ts new file mode 100644 index 0000000..dd66880 --- /dev/null +++ b/packages/api/src/adapter-azure/sqlValues.ts @@ -0,0 +1,13 @@ +export function normalizeSqlRow(row: Record): Record { + 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) + return value +} diff --git a/packages/api/src/azure.ts b/packages/api/src/azure.ts index b8ea2ba..456e86e 100644 --- a/packages/api/src/azure.ts +++ b/packages/api/src/azure.ts @@ -46,6 +46,14 @@ export function azureAccountName(): string { return process.env.FLOCI_AZURE_ACCOUNT_NAME ?? 'devstoreaccount1' } +export function azureSubscriptionId(): string { + return process.env.FLOCI_AZURE_SUBSCRIPTION_ID ?? '00000000-0000-0000-0000-000000000000' +} + +export function azureResourceGroup(): string { + return process.env.FLOCI_AZURE_RESOURCE_GROUP ?? 'floci-local' +} + export const azure = new AzureRestRuntimeClient() function errorMessage(error: unknown): string { diff --git a/packages/api/src/cloud-spi/databaseSchema.ts b/packages/api/src/cloud-spi/databaseSchema.ts index f02f053..d7c51fa 100644 --- a/packages/api/src/cloud-spi/databaseSchema.ts +++ b/packages/api/src/cloud-spi/databaseSchema.ts @@ -28,36 +28,73 @@ export function azureDatabaseSchema(): ServiceSchema { return { cloud: 'azure', service: 'database', - displayName: 'Cosmos DB', + displayName: 'Azure Databases', fields: [ { - name: 'databaseName', - label: 'Database Name', + name: 'engine', + label: 'Database Engine', + type: 'select', + required: true, + defaultValue: 'azure-sql', + options: [ + {label: 'Azure SQL Database', value: 'azure-sql'}, + {label: 'Azure Database for PostgreSQL', value: 'postgresql'}, + ], + }, + { + name: 'serverName', + label: 'Server Name', type: 'text', required: true, validation: { minLength: 1, - maxLength: 255, - pattern: '^[A-Za-z0-9._-]+$', - message: 'Use letters, numbers, dot, underscore, or dash.', + maxLength: 63, + pattern: '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', + message: 'Use 1-63 lowercase letters, numbers, or hyphens.', + }, + }, + { + name: 'location', + label: 'Location', + type: 'text', + required: true, + defaultValue: 'eastus', + }, + { + name: 'administratorLogin', + label: 'Administrator Login', + type: 'text', + required: true, + defaultValue: 'sa', + }, + { + name: 'administratorLoginPassword', + label: 'Administrator Password', + type: 'password', + required: true, + span: true, + description: 'Required by the local database runtime. The value is not returned by the API.', + validation: { + minLength: 8, + message: 'Use at least 8 characters.', }, }, ], actions: ['list', 'create', 'delete', 'inspect'], capabilities: { resourceActions: [ - {name: 'list', label: 'List databases', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'create', label: 'Create database', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'delete', label: 'Delete database', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'list', label: 'List database servers', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create database server', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete database server', enabled: true, status: 'available', runtimeRequired: true}, {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, ], }, filters: databaseFilters, columns: [ - {name: 'name', label: 'Database'}, + {name: 'name', label: 'Name'}, {name: 'engine', label: 'Engine'}, {name: 'status', label: 'Status'}, - {name: 'createdAt', label: 'Created At'}, + {name: 'region', label: 'Region'}, ], } } diff --git a/packages/api/src/cloud-spi/noSqlSchema.ts b/packages/api/src/cloud-spi/noSqlSchema.ts new file mode 100644 index 0000000..d65fe68 --- /dev/null +++ b/packages/api/src/cloud-spi/noSqlSchema.ts @@ -0,0 +1,76 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const noSqlFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const noSqlColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'engine', label: 'Engine'}, + {name: 'status', label: 'Status'}, + {name: 'createdAt', label: 'Created At'}, +] + +export function awsNoSqlSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'nosql', + displayName: 'Amazon DynamoDB', + fields: [], + actions: ['list', 'inspect'], + filters: noSqlFilters, + columns: noSqlColumns, + } +} + +export function azureNoSqlSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'nosql', + displayName: 'Azure Cosmos DB NoSQL', + fields: [ + { + name: 'databaseName', + label: 'Database Name', + type: 'text', + required: true, + validation: { + minLength: 1, + maxLength: 255, + pattern: '^[A-Za-z0-9._-]+$', + message: 'Use letters, numbers, dot, underscore, or dash.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List Cosmos databases', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create Cosmos database', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete Cosmos database', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: noSqlFilters, + columns: noSqlColumns, + } +} + +export function gcpNoSqlSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'nosql', + displayName: 'Google Firestore', + fields: [], + actions: ['list', 'inspect'], + filters: noSqlFilters, + columns: noSqlColumns, + } +} + +export function noSqlSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'aws') return awsNoSqlSchema() + if (cloud === 'azure') return azureNoSqlSchema() + if (cloud === 'gcp') return gcpNoSqlSchema() + return null +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index bf9da0d..e0aaee9 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -1,6 +1,6 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' +export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'nosql' | 'serverless' | 'compute' | 'networking' export type CloudAvailability = 'available' | 'coming_soon' @@ -26,7 +26,7 @@ export interface CloudStatus { error: string | null } -export type FieldType = 'text' | 'select' +export type FieldType = 'text' | 'select' | 'password' export interface FieldSchema { name: string @@ -36,6 +36,7 @@ export interface FieldSchema { description?: string group?: string span?: boolean + defaultValue?: string validation?: { pattern?: string minLength?: number @@ -83,7 +84,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'sql-server' | 'postgres-flexible-server' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' region: string | null createdAt: string | null status?: string | null @@ -137,6 +138,44 @@ export interface CosmosQueryResult { count: number } +export interface SqlConnectionInput { + username: string + password: string + database?: string + engine?: 'azure-sql' | 'postgresql' +} + +export interface SqlDatabase { + name: string + state: string + createdAt: string | null + isSystem: boolean +} + +export interface SqlTable { + schema: string + name: string + type: 'table' | 'view' + rowCount: number | null +} + +export interface SqlColumn { + name: string + type: string +} + +export interface SqlResultSet { + columns: SqlColumn[] + rows: Array> + truncated: boolean +} + +export interface SqlQueryResult { + resultSets: SqlResultSet[] + rowsAffected: number[] + durationMs: number +} + export interface ResourceQuery { search?: string } @@ -172,4 +211,7 @@ export interface CloudServiceAdapter { upsertCosmosItem?(databaseId: string, containerId: string, document: Record): Promise deleteCosmosItem?(databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise queryCosmosItems?(databaseId: string, containerId: string, query: string): Promise + listSqlDatabases?(serverId: string, connection: SqlConnectionInput): Promise + listSqlTables?(serverId: string, connection: SqlConnectionInput): Promise + querySql?(serverId: string, connection: SqlConnectionInput, query: string): Promise } diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index b9740c0..af49ec4 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -4,6 +4,7 @@ import {AwsNetworkingAdapter} from './adapter-aws/AwsNetworkingAdapter' import {AwsDatabaseAdapter} from './adapter-aws/AwsDatabaseAdapter' import {AwsEksAdapter} from './adapter-aws/AwsEksAdapter' import {AwsStorageAdapter} from './adapter-aws/AwsStorageAdapter' +import {AzureNoSqlAdapter} from './adapter-azure/AzureNoSqlAdapter' import {AzureDatabaseAdapter} from './adapter-azure/AzureDatabaseAdapter' import {AzureStorageAdapter} from './adapter-azure/AzureStorageAdapter' import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter' @@ -35,6 +36,7 @@ export function createCloudProxyService(accountId?: string | null): CloudProxySe new AwsServerlessAdapter(clients.lambda), new AzureStorageAdapter(), new AzureDatabaseAdapter(), + new AzureNoSqlAdapter(), new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), new AzureServerlessAdapter(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 49296be..980a52c 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -1,8 +1,20 @@ import {describe, expect, test} from 'bun:test' import {Hono} from 'hono' import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema' import {awsStorageSchema, azureStorageSchema} from '../cloud-spi/storageSchema' -import type {CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput} from '../cloud-spi/types' +import type { + CloudResource, + CloudServiceAdapter, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + CreateResourceInput, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from '../cloud-spi/types' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {CloudProxyService} from '../service/CloudProxyService' import {createCloudRoutes} from './clouds' @@ -79,7 +91,29 @@ describe('cloud schema routes', () => { expect(res.status).toBe(200) expect(body.cloud).toBe('azure') expect(body.service).toBe('database') - expect(body.displayName).toBe('Cosmos DB') + expect(body.displayName).toBe('Azure Databases') + expect(body.fields[0]).toMatchObject({ + name: 'engine', + defaultValue: 'azure-sql', + }) + expect(body.fields).toEqual(expect.arrayContaining([ + expect.objectContaining({name: 'serverName'}), + expect.objectContaining({name: 'administratorLoginPassword', type: 'password'}), + ])) + }) + + test('returns Azure NoSQL schema when the adapter is registered', async () => { + const app = appWithRoutes([mockAdapter('azure', { + service: 'nosql', + schema: azureNoSqlSchema, + })]) + const res = await app.request('/api/clouds/azure/services/nosql/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.service).toBe('nosql') + expect(body.displayName).toBe('Azure Cosmos DB NoSQL') + expect(body.fields[0].name).toBe('databaseName') }) test('returns GCP storage schema', async () => { @@ -111,11 +145,21 @@ describe('cloud schema routes', () => { const gcpBody = await gcpRes.json() expect(azureRes.status).toBe(200) - expect(azureBody.displayName).toBe('Cosmos DB') + expect(azureBody.displayName).toBe('Azure Databases') expect(gcpRes.status).toBe(200) expect(gcpBody.displayName).toBe('Cloud SQL') }) + test('returns provider NoSQL schemas without registered adapters', async () => { + const awsRes = await appWithRoutes().request('/api/clouds/aws/services/nosql/schema') + const azureRes = await appWithRoutes().request('/api/clouds/azure/services/nosql/schema') + const gcpRes = await appWithRoutes().request('/api/clouds/gcp/services/nosql/schema') + + expect((await awsRes.json()).displayName).toBe('Amazon DynamoDB') + expect((await azureRes.json()).displayName).toBe('Azure Cosmos DB NoSQL') + expect((await gcpRes.json()).displayName).toBe('Google Firestore') + }) + test('returns AWS cloud status', async () => { const res = await appWithRoutes().request('/api/clouds/aws/status') const body = await res.json() @@ -146,10 +190,10 @@ describe('cloud schema routes', () => { expect(body.objects[0].name).toBe('object.txt') }) - test('lists Cosmos containers through the cloud database adapter', async () => { + test('lists Cosmos containers through the cloud NoSQL adapter', async () => { const app = appWithRoutes([mockAdapter('azure', { - service: 'database', - schema: azureDatabaseSchema, + service: 'nosql', + schema: azureNoSqlSchema, listCosmosContainers: async (databaseId: string): Promise => [{ id: 'items', name: 'items', @@ -160,7 +204,7 @@ describe('cloud schema routes', () => { }], })]) - const res = await app.request('/api/clouds/azure/services/database/resources/appdb/containers') + const res = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers') const body = await res.json() expect(res.status).toBe(200) @@ -168,16 +212,16 @@ describe('cloud schema routes', () => { expect(body[0].name).toBe('items') }) - test('creates and deletes Cosmos databases through the cloud database adapter', async () => { + test('creates and deletes Cosmos databases through the cloud NoSQL adapter', async () => { const deleted: string[] = [] const app = appWithRoutes([mockAdapter('azure', { - service: 'database', - schema: azureDatabaseSchema, + service: 'nosql', + schema: azureNoSqlSchema, create: async (input: CreateResourceInput): Promise => ({ id: String(input.values.databaseName), name: String(input.values.databaseName), cloud: 'azure', - service: 'database', + service: 'nosql', type: 'cosmos-database', region: null, createdAt: null, @@ -188,12 +232,12 @@ describe('cloud schema routes', () => { }, })]) - const createRes = await app.request('/api/clouds/azure/services/database/resources', { + const createRes = await app.request('/api/clouds/azure/services/nosql/resources', { method: 'POST', body: JSON.stringify({databaseName: 'appdb'}), }) const created = await createRes.json() - const deleteRes = await app.request('/api/clouds/azure/services/database/resources/appdb', {method: 'DELETE'}) + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb', {method: 'DELETE'}) expect(createRes.status).toBe(201) expect(created.type).toBe('cosmos-database') @@ -202,11 +246,11 @@ describe('cloud schema routes', () => { expect(deleted).toEqual(['appdb']) }) - test('creates and deletes Cosmos containers through the cloud database adapter', async () => { + test('creates and deletes Cosmos containers through the cloud NoSQL adapter', async () => { const deleted: Array<{databaseId: string; containerId: string}> = [] const app = appWithRoutes([mockAdapter('azure', { - service: 'database', - schema: azureDatabaseSchema, + service: 'nosql', + schema: azureNoSqlSchema, createCosmosContainer: async (databaseId: string, input: CreateResourceInput): Promise => ({ id: String(input.values.containerName), name: String(input.values.containerName), @@ -220,12 +264,12 @@ describe('cloud schema routes', () => { }, })]) - const createRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers', { + const createRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers', { method: 'POST', body: JSON.stringify({containerName: 'items', partitionKeyPath: '/category'}), }) const created = await createRes.json() - const deleteRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers/items', {method: 'DELETE'}) + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items', {method: 'DELETE'}) expect(createRes.status).toBe(201) expect(created.databaseId).toBe('appdb') @@ -234,11 +278,11 @@ describe('cloud schema routes', () => { expect(deleted).toEqual([{databaseId: 'appdb', containerId: 'items'}]) }) - test('upserts, deletes, and queries Cosmos items through the cloud database adapter', async () => { + test('upserts, deletes, and queries Cosmos items through the cloud NoSQL adapter', async () => { const deleted: Array<{databaseId: string; containerId: string; itemId: string; partitionKey?: string | null}> = [] const app = appWithRoutes([mockAdapter('azure', { - service: 'database', - schema: azureDatabaseSchema, + service: 'nosql', + schema: azureNoSqlSchema, upsertCosmosItem: async (databaseId: string, containerId: string, document: Record): Promise => ({ id: String(document.id), databaseId, @@ -257,17 +301,17 @@ describe('cloud schema routes', () => { }), })]) - const upsertRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers/items/items', { + const upsertRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/items', { method: 'POST', body: JSON.stringify({id: 'item-1', category: 'demo'}), }) const upserted = await upsertRes.json() - const queryRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers/items/query', { + const queryRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/query', { method: 'POST', body: JSON.stringify({query: 'SELECT * FROM c'}), }) const queryBody = await queryRes.json() - const deleteRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers/items/items/item-1?partitionKey=demo', {method: 'DELETE'}) + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/items/item-1?partitionKey=demo', {method: 'DELETE'}) expect(upsertRes.status).toBe(201) expect(upserted.partitionKey).toBe('demo') @@ -277,6 +321,56 @@ describe('cloud schema routes', () => { expect(deleted).toEqual([{databaseId: 'appdb', containerId: 'items', itemId: 'item-1', partitionKey: 'demo'}]) }) + test('browses Azure SQL databases and tables and executes queries', async () => { + const calls: Array<{operation: string; serverId: string; connection: SqlConnectionInput; query?: string}> = [] + const app = appWithRoutes([mockAdapter('azure', { + service: 'database', + schema: azureDatabaseSchema, + listSqlDatabases: async (serverId, connection): Promise => { + calls.push({operation: 'databases', serverId, connection}) + return [{name: 'ordersdb', state: 'ONLINE', createdAt: null, isSystem: false}] + }, + listSqlTables: async (serverId, connection): Promise => { + calls.push({operation: 'tables', serverId, connection}) + return [{schema: 'dbo', name: 'orders', type: 'table', rowCount: 1}] + }, + querySql: async (serverId, connection, query): Promise => { + calls.push({operation: 'query', serverId, connection, query}) + return { + resultSets: [{ + columns: [{name: 'orderId', type: 'int'}], + rows: [{orderId: 1042}], + truncated: false, + }], + rowsAffected: [1], + durationMs: 4, + } + }, + })]) + const credentials = {username: 'sa', password: 'LocalDev!2026'} + const databasesRes = await app.request('/api/clouds/azure/services/database/resources/app-sql/sql/databases', { + method: 'POST', + body: JSON.stringify(credentials), + }) + const tablesRes = await app.request('/api/clouds/azure/services/database/resources/app-sql/sql/tables', { + method: 'POST', + body: JSON.stringify({...credentials, database: 'ordersdb'}), + }) + const queryRes = await app.request('/api/clouds/azure/services/database/resources/app-sql/sql/query', { + method: 'POST', + body: JSON.stringify({...credentials, database: 'ordersdb', query: 'SELECT * FROM dbo.orders'}), + }) + + expect(await databasesRes.json()).toEqual([{name: 'ordersdb', state: 'ONLINE', createdAt: null, isSystem: false}]) + expect(await tablesRes.json()).toEqual([{schema: 'dbo', name: 'orders', type: 'table', rowCount: 1}]) + expect(await queryRes.json()).toMatchObject({resultSets: [{rows: [{orderId: 1042}]}], rowsAffected: [1]}) + expect(calls).toEqual([ + {operation: 'databases', serverId: 'app-sql', connection: credentials}, + {operation: 'tables', serverId: 'app-sql', connection: {...credentials, database: 'ordersdb'}}, + {operation: 'query', serverId: 'app-sql', connection: {...credentials, database: 'ordersdb'}, query: 'SELECT * FROM dbo.orders'}, + ]) + }) + test('normalizes runtime unavailable errors', async () => { const app = appWithRoutes([ mockAdapter('aws', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 93cb831..19cae3b 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -1,6 +1,6 @@ import {Hono} from 'hono' import type {Context} from 'hono' -import type {CloudProvider, CloudServiceType} from '../cloud-spi/types' +import type {CloudProvider, CloudServiceType, SqlConnectionInput} from '../cloud-spi/types' import {serviceForAccount} from '../cloudProxy' import {CloudProxyService} from '../service/CloudProxyService' @@ -54,7 +54,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.get('/:cloud/services/database/resources/:id/containers', async (c) => { + app.get('/:cloud/services/nosql/resources/:id/containers', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -64,7 +64,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.post('/:cloud/services/database/resources/:id/containers', async (c) => { + app.post('/:cloud/services/nosql/resources/:id/containers', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -75,7 +75,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.delete('/:cloud/services/database/resources/:id/containers/:containerId', async (c) => { + app.delete('/:cloud/services/nosql/resources/:id/containers/:containerId', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -85,7 +85,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.get('/:cloud/services/database/resources/:id/containers/:containerId/items', async (c) => { + app.get('/:cloud/services/nosql/resources/:id/containers/:containerId/items', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -95,7 +95,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.post('/:cloud/services/database/resources/:id/containers/:containerId/items', async (c) => { + app.post('/:cloud/services/nosql/resources/:id/containers/:containerId/items', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -106,7 +106,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.delete('/:cloud/services/database/resources/:id/containers/:containerId/items/:itemId', async (c) => { + app.delete('/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -116,7 +116,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.post('/:cloud/services/database/resources/:id/containers/:containerId/query', async (c) => { + app.post('/:cloud/services/nosql/resources/:id/containers/:containerId/query', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -127,6 +127,40 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.post('/:cloud/services/database/resources/:id/sql/databases', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const connection = await c.req.json() + const databases = await svc(c).listSqlDatabases(cloud, c.req.param('id'), connection) + return c.json(databases) + }) + }) + + app.post('/:cloud/services/database/resources/:id/sql/tables', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const connection = await c.req.json() + const tables = await svc(c).listSqlTables(cloud, c.req.param('id'), connection) + return c.json(tables) + }) + }) + + app.post('/:cloud/services/database/resources/:id/sql/query', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json() + const {query = '', ...connection} = body + const result = await svc(c).querySql(cloud, c.req.param('id'), connection, query) + return c.json(result) + }) + }) + app.get('/:cloud/services/:service/resources/:id', async (c) => { const cloud = c.req.param('cloud') as CloudProvider const serviceType = c.req.param('service') as CloudServiceType @@ -261,7 +295,7 @@ function isCloudProvider(value: string): value is CloudProvider { } function isServiceType(value: string): value is CloudServiceType { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' + return value === 'storage' || value === 'k8s' || value === 'database' || value === 'nosql' || value === 'serverless' || value === 'compute' || value === 'networking' } async function withRuntime(c: Context, handler: () => Promise): Promise { diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 826bf12..bff259b 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -12,6 +12,10 @@ import type { ResourceQuery, ServerlessInvokeResult, ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, StorageObjectDownload, StorageObjectList, } from '../cloud-spi/types' @@ -20,6 +24,7 @@ import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {serverlessSchemaFor} from '../cloud-spi/serverlessSchema' import {k8sSchemaFor} from '../cloud-spi/eksSchema' import {databaseSchemaFor} from '../cloud-spi/databaseSchema' +import {noSqlSchemaFor} from '../cloud-spi/noSqlSchema' import {azureEndpoint} from '../azure' import {checkGcpRuntime, gcpEndpoint} from '../gcp' @@ -55,6 +60,12 @@ export class CloudProxyService { displayName: 'Database', availability: this.registry.get(cloud, 'database') ? 'available' : 'coming_soon', }) + services.push({ + cloud, + service: 'nosql', + displayName: 'NoSQL', + availability: this.registry.get(cloud, 'nosql') ? 'available' : 'coming_soon', + }) services.push({ cloud, service: 'serverless', @@ -82,6 +93,7 @@ export class CloudProxyService { if (service === 'storage') return storageSchemaFor(cloud) if (service === 'k8s') return k8sSchemaFor(cloud) if (service === 'database') return databaseSchemaFor(cloud) + if (service === 'nosql') return noSqlSchemaFor(cloud) if (service === 'serverless') return serverlessSchemaFor(cloud) return null } @@ -200,47 +212,65 @@ async invokeResource( } async listCosmosContainers(cloud: CloudProvider, databaseId: string): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.listCosmosContainers) throw new Error(`Cosmos containers are not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.listCosmosContainers) throw new Error(`Cosmos containers are not supported for ${cloud}/nosql`) return adapter.listCosmosContainers(databaseId) } async createCosmosContainer(cloud: CloudProvider, databaseId: string, input: CreateResourceInput): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.createCosmosContainer) throw new Error(`Cosmos container creation is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.createCosmosContainer) throw new Error(`Cosmos container creation is not supported for ${cloud}/nosql`) return adapter.createCosmosContainer(databaseId, input) } async deleteCosmosContainer(cloud: CloudProvider, databaseId: string, containerId: string): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.deleteCosmosContainer) throw new Error(`Cosmos container deletion is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.deleteCosmosContainer) throw new Error(`Cosmos container deletion is not supported for ${cloud}/nosql`) await adapter.deleteCosmosContainer(databaseId, containerId) } async listCosmosItems(cloud: CloudProvider, databaseId: string, containerId: string): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.listCosmosItems) throw new Error(`Cosmos items are not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.listCosmosItems) throw new Error(`Cosmos items are not supported for ${cloud}/nosql`) return adapter.listCosmosItems(databaseId, containerId) } async upsertCosmosItem(cloud: CloudProvider, databaseId: string, containerId: string, document: Record): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.upsertCosmosItem) throw new Error(`Cosmos item upsert is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.upsertCosmosItem) throw new Error(`Cosmos item upsert is not supported for ${cloud}/nosql`) return adapter.upsertCosmosItem(databaseId, containerId, document) } async deleteCosmosItem(cloud: CloudProvider, databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.deleteCosmosItem) throw new Error(`Cosmos item deletion is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.deleteCosmosItem) throw new Error(`Cosmos item deletion is not supported for ${cloud}/nosql`) await adapter.deleteCosmosItem(databaseId, containerId, itemId, partitionKey) } async queryCosmosItems(cloud: CloudProvider, databaseId: string, containerId: string, query: string): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.queryCosmosItems) throw new Error(`Cosmos query is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.queryCosmosItems) throw new Error(`Cosmos query is not supported for ${cloud}/nosql`) return adapter.queryCosmosItems(databaseId, containerId, query) } + async listSqlDatabases(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listSqlDatabases) throw new Error(`SQL database browsing is not supported for ${cloud}/database`) + return adapter.listSqlDatabases(serverId, connection) + } + + async listSqlTables(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listSqlTables) throw new Error(`SQL table browsing is not supported for ${cloud}/database`) + return adapter.listSqlTables(serverId, connection) + } + + async querySql(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput, query: string): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.querySql) throw new Error(`SQL query is not supported for ${cloud}/database`) + return adapter.querySql(serverId, connection, query) + } + private requireAdapter(cloud: CloudProvider, service: CloudServiceType) { const adapter = this.registry.get(cloud, service) if (!adapter) throw new Error(`No adapter registered for ${cloud}/${service}`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index a45b849..cb67c20 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -30,21 +30,28 @@ export const apiEndpointKeys = { copy: "clouds.services.storage.objects.copy", }, }, - database: { + nosql: { cosmos: { containers: { - list: "clouds.services.database.cosmos.containers.list", - create: "clouds.services.database.cosmos.containers.create", - delete: "clouds.services.database.cosmos.containers.delete", + list: "clouds.services.nosql.cosmos.containers.list", + create: "clouds.services.nosql.cosmos.containers.create", + delete: "clouds.services.nosql.cosmos.containers.delete", }, items: { - list: "clouds.services.database.cosmos.items.list", - upsert: "clouds.services.database.cosmos.items.upsert", - delete: "clouds.services.database.cosmos.items.delete", - query: "clouds.services.database.cosmos.items.query", + list: "clouds.services.nosql.cosmos.items.list", + upsert: "clouds.services.nosql.cosmos.items.upsert", + delete: "clouds.services.nosql.cosmos.items.delete", + query: "clouds.services.nosql.cosmos.items.query", }, }, }, + database: { + sql: { + databases: "clouds.services.database.sql.databases.list", + tables: "clouds.services.database.sql.tables.list", + query: "clouds.services.database.sql.query", + }, + }, }, aws: { eks: { @@ -293,57 +300,81 @@ export const endpointRegistry: EndpointRegistry = new Map([ }, ], [ - apiEndpointKeys.clouds.database.cosmos.containers.list, + apiEndpointKeys.clouds.nosql.cosmos.containers.list, { - path: "/clouds/:cloud/services/database/resources/:id/containers", + path: "/clouds/:cloud/services/nosql/resources/:id/containers", method: "GET", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.containers.create, + apiEndpointKeys.clouds.nosql.cosmos.containers.create, { - path: "/clouds/:cloud/services/database/resources/:id/containers", + path: "/clouds/:cloud/services/nosql/resources/:id/containers", method: "POST", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.containers.delete, + apiEndpointKeys.clouds.nosql.cosmos.containers.delete, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId", method: "DELETE", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.items.list, + apiEndpointKeys.clouds.nosql.cosmos.items.list, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId/items", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items", method: "GET", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.items.upsert, + apiEndpointKeys.clouds.nosql.cosmos.items.upsert, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId/items", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items", method: "POST", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.items.delete, + apiEndpointKeys.clouds.nosql.cosmos.items.delete, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId/items/:itemId", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId", method: "DELETE", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.items.query, + apiEndpointKeys.clouds.nosql.cosmos.items.query, + { + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/query", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.sql.databases, + { + path: "/clouds/:cloud/services/database/resources/:id/sql/databases", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.sql.tables, + { + path: "/clouds/:cloud/services/database/resources/:id/sql/tables", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.sql.query, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId/query", + path: "/clouds/:cloud/services/database/resources/:id/sql/query", method: "POST", telemetry: { service: "cloud-proxy" }, }, diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 9bd9fc7..3f85e77 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -6,12 +6,26 @@ import type { CloudServiceType, CloudStatus, } from "@/types/cloud"; -import type { CloudResource, CosmosContainer, CosmosItem, CosmosQueryResult, StorageObjectList } from "@/types/resource"; +import type { + CloudResource, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + SqlCredentials, + SqlDatabase, + SqlEngine, + SqlQueryResult, + SqlTable, + StorageObjectList, +} from "@/types/resource"; import type { ServiceSchema } from "@/types/schema"; import { getAccountId } from "@/lib/accountStore"; type CloudPathParams = Record; +const AZURE_DATABASE_CREATE_TIMEOUT_MS = 5 * 60_000; +const SQL_DATA_TIMEOUT_MS = 45_000; + export async function listClouds( signal?: AbortSignal, ): Promise { @@ -93,9 +107,13 @@ export async function createCloudResource( values: Record, signal?: AbortSignal, ): Promise { + const timeout = + cloud === "azure" && service === "database" + ? AZURE_DATABASE_CREATE_TIMEOUT_MS + : undefined; const res = await apiClient.call>( apiEndpointKeys.clouds.resources.create, - requestOptions(cloud, service, { signal, body: values }), + requestOptions(cloud, service, { signal, body: values, timeout }), { cloud, service }, ); return res.data; @@ -229,8 +247,8 @@ export async function listCosmosContainers( signal?: AbortSignal, ): Promise { const res = await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.containers.list, - requestOptions(cloud, "database", { signal }), + apiEndpointKeys.clouds.nosql.cosmos.containers.list, + requestOptions(cloud, "nosql", { signal }), databasePathParams(cloud, databaseId), ); return res.data; @@ -243,8 +261,8 @@ export async function createCosmosContainer( signal?: AbortSignal, ): Promise { const res = await apiClient.call>( - apiEndpointKeys.clouds.database.cosmos.containers.create, - requestOptions(cloud, "database", { signal, body: values }), + apiEndpointKeys.clouds.nosql.cosmos.containers.create, + requestOptions(cloud, "nosql", { signal, body: values }), databasePathParams(cloud, databaseId), ); return res.data; @@ -257,8 +275,8 @@ export async function deleteCosmosContainer( signal?: AbortSignal, ): Promise { await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.containers.delete, - requestOptions(cloud, "database", { signal }), + apiEndpointKeys.clouds.nosql.cosmos.containers.delete, + requestOptions(cloud, "nosql", { signal }), { ...databasePathParams(cloud, databaseId), containerId }, ); } @@ -272,8 +290,8 @@ export async function listCosmosItems( signal?: AbortSignal, ): Promise { const res = await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.items.list, - requestOptions(cloud, "database", { signal }), + apiEndpointKeys.clouds.nosql.cosmos.items.list, + requestOptions(cloud, "nosql", { signal }), { ...databasePathParams(cloud, databaseId), containerId }, ); return res.data; @@ -287,8 +305,8 @@ export async function upsertCosmosItem( signal?: AbortSignal, ): Promise { const res = await apiClient.call>( - apiEndpointKeys.clouds.database.cosmos.items.upsert, - requestOptions(cloud, "database", { signal, body: document }), + apiEndpointKeys.clouds.nosql.cosmos.items.upsert, + requestOptions(cloud, "nosql", { signal, body: document }), { ...databasePathParams(cloud, databaseId), containerId }, ); return res.data; @@ -303,8 +321,8 @@ export async function deleteCosmosItem( signal?: AbortSignal, ): Promise { await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.items.delete, - requestOptions(cloud, "database", { + apiEndpointKeys.clouds.nosql.cosmos.items.delete, + requestOptions(cloud, "nosql", { signal, params: partitionKey ? { partitionKey } : undefined, }), @@ -320,13 +338,79 @@ export async function queryCosmosItems( signal?: AbortSignal, ): Promise { const res = await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.items.query, - requestOptions(cloud, "database", { signal, body: { query } }), + apiEndpointKeys.clouds.nosql.cosmos.items.query, + requestOptions(cloud, "nosql", { signal, body: { query } }), { ...databasePathParams(cloud, databaseId), containerId }, ); return res.data; } +export async function listSqlDatabases( + cloud: CloudProvider, + serverId: string, + engine: SqlEngine, + credentials: SqlCredentials, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.sql.databases, + requestOptions(cloud, "database", { + signal, + body: { ...credentials, engine }, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + +export async function listSqlTables( + cloud: CloudProvider, + serverId: string, + engine: SqlEngine, + database: string, + credentials: SqlCredentials, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call< + SqlTable[], + SqlCredentials & { database: string; engine: SqlEngine } + >( + apiEndpointKeys.clouds.database.sql.tables, + requestOptions(cloud, "database", { + signal, + body: { ...credentials, database, engine }, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + +export async function querySql( + cloud: CloudProvider, + serverId: string, + engine: SqlEngine, + database: string, + credentials: SqlCredentials, + query: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call< + SqlQueryResult, + SqlCredentials & { database: string; engine: SqlEngine; query: string } + >( + apiEndpointKeys.clouds.database.sql.query, + requestOptions(cloud, "database", { + signal, + body: { ...credentials, database, engine, query }, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + function requestOptions( cloud: CloudProvider, service: string, @@ -336,6 +420,7 @@ function requestOptions( body?: TBody; rawBody?: BodyInit; headers?: HeadersInit; + timeout?: number; } = {}, ) { return { diff --git a/packages/frontend/src/components/AzureSqlPanel.tsx b/packages/frontend/src/components/AzureSqlPanel.tsx new file mode 100644 index 0000000..36cd312 --- /dev/null +++ b/packages/frontend/src/components/AzureSqlPanel.tsx @@ -0,0 +1,436 @@ +import { FormEvent, useEffect, useState } from "react"; +import { Code2, Database, Play, Plug, RefreshCw, Table2, Unplug } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { + listSqlDatabases, + listSqlTables, + querySql, +} from "@/api/cloudProxyClient"; +import type { CloudProvider } from "@/types/cloud"; +import type { + CloudResource, + SqlCredentials, + SqlDatabase, + SqlEngine, + SqlQueryResult, + SqlTable, +} from "@/types/resource"; + +const SQL_SERVER_DEFAULT_QUERY = `SELECT + DB_NAME() AS database_name, + @@SERVERNAME AS server_name, + @@VERSION AS version;`; +const POSTGRES_DEFAULT_QUERY = `SELECT + current_database() AS database_name, + current_user AS username, + version();`; + +interface AzureSqlPanelProps { + cloud: CloudProvider; + resource?: CloudResource; + runtimeReachable: boolean; +} + +export function AzureSqlPanel({ + cloud, + resource, + runtimeReachable, +}: AzureSqlPanelProps) { + const serverId = resource?.name; + const engine: SqlEngine = + resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql"; + const defaultDatabase = engine === "postgresql" ? "postgres" : "master"; + const defaultQuery = + engine === "postgresql" ? POSTGRES_DEFAULT_QUERY : SQL_SERVER_DEFAULT_QUERY; + const administratorLogin = metadataString( + resource?.metadata.administratorLogin, + ); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [connected, setConnected] = useState(false); + const [databases, setDatabases] = useState([]); + const [selectedDatabase, setSelectedDatabase] = useState(defaultDatabase); + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(); + const [queryText, setQueryText] = useState(defaultQuery); + + const credentials: SqlCredentials = { username, password }; + + const tablesMut = useMutation({ + mutationFn: (database: string) => + listSqlTables(cloud, serverId ?? "", engine, database, credentials), + onSuccess: setTables, + }); + + const databasesMut = useMutation({ + mutationFn: () => + listSqlDatabases(cloud, serverId ?? "", engine, credentials), + onSuccess: (items) => { + setDatabases(items); + setConnected(true); + const database = items.some((item) => item.name === selectedDatabase) + ? selectedDatabase + : (items[0]?.name ?? defaultDatabase); + setSelectedDatabase(database); + tablesMut.mutate(database); + }, + }); + + const queryMut = useMutation({ + mutationFn: ({ database, query }: { database: string; query: string }) => + querySql(cloud, serverId ?? "", engine, database, credentials, query), + }); + + useEffect(() => { + setUsername(administratorLogin); + setPassword(""); + setConnected(false); + setDatabases([]); + setSelectedDatabase(defaultDatabase); + setTables([]); + setSelectedTable(undefined); + setQueryText(defaultQuery); + }, [administratorLogin, cloud, defaultDatabase, defaultQuery, serverId]); + + if (cloud !== "azure") return null; + + if (!serverId) { + return ( +
+
+

Select an Azure database server

+

Databases, tables, rows, and SQL queries load after connection.

+
+
+ ); + } + + function connect(event: FormEvent) { + event.preventDefault(); + if (!username || !password) return; + tablesMut.reset(); + queryMut.reset(); + databasesMut.mutate(); + } + + function disconnect() { + setConnected(false); + setPassword(""); + setDatabases([]); + setTables([]); + setSelectedTable(undefined); + databasesMut.reset(); + tablesMut.reset(); + queryMut.reset(); + } + + function selectDatabase(database: SqlDatabase) { + setSelectedDatabase(database.name); + setSelectedTable(undefined); + setTables([]); + queryMut.reset(); + tablesMut.mutate(database.name); + } + + function previewTable(table: SqlTable) { + const tableName = `${quoteIdentifier(table.schema, engine)}.${quoteIdentifier(table.name, engine)}`; + const query = engine === "postgresql" + ? `SELECT * FROM ${tableName} LIMIT 100;` + : `SELECT TOP (100) * FROM ${tableName};`; + setSelectedTable(table); + setQueryText(query); + queryMut.mutate({ database: selectedDatabase, query }); + } + + function runQuery() { + if (!queryText.trim()) return; + queryMut.mutate({ database: selectedDatabase, query: queryText }); + } + + const connectionError = + databasesMut.error instanceof Error ? databasesMut.error.message : null; + + return ( +
+
+
+ + + {engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"} + {resource.name} + {metadataString(resource.metadata.fullyQualifiedDomainName)} + +
+ + + {connected ? ( + + ) : ( + + )} + + Credentials remain in this page only and are sent to the local API per request. + + {connectionError &&
{connectionError}
} +
+ + {!connected ? ( +
+

Connect to browse SQL data

+

Use the administrator login configured for this local engine.

+
+ ) : ( +
+
+ databasesMut.mutate()} + > + + + } + /> +
+ {databases.map((database) => ( + + ))} +
+
+ +
+ tablesMut.mutate(selectedDatabase)} + > + + + } + /> + {tablesMut.error instanceof Error && ( +
{tablesMut.error.message}
+ )} +
+ {tablesMut.isPending &&
Loading objects
} + {!tablesMut.isPending && tables.length === 0 && ( +
No user tables or views
+ )} + {tables.map((table) => ( + + ))} +
+
+ +
+ +
+