Files
codex-app-server/deploy/README.md
T
host e8e256f6e5 deploy: add git-pull source for host deployment
Host can now fetch code directly from GitHub instead of receiving a VM-built tarball, bypassing the VM->host scp/kex failures and stale-tar risk. Design: VM sends only two small scripts (host_git_fetch.sh + host_deploy.sh, sha-verified via the existing transfer chain, now with a chunk_key fallback that does not need SSH_PASSWORD); host clones via a read-only deploy key (SSH only, HTTPS refused, key must exist with mode 0600), checks out the exact 40-char commit, verifies HEAD, builds a tar in the same format as the VM release (DEPLOYED_COMMIT + RELEASE.json, member names without ./ prefix so host_deploy.sh tar -O works), then calls the unchanged host_deploy.sh for staging swap, backup, health and rollback. Git fetch tolerates the known intermittent host->GitHub degradation: 120s timeout, 3 attempts, 10s sleep, live untouched until host_deploy runs. VM gates: clean tree, tests, commit must be on origin/main, SSH repo only, full sha only; --source tar remains the default. Tests: deploy suite OK, including 6 new host_git_fetch simulations (success with clean tar members, unknown commit, key permissions/missing, HTTPS refusal, short sha) and 4 new deploy.py git-mode helpers plus chunk_key fallback; git-mode dry-run verified against HEAD 630d14f.
2026-10-29 03:37:55 +08:00

