169 lines
7.0 KiB
TypeScript
169 lines
7.0 KiB
TypeScript
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<CloudResource[]> {
|
|
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<CloudResource | null> {
|
|
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<CloudResource> {
|
|
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<void> {
|
|
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<CloudResource> {
|
|
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<void> {
|
|
await this.lightsail.send(new StartInstanceCommand({instanceName: id}))
|
|
}
|
|
|
|
async stop(id: string): Promise<void> {
|
|
await this.lightsail.send(new StopInstanceCommand({instanceName: id}))
|
|
}
|
|
|
|
async reboot(id: string): Promise<void> {
|
|
await this.lightsail.send(new RebootInstanceCommand({instanceName: id}))
|
|
}
|
|
|
|
async health(): Promise<void> {
|
|
await this.lightsail.send(new GetInstancesCommand({}))
|
|
}
|
|
}
|
|
|
|
async function toResource(instance: Instance): Promise<CloudResource> {
|
|
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<HostPortMapping[]> {
|
|
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'
|
|
}
|