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'
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { KinesisClient } from "@aws-sdk/client-kinesis";
|
||||
import { SESv2Client } from "@aws-sdk/client-sesv2";
|
||||
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
||||
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
||||
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -89,6 +90,7 @@ export type AwsClients = {
|
||||
sesv2: SESv2Client;
|
||||
cloudwatch: CloudWatchClient;
|
||||
elasticache: ElastiCacheClient;
|
||||
lightsail: LightsailClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -133,6 +135,7 @@ function buildClients(accountId: string): AwsClients {
|
||||
sesv2: new SESv2Client(base),
|
||||
cloudwatch: new CloudWatchClient(base),
|
||||
elasticache: new ElastiCacheClient(base),
|
||||
lightsail: new LightsailClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema} from './types'
|
||||
|
||||
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
|
||||
{name: 'list', label: 'List instances', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'start', label: 'Start instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'stop', label: 'Stop instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'reboot', label: 'Reboot instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'update', label: 'Manage public ports', enabled: true, status: 'available', runtimeRequired: true},
|
||||
]
|
||||
|
||||
const fields: FieldSchema[] = [
|
||||
{name: 'name', label: 'Instance name', type: 'text', required: true, validation: {minLength: 1, maxLength: 255, message: 'Provide an instance name.'}},
|
||||
{
|
||||
name: 'blueprintId', label: 'Blueprint', type: 'select', required: true, defaultValue: 'ubuntu_22_04', options: [
|
||||
{label: 'Ubuntu 22.04 LTS', value: 'ubuntu_22_04'},
|
||||
{label: 'Ubuntu 24.04 LTS', value: 'ubuntu_24_04'},
|
||||
{label: 'Amazon Linux 2023', value: 'amazon_linux_2023'},
|
||||
{label: 'Nginx', value: 'nginx'},
|
||||
{label: 'WordPress', value: 'wordpress'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'bundleId', label: 'Instance plan', type: 'select', required: true, defaultValue: 'nano_3_0', options: [
|
||||
{label: 'Nano', value: 'nano_3_0'},
|
||||
{label: 'Micro', value: 'micro_3_0'},
|
||||
{label: 'Small', value: 'small_3_0'},
|
||||
{label: 'Medium', value: 'medium_3_0'},
|
||||
],
|
||||
},
|
||||
{name: 'availabilityZone', label: 'Availability Zone', type: 'text', required: true, defaultValue: 'us-east-1a'},
|
||||
]
|
||||
|
||||
export function awsLightsailSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'lightsail',
|
||||
displayName: 'Amazon Lightsail',
|
||||
fields,
|
||||
actions: ['list', 'create', 'inspect', 'update', 'delete'],
|
||||
capabilities: {resourceActions},
|
||||
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
|
||||
columns: [
|
||||
{name: 'name', label: 'Name'},
|
||||
{name: 'status', label: 'State', format: 'badge'},
|
||||
{name: 'blueprint', label: 'Blueprint', path: 'metadata.blueprintName'},
|
||||
{name: 'bundle', label: 'Plan', path: 'metadata.bundleId'},
|
||||
{name: 'publicIp', label: 'Public IP', path: 'metadata.publicIpAddress'},
|
||||
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,12 @@ export const SERVICE_CATALOG = {
|
||||
group: 'Compute',
|
||||
order: 51,
|
||||
},
|
||||
lightsail: {
|
||||
displayName: 'Lightsail',
|
||||
iconKey: 'lightsail',
|
||||
group: 'Compute',
|
||||
order: 60,
|
||||
},
|
||||
codebuild: {
|
||||
displayName: 'CodeBuild',
|
||||
iconKey: 'codebuild',
|
||||
|
||||
@@ -46,6 +46,7 @@ import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
|
||||
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
|
||||
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
||||
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
||||
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
import {createEksService} from './services/eks'
|
||||
@@ -94,6 +95,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)),
|
||||
new AwsKinesisAdapter(clients.kinesis),
|
||||
new AwsElastiCacheAdapter(clients.elasticache),
|
||||
new AwsLightsailAdapter(clients.lightsail),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
|
||||
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
||||
import {serviceForAccount} from '../cloudProxy'
|
||||
import {CloudProxyService} from '../service/CloudProxyService'
|
||||
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
||||
|
||||
// Header (and query-param fallback for direct links such as object downloads)
|
||||
// used by the frontend to scope every request to an AWS account.
|
||||
@@ -136,6 +137,40 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/aws/services/lightsail/resources/:id/static-files', async (c) => {
|
||||
return withRuntime(c, async () => {
|
||||
const form = await c.req.formData()
|
||||
const files = form.getAll('files').filter((entry): entry is File => entry instanceof File)
|
||||
if (files.length === 0 || files.length !== form.getAll('files').length) {
|
||||
throw new ValidationError('At least one file is required.')
|
||||
}
|
||||
const upload = new FormData()
|
||||
for (const file of files) {
|
||||
upload.append('files', file, file.name)
|
||||
}
|
||||
return c.json(await deployLightsailStaticFiles(c.req.param('id'), upload))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/aws/services/lightsail/resources/:id/node-application', async (c) => {
|
||||
return withRuntime(c, async () => {
|
||||
const form = await c.req.formData()
|
||||
const files = form.getAll('files').filter((entry): entry is File => entry instanceof File)
|
||||
if (files.length === 0 || files.length !== form.getAll('files').length) {
|
||||
throw new ValidationError('At least one file or zip archive is required.')
|
||||
}
|
||||
const portValue = form.get('port')
|
||||
const port = typeof portValue === 'string' && /^\d+$/.test(portValue) ? Number(portValue) : 3000
|
||||
if (port < 1 || port > 65535) throw new ValidationError('Port must be between 1 and 65535.')
|
||||
const upload = new FormData()
|
||||
for (const file of files) upload.append('files', file, file.name)
|
||||
const startCommand = form.get('startCommand')
|
||||
if (typeof startCommand === 'string' && startCommand.trim()) upload.append('startCommand', startCommand.trim())
|
||||
upload.append('port', String(port))
|
||||
return c.json(await deployLightsailNodeApplication(c.req.param('id'), upload))
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/nosql/resources/:id/containers', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {createEc2Router} from './ec2'
|
||||
import {SshCheckError, type SshCheckResult} from '../services/sshCheck'
|
||||
import type {
|
||||
CreateAmiInput,
|
||||
CreateNatGatewayInput,
|
||||
@@ -202,6 +203,51 @@ describe('POST /instances/:id/terminate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /instances/:id/ssh/check', () => {
|
||||
test('returns the backend SSH check response', async () => {
|
||||
let checkedInstanceId: string | undefined
|
||||
const router = createEc2Router(fakeService, {
|
||||
check: async (instanceId: string): Promise<SshCheckResult> => {
|
||||
checkedInstanceId = instanceId
|
||||
return {
|
||||
ok: true,
|
||||
banner: 'SSH-2.0-OpenSSH_9.7',
|
||||
checkedFrom: 'UI backend',
|
||||
checkedAt: '2026-10-08T12:00:00.000Z',
|
||||
target: {host: 'host.docker.internal', port: 2222},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const res = await router.request('/instances/i-test1/ssh/check', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({host: 'attacker.example', port: 22}),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(checkedInstanceId).toBe('i-test1')
|
||||
expect(await res.json()).toEqual({
|
||||
ok: true,
|
||||
banner: 'SSH-2.0-OpenSSH_9.7',
|
||||
checkedFrom: 'UI backend',
|
||||
checkedAt: '2026-10-08T12:00:00.000Z',
|
||||
target: {host: 'host.docker.internal', port: 2222},
|
||||
})
|
||||
})
|
||||
|
||||
test('maps typed checker errors', async () => {
|
||||
const router = createEc2Router(fakeService, {
|
||||
check: async () => { throw new SshCheckError('CONNECTION_TIMEOUT', 'SSH connection timed out', 504) },
|
||||
})
|
||||
|
||||
const res = await router.request('/instances/i-test1/ssh/check', {method: 'POST'})
|
||||
|
||||
expect(res.status).toBe(504)
|
||||
expect(await res.json()).toEqual({ok: false, code: 'CONNECTION_TIMEOUT', error: 'SSH connection timed out'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /instances/:id/security-groups', () => {
|
||||
test('replaces security groups', async () => {
|
||||
let receivedGroupIds: string[] | undefined
|
||||
|
||||
@@ -11,8 +11,10 @@ import type {
|
||||
VpcWizardInput,
|
||||
} from '../services/ec2'
|
||||
import {ec2Service} from '../services/ec2'
|
||||
import {SshCheckError, sshCheckService} from '../services/sshCheck'
|
||||
|
||||
type Ec2Service = typeof ec2Service
|
||||
type SshCheckService = typeof sshCheckService
|
||||
type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType}
|
||||
|
||||
type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
|
||||
@@ -21,6 +23,21 @@ type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
|
||||
sshPort?: number
|
||||
containerId?: string
|
||||
containerStatus?: 'running' | 'stopped' | 'unavailable'
|
||||
state?: string
|
||||
launchStatus?: string
|
||||
launchEvents?: Array<{stage?: string; status?: string; timestamp?: string; message?: string}>
|
||||
selectedImageType?: string
|
||||
selectedImageReference?: string
|
||||
sshUser?: string
|
||||
sshPreinstalled?: boolean
|
||||
initType?: string
|
||||
sshServiceName?: string
|
||||
sshStatus?: string
|
||||
sshStatusMessage?: string
|
||||
keyName?: string
|
||||
keyInjectionStatus?: string
|
||||
publishedPorts?: Record<string, number>
|
||||
failure?: {code?: string; message?: string} | string
|
||||
}>}}
|
||||
|
||||
async function connectionDetails(instanceId: string) {
|
||||
@@ -49,7 +66,7 @@ function handleError(c: Context, error: unknown) {
|
||||
return c.json({error: msg}, 400)
|
||||
}
|
||||
|
||||
export function createEc2Router(svc: Ec2Service = ec2Service) {
|
||||
export function createEc2Router(svc: Ec2Service = ec2Service, sshChecker: SshCheckService = sshCheckService) {
|
||||
const app = new Hono()
|
||||
|
||||
// Instances
|
||||
@@ -108,9 +125,23 @@ export function createEc2Router(svc: Ec2Service = ec2Service) {
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/instances/:instanceId/ssh/check', async (c) => {
|
||||
try {
|
||||
return c.json(await sshChecker.check(c.req.param('instanceId')))
|
||||
} catch (error) {
|
||||
if (error instanceof SshCheckError) {
|
||||
return c.json({ok: false, code: error.code, error: error.message}, error.status)
|
||||
}
|
||||
return c.json({ok: false, code: 'DIAGNOSTICS_UNAVAILABLE', error: 'SSH check failed'}, 502)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/instances/:instanceId/tags', async (c) => {
|
||||
try {
|
||||
const {toAdd, toRemove} = await c.req.json<{toAdd: Ec2Tag[]; toRemove: string[]}>()
|
||||
if (!Array.isArray(toAdd) || !Array.isArray(toRemove)) {
|
||||
return c.json({error: 'toAdd and toRemove must be arrays'}, 400)
|
||||
}
|
||||
await svc.updateInstanceTags(c.req.param('instanceId'), toAdd, toRemove)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {afterEach, describe, expect, mock, test} from 'bun:test'
|
||||
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from './lightsailDeployment'
|
||||
|
||||
const originalEndpoint = process.env.FLOCI_ENDPOINT
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||
globalThis.fetch = originalFetch
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe('deployLightsailNodeApplication', () => {
|
||||
test('forwards package uploads and preserves core deployment diagnostics', async () => {
|
||||
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
||||
const fetch = mock(() => Promise.resolve(new Response(JSON.stringify({status: 'deployed', files: 2, port: 3000, releaseId: 'release'}), {headers: {'content-type': 'application/json'}})))
|
||||
globalThis.fetch = fetch as unknown as typeof globalThis.fetch
|
||||
const form = new FormData()
|
||||
form.append('files', new File(['{}'], 'package.json'))
|
||||
form.append('port', '3000')
|
||||
|
||||
await expect(deployLightsailNodeApplication('node app', form)).resolves.toEqual({status: 'deployed', files: 2, port: 3000, releaseId: 'release'})
|
||||
expect(fetch).toHaveBeenCalledWith('http://core.test:4566/_floci/diagnostics/lightsail/instances/node%20app/node-application', expect.objectContaining({
|
||||
method: 'POST', body: form, redirect: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('deployLightsailStaticFiles', () => {
|
||||
test('forwards multipart files only to the diagnostics deployment endpoint', async () => {
|
||||
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
||||
const fetch = mock(() => Promise.resolve(new Response(JSON.stringify({status: 'deployed', files: 1}), {headers: {'content-type': 'application/json'}})))
|
||||
globalThis.fetch = fetch as unknown as typeof globalThis.fetch
|
||||
const form = new FormData()
|
||||
form.append('files', new File(['<h1>ok</h1>'], 'site/index.html'))
|
||||
|
||||
await expect(deployLightsailStaticFiles('web site', form)).resolves.toEqual({status: 'deployed', files: 1})
|
||||
expect(fetch).toHaveBeenCalledWith('http://core.test:4566/_floci/diagnostics/lightsail/instances/web%20site/static-files', expect.objectContaining({
|
||||
method: 'POST', body: form, redirect: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import {httpStatusToCloudError, RuntimeUnavailableError} from '../cloud-spi/errors'
|
||||
|
||||
export type LightsailStaticDeployment = {status: 'deployed'; files: number}
|
||||
export type LightsailNodeDeployment = {status: 'deployed'; files: number; port: number; releaseId: string}
|
||||
|
||||
export async function deployLightsailStaticFiles(instanceName: string, form: FormData): Promise<LightsailStaticDeployment> {
|
||||
const endpoint = process.env.FLOCI_ENDPOINT
|
||||
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(new URL(`/_floci/diagnostics/lightsail/instances/${encodeURIComponent(instanceName)}/static-files`, endpoint).toString(), {
|
||||
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
|
||||
}
|
||||
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown}
|
||||
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Static file deployment failed')
|
||||
if (body.status !== 'deployed' || typeof body.files !== 'number') {
|
||||
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid deployment response')
|
||||
}
|
||||
return {status: 'deployed', files: body.files}
|
||||
}
|
||||
|
||||
export async function deployLightsailNodeApplication(instanceName: string, form: FormData): Promise<LightsailNodeDeployment> {
|
||||
const endpoint = process.env.FLOCI_ENDPOINT
|
||||
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(new URL(`/_floci/diagnostics/lightsail/instances/${encodeURIComponent(instanceName)}/node-application`, endpoint).toString(), {
|
||||
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(360_000),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
|
||||
}
|
||||
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown; port?: unknown; releaseId?: unknown}
|
||||
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Node.js deployment failed')
|
||||
if (body.status !== 'deployed' || typeof body.files !== 'number' || typeof body.port !== 'number' || typeof body.releaseId !== 'string') {
|
||||
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid Node.js deployment response')
|
||||
}
|
||||
return {status: 'deployed', files: body.files, port: body.port, releaseId: body.releaseId}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {createServer, type Server} from 'node:net'
|
||||
import {
|
||||
createSshCheckService,
|
||||
readSshBanner,
|
||||
SshCheckError,
|
||||
type SshTarget,
|
||||
} from './sshCheck'
|
||||
|
||||
const servers: Server[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))))
|
||||
})
|
||||
|
||||
async function listen(handler: (socket: import('node:net').Socket) => void): Promise<SshTarget> {
|
||||
const server = createServer(handler)
|
||||
servers.push(server)
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Test server has no TCP address')
|
||||
return {host: '127.0.0.1', port: address.port}
|
||||
}
|
||||
|
||||
describe('readSshBanner', () => {
|
||||
test('reads a real SSH identification banner', async () => {
|
||||
const target = await listen((socket) => socket.end('SSH-2.0-OpenSSH_9.7\r\n'))
|
||||
|
||||
expect(await readSshBanner(target, 500)).toBe('SSH-2.0-OpenSSH_9.7')
|
||||
})
|
||||
|
||||
test('assembles a fragmented banner', async () => {
|
||||
const target = await listen((socket) => {
|
||||
socket.write('SSH-2.0-Open')
|
||||
setTimeout(() => socket.end('SSH_9.7\r\n'), 10)
|
||||
})
|
||||
|
||||
expect(await readSshBanner(target, 500)).toBe('SSH-2.0-OpenSSH_9.7')
|
||||
})
|
||||
|
||||
test('rejects a connection that closes without an SSH banner', async () => {
|
||||
const target = await listen((socket) => socket.end('HTTP/1.1 200 OK\r\n'))
|
||||
|
||||
await expect(readSshBanner(target, 500)).rejects.toMatchObject({code: 'SSH_BANNER_MISSING'})
|
||||
})
|
||||
|
||||
test('rejects an SSH identification line longer than the protocol limit', async () => {
|
||||
const target = await listen((socket) => socket.end(`SSH-2.0-${'x'.repeat(246)}\r\n`))
|
||||
|
||||
await expect(readSshBanner(target, 500)).rejects.toMatchObject({code: 'SSH_BANNER_MISSING'})
|
||||
})
|
||||
|
||||
test('maps a silent socket to a timeout', async () => {
|
||||
const target = await listen(() => {})
|
||||
|
||||
await expect(readSshBanner(target, 20)).rejects.toMatchObject({code: 'CONNECTION_TIMEOUT'})
|
||||
})
|
||||
|
||||
test('maps a refused socket connection', async () => {
|
||||
const temporaryServer = createServer()
|
||||
await new Promise<void>((resolve) => temporaryServer.listen(0, '127.0.0.1', resolve))
|
||||
const address = temporaryServer.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Test server has no TCP address')
|
||||
const port = address.port
|
||||
await new Promise<void>((resolve) => temporaryServer.close(() => resolve()))
|
||||
|
||||
await expect(readSshBanner({host: '127.0.0.1', port}, 500)).rejects.toMatchObject({code: 'CONNECTION_REFUSED'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sshCheckService', () => {
|
||||
test('derives the exact instance target and overrides a loopback host', async () => {
|
||||
let receivedTarget: SshTarget | undefined
|
||||
const service = createSshCheckService({
|
||||
fetchDiagnostics: async () => ({ec2: {instances: [
|
||||
{instanceId: 'i-other', sshHost: '192.0.2.10', sshPort: 2200, containerStatus: 'running'},
|
||||
{instanceId: 'i-wanted', sshHost: '127.0.0.1', sshPort: 2222, containerStatus: 'running'},
|
||||
]}}),
|
||||
loopbackHost: 'ssh-host.test',
|
||||
connect: async (target) => {
|
||||
receivedTarget = target
|
||||
return 'SSH-2.0-test'
|
||||
},
|
||||
now: () => new Date('2026-10-08T12:00:00.000Z'),
|
||||
})
|
||||
|
||||
const result = await service.check('i-wanted')
|
||||
|
||||
expect(receivedTarget).toEqual({host: 'ssh-host.test', port: 2222})
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
banner: 'SSH-2.0-test',
|
||||
checkedFrom: 'UI backend',
|
||||
checkedAt: '2026-10-08T12:00:00.000Z',
|
||||
target: {host: 'ssh-host.test', port: 2222},
|
||||
})
|
||||
})
|
||||
|
||||
test('does not allow diagnostics to redirect the backend probe', async () => {
|
||||
let receivedTarget: SshTarget | undefined
|
||||
const service = createSshCheckService({
|
||||
fetchDiagnostics: async () => ({ec2: {instances: [
|
||||
{instanceId: 'i-wanted', sshHost: '192.0.2.20', sshPort: 2222, containerStatus: 'running'},
|
||||
]}}),
|
||||
loopbackHost: 'ssh-host.test',
|
||||
connect: async (target) => {
|
||||
receivedTarget = target
|
||||
return 'SSH-2.0-test'
|
||||
},
|
||||
})
|
||||
|
||||
await service.check('i-wanted')
|
||||
|
||||
expect(receivedTarget).toEqual({host: 'ssh-host.test', port: 2222})
|
||||
})
|
||||
|
||||
test('reports malformed diagnostics before attempting a connection', async () => {
|
||||
const service = createSshCheckService({
|
||||
fetchDiagnostics: async () => ({ec2: {instances: {} as never}}),
|
||||
connect: async () => { throw new Error('must not connect') },
|
||||
})
|
||||
|
||||
await expect(service.check('i-running')).rejects.toMatchObject({
|
||||
code: 'DIAGNOSTICS_UNAVAILABLE',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
test('reports missing diagnostics for an unmatched instance', async () => {
|
||||
const service = createSshCheckService({fetchDiagnostics: async () => ({ec2: {instances: []}})})
|
||||
|
||||
await expect(service.check('i-missing')).rejects.toMatchObject({code: 'DIAGNOSTICS_UNAVAILABLE', status: 404})
|
||||
})
|
||||
|
||||
test('reports a stopped instance before attempting a connection', async () => {
|
||||
const service = createSshCheckService({
|
||||
fetchDiagnostics: async () => ({ec2: {instances: [
|
||||
{instanceId: 'i-stopped', sshHost: '127.0.0.1', sshPort: 2222, containerStatus: 'stopped'},
|
||||
]}}),
|
||||
connect: async () => { throw new Error('must not connect') },
|
||||
})
|
||||
|
||||
await expect(service.check('i-stopped')).rejects.toMatchObject({code: 'INSTANCE_NOT_RUNNING', status: 409})
|
||||
})
|
||||
|
||||
test('rejects an invalid banner returned by a connector', async () => {
|
||||
const service = createSshCheckService({
|
||||
fetchDiagnostics: async () => ({ec2: {instances: [
|
||||
{instanceId: 'i-running', sshHost: '127.0.0.1', sshPort: 2222, containerStatus: 'running'},
|
||||
]}}),
|
||||
connect: async () => 'not ssh',
|
||||
})
|
||||
|
||||
await expect(service.check('i-running')).rejects.toBeInstanceOf(SshCheckError)
|
||||
await expect(service.check('i-running')).rejects.toMatchObject({code: 'SSH_BANNER_MISSING'})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import {createConnection} from 'node:net'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 3_000
|
||||
const MAX_BANNER_BYTES = 8_192
|
||||
|
||||
type Ec2DiagnosticInstance = {
|
||||
instanceId?: string
|
||||
sshHost?: string
|
||||
sshPort?: number
|
||||
containerStatus?: 'running' | 'stopped' | 'unavailable'
|
||||
}
|
||||
|
||||
type DiagnosticResponse = {
|
||||
ec2?: {instances?: Ec2DiagnosticInstance[]}
|
||||
}
|
||||
|
||||
export type SshTarget = {
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
export type SshCheckResult = {
|
||||
ok: true
|
||||
banner: string
|
||||
checkedFrom: 'UI backend'
|
||||
checkedAt: string
|
||||
target: SshTarget
|
||||
}
|
||||
|
||||
export type SshCheckErrorCode =
|
||||
| 'DIAGNOSTICS_UNAVAILABLE'
|
||||
| 'INSTANCE_NOT_RUNNING'
|
||||
| 'CONNECTION_REFUSED'
|
||||
| 'CONNECTION_TIMEOUT'
|
||||
| 'SSH_BANNER_MISSING'
|
||||
|
||||
export class SshCheckError extends Error {
|
||||
constructor(
|
||||
readonly code: SshCheckErrorCode,
|
||||
message: string,
|
||||
readonly status: 404 | 409 | 502 | 503 | 504,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SshCheckError'
|
||||
}
|
||||
}
|
||||
|
||||
export type DiagnosticFetcher = () => Promise<DiagnosticResponse>
|
||||
export type SshSocketConnector = (target: SshTarget, timeoutMs: number) => Promise<string>
|
||||
|
||||
type SshCheckDependencies = {
|
||||
fetchDiagnostics?: DiagnosticFetcher
|
||||
connect?: SshSocketConnector
|
||||
loopbackHost?: string
|
||||
now?: () => Date
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
async function fetchDiagnostics(): Promise<DiagnosticResponse> {
|
||||
const endpoint = process.env.FLOCI_ENDPOINT
|
||||
if (!endpoint) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${endpoint}/_floci/diagnose`, {signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})
|
||||
} catch {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json() as DiagnosticResponse
|
||||
} catch {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
|
||||
}
|
||||
}
|
||||
|
||||
export function readSshBanner(target: SshTarget, timeoutMs: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection(target)
|
||||
let settled = false
|
||||
let buffer = Buffer.alloc(0)
|
||||
|
||||
const finish = (error?: SshCheckError, banner?: string) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
if (error) reject(error)
|
||||
else resolve(banner!)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(new SshCheckError('CONNECTION_TIMEOUT', `SSH connection to ${target.host}:${target.port} timed out`, 504))
|
||||
}, timeoutMs)
|
||||
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
if (buffer.length > MAX_BANNER_BYTES) {
|
||||
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server did not send a valid banner', 502))
|
||||
return
|
||||
}
|
||||
|
||||
const lines = buffer.toString('utf8').split(/\r?\n/)
|
||||
for (let index = 0; index < lines.length - 1; index++) {
|
||||
const line = lines[index]
|
||||
if (/^SSH-\d+\.\d+-[\x21-\x7e]+(?: [\x20-\x7e]*)?$/.test(line)
|
||||
&& Buffer.byteLength(line, 'ascii') <= 253) {
|
||||
finish(undefined, line)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
socket.on('end', () => {
|
||||
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server closed without sending a valid banner', 502))
|
||||
})
|
||||
socket.on('close', () => {
|
||||
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server closed without sending a valid banner', 502))
|
||||
})
|
||||
socket.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
finish(new SshCheckError('CONNECTION_REFUSED', `SSH connection to ${target.host}:${target.port} was refused`, 503))
|
||||
return
|
||||
}
|
||||
if (error.code === 'ETIMEDOUT') {
|
||||
finish(new SshCheckError('CONNECTION_TIMEOUT', `SSH connection to ${target.host}:${target.port} timed out`, 504))
|
||||
return
|
||||
}
|
||||
finish(new SshCheckError('CONNECTION_REFUSED', `SSH connection to ${target.host}:${target.port} failed`, 503))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function createSshCheckService(dependencies: SshCheckDependencies = {}) {
|
||||
const diagnosticFetcher = dependencies.fetchDiagnostics ?? fetchDiagnostics
|
||||
const connector = dependencies.connect ?? readSshBanner
|
||||
const sshHost = dependencies.loopbackHost || process.env.FLOCI_EC2_SSH_HOST || 'localhost'
|
||||
const now = dependencies.now ?? (() => new Date())
|
||||
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
|
||||
return {
|
||||
async check(instanceId: string): Promise<SshCheckResult> {
|
||||
let diagnostics: DiagnosticResponse
|
||||
try {
|
||||
diagnostics = await diagnosticFetcher()
|
||||
} catch (error) {
|
||||
if (error instanceof SshCheckError) throw error
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
}
|
||||
|
||||
if (!diagnostics || typeof diagnostics !== 'object'
|
||||
|| !Array.isArray(diagnostics.ec2?.instances)) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
|
||||
}
|
||||
const instance = diagnostics.ec2.instances.find((candidate) => candidate.instanceId === instanceId)
|
||||
if (!instance) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
|
||||
}
|
||||
if (!instance.containerStatus) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
|
||||
}
|
||||
if (instance.containerStatus !== 'running') {
|
||||
throw new SshCheckError('INSTANCE_NOT_RUNNING', `Instance ${instanceId} is not running`, 409)
|
||||
}
|
||||
if (typeof instance.sshPort !== 'number' || !Number.isInteger(instance.sshPort)
|
||||
|| instance.sshPort < 1 || instance.sshPort > 65_535) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
|
||||
}
|
||||
|
||||
const target = {
|
||||
host: sshHost,
|
||||
port: instance.sshPort,
|
||||
}
|
||||
const banner = await connector(target, timeoutMs)
|
||||
if (!/^SSH-\d+\.\d+-/.test(banner)) {
|
||||
throw new SshCheckError('SSH_BANNER_MISSING', 'SSH server did not send a valid banner', 502)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
banner,
|
||||
checkedFrom: 'UI backend',
|
||||
checkedAt: now().toISOString(),
|
||||
target,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const sshCheckService = createSshCheckService()
|
||||
Reference in New Issue
Block a user