diff --git a/README.md b/README.md index 5079987..410c6df 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Compute | EKS / AKS / GKE | Yes (list, inspect) | No | Yes (list, create, inspect, delete) | | Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | | Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | -| Databases | Database | Yes (list, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | +| Databases | Database | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | | Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No | | Networking | Networking | Yes (list) | No | No | | Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | No | No | @@ -134,12 +134,15 @@ Current gaps:
Database -Two different database models are currently exposed under one category: +Relational and document database workflows across providers: -- AWS RDS: list and inspect oriented. +- AWS RDS: list, inspect, create, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). +- AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation. - Azure Cosmos DB NoSQL: database, container, and document workflows. +- Azure SQL and PostgreSQL Flexible Server: instance management and SQL query editor. +- GCP Cloud SQL: list, inspect, create, and delete database instances. -Cosmos DB currently includes: +Cosmos DB includes: - List, create, and delete databases. - List, create, and delete containers. @@ -148,8 +151,8 @@ Cosmos DB currently includes: Current gaps: -- No unified cross-provider database contract beyond the shared category shell. -- No GCP database adapter yet. +- AWS RDS snapshot creation: the Cloud Proxy operation is available, but the current Floci runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list. +- AWS RDS instance stop/start and update operations are not implemented in the current local Floci runtime. - AWS DynamoDB is not rebuilt into the new Cloud Explorer model yet.
diff --git a/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts b/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts new file mode 100644 index 0000000..b3a3e33 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts @@ -0,0 +1,417 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateDBInstanceCommand, + CreateDBSnapshotCommand, + DeleteDBInstanceCommand, + DescribeDBInstancesCommand, + DescribeDBSnapshotsCommand, + DescribeOrderableDBInstanceOptionsCommand, + ListTagsForResourceCommand, + type RDSClient, +} from '@aws-sdk/client-rds' +import {ConflictError, ValidationError} from '../cloud-spi/errors' +import {createRdsService} from '../services/rds' +import {mapAwsSdkError} from './awsErrors' +import {AwsDatabaseAdapter} from './AwsDatabaseAdapter' + +type RdsCommand = + | CreateDBInstanceCommand + | CreateDBSnapshotCommand + | DeleteDBInstanceCommand + | DescribeDBInstancesCommand + | DescribeDBSnapshotsCommand + | DescribeOrderableDBInstanceOptionsCommand + | ListTagsForResourceCommand + +type Responder = (command: RdsCommand) => unknown | Promise + +function recordingRds(responder: Responder) { + const commands: RdsCommand[] = [] + const client = { + async send(command: RdsCommand) { + commands.push(command) + return responder(command) + }, + } as unknown as RDSClient + + return {client, commands} +} + +function adapterWith(responder: Responder) { + const recording = recordingRds(responder) + return { + ...recording, + adapter: new AwsDatabaseAdapter(createRdsService(recording.client), recording.client), + } +} + +function instanceResponse(command: CreateDBInstanceCommand) { + return { + DBInstance: { + DBInstanceIdentifier: command.input.DBInstanceIdentifier, + Engine: command.input.Engine, + EngineVersion: command.input.EngineVersion, + DBInstanceClass: command.input.DBInstanceClass, + AllocatedStorage: command.input.AllocatedStorage, + MasterUsername: command.input.MasterUsername, + DBName: command.input.DBName, + DBInstanceStatus: 'creating', + }, + } +} + +const validCreateValues = { + dbInstanceIdentifier: 'orders-db', + engine: 'postgres', + masterUserPassword: 'secret-pass', +} + +describe('AwsDatabaseAdapter', () => { + test('keeps list search behavior intact', async () => { + const {adapter} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(DescribeDBInstancesCommand) + return { + DBInstances: [ + {DBInstanceIdentifier: 'orders-db', Engine: 'postgres', VpcSecurityGroups: []}, + {DBInstanceIdentifier: 'users-db', Engine: 'mysql', VpcSecurityGroups: []}, + ], + } + }) + + const resources = await adapter.list({search: ' ORDERS '}) + + expect(resources.map(({id}) => id)).toEqual(['orders-db']) + }) + + test('keeps get 404 behavior intact', async () => { + const {adapter} = adapterWith(async () => { + throw Object.assign(new Error('not found'), {$metadata: {httpStatusCode: 404}}) + }) + + await expect(adapter.get('missing-db')).resolves.toBeNull() + }) + + test('creates an instance with required values and provider defaults', async () => { + const {adapter, commands} = adapterWith((command) => { + expect(command).toBeInstanceOf(CreateDBInstanceCommand) + return instanceResponse(command as CreateDBInstanceCommand) + }) + + const resource = await adapter.create({values: { + dbInstanceIdentifier: ' orders-db ', + engine: 'postgres', + masterUserPassword: 'secret-pass', + }}) + + const createCommand = commands[0] as CreateDBInstanceCommand + const {MasterUserPassword, ...nonSensitiveInput} = createCommand.input + expect(nonSensitiveInput).toEqual({ + DBInstanceIdentifier: 'orders-db', + Engine: 'postgres', + DBInstanceClass: 'db.t3.micro', + AllocatedStorage: 20, + MasterUsername: 'root', + }) + expect(MasterUserPassword === validCreateValues.masterUserPassword).toBe(true) + expect(JSON.stringify(resource).includes(validCreateValues.masterUserPassword)).toBe(false) + expect(resource).toMatchObject({ + id: 'orders-db', + name: 'orders-db', + type: 'db-instance', + status: 'creating', + engine: 'postgres', + instanceClass: 'db.t3.micro', + }) + }) + + test('maps all nine create fields including security groups', async () => { + const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand)) + + await adapter.create({values: { + dbInstanceIdentifier: 'orders-db', + engine: 'mariadb', + masterUserPassword: 'secret-pass', + dbInstanceClass: ' db.m6g.large ', + allocatedStorage: '100', + masterUsername: ' app_user ', + dbName: ' orders ', + engineVersion: ' 10.11 ', + securityGroupIds: 'sg-12345678, sg-87654321', + }}) + + const createCommand = commands[0] as CreateDBInstanceCommand + const {MasterUserPassword, ...nonSensitiveInput} = createCommand.input + expect(nonSensitiveInput).toEqual({ + DBInstanceIdentifier: 'orders-db', + Engine: 'mariadb', + DBInstanceClass: 'db.m6g.large', + AllocatedStorage: 100, + MasterUsername: 'app_user', + DBName: 'orders', + EngineVersion: '10.11', + VpcSecurityGroupIds: ['sg-12345678', 'sg-87654321'], + }) + expect(MasterUserPassword === validCreateValues.masterUserPassword).toBe(true) + }) + + test('accepts array of security group ids', async () => { + const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand)) + + await adapter.create({values: { + ...validCreateValues, + securityGroupIds: ['sg-11111111', 'sg-22222222'], + }}) + + const createCommand = commands[0] as CreateDBInstanceCommand + expect(createCommand.input.VpcSecurityGroupIds).toEqual(['sg-11111111', 'sg-22222222']) + }) + + test('uses defaults for blank values and omits blank database name and engine version', async () => { + const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand)) + + await adapter.create({values: { + ...validCreateValues, + dbInstanceClass: ' ', + allocatedStorage: '', + masterUsername: ' ', + dbName: ' ', + }}) + + const input = (commands[0] as CreateDBInstanceCommand).input + expect(input.DBInstanceClass).toBe('db.t3.micro') + expect(input.AllocatedStorage).toBe(20) + expect(input.MasterUsername).toBe('root') + expect(input.DBName).toBeUndefined() + expect(input.EngineVersion).toBeUndefined() + }) + + test('rejects invalid instance identifiers before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + for (const dbInstanceIdentifier of ['', 'Orders-db', '1-orders', 'orders--db', 'orders-', 'a'.repeat(64)]) { + await expect(adapter.create({values: { + ...validCreateValues, + dbInstanceIdentifier, + }})).rejects.toBeInstanceOf(ValidationError) + } + expect(commands).toHaveLength(0) + }) + + test('rejects unsupported engines before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + for (const engine of ['', 'oracle', 'POSTGRES', 42]) { + await expect(adapter.create({values: {...validCreateValues, engine}})).rejects.toBeInstanceOf(ValidationError) + } + expect(commands).toHaveLength(0) + }) + + test('rejects invalid passwords without exposing them or calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + for (const masterUserPassword of ['', 'short', 'x'.repeat(129), 42]) { + let caught: unknown + try { + await adapter.create({values: {...validCreateValues, masterUserPassword}}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(ValidationError) + const exposesPassword = caught instanceof Error + && typeof masterUserPassword === 'string' + && masterUserPassword.length > 0 + && caught.message.includes(masterUserPassword) + expect(exposesPassword).toBe(false) + } + expect(commands).toHaveLength(0) + }) + + test('rejects non-positive, fractional, and non-numeric storage before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + for (const allocatedStorage of [0, -1, 1.5, '0', '-1', '1.5', 'twenty', true]) { + await expect(adapter.create({values: { + ...validCreateValues, + allocatedStorage, + }})).rejects.toBeInstanceOf(ValidationError) + } + expect(commands).toHaveLength(0) + }) + + test('rejects storage that exceeds the safe integer range before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + await expect(adapter.create({values: { + ...validCreateValues, + allocatedStorage: '9'.repeat(400), + }})).rejects.toBeInstanceOf(ValidationError) + expect(commands).toHaveLength(0) + }) + + test('leaves provider failures for the shared AWS error mapper', async () => { + const providerError = Object.assign(new Error('instance already exists'), { + name: 'DBInstanceAlreadyExistsFault', + $fault: 'client', + $metadata: {httpStatusCode: 400}, + }) + const {adapter} = adapterWith(async () => { + throw providerError + }) + + let caught: unknown + try { + await adapter.create({values: validCreateValues}) + } catch (error) { + caught = error + } + + expect(caught).toBe(providerError) + expect(mapAwsSdkError(caught)).toBeInstanceOf(ConflictError) + expect(caught instanceof Error && caught.message.includes(validCreateValues.masterUserPassword)).toBe(false) + }) + + test('deletes without a final snapshot', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + await adapter.delete('orders-db') + + const deleteCommand = commands[0] as DeleteDBInstanceCommand + expect(deleteCommand).toBeInstanceOf(DeleteDBInstanceCommand) + expect(deleteCommand.input).toEqual({ + DBInstanceIdentifier: 'orders-db', + SkipFinalSnapshot: true, + }) + }) + + test('lists and normalizes database snapshots', async () => { + const {adapter, commands} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(DescribeDBSnapshotsCommand) + return { + DBSnapshots: [{ + DBSnapshotIdentifier: 'orders-db-snapshot-1', + DBInstanceIdentifier: 'orders-db', + DBSnapshotArn: 'arn:aws:rds:eu-west-2:000000000000:snapshot:orders-db-snapshot-1', + Status: 'available', + Engine: 'postgres', + EngineVersion: '16.4', + AllocatedStorage: 100, + SnapshotType: 'manual', + SnapshotCreateTime: new Date('2026-08-14T09:30:00.000Z'), + Port: 5432, + AvailabilityZone: 'eu-west-2a', + VpcId: 'vpc-123', + }], + } + }) + + const snapshots = await adapter.listDatabaseSnapshots('orders-db') + + expect((commands[0] as DescribeDBSnapshotsCommand).input).toEqual({ + DBInstanceIdentifier: 'orders-db', + Marker: undefined, + }) + expect(snapshots).toEqual([{ + id: 'orders-db-snapshot-1', + name: 'orders-db-snapshot-1', + instanceIdentifier: 'orders-db', + status: 'available', + engine: 'postgres', + version: '16.4', + createdAt: '2026-08-14T09:30:00.000Z', + metadata: { + arn: 'arn:aws:rds:eu-west-2:000000000000:snapshot:orders-db-snapshot-1', + allocatedStorage: 100, + snapshotType: 'manual', + port: 5432, + availabilityZone: 'eu-west-2a', + vpcId: 'vpc-123', + }, + }]) + }) + + test('does not swallow snapshot provider failures', async () => { + const providerError = Object.assign(new Error('operation unavailable'), { + name: 'UnsupportedOperation', + $fault: 'client', + $metadata: {httpStatusCode: 501}, + }) + const {adapter} = adapterWith(async () => { + throw providerError + }) + + await expect(adapter.listDatabaseSnapshots()).rejects.toBe(providerError) + }) + + test('creates and normalizes a database snapshot', async () => { + const {adapter, commands} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(CreateDBSnapshotCommand) + return { + DBSnapshot: { + DBSnapshotIdentifier: 'orders-db-snapshot-1', + DBInstanceIdentifier: 'orders-db', + Status: 'creating', + Engine: 'postgres', + SnapshotType: 'manual', + }, + } + }) + + const snapshot = await adapter.createDatabaseSnapshot({ + instanceIdentifier: ' orders-db ', + snapshotIdentifier: ' orders-db-snapshot-1 ', + }) + + const createCommand = commands[0] as CreateDBSnapshotCommand + expect(createCommand.input).toEqual({ + DBInstanceIdentifier: 'orders-db', + DBSnapshotIdentifier: 'orders-db-snapshot-1', + }) + expect(snapshot).toMatchObject({ + id: 'orders-db-snapshot-1', + name: 'orders-db-snapshot-1', + instanceIdentifier: 'orders-db', + status: 'creating', + engine: 'postgres', + metadata: {snapshotType: 'manual'}, + }) + }) + + test('rejects missing and invalid snapshot identifiers before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + const invalidInputs = [ + {instanceIdentifier: '', snapshotIdentifier: 'orders-db-snapshot-1'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: ''}, + {instanceIdentifier: 'orders db', snapshotIdentifier: 'orders-db-snapshot-1'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: '1-orders-snapshot'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: 'orders--snapshot'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: 'orders-snapshot-'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: 'a'.repeat(256)}, + ] + + for (const input of invalidInputs) { + await expect(adapter.createDatabaseSnapshot(input)).rejects.toBeInstanceOf(ValidationError) + } + expect(commands).toHaveLength(0) + }) + + test('lists and deduplicates orderable instance classes', async () => { + const {adapter, commands} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(DescribeOrderableDBInstanceOptionsCommand) + return { + OrderableDBInstanceOptions: [ + {DBInstanceClass: 'db.t3.micro', Engine: 'postgres'}, + {DBInstanceClass: 'db.m8g.large', Engine: 'postgres'}, + {DBInstanceClass: 'db.t3.micro', Engine: 'postgres'}, + ], + } + }) + + const classes = await adapter.listDatabaseOrderableInstanceClasses('postgres') + + expect((commands[0] as DescribeOrderableDBInstanceOptionsCommand).input).toEqual({ + Engine: 'postgres', + Marker: undefined, + }) + expect(classes).toEqual(['db.t3.micro', 'db.m8g.large']) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts b/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts index 9fdaf29..c08a224 100644 --- a/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts +++ b/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts @@ -1,17 +1,22 @@ -import {NotSupportedError} from '../cloud-spi/errors' +import {ValidationError} from '../cloud-spi/errors' import {ListTagsForResourceCommand, type RDSClient} from '@aws-sdk/client-rds' import {rds as defaultRds} from '../aws' import {awsDatabaseSchema} from '../cloud-spi/databaseSchema' import type { CloudResource, CloudServiceAdapter, + CreateDatabaseSnapshotInput, CreateResourceInput, + DatabaseSnapshot, ResourceQuery, ServiceSchema, } from '../cloud-spi/types' -import {rdsService, type RdsInstance} from '../services/rds' +import {rdsService, type RdsInstance, type RdsSnapshot} from '../services/rds' -type RdsServiceShape = Pick +type RdsServiceShape = Pick< + typeof rdsService, + 'listInstances' | 'describeInstance' | 'createInstance' | 'deleteInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses' +> export class AwsDatabaseAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const @@ -41,12 +46,52 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter { } } - async create(_input: CreateResourceInput): Promise { - throw new NotSupportedError('Database creation is not supported from the dynamic Cloud Explorer.') + async create(input: CreateResourceInput): Promise { + const values = input.values + const dbInstanceIdentifier = instanceIdentifier(values.dbInstanceIdentifier) + const engine = databaseEngine(values.engine) + const masterUserPassword = password(values.masterUserPassword) + const dbInstanceClass = stringValue(values.dbInstanceClass) || 'db.t3.micro' + const allocatedStorage = storageSize(values.allocatedStorage) + const masterUsername = stringValue(values.masterUsername) || 'root' + const dbName = stringValue(values.dbName) || undefined + const engineVersion = stringValue(values.engineVersion) || undefined + const vpcSecurityGroupIds = securityGroupIds(values.securityGroupIds ?? values.vpcSecurityGroupIds) + + const instance = await this.rdsService_.createInstance({ + DBInstanceIdentifier: dbInstanceIdentifier, + Engine: engine, + MasterUserPassword: masterUserPassword, + DBInstanceClass: dbInstanceClass, + AllocatedStorage: allocatedStorage, + MasterUsername: masterUsername, + DBName: dbName, + EngineVersion: engineVersion, + VpcSecurityGroupIds: vpcSecurityGroupIds, + }) + return this.toResource(instance) + } + + async delete(id: string): Promise { + await this.rdsService_.deleteInstance(id) + } + + async listDatabaseSnapshots(instanceIdentifier?: string): Promise { + const normalizedIdentifier = stringValue(instanceIdentifier) || undefined + return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot) } - async delete(_id: string): Promise { - throw new NotSupportedError('Database deletion is not supported from the dynamic Cloud Explorer.') + async createDatabaseSnapshot(input: CreateDatabaseSnapshotInput): Promise { + const sourceIdentifier = instanceIdentifier(input.instanceIdentifier) + const snapshotIdentifier = databaseSnapshotIdentifier(input.snapshotIdentifier) + return snapshotToDatabaseSnapshot( + await this.rdsService_.createSnapshot(sourceIdentifier, snapshotIdentifier), + ) + } + + async listDatabaseOrderableInstanceClasses(engine?: string): Promise { + const normalizedEngine = stringValue(engine) || 'postgres' + return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine) } private async toResource(instance: RdsInstance): Promise { @@ -98,6 +143,91 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter { } } +const INSTANCE_IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/ +const SNAPSHOT_IDENTIFIER_PATTERN = /^(?!.*--)[A-Za-z](?:[A-Za-z0-9-]{0,253}[A-Za-z0-9])?$/ + +function instanceIdentifier(value: unknown): string { + const identifier = stringValue(value) + if (!INSTANCE_IDENTIFIER_PATTERN.test(identifier)) { + throw new ValidationError('dbInstanceIdentifier must be 1-63 lowercase letters, numbers, or hyphens, begin with a letter, and not end with or repeat a hyphen') + } + return identifier +} + +function databaseSnapshotIdentifier(value: unknown): string { + const identifier = stringValue(value) + if (!SNAPSHOT_IDENTIFIER_PATTERN.test(identifier)) { + throw new ValidationError('snapshotIdentifier must be 1-255 letters, numbers, or hyphens, begin with a letter, and not end with or repeat a hyphen') + } + return identifier +} + +function databaseEngine(value: unknown): 'postgres' | 'mysql' | 'mariadb' { + const engine = stringValue(value) + if (engine === 'postgres' || engine === 'mysql' || engine === 'mariadb') return engine + throw new ValidationError('engine must be postgres, mysql, or mariadb') +} + +function password(value: unknown): string { + if (typeof value !== 'string' || value.length < 8 || value.length > 128) { + throw new ValidationError('masterUserPassword must be 8-128 characters') + } + return value +} + +function storageSize(value: unknown): number { + if (value === undefined || (typeof value === 'string' && !value.trim())) return 20 + + const normalized = typeof value === 'string' ? value.trim() : value + const hasValidSyntax = typeof normalized === 'number' + || (typeof normalized === 'string' && /^[1-9][0-9]*$/.test(normalized)) + if (!hasValidSyntax) { + throw new ValidationError('allocatedStorage must be a positive integer') + } + + const storage = Number(normalized) + if (!Number.isSafeInteger(storage) || storage <= 0) { + throw new ValidationError('allocatedStorage must be a positive integer') + } + return storage +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function securityGroupIds(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + const list = value.map(stringValue).filter(Boolean) + return list.length > 0 ? list : undefined + } + if (typeof value === 'string') { + const list = value.split(',').map((s) => s.trim()).filter(Boolean) + return list.length > 0 ? list : undefined + } + return undefined +} + +function snapshotToDatabaseSnapshot(snapshot: RdsSnapshot): DatabaseSnapshot { + return { + id: snapshot.identifier, + name: snapshot.identifier, + instanceIdentifier: snapshot.instanceIdentifier ?? null, + status: snapshot.status ?? null, + engine: snapshot.engine ?? null, + version: snapshot.engineVersion ?? null, + createdAt: snapshot.createdAt ?? null, + metadata: { + arn: snapshot.arn, + allocatedStorage: snapshot.allocatedStorage, + snapshotType: snapshot.snapshotType, + port: snapshot.port, + availabilityZone: snapshot.availabilityZone, + vpcId: snapshot.vpcId, + }, + } +} + function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { const normalized = search?.trim().toLowerCase() if (!normalized) return resources diff --git a/packages/api/src/cloud-spi/databaseSchema.ts b/packages/api/src/cloud-spi/databaseSchema.ts index 46475b5..8b0daa1 100644 --- a/packages/api/src/cloud-spi/databaseSchema.ts +++ b/packages/api/src/cloud-spi/databaseSchema.ts @@ -27,8 +27,111 @@ export function awsDatabaseSchema(): ServiceSchema { cloud: 'aws', service: 'database', displayName: 'AWS RDS', - fields: [], - actions: ['list', 'inspect'], + fields: [ + { + name: 'dbInstanceIdentifier', + label: 'DB Instance Identifier', + type: 'text', + required: true, + validation: { + pattern: '^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$', + minLength: 1, + maxLength: 63, + }, + }, + { + name: 'engine', + label: 'Engine', + type: 'select', + required: true, + defaultValue: 'postgres', + options: [ + {label: 'PostgreSQL', value: 'postgres'}, + {label: 'MySQL', value: 'mysql'}, + {label: 'MariaDB', value: 'mariadb'}, + ], + }, + { + name: 'masterUserPassword', + label: 'Master User Password', + type: 'password', + required: true, + span: true, + validation: {minLength: 8, maxLength: 128}, + }, + { + name: 'dbInstanceClass', + label: 'DB Instance Class', + type: 'text', + required: false, + defaultValue: 'db.t3.micro', + }, + { + name: 'allocatedStorage', + label: 'Allocated Storage', + type: 'text', + required: false, + defaultValue: '20', + validation: {pattern: '^[1-9][0-9]*$'}, + }, + { + name: 'masterUsername', + label: 'Master Username', + type: 'text', + required: false, + defaultValue: 'root', + validation: {maxLength: 63}, + }, + { + name: 'dbName', + label: 'Database Name', + type: 'text', + required: false, + defaultValue: '', + validation: {maxLength: 64}, + }, + { + name: 'engineVersion', + label: 'Engine Version', + type: 'text', + required: false, + defaultValue: '', + }, + { + name: 'securityGroupIds', + label: 'Security Group IDs', + type: 'text', + required: false, + span: true, + description: 'One or more SG IDs separated by commas — e.g. sg-111, sg-222', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List DB instances', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create DB instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete DB instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect DB instance', enabled: true, status: 'available', runtimeRequired: true}, + ], + databaseActions: [ + { + name: 'listSnapshots', + label: 'List DB snapshots', + enabled: true, + status: 'available', + runtimeRequired: true, + }, + { + name: 'createSnapshot', + label: 'Create DB snapshot', + enabled: true, + status: 'partial', + reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.', + runtimeRequired: true, + }, + ], + }, filters: databaseFilters, columns: databaseColumns, } diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index cf32aa2..437162f 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -102,6 +102,7 @@ export type ResourceActionName = | 'reboot' | 'updateTags' export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy' +export type DatabaseActionName = 'listSnapshots' | 'createSnapshot' export type KubernetesActionName = | 'listNodegroups' | 'createNodegroup' @@ -150,6 +151,7 @@ export interface ServiceSchema { capabilities?: { resourceActions?: CapabilitySchema[] objectActions?: CapabilitySchema[] + databaseActions?: CapabilitySchema[] kubernetesActions?: CapabilitySchema[] } filters: FieldSchema[] @@ -179,6 +181,22 @@ export interface CloudResource { metadata: Record } +export interface DatabaseSnapshot { + id: string + name: string + instanceIdentifier: string | null + status: string | null + engine: string | null + version: string | null + createdAt: string | null + metadata: Record +} + +export interface CreateDatabaseSnapshotInput { + instanceIdentifier: string + snapshotIdentifier: string +} + export interface StorageObject { key: string name: string @@ -364,6 +382,9 @@ export interface CloudServiceAdapter { putObject?(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise getObject?(resourceId: string, key: string): Promise deleteObject?(resourceId: string, key: string): Promise + listDatabaseSnapshots?(instanceIdentifier?: string): Promise + createDatabaseSnapshot?(input: CreateDatabaseSnapshotInput): Promise + listDatabaseOrderableInstanceClasses?(engine?: string): Promise invoke?(id: string, payload: string): Promise // Lifecycle verbs. Optional because most categories have no notion of them; // an adapter that advertises one in `capabilities` must implement it, which diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index 72998e4..2120256 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -1,7 +1,7 @@ import {describe, expect, test} from 'bun:test' import {createCloudAdapterRegistry} from './cloudProxy' import {isServiceType} from './cloud-spi/serviceCatalog' -import type {CloudProvider, CloudServiceAdapter, CloudServiceType, KubernetesActionName, ResourceActionName} from './cloud-spi/types' +import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName} from './cloud-spi/types' /** * Guards the schema-to-implementation contract for every registered adapter. @@ -29,6 +29,11 @@ const METHOD_FOR_ACTION: Record = updateTags: 'updateTags', } +const METHOD_FOR_DATABASE_ACTION = { + listSnapshots: 'listDatabaseSnapshots', + createSnapshot: 'createDatabaseSnapshot', +} satisfies Record + const METHOD_FOR_KUBERNETES_ACTION: Record = { listNodegroups: 'listKubernetesNodegroups', createNodegroup: 'createKubernetesNodegroup', @@ -91,6 +96,18 @@ describe('registered adapters honour their schema', () => { ).toBe('function') } + const databaseCapabilities = adapter.schema().capabilities?.databaseActions ?? [] + const databaseClaims = databaseCapabilities.filter( + (capability) => capability.status === 'available' || (capability.status === 'partial' && capability.enabled), + ) + for (const capability of databaseClaims) { + const method = METHOD_FOR_DATABASE_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as ${capability.status} but has no ${String(method)}()`, + ).toBe('function') + } + const kubernetesCapabilities = adapter.schema().capabilities?.kubernetesActions ?? [] const kubernetesClaims = kubernetesCapabilities.filter((capability) => capability.status === 'available') for (const capability of kubernetesClaims) { @@ -106,6 +123,7 @@ describe('registered adapters honour their schema', () => { const capabilities = [ ...(adapter.schema().capabilities?.resourceActions ?? []), ...(adapter.schema().capabilities?.objectActions ?? []), + ...(adapter.schema().capabilities?.databaseActions ?? []), ...(adapter.schema().capabilities?.kubernetesActions ?? []), ] @@ -135,6 +153,41 @@ describe('registered adapters honour their schema', () => { }) describe('adapter capability advertisements match the runtime reality', () => { + test('AWS database advertises instance and snapshot capabilities', () => { + const schema = adapterFor('aws', 'database').schema() + + expect(schema.actions).toEqual(['list', 'create', 'delete', 'inspect']) + expect(schema.capabilities?.resourceActions?.map(({name, enabled, status, runtimeRequired}) => ({ + name, + enabled, + status, + runtimeRequired, + }))).toEqual([ + {name: 'list', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', enabled: true, status: 'available', runtimeRequired: true}, + ]) + expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'create')?.label).toBe('Create DB instance') + expect(schema.capabilities?.databaseActions).toEqual([ + { + name: 'listSnapshots', + label: 'List DB snapshots', + enabled: true, + status: 'available', + runtimeRequired: true, + }, + { + name: 'createSnapshot', + label: 'Create DB snapshot', + enabled: true, + status: 'partial', + reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.', + runtimeRequired: true, + }, + ]) + }) + test('AWS serverless advertises invoke and implements it', () => { const adapter = adapterFor('aws', 'serverless') const invoke = adapter.schema().capabilities?.resourceActions?.find((c) => c.name === 'invoke') diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 4ecafaa..c04a3e3 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -4,7 +4,6 @@ import { serveStatic } from "hono/bun"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; import eks from "./routes/eks"; -import rds from "./routes/rds"; import ec2 from "./routes/ec2"; import secretsmanager from "./routes/secretsmanager"; import clouds from "./routes/clouds"; @@ -42,7 +41,6 @@ app.use( app.use("*", logger()); app.route("/api/eks", eks); -app.route("/api/rds", rds); app.route("/api/ec2", ec2); app.route("/api/secretsmanager", secretsmanager); app.route("/api/clouds", clouds); diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index da98f2c..48b20fc 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -1,13 +1,24 @@ import {describe, expect, test} from 'bun:test' import {Hono} from 'hono' import {NotImplementedByRuntimeError, RuntimeUnavailableError, ValidationError} from '../cloud-spi/errors' -import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import {awsDatabaseSchema, azureDatabaseSchema} from '../cloud-spi/databaseSchema' import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema' import {awsDynamoDbSchema} from '../cloud-spi/dynamodbSchema' import {awsEksSchema} from '../cloud-spi/eksSchema' import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema' import {awsSesEmailSchema} from '../cloud-spi/emailSchema' -import type {CloudProvider, CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput, NoSqlItem} from '../cloud-spi/types' +import type { + CloudProvider, + CloudResource, + CloudServiceAdapter, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + CreateDatabaseSnapshotInput, + CreateResourceInput, + DatabaseSnapshot, + NoSqlItem, +} from '../cloud-spi/types' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {CloudProxyService} from '../service/CloudProxyService' import type {RuntimeProbe} from '../service/runtimeProbe' @@ -72,6 +83,151 @@ function appWithRoutes( return app } +const databaseSnapshot: DatabaseSnapshot = { + id: 'orders-db-snapshot-1', + name: 'orders-db-snapshot-1', + instanceIdentifier: 'orders-db', + status: 'available', + engine: 'postgres', + version: '16.4', + createdAt: '2026-08-14T09:30:00.000Z', + metadata: {snapshotType: 'manual'}, +} + +describe('database snapshot routes', () => { + test('lists snapshots with the exact optional instance filter', async () => { + let delegatedIdentifier: string | undefined + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + listDatabaseSnapshots: async (instanceIdentifier) => { + delegatedIdentifier = instanceIdentifier + return [databaseSnapshot] + }, + })]) + + const res = await app.request( + '/api/clouds/aws/services/database/snapshots?instanceIdentifier=orders-db', + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual([databaseSnapshot]) + expect(delegatedIdentifier).toBe('orders-db') + }) + + test('creates a snapshot with the raw two-field input', async () => { + let delegatedInput: CreateDatabaseSnapshotInput | undefined + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + createDatabaseSnapshot: async (input) => { + delegatedInput = input + return databaseSnapshot + }, + })]) + const input = { + instanceIdentifier: 'orders-db', + snapshotIdentifier: 'orders-db-snapshot-1', + } + + const res = await app.request('/api/clouds/aws/services/database/snapshots', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify(input), + }) + + expect(res.status).toBe(201) + expect(await res.json()).toEqual(databaseSnapshot) + expect(delegatedInput).toEqual(input) + }) + + test('returns 501 when the database adapter lacks either snapshot method', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + })]) + + const listRes = await app.request('/api/clouds/aws/services/database/snapshots') + const createRes = await app.request('/api/clouds/aws/services/database/snapshots', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + instanceIdentifier: 'orders-db', + snapshotIdentifier: 'orders-db-snapshot-1', + }), + }) + + expect(listRes.status).toBe(501) + expect((await listRes.json()).code).toBe('operation_not_supported') + expect(createRes.status).toBe(501) + expect((await createRes.json()).code).toBe('operation_not_supported') + }) + + test('maps adapter snapshot validation errors to 400', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + createDatabaseSnapshot: async () => { + throw new ValidationError('snapshotIdentifier is required') + }, + })]) + + const res = await app.request('/api/clouds/aws/services/database/snapshots', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({instanceIdentifier: 'orders-db', snapshotIdentifier: ''}), + }) + expect(res.status).toBe(400) + const body = await res.json() + + expect(body).toMatchObject({ + code: 'invalid_request', + message: 'snapshotIdentifier is required', + }) + }) + + test('maps AWS UnsupportedOperation snapshot errors through the shared mapper', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + listDatabaseSnapshots: async () => { + throw Object.assign(new Error('RDS snapshots are unavailable'), { + name: 'UnsupportedOperation', + $fault: 'client', + $metadata: {httpStatusCode: 500}, + }) + }, + })]) + + const res = await app.request('/api/clouds/aws/services/database/snapshots') + expect(res.status).toBe(501) + const body = await res.json() + + expect(body.code).toBe('operation_not_implemented') + expect(body.detail).toBe('RDS snapshots are unavailable') + }) + + test('lists orderable instance classes with engine filter', async () => { + let delegatedEngine: string | undefined + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + listDatabaseOrderableInstanceClasses: async (engine) => { + delegatedEngine = engine + return ['db.t3.micro', 'db.m8g.large'] + }, + })]) + + const res = await app.request( + '/api/clouds/aws/services/database/orderable-classes?engine=postgres', + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(['db.t3.micro', 'db.m8g.large']) + expect(delegatedEngine).toBe('postgres') + }) +}) + describe('cloud schema routes', () => { test('returns AWS storage schema', async () => { const res = await appWithRoutes().request('/api/clouds/aws/services/storage/schema') diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index cd0dc7f..76e7aff 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -1,6 +1,11 @@ import {Hono} from 'hono' import type {Context} from 'hono' -import type {CloudProvider, CloudServiceType, SqlConnectionInput} from '../cloud-spi/types' +import type { + CloudProvider, + CloudServiceType, + CreateDatabaseSnapshotInput, + SqlConnectionInput, +} from '../cloud-spi/types' import {toHttpError} from '../cloud-spi/errors' import {isServiceType} from '../cloud-spi/serviceCatalog' import {mapAwsSdkError} from '../adapter-aws/awsErrors' @@ -55,6 +60,37 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { return c.json(schema) }) + app.get('/:cloud/services/database/snapshots', 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 snapshots = await svc(c).listDatabaseSnapshots(cloud, c.req.query('instanceIdentifier')) + return c.json(snapshots) + }) + }) + + app.post('/:cloud/services/database/snapshots', 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 input = await c.req.json() + const snapshot = await svc(c).createDatabaseSnapshot(cloud, input) + return c.json(snapshot, 201) + }) + }) + + app.get('/:cloud/services/database/orderable-classes', 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 classes = await svc(c).listDatabaseOrderableInstanceClasses(cloud, c.req.query('engine')) + return c.json(classes) + }) + }) + app.get('/:cloud/services/:service/resources', async (c) => { const cloud = c.req.param('cloud') as CloudProvider const serviceType = c.req.param('service') as CloudServiceType diff --git a/packages/api/src/routes/rds.ts b/packages/api/src/routes/rds.ts deleted file mode 100644 index d50bb3d..0000000 --- a/packages/api/src/routes/rds.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Hono } from "hono"; -import { rdsService } from "../services/rds"; - -const app = new Hono(); - -app.get("/instances", async (c) => { - return c.json(await rdsService.listInstances()); -}); - -app.get("/instances/:identifier", async (c) => { - return c.json(await rdsService.describeInstance(c.req.param("identifier"))); -}); - -app.get("/snapshots", async (c) => { - return c.json(await rdsService.listSnapshots(c.req.query("instanceIdentifier"))); -}); - -app.post("/snapshots", async (c) => { - const body = await c.req.json<{ - instanceIdentifier: string; - snapshotIdentifier?: string; - }>(); - if (!body.instanceIdentifier) { - return c.json({ error: "instanceIdentifier is required" }, 400); - } - const snapshotIdentifier = - body.snapshotIdentifier?.trim() || - `${body.instanceIdentifier}-snapshot-${Date.now()}`; - return c.json( - await rdsService.createSnapshot(body.instanceIdentifier, snapshotIdentifier), - 201, - ); -}); - -export default app; diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index cd08653..38012ef 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -10,9 +10,11 @@ import type { CosmosContainer, CosmosItem, CosmosQueryResult, + CreateDatabaseSnapshotInput, CreateKubernetesFargateProfileInput, CreateKubernetesNodegroupInput, CreateResourceInput, + DatabaseSnapshot, KubernetesFargateProfile, KubernetesNodegroup, NoSqlItem, @@ -294,6 +296,24 @@ async invokeResource( return adapter.queryCosmosItems(databaseId, containerId, query) } + async listDatabaseSnapshots(cloud: CloudProvider, instanceIdentifier?: string): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listDatabaseSnapshots) throw new NotSupportedError(`Snapshot listing is not supported for ${cloud}/database`) + return adapter.listDatabaseSnapshots(instanceIdentifier) + } + + async createDatabaseSnapshot(cloud: CloudProvider, input: CreateDatabaseSnapshotInput): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.createDatabaseSnapshot) throw new NotSupportedError(`Snapshot creation is not supported for ${cloud}/database`) + return adapter.createDatabaseSnapshot(input) + } + + async listDatabaseOrderableInstanceClasses(cloud: CloudProvider, engine?: string): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listDatabaseOrderableInstanceClasses) throw new NotSupportedError(`Orderable instance class listing is not supported for ${cloud}/database`) + return adapter.listDatabaseOrderableInstanceClasses(engine) + } + async listSqlDatabases(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput): Promise { const adapter = this.requireAdapter(cloud, 'database') if (!adapter.listSqlDatabases) throw new NotSupportedError(`SQL database browsing is not supported for ${cloud}/database`) diff --git a/packages/api/src/services/rds.ts b/packages/api/src/services/rds.ts index f5dfe4f..36289e0 100644 --- a/packages/api/src/services/rds.ts +++ b/packages/api/src/services/rds.ts @@ -1,7 +1,11 @@ import { + CreateDBInstanceCommand, CreateDBSnapshotCommand, + DeleteDBInstanceCommand, DescribeDBInstancesCommand, DescribeDBSnapshotsCommand, + DescribeOrderableDBInstanceOptionsCommand, + type CreateDBInstanceCommandInput, type DBInstance, type DBSnapshot, type RDSClient, @@ -158,25 +162,34 @@ export function createRdsService(client: RDSClient = awsClients.rds) { return toRdsInstance(res.DBInstances?.[0] ?? {}); }, + async createInstance(input: CreateDBInstanceCommandInput): Promise { + const res = await client.send(new CreateDBInstanceCommand(input)); + return toRdsInstance(res.DBInstance ?? {}); + }, + + async deleteInstance(identifier: string): Promise { + await client.send( + new DeleteDBInstanceCommand({ + DBInstanceIdentifier: identifier, + SkipFinalSnapshot: true, + }), + ); + }, + async listSnapshots(instanceIdentifier?: string): Promise { const snapshots: RdsSnapshot[] = []; let marker: string | undefined; - try { - do { - const res = await client.send( - new DescribeDBSnapshotsCommand({ - DBInstanceIdentifier: instanceIdentifier, - Marker: marker, - }), - ); - snapshots.push(...(res.DBSnapshots ?? []).map(toRdsSnapshot)); - marker = res.Marker; - } while (marker); - } catch (error) { - if (isUnsupportedOperation(error)) return []; - throw error; - } + do { + const res = await client.send( + new DescribeDBSnapshotsCommand({ + DBInstanceIdentifier: instanceIdentifier, + Marker: marker, + }), + ); + snapshots.push(...(res.DBSnapshots ?? []).map(toRdsSnapshot)); + marker = res.Marker; + } while (marker); return snapshots; }, @@ -190,11 +203,29 @@ export function createRdsService(client: RDSClient = awsClients.rds) { ); return toRdsSnapshot(res.DBSnapshot ?? {}); }, - }; -} -function isUnsupportedOperation(error: unknown) { - return error instanceof Error && error.message.includes("is not supported"); + async listOrderableInstanceClasses(engine: string): Promise { + const classes = new Set(); + let marker: string | undefined; + + do { + const res = await client.send( + new DescribeOrderableDBInstanceOptionsCommand({ + Engine: engine, + Marker: marker, + }), + ); + for (const option of res.OrderableDBInstanceOptions ?? []) { + if (option.DBInstanceClass) { + classes.add(option.DBInstanceClass); + } + } + marker = res.Marker; + } while (marker); + + return Array.from(classes); + }, + }; } export const rdsService = createRdsService(); diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 3f610ac..fed02de 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -55,6 +55,13 @@ export const apiEndpointKeys = { tables: "clouds.services.database.sql.tables.list", query: "clouds.services.database.sql.query", }, + snapshots: { + list: "clouds.services.database.snapshots.list", + create: "clouds.services.database.snapshots.create", + }, + orderableClasses: { + list: "clouds.services.database.orderable-classes.list", + }, }, k8s: { nodegroups: { @@ -93,16 +100,6 @@ export const apiEndpointKeys = { delete: "aws.eks.fargate-profiles.delete", }, }, - rds: { - instances: { - list: "aws.rds.instances.list", - describe: "aws.rds.instances.describe", - }, - snapshots: { - list: "aws.rds.snapshots.list", - create: "aws.rds.snapshots.create", - }, - }, secretsmanager: { secrets: { list: "aws.secretsmanager.secrets.list", @@ -408,6 +405,30 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "cloud-proxy" }, }, ], + [ + apiEndpointKeys.clouds.database.snapshots.list, + { + path: "/clouds/:cloud/services/database/snapshots", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.snapshots.create, + { + path: "/clouds/:cloud/services/database/snapshots", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.orderableClasses.list, + { + path: "/clouds/:cloud/services/database/orderable-classes", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], [ apiEndpointKeys.clouds.nosql.items.list, { @@ -555,40 +576,6 @@ export const endpointRegistry: EndpointRegistry = new Map([ }, ], - // AWS RDS - [ - apiEndpointKeys.aws.rds.instances.list, - { - path: "/rds/instances", - method: "GET", - telemetry: { provider: "aws", service: "rds" }, - }, - ], - [ - apiEndpointKeys.aws.rds.instances.describe, - { - path: "/rds/instances/:identifier", - method: "GET", - telemetry: { provider: "aws", service: "rds" }, - }, - ], - [ - apiEndpointKeys.aws.rds.snapshots.list, - { - path: "/rds/snapshots", - method: "GET", - telemetry: { provider: "aws", service: "rds" }, - }, - ], - [ - apiEndpointKeys.aws.rds.snapshots.create, - { - path: "/rds/snapshots", - method: "POST", - telemetry: { provider: "aws", service: "rds" }, - }, - ], - // AWS Secrets Manager [ apiEndpointKeys.aws.secretsmanager.secrets.list, diff --git a/packages/frontend/src/api/aws/rds.api.ts b/packages/frontend/src/api/aws/rds.api.ts deleted file mode 100644 index 428bd90..0000000 --- a/packages/frontend/src/api/aws/rds.api.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { apiClient, apiEndpointKeys } from "@/api/api"; -import type { ResourceSummary } from "@/api/types"; - -export type RdsEndpoint = { - address?: string; - port?: number; - hostedZoneId?: string; -}; - -export type RdsVpcSecurityGroup = { - id?: string; - status?: string; -}; - -export type RdsSubnet = { - identifier?: string; - availabilityZone?: string; - status?: string; -}; - -export type RdsDbSubnetGroup = { - name?: string; - vpcId?: string; - status?: string; - subnets: RdsSubnet[]; -}; - -export type RdsInstance = { - identifier: string; - arn?: string; - resourceId?: string; - createdAt?: string; - status?: string; - engine?: string; - engineVersion?: string; - instanceClass?: string; - dbName?: string; - masterUsername?: string; - allocatedStorage?: number; - storageType?: string; - availabilityZone?: string; - multiAz?: boolean; - publiclyAccessible?: boolean; - iamDatabaseAuthenticationEnabled?: boolean; - preferredBackupWindow?: string; - preferredMaintenanceWindow?: string; - endpoint?: RdsEndpoint; - vpcSecurityGroups: RdsVpcSecurityGroup[]; - subnetGroup?: RdsDbSubnetGroup; -}; - -export type RdsSnapshot = { - identifier: string; - instanceIdentifier?: string; - arn?: string; - status?: string; - engine?: string; - engineVersion?: string; - allocatedStorage?: number; - snapshotType?: string; - createdAt?: string; - port?: number; - availabilityZone?: string; - vpcId?: string; -}; - -export async function listRdsInstances( - signal?: AbortSignal, -): Promise { - const res = await apiClient.call( - apiEndpointKeys.aws.rds.instances.list, - { signal }, - ); - - return res.data; -} - -export async function describeRdsInstance( - identifier: string, - signal?: AbortSignal, -): Promise { - const res = await apiClient.call( - apiEndpointKeys.aws.rds.instances.describe, - { signal }, - { identifier }, - ); - - return res.data; -} - -export async function listRdsSnapshots( - instanceIdentifier?: string, - signal?: AbortSignal, -): Promise { - const res = await apiClient.call( - apiEndpointKeys.aws.rds.snapshots.list, - { - signal, - params: instanceIdentifier ? { instanceIdentifier } : undefined, - }, - ); - - return res.data; -} - -export async function createRdsSnapshot( - instanceIdentifier: string, - snapshotIdentifier?: string, - signal?: AbortSignal, -): Promise { - const res = await apiClient.call< - RdsSnapshot, - { instanceIdentifier: string; snapshotIdentifier?: string } - >( - apiEndpointKeys.aws.rds.snapshots.create, - { - signal, - body: { instanceIdentifier, snapshotIdentifier }, - }, - ); - - return res.data; -} - -export async function listRdsResources( - signal?: AbortSignal, -): Promise { - const instances = await listRdsInstances(signal); - - return instances.map((instance) => ({ - id: instance.arn ?? instance.identifier, - name: instance.identifier, - status: instance.status, - description: instance.dbName, - metadata: { - engine: instance.engine, - version: instance.engineVersion, - class: instance.instanceClass, - endpoint: instance.endpoint?.address, - port: instance.endpoint?.port, - }, - })); -} - -export const rdsClient = { - listInstances: listRdsInstances, - describeInstance: describeRdsInstance, - listSnapshots: listRdsSnapshots, - createSnapshot: createRdsSnapshot, - listResources: listRdsResources, -}; diff --git a/packages/frontend/src/api/aws/rds.mutations.ts b/packages/frontend/src/api/aws/rds.mutations.ts deleted file mode 100644 index 7418d62..0000000 --- a/packages/frontend/src/api/aws/rds.mutations.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { rdsClient } from "./rds.api"; -import { rdsQueryKeys } from "./rds.queries"; - -export type CreateRdsSnapshotInput = { - instanceIdentifier: string; - snapshotIdentifier?: string; -}; - -export function useCreateRdsSnapshotMutation() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: ({ instanceIdentifier, snapshotIdentifier }: CreateRdsSnapshotInput) => - rdsClient.createSnapshot(instanceIdentifier, snapshotIdentifier), - onSuccess: (_, input) => { - void qc.invalidateQueries({ - queryKey: rdsQueryKeys.snapshots(input.instanceIdentifier), - }); - void qc.invalidateQueries({ - queryKey: rdsQueryKeys.snapshots(), - }); - }, - }); -} diff --git a/packages/frontend/src/api/aws/rds.queries.ts b/packages/frontend/src/api/aws/rds.queries.ts deleted file mode 100644 index 861698d..0000000 --- a/packages/frontend/src/api/aws/rds.queries.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { rdsClient } from "./rds.api"; - -export const rdsQueryKeys = { - instances: ["rds", "instances"] as const, - instance: (identifier: string | null) => - ["rds", "instance", identifier] as const, - snapshots: (instanceIdentifier?: string | null) => - ["rds", "snapshots", instanceIdentifier ?? "all"] as const, -}; - -export function useRdsInstancesQuery() { - return useQuery({ - queryKey: rdsQueryKeys.instances, - queryFn: ({ signal }) => rdsClient.listInstances(signal), - refetchInterval: 30_000, - }); -} - -export function useRdsInstanceQuery(identifier: string | null) { - return useQuery({ - queryKey: rdsQueryKeys.instance(identifier), - queryFn: ({ signal }) => rdsClient.describeInstance(identifier!, signal), - enabled: Boolean(identifier), - refetchInterval: 30_000, - }); -} - -export function useRdsSnapshotsQuery(instanceIdentifier?: string | null) { - return useQuery({ - queryKey: rdsQueryKeys.snapshots(instanceIdentifier), - queryFn: ({ signal }) => - rdsClient.listSnapshots(instanceIdentifier ?? undefined, signal), - enabled: instanceIdentifier !== null, - refetchInterval: 30_000, - }); -} diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index b724b01..77c1bdc 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -12,8 +12,10 @@ import type { CosmosContainer, CosmosItem, CosmosQueryResult, + CreateDatabaseSnapshotInput, CreateKubernetesFargateProfileInput, CreateKubernetesNodegroupInput, + DatabaseSnapshot, KubernetesFargateProfile, KubernetesNodegroup, NoSqlItem, @@ -29,7 +31,7 @@ import { getAccountId } from "@/lib/accountStore"; type CloudPathParams = Record; -const AZURE_DATABASE_CREATE_TIMEOUT_MS = 5 * 60_000; +const DATABASE_MUTATION_TIMEOUT_MS = 5 * 60_000; const SQL_DATA_TIMEOUT_MS = 45_000; /** Cold starts pull a container image; measured at ~60s on a first invoke. */ const INVOKE_TIMEOUT_MS = 120_000; @@ -129,8 +131,8 @@ export async function createCloudResource( signal?: AbortSignal, ): Promise { const timeout = - cloud === "azure" && service === "database" - ? AZURE_DATABASE_CREATE_TIMEOUT_MS + (cloud === "azure" || cloud === "aws") && service === "database" + ? DATABASE_MUTATION_TIMEOUT_MS : undefined; const res = await apiClient.call>( apiEndpointKeys.clouds.resources.create, @@ -443,6 +445,51 @@ export async function querySql( return res.data; } +export async function listDatabaseSnapshots( + cloud: CloudProvider, + instanceIdentifier?: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.snapshots.list, + requestOptions(cloud, "database", { + signal, + params: instanceIdentifier ? { instanceIdentifier } : undefined, + }), + { cloud }, + ); + return res.data; +} + +export async function createDatabaseSnapshot( + cloud: CloudProvider, + input: CreateDatabaseSnapshotInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.snapshots.create, + requestOptions(cloud, "database", { signal, body: input }), + { cloud }, + ); + return res.data; +} + +export async function listDatabaseOrderableClasses( + cloud: CloudProvider, + engine?: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.orderableClasses.list, + requestOptions(cloud, "database", { + signal, + params: engine ? { engine } : undefined, + }), + { cloud }, + ); + return res.data; +} + export async function listNoSqlItems( cloud: CloudProvider, resourceId: string, diff --git a/packages/frontend/src/components/CreateRdsInstanceForm.tsx b/packages/frontend/src/components/CreateRdsInstanceForm.tsx new file mode 100644 index 0000000..77111ba --- /dev/null +++ b/packages/frontend/src/components/CreateRdsInstanceForm.tsx @@ -0,0 +1,337 @@ +import { type FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Plus } from "lucide-react"; +import { + createCloudResource, + listDatabaseOrderableClasses, +} from "@/api/cloudProxyClient"; +import { + listEc2SecurityGroups, + type Ec2SecurityGroup, +} from "@/api/aws/ec2.api"; +import type { CloudProvider } from "@/types/cloud"; +import type { CloudResource } from "@/types/resource"; + +const IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +interface CreateRdsInstanceFormProps { + cloud: CloudProvider; + onSuccess: (resource: CloudResource) => void; + onCancel: () => void; +} + +export function CreateRdsInstanceForm({ + cloud, + onSuccess, + onCancel, +}: CreateRdsInstanceFormProps) { + const qc = useQueryClient(); + + // Instance configuration + const [dbInstanceIdentifier, setDbInstanceIdentifier] = useState(""); + const [engine, setEngine] = useState("postgres"); + const [dbInstanceClass, setDbInstanceClass] = useState(""); + const [masterUserPassword, setMasterUserPassword] = useState(""); + + // Networking settings + const [selectedSgs, setSelectedSgs] = useState([]); + + // Additional settings + const [masterUsername, setMasterUsername] = useState("root"); + const [allocatedStorage, setAllocatedStorage] = useState("20"); + const [dbName, setDbName] = useState(""); + const [engineVersion, setEngineVersion] = useState(""); + const [validationError, setValidationError] = useState(null); + + // Fetch available instance classes from AWS SDK via backend + const classesQuery = useQuery({ + queryKey: ["cloud-database-orderable-classes", cloud, engine], + queryFn: ({ signal }) => listDatabaseOrderableClasses(cloud, engine, signal), + enabled: Boolean(engine), + }); + + const availableClasses = classesQuery.data ?? []; + const selectedClass = + dbInstanceClass && availableClasses.includes(dbInstanceClass) + ? dbInstanceClass + : (availableClasses[0] ?? ""); + + // Fetch available security groups (AWS) + const sgsQuery = useQuery({ + queryKey: ["ec2", "security-groups"], + queryFn: ({ signal }) => listEc2SecurityGroups(undefined, signal), + enabled: cloud === "aws", + }); + + function toggleSg(groupId: string, checked: boolean) { + setSelectedSgs((prev) => + checked ? [...prev, groupId] : prev.filter((id) => id !== groupId), + ); + } + + const createMut = useMutation({ + mutationFn: (values: Record) => + createCloudResource(cloud, "database", values), + onSuccess: (created) => { + void qc.invalidateQueries({ + queryKey: ["cloud-resources", cloud, "database"], + }); + onSuccess(created); + }, + }); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + setValidationError(null); + + const trimmedIdentifier = dbInstanceIdentifier.trim(); + if (!trimmedIdentifier) { + setValidationError("DB Instance Identifier is required."); + return; + } + + if (!IDENTIFIER_PATTERN.test(trimmedIdentifier)) { + setValidationError( + "DB Instance Identifier must start with a lowercase letter, contain only lowercase letters, digits, and hyphens, and cannot end with a hyphen or contain consecutive hyphens.", + ); + return; + } + + if (!selectedClass) { + setValidationError("DB Instance Class is required."); + return; + } + + if (masterUserPassword.length < 8) { + setValidationError("Master User Password must be at least 8 characters."); + return; + } + + createMut.mutate({ + dbInstanceIdentifier: trimmedIdentifier, + engine, + dbInstanceClass: selectedClass, + masterUserPassword, + masterUsername: masterUsername.trim() || "root", + allocatedStorage: allocatedStorage.trim() || "20", + dbName: dbName.trim() || undefined, + engineVersion: engineVersion.trim() || undefined, + securityGroupIds: selectedSgs.length ? selectedSgs : undefined, + }); + } + + const errorMessage = + validationError ?? + (createMut.error instanceof Error ? createMut.error.message : null); + + return ( +
+ {/* ── Required Instance Settings ─────────────────────────────── */} +
Instance Configuration
+ + + + + + + + + + {/* ── Networking — optional ─────────────────────────────────── */} + {cloud === "aws" && ( + <> +
Networking — optional
+
+ + Security Groups + +
+ {sgsQuery.isLoading && Loading…} + {!sgsQuery.isLoading && (sgsQuery.data ?? []).length === 0 && ( + No security groups found. + )} + {(sgsQuery.data ?? []).map((sg: Ec2SecurityGroup) => ( + + ))} +
+
+ + )} + + {/* ── Additional Database Settings ───────────────────────────── */} +
Database Settings — optional
+ + + + + + + + + + {/* ── Actions ────────────────────────────────────────────────── */} +
+ + +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ ); +} diff --git a/packages/frontend/src/components/DatabaseSnapshotsPanel.tsx b/packages/frontend/src/components/DatabaseSnapshotsPanel.tsx new file mode 100644 index 0000000..6d71816 --- /dev/null +++ b/packages/frontend/src/components/DatabaseSnapshotsPanel.tsx @@ -0,0 +1,236 @@ +import { useState } from "react"; +import { Camera, Loader2, Plus, RefreshCw } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createDatabaseSnapshot, listDatabaseSnapshots } from "@/api/cloudProxyClient"; +import { HttpError } from "@/api/HttpClient"; +import { EmptyState } from "@/components/EmptyState"; +import { capabilityEnabled } from "@/lib/capabilities"; +import type { CloudProvider } from "@/types/cloud"; +import type { CloudResource } from "@/types/resource"; +import type { CapabilitySchema, DatabaseActionName } from "@/types/schema"; + +export interface DatabaseSnapshotsPanelProps { + cloud: CloudProvider; + instances: CloudResource[]; + listCapability?: CapabilitySchema; + createCapability?: CapabilitySchema; + runtimeReachable: boolean; +} + +export function DatabaseSnapshotsPanel({ + cloud, + instances, + listCapability, + createCapability, + runtimeReachable, +}: DatabaseSnapshotsPanelProps) { + const qc = useQueryClient(); + const [sourceInstance, setSourceInstance] = useState(""); + const [snapshotId, setSnapshotId] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + + const canList = runtimeReachable && capabilityEnabled(listCapability); + const canCreate = runtimeReachable && capabilityEnabled(createCapability); + + const snapshotsQuery = useQuery({ + queryKey: ["database-snapshots", cloud], + queryFn: ({ signal }) => listDatabaseSnapshots(cloud, undefined, signal), + enabled: canList, + }); + + const createMut = useMutation({ + mutationFn: (input: { instanceIdentifier: string; snapshotIdentifier: string }) => + createDatabaseSnapshot(cloud, input), + onSuccess: () => { + setSourceInstance(""); + setSnapshotId(""); + setCreateOpen(false); + void qc.invalidateQueries({ queryKey: ["database-snapshots", cloud] }); + }, + }); + + const handleSourceChange = (newInstance: string) => { + setSourceInstance(newInstance); + if (newInstance) { + setSnapshotId(`${newInstance}-snapshot-${Date.now()}`); + } else { + setSnapshotId(""); + } + }; + + const isFormValid = Boolean(sourceInstance.trim() && snapshotId.trim()); + + const handleCreateSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!isFormValid || !canCreate || createMut.isPending) return; + createMut.mutate({ + instanceIdentifier: sourceInstance.trim(), + snapshotIdentifier: snapshotId.trim(), + }); + }; + + const getErrorMessage = (error: unknown): string => { + if ( + error instanceof HttpError && + typeof error.body === "object" && + error.body !== null && + "code" in error.body && + (error.body as { code?: unknown }).code === "operation_not_implemented" + ) { + return "Snapshot creation is available in Floci UI, but the selected Floci runtime does not implement CreateDBSnapshot."; + } + if (error instanceof Error) { + return error.message; + } + return "Snapshot creation failed."; + }; + + return ( +
+
+
+

Database Snapshots

+ + {snapshotsQuery.data ? `${snapshotsQuery.data.length} snapshots` : "Snapshots"} + +
+
+ + +
+
+ + {createOpen && ( +
+
+
+ + +
+ +
+ + setSnapshotId(e.target.value)} + placeholder="e.g. my-db-snapshot-1" + /> +
+ + {createMut.isError && ( +
+ {getErrorMessage(createMut.error)} +
+ )} + +
+ + +
+
+
+ )} + + {!canList ? ( +

+ {listCapability?.reason ?? "Snapshot listing is not available."} +

+ ) : snapshotsQuery.isLoading ? ( +
+ +

Loading snapshots…

+
+ ) : snapshotsQuery.isError ? ( +
+ {snapshotsQuery.error instanceof Error + ? snapshotsQuery.error.message + : "Failed to load database snapshots."} +
+ ) : (snapshotsQuery.data?.length ?? 0) === 0 ? ( + + ) : ( +
+ {snapshotsQuery.data?.map((snapshot) => ( +
+
+ {snapshot.name} + + {snapshot.instanceIdentifier + ? `Instance: ${snapshot.instanceIdentifier}` + : "Unknown instance"} + {snapshot.createdAt + ? ` · ${snapshot.createdAt}` + : " · No creation timestamp"} + {snapshot.engine ? ` · ${snapshot.engine}` : ""} + {snapshot.version ? ` (${snapshot.version})` : ""} + +
+ + {snapshot.status ?? "unknown"} + +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 2fcc421..6b0061f 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -34,6 +34,8 @@ import { CosmosNoSqlPanel } from "@/components/CosmosNoSqlPanel"; import { AzureSqlPanel } from "@/components/AzureSqlPanel"; import { ServerlessInvokePanel } from "@/components/ServerlessInvokePanel"; import { DynamoDbTableExplorer } from "@/components/DynamoDbTableExplorer"; +import { DatabaseSnapshotsPanel } from "@/components/DatabaseSnapshotsPanel"; +import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm"; interface DynamicResourceViewProps { cloud: CloudProvider; @@ -57,12 +59,21 @@ export function DynamicResourceView({ }: DynamicResourceViewProps) { const qc = useQueryClient(); const [search, setSearch] = useState(""); + const [databaseTab, setDatabaseTab] = useState<"instances" | "snapshots">("instances"); const [selected, setSelected] = useState(); const [selectedObject, setSelectedObject] = useState< StorageObject | undefined >(); const [createOpen, setCreateOpen] = useState(false); const [clearConfirm, setClearConfirm] = useState(false); + + const handleDatabaseTabChange = (tab: "instances" | "snapshots") => { + setDatabaseTab(tab); + if (tab === "snapshots") { + setSearch(""); + } + }; + const resourcesKey = useMemo( () => ["cloud-resources", cloud, service, search], [cloud, service, search], @@ -122,6 +133,7 @@ export function DynamicResourceView({ setCreateOpen(false); setClearConfirm(false); setSearch(""); + setDatabaseTab("instances"); }, [cloud, service]); useEffect(() => { @@ -201,19 +213,64 @@ export function DynamicResourceView({ ); const createCapability = capabilityFor(resourceCapabilities, "create"); const createResourceLabel = createCapability?.label ?? "Create resource"; + const databaseCapabilities = withServiceAvailability( + withRuntimeState( + normalizeCapabilities(schema.capabilities?.databaseActions), + runtimeReachable, + ), + serviceAvailability, + ); + const listSnapshotCapability = capabilityFor( + databaseCapabilities, + "listSnapshots", + ); + const createSnapshotCapability = capabilityFor( + databaseCapabilities, + "createSnapshot", + ); const canUseRuntime = runtimeReachable && adapterAvailable; const canCreateResource = canUseRuntime && capabilityEnabled(createCapability); + const isAwsDatabase = cloud === "aws" && service === "database"; + const showDatabaseSnapshots = isAwsDatabase && databaseTab === "snapshots"; return (
-
+ {isAwsDatabase && ( +
+ + +
+ )} + + {showDatabaseSnapshots ? ( + + ) : ( +

Resources

@@ -311,6 +368,15 @@ export function DynamicResourceView({ }} onCancel={() => setCreateOpen(false)} /> + ) : service === "database" && cloud === "aws" ? ( + { + setSelected(resource); + setCreateOpen(false); + }} + onCancel={() => setCreateOpen(false)} + /> ) : ( resourcesQuery.refetch(), })}
+ )}
- {activeSelected && ( + {activeSelected && !showDatabaseSnapshots && ( )} {isAwsDatabase && } - {isAwsDatabase && ( - - )} {isDatabase && !isAwsDatabase && ( )} @@ -323,67 +318,6 @@ function DatabaseLifecycleSection({ status }: { status?: string | null }) { ); } -function DatabaseSnapshotsSection({ - instanceIdentifier, -}: { - instanceIdentifier: string; -}) { - const snapshotsQuery = useRdsSnapshotsQuery(instanceIdentifier); - const createSnapshot = useCreateRdsSnapshotMutation(); - - return ( -
-
-

Snapshots

- -
- {createSnapshot.isError && ( -

- {createSnapshot.error instanceof Error - ? createSnapshot.error.message - : "Snapshot creation failed."} -

- )} - {snapshotsQuery.isLoading ? ( -

Loading snapshots.

- ) : snapshotsQuery.isError ? ( -

- {snapshotsQuery.error instanceof Error - ? snapshotsQuery.error.message - : "Failed to load snapshots."} -

- ) : (snapshotsQuery.data?.length ?? 0) === 0 ? ( -

- No snapshots returned for this DB instance. -

- ) : ( -
- {snapshotsQuery.data?.map((snapshot) => ( -
-
- {snapshot.identifier} - {snapshot.createdAt ?? "No creation timestamp"} -
- - {snapshot.status ?? "unknown"} - -
- ))} -
- )} -
- ); -} function InspectorItem({ label, value }: { label: string; value: string }) { return ( diff --git a/packages/frontend/src/features/rds/RDSPage.tsx b/packages/frontend/src/features/rds/RDSPage.tsx deleted file mode 100644 index a41476e..0000000 --- a/packages/frontend/src/features/rds/RDSPage.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Database, HardDrive, Info, Network, RefreshCw } from "lucide-react"; -import { EmptyState } from "@/components/EmptyState"; -import type { RdsInstance } from "@/api/aws/rds.api"; -import { - useRdsInstanceQuery, - useRdsInstancesQuery, -} from "@/api/aws/rds.queries"; - -function statusClass(status?: string) { - const normalized = status?.toLowerCase(); - if (normalized === "available") return "healthy"; - if ( - normalized === "creating" || - normalized === "modifying" || - normalized === "backing-up" - ) { - return "degraded"; - } - if (normalized === "failed" || normalized === "deleting") return "unavailable"; - return "unknown"; -} - -function yesNo(value?: boolean) { - if (value === undefined) return "-"; - return value ? "Enabled" : "Disabled"; -} - -function InstanceListItem({ - instance, - active, - onSelect, -}: { - instance: RdsInstance; - active: boolean; - onSelect: () => void; -}) { - return ( - - ); -} - -function Meta({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function InstanceSummary({ instance }: { instance: RdsInstance }) { - const endpoint = instance.endpoint; - - return ( -
-
-
- -

Instance

-
-
-
- - - - - - - -
-
-
- -
-
- -

Storage

-
-
-
- - - - - - - -
-
-
-
- ); -} - -function NetworkTable({ instance }: { instance: RdsInstance }) { - const subnets = instance.subnetGroup?.subnets ?? []; - - return ( -
-
-
- -

Security groups

-
- {instance.vpcSecurityGroups.length === 0 ? ( -

No security groups.

- ) : ( - - - - - - - - - {instance.vpcSecurityGroups.map((group) => ( - - - - - ))} - -
IDStatus
{group.id ?? "-"}{group.status ?? "-"}
- )} -
- -
-
- -

Subnets

-
- {subnets.length === 0 ? ( -

No subnet group.

- ) : ( - - - - - - - - - - {subnets.map((subnet) => ( - - - - - - ))} - -
SubnetAZStatus
{subnet.identifier ?? "-"}{subnet.availabilityZone ?? "-"}{subnet.status ?? "-"}
- )} -
-
- ); -} - -export function RDSPage() { - const instancesQuery = useRdsInstancesQuery(); - const instances = useMemo(() => instancesQuery.data ?? [], [instancesQuery.data]); - const [selectedIdentifier, setSelectedIdentifier] = useState(null); - - useEffect(() => { - if (!selectedIdentifier && instances[0]) { - setSelectedIdentifier(instances[0].identifier); - } - }, [instances, selectedIdentifier]); - - const selectedFromList = useMemo( - () => - instances.find((instance) => instance.identifier === selectedIdentifier) ?? - null, - [instances, selectedIdentifier], - ); - const instanceQuery = useRdsInstanceQuery(selectedIdentifier); - const selectedInstance = instanceQuery.data ?? selectedFromList; - - return ( - <> -
-
-

RDS

- - - Database instances - -
- -
- -
- - -
- {!selectedInstance ? ( - - ) : ( -
-
- -

{selectedInstance.identifier}

- - {selectedInstance.status ?? "unknown"} - -
- - {instanceQuery.isError ? ( - - ) : ( - <> - - - - )} -
- )} -
-
- - ); -} diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index 81f9ea5..bdb0551 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -1379,10 +1379,20 @@ pre, } .resource-main { - display: grid; + display: flex; + flex-direction: column; + min-height: 0; +} + +.resource-main > .table-panel { + flex: 1; min-height: 0; } +.resource-main > .drawer-tabs { + padding: 0 4px; +} + .resource-table-bar { justify-content: space-between; gap: 10px; @@ -3255,13 +3265,15 @@ button.console-service-card { /* ─── Drawer tabs ────────────────────────────────────────────────────────── */ .drawer-tabs { display: flex; + align-items: flex-end; border-bottom: 1px solid var(--border); padding: 0 14px; flex-shrink: 0; } .drawer-tab { - padding: 8px 14px; + padding: 5px 12px; + line-height: 1.3; background: none; border: none; border-bottom: 2px solid transparent; diff --git a/packages/frontend/src/lib/capabilities.ts b/packages/frontend/src/lib/capabilities.ts index b4c5852..ea88843 100644 --- a/packages/frontend/src/lib/capabilities.ts +++ b/packages/frontend/src/lib/capabilities.ts @@ -1,7 +1,7 @@ -import type {CapabilitySchema, CapabilityStatus, KubernetesActionName, ObjectActionName, ResourceActionName} from '@/types/schema' +import type {CapabilitySchema, CapabilityStatus, DatabaseActionName, KubernetesActionName, ObjectActionName, ResourceActionName} from '@/types/schema' import type {CloudAvailability} from '@/types/cloud' -export type CapabilityActionName = ResourceActionName | ObjectActionName | KubernetesActionName +export type CapabilityActionName = ResourceActionName | ObjectActionName | KubernetesActionName | DatabaseActionName export type AnyCapability = CapabilitySchema export type CapabilityInput = CapabilitySchema | TAction @@ -19,6 +19,8 @@ const actionLabels: Record = { stop: 'Stop', reboot: 'Reboot', updateTags: 'Edit tags', + listSnapshots: 'List DB snapshots', + createSnapshot: 'Create DB snapshot', listNodegroups: 'List nodegroups', createNodegroup: 'Create nodegroup', deleteNodegroup: 'Delete nodegroup', diff --git a/packages/frontend/src/pages/CloudExplorerPage.tsx b/packages/frontend/src/pages/CloudExplorerPage.tsx index 712b2ae..40bfaa7 100644 --- a/packages/frontend/src/pages/CloudExplorerPage.tsx +++ b/packages/frontend/src/pages/CloudExplorerPage.tsx @@ -128,6 +128,7 @@ function ServiceInfoDialog({ ...(schema.capabilities.resourceActions ?? []), ...(schema.capabilities.objectActions ?? []), ...(schema.capabilities.kubernetesActions ?? []), + ...(schema.capabilities.databaseActions ?? []), ]), status?.runtime === 'reachable', ), diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index cdc078e..4b241c6 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -23,6 +23,22 @@ export interface CloudResource { metadata: Record } +export interface DatabaseSnapshot { + id: string + name: string + instanceIdentifier: string | null + status: string | null + engine: string | null + version: string | null + createdAt: string | null + metadata: Record +} + +export interface CreateDatabaseSnapshotInput { + instanceIdentifier: string + snapshotIdentifier: string +} + export interface StorageObject { key: string name: string diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index 3ba1d81..70fced6 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -15,6 +15,7 @@ export type ResourceActionName = | 'reboot' | 'updateTags' export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy' +export type DatabaseActionName = 'listSnapshots' | 'createSnapshot' export type KubernetesActionName = | 'listNodegroups' | 'createNodegroup' @@ -74,6 +75,7 @@ export interface ServiceSchema { capabilities?: { resourceActions?: Array | ResourceActionName> objectActions?: Array | ObjectActionName> + databaseActions?: Array | DatabaseActionName> kubernetesActions?: Array | KubernetesActionName> } filters: FieldSchema[]