From 9f7b01e4db18d354899a2ee9669adf0f5ad88204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E4=B8=80=E9=B8=A3?= <1000000008@qq.com> Date: Fri, 13 Nov 2026 19:08:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BA=91=E7=AE=A1=E5=8A=A9=E6=89=8B=E4=B8=8EGPU=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E6=81=A2=E5=A4=8D=E6=A0=B8=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/src/api/audit-labels.ts | 2 +- console/src/api/index.ts | 10 +- console/src/api/schema.d.ts | 840 +++++++++++- console/src/layouts/ConsoleLayout.vue | 2 +- console/src/router/index.ts | 2 +- console/src/views/CommonPeople.vue | 23 +- console/src/views/CommonWorkbench.vue | 42 +- console/src/views/Desktops.vue | 16 +- console/src/views/GPUs.vue | 5 +- console/src/views/Templates.vue | 17 +- console/src/views/Users.vue | 23 +- deploy/compose.live.yml | 1 + docs/cloud-common-assistant-20261113.md | 30 + ...loud-gpu-health-exclusive-logs-20261112.md | 4 +- docs/cloud-gpu-recovery-20261113.md | 24 + docs/openapi/platform.v1.json | 1215 ++++++++++++++++- platform/internal/config/config.go | 3 +- platform/internal/model/model.go | 19 +- platform/internal/server/admin.go | 14 +- platform/internal/server/assistant.go | 302 ++++ platform/internal/server/assistant_test.go | 44 + platform/internal/server/catalog.go | 25 +- platform/internal/server/catalog_test.go | 32 +- platform/internal/server/desktop_readiness.go | 121 ++ .../internal/server/desktop_readiness_test.go | 38 + platform/internal/server/diagnostics.go | 11 +- platform/internal/server/exclusive_gpu.go | 36 +- .../internal/server/exclusive_gpu_test.go | 31 +- platform/internal/server/gpu.go | 31 +- platform/internal/server/gpu_agent.go | 141 ++ platform/internal/server/gpu_agent_test.go | 50 + platform/internal/server/gpu_recovery.go | 145 ++ platform/internal/server/gpu_recovery_test.go | 161 +++ platform/internal/server/observability.go | 2 +- platform/internal/server/provision_worker.go | 9 +- platform/internal/server/server.go | 6 + platform/internal/server/server_test.go | 9 +- platform/internal/server/terminal.go | 52 + platform/internal/store/schema.sql | 26 + platform/pkg/proxmox/client.go | 37 +- platform/pkg/proxmox/client_test.go | 65 + tools/generate-platform-openapi.mjs | 30 +- 42 files changed, 3576 insertions(+), 120 deletions(-) create mode 100644 docs/cloud-common-assistant-20261113.md create mode 100644 docs/cloud-gpu-recovery-20261113.md create mode 100644 platform/internal/server/assistant.go create mode 100644 platform/internal/server/assistant_test.go create mode 100644 platform/internal/server/desktop_readiness.go create mode 100644 platform/internal/server/desktop_readiness_test.go create mode 100644 platform/internal/server/gpu_agent.go create mode 100644 platform/internal/server/gpu_agent_test.go create mode 100644 platform/internal/server/gpu_recovery.go create mode 100644 platform/internal/server/gpu_recovery_test.go diff --git a/console/src/api/audit-labels.ts b/console/src/api/audit-labels.ts index f43b312..add3323 100644 --- a/console/src/api/audit-labels.ts +++ b/console/src/api/audit-labels.ts @@ -1 +1 @@ -export const auditActionLabels: Record = { export_diagnostics: '导出诊断包', read_diagnostics: '读取终端诊断', 'batch-start': '批量开机提交', 'batch-shutdown': '批量关机提交', 'batch-reboot': '批量重启提交', reconcile_batch_power: '核对批量电源', 'snapshot-create': '创建快照', 'snapshot-delete': '删除快照', 'snapshot-rollback': '还原快照', reconcile_snapshot: '核对快照', 'configure-hardware': '调整 CPU/内存', 'expand-disk': '磁盘扩容', reconcile_hardware: '核对规格', save_pve_connection: '保存 PVE 接入', create_terminal_policy: '创建终端策略', deploy_terminal_policy: '下发终端策略', terminal_policy_applied: '策略应用成功', terminal_policy_failed: '策略应用失败', update_terminal: '更新终端资料', disable_terminal: '停用终端', reactivate_terminal: '重新激活终端', update_user: '更新用户资料', batch_grant_bindings: '批量分配桌面', batch_revoke_bindings: '批量撤销桌面', create_administrator: '新增管理员', update_administrator: '更新管理员', reset_administrator_password: '重置管理员密码', change_own_password: '修改本人密码', reconcile_clone: '核对克隆', clone: '克隆桌面', register_template: '登记模板', update_template: '更新模板', validate_template: '验证模板', save_pool: '保存桌面池', update_pool_members: '更新池成员', configure_desktop: '配置连接', start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面', save_alert_rule: '保存告警规则', acknowledge_alert: '确认告警', export_audit_logs: '导出操作审计', export_tasks: '导出任务记录', revoke_administrator_session: '撤销管理员登录', enable_mfa: '开启双重验证', disable_mfa: '关闭双重验证', rotate_mfa_recovery: '更新恢复码', 'prepare-desktop': '准备独占显卡桌面', 'reconcile-gpu-switch': '核对显卡切换' } +export const auditActionLabels: Record = { export_diagnostics: '导出诊断包', assistant_grant_desktop: '助手分配桌面', assistant_revoke_desktop: '助手撤销桌面', read_diagnostics: '读取终端诊断', 'batch-start': '批量开机提交', 'batch-shutdown': '批量关机提交', 'batch-reboot': '批量重启提交', reconcile_batch_power: '核对批量电源', 'snapshot-create': '创建快照', 'snapshot-delete': '删除快照', 'snapshot-rollback': '还原快照', reconcile_snapshot: '核对快照', 'configure-hardware': '调整 CPU/内存', 'expand-disk': '磁盘扩容', reconcile_hardware: '核对规格', save_pve_connection: '保存 PVE 接入', create_terminal_policy: '创建终端策略', deploy_terminal_policy: '下发终端策略', terminal_policy_applied: '策略应用成功', terminal_policy_failed: '策略应用失败', update_terminal: '更新终端资料', disable_terminal: '停用终端', reactivate_terminal: '重新激活终端', update_user: '更新用户资料', batch_grant_bindings: '批量分配桌面', batch_revoke_bindings: '批量撤销桌面', create_administrator: '新增管理员', update_administrator: '更新管理员', reset_administrator_password: '重置管理员密码', change_own_password: '修改本人密码', reconcile_clone: '核对克隆', clone: '克隆桌面', register_template: '登记模板', update_template: '更新模板', validate_template: '验证模板', save_pool: '保存桌面池', update_pool_members: '更新池成员', configure_desktop: '配置连接', start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面', save_alert_rule: '保存告警规则', acknowledge_alert: '确认告警', export_audit_logs: '导出操作审计', export_tasks: '导出任务记录', revoke_administrator_session: '撤销管理员登录', enable_mfa: '开启双重验证', disable_mfa: '关闭双重验证', rotate_mfa_recovery: '更新恢复码', 'prepare-desktop': '准备独占显卡桌面', 'reconcile-gpu-switch': '核对显卡切换' } diff --git a/console/src/api/index.ts b/console/src/api/index.ts index dc3577c..e8bab12 100644 --- a/console/src/api/index.ts +++ b/console/src/api/index.ts @@ -37,10 +37,12 @@ export type AdministratorSession = components['schemas']['AdministratorSession'] export type CreateAdministrator = components['schemas']['CreateAdministrator'] export type UpdateAdministrator = components['schemas']['UpdateAdministrator'] export type Desktop = components['schemas']['Desktop'] +export type DesktopReadiness = components['schemas']['DesktopReadiness'] export type ImageTemplate = components['schemas']['ImageTemplate'] export type DesktopPool = components['schemas']['DesktopPool'] export type PoolInput = components['schemas']['PoolInput'] export type TemplateInput = components['schemas']['TemplateInput'] +export type TemplateUpdate = components['schemas']['TemplateUpdate'] export type CloneInput = components['schemas']['CloneInput'] export type Storage = components['schemas']['Storage'] export type ConnectionInput = components['schemas']['ConnectionInput'] @@ -64,6 +66,8 @@ export type BindingInput = components['schemas']['BindingInput'] export type ImportInput = components['schemas']['ImportInput'] export type EnrollmentInput = components['schemas']['EnrollmentInput'] export type DiscoveredVM = components['schemas']['DiscoveredVM'] +export type AssistantPlanResult = components['schemas']['AssistantPlanResult'] +export type AssistantExecuteResult = components['schemas']['AssistantExecuteResult'] export type DesktopAction = components['schemas']['DesktopAction']['action'] export type TerminalAction = components['schemas']['TerminalAction']['action'] let accessToken = '' @@ -91,6 +95,9 @@ const commandHeaders = () => ({ 'Idempotency-Key': crypto.randomUUID() }) const query = { page: 1, pageSize: 100 } export const api = { diagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics', { params: { query: filters } })), + assistantPlan: (text: string) => result(client.POST('/assistant/plan', { body: { text } })), + assistantExecute: (runId: string, key = crypto.randomUUID()) => result(client.POST('/assistant/execute', { params: { header: { 'Idempotency-Key': key } }, body: { runId } })), + assistantRun: (id: string) => result(client.GET('/assistant/runs/{id}', { params: { path: { id } } })), exportDiagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics/export', { params: { query: filters }, parseAs: 'blob' })), collectTerminalLogs: (id: string, key: string) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body: { action: 'collect-logs' } })), gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })), @@ -161,7 +168,7 @@ export const api = { templates: (page = 1) => result(client.GET('/templates', { params: { query: { ...query, page } } })), discoverTemplates: (page = 1) => result(client.GET('/resources/templates', { params: { query: { ...query, page } } })), registerTemplate: (body: TemplateInput) => result(client.POST('/templates', { body })), - updateTemplate: (id: string, enabled: boolean) => result(client.PATCH('/templates/{id}', { params: { path: { id } }, body: { enabled } })), + updateTemplate: (id: string, body: TemplateUpdate) => result(client.PATCH('/templates/{id}', { params: { path: { id } }, body })), validateTemplate: (id: string) => result(client.POST('/templates/{id}/validate', { params: { path: { id } } })), pools: (page = 1) => result(client.GET('/pools', { params: { query: { ...query, page } } })), savePool: (id: string, body: PoolInput) => id ? result(client.PUT('/pools/{id}', { params: { path: { id } }, body })) : result(client.POST('/pools', { body })), @@ -170,6 +177,7 @@ export const api = { checkClone: (body: CloneInput) => result(client.POST('/desktops/clone/check', { body })), cloneDesktop: (body: CloneInput, key: string) => result(client.POST('/desktops/clone', { params: { header: { 'Idempotency-Key': key } }, body })), configureDesktop: (id: string, body: ConnectionInput) => result(client.PUT('/desktops/{id}/connection', { params: { path: { id } }, body })), + checkDesktopReadiness: (id: string) => result(client.POST('/desktops/{id}/readiness/check', { params: { path: { id } } })), sync: () => result(client.POST('/resources/sync', { params: { header: commandHeaders() } })), importDesktop: (body: ImportInput) => result(client.POST('/desktops/import', { body })), desktopAction: (id: string, action: DesktopAction) => result(client.POST('/desktops/{id}/actions', { params: { path: { id }, header: commandHeaders() }, body: { action } })), diff --git a/console/src/api/schema.d.ts b/console/src/api/schema.d.ts index 024aa63..1ae2f00 100644 --- a/console/src/api/schema.d.ts +++ b/console/src/api/schema.d.ts @@ -644,6 +644,54 @@ export interface paths { patch?: never; trace?: never; }; + "/assistant/plan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["assistantPlan"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/assistant/execute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["assistantExecute"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/assistant/runs/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["assistantRun"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/settings": { parameters: { query?: never; @@ -708,6 +756,22 @@ export interface paths { patch?: never; trace?: never; }; + "/gpu-agent/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["ingestGPUHealth"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/resources/vms": { parameters: { query?: never; @@ -900,6 +964,22 @@ export interface paths { patch?: never; trace?: never; }; + "/desktops/{id}/readiness/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["checkDesktopReadiness"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/resources/sync": { parameters: { query?: never; @@ -1270,6 +1350,22 @@ export interface paths { patch?: never; trace?: never; }; + "/terminal/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appendTerminalLogs"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/audit-logs": { parameters: { query?: never; @@ -1569,6 +1665,22 @@ export interface components { poolId?: string | null; setupRequired?: boolean; }; + DesktopReadinessCheck: { + name: string; + /** @enum {string} */ + status: "pass" | "fail" | "unknown"; + message: string; + }; + DesktopReadiness: { + desktopId: string; + /** Format: date-time */ + observedAt: string; + ready: boolean; + powerState: string; + serviceState: string; + setupRequired: boolean; + checks: components["schemas"]["DesktopReadinessCheck"][]; + }; Terminal: { id: string; sn: string; @@ -1670,6 +1782,25 @@ export interface components { driverBinding: string; }; }; + GPUAgentSample: { + address: string; + /** Format: date-time */ + observedAt?: string | null; + temperatureC?: number | null; + utilizationPercent?: number | null; + memoryUsedBytes?: number | null; + memoryTotalBytes?: number | null; + driver?: string; + driverBinding?: string; + }; + GPUAgentReport: { + node: string; + samples: components["schemas"]["GPUAgentSample"][]; + }; + GPUAgentAccepted: { + accepted: number; + node: string; + }; Task: { id: string; kind: string; @@ -1887,6 +2018,69 @@ export interface components { message?: string; diagnostics?: components["schemas"]["DeviceDiagnostics"]; }; + AssistantInput: { + text: string; + }; + AssistantCandidate: { + id: string; + label: string; + kind: string; + }; + AssistantPlan: { + action: string; + /** @enum {string} */ + kind: "action" | "route" | "blocked"; + title: string; + summary: string; + userId: string; + userName: string; + desktopId: string; + desktopName: string; + desktopVmid: number; + missing: string[]; + candidates: components["schemas"]["AssistantCandidate"][]; + risk: string; + confirmationRequired: boolean; + path: string; + reason: string; + }; + AssistantPlanResult: { + runId: string; + status: string; + input: string; + plan: components["schemas"]["AssistantPlan"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + expiresAt: string; + }; + AssistantExecuteInput: { + runId: string; + }; + AssistantExecuteResult: { + runId: string; + status: string; + result: { + userId: string; + desktopId: string; + action: string; + message: string; + }; + }; + AssistantRun: { + runId: string; + input: string; + action: string; + status: string; + plan: components["schemas"]["AssistantPlan"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + expiresAt: string; + /** Format: date-time */ + executedAt: string | null; + result: Record; + }; PveConnectionInput: { url: string; tokenId: string; @@ -2242,6 +2436,7 @@ export interface components { node: string; vmid: number; enabled: boolean; + hardwareEncodingVerified: boolean; /** Format: date-time */ validatedAt: string; }; @@ -2252,9 +2447,11 @@ export interface components { os: "Windows" | "Ubuntu" | "麒麟"; node: string; vmid: number; + hardwareEncodingVerified?: boolean; }; TemplateUpdate: { - enabled: boolean; + enabled?: boolean; + hardwareEncodingVerified?: boolean; }; DesktopPool: { id: string; @@ -2409,6 +2606,8 @@ export interface components { agentVersion: string; kioskState: string; logText: string; + /** @enum {string} */ + source: "snapshot" | "stream"; }; TerminalLogArchivePage: { items: components["schemas"]["TerminalLogArchive"][]; @@ -2419,6 +2618,17 @@ export interface components { observedAt: string; retentionDays: number; }; + TerminalLogEntry: { + /** Format: date-time */ + capturedAt?: string | null; + /** @enum {string} */ + level?: "debug" | "info" | "warn" | "error"; + message: string; + }; + TerminalLogBatch: { + bootId?: string; + entries: components["schemas"]["TerminalLogEntry"][]; + }; GPUExclusiveMember: { desktopId: string; name: string; @@ -7624,6 +7834,321 @@ export interface operations { }; }; }; + assistantPlan: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssistantInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssistantPlanResult"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + assistantExecute: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssistantExecuteInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssistantExecuteResult"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + assistantRun: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssistantRun"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; settings: { parameters: { query?: never; @@ -8037,6 +8562,111 @@ export interface operations { }; }; }; + ingestGPUHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GPUAgentReport"]; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GPUAgentAccepted"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; discoverVMs: { parameters: { query?: { @@ -9521,6 +10151,109 @@ export interface operations { }; }; }; + checkDesktopReadiness: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DesktopReadiness"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; syncResources: { parameters: { query?: never; @@ -12194,6 +12927,111 @@ export interface operations { }; }; }; + appendTerminalLogs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TerminalLogBatch"]; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Accepted"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; audits: { parameters: { query?: { diff --git a/console/src/layouts/ConsoleLayout.vue b/console/src/layouts/ConsoleLayout.vue index 0e65910..f071370 100644 --- a/console/src/layouts/ConsoleLayout.vue +++ b/console/src/layouts/ConsoleLayout.vue @@ -30,7 +30,7 @@ const items = [ { path: '/monitor', title: '监控告警', icon: DataAnalysis, group: 'manage' }, { path: '/templates', title: '镜像模板', icon: Files, group: 'manage' }, { path: '/pools', title: '桌面池', icon: Collection, group: 'manage' }, - { path: '/users', title: '用户与授权', icon: User, group: 'manage' }, + { path: '/users', title: '员工与授权', icon: User, group: 'work' }, { path: '/tasks', title: '任务与审计', icon: List, group: 'manage' }, { path: '/diagnostics', title: '日志与诊断', icon: Files, group: 'manage' }, { path: '/settings', title: '系统设置', icon: Setting, group: 'manage' }, diff --git a/console/src/router/index.ts b/console/src/router/index.ts index cf2f954..737dfc9 100644 --- a/console/src/router/index.ts +++ b/console/src/router/index.ts @@ -21,7 +21,7 @@ export const router = createRouter({ history: createWebHistory(), routes: [ { path: 'sessions', component: () => import('../views/Sessions.vue'), meta: { title: '远程会话' } }, { path: 'terminals', component: () => import('../views/Terminals.vue'), meta: { title: '终端管理' } }, { path: 'terminal-policies', component: () => import('../views/TerminalPolicies.vue'), meta: { title: '终端策略' } }, - { path: 'users', component: () => import('../views/Users.vue'), meta: { title: '用户与授权' } }, + { path: 'users', component: () => import('../views/Users.vue'), meta: { title: '员工与授权' } }, { path: 'tasks', component: () => import('../views/Tasks.vue'), meta: { title: '任务与审计' } }, { path: 'diagnostics', component: () => import('../views/Diagnostics.vue'), meta: { title: '日志与诊断', operateOnly: true } }, { path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } }, diff --git a/console/src/views/CommonPeople.vue b/console/src/views/CommonPeople.vue index 697a5cd..2f34dde 100644 --- a/console/src/views/CommonPeople.vue +++ b/console/src/views/CommonPeople.vue @@ -7,6 +7,8 @@ import { useAction } from '../composables/useAction' import StatusTag from '../components/StatusTag.vue' const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction() const search = ref(''), page = ref(1), creating = ref(false), editing = ref(false), reviewing = ref(false), person = ref(null) +const profileVisible = ref(false), profileId = ref(''), profileUsername = ref('') +const profile = reactive({ displayName: '', enabled: true }) const form = reactive({ username: '', displayName: '' }), selected = ref([]), action = ref<'grant' | 'revoke'>('grant') const filtered = computed(() => data.users.filter(u => `${u.displayName} ${u.username}`.toLowerCase().includes(search.value.toLowerCase()))) const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10)) @@ -28,6 +30,18 @@ async function create() { if (!auth.canAdmin || !form.username.trim() || !form.displayName.trim()) return if (await run(() => api.createUser({ username: form.username.trim(), displayName: form.displayName.trim() }), '员工已添加,请继续分配桌面')) { creating.value = false; form.username = ''; form.displayName = '' } } +function editProfile(user: User) { + profileId.value = user.id + profileUsername.value = user.username + Object.assign(profile, { displayName: user.displayName, enabled: user.enabled }) + profileVisible.value = true +} +async function saveProfile() { + if (!auth.canAdmin || !profile.displayName.trim() || busy.value) return + const existing = data.users.find(user => user.id === profileId.value) + if (existing?.enabled !== profile.enabled && !await confirm(profile.enabled ? '启用员工' : '停用员工', '状态变更会作废该员工未消费的连接票据。停用会阻止后续连接,但保留桌面授权和已有串流。')) return + if (await run(() => api.updateUser(profileId.value, { displayName: profile.displayName.trim(), enabled: profile.enabled }), '员工资料已保存')) profileVisible.value = false +} async function save() { if (!auth.canAdmin || !person.value || !selected.value.length || !reviewing.value || busy.value) return const userId = person.value.id, desktopIds = [...selected.value], operation = action.value @@ -36,9 +50,14 @@ async function save() { + + diff --git a/console/src/views/CommonWorkbench.vue b/console/src/views/CommonWorkbench.vue index 2f11c70..f8dfd9d 100644 --- a/console/src/views/CommonWorkbench.vue +++ b/console/src/views/CommonWorkbench.vue @@ -2,7 +2,7 @@ import { computed, ref } from 'vue' import { usePlatform } from '../stores/platform' import { useAuth } from '../stores/auth' -import { api, errorMessage, type Desktop, type User } from '../api' +import { api, errorMessage, type AssistantPlanResult } from '../api' import { ElMessage } from 'element-plus' import { useAction } from '../composables/useAction' const data = usePlatform(), auth = useAuth(), { busy, confirm } = useAction() @@ -10,42 +10,26 @@ const running = computed(() => data.desktops.filter(d => d.powerState === 'runni const unassigned = computed(() => data.users.filter(u => u.enabled && !u.desktopIds.length).length) const offline = computed(() => data.terminals.filter(t => t.enabled !== false && t.status !== 'disabled' && t.status !== 'online').length) const unresolved = computed(() => data.tasks.filter(t => t.status === 'timeout').length) -const request = ref(''), plan = ref<{ kind: 'grant' | 'route' | 'blocked'; title: string; summary: string; user?: User; desktop?: Desktop; path?: string; reason?: string } | null>(null), executed = ref(false) -const unassignedDesktops = computed(() => data.desktops.filter(d => !data.users.some(u => u.desktopIds.includes(d.id)))) -function generatePlan() { - executed.value = false +const request = ref(''), plan = ref(null), runningPlan = ref(false), executed = ref(false), assistantError = ref('') +async function generatePlan() { const text = request.value.trim() - if (!text) { plan.value = null; return } - if (/分配|授权|安排/.test(text)) { - const userMatches = data.users.filter(u => text.includes(u.displayName) || text.includes(u.username)) - const desktopMatches = data.desktops.filter(d => text.includes(d.name) || text.includes(String(d.vmid))) - if (userMatches.length !== 1) { plan.value = { kind: 'blocked', title: '还需要确认员工', summary: '请写出员工姓名或账号;同名员工需要到“员工与桌面”页面选择准确对象。', reason: userMatches.length ? '匹配到多个员工' : '没有匹配到员工' }; return } - const user = userMatches[0] - if (!user) { plan.value = { kind: 'blocked', title: '还需要确认员工', summary: '请写出员工姓名或账号。', reason: '没有匹配到员工' }; return } - if (desktopMatches.length > 1) { plan.value = { kind: 'blocked', title: '还需要确认桌面', summary: '匹配到多个同名桌面,请写出 VMID,或到“员工与桌面”页面选择准确对象。', reason: '匹配到多个桌面' }; return } - const desktop = (desktopMatches.length === 1 ? desktopMatches[0] : unassignedDesktops.value[0]) - if (!desktop) { plan.value = { kind: 'blocked', title: '暂时无法生成授权方案', summary: '没有找到可分配的已有桌面。请先由运维纳管桌面,或到高级版检查模板和桌面池。', reason: '没有可用桌面' }; return } - if (user.desktopIds.includes(desktop.id)) { plan.value = { kind: 'blocked', title: '员工已经有这台桌面', summary: `${user.displayName} 已拥有“${desktop.name}”的使用权限,不需要重复授权。`, user, desktop, reason: '授权已存在' }; return } - plan.value = { kind: 'grant', title: '分配已有桌面', summary: `为 ${user.displayName} 追加“${desktop.name}”的使用权限。不会新建 VM,也不会自动开机。`, user, desktop } - return - } - if (/打不开|无法连接|连不上|离线/.test(text)) { plan.value = { kind: 'route', title: '进入问题处理', summary: '先检查设备在线、员工关联、桌面授权、电源和连接服务状态。', path: '/help' }; return } - if (/异常|故障|告警|问题/.test(text)) { plan.value = { kind: 'route', title: '查看需要处理的记录', summary: '打开操作记录,查看失败任务、结果未确认项和最近操作。', path: '/records' }; return } - plan.value = { kind: 'blocked', title: '暂不支持这个场景', summary: '当前无模型接入,只支持“给某员工分配一台已有桌面”、问题处理和操作记录。请改用明确的员工姓名与桌面名称。', reason: '固定场景未匹配' } + if (!text || runningPlan.value) return + runningPlan.value = true; assistantError.value = ''; executed.value = false + try { plan.value = await api.assistantPlan(text) } catch (e) { assistantError.value = errorMessage(e) } + finally { runningPlan.value = false } } +function chooseCandidate(candidate: { label: string }) { request.value = `${request.value.trim()} ${candidate.label}`.trim(); void generatePlan() } async function executePlan() { - if (!plan.value || plan.value.kind !== 'grant' || !plan.value.user || !plan.value.desktop || !auth.canAdmin || busy.value) return - if (!await confirm('确认分配桌面', `将为 ${plan.value.user.displayName} 追加“${plan.value.desktop.name}”的使用权限。其他授权保持不变。`)) return - try { - await api.batchBindings({ userIds: [plan.value.user.id], desktopIds: [plan.value.desktop.id], action: 'grant' }) - await data.refresh(); executed.value = true; ElMessage.success('方案已执行,授权已保存') - } catch (e) { ElMessage.error(errorMessage(e)) } + if (!plan.value || plan.value.plan.kind !== 'action' || !auth.canAdmin || busy.value || executed.value) return + if (!await confirm('确认执行方案', plan.value.plan.summary)) return + try { await api.assistantExecute(plan.value.runId); await data.refresh(); executed.value = true; ElMessage.success('方案已执行,操作已保存') } + catch (e) { ElMessage.error(errorMessage(e)); await generatePlan() } } diff --git a/deploy/compose.live.yml b/deploy/compose.live.yml index 603adb4..6689785 100644 --- a/deploy/compose.live.yml +++ b/deploy/compose.live.yml @@ -37,6 +37,7 @@ services: PVE_CA_FILE: /run/secrets/pve-ca.pem VDI_EXCLUSIVE_GPU_NODE: ${VDI_EXCLUSIVE_GPU_NODE:-} VDI_EXCLUSIVE_GPU_VMIDS: ${VDI_EXCLUSIVE_GPU_VMIDS:-} + VDI_GPU_AGENT_TOKEN: ${VDI_GPU_AGENT_TOKEN:-} volumes: - ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro depends_on: diff --git a/docs/cloud-common-assistant-20261113.md b/docs/cloud-common-assistant-20261113.md new file mode 100644 index 0000000..a5592d2 --- /dev/null +++ b/docs/cloud-common-assistant-20261113.md @@ -0,0 +1,30 @@ +# 通用版固定场景助手 + +通用版助手面向非技术用户,使用“自然语言输入 + 受控业务脚本 + 确认执行”的方式工作。当前不依赖 AI 模型;后端先用固定场景解析,后续可以只替换解析层接入模型。 + +## 当前脚本 + +- 分配已有桌面 +- 撤销桌面授权 +- 桌面连接问题处理 +- 设备与桌面状态查询入口 + +分配和撤销会先生成方案,要求员工和桌面唯一匹配;没有明确桌面时展示候选项,不再自动选择第一台空闲桌面。方案有效期 5 分钟,确认执行时后端会重新检查用户、桌面、授权、权限和并发状态。 + +## 后端接口 + +- `POST /api/v1/assistant/plan`:根据一句话生成方案 +- `POST /api/v1/assistant/execute`:确认并执行方案 +- `GET /api/v1/assistant/runs/{id}`:查看方案和执行结果 + +方案记录写入 `assistant_runs`,包含原始输入、结构化意图、状态、有效期、执行结果和操作者。执行沿用授权锁和审计日志;同一个方案重复提交会返回已保存结果,不会重复修改授权。 + +## 安全边界 + +助手只产生允许的业务动作,不能执行任意 Shell、SQL 或 PVE 命令。AI 接入后也只能输出受限结构化意图,最终权限判断和资源变更仍由平台后端完成。 + +## 验证 + +- Go 全量测试通过,包含缺少桌面时展示候选、明确方案执行、状态重新校验和重复确认幂等测试。 +- 前端 typecheck、lint 和生产构建通过。 +- 真实环境已验证:root 账号生成“撤销桌面授权”方案并执行,再生成“分配桌面”方案恢复原授权;重复提交同一方案返回已执行结果,没有重复写入。 diff --git a/docs/cloud-gpu-health-exclusive-logs-20261112.md b/docs/cloud-gpu-health-exclusive-logs-20261112.md index 247a65d..b90ec67 100644 --- a/docs/cloud-gpu-health-exclusive-logs-20261112.md +++ b/docs/cloud-gpu-health-exclusive-logs-20261112.md @@ -2,9 +2,9 @@ ## 已实现 -- `GET /api/v1/resources/gpus?node=...` 在每个 GPU 上返回 `health`:PVE PCI 清单中的驱动字段、驱动绑定状态、来源和采集时间。标准 PVE PCI 接口没有温度、利用率、显存指标时返回 `status=not_collected` 和空值,不把 VM 配置引用当作健康数据。 +- `GET /api/v1/resources/gpus?node=...` 在每个 GPU 上返回 `health`:PVE PCI 清单中的驱动字段、驱动绑定状态、来源和采集时间。标准 PVE PCI 接口没有温度、利用率、显存指标;配置 `VDI_GPU_AGENT_TOKEN` 后,节点采集器通过 `POST /api/v1/gpu-agent/health` 上报近 10 分钟的真实指标,页面才显示 `status=collected`,没有上报时保持 `not_collected`。 - `GET /api/v1/resources/gpu-exclusive?node=...` 提供独占组分配前检查:advisory lock、成员完整性、PVE 实际电源、活跃终端会话、未完成任务、冲突原因、`allocationReady` 和 `releaseReady`。接口只读,不会关机、切换显卡或重放任务。 -- 终端成功回传 `collect-logs` 后,日志写入 `terminal_log_archives`,按终端和时间建立索引,保留 30 天,并通过 `GET /api/v1/terminal-logs` 支持终端、时间范围和关键词检索。沿用诊断脱敏;这是回传快照归档,不是实时日志流。 +- 终端成功回传 `collect-logs` 后,日志写入 `terminal_log_archives`;终端正常运行时可通过设备令牌调用 `POST /api/v1/terminal/logs` 持续上报小批量事件,写入 `terminal_log_entries`。两类记录在 `GET /api/v1/terminal-logs` 统一检索,均按终端和时间建立索引、保留 30 天并执行脱敏。 - 高级版 GPU 页面显示健康状态、驱动绑定、独占资源冲突和释放条件;日志与诊断页面增加持续归档检索区。 ## 真实边界 diff --git a/docs/cloud-gpu-recovery-20261113.md b/docs/cloud-gpu-recovery-20261113.md new file mode 100644 index 0000000..7a375df --- /dev/null +++ b/docs/cloud-gpu-recovery-20261113.md @@ -0,0 +1,24 @@ +# GPU 切换偶发超时的自动核对 + +## 原因 + +2026-11-13 16:02(北京时间),Windows 10(VMID 100)的启动请求已被 PVE 接受,但查询原任务状态时 HTTP 超时。原任务实际以 `exitstatus=OK` 结束;旧逻辑把一次读取失败升级成持续阻塞,需要管理员手动核对。 + +## 行为 + +- PVE 任务状态和实际电源的 GET 查询,对网络暂时性故障及 HTTP 408/429/500/502/503/504 最多读取 3 次,间隔 250/500 毫秒,并遵守调用方取消和超时。权限错误不重试。 +- 电源 POST 不自动重发。拿不到原任务 UPID 时保留阻塞,需要核查,避免重复关机或显卡争用。 +- 后台在没有进行中的 GPU/创建操作时,间隔至少 15 秒选择一条待核对任务;恢复过程中仍持有独占组锁。每次核对最长 60 秒。 +- 已知原任务结束后,检查独占组配置、全部成员电源、员工/终端授权和 Sunshine 响应。只有这些条件全部满足才自动完成为成功,并更新桌面状态、记录系统审计。 +- 任务已结束但准备流程未完成,自动结束为失败并解除该任务的阻塞,用户可以重新选择桌面;后台不会补发后续电源操作。 +- 仍无法确认电源、原任务未结束或发现多台成员同时运行时保持阻塞。最近 24 小时内具有 UPID 的不确定任务继续后台核对,过期任务保留供管理员处理。 +- API 重启后发现的运行中 GPU 任务只恢复观察,不重放命令。尚未提交电源操作的任务结束为可重试失败。 +- 前 5 分钟内,设备查询可自动核对任务时返回 `running`,让现有终端继续轮询。同一终端重新准备同一桌面复用原任务,不重复创建。 + +## 验证方式 + +测试使用独立 Docker PostgreSQL(`vdi-cloud-gpu-recovery-test-db`),不连接业务库;PVE 和 Sunshine 均使用测试 HTTP 服务。覆盖读取失败后恢复、不确定 POST 不重放、实际多 VM 冲突、原任务失败、连接服务未启动、授权撤销、配置变化、API 重启、同一任务复用和有限等待。 + +现场部署仅更新 API,无 VM 电源操作、终端重启或 GPU 重新绑定。回退镜像为 `vdi-cloud-live-api:before-gpu-recovery-20261113`。 + +验证结果:带专用数据库的 `go test ./... -count=1` 和 `go vet ./...` 均通过;GPU 故障注入及重启恢复用例通过。部署后真实 `/healthz` 返回生产模式 `ok`,独占组冲突为空,VM 100 保持运行,101/102 保持关机。临时测试数据库容器已清理。 diff --git a/docs/openapi/platform.v1.json b/docs/openapi/platform.v1.json index 3517e76..1ad3c72 100644 --- a/docs/openapi/platform.v1.json +++ b/docs/openapi/platform.v1.json @@ -6160,6 +6160,384 @@ } } }, + "/assistant/plan": { + "post": { + "operationId": "assistantPlan", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantInput" + } + } + } + }, + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantPlanResult" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/assistant/execute": { + "post": { + "operationId": "assistantExecute", + "security": [ + { + "adminBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantExecuteInput" + } + } + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "maxLength": 100 + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantExecuteResult" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/assistant/runs/{id}": { + "get": { + "operationId": "assistantRun", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantRun" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/settings": { "get": { "operationId": "settings", @@ -6659,6 +7037,129 @@ } } }, + "/gpu-agent/health": { + "post": { + "operationId": "ingestGPUHealth", + "security": [ + { + "gpuAgent": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GPUAgentReport" + } + } + } + }, + "parameters": [], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GPUAgentAccepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/resources/vms": { "get": { "operationId": "discoverVMs", @@ -8525,6 +9026,128 @@ } } }, + "/desktops/{id}/readiness/check": { + "post": { + "operationId": "checkDesktopReadiness", + "security": [ + { + "adminBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopReadiness" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/resources/sync": { "post": { "operationId": "syncResources", @@ -12038,6 +12661,129 @@ } } }, + "/terminal/logs": { + "post": { + "operationId": "appendTerminalLogs", + "security": [ + { + "deviceBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TerminalLogBatch" + } + } + } + }, + "parameters": [], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Request failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/audit-logs": { "get": { "operationId": "audits", @@ -14187,6 +14933,71 @@ ], "additionalProperties": false }, + "DesktopReadinessCheck": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pass", + "fail", + "unknown" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "name", + "status", + "message" + ], + "additionalProperties": false + }, + "DesktopReadiness": { + "type": "object", + "properties": { + "desktopId": { + "type": "string" + }, + "observedAt": { + "type": "string", + "format": "date-time" + }, + "ready": { + "type": "boolean" + }, + "powerState": { + "type": "string" + }, + "serviceState": { + "type": "string" + }, + "setupRequired": { + "type": "boolean" + }, + "checks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DesktopReadinessCheck" + } + } + }, + "required": [ + "desktopId", + "observedAt", + "ready", + "powerState", + "serviceState", + "setupRequired", + "checks" + ], + "additionalProperties": false + }, "Terminal": { "type": "object", "properties": { @@ -14602,6 +15413,82 @@ ], "additionalProperties": false }, + "GPUAgentSample": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "observedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "temperatureC": { + "type": "number", + "nullable": true + }, + "utilizationPercent": { + "type": "number", + "nullable": true + }, + "memoryUsedBytes": { + "type": "integer", + "nullable": true + }, + "memoryTotalBytes": { + "type": "integer", + "nullable": true + }, + "driver": { + "type": "string" + }, + "driverBinding": { + "type": "string" + } + }, + "required": [ + "address" + ], + "additionalProperties": false + }, + "GPUAgentReport": { + "type": "object", + "properties": { + "node": { + "type": "string" + }, + "samples": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GPUAgentSample" + }, + "minItems": 1, + "maxItems": 256 + } + }, + "required": [ + "node", + "samples" + ], + "additionalProperties": false + }, + "GPUAgentAccepted": { + "type": "object", + "properties": { + "accepted": { + "type": "integer" + }, + "node": { + "type": "string" + } + }, + "required": [ + "accepted", + "node" + ], + "additionalProperties": false + }, "Task": { "type": "object", "properties": { @@ -15480,6 +16367,256 @@ ], "additionalProperties": false }, + "AssistantInput": { + "type": "object", + "properties": { + "text": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "AssistantCandidate": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "kind" + ], + "additionalProperties": false + }, + "AssistantPlan": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "action", + "route", + "blocked" + ] + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "desktopId": { + "type": "string" + }, + "desktopName": { + "type": "string" + }, + "desktopVmid": { + "type": "integer" + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantCandidate" + } + }, + "risk": { + "type": "string" + }, + "confirmationRequired": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "action", + "kind", + "title", + "summary", + "userId", + "userName", + "desktopId", + "desktopName", + "desktopVmid", + "missing", + "candidates", + "risk", + "confirmationRequired", + "path", + "reason" + ], + "additionalProperties": false + }, + "AssistantPlanResult": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "input": { + "type": "string" + }, + "plan": { + "$ref": "#/components/schemas/AssistantPlan" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "runId", + "status", + "input", + "plan", + "createdAt", + "expiresAt" + ], + "additionalProperties": false + }, + "AssistantExecuteInput": { + "type": "object", + "properties": { + "runId": { + "type": "string" + } + }, + "required": [ + "runId" + ], + "additionalProperties": false + }, + "AssistantExecuteResult": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "status": { + "type": "string" + }, + "result": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "desktopId": { + "type": "string" + }, + "action": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "userId", + "desktopId", + "action", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "runId", + "status", + "result" + ], + "additionalProperties": false + }, + "AssistantRun": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "input": { + "type": "string" + }, + "action": { + "type": "string" + }, + "status": { + "type": "string" + }, + "plan": { + "$ref": "#/components/schemas/AssistantPlan" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "executedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "result": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "runId", + "input", + "action", + "status", + "plan", + "createdAt", + "expiresAt", + "executedAt", + "result" + ], + "additionalProperties": false + }, "PveConnectionInput": { "type": "object", "properties": { @@ -16910,6 +18047,9 @@ "enabled": { "type": "boolean" }, + "hardwareEncodingVerified": { + "type": "boolean" + }, "validatedAt": { "type": "string", "format": "date-time" @@ -16923,6 +18063,7 @@ "node", "vmid", "enabled", + "hardwareEncodingVerified", "validatedAt" ], "additionalProperties": false @@ -16949,6 +18090,9 @@ }, "vmid": { "type": "integer" + }, + "hardwareEncodingVerified": { + "type": "boolean" } }, "required": [ @@ -16965,11 +18109,12 @@ "properties": { "enabled": { "type": "boolean" + }, + "hardwareEncodingVerified": { + "type": "boolean" } }, - "required": [ - "enabled" - ], + "required": [], "additionalProperties": false }, "DesktopPool": { @@ -17575,6 +18720,13 @@ }, "logText": { "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "snapshot", + "stream" + ] } }, "required": [ @@ -17586,7 +18738,8 @@ "bootId", "agentVersion", "kioskState", - "logText" + "logText", + "source" ], "additionalProperties": false }, @@ -17626,6 +18779,54 @@ ], "additionalProperties": false }, + "TerminalLogEntry": { + "type": "object", + "properties": { + "capturedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "level": { + "type": "string", + "enum": [ + "debug", + "info", + "warn", + "error" + ] + }, + "message": { + "type": "string", + "maxLength": 16000 + } + }, + "required": [ + "message" + ], + "additionalProperties": false + }, + "TerminalLogBatch": { + "type": "object", + "properties": { + "bootId": { + "type": "string", + "maxLength": 120 + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TerminalLogEntry" + }, + "minItems": 1, + "maxItems": 100 + } + }, + "required": [ + "entries" + ], + "additionalProperties": false + }, "GPUExclusiveMember": { "type": "object", "properties": { @@ -18350,6 +19551,12 @@ "type": "http", "scheme": "bearer", "description": "设备激活后获得的独立令牌,不是管理员 JWT" + }, + "gpuAgent": { + "type": "apiKey", + "in": "header", + "name": "X-GPU-Agent-Token", + "description": "仅供受信任计算节点采集器使用的独立令牌" } } } diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index 987341e..b94f59a 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -14,10 +14,11 @@ type Config struct { PVEURL, PVETokenID, PVETokenSecret, PVECAFile string ExclusiveGPUNode string ExclusiveGPUVMIDs []int + GPUAgentToken string } func Load() (Config, error) { - c := Config{Mode: env("VDI_MODE", "production"), Address: env("VDI_LISTEN", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("VDI_JWT_SECRET"), AdminUsername: os.Getenv("VDI_ADMIN_USERNAME"), AdminPassword: os.Getenv("VDI_ADMIN_PASSWORD"), PVEURL: os.Getenv("PVE_URL"), PVETokenID: os.Getenv("PVE_TOKEN_ID"), PVETokenSecret: os.Getenv("PVE_TOKEN_SECRET"), PVECAFile: os.Getenv("PVE_CA_FILE")} + c := Config{Mode: env("VDI_MODE", "production"), Address: env("VDI_LISTEN", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("VDI_JWT_SECRET"), AdminUsername: os.Getenv("VDI_ADMIN_USERNAME"), AdminPassword: os.Getenv("VDI_ADMIN_PASSWORD"), PVEURL: os.Getenv("PVE_URL"), PVETokenID: os.Getenv("PVE_TOKEN_ID"), PVETokenSecret: os.Getenv("PVE_TOKEN_SECRET"), PVECAFile: os.Getenv("PVE_CA_FILE"), GPUAgentToken: os.Getenv("VDI_GPU_AGENT_TOKEN")} c.ExclusiveGPUNode = strings.TrimSpace(os.Getenv("VDI_EXCLUSIVE_GPU_NODE")) ids := strings.TrimSpace(os.Getenv("VDI_EXCLUSIVE_GPU_VMIDS")) if (c.ExclusiveGPUNode == "") != (ids == "") { diff --git a/platform/internal/model/model.go b/platform/internal/model/model.go index e190639..d7743e6 100644 --- a/platform/internal/model/model.go +++ b/platform/internal/model/model.go @@ -31,15 +31,16 @@ type Desktop struct { } type ImageTemplate struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Version string `json:"version" db:"version"` - OS string `json:"os" db:"os"` - Node string `json:"node" db:"node"` - VMID int `json:"vmid" db:"vmid"` - Digest string `json:"-" db:"digest"` - Enabled bool `json:"enabled" db:"enabled"` - ValidatedAt time.Time `json:"validatedAt" db:"validated_at"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Version string `json:"version" db:"version"` + OS string `json:"os" db:"os"` + Node string `json:"node" db:"node"` + VMID int `json:"vmid" db:"vmid"` + Digest string `json:"-" db:"digest"` + Enabled bool `json:"enabled" db:"enabled"` + HardwareEncodingVerified bool `json:"hardwareEncodingVerified" db:"hardware_encoding_verified"` + ValidatedAt time.Time `json:"validatedAt" db:"validated_at"` } type DesktopPool struct { ID string `json:"id" db:"id"` diff --git a/platform/internal/server/admin.go b/platform/internal/server/admin.go index bb405b7..9936dc0 100644 --- a/platform/internal/server/admin.go +++ b/platform/internal/server/admin.go @@ -43,7 +43,7 @@ func (s *Server) settings(c *gin.Context) { if pve != nil { provider = "proxmox" } - c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": pve != nil, "terminalTransport": "https-poll", "capabilities": []string{"diagnostics-center", "admin-mfa", "administrator-sessions", "task-history", "audit-query-export", "terminal-metric-alerts", "terminal-system-metrics", "gpu-inventory", "desktop-batches", "desktop-snapshots", "pve-connection", "desktop-hardware", "login-rate-limit", "csv-imports", "session-history", "monitor-alerts", "administrators", "password", "auth", "resources", "desktops", "templates", "pools", "clone", "desktop-connection", "terminals", "terminal-lifecycle", "terminal-policies", "users", "batch-bindings", "bindings", "tasks", "audit"}}) + c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": pve != nil, "terminalTransport": "https-poll", "capabilities": []string{"common-assistant", "diagnostics-center", "admin-mfa", "administrator-sessions", "task-history", "audit-query-export", "terminal-metric-alerts", "terminal-system-metrics", "gpu-inventory", "gpu-health-agent", "desktop-batches", "desktop-snapshots", "pve-connection", "desktop-hardware", "login-rate-limit", "csv-imports", "session-history", "monitor-alerts", "administrators", "password", "auth", "resources", "desktops", "templates", "pools", "clone", "desktop-connection", "terminals", "terminal-lifecycle", "terminal-policies", "users", "batch-bindings", "bindings", "tasks", "audit"}}) } func (s *Server) resources(c *gin.Context) { items := []model.Resource{} @@ -514,6 +514,7 @@ func (s *Server) RunWorker(ctx context.Context) { workerCtx, stop := context.WithCancel(ctx) provisionDone := make(chan struct{}, 1) provisionBusy := false + var nextGPUCheck time.Time defer func() { stop() if provisionBusy { @@ -532,7 +533,8 @@ func (s *Server) RunWorker(ctx context.Context) { if err = conn.PingContext(ctx); err != nil { return } - // Unknown outcomes are never automatically retried after a worker restart. + // Unknown commands are never replayed. Known GPU tasks are observed + // separately below so a transient PVE read failure can recover. var expired []model.Task if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND ((kind NOT IN ('provision','hardware','snapshot','batch-desktop','gpu-prepare') AND created_at= 20 { + break + } + plan.Candidates = append(plan.Candidates, assistantCandidate{ID: user.ID, Label: user.DisplayName + " · " + user.Username, Kind: "user"}) + } + } + plan.Title, plan.Summary, plan.Reason = "需要确认员工", "请写出员工姓名或账号;如果有同名员工,请选择准确对象。", "没有唯一匹配到员工" + } else { + user := userMatches[0] + desktopMatches := []model.Desktop{} + for _, desktop := range desktops { + if assistantTextMatch(text, desktop.Name) || strings.Contains(text, strings.TrimSpace(stringID(desktop.VMID))) { + desktopMatches = append(desktopMatches, desktop) + } + } + if len(desktopMatches) != 1 { + plan.Missing = append(plan.Missing, "desktop") + for _, desktop := range desktopMatches { + plan.Candidates = append(plan.Candidates, assistantCandidate{ID: desktop.ID, Label: desktop.Name + " · VM " + stringID(desktop.VMID), Kind: "desktop"}) + } + if len(desktopMatches) == 0 { + for i, desktop := range desktops { + if i >= 20 { + break + } + plan.Candidates = append(plan.Candidates, assistantCandidate{ID: desktop.ID, Label: desktop.Name + " · VM " + stringID(desktop.VMID), Kind: "desktop"}) + } + } + plan.Title, plan.Summary, plan.Reason = "需要确认桌面", "请写出桌面名称或 VMID,再继续生成方案。", "没有唯一匹配到桌面" + } else { + desktop := desktopMatches[0] + plan.UserID, plan.UserName, plan.DesktopID, plan.DesktopName, plan.DesktopVMID = user.ID, user.DisplayName, desktop.ID, desktop.Name, desktop.VMID + plan.Action, plan.Kind, plan.Risk, plan.ConfirmationRequired = map[bool]string{true: "grant_desktop", false: "revoke_desktop"}[grant], "action", "medium", true + if grant { + plan.Title, plan.Summary = "分配已有桌面", "为 "+user.DisplayName+" 追加“"+desktop.Name+"”使用权限。不会创建 VM、不会自动开机。" + var exists bool + if err := s.DB.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil { + s.dbError(c, err) + return + } + if exists { + plan.Kind, plan.Reason, plan.Summary = "blocked", "授权已经存在", user.DisplayName+" 已经拥有“"+desktop.Name+"”的使用权限。" + } + } else { + plan.Title, plan.Summary = "撤销桌面授权", "撤销 "+user.DisplayName+" 对“"+desktop.Name+"”的使用权限,不会关机或中断已建立的连接。" + var exists bool + if err := s.DB.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil { + s.dbError(c, err) + return + } + if !exists { + plan.Kind, plan.Reason, plan.Summary = "blocked", "授权不存在", user.DisplayName+" 当前没有“"+desktop.Name+"”的使用权限。" + } + } + } + } + } + planJSON, _ := json.Marshal(plan) + id := store.ID("assist_") + status := "planned" + if plan.Kind == "blocked" { + status = "blocked" + } + expires := time.Now().UTC().Add(5 * time.Minute) + fingerprint := sha256.Sum256(append([]byte(text+"|"+c.MustGet("admin").(model.Administrator).Username+"|"), planJSON...)) + if _, err := s.DB.ExecContext(c.Request.Context(), `INSERT INTO assistant_runs(id,actor,input_text,action,status,plan,fingerprint,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, id, s.admin(c).Username, text, plan.Action, status, string(planJSON), hex.EncodeToString(fingerprint[:]), expires); err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"runId": id, "status": status, "input": text, "plan": plan, "createdAt": time.Now().UTC(), "expiresAt": expires}) +} + +func stringID(value int) string { return strconv.Itoa(value) } + +func (s *Server) assistantExecute(c *gin.Context) { + var in struct { + RunID string `json:"runId" binding:"required,max=100"` + } + if !s.decode(c, &in) { + return + } + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + var row struct { + ID string `db:"id"` + Input string `db:"input_text"` + Action string `db:"action"` + Status string `db:"status"` + Plan string `db:"plan"` + ExpiresAt time.Time `db:"expires_at"` + Result []byte `db:"result"` + } + if err = tx.GetContext(ctx, &row, "SELECT id,input_text,action,status,plan,expires_at,result FROM assistant_runs WHERE id=$1 AND actor=$2 FOR UPDATE", in.RunID, s.admin(c).Username); err != nil { + s.dbError(c, err) + return + } + if row.Status == "executed" { + result := map[string]any{} + _ = json.Unmarshal(row.Result, &result) + c.JSON(200, gin.H{"runId": in.RunID, "status": "executed", "result": result, "idempotent": true}) + return + } + if row.Status != "planned" { + s.fail(c, 409, "assistant_not_executable", "方案已经执行、阻止或失效") + return + } + if time.Now().UTC().After(row.ExpiresAt) { + _, _ = tx.ExecContext(ctx, "UPDATE assistant_runs SET status='expired' WHERE id=$1", in.RunID) + s.fail(c, 409, "assistant_plan_expired", "方案已过期,请重新生成") + return + } + var plan assistantPlan + if json.Unmarshal([]byte(row.Plan), &plan) != nil || plan.Kind != "action" || (plan.Action != "grant_desktop" && plan.Action != "revoke_desktop") { + s.fail(c, 409, "assistant_invalid_plan", "方案内容无效,请重新生成") + return + } + if !s.lockAuthorization(c, tx) { + return + } + var user model.User + var desktop model.Desktop + if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", plan.UserID); err != nil { + s.fail(c, 409, "assistant_state_changed", "员工已不存在或状态已变化,请重新生成方案") + return + } + if err = tx.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", plan.DesktopID); err != nil { + s.fail(c, 409, "assistant_state_changed", "桌面已不存在或状态已变化,请重新生成方案") + return + } + var exists bool + if err = tx.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM bindings WHERE user_id=$1 AND desktop_id=$2)", user.ID, desktop.ID); err != nil { + s.dbError(c, err) + return + } + if plan.Action == "grant" || plan.Action == "grant_desktop" { + if exists { + s.fail(c, 409, "assistant_state_changed", "授权已经被其他操作创建,请重新生成方案") + return + } + _, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) VALUES($1,$2)", user.ID, desktop.ID) + } else { + if !exists { + s.fail(c, 409, "assistant_state_changed", "授权已经被其他操作撤销,请重新生成方案") + return + } + _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1 AND desktop_id=$2", user.ID, desktop.ID) + } + if err == nil { + _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", user.ID) + } + if err == nil { + err = accountAudit(ctx, tx, s.admin(c).Username, "assistant_"+plan.Action, user.ID, c.GetString("requestId")) + } + if err != nil { + s.dbError(c, err) + return + } + result := map[string]any{"userId": user.ID, "desktopId": desktop.ID, "action": plan.Action, "message": "操作已保存"} + encoded, _ := json.Marshal(result) + if _, err = tx.ExecContext(ctx, "UPDATE assistant_runs SET status='executed',executed_at=now(),result=$1 WHERE id=$2", string(encoded), in.RunID); err != nil { + s.dbError(c, err) + return + } + if err = tx.Commit(); err != nil { + s.dbError(c, err) + return + } + c.JSON(200, gin.H{"runId": in.RunID, "status": "executed", "result": result}) +} + +func (s *Server) assistantRun(c *gin.Context) { + var row struct { + ID string `db:"id"` + Input string `db:"input_text"` + Action string `db:"action"` + Status string `db:"status"` + Plan string `db:"plan"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + ExecutedAt *time.Time `db:"executed_at"` + Result []byte `db:"result"` + } + query := "SELECT id,input_text,action,status,plan,created_at,expires_at,executed_at,result FROM assistant_runs WHERE id=$1" + args := []any{c.Param("id")} + if s.admin(c).Role != "super_admin" { + query += " AND actor=$2" + args = append(args, s.admin(c).Username) + } + if err := s.DB.GetContext(c.Request.Context(), &row, query, args...); err != nil { + s.dbError(c, err) + return + } + var plan assistantPlan + _ = json.Unmarshal([]byte(row.Plan), &plan) + result := map[string]any{} + _ = json.Unmarshal(row.Result, &result) + c.JSON(200, gin.H{"runId": row.ID, "input": row.Input, "action": row.Action, "status": row.Status, "plan": plan, "createdAt": row.CreatedAt, "expiresAt": row.ExpiresAt, "executedAt": row.ExecutedAt, "result": result}) +} diff --git a/platform/internal/server/assistant_test.go b/platform/internal/server/assistant_test.go new file mode 100644 index 0000000..5f43d4e --- /dev/null +++ b/platform/internal/server/assistant_test.go @@ -0,0 +1,44 @@ +package server + +import "testing" + +func TestAssistantPlanRequiresExplicitDesktopAndExecutesWithRecheck(t *testing.T) { + f := newFixtureWorker(t, false) + admin := f.login("admin") + code, out := f.request("POST", "/assistant/plan", admin, map[string]any{"text": "给王工分配一台桌面"}, "") + expect(t, 200, code, out) + if out["status"] != "blocked" { + t.Fatalf("unexpected plan status: %#v", out) + } + plan := out["plan"].(map[string]any) + if plan["kind"] != "blocked" || plan["reason"] != "没有唯一匹配到桌面" { + t.Fatalf("assistant selected a desktop without confirmation: %#v", plan) + } + code, out = f.request("POST", "/assistant/plan", admin, map[string]any{"text": "给王工分配国产办公桌面"}, "") + expect(t, 200, code, out) + if out["status"] != "planned" || out["plan"].(map[string]any)["kind"] != "action" { + t.Fatalf("unexpected executable plan: %#v", out) + } + runID := out["runId"].(string) + code, out = f.request("POST", "/assistant/execute", admin, map[string]any{"runId": runID}, "assistant-test-1") + expect(t, 200, code, out) + var exists bool + if err := f.db.Get(&exists, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id JOIN desktops d ON d.id=b.desktop_id WHERE u.display_name='王工' AND d.name='国产办公桌面')`); err != nil || !exists { + t.Fatalf("assistant did not create binding: %v", err) + } + code, out = f.request("POST", "/assistant/execute", admin, map[string]any{"runId": runID}, "assistant-test-2") + if code != 200 || out["idempotent"] != true { + t.Fatalf("assistant execution was not idempotent: %d %#v", code, out) + } +} + +func TestAssistantPlanBlocksAmbiguousUser(t *testing.T) { + f := newFixtureWorker(t, false) + admin := f.login("admin") + code, out := f.request("POST", "/assistant/plan", admin, map[string]any{"text": "请分配研发办公桌面"}, "") + expect(t, 200, code, out) + plan := out["plan"].(map[string]any) + if plan["kind"] != "blocked" || plan["reason"] != "没有唯一匹配到员工" { + t.Fatalf("missing employee was not blocked: %#v", plan) + } +} diff --git a/platform/internal/server/catalog.go b/platform/internal/server/catalog.go index f464d37..47596b9 100644 --- a/platform/internal/server/catalog.go +++ b/platform/internal/server/catalog.go @@ -48,11 +48,12 @@ func (s *Server) templateList(c *gin.Context) { func (s *Server) registerTemplate(c *gin.Context) { pve := s.currentPVE() var in struct { - Name string `json:"name" binding:"required,max=80"` - Version string `json:"version" binding:"required,max=40"` - OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"` - Node string `json:"node" binding:"required,max=100"` - VMID int `json:"vmid" binding:"min=100,max=999999999"` + Name string `json:"name" binding:"required,max=80"` + Version string `json:"version" binding:"required,max=40"` + OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"` + Node string `json:"node" binding:"required,max=100"` + VMID int `json:"vmid" binding:"min=100,max=999999999"` + HardwareEncodingVerified bool `json:"hardwareEncodingVerified"` } if !s.decode(c, &in) { return @@ -82,7 +83,7 @@ func (s *Server) registerTemplate(c *gin.Context) { return } var item model.ImageTemplate - err = s.DB.GetContext(c.Request.Context(), &item, `INSERT INTO image_templates(id,name,version,os,node,vmid,digest) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`, store.ID("tpl_"), in.Name, in.Version, in.OS, in.Node, in.VMID, digest) + err = s.DB.GetContext(c.Request.Context(), &item, `INSERT INTO image_templates(id,name,version,os,node,vmid,digest,hardware_encoding_verified) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, store.ID("tpl_"), in.Name, in.Version, in.OS, in.Node, in.VMID, digest, in.HardwareEncodingVerified) if conflict(err) { s.fail(c, 409, "template_conflict", "该模板或名称版本已经登记") return @@ -96,13 +97,18 @@ func (s *Server) registerTemplate(c *gin.Context) { } func (s *Server) updateTemplate(c *gin.Context) { var in struct { - Enabled *bool `json:"enabled" binding:"required"` + Enabled *bool `json:"enabled"` + HardwareEncodingVerified *bool `json:"hardwareEncodingVerified"` } if !s.decode(c, &in) { return } + if in.Enabled == nil && in.HardwareEncodingVerified == nil { + s.fail(c, 400, "invalid_request", "至少提供一个模板状态字段") + return + } var item model.ImageTemplate - if err := s.DB.GetContext(c.Request.Context(), &item, "UPDATE image_templates SET enabled=$1 WHERE id=$2 RETURNING *", *in.Enabled, c.Param("id")); err != nil { + if err := s.DB.GetContext(c.Request.Context(), &item, `UPDATE image_templates SET enabled=COALESCE($1,enabled), hardware_encoding_verified=COALESCE($2,hardware_encoding_verified) WHERE id=$3 RETURNING *`, in.Enabled, in.HardwareEncodingVerified, c.Param("id")); err != nil { s.dbError(c, err) return } @@ -303,6 +309,9 @@ func (s *Server) clonePreflight(ctx context.Context, in cloneInput) (model.Image if err := s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1 AND enabled", in.TemplateID); err != nil { return template, errors.New("模板不存在或已停用") } + if !template.HardwareEncodingVerified { + return template, errors.New("模板尚未确认硬件编码,请先在镜像模板中完成硬件编码验收") + } config, err := pve.VMConfig(ctx, template.Node, template.VMID) if err != nil { return template, errors.New("无法读取源模板配置") diff --git a/platform/internal/server/catalog_test.go b/platform/internal/server/catalog_test.go index f306e4a..4dc4dec 100644 --- a/platform/internal/server/catalog_test.go +++ b/platform/internal/server/catalog_test.go @@ -97,7 +97,7 @@ func fakeCatalogPVE(t *testing.T, f *fixture, outcome string) *catalogPVE { } func registerCatalog(t *testing.T, f *fixture, admin string) (string, string) { t.Helper() - status, out := f.request("POST", "/templates", admin, map[string]any{"name": "Ubuntu base", "version": "1.0", "os": "Ubuntu", "node": "node-a", "vmid": 8000}, "") + status, out := f.request("POST", "/templates", admin, map[string]any{"name": "Ubuntu base", "version": "1.0", "os": "Ubuntu", "node": "node-a", "vmid": 8000, "hardwareEncodingVerified": true}, "") expect(t, 201, status, out) tpl := out["id"].(string) status, out = f.request("POST", "/pools", admin, map[string]any{"name": "Engineering", "type": "dedicated", "maxCount": 1, "templateId": tpl, "enabled": true}, "") @@ -218,6 +218,36 @@ func TestClonePreflightAndCatalogRBAC(t *testing.T) { status, out = f.request("POST", "/desktops/clone", admin, body, "disabled") expect(t, 409, status, out) } + +func TestTemplateValidationRequiresHardwareReacceptanceAfterSourceChange(t *testing.T) { + f := newFixture(t) + p := fakeCatalogPVE(t, f, "success") + admin := f.login("admin") + tpl, _ := registerCatalog(t, f, admin) + + status, out := f.request("POST", "/templates/"+tpl+"/validate", admin, nil, "") + expect(t, 200, status, out) + if out["hardwareEncodingVerified"] != true { + t.Fatal("unchanged template lost hardware encoding acceptance", out) + } + + p.changed.Store(true) + status, out = f.request("POST", "/templates/"+tpl+"/validate", admin, nil, "") + expect(t, 200, status, out) + var digest string + if err := f.db.Get(&digest, "SELECT digest FROM image_templates WHERE id=$1", tpl); err != nil { + t.Fatal(err) + } + if out["hardwareEncodingVerified"] != false || digest != "v2" { + t.Fatal("changed template remained accepted", out) + } + + status, out = f.request("POST", "/desktops/clone/check", admin, cloneBody(tpl, ""), "") + expect(t, 200, status, out) + if out["available"] != false { + t.Fatal("unverified changed template passed clone preflight", out) + } +} func TestAmbiguousAndFailedCloneRetainPoolCapacity(t *testing.T) { for _, outcome := range []string{"ambiguous", "failed"} { t.Run(outcome, func(t *testing.T) { diff --git a/platform/internal/server/desktop_readiness.go b/platform/internal/server/desktop_readiness.go new file mode 100644 index 0000000..5a520ed --- /dev/null +++ b/platform/internal/server/desktop_readiness.go @@ -0,0 +1,121 @@ +package server + +import ( + "context" + "database/sql" + "net" + "strings" + "time" + + "github.com/gin-gonic/gin" + "vdi-cloud/platform/internal/model" +) + +type desktopReadinessCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` +} + +type desktopReadiness struct { + DesktopID string `json:"desktopId"` + ObservedAt time.Time `json:"observedAt"` + Ready bool `json:"ready"` + PowerState string `json:"powerState"` + ServiceState string `json:"serviceState"` + SetupRequired bool `json:"setupRequired"` + Checks []desktopReadinessCheck `json:"checks"` +} + +// checkDesktopReadiness verifies the parts of the provisioning contract that +// the platform can observe without logging into the guest OS. It never starts +// or stops a VM and never changes guest configuration. +func (s *Server) checkDesktopReadiness(c *gin.Context) { + ctx := c.Request.Context() + var desktop model.Desktop + if err := s.DB.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil { + if err == sql.ErrNoRows { + s.fail(c, 404, "desktop_not_found", "桌面不存在") + } else { + s.dbError(c, err) + } + return + } + + result := desktopReadiness{ + DesktopID: desktop.ID, ObservedAt: time.Now().UTC(), Ready: false, + PowerState: desktop.PowerState, ServiceState: desktop.ServiceState, + SetupRequired: desktop.SetupRequired, Checks: []desktopReadinessCheck{}, + } + add := func(name, status, message string) { + result.Checks = append(result.Checks, desktopReadinessCheck{Name: name, Status: status, Message: message}) + } + + if desktop.Provider != "proxmox" { + add("provider", "fail", "桌面不是 Proxmox 资源,无法执行真实连接探测") + c.JSON(200, result) + return + } + if desktop.Endpoint == "" { + add("endpoint", "fail", "尚未配置桌面 IP 或主机名") + } else { + add("endpoint", "pass", "已配置桌面连接地址") + } + if desktop.SetupRequired { + add("initialization", "fail", "尚未确认系统身份、硬件编码、Sunshine 和终端配对") + } else { + add("initialization", "pass", "已完成平台初始化确认") + } + + pve := s.currentPVE() + if pve == nil { + add("power", "unknown", "尚未配置 Proxmox,无法读取真实电源状态") + } else { + power, err := pve.Power(ctx, desktop.Node, desktop.VMID) + if err != nil { + result.PowerState = "unknown" + add("power", "unknown", "无法读取 Proxmox 实际电源状态") + } else { + result.PowerState = power + if power == "running" { + add("power", "pass", "虚拟机正在运行") + } else { + add("power", "fail", "虚拟机未运行,请先开机") + } + } + } + + serviceReady := false + if desktop.Endpoint != "" && result.PowerState == "running" { + serviceReady = sunshineReady(ctx, desktop.Endpoint) + if serviceReady { + result.ServiceState = "ready" + add("sunshine", "pass", "Sunshine serverinfo 已响应") + } else { + result.ServiceState = "unknown" + add("sunshine", "fail", "Sunshine 未响应,请检查服务、网络和硬件编码配置") + } + } else { + add("sunshine", "unknown", "虚拟机未运行或尚未配置连接地址,暂不探测 Sunshine") + } + + result.Ready = !desktop.SetupRequired && result.PowerState == "running" && serviceReady + if _, err := s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state=$2,updated_at=now() WHERE id=$3", result.PowerState, result.ServiceState, desktop.ID); err != nil { + s.dbError(c, err) + return + } + c.JSON(200, result) +} + +// sunshineReadyAt is split out so the real probe remains fixed to Sunshine's +// serverinfo port while tests can use an isolated local listener. +func sunshineReadyAt(ctx context.Context, endpoint, port string) bool { + host := endpoint + if parsed, _, err := net.SplitHostPort(endpoint); err == nil { + host = parsed + } + if strings.ContainsAny(host, "/\\@?#") || host == "" { + return false + } + return sunshineHTTPProbe(ctx, host, port) +} diff --git a/platform/internal/server/desktop_readiness_test.go b/platform/internal/server/desktop_readiness_test.go new file mode 100644 index 0000000..97c6211 --- /dev/null +++ b/platform/internal/server/desktop_readiness_test.go @@ -0,0 +1,38 @@ +package server + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestSunshineReadyAt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/serverinfo" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(`desktop-test`)) + })) + t.Cleanup(server.Close) + host, port, ok := splitTestEndpoint(t, server.URL) + if !ok || !sunshineReadyAt(context.Background(), host, port) { + t.Fatal("expected Sunshine probe to pass") + } + if sunshineReadyAt(context.Background(), host, "1") { + t.Fatal("unexpected Sunshine probe success on wrong port") + } +} + +func splitTestEndpoint(t *testing.T, raw string) (string, string, bool) { + t.Helper() + parsed, err := url.Parse(raw) + if err != nil { + return "", "", false + } + host, port, err := net.SplitHostPort(parsed.Host) + return host, port, err == nil && host != "" && port != "" +} diff --git a/platform/internal/server/diagnostics.go b/platform/internal/server/diagnostics.go index 5a3e8ac..8769745 100644 --- a/platform/internal/server/diagnostics.go +++ b/platform/internal/server/diagnostics.go @@ -63,6 +63,7 @@ type terminalLogArchive struct { AgentVersion string `json:"agentVersion" db:"agent_version"` KioskState string `json:"kioskState" db:"kiosk_state"` LogText string `json:"logText" db:"log_text"` + Source string `json:"source" db:"source"` } type terminalLogArchivePage struct { Items []terminalLogArchive `json:"items"` @@ -87,13 +88,19 @@ func (s *Server) terminalLogArchives(c *gin.Context) { page, size := pageParams(c) args := []any{from, to, terminalID, keyword} var total int - if err := s.DB.GetContext(c.Request.Context(), &total, `SELECT count(*) FROM terminal_log_archives WHERE captured_at>=$1 AND captured_at<=$2 AND ($3='' OR terminal_id=$3) AND ($4='' OR log_text ILIKE '%'||$4||'%')`, args...); err != nil { + if err := s.DB.GetContext(c.Request.Context(), &total, `SELECT count(*) FROM ( + SELECT captured_at,terminal_id,log_text FROM terminal_log_archives + UNION ALL SELECT captured_at,terminal_id,message FROM terminal_log_entries + ) logs WHERE captured_at>=$1 AND captured_at<=$2 AND ($3='' OR terminal_id=$3) AND ($4='' OR log_text ILIKE '%'||$4||'%')`, args...); err != nil { s.dbError(c, err) return } items := []terminalLogArchive{} args = append(args, size, (page-1)*size) - if err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT a.id,a.task_id,a.terminal_id,t.name terminal_name,a.captured_at,a.boot_id,a.agent_version,a.kiosk_state,a.log_text FROM terminal_log_archives a JOIN terminals t ON t.id=a.terminal_id WHERE a.captured_at>=$1 AND a.captured_at<=$2 AND ($3='' OR a.terminal_id=$3) AND ($4='' OR a.log_text ILIKE '%'||$4||'%') ORDER BY a.captured_at DESC,a.id DESC LIMIT $5 OFFSET $6`, args...); err != nil { + if err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT logs.id,logs.task_id,logs.terminal_id,t.name terminal_name,logs.captured_at,logs.boot_id,logs.agent_version,logs.kiosk_state,logs.log_text,logs.source FROM ( + SELECT a.id,a.task_id,a.terminal_id,a.captured_at,a.boot_id,a.agent_version,a.kiosk_state,a.log_text,'snapshot' source FROM terminal_log_archives a + UNION ALL SELECT e.id,'' task_id,e.terminal_id,e.captured_at,e.boot_id,'' agent_version,e.level kiosk_state,e.message log_text,'stream' source FROM terminal_log_entries e + ) logs JOIN terminals t ON t.id=logs.terminal_id WHERE logs.captured_at>=$1 AND logs.captured_at<=$2 AND ($3='' OR logs.terminal_id=$3) AND ($4='' OR logs.log_text ILIKE '%'||$4||'%') ORDER BY logs.captured_at DESC,logs.id DESC LIMIT $5 OFFSET $6`, args...); err != nil { s.dbError(c, err) return } diff --git a/platform/internal/server/exclusive_gpu.go b/platform/internal/server/exclusive_gpu.go index 147c5fe..9e7c5c1 100644 --- a/platform/internal/server/exclusive_gpu.go +++ b/platform/internal/server/exclusive_gpu.go @@ -291,8 +291,12 @@ func (s *Server) prepareGPUDesktop(c *gin.Context) { var previous model.Task err = tx.GetContext(ctx, &previous, `SELECT * FROM tasks WHERE kind='gpu-prepare' AND status IN ('queued','running','timeout') ORDER BY created_at DESC LIMIT 1`) if err == nil { - if previous.Actor == "device:"+terminal && previous.TargetID == d.ID && previous.Status != "timeout" { - s.deviceJSON(c, 202, gin.H{"taskId": previous.ID, "status": previous.Status, "message": previous.Message}) + if previous.Actor == "device:"+terminal && previous.TargetID == d.ID && (previous.Status != "timeout" || s.gpuRecoveryPending(previous)) { + status := previous.Status + if status == "timeout" { + status = "running" + } + s.deviceJSON(c, 202, gin.H{"taskId": previous.ID, "status": status, "message": previous.Message}) return } s.fail(c, 409, "gpu_switch_busy", "已有显卡切换任务或未确认结果,请稍后重试或联系管理员") @@ -343,7 +347,11 @@ func (s *Server) gpuPrepareStatus(c *gin.Context) { s.dbError(c, err) return } - s.deviceJSON(c, 200, gin.H{"taskId": task.ID, "status": task.Status, "message": task.Message}) + status := task.Status + if s.gpuRecoveryPending(task) { + status = "running" + } + s.deviceJSON(c, 200, gin.H{"taskId": task.ID, "status": status, "message": task.Message}) } func (s *Server) saveGPUStage(ctx context.Context, t model.Task, state gpuPrepareState, message string) error { @@ -359,15 +367,11 @@ func (s *Server) saveGPUStage(ctx context.Context, t model.Task, state gpuPrepar } func sunshineReady(ctx context.Context, endpoint string) bool { - host := endpoint - if parsed, _, err := net.SplitHostPort(endpoint); err == nil { - host = parsed - } - // Platform endpoints are hostnames/IPs, never arbitrary URLs or paths. - if strings.ContainsAny(host, "/\\@?#") || host == "" { - return false - } - req, err := http.NewRequestWithContext(ctx, "GET", "http://"+net.JoinHostPort(host, "47989")+"/serverinfo", nil) + return sunshineReadyAt(ctx, endpoint, "47989") +} + +func sunshineHTTPProbe(ctx context.Context, host, port string) bool { + req, err := http.NewRequestWithContext(ctx, "GET", "http://"+net.JoinHostPort(host, port)+"/serverinfo", nil) if err != nil { return false } @@ -532,6 +536,7 @@ func (s *Server) executeGPUPrepare(parent context.Context, t model.Task) { expected = "running" } if e != nil || power != expected { + uncertain = true err = errors.New("final GPU ownership check failed") break } @@ -546,9 +551,12 @@ func (s *Server) executeGPUPrepare(parent context.Context, t model.Task) { log.Printf("GPU prepare task=%s phase=%s error=%v", t.ID, state.Phase, err) status = "failed" message = "桌面准备失败,请检查桌面授权、占用状态及 PVE" - if uncertain { + if uncertain || (state.Phase == "service-waiting" && s.knownGPURecovery(state)) { status = "timeout" - message = "电源操作或服务状态尚未确认,已阻止再次切换;请管理员核对 PVE 原任务" + message = "电源操作尚未确认,平台正在自动核对原任务;不会重复发送电源命令" + if state.UPID == "" { + message = "未收到 PVE 原任务编号,请管理员核查;不会重复发送电源命令" + } } } finalCtx, finish := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/platform/internal/server/exclusive_gpu_test.go b/platform/internal/server/exclusive_gpu_test.go index 23163cf..b349f59 100644 --- a/platform/internal/server/exclusive_gpu_test.go +++ b/platform/internal/server/exclusive_gpu_test.go @@ -17,11 +17,12 @@ import ( ) type exclusiveTestPVE struct { - mu sync.Mutex - power map[int]string - actions []string - mode string - confirmed bool + mu sync.Mutex + power map[int]string + actions []string + mode string + confirmed bool + readFailures int } func exclusiveFixture(t *testing.T, mode string) (*fixture, *exclusiveTestPVE) { @@ -49,7 +50,10 @@ func exclusiveFixture(t *testing.T, mode string) (*fixture, *exclusiveTestPVE) { var id int switch { case strings.Contains(path, "/tasks/"): - if p.mode == "poll-failure" { + if p.mode == "poll-failure" || p.readFailures > 0 { + if p.readFailures > 0 { + p.readFailures-- + } w.WriteHeader(503) return } @@ -62,7 +66,11 @@ func exclusiveFixture(t *testing.T, mode string) (*fixture, *exclusiveTestPVE) { p.power[102] = "running" } } - data = map[string]string{"status": "stopped", "exitstatus": "OK"} + exitStatus := "OK" + if p.mode == "failed-task" { + exitStatus = "ERROR" + } + data = map[string]string{"status": "stopped", "exitstatus": exitStatus} case strings.HasSuffix(path, "/status/current"): fmt.Sscanf(path, "/nodes/gpu-node/qemu/%d/status/current", &id) data = map[string]string{"status": p.power[id]} @@ -202,7 +210,14 @@ func TestExclusiveGPUUnknownSubmissionNeverReplayed(t *testing.T) { t.Fatal("replayed uncertain power action", p.actions) } code, out := f.request("POST", "/terminal/desktop-prepare", "gpu-device", map[string]any{"desktopId": "desk_demo_ubuntu"}, "") - expect(t, 409, code, out) + if mode == "poll-failure" { + expect(t, 202, code, out) + if out["taskId"] != task.ID || out["status"] != "running" { + t.Fatal("recovering task was not reused", out) + } + } else { + expect(t, 409, code, out) + } code, out = f.request("POST", "/tasks/"+task.ID+"/gpu-reconcile", f.login("admin"), nil, "") if mode == "ambiguous" { expect(t, 409, code, out) diff --git a/platform/internal/server/gpu.go b/platform/internal/server/gpu.go index b06c28e..3b9fde4 100644 --- a/platform/internal/server/gpu.go +++ b/platform/internal/server/gpu.go @@ -103,6 +103,11 @@ func (s *Server) gpuInventory(c *gin.Context) { s.fail(c, 502, "pci_unavailable", "无法读取节点 PCI 清单,请检查 PVE 连通性及根路径 Sys.Audit 权限") return } + healthSamples, err := s.latestGPUHealth(ctx, node) + if err != nil { + s.dbError(c, err) + return + } items := []gpuItem{} observedAt := time.Now().UTC() for _, d := range devices { @@ -122,7 +127,23 @@ func (s *Server) gpuInventory(c *gin.Context) { if strings.TrimSpace(d.Driver) != "" { driverBinding = "bound" } - items = append(items, gpuItem{ID: node + ":" + address, Address: address, Name: d.DeviceName, Vendor: d.VendorName, VendorID: d.Vendor, DeviceID: d.Device, Class: d.Class, IOMMUGroup: group, Health: gpuHealth{Status: "not_collected", Source: "pve-pci", ObservedAt: observedAt, Driver: d.Driver, DriverBinding: driverBinding}, References: []gpuReference{}}) + health := gpuHealth{Status: "not_collected", Source: "pve-pci", ObservedAt: observedAt, Driver: d.Driver, DriverBinding: driverBinding} + if sample, ok := healthSamples[address]; ok { + health.Status = "collected" + health.Source = sample.Source + health.ObservedAt = sample.ObservedAt + health.TemperatureC = sample.TemperatureC + health.UtilizationPercent = sample.UtilizationPercent + health.MemoryUsedBytes = sample.MemoryUsedBytes + health.MemoryTotalBytes = sample.MemoryTotalBytes + if sample.Driver != "" { + health.Driver = sample.Driver + } + if sample.DriverBinding != "" { + health.DriverBinding = sample.DriverBinding + } + } + items = append(items, gpuItem{ID: node + ":" + address, Address: address, Name: d.DeviceName, Vendor: d.VendorName, VendorID: d.Vendor, DeviceID: d.Device, Class: d.Class, IOMMUGroup: group, Health: health, References: []gpuReference{}}) } fullVisibility, err := pve.HasPermission(ctx, "/", "VM.Audit") if err != nil || !fullVisibility { @@ -244,7 +265,13 @@ func (s *Server) gpuInventory(c *gin.Context) { } } sort.Slice(items, func(i, j int) bool { return items[i].Address < items[j].Address }) - c.JSON(200, gin.H{"node": node, "observedAt": observedAt, "complete": len(issues) == 0, "visibleVmCount": visibleCount, "scannedVmCount": scannedCount, "items": items, "issues": issues, "health": gin.H{"status": "partial", "source": "pve-pci", "temperature": "not_collected", "utilization": "not_collected", "memory": "not_collected", "driverBinding": "reported_when_pve_exposes_driver"}}) + healthStatus := "not_collected" + healthSource := "pve-pci" + if len(healthSamples) > 0 { + healthStatus = "collected" + healthSource = "node-agent" + } + c.JSON(200, gin.H{"node": node, "observedAt": observedAt, "complete": len(issues) == 0, "visibleVmCount": visibleCount, "scannedVmCount": scannedCount, "items": items, "issues": issues, "health": gin.H{"status": healthStatus, "source": healthSource, "temperature": healthStatus, "utilization": healthStatus, "memory": healthStatus, "driverBinding": "reported_when_collected"}}) } func rawOptionText(value json.RawMessage) string { diff --git a/platform/internal/server/gpu_agent.go b/platform/internal/server/gpu_agent.go new file mode 100644 index 0000000..227aaa5 --- /dev/null +++ b/platform/internal/server/gpu_agent.go @@ -0,0 +1,141 @@ +package server + +import ( + "context" + "crypto/subtle" + "strings" + "time" + + "github.com/gin-gonic/gin" + "vdi-cloud/platform/pkg/proxmox" +) + +// gpuAgentSample is the small, vendor-neutral contract implemented by a node +// collector. The collector may use nvidia-smi, rocm-smi, intel_gpu_top or a +// vendor API, while the platform keeps the same health model for every GPU. +type gpuAgentSample struct { + Address string `json:"address" binding:"required"` + ObservedAt string `json:"observedAt"` + TemperatureC *float64 `json:"temperatureC"` + UtilizationPercent *float64 `json:"utilizationPercent"` + MemoryUsedBytes *int64 `json:"memoryUsedBytes"` + MemoryTotalBytes *int64 `json:"memoryTotalBytes"` + Driver string `json:"driver"` + DriverBinding string `json:"driverBinding"` +} + +type gpuAgentReport struct { + Node string `json:"node" binding:"required,max=100"` + Samples []gpuAgentSample `json:"samples" binding:"required,min=1,max=256"` +} + +func (s *Server) gpuAgentAuth(c *gin.Context) { + if strings.TrimSpace(s.Config.GPUAgentToken) == "" { + s.fail(c, 503, "gpu_agent_unconfigured", "尚未配置 GPU 节点采集令牌") + return + } + provided := c.GetHeader("X-GPU-Agent-Token") + if len(provided) != len(s.Config.GPUAgentToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.Config.GPUAgentToken)) != 1 { + s.fail(c, 401, "gpu_agent_unauthorized", "GPU 节点采集令牌无效") + return + } + c.Next() +} + +func validGPUHealth(sample gpuAgentSample) bool { + if sample.TemperatureC != nil && (*sample.TemperatureC < -40 || *sample.TemperatureC > 150) { + return false + } + if sample.UtilizationPercent != nil && (*sample.UtilizationPercent < 0 || *sample.UtilizationPercent > 100) { + return false + } + if sample.MemoryUsedBytes != nil && *sample.MemoryUsedBytes < 0 { + return false + } + if sample.MemoryTotalBytes != nil && *sample.MemoryTotalBytes < 0 { + return false + } + if sample.MemoryUsedBytes != nil && sample.MemoryTotalBytes != nil && *sample.MemoryUsedBytes > *sample.MemoryTotalBytes { + return false + } + _, ok := proxmox.NormalizePCIAddress(sample.Address) + return ok +} + +func (s *Server) ingestGPUHealth(c *gin.Context) { + var in gpuAgentReport + if !s.decode(c, &in) { + return + } + in.Node = strings.TrimSpace(in.Node) + if in.Node == "" || strings.ContainsAny(in.Node, "/\\?# ") { + s.fail(c, 400, "invalid_node", "节点名称无效") + return + } + ctx := c.Request.Context() + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + s.dbError(c, err) + return + } + defer tx.Rollback() + for _, sample := range in.Samples { + if !validGPUHealth(sample) { + s.fail(c, 400, "invalid_gpu_health", "GPU 健康数据超出范围或 PCI 地址无效") + return + } + address, _ := proxmox.NormalizePCIAddress(sample.Address) + observedAt := time.Now().UTC() + if strings.TrimSpace(sample.ObservedAt) != "" { + observedAt, err = time.Parse(time.RFC3339Nano, sample.ObservedAt) + if err != nil || observedAt.After(time.Now().Add(2*time.Minute)) || observedAt.Before(time.Now().Add(-10*time.Minute)) { + s.fail(c, 400, "invalid_gpu_health", "采集时间必须是最近 10 分钟内的 RFC3339 时间") + return + } + observedAt = observedAt.UTC() + } + _, err = tx.ExecContext(ctx, `INSERT INTO gpu_health_samples(node,address,observed_at,temperature_c,utilization_percent,memory_used_bytes,memory_total_bytes,driver,driver_binding,source) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,'node-agent') ON CONFLICT DO NOTHING`, in.Node, address, observedAt, sample.TemperatureC, sample.UtilizationPercent, sample.MemoryUsedBytes, sample.MemoryTotalBytes, strings.TrimSpace(sample.Driver), strings.TrimSpace(sample.DriverBinding)) + if err != nil { + s.dbError(c, err) + return + } + } + if _, err = tx.ExecContext(ctx, "DELETE FROM gpu_health_samples WHERE observed_at < now() - interval '7 days'"); err != nil { + s.dbError(c, err) + return + } + if err = tx.Commit(); err != nil { + s.dbError(c, err) + return + } + c.JSON(202, gin.H{"accepted": len(in.Samples), "node": in.Node}) +} + +type latestGPUHealth struct { + Address string `db:"address"` + ObservedAt time.Time `db:"observed_at"` + TemperatureC *float64 `db:"temperature_c"` + UtilizationPercent *float64 `db:"utilization_percent"` + MemoryUsedBytes *int64 `db:"memory_used_bytes"` + MemoryTotalBytes *int64 `db:"memory_total_bytes"` + Driver string `db:"driver"` + DriverBinding string `db:"driver_binding"` + Source string `db:"source"` +} + +func (s *Server) latestGPUHealth(ctx context.Context, node string) (map[string]latestGPUHealth, error) { + // Kept separate from inventory assembly so a missing collector is an + // ordinary empty result and remains visibly "not_collected" in the API. + items := []latestGPUHealth{} + query := `SELECT DISTINCT ON(address) address,observed_at,temperature_c,utilization_percent,memory_used_bytes,memory_total_bytes,driver,driver_binding,source +FROM gpu_health_samples WHERE node=$1 AND observed_at > now()-interval '10 minutes' ORDER BY address,observed_at DESC` + if err := s.DB.SelectContext(ctx, &items, query, node); err != nil { + return nil, err + } + result := make(map[string]latestGPUHealth, len(items)) + for _, item := range items { + result[item.Address] = item + } + return result, nil +} diff --git a/platform/internal/server/gpu_agent_test.go b/platform/internal/server/gpu_agent_test.go new file mode 100644 index 0000000..e7ca3ba --- /dev/null +++ b/platform/internal/server/gpu_agent_test.go @@ -0,0 +1,50 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGPUAgentHealthIsAuthenticatedAndVisibleInInventory(t *testing.T) { + f := newFixture(t) + f.server.Config.GPUAgentToken = "agent-token" + fakeGPUPVE(t, f, "normal") + + status, out := f.request("POST", "/gpu-agent/health", "", map[string]any{ + "node": "node-a", + "samples": []map[string]any{{ + "address": "0000:01:00.0", "temperatureC": 61.5, "utilizationPercent": 42.0, + "memoryUsedBytes": 1024, "memoryTotalBytes": 4096, "driver": "nvidia", "driverBinding": "bound", + }}, + }, "") + // f.request cannot attach the collector header, so the route must reject it. + expect(t, 401, status, out) + + body, _ := json.Marshal(map[string]any{ + "node": "node-a", "samples": []map[string]any{{"address": "0000:01:00.0", "temperatureC": 61.5, "driver": "nvidia", "driverBinding": "bound"}}, + }) + r := httptest.NewRequest(http.MethodPost, "/api/v1/gpu-agent/health", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-GPU-Agent-Token", "agent-token") + w := httptest.NewRecorder() + f.server.Router.ServeHTTP(w, r) + expect(t, 202, w.Code, w.Body.String()) + + status, out = f.request("GET", "/resources/gpus?node=node-a", f.login("admin"), nil, "") + expect(t, 200, status, out) + items := out["items"].([]any) + var found bool + for _, raw := range items { + item := raw.(map[string]any) + if item["address"] == "0000:01:00.0" { + health := item["health"].(map[string]any) + found = health["status"] == "collected" && health["driverBinding"] == "bound" && health["temperatureC"] == 61.5 && health["driver"] == "nvidia" + } + } + if !found { + t.Fatal("ingested GPU health was not visible in inventory", out) + } +} diff --git a/platform/internal/server/gpu_recovery.go b/platform/internal/server/gpu_recovery.go new file mode 100644 index 0000000..6ad5cc4 --- /dev/null +++ b/platform/internal/server/gpu_recovery.go @@ -0,0 +1,145 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "vdi-cloud/platform/internal/model" + "vdi-cloud/platform/internal/store" +) + +func (s *Server) knownGPURecovery(state gpuPrepareState) bool { + return state.UPID != "" && state.Node == s.Config.ExclusiveGPUNode && + fmt.Sprint(state.Members) == fmt.Sprint(s.Config.ExclusiveGPUVMIDs) && + (state.Phase == "start-waiting" || state.Phase == "shutdown-waiting" || state.Phase == "service-waiting") +} + +func (s *Server) gpuRecoveryPending(task model.Task) bool { + var state gpuPrepareState + return task.Status == "timeout" && time.Since(task.CreatedAt) < 5*time.Minute && + json.Unmarshal([]byte(task.ProviderRef), &state) == nil && s.knownGPURecovery(state) +} + +// Recovery observes the original UPID and every member under the same group +// lock as preparation. It never sends a power command, even after an API +// restart or a lost submission response. +func (s *Server) recoverGPUPrepare(parent context.Context, task model.Task) { + ctx, cancel := context.WithTimeout(parent, 60*time.Second) + defer cancel() + if s.RefreshCompute(ctx) != nil { + return + } + pve := s.currentPVE() + if pve == nil { + return + } + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return + } + defer tx.Rollback() + locked, err := tryGPULock(ctx, tx) + if err != nil || !locked { + return + } + if err = tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 AND kind='gpu-prepare' AND status IN ('running','timeout') FOR UPDATE", task.ID); err != nil { + return + } + finish := func(status, message string, power map[string]string) { + for id, value := range power { + if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state=CASE WHEN $2=$3 AND $4='success' THEN 'ready' ELSE 'unknown' END,updated_at=now() WHERE id=$2", value, id, task.TargetID, status); err != nil { + return + } + } + if _, err = tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3", status, message, task.ID); err != nil { + return + } + if status != "timeout" { + if _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,'system','auto-reconcile-gpu-switch',$2,$3,$4,now())", store.ID("audit_"), task.TargetID, status, task.ID); err != nil { + return + } + } + _ = tx.Commit() + } + var state gpuPrepareState + if json.Unmarshal([]byte(task.ProviderRef), &state) == nil && state.Phase == "queued" && state.UPID == "" { + finish("failed", "服务重启前尚未提交电源操作,可重新选择桌面", nil) + return + } + if json.Unmarshal([]byte(task.ProviderRef), &state) != nil || !s.knownGPURecovery(state) { + finish("timeout", "未记录可核对的原任务或独占组配置已变化,请管理员核查;不会重复发送电源命令", nil) + return + } + original, err := pve.Task(ctx, state.Node, state.UPID) + if err != nil || original.Status != "stopped" || original.ExitStatus == "" { + finish("timeout", "平台正在自动核对原 PVE 任务,暂不重复切换;计算节点恢复后将继续检查", nil) + return + } + members, err := s.gpuMembers(ctx, tx) + if err != nil { + finish("timeout", "独占显卡组成员不完整,请管理员核查", nil) + return + } + powers := map[string]string{} + running, matches, submittedMember := 0, true, false + var target model.Desktop + for _, d := range members { + power, readErr := pve.Power(ctx, d.Node, d.VMID) + if readErr != nil || (power != "running" && power != "stopped") { + finish("timeout", "平台正在自动核对虚拟机实际电源状态,暂不重复切换", nil) + return + } + powers[d.ID] = power + if d.VMID == state.VMID { + submittedMember = true + } + if power == "running" { + running++ + } + if d.ID == task.TargetID { + target = d + matches = matches && power == "running" + } else { + matches = matches && power == "stopped" + } + } + if running > 1 || target.ID == "" || !submittedMember { + finish("timeout", "实际电源或显卡归属存在冲突,请管理员核查;平台不会自动关机", powers) + return + } + if original.ExitStatus != "OK" || !matches { + finish("failed", "已自动核对原任务和电源状态,本次准备未完成;可重新选择桌面", powers) + return + } + var allowed bool + err = tx.GetContext(ctx, &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id JOIN terminals t ON t.id=u.terminal_id WHERE b.desktop_id=$1 AND t.id=$2 AND u.enabled AND t.enabled)`, target.ID, state.Terminal) + if err != nil { + return + } + if !allowed || target.SetupRequired || target.Endpoint == "" { + finish("failed", "原任务已核对,但桌面授权或连接配置已变化,请检查配置后重新选择", powers) + return + } + if !sunshineReady(ctx, target.Endpoint) { + if time.Since(task.CreatedAt) < 5*time.Minute { + finish("timeout", "原任务已确认成功,正在等待桌面连接服务恢复", powers) + } else { + finish("failed", "原任务及显卡归属已自动确认,但 Sunshine 仍未响应,请检查桌面服务", powers) + } + return + } + finish("success", "已自动核对原任务、独占显卡归属和连接服务,桌面可连接", powers) +} + +// Only called by the worker while no GPU/provisioning operation is running. +// A restart resumes observations, never the command sequence. +func (s *Server) nextGPURecovery(ctx context.Context) (model.Task, error) { + var task model.Task + err := s.DB.GetContext(ctx, &task, `SELECT * FROM tasks WHERE kind='gpu-prepare' +AND ((status='running') + OR (status='timeout' AND provider_ref::jsonb->>'upid' IS NOT NULL AND provider_ref::jsonb->>'upid' != '' AND created_at>now()-interval '24 hours')) +ORDER BY updated_at,id LIMIT 1`) + return task, err +} diff --git a/platform/internal/server/gpu_recovery_test.go b/platform/internal/server/gpu_recovery_test.go new file mode 100644 index 0000000..36647a6 --- /dev/null +++ b/platform/internal/server/gpu_recovery_test.go @@ -0,0 +1,161 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "testing" + "time" + + "vdi-cloud/platform/internal/model" +) + +func serveRecoverySunshine(t *testing.T) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:47989") + if err != nil { + t.Fatal(err) + } + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `test-sunshine`) + })} + go srv.Serve(listener) + t.Cleanup(func() { srv.Close() }) +} + +func seedGPURecovery(t *testing.T, f *fixture, status string) model.Task { + t.Helper() + task := prepareExclusive(t, f) + state := gpuPrepareState{Phase: "start-waiting", Node: "gpu-node", Members: []int{100, 101, 102}, Terminal: "gpu-box", VMID: 102, UPID: "UPID:gpu-test"} + data, _ := json.Marshal(state) + if err := f.db.Get(&task, "UPDATE tasks SET status=$1,provider_ref=$2 WHERE id=$3 RETURNING *", status, string(data), task.ID); err != nil { + t.Fatal(err) + } + return task +} + +func TestGPURecoveryRequiresActualOwnershipAndSunshine(t *testing.T) { + for _, scenario := range []string{"success", "original-failed", "pending-shutdown", "multiple-running", "pve-unreachable", "sunshine-unreachable", "authorization-revoked", "config-changed", "unknown-submission"} { + t.Run(scenario, func(t *testing.T) { + f, p := exclusiveFixture(t, "normal") + task := seedGPURecovery(t, f, "timeout") + p.power[100], p.power[102] = "stopped", "running" + want := "timeout" + switch scenario { + case "success": + serveRecoverySunshine(t) + want = "success" + case "original-failed": + p.mode = "failed-task" + want = "failed" + case "pending-shutdown": + p.power[102] = "stopped" + want = "failed" + case "multiple-running": + p.power[100] = "running" + case "pve-unreachable": + p.mode = "poll-failure" + case "authorization-revoked": + if _, err := f.db.Exec("UPDATE users SET enabled=false"); err != nil { + t.Fatal(err) + } + want = "failed" + case "config-changed": + f.server.Config.ExclusiveGPUVMIDs = []int{100, 102} + case "unknown-submission": + if _, err := f.db.Exec("UPDATE tasks SET provider_ref=$1 WHERE id=$2", `{"phase":"start-submitting","node":"gpu-node","members":[100,101,102],"terminal":"gpu-box","vmid":102}`, task.ID); err != nil { + t.Fatal(err) + } + } + f.server.recoverGPUPrepare(context.Background(), task) + var result model.Task + if err := f.db.Get(&result, "SELECT * FROM tasks WHERE id=$1", task.ID); err != nil { + t.Fatal(err) + } + if result.Status != want || len(p.actions) != 0 { + t.Fatal("incorrect read-only recovery", result.Status, want, p.actions) + } + if want == "success" { + var power, service string + f.db.QueryRow("SELECT power_state,service_state FROM desktops WHERE id=$1", task.TargetID).Scan(&power, &service) + if power != "running" || service != "ready" { + t.Fatal("desktop cache did not converge", power, service) + } + f.server.recoverGPUPrepare(context.Background(), task) + var n int + f.db.Get(&n, "SELECT count(*) FROM audit_logs WHERE action='auto-reconcile-gpu-switch' AND request_id=$1", task.ID) + if n != 1 { + t.Fatal("recovery audit was duplicated", n) + } + } + }) + } +} + +func TestGPUWorkerRecoversOriginalTaskAfterRestartWithoutCommands(t *testing.T) { + f, p := exclusiveFixture(t, "normal") + task := seedGPURecovery(t, f, "running") + p.power[100], p.power[102] = "stopped", "running" + serveRecoverySunshine(t) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { f.server.RunWorker(ctx); close(done) }() + defer func() { cancel(); <-done }() + deadline := time.Now().Add(5 * time.Second) + for { + var status string + if err := f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID); err != nil { + t.Fatal(err) + } + if status == "success" { + break + } + if time.Now().After(deadline) { + t.Fatal("worker did not recover orphaned task", status) + } + time.Sleep(20 * time.Millisecond) + } + p.mu.Lock() + defer p.mu.Unlock() + if len(p.actions) != 0 { + t.Fatal("restart replayed a power command", p.actions) + } +} + +func TestGPUTransientReadFailureKeepsSingleCommandSequence(t *testing.T) { + f, p := exclusiveFixture(t, "normal") + p.readFailures = 1 + serveRecoverySunshine(t) + task := prepareExclusive(t, f) + f.server.executeGPUPrepare(context.Background(), task) + var status string + f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID) + if status != "success" || fmt.Sprint(p.actions) != "[100:shutdown 102:start]" { + t.Fatal("transient poll failure interrupted or replayed commands", status, p.actions) + } +} + +func TestGPURecoveryPollingRetainsTaskAndDeviceIsolation(t *testing.T) { + f, _ := exclusiveFixture(t, "normal") + task := seedGPURecovery(t, f, "timeout") + code, out := f.request("GET", "/terminal/desktop-prepare/"+task.ID, "gpu-device", nil, "") + expect(t, 200, code, out) + if out["status"] != "running" { + t.Fatal("terminal stopped polling recoverable task", out) + } + code, out = f.request("POST", "/terminal/desktop-prepare", "gpu-device", map[string]any{"desktopId": task.TargetID}, "") + expect(t, 202, code, out) + if out["taskId"] != task.ID { + t.Fatal("new task submitted during recovery", out) + } + if _, err := f.db.Exec("UPDATE tasks SET created_at=now()-interval '6 minutes' WHERE id=$1", task.ID); err != nil { + t.Fatal(err) + } + code, out = f.request("GET", "/terminal/desktop-prepare/"+task.ID, "gpu-device", nil, "") + expect(t, 200, code, out) + if out["status"] != "timeout" { + t.Fatal("terminal waiting exceeded recovery window", out) + } +} diff --git a/platform/internal/server/observability.go b/platform/internal/server/observability.go index fdc0ef8..365e20b 100644 --- a/platform/internal/server/observability.go +++ b/platform/internal/server/observability.go @@ -299,7 +299,7 @@ func (s *Server) observe(ctx context.Context) error { } } // Bounded retention. Current incidents and active session records are preserved. - for _, query := range []string{"DELETE FROM login_limits WHERE window_started 120 || strings.IndexByte(in.BootID, 0) >= 0 { + s.fail(c, 400, "invalid_logs", "日志启动标识无效") + return + } + tx := deviceTx(c) + for _, entry := range in.Entries { + message := redactDiagnostic(strings.TrimSpace(entry.Message)) + if message == "" || len(message) > 16000 { + s.fail(c, 400, "invalid_logs", "日志内容不能为空或超出长度限制") + return + } + capturedAt := time.Now().UTC() + if strings.TrimSpace(entry.CapturedAt) != "" { + parsed, err := time.Parse(time.RFC3339Nano, entry.CapturedAt) + if err != nil || parsed.After(time.Now().Add(2*time.Minute)) || parsed.Before(time.Now().Add(-10*time.Minute)) { + s.fail(c, 400, "invalid_logs", "日志时间必须是最近 10 分钟内的 RFC3339 时间") + return + } + capturedAt = parsed.UTC() + } + level := strings.TrimSpace(entry.Level) + if level == "" { + level = "info" + } + if _, err := tx.ExecContext(c.Request.Context(), `INSERT INTO terminal_log_entries(id,terminal_id,captured_at,boot_id,level,message) VALUES($1,$2,$3,$4,$5,$6)`, store.ID("log_"), c.GetString("terminalId"), capturedAt, redactDiagnostic(in.BootID), level, message); err != nil { + s.dbError(c, err) + return + } + } + _, _ = tx.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online' WHERE id=$1", c.GetString("terminalId")) + s.deviceJSON(c, 202, gin.H{"accepted": len(in.Entries), "serverTime": time.Now().UTC()}) +} func (s *Server) deviceConfig(c *gin.Context) { var version int if err := deviceTx(c).GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil { diff --git a/platform/internal/store/schema.sql b/platform/internal/store/schema.sql index 8826677..a92d727 100644 --- a/platform/internal/store/schema.sql +++ b/platform/internal/store/schema.sql @@ -82,12 +82,27 @@ CREATE TABLE IF NOT EXISTS terminal_log_archives ( ); CREATE INDEX IF NOT EXISTS terminal_log_archives_terminal_time ON terminal_log_archives(terminal_id,captured_at DESC,id); CREATE INDEX IF NOT EXISTS terminal_log_archives_time ON terminal_log_archives(captured_at DESC); +CREATE TABLE IF NOT EXISTS terminal_log_entries ( + id text PRIMARY KEY, terminal_id text NOT NULL REFERENCES terminals(id) ON DELETE CASCADE, + captured_at timestamptz NOT NULL DEFAULT now(), boot_id text NOT NULL DEFAULT '', level text NOT NULL DEFAULT 'info', + message text NOT NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX IF NOT EXISTS terminal_log_entries_terminal_time ON terminal_log_entries(terminal_id,captured_at DESC,id); +CREATE INDEX IF NOT EXISTS terminal_log_entries_time ON terminal_log_entries(captured_at DESC); +CREATE TABLE IF NOT EXISTS assistant_runs ( + id text PRIMARY KEY, actor text NOT NULL REFERENCES administrators(username), input_text text NOT NULL, + action text NOT NULL, status text NOT NULL CHECK(status IN ('planned','blocked','executed','expired','failed')), + plan jsonb NOT NULL, fingerprint text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, executed_at timestamptz, result jsonb NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX IF NOT EXISTS assistant_runs_actor_time ON assistant_runs(actor,created_at DESC); CREATE TABLE IF NOT EXISTS image_templates ( id text PRIMARY KEY, name text NOT NULL, version text NOT NULL, os text NOT NULL CHECK(os IN ('Windows','Ubuntu','麒麟')), node text NOT NULL, vmid integer NOT NULL CHECK(vmid>=100), digest text NOT NULL, enabled boolean NOT NULL DEFAULT true, validated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(node,vmid), UNIQUE(name,version) ); +ALTER TABLE image_templates ADD COLUMN IF NOT EXISTS hardware_encoding_verified boolean NOT NULL DEFAULT false; CREATE TABLE IF NOT EXISTS desktop_pools ( id text PRIMARY KEY, name text NOT NULL UNIQUE, type text NOT NULL CHECK(type IN ('dedicated','shared')), max_count integer NOT NULL CHECK(max_count BETWEEN 1 AND 10000), @@ -124,6 +139,17 @@ CREATE TABLE IF NOT EXISTS resource_samples ( memory double precision, storage double precision, PRIMARY KEY(resource_id,sampled_at) ); CREATE INDEX IF NOT EXISTS resource_samples_time ON resource_samples(sampled_at); +-- GPU health is reported by an explicitly configured node collector. PVE's +-- PCI inventory does not expose live temperature, utilization or VRAM usage. +CREATE TABLE IF NOT EXISTS gpu_health_samples ( + node text NOT NULL, address text NOT NULL, observed_at timestamptz NOT NULL DEFAULT now(), + temperature_c double precision, utilization_percent double precision, + memory_used_bytes bigint, memory_total_bytes bigint, + driver text NOT NULL DEFAULT '', driver_binding text NOT NULL DEFAULT '', source text NOT NULL, + PRIMARY KEY(node,address,observed_at) +); +CREATE INDEX IF NOT EXISTS gpu_health_latest ON gpu_health_samples(node,address,observed_at DESC); +DELETE FROM gpu_health_samples WHERE observed_at < now() - interval '7 days'; CREATE TABLE IF NOT EXISTS alert_rules ( id text PRIMARY KEY, name text NOT NULL, metric text NOT NULL CHECK(metric IN ('node_cpu','node_memory','node_storage','node_unavailable','terminal_offline')), threshold double precision NOT NULL, hold_seconds integer NOT NULL DEFAULT 0, enabled boolean NOT NULL DEFAULT true diff --git a/platform/pkg/proxmox/client.go b/platform/pkg/proxmox/client.go index 7ad5205..ed2586f 100644 --- a/platform/pkg/proxmox/client.go +++ b/platform/pkg/proxmox/client.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -123,16 +124,48 @@ func (c *Client) Action(ctx context.Context, node string, vmid int, action strin } func (c *Client) Task(ctx context.Context, node, upid string) (TaskStatus, error) { var status TaskStatus - err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", nil, &status) + err := c.readWithRetry(ctx, "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", &status) return status, err } func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) { var status struct { Status string `json:"status"` } - err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), nil, &status) + err := c.readWithRetry(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), &status) return status.Status, err } + +// Retry only observations. A lost POST response may already have changed the +// VM, so commands must never use this path. +func (c *Client) readWithRetry(ctx context.Context, path string, out any) error { + var err error + for attempt := 0; attempt < 3; attempt++ { + if err = ctx.Err(); err != nil { + return err + } + err = c.request(ctx, "GET", path, nil, out) + if err == nil || !retryableRead(err) || attempt == 2 { + return err + } + timer := time.NewTimer(time.Duration(attempt+1) * 250 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + return err +} + +func retryableRead(err error) bool { + var response *HTTPError + if errors.As(err, &response) { + return response.Status == 408 || response.Status == 429 || response.Status == 500 || response.Status == 502 || response.Status == 503 || response.Status == 504 + } + var network net.Error + return errors.As(err, &network) && (network.Timeout() || network.Temporary()) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) +} func (c *Client) Wait(ctx context.Context, node, upid string, interval time.Duration) error { ticker := time.NewTicker(interval) defer ticker.Stop() diff --git a/platform/pkg/proxmox/client_test.go b/platform/pkg/proxmox/client_test.go index 9487e00..517ac65 100644 --- a/platform/pkg/proxmox/client_test.go +++ b/platform/pkg/proxmox/client_test.go @@ -2,6 +2,7 @@ package proxmox import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -58,6 +59,70 @@ func TestTLSAndTaskLifecycle(t *testing.T) { t.Fatal("restore must never map to hard reset") } } + +func TestObservationRetriesDoNotReplayPowerCommands(t *testing.T) { + for _, endpoint := range []string{"task", "power"} { + t.Run(endpoint, func(t *testing.T) { + reads, writes := 0, 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + writes++ + w.WriteHeader(http.StatusServiceUnavailable) + return + } + reads++ + if reads < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + fmtBody := `{"data":{"status":"running"}}` + if endpoint == "task" { + fmtBody = `{"data":{"status":"stopped","exitstatus":"OK"}}` + } + w.Write([]byte(fmtBody)) + })) + defer ts.Close() + c := &Client{BaseURL: ts.URL, HTTP: ts.Client()} + var err error + if endpoint == "task" { + err = c.Wait(context.Background(), "node", "UPID:one", time.Millisecond) + } else { + _, err = c.Power(context.Background(), "node", 100) + } + if err != nil || reads != 3 { + t.Fatal("observation did not recover", reads, err) + } + if _, err = c.Action(context.Background(), "node", 100, "start"); err == nil || writes != 1 { + t.Fatal("command response loss must not replay POST", writes, err) + } + }) + } +} + +func TestObservationRetriesAreBoundedAndRespectCancellation(t *testing.T) { + for _, status := range []int{http.StatusForbidden, http.StatusServiceUnavailable} { + calls := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(status) + })) + c := &Client{BaseURL: ts.URL, HTTP: ts.Client()} + _, err := c.Power(context.Background(), "node", 100) + want := 1 + if status == http.StatusServiceUnavailable { + want = 3 + } + if err == nil || calls != want { + t.Fatal("incorrect retry limit", calls, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err = c.Power(ctx, "node", 100); !errors.Is(err, context.Canceled) || calls != want { + t.Fatal("cancellation ignored", calls, err) + } + ts.Close() + } +} func TestFailedPVEJobIsNotSuccess(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`)) diff --git a/tools/generate-platform-openapi.mjs b/tools/generate-platform-openapi.mjs index e935068..96917cd 100644 --- a/tools/generate-platform-openapi.mjs +++ b/tools/generate-platform-openapi.mjs @@ -17,6 +17,8 @@ const schemas = { MFARecovery: object({ recoveryCodes: array(string) }), LoginResult: object({ accessToken: string, expiresIn: integer, user: ref('Administrator') }), Desktop: object({ id: string, name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, pool: string, powerState: string, serviceState: string, provider: { type: 'string', enum: ['demo', 'proxmox'] }, endpoint: string, app: string, width: integer, height: integer, fps: integer, updatedAt: date }), + DesktopReadinessCheck: object({ name: string, status: { type: 'string', enum: ['pass', 'fail', 'unknown'] }, message: string }), + DesktopReadiness: object({ desktopId: string, observedAt: date, ready: boolean, powerState: string, serviceState: string, setupRequired: boolean, checks: array(ref('DesktopReadinessCheck')) }), Terminal: object({ id: string, sn: string, name: string, group: string, status: string, agentVersion: string, lastSeen: { ...date, nullable: true }, sessionDesktopId: { ...string, nullable: true }, configVersion: integer, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }), User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }), Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }), @@ -25,6 +27,9 @@ const schemas = { GPUItem: object({ id: string, address: string, name: string, vendor: string, vendorId: string, deviceId: string, class: string, iommuGroup: { ...integer, nullable: true }, state: { type: 'string', enum: ['unreferenced', 'unknown', 'referenced', 'multiple'] }, health: ref('GPUHealth'), references: array(ref('GPUReference')) }), GPUIssue: object({ code: string, vmid: integer, message: string }), GPUInventory: object({ node: string, observedAt: date, complete: boolean, visibleVmCount: integer, scannedVmCount: integer, items: array(ref('GPUItem')), issues: array(ref('GPUIssue')), health: object({ status: string, source: string, temperature: string, utilization: string, memory: string, driverBinding: string }) }), + GPUAgentSample: object({ address: string, observedAt: { ...date, nullable: true }, temperatureC: { ...number, nullable: true }, utilizationPercent: { ...number, nullable: true }, memoryUsedBytes: { ...integer, nullable: true }, memoryTotalBytes: { ...integer, nullable: true }, driver: string, driverBinding: string }, ['address']), + GPUAgentReport: object({ node: string, samples: { ...array(ref('GPUAgentSample')), minItems: 1, maxItems: 256 } }, ['node', 'samples']), + GPUAgentAccepted: object({ accepted: integer, node: string }), Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }), Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }), Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }), @@ -58,6 +63,13 @@ const schemas = { DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }), SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }), AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']), + AssistantInput: object({ text: { ...string, maxLength: 500 } }, ['text']), + AssistantCandidate: object({ id: string, label: string, kind: string }), + AssistantPlan: object({ action: string, kind: { type: 'string', enum: ['action', 'route', 'blocked'] }, title: string, summary: string, userId: string, userName: string, desktopId: string, desktopName: string, desktopVmid: integer, missing: array(string), candidates: array(ref('AssistantCandidate')), risk: string, confirmationRequired: boolean, path: string, reason: string }), + AssistantPlanResult: object({ runId: string, status: string, input: string, plan: ref('AssistantPlan'), createdAt: date, expiresAt: date }), + AssistantExecuteInput: object({ runId: string }, ['runId']), + AssistantExecuteResult: object({ runId: string, status: string, result: object({ userId: string, desktopId: string, action: string, message: string }) }), + AssistantRun: object({ runId: string, input: string, action: string, status: string, plan: ref('AssistantPlan'), createdAt: date, expiresAt: date, executedAt: { ...date, nullable: true }, result: object({}, []) }), } Object.assign(schemas, { PveConnectionInput: object({ url: string, tokenId: string, tokenSecret: { ...string, format: 'password', writeOnly: true }, caPem: string, revision: string }, ['url', 'tokenId', 'revision']), @@ -98,9 +110,9 @@ Object.assign(schemas, { UpdateAdministrator: object({ displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] }, enabled: boolean }), ResetAdministratorPassword: object({ password: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }), ChangePassword: object({ currentPassword: { type: 'string', format: 'password' }, newPassword: { type: 'string', format: 'password', minLength: 12, maxLength: 72 } }), - ImageTemplate: object({ id: string, name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, enabled: boolean, validatedAt: date }), - TemplateInput: object({ name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer }), - TemplateUpdate: object({ enabled: boolean }), + ImageTemplate: object({ id: string, name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, enabled: boolean, hardwareEncodingVerified: boolean, validatedAt: date }), + TemplateInput: object({ name: string, version: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, hardwareEncodingVerified: boolean }, ['name', 'version', 'os', 'node', 'vmid']), + TemplateUpdate: object({ enabled: boolean, hardwareEncodingVerified: boolean }, []), DesktopPool: object({ id: string, name: string, type: { type: 'string', enum: ['dedicated', 'shared'] }, maxCount: integer, templateId: { ...string, nullable: true }, enabled: boolean, createdAt: date, desktopCount: integer, reservedCount: integer, assignedCount: integer, activeCount: integer }), PoolInput: object({ name: string, type: { type: 'string', enum: ['dedicated', 'shared'] }, maxCount: integer, templateId: { ...string, nullable: true }, enabled: boolean }), PoolMembers: object({ desktopIds: array(string) }), @@ -119,8 +131,10 @@ schemas.GPUReconcileResult = object({ reconciled: boolean, vmid: string }) schemas.DiagnosticSubject = object({ kind: { type: 'string', enum: ['terminal', 'desktop'] }, id: string, name: string, state: string, node: string, vmid: integer, agentVersion: string, lastSeen: { ...date, nullable: true } }) schemas.DiagnosticEvent = object({ id: string, source: { type: 'string', enum: ['tasks', 'audits', 'sessions', 'alerts', 'logs'] }, time: date, action: string, status: string, targetId: string, targetName: string, actor: string, message: string, details: string }) schemas.DiagnosticReport = object({ subject: ref('DiagnosticSubject'), from: date, to: date, observedAt: date, items: array(ref('DiagnosticEvent')), counts: object({ tasks: integer, audits: integer, sessions: integer, alerts: integer, logs: integer }), total: integer, page: integer, pageSize: integer, source: string, notes: array(string) }) -schemas.TerminalLogArchive = object({ id: string, taskId: string, terminalId: string, terminalName: string, capturedAt: date, bootId: string, agentVersion: string, kioskState: string, logText: string }) +schemas.TerminalLogArchive = object({ id: string, taskId: string, terminalId: string, terminalName: string, capturedAt: date, bootId: string, agentVersion: string, kioskState: string, logText: string, source: { type: 'string', enum: ['snapshot', 'stream'] } }) schemas.TerminalLogArchivePage = object({ items: array(ref('TerminalLogArchive')), page: integer, pageSize: integer, total: integer, observedAt: date, retentionDays: integer }) +schemas.TerminalLogEntry = object({ capturedAt: { ...date, nullable: true }, level: { type: 'string', enum: ['debug', 'info', 'warn', 'error'] }, message: { ...string, maxLength: 16000 } }, ['message']) +schemas.TerminalLogBatch = object({ bootId: { ...string, maxLength: 120 }, entries: { ...array(ref('TerminalLogEntry')), minItems: 1, maxItems: 100 } }, ['entries']) schemas.GPUExclusiveMember = object({ desktopId: string, name: string, vmid: integer, powerState: string, powerObserved: boolean, activeSessions: integer, activeTasks: integer, conflicts: array(string), eligible: boolean }) schemas.GPUExclusiveStatus = object({ enabled: boolean, node: string, vmids: array(integer), observedAt: date, lockAvailable: boolean, groupComplete: boolean, members: array(ref('GPUExclusiveMember')), conflicts: array(string), allocationReady: boolean, releaseReady: boolean, releasePolicy: string, activeTasks: array(ref('Task')) }) schemas.Administrator.properties.enabled = boolean @@ -195,10 +209,14 @@ const paths = { '/administrators/{id}/sessions': { get: operation('administratorSessions', 'AdministratorSessionList', { list: true, param: true }) }, '/administrators/{id}/sessions/{sessionKey}': { delete: { ...operation('revokeAdministratorSession', null, { param: true, status: 204 }), parameters: [{ name: 'id', in: 'path', required: true, schema: string }, { name: 'sessionKey', in: 'path', required: true, schema: { ...string, minLength: 64, maxLength: 64 } }] } }, '/overview': { get: operation('overview', 'Overview') }, + '/assistant/plan': { post: operation('assistantPlan', 'AssistantPlanResult', { input: 'AssistantInput' }) }, + '/assistant/execute': { post: operation('assistantExecute', 'AssistantExecuteResult', { input: 'AssistantExecuteInput', command: true }) }, + '/assistant/runs/{id}': { get: operation('assistantRun', 'AssistantRun', { param: true }) }, '/settings': { get: operation('settings', 'Settings') }, '/resources': { get: operation('resources', 'ResourceList', { list: true }) }, '/resources/gpus': { get: operation('gpuInventory', 'GPUInventory', { node: true }) }, '/resources/gpu-exclusive': { get: operation('gpuExclusiveStatus', 'GPUExclusiveStatus', { node: true }) }, + '/gpu-agent/health': { post: operation('ingestGPUHealth', 'GPUAgentAccepted', { input: 'GPUAgentReport', security: 'gpuAgent', status: 202 }) }, '/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) }, '/resources/templates': { get: operation('discoverTemplates', 'DiscoveredVMList', { list: true }) }, '/resources/storage': { get: operation('storages', 'StorageList', { list: true, node: true }) }, @@ -211,6 +229,7 @@ const paths = { '/desktops/clone/check': { post: operation('checkClone', 'CloneCheck', { input: 'CloneInput' }) }, '/desktops/clone': { post: operation('cloneDesktop', 'Task', { input: 'CloneInput', command: true, status: 202 }) }, '/desktops/{id}/connection': { put: operation('configureDesktop', 'Desktop', { param: true, input: 'ConnectionInput' }) }, + '/desktops/{id}/readiness/check': { post: operation('checkDesktopReadiness', 'DesktopReadiness', { param: true }) }, '/resources/sync': { post: operation('syncResources', 'Task', { status: 202, command: true }) }, '/desktops': { get: operation('desktops', 'DesktopList', { list: true }) }, '/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) }, @@ -234,6 +253,7 @@ const paths = { '/diagnostics': { get: operation('diagnostics', 'DiagnosticReport') }, '/diagnostics/export': { get: operation('exportDiagnostics', null) }, '/terminal-logs': { get: operation('terminalLogArchives', 'TerminalLogArchivePage') }, + '/terminal/logs': { post: operation('appendTerminalLogs', 'Accepted', { input: 'TerminalLogBatch', security: 'deviceBearer', status: 202 }) }, '/audit-logs': { get: operation('audits', 'AuditList', { list: true, period: true }) }, '/audit-logs/export': { get: operation('exportAudits', null, { period: true }) }, '/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) }, @@ -274,7 +294,7 @@ paths['/audit-logs/export'].get.parameters.push(...auditFilters, { name: 'keywor paths['/audit-logs/export'].get.description = 'Authenticated administrators of all roles can export matching audit fields (maximum 10000 rows). No pagination or silent truncation. CSV uses UTF-8 BOM and formula-safe cells. Successful preparation is audited.' paths['/audit-logs/export'].get.responses[200] = { description: 'Complete filtered CSV, with UTC timestamps and raw action/result codes', headers: { 'X-Export-Count': { schema: integer } }, content: { 'text/csv': { schema: { type: 'string', format: 'binary' } } } } paths['/audit-logs/export'].get.responses[413] = { description: 'Over 10000 matching records; narrow the filter', content: json(ref('Error')) } -const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' } } } } +const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' }, gpuAgent: { type: 'apiKey', in: 'header', name: 'X-GPU-Agent-Token', description: '仅供受信任计算节点采集器使用的独立令牌' } } } } const directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url)) mkdirSync(directory, { recursive: true }) writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`)