2 Commits

Author SHA1 Message Date
573177e118 (refactor) make Docker backup root static in Django setup
Remove backup_root from the normal Django global config form and display
the fixed container path /backups instead.

Always persist /backups from the setup form so Docker deployments do not
mix host paths with container paths.

Update tests and docs to clarify that the host backup directory is chosen
through the Docker mount, while Django always uses /backups internally.
2026-05-19 13:14:22 +02:00
3da877eb8a (feature) queue manual backups from the Django host page
Add a staff-only manual backup form to host detail pages with safe
dry-run defaults and optional retention settings.

Queue manual BackupRun records through the existing worker-backed runner
path instead of executing backups inside the web request.

Validate disabled hosts, missing global config, and invalid methods with
view tests covering the new UI flow.
2026-05-19 13:04:50 +02:00
9 changed files with 194 additions and 11 deletions

View File

@@ -129,15 +129,15 @@ docker compose up --build web scheduler worker
``` ```
The container persists `/opt/pobsync` and the SQLite database in Docker volumes. 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. Backup data is always available at `/backups` inside the containers. By default this uses `./backups` on the host.
Override it with `POBSYNC_BACKUP_ROOT`: Override the host-side mount with `POBSYNC_BACKUP_ROOT`:
``` ```
POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync docker compose up --build web scheduler worker 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, The Django setup UI keeps the backup root fixed at `/backups`; only the Docker mount decides which host directory
set it directly to the host path, for example `/mnt/backups/pobsync`. that points to.
## Docker With MariaDB ## Docker With MariaDB

View File

@@ -84,7 +84,6 @@ class GlobalConfigForm(forms.ModelForm):
model = GlobalConfig model = GlobalConfig
fields = ( fields = (
"name", "name",
"backup_root",
"ssh_user", "ssh_user",
"ssh_port", "ssh_port",
"ssh_options", "ssh_options",
@@ -103,13 +102,13 @@ class GlobalConfigForm(forms.ModelForm):
) )
help_texts = { help_texts = {
"name": "Usually 'default'. The backup engine currently reads the default config.", "name": "Usually 'default'. The backup engine currently reads the default config.",
"backup_root": "Directory that contains host backup folders.",
"default_source_root": "Used by hosts without a custom source root.", "default_source_root": "Used by hosts without a custom source root.",
"default_destination_subdir": "Optional subdirectory below each snapshot.", "default_destination_subdir": "Optional subdirectory below each snapshot.",
} }
def save(self, commit: bool = True): def save(self, commit: bool = True):
instance = super().save(commit=False) instance = super().save(commit=False)
instance.backup_root = settings.POBSYNC_BACKUP_ROOT
instance.pobsync_home = settings.POBSYNC_HOME instance.pobsync_home = settings.POBSYNC_HOME
if commit: if commit:
instance.save() instance.save()
@@ -117,6 +116,23 @@ class GlobalConfigForm(forms.ModelForm):
return instance return instance
class ManualBackupForm(forms.Form):
dry_run = forms.BooleanField(
required=False,
initial=True,
help_text="Queue rsync in dry-run mode without writing a snapshot.",
)
prune = forms.BooleanField(
required=False,
help_text="Apply retention after a successful non-dry-run backup.",
)
prune_max_delete = forms.IntegerField(min_value=0, initial=10)
prune_protect_bases = forms.BooleanField(
required=False,
help_text="Keep snapshots that are used as bases by other snapshots.",
)
class ScheduleConfigForm(forms.ModelForm): class ScheduleConfigForm(forms.ModelForm):
cron_expr = forms.CharField( cron_expr = forms.CharField(
label="Cron expression", label="Cron expression",

View File

@@ -78,6 +78,7 @@
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; } .status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; } .status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.stack { display: grid; gap: 4px; } .stack { display: grid; gap: 4px; }
.stack.spaced { margin-bottom: 14px; }
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; } .actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
.actions.inline { margin: 12px 0 0; } .actions.inline { margin: 12px 0 0; }

View File

@@ -11,6 +11,10 @@
<section class="panel"> <section class="panel">
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2> <h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>
<div class="stack spaced">
<div><strong>Backup root:</strong> {{ backup_root }}</div>
<div class="muted">This is the fixed path inside the Docker containers. Change the host directory by changing the Docker mount.</div>
</div>
<form method="post" class="form-grid"> <form method="post" class="form-grid">
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors }} {{ form.non_field_errors }}

View File

