485 lines
20 KiB
TypeScript
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
|
|
}
|