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, "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 = commands[0] self.assertEqual(mkdir_cmd[0], "ssh") self.assertIn("-p", mkdir_cmd) self.assertIn("2222", mkdir_cmd) self.assertTrue(any("/opt/app/svc/cfg" in part for part in 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()