import {ValidationError} from '../cloud-spi/errors' import {ListTagsForResourceCommand, type ModifyDBInstanceCommandInput, type RDSClient} from '@aws-sdk/client-rds' import {ExecuteStatementCommand, type Field, type RDSDataClient} from '@aws-sdk/client-rds-data' import {rds as defaultRds, awsClients} from '../aws' import {awsDatabaseSchema} from '../cloud-spi/databaseSchema' import type { CloudResource, CloudServiceAdapter, CreateDatabaseSnapshotInput, CreateResourceInput, DatabaseSnapshot, ResourceQuery, ServiceSchema, SqlConnectionInput, SqlDatabase, SqlQueryResult, SqlTable, UpdateResourceInput, } from '../cloud-spi/types' import {rdsService, type RdsInstance, type RdsSnapshot} from '../services/rds' type RdsServiceShape = Pick< typeof rdsService, 'listInstances' | 'describeInstance' | 'createInstance' | 'modifyInstance' | 'deleteInstance' | 'startInstance' | 'stopInstance' | 'rebootInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses' > export class AwsDatabaseAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'database' as const constructor( private readonly rdsService_: RdsServiceShape = rdsService, private readonly rds: RDSClient = defaultRds, private readonly rdsData: RDSDataClient = awsClients.rdsData, ) {} schema(): ServiceSchema { return awsDatabaseSchema() } async list(query: ResourceQuery = {}): Promise { const instances = await this.rdsService_.listInstances() const resources = await Promise.all(instances.map((instance) => this.toResource(instance))) return filterBySearch(resources, query.search) } async get(id: string): Promise { try { return await this.toResource(await this.rdsService_.describeInstance(id)) } catch (error) { if (hasHttpStatus(error, 404)) return null throw error } } 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 update(id: string, input: UpdateResourceInput): Promise { const values = input.values ?? {} const MasterUserPassword = optionalPassword(values.masterUserPassword) const EnableIAMDatabaseAuthentication = optionalBoolean( values.enableIamDatabaseAuthentication, 'enableIamDatabaseAuthentication', ) const DBSubnetGroupName = optionalString(values.dbSubnetGroupName) const VpcSecurityGroupIds = optionalCsvList(values.vpcSecurityGroupIds) const OptionGroupName = optionalString(values.optionGroupName) const AutoMinorVersionUpgrade = optionalBoolean( values.autoMinorVersionUpgrade, 'autoMinorVersionUpgrade', ) const hasAnyUpdate = MasterUserPassword !== undefined || EnableIAMDatabaseAuthentication !== undefined || DBSubnetGroupName !== undefined || VpcSecurityGroupIds !== undefined || OptionGroupName !== undefined || AutoMinorVersionUpgrade !== undefined if (!hasAnyUpdate) { throw new ValidationError('At least one supported field must be updated') } const modifyInput: ModifyDBInstanceCommandInput = { DBInstanceIdentifier: id, ...(MasterUserPassword !== undefined && {MasterUserPassword}), ...(EnableIAMDatabaseAuthentication !== undefined && {EnableIAMDatabaseAuthentication}), ...(DBSubnetGroupName !== undefined && {DBSubnetGroupName}), ...(VpcSecurityGroupIds !== undefined && {VpcSecurityGroupIds}), ...(OptionGroupName !== undefined && {OptionGroupName}), ...(AutoMinorVersionUpgrade !== undefined && {AutoMinorVersionUpgrade}), } const updated = await this.rdsService_.modifyInstance(modifyInput) return this.toResource(updated) } async delete(id: string): Promise { await this.rdsService_.deleteInstance(id) } async start(id: string): Promise { await this.rdsService_.startInstance(id) } async stop(id: string): Promise { await this.rdsService_.stopInstance(id) } async reboot(id: string): Promise { await this.rdsService_.rebootInstance(id) } async listDatabaseSnapshots(instanceIdentifier?: string): Promise { const normalizedIdentifier = stringValue(instanceIdentifier) || undefined return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot) } 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) } async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { const instance = await this.rdsService_.describeInstance(serverId) const engine = sqlEngine(instance.engine) const result = await this.execute(instance, defaultDatabase(instance, engine), databaseListQuery(engine)) return (result.resultSets[0]?.rows ?? []).flatMap((row) => { const name = stringValue(row.name) if (!name) return [] return [{ name, state: stringValue(row.state) || 'ONLINE', createdAt: null, isSystem: row.isSystem === true, }] }) } async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise { const instance = await this.rdsService_.describeInstance(serverId) const engine = sqlEngine(instance.engine) const database = sqlDatabase(connection, instance, engine) const result = await this.execute(instance, database, tableListQuery(engine)) return (result.resultSets[0]?.rows ?? []).flatMap((row) => { const schema = stringValue(row.schemaName) const name = stringValue(row.name) if (!schema || !name) return [] return [{ schema, name, type: row.objectType === 'view' ? 'view' as const : 'table' as const, rowCount: integerValue(row.rowCount), }] }) } async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise { if (!query.trim()) throw new ValidationError('SQL query is required') const instance = await this.rdsService_.describeInstance(serverId) const engine = sqlEngine(instance.engine) return this.execute(instance, sqlDatabase(connection, instance, engine), query) } private async execute(instance: RdsInstance, database: string, sql: string): Promise { if (!instance.arn) throw new ValidationError('DB instance ARN is not available') const startedAt = performance.now() const response = await this.rdsData.send(new ExecuteStatementCommand({ resourceArn: instance.arn, // Floci falls back to the RDS instance credentials when this local // placeholder does not resolve to a Secrets Manager secret. secretArn: 'arn:aws:secretsmanager:us-east-1:000000000000:secret:floci-ui-rds-data', database, sql, includeResultMetadata: true, })) const columns = (response.columnMetadata ?? []).map((column, index) => ({ name: column.name ?? `column_${index + 1}`, type: String(column.typeName ?? column.type ?? 'unknown'), })) const rows = (response.records ?? []).map((record) => Object.fromEntries( record.map((field, index) => [columns[index]?.name ?? `column_${index + 1}`, fieldValue(field)]), )) return { resultSets: columns.length > 0 ? [{columns, rows, truncated: false}] : [], rowsAffected: [response.numberOfRecordsUpdated ?? 0], durationMs: Math.round(performance.now() - startedAt), } } private async toResource(instance: RdsInstance): Promise { const tags = instance.arn ? await this.getTags(instance.arn) : [] const vpcSecurityGroupIds = (instance.vpcSecurityGroups ?? []) .map((group) => group.id) .filter((id): id is string => Boolean(id)) .join(', ') return { id: instance.identifier, name: instance.identifier, cloud: 'aws', service: 'database', type: 'db-instance', region: instance.availabilityZone ?? null, createdAt: instance.createdAt ?? null, status: instance.status ?? null, version: instance.engineVersion ?? null, engine: instance.engine ?? null, instanceClass: instance.instanceClass ?? null, metadata: { arn: instance.arn, resourceId: instance.resourceId, dbName: instance.dbName, masterUsername: instance.masterUsername, allocatedStorage: instance.allocatedStorage, storageType: instance.storageType, endpoint: instance.endpoint, multiAz: instance.multiAz, publiclyAccessible: instance.publiclyAccessible, iamDatabaseAuthenticationEnabled: instance.iamDatabaseAuthenticationEnabled, preferredBackupWindow: instance.preferredBackupWindow, preferredMaintenanceWindow: instance.preferredMaintenanceWindow, vpcSecurityGroups: instance.vpcSecurityGroups, vpcSecurityGroupIds, subnetGroup: instance.subnetGroup, optionGroupName: instance.optionGroupName, autoMinorVersionUpgrade: instance.autoMinorVersionUpgrade, tags, }, } } private async getTags(arn: string): Promise> { try { const res = await this.rds.send(new ListTagsForResourceCommand({ResourceName: arn})) return (res.TagList ?? []).map((tag) => ({ key: tag.Key ?? '', value: tag.Value ?? '', })) } catch (error) { if (error instanceof Error && error.message.includes('ListTagsForResource is not supported')) return [] throw error } } } const POSTGRES_DATABASES_QUERY = ` SELECT datname AS name, CASE WHEN datallowconn THEN 'ONLINE' ELSE 'OFFLINE' END AS state, datname IN ('postgres', 'template0', 'template1') AS "isSystem" FROM pg_database WHERE datallowconn ORDER BY "isSystem" DESC, datname` const POSTGRES_TABLES_QUERY = ` SELECT namespace.nspname AS "schemaName", relation.relname AS name, CASE WHEN relation.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END AS "objectType", CASE WHEN relation.relkind IN ('r', 'p') THEN relation.reltuples::bigint ELSE NULL END AS "rowCount" FROM pg_class AS relation INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema') AND namespace.nspname NOT LIKE 'pg_toast%' AND relation.relkind IN ('r', 'p', 'v', 'm') ORDER BY namespace.nspname, relation.relname` const MYSQL_DATABASES_QUERY = ` SELECT schema_name AS name, 'ONLINE' AS state, schema_name IN ('information_schema', 'mysql', 'performance_schema', 'sys') AS isSystem FROM information_schema.schemata ORDER BY isSystem DESC, schema_name` const MYSQL_TABLES_QUERY = ` SELECT table_schema AS schemaName, table_name AS name, CASE WHEN table_type = 'VIEW' THEN 'view' ELSE 'table' END AS objectType, table_rows AS rowCount FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name` function sqlEngine(engine: string | undefined): 'postgresql' | 'mysql' { if (engine === 'postgres') return 'postgresql' if (engine === 'mysql') return 'mysql' throw new ValidationError('SQL exploration is available only for PostgreSQL and MySQL RDS instances') } function defaultDatabase(instance: RdsInstance, engine: 'postgresql' | 'mysql'): string { return instance.dbName || (engine === 'postgresql' ? 'postgres' : 'mysql') } function sqlDatabase(connection: SqlConnectionInput, instance: RdsInstance, engine: 'postgresql' | 'mysql'): string { return stringValue(connection.database) || defaultDatabase(instance, engine) } function databaseListQuery(engine: 'postgresql' | 'mysql'): string { return engine === 'postgresql' ? POSTGRES_DATABASES_QUERY : MYSQL_DATABASES_QUERY } function tableListQuery(engine: 'postgresql' | 'mysql'): string { return engine === 'postgresql' ? POSTGRES_TABLES_QUERY : MYSQL_TABLES_QUERY } function integerValue(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value) if (typeof value === 'string' && /^-?\d+$/.test(value)) return Number(value) return null } function fieldValue(field: Field): unknown { if (field.isNull) return null if (field.stringValue !== undefined) return field.stringValue if (field.longValue !== undefined) return Number(field.longValue) if (field.doubleValue !== undefined) return field.doubleValue if (field.booleanValue !== undefined) return field.booleanValue if (field.blobValue !== undefined) return Buffer.from(field.blobValue).toString('base64') if (field.arrayValue !== undefined) return field.arrayValue return null } 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 return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) } function hasHttpStatus(error: unknown, status: number): boolean { if (typeof error !== 'object' || error === null) return false const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata return metadata?.httpStatusCode === status } function optionalPassword(value: unknown): string | undefined { if (value === undefined || value === null) return undefined const str = String(value) if (str === '') return undefined if (str.length < 8 || str.length > 128) { throw new ValidationError('masterUserPassword must be between 8 and 128 characters') } return str } function optionalBoolean(value: unknown, fieldName: string): boolean | undefined { if (value === undefined || value === null) return undefined if (typeof value === 'boolean') return value if (typeof value === 'string') { const trimmed = value.trim() if (trimmed === '') return undefined if (trimmed === 'true') return true if (trimmed === 'false') return false } throw new ValidationError(`${fieldName} must be a boolean (true or false)`) } function optionalString(value: unknown): string | undefined { if (value === undefined || value === null) return undefined const trimmed = String(value).trim() return trimmed === '' ? undefined : trimmed } function optionalCsvList(value: unknown): string[] | undefined { if (value === undefined || value === null) return undefined if (Array.isArray(value)) { const items = value.map((v) => String(v).trim()).filter(Boolean) return items.length > 0 ? items : undefined } const str = String(value).trim() if (!str) return undefined const items = str.split(',').map((s) => s.trim()).filter(Boolean) return items.length > 0 ? items : undefined }