feat: display platform agent status and version
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -11,6 +11,7 @@ AGENT_PORT=3100
|
|||||||
AGENT_GATEWAY_TOKEN=
|
AGENT_GATEWAY_TOKEN=
|
||||||
HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org
|
HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org
|
||||||
HEADSCALE_API_KEY=
|
HEADSCALE_API_KEY=
|
||||||
|
HEADSCALE_GATEWAY_USERS=local-platform-gateway,platform-gateway
|
||||||
HEADSCALE_TEACHING_DEVICE_OWNER=tagged-devices
|
HEADSCALE_TEACHING_DEVICE_OWNER=tagged-devices
|
||||||
LMS_GATEWAY_TOKEN=change-me
|
LMS_GATEWAY_TOKEN=change-me
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const config = {
|
|||||||
headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'),
|
headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'),
|
||||||
headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '',
|
headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '',
|
||||||
headscaleTeachingDeviceOwner: (process.env.HEADSCALE_TEACHING_DEVICE_OWNER ?? 'tagged-devices').trim().replace(/@+$/, ''),
|
headscaleTeachingDeviceOwner: (process.env.HEADSCALE_TEACHING_DEVICE_OWNER ?? 'tagged-devices').trim().replace(/@+$/, ''),
|
||||||
|
headscaleGatewayUsers: (process.env.HEADSCALE_GATEWAY_USERS ?? process.env.HEADSCALE_GATEWAY_USER ?? 'local-platform-gateway')
|
||||||
|
.split(',')
|
||||||
|
.map((user) => user.trim().replace(/@+$/, ''))
|
||||||
|
.filter(Boolean),
|
||||||
lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
||||||
launcherGatewayToken: process.env.LAUNCHER_GATEWAY_TOKEN ?? process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
launcherGatewayToken: process.env.LAUNCHER_GATEWAY_TOKEN ?? process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
||||||
adminGatewayToken: process.env.ADMIN_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
adminGatewayToken: process.env.ADMIN_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ lmsRoutes.get('/lms/platforms/:id/status', async (c) => {
|
|||||||
sshUser: platform.sshUser ?? null,
|
sshUser: platform.sshUser ?? null,
|
||||||
workspaceRoot: platform.workspaceRoot ?? null,
|
workspaceRoot: platform.workspaceRoot ?? null,
|
||||||
lastSeenAt: platform.lastSeenAt,
|
lastSeenAt: platform.lastSeenAt,
|
||||||
|
agentStartedAt: agentHealth.startedAt ?? null,
|
||||||
|
agentUptimeSeconds: agentHealth.uptimeSeconds ?? null,
|
||||||
state: platform.state,
|
state: platform.state,
|
||||||
message: agentHealth.message ?? null,
|
message: agentHealth.message ?? null,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { config } from '../config'
|
|||||||
import type { Platform } from '../types'
|
import type { Platform } from '../types'
|
||||||
|
|
||||||
export class AgentClient {
|
export class AgentClient {
|
||||||
async health(platform: Platform): Promise<{ ok: boolean; message?: string }> {
|
async health(platform: Platform): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
startedAt?: string | null;
|
||||||
|
uptimeSeconds?: number | null;
|
||||||
|
}> {
|
||||||
if (!platform.agentUrl) return { ok: false, message: 'platform_agent_url_missing' }
|
if (!platform.agentUrl) return { ok: false, message: 'platform_agent_url_missing' }
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -14,8 +19,14 @@ export class AgentClient {
|
|||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
})
|
})
|
||||||
if (!response.ok) return { ok: false, message: `agent_health_failed:${response.status}` }
|
if (!response.ok) return { ok: false, message: `agent_health_failed:${response.status}` }
|
||||||
const payload = await response.json().catch(() => ({ ok: true }))
|
const payload = await response.json().catch(() => ({ ok: true })) as Record<string, unknown>
|
||||||
return { ok: payload?.ok !== false }
|
return {
|
||||||
|
ok: payload?.ok !== false,
|
||||||
|
startedAt: typeof payload?.startedAt === 'string' ? payload.startedAt : null,
|
||||||
|
uptimeSeconds: typeof payload?.uptimeSeconds === 'number' && Number.isFinite(payload.uptimeSeconds)
|
||||||
|
? Math.max(0, Math.floor(payload.uptimeSeconds))
|
||||||
|
: null,
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type HeadscalePolicyStatus = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generatedPrefix = 'group:lms-platform-'
|
const generatedPrefix = 'group:lms-platform-'
|
||||||
|
const gatewayGroup = 'group:lms-platform-gateway'
|
||||||
|
|
||||||
export class HeadscaleAdapter {
|
export class HeadscaleAdapter {
|
||||||
async applyGrant(_grant: AccessGrant): Promise<{ applied: boolean; mode: 'noop' | 'headscale' }> {
|
async applyGrant(_grant: AccessGrant): Promise<{ applied: boolean; mode: 'noop' | 'headscale' }> {
|
||||||
@@ -77,8 +78,24 @@ export class HeadscaleAdapter {
|
|||||||
}> {
|
}> {
|
||||||
const nodes = await this.listNodes()
|
const nodes = await this.listNodes()
|
||||||
const unresolved: string[] = []
|
const unresolved: string[] = []
|
||||||
const groups: Record<string, string[]> = {}
|
const groups: Record<string, string[]> = {
|
||||||
const acls: HeadscalePolicy['acls'] = []
|
[gatewayGroup]: [...new Set(config.headscaleGatewayUsers.map((user) => this.toHeadscaleUserName(user)))],
|
||||||
|
}
|
||||||
|
const acls: HeadscalePolicy['acls'] = [
|
||||||
|
{
|
||||||
|
// The local bridge initiates discovery from the trusted gateway host.
|
||||||
|
action: 'accept',
|
||||||
|
src: [gatewayGroup],
|
||||||
|
dst: ['tag:teaching-device:*'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// DDS discovery and topic payloads use independent UDP flows, so the
|
||||||
|
// teaching device must also be able to reach the bridge on the gateway.
|
||||||
|
action: 'accept',
|
||||||
|
src: ['tag:teaching-device'],
|
||||||
|
dst: [`${gatewayGroup}:*`],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
assignments.forEach((assignment, index) => {
|
assignments.forEach((assignment, index) => {
|
||||||
const users = [...new Set(assignment.studentEmails.map((email) => this.toHeadscaleUserName(email)).filter(Boolean))]
|
const users = [...new Set(assignment.studentEmails.map((email) => this.toHeadscaleUserName(email)).filter(Boolean))]
|
||||||
|
|||||||
Reference in New Issue
Block a user