diff --git a/src/pobsync_backend/admin.py b/src/pobsync_backend/admin.py index 327ecb0..f5a2cde 100644 --- a/src/pobsync_backend/admin.py +++ b/src/pobsync_backend/admin.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.utils.html import format_html from django.utils.http import urlencode -from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential +from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential @admin.register(SshCredential) @@ -173,6 +173,16 @@ class SnapshotRecordAdmin(admin.ModelAdmin): return format_html('{}', url, count) +@admin.register(PurgedSnapshot) +class PurgedSnapshotAdmin(admin.ModelAdmin): + list_display = ("host_name", "kind", "dirname", "action", "reason", "triggered_by", "purged_at") + list_filter = ("action", "kind", "purged_at") + search_fields = ("host_name", "dirname", "path", "reason", "triggered_by") + list_select_related = ("host",) + readonly_fields = ("purged_at",) + date_hierarchy = "purged_at" + + @admin.register(ScheduleConfig) class ScheduleConfigAdmin(admin.ModelAdmin): list_display = ("host", "cron_expr", "enabled", "prune", "last_status", "last_started_at", "updated_at") diff --git a/src/pobsync_backend/backup_runner.py b/src/pobsync_backend/backup_runner.py index d75c07c..f9975fb 100644 --- a/src/pobsync_backend/backup_runner.py +++ b/src/pobsync_backend/backup_runner.py @@ -109,6 +109,7 @@ def execute_backup_run( protect_bases=bool(prune_protect_bases), yes=True, max_delete=int(prune_max_delete), + action=run.run_type, acquire_lock=False, ) except Exception as exc: diff --git a/src/pobsync_backend/management/commands/run_pobsync_retention.py b/src/pobsync_backend/management/commands/run_pobsync_retention.py index 2100a09..cbd098d 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_retention.py +++ b/src/pobsync_backend/management/commands/run_pobsync_retention.py @@ -36,6 +36,7 @@ class Command(BaseCommand): protect_bases=bool(options["protect_bases"]), yes=True, max_delete=int(options["max_delete"]), + action="cli", ) else: result = run_sql_retention_plan( diff --git a/src/pobsync_backend/migrations/0013_purgedsnapshot.py b/src/pobsync_backend/migrations/0013_purgedsnapshot.py new file mode 100644 index 0000000..fe66bb1 --- /dev/null +++ b/src/pobsync_backend/migrations/0013_purgedsnapshot.py @@ -0,0 +1,50 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("pobsync_backend", "0012_review_state"), + ] + + operations = [ + migrations.CreateModel( + name="PurgedSnapshot", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("host_name", models.CharField(max_length=255)), + ("kind", models.CharField(max_length=16)), + ("dirname", models.CharField(max_length=255)), + ("path", models.CharField(max_length=1024)), + ("reason", models.CharField(blank=True, max_length=512)), + ( + "action", + models.CharField( + choices=[ + ("manual", "Manual"), + ("scheduled", "Scheduled"), + ("cli", "CLI"), + ("incomplete_cleanup", "Incomplete cleanup"), + ], + max_length=32, + ), + ), + ("triggered_by", models.CharField(blank=True, max_length=150)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("purged_at", models.DateTimeField(auto_now_add=True)), + ( + "host", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="purged_snapshots", + to="pobsync_backend.hostconfig", + ), + ), + ], + options={ + "ordering": ["-purged_at", "host_name", "dirname"], + }, + ), + ] diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py index bc00671..e890165 100644 --- a/src/pobsync_backend/models.py +++ b/src/pobsync_backend/models.py @@ -173,6 +173,31 @@ class SnapshotRecord(models.Model): return f"{self.host}/{self.kind}/{self.dirname}" +class PurgedSnapshot(models.Model): + class Action(models.TextChoices): + MANUAL = "manual", "Manual" + SCHEDULED = "scheduled", "Scheduled" + CLI = "cli", "CLI" + INCOMPLETE_CLEANUP = "incomplete_cleanup", "Incomplete cleanup" + + host = models.ForeignKey(HostConfig, on_delete=models.SET_NULL, null=True, blank=True, related_name="purged_snapshots") + host_name = models.CharField(max_length=255) + kind = models.CharField(max_length=16) + dirname = models.CharField(max_length=255) + path = models.CharField(max_length=1024) + reason = models.CharField(max_length=512, blank=True) + action = models.CharField(max_length=32, choices=Action.choices) + triggered_by = models.CharField(max_length=150, blank=True) + metadata = models.JSONField(default=dict, blank=True) + purged_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-purged_at", "host_name", "dirname"] + + def __str__(self) -> str: + return f"{self.host_name}/{self.kind}/{self.dirname}" + + class ScheduleConfig(TimestampedModel): host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule") cron_expr = models.CharField(max_length=128) diff --git a/src/pobsync_backend/retention.py b/src/pobsync_backend/retention.py index d505683..c658db3 100644 --- a/src/pobsync_backend/retention.py +++ b/src/pobsync_backend/retention.py @@ -12,7 +12,7 @@ from pobsync.paths import PobsyncPaths from pobsync.retention import Snapshot, apply_base_protection, build_retention_plan from pobsync.util import sanitize_host -from .models import HostConfig, SnapshotRecord +from .models import HostConfig, PurgedSnapshot, SnapshotRecord def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict[str, Any]: @@ -65,6 +65,8 @@ def run_sql_retention_apply( protect_bases: bool, yes: bool, max_delete: int, + action: str = PurgedSnapshot.Action.MANUAL, + triggered_by: str = "", acquire_lock: bool = True, ) -> dict[str, Any]: host = sanitize_host(host) @@ -101,6 +103,7 @@ def run_sql_retention_apply( raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}") path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname) + reason = str(item.get("reason") or "outside retention policy") if not path.exists(): actions.append(f"skip missing {snap_kind}/{dirname}") continue @@ -108,9 +111,19 @@ def run_sql_retention_apply( raise ConfigError(f"Refusing to delete non-directory path: {path}") _remove_snapshot_tree(path) + _record_purged_snapshot( + host_config=_enabled_host_config(host), + kind=snap_kind, + dirname=dirname, + path=path, + reason=reason, + action=action, + triggered_by=triggered_by, + metadata={"source": "retention", "protect_bases": bool(protect_bases), "retention_kind": kind}, + ) SnapshotRecord.objects.filter(host__host=host, kind=snap_kind, dirname=dirname).delete() actions.append(f"deleted {snap_kind} {dirname}") - deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path)}) + deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path), "reason": reason}) return { "ok": True, @@ -137,6 +150,7 @@ def run_incomplete_cleanup( host: str, yes: bool, max_delete: int, + triggered_by: str = "", acquire_lock: bool = True, ) -> dict[str, Any]: host = sanitize_host(host) @@ -177,6 +191,16 @@ def run_incomplete_cleanup( _remove_snapshot_tree(path) actions.append(f"deleted incomplete {dirname}") + _record_purged_snapshot( + host_config=host_config, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname=dirname, + path=path, + reason="manual incomplete cleanup", + action=PurgedSnapshot.Action.INCOMPLETE_CLEANUP, + triggered_by=triggered_by, + metadata={"source": "incomplete_cleanup"}, + ) SnapshotRecord.objects.filter( host__host=host, kind=SnapshotRecord.Kind.INCOMPLETE, @@ -282,6 +306,30 @@ def _snapshot_delete_path(*, path: Path, dirname: str) -> Path: return path +def _record_purged_snapshot( + *, + host_config: HostConfig, + kind: str, + dirname: str, + path: Path, + reason: str, + action: str, + triggered_by: str, + metadata: dict[str, Any], +) -> None: + PurgedSnapshot.objects.create( + host=host_config, + host_name=host_config.host, + kind=kind, + dirname=dirname, + path=str(path), + reason=reason, + action=action, + triggered_by=triggered_by, + metadata=metadata, + ) + + def _validate_incomplete_delete_path(*, host: str, path: Path, dirname: str) -> None: path_parts = path.parts if path.name != dirname or ".incomplete" not in path_parts or host not in path_parts: diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 2bda493..a54e024 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -375,6 +375,7 @@ SSH Keys Self Check Logs + Purged Changelog Status API diff --git a/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html b/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html new file mode 100644 index 0000000..8f54524 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html @@ -0,0 +1,74 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Purged Snapshots | pobsync{% endblock %} + +{% block content %} +

