Files
floci-ui/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts
T
2026-10-01 09:18:04 -04:00

485 lines
20 KiB
TypeScript

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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<CloudResource> {
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<void> {
await this.rdsService_.deleteInstance(id)
}
async start(id: string): Promise<void> {
await this.rdsService_.startInstance(id)
}
async stop(id: string): Promise<void> {
await this.rdsService_.stopInstance(id)
}
async reboot(id: string): Promise<void> {
await this.rdsService_.rebootInstance(id)
}
async listDatabaseSnapshots(instanceIdentifier?: string): Promise<DatabaseSnapshot[]> {
const normalizedIdentifier = stringValue(instanceIdentifier) || undefined
return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot)
}
async createDatabaseSnapshot(input: CreateDatabaseSnapshotInput): Promise<DatabaseSnapshot> {
const sourceIdentifier = instanceIdentifier(input.instanceIdentifier)
const snapshotIdentifier = databaseSnapshotIdentifier(input.snapshotIdentifier)
return snapshotToDatabaseSnapshot(
await this.rdsService_.createSnapshot(sourceIdentifier, snapshotIdentifier),
)
}
async listDatabaseOrderableInstanceClasses(engine?: string): Promise<string[]> {
const normalizedEngine = stringValue(engine) || 'postgres'
return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine)
}
async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
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<SqlTable[]> {
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<SqlQueryResult> {
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<SqlQueryResult> {
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<CloudResource> {
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<Array<{key: string; value: string}>> {
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
}