feat: Lightsail + svg animation
This commit is contained in:
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
|
||||
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
||||
import {serviceForAccount} from '../cloudProxy'
|
||||
import {CloudProxyService} from '../service/CloudProxyService'
|
||||
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
||||
|
||||
// Header (and query-param fallback for direct links such as object downloads)
|
||||
// used by the frontend to scope every request to an AWS account.
|
||||
@@ -136,6 +137,40 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/aws/services/lightsail/resources/:id/static-files', async (c) => {
|
||||
return withRuntime(c, async () => {
|
||||
const form = await c.req.formData()
|
||||
const files = form.getAll('files').filter((entry): entry is File => entry instanceof File)
|
||||
if (files.length === 0 || files.length !== form.getAll('files').length) {
|
||||
throw new ValidationError('At least one file is required.')
|
||||
}
|
||||
const upload = new FormData()
|
||||
for (const file of files) {
|
||||
upload.append('files', file, file.name)
|
||||
}
|
||||
return c.json(await deployLightsailStaticFiles(c.req.param('id'), upload))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/aws/services/lightsail/resources/:id/node-application', async (c) => {
|
||||
return withRuntime(c, async () => {
|
||||
const form = await c.req.formData()
|
||||
const files = form.getAll('files').filter((entry): entry is File => entry instanceof File)
|
||||
if (files.length === 0 || files.length !== form.getAll('files').length) {
|
||||
throw new ValidationError('At least one file or zip archive is required.')
|
||||
}
|
||||
const portValue = form.get('port')
|
||||
const port = typeof portValue === 'string' && /^\d+$/.test(portValue) ? Number(portValue) : 3000
|
||||
if (port < 1 || port > 65535) throw new ValidationError('Port must be between 1 and 65535.')
|
||||
const upload = new FormData()
|
||||
for (const file of files) upload.append('files', file, file.name)
|
||||
const startCommand = form.get('startCommand')
|
||||
if (typeof startCommand === 'string' && startCommand.trim()) upload.append('startCommand', startCommand.trim())
|
||||
upload.append('port', String(port))
|
||||
return c.json(await deployLightsailNodeApplication(c.req.param('id'), upload))
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/nosql/resources/:id/containers', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {createEc2Router} from './ec2'
|
||||
import {SshCheckError, type SshCheckResult} from '../services/sshCheck'
|
||||
import type {
|
||||
CreateAmiInput,
|
||||
CreateNatGatewayInput,
|
||||
@@ -202,6 +203,51 @@ describe('POST /instances/:id/terminate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /instances/:id/ssh/check', () => {
|
||||
test('returns the backend SSH check response', async () => {
|
||||
let checkedInstanceId: string | undefined
|
||||
const router = createEc2Router(fakeService, {
|
||||
check: async (instanceId: string): Promise<SshCheckResult> => {
|
||||
checkedInstanceId = instanceId
|
||||
return {
|
||||
ok: true,
|
||||
banner: 'SSH-2.0-OpenSSH_9.7',
|
||||
checkedFrom: 'UI backend',
|
||||
checkedAt: '2026-10-08T12:00:00.000Z',
|
||||
target: {host: 'host.docker.internal', port: 2222},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const res = await router.request('/instances/i-test1/ssh/check', {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({host: 'attacker.example', port: 22}),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(checkedInstanceId).toBe('i-test1')
|
||||
expect(await res.json()).toEqual({
|
||||
ok: true,
|
||||
banner: 'SSH-2.0-OpenSSH_9.7',
|
||||
checkedFrom: 'UI backend',
|
||||
checkedAt: '2026-10-08T12:00:00.000Z',
|
||||
target: {host: 'host.docker.internal', port: 2222},
|
||||
})
|
||||
})
|
||||
|
||||
test('maps typed checker errors', async () => {
|
||||
const router = createEc2Router(fakeService, {
|
||||
check: async () => { throw new SshCheckError('CONNECTION_TIMEOUT', 'SSH connection timed out', 504) },
|
||||
})
|
||||
|
||||
const res = await router.request('/instances/i-test1/ssh/check', {method: 'POST'})
|
||||
|
||||
expect(res.status).toBe(504)
|
||||
expect(await res.json()).toEqual({ok: false, code: 'CONNECTION_TIMEOUT', error: 'SSH connection timed out'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /instances/:id/security-groups', () => {
|
||||
test('replaces security groups', async () => {
|
||||
let receivedGroupIds: string[] | undefined
|
||||
|
||||
@@ -11,8 +11,10 @@ import type {
|
||||
VpcWizardInput,
|
||||
} from '../services/ec2'
|
||||
import {ec2Service} from '../services/ec2'
|
||||
import {SshCheckError, sshCheckService} from '../services/sshCheck'
|
||||
|
||||
type Ec2Service = typeof ec2Service
|
||||
type SshCheckService = typeof sshCheckService
|
||||
type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType}
|
||||
|
||||
type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
|
||||
@@ -21,6 +23,21 @@ type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
|
||||
sshPort?: number
|
||||
containerId?: string
|
||||
containerStatus?: 'running' | 'stopped' | 'unavailable'
|
||||
state?: string
|
||||
launchStatus?: string
|
||||
launchEvents?: Array<{stage?: string; status?: string; timestamp?: string; message?: string}>
|
||||
selectedImageType?: string
|
||||
selectedImageReference?: string
|
||||
sshUser?: string
|
||||
sshPreinstalled?: boolean
|
||||
initType?: string
|
||||
sshServiceName?: string
|
||||
sshStatus?: string
|
||||
sshStatusMessage?: string
|
||||
keyName?: string
|
||||
keyInjectionStatus?: string
|
||||
publishedPorts?: Record<string, number>
|
||||
failure?: {code?: string; message?: string} | string
|
||||
}>}}
|
||||
|
||||
async function connectionDetails(instanceId: string) {
|
||||
@@ -49,7 +66,7 @@ function handleError(c: Context, error: unknown) {
|
||||
return c.json({error: msg}, 400)
|
||||
}
|
||||
|
||||
export function createEc2Router(svc: Ec2Service = ec2Service) {
|
||||
export function createEc2Router(svc: Ec2Service = ec2Service, sshChecker: SshCheckService = sshCheckService) {
|
||||
const app = new Hono()
|
||||
|
||||
// Instances
|
||||
@@ -108,9 +125,23 @@ export function createEc2Router(svc: Ec2Service = ec2Service) {
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/instances/:instanceId/ssh/check', async (c) => {
|
||||
try {
|
||||
return c.json(await sshChecker.check(c.req.param('instanceId')))
|
||||
} catch (error) {
|
||||
if (error instanceof SshCheckError) {
|
||||
return c.json({ok: false, code: error.code, error: error.message}, error.status)
|
||||
}
|
||||
return c.json({ok: false, code: 'DIAGNOSTICS_UNAVAILABLE', error: 'SSH check failed'}, 502)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/instances/:instanceId/tags', async (c) => {
|
||||
try {
|
||||
const {toAdd, toRemove} = await c.req.json<{toAdd: Ec2Tag[]; toRemove: string[]}>()
|
||||
if (!Array.isArray(toAdd) || !Array.isArray(toRemove)) {
|
||||
return c.json({error: 'toAdd and toRemove must be arrays'}, 400)
|
||||
}
|
||||
await svc.updateInstanceTags(c.req.param('instanceId'), toAdd, toRemove)
|
||||
return c.json({ok: true})
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user