(release) Add purged snapshot audit overview

Record snapshot purge history whenever retention or incomplete cleanup removes
snapshot directories and SQL records. Store the purge reason, original kind,
path, action source, and triggering operator so manual, scheduled, CLI, and
incomplete cleanup actions remain auditable after the original snapshot record
is deleted.

Add a staff-only Purged Snapshots page with host/action filters and register
the audit model in Django admin.

Refs #16
Refs #8
This commit is contained in:
2026-05-21 03:46:38 +02:00
parent 5b5a5bc637
commit ea9e3e41e3
13 changed files with 340 additions and 8 deletions

View File

@@ -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()

View File

@@ -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")

View File

@@ -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)