2 Commits

Author SHA1 Message Date
20a9f93378 (docs) Add targeted restore examples
Extend the restore guidance with directory and single-file dry-run
examples so operators can restore a focused path without copying an
entire snapshot.

Render the examples on snapshot detail pages using the selected
snapshot's data path and the host-specific staging destination.
2026-05-21 02:05:19 +02:00
b78f102e9d (docs) Add manual restore guidance for snapshots
Document the manual restore workflow in the README and surface snapshot-
specific restore commands on the snapshot detail page.

The guidance keeps restores intentionally manual for now: inspect the
snapshot data directory, run rsync with --dry-run, restore to staging
first, and treat hardlinked snapshot files as read-only.
2026-05-21 02:01:40 +02:00
4 changed files with 129 additions and 0 deletions

View File

@@ -154,6 +154,45 @@ The UI includes:
- `/self-check/` for runtime checks - `/self-check/` for runtime checks
- `/logs/` for filtered pobsync service logs - `/logs/` for filtered pobsync service logs
## Restoring Data
pobsync 1.0 treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
detail page, but it does not run restore commands for you yet. That is deliberate: restores should be inspected and
tested before data is copied back into a live system.
Each snapshot directory contains:
```
<snapshot>/data/ # backed-up filesystem contents
<snapshot>/meta/ # metadata and rsync logs
```
Use the `data/` directory as the rsync source. Start with a dry run and restore to a staging path first:
```
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/ /restore/example.org/
rsync -aHAX --numeric-ids --info=progress2 /backups/example.org/scheduled/<snapshot>/data/ /restore/example.org/
```
After validating the staged files, copy the specific files or directories back to the target machine. For a full-host
restore, use another dry run before writing to the remote root:
```
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/ root@example.org:/
```
For most incidents, prefer a targeted restore instead of copying the whole snapshot. Keep paths relative to the
snapshot's `data/` directory:
```
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/etc/nginx/ /restore/example.org/etc/nginx/
rsync -aHAX --numeric-ids --info=progress2 --dry-run /backups/example.org/scheduled/<snapshot>/data/home/example/site/public_html/index.php /restore/example.org/home/example/site/public_html/index.php
```
Snapshots may use hardlinks for files that are unchanged between backups. That saves disk space and is safe for normal
restore copies, but do not edit files inside snapshot directories. Treat snapshots as read-only and copy data out with
rsync.
## SSH Keys ## SSH Keys
SSH keys can be managed from `/ssh-credentials/`. The recommended flow is to generate keys from Django or during the SSH keys can be managed from `/ssh-credentials/`. The recommended flow is to generate keys from Django or during the

View File

@@ -60,6 +60,48 @@
</section> </section>
{% endif %} {% endif %}
<section class="panel">
<h2>Restore Guidance</h2>
<div class="stack spaced">
<div><strong>Snapshot data source:</strong> {{ restore.source_path }}</div>
<div><strong>Example staging destination:</strong> {{ restore.destination_path }}</div>
<div class="muted">
Restore from the snapshot's <code>data/</code> directory. Start with a dry run, restore to a staging path first,
and only then copy data back to a live host or service path.
</div>
</div>
<div class="stack spaced">
<div><strong>Inspect the snapshot:</strong></div>
<pre>{{ restore.inspect_command }}</pre>
</div>
<div class="stack spaced">
<div><strong>Dry-run restore to staging:</strong></div>
<pre>{{ restore.dry_run_command }}</pre>
</div>
<div class="stack spaced">
<div><strong>Restore to staging:</strong></div>
<pre>{{ restore.local_command }}</pre>
</div>
<div class="stack spaced">
<div><strong>Dry-run a directory restore:</strong></div>
<pre>{{ restore.partial_dry_run_command }}</pre>
<div class="muted">Replace <code>{{ restore.example_relative_path }}</code> with the path you want to restore.</div>
</div>
<div class="stack spaced">
<div><strong>Dry-run a single file restore:</strong></div>
<pre>{{ restore.file_dry_run_command }}</pre>
<div class="muted">Replace <code>{{ restore.example_file_relative_path }}</code> with the file you want to restore.</div>
</div>
<div class="stack spaced">
<div><strong>Dry-run restore back to the source host:</strong></div>
<pre>{{ restore.remote_dry_run_command }}</pre>
</div>
<p class="muted">
Snapshots can contain hardlinks to files shared with earlier snapshots. Treat snapshot directories as read-only:
copy data out with rsync instead of editing files in place.
</p>
</section>
<section class="panel"> <section class="panel">
<h2>Backup Runs</h2> <h2>Backup Runs</h2>
<table> <table>

View File

