diff --git a/OS_img/nginx.svg b/OS_img/nginx.svg new file mode 100644 index 0000000..844bd13 --- /dev/null +++ b/OS_img/nginx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/OS_img/node-js.svg b/OS_img/node-js.svg new file mode 100644 index 0000000..095a1d2 --- /dev/null +++ b/OS_img/node-js.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/OS_img/wordpress.svg b/OS_img/wordpress.svg new file mode 100644 index 0000000..ec09c05 --- /dev/null +++ b/OS_img/wordpress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index bc78aaf..bd32526 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - "4501:4501" environment: FLOCI_ENDPOINT: http://floci:4566 + FLOCI_EC2_SSH_HOST: host.docker.internal FLOCI_AZURE_ENDPOINT: http://floci-az:4577 AWS_REGION: us-east-1 AWS_ACCESS_KEY_ID: test @@ -36,6 +37,8 @@ services: FLOCI_GCP_ENDPOINT: http://floci-gcp:4588 FLOCI_GCP_PROJECT: floci-local PORT: "4501" + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: - floci networks: diff --git a/packages/api/package.json b/packages/api/package.json index 006481e..b85b6b9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -32,6 +32,7 @@ "@aws-sdk/client-kinesis": "^3.1076.0", "@aws-sdk/client-kms": "^3.1096.0", "@aws-sdk/client-lambda": "^3.1076.0", + "@aws-sdk/client-lightsail": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds-data": "^3.1076.0", "@aws-sdk/client-s3": "^3.1076.0", diff --git a/packages/api/src/adapter-aws/AwsLightsailAdapter.test.ts b/packages/api/src/adapter-aws/AwsLightsailAdapter.test.ts new file mode 100644 index 0000000..5e7727a --- /dev/null +++ b/packages/api/src/adapter-aws/AwsLightsailAdapter.test.ts @@ -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'}}}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsLightsailAdapter.ts b/packages/api/src/adapter-aws/AwsLightsailAdapter.ts new file mode 100644 index 0000000..0a49d10 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsLightsailAdapter.ts @@ -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 { + 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' +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index a01ae72..98dd31a 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -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), }; } diff --git a/packages/api/src/cloud-spi/lightsailSchema.ts b/packages/api/src/cloud-spi/lightsailSchema.ts new file mode 100644 index 0000000..26bcaf4 --- /dev/null +++ b/packages/api/src/cloud-spi/lightsailSchema.ts @@ -0,0 +1,54 @@ +import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema} from './types' + +const resourceActions: CapabilitySchema[] = [ + {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'}, + ], + } +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index 4682a11..2547543 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -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', diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 211b5ce..53647a8 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -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(), diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 6cf0c79..4e34a75 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -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) diff --git a/packages/api/src/routes/ec2.test.ts b/packages/api/src/routes/ec2.test.ts index 3e4de8f..1990860 100644 --- a/packages/api/src/routes/ec2.test.ts +++ b/packages/api/src/routes/ec2.test.ts @@ -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 => { + 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 diff --git a/packages/api/src/routes/ec2.ts b/packages/api/src/routes/ec2.ts index e1e19a3..96c78b4 100644 --- a/packages/api/src/routes/ec2.ts +++ b/packages/api/src/routes/ec2.ts @@ -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 + 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) { diff --git a/packages/api/src/services/lightsailDeployment.test.ts b/packages/api/src/services/lightsailDeployment.test.ts new file mode 100644 index 0000000..0795567 --- /dev/null +++ b/packages/api/src/services/lightsailDeployment.test.ts @@ -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(['

ok

