mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-11-03 03:38:15 +00:00
svc-bkp-rmt-2-loc: migrate pull script to Python + add unit tests; lock down backup-provider ACLs
- Replace Bash pull-specific-host.sh with Python pull-specific-host.py (argparse, identical logic) - Update role vars and runner template to call python script - Add __init__.py files for test discovery/imports - Add unittest: tests/unit/roles/svc-bkp-rmt-2-loc/files/test_pull_specific_host.py (mocks subprocess/os/time; covers success, no types, find-fail, retry-exhaustion) - Backup provider SSH wrapper: align allowed ls path (backup-docker-to-local) - Split user role tasks: 01_core (sudoers), 02_permissions_ssh (SSH keys + wrapper), 03_permissions_folders (ownership + default ACLs + depth-limited chown/chmod) - Ensure default ACLs grant rwx to 'backup' and none to group/other; keep sudo rsync working Ref: ChatGPT discussion (2025-10-14) — https://chatgpt.com/share/68ee920a-9b98-800f-8806-ddcfe0255149
This commit is contained in:
0
tests/unit/roles/svc-bkp-rmt-2-loc/__init__.py
Normal file
0
tests/unit/roles/svc-bkp-rmt-2-loc/__init__.py
Normal file
@@ -0,0 +1,122 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user