@@ -1421,6 +1421,19 @@ class ViewTests(TestCase):
self.assertContains(response, "Stats") self.assertContains(response, "Stats")
self.assertContains(response, "Files seen:</strong> 100") self.assertContains(response, "Files seen:</strong> 100")
self.assertContains(response, "Hardlinked files:</strong> 9") self.assertContains(response, "Hardlinked files:</strong> 9")
self.assertContains(response, "Restore Guidance")
self.assertContains(response, f"{base.path}/data")
self.assertContains(response, f"/restore/{host.host}")
self.assertContains(response, "rsync -aHAX --numeric-ids --info=progress2 --dry-run")
self.assertContains(response, f"{base.path}/data/")
self.assertContains(response, "root@web-01.example.test:/")
self.assertContains(response, "Dry-run a directory restore")
self.assertContains(response, f"{base.path}/data/etc/nginx/")
self.assertContains(response, f"/restore/{host.host}/etc/nginx/")
self.assertContains(response, "Dry-run a single file restore")
self.assertContains(response, f"{base.path}/data/home/example/site/public_html/index.php")
self.assertContains(response, f"/restore/{host.host}/home/example/site/public_html/index.php")
self.assertContains(response, "Treat snapshot directories as read-only")
self.assertContains(response, child.dirname) self.assertContains(response, child.dirname)
self.assertContains(response, f"Run {run.id}") self.assertContains(response, f"Run {run.id}")
self.assertContains(response, reverse("run_detail", args=[run.id])) self.assertContains(response, reverse("run_detail", args=[run.id]))

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shlex
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -494,12 +495,14 @@ def snapshot_detail(request, snapshot_id: int):
SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"), SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"),
id=snapshot_id, id=snapshot_id,
) )
restore = _snapshot_restore_guidance(snapshot)
context = { context = {
"snapshot": snapshot, "snapshot": snapshot,
"stats": snapshot.metadata.get("stats") if isinstance(snapshot.metadata, dict) else {}, "stats": snapshot.metadata.get("stats") if isinstance(snapshot.metadata, dict) else {},
"metadata_json": _pretty_json(snapshot.metadata), "metadata_json": _pretty_json(snapshot.metadata),
"backup_runs": snapshot.backup_runs.select_related("host").order_by("-created_at"), "backup_runs": snapshot.backup_runs.select_related("host").order_by("-created_at"),
"derived_snapshots": snapshot.derived_snapshots.select_related("host").order_by("-started_at", "dirname"), "derived_snapshots": snapshot.derived_snapshots.select_related("host").order_by("-started_at", "dirname"),
"restore": restore,
} }
return render(request, "pobsync_backend/snapshot_detail.html", context) return render(request, "pobsync_backend/snapshot_detail.html", context)
@@ -790,6 +793,38 @@ def _pretty_json(value: object) -> str:
return json.dumps(value or {}, indent=2, sort_keys=True) return json.dumps(value or {}, indent=2, sort_keys=True)
def _snapshot_restore_guidance(snapshot: SnapshotRecord) -> dict[str, str]:
source_path = Path(snapshot.path) / "data"
destination_path = Path("/restore") / snapshot.host.host
example_relative_path = Path("etc") / "nginx"
example_file_relative_path = Path("home") / "example" / "site" / "public_html" / "index.php"
quoted_source = _quote_path_with_trailing_slash(source_path)
quoted_destination = _quote_path_with_trailing_slash(destination_path)
quoted_partial_source = _quote_path_with_trailing_slash(source_path / example_relative_path)
quoted_partial_destination = _quote_path_with_trailing_slash(destination_path / example_relative_path)
quoted_file_source = shlex.quote(str(source_path / example_file_relative_path))
quoted_file_destination = shlex.quote(str(destination_path / example_file_relative_path))
quoted_remote_destination = shlex.quote(f"root@{snapshot.host.address or snapshot.host.host}:/")
common_args = "rsync -aHAX --numeric-ids --info=progress2"
return {
"source_path": str(source_path),
"destination_path": str(destination_path),
"example_relative_path": str(example_relative_path),
"example_file_relative_path": str(example_file_relative_path),
"inspect_command": f"ls -la {quoted_source}",
"dry_run_command": f"{common_args} --dry-run {quoted_source} {quoted_destination}",
"local_command": f"{common_args} {quoted_source} {quoted_destination}",
"partial_dry_run_command": f"{common_args} --dry-run {quoted_partial_source} {quoted_partial_destination}",
"file_dry_run_command": f"{common_args} --dry-run {quoted_file_source} {quoted_file_destination}",
"remote_dry_run_command": f"{common_args} --dry-run {quoted_source} {quoted_remote_destination}",
}
def _quote_path_with_trailing_slash(path: Path) -> str:
return shlex.quote(str(path).rstrip("/") + "/")
def _run_rsync_log_path(run: BackupRun) -> Path | None: def _run_rsync_log_path(run: BackupRun) -> Path | None:
if isinstance(run.result, dict): if isinstance(run.result, dict):
log = run.result.get("log") log = run.result.get("log")