Files
codex-app-server/deploy/tests/test_coverage85_deploy.py
T

399 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""覆盖率 78%->85% 第 2 步:deploy.py 的剩余缺口真实行为测试。
只测本地逻辑与参数分发:真实临时 git 仓库建包、真实 tarball
读清单;凡是触网/触主机的步骤一律用 subprocess/ssh_cmd/transfer
替身在调用边界拦住(替身只决定命令返回值,不替换被测函数
本身),绝不连真实主机、不跑真实部署。"""
import io
import os
import subprocess
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from types import SimpleNamespace
from unittest import mock
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(REPO_ROOT, "deploy"))
import deploy as deploy_mod # noqa: E402
def _make_git_repo(path):
def git(*args):
subprocess.run(["git", "-C", path] + list(args),
check=True, capture_output=True, text=True)
subprocess.run(["git", "init", "-q", path], check=True,
capture_output=True)
git("config", "user.email", "test@test.local")
git("config", "user.name", "Test")
with open(os.path.join(path, "hello.txt"), "w") as f:
f.write("hello\n")
git("add", ".")
git("commit", "-qm", "init")
head = subprocess.run(["git", "-C", path, "rev-parse", "HEAD"],
check=True, capture_output=True,
text=True).stdout.strip()
git("update-ref", "refs/remotes/origin/main", head)
return head
def _cp(args=(), returncode=0, **kwargs):
return subprocess.CompletedProcess(args=list(args),
returncode=returncode,
stdout="", stderr="")
class DeployCleanTreeTest(unittest.TestCase):
def test_clean_and_dirty_and_allow_dirty(self):
with tempfile.TemporaryDirectory() as tmp:
_make_git_repo(tmp)
old_root = deploy_mod.REPO_ROOT
deploy_mod.REPO_ROOT = tmp
try:
deploy_mod.check_clean_tree(False) # 干净:不退出
with open(os.path.join(tmp, "hello.txt"), "a") as f:
f.write("dirty\n")
with self.assertRaises(SystemExit) as ctx:
deploy_mod.check_clean_tree(False)
self.assertEqual(ctx.exception.code, 2)
out = io.StringIO()
with redirect_stdout(out):
deploy_mod.check_clean_tree(True) # 只告警不退出
self.assertIn("警告", out.getvalue())
subprocess.run(["git", "-C", tmp, "add", "."],
check=True, capture_output=True)
with self.assertRaises(SystemExit):
deploy_mod.check_clean_tree(False) # 暂存区脏也拦
finally:
deploy_mod.REPO_ROOT = old_root
def test_run_failure_exits(self):
with self.assertRaises(SystemExit) as ctx:
deploy_mod.run(["false"], check=True)
self.assertEqual(ctx.exception.code, 1)
r = deploy_mod.run(["false"], check=False)
self.assertNotEqual(r.returncode, 0)
class DeployRemoteHelpersTest(unittest.TestCase):
def test_remote_sha_none_on_rc(self):
with mock.patch.object(deploy_mod.subprocess, "run",
return_value=_cp(returncode=1)):
self.assertIsNone(deploy_mod.remote_sha("h", "u", "/tmp/x"))
def test_remote_rm_issues_rm_command(self):
seen = []
def fake_run(cmd, **kwargs):
seen.append(cmd)
return _cp(cmd)
with mock.patch.object(
deploy_mod, "ssh_cmd",
side_effect=lambda h, u, c: ["ssh", "dummy", c]):
with mock.patch.object(deploy_mod.subprocess, "run",
side_effect=fake_run):
deploy_mod.remote_rm("h", "u", "/tmp/x.tar.gz")
self.assertTrue(any(c[-1] == "rm -f /tmp/x.tar.gz" for c in seen))
def test_build_release_archive_failure_exits(self):
with tempfile.TemporaryDirectory() as tmp:
_make_git_repo(tmp)
old_root = deploy_mod.REPO_ROOT
deploy_mod.REPO_ROOT = tmp
try:
# rev-parse 能过、archive 必败的 ref:一个 blob 的 sha
#(rev-parse 成功,git archive 拒绝非提交对象)。
blob = subprocess.run(
["git", "-C", tmp, "hash-object", "-w", "--stdin"],
input="blob-content\n", check=True, capture_output=True,
text=True).stdout.strip()
self.assertEqual(len(blob), 40)
with self.assertRaises(SystemExit) as ctx:
deploy_mod.build_release(blob, tmp)
self.assertEqual(ctx.exception.code, 1)
finally:
deploy_mod.REPO_ROOT = old_root
class DeployTransferTest(unittest.TestCase):
def _helpers(self, key=True, plain=True):
def fake_helper(name):
if name == "chunk_send_key_explicit.py" and key:
return "/helpers/chunk_send_key_explicit.py"
if name == "chunk_send.py" and plain:
return "/helpers/chunk_send.py"
return None
return fake_helper
def test_all_attempts_fail_exits(self):
def fake_run(cmd, **kwargs):
if cmd and cmd[0] == "scp":
return subprocess.CompletedProcess(
args=cmd, returncode=1, stdout="", stderr="reset")
return subprocess.CompletedProcess(
args=cmd, returncode=1, stdout="", stderr="chunk fail")
with mock.patch.object(deploy_mod, "helper_path",
side_effect=self._helpers()):
with mock.patch.object(deploy_mod.subprocess, "run",
side_effect=fake_run):
with mock.patch.dict(os.environ,
{"SSH_PASSWORD": "x"}):
with self.assertRaises(SystemExit) as ctx:
deploy_mod.transfer("h", "u", "/tmp/a", "/tmp/b",
"0" * 64)
self.assertEqual(ctx.exception.code, 1)
def test_chunk_decode_failure_continues_then_exits(self):
def fake_run(cmd, **kwargs):
if cmd and cmd[0] == "scp":
return subprocess.CompletedProcess(
args=cmd, returncode=1, stdout="", stderr="reset")
if cmd and cmd[0] == "python3":
return _cp(cmd) # 分片脚本本身成功
return subprocess.CompletedProcess(
args=cmd, returncode=1, stdout="", stderr="decode fail")
with mock.patch.object(deploy_mod, "helper_path",
side_effect=self._helpers()):
with mock.patch.object(deploy_mod, "ssh_cmd",
return_value=["ssh", "dummy"]):
with mock.patch.object(deploy_mod.subprocess, "run",
side_effect=fake_run):
with mock.patch.dict(os.environ,
{"SSH_PASSWORD": "x"}):
with self.assertRaises(SystemExit):
deploy_mod.transfer("h", "u", "/tmp/a",
"/tmp/b", "0" * 64)
def test_sha_mismatch_removes_and_exits(self):
removed = []
def fake_run(cmd, **kwargs):
return _cp(cmd) # scp 成功
with mock.patch.object(deploy_mod, "helper_path",
side_effect=self._helpers(key=False,
plain=False)):
with mock.patch.object(deploy_mod.subprocess, "run",
side_effect=fake_run):
with mock.patch.object(
deploy_mod, "remote_sha",
return_value="1" * 64):
with mock.patch.object(
deploy_mod, "remote_rm",
side_effect=lambda h, u, p: removed.append(p)):
with self.assertRaises(SystemExit):
deploy_mod.transfer("h", "u", "/tmp/a",
"/tmp/b", "0" * 64)
self.assertEqual(removed, ["/tmp/b"])
class DeployViaGitTest(unittest.TestCase):
def _args(self, **over):
base = dict(tarball=None, build_only=False, host="h.example",
user="root", dry_run=False,
repo="git@github.com:openqj/codex-app-server.git",
git_key="/root/.ssh/id_codex_github",
allow_dirty=True, skip_tests=True, ref="HEAD")
base.update(over)
return SimpleNamespace(**base)
def test_conflicts_and_guards_exit_2(self):
for over, in_msg in [
(dict(tarball="/tmp/x.tgz"), "tarball"),
(dict(build_only=True), "build-only"),
(dict(host=None, dry_run=False), "host"),
(dict(repo="https://github.com/x/y.git"), "SSH")]:
with self.subTest(over=over):
err = io.StringIO()
with redirect_stderr(err):
with self.assertRaises(SystemExit) as ctx:
deploy_mod.deploy_via_git(self._args(**over))
self.assertEqual(ctx.exception.code, 2)
def test_dry_run_success(self):
with tempfile.TemporaryDirectory() as tmp:
head = _make_git_repo(tmp)
old_root = deploy_mod.REPO_ROOT
deploy_mod.REPO_ROOT = tmp
try:
out = io.StringIO()
with redirect_stdout(out):
rc = deploy_mod.deploy_via_git(
self._args(dry_run=True))
self.assertEqual(rc, 0)
self.assertIn(head, out.getvalue())
finally:
deploy_mod.REPO_ROOT = old_root
def _ssh_fake(self, head, script_rc=0, script_out=None, live=None):
real_run = subprocess.run
def fake_run(cmd, **kwargs):
if cmd and cmd[0] == "git":
return real_run(cmd, **kwargs)
last = cmd[-1] if cmd else ""
if "cat /opt/codex-app-server/DEPLOYED_COMMIT" in last:
return subprocess.CompletedProcess(
args=cmd, returncode=0,
stdout=(live if live is not None else head) + "\n",
stderr="")
if last.startswith("sh "):
return subprocess.CompletedProcess(
args=cmd, returncode=script_rc,
stdout=(script_out if script_out is not None
else "RELEASE_OK commit=%s" % head),
stderr="remote-err" if script_rc != 0 else "")
return _cp(cmd)
return fake_run
def test_non_dry_run_success_and_failures(self):
with tempfile.TemporaryDirectory() as tmp:
head = _make_git_repo(tmp)
old_root = deploy_mod.REPO_ROOT
deploy_mod.REPO_ROOT = tmp
try:
transfers = []
with mock.patch.object(
deploy_mod, "ssh_cmd",
side_effect=lambda h, u, c: ["ssh", "dummy", c]):
with mock.patch.object(
deploy_mod, "transfer",
side_effect=lambda *a: transfers.append(a)):
with mock.patch.object(
deploy_mod.subprocess, "run",
side_effect=self._ssh_fake(head)):
rc = deploy_mod.deploy_via_git(self._args())
self.assertEqual(rc, 0)
self.assertEqual(len(transfers), 2)
# 主机脚本失败 -> exit 1
with mock.patch.object(
deploy_mod, "ssh_cmd",
side_effect=lambda h, u, c: ["ssh", "dummy", c]):
with mock.patch.object(deploy_mod, "transfer"):
with mock.patch.object(
deploy_mod.subprocess, "run",
side_effect=self._ssh_fake(
head, script_rc=1, script_out="boom")):
with self.assertRaises(SystemExit):
deploy_mod.deploy_via_git(self._args())
# live 二次核对不符 -> exit 1
with mock.patch.object(
deploy_mod, "ssh_cmd",
side_effect=lambda h, u, c: ["ssh", "dummy", c]):
with mock.patch.object(deploy_mod, "transfer"):
with mock.patch.object(
deploy_mod.subprocess, "run",
side_effect=self._ssh_fake(
head, live="0" * 40)):
with self.assertRaises(SystemExit):
deploy_mod.deploy_via_git(self._args())
finally:
deploy_mod.REPO_ROOT = old_root
class DeployMainTest(unittest.TestCase):
def _tarball(self, tmp):
head = _make_git_repo(tmp)
old_root = deploy_mod.REPO_ROOT
deploy_mod.REPO_ROOT = tmp
try:
tarball, sha, commit = deploy_mod.build_release("HEAD", tmp)
finally:
deploy_mod.REPO_ROOT = old_root
self.assertEqual(commit, head)
return tarball, head
def test_no_host_errors(self):
with self.assertRaises(SystemExit) as ctx:
deploy_mod.main([])
self.assertEqual(ctx.exception.code, 2)
def test_tarball_mismatch_and_dry_run(self):
with tempfile.TemporaryDirectory() as tmp:
tarball, head = self._tarball(tmp)
with self.assertRaises(SystemExit) as ctx:
deploy_mod.main(["h.example", "--tarball", tarball,
"--commit", "0" * 40])
self.assertEqual(ctx.exception.code, 2)
rc = deploy_mod.main(["h.example", "--tarball", tarball,
"--dry-run"])
self.assertEqual(rc, 0)
def test_build_only(self):
with tempfile.TemporaryDirectory() as tmp:
out = io.StringIO()
with redirect_stdout(out):
rc = deploy_mod.main(["--build-only", "--allow-dirty",
"--skip-tests", "--out-dir", tmp])
self.assertEqual(rc, 0)
self.assertIn("仅构建", out.getvalue())
self.assertTrue(any(n.endswith(".tar.gz")
for n in os.listdir(tmp)))
def test_git_dry_run_via_main(self):
with tempfile.TemporaryDirectory() as tmp:
_make_git_repo(tmp)
old_root = deploy_mod.REPO_ROOT
deploy_mod.REPO_ROOT = tmp
try:
rc = deploy_mod.main(["--source", "git", "--dry-run",
"--skip-tests", "--allow-dirty"])
self.assertEqual(rc, 0)
finally:
deploy_mod.REPO_ROOT = old_root
def _ssh_fake_main(self, head, script_rc=0, live=None):
def fake_run(cmd, **kwargs):
last = cmd[-1] if cmd else ""
if "cat /opt/codex-app-server/DEPLOYED_COMMIT" in last:
return subprocess.CompletedProcess(
args=cmd, returncode=0,
stdout=(live if live is not None else head) + "\n",
stderr="")
if last.startswith("sh "):
return subprocess.CompletedProcess(
args=cmd, returncode=script_rc,
stdout="RELEASE_OK commit=%s" % head if script_rc == 0
else "boom",
stderr="remote-err" if script_rc != 0 else "")
return _cp(cmd)
return fake_run
def test_tar_deploy_success_and_failures(self):
with tempfile.TemporaryDirectory() as tmp:
tarball, head = self._tarball(tmp)
for script_rc, live, ok in [(0, None, True),
(1, None, False),
(0, "0" * 40, False)]:
with self.subTest(script_rc=script_rc, live=live):
with mock.patch.object(
deploy_mod, "ssh_cmd",
side_effect=lambda h, u, c: ["ssh", "dummy", c]):
with mock.patch.object(deploy_mod, "transfer"):
with mock.patch.object(
deploy_mod.subprocess, "run",
side_effect=self._ssh_fake_main(
head, script_rc, live)):
if ok:
rc = deploy_mod.main(
["h.example", "--tarball", tarball])
self.assertEqual(rc, 0)
else:
with self.assertRaises(SystemExit):
deploy_mod.main(
["h.example", "--tarball",
tarball])
if __name__ == "__main__":
unittest.main()