Add a cancel action for queued and running backup runs. Queued runs are cancelled immediately, while running runs are marked for cancellation and the worker terminates the active rsync process group. Make dry-run log paths run-specific and add a defensive default dry-run timeout so stuck dry-runs do not remain running indefinitely. Remove rsync exit codes from run overview tables while keeping detailed diagnostics available on the run detail payload.
161 lines
6.5 KiB
Python
161 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from unittest.mock import patch
|
|
|
|
from django.test import SimpleTestCase
|
|
|
|
from pobsync.commands.run_scheduled import run_scheduled
|
|
from pobsync.rsync import RsyncResult
|
|
|
|
|
|
class FakeConfigSource:
|
|
def __init__(self, backup_root: str = "/tmp/pobsync-test-backups") -> None:
|
|
self.backup_root = backup_root
|
|
|
|
def effective_config_for_host(self, host: str) -> dict:
|
|
return {
|
|
"backup_root": self.backup_root,
|
|
"host": host,
|
|
"address": "example.test",
|
|
"ssh": {"user": "root", "port": 22, "options": []},
|
|
"rsync": {
|
|
"binary": "rsync",
|
|
"args_effective": ["--archive"],
|
|
"timeout_seconds": 0,
|
|
"bwlimit_kbps": 0,
|
|
},
|
|
"source_root": "/",
|
|
"includes": [],
|
|
"excludes_effective": [],
|
|
"retention": {"daily": 7, "weekly": 4, "monthly": 3, "yearly": 1},
|
|
}
|
|
|
|
|
|
class RunScheduledConfigSourceTests(SimpleTestCase):
|
|
def test_dry_run_uses_injected_config_source(self) -> None:
|
|
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
|
|
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])
|
|
|
|
result = run_scheduled(
|
|
prefix=Path("/missing-prefix"),
|
|
host="web-01",
|
|
dry_run=True,
|
|
config_source=FakeConfigSource(),
|
|
)
|
|
|
|
self.assertTrue(result["ok"])
|
|
self.assertEqual(result["host"], "web-01")
|
|
run_rsync.assert_called_once()
|
|
|
|
def test_failed_dry_run_includes_log_tail(self) -> None:
|
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_path.write_text("Permission denied (publickey).\nrsync error\n", encoding="utf-8")
|
|
return RsyncResult(exit_code=12, command=command)
|
|
|
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
|
result = run_scheduled(
|
|
prefix=Path("/missing-prefix"),
|
|
host="web-01",
|
|
dry_run=True,
|
|
config_source=FakeConfigSource(),
|
|
)
|
|
|
|
self.assertFalse(result["ok"])
|
|
self.assertEqual(result["rsync"]["exit_code"], 12)
|
|
self.assertEqual(result["rsync"]["log_tail"], ["Permission denied (publickey).", "rsync error"])
|
|
|
|
def test_dry_run_clears_previous_log_before_running(self) -> None:
|
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
|
self.assertFalse(log_path.exists())
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_path.write_text("current run only\n", encoding="utf-8")
|
|
return RsyncResult(exit_code=0, command=command)
|
|
|
|
old_log = Path("/tmp/pobsync-dryrun/web-01/adhoc/rsync.log")
|
|
old_log.parent.mkdir(parents=True, exist_ok=True)
|
|
old_log.write_text("old failure\n", encoding="utf-8")
|
|
|
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
|
result = run_scheduled(
|
|
prefix=Path("/missing-prefix"),
|
|
host="web-01",
|
|
dry_run=True,
|
|
config_source=FakeConfigSource(),
|
|
)
|
|
|
|
self.assertTrue(result["ok"])
|
|
self.assertEqual(result["rsync"]["log_tail"], ["current run only"])
|
|
|
|
def test_dry_run_uses_run_specific_log_path_and_default_timeout(self) -> None:
|
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
|
self.assertEqual(log_path, Path("/tmp/pobsync-dryrun/web-01/run-42/rsync.log"))
|
|
self.assertEqual(timeout_seconds, 900)
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_path.write_text("run 42\n", encoding="utf-8")
|
|
return RsyncResult(exit_code=0, command=command)
|
|
|
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
|
result = run_scheduled(
|
|
prefix=Path("/missing-prefix"),
|
|
host="web-01",
|
|
dry_run=True,
|
|
config_source=FakeConfigSource(),
|
|
run_id=42,
|
|
)
|
|
|
|
self.assertTrue(result["ok"])
|
|
self.assertEqual(result["log"], "/tmp/pobsync-dryrun/web-01/run-42/rsync.log")
|
|
self.assertEqual(result["timeout_seconds"], 900)
|
|
|
|
def test_dry_run_reports_cancelled_rsync(self) -> None:
|
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
|
self.assertTrue(cancel_check())
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_path.write_text("[pobsync] rsync cancelled\n", encoding="utf-8")
|
|
return RsyncResult(exit_code=130, command=command, cancelled=True)
|
|
|
|
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
|
|
result = run_scheduled(
|
|
prefix=Path("/missing-prefix"),
|
|
host="web-01",
|
|
dry_run=True,
|
|
config_source=FakeConfigSource(),
|
|
cancel_check=lambda: True,
|
|
)
|
|
|
|
self.assertFalse(result["ok"])
|
|
self.assertTrue(result["cancelled"])
|
|
self.assertEqual(result["rsync"]["exit_code"], 130)
|
|
|
|
def test_successful_real_run_applies_prune_when_requested(self) -> None:
|
|
with TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "home"
|
|
with (
|
|
patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync,
|
|
patch("pobsync.commands.retention_apply.run_retention_plan") as plan,
|
|
):
|
|
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--archive"])
|
|
plan.return_value = {
|
|
"ok": True,
|
|
"delete": [],
|
|
"keep": [],
|
|
"reasons": {},
|
|
"protect_bases": False,
|
|
}
|
|
|
|
result = run_scheduled(
|
|
prefix=prefix,
|
|
host="web-01",
|
|
dry_run=False,
|
|
prune=True,
|
|
prune_max_delete=10,
|
|
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")),
|
|
)
|
|
|
|
self.assertTrue(result["ok"])
|
|
self.assertIsNotNone(result["prune"])
|
|
self.assertEqual(result["prune"]["deleted"], [])
|