Compare commits
3 Commits
6bcc15c174
...
fe8e65e12e
| Author | SHA1 | Date | |
|---|---|---|---|
| fe8e65e12e | |||
| aea22597ba | |||
| 66e1f549b9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
var/
|
var/
|
||||||
|
backups/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -125,10 +125,19 @@ This starts Django on:
|
|||||||
Run the scheduler alongside the web admin:
|
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.
|
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
|
## Docker With MariaDB
|
||||||
|
|
||||||
@@ -139,7 +148,7 @@ docker compose --profile mariadb up --build web-mariadb
|
|||||||
With the scheduler:
|
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.
|
SQLite remains the default because it is enough for a single backup server and keeps deployment simple.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pobsync_state:/opt/pobsync
|
- pobsync_state:/opt/pobsync
|
||||||
- pobsync_db:/var/lib/pobsync
|
- pobsync_db:/var/lib/pobsync
|
||||||
|
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
build: .
|
build: .
|
||||||
@@ -26,6 +27,21 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pobsync_state:/opt/pobsync
|
- pobsync_state:/opt/pobsync
|
||||||
- pobsync_db:/var/lib/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:
|
web-mariadb:
|
||||||
profiles: ["mariadb"]
|
profiles: ["mariadb"]
|
||||||
@@ -48,6 +64,7 @@ services:
|
|||||||
- "8010:8000"
|
- "8010:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- pobsync_state:/opt/pobsync
|
- pobsync_state:/opt/pobsync
|
||||||
|
- ${POBSYNC_BACKUP_ROOT:-./backups}:/backups
|
||||||
|
|
||||||
scheduler-mariadb:
|
scheduler-mariadb:
|
||||||
profiles: ["mariadb"]
|
profiles: ["mariadb"]
|
||||||
@@ -68,6 +85,28 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- pobsync_state:/opt/pobsync
|
- 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:
|
db:
|
||||||
profiles: ["mariadb"]
|
profiles: ["mariadb"]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ COMMAND_ALIASES = {
|
|||||||
"retention": "run_pobsync_retention",
|
"retention": "run_pobsync_retention",
|
||||||
"discover-snapshots": "discover_pobsync_snapshots",
|
"discover-snapshots": "discover_pobsync_snapshots",
|
||||||
"scheduler": "run_pobsync_scheduler",
|
"scheduler": "run_pobsync_scheduler",
|
||||||
|
"worker": "run_pobsync_worker",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
148
src/pobsync_backend/backup_runner.py
Normal file
148
src/pobsync_backend/backup_runner.py
Normal 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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from .models import GlobalConfig, HostConfig, ScheduleConfig
|
from .models import GlobalConfig, HostConfig, ScheduleConfig
|
||||||
from .scheduler import parse_cron_expr
|
from .scheduler import parse_cron_expr
|
||||||
@@ -84,7 +85,6 @@ class GlobalConfigForm(forms.ModelForm):
|
|||||||
fields = (
|
fields = (
|
||||||
"name",
|
"name",
|
||||||
"backup_root",
|
"backup_root",
|
||||||
"pobsync_home",
|
|
||||||
"ssh_user",
|
"ssh_user",
|
||||||
"ssh_port",
|
"ssh_port",
|
||||||
"ssh_options",
|
"ssh_options",
|
||||||
@@ -104,11 +104,18 @@ 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.",
|
"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_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):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
instance.pobsync_home = settings.POBSYNC_HOME
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
self.save_m2m()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class ScheduleConfigForm(forms.ModelForm):
|
class ScheduleConfigForm(forms.ModelForm):
|
||||||
cron_expr = forms.CharField(
|
cron_expr = forms.CharField(
|
||||||
|
|||||||
@@ -5,18 +5,14 @@ from typing import Any
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
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.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.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):
|
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:
|
def add_arguments(self, parser) -> None:
|
||||||
parser.add_argument("host", help="Host to back up")
|
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", action="store_true", help="Apply retention after a successful run")
|
||||||
parser.add_argument("--prune-max-delete", type=int, default=10)
|
parser.add_argument("--prune-max-delete", type=int, default=10)
|
||||||
parser.add_argument("--prune-protect-bases", action="store_true")
|
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:
|
def handle(self, *args: Any, **options: Any) -> None:
|
||||||
host_name = options["host"]
|
host_name = options["host"]
|
||||||
@@ -36,86 +33,20 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
run = BackupRun.objects.create(
|
run = BackupRun.objects.create(
|
||||||
host=host,
|
host=host,
|
||||||
run_type=BackupRun.RunType.SCHEDULED,
|
run_type=BackupRun.RunType.MANUAL if options["manual"] else BackupRun.RunType.SCHEDULED,
|
||||||
status=BackupRun.Status.RUNNING,
|
status=BackupRun.Status.RUNNING,
|
||||||
started_at=timezone.now(),
|
|
||||||
)
|
)
|
||||||
|
execute_backup_run(
|
||||||
try:
|
run=run,
|
||||||
result = run_scheduled(
|
prefix=paths.home,
|
||||||
prefix=paths.home,
|
dry_run=bool(options["dry_run"]),
|
||||||
host=host.host,
|
prune=bool(options["prune"]),
|
||||||
dry_run=bool(options["dry_run"]),
|
prune_max_delete=int(options["prune_max_delete"]),
|
||||||
prune=False,
|
prune_protect_bases=bool(options["prune_protect_bases"]),
|
||||||
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",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
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}."))
|
self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}."))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -121,6 +121,17 @@
|
|||||||
.field textarea { min-height: 92px; resize: vertical; }
|
.field textarea { min-height: 92px; resize: vertical; }
|
||||||
.field .helptext { color: var(--muted); font-size: 12px; }
|
.field .helptext { color: var(--muted); font-size: 12px; }
|
||||||
.field input[type="checkbox"] { justify-self: start; }
|
.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 {
|
.errorlist {
|
||||||
color: var(--failed);
|
color: var(--failed);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -84,10 +84,10 @@
|
|||||||
{% for run in latest_runs %}
|
{% for run in latest_runs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
|
<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.started_at|default:"" }}</td>
|
||||||
<td>{{ run.ended_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>
|
<td>{{ run.rsync_exit_code|default:"" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -67,10 +67,10 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for run in latest_runs %}
|
{% for run in latest_runs %}
|
||||||
<tr>
|
<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.started_at|default:"" }}</td>
|
||||||
<td>{{ run.ended_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.base_path|default:"" }}</td>
|
||||||
<td>{{ run.rsync_exit_code|default:"" }}</td>
|
<td>{{ run.rsync_exit_code|default:"" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -99,8 +99,8 @@
|
|||||||
<td>{{ snapshot.kind }}</td>
|
<td>{{ snapshot.kind }}</td>
|
||||||
<td>{{ snapshot.status }}</td>
|
<td>{{ snapshot.status }}</td>
|
||||||
<td>{{ snapshot.started_at|default:"" }}</td>
|
<td>{{ snapshot.started_at|default:"" }}</td>
|
||||||
<td>{{ snapshot.dirname }}</td>
|
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
|
||||||
<td>{% if snapshot.base %}{{ snapshot.base.dirname }}{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
|
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
70
src/pobsync_backend/tests/test_backup_worker.py
Normal file
70
src/pobsync_backend/tests/test_backup_worker.py
Normal 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)
|
||||||
@@ -46,3 +46,10 @@ class ConsoleEntrypointTests(SimpleTestCase):
|
|||||||
|
|
||||||
self.assertEqual(exit_code, 0)
|
self.assertEqual(exit_code, 0)
|
||||||
execute.assert_called_once_with(["pobsync", "discover_pobsync_snapshots", "--host", "web-01"])
|
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"])
|
||||||
|
|||||||
@@ -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 = {
|
run_scheduled.return_value = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"dry_run": False,
|
"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"})
|
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
|
||||||
|
|
||||||
with (
|
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(
|
patch(
|
||||||
"pobsync_backend.management.commands.run_pobsync_backup.run_sql_retention_apply"
|
"pobsync_backend.backup_runner.run_sql_retention_apply"
|
||||||
) as retention_apply,
|
) as retention_apply,
|
||||||
):
|
):
|
||||||
run_scheduled.return_value = {
|
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"})
|
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
|
||||||
|
|
||||||
with (
|
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(
|
patch(
|
||||||
"pobsync_backend.management.commands.run_pobsync_backup.run_sql_retention_apply"
|
"pobsync_backend.backup_runner.run_sql_retention_apply"
|
||||||
) as retention_apply,
|
) as retention_apply,
|
||||||
):
|
):
|
||||||
run_scheduled.return_value = {
|
run_scheduled.return_value = {
|
||||||
@@ -155,7 +155,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
meta_dir.mkdir(parents=True)
|
meta_dir.mkdir(parents=True)
|
||||||
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "failed", "started_at": "2026-05-19T02:15:00Z"})
|
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 = {
|
run_scheduled.return_value = {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
@@ -179,7 +179,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
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 = {
|
run_scheduled.return_value = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
@@ -198,3 +198,28 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
self.assertEqual(BackupRun.objects.count(), 1)
|
self.assertEqual(BackupRun.objects.count(), 1)
|
||||||
self.assertIsNone(BackupRun.objects.get().snapshot)
|
self.assertIsNone(BackupRun.objects.get().snapshot)
|
||||||
self.assertEqual(SnapshotRecord.objects.count(), 0)
|
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)
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ class ViewTests(TestCase):
|
|||||||
{
|
{
|
||||||
"name": "default",
|
"name": "default",
|
||||||
"backup_root": "/backups",
|
"backup_root": "/backups",
|
||||||
"pobsync_home": "/opt/pobsync",
|
|
||||||
"ssh_user": "backup",
|
"ssh_user": "backup",
|
||||||
"ssh_port": "2222",
|
"ssh_port": "2222",
|
||||||
"ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes",
|
"ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes",
|
||||||
@@ -100,6 +99,7 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Global config saved for default.")
|
self.assertContains(response, "Global config saved for default.")
|
||||||
config = GlobalConfig.objects.get(name="default")
|
config = GlobalConfig.objects.get(name="default")
|
||||||
self.assertEqual(config.backup_root, "/backups")
|
self.assertEqual(config.backup_root, "/backups")
|
||||||
|
self.assertEqual(config.pobsync_home, "/opt/pobsync")
|
||||||
self.assertEqual(config.ssh_user, "backup")
|
self.assertEqual(config.ssh_user, "backup")
|
||||||
self.assertEqual(config.ssh_port, 2222)
|
self.assertEqual(config.ssh_port, 2222)
|
||||||
self.assertEqual(config.ssh_options, ["StrictHostKeyChecking=no", "BatchMode=yes"])
|
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_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:
|
||||||
|
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:
|
def test_create_host_config_form_creates_host(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
@@ -165,6 +180,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, 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:
|
def test_host_detail_returns_404_for_unknown_host(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -173,6 +190,52 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
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, ""ok": 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:
|
def test_discover_host_snapshots_action_discovers_and_redirects(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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.db.models import Count
|
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}.")
|
messages.success(request, f"Global config saved for {saved_config.name}.")
|
||||||
return redirect("dashboard")
|
return redirect("dashboard")
|
||||||
else:
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -100,6 +102,31 @@ 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
|
||||||
|
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
|
@staff_member_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def discover_host_snapshots(request, host: str):
|
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]:
|
def _default_global_initial() -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"name": "default",
|
"name": "default",
|
||||||
"backup_root": "/opt/pobsync/backups",
|
"backup_root": "/backups",
|
||||||
"pobsync_home": "/opt/pobsync",
|
|
||||||
"ssh_user": "root",
|
"ssh_user": "root",
|
||||||
"ssh_port": 22,
|
"ssh_port": 22,
|
||||||
"rsync_binary": "rsync",
|
"rsync_binary": "rsync",
|
||||||
@@ -229,3 +255,7 @@ def _default_host_initial() -> dict[str, object]:
|
|||||||
"retention_monthly": 12,
|
"retention_monthly": 12,
|
||||||
"retention_yearly": 0,
|
"retention_yearly": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pretty_json(value: object) -> str:
|
||||||
|
return json.dumps(value or {}, indent=2, sort_keys=True)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ urlpatterns = [
|
|||||||
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"),
|
||||||
|
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/", api.api_index),
|
||||||
path("api/status/", api.status),
|
path("api/status/", api.status),
|
||||||
path("api/hosts/", api.hosts),
|
path("api/hosts/", api.hosts),
|
||||||
|
|||||||
Reference in New Issue
Block a user