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.
This commit is contained in:
1 parent
630d14fe07
commit
e8e256f6e5
5 files changed
+542
-7
No files matched your search
@@ -65,6 +65,43 @@ python3 deploy/deploy.py <host> --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 <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 侧运行:
|
||||
|
||||
+140
-7
@@ -26,6 +26,14 @@
|
||||
python3 deploy/deploy.py <host> --build-only [--out-dir DIR]
|
||||
python3 deploy/deploy.py <host> --tarball <path> --commit <sha>
|
||||
python3 deploy/deploy.py <host> --skip-tests # CI 里测试已跑过时用
|
||||
python3 deploy/deploy.py <host> --source git [--repo <ssh-url>]
|
||||
# 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 <repo> <commit> "
|
||||
"<git_key> <host_deploy.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)
|
||||
|
||||
@@ -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 <repo> <commit> [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 <repo> <commit> [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"
|
||||
@@ -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 逐套跑(不是包路径)。"""
|
||||
|
||||
@@ -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 <tarball> <sha> <commit>。
|
||||
"""
|
||||
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()
|
||||
Reference in new issue
Block a user