'], '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', + })) + }) +}) diff --git a/packages/api/src/services/lightsailDeployment.ts b/packages/api/src/services/lightsailDeployment.ts new file mode 100644 index 0000000..c14c846 --- /dev/null +++ b/packages/api/src/services/lightsailDeployment.ts @@ -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 { + 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 { + 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} +} diff --git a/packages/api/src/services/sshCheck.test.ts b/packages/api/src/services/sshCheck.test.ts new file mode 100644 index 0000000..6576789 --- /dev/null +++ b/packages/api/src/services/sshCheck.test.ts @@ -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((resolve) => server.close(() => resolve())))) +}) + +async function listen(handler: (socket: import('node:net').Socket) => void): Promise { + const server = createServer(handler) + servers.push(server) + await new Promise((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((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((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'}) + }) +}) diff --git a/packages/api/src/services/sshCheck.ts b/packages/api/src/services/sshCheck.ts new file mode 100644 index 0000000..ff18b18 --- /dev/null +++ b/packages/api/src/services/sshCheck.ts @@ -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 +export type SshSocketConnector = (target: SshTarget, timeoutMs: number) => Promise + +type SshCheckDependencies = { + fetchDiagnostics?: DiagnosticFetcher + connect?: SshSocketConnector + loopbackHost?: string + now?: () => Date + timeoutMs?: number +} + +async function fetchDiagnostics(): Promise { + 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 { + 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 { + 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() diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index b75bd05..0b676c9 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -25,7 +25,9 @@ export const apiEndpointKeys = { invoke: "clouds.services.resources.invoke", start: "clouds.services.resources.start", stop: "clouds.services.resources.stop", - reboot: "clouds.services.resources.reboot", + reboot: "clouds.services.resources.reboot", + deployLightsailStaticFiles: "clouds.services.resources.lightsail.static-files.deploy", + deployLightsailNodeApplication: "clouds.services.resources.lightsail.node-application.deploy", publish: "clouds.services.resources.notifications.publish", createSnsSubscription: "clouds.services.resources.notifications.subscription.create", listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list", @@ -228,6 +230,7 @@ export const apiEndpointKeys = { securityGroups: "aws.ec2.instances.securityGroups", createImage: "aws.ec2.instances.create-image", console: "aws.ec2.instances.console", + checkSsh: "aws.ec2.instances.ssh.check", }, amis: { list: "aws.ec2.amis.list", @@ -643,6 +646,20 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "PATCH", }, ], + [ + apiEndpointKeys.clouds.resources.deployLightsailStaticFiles, + { + path: "/clouds/aws/services/lightsail/resources/:id/static-files", + method: "POST", + }, + ], + [ + apiEndpointKeys.clouds.resources.deployLightsailNodeApplication, + { + path: "/clouds/aws/services/lightsail/resources/:id/node-application", + method: "POST", + }, + ], [ apiEndpointKeys.clouds.resources.delete, { @@ -1129,6 +1146,7 @@ export const endpointRegistry: EndpointRegistry = new Map([ [apiEndpointKeys.aws.ec2.instances.securityGroups, { path: "/ec2/instances/:instanceId/security-groups", method: "PUT" }], [apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST" }], [apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET" }], + [apiEndpointKeys.aws.ec2.instances.checkSsh, { path: "/ec2/instances/:instanceId/ssh/check", method: "POST" }], [apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET" }], [apiEndpointKeys.aws.ec2.amis.deregister, { path: "/ec2/amis/:imageId/deregister", method: "POST" }], [apiEndpointKeys.aws.ec2.keyPairs.list, { path: "/ec2/key-pairs", method: "GET" }], diff --git a/packages/frontend/src/api/aws/ec2.api.test.ts b/packages/frontend/src/api/aws/ec2.api.test.ts index 48554a3..eff8ed0 100644 --- a/packages/frontend/src/api/aws/ec2.api.test.ts +++ b/packages/frontend/src/api/aws/ec2.api.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { authorizeEc2SecurityGroupEgress, authorizeEc2SecurityGroupIngress, + checkEc2InstanceSsh, createEc2SecurityGroup, revokeEc2SecurityGroupEgress, revokeEc2SecurityGroupIngress, + updateEc2InstanceTags, updateEc2InstanceSecurityGroups, } from "./ec2.api"; @@ -50,6 +52,37 @@ describe("createEc2SecurityGroup", () => { ); }); +describe("checkEc2InstanceSsh", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + ok: true, + banner: "SSH-2.0-OpenSSH_9.6", + checkedFrom: "UI backend", + checkedAt: "2026-10-09T00:00:00Z", + target: { host: "host.docker.internal", port: 2200 }, + }), {status: 200, headers: {"Content-Type": "application/json"}})); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockReset(); + }); + + it("checks SSH through the UI backend", async () => { + await expect(checkEc2InstanceSsh("i-test")).resolves.toMatchObject({ + ok: true, + banner: "SSH-2.0-OpenSSH_9.6", + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/ec2/instances/i-test/ssh/check"); + expect(init?.method).toBe("POST"); + }); +}); + describe("EC2 security-group rule requests", () => { const fetchMock = vi.fn(); @@ -111,3 +144,32 @@ describe("updateEc2InstanceSecurityGroups", () => { expect(JSON.parse(String(init?.body))).toEqual({groupIds: ["sg-default", "sg-web"]}); }); }); + +describe("updateEc2InstanceTags", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockReset(); + }); + + it("sends tags as one JSON object", async () => { + await updateEc2InstanceTags("i-test", [{ key: "Name", value: "web-01" }], ["old-tag"]); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/ec2/instances/i-test/tags"); + expect(init?.method).toBe("PUT"); + expect(JSON.parse(String(init?.body))).toEqual({ + toAdd: [{ key: "Name", value: "web-01" }], + toRemove: ["old-tag"], + }); + }); +}); diff --git a/packages/frontend/src/api/aws/ec2.api.ts b/packages/frontend/src/api/aws/ec2.api.ts index c1237d1..f76e4ea 100644 --- a/packages/frontend/src/api/aws/ec2.api.ts +++ b/packages/frontend/src/api/aws/ec2.api.ts @@ -83,6 +83,35 @@ export type Ec2InstanceConnection = { sshPort?: number; containerId?: string; containerStatus: 'running' | 'stopped' | 'unavailable'; + state?: string; + launchStatus?: string; + launchEvents?: Ec2LaunchEvent[]; + selectedImageType?: string; + selectedImageReference?: string; + sshUser?: string; + sshPreinstalled?: boolean; + initType?: string; + sshServiceName?: string; + sshStatus?: string; + sshStatusMessage?: string; + keyInjectionStatus?: string; + publishedPorts?: Record; + failure?: { code?: string; message?: string } | string; +}; + +export type Ec2LaunchEvent = { + stage?: string; + status?: string; + timestamp?: string; + message?: string; +}; + +export type Ec2SshCheckResult = { + ok: true; + banner: string; + checkedFrom: 'UI backend'; + checkedAt: string; + target: { host: string; port: number }; }; export type Ec2KeyPair = { @@ -323,6 +352,15 @@ export async function terminateEc2Instance(instanceId: string): Promise { ); } +export async function checkEc2InstanceSsh(instanceId: string): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.instances.checkSsh, + { body: {} }, + { instanceId }, + ); + return res.data; +} + export async function updateEc2InstanceTags( instanceId: string, toAdd: Ec2Tag[], @@ -330,7 +368,7 @@ export async function updateEc2InstanceTags( ): Promise { await apiClient.call( apiEndpointKeys.aws.ec2.instances.tags, - { body: JSON.stringify({ toAdd, toRemove }), headers: { "content-type": "application/json" } }, + { body: { toAdd, toRemove } }, { instanceId }, ); } diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index bcc8bae..a9bef9c 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -45,6 +45,7 @@ import { getAccountId } from "@/lib/accountStore"; type CloudPathParams = Record; const DATABASE_MUTATION_TIMEOUT_MS = 5 * 60_000; +const LIGHTSAIL_CREATION_TIMEOUT_MS = 3 * 60_000; const SQL_DATA_TIMEOUT_MS = 45_000; /** Floci completes Insights queries instantly by default, but bounded server-side polling can take longer. */ const LOGS_QUERY_TIMEOUT_MS = 20_000; @@ -164,10 +165,12 @@ export async function createCloudResource( values: Record, signal?: AbortSignal, ): Promise { - const timeout = - (cloud === "azure" || cloud === "aws") && service === "database" - ? DATABASE_MUTATION_TIMEOUT_MS - : undefined; + const timeout = + (cloud === "azure" || cloud === "aws") && service === "database" + ? DATABASE_MUTATION_TIMEOUT_MS + : cloud === "aws" && service === "lightsail" + ? LIGHTSAIL_CREATION_TIMEOUT_MS + : undefined; const res = await apiClient.call>( apiEndpointKeys.clouds.resources.create, requestOptions(cloud, service, { signal, body: values, timeout }), @@ -1104,6 +1107,36 @@ export async function uploadStorageObject( ); } +export type LightsailStaticDeployment = {status: 'deployed'; files: number} +export type LightsailNodeDeployment = {status: 'deployed'; files: number; port: number; releaseId: string} + +export async function deployLightsailStaticFiles(instanceId: string, files: File[]): Promise { + const form = new FormData() + for (const file of files) { + const path = file.webkitRelativePath || file.name + form.append('files', file, path) + } + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.deployLightsailStaticFiles, + requestOptions('aws', 'lightsail', {rawBody: form}), + {id: instanceId}, + ) + return res.data +} + +export async function deployLightsailNodeApplication(instanceId: string, files: File[], startCommand: string, port: number): Promise { + const form = new FormData() + for (const file of files) form.append('files', file, file.webkitRelativePath || file.name) + if (startCommand.trim()) form.append('startCommand', startCommand.trim()) + form.append('port', String(port)) + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.deployLightsailNodeApplication, + requestOptions('aws', 'lightsail', {rawBody: form, timeout: 370_000}), + {id: instanceId}, + ) + return res.data +} + export function storageObjectDownloadUrl( cloud: CloudProvider, resourceId: string, diff --git a/packages/frontend/src/components/ComputePanel.tsx b/packages/frontend/src/components/ComputePanel.tsx index 363c277..0e04c83 100644 --- a/packages/frontend/src/components/ComputePanel.tsx +++ b/packages/frontend/src/components/ComputePanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { CopyValueButton } from './CopyValueButton' +import { ServerLaunchProgress } from './ServerLaunchProgress' import { AlertTriangle, HardDrive, @@ -18,6 +19,7 @@ import { } from 'lucide-react' import { authorizeEc2SecurityGroupIngress, + checkEc2InstanceSsh, createEc2Ami, createEc2KeyPair, createEc2SecurityGroup, @@ -53,6 +55,8 @@ import debianImage from '../../../../OS_img/debian.jpg' import redHatImage from '../../../../OS_img/RedHat.png' import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png' +const EC2_LAUNCH_MINIMUM_MS = 5_000 + // ─── Props ──────────────────────────────────────────────────────────────────── interface ComputePanelProps { @@ -420,6 +424,9 @@ function InstancePanel({ instanceId }: { instanceId: string }) { invalidate() }, }) + const sshCheck = useMutation({ + mutationFn: () => checkEc2InstanceSsh(instanceId), + }) if (query.isLoading) { return ( @@ -656,25 +663,82 @@ function InstancePanel({ instanceId }: { instanceId: string }) { {tab === 'connection' && (() => { const connection = inst.connection + const sshUser = connection?.sshUser || 'root' const command = connection?.sshHost && connection.sshPort && inst.keyName - ? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} root@${connection.sshHost}` + ? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} ${sshUser}@${connection.sshHost}` : undefined - if (state === 'stopped') { - return

Start this instance before connecting over SSH.

+ if (!connection) { + return

Connection diagnostics are unavailable.

} - if (state === 'terminated') { - return

Terminated instances cannot accept connections.

- } - if (state !== 'running') { - return

SSH is unavailable while this instance is {state}.

- } - if (!command || connection?.containerStatus !== 'running') { - return

SSH is unavailable. Backing container status: {connection?.containerStatus ?? 'unavailable'}.

- } - return
-
SSH port{connection.sshPort}
-
Backing container{connection.containerStatus}
-
Command{command}
+ const canCheckSsh = state === 'running' && connection.containerStatus === 'running' + && Boolean(connection.sshPort) + const failure = typeof connection.failure === 'string' + ? connection.failure + : connection.failure?.message + return
+ {!canCheckSsh &&
+ {state === 'terminated' + ? 'This instance is terminated. Launch diagnostics remain available below.' + : state === 'stopped' + ? 'Start this instance before connecting over SSH.' + : `SSH is unavailable while this instance is ${state}.`} +
} +
+
+ Launch + {connection.launchStatus ?? 'unknown'} + {connection.selectedImageReference ?? 'No selected image recorded'} +
+
+ SSH readiness + {connection.sshStatus ?? 'unknown'} + {connection.sshStatusMessage ?? 'No readiness result recorded'} +
+
+ Key injection + {connection.keyInjectionStatus ?? (inst.keyName ? 'unknown' : 'not requested')} + {inst.keyName ?? 'No key pair selected'} +
+
+ + {failure &&
{failure}
} + +
+
SSH target{connection.sshPort ? `${connection.sshHost}:${connection.sshPort}` : 'Not published'}
+
Backing container{connection.containerStatus}
+
Image source{connection.selectedImageType ?? 'unknown'}
+
Init / SSH service{connection.initType ?? 'unknown'} / {connection.sshServiceName ?? 'unknown'}
+ {command &&
Command{command}
} +
+ + {canCheckSsh &&
+
+ Live SSH banner check +

Runs from the UI backend, using the published diagnostic port.

+
+ +
} + {sshCheck.isSuccess &&
+ {sshCheck.data.banner} + Checked from {sshCheck.data.checkedFrom} at {new Date(sshCheck.data.checkedAt).toLocaleString()} + {sshCheck.data.target.host}:{sshCheck.data.target.port} +
} + {sshCheck.isError &&
+ SSH check failed + {sshCheck.error instanceof Error ? sshCheck.error.message : 'Unable to verify SSH'} +
} + + {(connection.launchEvents?.length ?? 0) > 0 &&
+

Launch timeline

+ {connection.launchEvents?.map((event, index) =>
+ +
{event.stage ?? 'Launch event'}{event.message ?? event.status ?? 'Recorded'}
+ +
)} +
}
})()} @@ -810,7 +874,7 @@ const QUICK_START_AMIS: Array<{ family: Exclude; label: stri { family: 'ubuntu', label: 'Ubuntu', image: ubuntuImage }, { family: 'suse', label: 'openSUSE', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2330ba78"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="30" font-weight="bold" fill="white"%3ES%3C/text%3E%3C/svg%3E' }, { family: 'red-hat', label: 'Red Hat', image: redHatImage }, - { family: 'debian', label: 'Debian 13', image: debianImage, preferredImageId: 'ami-debian13' }, + { family: 'debian', label: 'Debian 12', image: debianImage, preferredImageId: 'ami-debian12' }, ] const SG_PRESETS: Array<{ label: string; protocol: string; portInput: string; cidr: string }> = [ @@ -830,12 +894,13 @@ function parseRule(rule: InboundRule): IpPermissionInput { return { protocol: rule.protocol, fromPort, toPort, cidr: rule.cidr } } -const INSTANCE_TYPES = [ +const X86_INSTANCE_TYPES = [ 't2.micro', 't2.small', 't2.medium', 't3.micro', 't3.small', 't3.medium', 't3.large', 't3.xlarge', 'm5.large', 'm5.xlarge', 'c5.large', 'c5.xlarge', ] +const ARM_INSTANCE_TYPES = ['t4g.micro', 't4g.small', 't4g.medium', 'm6gd.large', 'm6gd.2xlarge', 'm7gd.large', 'm7gd.2xlarge', 'm8gd.medium', 'm8gd.large', 'm8gd.2xlarge'] export function LaunchInstanceForm({ cloud, @@ -906,7 +971,15 @@ export function LaunchInstanceForm({ }, [selectedSubnet, sgsQ.data]) const createMut = useMutation({ - mutationFn: (values: Record) => createCloudResource(cloud, 'compute', values), + mutationFn: async (values: Record) => { + const startedAt = Date.now() + const created = await createCloudResource(cloud, 'compute', values) + const remaining = EC2_LAUNCH_MINIMUM_MS - (Date.now() - startedAt) + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)) + } + return created + }, onSuccess: (created) => { void qc.invalidateQueries({ queryKey: ['cloud-resources', cloud, 'compute'] }) onSuccess(created) @@ -1030,8 +1103,15 @@ export function LaunchInstanceForm({ return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch)) }) const selectedAmi = amis.find((ami) => ami.imageId === imageId) + const compatibleInstanceTypes = selectedAmi?.architecture === 'arm64' ? ARM_INSTANCE_TYPES : X86_INSTANCE_TYPES const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId)) const availableSecurityGroups = (sgsQ.data ?? []).filter((group) => !selectedSubnet || group.vpcId === selectedSubnet.vpcId) + + useEffect(() => { + if (!compatibleInstanceTypes.includes(instanceType)) { + setInstanceType(compatibleInstanceTypes[0]) + } + }, [compatibleInstanceTypes, instanceType]) const steps: Array<{ number: LaunchStep; label: string }> = [ { number: 1, label: 'Name and AMI' }, { number: 2, label: 'Instance and key pair' }, @@ -1115,8 +1195,9 @@ export function LaunchInstanceForm({
Key pair @@ -1314,6 +1395,7 @@ export function LaunchInstanceForm({ {stepError &&
{stepError}
} {createMut.error instanceof Error &&
{createMut.error.message}
} + {createMut.isPending &&
}
{step < 4 ? : } diff --git a/packages/frontend/src/components/CreateRdsInstanceForm.tsx b/packages/frontend/src/components/CreateRdsInstanceForm.tsx index 3c0ab7e..d94a430 100644 --- a/packages/frontend/src/components/CreateRdsInstanceForm.tsx +++ b/packages/frontend/src/components/CreateRdsInstanceForm.tsx @@ -11,8 +11,10 @@ import { } from "@/api/aws/ec2.api"; import type { CloudProvider } from "@/types/cloud"; import type { CloudResource } from "@/types/resource"; +import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress"; const IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const DATABASE_CREATION_MINIMUM_MS = 5_000; interface CreateRdsInstanceFormProps { cloud: CloudProvider; @@ -70,8 +72,15 @@ export function CreateRdsInstanceForm({ } const createMut = useMutation({ - mutationFn: (values: Record) => - createCloudResource(cloud, "database", values), + mutationFn: async (values: Record) => { + const startedAt = Date.now(); + const created = await createCloudResource(cloud, "database", values); + const remaining = DATABASE_CREATION_MINIMUM_MS - (Date.now() - startedAt); + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)); + } + return created; + }, onSuccess: (created) => { void qc.invalidateQueries({ queryKey: ["cloud-resources", cloud, "database"], @@ -300,6 +309,7 @@ export function CreateRdsInstanceForm({ {/* ── Actions ────────────────────────────────────────────────── */} + {createMut.isPending &&
}
; +} diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index e1383fc..880d43c 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -43,6 +43,7 @@ import { ServerlessInvokePanel } from "@/components/ServerlessInvokePanel"; import { dataExplorerPath } from "@/lib/dataExplorer"; import { DatabaseSnapshotsPanel } from "@/components/DatabaseSnapshotsPanel"; import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm"; +import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress"; import { AppConfigPanel } from "@/components/AppConfigPanel"; import { KmsCryptoPanel } from "@/components/KmsCryptoPanel"; import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel"; @@ -73,6 +74,8 @@ import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel"; import {KinesisPanel} from "@/components/KinesisPanel"; import {SesConsolePanel} from '@/components/SesConsolePanel' import {ElastiCacheManagementPanel} from '@/components/ElastiCacheManagementPanel' +import {LightsailLaunchPanel} from '@/components/LightsailLaunchPanel' +import {LightsailInstancePanel} from '@/components/LightsailInstancePanel' import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate"; import {workflowStudioPath} from "@/lib/workflowStudio"; import {useNavigate, useSearchParams} from "react-router-dom"; @@ -88,6 +91,8 @@ interface DynamicResourceViewProps { onOpenInfo: () => void; } +const ELASTICACHE_CREATION_MINIMUM_MS = 5_000; + interface CreateMutationVariables { values: Record; destination?: WorkflowCreationDestination; @@ -220,6 +225,10 @@ export function DynamicResourceView({ delete lambdaValues.blueprintQueueName; delete lambdaValues.blueprintSqsBatchSize; const resource = await createCloudResource(cloud, service, lambdaValues); + if (cloud === "aws" && service === "elasticache") { + await new Promise((resolve) => setTimeout(resolve, ELASTICACHE_CREATION_MINIMUM_MS)); + return resource; + } if (blueprintBucketName) { let bucketCreated = false; try { @@ -817,6 +826,13 @@ export function DynamicResourceView({ }} onCancel={() => setCreateOpen(false)} /> + ) : service === "lightsail" && cloud === "aws" ? ( + setCreateOpen(false)} + onSubmit={(values) => createMut.mutate({values})} + /> ) : service === "database" && cloud === "aws" ? ( setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})}/> ) : ( + <> setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})} /> + {service === "elasticache" && cloud === "aws" && createMut.isPending && } + )}
)} @@ -1043,6 +1062,7 @@ export function DynamicResourceView({ )} {service === "kinesis" && cloud === "aws" && } {service === "elasticache" && cloud === "aws" && } + {service === "lightsail" && cloud === "aws" && setSelected(undefined)} />} {editingResource && schema.updateFields && (
({ + deleteCloudResource: vi.fn(), + rebootCloudResource: vi.fn(), + startCloudResource: vi.fn(), + stopCloudResource: vi.fn(), + updateCloudResource: vi.fn(), + deployLightsailStaticFiles: vi.fn(), + deployLightsailNodeApplication: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => apiMocks) + +const instance: CloudResource = { + id: 'web', name: 'web', cloud: 'aws', service: 'lightsail', type: 'instance', region: 'us-east-1a', createdAt: null, status: 'running', + metadata: { + blueprintName: 'Ubuntu', bundleId: 'nano_3_0', publicIpAddress: '127.0.0.1', privateIpAddress: '10.0.0.10', username: 'ubuntu', + networking: {staticIp: '127.0.0.2', ports: [{fromPort: 80, toPort: 80, protocol: 'tcp'}]}, + hardware: {disks: [{name: 'web-root', sizeInGb: 20, isSystemDisk: true, state: 'in-use'}]}, + hostPortMappings: [{instancePort: 80, host: '127.0.0.1', hostPort: 8080}], + }, +} + +function renderPanel(onDeleted = vi.fn()) { + const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}}) + render() + return {onDeleted} +} + +beforeEach(() => Object.values(apiMocks).forEach((mock) => mock.mockReset())) + +test('shows Lightsail instance state and invokes lifecycle actions from Actions', async () => { + apiMocks.stopCloudResource.mockResolvedValue(undefined) + const user = userEvent.setup() + renderPanel() + + expect(screen.getByText('running')).toBeVisible() + await user.click(screen.getByText(/Actions/)) + await user.click(screen.getByRole('button', {name: 'Stop'})) + + await waitFor(() => expect(apiMocks.stopCloudResource).toHaveBeenCalledWith('aws', 'lightsail', 'web')) +}) + +test('manages firewall rules through generic resource updates and shows browser mappings', async () => { + apiMocks.updateCloudResource.mockResolvedValue(instance) + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('tab', {name: 'Networking'})) + expect(screen.getByText('127.0.0.2')).toBeVisible() + expect(screen.getByRole('link', {name: 'Open'})).toHaveAttribute('href', 'http://127.0.0.1:8080') + await user.click(screen.getByRole('button', {name: 'Remove'})) + + await waitFor(() => expect(apiMocks.updateCloudResource).toHaveBeenCalledWith('aws', 'lightsail', 'web', {action: 'closePort', fromPort: 80, toPort: 80, protocol: 'tcp'})) +}) + +test('shows WordPress site and admin links from the allocated host port', () => { + const wordpress = {...instance, metadata: {...instance.metadata, blueprintId: 'wordpress'}} + const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}}) + render() + expect(screen.getByRole('link', {name: /Open site/})).toHaveAttribute('href', 'http://127.0.0.1:8080') + expect(screen.getByRole('link', {name: /Open WordPress admin/})).toHaveAttribute('href', 'http://127.0.0.1:8080/wp-admin') +}) + +test('labels storage and connect limitations without exposing unavailable operations', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('tab', {name: 'Storage'})) + expect(screen.getByText(/metadata-only/i)).toBeVisible() + expect(screen.getByText('web-root')).toBeVisible() + await user.click(screen.getByRole('tab', {name: 'Connect'})) + expect(screen.getByText(/Browser SSH and key injection are unavailable/i)).toBeVisible() +}) + +test('confirms deletion through the generic resource delete method', async () => { + apiMocks.deleteCloudResource.mockResolvedValue(undefined) + const user = userEvent.setup() + const {onDeleted} = renderPanel() + + await user.click(screen.getByText(/Actions/)) + await user.click(screen.getByRole('button', {name: 'Delete'})) + await user.click(screen.getByRole('button', {name: 'Delete instance'})) + + await waitFor(() => expect(apiMocks.deleteCloudResource).toHaveBeenCalledWith('aws', 'lightsail', 'web')) + expect(onDeleted).toHaveBeenCalled() +}) + +test('offers static file deployment only for Nginx instances', async () => { + const nginx = {...instance, metadata: {...instance.metadata, blueprintId: 'nginx'}} + const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}}) + render() + expect(screen.getByRole('tab', {name: 'Deploy'})).toBeVisible() +}) + +test('offers a package-aware deployment form for Node.js instances', () => { + const node = {...instance, metadata: {...instance.metadata, blueprintId: 'nodejs'}} + const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}}) + render() + expect(screen.getByRole('tab', {name: 'Deploy'})).toBeVisible() +}) diff --git a/packages/frontend/src/components/LightsailInstancePanel.tsx b/packages/frontend/src/components/LightsailInstancePanel.tsx new file mode 100644 index 0000000..4e01182 --- /dev/null +++ b/packages/frontend/src/components/LightsailInstancePanel.tsx @@ -0,0 +1,135 @@ +import {ChevronDown, ExternalLink, HardDrive, Play, RotateCw, Settings, Square, Terminal, Trash2} from 'lucide-react' +import {useEffect, useRef, useState} from 'react' +import {useMutation, useQueryClient} from '@tanstack/react-query' +import {deleteCloudResource, deployLightsailNodeApplication, deployLightsailStaticFiles, rebootCloudResource, startCloudResource, stopCloudResource, updateCloudResource} from '@/api/cloudProxyClient' +import type {CloudResource} from '@/types/resource' + +type Tab = 'overview' | 'deploy' | 'networking' | 'storage' | 'connect' +type PublicPort = {fromPort: number; toPort: number; protocol: string} +type HostPortMapping = {instancePort: number; host: string; hostPort: number; protocol?: string} +type Disk = {name: string; sizeInGb: number; isSystemDisk: boolean; path?: string; state?: string} + +export function LightsailInstancePanel({resource, runtimeReachable, onDeleted}: {resource?: CloudResource; runtimeReachable: boolean; onDeleted?: () => void}) { + const queryClient = useQueryClient() + const [tab, setTab] = useState('overview') + const [fromPort, setFromPort] = useState('80') + const [toPort, setToPort] = useState('80') + const [protocol, setProtocol] = useState('tcp') + const [nodePort, setNodePort] = useState('3000') + const [startCommand, setStartCommand] = useState('') + const [confirmDelete, setConfirmDelete] = useState(false) + const fileInput = useRef(null) + const ports = publicPorts(resource?.metadata.networking) + const hostMappings = hostPortMappings(resource?.metadata.hostPortMappings) + const disks = instanceDisks(resource?.metadata.hardware) + const status = resource?.status?.toLowerCase() + const publicIp = stringMetadata(resource?.metadata.publicIpAddress) + const privateIp = stringMetadata(resource?.metadata.privateIpAddress) + const staticIp = staticIpAddress(resource?.metadata.networking) + + useEffect(() => { + setTab('overview') + setFromPort('80') + setToPort('80') + setProtocol('tcp') + setConfirmDelete(false) + }, [resource?.id]) + + const refresh = () => { + if (!resource) return + void queryClient.invalidateQueries({queryKey: ['cloud-resources', resource.cloud, resource.service]}) + void queryClient.invalidateQueries({queryKey: ['cloud-resource', resource.cloud, resource.service, resource.id]}) + } + const lifecycle = useMutation({ + mutationFn: (action: 'start' | 'stop' | 'reboot') => { + if (!resource) throw new Error('Select a Lightsail instance.') + if (action === 'start') return startCloudResource(resource.cloud, resource.service, resource.id) + if (action === 'stop') return stopCloudResource(resource.cloud, resource.service, resource.id) + return rebootCloudResource(resource.cloud, resource.service, resource.id) + }, + onSuccess: refresh, + }) + const portMutation = useMutation({ + mutationFn: (values: {action: 'openPort' | 'closePort'; fromPort: number; toPort: number; protocol: string}) => { + if (!resource) throw new Error('Select a Lightsail instance.') + return updateCloudResource(resource.cloud, resource.service, resource.id, values) + }, + onSuccess: refresh, + }) + const deleteMutation = useMutation({ + mutationFn: () => { + if (!resource) throw new Error('Select a Lightsail instance.') + return deleteCloudResource(resource.cloud, resource.service, resource.id) + }, + onSuccess: () => { + refresh() + onDeleted?.() + }, + }) + const deployment = useMutation({ + mutationFn: (files: File[]) => { + if (!resource) throw new Error('Select a Lightsail instance.') + return deployLightsailStaticFiles(resource.id, files) + }, + }) + const nodeDeployment = useMutation({ + mutationFn: async (files: File[]) => { + if (!resource) throw new Error('Select a Lightsail instance.') + const port = Number(nodePort) + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Port must be between 1 and 65535.') + const result = await deployLightsailNodeApplication(resource.id, files, startCommand, port) + try { + await updateCloudResource(resource.cloud, resource.service, resource.id, {action: 'openPort', fromPort: port, toPort: port, protocol: 'tcp'}) + } catch (error) { + throw Object.assign(new Error(`Deployment succeeded, but port ${port} could not be opened: ${error instanceof Error ? error.message : 'unknown error'}`), {cause: error}) + } + return result + }, + onSuccess: refresh, + }) + + if (!resource) { + return

Select a Lightsail instance

Manage its lifecycle, networking, storage metadata, and connection details.

+ } + + const validPortRange = validPort(fromPort) && validPort(toPort) && Number(fromPort) <= Number(toPort) + const busy = lifecycle.isPending || portMutation.isPending || deleteMutation.isPending || deployment.isPending || nodeDeployment.isPending + const error = lifecycle.error ?? portMutation.error ?? deleteMutation.error ?? deployment.error ?? nodeDeployment.error + const nginx = stringMetadata(resource.metadata.blueprintId) === 'nginx' + const nodejs = stringMetadata(resource.metadata.blueprintId) === 'nodejs' + const wordpress = stringMetadata(resource.metadata.blueprintId) === 'wordpress' + const wordpressPort = hostMappings.find((mapping) => mapping.instancePort === 80) + const stateLabel = resource.status ?? 'Unknown' + + return
+
+

Amazon Lightsail instance

{resource.name}

{resource.id}{resource.region && <> · {resource.region}}

+
{stateLabel}
Actions
+
+
+ + {(nginx || nodejs) && } + + + +
+ {tab === 'overview' &&
{wordpress && wordpressPort &&

Open site Open WordPress admin

}

Use the Networking tab to manage public firewall rules and open mapped browser endpoints.

} + {tab === 'deploy' && nginx &&

Deploy static files

Selected files are copied to /usr/share/nginx/html. Folder paths are preserved when your browser provides them.

{ const files = Array.from(event.target.files ?? []); if (files.length) deployment.mutate(files); event.currentTarget.value = '' }}/>{deployment.data &&

Deployment complete: {deployment.data.files} {deployment.data.files === 1 ? 'file' : 'files'} copied.

}
} + {tab === 'deploy' && nodejs &&

Deploy Node.js application

Upload a zip archive or source files. package.json is required; package-lock.json uses npm ci, otherwise npm install. Existing releases remain active if deployment fails.

{ const files = Array.from(event.target.files ?? []); if (files.length) nodeDeployment.mutate(files); event.currentTarget.value = '' }}/>{nodeDeployment.data &&

Release {nodeDeployment.data.releaseId} is healthy on port {nodeDeployment.data.port}. The browser port was opened automatically.

}
} + {tab === 'networking' &&

Static IP is metadata-only. Attach or detach operations are not available from this console.

Firewall rules

Public ports returned by the Lightsail instance networking API.

{ event.preventDefault(); if (validPortRange) portMutation.mutate({action: 'openPort', fromPort: Number(fromPort), toPort: Number(toPort), protocol}) }}>
{ports.length > 0 ?
{ports.map((port) => )}
Port rangeProtocol
{formatPortRange(port)}{port.protocol.toUpperCase()}
:

No public firewall rules are configured.

}

Browser links

{hostMappings.length > 0 ?
{hostMappings.map((mapping) => )}
Instance portHost mapping
{mapping.instancePort}{mapping.host}:{mapping.hostPort}Open
: publicIp ? Open HTTP endpoint :

No browser endpoint was returned for this instance.

}
} + {tab === 'storage' &&

Disks

Disk details are metadata-only. Creating, attaching, detaching, or resizing disks is not available from this console.

{disks.length > 0 ?
{disks.map((disk) => )}
NameSizeTypeState
{disk.name}{disk.sizeInGb} GB{disk.isSystemDisk ? 'System disk' : 'Attached disk'}{disk.state ?? 'Unknown'}
:

No disk metadata was returned by the runtime.

}
} + {tab === 'connect' &&

Connect to instance

Browser SSH and key injection are unavailable in the local Lightsail runtime. This console does not generate credentials or claim a connection is available.

} + {confirmDelete &&
Delete {resource.name}? This cannot be undone.
} + {error instanceof Error &&

{error.message}

} +
+} + +function Detail({label, value}: {label: string; value?: string}) { return
{label}{value ?? 'Not assigned'}
} +function validPort(value: string): boolean { return /^\d+$/.test(value) && Number(value) >= 0 && Number(value) <= 65535 } +function formatPortRange(port: PublicPort): string { return port.fromPort === port.toPort ? String(port.fromPort) : `${port.fromPort}-${port.toPort}` } +function browserUrl(mapping: HostPortMapping): string { return `http://${mapping.host}:${mapping.hostPort}` } +function stringMetadata(value: unknown): string | undefined { return typeof value === 'string' && value ? value : undefined } +function publicPorts(networking: unknown): PublicPort[] { if (!networking || typeof networking !== 'object' || !Array.isArray((networking as {ports?: unknown}).ports)) return []; return (networking as {ports: unknown[]}).ports.flatMap((port) => { if (!port || typeof port !== 'object') return []; const value = port as {fromPort?: unknown; toPort?: unknown; protocol?: unknown}; return typeof value.fromPort === 'number' && typeof value.toPort === 'number' && typeof value.protocol === 'string' ? [{fromPort: value.fromPort, toPort: value.toPort, protocol: value.protocol}] : [] }) } +function hostPortMappings(value: unknown): HostPortMapping[] { if (!Array.isArray(value)) return []; return value.flatMap((mapping) => { if (!mapping || typeof mapping !== 'object') return []; const item = mapping as {instancePort?: unknown; host?: unknown; hostPort?: unknown; protocol?: unknown}; return typeof item.instancePort === 'number' && typeof item.host === 'string' && typeof item.hostPort === 'number' ? [{instancePort: item.instancePort, host: item.host, hostPort: item.hostPort, protocol: typeof item.protocol === 'string' ? item.protocol : undefined}] : [] }) } +function staticIpAddress(networking: unknown): string | undefined { return networking && typeof networking === 'object' ? stringMetadata((networking as {staticIp?: unknown}).staticIp) : undefined } +function instanceDisks(hardware: unknown): Disk[] { if (!hardware || typeof hardware !== 'object' || !Array.isArray((hardware as {disks?: unknown}).disks)) return []; return (hardware as {disks: unknown[]}).disks.flatMap((disk) => { if (!disk || typeof disk !== 'object') return []; const value = disk as {name?: unknown; sizeInGb?: unknown; isSystemDisk?: unknown; path?: unknown; state?: unknown}; return typeof value.name === 'string' && typeof value.sizeInGb === 'number' && typeof value.isSystemDisk === 'boolean' ? [{name: value.name, sizeInGb: value.sizeInGb, isSystemDisk: value.isSystemDisk, path: stringMetadata(value.path), state: stringMetadata(value.state)}] : [] }) } diff --git a/packages/frontend/src/components/LightsailLaunchPanel.tsx b/packages/frontend/src/components/LightsailLaunchPanel.tsx new file mode 100644 index 0000000..a4835e2 --- /dev/null +++ b/packages/frontend/src/components/LightsailLaunchPanel.tsx @@ -0,0 +1,101 @@ +import {useState} from 'react' +import {Box, Check, Terminal} from 'lucide-react' +import amazonLinuxImage from '../../../../OS_img/amazon_Linux.png' +import nginxImage from '../../../../OS_img/nginx.svg' +import nodeJsImage from '../../../../OS_img/node-js.svg' +import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png' +import wordpressImage from '../../../../OS_img/wordpress.svg' +import {ServerLaunchProgress} from './ServerLaunchProgress' + +type Blueprint = { + id: string + name: string + description: string + kind: 'app' | 'os' + icon?: string + available: boolean + unavailableReason?: string +} + +const blueprints: Blueprint[] = [ + {id: 'nginx', name: 'Nginx', description: 'Nginx web server', kind: 'app', icon: nginxImage, available: true}, + {id: 'nodejs', name: 'Node.js', description: 'Node.js current LTS application server', kind: 'app', icon: nodeJsImage, available: true}, + {id: 'wordpress', name: 'WordPress', description: 'WordPress application stack with managed MariaDB', kind: 'app', icon: wordpressImage, available: true}, + {id: 'ubuntu_22_04', name: 'Ubuntu 22.04 LTS', description: 'Ubuntu Linux', kind: 'os', icon: ubuntuImage, available: true}, + {id: 'ubuntu_24_04', name: 'Ubuntu 24.04 LTS', description: 'Ubuntu Linux', kind: 'os', icon: ubuntuImage, available: true}, + {id: 'amazon_linux_2023', name: 'Amazon Linux 2023', description: 'Amazon Linux', kind: 'os', icon: amazonLinuxImage, available: true}, +] + +const plans = [ + {id: 'nano_3_0', name: 'Nano', detail: '1 vCPU, 512 MB RAM, 20 GB SSD'}, + {id: 'micro_3_0', name: 'Micro', detail: '2 vCPU, 1 GB RAM, 40 GB SSD'}, + {id: 'small_3_0', name: 'Small', detail: '2 vCPU, 2 GB RAM, 60 GB SSD'}, + {id: 'medium_3_0', name: 'Medium', detail: '2 vCPU, 4 GB RAM, 80 GB SSD'}, +] + +interface LightsailLaunchPanelProps { + isSubmitting: boolean + submitError: string | null + onCancel: () => void + onSubmit: (values: Record) => void +} + +export function LightsailLaunchPanel({isSubmitting, submitError, onCancel, onSubmit}: LightsailLaunchPanelProps) { + const [category, setCategory] = useState<'app' | 'os'>('app') + const [blueprintId, setBlueprintId] = useState('nginx') + const [bundleId, setBundleId] = useState('nano_3_0') + const [name, setName] = useState('') + const [availabilityZone, setAvailabilityZone] = useState('us-east-1a') + const visibleBlueprints = blueprints.filter((blueprint) => blueprint.kind === category) + + function selectCategory(next: 'app' | 'os') { + setCategory(next) + setBlueprintId(blueprints.find((blueprint) => blueprint.kind === next && blueprint.available)?.id ?? '') + } + + return
+
+
+

