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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -105,23 +106,32 @@ Current gaps:
<details>
<summary><strong>Database</strong></summary>

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.

</details>

<details>
<summary><strong>NoSQL</strong></summary>

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.

</details>

Expand Down
6 changes: 5 additions & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
204 changes: 63 additions & 141 deletions packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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<CloudResource[]> {
calls.push(`${engine}:list`)
return [resource]
},
async get(): Promise<CloudResource> {
return resource
},
async create(_input: CreateResourceInput): Promise<CloudResource> {
calls.push(`${engine}:create`)
return resource
},
async delete(): Promise<void> {},
async listSqlDatabases(_serverId: string, _connection: SqlConnectionInput): Promise<SqlDatabase[]> {
return []
},
async listSqlTables(_serverId: string, _connection: SqlConnectionInput): Promise<SqlTable[]> {
return []
},
async querySql(_serverId: string, _connection: SqlConnectionInput, _query: string): Promise<SqlQueryResult> {
calls.push(`${engine}:query`)
return {resultSets: [], rowsAffected: [], durationMs: 0}
},
}
}
Loading