fix: add safe frontend rebuild workflow

This commit is contained in:
Codex
2026-05-05 02:45:58 +00:00
parent cce8a78508
commit 5a62af6260
7 changed files with 79 additions and 3 deletions
+1
View File
@@ -11,6 +11,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts server start`:以异步 job 启动 database、backend-core、frontend、provider-gateway,部署规则见 `docs/reference/deployment.md`
- `bun scripts/cli.ts server status`:查询固定端口、容器状态、健康检查和访问 URL,判定标准见 `docs/reference/deployment.md`
- `bun scripts/cli.ts server logs`:分页返回文件日志与 Docker 日志尾部,日志规则见 `docs/reference/observability.md`
- `bun scripts/cli.ts server rebuild <backend-core|frontend|provider-gateway>`:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,使用规则见 `docs/reference/cli.md``docs/reference/provider-gateway.md`
- `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。
- `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`
+4
View File
@@ -75,3 +75,7 @@
## T18 Provider Gateway 版本与自动更新记录
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:gateway-version-label``frontend:gateway-version-records-visible` passed;再登录公网 frontend,进入 `资源节点 / 网关版本`,确认每个 Provider 行都显示 provider-gateway 版本号、升级策略、能力摘要、最近自动更新记录,并在下方以表格记录 `provider.upgrade` 的状态、模式、任务 id、来源、耗时、策略、结果摘要和更新时间。自动更新记录默认必须是结构化控件,不得展示裸 JSON;完整 task/result 只能通过 `查看原始JSON` 按钮查看。
## T19 前端单服务重建
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md``scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts server rebuild frontend`,确认命令立即返回 `server_rebuild` job id;随后运行 `bun scripts/cli.ts job status latest` 直到状态为 `succeeded`stdout 中必须能看到先 build、再按 `frontend` 服务容器 label 移除、最后 `--no-deps frontend` 启动的过程。重建后运行 `bun scripts/cli.ts server status``bun scripts/cli.ts e2e run`,确认公网 frontend 恢复健康、Playwright 登录通过、database 命名卷未被删除;正式验收不得要求人工执行 `docker rm` 作为兜底。
-2
View File
@@ -1,5 +1,3 @@
version: "3.8"
services:
database:
image: postgres:16-alpine
+3
View File
@@ -12,6 +12,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
- `server stop` 创建异步 job,在后台停止固定 Compose project 中的全部 UniDesk 服务。
- `server status` 查询公开端口、内部端口、Compose 容器、core/frontend/provider/database 健康检查和访问 URL。
- `server logs` 返回 `logs/` 文件日志和 Docker 容器日志的尾部,默认限制输出大小,避免日志爆炸。
- `server rebuild <backend-core|frontend|provider-gateway>` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程。
- `ssh <providerId> [ssh-like args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;无后续参数时进入远端登录 shell,有后续参数时按 ssh 远端命令体验执行并返回远端 exit code。
- `job list``job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。
- `debug health``debug dispatch``debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。
@@ -21,6 +22,8 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
长时操作采用 Fire-and-Forget 模式:CLI 创建 `.state/jobs/{jobId}.json`,后台进程执行真实命令,并将 stdout、stderr 分别写入 `.state/jobs/{jobId}.stdout.log``.state/jobs/{jobId}.stderr.log`。调用者通过 `bun scripts/cli.ts job status <jobId>` 查询进度和尾部输出。
`server rebuild``server start``server stop` 一样必须通过 job 状态确认结果。重建 frontend 的标准流程是运行 `bun scripts/cli.ts server rebuild frontend`,随后轮询 `bun scripts/cli.ts job status latest``succeeded`,再用 `server status``e2e run` 验证公网 frontend;不得把 `docker rm` 手工兜底当成正式交付步骤。
## Output Contract
每条命令的最外层 JSON 包含 `ok``command``data``error`。失败时 CLI 设置非零退出码,但仍然输出 JSON 错误对象;错误对象应包含 `name``message` 和可用的 `stack`
+14
View File
@@ -13,10 +13,24 @@
Docker Compose 只能向公网暴露两个接口:frontend host port 和 provider ingress host port。backend-core REST API 和 PostgreSQL database 必须只在 Docker 内部网络中可达,不允许映射到宿主机公网端口;浏览器访问 core API 必须通过 frontend 的同源代理完成。
## Docker Compose Runtime
CLI 会优先使用 `docker compose` v2 plugin;当 v2 plugin 不存在时才回退到 `docker-compose` v1。主 server 可以通过安装系统包形式的 Compose v2 plugin 完成无中断升级,因为该动作只增加 Docker CLI plugin,不要求重启 Docker daemon,也不要求重建或停止已有容器。若 Compose v2 build 提示 buildx plugin 不存在,应同样以系统包安装 buildx CLI plugin,而不是重启 Docker 或重建业务容器。
Compose v2 安装后仍然必须遵守 UniDesk 的服务控制入口:全栈生命周期用 `server start` / `server stop`,单服务重建用 `server rebuild <service>`。不要因为 v2 可用就直接在生产栈上手工执行未纳入 CLI 的 `up --build``down -v` 或跨项目清理命令;所有会影响容器的动作都应保持 job 可观测、Compose project 固定、database named volume 保留。
## Start And Stop
`bun scripts/cli.ts server start``bun scripts/cli.ts server stop` 都是异步 job。启动 job 会先清理固定 Compose project 的旧容器,再重新构建并启动,避免主 server 上残留旧容器或旧镜像配置。启动和停止流程禁止删除 Docker named volume。
## Single Service Rebuild
前端、backend-core 或本机 provider-gateway 需要重建时,统一使用 `bun scripts/cli.ts server rebuild <service>`,其中 `<service>` 只能是 `backend-core``frontend``provider-gateway`。该命令先执行目标服务镜像构建,只有构建成功后才移除旧容器,避免构建失败导致运行中的服务被提前停掉。
单服务重建必须按 Docker Compose label 精确选择旧容器:`com.docker.compose.project` 等于 `config.json` 中的固定 project name`com.docker.compose.service` 等于目标服务名。删除范围不得扩大到其他 Compose project、database 容器、named volume 或未匹配 label 的容器;随后必须通过 CLI 解析出的 Compose 命令执行 `up -d --no-deps <service>` 启动目标服务,避免因为重建 frontend 而连带重启 database 或 backend-core。
当前主 server 只安装 Docker Compose v1 时,直接执行 `docker-compose up -d --no-deps --build frontend` 可能走 recreate 路径并触发 `ContainerConfig` 兼容问题。正式流程不得依赖人工 `docker rm` 兜底,而应由 `server rebuild frontend` 固化为可观测 jobbuild-first、label-scoped remove、no-deps up、保留 named volume。
## Health Criteria
服务跑通的最低标准是:backend-core 内网 `/health` 返回 okfrontend 公网 `/health` 返回 okprovider ingress 公网 `/health` 返回 okdatabase 在容器内 `pg_isready` 可用,`/api/nodes` 中出现 `main-server` provider 且状态为 `online``/api/nodes/system-status` 中出现 `main-server` 的 CPU/内存/硬盘采样,`/api/nodes/docker-status` 中出现 `main-server` 的 Docker 快照。交付前还必须运行 `bun scripts/cli.ts e2e run`,并以 `docs/reference/e2e.md` 的门禁作为最终判定。
+9 -1
View File
@@ -1,6 +1,6 @@
import { readConfig } from "./src/config";
import { debugDispatch, debugHealth, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug";
import { stackLogs, stackStatus, startStack, stopStack } from "./src/docker";
import { isRebuildableService, rebuildService, stackLogs, stackStatus, startStack, stopStack } from "./src/docker";
import { runE2E } from "./src/e2e";
import { emitError, emitJson } from "./src/output";
import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
@@ -25,6 +25,7 @@ function help(): unknown {
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
{ command: "server rebuild <backend-core|frontend|provider-gateway>", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." },
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge." },
{ command: "job list", description: "List async jobs from .state/jobs." },
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
@@ -141,6 +142,13 @@ async function main(): Promise<void> {
emitJson(commandName, stackLogs(config, numberOption("--tail-bytes", 3000)));
return;
}
if (sub === "rebuild") {
if (!isRebuildableService(third)) {
throw new Error("server rebuild requires one of: backend-core, frontend, provider-gateway");
}
emitJson(commandName, rebuildService(config, third));
return;
}
}
if (top === "job") {
+48
View File
@@ -18,6 +18,13 @@ export interface ContainerStatus {
ports: string;
}
const rebuildableServices = ["backend-core", "frontend", "provider-gateway"] as const;
export type RebuildableService = typeof rebuildableServices[number];
export function isRebuildableService(value: string | undefined): value is RebuildableService {
return rebuildableServices.some((service) => service === value);
}
export function resolveComposeCommand(config: UniDeskConfig, envFile: string): string[] {
const composeFile = rootPath(config.docker.composeFile);
if (commandOk(["docker", "compose", "version"], repoRoot)) {
@@ -133,6 +140,47 @@ export function stopStack(config: UniDeskConfig): unknown {
return { job, runtimeEnv, command, portsBeforeStop: fixedPorts(config) };
}
export function rebuildService(config: UniDeskConfig, service: RebuildableService): unknown {
const runtimeEnv = writeComposeEnv(config, false);
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
const buildCommand = [...compose, "build", service];
const listServiceContainersCommand = [
"docker",
"ps",
"-aq",
"--filter",
`label=com.docker.compose.project=${config.docker.projectName}`,
"--filter",
`label=com.docker.compose.service=${service}`,
];
const upCommand = [...compose, "up", "-d", "--no-deps", service];
const script = [
"set -euo pipefail",
`echo ${shellJoin(["rebuild_service", service, "build_first_then_replace_container"])}`,
shellJoin(buildCommand),
`ids=$(${shellJoin(listServiceContainersCommand)})`,
`if [ -n "$ids" ]; then echo "remove_existing_compose_service_containers service=${service} ids=$ids"; docker rm -f $ids; else echo "no_existing_compose_service_containers service=${service}"; fi`,
shellJoin(upCommand),
].join("; ");
const command = ["bash", "-lc", script];
const job = startJob("server_rebuild", command, `Rebuild and replace UniDesk ${service} without Docker Compose v1 recreate`);
return {
job,
runtimeEnv,
service,
command,
strategy: {
buildBeforeRemove: true,
removeScope: {
projectLabel: config.docker.projectName,
serviceLabel: service,
},
noDeps: true,
namedVolumesPreserved: true,
},
};
}
function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number; listening: boolean }> {
return [
{ name: "frontend", port: config.network.frontend.port, listening: isPortListening(config.network.frontend.port) },