3 Commits

Author SHA1 Message Date
fe8e65e12e (feature) add queued backup worker foundation
Move backup execution out of the management command into a reusable
backup runner service that can execute an existing BackupRun record.

Add queue primitives and a run_pobsync_worker command so manual backup
requests can be recorded as queued SQL state and processed outside the
web request path.

Add a worker Docker service and pobsync worker CLI alias, with tests for
queued run creation, worker execution, manual run typing, and command
mapping.
2026-05-19 13:00:12 +02:00
aea22597ba (bugfix) preserve saved global backup root in Django setup form
Fix the global config edit view so default initial values are only used
when creating a new config, preventing saved backup_root values from
being hidden by form defaults.

Keep pobsync_home as an internal runtime setting instead of exposing it
in the normal Django setup form.

Mount a host backup directory into Docker at /backups and document
POBSYNC_BACKUP_ROOT so backup_root behaves predictably in containers.
2026-05-19 12:48:32 +02:00
66e1f549b9 (feature) add Django detail views for backup runs and snapshots
Add staff-only run and snapshot detail pages so scheduler and command
output can be inspected from the Django UI.

Link dashboard and host detail tables to the new detail views, including
snapshot/base relationships and linked backup runs.

Render stored result and metadata JSON in readable form and cover the new
inspection views with tests.
2026-05-19 12:31:47 +02:00
19 changed files with 640 additions and 103 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ __pycache__/
*.py[cod]
.venv/
var/
backups/
.pytest_cache/
.mypy_cache/
*.egg-info/

View File

@@ -125,10 +125,19 @@ This starts Django on:
Run the scheduler alongside the web admin:
```
docker compose up --build web scheduler
docker compose up --build web scheduler worker
```
The container persists `/opt/pobsync` and the SQLite database in Docker volumes.
Backup data is mounted at `/backups` inside the containers. By default this uses `./backups` on the host.
Override it with `POBSYNC_BACKUP_ROOT`:
```
POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync docker compose up --build web scheduler worker
```
In the Django global config, set the backup root to `/backups` when running in Docker. For local, non-Docker use,
set it directly to the host path, for example `/mnt/backups/pobsync`.
## Docker With MariaDB
@@ -139,7 +148,7 @@ docker compose --profile mariadb up --build web-mariadb
With the scheduler:
```
docker compose --profile mariadb up --build web-mariadb scheduler-mariadb
docker compose --profile mariadb up --build web-mariadb scheduler-mariadb worker-mariadb
```
SQLite remains the default because it is enough for a single backup server and keeps deployment simple.

View File

@@ -13,6 +13,7 @@ services:
volumes:
- pobsync_state:/opt/pobsync
- pobsync_db:/var/lib/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
scheduler:
build: .
@@ -26,6 +27,21 @@ services:
volumes:
- pobsync_state:/opt/pobsync
- pobsync_db:/var/lib/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
worker:
build: .
command: python manage.py run_pobsync_worker --loop --interval 15
environment:
POBSYNC_DJANGO_DEBUG: "1"
POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me"
POBSYNC_DJANGO_ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0"
POBSYNC_HOME: "/opt/pobsync"
POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3"
volumes:
- pobsync_state:/opt/pobsync
- pobsync_db:/var/lib/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
web-mariadb:
profiles: ["mariadb"]
@@ -48,6 +64,7 @@ services:
- "8010:8000"
volumes:
- pobsync_state:/opt/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
scheduler-mariadb:
profiles: ["mariadb"]
@@ -68,6 +85,28 @@ services:
condition: service_healthy
volumes:
- pobsync_state:/opt/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
worker-mariadb:
profiles: ["mariadb"]
build: .
command: python manage.py run_pobsync_worker --loop --interval 15
environment:
POBSYNC_DJANGO_DEBUG: "1"
POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me"
POBSYNC_DJANGO_ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0"
POBSYNC_HOME: "/opt/pobsync"
POBSYNC_DB_ENGINE: "mariadb"
POBSYNC_DB_HOST: "db"
POBSYNC_DB_NAME: "pobsync"
POBSYNC_DB_USER: "pobsync"
POBSYNC_DB_PASSWORD: "pobsync"
depends_on:
db:
condition: service_healthy
volumes:
- pobsync_state:/opt/pobsync
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
db:
profiles: ["mariadb"]

