(feature) Add backup safety and preflight validation #13
@@ -27,12 +27,51 @@
|
|||||||
<h2>Failure</h2>
|
<h2>Failure</h2>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
||||||
<div><strong>Summary:</strong> {{ failure.summary|default:"" }}</div>
|
<div><strong>Summary:</strong> {{ failure_summary }}</div>
|
||||||
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if dry_run_summary %}
|
||||||
|
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
||||||
|
<h2>Dry Run Summary</h2>
|
||||||
|
<section class="grid" aria-label="Dry run summary">
|
||||||
|
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
|
||||||
|
</section>
|
||||||
|
<div class="stack">
|
||||||
|
{% if dry_run_summary.duration_seconds is not None %}
|
||||||
|
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<strong>Log:</strong>
|
||||||
|
{% if dry_run_summary.log_available %}
|
||||||
|
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
||||||
|
{% elif rsync_log_path %}
|
||||||
|
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">not recorded yet</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if dry_run_summary.warnings %}
|
||||||
|
<div><strong>Warnings:</strong></div>
|
||||||
|
<ul>
|
||||||
|
{% for warning in dry_run_summary.warnings %}
|
||||||
|
<li>{{ warning }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div><strong>Warnings:</strong> none recorded</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="two-col">
|
<div class="two-col">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Timing</h2>
|
<h2>Timing</h2>
|
||||||
|
|||||||
@@ -1066,12 +1066,69 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "--archive")
|
self.assertContains(response, "--archive")
|
||||||
self.assertContains(response, "Rsync Log")
|
self.assertContains(response, "Rsync Log")
|
||||||
self.assertContains(response, "sending incremental file list")
|
self.assertContains(response, "sending incremental file list")
|
||||||
|
self.assertContains(response, "Dry Run Summary")
|
||||||
|
self.assertContains(response, "Files Seen")
|
||||||
|
self.assertContains(response, "Would Transfer")
|
||||||
|
self.assertContains(response, "Transfer Estimate")
|
||||||
|
self.assertContains(response, "Warnings:</strong> none recorded")
|
||||||
self.assertContains(response, "Stats")
|
self.assertContains(response, "Stats")
|
||||||
self.assertContains(response, "Files seen:</strong> 10")
|
self.assertContains(response, "Files seen:</strong> 10")
|
||||||
self.assertContains(response, "Estimated link-dest saving")
|
self.assertContains(response, "Estimated link-dest saving")
|
||||||
self.assertContains(response, ""ok": true")
|
self.assertContains(response, ""ok": true")
|
||||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
||||||
|
|
||||||
|
def test_run_detail_surfaces_dry_run_warnings_and_log_link(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
log_path = Path(tmp) / "dry-run" / "rsync.log"
|
||||||
|
log_path.parent.mkdir(parents=True)
|
||||||
|
log_path.write_text("WARNING: noisy shell output\npermission denied\n", encoding="utf-8")
|
||||||
|
run = BackupRun.objects.create(
|
||||||
|
host=host,
|
||||||
|
status=BackupRun.Status.FAILED,
|
||||||
|
rsync_exit_code=255,
|
||||||
|
result={
|
||||||
|
"ok": False,
|
||||||
|
"dry_run": True,
|
||||||
|
"log": str(log_path),
|
||||||
|
"failure": {
|
||||||
|
"category": "transport",
|
||||||
|
"message": "Rsync transport failed.",
|
||||||
|
"hint": "Check SSH access.",
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"duration_seconds": 4,
|
||||||
|
"rsync": {
|
||||||
|
"files_total": 25,
|
||||||
|
"files_transferred": 3,
|
||||||
|
"total_file_size_bytes": 10_000,
|
||||||
|
"total_transferred_file_size_bytes": 1_500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"rsync": {
|
||||||
|
"exit_code": 255,
|
||||||
|
"log_tail": ["WARNING: noisy shell output", "permission denied"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Dry Run Summary")
|
||||||
|
self.assertContains(response, "failed")
|
||||||
|
self.assertContains(response, "Files Seen")
|
||||||
|
self.assertContains(response, "25")
|
||||||
|
self.assertContains(response, "Would Transfer")
|
||||||
|
self.assertContains(response, "3")
|
||||||
|
self.assertContains(response, "1.5")
|
||||||
|
self.assertContains(response, "Open full rsync log")
|
||||||
|
self.assertContains(response, reverse("run_rsync_log", args=[run.id]))
|
||||||
|
self.assertContains(response, "Rsync transport failed.")
|
||||||
|
self.assertContains(response, "Check SSH access.")
|
||||||
|
self.assertContains(response, "WARNING: noisy shell output")
|
||||||
|
|
||||||
def test_run_detail_links_existing_rsync_log(self) -> None:
|
def test_run_detail_links_existing_rsync_log(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|||||||
@@ -415,19 +415,29 @@ def run_detail(request, run_id: int):
|
|||||||
prune_result = result.get("prune") if isinstance(result.get("prune"), dict) else {}
|
prune_result = result.get("prune") if isinstance(result.get("prune"), dict) else {}
|
||||||
rsync_log_path = _run_rsync_log_path(run)
|
rsync_log_path = _run_rsync_log_path(run)
|
||||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||||
|
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||||
context = {
|
context = {
|
||||||
"run": run,
|
"run": run,
|
||||||
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
||||||
"requested": result.get("requested") if isinstance(result.get("requested"), dict) else {},
|
"requested": requested,
|
||||||
"stats": run_stats if isinstance(run_stats, dict) else {},
|
"stats": run_stats if isinstance(run_stats, dict) else {},
|
||||||
"rsync": rsync_result,
|
"rsync": rsync_result,
|
||||||
"rsync_command": _run_rsync_command(rsync_result),
|
"rsync_command": _run_rsync_command(rsync_result),
|
||||||
"failure": failure,
|
"failure": failure,
|
||||||
|
"failure_summary": failure.get("message") or failure.get("summary") or "",
|
||||||
"prune_result": prune_result,
|
"prune_result": prune_result,
|
||||||
"has_prune_result": bool(prune_result),
|
"has_prune_result": bool(prune_result),
|
||||||
"rsync_log_path": str(rsync_log_path) if rsync_log_path is not None else "",
|
"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_exists": bool(rsync_log_path and rsync_log_path.exists()),
|
||||||
"rsync_log_tail": rsync_log_tail,
|
"rsync_log_tail": rsync_log_tail,
|
||||||
|
"dry_run_summary": _dry_run_summary(
|
||||||
|
result=result,
|
||||||
|
requested=requested,
|
||||||
|
stats=run_stats if isinstance(run_stats, dict) else {},
|
||||||
|
failure=failure,
|
||||||
|
rsync_log_tail=rsync_log_tail,
|
||||||
|
rsync_log_exists=bool(rsync_log_path and rsync_log_path.exists()),
|
||||||
|
),
|
||||||
"result_json": _pretty_json(run.result),
|
"result_json": _pretty_json(run.result),
|
||||||
}
|
}
|
||||||
return render(request, "pobsync_backend/run_detail.html", context)
|
return render(request, "pobsync_backend/run_detail.html", context)
|
||||||
@@ -739,6 +749,48 @@ def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _dry_run_summary(
|
||||||
|
*,
|
||||||
|
result: dict,
|
||||||
|
requested: dict,
|
||||||
|
stats: dict,
|
||||||
|
failure: dict,
|
||||||
|
rsync_log_tail: list[str],
|
||||||
|
rsync_log_exists: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if not (result.get("dry_run") or requested.get("dry_run")):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
rsync_stats = stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {}
|
||||||
|
warnings = []
|
||||||
|
if failure:
|
||||||
|
message = failure.get("message") or failure.get("summary")
|
||||||
|
hint = failure.get("hint")
|
||||||
|
if message:
|
||||||
|
warnings.append(str(message))
|
||||||
|
if hint:
|
||||||
|
warnings.append(str(hint))
|
||||||
|
for line in rsync_log_tail:
|
||||||
|
lowered = line.lower()
|
||||||
|
if "warning" in lowered or "permission denied" in lowered or "failed" in lowered:
|
||||||
|
warnings.append(line)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": result.get("ok"),
|
||||||
|
"status": "passed" if result.get("ok") else ("failed" if result.get("ok") is False else "running"),
|
||||||
|
"highlight_class": "success" if result.get("ok") else ("failed" if result.get("ok") is False else "warning"),
|
||||||
|
"files_seen": rsync_stats.get("files_total"),
|
||||||
|
"files_would_transfer": rsync_stats.get("files_transferred"),
|
||||||
|
"total_file_size_bytes": rsync_stats.get("total_file_size_bytes"),
|
||||||
|
"transfer_estimate_bytes": rsync_stats.get("total_transferred_file_size_bytes")
|
||||||
|
or rsync_stats.get("literal_data_bytes"),
|
||||||
|
"link_dest_estimated_savings_bytes": rsync_stats.get("link_dest_estimated_savings_bytes"),
|
||||||
|
"duration_seconds": stats.get("duration_seconds"),
|
||||||
|
"log_available": rsync_log_exists,
|
||||||
|
"warnings": list(dict.fromkeys(warnings)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _log_context(request) -> dict[str, object]:
|
def _log_context(request) -> dict[str, object]:
|
||||||
units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service")
|
units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service")
|
||||||
priorities = {
|
priorities = {
|
||||||
|
|||||||
Reference in New Issue
Block a user