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
@@ -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