Purged Snapshots

+ +
+ Back to dashboard +
+ +
+

Filters

+
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+ +
+

Purged Snapshot History

+

Showing up to 200 of {{ total_count }} purged snapshot record(s).

+ + + + + + + + + + + + + + + {% for snapshot in purged_snapshots %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
PurgedHostKindDirnameActionReasonTriggered byPath
{{ snapshot.purged_at }}{% if snapshot.host %}{{ snapshot.host_name }}{% else %}{{ snapshot.host_name }}{% endif %}{{ snapshot.kind }}{{ snapshot.dirname }}{{ snapshot.get_action_display }}{{ snapshot.reason|default:"" }}{{ snapshot.triggered_by|default:"" }}{{ snapshot.path }}
No purged snapshots recorded yet.
+
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_run_backup_records_snapshot.py b/src/pobsync_backend/tests/test_run_backup_records_snapshot.py index 493713b..291dfa2 100644 --- a/src/pobsync_backend/tests/test_run_backup_records_snapshot.py +++ b/src/pobsync_backend/tests/test_run_backup_records_snapshot.py @@ -96,6 +96,7 @@ class RunBackupRecordsSnapshotTests(TestCase): protect_bases=True, yes=True, max_delete=3, + action=BackupRun.RunType.SCHEDULED, acquire_lock=False, ) run = BackupRun.objects.get() diff --git a/src/pobsync_backend/tests/test_sql_retention.py b/src/pobsync_backend/tests/test_sql_retention.py index 9821338..9918353 100644 --- a/src/pobsync_backend/tests/test_sql_retention.py +++ b/src/pobsync_backend/tests/test_sql_retention.py @@ -11,7 +11,7 @@ from django.core.management import call_command from django.test import TestCase from pobsync.errors import ConfigError -from pobsync_backend.models import HostConfig, SnapshotRecord +from pobsync_backend.models import HostConfig, PurgedSnapshot, SnapshotRecord from pobsync_backend.retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan @@ -87,10 +87,26 @@ class SqlRetentionTests(TestCase): self.assertTrue(new_dir.exists()) 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["deleted"], + [ + { + "dirname": old.dirname, + "kind": "scheduled", + "path": str(old_dir), + "reason": "outside retention policy", + } + ], + ) self.assertEqual(result["planned_delete_count"], 1) self.assertEqual(result["max_delete"], 1) self.assertEqual(result["incomplete_ignored_count"], 0) + purged = PurgedSnapshot.objects.get(dirname=old.dirname) + self.assertEqual(purged.host_name, host.host) + self.assertEqual(purged.kind, "scheduled") + self.assertEqual(purged.path, str(old_dir)) + self.assertEqual(purged.reason, "outside retention policy") + self.assertEqual(purged.action, PurgedSnapshot.Action.MANUAL) def test_apply_deletes_snapshot_with_readonly_data_directory(self) -> None: with TemporaryDirectory() as tmp: @@ -126,7 +142,17 @@ class SqlRetentionTests(TestCase): self.assertFalse(old_dir.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["deleted"], + [ + { + "dirname": old.dirname, + "kind": "scheduled", + "path": str(old_dir), + "reason": "outside retention policy", + } + ], + ) def test_apply_respects_max_delete(self) -> None: host = HostConfig.objects.create( @@ -183,6 +209,9 @@ class SqlRetentionTests(TestCase): [{"dirname": incomplete_dir.name, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(incomplete_dir)}], ) self.assertEqual(result["planned_delete_count"], 1) + purged = PurgedSnapshot.objects.get(dirname=incomplete_dir.name) + self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP) + self.assertEqual(purged.reason, "manual incomplete cleanup") def test_incomplete_cleanup_respects_max_delete(self) -> None: host = HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 131603d..f65f82a 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -12,7 +12,15 @@ from django.test import TestCase, override_settings from django.urls import reverse from pobsync.util import write_yaml_atomic -from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential +from pobsync_backend.models import ( + BackupRun, + GlobalConfig, + HostConfig, + PurgedSnapshot, + ScheduleConfig, + SnapshotRecord, + SshCredential, +) class ViewTests(TestCase): @@ -328,6 +336,61 @@ class ViewTests(TestCase): self.assertIn("--since", command) self.assertIn("6 hours ago", command) + def test_purged_snapshots_view_renders_history(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + PurgedSnapshot.objects.create( + host=host, + host_name=host.host, + kind=SnapshotRecord.Kind.SCHEDULED, + dirname="20260518-021500Z__OLDSNAP", + path=f"/backups/{host.host}/scheduled/20260518-021500Z__OLDSNAP", + reason="outside retention policy", + action=PurgedSnapshot.Action.SCHEDULED, + triggered_by="pobsync-scheduler", + ) + + response = self.client.get(reverse("purged_snapshots")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Purged Snapshots") + self.assertContains(response, "20260518-021500Z__OLDSNAP") + self.assertContains(response, "outside retention policy") + self.assertContains(response, "Scheduled") + self.assertContains(response, "pobsync-scheduler") + + def test_purged_snapshots_view_filters_by_host_and_action(self) -> None: + self.client.force_login(self.staff_user) + web = HostConfig.objects.create(host="web-01", address="web-01.example.test") + db = HostConfig.objects.create(host="db-01", address="db-01.example.test") + PurgedSnapshot.objects.create( + host=web, + host_name=web.host, + kind=SnapshotRecord.Kind.SCHEDULED, + dirname="20260518-021500Z__WEBOLD", + path=f"/backups/{web.host}/scheduled/20260518-021500Z__WEBOLD", + reason="outside retention policy", + action=PurgedSnapshot.Action.MANUAL, + ) + PurgedSnapshot.objects.create( + host=db, + host_name=db.host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260518-021500Z__DBBROKEN", + path=f"/backups/{db.host}/.incomplete/20260518-021500Z__DBBROKEN", + reason="manual incomplete cleanup", + action=PurgedSnapshot.Action.INCOMPLETE_CLEANUP, + ) + + response = self.client.get( + reverse("purged_snapshots"), + {"host": db.host, "action": PurgedSnapshot.Action.INCOMPLETE_CLEANUP}, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "20260518-021500Z__DBBROKEN") + self.assertNotContains(response, "20260518-021500Z__WEBOLD") + def test_ssh_credentials_view_creates_key(self) -> None: self.client.force_login(self.staff_user) @@ -1881,6 +1944,10 @@ class ViewTests(TestCase): self.assertContains(response, "Retention deleted 1 snapshot(s) for web-01.") self.assertFalse(SnapshotRecord.objects.filter(pk=old_snapshot.pk).exists()) self.assertTrue(SnapshotRecord.objects.filter(pk=new_snapshot.pk).exists()) + purged = PurgedSnapshot.objects.get(dirname=old_snapshot.dirname) + self.assertEqual(purged.action, PurgedSnapshot.Action.MANUAL) + self.assertEqual(purged.triggered_by, self.staff_user.username) + self.assertEqual(purged.reason, "outside retention policy") def test_retention_apply_rejects_bad_confirmation(self) -> None: self.client.force_login(self.staff_user) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index f0ef91c..bca4d2d 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -33,7 +33,7 @@ from .forms import ( SshCredentialForm, ) from .host_ops import ensure_host_directories -from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential +from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential from .preflight import collect_backup_gate, effective_host_config_preview, run_remote_preflight from .retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan from .self_check import collect_self_checks, summarize_self_checks @@ -145,6 +145,27 @@ def logs(request): return render(request, "pobsync_backend/logs.html", context) +@staff_member_required +def purged_snapshots(request): + host = request.GET.get("host", "").strip() + action = request.GET.get("action", "").strip() + purged = PurgedSnapshot.objects.select_related("host").order_by("-purged_at", "host_name", "dirname") + if host: + purged = purged.filter(host_name=host) + if action: + purged = purged.filter(action=action) + + context = { + "purged_snapshots": purged[:200], + "hosts": HostConfig.objects.order_by("host"), + "actions": PurgedSnapshot.Action.choices, + "selected_host": host, + "selected_action": action, + "total_count": purged.count(), + } + return render(request, "pobsync_backend/purged_snapshots.html", context) + + @staff_member_required def ssh_credentials(request): context = { @@ -693,6 +714,8 @@ def apply_host_retention(request, host: str): protect_bases=protect_bases, yes=True, max_delete=form.cleaned_data["max_delete"], + action=PurgedSnapshot.Action.MANUAL, + triggered_by=request.user.get_username(), ) except PobsyncError as exc: messages.error(request, str(exc)) @@ -733,6 +756,7 @@ def cleanup_host_incomplete_snapshots(request, host: str): host=host_config.host, yes=True, max_delete=form.cleaned_data["max_delete"], + triggered_by=request.user.get_username(), ) except PobsyncError as exc: messages.error(request, str(exc)) diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 240b3ee..6f77f1c 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path("changelog/", views.changelog, name="changelog"), path("self-check/", views.self_check, name="self_check"), path("logs/", views.logs, name="logs"), + path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"), path("config/global/", views.edit_global_config, name="edit_global_config"), path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"), path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),