feat: Lightsail + svg animation

This commit is contained in:
Zakaria
2026-10-11 14:19:00 -04:00
parent 0036ef7e51
commit ca1e845cb6
40 changed files with 1887 additions and 34 deletions
@@ -0,0 +1,113 @@
import {afterEach, describe, expect, test} from 'bun:test'
import {
CloseInstancePublicPortsCommand,
CreateInstancesCommand,
DeleteInstanceCommand,
GetInstanceCommand,
GetInstancesCommand,
OpenInstancePublicPortsCommand,
RebootInstanceCommand,
StartInstanceCommand,
StopInstanceCommand,
type LightsailClient,
} from '@aws-sdk/client-lightsail'
import {AwsLightsailAdapter} from './AwsLightsailAdapter'
const originalEndpoint = process.env.FLOCI_ENDPOINT
afterEach(() => {
process.env.FLOCI_ENDPOINT = originalEndpoint
})
function stubLightsail() {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
if (command instanceof GetInstancesCommand) return {instances: [instance('web')]}
if (command instanceof GetInstanceCommand) return {instance: instance(command.input.instanceName ?? 'web')}
return {}
},
} as unknown as LightsailClient
return {client, sent}
}
function instance(name: string) {
return {
name,
arn: `arn:aws:lightsail:us-east-1:000000000000:Instance/${name}`,
createdAt: new Date('2026-10-09T00:00:00Z'),
state: {name: 'running'},
blueprintId: 'ubuntu_22_04',
blueprintName: 'Ubuntu',
bundleId: 'nano_3_0',
publicIpAddress: '203.0.113.10',
privateIpAddress: '10.0.0.10',
networking: {staticIp: '203.0.113.11', ports: [{fromPort: 80, toPort: 80, protocol: 'tcp'}]},
hardware: {disks: [{name: `${name}-root`, sizeInGb: 20, isSystemDisk: true, state: 'in-use'}]},
location: {availabilityZone: 'us-east-1a'},
}
}
describe('AwsLightsailAdapter', () => {
test('maps Lightsail instances to Cloud Explorer resources', async () => {
const [resource] = await new AwsLightsailAdapter(stubLightsail().client).list()
expect(resource).toMatchObject({id: 'web', service: 'lightsail', status: 'running', region: 'us-east-1a'})
expect(resource?.metadata.publicIpAddress).toBe('203.0.113.10')
expect(resource?.metadata).toMatchObject({privateIpAddress: '10.0.0.10', networking: {staticIp: '203.0.113.11'}, hardware: {disks: [{name: 'web-root'}]}})
})
test('merges diagnostic host-port mappings into UI metadata', async () => {
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
const fetchMock = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify({mappings: [
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
]}))
try {
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
expect(resource?.metadata.hostPortMappings).toEqual([
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
])
} finally {
globalThis.fetch = fetchMock
}
})
test('uses Lightsail CreateInstances request fields', async () => {
const stub = stubLightsail()
await new AwsLightsailAdapter(stub.client).create({values: {
name: 'web', blueprintId: 'ubuntu_22_04', bundleId: 'nano_3_0', availabilityZone: 'us-east-1a',
}})
expect(stub.sent.find((command) => command instanceof CreateInstancesCommand))
.toMatchObject({input: {
instanceNames: ['web'], blueprintId: 'ubuntu_22_04', bundleId: 'nano_3_0', availabilityZone: 'us-east-1a',
}})
})
test('deletes instances by name', async () => {
const stub = stubLightsail()
await new AwsLightsailAdapter(stub.client).delete('web')
expect(stub.sent.find((command) => command instanceof DeleteInstanceCommand))
.toMatchObject({input: {instanceName: 'web'}})
})
test('uses Lightsail lifecycle commands', async () => {
const stub = stubLightsail()
const adapter = new AwsLightsailAdapter(stub.client)
await adapter.start('web')
await adapter.stop('web')
await adapter.reboot('web')
expect(stub.sent.find((command) => command instanceof StartInstanceCommand)).toMatchObject({input: {instanceName: 'web'}})
expect(stub.sent.find((command) => command instanceof StopInstanceCommand)).toMatchObject({input: {instanceName: 'web'}})
expect(stub.sent.find((command) => command instanceof RebootInstanceCommand)).toMatchObject({input: {instanceName: 'web'}})
})
test('opens and closes public ports with the Lightsail port contract', async () => {
const stub = stubLightsail()
const adapter = new AwsLightsailAdapter(stub.client)
await adapter.update('web', {values: {action: 'openPort', fromPort: 8080, toPort: 8080, protocol: 'tcp'}})
await adapter.update('web', {values: {action: 'closePort', fromPort: 8080, toPort: 8080, protocol: 'tcp'}})
expect(stub.sent.find((command) => command instanceof OpenInstancePublicPortsCommand)).toMatchObject({input: {instanceName: 'web', portInfo: {fromPort: 8080, toPort: 8080, protocol: 'tcp'}}})
expect(stub.sent.find((command) => command instanceof CloseInstancePublicPortsCommand)).toMatchObject({input: {instanceName: 'web', portInfo: {fromPort: 8080, toPort: 8080, protocol: 'tcp'}}})
})
})
@@ -0,0 +1,168 @@
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'
}