371 lines
21 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` 环境变量)。
## Git 直拉部署(新,--source git;tar 仍是默认)
VM 侧不再建 tar、不传大包,只传两个小脚本(`host_git_fetch.sh` +
`host_deploy.sh`)并让主机用只读 deploy key 直拉 GitHub。主机在
staging 里 checkout 精确 commit、验 HEAD、本地建包,再调已有的
`host_deploy.sh` 做换目录、备份、健康、回滚;换目录逻辑一律复用,
不复制、不漂移。tar 路径不受影响,默认仍是 tar,必须显式
`--source git` 才走 git 源。
```sh
python3 deploy/deploy.py <host> --source git
python3 deploy/deploy.py <host> --source git --dry-run
```
前提(缺一不可,缺了就拒绝、live 不动):
- **主机凭证:只读 deploy key。** 仓库是私有的,裸 https 拉不到;
HTTPS token 不在本模式内。主机上生成密钥对(私钥只放主机,
权限 0600,默认路径 `/root/.ssh/id_codex_github`),把公钥以
只读方式加到 GitHub 仓库 Settings > Deploy keys;私钥绝不进
仓库、聊天或记忆,deploy.py 只传密钥路径、不传内容。主机上
已有的 SSH 密钥若只对应其它仓库(如 hatch-frontend),拉本仓
会报 Repository not found,不能复用,必须另配本仓的 key。
- **commit 必须已推到 origin/main。** VM 侧先核 commit 在
origin/main 里,主机侧再核 checkout 后的 HEAD,两处都必须
等于期望的 40 位完整 commit;短 sha、未推的本地 HEAD、HTTPS
URL 一律拒绝。绝不只 pull main:main 在部署中途移动会重演
中途部署中断的事故。
- **间歇劣化容忍。** 主机到 GitHub 的通路间歇劣化(2026-10-26、
2026-10-29 实测:15 秒超时后 2.8 秒又通,api/codeload 正常时
github.com Web 前端仍可能超时),取包单次超时 120 秒、重试
3 次、间隔 10 秒;重试耗尽才退出,live 在此之前绝不动。取包
失败时改走 tar 路径再推一次,不要反复重试 git 源。
- **小脚本传输仍走 transfer()。** 两个脚本先经 scp、再经
chunk_key 分片(密钥版,不依赖 SSH_PASSWORD)、再经 chunk
分片(密码版),每路传完验 sha;脚本传不到,部署不开始。
## 部署: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 秒),成功/失败都明说;主机上没有备份包
时直接报错退出,不编造。
### 回滚成功标准:清单里每个端口都通过(P2-ops)
rollback.sh 与 host_deploy.sh 共用同一份 unit 清单约定:server-1 必含,
server-2 以 `systemctl cat` 探测为准(存在才纳入停/起与健康检查),worker
是 autoscaler active 时用 autoscaler、否则用 cloud-worker。两脚本改动清单
时必须同步改,`deploy/tests/test_p2_ops.py` 会双向核对(只改一处就变红)。
回滚成功 = 8765 通过,且 server-2 存在时 8766 也通过。只通 8765 不算成功:
LB 上游的 server-2 若仍跑故障代码,回滚等于没回完。
### 回滚边界:只回代码,不回数据库
数据库迁移是只加列的幂等操作(例如 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 保证中途失败也删)。
范围(P2-ops):演练 PASS 只证明数据库备份可恢复,不证明 worktree /
链路 / 上传可恢复——那些不在备份里,见“备份制度”一节。
安全红线:恢复目标永远是新生成的测试库名,绝不等于生产库名;
`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 日志”,告警出口可选(P2-ops)
默认不配告警出口时,monitor.sh 仍然只做检查 + journal 日志,不会发消息
给任何人。需要 fail 时通知时,在 monitor.service 的 `Environment=`(或
monitor.timer 的运行环境)里二选一配置,默认关闭:
```ini
# 二选一,同时配了只用 webhook:
Environment=MONITOR_ALERT_URL=https://example.invalid/hook
# 或:
Environment=MONITOR_ALERT_MAILTO=ops@example.invalid
```
- 只在 overall=fail 时发,pass 时不发;故障持续期间 timer 每 5 分钟跑
一次就会重发,接收端需自行去重(本机不做去重状态文件,免得状态文件
本身成为新的故障点)。
- 告警内容只有 JSON 检查结果(pass/fail 与 server_mode),不含 health
正文、不含任何环境变量、不含密钥;URL 与收件人只走环境变量。
- 邮件路径依赖本机 `mail` 命令,没有它时会记一条“告警未发出”到 journal,
不会静默假装已通知。
### 单机模式:MONITOR_SINGLE_HOST=1(P2-ops)
默认 server 检查要求 server-1 与 server-2 双 active。确认是单机部署时,
在 monitor 运行环境里设 `MONITOR_SINGLE_HOST=1`,server 检查只查
server-1;否则单机主机的 server 检查会恒 fail,恒 fail 会掩盖真故障。
双机主机上绝不要设它(设了就是静默降级)。输出 JSON 的 `server_mode`
字段写明本次是 single 还是 dual,降级可见。
手工验证步骤:主机上跑一次 monitor.sh 看 JSON 与退出码,再跑一次
--self-test 看 SELF-TEST PASS,最后 `systemctl list-timers | grep monitor`
确认 timer 已启用。--self-test 时内层检查会清掉告警出口,不会误发告警。
## 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 里,
脚本不删审计表——审计就该留痕,这一点不是清理遗漏。
## 备份制度:什么可恢复、什么不可恢复(P2-ops,二选一里选了“写清区分”)
不要谎称已做全量备份。本节写清现状;worktree / 链路 / 上传目录纳入
备份范围是另一单(需先定量:租户目录大小、备份窗口、恢复演练抽查),
不在本批做。
| 内容 | 在备份里吗 | 恢复手段 |
| --- | --- | --- |
| 数据库(Postgres 全库) | 是 | pg-backup 每天一次(留 7 份)+ restore-drill 恢复到测试库比对行数 |
| 代码(/opt/codex-app-server,不含 work/) | 是 | 每次部署前打备份 tar(留 5 份)+ rollback.sh 恢复 |
| 任务 worktree(work/、tenants/ 下各任务目录) | 否 | 无。主机丢了就丢了;数据库里的任务行还在,但 worktree 没了,续跑/链路/附件都会缺 |
| 链路文件(host-traces/)与上传文件(附件包) | 否 | 无。链路是过程记录,丢了只影响可观测性;上传附件丢了则任务附件不可下载 |
| 运行配置(/etc/codex-app-server/env、unit 文件、nginx/Caddy 配置) | 否(不在代码 tar 与数据库备份里) | 换机时从备份清单手动重贴,见“公网入口”与 new-host-checklist.md |
后果要说透:只靠现有备份,数据库能恢复、代码能回滚,但一台主机彻底丢了,
在跑任务的工作目录与历史链路是回不来的;恢复后需要把受影响的任务标记为
失败/重建,而不是假装它们还能续跑。restore-drill.sh 的 PASS 只证明数据库
备份可恢复,不证明整机可恢复(脚本头注释同样写明)。
## 公网入口与 webhook secret
- `caddy/Caddyfile`(P2-ops:占位符改为 Caddy 原生变量模板):只暴露
`/webhooks/*` 与 `/oauth/*`,任务/项目/管理 API 对外一律 403;自动证书
走 Caddy(本批二选一里选了 Caddy,certbot 路径未做成模板、只是备选,
不要假装两条路都已就绪)。
- `setup-webhook-secret.sh`:在主机上生成 webhook secret 并写入 env 文件,
绝不打印 secret;已设则不动,避免轮换后 GitHub 端旧 secret 失效。
### Caddy 模板部署步骤(新机可复现,不申请真实证书也能备好文件)
```sh
# 1. 主机上装 Caddy(包管理器或官方二进制均可),建运行目录:
mkdir -p /etc/caddy
cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile
cp deploy/caddy/caddy.service /etc/systemd/system/caddy.service
# 2. 写变量文件(域名与邮箱不是密钥,但也不要写进仓库):
cat > /etc/caddy/env <<'EOF'
DOMAIN=example.invalid
ACME_EMAIL=ops@example.invalid
EOF
chmod 600 /etc/caddy/env
# 3. Caddyfile 里的 {$DOMAIN} / {$ACME_EMAIL} 由 Caddy 运行时从环境变量
# 展开;变量未设时 Caddy 启动即报错(fail-fast,不会静默跑成空域名)。
systemctl daemon-reload
systemctl enable --now caddy
# 4. 验证:curl -v https://$DOMAIN/webhooks/github 应被 Caddy 转发
# (控制面本身仍只监听 127.0.0.1:8765,不直接对外)。
```
### 当前生产现状(换机需重贴,模板不等于已上线)
当前生产走宝塔 nginx(api.diante.me 等 vhost 由 BT 面板管理),不是
Caddy;BT 若重生成 vhost 会吃掉 location,需从备份贴回(见
new-host-checklist.md 第 8 节)。仓库内 Caddy 模板是“新机可复现”的选项,
不是“生产已切 Caddy”的声明。备份清单必须另含:BT vhost 配置备份、
/etc/caddy/Caddyfile(如已用 Caddy)、caddy.service、unit 文件
(codex-cloud-server*.service 等不在 deploy tar 里,改动后须单独 scp,
见 codex-cloud-server-2.service 头注释)——这些都不在代码 tar 与数据库
备份里,换机时不重贴就等于没备份。
## 仍未做(不要假装已做)
- 回滚不回数据库:见回滚边界一节;改类型/删列的迁移需要单独评估。
- 恢复演练不碰生产库:演练只比行数,不比对具体内容;恢复到生产库的
真实灾难恢复流程(停服务、恢复、重启、验 health)还没演练过。
- worktree / 链路 / 上传目录未纳入备份:见“备份制度”一节;主机彻底丢了
这些回不来,需要另单定量后做。
- 生产仍走宝塔 nginx,未切 Caddy:Caddy 模板只是仓库内可部署选项。
- CloudExecutor、GUI、Slack 执行、推送实发:与本轮无关,仍是 0 或占位。