import unittest import sys import types from pathlib import Path from unittest.mock import patch, MagicMock import subprocess import time import os def load_module(): """ Dynamically load the target script: roles/svc-bkp-rmt-2-loc/files/pull-specific-host.py relative to this test file. """ here = Path(__file__).resolve() # tests/unit/roles/svc-bkp-rmt-2-loc/files -> up 5 levels to repo root repo_root = here.parents[5] target_path = repo_root / "roles" / "svc-bkp-rmt-2-loc" / "files" / "pull-specific-host.py" if not target_path.exists(): raise FileNotFoundError(f"Cannot find script at {target_path}") spec = types.ModuleType("pull_specific_host_module") code = target_path.read_text(encoding="utf-8") exec(compile(code, str(target_path), "exec"), spec.__dict__) return spec class PullSpecificHostTests(unittest.TestCase): def setUp(self): self.mod = load_module() self.hash64 = "a" * 64 self.host = "1.2.3.4" self.remote = f"backup@{self.host}" self.base = f"/Backups/{self.hash64}/" self.backup_type = "backup-docker-to-local" self.type_dir = f"{self.base}{self.backup_type}/" self.last_local = f"{self.type_dir}20250101000000" self.last_remote = f"{self.type_dir}20250202000000" def _completed(self, stdout="", returncode=0): return subprocess.CompletedProcess(args="mock", returncode=returncode, stdout=stdout, stderr="") def _run_side_effect_success(self, command, capture_output=True, shell=True, text=True, check=False): cmd = command if isinstance(command, str) else " ".join(command) if cmd.startswith(f'ssh "{self.remote}" sha256sum /etc/machine-id'): return self._completed(stdout=f"{self.hash64} /etc/machine-id\n") if cmd.startswith(f'ssh "{self.remote}" "find {self.base} -maxdepth 1 -type d -execdir basename {{}} ;"'): return self._completed(stdout=f"{self.hash64}\n{self.backup_type}\n") if cmd.startswith(f"ls -d {self.type_dir}* | tail -1"): return self._completed(stdout=self.last_local) if cmd.startswith(f'ssh "{self.remote}" "ls -d {self.type_dir}*'): return self._completed(stdout=f"{self.last_remote}\n") return self._completed(stdout="") def _run_side_effect_find_fail(self, command, capture_output=True, shell=True, text=True, check=False): cmd = command if isinstance(command, str) else " ".join(command) if cmd.startswith(f'ssh "backup@{self.host}" "find {self.base} -maxdepth 1 -type d -execdir basename {{}} ;"'): raise subprocess.CalledProcessError(returncode=1, cmd=cmd, output="", stderr="find: error") if cmd.startswith(f'ssh "backup@{self.host}" sha256sum /etc/machine-id'): return self._completed(stdout=f"{self.hash64} /etc/machine-id\n") return self._completed(stdout="") def _run_side_effect_no_types(self, command, capture_output=True, shell=True, text=True, check=False): cmd = command if isinstance(command, str) else " ".join(command) if cmd.startswith(f'ssh "{self.remote}" sha256sum /etc/machine-id'): return self._completed(stdout=f"{self.hash64} /etc/machine-id\n") if cmd.startswith(f'ssh "{self.remote}" "find {self.base} -maxdepth 1 -type d -execdir basename {{}} ;"'): return self._completed(stdout="") return self._completed(stdout="") @patch("time.sleep", new=lambda *a, **k: None) @patch.object(os, "makedirs") @patch.object(os, "system") @patch.object(subprocess, "run") def test_success_rsync_zero_exit(self, mock_run, mock_system, _mkd): mock_run.side_effect = self._run_side_effect_success mock_system.return_value = 0 with self.assertRaises(SystemExit) as cm: self.mod.pull_backups(self.host) self.assertEqual(cm.exception.code, 0) self.assertTrue(mock_system.called, "rsync (os.system) should be called") @patch("time.sleep", new=lambda *a, **k: None) @patch.object(os, "makedirs") @patch.object(os, "system") @patch.object(subprocess, "run") def test_no_backup_types_exit_zero(self, mock_run, mock_system, _mkd): mock_run.side_effect = self._run_side_effect_no_types mock_system.return_value = 0 with self.assertRaises(SystemExit) as cm: self.mod.pull_backups(self.host) self.assertEqual(cm.exception.code, 0) self.assertFalse(mock_system.called, "rsync should not be called when no types found") @patch("time.sleep", new=lambda *a, **k: None) @patch.object(os, "makedirs") @patch.object(os, "system") @patch.object(subprocess, "run") def test_find_failure_exits_one(self, mock_run, mock_system, _mkd): mock_run.side_effect = self._run_side_effect_find_fail mock_system.return_value = 0 with self.assertRaises(SystemExit) as cm: self.mod.pull_backups(self.host) self.assertEqual(cm.exception.code, 1) self.assertFalse(mock_system.called, "rsync should not be called when find fails") @patch("time.sleep", new=lambda *a, **k: None) @patch.object(os, "makedirs") @patch.object(os, "system") @patch.object(subprocess, "run") def test_rsync_fails_after_retries_exit_nonzero(self, mock_run, mock_system, _mkd): mock_run.side_effect = self._run_side_effect_success mock_system.side_effect = [1] * 12 # 12 retries in the script with self.assertRaises(SystemExit) as cm: self.mod.pull_backups(self.host) self.assertEqual(cm.exception.code, 1) self.assertEqual(mock_system.call_count, 12, "rsync should have retried 12 times") if __name__ == "__main__": unittest.main()