* 部署自动化:唯一发布链路 deploy.py + host_deploy.sh,sha/commit 双重验真,失败回滚 旧链路三次栽过的坑:通用 tar 路径导致旧包误部署、传输不验 sha、 主机脚本多份漂移、health 通过不等于新代码上线。本单把四点变硬门禁: - 产物唯一命名 codex-app-server-<commit>.tar.gz + .sha256,远端唯一路径 - git archive 产物 + DEPLOYED_COMMIT 清单,默认拒绝脏工作区 - 传输链 scp -> scp_run -> chunk 分片,每路传完远端验 sha,不符删掉重来 - 主机侧唯一脚本:先验 sha/清单,staging 解包 + 编译检查,换目录上线, work/ 原样保留,health + 上线 commit 不符即回滚并退出非零 - deploy.py 成功后独立 ssh 再读一次 live 清单二次核对 - CI:host-verify 加 deploy/tests;release.yml 只在手动/tag + production environment 审批时部署,主干推送绝不自动部署生产 - deploy.sh 标记为旧链路弃用 - health 的 commit 字段已在父提交 HA 阶段 0 里随 server.py 落地, 本分支整体包含它;commit 为空时只靠文件核对,门禁同样成立 测试:host 32、cloud 487(4 跳过)、deploy 9,全部通过; host_deploy.sh 仿真覆盖成功/坏 sha/清单不符/health commit 不符回滚。 生产实机部署未做,等确认后再用新链路推。 * HA stage 1: worktree_host + queued cross-host PR jobs --------- Co-authored-by: host <host@test.local>
241 lines
13 KiB
Markdown
241 lines
13 KiB
Markdown
# 部署与运维(deploy/)
|
||
|
||
本目录是主机部署与运维的脚本集,与 `cloud/` 代码分开:部署脚本只负责
|
||
传代码、备份、重启、验证,不改任务逻辑;运维脚本只负责检查与演练,
|
||
不跑模型、不推代码。
|
||
|
||
## 发布链路(新,默认用这个):deploy.py + host_deploy.sh
|
||
|
||
VM 侧运行,一条命令走完构建、传输、验真、部署、回滚核对:
|
||
|
||
```sh
|
||
python3 deploy/deploy.py <host> [user]
|
||
# 干跑(只构建、不传输、不动主机):
|
||
python3 deploy/deploy.py <host> --dry-run
|
||
# 只构建发布包(给 CI artifact / 手动检查用):
|
||
python3 deploy/deploy.py --build-only --out-dir work/external-install/releases
|
||
# 部署已有发布包(CI release 作业就是这么调的):
|
||
python3 deploy/deploy.py <host> --tarball <包> --skip-tests
|
||
```
|
||
|
||
它修掉的正是旧手工链路三次栽过的坑:
|
||
|
||
- **产物唯一命名,旧包无法冒充新包。** 发布包是
|
||
`codex-app-server-<commit>.tar.gz` + `.sha256`,远端路径是
|
||
`/tmp/codex-app-server-releases/<commit>.tar.gz`,不再用
|
||
`/tmp/codex-app-server-code.tar.gz` 这种通用路径。scp 断了、
|
||
旧包残留,都不可能被下一步误当成新包。
|
||
- **传输、主机、二次核对三道 sha/commit 门。** 传输链自动
|
||
fallback:scp(SSH_PROXY_CMD)→ ssh-helpers/scp_run.py(此前
|
||
实测最稳)→ ssh-helpers/chunk_send.py(base64 分片);每路
|
||
传完都在远端跑 `sha256sum` 比对,不符就删掉该次产物再试
|
||
下一路,绝不把错包交给主机脚本。主机侧只用
|
||
`deploy/host_deploy.sh` 这一份(VM 侧随包一起传过去,不依赖
|
||
主机旧代码里的版本),它先验 tar sha、再验包内
|
||
`DEPLOYED_COMMIT`、再 staging 解包 + 编译检查,任何一步
|
||
不符都直接退出、旧代码原地不动。
|
||
- **换目录代替原地 rm + 解包。** 主机侧先停服务,再把 live
|
||
换成 old、staging 换成 live,`work/` 与
|
||
`host-config-real.json`(如有)原样搬回;中途失败不会留
|
||
半解包的 live。备份名含旧 commit + 时间戳,留最近 5 份。
|
||
- **health 通过不再等于上线。** 主机脚本要求 health
|
||
`ok=true` 且 `backend=postgres` 且 `db_ok=true`,且 live 的
|
||
`DEPLOYED_COMMIT` 等于期望 commit;deploy.py 成功后还要
|
||
独立 ssh 再读一次 live 清单核对,两处都对上才打印成功。
|
||
旧链路“health 通过但跑的是旧代码”在新链路里会触发回滚
|
||
并退出非零。health 的 `commit` 字段已在父提交 HA 阶段 0
|
||
里随 server.py 一起落地(`_deployed_commit`,无文件时为
|
||
null);本单不依赖它也能成立,commit 为空时只靠文件核对。
|
||
- **产物来自 git archive,默认拒绝脏工作区。** 部署的永远是
|
||
已提交的 HEAD,不是工作区里没提交的临时改动;`--allow-dirty`
|
||
也只告警,部署的仍是 HEAD,不会把脏文件偷偷带上去。
|
||
- **CI 发布与手工发布同一套脚本。** `.github/workflows/release.yml`
|
||
只在手动 workflow_dispatch 或打 `v*` tag 时触发,走
|
||
`environment: production` 审批;主干推送绝不自动部署生产。
|
||
Actions 里没配 `DEPLOY_HOST` / `DEPLOY_SSH_KEY` 时,build
|
||
作业仍产出发布包 artifact,deploy 作业明确失败并提示改走
|
||
VM 侧 deploy.py,绝不静默跳过又报成功。
|
||
|
||
多机滚动(HA 阶段 2 的前置):当前 deploy.py 一次只推一台
|
||
主机,多机时按主机逐台跑、每台 health + commit 都通过再推
|
||
下一台,第一台失败就停,不要并行推两台。`--hosts` 批量滚动
|
||
是后续一单,不在本轮做。
|
||
|
||
密钥规则与旧脚本一致:deploy.py 绝不接受密码参数、不读
|
||
env 文件、不打印密钥;SSH 走 key,或调用方已有的
|
||
ssh-helpers pty 通道(密码只走 `SSH_PASSWORD` 环境变量)。
|
||
|
||
## 部署:deploy.sh(旧链路,已弃用,新发布不要再用)
|
||
|
||
VM 侧运行:
|
||
|
||
```sh
|
||
deploy/deploy.sh <host> [user] [--dry-run]
|
||
# 例如:deploy/deploy.sh 124.222.165.170 root
|
||
# 干跑(只打印步骤,不传代码、不动主机):
|
||
# deploy/deploy.sh <host> --dry-run
|
||
```
|
||
|
||
- 密码:脚本绝不接受密码参数、不读 env 文件、不打印密钥。SSH 密码由
|
||
ssh/scp 交互提示输入,或由调用方自己的 SSH 通道(key)提供。
|
||
- 经代理:设环境变量 `SSH_PROXY_CMD` 传入 ProxyCommand 内容,ssh 会自行
|
||
展开 `%h %p`,例如:
|
||
`SSH_PROXY_CMD="python3 /tmp/proxy_tunnel.py %h %p" deploy/deploy.sh <host>`;
|
||
未设则直连 ssh。
|
||
- 备份保留策略:每次部署前在主机上把当前 `/opt/codex-app-server` 打包到
|
||
`/opt/codex-app-server-backups/codex-app-server-<时间戳>.tar.gz`(排除
|
||
work/ 目录以免过大),只留最近 3 份,多余的自动删掉。
|
||
- 健康门:重启后循环 curl `localhost:8765/health` 最多 60 秒,要求
|
||
`ok=true` 且 `backend=postgres` 且 `db_ok=true`;失败时自动恢复刚打的
|
||
备份包并重启,再验一次。回滚后 health 恢复也算部署失败(退出非零),
|
||
因为新代码没上去;回滚后 health 仍失败则退出非零并明说
|
||
“已回滚但 health 仍失败,需人工介入”。
|
||
- 成功时只打印新代码的 git commit(从本机 git 取,不编造版本号)。
|
||
|
||
## 回滚:rollback.sh
|
||
|
||
VM 侧运行,恢复主机上最新的一份备份包:
|
||
|
||
```sh
|
||
deploy/rollback.sh <host> [user]
|
||
```
|
||
|
||
恢复后同样重启并验 health(60 秒),成功/失败都明说;主机上没有备份包
|
||
时直接报错退出,不编造。
|
||
|
||
### 回滚边界:只回代码,不回数据库
|
||
|
||
数据库迁移是只加列的幂等操作(例如 projects.repo_url、tasks 的 6 列),
|
||
回滚代码后老代码仍能读新列——新列对老代码只是多余的列,不影响已有
|
||
查询(老代码的 SELECT 只取它认识的列,或用 `_row_get` 容错)。所以只回
|
||
代码是安全的;数据库本身绝不会被 deploy.sh / rollback.sh 动到。这一点
|
||
逻辑上成立的前提是:迁移永远只加列、不改列类型、不删列;如果将来有一次
|
||
迁移要改类型或删列,必须先单独评估回滚方案,不能直接套用本脚本。
|
||
|
||
## 备份恢复演练:restore-drill.sh
|
||
|
||
主机侧运行(root,或能读 `/etc/codex-app-server/env` 的用户):
|
||
|
||
```sh
|
||
sh /opt/codex-app-server/deploy/restore-drill.sh
|
||
# 可选:BACKUP_DIR=/var/lib/codex-app-server/backups(默认就是这个)
|
||
```
|
||
|
||
步骤:跑一次 `backup_postgres` 生成新鲜备份 → 新建测试库
|
||
`codex_control_restore_test_<时间戳>` → 用 `cloud.backup.restore_postgres`
|
||
把备份恢复进去 → 比对生产库与测试库的 tasks / projects / users /
|
||
audit_log 行数(行数相等即通过,只比行数,不比对具体内容,不打印任何
|
||
行内容)→ 比对完立即删掉测试库(trap 保证中途失败也删)。
|
||
|
||
安全红线:恢复目标永远是新生成的测试库名,绝不等于生产库名;
|
||
`restore_postgres` 本身也会拒绝目标库名等于 `CLOUD_DB_NAME`
|
||
(`allow_production` 绝不在本脚本里设为 True),双重保险。本脚本绝不
|
||
恢复到生产库,绝不删生产库;全程只打印 PASS/FAIL 与行数,不打印密码,
|
||
不打印备份内容。
|
||
|
||
## 监控:monitor.sh + monitor.service / monitor.timer
|
||
|
||
主机侧运行,codexsvc 可读权限即可,不读 env 文件:
|
||
|
||
```sh
|
||
sh /opt/codex-app-server/deploy/monitor.sh # 输出一行 JSON,任一项 fail 则退出非零
|
||
sh /opt/codex-app-server/deploy/monitor.sh --self-test # 只验证脚本机制(见下)
|
||
```
|
||
|
||
检查项:systemctl is-active server/worker、curl health(ok/backend/db_ok/
|
||
worker_ok)、pg_isready、docker ps 里 codex-postgres 在运行、磁盘使用率
|
||
<90%、/opt/codex-app-server 存在。health 与 pg_isready 都不需要密码;
|
||
需要密码的 SELECT 1 那一步直接跳过,不在脚本里读密码。
|
||
|
||
`--self-test` 只验证脚本本身的机制(health JSON 解析 + 输出是合法 JSON):
|
||
它起一个本地 canned health server,断言 health 那一项为 pass;其它项在
|
||
非主机机器上 fail 是正常的,self-test 不因此失败。不要把 --self-test
|
||
通过误读成“主机健康”。
|
||
|
||
定时(主机上安装,示例):
|
||
|
||
```sh
|
||
cp /opt/codex-app-server/deploy/monitor.service /etc/systemd/system/monitor.service
|
||
cp /opt/codex-app-server/deploy/monitor.timer /etc/systemd/system/monitor.timer
|
||
systemctl daemon-reload
|
||
systemctl enable --now monitor.timer
|
||
journalctl -u monitor.service -f # 看每次检查的输出
|
||
```
|
||
|
||
timer 每 5 分钟跑一次,日志进 journal。
|
||
|
||
### 监控边界:本轮只有“主机侧检查 + journal 日志”
|
||
|
||
monitor.sh 本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行
|
||
决定通道(例如 SSH key 常驻通道让外部 cron 拉 journal,或者一个
|
||
webhook URL 让 monitor 失败时 POST 过去),在通道定下来之前,不要假装
|
||
monitor.sh 会通知任何人。手工验证步骤:主机上跑一次 monitor.sh 看 JSON
|
||
与退出码,再跑一次 --self-test 看 SELF-TEST PASS,最后
|
||
`systemctl list-timers | grep monitor` 确认 timer 已启用。
|
||
|
||
## worker 心跳与 health 的 worker 字段
|
||
|
||
worker 的 `run_loop` 每次循环结束时写心跳文件,内容只有
|
||
`{"last_seen": epoch秒, "pid": 进程号}`,不含任何密钥;写失败只记日志,
|
||
不炸 worker。心跳文件路径:worker 用 `--heartbeat-file` 指定(主机部署
|
||
时指到 `/var/lib/codex-app-server/worker-heartbeat.json`),未指定时看
|
||
环境变量 `CLOUD_WORKER_HEARTBEAT_FILE`,再未指定则为
|
||
`./worker-heartbeat.json`(VM 本地跑的默认,不在代码里硬编码主机路径)。
|
||
|
||
server 的 `GET /health` 在原有 ok/service/backend/db_ok 之外加两个字段:
|
||
`worker_last_seen_seconds_ago`(心跳文件不存在或读失败时为 None)与
|
||
`worker_ok`(心跳在 180 秒内为 true,否则 false;阈值可经环境变量
|
||
`CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS` 调,默认 180)。health 本身仍
|
||
返回 200——worker 不 ok 不代表 server 挂了,调用方看 worker_ok 字段判断,
|
||
不要把 health 改成 503 破坏已有调用。
|
||
|
||
主机部署时两边路径必须一致:worker 单元加
|
||
`--heartbeat-file /var/lib/codex-app-server/worker-heartbeat.json`,同时
|
||
在 server 的 env 文件(`/etc/codex-app-server/env`,server 单元的
|
||
EnvironmentFile)里加一行
|
||
`CLOUD_WORKER_HEARTBEAT_FILE=/var/lib/codex-app-server/worker-heartbeat.json`。
|
||
deploy.sh 不会自动改 env 文件(那是 root-only 的密钥文件),这一行由
|
||
部署操作人在主机上手动加,加完重启 server 即可。
|
||
|
||
## 多用户真实验证:verify-multiuser.py
|
||
|
||
主机侧以 codexsvc 身份运行,直接对生产库 + 本地 HTTP API 验证:
|
||
|
||
```sh
|
||
python3 /opt/codex-app-server/deploy/verify-multiuser.py
|
||
# 可选:--base http://127.0.0.1:8765 --db /var/lib/codex-app-server/control.db
|
||
```
|
||
|
||
它在内存里创建 4 个测试用户(owner / maintainer / contributor / viewer)
|
||
与 2 个测试项目(P2 只有 owner 是成员,用于隔离验证),再用 HTTP API
|
||
验证:owner/maintainer/contributor/viewer 的建任务、读任务、merge 权限,
|
||
以及跨项目隔离(P1 成员读 P2 任务 403)、无 key 401。merge 验证用的
|
||
completed 测试任务是直接在 DB 里把测试任务置 completed,不跑真实模型。
|
||
|
||
防误伤要点(脚本逻辑里已实现):
|
||
- 测试用户、项目、任务全部用 `multiuser-test-` 前缀,清理时只删这些
|
||
前缀的行,绝不碰其它生产数据;清理失败时退出非零并列出残留测试 id
|
||
(不含 key),不静默假装干净。
|
||
- 测试任务创建时都带一个永不存在的依赖 `multiuser-test-never-completes`:
|
||
worker 只派依赖全部 completed 的 queued 任务,所以这些测试任务永远
|
||
不会被生产 worker 认领去跑模型,不花模型钱、不改任何仓库。
|
||
- API key 只存在内存里,脚本全程只打印每项 PASS/FAIL 与总数,绝不打印
|
||
任何 API key;任务 prompt 与用户 id 也不打印,只打印检查名与结果。
|
||
- 审计日志是 append-only 的:验证过程的审计条目会留在 audit_log 里,
|
||
脚本不删审计表——审计就该留痕,这一点不是清理遗漏。
|
||
|
||
## 公网入口与 webhook secret(已有文件,不属于本轮新增)
|
||
|
||
- `caddy/Caddyfile`:只暴露 `/webhooks/*` 与 `/oauth/*`,任务/项目/管理
|
||
API 对外一律 403;域名与邮箱占位符需部署时替换。
|
||
- `setup-webhook-secret.sh`:在主机上生成 webhook secret 并写入 env 文件,
|
||
绝不打印 secret;已设则不动,避免轮换后 GitHub 端旧 secret 失效。
|
||
|
||
## 仍未做(不要假装已做)
|
||
|
||
- 推送告警通道:monitor 失败时通知到 Muse 聊天的通道还没定(见监控一节)。
|
||
- 回滚不回数据库:见回滚边界一节;改类型/删列的迁移需要单独评估。
|
||
- 恢复演练不碰生产库:演练只比行数,不比对具体内容;恢复到生产库的
|
||
真实灾难恢复流程(停服务、恢复、重启、验 health)还没演练过。
|
||
- CloudExecutor、GUI、Slack 执行、推送实发:与本轮无关,仍是 0 或占位。
|