Amazon Lightsail

+

Create an instance

+

Choose a blueprint, a plan, and a name. The instance launches locally through Floci.

+
+ +
+ +
+

Select a platform

+
+ + +
+
+ +
+

Select a blueprint

+
+ + +
+
+ {visibleBlueprints.map((blueprint) => )} +
+
+ +
+

Choose your instance plan

+
{plans.map((plan) => )}
+
+ +
+ + +
+ {isSubmitting && } + {submitError &&

{submitError}

} +
+
+} diff --git a/packages/frontend/src/components/LoadBalancerListenerPanel.tsx b/packages/frontend/src/components/LoadBalancerListenerPanel.tsx index 13c7cc9..8dd816e 100644 --- a/packages/frontend/src/components/LoadBalancerListenerPanel.tsx +++ b/packages/frontend/src/components/LoadBalancerListenerPanel.tsx @@ -3,6 +3,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useEffect, useState} from 'react' import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient' import {CopyValueButton} from '@/components/CopyValueButton' +import {LoadBalancingProgress} from '@/components/LoadBalancingProgress' import {useAccountId} from '@/lib/accountStore' import type {CloudResource} from '@/types/resource' @@ -33,7 +34,13 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) { }, [resource?.id]) const createMutation = useMutation({ - mutationFn: () => putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn}), + mutationFn: async () => { + const startedAt = Date.now() + const created = await putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn}) + const remaining = 5_000 - (Date.now() - startedAt) + if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining)) + return created + }, onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}), }) const deleteMutation = useMutation({ @@ -62,6 +69,7 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
{targetGroups.length === 0 && targetGroupsQuery.isSuccess &&

No target groups are available in this account.

} + {createMutation.isPending &&
} {error instanceof Error &&

{error.message}

} {listenersQuery.isLoading &&
Loading listeners
} diff --git a/packages/frontend/src/components/LoadBalancingProgress.tsx b/packages/frontend/src/components/LoadBalancingProgress.tsx new file mode 100644 index 0000000..1777dd6 --- /dev/null +++ b/packages/frontend/src/components/LoadBalancingProgress.tsx @@ -0,0 +1,19 @@ +interface LoadBalancingProgressProps { + kind: 'listener' | 'target' + description: string +} + +export function LoadBalancingProgress({kind, description}: LoadBalancingProgressProps) { + return
+ {kind === 'listener' ? : } +
{kind === 'listener' ? 'Creating listener' : 'Registering target'}{description}
+
+} + +function ListenerIcon() { + return +} + +function TargetIcon() { + return +} diff --git a/packages/frontend/src/components/NetworkingPanel.tsx b/packages/frontend/src/components/NetworkingPanel.tsx index 43b98e9..51e2be2 100644 --- a/packages/frontend/src/components/NetworkingPanel.tsx +++ b/packages/frontend/src/components/NetworkingPanel.tsx @@ -55,6 +55,7 @@ import {deleteFlatItem, listFlatItems, putFlatItem} from '@/api/cloudProxyClient import type {CloudProvider} from '@/types/cloud' import type {ChildItem, CloudResource} from '@/types/resource' import {calculateSubnetCidrs, isValidCidr, isValidPort} from '@/lib/network' +import {clientId} from '@/lib/clientId' // ─── Props ──────────────────────────────────────────────────────────────────── @@ -193,7 +194,7 @@ function ConfirmDeleteModal({title, detail, onConfirm, onClose, isPending}: { function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules: EditRule[]) => void}) { function addRule() { - onChange([...rules, {rid: crypto.randomUUID(), protocol: 'tcp', fromPort: '', toPort: '', cidr: '0.0.0.0/0'}]) + onChange([...rules, {rid: clientId(), protocol: 'tcp', fromPort: '', toPort: '', cidr: '0.0.0.0/0'}]) } function removeRule(rid: string) { onChange(rules.filter((r) => r.rid !== rid)) diff --git a/packages/frontend/src/components/ServerLaunchProgress.tsx b/packages/frontend/src/components/ServerLaunchProgress.tsx new file mode 100644 index 0000000..f69e016 --- /dev/null +++ b/packages/frontend/src/components/ServerLaunchProgress.tsx @@ -0,0 +1,19 @@ +interface ServerLaunchProgressProps { + name: string + description: string +} + +export function ServerLaunchProgress({name, description}: ServerLaunchProgressProps) { + return
+ +
Launching {name || 'instance'}{description}
+
+} diff --git a/packages/frontend/src/components/TargetGroupPanel.tsx b/packages/frontend/src/components/TargetGroupPanel.tsx index fd5bd73..8792dc6 100644 --- a/packages/frontend/src/components/TargetGroupPanel.tsx +++ b/packages/frontend/src/components/TargetGroupPanel.tsx @@ -3,6 +3,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useEffect, useState} from 'react' import {getLoadBalancingDependencies, listFlatItems, putFlatItem, deleteFlatItem} from '@/api/cloudProxyClient' import {useAccountId} from '@/lib/accountStore' +import {LoadBalancingProgress} from '@/components/LoadBalancingProgress' import type {CloudResource} from '@/types/resource' type Props = { @@ -37,7 +38,21 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) { void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()]) } const registerMutation = useMutation({ - mutationFn: () => putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)}), + mutationFn: async () => { + const startedAt = Date.now() + await putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)}) + while (true) { + const health = await listFlatItems('aws', 'targetgroups', resource?.id ?? '') + const target = health.items.find((item) => stringValue(item.body.id) === instanceId && Number(item.body.port) === Number(port)) + const state = stringValue(target?.body.state).toLowerCase() + if (state === 'healthy' || state === 'unhealthy') { + const remaining = 5_000 - (Date.now() - startedAt) + if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining)) + return + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + }, onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}), }) const deregisterMutation = useMutation({ @@ -63,6 +78,7 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
{instances.length === 0 && dependenciesQuery.isSuccess &&

No running instances are available in this account.

} + {registerMutation.isPending &&
} {error instanceof Error &&

{error.message}

} {healthQuery.isLoading &&
Loading target health
} diff --git a/packages/frontend/src/components/serviceIcons.tsx b/packages/frontend/src/components/serviceIcons.tsx index 56eb112..065b405 100644 --- a/packages/frontend/src/components/serviceIcons.tsx +++ b/packages/frontend/src/components/serviceIcons.tsx @@ -41,6 +41,7 @@ import eventBridgeIcon from '@/assets/aws-services/eventbridge.svg' import iamIcon from '@/assets/aws-services/iam.svg' import kmsIcon from '@/assets/aws-services/kms.svg' import lambdaIcon from '@/assets/aws-services/lambda.svg' +import lightsailIcon from '../../../../aws-icons-svg/Compute/Lightsail.svg' import parameterStoreIcon from '@/assets/aws-services/parameter-store.svg' import rdsIcon from '@/assets/aws-services/rds.svg' import s3Icon from '@/assets/aws-services/s3.svg' @@ -68,6 +69,7 @@ const SERVICE_ICONS: Record = { database: Table2, nosql: Database, k8s: Boxes, + lightsail: Server, compute: Server, containers: Container, networking: Network, @@ -112,6 +114,7 @@ const AWS_SERVICE_ICON_ASSETS: Record = { kms: kmsIcon, k8s: eksIcon, loadbalancing: elasticLoadBalancingIcon, + lightsail: lightsailIcon, logs: cloudWatchLogsIcon, messaging: sqsIcon, kinesis: kinesisIcon, diff --git a/packages/frontend/src/features/ec2/sgRuleHelpers.ts b/packages/frontend/src/features/ec2/sgRuleHelpers.ts index cc912f9..5996a8a 100644 --- a/packages/frontend/src/features/ec2/sgRuleHelpers.ts +++ b/packages/frontend/src/features/ec2/sgRuleHelpers.ts @@ -1,9 +1,10 @@ import { isValidCidr, isValidPort } from "@/lib/network"; +import { clientId } from "@/lib/clientId"; import type { SgRule } from "./SgRuleTable"; export function newRule(): SgRule { return { - id: crypto.randomUUID(), + id: clientId(), type: "custom-tcp", protocol: "tcp", fromPort: "", diff --git a/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx b/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx index 194ef77..ca6d961 100644 --- a/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx +++ b/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx @@ -30,6 +30,7 @@ import { usePutSecretValueMutation, } from '@/api/aws/secretsmanager.mutations' import { timeAgo } from '@/lib/utils' +import { clientId } from '@/lib/clientId' type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } type JsonValueKind = 'string' | 'number' | 'boolean' | 'null' | 'json' @@ -338,7 +339,7 @@ function SecretDrawer({ function addKeyValueEntry() { setKeyValueEntries((entries) => [ ...entries, - { id: crypto.randomUUID(), key: '', value: '', kind: 'string' }, + { id: clientId(), key: '', value: '', kind: 'string' }, ]) setEditorError('') } diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index a4353b0..14682ab 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -4875,6 +4875,181 @@ button.console-service-card { word-break: break-all; } +.ec2-connect-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.ec2-readiness-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.ec2-readiness-card { + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 7px; + background: color-mix(in srgb, var(--surface) 82%, var(--bg)); + display: flex; + flex-direction: column; + gap: 5px; +} + +.ec2-readiness-card > span, +.ec2-readiness-card small { + color: var(--text-3); + font-size: 10px; +} + +.ec2-readiness-card strong { + color: var(--status-success); + font: 600 13px "Space Mono", monospace; +} + +.ec2-readiness-card strong.error, +.ec2-check-result.error strong { + color: var(--status-error); +} + +.ec2-connect-error, +.ec2-check-result { + display: flex; + gap: 8px; + padding: 10px 12px; + border-radius: 6px; + font-size: 11px; +} + +.ec2-connect-error { + color: var(--status-error); + border: 1px solid color-mix(in srgb, var(--status-error) 35%, transparent); + background: color-mix(in srgb, var(--status-error) 8%, transparent); +} + +.ec2-connection-notice { + padding: 9px 11px; + border-left: 2px solid var(--text-3); + background: color-mix(in srgb, var(--surface) 82%, var(--bg)); + color: var(--text-2); + font-size: 11px; +} + +.ec2-connect-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.ec2-command-row { + grid-column: 1 / -1; +} + +.ec2-command-row code { + overflow-wrap: anywhere; +} + +.ec2-ssh-check { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.ec2-ssh-check strong, +.ec2-launch-timeline h4 { + margin: 0; + font-size: 12px; +} + +.ec2-ssh-check p { + margin: 3px 0 0; + color: var(--text-3); + font-size: 10px; +} + +.ec2-check-result { + flex-direction: column; + border: 1px solid var(--border); + background: var(--surface); +} + +.ec2-check-result.success strong { + color: var(--status-success); + font-family: "Space Mono", monospace; +} + +.ec2-check-result span { + color: var(--text-3); +} + +.ec2-launch-timeline { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.ec2-launch-event { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + padding: 7px 0; +} + +.ec2-launch-event div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ec2-launch-event strong { + font-size: 11px; + font-weight: 500; +} + +.ec2-launch-event small, +.ec2-launch-event time { + color: var(--text-3); + font-size: 10px; +} + +.ec2-event-dot { + width: 7px; + height: 7px; + margin-top: 3px; + border-radius: 50%; + background: var(--text-3); +} + +.ec2-event-dot.ok, +.ec2-event-dot.ready, +.ec2-event-dot.completed, +.ec2-event-dot.success { + background: var(--status-success); +} + +.ec2-event-dot.failed, +.ec2-event-dot.error { + background: var(--status-error); +} + +@media (max-width: 720px) { + .ec2-readiness-grid, + .ec2-connect-details { + grid-template-columns: 1fr; + } + + .ec2-ssh-check { + align-items: flex-start; + flex-direction: column; + } +} + /* ─── Copy object modal ──────────────────────────────────────────────────── */ .copy-modal-overlay { position: fixed; @@ -5709,3 +5884,64 @@ button.console-service-card { .redis-value { flex: 1; min-height: 110px; max-height: 310px; overflow: auto; margin: 0; padding: 12px; color: var(--text); font-size: 11px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } .redis-set-form { border-top: 1px solid var(--border); background: var(--raised); } .redis-set-form strong { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 12px; } + +.lightsail-launch { max-width: 980px; padding: 28px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } +.lightsail-launch-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; } +.lightsail-launch h2, .lightsail-launch h3 { margin: 0; } +.lightsail-launch h2 { font-size: 25px; } +.lightsail-launch h3 { font-size: 17px; margin-bottom: 13px; } +.lightsail-section { margin-top: 30px; } +.lightsail-platforms, .lightsail-blueprints, .lightsail-plans { display: flex; flex-wrap: wrap; gap: 12px; } +.lightsail-platform, .lightsail-blueprint, .lightsail-plans button { border: 1px solid var(--border); border-radius: 9px; background: var(--surface); color: var(--text); text-align: left; } +.lightsail-platform { display: flex; align-items: center; gap: 11px; min-width: 250px; padding: 16px; } +.lightsail-platform.selected, .lightsail-blueprint.selected, .lightsail-plans button.selected { border: 2px solid #ec7211; box-shadow: 0 0 0 1px color-mix(in srgb, #ec7211 18%, transparent); } +.lightsail-platform svg:last-child { margin-left: auto; color: #ec7211; } +.lightsail-platform span, .lightsail-blueprint span, .lightsail-plans button { display: flex; flex-direction: column; gap: 3px; } +.lightsail-launch small { color: var(--text-muted); } +.lightsail-tabs { display: flex; gap: 8px; margin-bottom: 14px; } +.lightsail-tabs button { border: 1px solid #ec7211; border-radius: 6px; padding: 8px 14px; background: transparent; color: #b7550a; } +.lightsail-tabs button.active { background: #ec7211; color: white; } +.lightsail-blueprint { width: 210px; min-height: 84px; padding: 13px; display: flex; align-items: center; gap: 13px; } +.lightsail-blueprint img { width: 42px; height: 42px; object-fit: contain; } +.lightsail-wordpress { display: grid; place-items: center; width: 42px; height: 42px; border: 2px solid #21759b; border-radius: 50%; color: #21759b; font-family: Georgia, serif; font-size: 29px; } +.lightsail-plans button { width: 205px; padding: 13px; } +.lightsail-config { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 28px; } +.lightsail-config label { display: grid; gap: 6px; font-weight: 600; } +.server-launch-progress { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #ec7211 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #ec7211 8%, var(--surface)); } +.server-launch-progress svg { width: 70px; height: 56px; flex: 0 0 auto; } +.server-launch-progress div { display: flex; flex-direction: column; gap: 3px; } +.server-launch-progress small { color: var(--text-muted); } +.database-launch-progress { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #1769c2 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #1769c2 8%, var(--surface)); } +.database-launch-progress svg { width: 56px; height: 56px; flex: 0 0 auto; } +.database-launch-progress div { display: flex; flex-direction: column; gap: 3px; } +.database-launch-progress small { color: var(--text-muted); } +.database-led { animation: database-led-blink 1.4s ease-in-out infinite; } +.database-led-b { animation-delay: .22s; } +.database-led-c { animation-delay: .44s; } +.database-led-d { animation-delay: .66s; } +@keyframes database-led-blink { 0%, 25%, 100% { opacity: .28; } 45%, 72% { opacity: 1; } } +.load-balancing-progress { display: flex; align-items: center; gap: 14px; margin-top: 16px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #ffc52e 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #ffc52e 8%, var(--surface)); } +.load-balancing-progress svg { width: 72px; height: 64px; flex: 0 0 auto; } +.load-balancing-progress div { display: flex; flex-direction: column; gap: 3px; } +.load-balancing-progress small { color: var(--text-muted); } +.lb-device { fill: #30283d; }.lb-panel { fill: #2b2440; stroke: #17132f; stroke-width: 5; }.lb-wire { fill: none; stroke: #17132f; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }.lb-yellow { fill: #ffc12d; }.lb-green { fill: #31aa98; }.lb-port { fill: none; stroke: #ffc52e; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }.lb-ports { animation: lb-port-morph 4.5s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }.lb-hub { animation: lb-hub-pulse 4.5s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }.lb-blink-1 { animation: lb-blink-one 4.5s steps(1, end) infinite; }.lb-blink-2 { animation: lb-blink-two 4.5s steps(1, end) infinite; }.lb-blink-3 { animation: lb-blink-three 4.5s steps(1, end) infinite; } +@keyframes lb-blink-one { 0%, 18%, 42%, 68%, 100% { opacity: 1; } 19%, 41%, 69%, 84% { opacity: .22; } } @keyframes lb-blink-two { 0%, 28%, 58%, 88%, 100% { opacity: .28; } 29%, 57%, 89%, 99% { opacity: 1; } } @keyframes lb-blink-three { 0%, 12%, 48%, 76%, 100% { opacity: 1; } 13%, 47%, 77%, 92% { opacity: .2; } } @keyframes lb-port-morph { 0%, 18%, 82%, 100% { transform: scaleX(.62); } 42%, 62% { transform: scaleX(1.18); } } @keyframes lb-hub-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.13); } } +@media (max-width: 640px) { .lightsail-launch { padding: 18px; } .lightsail-config { grid-template-columns: 1fr; } .lightsail-platform, .lightsail-blueprint, .lightsail-plans button { width: 100%; } } + +.lightsail-instance-panel { overflow: visible; } +.lightsail-instance-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 18px 18px 8px; } +.lightsail-instance-header h2 { margin: 3px 0; font-size: 22px; } +.lightsail-instance-controls { display: flex; align-items: center; gap: 10px; } +.lightsail-instance-controls summary { display: flex; align-items: center; gap: 4px; } +.lightsail-actions-menu { min-width: 154px; right: 0; left: auto; } +.lightsail-instance-tabs { margin: 0 18px; overflow-x: auto; } +.lightsail-overview, .lightsail-tab-content { padding: 18px; } +.lightsail-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 18px; } +.lightsail-detail { display: grid; gap: 5px; padding: 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-raised, var(--surface)); min-width: 0; } +.lightsail-detail span { color: var(--text-muted); font-size: 12px; } +.lightsail-detail code { overflow-wrap: anywhere; } +.lightsail-tab-content h3 { display: flex; align-items: center; gap: 7px; margin: 20px 0 5px; font-size: 16px; } +.lightsail-tab-content h3:first-child { margin-top: 0; } +.lightsail-port-form { align-items: end; margin: 16px 0; } +.lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; } +@media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } } diff --git a/packages/frontend/src/lib/clientId.ts b/packages/frontend/src/lib/clientId.ts new file mode 100644 index 0000000..cf7842b --- /dev/null +++ b/packages/frontend/src/lib/clientId.ts @@ -0,0 +1,7 @@ +export function clientId(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dd105a..6387232 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: '@aws-sdk/client-lambda': specifier: ^3.1076.0 version: 3.1076.0 + '@aws-sdk/client-lightsail': + specifier: ^3.1076.0 + version: 3.1148.0 '@aws-sdk/client-rds': specifier: ^3.1076.0 version: 3.1076.0 @@ -368,6 +371,10 @@ packages: resolution: {integrity: sha512-uueu0Do1dy3cm42AdjL3zivHoc5b55vOL3g6BjxUv2xMifSLKZi5Ilh5FhXVDdS/Uwxak/JH+vVQDDnEuQ52gA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-lightsail@3.1148.0': + resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-rds-data@3.1144.0': resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==} engines: {node: '>=20.0.0'} @@ -3047,6 +3054,17 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/client-lightsail@3.1148.0': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/client-rds-data@3.1144.0': dependencies: '@aws-sdk/core': 3.978.1