Files
codex-app-server/deploy
host 1dc2f7dc21 egress: 白名单加一级时间/天气域名,供 live-info fetch 核数
2026-10-30 端到端再测:web_search 已通,但 web_fetch
4 次全败于 Tinyproxy 403 Filtered——模型要 fetch 的
一级来源域名都不在白名单里,搜索摘要又无可靠数字,
故按技能规则未报数字。加:worldtimeapi.org、timeapi.io
(时间 API)、open-meteo.com(免费天气 API,无 key)、
weather.com.cn(中国气象局英文站)、meteoblue.com、
accuweather.com(搜索结果里的天气一级来源)。
刻意不加 time.is/time.org.cn/time.ms(次级时间站)与
任何新闻/社交域名,白名单仍 fail-closed。
主机 /etc/tinyproxy/tinyproxy-filter 须同步此文件并
reload tinyproxy 才能生效,仓库文件是下次部署不被
revert 的依据。
2026-10-30 01:16:49 +08:00
..
…

部署与运维(deploy/)

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

发布链路(新,默认用这个):deploy.py + host_deploy.sh

VM 侧运行,一条命令走完构建、传输、验真、部署、回滚核对:

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 源。

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 侧运行:

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

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 /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 /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 日志”,告警出口可选(P2-ops)

默认不配告警出口时,monitor.sh 仍然只做检查 + journal 日志,不会发消息 给任何人。需要 fail 时通知时,在 monitor.service 的 Environment=(或 monitor.timer 的运行环境)里二选一配置,默认关闭:

# 二选一,同时配了只用 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 验证:

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 模板部署步骤(新机可复现,不申请真实证书也能备好文件)

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