diff --git a/AGENTS.md b/AGENTS.md index 4917cbb7..61f3a5c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `:以 build-first、label-scoped replace 的异步 job 重建单个服务,避免 Docker Compose v1 recreate 问题,规则见 `docs/reference/deployment.md`。 - `bun scripts/cli.ts ssh [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`。 diff --git a/TEST.md b/TEST.md index b558de42..9325dbe7 100644 --- a/TEST.md +++ b/TEST.md @@ -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` 作为兜底。 diff --git a/docker-compose.yml b/docker-compose.yml index c80d9fd8..5d9005a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: database: image: postgres:16-alpine diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 70d27388..a91a9088 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 ` 创建异步 job,先构建目标服务镜像,构建成功后只按 Compose project/service label 移除该服务旧容器,再用 `--no-deps` 启动目标服务;该命令用于替代手工删除容器的兜底流程。 - `ssh [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 ` 查询进度和尾部输出。 +`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`。 diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 57f5f6f8..f82fd68f 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -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 `。不要因为 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 `,其中 `` 只能是 `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 ` 启动目标服务,避免因为重建 frontend 而连带重启 database 或 backend-core。 + +当前主 server 只安装 Docker Compose v1 时,直接执行 `docker-compose up -d --no-deps --build frontend` 可能走 recreate 路径并触发 `ContainerConfig` 兼容问题。正式流程不得依赖人工 `docker rm` 兜底,而应由 `server rebuild frontend` 固化为可观测 job:build-first、label-scoped remove、no-deps up、保留 named volume。 + ## Health Criteria 服务跑通的最低标准是:backend-core 内网 `/health` 返回 ok,frontend 公网 `/health` 返回 ok,provider ingress 公网 `/health` 返回 ok,database 在容器内 `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` 的门禁作为最终判定。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 277bf690..d911c0eb 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -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 ", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." }, { command: "ssh [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 [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." }, @@ -141,6 +142,13 @@ async function main(): Promise { 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") { diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 70ad8f6b..0b480257 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -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) },