CI/CD - CodeBuild + CodeDeploy + CodePipeline
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {ListTagsForResourceCommand, type ModifyDBInstanceCommandInput, type RDSClient} from '@aws-sdk/client-rds'
|
||||
import {rds as defaultRds} from '../aws'
|
||||
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,
|
||||
@@ -10,13 +11,17 @@ import type {
|
||||
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' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses'
|
||||
'listInstances' | 'describeInstance' | 'createInstance' | 'modifyInstance' | 'deleteInstance' | 'startInstance' | 'stopInstance' | 'rebootInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses'
|
||||
>
|
||||
|
||||
export class AwsDatabaseAdapter implements CloudServiceAdapter {
|
||||
@@ -26,6 +31,7 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
|
||||
constructor(
|
||||
private readonly rdsService_: RdsServiceShape = rdsService,
|
||||
private readonly rds: RDSClient = defaultRds,
|
||||
private readonly rdsData: RDSDataClient = awsClients.rdsData,
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
@@ -118,6 +124,18 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
|
||||
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)
|
||||
@@ -136,6 +154,73 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
|
||||
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 ?? [])
|
||||
@@ -192,6 +277,75 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
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])?$/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user