399 lines
17 KiB
Python
399 lines
17 KiB
Python
"""覆盖率 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()
|