release-hardening_1.0 #21
@@ -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