Files
codex-app-server/deploy/README.md
T

242 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 部署与运维(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` 与
`localhost:8766/health`(server-2 存在时)最多 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 或占位。