Files
floci-ui/packages/api/src/routes/ec2.ts
T
2026-10-11 14:19:00 -04:00

563 lines
18 KiB
TypeScript

import {Hono} from 'hono'
import type {Context} from 'hono'
import type {
CreateAmiInput,
CreateNatGatewayInput,
CreateRouteInput,
Ec2KeyPairType,
Ec2Tag,
IpPermissionInput,
RunInstanceInput,
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<{
instanceId?: string
sshHost?: string
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) {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) {
return undefined
}
try {
const response = await fetch(`${endpoint}/_floci/diagnose`)
if (!response.ok) {
return undefined
}
return (await response.json() as Ec2DiagnosticResponse).ec2?.instances
?.find((instance) => instance.instanceId === instanceId)
} catch {
return undefined
}
}
function isEc2KeyPairType(value: unknown): value is Ec2KeyPairType {
return value === 'rsa' || value === 'ed25519'
}
function handleError(c: Context, error: unknown) {
const msg = error instanceof Error ? error.message : 'Unknown error'
return c.json({error: msg}, 400)
}
export function createEc2Router(svc: Ec2Service = ec2Service, sshChecker: SshCheckService = sshCheckService) {
const app = new Hono()
// Instances
app.get('/instances', async (c) => {
return c.json(await svc.listInstances())
})
app.get('/instances/:instanceId', async (c) => {
const instanceId = c.req.param('instanceId')
const [instance, connection] = await Promise.all([svc.describeInstance(instanceId), connectionDetails(instanceId)])
return c.json({...instance, connection})
})
app.post('/instances', async (c) => {
try {
const input = await c.req.json<RunInstanceInput>()
return c.json(await svc.runInstance(input))
} catch (error) {
return handleError(c, error)
}
})
app.post('/instances/:instanceId/start', async (c) => {
try {
await svc.startInstance(c.req.param('instanceId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/instances/:instanceId/stop', async (c) => {
try {
await svc.stopInstance(c.req.param('instanceId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/instances/:instanceId/reboot', async (c) => {
try {
await svc.rebootInstance(c.req.param('instanceId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/instances/:instanceId/terminate', async (c) => {
try {
await svc.terminateInstance(c.req.param('instanceId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
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) {
return handleError(c, error)
}
})
app.put('/instances/:instanceId/security-groups', async (c) => {
try {
const {groupIds} = await c.req.json<{groupIds: string[]}>()
if (!Array.isArray(groupIds) || groupIds.length === 0 || groupIds.some((groupId) => !groupId)) {
return c.json({error: 'At least one security group is required'}, 400)
}
await svc.updateInstanceSecurityGroups(c.req.param('instanceId'), groupIds)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/instances/:instanceId/image', async (c) => {
try {
const body = await c.req.json<Omit<CreateAmiInput, 'instanceId'>>()
const result = await svc.createImage({...body, instanceId: c.req.param('instanceId')})
return c.json(result)
} catch (error) {
return handleError(c, error)
}
})
app.get('/instances/:instanceId/console', async (c) => {
try {
return c.json(await svc.getConsoleOutput(c.req.param('instanceId')))
} catch (error) {
return handleError(c, error)
}
})
// AMIs
app.get('/amis', async (c) => {
return c.json(await svc.listAmis())
})
app.post('/amis/:imageId/deregister', async (c) => {
try {
await svc.deregisterImage(c.req.param('imageId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// Key pairs
app.get('/key-pairs', async (c) => {
return c.json(await svc.listKeyPairs())
})
app.post('/key-pairs', async (c) => {
try {
const {name, keyType = 'rsa'} = await c.req.json<CreateKeyPairInput>()
if (!isEc2KeyPairType(keyType)) {
return c.json({error: 'keyType must be rsa or ed25519'}, 400)
}
return c.json(await svc.createKeyPair(name, keyType))
} catch (error) {
return handleError(c, error)
}
})
app.delete('/key-pairs/:name', async (c) => {
try {
await svc.deleteKeyPair(c.req.param('name'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// Security groups
app.get('/security-groups', async (c) => {
const vpcId = c.req.query('vpcId')
return c.json(await svc.listSecurityGroups(vpcId))
})
app.post('/security-groups', async (c) => {
try {
const {name, description, vpcId} = await c.req.json<{name: string; description: string; vpcId: string}>()
return c.json(await svc.createSecurityGroup(name, description, vpcId))
} catch (error) {
return handleError(c, error)
}
})
app.delete('/security-groups/:groupId', async (c) => {
try {
await svc.deleteSecurityGroup(c.req.param('groupId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/security-groups/:groupId/ingress', async (c) => {
try {
const permission = await c.req.json<IpPermissionInput>()
await svc.authorizeSecurityGroupIngress(c.req.param('groupId'), permission)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.delete('/security-groups/:groupId/ingress', async (c) => {
try {
const permission = await c.req.json<IpPermissionInput>()
await svc.revokeSecurityGroupIngress(c.req.param('groupId'), permission)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/security-groups/:groupId/egress', async (c) => {
try {
const permission = await c.req.json<IpPermissionInput>()
await svc.authorizeSecurityGroupEgress(c.req.param('groupId'), permission)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.delete('/security-groups/:groupId/egress', async (c) => {
try {
const permission = await c.req.json<IpPermissionInput>()
await svc.revokeSecurityGroupEgress(c.req.param('groupId'), permission)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// Meta
app.get('/availability-zones', async (c) => {
return c.json(await svc.listAvailabilityZones())
})
app.get('/instance-types', async (c) => {
return c.json(await svc.listInstanceTypes())
})
// VPCs
app.get('/vpcs', async (c) => {
return c.json(await svc.listVpcs())
})
app.post('/vpcs', async (c) => {
try {
const {cidrBlock} = await c.req.json<{cidrBlock: string}>()
return c.json(await svc.createVpc(cidrBlock))
} catch (error) {
return handleError(c, error)
}
})
app.delete('/vpcs/:vpcId', async (c) => {
try {
await svc.deleteVpc(c.req.param('vpcId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.get('/vpcs/:vpcId/attributes', async (c) => {
try {
return c.json(await svc.getVpcAttributes(c.req.param('vpcId')))
} catch (error) {
return handleError(c, error)
}
})
app.put('/vpcs/:vpcId/attributes', async (c) => {
try {
const {attribute, value} = await c.req.json<{attribute: 'enableDnsHostnames' | 'enableDnsSupport'; value: boolean}>()
await svc.modifyVpcAttribute(c.req.param('vpcId'), attribute, value)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// Subnets
app.get('/subnets', async (c) => {
const vpcId = c.req.query('vpcId')
return c.json(await svc.listSubnets(vpcId))
})
app.post('/subnets', async (c) => {
try {
const {vpcId, cidrBlock, availabilityZone} = await c.req.json<{
vpcId: string
cidrBlock: string
availabilityZone?: string
}>()
return c.json(await svc.createSubnet(vpcId, cidrBlock, availabilityZone))
} catch (error) {
return handleError(c, error)
}
})
app.delete('/subnets/:subnetId', async (c) => {
try {
await svc.deleteSubnet(c.req.param('subnetId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.put('/subnets/:subnetId/attributes', async (c) => {
try {
const {mapPublicIpOnLaunch} = await c.req.json<{mapPublicIpOnLaunch: boolean}>()
await svc.modifySubnetMapPublicIp(c.req.param('subnetId'), mapPublicIpOnLaunch)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// Internet Gateways
app.get('/internet-gateways', async (c) => {
return c.json(await svc.listInternetGateways())
})
app.post('/internet-gateways', async (c) => {
try {
const body = await c.req.json<{name?: string}>().catch(() => ({} as {name?: string}))
return c.json(await svc.createInternetGateway(body.name))
} catch (error) {
return handleError(c, error)
}
})
app.post('/internet-gateways/:igwId/attach', async (c) => {
try {
const {vpcId} = await c.req.json<{vpcId: string}>()
await svc.attachInternetGateway(c.req.param('igwId'), vpcId)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/internet-gateways/:igwId/detach', async (c) => {
try {
const {vpcId} = await c.req.json<{vpcId: string}>()
await svc.detachInternetGateway(c.req.param('igwId'), vpcId)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.delete('/internet-gateways/:igwId', async (c) => {
try {
await svc.deleteInternetGateway(c.req.param('igwId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// NAT Gateways
app.get('/nat-gateways', async (c) => {
const vpcId = c.req.query('vpcId')
return c.json(await svc.listNatGateways(vpcId))
})
app.post('/nat-gateways', async (c) => {
try {
const input = await c.req.json<CreateNatGatewayInput>()
return c.json(await svc.createNatGateway(input))
} catch (error) {
return handleError(c, error)
}
})
app.delete('/nat-gateways/:natId', async (c) => {
try {
await svc.deleteNatGateway(c.req.param('natId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// Route Tables
app.get('/route-tables', async (c) => {
const vpcId = c.req.query('vpcId')
return c.json(await svc.listRouteTables(vpcId))
})
app.post('/route-tables', async (c) => {
try {
const {vpcId, name} = await c.req.json<{vpcId: string; name?: string}>()
return c.json(await svc.createRouteTable(vpcId, name))
} catch (error) {
return handleError(c, error)
}
})
app.delete('/route-tables/:rtbId', async (c) => {
try {
await svc.deleteRouteTable(c.req.param('rtbId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/route-tables/:rtbId/routes', async (c) => {
try {
const input = await c.req.json<CreateRouteInput>()
await svc.createRoute(c.req.param('rtbId'), input)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.delete('/route-tables/:rtbId/routes', async (c) => {
try {
const cidr = c.req.query('cidr')
if (!cidr) return handleError(c, new Error('cidr query parameter is required'))
await svc.deleteRoute(c.req.param('rtbId'), cidr)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/route-tables/:rtbId/associations', async (c) => {
try {
const {subnetId} = await c.req.json<{subnetId: string}>()
const associationId = await svc.associateRouteTable(c.req.param('rtbId'), subnetId)
return c.json({associationId})
} catch (error) {
return handleError(c, error)
}
})
app.delete('/route-table-associations/:associationId', async (c) => {
try {
await svc.disassociateRouteTable(c.req.param('associationId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// Elastic IPs
app.get('/elastic-ips', async (c) => {
return c.json(await svc.listElasticIps())
})
app.post('/elastic-ips', async (c) => {
try {
const body = await c.req.json<{name?: string}>().catch(() => ({} as {name?: string}))
return c.json(await svc.allocateElasticIp(body.name))
} catch (error) {
return handleError(c, error)
}
})
app.post('/elastic-ips/:allocationId/release', async (c) => {
try {
await svc.releaseElasticIp(c.req.param('allocationId'))
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
app.post('/elastic-ips/:allocationId/associate', async (c) => {
try {
const {instanceId} = await c.req.json<{instanceId: string}>()
return c.json(await svc.associateElasticIp(c.req.param('allocationId'), instanceId))
} catch (error) {
return handleError(c, error)
}
})
app.post('/elastic-ips/:allocationId/disassociate', async (c) => {
try {
const {associationId} = await c.req.json<{associationId: string}>()
await svc.disassociateElasticIp(associationId)
return c.json({ok: true})
} catch (error) {
return handleError(c, error)
}
})
// VPC Wizard — creates VPC + IGW + subnets + route tables + optional NAT GW in one call
app.post('/vpc-wizard', async (c) => {
try {
const input = await c.req.json<VpcWizardInput>()
const result = await svc.createVpcWizard(input)
return c.json(result)
} catch (error) {
return handleError(c, error)
}
})
return app
}
export default createEc2Router()