(feature) Add host backup preflight gates
Introduce a host preflight layer that separates dry-run blockers from real backup blockers. Show the effective per-host backup configuration in Django before queueing a run. Block real backup queueing when failed host checks remain, while still allowing dry-runs when only local storage preparation is missing.
This commit is contained in:
@@ -557,18 +557,22 @@ class ViewTests(TestCase):
|
||||
|
||||
def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
GlobalConfig.objects.create(name="default", backup_root="/backups")
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
source_root="/srv",
|
||||
retention_daily=7,
|
||||
)
|
||||
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", prune=True, last_status="success")
|
||||
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
|
||||
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||
with TemporaryDirectory() as tmp:
|
||||
backup_root = Path(tmp)
|
||||
GlobalConfig.objects.create(name="default", backup_root=str(backup_root), rsync_args=["--archive"])
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
source_root="/srv",
|
||||
retention_daily=7,
|
||||
)
|
||||
for subdir in ("scheduled", "manual", ".incomplete"):
|
||||
(backup_root / host.host / subdir).mkdir(parents=True)
|
||||
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", prune=True, last_status="success")
|
||||
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
|
||||
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "web-01")
|
||||
@@ -587,12 +591,54 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "Queue backup")
|
||||
self.assertContains(response, "Host Check")
|
||||
self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
|
||||
self.assertContains(response, "ready")
|
||||
self.assertContains(response, "warning")
|
||||
self.assertContains(response, "Snapshot Discovery")
|
||||
self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
|
||||
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
|
||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
||||
|
||||
def test_host_detail_renders_effective_config_preview(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
credential = SshCredential.objects.create(name="default-key", key_path="/var/lib/pobsync/id_ed25519")
|
||||
GlobalConfig.objects.create(
|
||||
name="default",
|
||||
backup_root="/backups",
|
||||
default_ssh_credential=credential,
|
||||
ssh_user="root",
|
||||
ssh_port=2222,
|
||||
ssh_options=["-oBatchMode=yes"],
|
||||
rsync_args=["--archive", "--numeric-ids"],
|
||||
rsync_extra_args=["--delete"],
|
||||
rsync_timeout_seconds=300,
|
||||
rsync_bwlimit_kbps=2048,
|
||||
default_source_root="/",
|
||||
excludes_default=["/proc/***", "/sys/***"],
|
||||
retention_daily=14,
|
||||
retention_weekly=4,
|
||||
retention_monthly=2,
|
||||
retention_yearly=1,
|
||||
)
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
includes=["/srv/www/***"],
|
||||
excludes_add=["/srv/www/cache/***"],
|
||||
rsync_extra_args=["--one-file-system"],
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Effective Config")
|
||||
self.assertContains(response, "root@web-01.example.test:2222")
|
||||
self.assertContains(response, "default-key")
|
||||
self.assertContains(response, "-oBatchMode=yes")
|
||||
self.assertContains(response, "--archive --numeric-ids --delete --one-file-system")
|
||||
self.assertContains(response, "/srv/www/***")
|
||||
self.assertContains(response, "/srv/www/cache/***")
|
||||
self.assertContains(response, "d14")
|
||||
self.assertContains(response, "w8")
|
||||
|
||||
def test_host_detail_renders_backup_trends(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
GlobalConfig.objects.create(name="default", backup_root="/backups")
|
||||
@@ -779,7 +825,7 @@ class ViewTests(TestCase):
|
||||
|
||||
def test_queue_manual_backup_creates_queued_run_and_redirects_to_run_detail(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
GlobalConfig.objects.create(name="default", backup_root="/backups")
|
||||
GlobalConfig.objects.create(name="default", backup_root="/backups", rsync_args=["--archive"])
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
|
||||
response = self.client.post(
|
||||
@@ -812,14 +858,18 @@ class ViewTests(TestCase):
|
||||
|
||||
def test_queue_manual_backup_quick_action_can_queue_real_backup(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
GlobalConfig.objects.create(name="default", backup_root="/backups")
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
with TemporaryDirectory() as tmp:
|
||||
backup_root = Path(tmp)
|
||||
GlobalConfig.objects.create(name="default", backup_root=str(backup_root), rsync_args=["--archive"])
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
for subdir in ("scheduled", "manual", ".incomplete"):
|
||||
(backup_root / host.host / subdir).mkdir(parents=True)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("queue_manual_backup", args=[host.host]),
|
||||
{"prune_max_delete": "10"},
|
||||
follow=True,
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("queue_manual_backup", args=[host.host]),
|
||||
{"prune_max_delete": "10"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
run = BackupRun.objects.get(host=host)
|
||||
self.assertRedirects(response, reverse("run_detail", args=[run.id]))
|
||||
@@ -834,6 +884,41 @@ class ViewTests(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_queue_manual_backup_blocks_real_backup_when_host_directories_are_missing(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
with TemporaryDirectory() as tmp:
|
||||
backup_root = Path(tmp)
|
||||
GlobalConfig.objects.create(name="default", backup_root=str(backup_root), rsync_args=["--archive"])
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("queue_manual_backup", args=[host.host]),
|
||||
{"prune_max_delete": "10"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||
self.assertContains(response, "Cannot queue real backup until failed preflight checks are resolved")
|
||||
self.assertContains(response, "Host backup root")
|
||||
self.assertFalse(BackupRun.objects.exists())
|
||||
|
||||
def test_queue_manual_backup_allows_dry_run_with_only_storage_preflight_failures(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
with TemporaryDirectory() as tmp:
|
||||
backup_root = Path(tmp)
|
||||
GlobalConfig.objects.create(name="default", backup_root=str(backup_root), rsync_args=["--archive"])
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("queue_manual_backup", args=[host.host]),
|
||||
{"dry_run": "on", "verbose_output": "on", "prune_max_delete": "10"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
run = BackupRun.objects.get(host=host)
|
||||
self.assertRedirects(response, reverse("run_detail", args=[run.id]))
|
||||
self.assertEqual(run.result["requested"]["dry_run"], True)
|
||||
|
||||
def test_queue_manual_backup_requires_default_global_config(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
@@ -846,7 +931,7 @@ class ViewTests(TestCase):
|
||||
|
||||
def test_queue_manual_backup_rejects_disabled_host(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
GlobalConfig.objects.create(name="default", backup_root="/backups")
|
||||
GlobalConfig.objects.create(name="default", backup_root="/backups", rsync_args=["--archive"])
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test", enabled=False)
|
||||
|
||||
response = self.client.post(reverse("queue_manual_backup", args=[host.host]), {"dry_run": "on"}, follow=True)
|
||||
|
||||
Reference in New Issue
Block a user