@@ -51,6 +51,27 @@
</section> </section>
</div> </div>
<section class="panel">
<h2>Queue Manual Backup</h2>
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
{% csrf_token %}
{{ manual_backup_form.non_field_errors }}
{% for field in manual_backup_form %}
<div class="field">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
</div>
{% endfor %}
<div class="actions">
<button type="submit">Queue backup</button>
</div>
</form>
</section>
<section class="panel"> <section class="panel">
<h2>Latest Runs</h2> <h2>Latest Runs</h2>
<table> <table>

View File

@@ -75,7 +75,6 @@ class ViewTests(TestCase):
reverse("edit_global_config"), reverse("edit_global_config"),
{ {
"name": "default", "name": "default",
"backup_root": "/backups",
"ssh_user": "backup", "ssh_user": "backup",
"ssh_port": "2222", "ssh_port": "2222",
"ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes", "ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes",
@@ -109,7 +108,7 @@ class ViewTests(TestCase):
self.assertEqual(config.retention_daily, 7) self.assertEqual(config.retention_daily, 7)
self.assertEqual(config.retention_yearly, 1) self.assertEqual(config.retention_yearly, 1)
def test_global_config_form_renders_saved_backup_root_on_edit(self) -> None: def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
GlobalConfig.objects.create( GlobalConfig.objects.create(
name="default", name="default",
@@ -120,10 +119,48 @@ class ViewTests(TestCase):
response = self.client.get(reverse("edit_global_config")) response = self.client.get(reverse("edit_global_config"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "/mnt/pobsync/backups") self.assertContains(response, "Backup root:")
self.assertContains(response, "/backups")
self.assertNotContains(response, "/mnt/pobsync/backups")
self.assertNotContains(response, "/opt/pobsync/backups") self.assertNotContains(response, "/opt/pobsync/backups")
self.assertNotContains(response, "Pobsync home") self.assertNotContains(response, "Pobsync home")
def test_global_config_form_resets_backup_root_to_static_container_path(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.post(
reverse("edit_global_config"),
{
"name": "default",
"ssh_user": "root",
"ssh_port": "22",
"ssh_options": "",
"rsync_binary": "rsync",
"rsync_args": "",
"rsync_extra_args": "",
"rsync_timeout_seconds": "0",
"rsync_bwlimit_kbps": "0",
"default_source_root": "/",
"default_destination_subdir": "",
"excludes_default": "",
"retention_daily": "14",
"retention_weekly": "8",
"retention_monthly": "12",
"retention_yearly": "0",
},
follow=True,
)
self.assertRedirects(response, reverse("dashboard"))
config = GlobalConfig.objects.get(name="default")
self.assertEqual(config.backup_root, "/backups")
self.assertEqual(config.pobsync_home, "/opt/pobsync")
def test_create_host_config_form_creates_host(self) -> None: def test_create_host_config_form_creates_host(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -180,6 +217,8 @@ class ViewTests(TestCase):
self.assertContains(response, "Discover snapshots") self.assertContains(response, "Discover snapshots")
self.assertContains(response, "Edit schedule") self.assertContains(response, "Edit schedule")
self.assertContains(response, "Edit config") self.assertContains(response, "Edit config")
self.assertContains(response, "Queue Manual Backup")
self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id])) self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
@@ -190,6 +229,66 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_queue_manual_backup_creates_queued_run_and_redirects_to_run_detail(self) -> None:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/backups")
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
response = self.client.post(
reverse("queue_manual_backup", args=[host.host]),
{
"dry_run": "on",
"prune": "on",
"prune_max_delete": "4",
"prune_protect_bases": "on",
},
follow=True,
)
run = BackupRun.objects.get(host=host)
self.assertRedirects(response, reverse("run_detail", args=[run.id]))
self.assertContains(response, f"Queued manual backup run {run.id} for web-01.")
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": 4,
"prune_protect_bases": True,
},
)
def test_queue_manual_backup_requires_default_global_config(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
response = self.client.post(reverse("queue_manual_backup", args=[host.host]), {"dry_run": "on"}, follow=True)
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
self.assertContains(response, "Create the default global config before queueing backups.")
self.assertFalse(BackupRun.objects.exists())
def test_queue_manual_backup_rejects_disabled_host(self) -> None:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/backups")
host = HostConfig.objects.create(host="web-01", address="web-01.example.test", enabled=False)
response = self.client.post(reverse("queue_manual_backup", args=[host.host]), {"dry_run": "on"}, follow=True)
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
self.assertContains(response, "Cannot queue backup for disabled host web-01.")
self.assertFalse(BackupRun.objects.exists())
def test_queue_manual_backup_requires_post(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
response = self.client.get(reverse("queue_manual_backup", args=[host.host]))
self.assertEqual(response.status_code, 405)
def test_run_detail_renders_result_payload(self) -> None: def test_run_detail_renders_result_payload(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")

View File

@@ -4,13 +4,15 @@ import json
from django.contrib import messages from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.conf import settings
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from pobsync.errors import ConfigError from pobsync.errors import ConfigError
from .forms import CreateHostConfigForm, GlobalConfigForm, HostConfigForm, ScheduleConfigForm from .backup_runner import queue_backup_run
from .forms import CreateHostConfigForm, GlobalConfigForm, HostConfigForm, ManualBackupForm, ScheduleConfigForm
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
from .retention import run_sql_retention_plan from .retention import run_sql_retention_plan
from .snapshot_discovery import discover_snapshots from .snapshot_discovery import discover_snapshots
@@ -59,6 +61,7 @@ def edit_global_config(request):
{ {
"global_config": global_config, "global_config": global_config,
"form": form, "form": form,
"backup_root": settings.POBSYNC_BACKUP_ROOT,
}, },
) )
@@ -90,6 +93,7 @@ def host_detail(request, host: str):
context = { context = {
"host": host_config, "host": host_config,
"schedule": _schedule_for_host(host_config), "schedule": _schedule_for_host(host_config),
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)),
"latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10], "latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10],
"snapshots": host_config.snapshots.select_related("base").order_by("-started_at", "dirname")[:20], "snapshots": host_config.snapshots.select_related("base").order_by("-started_at", "dirname")[:20],
"counts": { "counts": {
@@ -102,6 +106,33 @@ def host_detail(request, host: str):
return render(request, "pobsync_backend/host_detail.html", context) return render(request, "pobsync_backend/host_detail.html", context)
@staff_member_required
@require_POST
def queue_manual_backup(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
if not host_config.enabled:
messages.error(request, f"Cannot queue backup for disabled host {host_config.host}.")
return redirect("host_detail", host=host_config.host)
if not GlobalConfig.objects.filter(name="default").exists():
messages.error(request, "Create the default global config before queueing backups.")
return redirect("host_detail", host=host_config.host)
form = ManualBackupForm(request.POST)
if not form.is_valid():
messages.error(request, "Manual backup options are invalid.")
return redirect("host_detail", host=host_config.host)
run = queue_backup_run(
host=host_config,
dry_run=form.cleaned_data["dry_run"],
prune=form.cleaned_data["prune"],
prune_max_delete=form.cleaned_data["prune_max_delete"],
prune_protect_bases=form.cleaned_data["prune_protect_bases"],
)
messages.success(request, f"Queued manual backup run {run.id} for {host_config.host}.")
return redirect("run_detail", run_id=run.id)
@staff_member_required @staff_member_required
def run_detail(request, run_id: int): def run_detail(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
@@ -235,7 +266,6 @@ def _default_schedule_initial() -> dict[str, object]:
def _default_global_initial() -> dict[str, object]: def _default_global_initial() -> dict[str, object]:
return { return {
"name": "default", "name": "default",
"backup_root": "/backups",
"ssh_user": "root", "ssh_user": "root",
"ssh_port": 22, "ssh_port": 22,
"rsync_binary": "rsync", "rsync_binary": "rsync",
@@ -257,5 +287,15 @@ def _default_host_initial() -> dict[str, object]:
} }
def _default_manual_backup_initial(host_config: HostConfig) -> dict[str, object]:
schedule = _schedule_for_host(host_config)
return {
"dry_run": True,
"prune": bool(schedule.prune) if schedule else False,
"prune_max_delete": schedule.prune_max_delete if schedule else 10,
"prune_protect_bases": bool(schedule.prune_protect_bases) if schedule else False,
}
def _pretty_json(value: object) -> str: 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)

View File

@@ -89,3 +89,4 @@ STATIC_ROOT = os.getenv("POBSYNC_STATIC_ROOT", str(BASE_DIR / "var" / "static"))
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
POBSYNC_HOME = os.getenv("POBSYNC_HOME", "/opt/pobsync") POBSYNC_HOME = os.getenv("POBSYNC_HOME", "/opt/pobsync")
POBSYNC_BACKUP_ROOT = "/backups"

View File

@@ -12,6 +12,7 @@ urlpatterns = [
path("hosts/new/", views.create_host_config, name="create_host_config"), path("hosts/new/", views.create_host_config, name="create_host_config"),
path("hosts/<str:host>/", views.host_detail, name="host_detail"), path("hosts/<str:host>/", views.host_detail, name="host_detail"),
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"), path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
path("hosts/<str:host>/queue-backup/", views.queue_manual_backup, name="queue_manual_backup"),
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), 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>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"), path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),