Compare commits
2 Commits
fe8e65e12e
...
573177e118
| Author | SHA1 | Date | |
|---|---|---|---|
| 573177e118 | |||
| 3da877eb8a |
@@ -129,15 +129,15 @@ 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`:
|
||||
Backup data is always available at `/backups` inside the containers. By default this uses `./backups` on the host.
|
||||
Override the host-side mount 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`.
|
||||
The Django setup UI keeps the backup root fixed at `/backups`; only the Docker mount decides which host directory
|
||||
that points to.
|
||||
|
||||
## Docker With MariaDB
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ class GlobalConfigForm(forms.ModelForm):
|
||||
model = GlobalConfig
|
||||
fields = (
|
||||
"name",
|
||||
"backup_root",
|
||||
"ssh_user",
|
||||
"ssh_port",
|
||||
"ssh_options",
|
||||
@@ -103,13 +102,13 @@ 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.",
|
||||
"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.backup_root = settings.POBSYNC_BACKUP_ROOT
|
||||
instance.pobsync_home = settings.POBSYNC_HOME
|
||||
if commit:
|
||||
instance.save()
|
||||
@@ -117,6 +116,23 @@ class GlobalConfigForm(forms.ModelForm):
|
||||
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):
|
||||
cron_expr = forms.CharField(
|
||||
label="Cron expression",
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
|
||||
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
||||
.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); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
|
||||
.actions.inline { margin: 12px 0 0; }
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
<section class="panel">
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
|
||||
@@ -51,6 +51,27 @@
|
||||
</section>
|
||||
</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">
|
||||
<h2>Latest Runs</h2>
|
||||
<table>
|
||||
|
||||
@@ -75,7 +75,6 @@ class ViewTests(TestCase):
|
||||
reverse("edit_global_config"),
|
||||
{
|
||||
"name": "default",
|
||||
"backup_root": "/backups",
|
||||
"ssh_user": "backup",
|
||||
"ssh_port": "2222",
|
||||
"ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes",
|
||||
@@ -109,7 +108,7 @@ 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:
|
||||
def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
GlobalConfig.objects.create(
|
||||
name="default",
|
||||
@@ -120,10 +119,48 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("edit_global_config"))
|
||||
|
||||
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, "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:
|
||||
self.client.force_login(self.staff_user)
|
||||
|
||||
@@ -180,6 +217,8 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "Discover snapshots")
|
||||
self.assertContains(response, "Edit schedule")
|
||||
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("snapshot_detail", args=[snapshot.id]))
|
||||
|
||||
@@ -190,6 +229,66 @@ class ViewTests(TestCase):
|
||||
|
||||
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:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
|
||||
@@ -4,13 +4,15 @@ import json
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.conf import settings
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
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 .retention import run_sql_retention_plan
|
||||
from .snapshot_discovery import discover_snapshots
|
||||
@@ -59,6 +61,7 @@ def edit_global_config(request):
|
||||
{
|
||||
"global_config": global_config,
|
||||
"form": form,
|
||||
"backup_root": settings.POBSYNC_BACKUP_ROOT,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -90,6 +93,7 @@ def host_detail(request, host: str):
|
||||
context = {
|
||||
"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],
|
||||
"snapshots": host_config.snapshots.select_related("base").order_by("-started_at", "dirname")[:20],
|
||||
"counts": {
|
||||
@@ -102,6 +106,33 @@ def host_detail(request, host: str):
|
||||
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
|
||||
def run_detail(request, run_id: int):
|
||||
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]:
|
||||
return {
|
||||
"name": "default",
|
||||
"backup_root": "/backups",
|
||||
"ssh_user": "root",
|
||||
"ssh_port": 22,
|
||||
"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:
|
||||
return json.dumps(value or {}, indent=2, sort_keys=True)
|
||||
|
||||
@@ -89,3 +89,4 @@ STATIC_ROOT = os.getenv("POBSYNC_STATIC_ROOT", str(BASE_DIR / "var" / "static"))
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
POBSYNC_HOME = os.getenv("POBSYNC_HOME", "/opt/pobsync")
|
||||
POBSYNC_BACKUP_ROOT = "/backups"
|
||||
|
||||
@@ -12,6 +12,7 @@ urlpatterns = [
|
||||
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>/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>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
|
||||
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||
|
||||
Reference in New Issue
Block a user