(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:
@@ -6,7 +6,7 @@ from django.urls import reverse
|
|||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.http import urlencode
|
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)
|
@admin.register(SshCredential)
|
||||||
@@ -173,6 +173,16 @@ class SnapshotRecordAdmin(admin.ModelAdmin):
|
|||||||
return format_html('<a href="{}">{}</a>', url, count)
|
return format_html('<a href="{}">{}</a>', 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)
|
@admin.register(ScheduleConfig)
|
||||||
class ScheduleConfigAdmin(admin.ModelAdmin):
|
class ScheduleConfigAdmin(admin.ModelAdmin):
|
||||||
list_display = ("host", "cron_expr", "enabled", "prune", "last_status", "last_started_at", "updated_at")
|
list_display = ("host", "cron_expr", "enabled", "prune", "last_status", "last_started_at", "updated_at")
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ def execute_backup_run(
|
|||||||
protect_bases=bool(prune_protect_bases),
|
protect_bases=bool(prune_protect_bases),
|
||||||
yes=True,
|
yes=True,
|
||||||
max_delete=int(prune_max_delete),
|
max_delete=int(prune_max_delete),
|
||||||
|
action=run.run_type,
|
||||||
acquire_lock=False,
|
acquire_lock=False,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class Command(BaseCommand):
|
|||||||
protect_bases=bool(options["protect_bases"]),
|
protect_bases=bool(options["protect_bases"]),
|
||||||
yes=True,
|
yes=True,
|
||||||
max_delete=int(options["max_delete"]),
|
max_delete=int(options["max_delete"]),
|
||||||
|
action="cli",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = run_sql_retention_plan(
|
result = run_sql_retention_plan(
|
||||||
|
|||||||
50
src/pobsync_backend/migrations/0013_purgedsnapshot.py
Normal file
50
src/pobsync_backend/migrations/0013_purgedsnapshot.py
Normal file
@@ -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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -173,6 +173,31 @@ class SnapshotRecord(models.Model):
|
|||||||
return f"{self.host}/{self.kind}/{self.dirname}"
|
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):
|
class ScheduleConfig(TimestampedModel):
|
||||||
host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule")
|
host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule")
|
||||||
cron_expr = models.CharField(max_length=128)
|
cron_expr = models.CharField(max_length=128)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pobsync.paths import PobsyncPaths
|
|||||||
from pobsync.retention import Snapshot, apply_base_protection, build_retention_plan
|
from pobsync.retention import Snapshot, apply_base_protection, build_retention_plan
|
||||||
from pobsync.util import sanitize_host
|
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]:
|
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,
|
protect_bases: bool,
|
||||||
yes: bool,
|
yes: bool,
|
||||||
max_delete: int,
|
max_delete: int,
|
||||||
|
action: str = PurgedSnapshot.Action.MANUAL,
|
||||||
|
triggered_by: str = "",
|
||||||
acquire_lock: bool = True,
|
acquire_lock: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
host = sanitize_host(host)
|
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}")
|
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
|
||||||
|
|
||||||
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
|
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
|
||||||
|
reason = str(item.get("reason") or "outside retention policy")
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
actions.append(f"skip missing {snap_kind}/{dirname}")
|
actions.append(f"skip missing {snap_kind}/{dirname}")
|
||||||
continue
|
continue
|
||||||
@@ -108,9 +111,19 @@ def run_sql_retention_apply(
|
|||||||
raise ConfigError(f"Refusing to delete non-directory path: {path}")
|
raise ConfigError(f"Refusing to delete non-directory path: {path}")
|
||||||
|
|
||||||
_remove_snapshot_tree(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()
|
SnapshotRecord.objects.filter(host__host=host, kind=snap_kind, dirname=dirname).delete()
|
||||||
actions.append(f"deleted {snap_kind} {dirname}")
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -137,6 +150,7 @@ def run_incomplete_cleanup(
|
|||||||
host: str,
|
host: str,
|
||||||
yes: bool,
|
yes: bool,
|
||||||
max_delete: int,
|
max_delete: int,
|
||||||
|
triggered_by: str = "",
|
||||||
acquire_lock: bool = True,
|
acquire_lock: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
host = sanitize_host(host)
|
host = sanitize_host(host)
|
||||||
@@ -177,6 +191,16 @@ def run_incomplete_cleanup(
|
|||||||
_remove_snapshot_tree(path)
|
_remove_snapshot_tree(path)
|
||||||
actions.append(f"deleted incomplete {dirname}")
|
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(
|
SnapshotRecord.objects.filter(
|
||||||
host__host=host,
|
host__host=host,
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
@@ -282,6 +306,30 @@ def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
|
|||||||
return 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:
|
def _validate_incomplete_delete_path(*, host: str, path: Path, dirname: str) -> None:
|
||||||
path_parts = path.parts
|
path_parts = path.parts
|
||||||
if path.name != dirname or ".incomplete" not in path_parts or host not in path_parts:
|
if path.name != dirname or ".incomplete" not in path_parts or host not in path_parts:
|
||||||
|
|||||||
@@ -375,6 +375,7 @@
|
|||||||
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
||||||
<a href="{% url 'self_check' %}">Self Check</a>
|
<a href="{% url 'self_check' %}">Self Check</a>
|
||||||
<a href="{% url 'logs' %}">Logs</a>
|
<a href="{% url 'logs' %}">Logs</a>
|
||||||
|
<a href="{% url 'purged_snapshots' %}">Purged</a>
|
||||||
<a href="{% url 'changelog' %}">Changelog</a>
|
<a href="{% url 'changelog' %}">Changelog</a>
|
||||||
<a href="/api/status/">Status API</a>
|
<a href="/api/status/">Status API</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Purged Snapshots | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Purged Snapshots</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Purged snapshot actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<form method="get" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="host">Host</label>
|
||||||
|
<select id="host" name="host">
|
||||||
|
<option value="">All hosts</option>
|
||||||
|
{% for host in hosts %}
|
||||||
|
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="action">Action</label>
|
||||||
|
<select id="action" name="action">
|
||||||
|
<option value="">All actions</option>
|
||||||
|
{% for value, label in actions %}
|
||||||
|
<option value="{{ value }}" {% if selected_action == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Apply filters</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'purged_snapshots' %}">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Purged Snapshot History</h2>
|
||||||
|
<p class="muted">Showing up to 200 of {{ total_count }} purged snapshot record(s).</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Purged</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Dirname</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Triggered by</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snapshot in purged_snapshots %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ snapshot.purged_at }}</td>
|
||||||
|
<td>{% if snapshot.host %}<a href="{% url 'host_detail' snapshot.host.host %}">{{ snapshot.host_name }}</a>{% else %}{{ snapshot.host_name }}{% endif %}</td>
|
||||||
|
<td>{{ snapshot.kind }}</td>
|
||||||
|
<td>{{ snapshot.dirname }}</td>
|
||||||
|
<td><span class="status skipped">{{ snapshot.get_action_display }}</span></td>
|
||||||
|
<td>{{ snapshot.reason|default:"" }}</td>
|
||||||
|
<td>{{ snapshot.triggered_by|default:"" }}</td>
|
||||||
|
<td class="muted">{{ snapshot.path }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="8" class="muted">No purged snapshots recorded yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -96,6 +96,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
protect_bases=True,
|
protect_bases=True,
|
||||||
yes=True,
|
yes=True,
|
||||||
max_delete=3,
|
max_delete=3,
|
||||||
|
action=BackupRun.RunType.SCHEDULED,
|
||||||
acquire_lock=False,
|
acquire_lock=False,
|
||||||
)
|
)
|
||||||
run = BackupRun.objects.get()
|
run = BackupRun.objects.get()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.core.management import call_command
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from pobsync.errors import ConfigError
|
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
|
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(new_dir.exists())
|
||||||
self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists())
|
self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists())
|
||||||
self.assertFalse(SnapshotRecord.objects.filter(pk=old.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["planned_delete_count"], 1)
|
||||||
self.assertEqual(result["max_delete"], 1)
|
self.assertEqual(result["max_delete"], 1)
|
||||||
self.assertEqual(result["incomplete_ignored_count"], 0)
|
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:
|
def test_apply_deletes_snapshot_with_readonly_data_directory(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
@@ -126,7 +142,17 @@ class SqlRetentionTests(TestCase):
|
|||||||
|
|
||||||
self.assertFalse(old_dir.exists())
|
self.assertFalse(old_dir.exists())
|
||||||
self.assertFalse(SnapshotRecord.objects.filter(pk=old.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",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_apply_respects_max_delete(self) -> None:
|
def test_apply_respects_max_delete(self) -> None:
|
||||||
host = HostConfig.objects.create(
|
host = HostConfig.objects.create(
|
||||||
@@ -183,6 +209,9 @@ class SqlRetentionTests(TestCase):
|
|||||||
[{"dirname": incomplete_dir.name, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(incomplete_dir)}],
|
[{"dirname": incomplete_dir.name, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(incomplete_dir)}],
|
||||||
)
|
)
|
||||||
self.assertEqual(result["planned_delete_count"], 1)
|
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:
|
def test_incomplete_cleanup_respects_max_delete(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ from django.test import TestCase, override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from pobsync.util import write_yaml_atomic
|
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):
|
class ViewTests(TestCase):
|
||||||
@@ -328,6 +336,61 @@ class ViewTests(TestCase):
|
|||||||
self.assertIn("--since", command)
|
self.assertIn("--since", command)
|
||||||
self.assertIn("6 hours ago", 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:
|
def test_ssh_credentials_view_creates_key(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
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.assertContains(response, "Retention deleted 1 snapshot(s) for web-01.")
|
||||||
self.assertFalse(SnapshotRecord.objects.filter(pk=old_snapshot.pk).exists())
|
self.assertFalse(SnapshotRecord.objects.filter(pk=old_snapshot.pk).exists())
|
||||||
self.assertTrue(SnapshotRecord.objects.filter(pk=new_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:
|
def test_retention_apply_rejects_bad_confirmation(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from .forms import (
|
|||||||
SshCredentialForm,
|
SshCredentialForm,
|
||||||
)
|
)
|
||||||
from .host_ops import ensure_host_directories
|
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 .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 .retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan
|
||||||
from .self_check import collect_self_checks, summarize_self_checks
|
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)
|
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
|
@staff_member_required
|
||||||
def ssh_credentials(request):
|
def ssh_credentials(request):
|
||||||
context = {
|
context = {
|
||||||
@@ -693,6 +714,8 @@ def apply_host_retention(request, host: str):
|
|||||||
protect_bases=protect_bases,
|
protect_bases=protect_bases,
|
||||||
yes=True,
|
yes=True,
|
||||||
max_delete=form.cleaned_data["max_delete"],
|
max_delete=form.cleaned_data["max_delete"],
|
||||||
|
action=PurgedSnapshot.Action.MANUAL,
|
||||||
|
triggered_by=request.user.get_username(),
|
||||||
)
|
)
|
||||||
except PobsyncError as exc:
|
except PobsyncError as exc:
|
||||||
messages.error(request, str(exc))
|
messages.error(request, str(exc))
|
||||||
@@ -733,6 +756,7 @@ def cleanup_host_incomplete_snapshots(request, host: str):
|
|||||||
host=host_config.host,
|
host=host_config.host,
|
||||||
yes=True,
|
yes=True,
|
||||||
max_delete=form.cleaned_data["max_delete"],
|
max_delete=form.cleaned_data["max_delete"],
|
||||||
|
triggered_by=request.user.get_username(),
|
||||||
)
|
)
|
||||||
except PobsyncError as exc:
|
except PobsyncError as exc:
|
||||||
messages.error(request, str(exc))
|
messages.error(request, str(exc))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ urlpatterns = [
|
|||||||
path("changelog/", views.changelog, name="changelog"),
|
path("changelog/", views.changelog, name="changelog"),
|
||||||
path("self-check/", views.self_check, name="self_check"),
|
path("self-check/", views.self_check, name="self_check"),
|
||||||
path("logs/", views.logs, name="logs"),
|
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("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||||
|
|||||||
Reference in New Issue
Block a user