@@ -165,8 +205,9 @@
{% if active_run %}
{{ active_run.status }}
Run {{ active_run.id }}
- {% elif can_queue_backup %}
-
ready
+ {% elif has_global_config and host.enabled %}
+
{{ backup_gate.state }}
+
{{ backup_gate.message }}
{% elif not host.enabled %}
disabled
{% elif not has_global_config %}
@@ -180,20 +221,24 @@
-
+
- {% if not can_queue_backup %}
+ {% if active_run %}
+
Wait for the active run to finish, or cancel it from the run detail page.
+ {% elif not can_queue_dry_run or not can_queue_real_backup %}
{% if not has_global_config %}
Create the default global config before queueing backups.
{% elif not host.enabled %}
Enable this host before queueing backups.
+ {% elif backup_gate.real_blockers %}
+
Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.
{% endif %}
{% endif %}
@@ -212,7 +257,7 @@
{% endfor %}
-
+
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py
index 6b00d13..629ca87 100644
--- a/src/pobsync_backend/tests/test_views.py
+++ b/src/pobsync_backend/tests/test_views.py
@@ -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)
diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py
index 98b4969..c9b45de 100644
--- a/src/pobsync_backend/views.py
+++ b/src/pobsync_backend/views.py
@@ -29,8 +29,9 @@ from .forms import (
ScheduleConfigForm,
SshCredentialForm,
)
-from .host_ops import collect_host_checks, ensure_host_directories
+from .host_ops import ensure_host_directories
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
+from .preflight import collect_backup_gate, effective_host_config_preview
from .retention import run_sql_retention_apply, run_sql_retention_plan
from .self_check import collect_self_checks, summarize_self_checks
from .scheduler import next_due_after
@@ -260,14 +261,15 @@ def create_host_config(request):
@staff_member_required
def host_detail(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
+ global_config = GlobalConfig.objects.filter(name="default").first()
schedule = _schedule_for_host(host_config)
queued_runs = host_config.runs.filter(status=BackupRun.Status.QUEUED)
running_runs = host_config.runs.filter(status=BackupRun.Status.RUNNING)
active_run = host_config.runs.filter(
status__in=[BackupRun.Status.QUEUED, BackupRun.Status.RUNNING]
).order_by("created_at", "id").first()
- has_global_config = GlobalConfig.objects.filter(name="default").exists()
- host_checks = collect_host_checks(host_config)
+ has_global_config = global_config is not None
+ backup_gate = collect_backup_gate(host_config, global_config)
stats_summary = collect_host_stats(host=host_config, limit=10)
context = {
"host": host_config,
@@ -275,11 +277,14 @@ def host_detail(request, host: str):
"next_run_at": _next_run_for_schedule(schedule, host_config),
"scheduler_timezone": timezone.get_current_timezone_name(),
"discovery": inspect_snapshot_discovery(host=host_config),
- "host_checks": host_checks,
- "host_check_summary": summarize_self_checks(host_checks),
+ "host_checks": backup_gate.checks,
+ "host_check_summary": summarize_self_checks(backup_gate.checks),
+ "backup_gate": backup_gate,
+ "effective_config": effective_host_config_preview(host_config, global_config) if global_config else {},
"stats_summary": stats_summary,
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)),
- "can_queue_backup": host_config.enabled and has_global_config,
+ "can_queue_dry_run": host_config.enabled and has_global_config and backup_gate.can_queue_dry_run and active_run is None,
+ "can_queue_real_backup": host_config.enabled and has_global_config and backup_gate.can_queue_real and active_run is None,
"has_global_config": has_global_config,
"active_run": active_run,
"latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10],
@@ -338,7 +343,8 @@ def queue_manual_backup(request, host: str):
if not host_config.enabled:
messages.error(request, f"Cannot queue backup for disabled host {host_config.host}.")
return redirect("host_detail", host=host_config.host)
- if not GlobalConfig.objects.filter(name="default").exists():
+ global_config = GlobalConfig.objects.filter(name="default").first()
+ if global_config is None:
messages.error(request, "Create the default global config before queueing backups.")
return redirect("host_detail", host=host_config.host)
@@ -347,6 +353,17 @@ def queue_manual_backup(request, host: str):
messages.error(request, "Manual backup options are invalid.")
return redirect("host_detail", host=host_config.host)
+ backup_gate = collect_backup_gate(host_config, global_config)
+ if form.cleaned_data["dry_run"]:
+ if not backup_gate.can_queue_dry_run:
+ blockers = ", ".join(check.name for check in backup_gate.dry_run_blockers)
+ messages.error(request, f"Cannot queue dry-run until failed preflight checks are resolved: {blockers}.")
+ return redirect("host_detail", host=host_config.host)
+ elif not backup_gate.can_queue_real:
+ blockers = ", ".join(check.name for check in backup_gate.real_blockers)
+ messages.error(request, f"Cannot queue real backup until failed preflight checks are resolved: {blockers}.")
+ return redirect("host_detail", host=host_config.host)
+
run = queue_backup_run(
host=host_config,
dry_run=form.cleaned_data["dry_run"],