first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
@@ -0,0 +1,330 @@
import {ValidationError} from '../cloud-spi/errors'
import {ListTagsForResourceCommand, type ModifyDBInstanceCommandInput, 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,
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'
>
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,
) {}
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 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)
}
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 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
}