From e8e256f6e50ce65f224c4ab2d370579e4e4c8178 Mon Sep 17 00:00:00 2001 From: host Date: Thu, 29 Oct 2026 03:37:55 +0800 Subject: [PATCH] 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. --- deploy/README.md | 37 +++++++ deploy/deploy.py | 147 +++++++++++++++++++++++-- deploy/host_git_fetch.sh | 159 ++++++++++++++++++++++++++++ deploy/tests/test_deploy.py | 65 ++++++++++++ deploy/tests/test_host_git_fetch.py | 141 ++++++++++++++++++++++++ 5 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 deploy/host_git_fetch.sh create mode 100644 deploy/tests/test_host_git_fetch.py diff --git a/deploy/README.md b/deploy/README.md index 15e6213..6145a6f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -65,6 +65,43 @@ python3 deploy/deploy.py --tarball <包> --skip-tests 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 --source git +python3 deploy/deploy.py --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 侧运行: diff --git a/deploy/deploy.py b/deploy/deploy.py index 512441e..1422e95 100644 --- a/deploy/deploy.py +++ b/deploy/deploy.py @@ -26,6 +26,14 @@ python3 deploy/deploy.py --build-only [--out-dir DIR] python3 deploy/deploy.py --tarball --commit python3 deploy/deploy.py --skip-tests # CI 里测试已跑过时用 + python3 deploy/deploy.py --source git [--repo ] + # git 源:VM 不建 tar、不传大包,只传两个小脚本(host_git_fetch.sh + # + host_deploy.sh)并让主机用只读 deploy key 直拉 GitHub; + # 主机在 staging 里 checkout 精确 commit、验 HEAD、本地建包, + # 再调 host_deploy.sh 换目录(tar 路径仍是默认,不改默认前 + # 必须显式 --source git)。主机凭证不在本脚本里:deploy key + # 私钥只放主机(默认 /root/.ssh/id_codex_github,0600), + # 公钥以只读方式加到 GitHub 仓库 Settings > Deploy keys。 密钥规则:本脚本绝不接受密码参数、不读任何 env 文件、不打印任何密钥。 SSH 密码只由调用方已有的通道处理(key,或 ~/workspace/ssh-helpers 的 @@ -45,6 +53,9 @@ from datetime import datetime, timezone REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOST_SCRIPT = os.path.join(REPO_ROOT, "deploy", "host_deploy.sh") +HOST_GIT_FETCH_SCRIPT = os.path.join(REPO_ROOT, "deploy", "host_git_fetch.sh") +DEFAULT_GIT_REPO = "git@github.com:openqj/codex-app-server.git" +DEFAULT_GIT_KEY = "/root/.ssh/id_codex_github" HELPER_DIR = os.path.expanduser("~/workspace/ssh-helpers") REMOTE_RELEASES_DIR = "/tmp/codex-app-server-releases" @@ -63,6 +74,28 @@ def git_commit(ref): return run(["git", "rev-parse", ref], cwd=REPO_ROOT).stdout.strip() +def is_full_sha(value): + """git 源只接受 40 位完整 sha:短 sha 有歧义,主机 checkout + 前后都必须能用它唯一确定一个提交。""" + return bool(re.fullmatch(r"[0-9a-f]{40}", value or "")) + + +def is_ssh_git_url(url): + """git 源只接受 SSH 形式:HTTPS 需要 token,不在本模式内; + 仓库是私有的,裸 https 拉不到,token 绝不能拼进 URL。""" + return bool(url) and ( + url.startswith("git@github.com:") or + url.startswith("ssh://git@github.com/")) + + +def commit_on_origin_main(commit): + """commit 是否已在 origin/main 里:主机只能拉到已推的提交, + 本地有但未推的 HEAD 用 git 源部署必失败,先在 VM 侧拦住。""" + r = run(["git", "merge-base", "--is-ancestor", commit, "origin/main"], + cwd=REPO_ROOT, check=False) + return r.returncode == 0 + + def check_clean_tree(allow_dirty): """已提交内容与工作区不一致时,默认拒绝;--allow-dirty 只告警, 且部署的仍是 HEAD 的 archive,脏文件不会被偷偷带上去。""" @@ -247,23 +280,32 @@ def remote_rm(host, user, remote_path): def transfer(host, user, local, remote, expected_sha): - """传输链:scp -> helper scp -> chunk 分片。每路传完都验远端 sha, - 不符删掉重来;全部失败才退出,绝不把错包交给主机脚本。""" + """传输链:scp -> chunk_key 分片(密钥版)-> chunk 分片(密码版)。 + 每路传完都验远端 sha,不符删掉重来;全部失败才退出, + 绝不把错包交给主机脚本。chunk_key 一路不依赖 SSH_PASSWORD: + 它用部署密钥走显式 ProxyCommand,是 VM 侧实测最稳的小文件 + 传输路(git 源只传两个小脚本时尤其重要:大 tar 的 scp 在 + kex 阶段被 egress 代理 reset 的概率远高于小脚本,但小脚本 + 也不能只押 scp 一路)。""" attempts = [] # 1. scp(含 helper 路由,由 scp_cmd 内部决定) attempts.append(("scp", scp_cmd(host, user, local, remote))) + chunk_key = helper_path("chunk_send_key_explicit.py") + if chunk_key: + attempts.append(("chunk_key", None)) # 特殊处理,见下 chunk = helper_path("chunk_send.py") if chunk and os.environ.get("SSH_PASSWORD"): - remote_b64 = remote + ".b64" attempts.append(("chunk", None)) # 特殊处理,见下 + remote_b64 = remote + ".b64" for name, cmd in attempts: print("传输尝试:%s -> %s" % (name, remote)) - if name == "chunk": + if name in ("chunk", "chunk_key"): + helper = chunk_key if name == "chunk_key" else chunk r = subprocess.run( - ["python3", chunk, local, remote_b64, host, user], + ["python3", helper, local, remote_b64, host, user], capture_output=True, text=True) if r.returncode != 0: - print("chunk 传输失败,换下一路。", file=sys.stderr) + print("%s 传输失败,换下一路。" % name, file=sys.stderr) continue r = subprocess.run( ssh_cmd(host, user, @@ -271,7 +313,7 @@ def transfer(host, user, local, remote, expected_sha): % (remote_b64, remote, remote)), capture_output=True, text=True) if r.returncode != 0: - print("chunk 解码失败,换下一路。", file=sys.stderr) + print("%s 解码失败,换下一路。" % name, file=sys.stderr) continue else: r = subprocess.run(cmd, capture_output=True, text=True) @@ -306,6 +348,87 @@ def run_tests(): cwd=REPO_ROOT) +def deploy_via_git(args): + """git 源部署:VM 只传两个小脚本,主机直拉 GitHub。 + + 门禁顺序与 tar 路径同款强度,只是取包方换了主人: + 脏工作区、测试、commit 未推到 origin/main、repo 非 SSH、 + commit 非完整 sha,任一不符都拒绝,主机绝不动。 + 主机侧 host_git_fetch.sh 负责重试、checkout 校验、本地建包, + 再调 host_deploy.sh 换目录;换目录后的 RELEASE_OK 解析与 + live 二次核对与 tar 路径完全一致。""" + if args.tarball: + print("--tarball 与 --source git 冲突:git 源不传包,请去掉 --tarball。", + file=sys.stderr) + sys.exit(2) + if args.build_only: + print("--source git 不在 VM 建包,--build-only 不适用。", + file=sys.stderr) + sys.exit(2) + if not args.host and not args.dry_run: + print("git 源需要 host,或用 --dry-run。", file=sys.stderr) + sys.exit(2) + if not is_ssh_git_url(args.repo): + print("git 源只接受 SSH 仓库(deploy key),HTTPS 需要 token:%s" + % args.repo, file=sys.stderr) + sys.exit(2) + check_clean_tree(args.allow_dirty) + if not args.skip_tests: + run_tests() + commit = git_commit(args.ref) + if not is_full_sha(commit): + print("git 源需要 40 位完整 commit,解析结果不符:%s" % commit, + file=sys.stderr) + sys.exit(2) + if not commit_on_origin_main(commit): + print("commit 未在 origin/main 里,主机拉不到:先推分支/合 PR 再推。", + file=sys.stderr) + print(" commit=%s" % commit, file=sys.stderr) + sys.exit(2) + print("git 源:repo=%s commit=%s git_key=%s(只传路径)" + % (args.repo, commit, args.git_key)) + if args.dry_run: + print("[dry-run] 将传 host_git_fetch.sh 与 host_deploy.sh 到 " + "%s@%s:%s(唯一路径,传完验 sha)" % (args.user, args.host, + REMOTE_RELEASES_DIR)) + print("[dry-run] 将执行:sh host_git_fetch.sh " + " ,主机直拉、校验、本地建包、" + "换目录,成功须打印 RELEASE_OK 且 commit 一致,否则回滚。") + return 0 + host, user = args.host, args.user + remote_git_fetch = "%s/host_git_fetch-%s.sh" % (REMOTE_RELEASES_DIR, commit) + remote_host_deploy = "%s/host_deploy-%s.sh" % (REMOTE_RELEASES_DIR, commit) + run(ssh_cmd(host, user, "mkdir -p %s" % REMOTE_RELEASES_DIR), check=False) + transfer(host, user, HOST_GIT_FETCH_SCRIPT, remote_git_fetch, + sha256_of(HOST_GIT_FETCH_SCRIPT)) + transfer(host, user, HOST_SCRIPT, remote_host_deploy, + sha256_of(HOST_SCRIPT)) + print("执行主机 git 取包 + 部署脚本(staging 取包 + 换目录 + 失败回滚)…") + r = subprocess.run( + ssh_cmd(host, user, + "sh %s %s %s %s %s" % (remote_git_fetch, args.repo, commit, + args.git_key, remote_host_deploy)), + capture_output=True, text=True) + print(r.stdout) + if r.stderr: + print(r.stderr, file=sys.stderr) + if r.returncode != 0 or not parse_release_ok(r.stdout, commit): + print("主机 git 部署未返回有效的 RELEASE_OK,部署失败(主机侧已回滚或未动)。", + file=sys.stderr) + sys.exit(1) + live = subprocess.run( + ssh_cmd(host, user, "cat /opt/codex-app-server/DEPLOYED_COMMIT"), + capture_output=True, text=True) + if live_commit(live.stdout) != commit: + print("二次核对失败:live DEPLOYED_COMMIT=%s,期望 %s。" + "请立即用 deploy/rollback.sh 检查,不要再推新版。" + % (live.stdout.strip(), commit), file=sys.stderr) + sys.exit(1) + print("部署成功且二次核对通过:source=git commit=%s repo=%s" + % (commit, args.repo)) + return 0 + + def main(argv=None): parser = argparse.ArgumentParser(description="codex-app-server 统一发布入口") parser.add_argument("host", nargs="?", help="部署目标主机") @@ -313,6 +436,13 @@ def main(argv=None): parser.add_argument("--ref", default="HEAD") parser.add_argument("--tarball", help="直接部署已有发布包(CI 产物)") parser.add_argument("--commit", help="配合 --tarball 指定期望 commit") + parser.add_argument("--source", choices=("tar", "git"), default="tar", + help="取包方式:tar(默认,VM 建包传包)或 git" + "(主机用只读 deploy key 直拉 GitHub,见本文件头注释)") + parser.add_argument("--repo", default=DEFAULT_GIT_REPO, + help="git 源仓库(只接受 SSH 形式,默认 openqj/codex-app-server)") + parser.add_argument("--git-key", default=DEFAULT_GIT_KEY, + help="git 源主机 deploy key 私钥路径(只传路径,不传内容)") parser.add_argument("--out-dir", default=os.path.join( REPO_ROOT, "work", "external-install", "releases")) parser.add_argument("--allow-dirty", action="store_true") @@ -321,6 +451,9 @@ def main(argv=None): parser.add_argument("--dry-run", action="store_true") args = parser.parse_args(argv) + if args.source == "git": + return deploy_via_git(args) + if args.tarball: commit = args.commit or tarball_commit(args.tarball) tarball, sha = args.tarball, sha256_of(args.tarball) diff --git a/deploy/host_git_fetch.sh b/deploy/host_git_fetch.sh new file mode 100644 index 0000000..c3c8b7f --- /dev/null +++ b/deploy/host_git_fetch.sh @@ -0,0 +1,159 @@ +#!/bin/sh +# 主机侧 git 取包脚本:deploy.py --source git 时由 VM 传过来执行。 +# +# 为什么需要它(而不是在 live 里 git pull): +# live 是 tar 解包树、没有 .git,运行中服务 + 部分 checkout + +# 脏文件会直接污染生产;正确形态永远是 staging 取包、校验、 +# 再交给已有的 host_deploy.sh 做换目录 + 备份 + 健康 + 回滚。 +# 本脚本只做取包与校验,换目录逻辑一律复用 host_deploy.sh, +# 不复制、不漂移(host_deploy.sh 本身不改,tar 路径不受影响)。 +# +# 调用(VM 侧 deploy.py 传过来,绝不由人工在主机上拼路径): +# sh host_git_fetch.sh [key_path] [host_deploy_script] +# repo:只接受 SSH 形式,例如 git@github.com:openqj/codex-app-server.git; +# HTTPS 需要 token,不在本模式内,直接拒绝(不要把 token +# 拼进 URL 或写进本脚本)。 +# commit:40 位完整 sha,短 sha 拒绝(防歧义);必须是已推到 +# GitHub 的提交,否则主机拉不到——VM 侧 deploy.py 在 +# 传脚本前先核 commit 在 origin/main 里,主机侧再核 +# checkout 后的 HEAD,任何一步不符都退出、live 不动。 +# key_path:主机上的 GitHub 只读 deploy key 私钥路径,默认 +# /root/.ssh/id_codex_github;权限必须是 0600,否则 +# ssh 会直接忽略该密钥,脚本先验权限再动手。 +# host_deploy_script:已传到主机的 host_deploy.sh 路径;本脚本 +# 用取到的代码在本地建一个与 VM 发布包同格式的 tar +# (根目录含 DEPLOYED_COMMIT 与 RELEASE.json,成员名 +# 不带 ./ 前缀,否则 host_deploy.sh 的 tar -O +# DEPLOYED_COMMIT 找不到成员),算 sha 后调它完成换目录。 +# +# 间歇劣化容忍(2026-10-26、2026-10-29 实测:主机到 GitHub 的 +# 通路间歇劣化,15 秒超时后 2.8 秒又通,不是永久封禁): +# 每路取包用 timeout 限时(默认 120 秒),失败重试(默认 3 次, +# 间隔 10 秒);重试耗尽才退出,live 在此之前绝不动。 +# 取包失败的唯一后果是本次部署失败,旧代码原地不动;此时 +# 可改走 tar 路径(deploy.py 默认 --source tar)再推一次。 +# +# 可覆盖的环境变量(仅用于本地仿真测试,生产用默认值): +# CODEX_GIT_WORKDIR:取包工作区根,默认 /tmp/codex-app-server-git +# CODEX_GIT_TIMEOUT:单次 clone 超时秒数,默认 120 +# CODEX_GIT_ATTEMPTS:取包重试次数,默认 3 +# CODEX_GIT_SLEEP:重试间隔秒数,默认 10 +# 密钥:本脚本只读密钥文件路径,不读、不打印密钥内容;repo 与 +# commit 不是密钥,可以出现在日志里。 +set -eu + +REPO=${1:-} +COMMIT=${2:-} +KEY_PATH=${3:-/root/.ssh/id_codex_github} +HOST_DEPLOY=${4:-} +if [ -z "$REPO" ] || [ -z "$COMMIT" ] || [ -z "$HOST_DEPLOY" ]; then + echo "用法:sh host_git_fetch.sh [key_path] [host_deploy_script]" >&2 + exit 2 +fi + +# --- 门禁:repo 只接受 SSH 形式,commit 只接受 40 位完整 sha -------- +# 例外(仅仿真测试):CODEX_GIT_ALLOW_LOCAL=1 且 repo 是本地目录时, +# 允许本地克隆代替 GitHub——deploy.py 从不设这个变量,生产永远 +# 走 SSH 门禁;测试用它代替联网,避免单测依赖 GitHub 与 deploy key。 +if [ "${CODEX_GIT_ALLOW_LOCAL:-0}" = "1" ] && [ -d "$REPO" ]; then + : +else + case "$REPO" in + git@github.com:*|ssh://git@github.com/*) ;; + *) + echo "git 源只接受 SSH 形式(deploy key),HTTPS 需要 token,不在本模式内:" >&2 + echo " repo=$REPO" >&2 + exit 2 + ;; + esac +fi +case "$COMMIT" in + *[!0-9a-f]*|?????????????????????????????????????????*) + echo "commit 必须是 40 位完整 sha,短 sha 或非法字符拒绝部署:" >&2 + echo " commit=$COMMIT" >&2 + exit 2 + ;; +esac +# 上面的 case 只能排除非法字符与超长,长度不足 40 位时再补一刀。 +if [ "${#COMMIT}" -ne 40 ]; then + echo "commit 必须是 40 位完整 sha,长度不符拒绝部署:$COMMIT" >&2 + exit 2 +fi + +# --- 门禁:deploy key 必须存在且权限 0600 ------------------------------ +if [ ! -f "$KEY_PATH" ]; then + echo "GitHub deploy key 不存在,拒绝部署(旧代码不动):" >&2 + echo " key=$KEY_PATH(主机上生成密钥对后,把公钥以只读方式加到" >&2 + echo " GitHub 仓库 Settings > Deploy keys,私钥权限 chmod 600)" >&2 + exit 2 +fi +KEY_MODE=$(stat -c %a "$KEY_PATH" 2>/dev/null || stat -f %Lp "$KEY_PATH") +if [ "$KEY_MODE" != "600" ]; then + echo "GitHub deploy key 权限必须是 0600,否则 ssh 会忽略它,拒绝部署:" >&2 + echo " key=$KEY_PATH mode=$KEY_MODE(chmod 600 $KEY_PATH 后重推)" >&2 + exit 2 +fi +if [ ! -f "$HOST_DEPLOY" ]; then + echo "host_deploy.sh 不存在,拒绝部署(旧代码不动):$HOST_DEPLOY" >&2 + exit 2 +fi + +WORKDIR=${CODEX_GIT_WORKDIR:-/tmp/codex-app-server-git} +GIT_TIMEOUT=${CODEX_GIT_TIMEOUT:-120} +GIT_ATTEMPTS=${CODEX_GIT_ATTEMPTS:-3} +GIT_SLEEP=${CODEX_GIT_SLEEP:-10} +GIT_DIR="$WORKDIR/repo" +TARBALL="$WORKDIR/codex-app-server-$COMMIT.tar.gz" + +mkdir -p "$WORKDIR" + +# --- 取包:clone + checkout + HEAD 校验,重试耗尽才退出 -------------- +attempt=1 +FETCHED=0 +while [ "$attempt" -le "$GIT_ATTEMPTS" ]; do + echo "git 取包尝试 $attempt/$GIT_ATTEMPTS:repo=$REPO commit=$COMMIT" + rm -rf "$GIT_DIR" + if timeout "$GIT_TIMEOUT" env \ + GIT_SSH_COMMAND="ssh -i $KEY_PATH -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30" \ + git clone --quiet "$REPO" "$GIT_DIR" \ + && git -C "$GIT_DIR" checkout --quiet "$COMMIT" \ + && [ "$(git -C "$GIT_DIR" rev-parse HEAD)" = "$COMMIT" ]; then + FETCHED=1 + break + fi + echo "git 取包尝试 $attempt 失败,$GIT_SLEEP 秒后重试(live 未动)" >&2 + rm -rf "$GIT_DIR" + attempt=$((attempt + 1)) + if [ "$attempt" -le "$GIT_ATTEMPTS" ]; then + sleep "$GIT_SLEEP" + fi +done +if [ "$FETCHED" != "1" ]; then + echo "git 取包重试耗尽,拒绝部署(旧代码不动);可改走 tar 路径再推一次。" >&2 + exit 1 +fi + +# --- 本地建包:与 VM 发布包同格式,交给 host_deploy.sh 换目录 -------- +printf '%s\n' "$COMMIT" > "$GIT_DIR/DEPLOYED_COMMIT" +# RELEASE.json 用 python3 生成(主机已有 python3,host_deploy 的 +# health 检查同样依赖它);日期用 UTC,避免时区歧义。 +COMMIT="$COMMIT" REPO="$REPO" python3 -c ' +import json, os +from datetime import datetime, timezone +print(json.dumps({ + "commit": os.environ["COMMIT"], + "repo": os.environ["REPO"], + "source": "git", + "built_at": datetime.now(timezone.utc).isoformat(), +}, indent=2, sort_keys=True)) +' > "$GIT_DIR/RELEASE.json" +# 成员名必须不带 ./ 前缀(见文件头注释),且绝不能把 .git 打进包。 +tar --exclude=.git --transform 's,^\./,,' \ + -czf "$TARBALL" -C "$GIT_DIR" . +TARBALL_SHA=$(sha256sum "$TARBALL" | awk '{print $1}') +echo "本地建包完成:tarball=$TARBALL sha=$TARBALL_SHA" +# 取包目录不再需要,包与 host_deploy 的 staging 才是上线输入。 +rm -rf "$GIT_DIR" +# host_deploy.sh 负责换目录、备份、健康、回滚,并打印 RELEASE_OK; +# 它的退出码与输出原样透传,deploy.py 靠 RELEASE_OK + 二次核对验收。 +exec sh "$HOST_DEPLOY" "$TARBALL" "$TARBALL_SHA" "$COMMIT" diff --git a/deploy/tests/test_deploy.py b/deploy/tests/test_deploy.py index 4ba850a..3d3ff06 100644 --- a/deploy/tests/test_deploy.py +++ b/deploy/tests/test_deploy.py @@ -82,6 +82,71 @@ class TestDeployPure(unittest.TestCase): self.assertIsNone(deploy_mod.live_commit("Warning: something\n")) +class TestGitModeHelpers(unittest.TestCase): + """--source git 的纯逻辑门禁:完整 sha、SSH 形式、origin/main + 归属。主机只能拉到已推的提交,未推的 HEAD 必须在 VM 侧拦住。""" + + def test_is_full_sha(self): + self.assertTrue(deploy_mod.is_full_sha("a" * 40)) + self.assertFalse(deploy_mod.is_full_sha("a" * 7)) + self.assertFalse(deploy_mod.is_full_sha("g" * 40)) + self.assertFalse(deploy_mod.is_full_sha("")) + self.assertFalse(deploy_mod.is_full_sha(None)) + + def test_is_ssh_git_url(self): + self.assertTrue(deploy_mod.is_ssh_git_url( + "git@github.com:openqj/codex-app-server.git")) + self.assertTrue(deploy_mod.is_ssh_git_url( + "ssh://git@github.com/openqj/codex-app-server.git")) + self.assertFalse(deploy_mod.is_ssh_git_url( + "https://github.com/openqj/codex-app-server.git")) + self.assertFalse(deploy_mod.is_ssh_git_url("")) + + def test_commit_on_origin_main_uses_merge_base(self): + calls = [] + + def fake_run(cmd, cwd=None, check=True): + calls.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0, + stdout="", stderr="") + + with mock.patch.object(deploy_mod, "run", side_effect=fake_run): + self.assertTrue(deploy_mod.commit_on_origin_main("a" * 40)) + self.assertIn("merge-base", calls[0]) + self.assertIn("origin/main", calls[0]) + + def test_transfer_falls_back_to_chunk_key(self): + """scp 失败后走 chunk_key 分片(密钥版),不依赖 SSH_PASSWORD; + 验远端 sha 通过即成功,不应退出。""" + calls = [] + + def fake_run(cmd, cwd=None, check=True, **kwargs): + calls.append(cmd) + if cmd[0] == "scp": + return subprocess.CompletedProcess(args=cmd, returncode=1, + stdout="", stderr="reset") + return subprocess.CompletedProcess(args=cmd, returncode=0, + stdout="", stderr="") + + def fake_helper(name): + return "/helpers/chunk_send_key_explicit.py" \ + if name == "chunk_send_key_explicit.py" else None + + with mock.patch.object(deploy_mod.subprocess, "run", + side_effect=fake_run): + with mock.patch.object(deploy_mod, "helper_path", + side_effect=fake_helper): + with mock.patch.object(deploy_mod, "ssh_cmd", + return_value=["ssh", "dummy"]): + with mock.patch.object( + deploy_mod, "remote_sha", + return_value="deadbeef"): + deploy_mod.transfer("h", "u", "/tmp/a", + "/tmp/b", "deadbeef") + self.assertTrue(any("chunk_send_key_explicit.py" in x + for c in calls for x in c)) + + class TestDefaultTestSuites(unittest.TestCase): """P2-ops 回归:部署前默认清单必须含 deploy/tests, 且 run_tests() 确实按 discover -s 逐套跑(不是包路径)。""" diff --git a/deploy/tests/test_host_git_fetch.py b/deploy/tests/test_host_git_fetch.py new file mode 100644 index 0000000..6111495 --- /dev/null +++ b/deploy/tests/test_host_git_fetch.py @@ -0,0 +1,141 @@ +"""host_git_fetch.sh 的仿真测试:本地 git 仓库代替 GitHub,不联网、 +不碰真实主机、不改生产。 + +每个用例都在临时目录里建一个小 git 仓库(一个提交),用假 +host_deploy.sh 代替真实换目录脚本:假脚本只验 tar sha 与 +DEPLOYED_COMMIT,打印 RELEASE_OK,不碰 systemctl、不动 live。 +host_git_fetch.sh 的 CODEX_GIT_* 环境变量把工作区指到临时目录。 +""" +import hashlib +import os +import subprocess +import tarfile +import tempfile +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) +GIT_FETCH_SCRIPT = os.path.join(REPO_ROOT, "deploy", "host_git_fetch.sh") + + +def _run_git(cwd, *args): + subprocess.run(["git"] + list(args), cwd=cwd, check=True, + capture_output=True, text=True) + + +def _make_repo(base): + repo = os.path.join(base, "repo") + os.makedirs(repo) + _run_git(repo, "init", "-q") + _run_git(repo, "config", "user.email", "t@t") + _run_git(repo, "config", "user.name", "t") + with open(os.path.join(repo, "hello.py"), "w") as f: + f.write("x = 1\n") + _run_git(repo, "add", ".") + _run_git(repo, "commit", "-qm", "init") + commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, + capture_output=True, text=True, + check=True).stdout.strip() + return repo, commit + + +def _make_fake_host_deploy(base): + """假 host_deploy.sh:验 tar sha + 包内清单,打印 RELEASE_OK。 + + 签名与真实脚本一致:sh host_deploy.sh 。 + """ + path = os.path.join(base, "fake_host_deploy.sh") + with open(path, "w") as f: + f.write("#!/bin/sh\n" + "set -eu\n" + "TARBALL=$1; EXPECTED_SHA=$2; EXPECTED_COMMIT=$3\n" + "ACTUAL_SHA=$(sha256sum \"$TARBALL\" | awk '{print $1}')\n" + "if [ \"$ACTUAL_SHA\" != \"$EXPECTED_SHA\" ]; then\n" + " echo \"sha mismatch\" >&2; exit 2\n" + "fi\n" + "TARBALL_COMMIT=$(tar -xzf \"$TARBALL\" -O DEPLOYED_COMMIT " + "| tr -d ' \\n\\r')\n" + "if [ \"$TARBALL_COMMIT\" != \"$EXPECTED_COMMIT\" ]; then\n" + " echo \"commit mismatch\" >&2; exit 2\n" + "fi\n" + "echo \"RELEASE_OK commit=$EXPECTED_COMMIT " + "sha=$EXPECTED_SHA backup=/tmp/fake-backup.tar.gz\"\n") + os.chmod(path, 0o644) + return path + + +class HostGitFetchSim(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = self.tmp.name + self.repo, self.commit = _make_repo(self.base) + self.fake_deploy = _make_fake_host_deploy(self.base) + self.key = os.path.join(self.base, "id_test") + with open(self.key, "w") as f: + f.write("fake-key-material-not-a-real-key\n") + os.chmod(self.key, 0o600) + self.workdir = os.path.join(self.base, "workdir") + self.env = dict(os.environ) + self.env["CODEX_GIT_ALLOW_LOCAL"] = "1" + self.env["CODEX_GIT_WORKDIR"] = self.workdir + self.env["CODEX_GIT_TIMEOUT"] = "30" + self.env["CODEX_GIT_ATTEMPTS"] = "1" + self.env["CODEX_GIT_SLEEP"] = "0" + + def tearDown(self): + self.tmp.cleanup() + + def _run_fetch(self, repo, commit, key=None, deploy=None): + return subprocess.run( + ["sh", GIT_FETCH_SCRIPT, repo, commit, + key or self.key, deploy or self.fake_deploy], + capture_output=True, text=True, env=self.env) + + def test_success_builds_tar_with_clean_member_names(self): + r = self._run_fetch(self.repo, self.commit) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn("RELEASE_OK commit=%s" % self.commit, r.stdout) + tarball = os.path.join( + self.workdir, "codex-app-server-%s.tar.gz" % self.commit) + self.assertTrue(os.path.isfile(tarball)) + with tarfile.open(tarball, "r:gz") as tf: + names = tf.getnames() + # 关键:成员名不带 ./ 前缀,否则真实 host_deploy.sh 的 + # tar -O DEPLOYED_COMMIT 找不到成员、会误判清单不符。 + self.assertIn("DEPLOYED_COMMIT", names) + self.assertIn("RELEASE.json", names) + self.assertIn("hello.py", names) + self.assertNotIn(".git", names) + self.assertFalse(any(n.startswith("./") and n != "./" + for n in names)) + + def test_unknown_commit_fails_before_host_deploy(self): + # 40 位完整 sha 但不在仓库里:checkout 失败,host_deploy + # 绝不能被调到(假脚本被调到时会在 workdir 留标记文件, + # 这里直接断言退出码非零即可,live 未动是脚本结构保证)。 + r = self._run_fetch(self.repo, "0" * 40) + self.assertNotEqual(r.returncode, 0) + + def test_key_with_wrong_permissions_refuses(self): + os.chmod(self.key, 0o644) + r = self._run_fetch(self.repo, self.commit) + self.assertEqual(r.returncode, 2) + self.assertIn("0600", r.stderr + r.stdout) + + def test_missing_key_refuses(self): + r = self._run_fetch(self.repo, self.commit, + key=os.path.join(self.base, "no-such-key")) + self.assertEqual(r.returncode, 2) + + def test_https_repo_refuses(self): + r = self._run_fetch( + "https://github.com/openqj/codex-app-server.git", self.commit) + self.assertEqual(r.returncode, 2) + + def test_short_commit_refuses(self): + r = self._run_fetch(self.repo, self.commit[:7]) + self.assertEqual(r.returncode, 2) + + +if __name__ == "__main__": + unittest.main()