import { CloseInstancePublicPortsCommand, CreateInstancesCommand, DeleteInstanceCommand, GetInstanceCommand, GetInstancesCommand, OpenInstancePublicPortsCommand, type NetworkProtocol, RebootInstanceCommand, StartInstanceCommand, StopInstanceCommand, type Instance, type LightsailClient, } from '@aws-sdk/client-lightsail' import {NotFoundError, ValidationError} from '../cloud-spi/errors' import {awsLightsailSchema} from '../cloud-spi/lightsailSchema' import {awsClients} from '../aws' import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema, UpdateResourceInput} from '../cloud-spi/types' export class AwsLightsailAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'lightsail' as const constructor(private readonly lightsail: LightsailClient = awsClients.lightsail) {} schema(): ServiceSchema { return awsLightsailSchema() } async list(query: ResourceQuery = {}): Promise { const response = await this.lightsail.send(new GetInstancesCommand({})) const search = query.search?.trim().toLowerCase() const resources = await Promise.all((response.instances ?? []).map((instance) => toResource(instance))) return resources .filter((instance) => !search || instance.name.toLowerCase().includes(search)) } async get(id: string): Promise { try { const response = await this.lightsail.send(new GetInstanceCommand({instanceName: id})) return response.instance ? await toResource(response.instance) : null } catch (error) { if (isNotFound(error)) return null throw error } } async create(input: CreateResourceInput): Promise { const name = text(input.values.name) const blueprintId = text(input.values.blueprintId) const bundleId = text(input.values.bundleId) const availabilityZone = text(input.values.availabilityZone) if (!name || !blueprintId || !bundleId || !availabilityZone) { throw new ValidationError('name, blueprintId, bundleId, and availabilityZone are required') } await this.lightsail.send(new CreateInstancesCommand({ instanceNames: [name], blueprintId, bundleId, availabilityZone, })) const instance = await this.get(name) if (!instance) throw new ValidationError(`Lightsail did not return instance ${name} after creation`) return instance } async delete(id: string): Promise { if (!await this.get(id)) throw new NotFoundError(`Lightsail instance ${id} does not exist`) await this.lightsail.send(new DeleteInstanceCommand({instanceName: id})) } async update(id: string, input: UpdateResourceInput): Promise { const action = text(input.values.action) const fromPort = port(input.values.fromPort) const toPort = port(input.values.toPort) const protocol = text(input.values.protocol) if ((action !== 'openPort' && action !== 'closePort') || fromPort === null || toPort === null || fromPort > toPort || protocol !== 'tcp') { throw new ValidationError('action (openPort or closePort), valid fromPort/toPort, and protocol tcp are required') } const portInfo = {fromPort, toPort, protocol: protocol as NetworkProtocol, cidrs: ['0.0.0.0/0']} if (action === 'openPort') { await this.lightsail.send(new OpenInstancePublicPortsCommand({instanceName: id, portInfo})) } else { await this.lightsail.send(new CloseInstancePublicPortsCommand({instanceName: id, portInfo})) } const instance = await this.get(id) if (!instance) throw new NotFoundError(`Lightsail instance ${id} does not exist`) return instance } async start(id: string): Promise { await this.lightsail.send(new StartInstanceCommand({instanceName: id})) } async stop(id: string): Promise { await this.lightsail.send(new StopInstanceCommand({instanceName: id})) } async reboot(id: string): Promise { await this.lightsail.send(new RebootInstanceCommand({instanceName: id})) } async health(): Promise { await this.lightsail.send(new GetInstancesCommand({})) } } async function toResource(instance: Instance): Promise { const name = instance.name ?? '' const hostPortMappings = await lightsailHostPortMappings(name) return { id: name, name, cloud: 'aws', service: 'lightsail', type: 'instance', region: instance.location?.availabilityZone ?? null, createdAt: instance.createdAt?.toISOString() ?? null, status: instance.state?.name ?? null, metadata: { arn: instance.arn, blueprintId: instance.blueprintId, blueprintName: instance.blueprintName, bundleId: instance.bundleId, publicIpAddress: instance.publicIpAddress, privateIpAddress: instance.privateIpAddress, username: instance.username, networking: instance.networking, hardware: instance.hardware, hostPortMappings, }, } } type HostPortMapping = {instancePort: number; host: string; hostPort: number; protocol?: string} async function lightsailHostPortMappings(instanceName: string): Promise { const endpoint = process.env.FLOCI_ENDPOINT if (!endpoint || !instanceName) return [] try { const response = await fetch(new URL(`/_floci/diagnostics/lightsail/instances/${encodeURIComponent(instanceName)}/host-ports`, endpoint), { redirect: 'error', signal: AbortSignal.timeout(5_000), }) if (!response.ok) return [] const body = await response.json() as {mappings?: unknown} if (!Array.isArray(body.mappings)) return [] return body.mappings.flatMap((mapping): HostPortMapping[] => { if (!mapping || typeof mapping !== 'object') return [] const value = mapping as {instancePort?: unknown; host?: unknown; hostPort?: unknown; protocol?: unknown} return typeof value.instancePort === 'number' && typeof value.host === 'string' && typeof value.hostPort === 'number' ? [{instancePort: value.instancePort, host: value.host, hostPort: value.hostPort, protocol: typeof value.protocol === 'string' ? value.protocol : undefined}] : [] }) } catch { return [] } } function text(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function port(value: unknown): number | null { return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 65535 ? value : null } function isNotFound(error: unknown): boolean { return typeof error === 'object' && error !== null && (error as {name?: string}).name === 'NotFoundException' }