# 云测试发布目标:部署与验证(父执行) 目标:把本机治理平台的"整库发布执行"搬到云测试主机,与目标容器同机; 本机只保留封存源、平台与 capture 角色的 worker。本文所有命令都在**目标机上**执行, 除了明确标注"本机"的两步。 ## 0. 前置(缺一条都别继续) - 云端 compose 项目 `weknora-test-v071` 当前健康,且已确认 `weknora-test.dobot.cn` 的维护窗口可接受。 - 已交付镜像:`knowledge-governance-worker:<与验收提交同 sha>`(含 `/app/libraryctl`)。 - `/srv/kgtest-gate/{gate.py,state}`、`/srv/knowledge-governance/{library,target-journal,bundles,release-executor.env}`。 - 治理库接入方式已定:SSH 加密反向隧道(`deploy/cloud-test/pg-forward.sh`,默认 streamlocal unix socket)。本机 PG 在 Docker 内、无 5432 宿主机发布,**不要**写 `localhost:5432`。 ## 1. 云端入口闸门(一次) **先做 compose 身份核对(不能盲 `compose up`)**:父的现场记录里,某历史容器的 label 引用了已不存在的 `/tmp/weknora-test-compose.wiki-graph.yaml`。对这类容器执行 `docker compose up` 会让 compose 按"缺失的配置链"重建它,等于在没有可验证配置的情况下 重建业务容器。先逐容器读回配置链,只对**配置链文件都在**的服务做 `up -d`: ```bash for c in $(docker ps -q --filter label=com.docker.compose.project=weknora-test-v071); do docker inspect "$c" --format '{{.Name}} files={{index .Config.Labels "com.docker.compose.project.config_files"}}' done ``` 规则:`app`/`frontend` 的配置链齐全才允许 `up -d`;`ingress` 是本片段新增,没有历史包袱。 任何服务的 config_files 里有缺失文件 → **停手**,先补回可验证配置(或让父按运维流程处理), 不要用 `compose up` 把它"顺便"重建。只用显式服务名 `up -d app ingress frontend`, 不要 `up -d` 全项目(那会把无关服务一起重建)。 ```bash install -d -m 0755 /srv/kgtest-gate /srv/kgtest-gate/state install -m 0644 deploy/cloud-test/gate.py /srv/kgtest-gate/gate.py # 叠加片段来自本仓库 deploy/cloud-test/compose.release-overlay.yaml cd /opt/weknora-test-v071 # 现有 compose.yaml 所在目录 docker compose -f compose.yaml -f /path/to/compose.release-overlay.yaml config >/dev/null docker compose -f compose.yaml -f /path/to/compose.release-overlay.yaml up -d app ingress frontend curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health # 期望 200 docker inspect weknora-test-v071-app-1 --format '{{json .HostConfig.PortBindings}}' # 期望 null ``` 要点:`app` 不再发布宿主机端口(写入者发布端口 = 绕开闸门的直连,拓扑校验会拒绝); `ingress` 是宿主机 8080 上唯一的 HTTP 入口,`SIGTERM` 后写 `drain.json` 并 `exit 0`; `restart: "no"` 保证维护窗口停下的入口不会被 Docker 自己拉回来。 ### 1.1 入口不止 frontend:四条路径都要落到闸门上 只把 frontend 改成 `APP_HOST=ingress` **不等于**入口唯一。现场实测四条能到 app 的路径: | 入口 | 现状 | 必须改成 | 谁做 | |---|---|---|---| | 宿主机 nginx `weknora-test.dobot.cn` → `127.0.0.1:8088` | frontend | 不用改;frontend 的 `/api` 由 `APP_HOST` 决定 | 父不改 | | frontend 容器 → `app:8080` | `APP_HOST=app` | overlay 已改为 `APP_HOST=ingress` | overlay | | knowledge-adapter 容器 → `http://app:8080`(双网络) | 直连 app | adapter 的 `WEKNORA_BASE_URL=http://ingress:8080` | 父改 adapter 部署 | | DobotAI(host 网络)→ `http://127.0.0.1:8080` | 走宿主机发布端口 | 不用改;overlay 后该端口就是闸门 | 无 | `scripts/verify_cloud_release_target.py` 的 `entry/*` 三项就是在核对这张表 (adapter 仍指 `app:8080` 时报 FAIL —— 它就是一条真实存在的绕闸通道)。 ## 2. 快照(本机打包 → 云端导入) 自动 handoff 使用 `RELEASE_BUNDLE_DIR`(默认 `/srv/knowledge-governance/bundles`);启动器创建 0700 目录并在执行器内同路径挂载。本机 API/worker 的 `LIBRARY_HANDOFF_BUNDLE_DIR` 必须与它一致,因为 SSH 在宿主机写入、libraryctl 在容器中读取同一路径。手工单次导入可像下例单独映射 `/bundles`,但不能混用到自动传输配置。 ```bash # 本机(只读归档;输出了每个 blob 的 sha256,可与云端对拍) libraryctl snapshot-pack -snapshot -bundle /var/tmp/.tar # 传到云端 scp /var/tmp/.tar weknora-test:/srv/knowledge-governance/bundles/ # 云端:校验 + 原子装入(版本清单是提交点;重复导入幂等) docker run --rm \ -v /srv/knowledge-governance/library:/var/lib/knowledge-governance/library \ -v /srv/knowledge-governance/bundles:/bundles:ro \ knowledge-governance-worker: \ /app/libraryctl snapshot-unpack \ -archive /var/lib/knowledge-governance/library \ -bundle /bundles/.tar \ -snapshot ``` 导入失败时不会留下版本:blob 以 sha256 命名且先落盘,版本清单最后才 Link。 执行器每次使用快照前还会 `Load` 逐 blob 重算 sha256,所以"传输不可变"有两道证据。 ## 2.5 治理库连接(SSH 加密反向隧道) 本机治理 PG 在 Docker 内且没有 5432 宿主机发布,不能写 `localhost:5432`。 `deploy/cloud-test/pg-forward.sh`(本机运行)解析 PG 容器的真实 IP 后建反向转发: ```bash MODE=streamlocal bash deploy/cloud-test/pg-forward.sh # 推荐:云侧只出现 unix socket MODE=tcp bash deploy/cloud-test/pg-forward.sh # 云侧 127.0.0.1:15433,仅宿主进程可用 ``` - **streamlocal(默认)**:云侧生成 `/run/kgtest-pg/.s.PGSQL.5432`,执行器容器把该目录挂进去, `DATABASE_URL` 用 `host=/run/kgtest-pg&port=5432`。没有 TCP 端口、不受"容器看不到宿主 127.0.0.1"影响。 - **tcp**:`ssh -R 127.0.0.1:15433` 只在云**宿主机** loopback 上;执行器容器里的 127.0.0.1 是它自己,**不能**直接用。要用就必须在云侧再加 docker bridge → 127.0.0.1 的桥接(socat/haproxy),那是父的运维决定,脚本不替你开。 - 隧道断 = 执行器拿不到租约 = 作业 fail-closed,目标保持维护态(不会"假装成功")。 ### 治理凭据与挂载 目标机准备非空 0600 `/srv/knowledge-governance/governance.pgpass`,只读挂载为 `/run/secrets/governance_pgpass`;env 必须显式设置 `PGPASSFILE` 为该容器路径。 Unix socket 的 pgpass 主机匹配使用 `localhost`,字段中的冒号和反斜线须按 pgpass 规则转义。 不要把本机无密码 DATABASE_URL 当成已携带凭据。启动器挂载 `/run/kgtest-pg`, 默认网络为 `weknora-test-v071-network`;目录或密码文件不满足要求时在重建前拒绝。 可用 `RELEASE_PG_SOCKET_DIR` / `RELEASE_PGPASS_FILE` 覆盖宿主路径,容器路径不变。 执行器显式启动 `/app/worker`,不能沿用镜像默认的 `/app/api`;仅该执行器以 `--user 0:0` 运行,访问 root 私有 socket、0600 凭据和 0700 归档。挂载 Docker socket 意味着它具有宿主容器控制权限,并非只读权限;不得把该身份用于对外 API 或前端。 镜像必须预先加载,启动使用 `--pull never`,不允许现场隐式拉取替代产物。 ## 3. 执行器 ```bash # 1) 填写 /srv/knowledge-governance/release-executor.env(0600 root), # 字段见 deploy/cloud-test/release-executor.env.example。 # 2) 先让本机变成 capture 角色(否则两边都注册 release,谁抢到按谁的配置执行): # 本机 compose 加 LIBRARY_WORKER_ROLE=capture 重启 worker,日志应出现 # role=capture release=false source=true。 # 3) 起云端执行器: RELEASE_EXECUTOR_IMAGE=knowledge-governance-worker: bash deploy/cloud-test/run-release-executor.sh docker logs --tail 30 kgtest-release-executor # 期望:execution domain admitted / role=release-executor ``` ## 4. 只读预检(任何时候都可跑) ```bash python3 scripts/verify_cloud_release_target.py --host weknora-test --snapshot ``` 判据:ssh、项目服务清单、写入者无发布端口、闸门 healthy 且发布 8080、经闸门 /health=200、 drain 目录可写且无残留、redis/ES/RDS 可达、归档根 0700、快照清单摘要自证、执行器角色。 `MANUAL target-graph` 必须人工确认目标 KB `graph_enabled=false`(发布路径不写 Neo4j)。 有 FAIL 即退出码 1。 ## 5. 回退 ```bash docker rm -f kgtest-release-executor # 本机 worker 改回 LIBRARY_WORKER_ROLE=full(或保持 capture)并重启 cd /opt/weknora-test-v071 docker compose -f compose.yaml -f /path/to/compose.release-overlay.yaml down # 或去掉 -f overlay 重新 up: docker compose -f compose.yaml up -d app frontend ``` 治理库不需要回滚:维护记录与版本证据都带 `target_fingerprint` 与实例身份, 跨目标不会被误认。云端归档里的快照与预备份保留,不自动删除。 ## 6. 已知边界(交付时写明,不当成已完成) - 本机 UI 的"版本与快照"读的是本机归档:云端预备份/快照原件在页面上看不到, 只能看到治理库里的版本证据行。需要页面展示云端原件时再单独做只读读回链路。 - 目标 KB `graph_enabled` 没有 fail-closed 检查(当前只有源侧导出拒绝)。 - 维护窗口内 `weknora-test.dobot.cn` 会不可用(这是语义正确的关闭)。 - **归档自动传输未实现**:今天只有 `libraryctl snapshot-pack / snapshot-unpack`(人工两步)。 UI 里点"发布/恢复"不会把本机快照送到云端执行器,也不会把云端预备份读回本机页面。 一致寻址的现状与阻塞见本文 §7。 ## 7. 归档寻址:现状、缺口与阻塞(不许当成已完成) **寻址口径(现状)**:快照是内容寻址的(`snapshot_id` = 版本清单 sha256),本机与云端 各自有独立 `LIBRARY_ARCHIVE_ROOT`;治理库的版本证据行记录 `snapshot_id` 与 `source_job_id`。 因此"这份版本是谁"跨主机一致,而"这份版本的字节现在在哪台机器"**没有**被记录。 后果(真实缺口): 1. 本机页面能看到云端执行器登记的版本证据行,但**看不到云端预备份/快照原件**; 点"按历史版本恢复"时,若该快照不在执行器所在机器上,作业会在装配阶段失败 (`librarysnapshot.Load` 读不到),错误信息只会说"快照不可读"。 2. UI 无法触发传输:本机 API 没有读归档/推送归档的接口,云端也**不能**主动连本机 (公网 22 关闭、无入站);今天唯一通路是人工 `pack` → `scp` → `unpack`。 **为什么不用脚本硬糊**:定时 rsync/scp 无法保证"版本清单与 blob 同时可见 + 字节可校验", 而发布前的 `Load` 会逐 blob 重算 sha256,半拷贝的归档会让发布在维护窗口内失败, 正好落在最不该失败的时刻。 **建议的最小协议(未实现,需父批准)**:给执行器一条**出站**拉取路径 —— `GET /api/v1/library/snapshots/{id}/bundle`(本机 API 侧,仅服务已封存且未过期的快照, 返回带 sha256 的流)+ 在版本证据里补 `archive_host` 字段。执行器在装配前若本地缺快照, 按证据里的 `archive_host` 标记走到该 API 拉取并复用 `Unpack` 的校验/原子落位。 工作量约:API 1 个只读端点 + 证据字段 + 执行器一次拉取调用(~300 行 + 权限/审计)。 通路本身可以复用已有的 `ssh -R`(再加一条把本机 API 映到云侧 loopback/unix socket 的 反向转发),所以阻塞不在"连不上",而在:① 该端点等于让云侧凭据读走整库快照, 需要单独的鉴权与审计口径;② 版本证据要新增 `archive_host`,否则 UI 无法判断"该去哪台机器取"; ③ 拉取失败必须与"目标已进维护窗口"的时序绑定,不能先开窗口再发现取不到。 这三条都要父拍板,属于新协议而非配置。 ## 8. 自动快照搬运(run03 已实现,需按本节接线) 没有这一步,UI 点"发布/恢复"只会在执行器上以"快照不在本机归档"失败(fail-closed,不会开维护窗口)。 接线后链路变成:**授权 → 搬运并双向校验 → 才投递发布作业**。 ### 8.1 接线(API 与 capture worker 都要有同一组值) | 变量 | 含义 | |---|---| | `LIBRARY_HANDOFF_SSH_CONFIG` | 0600 ssh 配置(与 `BACKUP_HOST_SSH_CONFIG` 同一份即可,指向 `weknora-test`) | | `LIBRARY_HANDOFF_TARGET` | ssh 配置里的 Host 别名(`weknora-test`) | | `LIBRARY_HANDOFF_EXECUTOR` | 云端执行器容器名(`kgtest-release-executor`) | | `LIBRARY_HANDOFF_BUNDLE_DIR` | 云端暂存目录(`/srv/knowledge-governance/bundles`,只放传输中的 bundle) | | `LIBRARY_HANDOFF_REMOTE_ARCHIVE` | 云端归档根(必须等于执行器 env 的 `LIBRARY_ARCHIVE_ROOT`) | | `LIBRARY_HANDOFF_STAGING_DIR` | 本机暂存目录(容器内可见的 0700 目录) | | `LIBRARY_HANDOFF_MAX_BYTES` | 单份 bundle 上限(默认 8GiB) | | `LIBRARY_HANDOFF_TIMEOUT` | 单个远端动作超时(默认 10m) | | `LIBRARY_HANDOFF_PULL_INTERVAL` | 云端版本回传本机的扫描间隔(默认 5m;游标跨轮保留,重启后从头核对) | 目标/源身份(run05 起必需,二者**必须不同**): `LIBRARY_PRODUCTION_TENANT_ID` + `LIBRARY_PRODUCTION_KB_INTERNAL/EXTERNAL` = 云测试验收专用 KB (对内 `a8e9c8ad-411c-4c53-8421-599d29e9b179`、对外 `2e980686-2233-4c4e-b5a6-8b0069477b8e`,tenant 10000); `LIBRARY_SOURCE_TENANT_ID` + `LIBRARY_SOURCE_KB_INTERNAL/EXTERNAL` = 源 WKNFull 的 KB。 搬运按版本台账判定原产地:封存快照用源绑定核对,预备份用目标绑定核对;没有捕获事件或两类都有的快照一律拒绝。 原有业务四 KB(graph=true)不关闭、不覆盖;验收专用 KB 的 graph_enabled=false。 任一项缺失但其余存在 ⇒ **启动失败**(半接线最坏:API 以为会搬运、worker 搬不动)。 全都不配 ⇒ 保持旧行为(本机目标直接发布),不受影响。 ### 8.2 行为 - 发布/恢复作业先入 `library.snapshot.handoff`(capture 角色执行):本机 `Load` 逐 blob 校验 → `Pack` → SSH 流式写云端暂存 → 原子改名 → 云端 `libraryctl snapshot-unpack`(再逐 blob 校验)→ 读回版本清单摘要自证 → 身份(scope/tenant/KB)与治理库作业一致 → **才**投递 `library.sync|restore` (ID 仍是 `QueueJobIDFor(jobID)`,接管/续作路径不变)。 - 失败即作业失败,文案写明"未进入维护窗口、未写任何知识";发布队列行不存在。 - 云端已有同 ID 且校验通过 ⇒ 复用(不发数据);同 ID 异内容 ⇒ 拒绝。 - 同一快照并发搬运:本机 flock 串行化,云端内容寻址 + Link 幂等。 - 暂存 bundle 用后即删(只删自己这次的传输文件);归档本体(versions/blobs)**永不自动清理**。 - 本机回传:capture 角色按 `library_jobs` 里已成功作业的 `snapshot_id` 扫描,本机缺哪个就 `Fetch` 哪个(云端 pack → SSH 流回 → 本机 `Unpack` 逐 blob 校验)。回传成功后本机既有 API/页面路径(列表/详情/差异/存储/历史恢复)读到的就是真实字节;回传失败则版本不出现在本机, 页面不会把它显示成可恢复。 ### 8.3 只读核对命令(父可用) ```bash # 本机:把某个已封存快照推到执行器(会打印 bundle 每个 blob 的 sha256,可对拍) LIBRARY_HANDOFF_...=... libraryctl snapshot-push -snapshot -scope internal -tenant -kb # 本机:把云端快照取回本机归档 libraryctl snapshot-fetch -snapshot ``` 两条命令都只认部署注入的配置:不接受调用方指定远端路径/容器/上游地址。