155 lines
5.8 KiB
Python
155 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEPLOY_SCRIPTS = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy"
|
|
SYNC_PY = DEPLOY_SCRIPTS / "sync.py"
|
|
|
|
if str(DEPLOY_SCRIPTS) not in sys.path:
|
|
sys.path.insert(0, str(DEPLOY_SCRIPTS))
|
|
|
|
sync_spec = importlib.util.spec_from_file_location("deployer_sync", SYNC_PY)
|
|
assert sync_spec is not None and sync_spec.loader is not None
|
|
deployer_sync = importlib.util.module_from_spec(sync_spec)
|
|
sync_spec.loader.exec_module(deployer_sync)
|
|
|
|
|
|
def _info(service_dir: str, **overrides) -> dict:
|
|
info = {
|
|
"service_dir": service_dir,
|
|
"name": "demo-service",
|
|
"node": "edge",
|
|
"remote_dir": "/opt/app/svc",
|
|
"sync_exclude": ["data", "_data"],
|
|
"port": None,
|
|
"identity_file": None,
|
|
}
|
|
info.update(overrides)
|
|
return info
|
|
|
|
|
|
class DeployerSyncTests(unittest.TestCase):
|
|
def test_collect_sync_files_skips_excluded_directories(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
src = Path(temp)
|
|
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
|
|
(src / "data").mkdir()
|
|
(src / "data" / "db").write_text("nope\n", encoding="utf-8")
|
|
nested = src / "cfg"
|
|
nested.mkdir()
|
|
(nested / "app.env").write_text("x\n", encoding="utf-8")
|
|
(nested / "data").mkdir()
|
|
(nested / "data" / "cache").write_text("nope\n", encoding="utf-8")
|
|
|
|
files = {
|
|
p.relative_to(src).as_posix()
|
|
for p in deployer_sync.collect_sync_files(str(src), ["data", "_data"])
|
|
}
|
|
self.assertEqual(files, {"compose.yaml", "cfg/app.env"})
|
|
|
|
def test_prefers_rsync_when_available(self) -> None:
|
|
info = _info("/tmp/svc")
|
|
completed = subprocess.CompletedProcess(["rsync"], 0)
|
|
with mock.patch.object(deployer_sync.shutil, "which", return_value="/usr/bin/rsync"):
|
|
with mock.patch.object(
|
|
deployer_sync.subprocess, "run", return_value=completed
|
|
) as run:
|
|
rc = deployer_sync.sync_directory(info)
|
|
self.assertEqual(rc, 0)
|
|
cmd = run.call_args[0][0]
|
|
self.assertEqual(cmd[0], "rsync")
|
|
self.assertIn("--delete", cmd)
|
|
self.assertIn("--exclude=data/", cmd)
|
|
|
|
def test_falls_back_to_scp_when_rsync_missing(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
src = Path(temp)
|
|
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
|
|
(src / "data").mkdir()
|
|
(src / "data" / "db").write_text("nope\n", encoding="utf-8")
|
|
nested = src / "cfg"
|
|
nested.mkdir()
|
|
(nested / "app.env").write_text("x\n", encoding="utf-8")
|
|
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
commands.append(list(cmd))
|
|
return subprocess.CompletedProcess(cmd, 0)
|
|
|
|
info = _info(str(src), port=2222, identity_file="/tmp/key")
|
|
with mock.patch.object(deployer_sync.shutil, "which", return_value=None):
|
|
with mock.patch.object(
|
|
deployer_sync.subprocess, "run", side_effect=fake_run
|
|
):
|
|
rc = deployer_sync.sync_directory(info)
|
|
|
|
self.assertEqual(rc, 0)
|
|
self.assertTrue(commands)
|
|
mkdir_cmd = next(
|
|
command
|
|
for command in commands
|
|
if command and command[0] == "ssh" and command[-1].startswith("mkdir -p ")
|
|
)
|
|
self.assertEqual(mkdir_cmd[0], "ssh")
|
|
self.assertIn("-p", mkdir_cmd)
|
|
self.assertIn("2222", mkdir_cmd)
|
|
self.assertIn("/opt/app/svc/cfg", " ".join(mkdir_cmd))
|
|
|
|
scp_cmds = [c for c in commands if c and c[0] == "scp"]
|
|
self.assertEqual(len(scp_cmds), 2)
|
|
joined = "\n".join(" ".join(c) for c in scp_cmds)
|
|
self.assertIn("compose.yaml", joined)
|
|
self.assertIn("app.env", joined)
|
|
self.assertNotIn("db", joined)
|
|
self.assertNotIn("--delete", joined)
|
|
self.assertIn("-P", scp_cmds[0])
|
|
self.assertIn("2222", scp_cmds[0])
|
|
self.assertIn("-i", scp_cmds[0])
|
|
self.assertIn("/tmp/key", scp_cmds[0])
|
|
|
|
def test_rsync_filenotfound_falls_back_to_scp(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
src = Path(temp)
|
|
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
if cmd and cmd[0] == "rsync":
|
|
raise FileNotFoundError("rsync")
|
|
commands.append(list(cmd))
|
|
return subprocess.CompletedProcess(cmd, 0)
|
|
|
|
with mock.patch.object(
|
|
deployer_sync.shutil, "which", return_value="/usr/bin/rsync"
|
|
):
|
|
with mock.patch.object(
|
|
deployer_sync.subprocess, "run", side_effect=fake_run
|
|
):
|
|
rc = deployer_sync.sync_directory(_info(str(src)))
|
|
|
|
self.assertEqual(rc, 0)
|
|
self.assertTrue(any(c and c[0] == "scp" for c in commands))
|
|
|
|
def test_docs_say_scp_fallback_not_tar(self) -> None:
|
|
compose = (
|
|
REPO_ROOT / "skills" / "deployer" / "references" / "compose.md"
|
|
).read_text(encoding="utf-8")
|
|
check = (DEPLOY_SCRIPTS / "check.py").read_text(encoding="utf-8")
|
|
self.assertIn("本机无 rsync 时 scp 兜底", compose)
|
|
self.assertNotIn("本机无 rsync 时 tar over SSH 兜底", compose)
|
|
self.assertIn("scp fallback", check)
|
|
self.assertNotIn("tar-over-SSH", check)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|