fix: require provider remote upgrade

This commit is contained in:
Codex
2026-05-05 01:49:05 +00:00
parent f6d0bd1e3b
commit eb6df3ba92
11 changed files with 16 additions and 31 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun`TypeScript 运行时固定使用 Bun,组件入口和 CLI 都直接运行 `.ts` 文件,约束见 `docs/reference/config.md`
- `docker-compose.yml`:主 server 统一编排 core、frontend、database 和本机 provider gateway,且只公开 frontend/provider ingress,服务拓扑见 `docs/reference/deployment.md`
- `src/components/frontend`:前端源码固定使用 TypeScript + React,采用高信息密度工业控制台设计,资源节点含任务管理器风格资源监控与 Docker Desktop 风格状态页,界面规则见 `docs/reference/frontend.md`
- `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,并可配置维护专用 Host SSH / WSL SSH 桥,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`
- `src/components/provider-gateway`:当前主 server `74.48.78.17` 也作为 provider gateway 接入 UniDesk,外部节点通过 `ws://74.48.78.17:18082/ws/provider` 接入,必须支持 always-enabled 远程升级,并可配置维护专用 Host SSH / WSL SSH 桥,部署与 Playwright 公网前端验证方法见 `docs/reference/provider-gateway.md`
- `docs/reference/e2e.md`:交付前必须执行的自测门禁、Playwright 登录与 JSON 展示断言、数据库命名卷持久化要求。
## Architecture Docs
+2 -2
View File
@@ -56,9 +56,9 @@
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:system-status``frontend:system-monitor-visible` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `资源监控` 子标签,确认可以像 Windows 任务管理器一样看到 CPU、Memory、Disk 当前用量和历史曲线,Memory 明确显示为不含 Linux page cache / buffer 的实际内存占用,并能执行 `Provider Gateway 升级``预检升级`
## T14 Provider Gateway 远程升级预检
## T14 Provider Gateway 远程升级
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts debug dispatch main-server provider.upgrade`,随后查看任务历史或 `bun scripts/cli.ts debug health`,确认 `provider.upgrade` 通过真实 WebSocket 下发并以 `mode: plan` 成功返回升级计划正式执行升级只能通过前端 `资源监控``执行升级` 或等价的显式调度完成,不能使用 Host SSH 维护桥作为自动升级通道。
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts debug dispatch main-server provider.upgrade`,随后查看任务历史或 `bun scripts/cli.ts debug health`,确认 `provider.upgrade` 通过真实 WebSocket 下发并以 `mode: plan` 成功返回升级计划且计划中包含 `policy: "always-enabled"`;对明确要升级的计算节点,必须再运行 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000`,确认任务成功、result 包含 updater 容器信息、节点随后重新上线。正式执行升级只能通过前端 `资源监控``执行升级` 或等价的显式调度完成,不能使用 Host SSH 维护桥作为自动升级通道,也不能通过 `PROVIDER_UPGRADE_ENABLED` 或等价开关禁用远程升级
## T15 待处理任务可追溯
-1
View File
@@ -50,7 +50,6 @@
"diskPath": "/"
},
"upgrade": {
"enabled": true,
"hostProjectRoot": "/root/unidesk",
"workspacePath": "/workspace",
"composeFile": "docker-compose.yml",
-1
View File
@@ -112,7 +112,6 @@ services:
RECONNECT_MAX_MS: "${UNIDESK_RECONNECT_MAX_MS}"
DOCKER_SOCKET_PATH: "/var/run/docker.sock"
MONITOR_DISK_PATH: "${UNIDESK_MONITOR_DISK_PATH}"
PROVIDER_UPGRADE_ENABLED: "${UNIDESK_PROVIDER_UPGRADE_ENABLED}"
PROVIDER_UPGRADE_HOST_PROJECT_ROOT: "${UNIDESK_PROVIDER_UPGRADE_HOST_PROJECT_ROOT}"
PROVIDER_UPGRADE_WORKSPACE_PATH: "${UNIDESK_PROVIDER_UPGRADE_WORKSPACE_PATH}"
PROVIDER_UPGRADE_COMPOSE_FILE: "${UNIDESK_PROVIDER_UPGRADE_COMPOSE_FILE}"
+1 -1
View File
@@ -26,7 +26,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
## Debug Contract
`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status``/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`core 再通过 WebSocket 将 `docker.ps``provider.upgrade``host.ssh``echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。`host.ssh` 默认使用 `mode: "probe"` 做短超时维护桥自检;需要执行明确命令时使用 `--ssh-command` 进入 `mode: "exec"`,并配合 `--wait-ms``debug task` 查看 stdout、stderr、exitCode 与 probeLine。
`debug` 子命令必须复用真实模块与真实端点,禁止维护平行实现。`debug health` 会摘要展示 `/api/nodes/system-status``/api/nodes/docker-status`,避免输出完整快照造成信息爆炸。`debug dispatch` 会在 backend-core 容器内调用内部 `/api/dispatch`core 再通过 WebSocket 将 `docker.ps``provider.upgrade``host.ssh``echo` 任务下发给 provider gateway,因此它可以验证核心调度闭环,同时不需要公开 core REST API。`provider.upgrade` 默认使用 `mode: "plan"` 预检;需要验证一键升级时必须显式加 `--mode schedule`,并通过 `--wait-ms``debug task` 确认任务进入 `succeeded`、result 中包含 updater 容器信息和 `policy: "always-enabled"``host.ssh` 默认使用 `mode: "probe"` 做短超时维护桥自检;需要执行明确命令时使用 `--ssh-command` 进入 `mode: "exec"`,并配合 `--wait-ms``debug task` 查看 stdout、stderr、exitCode 与 probeLine。
## SSH Command
+1 -1
View File
@@ -16,7 +16,7 @@ TypeScript 运行时固定为 Bun。根目录 CLI、backend-core、frontend 和
## Provider Gateway Metrics And Upgrade
`providerGateway.metrics.diskPath` 指定资源监控页的硬盘采样路径,默认是 `/``providerGateway.upgrade` 定义远程升级 provider-gateway 所需的 Compose project、service、仓库挂载路径、派生 env 文件和 updater runner 镜像;这些字段由 CLI 写入 `.state/docker-compose.env`provider-gateway 只通过 WebSocket 接受 `provider.upgrade` 调度,不从隐藏环境或默认值静默补齐。
`providerGateway.metrics.diskPath` 指定资源监控页的硬盘采样路径,默认是 `/``providerGateway.upgrade` 定义远程升级 provider-gateway 所需的 Compose project、service、仓库挂载路径、派生 env 文件和 updater runner 镜像;这些字段由 CLI 写入 `.state/docker-compose.env`provider-gateway 只通过 WebSocket 接受 `provider.upgrade` 调度,不从隐藏环境或默认值静默补齐。远程升级没有 `enabled` 开关,长期接入节点必须具备 `mode: "schedule"` 一键升级能力;如果节点不满足升级前置条件,应修正 `PROVIDER_UPGRADE_*`、Docker socket 或仓库挂载,而不是在配置中禁用升级。
## SSH Forwarding
+4
View File
@@ -40,3 +40,7 @@ Before claiming delivery, run these checks and keep their JSON output or screens
3. `bun scripts/cli.ts server status`
4. `bun scripts/cli.ts e2e run`
5. a database persistence marker check across at least one CLI-controlled restart
## Provider Upgrade Gate
When delivery explicitly includes upgrading a compute node such as D601 or D518, the automated E2E plan check is not sufficient. The operator must first bootstrap any legacy provider manually if it cannot yet schedule upgrades, then run `provider.upgrade` with `mode: "schedule"` against that Provider ID, confirm the task succeeds, confirm the node reconnects in the public frontend, and finally verify any required `host.ssh` capability with `bun scripts/cli.ts ssh <PROVIDER_ID> hostname`. This schedule check is a node-upgrade gate, not a replacement for the standard public frontend Playwright E2E gate.
+6 -2
View File
@@ -10,7 +10,7 @@ Provider Gateway 是计算节点侧容器。它只主动连出到主 server 暴
当前主 server 公网 IP 是 `74.48.78.17``config.json` 中的 `network.publicHost` 必须保持为该地址;公网 frontend 入口是 `http://74.48.78.17:18081/`provider gateway 对外接入入口是 `ws://74.48.78.17:18082/ws/provider`provider ingress 健康检查是 `http://74.48.78.17:18082/health`。主 server 本机 provider 由根目录 `docker-compose.yml``provider-gateway` 服务启动,容器内使用 Docker 内网地址 `ws://backend-core:8081/ws/provider` 自接入;外部计算节点部署 provider-gateway 时必须改用公网 provider ingress URL,并复用 `config.json` / `.state/docker-compose.env` 中的 provider token、心跳间隔和重连参数。
计算节点部署 provider-gateway 的最小方法是:准备可运行 `unidesk_provider-gateway` 镜像的 Docker 环境,为节点分配唯一 `PROVIDER_ID` 与可读 `PROVIDER_NAME`,设置 `PROVIDER_SERVER_URL=ws://74.48.78.17:18082/ws/provider``PROVIDER_TOKEN``PROVIDER_LABELS_JSON``HEARTBEAT_INTERVAL_MS``RECONNECT_BASE_MS``RECONNECT_MAX_MS`,并挂载 `/var/run/docker.sock:/var/run/docker.sock` 作为 Docker 状态采集、任务执行和远程升级的唯一自动化通道。需要支持 `provider.upgrade`节点必须`PROVIDER_UPGRADE_*` 环境变量,把节点上的 UniDesk 仓库只读挂载到 `PROVIDER_UPGRADE_WORKSPACE_PATH`,并确保升级命令只重建 `provider-gateway` service,不影响 database、backend-core、frontend。需要维护桥连接 WSL 的节点必须额外设置 `HOST_SSH_HOST=host.docker.internal``HOST_SSH_PORT=22``HOST_SSH_USER=<WSL 用户>``HOST_SSH_KEY=/run/host-ssh/id_ed25519``HOST_REMOTE_CWD=/home/<WSL 用户>`,并把只含维护私钥的宿主目录只读挂载到 `/run/host-ssh`
计算节点部署 provider-gateway 的最小方法是:准备可运行 `unidesk_provider-gateway` 镜像的 Docker 环境,为节点分配唯一 `PROVIDER_ID` 与可读 `PROVIDER_NAME`,设置 `PROVIDER_SERVER_URL=ws://74.48.78.17:18082/ws/provider``PROVIDER_TOKEN``PROVIDER_LABELS_JSON``HEARTBEAT_INTERVAL_MS``RECONNECT_BASE_MS``RECONNECT_MAX_MS`,并挂载 `/var/run/docker.sock:/var/run/docker.sock` 作为 Docker 状态采集、任务执行和远程升级的唯一自动化通道。所有长期接入节点必须`PROVIDER_UPGRADE_*` 环境变量,把节点上的 UniDesk 仓库只读挂载到 `PROVIDER_UPGRADE_WORKSPACE_PATH`,并确保升级命令只重建 `provider-gateway` service,不影响 database、backend-core、frontend。需要维护桥连接 WSL 的节点必须额外设置 `HOST_SSH_HOST=host.docker.internal``HOST_SSH_PORT=22``HOST_SSH_USER=<WSL 用户>``HOST_SSH_KEY=/run/host-ssh/id_ed25519``HOST_REMOTE_CWD=/home/<WSL 用户>`,并把只含维护私钥的宿主目录只读挂载到 `/run/host-ssh`
## WSL Compute Node Deployment
@@ -20,7 +20,7 @@ WSL 节点应优先使用 WSL 内部原生 Docker Engine 和 `/var/run/docker.so
WSL provider 的最小环境文件应放在节点本地私有路径,例如 `/home/ubuntu/unidesk/.state/provider-<ID>.env`,并由 `docker run --env-file` 读取。`PROVIDER_LABELS_JSON` 在 Docker env-file 中可以写成单行 JSON;如果临时用 shell `source` 方式调试,必须对整段 JSON 加引号,否则 shell 会按 `{}` 和逗号拆分导致 JSON 解析失败。WSL 节点建议至少包含这些 labels:`host``role=wsl-provider``wsl=true``distro``docker=true`;运行时 provider-gateway 会自动追加 `runtime``dockerSocketPresent``gatewayUptimeSeconds``.state/provider-<ID>.env``logs/provider-<ID>/` 和容器日志属于节点本地运行态,必须保持在 `.gitignore` 覆盖范围内,不能提交 provider token、登录态或运行日志。
长期运行推荐用 systemd 管理 provider-gateway 容器,而不是只在交互 shell 中运行 Bun 进程。systemd unit 的稳定形态是:`ExecStartPre=-docker rm -f unidesk-provider-gateway-<ID>` 清理同名旧容器,`ExecStart=docker run --name unidesk-provider-gateway-<ID> --env-file ... -v /var/run/docker.sock:/var/run/docker.sock -v /home/ubuntu/unidesk:/workspace:ro -v /home/ubuntu/unidesk/logs/provider-<ID>:/var/log/unidesk unidesk_provider-gateway:<id>``ExecStop=docker stop unidesk-provider-gateway-<ID>`,并设置 `Restart=always`。临时部署可以直接使用 `docker run -d --restart unless-stopped`,但仍要保证容器名、env 文件、日志目录和镜像 tag 都带上节点 ID,便于 frontend、Docker 状态和本地排障互相对应。临时或一次性外部 WSL 节点如果没有完整远程升级回滚方案,应设置 `PROVIDER_UPGRADE_ENABLED=false`,保留 `provider.upgrade` 预检能力但禁止远端 schedule 自升级
长期运行推荐用 systemd 管理 provider-gateway 容器,而不是只在交互 shell 中运行 Bun 进程。systemd unit 的稳定形态是:`ExecStartPre=-docker rm -f unidesk-provider-gateway-<ID>` 清理同名旧容器,`ExecStart=docker run --name unidesk-provider-gateway-<ID> --env-file ... -v /var/run/docker.sock:/var/run/docker.sock -v /home/ubuntu/unidesk:/workspace:ro -v /home/ubuntu/unidesk/logs/provider-<ID>:/var/log/unidesk unidesk_provider-gateway:<id>``ExecStop=docker stop unidesk-provider-gateway-<ID>`,并设置 `Restart=always`。临时部署可以直接使用 `docker run -d --restart unless-stopped`,但仍要保证容器名、env 文件、日志目录和镜像 tag 都带上节点 ID,便于 frontend、Docker 状态和本地排障互相对应。`provider.upgrade` 是长期接入节点的必备能力,provider-gateway 不提供 `PROVIDER_UPGRADE_ENABLED` 或等价禁用开关;如果节点缺少升级环境变量,必须修正节点部署,而不是在服务端接受只能预检不能升级的半成品状态
WSL 本身会在没有前台进程时被 Windows 回收;如果该节点要作为长期在线算力,必须通过 Windows 启动项、计划任务或后台 `wsl.exe -d <distro> -u root -- bash -lc "systemctl start docker unidesk-provider-gateway-<ID>.service; exec sleep infinity"` 这类 keepalive 进程保持发行版运行。仅启用 WSL 内 systemd service 不等价于 Windows 层面的常驻守护。
@@ -58,6 +58,10 @@ provider-gateway 连接成功后必须周期性上报节点 CPU、内存和硬
backend-core 可以通过真实 WebSocket 调度向在线 provider 下发 `provider.upgrade``mode: "plan"` 只返回升级计划,用于 E2E 和人工预检;`mode: "schedule"` 会要求 provider-gateway 通过本地 Docker socket 启动一个 detached updater 容器,由 updater 在节点本地执行 `docker compose up -d --no-deps --build provider-gateway``--no-deps` 是强制要求,升级 provider-gateway 时不得重建或停止 database、backend-core、frontend。升级执行路径使用 Docker socket 和只读仓库挂载,不使用 Host SSH 维护桥作为自动调度通道。
远程升级策略固定为 always-enabled:只要 provider-gateway 在线并声明 `provider.upgrade``mode: "schedule"` 就必须真正调度升级容器,不允许被 `PROVIDER_UPGRADE_ENABLED=false`、前端隐藏按钮或服务端特殊名单禁用。升级能力的安全边界不是开关,而是显式 `PROVIDER_UPGRADE_*` 配置、Docker socket 权限、只读仓库挂载、固定 Compose service 和 `--no-deps` 约束。升级计划中必须展示 `policy: "always-enabled"`、updater 容器名、runner image、workspace、Compose project/service、env file、compose file 和实际 `docker run` 命令,方便前端任务历史与 CLI debug 直接诊断。
旧版 provider-gateway 如果只能返回 plan 或因为旧环境中的 `PROVIDER_UPGRADE_ENABLED=false` 拒绝 schedule,需要先通过任意现有维护通道手动 bootstrap 一次。bootstrap 的目标不是长期流程,而是把节点更新到支持 always-enabled 远程升级和 Host SSH / WSL SSH 维护桥的版本;完成后必须立刻用 `bun scripts/cli.ts debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 做一次真实一键升级验证,再用 `bun scripts/cli.ts debug health` 或公网 frontend 确认该节点仍在线、`unideskCapabilities` 包含 `provider.upgrade`,需要 SSH 维护的 WSL 节点还必须包含 `host.ssh`
## Host SSH Maintenance Bridge
宿主 SSH / WSL SSH 转发只作为应急维护辅助路径,不用于自动计算任务调度。实现参考 `../web-terminal` 的经验:容器内使用只读挂载的私钥,主动连接宿主或 WSL sshd,并设置 `BatchMode=yes``StrictHostKeyChecking=accept-new``ServerAliveInterval=20``ServerAliveCountMax=3`。主 server Compose 会把 `config.json``sshForwarding.keyDir` 只读挂载为 `/run/host-ssh`provider 标签会上报 `hostSshConfigured``hostSshKeyPresent``hostSshTarget`,便于在前端节点清单确认维护桥是否具备条件。
-8
View File
@@ -25,7 +25,6 @@ export interface UniDeskConfig {
reconnectMaxMs: number;
metrics: { diskPath: string };
upgrade: {
enabled: boolean;
hostProjectRoot: string;
workspacePath: string;
composeFile: string;
@@ -66,12 +65,6 @@ function numberField(obj: Record<string, unknown>, key: string, path: string): n
return value;
}
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
const value = obj[key];
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
return value;
}
function portPair(obj: Record<string, unknown>, key: string): { port: number; containerPort: number } {
const value = asRecord(obj[key], `network.${key}`);
return { port: numberField(value, "port", `network.${key}`), containerPort: numberField(value, "containerPort", `network.${key}`) };
@@ -126,7 +119,6 @@ export function readConfig(): UniDeskConfig {
diskPath: stringField(providerMetrics, "diskPath", "providerGateway.metrics"),
},
upgrade: {
enabled: booleanField(providerUpgrade, "enabled", "providerGateway.upgrade"),
hostProjectRoot: stringField(providerUpgrade, "hostProjectRoot", "providerGateway.upgrade"),
workspacePath: stringField(providerUpgrade, "workspacePath", "providerGateway.upgrade"),
composeFile: stringField(providerUpgrade, "composeFile", "providerGateway.upgrade"),
-1
View File
@@ -85,7 +85,6 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs),
UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs),
UNIDESK_MONITOR_DISK_PATH: config.providerGateway.metrics.diskPath,
UNIDESK_PROVIDER_UPGRADE_ENABLED: String(config.providerGateway.upgrade.enabled),
UNIDESK_PROVIDER_UPGRADE_HOST_PROJECT_ROOT: config.providerGateway.upgrade.hostProjectRoot,
UNIDESK_PROVIDER_UPGRADE_WORKSPACE_PATH: config.providerGateway.upgrade.workspacePath,
UNIDESK_PROVIDER_UPGRADE_COMPOSE_FILE: config.providerGateway.upgrade.composeFile,
+1 -13
View File
@@ -30,7 +30,6 @@ interface RuntimeConfig {
reconnectMaxMs: number;
dockerSocketPath: string;
monitorDiskPath: string;
upgradeEnabled: boolean;
upgradeHostProjectRoot: string;
upgradeWorkspacePath: string;
upgradeComposeFile: string;
@@ -105,13 +104,6 @@ function readOptionalNumberEnv(name: string): number | null {
return parsed;
}
function readBooleanEnv(name: string): boolean {
const raw = requiredEnv(name);
if (raw === "true") return true;
if (raw === "false") return false;
throw new Error(`Environment variable ${name} must be true or false, got ${raw}`);
}
function readConfig(): RuntimeConfig {
return {
serverUrl: requiredEnv("PROVIDER_SERVER_URL"),
@@ -124,7 +116,6 @@ function readConfig(): RuntimeConfig {
reconnectMaxMs: readNumberEnv("RECONNECT_MAX_MS"),
dockerSocketPath: requiredEnv("DOCKER_SOCKET_PATH"),
monitorDiskPath: requiredEnv("MONITOR_DISK_PATH"),
upgradeEnabled: readBooleanEnv("PROVIDER_UPGRADE_ENABLED"),
upgradeHostProjectRoot: requiredEnv("PROVIDER_UPGRADE_HOST_PROJECT_ROOT"),
upgradeWorkspacePath: requiredEnv("PROVIDER_UPGRADE_WORKSPACE_PATH"),
upgradeComposeFile: requiredEnv("PROVIDER_UPGRADE_COMPOSE_FILE"),
@@ -873,7 +864,7 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
script,
];
return {
enabled: config.upgradeEnabled,
policy: "always-enabled",
taskId,
updaterName,
runnerImage: config.upgradeRunnerImage,
@@ -894,9 +885,6 @@ async function runProviderUpgrade(taskId: string, payload: Record<string, JsonVa
const mode = payload.mode === "schedule" ? "schedule" : "plan";
const plan = upgradePlan(taskId);
if (mode === "plan") return { mode, message: "provider gateway upgrade plan generated; no container changed", plan };
if (!config.upgradeEnabled) {
throw new Error("provider gateway remote upgrade is disabled by PROVIDER_UPGRADE_ENABLED=false");
}
const dockerRunCommand = (plan.dockerRunCommand as JsonValue[]).map((item) => String(item));
const result = await runDockerCommand(dockerRunCommand.slice(1), 12_000);
if (!result.ok) {