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:
2025-10-14 20:10:49 +02:00
parent e54436821c
commit 05d7ddc491
14 changed files with 351 additions and 115 deletions

View 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()