Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ 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 server and Cosmos DB NoSQL 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,9 +105,10 @@ Current gaps:
<details>
<summary><strong>Database</strong></summary>

Two different database models are currently exposed under one category:
Three different database models are currently exposed under one category:

- AWS RDS: list and inspect oriented.
- Azure SQL Database: server list, create, delete, and inspect workflows.
- Azure Cosmos DB NoSQL: database, container, and document workflows.

Cosmos DB currently includes:
Expand Down
20 changes: 18 additions & 2 deletions packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import {describe, expect, test} from 'bun:test'
import {AzureDatabaseAdapter} from './AzureDatabaseAdapter'
import {AzureSqlAdapter} from './AzureSqlAdapter'
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'

describe('AzureDatabaseAdapter', () => {
test('combines Cosmos databases and Azure SQL servers', async () => {
const client = testClient({
'/dbs': {_count: 1, Databases: [{id: 'cosmosdb'}]},
'/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers?api-version=2021-11-01': {
value: [{name: 'app-sql', location: 'eastus', properties: {state: 'Ready'}}],
},
})
const adapter = new AzureDatabaseAdapter(client, new AzureSqlAdapter(client))

await expect(adapter.list()).resolves.toMatchObject([
{id: 'cosmosdb', type: 'cosmos-database'},
{id: 'sql-server:app-sql', type: 'sql-server'},
])
})

test('normalizes Cosmos databases as cloud database resources', async () => {
const adapter = new AzureDatabaseAdapter(testClient({
'/dbs': {
Expand Down Expand Up @@ -113,7 +129,7 @@ describe('AzureDatabaseAdapter', () => {
}, calls))

await expect(adapter.list()).resolves.toMatchObject([{id: 'fallbackdb'}])
expect(calls.map((call) => call.path)).toEqual(['/dbs', '/devstoreaccount1-cosmos/dbs'])
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 () => {
Expand All @@ -128,7 +144,7 @@ describe('AzureDatabaseAdapter', () => {
}, calls))

await expect(adapter.list()).resolves.toMatchObject([{id: 'nosqldb'}])
expect(calls.map((call) => call.path)).toEqual([
expect(calls.map((call) => call.path).filter((path) => path.endsWith('/dbs'))).toEqual([
'/dbs',
'/devstoreaccount1-cosmos/dbs',
'/devstoreaccount1-cosmos-nosql/dbs',
Expand Down
15 changes: 13 additions & 2 deletions packages/api/src/adapter-azure/AzureDatabaseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
import {AzureSqlAdapter, isSqlServerResourceId} from './AzureSqlAdapter'

interface CosmosListResponse<T> {
_count?: number
Expand All @@ -24,7 +25,10 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter {
readonly cloud = 'azure' as const
readonly service = 'database' as const

constructor(private readonly client: AzureRuntimeClient = azure) {}
constructor(
private readonly client: AzureRuntimeClient = azure,
private readonly sqlAdapter: AzureSqlAdapter = new AzureSqlAdapter(client),
) {}

schema(): ServiceSchema {
return azureDatabaseSchema()
Expand All @@ -33,15 +37,18 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter {
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const body = await this.cosmosJson<CosmosListResponse<CosmosRecord>>('/dbs', {method: 'GET'}, {emptyOnNotFound: true})
const databases = body?.Databases ?? []
return filterBySearch(databases.map(toDatabaseResource), query.search)
const cosmosResources = filterBySearch(databases.map(toDatabaseResource), query.search)
return [...cosmosResources, ...await this.sqlAdapter.list(query)]
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}

async get(id: string): Promise<CloudResource | null> {
if (isSqlServerResourceId(id)) return this.sqlAdapter.get(id)
const body = await this.cosmosJson<CosmosRecord>(`/dbs/${encodeSegment(id)}`, {method: 'GET'}, {emptyOnNotFound: true})
return body ? toDatabaseResource(body) : null
}

async create(input: CreateResourceInput): Promise<CloudResource> {
if (input.values.resourceType === 'sql-server') return this.sqlAdapter.create(input)
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.')
Expand All @@ -55,6 +62,10 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter {
}

async delete(id: string): Promise<void> {
if (isSqlServerResourceId(id)) {
await this.sqlAdapter.delete(id)
return
}
await this.cosmosFetch(`/dbs/${encodeSegment(id)}`, {method: 'DELETE'})
}

Expand Down
126 changes: 126 additions & 0 deletions packages/api/src/adapter-azure/AzureSqlAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {describe, expect, test} from 'bun:test'
import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure'
import {AzureSqlAdapter} from './AzureSqlAdapter'

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()
})
})

function testClient(responses: Record<string, unknown>, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient {
return {
endpoint: 'http://localhost:4577',
accountName: 'devstoreaccount1',
async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) {
calls.push({path, init})
if (!(path in responses)) {
if (options.emptyOnNotFound) return null
throw new Error(`Unexpected Azure runtime request: ${path}`)
}
return new Response(JSON.stringify(responses[path]), {status: 200, headers: {'content-type': 'application/json'}})
},
}
}
Loading