feat(deployer): fall back to scp when rsync is missing
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user