Files
codex-app-server/deploy
host 1f4463209b deploy: fix host backup tar exclude patterns
The host backup tar runs with -C /opt codex-app-server, so its
members are named codex-app-server/..., and the old patterns
./work and ./worker-heartbeat.json never matched anything.
The live work/ dir (host-state, worktrees rewritten by running
workers) was therefore included, tar hit 'file changed as we
read it', exited 1, and set -e aborted the deploy. Patterns
are now codex-app-server/work and
codex-app-server/worker-heartbeat.json, which actually match.
2026-10-26 07:00:24 +08:00
..

部署与运维(deploy/)

本目录是主机部署与运维的脚本集,与 cloud/ 代码分开:部署脚本只负责 传代码、备份、重启、验证,不改任务逻辑;运维脚本只负责检查与演练, 不跑模型、不推代码。

部署:deploy.sh

VM 侧运行:

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 侧运行,恢复主机上最新的一份备份包:

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 /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 /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 通过误读成“主机健康”。

定时(主机上安装,示例):

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 验证:

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 或占位。