feat: Lightsail + svg animation
This commit is contained in:
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user