diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py index 8be9435..ec2f6cc 100644 --- a/src/pobsync_backend/forms.py +++ b/src/pobsync_backend/forms.py @@ -249,12 +249,18 @@ class RetentionApplyForm(forms.Form): kind = forms.ChoiceField(choices=(("scheduled", "Scheduled"), ("manual", "Manual"), ("all", "All"))) protect_bases = forms.BooleanField(required=False) max_delete = forms.IntegerField(min_value=0, initial=10) + confirm_delete_count = forms.IntegerField(min_value=0) confirm_host = forms.CharField() - def __init__(self, *args, host_name: str, **kwargs) -> None: + def __init__(self, *args, host_name: str, expected_delete_count: int | None = None, **kwargs) -> None: self.host_name = host_name + self.expected_delete_count = expected_delete_count super().__init__(*args, **kwargs) self.fields["confirm_host"].help_text = f"Type {host_name} to confirm deletion." + if expected_delete_count is not None: + self.fields["confirm_delete_count"].help_text = ( + f"Type {expected_delete_count} to confirm the current number of planned deletions." + ) def clean_confirm_host(self) -> str: value = self.cleaned_data["confirm_host"].strip() @@ -262,6 +268,12 @@ class RetentionApplyForm(forms.Form): raise forms.ValidationError(f"Type {self.host_name} to confirm.") return value + def clean_confirm_delete_count(self) -> int: + value = self.cleaned_data["confirm_delete_count"] + if self.expected_delete_count is not None and value != self.expected_delete_count: + raise forms.ValidationError(f"Type {self.expected_delete_count} to confirm the delete count.") + return value + class ScheduleConfigForm(forms.ModelForm): cron_expr = forms.CharField( diff --git a/src/pobsync_backend/management/commands/run_pobsync_scheduler.py b/src/pobsync_backend/management/commands/run_pobsync_scheduler.py index 7443ad7..f4e7638 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_scheduler.py +++ b/src/pobsync_backend/management/commands/run_pobsync_scheduler.py @@ -10,7 +10,7 @@ from django.core.management.base import BaseCommand from django.db import transaction from django.utils import timezone -from pobsync_backend.models import ScheduleConfig +from pobsync_backend.models import BackupRun, ScheduleConfig from pobsync_backend.scheduler import due_key, is_due @@ -52,12 +52,13 @@ class Command(BaseCommand): if not is_due(schedule.cron_expr, now): continue + schedule_started_at = timezone.now() with transaction.atomic(): locked = ScheduleConfig.objects.select_for_update().get(pk=schedule.pk) if locked.last_due_key == current_due_key: continue locked.last_due_key = current_due_key - locked.last_started_at = timezone.now() + locked.last_started_at = schedule_started_at locked.last_status = "running" locked.save(update_fields=["last_due_key", "last_started_at", "last_status", "updated_at"]) @@ -72,6 +73,7 @@ class Command(BaseCommand): prune_max_delete=schedule.prune_max_delete, prune_protect_bases=schedule.prune_protect_bases, ) + status = _latest_scheduled_run_status(host_id=schedule.host_id, started_at=schedule_started_at) or status except Exception as exc: status = "failed" self.stderr.write(f"{schedule.host.host}: {type(exc).__name__}: {exc}") @@ -83,3 +85,16 @@ class Command(BaseCommand): ran += 1 return ran + + +def _latest_scheduled_run_status(*, host_id: int, started_at) -> str | None: + run = ( + BackupRun.objects.filter( + host_id=host_id, + run_type=BackupRun.RunType.SCHEDULED, + created_at__gte=started_at, + ) + .order_by("-created_at", "-id") + .first() + ) + return run.status if run is not None else None diff --git a/src/pobsync_backend/retention.py b/src/pobsync_backend/retention.py index 5c52908..0d17a45 100644 --- a/src/pobsync_backend/retention.py +++ b/src/pobsync_backend/retention.py @@ -23,6 +23,7 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict host_config = _enabled_host_config(host) retention = _retention_for_host(host_config) snapshots = _snapshots_for_retention(host_config=host_config, kind=kind) + incomplete_snapshots = _incomplete_snapshots_for_host(host_config) plan = build_retention_plan( snapshots=snapshots, @@ -36,6 +37,7 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict keep, reasons = apply_base_protection(snapshots=snapshots, keep=keep, reasons=reasons) delete = [snapshot for snapshot in snapshots if snapshot.dirname not in keep] + keep_items = [snapshot for snapshot in snapshots if snapshot.dirname in keep] return { "ok": True, @@ -45,7 +47,12 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict "retention": retention, "source": "sql", "keep": sorted(keep), - "delete": [_snapshot_to_delete_item(snapshot) for snapshot in delete], + "keep_items": [_snapshot_to_item(snapshot, reasons=reasons.get(snapshot.dirname, [])) for snapshot in keep_items], + "delete": [_snapshot_to_item(snapshot, reasons=["outside retention policy"]) for snapshot in delete], + "incomplete": [ + _snapshot_to_item(snapshot, reasons=["incomplete snapshot; excluded from retention cleanup"]) + for snapshot in incomplete_snapshots + ], "reasons": reasons, } @@ -71,8 +78,11 @@ def run_sql_retention_apply( def _do_apply() -> dict[str, Any]: plan = run_sql_retention_plan(host=host, kind=kind, protect_bases=bool(protect_bases)) delete_list = plan.get("delete") or [] + incomplete_list = plan.get("incomplete") or [] if not isinstance(delete_list, list): raise ConfigError("Invalid retention plan output: delete is not a list") + if not isinstance(incomplete_list, list): + raise ConfigError("Invalid retention plan output: incomplete is not a list") if max_delete == 0 and len(delete_list) > 0: raise ConfigError("Deletion blocked by --max-delete=0") if len(delete_list) > max_delete: @@ -109,6 +119,8 @@ def run_sql_retention_apply( "protect_bases": bool(protect_bases), "max_delete": max_delete, "source": "sql", + "planned_delete_count": len(delete_list), + "incomplete_ignored_count": len(incomplete_list), "deleted": deleted, "actions": actions, } @@ -146,6 +158,15 @@ def _snapshots_for_retention(*, host_config: HostConfig, kind: str) -> list[Snap return [_snapshot_from_record(record) for record in records] +def _incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]: + records = ( + SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE) + .select_related("base") + .order_by("-started_at", "dirname") + ) + return [_snapshot_from_record(record) for record in records] + + def _snapshot_from_record(record: SnapshotRecord) -> Snapshot: return Snapshot( kind=record.kind, @@ -173,13 +194,15 @@ def _base_meta_from_record(record: SnapshotRecord) -> dict[str, str] | None: return None -def _snapshot_to_delete_item(snapshot: Snapshot) -> dict[str, Any]: +def _snapshot_to_item(snapshot: Snapshot, *, reasons: list[str]) -> dict[str, Any]: return { "dirname": snapshot.dirname, "kind": snapshot.kind, "path": snapshot.path, "dt": snapshot.dt.isoformat(), "status": snapshot.status, + "reasons": reasons, + "reason": ", ".join(reasons), } diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 5156766..7cf0dc8 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -231,6 +231,17 @@ .host-card-stat.wide { grid-column: 1 / -1; } + .host-card-warning { + background: #fffaf0; + border: 1px solid #e7cf8a; + border-radius: 6px; + color: var(--running); + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + padding: 10px; + } .messages { display: grid; gap: 8px; margin-bottom: 18px; } .message { background: var(--panel); diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 37fd93d..9b2b6ba 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -118,6 +118,20 @@ + {% if host.retention_warning.has_warning %} +
+ retention + {% if host.retention_warning.prune_exceeded %} + Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}. + {% endif %} + {% if host.retention_warning.incomplete_count %} + {{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review. + {% endif %} + {% if host.retention_warning.error %} + {{ host.retention_warning.error }} + {% endif %} +
+ {% endif %} {% empty %}

No hosts configured yet.

diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 9180799..e10f681 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -68,6 +68,30 @@ + {% if retention_warning.has_warning %} +
+

Retention Warnings

+
+ {% if retention_warning.prune_exceeded %} +
+ Scheduled pruning would delete {{ retention_warning.delete_count }} snapshot(s), above max delete + {{ retention_warning.max_delete }}. Scheduled pruning will refuse this plan until the limit or retention + selection is adjusted. +
+ {% endif %} + {% if retention_warning.incomplete_count %} +
+ {{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete + snapshots automatically; inspect them before cleanup. +
+ {% endif %} + {% if retention_warning.error %} +
{{ retention_warning.error }}
+ {% endif %} +
+
+ {% endif %} + {% if effective_config %}

Effective Config

diff --git a/src/pobsync_backend/templates/pobsync_backend/retention_plan.html b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html index 1b1f9ec..ccacb38 100644 --- a/src/pobsync_backend/templates/pobsync_backend/retention_plan.html +++ b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html @@ -18,8 +18,31 @@
Kind
{{ plan.kind }}
Keep
{{ plan.keep|length }}
Would Delete
{{ plan.delete|length }}
+
Scheduled Limit
{{ scheduled_prune_limit|default:"none" }}
+
Incomplete
{{ plan.incomplete|length }}
+ {% if scheduled_prune_exceeded %} +
+

Scheduled Prune Limit

+

+ This plan would delete {{ plan.delete|length }} snapshot(s), which exceeds the scheduled prune limit of + {{ scheduled_prune_limit }}. Scheduled pruning will refuse to apply this plan until the limit or retention + selection is adjusted. +

+
+ {% endif %} + + {% if plan.incomplete %} +
+

Incomplete Snapshots

+

+ {{ plan.incomplete|length }} incomplete snapshot(s) exist for this host. Retention does not delete incomplete + snapshots automatically because they can indicate an interrupted backup that should be inspected first. +

+
+ {% endif %} +

Policy

@@ -28,6 +51,17 @@
Monthly: {{ plan.retention.monthly }}
Yearly: {{ plan.retention.yearly }}
Protect bases: {{ protect_bases|yesno:"yes,no" }}
+
+ {% if protect_bases %} + Base snapshots referenced by kept snapshots are also kept and marked with a base-of reason. + {% else %} + Base snapshots are only kept when they match the regular retention policy. + {% endif %} +
+ {% if schedule %} +
Schedule pruning: {{ schedule.prune|yesno:"enabled,disabled" }}
+
Schedule max delete: {{ schedule.prune_max_delete }}
+ {% endif %}
@@ -40,6 +74,7 @@ Dirname Started Status + Reason Path @@ -50,10 +85,11 @@ {{ snapshot.dirname }} {{ snapshot.dt }} {{ snapshot.status|default:"" }} + {{ snapshot.reason }} {{ snapshot.path }} {% empty %} - Retention would not delete snapshots for this selection. + Retention would not delete snapshots for this selection. {% endfor %} @@ -71,7 +107,7 @@ {{ apply_form.max_delete.errors }} {{ apply_form.max_delete }} -
Must be at least the number of snapshots shown in Would Delete.
+
Must be at least {{ plan.delete|length }} for the snapshots shown in Would Delete.
@@ -87,6 +123,13 @@
{{ apply_form.confirm_host.help_text }}
+
+ {{ apply_form.confirm_delete_count.errors }} + + {{ apply_form.confirm_delete_count }} +
{{ apply_form.confirm_delete_count.help_text }}
+
+
@@ -99,20 +142,54 @@ + + + - {% for dirname, reasons in plan.reasons.items %} + {% for snapshot in plan.keep_items %} - - + + + + + {% empty %} - + {% endfor %}
Kind DirnameStartedStatus Reasons
{{ dirname }}{{ reasons|join:", " }}{{ snapshot.kind }}{{ snapshot.dirname }}{{ snapshot.dt }}{{ snapshot.status|default:"" }}{{ snapshot.reason }}
No snapshots matched this retention selection.
No snapshots matched this retention selection.
+ + {% if plan.incomplete %} +
+

Incomplete Snapshots

+ + + + + + + + + + + + {% for snapshot in plan.incomplete %} + + + + + + + + {% endfor %} + +
DirnameStartedStatusReasonPath
{{ snapshot.dirname }}{{ snapshot.dt }}{{ snapshot.status|default:"" }}{{ snapshot.reason }}{{ snapshot.path }}
+
+ {% endif %} {% endblock %} diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html index e66cc28..6d82121 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -172,13 +172,49 @@
Status: {% if prune_result.ok %}ok{% else %}warning{% endif %}
{% if prune_result.source %}
Source: {{ prune_result.source }}
{% endif %} + {% if prune_result.kind %}
Kind: {{ prune_result.kind }}
{% endif %} + {% if prune_result.planned_delete_count is not None %}
Planned deletions: {{ prune_result.planned_delete_count }}
{% endif %} {% if prune_result.deleted %}
Deleted: {{ prune_result.deleted|length }}
{% endif %} + {% if prune_result.max_delete is not None %}
Max delete: {{ prune_result.max_delete }}
{% endif %} + {% if prune_result.protect_bases is not None %}
Protect bases: {{ prune_result.protect_bases|yesno:"yes,no" }}
{% endif %} + {% if prune_result.incomplete_ignored_count %}
Incomplete ignored: {{ prune_result.incomplete_ignored_count }}
{% endif %} + {% if prune_result.actions %} +
Actions:
+ + {% endif %} {% if prune_result.error %}
Error: {{ prune_result.error }}
{% endif %} {% if prune_result.type %}
Type: {{ prune_result.type }}
{% endif %}
{% endif %} + {% if retention_warning.has_warning %} +
+

Retention Warnings

+
+ {% if retention_warning.prune_exceeded %} +
+ Scheduled pruning for this host would delete {{ retention_warning.delete_count }} snapshot(s), above max + delete {{ retention_warning.max_delete }}. +
+ {% endif %} + {% if retention_warning.incomplete_count %} +
+ {{ retention_warning.incomplete_count }} incomplete snapshot(s) exist for this host and are excluded from + retention cleanup. +
+ {% endif %} + {% if retention_warning.error %} +
{{ retention_warning.error }}
+ {% endif %} +
+
+ {% endif %} +

Raw Result

{{ result_json }}
diff --git a/src/pobsync_backend/tests/test_scheduler.py b/src/pobsync_backend/tests/test_scheduler.py index 8cfd105..a19e989 100644 --- a/src/pobsync_backend/tests/test_scheduler.py +++ b/src/pobsync_backend/tests/test_scheduler.py @@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo from django.test import SimpleTestCase, TestCase from pobsync_backend.management.commands.run_pobsync_scheduler import Command -from pobsync_backend.models import HostConfig, ScheduleConfig +from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig from pobsync_backend.scheduler import due_key, is_due, next_due_after @@ -64,3 +64,30 @@ class SchedulerCommandTests(TestCase): self.assertEqual(call.call_count, 1) schedule = ScheduleConfig.objects.get(host=host) self.assertEqual(schedule.last_status, "success") + + def test_run_due_records_warning_status_from_scheduled_backup_run(self) -> None: + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + ScheduleConfig.objects.create(host=host, cron_expr="* * * * *", prune=True, prune_max_delete=1) + + def create_warning_run(*args, **kwargs) -> None: + BackupRun.objects.create( + host=host, + run_type=BackupRun.RunType.SCHEDULED, + status=BackupRun.Status.WARNING, + result={ + "ok": True, + "prune": { + "ok": False, + "type": "ConfigError", + "error": "Refusing to delete 2 snapshots (exceeds --max-delete=1)", + }, + }, + ) + + command = Command() + with patch("pobsync_backend.management.commands.run_pobsync_scheduler.call_command", side_effect=create_warning_run): + count = command._run_due(prefix=Path("/opt/pobsync"), dry_run=False) + + self.assertEqual(count, 1) + schedule = ScheduleConfig.objects.get(host=host) + self.assertEqual(schedule.last_status, "warning") diff --git a/src/pobsync_backend/tests/test_sql_retention.py b/src/pobsync_backend/tests/test_sql_retention.py index 32302ab..bdb903f 100644 --- a/src/pobsync_backend/tests/test_sql_retention.py +++ b/src/pobsync_backend/tests/test_sql_retention.py @@ -32,7 +32,10 @@ class SqlRetentionTests(TestCase): self.assertEqual(plan["source"], "sql") self.assertEqual(plan["keep"], [new.dirname]) + self.assertEqual([item["dirname"] for item in plan["keep_items"]], [new.dirname]) self.assertEqual([item["dirname"] for item in plan["delete"]], [old.dirname]) + self.assertEqual(plan["delete"][0]["reason"], "outside retention policy") + self.assertEqual(plan["incomplete"], []) def test_plan_can_protect_base_snapshot_from_sql_relation(self) -> None: host = HostConfig.objects.create( @@ -85,6 +88,9 @@ class SqlRetentionTests(TestCase): self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists()) self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists()) self.assertEqual(result["deleted"], [{"dirname": old.dirname, "kind": "scheduled", "path": str(old_dir)}]) + self.assertEqual(result["planned_delete_count"], 1) + self.assertEqual(result["max_delete"], 1) + self.assertEqual(result["incomplete_ignored_count"], 0) def test_apply_deletes_snapshot_with_readonly_data_directory(self) -> None: with TemporaryDirectory() as tmp: diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 9c66eee..e5f80b4 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -99,6 +99,35 @@ class ViewTests(TestCase): self.assertContains(response, "manual") self.assertContains(response, "1000") + def test_dashboard_surfaces_retention_warnings(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True, prune_max_delete=1) + self._snapshot(host, "20260517-021500Z__OLDSNP1") + self._snapshot(host, "20260518-021500Z__OLDSNP2") + self._snapshot(host, "20260519-021500Z__NEWSNAP") + SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260519-031500Z__BROKEN01", + path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01", + status="failed", + started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc), + ) + + response = self.client.get(reverse("dashboard")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Scheduled prune would delete 2 snapshot(s), above max 1.") + self.assertContains(response, "1 incomplete snapshot(s) need review.") + def test_dashboard_links_latest_snapshot_for_each_host(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") @@ -1168,9 +1197,15 @@ class ViewTests(TestCase): "hint": "Check network connectivity.", }, "prune": { - "ok": False, - "type": "ConfigError", - "error": "Deletion blocked by --max-delete=0", + "ok": True, + "source": "sql", + "kind": "scheduled", + "planned_delete_count": 1, + "max_delete": 1, + "protect_bases": True, + "incomplete_ignored_count": 1, + "deleted": [{"dirname": "20260518-021500Z__OLD"}], + "actions": ["deleted scheduled 20260518-021500Z__OLD"], }, }, ) @@ -1182,7 +1217,42 @@ class ViewTests(TestCase): self.assertContains(response, "transport") self.assertContains(response, "Check network connectivity.") self.assertContains(response, "Retention") - self.assertContains(response, "Deletion blocked by --max-delete=0") + self.assertContains(response, "Planned deletions") + self.assertContains(response, "Max delete") + self.assertContains(response, "Protect bases") + self.assertContains(response, "Incomplete ignored") + self.assertContains(response, "deleted scheduled 20260518-021500Z__OLD") + + def test_run_detail_surfaces_host_retention_warnings(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True, prune_max_delete=1) + self._snapshot(host, "20260517-021500Z__OLDSNP1") + self._snapshot(host, "20260518-021500Z__OLDSNP2") + self._snapshot(host, "20260519-021500Z__NEWSNAP") + SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260519-031500Z__BROKEN01", + path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01", + status="failed", + started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc), + ) + run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, result={"ok": True}) + + response = self.client.get(reverse("run_detail", args=[run.id])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Retention Warnings") + self.assertContains(response, "Scheduled pruning for this host would delete 2 snapshot(s)") + self.assertContains(response, "1 incomplete snapshot(s) exist for this host") def test_run_detail_infers_rsync_log_from_snapshot_path(self) -> None: self.client.force_login(self.staff_user) @@ -1351,6 +1421,32 @@ class ViewTests(TestCase): self.assertContains(response, new_snapshot.dirname) self.assertContains(response, "newest") self.assertContains(response, "Would Delete") + self.assertContains(response, "outside retention policy") + self.assertContains(response, "Confirm delete count") + self.assertContains(response, "Type 1 to confirm the current number of planned deletions.") + + def test_retention_plan_warns_when_scheduled_prune_limit_is_exceeded(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True, prune_max_delete=1) + self._snapshot(host, "20260517-021500Z__OLDSNP1") + self._snapshot(host, "20260518-021500Z__OLDSNP2") + self._snapshot(host, "20260519-021500Z__NEWSNAP") + + response = self.client.get(reverse("host_retention_plan", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Scheduled Prune Limit") + self.assertContains(response, "would delete 2 snapshot(s)") + self.assertContains(response, "scheduled prune limit of") + self.assertContains(response, "Schedule max delete: 1") def test_retention_plan_can_enable_base_protection(self) -> None: self.client.force_login(self.staff_user) @@ -1371,8 +1467,58 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Protect bases: yes") + self.assertContains(response, "Base snapshots referenced by kept snapshots") self.assertContains(response, f"base-of:{child.dirname}") + def test_retention_plan_surfaces_incomplete_snapshots_without_deleting_them(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + self._snapshot(host, "20260518-021500Z__OLDSNAP") + self._snapshot(host, "20260519-021500Z__NEWSNAP") + SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260519-031500Z__BROKEN01", + path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01", + status="failed", + started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc), + ) + + response = self.client.get(reverse("host_retention_plan", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Incomplete Snapshots") + self.assertContains(response, "20260519-031500Z__BROKEN01") + self.assertContains(response, "excluded from retention cleanup") + + def test_host_detail_surfaces_retention_warnings(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True, prune_max_delete=1) + self._snapshot(host, "20260517-021500Z__OLDSNP1") + self._snapshot(host, "20260518-021500Z__OLDSNP2") + self._snapshot(host, "20260519-021500Z__NEWSNAP") + + response = self.client.get(reverse("host_detail", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Retention Warnings") + self.assertContains(response, "Scheduled pruning would delete 2 snapshot(s), above max delete") + def test_retention_plan_rejects_invalid_kind(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") @@ -1412,6 +1558,7 @@ class ViewTests(TestCase): "kind": "scheduled", "max_delete": "1", "confirm_host": host.host, + "confirm_delete_count": "1", }, follow=True, ) @@ -1435,6 +1582,7 @@ class ViewTests(TestCase): "kind": "scheduled", "max_delete": "1", "confirm_host": "wrong", + "confirm_delete_count": "1", }, follow=True, ) @@ -1443,6 +1591,34 @@ class ViewTests(TestCase): self.assertContains(response, "Retention apply confirmation is invalid.") self.assertEqual(SnapshotRecord.objects.count(), 1) + def test_retention_apply_rejects_mismatched_delete_count_confirmation(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + self._snapshot(host, "20260518-021500Z__OLDSNAP") + self._snapshot(host, "20260519-021500Z__NEWSNAP") + + response = self.client.post( + reverse("apply_host_retention", args=[host.host]), + { + "kind": "scheduled", + "max_delete": "1", + "confirm_host": host.host, + "confirm_delete_count": "0", + }, + follow=True, + ) + + self.assertRedirects(response, reverse("host_retention_plan", args=[host.host])) + self.assertContains(response, "Retention apply confirmation is invalid.") + self.assertEqual(SnapshotRecord.objects.count(), 2) + def test_retention_apply_requires_post(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 524ea23..cf469a4 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -55,6 +55,7 @@ def dashboard(request): .first() ) host_config.next_run_at = _next_run_for_host(host_config) + host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config)) stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config) context = { "hosts": hosts, @@ -274,6 +275,7 @@ def host_detail(request, host: str): context = { "host": host_config, "schedule": schedule, + "retention_warning": _retention_warning_for_host(host_config, schedule), "next_run_at": _next_run_for_schedule(schedule, host_config), "scheduler_timezone": timezone.get_current_timezone_name(), "discovery": inspect_snapshot_discovery(host=host_config), @@ -427,6 +429,7 @@ def run_detail(request, run_id: int): "failure_summary": failure.get("message") or failure.get("summary") or "", "prune_result": prune_result, "has_prune_result": bool(prune_result), + "retention_warning": _retention_warning_for_host(run.host, _schedule_for_host(run.host)), "rsync_log_path": str(rsync_log_path) if rsync_log_path is not None else "", "rsync_log_exists": bool(rsync_log_path and rsync_log_path.exists()), "rsync_log_tail": rsync_log_tail, @@ -526,17 +529,25 @@ def host_retention_plan(request, host: str): except PobsyncError as exc: messages.error(request, str(exc)) return redirect("host_detail", host=host_config.host) + schedule = _schedule_for_host(host_config) + scheduled_prune_limit = schedule.prune_max_delete if schedule and schedule.prune else None + delete_count = len(plan["delete"]) context = { "host": host_config, "kind": kind, "protect_bases": protect_bases, "plan": plan, + "schedule": schedule, + "scheduled_prune_limit": scheduled_prune_limit, + "scheduled_prune_exceeded": scheduled_prune_limit is not None and delete_count > scheduled_prune_limit, "apply_form": RetentionApplyForm( host_name=host_config.host, + expected_delete_count=delete_count, initial={ "kind": kind, "protect_bases": protect_bases, - "max_delete": len(plan["delete"]), + "max_delete": delete_count, + "confirm_delete_count": delete_count, }, ), } @@ -547,7 +558,26 @@ def host_retention_plan(request, host: str): @require_POST def apply_host_retention(request, host: str): host_config = get_object_or_404(HostConfig, host=host) - form = RetentionApplyForm(request.POST, host_name=host_config.host) + raw_kind = request.POST.get("kind", "scheduled") + raw_protect_bases = request.POST.get("protect_bases") in {"1", "true", "on", "yes"} + expected_delete_count = None + if raw_kind in {"scheduled", "manual", "all"}: + try: + plan = run_sql_retention_plan( + host=host_config.host, + kind=raw_kind, + protect_bases=raw_protect_bases, + ) + except PobsyncError as exc: + messages.error(request, str(exc)) + return redirect("host_retention_plan", host=host_config.host) + expected_delete_count = len(plan.get("delete") or []) + + form = RetentionApplyForm( + request.POST, + host_name=host_config.host, + expected_delete_count=expected_delete_count, + ) if not form.is_valid(): messages.error(request, "Retention apply confirmation is invalid.") return redirect("host_retention_plan", host=host_config.host) @@ -652,6 +682,43 @@ def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostCon return None +def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]: + incomplete_count = host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count() + warning: dict[str, object] = { + "has_warning": incomplete_count > 0, + "incomplete_count": incomplete_count, + } + if schedule is None or not schedule.prune or not host_config.enabled: + return warning + try: + plan = run_sql_retention_plan( + host=host_config.host, + kind="scheduled", + protect_bases=bool(schedule.prune_protect_bases), + ) + except PobsyncError as exc: + warning.update( + { + "has_warning": True, + "error": str(exc), + } + ) + return warning + + delete_count = len(plan.get("delete") or []) + warning.update( + { + "delete_count": delete_count, + "max_delete": schedule.prune_max_delete, + "protect_bases": bool(schedule.prune_protect_bases), + "prune_exceeded": delete_count > schedule.prune_max_delete, + } + ) + if warning["prune_exceeded"]: + warning["has_warning"] = True + return warning + + def _default_schedule_initial() -> dict[str, object]: return { "cron_expr": "15 2 * * *",