View File

@@ -15,6 +15,7 @@ COMMAND_ALIASES = {
"retention": "run_pobsync_retention",
"discover-snapshots": "discover_pobsync_snapshots",
"scheduler": "run_pobsync_scheduler",
"worker": "run_pobsync_worker",
}

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
from pathlib import Path
from django.db import transaction
from django.utils import timezone
from pobsync.commands.run_scheduled import run_scheduled
from pobsync_backend.config_source import DjangoConfigSource
from pobsync_backend.models import BackupRun, HostConfig
from pobsync_backend.retention import run_sql_retention_apply
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
def queue_backup_run(
*,
host: HostConfig,
run_type: str = BackupRun.RunType.MANUAL,
dry_run: bool = False,
prune: bool = False,
prune_max_delete: int = 10,
prune_protect_bases: bool = False,
) -> BackupRun:
return BackupRun.objects.create(
host=host,
run_type=run_type,
status=BackupRun.Status.QUEUED,
result={
"requested": {
"dry_run": bool(dry_run),
"prune": bool(prune),
"prune_max_delete": int(prune_max_delete),
"prune_protect_bases": bool(prune_protect_bases),
}
},
)
def execute_backup_run(
*,
run: BackupRun,
prefix: Path,
dry_run: bool = False,
prune: bool = False,
prune_max_delete: int = 10,
prune_protect_bases: bool = False,
) -> BackupRun:
run.status = BackupRun.Status.RUNNING
run.started_at = run.started_at or timezone.now()
run.save(update_fields=["status", "started_at"])
try:
result = run_scheduled(
prefix=prefix,
host=run.host.host,
dry_run=bool(dry_run),
prune=False,
config_source=DjangoConfigSource(),
)
except Exception as exc:
run.status = BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.result = {"ok": False, "error": str(exc), "type": type(exc).__name__}
run.save(update_fields=["status", "ended_at", "result"])
raise
run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.snapshot_path = str(result.get("snapshot") or "")
run.base_path = str(result.get("base") or "")
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
run.rsync_exit_code = rsync.get("exit_code")
run.result = result
snapshot_record = None
if run.snapshot_path:
snapshot_path = Path(run.snapshot_path)
try:
kind = infer_snapshot_kind(snapshot_path)
snapshot_record, _created = upsert_snapshot_record(host=run.host, kind=kind, snapshot_dir=snapshot_path)
except ValueError:
snapshot_record = None
if result.get("ok") and not result.get("dry_run") and prune:
try:
result["prune"] = run_sql_retention_apply(
prefix=prefix,
host=run.host.host,
kind="scheduled",
protect_bases=bool(prune_protect_bases),
yes=True,
max_delete=int(prune_max_delete),
acquire_lock=False,
)
except Exception as exc:
result["prune"] = {"ok": False, "error": str(exc), "type": type(exc).__name__}
run.status = BackupRun.Status.FAILED
run.result = result
run.snapshot = snapshot_record
run.save(
update_fields=[
"status",
"ended_at",
"snapshot_path",
"snapshot",
"base_path",
"rsync_exit_code",
"result",
],
)
raise
run.snapshot = snapshot_record
run.result = result
run.save(
update_fields=[
"status",
"ended_at",
"snapshot_path",
"snapshot",
"base_path",
"rsync_exit_code",
"result",
],
)
return run
def claim_next_queued_run() -> BackupRun | None:
with transaction.atomic():
run = (
BackupRun.objects.select_related("host")
.filter(status=BackupRun.Status.QUEUED, host__enabled=True)
.order_by("created_at", "id")
.first()
)
if run is None:
return None
run.status = BackupRun.Status.RUNNING
run.started_at = timezone.now()
run.save(update_fields=["status", "started_at"])
return run
def requested_options(run: BackupRun) -> dict[str, object]:
requested = run.result.get("requested") if isinstance(run.result, dict) else None
if not isinstance(requested, dict):
return {}
return requested

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from django import forms
from django.conf import settings
from .models import GlobalConfig, HostConfig, ScheduleConfig
from .scheduler import parse_cron_expr
@@ -84,7 +85,6 @@ class GlobalConfigForm(forms.ModelForm):
fields = (
"name",
"backup_root",
"pobsync_home",
"ssh_user",
"ssh_port",
"ssh_options",
@@ -104,11 +104,18 @@ class GlobalConfigForm(forms.ModelForm):
help_texts = {
"name": "Usually 'default'. The backup engine currently reads the default config.",
"backup_root": "Directory that contains host backup folders.",
"pobsync_home": "Base directory for runtime state inside the container or host.",
"default_source_root": "Used by hosts without a custom source root.",
"default_destination_subdir": "Optional subdirectory below each snapshot.",
}
def save(self, commit: bool = True):
instance = super().save(commit=False)
instance.pobsync_home = settings.POBSYNC_HOME
if commit:
instance.save()
self.save_m2m()
return instance
class ScheduleConfigForm(forms.ModelForm):
cron_expr = forms.CharField(

View File

@@ -5,18 +5,14 @@ from typing import Any
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from pobsync.commands.run_scheduled import run_scheduled
from pobsync.paths import PobsyncPaths
from pobsync_backend.config_source import DjangoConfigSource
from pobsync_backend.backup_runner import execute_backup_run
from pobsync_backend.models import BackupRun, HostConfig
from pobsync_backend.retention import run_sql_retention_apply
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
class Command(BaseCommand):
help = "Run a scheduled pobsync backup and record the result in Django."
help = "Run a pobsync backup and record the result in Django."
def add_arguments(self, parser) -> None:
parser.add_argument("host", help="Host to back up")
@@ -25,6 +21,7 @@ class Command(BaseCommand):
parser.add_argument("--prune", action="store_true", help="Apply retention after a successful run")
parser.add_argument("--prune-max-delete", type=int, default=10)
parser.add_argument("--prune-protect-bases", action="store_true")
parser.add_argument("--manual", action="store_true", help="Record the run as manual instead of scheduled")
def handle(self, *args: Any, **options: Any) -> None:
host_name = options["host"]
@@ -36,86 +33,20 @@ class Command(BaseCommand):
run = BackupRun.objects.create(
host=host,
run_type=BackupRun.RunType.SCHEDULED,
run_type=BackupRun.RunType.MANUAL if options["manual"] else BackupRun.RunType.SCHEDULED,
status=BackupRun.Status.RUNNING,
started_at=timezone.now(),
)
try:
result = run_scheduled(
prefix=paths.home,
host=host.host,
dry_run=bool(options["dry_run"]),
prune=False,
config_source=DjangoConfigSource(),
)
except Exception as exc:
run.status = BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.result = {"ok": False, "error": str(exc), "type": type(exc).__name__}
run.save(update_fields=["status", "ended_at", "result"])
raise
run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.snapshot_path = str(result.get("snapshot") or "")
run.base_path = str(result.get("base") or "")
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
run.rsync_exit_code = rsync.get("exit_code")
run.result = result
snapshot_record = None
if run.snapshot_path:
snapshot_path = Path(run.snapshot_path)
try:
kind = infer_snapshot_kind(snapshot_path)
snapshot_record, _created = upsert_snapshot_record(host=host, kind=kind, snapshot_dir=snapshot_path)
except ValueError:
snapshot_record = None
if result.get("ok") and not result.get("dry_run") and options["prune"]:
try:
result["prune"] = run_sql_retention_apply(
prefix=paths.home,
host=host.host,
kind="scheduled",
protect_bases=bool(options["prune_protect_bases"]),
yes=True,
max_delete=int(options["prune_max_delete"]),
acquire_lock=False,
)
except Exception as exc:
result["prune"] = {"ok": False, "error": str(exc), "type": type(exc).__name__}
run.status = BackupRun.Status.FAILED
run.result = result
run.snapshot = snapshot_record
run.save(
update_fields=[
"status",
"ended_at",
"snapshot_path",
"snapshot",
"base_path",
"rsync_exit_code",
"result",
],
)
raise
run.snapshot = snapshot_record
run.result = result
run.save(
update_fields=[
"status",
"ended_at",
"snapshot_path",
"snapshot",
"base_path",
"rsync_exit_code",
"result",
],
execute_backup_run(
run=run,
prefix=paths.home,
dry_run=bool(options["dry_run"]),
prune=bool(options["prune"]),
prune_max_delete=int(options["prune_max_delete"]),
prune_protect_bases=bool(options["prune_protect_bases"]),
)
run.refresh_from_db()
if result.get("ok"):
if run.status == BackupRun.Status.SUCCESS:
self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}."))
return

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
from django.conf import settings
from django.core.management.base import BaseCommand
from pobsync.paths import PobsyncPaths
from pobsync_backend.backup_runner import claim_next_queued_run, execute_backup_run, requested_options
class Command(BaseCommand):
help = "Run queued pobsync backup jobs from the Django database."
def add_arguments(self, parser) -> None:
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory")
parser.add_argument("--once", action="store_true", help="Process one queued run and exit")
parser.add_argument("--loop", action="store_true", help="Keep checking for queued runs")
parser.add_argument("--interval", type=int, default=15, help="Loop interval in seconds")
def handle(self, *args: Any, **options: Any) -> None:
if not options["once"] and not options["loop"]:
options["once"] = True
paths = PobsyncPaths(home=Path(options["prefix"]))
while True:
count = self._run_once(prefix=paths.home)
self.stdout.write(f"Ran {count} queued backup run(s).")
if options["once"]:
return
time.sleep(max(1, int(options["interval"])))
def _run_once(self, *, prefix: Path) -> int:
run = claim_next_queued_run()
if run is None:
return 0
options = requested_options(run)
try:
execute_backup_run(
run=run,
prefix=prefix,
dry_run=bool(options.get("dry_run", False)),
prune=bool(options.get("prune", False)),
prune_max_delete=int(options.get("prune_max_delete", 10)),
prune_protect_bases=bool(options.get("prune_protect_bases", False)),
)
except Exception as exc:
self.stderr.write(f"{run.host.host}: {type(exc).__name__}: {exc}")
return 1

View File

@@ -121,6 +121,17 @@
.field textarea { min-height: 92px; resize: vertical; }
.field .helptext { color: var(--muted); font-size: 12px; }
.field input[type="checkbox"] { justify-self: start; }
pre {
background: #101820;
border-radius: 6px;
color: #edf4fb;
line-height: 1.5;
margin: 0;
overflow: auto;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.errorlist {
color: var(--failed);
list-style: none;

View File

@@ -84,10 +84,10 @@
{% for run in latest_runs %}
<tr>
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
<td>{{ run.started_at|default:"" }}</td>
<td>{{ run.ended_at|default:"" }}</td>
<td>{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
<td>{{ run.rsync_exit_code|default:"" }}</td>
</tr>
{% empty %}

View File

@@ -67,10 +67,10 @@
<tbody>
{% for run in latest_runs %}
<tr>
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
<td>{{ run.started_at|default:"" }}</td>
<td>{{ run.ended_at|default:"" }}</td>
<td>{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
<td>{{ run.base_path|default:"" }}</td>
<td>{{ run.rsync_exit_code|default:"" }}</td>
</tr>
@@ -99,8 +99,8 @@
<td>{{ snapshot.kind }}</td>
<td>{{ snapshot.status }}</td>
<td>{{ snapshot.started_at|default:"" }}</td>
<td>{{ snapshot.dirname }}</td>
<td>{% if snapshot.base %}{{ snapshot.base.dirname }}{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>

View File

@@ -0,0 +1,42 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
{% block content %}
<h1>Run {{ run.id }}</h1>
<section class="actions" aria-label="Run actions">
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
</section>
<section class="grid" aria-label="Run summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
<div class="metric"><div class="label">Status</div><div class="value">{{ run.status }}</div></div>
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
</section>
<div class="two-col">
<section class="panel">
<h2>Timing</h2>
<div class="stack">
<div><strong>Created:</strong> {{ run.created_at }}</div>
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
</div>
</section>
<section class="panel">
<h2>Snapshot</h2>
<div class="stack">
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
</div>
</section>
</div>
<section class="panel">
<h2>Result</h2>
<pre>{{ result_json }}</pre>
</section>
{% endblock %}

View File

@@ -0,0 +1,99 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
{% block content %}
<h1>{{ snapshot.dirname }}</h1>
<section class="actions" aria-label="Snapshot actions">
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
</section>
<section class="grid" aria-label="Snapshot summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>
<div class="metric"><div class="label">Kind</div><div class="value">{{ snapshot.kind }}</div></div>
<div class="metric"><div class="label">Status</div><div class="value">{{ snapshot.status|default:"" }}</div></div>
<div class="metric"><div class="label">Runs</div><div class="value">{{ backup_runs|length }}</div></div>
</section>
<div class="two-col">
<section class="panel">
<h2>Snapshot</h2>
<div class="stack">
<div><strong>Path:</strong> {{ snapshot.path }}</div>
<div><strong>Started:</strong> {{ snapshot.started_at|default:"" }}</div>
<div><strong>Ended:</strong> {{ snapshot.ended_at|default:"" }}</div>
<div><strong>Discovered:</strong> {{ snapshot.discovered_at }}</div>
</div>
</section>
<section class="panel">
<h2>Base</h2>
<div class="stack">
<div><strong>Record:</strong> {% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% endif %}</div>
<div><strong>Kind:</strong> {{ snapshot.base_kind }}</div>
<div><strong>Dirname:</strong> {{ snapshot.base_dirname }}</div>
<div><strong>Path:</strong> {{ snapshot.base_path }}</div>
</div>
</section>
</div>
<section class="panel">
<h2>Backup Runs</h2>
<table>
<thead>
<tr>
<th>Run</th>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Rsync</th>
</tr>
</thead>
<tbody>
{% for run in backup_runs %}
<tr>
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
<td>{{ run.started_at|default:"" }}</td>
<td>{{ run.ended_at|default:"" }}</td>
<td>{{ run.rsync_exit_code|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="muted">No backup runs linked to this snapshot.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>Derived Snapshots</h2>
<table>
<thead>
<tr>
<th>Kind</th>
<th>Status</th>
<th>Started</th>
<th>Dirname</th>
</tr>
</thead>
<tbody>
{% for child in derived_snapshots %}
<tr>
<td>{{ child.kind }}</td>
<td>{{ child.status }}</td>
<td>{{ child.started_at|default:"" }}</td>
<td><a href="{% url 'snapshot_detail' child.id %}">{{ child.dirname }}</a></td>
</tr>
{% empty %}
<tr><td colspan="4" class="muted">No derived snapshots linked to this snapshot.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>Metadata</h2>
<pre>{{ metadata_json }}</pre>
</section>
{% endblock %}

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from django.test import TestCase
from pobsync.util import write_yaml_atomic
from pobsync_backend.backup_runner import queue_backup_run
from pobsync_backend.management.commands.run_pobsync_worker import Command
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
class BackupWorkerTests(TestCase):
def test_queue_backup_run_records_requested_options(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = queue_backup_run(
host=host,
dry_run=True,
prune=True,
prune_max_delete=3,
prune_protect_bases=True,
)
self.assertEqual(run.status, BackupRun.Status.QUEUED)
self.assertEqual(run.run_type, BackupRun.RunType.MANUAL)
self.assertEqual(
run.result["requested"],
{
"dry_run": True,
"prune": True,
"prune_max_delete": 3,
"prune_protect_bases": True,
},
)
def test_worker_executes_next_queued_run(self) -> None:
with TemporaryDirectory() as tmp:
backup_root = Path(tmp) / "backups"
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH"
meta_dir = snapshot_dir / "meta"
meta_dir.mkdir(parents=True)
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
run = queue_backup_run(host=host)
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
run_scheduled.return_value = {
"ok": True,
"dry_run": False,
"host": host.host,
"snapshot": str(snapshot_dir),
"base": None,
"rsync": {"exit_code": 0},
}
count = Command()._run_once(prefix=Path(tmp) / "home")
self.assertEqual(count, 1)
run.refresh_from_db()
self.assertEqual(run.status, BackupRun.Status.SUCCESS)
self.assertEqual(SnapshotRecord.objects.count(), 1)
self.assertEqual(run.snapshot, SnapshotRecord.objects.get())
def test_worker_returns_zero_without_queued_runs(self) -> None:
count = Command()._run_once(prefix=Path("/opt/pobsync"))
self.assertEqual(count, 0)

View File

@@ -46,3 +46,10 @@ class ConsoleEntrypointTests(SimpleTestCase):
self.assertEqual(exit_code, 0)
execute.assert_called_once_with(["pobsync", "discover_pobsync_snapshots", "--host", "web-01"])
def test_maps_worker_alias_to_django_command(self) -> None:
with patch("pobsync.cli.execute_from_command_line") as execute:
exit_code = main(["worker", "--once"])
self.assertEqual(exit_code, 0)
execute.assert_called_once_with(["pobsync", "run_pobsync_worker", "--once"])

View File

@@ -32,7 +32,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
},
)
with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled:
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
run_scheduled.return_value = {
"ok": True,
"dry_run": False,
@@ -63,9 +63,9 @@ class RunBackupRecordsSnapshotTests(TestCase):
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
with (
patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled,
patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled,
patch(
"pobsync_backend.management.commands.run_pobsync_backup.run_sql_retention_apply"
"pobsync_backend.backup_runner.run_sql_retention_apply"
) as retention_apply,
):
run_scheduled.return_value = {
@@ -113,9 +113,9 @@ class RunBackupRecordsSnapshotTests(TestCase):
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
with (
patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled,
patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled,
patch(
"pobsync_backend.management.commands.run_pobsync_backup.run_sql_retention_apply"
"pobsync_backend.backup_runner.run_sql_retention_apply"
) as retention_apply,
):
run_scheduled.return_value = {
@@ -155,7 +155,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
meta_dir.mkdir(parents=True)
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "failed", "started_at": "2026-05-19T02:15:00Z"})
with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled:
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
run_scheduled.return_value = {
"ok": False,
"dry_run": False,
@@ -179,7 +179,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled:
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
run_scheduled.return_value = {
"ok": True,
"dry_run": True,
@@ -198,3 +198,28 @@ class RunBackupRecordsSnapshotTests(TestCase):
self.assertEqual(BackupRun.objects.count(), 1)
self.assertIsNone(BackupRun.objects.get().snapshot)
self.assertEqual(SnapshotRecord.objects.count(), 0)
def test_manual_flag_records_manual_run_type(self) -> None:
with TemporaryDirectory() as tmp:
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
run_scheduled.return_value = {
"ok": True,
"dry_run": True,
"host": host.host,
"base": None,
"rsync": {"exit_code": 0},
}
call_command(
"run_pobsync_backup",
host.host,
prefix=str(Path(tmp) / "home"),
dry_run=True,
manual=True,
stdout=StringIO(),
)
run = BackupRun.objects.get()
self.assertEqual(run.run_type, BackupRun.RunType.MANUAL)

View File

@@ -76,7 +76,6 @@ class ViewTests(TestCase):
{
"name": "default",
"backup_root": "/backups",
"pobsync_home": "/opt/pobsync",
"ssh_user": "backup",
"ssh_port": "2222",
"ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes",
@@ -100,6 +99,7 @@ class ViewTests(TestCase):
self.assertContains(response, "Global config saved for default.")
config = GlobalConfig.objects.get(name="default")
self.assertEqual(config.backup_root, "/backups")
self.assertEqual(config.pobsync_home, "/opt/pobsync")
self.assertEqual(config.ssh_user, "backup")
self.assertEqual(config.ssh_port, 2222)
self.assertEqual(config.ssh_options, ["StrictHostKeyChecking=no", "BatchMode=yes"])
@@ -109,6 +109,21 @@ class ViewTests(TestCase):
self.assertEqual(config.retention_daily, 7)
self.assertEqual(config.retention_yearly, 1)
def test_global_config_form_renders_saved_backup_root_on_edit(self) -> None:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(
name="default",
backup_root="/mnt/pobsync/backups",
pobsync_home="/custom/legacy/home",
)
response = self.client.get(reverse("edit_global_config"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "/mnt/pobsync/backups")
self.assertNotContains(response, "/opt/pobsync/backups")
self.assertNotContains(response, "Pobsync home")
def test_create_host_config_form_creates_host(self) -> None:
self.client.force_login(self.staff_user)
@@ -165,6 +180,8 @@ class ViewTests(TestCase):
self.assertContains(response, "Discover snapshots")
self.assertContains(response, "Edit schedule")
self.assertContains(response, "Edit config")
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
def test_host_detail_returns_404_for_unknown_host(self) -> None:
self.client.force_login(self.staff_user)
@@ -173,6 +190,52 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 404)
def test_run_detail_renders_result_payload(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
run = BackupRun.objects.create(
host=host,
status=BackupRun.Status.SUCCESS,
snapshot=snapshot,
snapshot_path=snapshot.path,
base_path="/backups/web-01/scheduled/base",
rsync_exit_code=0,
result={"ok": True, "snapshot": snapshot.path},
)
response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Run")
self.assertContains(response, "web-01")
self.assertContains(response, "success")
self.assertContains(response, "ABCDEFGH")
self.assertContains(response, "&quot;ok&quot;: true")
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
def test_snapshot_detail_renders_metadata_runs_and_children(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
base = self._snapshot(host, "20260518-021500Z__BASESNAP")
base.metadata = {"status": "success", "snapshot_id": "BASESNAP"}
base.save(update_fields=["metadata"])
child = self._snapshot(host, "20260519-021500Z__CHILDSNP")
child.base = base
child.base_dirname = base.dirname
child.save(update_fields=["base", "base_dirname"])
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=base)
response = self.client.get(reverse("snapshot_detail", args=[base.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, base.dirname)
self.assertContains(response, "BASESNAP")
self.assertContains(response, child.dirname)
self.assertContains(response, f"Run {run.id}")
self.assertContains(response, reverse("run_detail", args=[run.id]))
self.assertContains(response, reverse("snapshot_detail", args=[child.id]))
def test_discover_host_snapshots_action_discovers_and_redirects(self) -> None:
self.client.force_login(self.staff_user)
with TemporaryDirectory() as tmp:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import json
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.db.models import Count
@@ -49,7 +51,7 @@ def edit_global_config(request):
messages.success(request, f"Global config saved for {saved_config.name}.")
return redirect("dashboard")
else:
form = GlobalConfigForm(instance=global_config, initial=_default_global_initial())
form = GlobalConfigForm(instance=global_config) if global_config else GlobalConfigForm(initial=_default_global_initial())
return render(
request,
@@ -100,6 +102,31 @@ def host_detail(request, host: str):
return render(request, "pobsync_backend/host_detail.html", context)
@staff_member_required
def run_detail(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
context = {
"run": run,
"result_json": _pretty_json(run.result),
}
return render(request, "pobsync_backend/run_detail.html", context)
@staff_member_required
def snapshot_detail(request, snapshot_id: int):
snapshot = get_object_or_404(
SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"),
id=snapshot_id,
)
context = {
"snapshot": snapshot,
"metadata_json": _pretty_json(snapshot.metadata),
"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"),
}
return render(request, "pobsync_backend/snapshot_detail.html", context)
@staff_member_required
@require_POST
def discover_host_snapshots(request, host: str):
@@ -208,8 +235,7 @@ def _default_schedule_initial() -> dict[str, object]:
def _default_global_initial() -> dict[str, object]:
return {
"name": "default",
"backup_root": "/opt/pobsync/backups",
"pobsync_home": "/opt/pobsync",
"backup_root": "/backups",
"ssh_user": "root",
"ssh_port": 22,
"rsync_binary": "rsync",
@@ -229,3 +255,7 @@ def _default_host_initial() -> dict[str, object]:
"retention_monthly": 12,
"retention_yearly": 0,
}
def _pretty_json(value: object) -> str:
return json.dumps(value or {}, indent=2, sort_keys=True)

View File

@@ -15,6 +15,8 @@ urlpatterns = [
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
path("api/", api.api_index),
path("api/status/", api.status),
path("api/hosts/", api.hosts),