initial platform gateway service
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config'
|
||||
import { agentClient } from '../services/agent-client'
|
||||
import { headscaleAdapter } from '../services/headscale-adapter'
|
||||
import { headscaleNodeDiscovery } from '../services/headscale-node-discovery'
|
||||
import { platformRegistry } from '../services/platform-registry'
|
||||
import { sessionManager } from '../services/session-manager'
|
||||
import type { AuthUser } from '../types'
|
||||
|
||||
const sessionSchema = z.object({
|
||||
platformId: z.string().min(1),
|
||||
user: z.object({
|
||||
id: z.string().min(1),
|
||||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
}),
|
||||
agentOwnerEmail: z.string().email().optional(),
|
||||
ttlSeconds: z.number().int().positive().max(8 * 60 * 60).default(config.defaultSessionTtlSeconds),
|
||||
})
|
||||
|
||||
const visualizationSchema = z.object({
|
||||
stack: z.string().optional(),
|
||||
rosDomainId: z.string().optional(),
|
||||
profile: z.unknown().optional(),
|
||||
})
|
||||
|
||||
const accessSyncSchema = z.object({
|
||||
assignments: z.array(z.object({
|
||||
platformId: z.string().min(1),
|
||||
platformName: z.string().nullable().optional(),
|
||||
agentHostname: z.string().nullable().optional(),
|
||||
agentOwnerEmail: z.string().nullable().optional(),
|
||||
studentEmails: z.array(z.string().email()).default([]),
|
||||
})).default([]),
|
||||
})
|
||||
|
||||
export const lmsRoutes = new Hono()
|
||||
|
||||
lmsRoutes.use('/lms/*', async (c, next) => {
|
||||
const token = c.req.header('x-lms-gateway-token') ?? ''
|
||||
if (!config.lmsGatewayToken || token !== config.lmsGatewayToken) {
|
||||
return c.json({ error: 'invalid_lms_gateway_token' }, 401)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
lmsRoutes.post('/lms/platform-sessions', async (c) => {
|
||||
const body = sessionSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400)
|
||||
|
||||
const platform = await platformRegistry.resolvePlatform(
|
||||
body.data.platformId,
|
||||
body.data.agentOwnerEmail ?? body.data.user.email,
|
||||
)
|
||||
if (!platform) return c.json({ error: 'platform_not_found' }, 404)
|
||||
if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409)
|
||||
|
||||
const active = sessionManager.getActiveSessionByPlatform(body.data.platformId)
|
||||
if (active) {
|
||||
if (active.userId !== body.data.user.id) return c.json({ error: 'platform_busy', session: active }, 409)
|
||||
return c.json({ session: active, connection: connectionPayload(platform) })
|
||||
}
|
||||
|
||||
try {
|
||||
const session = sessionManager.start({
|
||||
platformId: body.data.platformId,
|
||||
ttlSeconds: body.data.ttlSeconds,
|
||||
user: body.data.user as AuthUser,
|
||||
})
|
||||
return c.json({ session, connection: connectionPayload(platform) }, 201)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) throw error
|
||||
if (error.message === 'platform_not_found') return c.json({ error: error.message }, 404)
|
||||
if (error.message === 'platform_busy' || error.message === 'platform_unavailable') {
|
||||
return c.json({ error: error.message }, 409)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
lmsRoutes.get('/lms/agents', async (c) => {
|
||||
const accountEmail = c.req.query('accountEmail') || undefined
|
||||
return c.json({ agents: await headscaleNodeDiscovery.listTeachingAgents(accountEmail) })
|
||||
})
|
||||
|
||||
lmsRoutes.get('/lms/platforms/:id/status', async (c) => {
|
||||
const accountEmail = c.req.query('accountEmail') || undefined
|
||||
const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail)
|
||||
if (!platform) return c.json({ error: 'platform_not_found' }, 404)
|
||||
|
||||
const headscaleOnline = Boolean(platform.agentUrl && platform.state !== 'offline')
|
||||
const agentHealth = headscaleOnline
|
||||
? await agentClient.health(platform)
|
||||
: { ok: false, message: 'headscale_offline' }
|
||||
|
||||
return c.json({
|
||||
platformId: c.req.param('id'),
|
||||
registered: true,
|
||||
headscaleOnline,
|
||||
agentHealthy: Boolean(agentHealth.ok),
|
||||
agentUrl: platform.agentUrl || null,
|
||||
sshUser: platform.sshUser ?? null,
|
||||
workspaceRoot: platform.workspaceRoot ?? null,
|
||||
lastSeenAt: platform.lastSeenAt,
|
||||
state: platform.state,
|
||||
message: agentHealth.message ?? null,
|
||||
})
|
||||
})
|
||||
|
||||
lmsRoutes.post('/lms/access-sync', async (c) => {
|
||||
const body = accessSyncSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400)
|
||||
|
||||
const result = await headscaleAdapter.syncAssignments(body.data.assignments)
|
||||
if (!result.applied) return c.json({ error: 'headscale_policy_apply_failed', result }, 502)
|
||||
const ssh = await syncPlatformSshKeys(body.data.assignments)
|
||||
return c.json({ ...result, ssh })
|
||||
})
|
||||
|
||||
lmsRoutes.post('/lms/platforms/:id/release', async (c) => {
|
||||
const user = await c.req.json<{ user?: AuthUser }>().catch(() => ({} as { user?: AuthUser }))
|
||||
const session = sessionManager.release(c.req.param('id'), user.user ?? { id: 'lms-system', role: 'ADMIN' })
|
||||
const platform = await platformRegistry.resolvePlatform(c.req.param('id'))
|
||||
if (platform) await agentClient.visualizationStop(platform).catch(() => undefined)
|
||||
return c.json({ session })
|
||||
})
|
||||
|
||||
lmsRoutes.get('/lms/platforms/:id/visualization/status', async (c) => {
|
||||
const platform = await platformRegistry.resolvePlatform(c.req.param('id'))
|
||||
if (!platform) return c.json({ error: 'platform_not_found' }, 404)
|
||||
if (!platform.agentUrl) return c.json({ ok: false, running: false, stack: null, message: 'platform_agent_not_connected' }, 409)
|
||||
return c.json(await agentClient.visualizationStatus(platform))
|
||||
})
|
||||
|
||||
lmsRoutes.get('/lms/platforms/:id/modules/statuses', async (c) => {
|
||||
const accountEmail = c.req.query('accountEmail') || undefined
|
||||
const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail)
|
||||
if (!platform) return c.json({ error: 'platform_not_found' }, 404)
|
||||
if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409)
|
||||
return c.json(await agentClient.moduleStatuses(platform))
|
||||
})
|
||||
|
||||
lmsRoutes.post('/lms/platforms/:id/modules/start', async (c) => {
|
||||
const accountEmail = c.req.query('accountEmail') || undefined
|
||||
const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail)
|
||||
if (!platform) return c.json({ error: 'platform_not_found' }, 404)
|
||||
if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409)
|
||||
const body = await c.req.json<{ folderName?: string; rosDomainId?: string }>().catch(() => ({}))
|
||||
return c.json(await agentClient.moduleStart(platform, body))
|
||||
})
|
||||
|
||||
lmsRoutes.post('/lms/platforms/:id/modules/stop', async (c) => {
|
||||
const accountEmail = c.req.query('accountEmail') || undefined
|
||||
const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail)
|
||||
if (!platform) return c.json({ error: 'platform_not_found' }, 404)
|
||||
if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409)
|
||||
const body = await c.req.json<{ containerKey?: string }>().catch(() => ({}))
|
||||
return c.json(await agentClient.moduleStop(platform, body))
|
||||
})
|
||||
|
||||
lmsRoutes.post('/lms/platforms/:id/visualization/start', async (c) => {
|
||||
const platform = await platformRegistry.resolvePlatform(c.req.param('id'))
|
||||
if (!platform) return c.json({ error: 'platform_not_found' }, 404)
|
||||
if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409)
|
||||
const body = visualizationSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400)
|
||||
return c.json(await agentClient.visualizationStart(platform, body.data))
|
||||
})
|
||||
|
||||
lmsRoutes.post('/lms/platforms/:id/visualization/stop', async (c) => {
|
||||
const platform = await platformRegistry.resolvePlatform(c.req.param('id'))
|
||||
if (!platform) return c.json({ error: 'platform_not_found' }, 404)
|
||||
if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409)
|
||||
const body = visualizationSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||
return c.json(await agentClient.visualizationStop(platform, body.success ? body.data : {}))
|
||||
})
|
||||
|
||||
function connectionPayload(platform: { agentUrl: string; sshUser?: string | null; workspaceRoot?: string | null }) {
|
||||
const agentUrl = platform.agentUrl.replace(/\/+$/, '')
|
||||
const bridgeHttpUrl = (() => {
|
||||
try {
|
||||
const url = new URL(agentUrl)
|
||||
url.port = '8765'
|
||||
return url.toString().replace(/\/+$/, '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
agentUrl,
|
||||
sshUser: platform.sshUser ?? null,
|
||||
workspaceRoot: platform.workspaceRoot ?? null,
|
||||
bridgeHttpUrl,
|
||||
bridgeWsUrl: bridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'),
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPlatformSshKeys(assignments: Array<{
|
||||
platformId: string
|
||||
agentOwnerEmail?: string | null
|
||||
studentEmails: string[]
|
||||
}>) {
|
||||
const emails = Array.from(new Set(assignments.flatMap((assignment) => assignment.studentEmails)))
|
||||
const publicKeysByEmail = await fetchLauncherSshKeys(emails).catch(() => ({} as Record<string, Array<{ publicKey: string }>>))
|
||||
const results: Array<{ platformId: string; applied: boolean; keyCount: number; message?: string }> = []
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const platform = await platformRegistry.resolvePlatform(
|
||||
assignment.platformId,
|
||||
assignment.agentOwnerEmail ?? undefined,
|
||||
)
|
||||
const publicKeys = assignment.studentEmails.flatMap((email) =>
|
||||
(publicKeysByEmail[email.toLowerCase()] ?? []).map((key) => key.publicKey),
|
||||
)
|
||||
if (!platform?.agentUrl) {
|
||||
results.push({ platformId: assignment.platformId, applied: false, keyCount: publicKeys.length, message: 'platform_agent_not_connected' })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await agentClient.syncAuthorizedKeys(platform, publicKeys, platform.sshUser)
|
||||
results.push({ platformId: assignment.platformId, applied: true, keyCount: publicKeys.length })
|
||||
} catch (error) {
|
||||
results.push({
|
||||
platformId: assignment.platformId,
|
||||
applied: false,
|
||||
keyCount: publicKeys.length,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function fetchLauncherSshKeys(emails: string[]): Promise<Record<string, Array<{ publicKey: string }>>> {
|
||||
if (!emails.length) return {}
|
||||
if (!config.launcherGatewayToken) return {}
|
||||
const response = await fetch(`${config.launcherServerUrl}/api/provision/ssh-keys/lookup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-lms-gateway-token': config.launcherGatewayToken,
|
||||
},
|
||||
body: JSON.stringify({ emails }),
|
||||
})
|
||||
if (!response.ok) throw new Error(`launcher_ssh_key_lookup_failed:${response.status}`)
|
||||
const payload = await response.json().catch(() => ({})) as { keysByEmail?: Record<string, Array<{ publicKey: string }>> }
|
||||
return payload.keysByEmail ?? {}
|
||||
}
|
||||
Reference in New Issue
Block a user