Compare commits
14 Commits
fc6df89370
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 51142081c9 | |||
| 02616eebbc | |||
| a61e3d8302 | |||
| 0450f8bdb0 | |||
| b4833560b5 | |||
| 81ee848f5f | |||
| 7f2bbe4d20 | |||
| 29f455a153 | |||
| 41ceab5a40 | |||
| 2ad119e214 | |||
| eb121453c8 | |||
| 67ffd6101b | |||
| 1f5c4e0756 | |||
| b5e87abad2 |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -1,5 +1,34 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.2.0 - 2026-05-28
|
||||||
|
|
||||||
|
Operations-focused release for more reliable production backups and maintenance.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Staff-only updater page for checking configured Gitea releases, inspecting the installed git checkout, fetching tags, pulling the current branch, and running the native systemd updater.
|
||||||
|
- Read-only control panel access level for authenticated non-staff users, with status pages visible and credentials, logs, configs, retention, and mutating actions kept staff-only.
|
||||||
|
- Run completion notifications for email and webhooks, including recorded delivery history per run and target.
|
||||||
|
- Dedicated hosts page with host cards, enabled/disabled filtering, and quick host/schedule/retention state controls.
|
||||||
|
- Per-host rsync bandwidth limit overrides with inherit, unlimited, and explicit limit semantics.
|
||||||
|
- Backup data totals by snapshot kind on dashboard and host detail pages, including unique/non-hardlinked data totals.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Real backup runs now default to verbose rsync progress output so the live run view behaves consistently with dry-runs.
|
||||||
|
- Run progress panels are shared between dry-runs and real runs for more consistent status, timing, cancellation, and log display.
|
||||||
|
- Incomplete snapshot cleanup now requires operator review before deletion.
|
||||||
|
- Incomplete snapshot size reporting now prefers on-disk measurement when metadata is stale or missing.
|
||||||
|
- Installer and environment examples now include optional updater configuration.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Remote preflight shell commands are now quoted correctly, including roots such as `/`.
|
||||||
|
- Worker reconciliation now detects real rsync failures and stale/running process state more reliably.
|
||||||
|
- Retention pruning and incomplete cleanup can delete snapshots containing restrictive directory modes preserved by rsync archive mode.
|
||||||
|
- Snapshot data summaries no longer count incomplete metadata/log files as backup data when measuring from disk.
|
||||||
|
- Filesystem SSH credential tests use writable test state without changing production defaults.
|
||||||
|
|
||||||
## 1.1.0 - 2026-05-21
|
## 1.1.0 - 2026-05-21
|
||||||
|
|
||||||
UI-focused release for the Django control panel.
|
UI-focused release for the Django control panel.
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -131,6 +131,11 @@ Create a superuser if needed:
|
|||||||
sudo -u pobsync pobsync-manage createsuperuser
|
sudo -u pobsync pobsync-manage createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The control panel supports two access levels. Django staff users can manage hosts, SSH keys, configs, retention,
|
||||||
|
notifications, logs, and administrative actions. Normal authenticated users can view backup status pages such as the
|
||||||
|
dashboard, hosts, runs, snapshots, schedules, purged history, changelog, and `/api/status/`, but cannot see SSH
|
||||||
|
credentials or run mutating actions.
|
||||||
|
|
||||||
For other Django management commands on native installs, use `pobsync-manage` so the production environment file is
|
For other Django management commands on native installs, use `pobsync-manage` so the production environment file is
|
||||||
loaded before Django starts:
|
loaded before Django starts:
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ The UI includes:
|
|||||||
- Django-managed SSH keys
|
- Django-managed SSH keys
|
||||||
- `/self-check/` for runtime checks
|
- `/self-check/` for runtime checks
|
||||||
- `/logs/` for filtered pobsync service logs
|
- `/logs/` for filtered pobsync service logs
|
||||||
|
- `/updater/` for checking Gitea releases, pulling the git checkout, and running the native updater
|
||||||
|
|
||||||
## Bandwidth Limits
|
## Bandwidth Limits
|
||||||
|
|
||||||
@@ -238,6 +244,21 @@ The updater is a thin wrapper around the installer for normal production deploys
|
|||||||
Python dependencies, runs migrations, collects static files, and restarts the systemd services so new Django code is
|
Python dependencies, runs migrations, collects static files, and restarts the systemd services so new Django code is
|
||||||
loaded.
|
loaded.
|
||||||
|
|
||||||
|
The Django control panel also exposes an `/updater/` page for staff users. It can check a Gitea releases endpoint, run
|
||||||
|
`git fetch`, run a fast-forward-only pull for the installed branch, and invoke the configured native update command.
|
||||||
|
Configure these optional environment variables in `/etc/pobsync/pobsync.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
POBSYNC_UPDATE_RELEASES_URL=https://code.example.test/api/v1/repos/owner/pobsync/releases
|
||||||
|
POBSYNC_UPDATE_RELEASES_TOKEN=
|
||||||
|
POBSYNC_UPDATE_GIT_REMOTE=origin
|
||||||
|
POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd
|
||||||
|
```
|
||||||
|
|
||||||
|
If the web service runs as the `pobsync` user, `POBSYNC_UPDATE_COMMAND` needs a matching sudoers rule or a different
|
||||||
|
operator-approved command. Without that, the page still shows update status and command output, but the native update
|
||||||
|
action will fail with a permission error instead of silently doing the wrong thing.
|
||||||
|
|
||||||
Use the full installer again when you intentionally want to change install-time settings, install OS packages, enable
|
Use the full installer again when you intentionally want to change install-time settings, install OS packages, enable
|
||||||
nginx, or rewrite the environment file:
|
nginx, or rewrite the environment file:
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,11 @@ POBSYNC_GUNICORN_WORKERS=2
|
|||||||
POBSYNC_GUNICORN_TIMEOUT=120
|
POBSYNC_GUNICORN_TIMEOUT=120
|
||||||
POBSYNC_WORKER_INTERVAL=15
|
POBSYNC_WORKER_INTERVAL=15
|
||||||
POBSYNC_SCHEDULER_INTERVAL=60
|
POBSYNC_SCHEDULER_INTERVAL=60
|
||||||
|
|
||||||
|
# Optional UI updater integration.
|
||||||
|
# Point this at the Gitea releases API endpoint, for example:
|
||||||
|
# https://code.example.test/api/v1/repos/owner/pobsync/releases
|
||||||
|
POBSYNC_UPDATE_RELEASES_URL=
|
||||||
|
POBSYNC_UPDATE_RELEASES_TOKEN=
|
||||||
|
POBSYNC_UPDATE_GIT_REMOTE=origin
|
||||||
|
POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pobsync"
|
name = "pobsync"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -472,6 +472,10 @@ POBSYNC_GUNICORN_WORKERS=2
|
|||||||
POBSYNC_GUNICORN_TIMEOUT=120
|
POBSYNC_GUNICORN_TIMEOUT=120
|
||||||
POBSYNC_WORKER_INTERVAL=15
|
POBSYNC_WORKER_INTERVAL=15
|
||||||
POBSYNC_SCHEDULER_INTERVAL=60
|
POBSYNC_SCHEDULER_INTERVAL=60
|
||||||
|
POBSYNC_UPDATE_RELEASES_URL=
|
||||||
|
POBSYNC_UPDATE_RELEASES_TOKEN=
|
||||||
|
POBSYNC_UPDATE_GIT_REMOTE=origin
|
||||||
|
POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd
|
||||||
EOF
|
EOF
|
||||||
chmod 0640 "$ENV_FILE"
|
chmod 0640 "$ENV_FILE"
|
||||||
chown "root:$SERVICE_GROUP" "$ENV_FILE"
|
chown "root:$SERVICE_GROUP" "$ENV_FILE"
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "1.1.0"
|
__version__ = "1.2.0"
|
||||||
|
|||||||
39
src/pobsync_backend/access.py
Normal file
39
src/pobsync_backend/access.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_status(user) -> bool:
|
||||||
|
return bool(user.is_authenticated)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_control_panel(user) -> bool:
|
||||||
|
return bool(user.is_authenticated and user.is_staff)
|
||||||
|
|
||||||
|
|
||||||
|
def status_view_required(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
|
||||||
|
return login_required(view_func)
|
||||||
|
|
||||||
|
|
||||||
|
def control_panel_admin_required(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
|
||||||
|
@login_required
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||||
|
if not can_manage_control_panel(request.user):
|
||||||
|
raise PermissionDenied
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def access_context(request: HttpRequest) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"can_view_status": can_view_status(request.user),
|
||||||
|
"can_manage_control_panel": can_manage_control_panel(request.user),
|
||||||
|
}
|
||||||
@@ -6,7 +6,17 @@ from django.urls import reverse
|
|||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
|
|
||||||
from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
|
from .models import (
|
||||||
|
BackupRun,
|
||||||
|
GlobalConfig,
|
||||||
|
HostConfig,
|
||||||
|
NotificationDelivery,
|
||||||
|
NotificationTarget,
|
||||||
|
PurgedSnapshot,
|
||||||
|
ScheduleConfig,
|
||||||
|
SnapshotRecord,
|
||||||
|
SshCredential,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SshCredential)
|
@admin.register(SshCredential)
|
||||||
@@ -136,6 +146,38 @@ class BackupRunAdmin(admin.ModelAdmin):
|
|||||||
return format_html('<a href="{}">{}</a>', url, obj.snapshot.dirname)
|
return format_html('<a href="{}">{}</a>', url, obj.snapshot.dirname)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(NotificationTarget)
|
||||||
|
class NotificationTargetAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "channel", "enabled", "last_status", "last_sent_at", "updated_at")
|
||||||
|
list_filter = ("enabled", "channel", "last_status")
|
||||||
|
search_fields = ("name", "email_to", "webhook_url", "notes")
|
||||||
|
readonly_fields = ("created_at", "updated_at", "last_status", "last_error", "last_sent_at")
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("name", "enabled", "channel", "statuses")}),
|
||||||
|
("Email", {"fields": ("email_to",)}),
|
||||||
|
("Webhook", {"fields": ("webhook_url", "webhook_headers")}),
|
||||||
|
("State", {"fields": ("last_status", "last_error", "last_sent_at"), "classes": ("collapse",)}),
|
||||||
|
("Notes", {"fields": ("notes",), "classes": ("collapse",)}),
|
||||||
|
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(NotificationDelivery)
|
||||||
|
class NotificationDeliveryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("target", "run", "status", "created_at")
|
||||||
|
list_filter = ("status", "target__channel", "created_at")
|
||||||
|
search_fields = ("target__name", "run__host__host", "error")
|
||||||
|
readonly_fields = ("target", "run", "status", "error", "payload", "created_at")
|
||||||
|
list_select_related = ("target", "run", "run__host")
|
||||||
|
date_hierarchy = "created_at"
|
||||||
|
|
||||||
|
def has_add_permission(self, request) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SnapshotRecord)
|
@admin.register(SnapshotRecord)
|
||||||
class SnapshotRecordAdmin(admin.ModelAdmin):
|
class SnapshotRecordAdmin(admin.ModelAdmin):
|
||||||
list_display = ("host", "kind", "dirname", "status", "base_link", "backup_run_count_link", "started_at", "discovered_at")
|
list_display = ("host", "kind", "dirname", "status", "base_link", "backup_run_count_link", "started_at", "discovered_at")
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
||||||
|
from .access import control_panel_admin_required, status_view_required
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def api_index(request) -> JsonResponse:
|
def api_index(request) -> JsonResponse:
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,7 @@ def api_index(request) -> JsonResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def status(request) -> JsonResponse:
|
def status(request) -> JsonResponse:
|
||||||
latest_run = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at").first()
|
latest_run = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at").first()
|
||||||
latest_schedule = ScheduleConfig.objects.select_related("host").order_by("-last_started_at", "-updated_at").first()
|
latest_schedule = ScheduleConfig.objects.select_related("host").order_by("-last_started_at", "-updated_at").first()
|
||||||
@@ -55,7 +55,7 @@ def status(request) -> JsonResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def hosts(request) -> JsonResponse:
|
def hosts(request) -> JsonResponse:
|
||||||
host_qs = (
|
host_qs = (
|
||||||
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
|
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
|
||||||
@@ -65,7 +65,7 @@ def hosts(request) -> JsonResponse:
|
|||||||
return JsonResponse({"ok": True, "hosts": [_host_payload(host) for host in host_qs]})
|
return JsonResponse({"ok": True, "hosts": [_host_payload(host) for host in host_qs]})
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def snapshots(request) -> JsonResponse:
|
def snapshots(request) -> JsonResponse:
|
||||||
snapshot_qs = SnapshotRecord.objects.select_related("host", "base").order_by("host__host", "-started_at", "dirname")
|
snapshot_qs = SnapshotRecord.objects.select_related("host", "base").order_by("host__host", "-started_at", "dirname")
|
||||||
host_filter = request.GET.get("host")
|
host_filter = request.GET.get("host")
|
||||||
@@ -78,7 +78,7 @@ def snapshots(request) -> JsonResponse:
|
|||||||
return JsonResponse({"ok": True, "snapshots": [_snapshot_payload(snapshot) for snapshot in snapshot_qs[:limit]]})
|
return JsonResponse({"ok": True, "snapshots": [_snapshot_payload(snapshot) for snapshot in snapshot_qs[:limit]]})
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def runs(request) -> JsonResponse:
|
def runs(request) -> JsonResponse:
|
||||||
run_qs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")
|
run_qs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")
|
||||||
host_filter = request.GET.get("host")
|
host_filter = request.GET.get("host")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from pobsync.commands.run_scheduled import (
|
|||||||
)
|
)
|
||||||
from pobsync_backend.config_source import DjangoConfigSource
|
from pobsync_backend.config_source import DjangoConfigSource
|
||||||
from pobsync_backend.models import BackupRun, HostConfig
|
from pobsync_backend.models import BackupRun, HostConfig
|
||||||
|
from pobsync_backend.notifications import notify_backup_run_completed
|
||||||
from pobsync_backend.retention import run_sql_retention_apply
|
from pobsync_backend.retention import run_sql_retention_apply
|
||||||
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
|
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ def queue_backup_run(
|
|||||||
host: HostConfig,
|
host: HostConfig,
|
||||||
run_type: str = BackupRun.RunType.MANUAL,
|
run_type: str = BackupRun.RunType.MANUAL,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
verbose_output: bool = False,
|
verbose_output: bool = True,
|
||||||
prune: bool = False,
|
prune: bool = False,
|
||||||
prune_max_delete: int = 10,
|
prune_max_delete: int = 10,
|
||||||
prune_protect_bases: bool = False,
|
prune_protect_bases: bool = False,
|
||||||
@@ -85,6 +86,7 @@ def execute_backup_run(
|
|||||||
"type": type(exc).__name__,
|
"type": type(exc).__name__,
|
||||||
}
|
}
|
||||||
run.save(update_fields=["status", "ended_at", "result"])
|
run.save(update_fields=["status", "ended_at", "result"])
|
||||||
|
notify_backup_run_completed(run)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
run.refresh_from_db()
|
run.refresh_from_db()
|
||||||
@@ -151,6 +153,7 @@ def execute_backup_run(
|
|||||||
"result",
|
"result",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
notify_backup_run_completed(run)
|
||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
@@ -277,6 +280,7 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_s
|
|||||||
run.rsync_exit_code = exit_code or 255
|
run.rsync_exit_code = exit_code or 255
|
||||||
run.result = result
|
run.result = result
|
||||||
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
|
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
|
||||||
|
notify_backup_run_completed(run)
|
||||||
return True
|
return True
|
||||||
if _running_rsync_process_missing(run=run, grace_seconds=grace_seconds):
|
if _running_rsync_process_missing(run=run, grace_seconds=grace_seconds):
|
||||||
result.update(
|
result.update(
|
||||||
@@ -301,6 +305,7 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_s
|
|||||||
run.rsync_exit_code = exit_code or 255
|
run.rsync_exit_code = exit_code or 255
|
||||||
run.result = result
|
run.result = result
|
||||||
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
|
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
|
||||||
|
notify_backup_run_completed(run)
|
||||||
return True
|
return True
|
||||||
if stale_worker:
|
if stale_worker:
|
||||||
result.update(
|
result.update(
|
||||||
@@ -318,6 +323,7 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_s
|
|||||||
run.ended_at = timezone.now()
|
run.ended_at = timezone.now()
|
||||||
run.result = result
|
run.result = result
|
||||||
run.save(update_fields=["status", "ended_at", "result"])
|
run.save(update_fields=["status", "ended_at", "result"])
|
||||||
|
notify_backup_run_completed(run)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -353,6 +359,7 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_s
|
|||||||
run.rsync_exit_code = exit_code
|
run.rsync_exit_code = exit_code
|
||||||
run.result = result
|
run.result = result
|
||||||
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
|
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
|
||||||
|
notify_backup_run_completed(run)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
9
src/pobsync_backend/context_processors.py
Normal file
9
src/pobsync_backend/context_processors.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
from .access import access_context
|
||||||
|
|
||||||
|
|
||||||
|
def pobsync_access(request: HttpRequest) -> dict[str, object]:
|
||||||
|
return access_context(request)
|
||||||
@@ -9,7 +9,7 @@ from tempfile import TemporaryDirectory
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from .models import GlobalConfig, HostConfig, ScheduleConfig, SshCredential
|
from .models import BackupRun, GlobalConfig, HostConfig, NotificationTarget, ScheduleConfig, SshCredential
|
||||||
from .scheduler import parse_cron_expr
|
from .scheduler import parse_cron_expr
|
||||||
|
|
||||||
|
|
||||||
@@ -153,6 +153,62 @@ class ManualBackupForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTargetForm(forms.ModelForm):
|
||||||
|
TERMINAL_STATUS_CHOICES = (
|
||||||
|
(BackupRun.Status.SUCCESS, BackupRun.Status.SUCCESS.label),
|
||||||
|
(BackupRun.Status.WARNING, BackupRun.Status.WARNING.label),
|
||||||
|
(BackupRun.Status.FAILED, BackupRun.Status.FAILED.label),
|
||||||
|
(BackupRun.Status.CANCELLED, BackupRun.Status.CANCELLED.label),
|
||||||
|
)
|
||||||
|
|
||||||
|
statuses = forms.MultipleChoiceField(
|
||||||
|
choices=TERMINAL_STATUS_CHOICES,
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
initial=[choice[0] for choice in TERMINAL_STATUS_CHOICES],
|
||||||
|
help_text="Send notifications for these terminal run statuses.",
|
||||||
|
)
|
||||||
|
email_to = forms.CharField(
|
||||||
|
widget=forms.Textarea,
|
||||||
|
required=False,
|
||||||
|
help_text="One recipient per line, or comma-separated.",
|
||||||
|
)
|
||||||
|
webhook_headers = forms.JSONField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 4}),
|
||||||
|
help_text='Optional JSON object with extra headers, for example {"Authorization": "Bearer ..."}.',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = NotificationTarget
|
||||||
|
fields = (
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"channel",
|
||||||
|
"statuses",
|
||||||
|
"email_to",
|
||||||
|
"webhook_url",
|
||||||
|
"webhook_headers",
|
||||||
|
"notes",
|
||||||
|
)
|
||||||
|
widgets = {
|
||||||
|
"notes": forms.Textarea,
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
channel = cleaned_data.get("channel")
|
||||||
|
if channel == NotificationTarget.Channel.EMAIL and not cleaned_data.get("email_to", "").strip():
|
||||||
|
self.add_error("email_to", "Email targets need at least one recipient.")
|
||||||
|
if channel == NotificationTarget.Channel.WEBHOOK and not cleaned_data.get("webhook_url"):
|
||||||
|
self.add_error("webhook_url", "Webhook targets need a URL.")
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def clean_email_to(self) -> str:
|
||||||
|
value = self.cleaned_data.get("email_to", "")
|
||||||
|
recipients = [line.strip() for line in value.replace(",", "\n").splitlines() if line.strip()]
|
||||||
|
return "\n".join(recipients)
|
||||||
|
|
||||||
|
|
||||||
class SshCredentialForm(forms.ModelForm):
|
class SshCredentialForm(forms.ModelForm):
|
||||||
private_key_file = forms.FileField(
|
private_key_file = forms.FileField(
|
||||||
required=False,
|
required=False,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
|
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Runtime state root")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run")
|
parser.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run")
|
||||||
parser.add_argument("--verbose-rsync", action="store_true", help="Write itemized rsync output to the run log")
|
parser.add_argument("--verbose-rsync", action="store_true", help="Write itemized rsync output to the run log")
|
||||||
|
parser.add_argument("--quiet-rsync", action="store_true", help="Skip default rsync progress output for real runs")
|
||||||
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")
|
||||||
@@ -32,6 +33,7 @@ class Command(BaseCommand):
|
|||||||
except HostConfig.DoesNotExist as exc:
|
except HostConfig.DoesNotExist as exc:
|
||||||
raise CommandError(f"Missing enabled host {host_name!r}") from exc
|
raise CommandError(f"Missing enabled host {host_name!r}") from exc
|
||||||
|
|
||||||
|
verbose_output = bool(options["dry_run"] or options["verbose_rsync"] or not options["quiet_rsync"])
|
||||||
run = BackupRun.objects.create(
|
run = BackupRun.objects.create(
|
||||||
host=host,
|
host=host,
|
||||||
run_type=BackupRun.RunType.MANUAL if options["manual"] else BackupRun.RunType.SCHEDULED,
|
run_type=BackupRun.RunType.MANUAL if options["manual"] else BackupRun.RunType.SCHEDULED,
|
||||||
@@ -39,7 +41,7 @@ class Command(BaseCommand):
|
|||||||
result={
|
result={
|
||||||
"requested": {
|
"requested": {
|
||||||
"dry_run": bool(options["dry_run"]),
|
"dry_run": bool(options["dry_run"]),
|
||||||
"verbose_output": bool(options["dry_run"] or options["verbose_rsync"]),
|
"verbose_output": verbose_output,
|
||||||
"prune": bool(options["prune"]),
|
"prune": bool(options["prune"]),
|
||||||
"prune_max_delete": int(options["prune_max_delete"]),
|
"prune_max_delete": int(options["prune_max_delete"]),
|
||||||
"prune_protect_bases": bool(options["prune_protect_bases"]),
|
"prune_protect_bases": bool(options["prune_protect_bases"]),
|
||||||
@@ -50,7 +52,7 @@ class Command(BaseCommand):
|
|||||||
run=run,
|
run=run,
|
||||||
prefix=paths.home,
|
prefix=paths.home,
|
||||||
dry_run=bool(options["dry_run"]),
|
dry_run=bool(options["dry_run"]),
|
||||||
verbose_output=bool(options["dry_run"] or options["verbose_rsync"]),
|
verbose_output=verbose_output,
|
||||||
prune=bool(options["prune"]),
|
prune=bool(options["prune"]),
|
||||||
prune_max_delete=int(options["prune_max_delete"]),
|
prune_max_delete=int(options["prune_max_delete"]),
|
||||||
prune_protect_bases=bool(options["prune_protect_bases"]),
|
prune_protect_bases=bool(options["prune_protect_bases"]),
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-05-28 19:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import pobsync_backend.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pobsync_backend', '0014_host_bwlimit_override'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NotificationTarget',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(max_length=128, unique=True)),
|
||||||
|
('enabled', models.BooleanField(default=True)),
|
||||||
|
('channel', models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook')], max_length=16)),
|
||||||
|
('statuses', models.JSONField(blank=True, default=pobsync_backend.models.default_notification_statuses)),
|
||||||
|
('email_to', models.TextField(blank=True)),
|
||||||
|
('webhook_url', models.URLField(blank=True, max_length=1024)),
|
||||||
|
('webhook_headers', models.JSONField(blank=True, default=dict)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('last_status', models.CharField(blank=True, max_length=16)),
|
||||||
|
('last_error', models.TextField(blank=True)),
|
||||||
|
('last_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NotificationDelivery',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('sent', 'Sent'), ('failed', 'Failed'), ('skipped', 'Skipped')], max_length=16)),
|
||||||
|
('error', models.TextField(blank=True)),
|
||||||
|
('payload', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_deliveries', to='pobsync_backend.backuprun')),
|
||||||
|
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='pobsync_backend.notificationtarget')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'notification deliveries',
|
||||||
|
'ordering': ['-created_at', 'target__name'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('target', 'run'), name='unique_notification_delivery_per_target_run')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -135,6 +135,63 @@ class BackupRun(models.Model):
|
|||||||
return f"{self.host} {self.run_type} {self.status}"
|
return f"{self.host} {self.run_type} {self.status}"
|
||||||
|
|
||||||
|
|
||||||
|
def default_notification_statuses() -> list[str]:
|
||||||
|
return [
|
||||||
|
BackupRun.Status.SUCCESS,
|
||||||
|
BackupRun.Status.WARNING,
|
||||||
|
BackupRun.Status.FAILED,
|
||||||
|
BackupRun.Status.CANCELLED,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTarget(TimestampedModel):
|
||||||
|
class Channel(models.TextChoices):
|
||||||
|
EMAIL = "email", "Email"
|
||||||
|
WEBHOOK = "webhook", "Webhook"
|
||||||
|
|
||||||
|
name = models.CharField(max_length=128, unique=True)
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||||
|
statuses = models.JSONField(default=default_notification_statuses, blank=True)
|
||||||
|
email_to = models.TextField(blank=True)
|
||||||
|
webhook_url = models.URLField(max_length=1024, blank=True)
|
||||||
|
webhook_headers = models.JSONField(default=dict, blank=True)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
last_status = models.CharField(max_length=16, blank=True)
|
||||||
|
last_error = models.TextField(blank=True)
|
||||||
|
last_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationDelivery(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
SENT = "sent", "Sent"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
SKIPPED = "skipped", "Skipped"
|
||||||
|
|
||||||
|
target = models.ForeignKey(NotificationTarget, on_delete=models.CASCADE, related_name="deliveries")
|
||||||
|
run = models.ForeignKey(BackupRun, on_delete=models.CASCADE, related_name="notification_deliveries")
|
||||||
|
status = models.CharField(max_length=16, choices=Status.choices)
|
||||||
|
error = models.TextField(blank=True)
|
||||||
|
payload = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["target", "run"], name="unique_notification_delivery_per_target_run"),
|
||||||
|
]
|
||||||
|
ordering = ["-created_at", "target__name"]
|
||||||
|
verbose_name_plural = "notification deliveries"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.target} run {self.run_id} {self.status}"
|
||||||
|
|
||||||
|
|
||||||
class SnapshotRecord(models.Model):
|
class SnapshotRecord(models.Model):
|
||||||
class Kind(models.TextChoices):
|
class Kind(models.TextChoices):
|
||||||
SCHEDULED = "scheduled", "Scheduled"
|
SCHEDULED = "scheduled", "Scheduled"
|
||||||
|
|||||||
168
src/pobsync_backend/notifications.py
Normal file
168
src/pobsync_backend/notifications.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import BackupRun, NotificationDelivery, NotificationTarget
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_RUN_STATUSES = {
|
||||||
|
BackupRun.Status.SUCCESS,
|
||||||
|
BackupRun.Status.WARNING,
|
||||||
|
BackupRun.Status.FAILED,
|
||||||
|
BackupRun.Status.CANCELLED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeliveryResult:
|
||||||
|
target: NotificationTarget
|
||||||
|
delivery: NotificationDelivery
|
||||||
|
sent: bool
|
||||||
|
|
||||||
|
|
||||||
|
def notify_backup_run_completed(run: BackupRun) -> list[DeliveryResult]:
|
||||||
|
if run.status not in TERMINAL_RUN_STATUSES:
|
||||||
|
return []
|
||||||
|
|
||||||
|
targets = [target for target in NotificationTarget.objects.filter(enabled=True) if _target_wants_status(target, run.status)]
|
||||||
|
return [_notify_target(target=target, run=run) for target in targets]
|
||||||
|
|
||||||
|
|
||||||
|
def _target_wants_status(target: NotificationTarget, status: str) -> bool:
|
||||||
|
statuses = target.statuses
|
||||||
|
if not isinstance(statuses, list):
|
||||||
|
return False
|
||||||
|
return status in {str(item) for item in statuses}
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_target(*, target: NotificationTarget, run: BackupRun) -> DeliveryResult:
|
||||||
|
payload = _run_payload(run)
|
||||||
|
delivery, created = NotificationDelivery.objects.get_or_create(
|
||||||
|
target=target,
|
||||||
|
run=run,
|
||||||
|
defaults={
|
||||||
|
"status": NotificationDelivery.Status.SKIPPED,
|
||||||
|
"payload": payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
return DeliveryResult(target=target, delivery=delivery, sent=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if target.channel == NotificationTarget.Channel.EMAIL:
|
||||||
|
_send_email(target=target, run=run, payload=payload)
|
||||||
|
elif target.channel == NotificationTarget.Channel.WEBHOOK:
|
||||||
|
_send_webhook(target=target, payload=payload)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported notification channel: {target.channel}")
|
||||||
|
except Exception as exc:
|
||||||
|
delivery.status = NotificationDelivery.Status.FAILED
|
||||||
|
delivery.error = str(exc)
|
||||||
|
delivery.save(update_fields=["status", "error"])
|
||||||
|
target.last_status = NotificationDelivery.Status.FAILED
|
||||||
|
target.last_error = str(exc)
|
||||||
|
target.save(update_fields=["last_status", "last_error", "updated_at"])
|
||||||
|
return DeliveryResult(target=target, delivery=delivery, sent=False)
|
||||||
|
|
||||||
|
delivery.status = NotificationDelivery.Status.SENT
|
||||||
|
delivery.save(update_fields=["status"])
|
||||||
|
target.last_status = NotificationDelivery.Status.SENT
|
||||||
|
target.last_error = ""
|
||||||
|
target.last_sent_at = timezone.now()
|
||||||
|
target.save(update_fields=["last_status", "last_error", "last_sent_at", "updated_at"])
|
||||||
|
return DeliveryResult(target=target, delivery=delivery, sent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_email(*, target: NotificationTarget, run: BackupRun, payload: dict[str, Any]) -> None:
|
||||||
|
recipients = [line.strip() for line in target.email_to.replace(",", "\n").splitlines() if line.strip()]
|
||||||
|
if not recipients:
|
||||||
|
raise ValueError("Email notification target has no recipients.")
|
||||||
|
|
||||||
|
subject = f"pobsync {run.status}: {run.host.host} run {run.id}"
|
||||||
|
message = _email_message(payload)
|
||||||
|
from_email = getattr(settings, "DEFAULT_FROM_EMAIL", "") or "pobsync@localhost"
|
||||||
|
sent = send_mail(subject, message, from_email, recipients, fail_silently=False)
|
||||||
|
if sent == 0:
|
||||||
|
raise ValueError("Django email backend reported zero sent messages.")
|
||||||
|
|
||||||
|
|
||||||
|
def _send_webhook(*, target: NotificationTarget, payload: dict[str, Any]) -> None:
|
||||||
|
if not target.webhook_url:
|
||||||
|
raise ValueError("Webhook notification target has no URL.")
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json", **_string_headers(target.webhook_headers)}
|
||||||
|
request = urllib.request.Request(
|
||||||
|
target.webhook_url,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers=headers,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
if response.status >= 400:
|
||||||
|
raise ValueError(f"Webhook returned HTTP {response.status}.")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise ValueError(f"Webhook returned HTTP {exc.code}.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _string_headers(headers: object) -> dict[str, str]:
|
||||||
|
if not isinstance(headers, dict):
|
||||||
|
return {}
|
||||||
|
return {str(key): str(value) for key, value in headers.items() if str(key).strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_payload(run: BackupRun) -> dict[str, Any]:
|
||||||
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
|
failure = result.get("failure") if isinstance(result.get("failure"), dict) else {}
|
||||||
|
prune = result.get("prune") if isinstance(result.get("prune"), dict) else {}
|
||||||
|
return {
|
||||||
|
"event": "backup_run.completed",
|
||||||
|
"run": {
|
||||||
|
"id": run.id,
|
||||||
|
"host": run.host.host,
|
||||||
|
"type": run.run_type,
|
||||||
|
"status": run.status,
|
||||||
|
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||||
|
"ended_at": run.ended_at.isoformat() if run.ended_at else None,
|
||||||
|
"snapshot": run.snapshot_path,
|
||||||
|
"rsync_exit_code": run.rsync_exit_code,
|
||||||
|
},
|
||||||
|
"failure": {
|
||||||
|
"category": failure.get("category"),
|
||||||
|
"message": failure.get("message") or result.get("error"),
|
||||||
|
"hint": failure.get("hint"),
|
||||||
|
},
|
||||||
|
"prune": {
|
||||||
|
"ok": prune.get("ok") if prune else None,
|
||||||
|
"error": prune.get("error") if prune else "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _email_message(payload: dict[str, Any]) -> str:
|
||||||
|
run = payload["run"]
|
||||||
|
lines = [
|
||||||
|
f"Host: {run['host']}",
|
||||||
|
f"Run: {run['id']}",
|
||||||
|
f"Type: {run['type']}",
|
||||||
|
f"Status: {run['status']}",
|
||||||
|
f"Started: {run['started_at'] or '-'}",
|
||||||
|
f"Ended: {run['ended_at'] or '-'}",
|
||||||
|
f"Snapshot: {run['snapshot'] or '-'}",
|
||||||
|
f"Rsync exit code: {run['rsync_exit_code'] if run['rsync_exit_code'] is not None else '-'}",
|
||||||
|
]
|
||||||
|
failure = payload.get("failure") if isinstance(payload.get("failure"), dict) else {}
|
||||||
|
if failure.get("message"):
|
||||||
|
lines.extend(["", f"Failure: {failure['message']}"])
|
||||||
|
prune = payload.get("prune") if isinstance(payload.get("prune"), dict) else {}
|
||||||
|
if prune.get("error"):
|
||||||
|
lines.extend(["", f"Retention: {prune['error']}"])
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -23,7 +23,7 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict
|
|||||||
host_config = _enabled_host_config(host)
|
host_config = _enabled_host_config(host)
|
||||||
retention = _retention_for_host(host_config)
|
retention = _retention_for_host(host_config)
|
||||||
snapshots = _snapshots_for_retention(host_config=host_config, kind=kind)
|
snapshots = _snapshots_for_retention(host_config=host_config, kind=kind)
|
||||||
incomplete_snapshots = _incomplete_snapshots_for_host(host_config)
|
incomplete_items = _incomplete_snapshot_items_for_host(host_config)
|
||||||
|
|
||||||
plan = build_retention_plan(
|
plan = build_retention_plan(
|
||||||
snapshots=snapshots,
|
snapshots=snapshots,
|
||||||
@@ -49,10 +49,9 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict
|
|||||||
"keep": sorted(keep),
|
"keep": sorted(keep),
|
||||||
"keep_items": [_snapshot_to_item(snapshot, reasons=reasons.get(snapshot.dirname, [])) for snapshot in keep_items],
|
"keep_items": [_snapshot_to_item(snapshot, reasons=reasons.get(snapshot.dirname, [])) for snapshot in keep_items],
|
||||||
"delete": [_snapshot_to_item(snapshot, reasons=["outside retention policy"]) for snapshot in delete],
|
"delete": [_snapshot_to_item(snapshot, reasons=["outside retention policy"]) for snapshot in delete],
|
||||||
"incomplete": [
|
"incomplete": incomplete_items,
|
||||||
_snapshot_to_item(snapshot, reasons=["incomplete snapshot; excluded from retention cleanup"])
|
"incomplete_reviewed_count": sum(1 for item in incomplete_items if item["reviewed"]),
|
||||||
for snapshot in incomplete_snapshots
|
"incomplete_unreviewed_count": sum(1 for item in incomplete_items if not item["reviewed"]),
|
||||||
],
|
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +163,15 @@ def run_incomplete_cleanup(
|
|||||||
|
|
||||||
def _do_cleanup() -> dict[str, Any]:
|
def _do_cleanup() -> dict[str, Any]:
|
||||||
host_config = _enabled_host_config(host)
|
host_config = _enabled_host_config(host)
|
||||||
|
unreviewed_count = _unreviewed_incomplete_count(host_config)
|
||||||
|
if unreviewed_count:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Refusing to delete {unreviewed_count} incomplete snapshot(s) that have not been reviewed."
|
||||||
|
)
|
||||||
|
|
||||||
incomplete_list = [
|
incomplete_list = [
|
||||||
_snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
|
_snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
|
||||||
for snapshot in _incomplete_snapshots_for_host(host_config)
|
for snapshot in _reviewed_incomplete_snapshots_for_host(host_config)
|
||||||
]
|
]
|
||||||
if max_delete == 0 and len(incomplete_list) > 0:
|
if max_delete == 0 and len(incomplete_list) > 0:
|
||||||
raise ConfigError("Incomplete cleanup blocked by --max-delete=0")
|
raise ConfigError("Incomplete cleanup blocked by --max-delete=0")
|
||||||
@@ -253,15 +258,39 @@ def _snapshots_for_retention(*, host_config: HostConfig, kind: str) -> list[Snap
|
|||||||
return [_snapshot_from_record(record) for record in records]
|
return [_snapshot_from_record(record) for record in records]
|
||||||
|
|
||||||
|
|
||||||
def _incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]:
|
def _incomplete_snapshot_items_for_host(host_config: HostConfig) -> list[dict[str, Any]]:
|
||||||
records = (
|
records = (
|
||||||
SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE)
|
SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||||
.select_related("base")
|
.select_related("base")
|
||||||
.order_by("-started_at", "dirname")
|
.order_by("-started_at", "dirname")
|
||||||
)
|
)
|
||||||
|
return [
|
||||||
|
_snapshot_record_to_item(record, reasons=["incomplete snapshot; excluded from retention cleanup"])
|
||||||
|
for record in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewed_incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]:
|
||||||
|
records = (
|
||||||
|
SnapshotRecord.objects.filter(
|
||||||
|
host=host_config,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
reviewed_at__isnull=False,
|
||||||
|
)
|
||||||
|
.select_related("base")
|
||||||
|
.order_by("-started_at", "dirname")
|
||||||
|
)
|
||||||
return [_snapshot_from_record(record) for record in records]
|
return [_snapshot_from_record(record) for record in records]
|
||||||
|
|
||||||
|
|
||||||
|
def _unreviewed_incomplete_count(host_config: HostConfig) -> int:
|
||||||
|
return SnapshotRecord.objects.filter(
|
||||||
|
host=host_config,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
reviewed_at__isnull=True,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_from_record(record: SnapshotRecord) -> Snapshot:
|
def _snapshot_from_record(record: SnapshotRecord) -> Snapshot:
|
||||||
return Snapshot(
|
return Snapshot(
|
||||||
kind=record.kind,
|
kind=record.kind,
|
||||||
@@ -301,6 +330,14 @@ def _snapshot_to_item(snapshot: Snapshot, *, reasons: list[str]) -> dict[str, An
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_record_to_item(record: SnapshotRecord, *, reasons: list[str]) -> dict[str, Any]:
|
||||||
|
item = _snapshot_to_item(_snapshot_from_record(record), reasons=reasons)
|
||||||
|
item["reviewed"] = record.reviewed_at is not None
|
||||||
|
item["reviewed_at"] = record.reviewed_at.isoformat() if record.reviewed_at else ""
|
||||||
|
item["reviewed_by"] = record.reviewed_by
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
|
def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
|
||||||
if path.name == "data" and path.parent.name == dirname:
|
if path.name == "data" and path.parent.name == dirname:
|
||||||
return path.parent
|
return path.parent
|
||||||
|
|||||||
@@ -120,12 +120,15 @@ def _backup_data_by_kind(host: HostConfig) -> dict[str, Any]:
|
|||||||
row = rows.setdefault(snapshot.kind, _empty_snapshot_data_row())
|
row = rows.setdefault(snapshot.kind, _empty_snapshot_data_row())
|
||||||
allocated = summary.get("allocated_size_bytes") or summary.get("apparent_size_bytes") or 0
|
allocated = summary.get("allocated_size_bytes") or summary.get("apparent_size_bytes") or 0
|
||||||
apparent = summary.get("apparent_size_bytes") or 0
|
apparent = summary.get("apparent_size_bytes") or 0
|
||||||
|
unique_apparent = summary.get("unique_apparent_size_bytes") or 0
|
||||||
row["count"] += 1
|
row["count"] += 1
|
||||||
row["allocated_size_bytes"] += int(allocated)
|
row["allocated_size_bytes"] += int(allocated)
|
||||||
row["apparent_size_bytes"] += int(apparent)
|
row["apparent_size_bytes"] += int(apparent)
|
||||||
|
row["unique_apparent_size_bytes"] += int(unique_apparent)
|
||||||
total["count"] += 1
|
total["count"] += 1
|
||||||
total["allocated_size_bytes"] += int(allocated)
|
total["allocated_size_bytes"] += int(allocated)
|
||||||
total["apparent_size_bytes"] += int(apparent)
|
total["apparent_size_bytes"] += int(apparent)
|
||||||
|
total["unique_apparent_size_bytes"] += int(unique_apparent)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"scheduled": rows[SnapshotRecord.Kind.SCHEDULED],
|
"scheduled": rows[SnapshotRecord.Kind.SCHEDULED],
|
||||||
@@ -140,6 +143,7 @@ def _empty_snapshot_data_row() -> dict[str, int]:
|
|||||||
"count": 0,
|
"count": 0,
|
||||||
"allocated_size_bytes": 0,
|
"allocated_size_bytes": 0,
|
||||||
"apparent_size_bytes": 0,
|
"apparent_size_bytes": 0,
|
||||||
|
"unique_apparent_size_bytes": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -157,6 +161,7 @@ def _sum_backup_data_by_kind(rows: Iterable[dict[str, dict[str, int]]]) -> dict[
|
|||||||
total_row["count"] += values.get("count", 0)
|
total_row["count"] += values.get("count", 0)
|
||||||
total_row["allocated_size_bytes"] += values.get("allocated_size_bytes", 0)
|
total_row["allocated_size_bytes"] += values.get("allocated_size_bytes", 0)
|
||||||
total_row["apparent_size_bytes"] += values.get("apparent_size_bytes", 0)
|
total_row["apparent_size_bytes"] += values.get("apparent_size_bytes", 0)
|
||||||
|
total_row["unique_apparent_size_bytes"] += values.get("unique_apparent_size_bytes", 0)
|
||||||
|
|
||||||
return total_rows
|
return total_rows
|
||||||
|
|
||||||
@@ -168,21 +173,28 @@ def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
|
|||||||
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
|
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
|
||||||
storage = stats.get("storage") if isinstance(stats.get("storage"), dict) else {}
|
storage = stats.get("storage") if isinstance(stats.get("storage"), dict) else {}
|
||||||
snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) else {}
|
snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) else {}
|
||||||
has_recorded_size = (
|
if snapshot.kind == SnapshotRecord.Kind.INCOMPLETE:
|
||||||
_int_at(snapshot_storage, "allocated_size_bytes") is not None
|
|
||||||
or _int_at(snapshot_storage, "apparent_size_bytes") is not None
|
|
||||||
)
|
|
||||||
if not has_recorded_size:
|
|
||||||
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
|
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
|
||||||
|
else:
|
||||||
|
has_recorded_size = (
|
||||||
|
_int_at(snapshot_storage, "allocated_size_bytes") is not None
|
||||||
|
or _int_at(snapshot_storage, "apparent_size_bytes") is not None
|
||||||
|
)
|
||||||
|
if not has_recorded_size:
|
||||||
|
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
|
||||||
|
apparent_size = _int_at(snapshot_storage, "apparent_size_bytes")
|
||||||
|
hardlinked_apparent = _int_at(snapshot_storage, "hardlinked_apparent_size_bytes") or 0
|
||||||
return {
|
return {
|
||||||
"id": snapshot.id,
|
"id": snapshot.id,
|
||||||
"dirname": snapshot.dirname,
|
"dirname": snapshot.dirname,
|
||||||
"kind": snapshot.kind,
|
"kind": snapshot.kind,
|
||||||
"status": snapshot.status,
|
"status": snapshot.status,
|
||||||
"started_at": snapshot.started_at,
|
"started_at": snapshot.started_at,
|
||||||
"apparent_size_bytes": _int_at(snapshot_storage, "apparent_size_bytes"),
|
"apparent_size_bytes": apparent_size,
|
||||||
"allocated_size_bytes": _int_at(snapshot_storage, "allocated_size_bytes"),
|
"allocated_size_bytes": _int_at(snapshot_storage, "allocated_size_bytes"),
|
||||||
"hardlinked_files": _int_at(snapshot_storage, "hardlinked_files"),
|
"hardlinked_files": _int_at(snapshot_storage, "hardlinked_files"),
|
||||||
|
"hardlinked_apparent_size_bytes": hardlinked_apparent,
|
||||||
|
"unique_apparent_size_bytes": max((apparent_size or 0) - hardlinked_apparent, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -275,6 +275,23 @@
|
|||||||
.status.skipped { color: var(--muted); background: #f7f9fb; }
|
.status.skipped { color: var(--muted); background: #f7f9fb; }
|
||||||
.stack { display: grid; gap: 5px; }
|
.stack { display: grid; gap: 5px; }
|
||||||
.stack.spaced { margin-bottom: 14px; }
|
.stack.spaced { margin-bottom: 14px; }
|
||||||
|
.detail-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px 14px;
|
||||||
|
grid-template-columns: minmax(120px, max-content) minmax(0, 1fr);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.detail-list dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.detail-list dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.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); }
|
||||||
.panel-grid {
|
.panel-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -919,16 +936,24 @@
|
|||||||
<span class="nav-primary" aria-label="Primary navigation">
|
<span class="nav-primary" aria-label="Primary navigation">
|
||||||
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
|
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
|
||||||
<a href="{% url 'hosts_list' %}" {% if request.resolver_match.url_name == "hosts_list" or request.resolver_match.url_name == "host_detail" or request.resolver_match.url_name == "create_host_config" or request.resolver_match.url_name == "edit_host_config" or request.resolver_match.url_name == "edit_host_schedule" %}aria-current="page"{% endif %}>Hosts</a>
|
<a href="{% url 'hosts_list' %}" {% if request.resolver_match.url_name == "hosts_list" or request.resolver_match.url_name == "host_detail" or request.resolver_match.url_name == "create_host_config" or request.resolver_match.url_name == "edit_host_config" or request.resolver_match.url_name == "edit_host_schedule" %}aria-current="page"{% endif %}>Hosts</a>
|
||||||
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
|
{% if can_manage_control_panel %}
|
||||||
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
|
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
|
||||||
|
<a href="{% url 'notification_targets' %}" {% if request.resolver_match.url_name == "notification_targets" or request.resolver_match.url_name == "create_notification_target" or request.resolver_match.url_name == "edit_notification_target" %}aria-current="page"{% endif %}>Notifications</a>
|
||||||
|
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
|
||||||
|
<a href="{% url 'updater' %}" {% if request.resolver_match.url_name == "updater" %}aria-current="page"{% endif %}>Updater</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
|
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="nav-secondary" aria-label="System navigation">
|
<span class="nav-secondary" aria-label="System navigation">
|
||||||
<a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a>
|
{% if can_manage_control_panel %}
|
||||||
|
<a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'changelog' %}" {% if request.resolver_match.url_name == "changelog" %}aria-current="page"{% endif %}>Changelog</a>
|
<a href="{% url 'changelog' %}" {% if request.resolver_match.url_name == "changelog" %}aria-current="page"{% endif %}>Changelog</a>
|
||||||
<a href="/api/status/">Status API</a>
|
<a href="/api/status/">Status API</a>
|
||||||
<a href="{% url 'admin:index' %}">Admin</a>
|
{% if can_manage_control_panel %}
|
||||||
|
<a href="{% url 'admin:index' %}">Admin</a>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="muted nav-user">{{ request.user.username }}</span>
|
<span class="muted nav-user">{{ request.user.username }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -9,27 +9,31 @@
|
|||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
|
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
|
||||||
</div>
|
</div>
|
||||||
<section class="actions" aria-label="Dashboard actions">
|
{% if can_manage_control_panel %}
|
||||||
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
|
<section class="actions" aria-label="Dashboard actions">
|
||||||
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
|
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
|
||||||
</section>
|
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if not global_config or not counts.hosts %}
|
{% if can_manage_control_panel %}
|
||||||
<section class="panel">
|
{% if not global_config or not counts.hosts %}
|
||||||
<h2>Setup</h2>
|
<section class="panel">
|
||||||
{% if not global_config %}
|
<h2>Setup</h2>
|
||||||
<p class="muted">No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.</p>
|
{% if not global_config %}
|
||||||
<div class="actions inline">
|
<p class="muted">No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.</p>
|
||||||
<a class="button-link" href="{% url 'edit_global_config' %}">Create global config</a>
|
<div class="actions inline">
|
||||||
</div>
|
<a class="button-link" href="{% url 'edit_global_config' %}">Create global config</a>
|
||||||
{% elif not counts.hosts %}
|
</div>
|
||||||
<p class="muted">Global config is ready. Add the first host to make this dashboard useful.</p>
|
{% elif not counts.hosts %}
|
||||||
<div class="actions inline">
|
<p class="muted">Global config is ready. Add the first host to make this dashboard useful.</p>
|
||||||
<a class="button-link" href="{% url 'create_host_config' %}">Add first host</a>
|
<div class="actions inline">
|
||||||
</div>
|
<a class="button-link" href="{% url 'create_host_config' %}">Add first host</a>
|
||||||
{% endif %}
|
</div>
|
||||||
</section>
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -27,10 +27,12 @@
|
|||||||
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete
|
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete
|
||||||
snapshots automatically; inspect them before cleanup.
|
snapshots automatically; inspect them before cleanup.
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
|
{% if can_manage_control_panel %}
|
||||||
{% csrf_token %}
|
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
|
||||||
<button type="submit" class="secondary">Mark incomplete reviewed</button>
|
{% csrf_token %}
|
||||||
</form>
|
<button type="submit" class="secondary">Mark incomplete reviewed</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if retention_warning.error %}
|
{% if retention_warning.error %}
|
||||||
<div>{{ retention_warning.error }}</div>
|
<div>{{ retention_warning.error }}</div>
|
||||||
@@ -80,8 +82,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel host-control-panel">
|
{% if can_manage_control_panel %}
|
||||||
<h2>Backup Control</h2>
|
<article class="panel host-control-panel">
|
||||||
|
<h2>Backup Control</h2>
|
||||||
<div class="operator-state">
|
<div class="operator-state">
|
||||||
{% if active_run %}
|
{% if active_run %}
|
||||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||||
@@ -105,6 +108,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="verbose_output" value="on">
|
||||||
<input type="hidden" name="prune_max_delete" value="10">
|
<input type="hidden" name="prune_max_delete" value="10">
|
||||||
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
|
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -120,10 +124,16 @@
|
|||||||
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
|
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<article class="panel host-control-panel">
|
<article class="panel host-control-panel">
|
||||||
<h2>Schedule <a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a></h2>
|
<h2>
|
||||||
|
Schedule
|
||||||
|
{% if can_manage_control_panel %}
|
||||||
|
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
{% if schedule %}
|
{% if schedule %}
|
||||||
<div class="host-control-meta">
|
<div class="host-control-meta">
|
||||||
<div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div>
|
<div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div>
|
||||||
@@ -135,7 +145,9 @@
|
|||||||
<p class="muted">Evaluated by the pobsync scheduler service.</p>
|
<p class="muted">Evaluated by the pobsync scheduler service.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">No schedule configured.</p>
|
<p class="muted">No schedule configured.</p>
|
||||||
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
|
{% if can_manage_control_panel %}
|
||||||
|
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -178,21 +190,28 @@
|
|||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Scheduled</div>
|
<div class="label">Scheduled</div>
|
||||||
<div class="value">{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="muted">unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Manual</div>
|
<div class="label">Manual</div>
|
||||||
<div class="value">{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="muted">unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Incomplete</div>
|
<div class="label">Incomplete</div>
|
||||||
<div class="value">{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="muted">measured from disk</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Total</div>
|
<div class="label">Total</div>
|
||||||
<div class="value">{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="muted">unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<p class="muted">Totals use the allocated snapshot size recorded in backup metadata, grouped by snapshot kind.</p>
|
<p class="muted">
|
||||||
|
Main totals use allocated snapshot size. Unique values estimate non-hardlinked visible data; incomplete
|
||||||
|
snapshots are measured from disk because their metadata can be stale.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if stats_summary.runs %}
|
{% if stats_summary.runs %}
|
||||||
@@ -244,8 +263,9 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="panel">
|
{% if can_manage_control_panel %}
|
||||||
<h2>Host Check</h2>
|
<section class="panel">
|
||||||
|
<h2>Host Check</h2>
|
||||||
<section class="grid" aria-label="Host check summary">
|
<section class="grid" aria-label="Host check summary">
|
||||||
<div class="metric"><div class="label">OK</div><div class="value">{{ host_check_summary.ok }}</div></div>
|
<div class="metric"><div class="label">OK</div><div class="value">{{ host_check_summary.ok }}</div></div>
|
||||||
<div class="metric"><div class="label">Warnings</div><div class="value">{{ host_check_summary.warning }}</div></div>
|
<div class="metric"><div class="label">Warnings</div><div class="value">{{ host_check_summary.warning }}</div></div>
|
||||||
@@ -271,26 +291,32 @@
|
|||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="panel-grid">
|
<div class="panel-grid">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Configuration</h2>
|
<h2>Configuration</h2>
|
||||||
<div class="host-control-meta">
|
<div class="host-control-meta">
|
||||||
<div><span class="label">Address</span><strong>{{ host.address }}</strong></div>
|
<div><span class="label">Address</span><strong>{{ host.address }}</strong></div>
|
||||||
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
|
{% if can_manage_control_panel %}
|
||||||
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
|
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
|
||||||
|
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
|
||||||
|
{% endif %}
|
||||||
<div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div>
|
<div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div>
|
||||||
<div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div>
|
<div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions inline">
|
{% if can_manage_control_panel %}
|
||||||
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
<div class="actions inline">
|
||||||
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
||||||
</div>
|
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
{% if can_manage_control_panel %}
|
||||||
<h2>Connection Preflight & SSH</h2>
|
<section class="panel">
|
||||||
|
<h2>Connection Preflight & SSH</h2>
|
||||||
{% if last_preflight %}
|
{% if last_preflight %}
|
||||||
<div class="host-control-meta">
|
<div class="host-control-meta">
|
||||||
<div>
|
<div>
|
||||||
@@ -333,7 +359,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Snapshot Storage</h2>
|
<h2>Snapshot Storage</h2>
|
||||||
@@ -352,16 +379,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions inline">
|
{% if can_manage_control_panel %}
|
||||||
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
<div class="actions inline">
|
||||||
{% csrf_token %}
|
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||||
<button type="submit" class="secondary compact">Discover snapshots</button>
|
{% csrf_token %}
|
||||||
</form>
|
<button type="submit" class="secondary compact">Discover snapshots</button>
|
||||||
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
</form>
|
||||||
{% csrf_token %}
|
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
||||||
<button type="submit" class="secondary compact">Prepare directories</button>
|
{% csrf_token %}
|
||||||
</form>
|
<button type="submit" class="secondary compact">Prepare directories</button>
|
||||||
</div>
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,8 +472,9 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="panel">
|
{% if can_manage_control_panel %}
|
||||||
<h2>Backup Options</h2>
|
<section class="panel">
|
||||||
|
<h2>Backup Options</h2>
|
||||||
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
|
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
|
||||||
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
|
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -463,7 +493,8 @@
|
|||||||
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
|
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
|
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
<h1>Hosts</h1>
|
<h1>Hosts</h1>
|
||||||
<div class="page-subtitle">Configured backup targets, schedules, retention state, and host-level controls.</div>
|
<div class="page-subtitle">Configured backup targets, schedules, retention state, and host-level controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<section class="actions" aria-label="Host actions">
|
{% if can_manage_control_panel %}
|
||||||
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
|
<section class="actions" aria-label="Host actions">
|
||||||
</section>
|
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="grid dashboard-summary-grid" aria-label="Host summary">
|
<section class="grid dashboard-summary-grid" aria-label="Host summary">
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }} | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<div class="page-kicker">Reports</div>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<div class="page-subtitle">Choose which completed backup statuses should trigger an email or webhook report.</div>
|
||||||
|
</div>
|
||||||
|
<section class="actions" aria-label="Notification target form actions">
|
||||||
|
<a class="button-link" href="{% url 'notification_targets' %}">Back to notifications</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>{% if target %}Edit Target{% else %}Create Target{% endif %}</h2>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
|
||||||
|
{% for field in 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="form-actions">
|
||||||
|
<button type="submit">{{ submit_label }}</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'notification_targets' %}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Notifications | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<div class="page-kicker">Reports</div>
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<div class="page-subtitle">Send email or webhook reports when backup runs finish.</div>
|
||||||
|
</div>
|
||||||
|
<section class="actions" aria-label="Notification actions">
|
||||||
|
<a class="button-link" href="{% url 'create_notification_target' %}">New target</a>
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Targets</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Events</th>
|
||||||
|
<th>Destination</th>
|
||||||
|
<th>Last delivery</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for target in targets %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url 'edit_notification_target' target.id %}">{{ target.name }}</a></td>
|
||||||
|
<td>{{ target.get_channel_display }}</td>
|
||||||
|
<td><span class="status {% if target.enabled %}ok{% else %}skipped{% endif %}">{{ target.enabled|yesno:"enabled,disabled" }}</span></td>
|
||||||
|
<td>{{ target.statuses|join:", " }}</td>
|
||||||
|
<td>
|
||||||
|
{% if target.channel == "email" %}
|
||||||
|
{{ target.email_to|linebreaksbr }}
|
||||||
|
{% else %}
|
||||||
|
<code>{{ target.webhook_url|truncatechars:70 }}</code>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if target.last_status %}
|
||||||
|
<span class="status {{ target.last_status }}">{{ target.last_status }}</span>
|
||||||
|
{% if target.last_error %}<div class="muted">{{ target.last_error|truncatechars:90 }}</div>{% endif %}
|
||||||
|
{% if target.last_sent_at %}<div class="muted">{{ target.last_sent_at }}</div>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">none</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><a class="button-link secondary" href="{% url 'edit_notification_target' target.id %}">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="7" class="muted">No notification targets configured yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Recent Deliveries</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Run</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for delivery in deliveries %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ delivery.target.name }}</td>
|
||||||
|
<td><a href="{% url 'run_detail' delivery.run.id %}">Run {{ delivery.run.id }}</a> {{ delivery.run.host.host }}</td>
|
||||||
|
<td><span class="status {{ delivery.status }}">{{ delivery.status }}</span></td>
|
||||||
|
<td>{{ delivery.created_at }}</td>
|
||||||
|
<td class="muted">{{ delivery.error|default:"" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="muted">No notification deliveries recorded yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -105,18 +105,22 @@
|
|||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Scheduled data</div>
|
<div class="label">Scheduled data</div>
|
||||||
<div class="value">{{ host.stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ host.stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="muted">unique {{ host.stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Manual data</div>
|
<div class="label">Manual data</div>
|
||||||
<div class="value">{{ host.stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ host.stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="muted">unique {{ host.stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Incomplete data</div>
|
<div class="label">Incomplete data</div>
|
||||||
<div class="value">{{ host.stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ host.stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="muted">measured from disk</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Total data</div>
|
<div class="label">Total data</div>
|
||||||
<div class="value">{{ host.stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ host.stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="muted">unique {{ host.stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,18 +130,22 @@
|
|||||||
<div>
|
<div>
|
||||||
<span class="label">Scheduled data</span>
|
<span class="label">Scheduled data</span>
|
||||||
<strong>{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</strong>
|
<strong>{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
|
<span class="muted">unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Manual data</span>
|
<span class="label">Manual data</span>
|
||||||
<strong>{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</strong>
|
<strong>{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
|
<span class="muted">unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Incomplete data</span>
|
<span class="label">Incomplete data</span>
|
||||||
<strong>{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</strong>
|
<strong>{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
|
<span class="muted">measured from disk</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Total snapshot data</span>
|
<span class="label">Total snapshot data</span>
|
||||||
<strong>{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</strong>
|
<strong>{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
|
<span class="muted">unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if run.status == "failed" or run.status == "warning" %}
|
{% if can_manage_control_panel and run.status == "failed" or can_manage_control_panel and run.status == "warning" %}
|
||||||
{% if not run.reviewed_at %}
|
{% if not run.reviewed_at %}
|
||||||
<section class="panel highlight warning">
|
<section class="panel highlight warning">
|
||||||
<h2>Review Required</h2>
|
<h2>Review Required</h2>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<strong>Log:</strong>
|
<strong>Log:</strong>
|
||||||
{% if dry_run_summary.log_available %}
|
{% if dry_run_summary.log_available and can_manage_control_panel %}
|
||||||
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
||||||
{% elif rsync_log_path %}
|
{% elif rsync_log_path %}
|
||||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
{% if live_progress.log.path %}
|
{% if live_progress.log.path %}
|
||||||
<div>
|
<div>
|
||||||
<strong>Log:</strong>
|
<strong>Log:</strong>
|
||||||
{% if live_progress.log.exists %}
|
{% if live_progress.log.exists and can_manage_control_panel %}
|
||||||
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted">{{ live_progress.log.path }} (missing)</span>
|
<span class="muted">{{ live_progress.log.path }} (missing)</span>
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
|
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Rsync log:</strong>
|
<strong>Rsync log:</strong>
|
||||||
{% if rsync_log_exists %}
|
{% if rsync_log_exists and can_manage_control_panel %}
|
||||||
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
|
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
|
||||||
{% elif rsync_log_path %}
|
{% elif rsync_log_path %}
|
||||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Rsync Log</h2>
|
<h2>Rsync Log</h2>
|
||||||
<div class="stack spaced">
|
<div class="stack spaced">
|
||||||
{% if rsync_log_exists %}
|
{% if rsync_log_exists and can_manage_control_panel %}
|
||||||
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
|
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
|
||||||
<div class="muted">{{ rsync_log_path }}</div>
|
<div class="muted">{{ rsync_log_path }}</div>
|
||||||
{% elif rsync_log_path %}
|
{% elif rsync_log_path %}
|
||||||
|
|||||||
@@ -45,8 +45,9 @@
|
|||||||
snapshots automatically because they can indicate an interrupted backup that should be inspected first.
|
snapshots automatically because they can indicate an interrupted backup that should be inspected first.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
After inspection, use the dedicated cleanup form below to delete only incomplete snapshot directories and their
|
{{ incomplete_unreviewed_count }} still need review. After inspection, mark them reviewed and use the dedicated
|
||||||
tracking records. Successful scheduled and manual snapshots are not touched by this cleanup.
|
cleanup form below to delete only incomplete snapshot directories and their tracking records. Successful
|
||||||
|
scheduled and manual snapshots are not touched by this cleanup.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -187,6 +188,7 @@
|
|||||||
<th>Dirname</th>
|
<th>Dirname</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Review</th>
|
||||||
<th>Reason</th>
|
<th>Reason</th>
|
||||||
<th>Path</th>
|
<th>Path</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -197,6 +199,14 @@
|
|||||||
<td>{{ snapshot.dirname }}</td>
|
<td>{{ snapshot.dirname }}</td>
|
||||||
<td>{{ snapshot.dt }}</td>
|
<td>{{ snapshot.dt }}</td>
|
||||||
<td>{{ snapshot.status|default:"" }}</td>
|
<td>{{ snapshot.status|default:"" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if snapshot.reviewed %}
|
||||||
|
<span class="status ok">reviewed</span>
|
||||||
|
<span class="muted">{{ snapshot.reviewed_by|default:"unknown" }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status warning">needs review</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ snapshot.reason }}</td>
|
<td>{{ snapshot.reason }}</td>
|
||||||
<td class="muted">{{ snapshot.path }}</td>
|
<td class="muted">{{ snapshot.path }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -205,40 +215,51 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>Cleanup Incomplete Snapshots</h3>
|
<h3>Cleanup Incomplete Snapshots</h3>
|
||||||
<p class="muted">
|
{% if incomplete_unreviewed_count %}
|
||||||
This deletes only incomplete snapshot directories and their tracking records. Successful manual and scheduled
|
<p class="muted">
|
||||||
snapshots are not touched.
|
Cleanup is blocked until all incomplete snapshots are reviewed. This extra step makes it explicit that the
|
||||||
</p>
|
interrupted backup was inspected before deletion.
|
||||||
<form method="post" action="{% url 'cleanup_host_incomplete_snapshots' host.host %}" class="form-grid">
|
</p>
|
||||||
{% csrf_token %}
|
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}" class="actions inline">
|
||||||
{{ incomplete_cleanup_form.non_field_errors }}
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary">Mark incomplete snapshots reviewed</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">
|
||||||
|
This deletes only reviewed incomplete snapshot directories and their tracking records. Successful manual and
|
||||||
|
scheduled snapshots are not touched.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{% url 'cleanup_host_incomplete_snapshots' host.host %}" class="form-grid">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ incomplete_cleanup_form.non_field_errors }}
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{{ incomplete_cleanup_form.max_delete.errors }}
|
{{ incomplete_cleanup_form.max_delete.errors }}
|
||||||
<label for="{{ incomplete_cleanup_form.max_delete.id_for_label }}">Max delete</label>
|
<label for="{{ incomplete_cleanup_form.max_delete.id_for_label }}">Max delete</label>
|
||||||
{{ incomplete_cleanup_form.max_delete }}
|
{{ incomplete_cleanup_form.max_delete }}
|
||||||
<div class="helptext">{{ incomplete_cleanup_form.max_delete.help_text }}</div>
|
<div class="helptext">{{ incomplete_cleanup_form.max_delete.help_text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{{ incomplete_cleanup_form.confirm_host.errors }}
|
{{ incomplete_cleanup_form.confirm_host.errors }}
|
||||||
<label for="{{ incomplete_cleanup_form.confirm_host.id_for_label }}">Confirm host</label>
|
<label for="{{ incomplete_cleanup_form.confirm_host.id_for_label }}">Confirm host</label>
|
||||||
{{ incomplete_cleanup_form.confirm_host }}
|
{{ incomplete_cleanup_form.confirm_host }}
|
||||||
<div class="helptext">{{ incomplete_cleanup_form.confirm_host.help_text }}</div>
|
<div class="helptext">{{ incomplete_cleanup_form.confirm_host.help_text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{{ incomplete_cleanup_form.confirm_delete_count.errors }}
|
{{ incomplete_cleanup_form.confirm_delete_count.errors }}
|
||||||
<label for="{{ incomplete_cleanup_form.confirm_delete_count.id_for_label }}">Confirm incomplete count</label>
|
<label for="{{ incomplete_cleanup_form.confirm_delete_count.id_for_label }}">Confirm incomplete count</label>
|
||||||
{{ incomplete_cleanup_form.confirm_delete_count }}
|
{{ incomplete_cleanup_form.confirm_delete_count }}
|
||||||
<div class="helptext">{{ incomplete_cleanup_form.confirm_delete_count.help_text }}</div>
|
<div class="helptext">{{ incomplete_cleanup_form.confirm_delete_count.help_text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="danger">Delete incomplete snapshots</button>
|
<button type="submit" class="danger">Delete incomplete snapshots</button>
|
||||||
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
|
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
122
src/pobsync_backend/templates/pobsync_backend/updater.html
Normal file
122
src/pobsync_backend/templates/pobsync_backend/updater.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Updater | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<div class="page-kicker">Operations</div>
|
||||||
|
<h1>Updater</h1>
|
||||||
|
<div class="page-subtitle">Check Gitea releases, pull the installed git checkout, and run the native systemd updater.</div>
|
||||||
|
</div>
|
||||||
|
<section class="actions" aria-label="Updater actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel-grid">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Installed App</h2>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<dt>Version</dt>
|
||||||
|
<dd>{{ status.installed_version }}</dd>
|
||||||
|
<dt>Git branch</dt>
|
||||||
|
<dd>{{ status.git.branch|default:"unknown" }}</dd>
|
||||||
|
<dt>Git commit</dt>
|
||||||
|
<dd>{{ status.git.commit|default:"unknown" }}</dd>
|
||||||
|
<dt>Git describe</dt>
|
||||||
|
<dd>{{ status.git.describe|default:"unknown" }}</dd>
|
||||||
|
<dt>App directory</dt>
|
||||||
|
<dd>{{ status.app_dir }}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Release Check</h2>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>
|
||||||
|
{% if status.update_available == True %}
|
||||||
|
<span class="status warning">update available</span>
|
||||||
|
{% elif status.update_available == False %}
|
||||||
|
<span class="status ok">up to date</span>
|
||||||
|
{% elif status.release_check_configured %}
|
||||||
|
<span class="status skipped">not checked</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status skipped">not configured</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>Latest release</dt>
|
||||||
|
<dd>
|
||||||
|
{% if status.latest_release %}
|
||||||
|
{% if status.latest_release.html_url %}
|
||||||
|
<a href="{{ status.latest_release.html_url }}">
|
||||||
|
{% if status.latest_release.tag_name %}{{ status.latest_release.tag_name }}{% else %}{{ status.latest_release.name }}{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{% if status.latest_release.tag_name %}{{ status.latest_release.tag_name }}{% else %}{{ status.latest_release.name }}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
none
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>Release endpoint</dt>
|
||||||
|
<dd>{% if status.release_check_configured %}configured{% else %}set POBSYNC_UPDATE_RELEASES_URL{% endif %}</dd>
|
||||||
|
</dl>
|
||||||
|
{% if status.release_error %}
|
||||||
|
<p class="status failed">{{ status.release_error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" class="actions inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="action" value="check_release">Check releases</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Update Actions</h2>
|
||||||
|
<p class="muted">Run these from the installed checkout. The native updater may require a sudoers rule for the pobsync service user.</p>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<dt>Git remote</dt>
|
||||||
|
<dd>{{ status.git_remote }}</dd>
|
||||||
|
<dt>Update command</dt>
|
||||||
|
<dd><code>{{ status.update_command }}</code></dd>
|
||||||
|
</dl>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" class="inline-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="secondary" type="submit" name="action" value="git_fetch">Fetch releases</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" class="inline-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="secondary" type="submit" name="action" value="git_pull">Pull current branch</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" class="inline-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="action" value="run_update">Run native updater</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if action_result %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Last Action Result</h2>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd><span class="status {% if action_result.ok %}ok{% else %}failed{% endif %}">{% if action_result.ok %}ok{% else %}failed{% endif %}</span></dd>
|
||||||
|
<dt>Exit code</dt>
|
||||||
|
<dd>{{ action_result.exit_code }}</dd>
|
||||||
|
<dt>Command</dt>
|
||||||
|
<dd><code>{{ action_result.command|join:" " }}</code></dd>
|
||||||
|
</dl>
|
||||||
|
{% if action_result.stdout %}
|
||||||
|
<h3>Stdout</h3>
|
||||||
|
<pre>{{ action_result.stdout }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
{% if action_result.stderr %}
|
||||||
|
<h3>Stderr</h3>
|
||||||
|
<pre>{{ action_result.stderr }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -18,6 +18,12 @@ class ApiTests(TestCase):
|
|||||||
is_staff=True,
|
is_staff=True,
|
||||||
is_superuser=True,
|
is_superuser=True,
|
||||||
)
|
)
|
||||||
|
self.readonly_user = user_model.objects.create_user(
|
||||||
|
username="viewer",
|
||||||
|
password="secret",
|
||||||
|
is_staff=False,
|
||||||
|
is_superuser=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_api_requires_staff_login(self) -> None:
|
def test_api_requires_staff_login(self) -> None:
|
||||||
response = self.client.get("/api/hosts/")
|
response = self.client.get("/api/hosts/")
|
||||||
@@ -25,6 +31,15 @@ class ApiTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn("/admin/login/", response["Location"])
|
self.assertIn("/admin/login/", response["Location"])
|
||||||
|
|
||||||
|
def test_readonly_user_can_access_status_endpoint_only(self) -> None:
|
||||||
|
self.client.force_login(self.readonly_user)
|
||||||
|
|
||||||
|
status_response = self.client.get("/api/status/")
|
||||||
|
hosts_response = self.client.get("/api/hosts/")
|
||||||
|
|
||||||
|
self.assertEqual(status_response.status_code, 200)
|
||||||
|
self.assertEqual(hosts_response.status_code, 403)
|
||||||
|
|
||||||
def test_hosts_endpoint_returns_counts_and_schedule(self) -> None:
|
def test_hosts_endpoint_returns_counts_and_schedule(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")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.utils import timezone
|
|||||||
from pobsync.util import write_yaml_atomic
|
from pobsync.util import write_yaml_atomic
|
||||||
from pobsync_backend.backup_runner import queue_backup_run, reconcile_running_runs
|
from pobsync_backend.backup_runner import queue_backup_run, reconcile_running_runs
|
||||||
from pobsync_backend.management.commands.run_pobsync_worker import Command
|
from pobsync_backend.management.commands.run_pobsync_worker import Command
|
||||||
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
|
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, NotificationDelivery, NotificationTarget, SnapshotRecord
|
||||||
|
|
||||||
|
|
||||||
class BackupWorkerTests(TestCase):
|
class BackupWorkerTests(TestCase):
|
||||||
@@ -39,13 +39,20 @@ class BackupWorkerTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_queue_backup_run_can_request_verbose_output(self) -> None:
|
def test_queue_backup_run_enables_verbose_output_by_default(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
run = queue_backup_run(host=host, verbose_output=True)
|
run = queue_backup_run(host=host)
|
||||||
|
|
||||||
self.assertTrue(run.result["requested"]["verbose_output"])
|
self.assertTrue(run.result["requested"]["verbose_output"])
|
||||||
|
|
||||||
|
def test_queue_backup_run_can_disable_verbose_output(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
run = queue_backup_run(host=host, verbose_output=False)
|
||||||
|
|
||||||
|
self.assertFalse(run.result["requested"]["verbose_output"])
|
||||||
|
|
||||||
def test_worker_executes_next_queued_run(self) -> None:
|
def test_worker_executes_next_queued_run(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
backup_root = Path(tmp) / "backups"
|
backup_root = Path(tmp) / "backups"
|
||||||
@@ -116,6 +123,42 @@ class BackupWorkerTests(TestCase):
|
|||||||
self.assertEqual(run.rsync_exit_code, 24)
|
self.assertEqual(run.rsync_exit_code, 24)
|
||||||
self.assertEqual(run.result["warning"]["category"], "vanished")
|
self.assertEqual(run.result["warning"]["category"], "vanished")
|
||||||
|
|
||||||
|
def test_worker_sends_notification_after_completed_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")
|
||||||
|
NotificationTarget.objects.create(
|
||||||
|
name="ops",
|
||||||
|
channel=NotificationTarget.Channel.EMAIL,
|
||||||
|
email_to="ops@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,
|
||||||
|
patch("pobsync_backend.notifications.send_mail", return_value=1) as send_mail,
|
||||||
|
):
|
||||||
|
run_scheduled.return_value = {
|
||||||
|
"ok": True,
|
||||||
|
"dry_run": False,
|
||||||
|
"host": host.host,
|
||||||
|
"snapshot": str(snapshot_dir),
|
||||||
|
"base": None,
|
||||||
|
"rsync": {"exit_code": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
Command()._run_once(prefix=Path(tmp) / "home")
|
||||||
|
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertEqual(run.status, BackupRun.Status.SUCCESS)
|
||||||
|
self.assertEqual(NotificationDelivery.objects.get(run=run).status, NotificationDelivery.Status.SENT)
|
||||||
|
send_mail.assert_called_once()
|
||||||
|
|
||||||
def test_worker_refreshes_heartbeat_while_run_is_active(self) -> None:
|
def test_worker_refreshes_heartbeat_while_run_is_active(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from pobsync import __version__
|
||||||
from pobsync.cli import main
|
from pobsync.cli import main
|
||||||
|
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ class ConsoleEntrypointTests(SimpleTestCase):
|
|||||||
exit_code = main(["--version"])
|
exit_code = main(["--version"])
|
||||||
|
|
||||||
self.assertEqual(exit_code, 0)
|
self.assertEqual(exit_code, 0)
|
||||||
self.assertEqual(stdout.getvalue().strip(), "pobsync 1.1.0")
|
self.assertEqual(stdout.getvalue().strip(), f"pobsync {__version__}")
|
||||||
|
|
||||||
def test_maps_backup_alias_to_django_command(self) -> None:
|
def test_maps_backup_alias_to_django_command(self) -> None:
|
||||||
with patch("pobsync.cli.execute_from_command_line") as execute:
|
with patch("pobsync.cli.execute_from_command_line") as execute:
|
||||||
|
|||||||
125
src/pobsync_backend/tests/test_notifications.py
Normal file
125
src/pobsync_backend/tests/test_notifications.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from pobsync_backend.models import BackupRun, HostConfig, NotificationDelivery, NotificationTarget
|
||||||
|
from pobsync_backend.notifications import notify_backup_run_completed
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTests(TestCase):
|
||||||
|
def test_email_notification_is_sent_for_matching_status(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
target = NotificationTarget.objects.create(
|
||||||
|
name="ops",
|
||||||
|
channel=NotificationTarget.Channel.EMAIL,
|
||||||
|
statuses=[BackupRun.Status.FAILED],
|
||||||
|
email_to="ops@example.test",
|
||||||
|
)
|
||||||
|
run = BackupRun.objects.create(
|
||||||
|
host=host,
|
||||||
|
status=BackupRun.Status.FAILED,
|
||||||
|
run_type=BackupRun.RunType.MANUAL,
|
||||||
|
started_at=timezone.now() - timedelta(minutes=5),
|
||||||
|
ended_at=timezone.now(),
|
||||||
|
rsync_exit_code=12,
|
||||||
|
result={"failure": {"message": "rsync failed"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.notifications.send_mail", return_value=1) as send_mail:
|
||||||
|
results = notify_backup_run_completed(run)
|
||||||
|
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertTrue(results[0].sent)
|
||||||
|
send_mail.assert_called_once()
|
||||||
|
subject, message, _from_email, recipients = send_mail.call_args.args
|
||||||
|
self.assertEqual(subject, f"pobsync failed: web-01 run {run.id}")
|
||||||
|
self.assertIn("Failure: rsync failed", message)
|
||||||
|
self.assertEqual(recipients, ["ops@example.test"])
|
||||||
|
delivery = NotificationDelivery.objects.get(target=target, run=run)
|
||||||
|
self.assertEqual(delivery.status, NotificationDelivery.Status.SENT)
|
||||||
|
target.refresh_from_db()
|
||||||
|
self.assertEqual(target.last_status, NotificationDelivery.Status.SENT)
|
||||||
|
self.assertEqual(target.last_error, "")
|
||||||
|
self.assertIsNotNone(target.last_sent_at)
|
||||||
|
|
||||||
|
def test_webhook_notification_posts_payload(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
target = NotificationTarget.objects.create(
|
||||||
|
name="discord",
|
||||||
|
channel=NotificationTarget.Channel.WEBHOOK,
|
||||||
|
webhook_url="https://hooks.example.test/pobsync",
|
||||||
|
webhook_headers={"X-Token": "secret"},
|
||||||
|
)
|
||||||
|
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, run_type=BackupRun.RunType.SCHEDULED)
|
||||||
|
response = Mock()
|
||||||
|
response.status = 204
|
||||||
|
response.__enter__ = Mock(return_value=response)
|
||||||
|
response.__exit__ = Mock(return_value=False)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.notifications.urllib.request.urlopen", return_value=response) as urlopen:
|
||||||
|
notify_backup_run_completed(run)
|
||||||
|
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(request.full_url, "https://hooks.example.test/pobsync")
|
||||||
|
self.assertEqual(request.get_method(), "POST")
|
||||||
|
self.assertEqual(request.headers["X-token"], "secret")
|
||||||
|
self.assertIn(f'"id": {run.id}', request.data.decode("utf-8"))
|
||||||
|
self.assertEqual(NotificationDelivery.objects.get(target=target, run=run).status, NotificationDelivery.Status.SENT)
|
||||||
|
|
||||||
|
def test_notification_filters_statuses(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
NotificationTarget.objects.create(
|
||||||
|
name="failures-only",
|
||||||
|
channel=NotificationTarget.Channel.EMAIL,
|
||||||
|
statuses=[BackupRun.Status.FAILED],
|
||||||
|
email_to="ops@example.test",
|
||||||
|
)
|
||||||
|
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.notifications.send_mail") as send_mail:
|
||||||
|
results = notify_backup_run_completed(run)
|
||||||
|
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
send_mail.assert_not_called()
|
||||||
|
self.assertEqual(NotificationDelivery.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_notification_delivery_is_idempotent_per_run_and_target(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
target = NotificationTarget.objects.create(
|
||||||
|
name="ops",
|
||||||
|
channel=NotificationTarget.Channel.EMAIL,
|
||||||
|
email_to="ops@example.test",
|
||||||
|
)
|
||||||
|
run = BackupRun.objects.create(host=host, status=BackupRun.Status.WARNING)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.notifications.send_mail", return_value=1) as send_mail:
|
||||||
|
notify_backup_run_completed(run)
|
||||||
|
notify_backup_run_completed(run)
|
||||||
|
|
||||||
|
self.assertEqual(NotificationDelivery.objects.filter(target=target, run=run).count(), 1)
|
||||||
|
send_mail.assert_called_once()
|
||||||
|
|
||||||
|
def test_failed_delivery_is_recorded_without_raising(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
target = NotificationTarget.objects.create(
|
||||||
|
name="broken",
|
||||||
|
channel=NotificationTarget.Channel.EMAIL,
|
||||||
|
email_to="ops@example.test",
|
||||||
|
)
|
||||||
|
run = BackupRun.objects.create(host=host, status=BackupRun.Status.FAILED)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.notifications.send_mail", side_effect=RuntimeError("smtp down")):
|
||||||
|
results = notify_backup_run_completed(run)
|
||||||
|
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertFalse(results[0].sent)
|
||||||
|
delivery = NotificationDelivery.objects.get(target=target, run=run)
|
||||||
|
self.assertEqual(delivery.status, NotificationDelivery.Status.FAILED)
|
||||||
|
self.assertEqual(delivery.error, "smtp down")
|
||||||
|
target.refresh_from_db()
|
||||||
|
self.assertEqual(target.last_status, NotificationDelivery.Status.FAILED)
|
||||||
|
self.assertEqual(target.last_error, "smtp down")
|
||||||
@@ -39,12 +39,16 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
"host": host.host,
|
"host": host.host,
|
||||||
"snapshot": str(snapshot_dir),
|
"snapshot": str(snapshot_dir),
|
||||||
"base": None,
|
"base": None,
|
||||||
|
"verbose_output": True,
|
||||||
"rsync": {"exit_code": 0},
|
"rsync": {"exit_code": 0},
|
||||||
}
|
}
|
||||||
call_command("run_pobsync_backup", host.host, prefix=str(Path(tmp) / "home"), stdout=StringIO())
|
call_command("run_pobsync_backup", host.host, prefix=str(Path(tmp) / "home"), stdout=StringIO())
|
||||||
|
|
||||||
|
run_scheduled.assert_called_once()
|
||||||
|
self.assertTrue(run_scheduled.call_args.kwargs["verbose_output"])
|
||||||
self.assertEqual(BackupRun.objects.count(), 1)
|
self.assertEqual(BackupRun.objects.count(), 1)
|
||||||
run = BackupRun.objects.get()
|
run = BackupRun.objects.get()
|
||||||
|
self.assertTrue(run.result["verbose_output"])
|
||||||
self.assertEqual(SnapshotRecord.objects.count(), 1)
|
self.assertEqual(SnapshotRecord.objects.count(), 1)
|
||||||
record = SnapshotRecord.objects.get()
|
record = SnapshotRecord.objects.get()
|
||||||
self.assertEqual(run.snapshot, record)
|
self.assertEqual(run.snapshot, record)
|
||||||
@@ -52,6 +56,45 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
|||||||
self.assertEqual(record.kind, "scheduled")
|
self.assertEqual(record.kind, "scheduled")
|
||||||
self.assertEqual(record.status, "success")
|
self.assertEqual(record.status, "success")
|
||||||
|
|
||||||
|
def test_backup_command_can_skip_default_verbose_rsync_output(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",
|
||||||
|
"ended_at": "2026-05-19T02:16:00Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
"verbose_output": False,
|
||||||
|
"rsync": {"exit_code": 0},
|
||||||
|
}
|
||||||
|
call_command(
|
||||||
|
"run_pobsync_backup",
|
||||||
|
host.host,
|
||||||
|
prefix=str(Path(tmp) / "home"),
|
||||||
|
quiet_rsync=True,
|
||||||
|
stdout=StringIO(),
|
||||||
|
)
|
||||||
|
|
||||||
|
run_scheduled.assert_called_once()
|
||||||
|
self.assertFalse(run_scheduled.call_args.kwargs["verbose_output"])
|
||||||
|
self.assertFalse(BackupRun.objects.get().result["verbose_output"])
|
||||||
|
|
||||||
def test_prune_uses_sql_retention_after_snapshot_record_is_created(self) -> None:
|
def test_prune_uses_sql_retention_after_snapshot_record_is_created(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
backup_root = Path(tmp) / "backups"
|
backup_root = Path(tmp) / "backups"
|
||||||
|
|||||||
@@ -254,6 +254,8 @@ class SqlRetentionTests(TestCase):
|
|||||||
path=str(incomplete_dir),
|
path=str(incomplete_dir),
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_by="admin",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = run_incomplete_cleanup(
|
result = run_incomplete_cleanup(
|
||||||
@@ -291,6 +293,8 @@ class SqlRetentionTests(TestCase):
|
|||||||
path=str(incomplete_dir),
|
path=str(incomplete_dir),
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_by="admin",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = run_incomplete_cleanup(
|
result = run_incomplete_cleanup(
|
||||||
@@ -305,6 +309,26 @@ class SqlRetentionTests(TestCase):
|
|||||||
self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
|
self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
|
||||||
self.assertEqual(result["deleted"][0]["dirname"], incomplete_dir.name)
|
self.assertEqual(result["deleted"][0]["dirname"], incomplete_dir.name)
|
||||||
|
|
||||||
|
def test_incomplete_cleanup_requires_reviewed_snapshots(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname="20260519-031500Z__BROKEN01",
|
||||||
|
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
||||||
|
status="failed",
|
||||||
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ConfigError, "have not been reviewed"):
|
||||||
|
run_incomplete_cleanup(
|
||||||
|
prefix=Path("/tmp/pobsync-test"),
|
||||||
|
host=host.host,
|
||||||
|
yes=True,
|
||||||
|
max_delete=1,
|
||||||
|
acquire_lock=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_incomplete_cleanup_respects_max_delete(self) -> None:
|
def test_incomplete_cleanup_respects_max_delete(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
SnapshotRecord.objects.create(
|
SnapshotRecord.objects.create(
|
||||||
@@ -314,6 +338,8 @@ class SqlRetentionTests(TestCase):
|
|||||||
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_by="admin",
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(ConfigError, "blocked by --max-delete=0"):
|
with self.assertRaisesRegex(ConfigError, "blocked by --max-delete=0"):
|
||||||
@@ -334,6 +360,8 @@ class SqlRetentionTests(TestCase):
|
|||||||
path=f"/backups/{host.host}/scheduled/20260519-031500Z__BROKEN01",
|
path=f"/backups/{host.host}/scheduled/20260519-031500Z__BROKEN01",
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_by="admin",
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(ConfigError, "unexpected incomplete snapshot path"):
|
with self.assertRaisesRegex(ConfigError, "unexpected incomplete snapshot path"):
|
||||||
|
|||||||
@@ -18,32 +18,44 @@ class StatsSummaryTests(TestCase):
|
|||||||
self._snapshot(web, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
|
self._snapshot(web, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
|
||||||
self._snapshot(web, "20260519-031500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=200)
|
self._snapshot(web, "20260519-031500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=200)
|
||||||
self._snapshot(db, "20260519-041500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=300)
|
self._snapshot(db, "20260519-041500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=300)
|
||||||
self._snapshot(db, "20260519-051500Z__BROKEN1", SnapshotRecord.Kind.INCOMPLETE, allocated=400)
|
with TemporaryDirectory() as tmp:
|
||||||
|
incomplete_usage = self._incomplete_snapshot_on_disk(
|
||||||
|
db,
|
||||||
|
Path(tmp),
|
||||||
|
"20260519-051500Z__BROKEN1",
|
||||||
|
)
|
||||||
|
|
||||||
stats = collect_dashboard_stats(hosts=[web, db], global_config=None)
|
stats = collect_dashboard_stats(hosts=[web, db], global_config=None)
|
||||||
|
|
||||||
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
|
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
|
||||||
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 400)
|
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 400)
|
||||||
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 200)
|
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 200)
|
||||||
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 400)
|
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], incomplete_usage["allocated_size_bytes"])
|
||||||
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
||||||
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 1000)
|
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 600 + incomplete_usage["allocated_size_bytes"])
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 1200 + incomplete_usage["apparent_size_bytes"])
|
||||||
|
|
||||||
def test_collect_host_stats_sums_backup_data_by_snapshot_kind(self) -> None:
|
def test_collect_host_stats_sums_backup_data_by_snapshot_kind(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
self._snapshot(host, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
|
self._snapshot(host, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
|
||||||
self._snapshot(host, "20260519-031500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=200)
|
self._snapshot(host, "20260519-031500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=200)
|
||||||
self._snapshot(host, "20260519-041500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=300)
|
self._snapshot(host, "20260519-041500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=300)
|
||||||
self._snapshot(host, "20260519-051500Z__BROKEN1", SnapshotRecord.Kind.INCOMPLETE, allocated=400)
|
with TemporaryDirectory() as tmp:
|
||||||
|
incomplete_usage = self._incomplete_snapshot_on_disk(
|
||||||
|
host,
|
||||||
|
Path(tmp),
|
||||||
|
"20260519-051500Z__BROKEN1",
|
||||||
|
)
|
||||||
|
|
||||||
stats = collect_host_stats(host=host)
|
stats = collect_host_stats(host=host)
|
||||||
|
|
||||||
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
|
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
|
||||||
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 300)
|
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 300)
|
||||||
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 300)
|
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 300)
|
||||||
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 400)
|
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], incomplete_usage["allocated_size_bytes"])
|
||||||
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
||||||
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 1000)
|
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 600 + incomplete_usage["allocated_size_bytes"])
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 1200 + incomplete_usage["apparent_size_bytes"])
|
||||||
|
|
||||||
def test_collect_host_stats_falls_back_to_filesystem_usage_for_snapshots_without_metadata(self) -> None:
|
def test_collect_host_stats_falls_back_to_filesystem_usage_for_snapshots_without_metadata(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
@@ -81,8 +93,87 @@ class StatsSummaryTests(TestCase):
|
|||||||
expected_usage["allocated_size_bytes"],
|
expected_usage["allocated_size_bytes"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_collect_host_stats_measures_incomplete_data_from_disk_even_with_stale_metadata(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-051500Z__BROKEN1"
|
||||||
|
data_dir = incomplete_dir / "data"
|
||||||
|
data_dir.mkdir(parents=True)
|
||||||
|
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
||||||
|
expected_usage = tree_usage(data_dir)
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname=incomplete_dir.name,
|
||||||
|
path=str(incomplete_dir),
|
||||||
|
status="failed",
|
||||||
|
metadata={
|
||||||
|
"stats": {
|
||||||
|
"storage": {
|
||||||
|
"snapshot": {
|
||||||
|
"apparent_size_bytes": 0,
|
||||||
|
"allocated_size_bytes": 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = collect_host_stats(host=host)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
stats["backup_data"]["incomplete"]["allocated_size_bytes"],
|
||||||
|
expected_usage["allocated_size_bytes"],
|
||||||
|
)
|
||||||
|
self.assertGreater(stats["backup_data"]["incomplete"]["apparent_size_bytes"], 0)
|
||||||
|
|
||||||
|
def test_collect_host_stats_reports_non_hardlinked_snapshot_data(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
self._snapshot_with_sizes(
|
||||||
|
host,
|
||||||
|
"20260519-021500Z__SCHED01",
|
||||||
|
SnapshotRecord.Kind.SCHEDULED,
|
||||||
|
allocated=1_200,
|
||||||
|
apparent=2_000,
|
||||||
|
hardlinked_apparent=1_500,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = collect_host_stats(host=host)
|
||||||
|
|
||||||
|
self.assertEqual(stats["backup_data"]["scheduled"]["apparent_size_bytes"], 2_000)
|
||||||
|
self.assertEqual(stats["backup_data"]["scheduled"]["unique_apparent_size_bytes"], 500)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 500)
|
||||||
|
|
||||||
def _snapshot(self, host: HostConfig, dirname: str, kind: str, *, allocated: int) -> SnapshotRecord:
|
def _snapshot(self, host: HostConfig, dirname: str, kind: str, *, allocated: int) -> SnapshotRecord:
|
||||||
|
return self._snapshot_with_sizes(host, dirname, kind, allocated=allocated)
|
||||||
|
|
||||||
|
def _incomplete_snapshot_on_disk(self, host: HostConfig, root: Path, dirname: str) -> dict:
|
||||||
|
incomplete_dir = root / host.host / ".incomplete" / dirname
|
||||||
|
data_dir = incomplete_dir / "data"
|
||||||
|
data_dir.mkdir(parents=True)
|
||||||
|
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
||||||
|
usage = tree_usage(data_dir)
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname=dirname,
|
||||||
|
path=str(incomplete_dir),
|
||||||
|
status="failed",
|
||||||
|
)
|
||||||
|
return usage
|
||||||
|
|
||||||
|
def _snapshot_with_sizes(
|
||||||
|
self,
|
||||||
|
host: HostConfig,
|
||||||
|
dirname: str,
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
allocated: int,
|
||||||
|
apparent: int | None = None,
|
||||||
|
hardlinked_apparent: int = 0,
|
||||||
|
) -> SnapshotRecord:
|
||||||
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
apparent_size = apparent if apparent is not None else allocated * 2
|
||||||
return SnapshotRecord.objects.create(
|
return SnapshotRecord.objects.create(
|
||||||
host=host,
|
host=host,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
@@ -94,8 +185,9 @@ class StatsSummaryTests(TestCase):
|
|||||||
"stats": {
|
"stats": {
|
||||||
"storage": {
|
"storage": {
|
||||||
"snapshot": {
|
"snapshot": {
|
||||||
"apparent_size_bytes": allocated * 2,
|
"apparent_size_bytes": apparent_size,
|
||||||
"allocated_size_bytes": allocated,
|
"allocated_size_bytes": allocated,
|
||||||
|
"hardlinked_apparent_size_bytes": hardlinked_apparent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/pobsync_backend/tests/test_updater.py
Normal file
72
src/pobsync_backend/tests/test_updater.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, override_settings
|
||||||
|
|
||||||
|
from pobsync_backend import updater
|
||||||
|
|
||||||
|
|
||||||
|
class UpdaterTests(SimpleTestCase):
|
||||||
|
@override_settings(
|
||||||
|
POBSYNC_UPDATE_RELEASES_URL="https://code.example.test/api/v1/repos/owner/pobsync/releases",
|
||||||
|
POBSYNC_UPDATE_RELEASES_TOKEN="secret",
|
||||||
|
)
|
||||||
|
def test_fetch_latest_release_reads_first_gitea_release(self) -> None:
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value.read.return_value = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"tag_name": "v1.2.0",
|
||||||
|
"name": "1.2.0",
|
||||||
|
"html_url": "https://code.example.test/releases/v1.2.0",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
with patch("pobsync_backend.updater.urlopen", return_value=response) as urlopen:
|
||||||
|
release = updater.fetch_latest_release()
|
||||||
|
|
||||||
|
self.assertEqual(release["tag_name"], "v1.2.0")
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(request.full_url, "https://code.example.test/api/v1/repos/owner/pobsync/releases")
|
||||||
|
self.assertEqual(request.headers["Authorization"], "token secret")
|
||||||
|
|
||||||
|
@override_settings(POBSYNC_UPDATE_RELEASES_URL="")
|
||||||
|
def test_collect_update_status_reports_unconfigured_release_check(self) -> None:
|
||||||
|
with patch("pobsync_backend.updater._git_status", return_value={"branch": "master"}):
|
||||||
|
status = updater.collect_update_status(check_release=True)
|
||||||
|
|
||||||
|
self.assertFalse(status["release_check_configured"])
|
||||||
|
self.assertEqual(status["release_error"], "POBSYNC_UPDATE_RELEASES_URL is not configured.")
|
||||||
|
self.assertIsNone(status["update_available"])
|
||||||
|
|
||||||
|
@override_settings(POBSYNC_UPDATE_GIT_REMOTE="upstream")
|
||||||
|
def test_run_git_fetch_uses_configured_remote(self) -> None:
|
||||||
|
completed = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
|
with patch("pobsync_backend.updater.subprocess.run", return_value=completed) as run:
|
||||||
|
result = updater.run_git_fetch()
|
||||||
|
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
self.assertEqual(result.command, ["git", "fetch", "--tags", "--prune", "upstream"])
|
||||||
|
run.assert_called_once()
|
||||||
|
|
||||||
|
@override_settings(POBSYNC_UPDATE_GIT_REMOTE="origin")
|
||||||
|
def test_run_git_pull_rejects_detached_checkout(self) -> None:
|
||||||
|
with patch("pobsync_backend.updater._git_current_branch", return_value=""):
|
||||||
|
result = updater.run_git_pull()
|
||||||
|
|
||||||
|
self.assertFalse(result.ok)
|
||||||
|
self.assertEqual(result.exit_code, 2)
|
||||||
|
self.assertIn("not on a branch", result.stderr)
|
||||||
|
|
||||||
|
@override_settings(POBSYNC_UPDATE_COMMAND="sudo -n scripts/update-systemd --verbose")
|
||||||
|
def test_run_native_update_splits_configured_command(self) -> None:
|
||||||
|
completed = MagicMock(returncode=1, stdout="", stderr="sudo failed")
|
||||||
|
with patch("pobsync_backend.updater.subprocess.run", return_value=completed):
|
||||||
|
result = updater.run_native_update()
|
||||||
|
|
||||||
|
self.assertFalse(result.ok)
|
||||||
|
self.assertEqual(result.command, ["sudo", "-n", "scripts/update-systemd", "--verbose"])
|
||||||
|
self.assertEqual(result.stderr, "sudo failed")
|
||||||
@@ -18,6 +18,8 @@ from pobsync_backend.models import (
|
|||||||
BackupRun,
|
BackupRun,
|
||||||
GlobalConfig,
|
GlobalConfig,
|
||||||
HostConfig,
|
HostConfig,
|
||||||
|
NotificationDelivery,
|
||||||
|
NotificationTarget,
|
||||||
PurgedSnapshot,
|
PurgedSnapshot,
|
||||||
ScheduleConfig,
|
ScheduleConfig,
|
||||||
SnapshotRecord,
|
SnapshotRecord,
|
||||||
@@ -34,6 +36,12 @@ class ViewTests(TestCase):
|
|||||||
is_staff=True,
|
is_staff=True,
|
||||||
is_superuser=True,
|
is_superuser=True,
|
||||||
)
|
)
|
||||||
|
self.readonly_user = user_model.objects.create_user(
|
||||||
|
username="viewer",
|
||||||
|
password="secret",
|
||||||
|
is_staff=False,
|
||||||
|
is_superuser=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_dashboard_requires_staff_login(self) -> None:
|
def test_dashboard_requires_staff_login(self) -> None:
|
||||||
response = self.client.get(reverse("dashboard"))
|
response = self.client.get(reverse("dashboard"))
|
||||||
@@ -52,7 +60,9 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, reverse("dashboard"))
|
self.assertContains(response, reverse("dashboard"))
|
||||||
self.assertContains(response, reverse("hosts_list"))
|
self.assertContains(response, reverse("hosts_list"))
|
||||||
self.assertContains(response, reverse("ssh_credentials"))
|
self.assertContains(response, reverse("ssh_credentials"))
|
||||||
|
self.assertContains(response, reverse("notification_targets"))
|
||||||
self.assertContains(response, reverse("logs"))
|
self.assertContains(response, reverse("logs"))
|
||||||
|
self.assertContains(response, reverse("updater"))
|
||||||
self.assertContains(response, reverse("purged_snapshots"))
|
self.assertContains(response, reverse("purged_snapshots"))
|
||||||
self.assertContains(response, reverse("self_check"))
|
self.assertContains(response, reverse("self_check"))
|
||||||
self.assertContains(response, reverse("changelog"))
|
self.assertContains(response, reverse("changelog"))
|
||||||
@@ -60,6 +70,23 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, reverse("admin:index"))
|
self.assertContains(response, reverse("admin:index"))
|
||||||
self.assertContains(response, '<a href="/" aria-current="page">Dashboard</a>', html=False)
|
self.assertContains(response, '<a href="/" aria-current="page">Dashboard</a>', html=False)
|
||||||
|
|
||||||
|
def test_readonly_navigation_hides_admin_and_sensitive_links(self) -> None:
|
||||||
|
self.client.force_login(self.readonly_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("dashboard"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, reverse("dashboard"))
|
||||||
|
self.assertContains(response, reverse("hosts_list"))
|
||||||
|
self.assertContains(response, reverse("changelog"))
|
||||||
|
self.assertContains(response, "/api/status/")
|
||||||
|
self.assertNotContains(response, reverse("ssh_credentials"))
|
||||||
|
self.assertNotContains(response, reverse("notification_targets"))
|
||||||
|
self.assertNotContains(response, reverse("logs"))
|
||||||
|
self.assertNotContains(response, reverse("updater"))
|
||||||
|
self.assertNotContains(response, reverse("self_check"))
|
||||||
|
self.assertNotContains(response, reverse("admin:index"))
|
||||||
|
|
||||||
def test_base_navigation_marks_current_secondary_page(self) -> None:
|
def test_base_navigation_marks_current_secondary_page(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
@@ -68,12 +95,166 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, f'<a href="{reverse("self_check")}" aria-current="page">Self Check</a>', html=False)
|
self.assertContains(response, f'<a href="{reverse("self_check")}" aria-current="page">Self Check</a>', html=False)
|
||||||
|
|
||||||
def test_changelog_requires_staff_login(self) -> None:
|
def test_changelog_requires_login(self) -> None:
|
||||||
response = self.client.get(reverse("changelog"))
|
response = self.client.get(reverse("changelog"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn("/admin/login/", response["Location"])
|
self.assertIn("/admin/login/", response["Location"])
|
||||||
|
|
||||||
|
def test_readonly_user_can_view_status_pages(self) -> None:
|
||||||
|
self.client.force_login(self.readonly_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
snapshot = SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.SCHEDULED,
|
||||||
|
dirname="20260519-021500Z__ABCDEFGH",
|
||||||
|
path="/backups/web-01/scheduled/20260519-021500Z__ABCDEFGH",
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||||
|
|
||||||
|
urls = [
|
||||||
|
reverse("dashboard"),
|
||||||
|
reverse("hosts_list"),
|
||||||
|
reverse("host_detail", args=[host.host]),
|
||||||
|
reverse("runs_list"),
|
||||||
|
reverse("run_detail", args=[run.id]),
|
||||||
|
reverse("snapshots_list"),
|
||||||
|
reverse("snapshot_detail", args=[snapshot.id]),
|
||||||
|
reverse("schedules_list"),
|
||||||
|
reverse("purged_snapshots"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
with self.subTest(url=url):
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_readonly_user_cannot_access_sensitive_or_mutating_views(self) -> None:
|
||||||
|
self.client.force_login(self.readonly_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
blocked_urls = [
|
||||||
|
reverse("ssh_credentials"),
|
||||||
|
reverse("logs"),
|
||||||
|
reverse("updater"),
|
||||||
|
reverse("self_check"),
|
||||||
|
reverse("edit_global_config"),
|
||||||
|
reverse("create_host_config"),
|
||||||
|
reverse("edit_host_config", args=[host.host]),
|
||||||
|
reverse("edit_host_schedule", args=[host.host]),
|
||||||
|
]
|
||||||
|
|
||||||
|
for url in blocked_urls:
|
||||||
|
with self.subTest(url=url):
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_updater_view_renders_status(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.views.collect_update_status") as collect_update_status:
|
||||||
|
collect_update_status.return_value = {
|
||||||
|
"app_dir": "/opt/pobsync/app",
|
||||||
|
"installed_version": "1.1.0",
|
||||||
|
"release_check_configured": True,
|
||||||
|
"update_command": "sudo -n scripts/update-systemd",
|
||||||
|
"git_remote": "origin",
|
||||||
|
"git": {"branch": "master", "commit": "abc1234", "describe": "v1.1.0"},
|
||||||
|
"latest_release": None,
|
||||||
|
"release_error": "",
|
||||||
|
"update_available": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.get(reverse("updater"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Updater")
|
||||||
|
self.assertContains(response, "1.1.0")
|
||||||
|
self.assertContains(response, "master")
|
||||||
|
collect_update_status.assert_called_once_with(check_release=False)
|
||||||
|
|
||||||
|
def test_updater_check_release_requests_release_status(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.views.collect_update_status") as collect_update_status:
|
||||||
|
collect_update_status.return_value = {
|
||||||
|
"app_dir": "/opt/pobsync/app",
|
||||||
|
"installed_version": "1.1.0",
|
||||||
|
"release_check_configured": True,
|
||||||
|
"update_command": "sudo -n scripts/update-systemd",
|
||||||
|
"git_remote": "origin",
|
||||||
|
"git": {"branch": "master", "commit": "abc1234", "describe": "v1.1.0"},
|
||||||
|
"latest_release": {"tag_name": "v1.2.0", "html_url": "https://code.example.test/releases/v1.2.0"},
|
||||||
|
"release_error": "",
|
||||||
|
"update_available": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(reverse("updater"), {"action": "check_release"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "update available")
|
||||||
|
self.assertContains(response, "v1.2.0")
|
||||||
|
collect_update_status.assert_called_once_with(check_release=True)
|
||||||
|
|
||||||
|
def test_updater_git_fetch_runs_action_and_renders_output(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
result = type(
|
||||||
|
"Result",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"exit_code": 0,
|
||||||
|
"command": ["git", "fetch", "--tags", "--prune", "origin"],
|
||||||
|
"stdout": "fetched",
|
||||||
|
"stderr": "",
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
with patch("pobsync_backend.views.run_git_fetch", return_value=result) as run_git_fetch, patch(
|
||||||
|
"pobsync_backend.views.collect_update_status"
|
||||||
|
) as collect_update_status:
|
||||||
|
collect_update_status.return_value = {
|
||||||
|
"app_dir": "/opt/pobsync/app",
|
||||||
|
"installed_version": "1.1.0",
|
||||||
|
"release_check_configured": False,
|
||||||
|
"update_command": "sudo -n scripts/update-systemd",
|
||||||
|
"git_remote": "origin",
|
||||||
|
"git": {"branch": "master", "commit": "abc1234", "describe": "v1.1.0"},
|
||||||
|
"latest_release": None,
|
||||||
|
"release_error": "",
|
||||||
|
"update_available": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(reverse("updater"), {"action": "git_fetch"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "fetched")
|
||||||
|
self.assertContains(response, "Updater action completed successfully.")
|
||||||
|
run_git_fetch.assert_called_once_with()
|
||||||
|
collect_update_status.assert_called_once_with(check_release=True)
|
||||||
|
|
||||||
|
def test_readonly_host_detail_hides_backup_controls_and_sensitive_config(self) -> None:
|
||||||
|
self.client.force_login(self.readonly_user)
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root="/backups")
|
||||||
|
credential = SshCredential.objects.create(name="root-key", key_path="/var/lib/pobsync/state/root-key")
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
ssh_credential=credential,
|
||||||
|
ssh_user="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Host Status")
|
||||||
|
self.assertNotContains(response, "Backup Control")
|
||||||
|
self.assertNotContains(response, "Backup Options")
|
||||||
|
self.assertNotContains(response, "Connection Preflight")
|
||||||
|
self.assertNotContains(response, "root-key")
|
||||||
|
self.assertNotContains(response, reverse("queue_manual_backup", args=[host.host]))
|
||||||
|
|
||||||
def test_changelog_renders_repository_changelog(self) -> None:
|
def test_changelog_renders_repository_changelog(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
@@ -94,6 +275,70 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Django control panel")
|
self.assertContains(response, "Django control panel")
|
||||||
self.assertContains(response, "Native systemd installer")
|
self.assertContains(response, "Native systemd installer")
|
||||||
|
|
||||||
|
def test_notification_targets_view_renders_targets_and_deliveries(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS)
|
||||||
|
target = NotificationTarget.objects.create(
|
||||||
|
name="ops",
|
||||||
|
channel=NotificationTarget.Channel.EMAIL,
|
||||||
|
email_to="ops@example.test",
|
||||||
|
last_status=NotificationDelivery.Status.SENT,
|
||||||
|
)
|
||||||
|
NotificationDelivery.objects.create(target=target, run=run, status=NotificationDelivery.Status.SENT)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("notification_targets"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Notifications")
|
||||||
|
self.assertContains(response, "ops")
|
||||||
|
self.assertContains(response, "ops@example.test")
|
||||||
|
self.assertContains(response, f"Run {run.id}")
|
||||||
|
|
||||||
|
def test_notification_target_form_creates_email_target(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("create_notification_target"),
|
||||||
|
{
|
||||||
|
"name": "ops",
|
||||||
|
"enabled": "on",
|
||||||
|
"channel": NotificationTarget.Channel.EMAIL,
|
||||||
|
"statuses": [BackupRun.Status.FAILED, BackupRun.Status.WARNING],
|
||||||
|
"email_to": "ops@example.test, backup@example.test",
|
||||||
|
"webhook_headers": "{}",
|
||||||
|
"notes": "Notify ops",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("notification_targets"))
|
||||||
|
self.assertContains(response, "Notification target ops created.")
|
||||||
|
target = NotificationTarget.objects.get(name="ops")
|
||||||
|
self.assertEqual(target.channel, NotificationTarget.Channel.EMAIL)
|
||||||
|
self.assertEqual(target.statuses, [BackupRun.Status.FAILED, BackupRun.Status.WARNING])
|
||||||
|
self.assertEqual(target.email_to, "ops@example.test\nbackup@example.test")
|
||||||
|
|
||||||
|
def test_notification_target_form_requires_channel_destination(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("create_notification_target"),
|
||||||
|
{
|
||||||
|
"name": "broken",
|
||||||
|
"enabled": "on",
|
||||||
|
"channel": NotificationTarget.Channel.WEBHOOK,
|
||||||
|
"statuses": [BackupRun.Status.FAILED],
|
||||||
|
"email_to": "",
|
||||||
|
"webhook_url": "",
|
||||||
|
"webhook_headers": "{}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Webhook targets need a URL.")
|
||||||
|
self.assertFalse(NotificationTarget.objects.exists())
|
||||||
|
|
||||||
def test_dashboard_renders_hosts_and_latest_runs(self) -> None:
|
def test_dashboard_renders_hosts_and_latest_runs(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")
|
||||||
@@ -198,12 +443,23 @@ class ViewTests(TestCase):
|
|||||||
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||||
scheduled = self._snapshot(web, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
scheduled = self._snapshot(web, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
||||||
manual = self._snapshot(web, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
|
manual = self._snapshot(web, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
|
||||||
incomplete = self._snapshot(db, "20260519-041500Z__BROKEN1", kind=SnapshotRecord.Kind.INCOMPLETE)
|
|
||||||
self._set_snapshot_storage(scheduled, allocated=100)
|
self._set_snapshot_storage(scheduled, allocated=100)
|
||||||
self._set_snapshot_storage(manual, allocated=200)
|
self._set_snapshot_storage(manual, allocated=200)
|
||||||
self._set_snapshot_storage(incomplete, allocated=300)
|
with TemporaryDirectory() as tmp:
|
||||||
|
incomplete_dir = Path(tmp) / db.host / ".incomplete" / "20260519-041500Z__BROKEN1"
|
||||||
|
data_dir = incomplete_dir / "data"
|
||||||
|
data_dir.mkdir(parents=True)
|
||||||
|
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
||||||
|
expected_usage = tree_usage(data_dir)
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=db,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname=incomplete_dir.name,
|
||||||
|
path=str(incomplete_dir),
|
||||||
|
status="failed",
|
||||||
|
)
|
||||||
|
|
||||||
response = self.client.get(reverse("dashboard_priority_live"))
|
response = self.client.get(reverse("dashboard_priority_live"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Scheduled data")
|
self.assertContains(response, "Scheduled data")
|
||||||
@@ -212,8 +468,8 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Total snapshot data")
|
self.assertContains(response, "Total snapshot data")
|
||||||
self.assertContains(response, "100 bytes", html=True)
|
self.assertContains(response, "100 bytes", html=True)
|
||||||
self.assertContains(response, "200 bytes", html=True)
|
self.assertContains(response, "200 bytes", html=True)
|
||||||
self.assertContains(response, "300 bytes", html=True)
|
self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"]))
|
||||||
self.assertContains(response, "600 bytes", html=True)
|
self.assertContains(response, filesizeformat(300 + expected_usage["allocated_size_bytes"]))
|
||||||
|
|
||||||
def test_dashboard_hosts_live_returns_hosts_partial(self) -> None:
|
def test_dashboard_hosts_live_returns_hosts_partial(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -233,12 +489,23 @@ class ViewTests(TestCase):
|
|||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
scheduled = self._snapshot(host, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
scheduled = self._snapshot(host, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
||||||
manual = self._snapshot(host, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
|
manual = self._snapshot(host, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
|
||||||
incomplete = self._snapshot(host, "20260519-041500Z__BROKEN1", kind=SnapshotRecord.Kind.INCOMPLETE)
|
|
||||||
self._set_snapshot_storage(scheduled, allocated=100)
|
self._set_snapshot_storage(scheduled, allocated=100)
|
||||||
self._set_snapshot_storage(manual, allocated=200)
|
self._set_snapshot_storage(manual, allocated=200)
|
||||||
self._set_snapshot_storage(incomplete, allocated=300)
|
with TemporaryDirectory() as tmp:
|
||||||
|
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-041500Z__BROKEN1"
|
||||||
|
data_dir = incomplete_dir / "data"
|
||||||
|
data_dir.mkdir(parents=True)
|
||||||
|
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
||||||
|
expected_usage = tree_usage(data_dir)
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname=incomplete_dir.name,
|
||||||
|
path=str(incomplete_dir),
|
||||||
|
status="failed",
|
||||||
|
)
|
||||||
|
|
||||||
response = self.client.get(reverse("dashboard_hosts_live"))
|
response = self.client.get(reverse("dashboard_hosts_live"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Scheduled data")
|
self.assertContains(response, "Scheduled data")
|
||||||
@@ -247,8 +514,8 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Total data")
|
self.assertContains(response, "Total data")
|
||||||
self.assertContains(response, "100 bytes", html=True)
|
self.assertContains(response, "100 bytes", html=True)
|
||||||
self.assertContains(response, "200 bytes", html=True)
|
self.assertContains(response, "200 bytes", html=True)
|
||||||
self.assertContains(response, "300 bytes", html=True)
|
self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"]))
|
||||||
self.assertContains(response, "600 bytes", html=True)
|
self.assertContains(response, filesizeformat(300 + expected_usage["allocated_size_bytes"]))
|
||||||
|
|
||||||
def test_dashboard_host_cards_measure_incomplete_data_without_snapshot_metadata(self) -> None:
|
def test_dashboard_host_cards_measure_incomplete_data_without_snapshot_metadata(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -1283,7 +1550,7 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("queue_manual_backup", args=[host.host]),
|
reverse("queue_manual_backup", args=[host.host]),
|
||||||
{"prune_max_delete": "10"},
|
{"verbose_output": "on", "prune_max_delete": "10"},
|
||||||
follow=True,
|
follow=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1521,7 +1788,7 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("queue_manual_backup", args=[host.host]),
|
reverse("queue_manual_backup", args=[host.host]),
|
||||||
{"prune_max_delete": "10"},
|
{"verbose_output": "on", "prune_max_delete": "10"},
|
||||||
follow=True,
|
follow=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1531,7 +1798,7 @@ class ViewTests(TestCase):
|
|||||||
run.result["requested"],
|
run.result["requested"],
|
||||||
{
|
{
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
"verbose_output": False,
|
"verbose_output": True,
|
||||||
"prune": False,
|
"prune": False,
|
||||||
"prune_max_delete": 10,
|
"prune_max_delete": 10,
|
||||||
"prune_protect_bases": False,
|
"prune_protect_bases": False,
|
||||||
@@ -2226,9 +2493,33 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Incomplete Snapshots")
|
self.assertContains(response, "Incomplete Snapshots")
|
||||||
self.assertContains(response, "20260519-031500Z__BROKEN01")
|
self.assertContains(response, "20260519-031500Z__BROKEN01")
|
||||||
self.assertContains(response, "excluded from retention cleanup")
|
self.assertContains(response, "excluded from retention cleanup")
|
||||||
|
self.assertContains(response, "needs review")
|
||||||
|
self.assertContains(response, "Cleanup is blocked until all incomplete snapshots are reviewed.")
|
||||||
|
self.assertContains(response, "Mark incomplete snapshots reviewed")
|
||||||
|
self.assertContains(response, "delete only incomplete snapshot directories")
|
||||||
|
self.assertNotContains(response, "Delete incomplete snapshots")
|
||||||
|
|
||||||
|
def test_retention_plan_offers_incomplete_cleanup_after_review(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname="20260519-031500Z__BROKEN01",
|
||||||
|
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
||||||
|
status="failed",
|
||||||
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_by="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "reviewed")
|
||||||
self.assertContains(response, "Delete incomplete snapshots")
|
self.assertContains(response, "Delete incomplete snapshots")
|
||||||
self.assertContains(response, "Type 1 to confirm the current number of incomplete snapshots.")
|
self.assertContains(response, "Type 1 to confirm the current number of incomplete snapshots.")
|
||||||
self.assertContains(response, "This deletes only incomplete snapshot directories")
|
self.assertContains(response, "This deletes only reviewed incomplete snapshot directories")
|
||||||
self.assertContains(response, 'class="danger"', html=False)
|
self.assertContains(response, 'class="danger"', html=False)
|
||||||
|
|
||||||
def test_incomplete_cleanup_deletes_incomplete_snapshot_after_confirmation(self) -> None:
|
def test_incomplete_cleanup_deletes_incomplete_snapshot_after_confirmation(self) -> None:
|
||||||
@@ -2246,6 +2537,8 @@ class ViewTests(TestCase):
|
|||||||
path=str(incomplete_dir),
|
path=str(incomplete_dir),
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_by="admin",
|
||||||
)
|
)
|
||||||
|
|
||||||
with override_settings(POBSYNC_HOME=str(home)):
|
with override_settings(POBSYNC_HOME=str(home)):
|
||||||
@@ -2291,6 +2584,33 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Incomplete cleanup confirmation is invalid.")
|
self.assertContains(response, "Incomplete cleanup confirmation is invalid.")
|
||||||
self.assertEqual(SnapshotRecord.objects.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), 1)
|
self.assertEqual(SnapshotRecord.objects.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), 1)
|
||||||
|
|
||||||
|
def test_incomplete_cleanup_rejects_unreviewed_snapshots(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
dirname="20260519-031500Z__BROKEN01",
|
||||||
|
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
||||||
|
status="failed",
|
||||||
|
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("cleanup_host_incomplete_snapshots", args=[host.host]),
|
||||||
|
{
|
||||||
|
"max_delete": "0",
|
||||||
|
"confirm_host": host.host,
|
||||||
|
"confirm_delete_count": "0",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("host_retention_plan", args=[host.host]))
|
||||||
|
self.assertContains(response, "have not been reviewed")
|
||||||
|
self.assertEqual(SnapshotRecord.objects.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), 1)
|
||||||
|
|
||||||
def test_host_detail_surfaces_retention_warnings(self) -> None:
|
def test_host_detail_surfaces_retention_warnings(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(
|
host = HostConfig.objects.create(
|
||||||
|
|||||||
149
src/pobsync_backend/updater.py
Normal file
149
src/pobsync_backend/updater.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from pobsync import __version__
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandResult:
|
||||||
|
command: list[str]
|
||||||
|
exit_code: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def collect_update_status(*, check_release: bool = False) -> dict[str, Any]:
|
||||||
|
app_dir = Path(settings.BASE_DIR)
|
||||||
|
status: dict[str, Any] = {
|
||||||
|
"app_dir": app_dir,
|
||||||
|
"installed_version": __version__,
|
||||||
|
"release_check_configured": bool(settings.POBSYNC_UPDATE_RELEASES_URL),
|
||||||
|
"update_command": settings.POBSYNC_UPDATE_COMMAND,
|
||||||
|
"git_remote": settings.POBSYNC_UPDATE_GIT_REMOTE,
|
||||||
|
"git": _git_status(app_dir),
|
||||||
|
"latest_release": None,
|
||||||
|
"release_error": "",
|
||||||
|
"update_available": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_release:
|
||||||
|
if not settings.POBSYNC_UPDATE_RELEASES_URL:
|
||||||
|
status["release_error"] = "POBSYNC_UPDATE_RELEASES_URL is not configured."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
latest_release = fetch_latest_release()
|
||||||
|
status["latest_release"] = latest_release
|
||||||
|
status["update_available"] = _version_key(latest_release.get("tag_name", "")) != _version_key(__version__)
|
||||||
|
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, ValueError) as exc:
|
||||||
|
status["release_error"] = str(exc)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_latest_release() -> dict[str, Any]:
|
||||||
|
request = Request(settings.POBSYNC_UPDATE_RELEASES_URL, headers={"Accept": "application/json"})
|
||||||
|
if settings.POBSYNC_UPDATE_RELEASES_TOKEN:
|
||||||
|
request.add_header("Authorization", f"token {settings.POBSYNC_UPDATE_RELEASES_TOKEN}")
|
||||||
|
|
||||||
|
with urlopen(request, timeout=10) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
if isinstance(payload, list):
|
||||||
|
if not payload:
|
||||||
|
raise ValueError("No releases were returned.")
|
||||||
|
release = payload[0]
|
||||||
|
elif isinstance(payload, dict):
|
||||||
|
release = payload
|
||||||
|
else:
|
||||||
|
raise ValueError("Release endpoint returned an unexpected payload.")
|
||||||
|
|
||||||
|
if not isinstance(release, dict):
|
||||||
|
raise ValueError("Release endpoint returned an unexpected release entry.")
|
||||||
|
return release
|
||||||
|
|
||||||
|
|
||||||
|
def run_git_fetch() -> CommandResult:
|
||||||
|
remote = settings.POBSYNC_UPDATE_GIT_REMOTE
|
||||||
|
return _run_command(["git", "fetch", "--tags", "--prune", remote])
|
||||||
|
|
||||||
|
|
||||||
|
def run_git_pull() -> CommandResult:
|
||||||
|
remote = settings.POBSYNC_UPDATE_GIT_REMOTE
|
||||||
|
branch = _git_current_branch(Path(settings.BASE_DIR))
|
||||||
|
if not branch:
|
||||||
|
return CommandResult(
|
||||||
|
command=["git", "pull", "--ff-only", remote],
|
||||||
|
exit_code=2,
|
||||||
|
stdout="",
|
||||||
|
stderr="Cannot pull automatically because the installed checkout is not on a branch.",
|
||||||
|
)
|
||||||
|
return _run_command(["git", "pull", "--ff-only", remote, branch])
|
||||||
|
|
||||||
|
|
||||||
|
def run_native_update() -> CommandResult:
|
||||||
|
return _run_command(shlex.split(settings.POBSYNC_UPDATE_COMMAND))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_command(command: list[str]) -> CommandResult:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=settings.BASE_DIR,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
return CommandResult(
|
||||||
|
command=command,
|
||||||
|
exit_code=completed.returncode,
|
||||||
|
stdout=completed.stdout[-6000:],
|
||||||
|
stderr=completed.stderr[-6000:],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_status(app_dir: Path) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"branch": _git_current_branch(app_dir),
|
||||||
|
"commit": _git_output(app_dir, ["git", "rev-parse", "--short", "HEAD"]),
|
||||||
|
"describe": _git_output(app_dir, ["git", "describe", "--tags", "--always", "--dirty"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _git_current_branch(app_dir: Path) -> str:
|
||||||
|
branch = _git_output(app_dir, ["git", "branch", "--show-current"])
|
||||||
|
return branch or _git_output(app_dir, ["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
|
|
||||||
|
|
||||||
|
def _git_output(app_dir: Path, command: list[str]) -> str:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=app_dir,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return ""
|
||||||
|
return completed.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _version_key(value: str) -> str:
|
||||||
|
return value.strip().removeprefix("v")
|
||||||
@@ -10,7 +10,6 @@ from pathlib import Path
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
@@ -24,6 +23,7 @@ from pobsync import __version__
|
|||||||
from pobsync.errors import PobsyncError
|
from pobsync.errors import PobsyncError
|
||||||
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS
|
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS
|
||||||
|
|
||||||
|
from .access import access_context, control_panel_admin_required, status_view_required
|
||||||
from .backup_runner import queue_backup_run
|
from .backup_runner import queue_backup_run
|
||||||
from .config_checks import collect_effective_host_config_checks, collect_global_config_checks
|
from .config_checks import collect_effective_host_config_checks, collect_global_config_checks
|
||||||
from .forms import (
|
from .forms import (
|
||||||
@@ -32,13 +32,24 @@ from .forms import (
|
|||||||
HostConfigForm,
|
HostConfigForm,
|
||||||
IncompleteCleanupForm,
|
IncompleteCleanupForm,
|
||||||
ManualBackupForm,
|
ManualBackupForm,
|
||||||
|
NotificationTargetForm,
|
||||||
RetentionApplyForm,
|
RetentionApplyForm,
|
||||||
SshCredentialGenerateForm,
|
SshCredentialGenerateForm,
|
||||||
ScheduleConfigForm,
|
ScheduleConfigForm,
|
||||||
SshCredentialForm,
|
SshCredentialForm,
|
||||||
)
|
)
|
||||||
from .host_ops import ensure_host_directories
|
from .host_ops import ensure_host_directories
|
||||||
from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
|
from .models import (
|
||||||
|
BackupRun,
|
||||||
|
GlobalConfig,
|
||||||
|
HostConfig,
|
||||||
|
NotificationDelivery,
|
||||||
|
NotificationTarget,
|
||||||
|
PurgedSnapshot,
|
||||||
|
ScheduleConfig,
|
||||||
|
SnapshotRecord,
|
||||||
|
SshCredential,
|
||||||
|
)
|
||||||
from .preflight import collect_backup_gate, effective_host_config_preview, run_remote_preflight
|
from .preflight import collect_backup_gate, effective_host_config_preview, run_remote_preflight
|
||||||
from .retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan
|
from .retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan
|
||||||
from .self_check import collect_self_checks, summarize_self_checks
|
from .self_check import collect_self_checks, summarize_self_checks
|
||||||
@@ -46,21 +57,22 @@ from .scheduler import next_due_after
|
|||||||
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
||||||
from .ssh_keys import SshKeyError, delete_generated_key_files, generate_ssh_key, merge_known_hosts, scan_known_host
|
from .ssh_keys import SshKeyError, delete_generated_key_files, generate_ssh_key, merge_known_hosts, scan_known_host
|
||||||
from .stats_summary import collect_dashboard_stats, collect_host_stats
|
from .stats_summary import collect_dashboard_stats, collect_host_stats
|
||||||
|
from .updater import collect_update_status, run_git_fetch, run_git_pull, run_native_update
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def dashboard(request):
|
def dashboard(request):
|
||||||
return render(request, "pobsync_backend/dashboard.html", _dashboard_context())
|
return render(request, "pobsync_backend/dashboard.html", _dashboard_context(request))
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def dashboard_priority_live(request):
|
def dashboard_priority_live(request):
|
||||||
return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context())
|
return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context(request))
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def dashboard_hosts_live(request):
|
def dashboard_hosts_live(request):
|
||||||
return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context())
|
return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context(request))
|
||||||
|
|
||||||
|
|
||||||
def _host_cards_context(*, enabled: str = "") -> dict[str, object]:
|
def _host_cards_context(*, enabled: str = "") -> dict[str, object]:
|
||||||
@@ -109,7 +121,7 @@ def _host_cards_context(*, enabled: str = "") -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_context() -> dict[str, object]:
|
def _dashboard_context(request) -> dict[str, object]:
|
||||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||||
host_context = _host_cards_context()
|
host_context = _host_cards_context()
|
||||||
hosts = host_context["hosts"]
|
hosts = host_context["hosts"]
|
||||||
@@ -118,6 +130,7 @@ def _dashboard_context() -> dict[str, object]:
|
|||||||
recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6]
|
recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6]
|
||||||
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
|
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
|
||||||
context = {
|
context = {
|
||||||
|
**access_context(request),
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
"global_config": global_config,
|
"global_config": global_config,
|
||||||
"stats_summary": stats_summary,
|
"stats_summary": stats_summary,
|
||||||
@@ -148,7 +161,7 @@ def _dashboard_context() -> dict[str, object]:
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def hosts_list(request):
|
def hosts_list(request):
|
||||||
enabled = request.GET.get("enabled", "").strip()
|
enabled = request.GET.get("enabled", "").strip()
|
||||||
if enabled not in {"", "yes", "no"}:
|
if enabled not in {"", "yes", "no"}:
|
||||||
@@ -160,15 +173,16 @@ def hosts_list(request):
|
|||||||
request,
|
request,
|
||||||
"pobsync_backend/hosts_list.html",
|
"pobsync_backend/hosts_list.html",
|
||||||
{
|
{
|
||||||
|
**access_context(request),
|
||||||
**context,
|
**context,
|
||||||
"global_config": global_config,
|
"global_config": global_config,
|
||||||
"show_host_controls": True,
|
"show_host_controls": request.user.is_staff,
|
||||||
"total_count": HostConfig.objects.count(),
|
"total_count": HostConfig.objects.count(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def update_host_state(request, host: str):
|
def update_host_state(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -279,7 +293,7 @@ def _retention_warning_summary(retention_warning) -> str:
|
|||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def changelog(request):
|
def changelog(request):
|
||||||
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
|
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
|
||||||
try:
|
try:
|
||||||
@@ -301,7 +315,7 @@ def changelog(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def self_check(request):
|
def self_check(request):
|
||||||
checks = collect_self_checks()
|
checks = collect_self_checks()
|
||||||
return render(
|
return render(
|
||||||
@@ -314,13 +328,106 @@ def self_check(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def logs(request):
|
def logs(request):
|
||||||
context = _log_context(request)
|
context = _log_context(request)
|
||||||
return render(request, "pobsync_backend/logs.html", context)
|
return render(request, "pobsync_backend/logs.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
|
def updater(request):
|
||||||
|
action_result = None
|
||||||
|
check_release = request.method == "POST" and request.POST.get("action") == "check_release"
|
||||||
|
if request.method == "POST":
|
||||||
|
action = request.POST.get("action")
|
||||||
|
if action == "git_fetch":
|
||||||
|
action_result = run_git_fetch()
|
||||||
|
check_release = True
|
||||||
|
elif action == "git_pull":
|
||||||
|
action_result = run_git_pull()
|
||||||
|
check_release = True
|
||||||
|
elif action == "run_update":
|
||||||
|
action_result = run_native_update()
|
||||||
|
check_release = True
|
||||||
|
elif action != "check_release":
|
||||||
|
messages.error(request, "Unknown updater action.")
|
||||||
|
|
||||||
|
if action_result is not None:
|
||||||
|
if action_result.ok:
|
||||||
|
messages.success(request, "Updater action completed successfully.")
|
||||||
|
else:
|
||||||
|
messages.error(request, f"Updater action failed with exit code {action_result.exit_code}.")
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"pobsync_backend/updater.html",
|
||||||
|
{
|
||||||
|
"status": collect_update_status(check_release=check_release),
|
||||||
|
"action_result": action_result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@control_panel_admin_required
|
||||||
|
def notification_targets(request):
|
||||||
|
targets = NotificationTarget.objects.order_by("name")
|
||||||
|
deliveries = NotificationDelivery.objects.select_related("target", "run", "run__host").order_by("-created_at")[:12]
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"pobsync_backend/notification_targets.html",
|
||||||
|
{
|
||||||
|
"targets": targets,
|
||||||
|
"deliveries": deliveries,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@control_panel_admin_required
|
||||||
|
def create_notification_target(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
form = NotificationTargetForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
target = form.save()
|
||||||
|
messages.success(request, f"Notification target {target.name} created.")
|
||||||
|
return redirect("notification_targets")
|
||||||
|
else:
|
||||||
|
form = NotificationTargetForm()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"pobsync_backend/notification_target_form.html",
|
||||||
|
{
|
||||||
|
"form": form,
|
||||||
|
"target": None,
|
||||||
|
"title": "New notification target",
|
||||||
|
"submit_label": "Create target",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@control_panel_admin_required
|
||||||
|
def edit_notification_target(request, target_id: int):
|
||||||
|
target = get_object_or_404(NotificationTarget, id=target_id)
|
||||||
|
if request.method == "POST":
|
||||||
|
form = NotificationTargetForm(request.POST, instance=target)
|
||||||
|
if form.is_valid():
|
||||||
|
target = form.save()
|
||||||
|
messages.success(request, f"Notification target {target.name} updated.")
|
||||||
|
return redirect("notification_targets")
|
||||||
|
else:
|
||||||
|
form = NotificationTargetForm(instance=target)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"pobsync_backend/notification_target_form.html",
|
||||||
|
{
|
||||||
|
"form": form,
|
||||||
|
"target": target,
|
||||||
|
"title": f"Edit notification target: {target.name}",
|
||||||
|
"submit_label": "Save target",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@status_view_required
|
||||||
def runs_list(request):
|
def runs_list(request):
|
||||||
status = request.GET.get("status", "").strip()
|
status = request.GET.get("status", "").strip()
|
||||||
run_type = request.GET.get("type", "").strip()
|
run_type = request.GET.get("type", "").strip()
|
||||||
@@ -352,7 +459,7 @@ def runs_list(request):
|
|||||||
return render(request, "pobsync_backend/runs_list.html", context)
|
return render(request, "pobsync_backend/runs_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def snapshots_list(request):
|
def snapshots_list(request):
|
||||||
kind = request.GET.get("kind", "").strip()
|
kind = request.GET.get("kind", "").strip()
|
||||||
status = request.GET.get("status", "").strip()
|
status = request.GET.get("status", "").strip()
|
||||||
@@ -378,7 +485,7 @@ def snapshots_list(request):
|
|||||||
return render(request, "pobsync_backend/snapshots_list.html", context)
|
return render(request, "pobsync_backend/snapshots_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def schedules_list(request):
|
def schedules_list(request):
|
||||||
enabled = request.GET.get("enabled", "").strip()
|
enabled = request.GET.get("enabled", "").strip()
|
||||||
prune = request.GET.get("prune", "").strip()
|
prune = request.GET.get("prune", "").strip()
|
||||||
@@ -416,7 +523,7 @@ def schedules_list(request):
|
|||||||
return render(request, "pobsync_backend/schedules_list.html", context)
|
return render(request, "pobsync_backend/schedules_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def purged_snapshots(request):
|
def purged_snapshots(request):
|
||||||
host = request.GET.get("host", "").strip()
|
host = request.GET.get("host", "").strip()
|
||||||
action = request.GET.get("action", "").strip()
|
action = request.GET.get("action", "").strip()
|
||||||
@@ -437,7 +544,7 @@ def purged_snapshots(request):
|
|||||||
return render(request, "pobsync_backend/purged_snapshots.html", context)
|
return render(request, "pobsync_backend/purged_snapshots.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def ssh_credentials(request):
|
def ssh_credentials(request):
|
||||||
context = {
|
context = {
|
||||||
"credentials": SshCredential.objects.order_by("name"),
|
"credentials": SshCredential.objects.order_by("name"),
|
||||||
@@ -445,7 +552,7 @@ def ssh_credentials(request):
|
|||||||
return render(request, "pobsync_backend/ssh_credentials.html", context)
|
return render(request, "pobsync_backend/ssh_credentials.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def create_ssh_credential(request):
|
def create_ssh_credential(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = SshCredentialForm(request.POST, request.FILES)
|
form = SshCredentialForm(request.POST, request.FILES)
|
||||||
@@ -466,7 +573,7 @@ def create_ssh_credential(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def generate_ssh_credential(request):
|
def generate_ssh_credential(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = SshCredentialGenerateForm(request.POST)
|
form = SshCredentialGenerateForm(request.POST)
|
||||||
@@ -502,7 +609,7 @@ def generate_ssh_credential(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def edit_ssh_credential(request, credential_id: int):
|
def edit_ssh_credential(request, credential_id: int):
|
||||||
credential = get_object_or_404(SshCredential, id=credential_id)
|
credential = get_object_or_404(SshCredential, id=credential_id)
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -524,7 +631,7 @@ def edit_ssh_credential(request, credential_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def delete_ssh_credential(request, credential_id: int):
|
def delete_ssh_credential(request, credential_id: int):
|
||||||
credential = get_object_or_404(SshCredential, id=credential_id)
|
credential = get_object_or_404(SshCredential, id=credential_id)
|
||||||
@@ -548,7 +655,7 @@ def delete_ssh_credential(request, credential_id: int):
|
|||||||
return redirect("ssh_credentials")
|
return redirect("ssh_credentials")
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def edit_global_config(request):
|
def edit_global_config(request):
|
||||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -574,7 +681,7 @@ def edit_global_config(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def create_host_config(request):
|
def create_host_config(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = CreateHostConfigForm(request.POST)
|
form = CreateHostConfigForm(request.POST)
|
||||||
@@ -602,7 +709,7 @@ def create_host_config(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def host_detail(request, host: str):
|
def host_detail(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||||
@@ -615,7 +722,9 @@ def host_detail(request, host: str):
|
|||||||
has_global_config = global_config is not None
|
has_global_config = global_config is not None
|
||||||
backup_gate = collect_backup_gate(host_config, global_config)
|
backup_gate = collect_backup_gate(host_config, global_config)
|
||||||
stats_summary = collect_host_stats(host=host_config, limit=10)
|
stats_summary = collect_host_stats(host=host_config, limit=10)
|
||||||
|
can_manage = request.user.is_staff
|
||||||
context = {
|
context = {
|
||||||
|
**access_context(request),
|
||||||
"host": host_config,
|
"host": host_config,
|
||||||
"schedule": schedule,
|
"schedule": schedule,
|
||||||
"retention_warning": _retention_warning_for_host(host_config, schedule),
|
"retention_warning": _retention_warning_for_host(host_config, schedule),
|
||||||
@@ -626,11 +735,11 @@ def host_detail(request, host: str):
|
|||||||
"host_check_summary": summarize_self_checks(backup_gate.checks),
|
"host_check_summary": summarize_self_checks(backup_gate.checks),
|
||||||
"backup_gate": backup_gate,
|
"backup_gate": backup_gate,
|
||||||
"last_preflight": (host_config.config or {}).get("last_preflight") if isinstance(host_config.config, dict) else {},
|
"last_preflight": (host_config.config or {}).get("last_preflight") if isinstance(host_config.config, dict) else {},
|
||||||
"effective_config": effective_host_config_preview(host_config, global_config) if global_config else {},
|
"effective_config": effective_host_config_preview(host_config, global_config) if global_config and can_manage else {},
|
||||||
"stats_summary": stats_summary,
|
"stats_summary": stats_summary,
|
||||||
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)),
|
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)),
|
||||||
"can_queue_dry_run": host_config.enabled and has_global_config and backup_gate.can_queue_dry_run and active_run is None,
|
"can_queue_dry_run": can_manage and host_config.enabled and has_global_config and backup_gate.can_queue_dry_run and active_run is None,
|
||||||
"can_queue_real_backup": host_config.enabled and has_global_config and backup_gate.can_queue_real and active_run is None,
|
"can_queue_real_backup": can_manage and host_config.enabled and has_global_config and backup_gate.can_queue_real and active_run is None,
|
||||||
"has_global_config": has_global_config,
|
"has_global_config": has_global_config,
|
||||||
"active_run": active_run,
|
"active_run": active_run,
|
||||||
"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],
|
||||||
@@ -650,7 +759,7 @@ 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
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def prepare_host_directories(request, host: str):
|
def prepare_host_directories(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -663,7 +772,7 @@ def prepare_host_directories(request, host: str):
|
|||||||
return redirect("host_detail", host=host_config.host)
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def scan_host_known_key(request, host: str):
|
def scan_host_known_key(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -685,7 +794,7 @@ def scan_host_known_key(request, host: str):
|
|||||||
return redirect("host_detail", host=host_config.host)
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def run_host_preflight(request, host: str):
|
def run_host_preflight(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -713,7 +822,7 @@ def run_host_preflight(request, host: str):
|
|||||||
return redirect("host_detail", host=host_config.host)
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def queue_manual_backup(request, host: str):
|
def queue_manual_backup(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -753,22 +862,22 @@ def queue_manual_backup(request, host: str):
|
|||||||
return redirect("run_detail", run_id=run.id)
|
return redirect("run_detail", run_id=run.id)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_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)
|
||||||
return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run))
|
return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run, request=request))
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def run_detail_live(request, run_id: int):
|
def run_detail_live(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)
|
||||||
context = _run_detail_context(run)
|
context = _run_detail_context(run, request=request)
|
||||||
response = render(request, "pobsync_backend/partials/run_detail_live.html", context)
|
response = render(request, "pobsync_backend/partials/run_detail_live.html", context)
|
||||||
response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false"
|
response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _run_detail_context(run: BackupRun) -> dict[str, object]:
|
def _run_detail_context(run: BackupRun, *, request=None) -> dict[str, object]:
|
||||||
result = run.result if isinstance(run.result, dict) else {}
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {}
|
run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {}
|
||||||
rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
|
rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
|
||||||
@@ -778,8 +887,10 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]:
|
|||||||
rsync_log_path = _run_rsync_log_path(run)
|
rsync_log_path = _run_rsync_log_path(run)
|
||||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||||
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||||
can_cancel = run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}
|
can_manage = bool(request and request.user.is_staff)
|
||||||
|
can_cancel = can_manage and run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}
|
||||||
return {
|
return {
|
||||||
|
**(access_context(request) if request is not None else {}),
|
||||||
"run": run,
|
"run": run,
|
||||||
"can_cancel": can_cancel,
|
"can_cancel": can_cancel,
|
||||||
"can_auto_refresh": can_cancel,
|
"can_auto_refresh": can_cancel,
|
||||||
@@ -810,7 +921,7 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def run_rsync_log(request, run_id: int):
|
def run_rsync_log(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)
|
||||||
log_path = _run_rsync_log_path(run)
|
log_path = _run_rsync_log_path(run)
|
||||||
@@ -819,7 +930,7 @@ def run_rsync_log(request, run_id: int):
|
|||||||
return FileResponse(log_path.open("rb"), content_type="text/plain; charset=utf-8")
|
return FileResponse(log_path.open("rb"), content_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def cancel_run(request, run_id: int):
|
def cancel_run(request, run_id: int):
|
||||||
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id)
|
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id)
|
||||||
@@ -843,7 +954,7 @@ def cancel_run(request, run_id: int):
|
|||||||
return redirect("run_detail", run_id=run.id)
|
return redirect("run_detail", run_id=run.id)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def resolve_run_review(request, run_id: int):
|
def resolve_run_review(request, run_id: int):
|
||||||
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id)
|
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id)
|
||||||
@@ -861,7 +972,7 @@ def resolve_run_review(request, run_id: int):
|
|||||||
return _redirect_after_run_review(request, run)
|
return _redirect_after_run_review(request, run)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def resolve_host_incomplete_reviews(request, host: str):
|
def resolve_host_incomplete_reviews(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -877,7 +988,7 @@ def resolve_host_incomplete_reviews(request, host: str):
|
|||||||
return redirect("host_detail", host=host_config.host)
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@status_view_required
|
||||||
def snapshot_detail(request, snapshot_id: int):
|
def snapshot_detail(request, snapshot_id: int):
|
||||||
snapshot = get_object_or_404(
|
snapshot = get_object_or_404(
|
||||||
SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"),
|
SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"),
|
||||||
@@ -895,7 +1006,7 @@ def snapshot_detail(request, snapshot_id: int):
|
|||||||
return render(request, "pobsync_backend/snapshot_detail.html", context)
|
return render(request, "pobsync_backend/snapshot_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def discover_host_snapshots(request, host: str):
|
def discover_host_snapshots(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -916,7 +1027,7 @@ def discover_host_snapshots(request, host: str):
|
|||||||
return redirect("host_detail", host=host_config.host)
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def host_retention_plan(request, host: str):
|
def host_retention_plan(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
kind = request.GET.get("kind", "scheduled")
|
kind = request.GET.get("kind", "scheduled")
|
||||||
@@ -933,6 +1044,8 @@ def host_retention_plan(request, host: str):
|
|||||||
scheduled_prune_limit = schedule.prune_max_delete if schedule and schedule.prune else None
|
scheduled_prune_limit = schedule.prune_max_delete if schedule and schedule.prune else None
|
||||||
delete_count = len(plan["delete"])
|
delete_count = len(plan["delete"])
|
||||||
incomplete_count = len(plan["incomplete"])
|
incomplete_count = len(plan["incomplete"])
|
||||||
|
incomplete_reviewed_count = int(plan.get("incomplete_reviewed_count") or 0)
|
||||||
|
incomplete_unreviewed_count = int(plan.get("incomplete_unreviewed_count") or 0)
|
||||||
context = {
|
context = {
|
||||||
"host": host_config,
|
"host": host_config,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
@@ -941,6 +1054,8 @@ def host_retention_plan(request, host: str):
|
|||||||
"schedule": schedule,
|
"schedule": schedule,
|
||||||
"scheduled_prune_limit": scheduled_prune_limit,
|
"scheduled_prune_limit": scheduled_prune_limit,
|
||||||
"scheduled_prune_exceeded": scheduled_prune_limit is not None and delete_count > scheduled_prune_limit,
|
"scheduled_prune_exceeded": scheduled_prune_limit is not None and delete_count > scheduled_prune_limit,
|
||||||
|
"incomplete_reviewed_count": incomplete_reviewed_count,
|
||||||
|
"incomplete_unreviewed_count": incomplete_unreviewed_count,
|
||||||
"apply_form": RetentionApplyForm(
|
"apply_form": RetentionApplyForm(
|
||||||
host_name=host_config.host,
|
host_name=host_config.host,
|
||||||
expected_delete_count=delete_count,
|
expected_delete_count=delete_count,
|
||||||
@@ -953,17 +1068,17 @@ def host_retention_plan(request, host: str):
|
|||||||
),
|
),
|
||||||
"incomplete_cleanup_form": IncompleteCleanupForm(
|
"incomplete_cleanup_form": IncompleteCleanupForm(
|
||||||
host_name=host_config.host,
|
host_name=host_config.host,
|
||||||
expected_delete_count=incomplete_count,
|
expected_delete_count=incomplete_reviewed_count,
|
||||||
initial={
|
initial={
|
||||||
"max_delete": incomplete_count,
|
"max_delete": incomplete_reviewed_count,
|
||||||
"confirm_delete_count": incomplete_count,
|
"confirm_delete_count": incomplete_reviewed_count,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
return render(request, "pobsync_backend/retention_plan.html", context)
|
return render(request, "pobsync_backend/retention_plan.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def apply_host_retention(request, host: str):
|
def apply_host_retention(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -1017,7 +1132,7 @@ def apply_host_retention(request, host: str):
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def cleanup_host_incomplete_snapshots(request, host: str):
|
def cleanup_host_incomplete_snapshots(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -1027,7 +1142,7 @@ def cleanup_host_incomplete_snapshots(request, host: str):
|
|||||||
messages.error(request, str(exc))
|
messages.error(request, str(exc))
|
||||||
return redirect("host_retention_plan", host=host_config.host)
|
return redirect("host_retention_plan", host=host_config.host)
|
||||||
|
|
||||||
incomplete_count = len(plan.get("incomplete") or [])
|
incomplete_count = int(plan.get("incomplete_reviewed_count") or 0)
|
||||||
form = IncompleteCleanupForm(
|
form = IncompleteCleanupForm(
|
||||||
request.POST,
|
request.POST,
|
||||||
host_name=host_config.host,
|
host_name=host_config.host,
|
||||||
@@ -1052,7 +1167,7 @@ def cleanup_host_incomplete_snapshots(request, host: str):
|
|||||||
return redirect("host_retention_plan", host=host_config.host)
|
return redirect("host_retention_plan", host=host_config.host)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def edit_host_config(request, host: str):
|
def edit_host_config(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||||
@@ -1078,7 +1193,7 @@ def edit_host_config(request, host: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@control_panel_admin_required
|
||||||
def edit_host_schedule(request, host: str):
|
def edit_host_schedule(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
schedule = _schedule_for_host(host_config)
|
schedule = _schedule_for_host(host_config)
|
||||||
@@ -1227,6 +1342,7 @@ def _default_manual_backup_initial(host_config: HostConfig) -> dict[str, object]
|
|||||||
schedule = _schedule_for_host(host_config)
|
schedule = _schedule_for_host(host_config)
|
||||||
return {
|
return {
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
|
"verbose_output": True,
|
||||||
"prune": bool(schedule.prune) if schedule else False,
|
"prune": bool(schedule.prune) if schedule else False,
|
||||||
"prune_max_delete": schedule.prune_max_delete if schedule else 10,
|
"prune_max_delete": schedule.prune_max_delete if schedule else 10,
|
||||||
"prune_protect_bases": bool(schedule.prune_protect_bases) if schedule else False,
|
"prune_protect_bases": bool(schedule.prune_protect_bases) if schedule else False,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"pobsync_backend.context_processors.pobsync_access",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -55,6 +56,8 @@ TEMPLATES = [
|
|||||||
|
|
||||||
WSGI_APPLICATION = "pobsync_server.wsgi.application"
|
WSGI_APPLICATION = "pobsync_server.wsgi.application"
|
||||||
|
|
||||||
|
LOGIN_URL = "/admin/login/"
|
||||||
|
|
||||||
|
|
||||||
def _database_config() -> dict[str, object]:
|
def _database_config() -> dict[str, object]:
|
||||||
engine = os.getenv("POBSYNC_DB_ENGINE", "sqlite").strip().lower()
|
engine = os.getenv("POBSYNC_DB_ENGINE", "sqlite").strip().lower()
|
||||||
@@ -102,3 +105,7 @@ POBSYNC_BACKUP_ROOT = os.getenv("POBSYNC_BACKUP_ROOT", "/backups")
|
|||||||
POBSYNC_ENV_FILE = os.getenv("POBSYNC_ENV_FILE", "/etc/pobsync/pobsync.env")
|
POBSYNC_ENV_FILE = os.getenv("POBSYNC_ENV_FILE", "/etc/pobsync/pobsync.env")
|
||||||
POBSYNC_SERVICE_USER = os.getenv("POBSYNC_SERVICE_USER", "pobsync")
|
POBSYNC_SERVICE_USER = os.getenv("POBSYNC_SERVICE_USER", "pobsync")
|
||||||
POBSYNC_SERVICE_GROUP = os.getenv("POBSYNC_SERVICE_GROUP", "pobsync")
|
POBSYNC_SERVICE_GROUP = os.getenv("POBSYNC_SERVICE_GROUP", "pobsync")
|
||||||
|
POBSYNC_UPDATE_RELEASES_URL = os.getenv("POBSYNC_UPDATE_RELEASES_URL", "")
|
||||||
|
POBSYNC_UPDATE_RELEASES_TOKEN = os.getenv("POBSYNC_UPDATE_RELEASES_TOKEN", "")
|
||||||
|
POBSYNC_UPDATE_GIT_REMOTE = os.getenv("POBSYNC_UPDATE_GIT_REMOTE", "origin")
|
||||||
|
POBSYNC_UPDATE_COMMAND = os.getenv("POBSYNC_UPDATE_COMMAND", "sudo -n scripts/update-systemd")
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ urlpatterns = [
|
|||||||
path("changelog/", views.changelog, name="changelog"),
|
path("changelog/", views.changelog, name="changelog"),
|
||||||
path("self-check/", views.self_check, name="self_check"),
|
path("self-check/", views.self_check, name="self_check"),
|
||||||
path("logs/", views.logs, name="logs"),
|
path("logs/", views.logs, name="logs"),
|
||||||
|
path("updater/", views.updater, name="updater"),
|
||||||
|
path("notifications/", views.notification_targets, name="notification_targets"),
|
||||||
|
path("notifications/new/", views.create_notification_target, name="create_notification_target"),
|
||||||
|
path("notifications/<int:target_id>/", views.edit_notification_target, name="edit_notification_target"),
|
||||||
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
||||||
path("schedules/", views.schedules_list, name="schedules_list"),
|
path("schedules/", views.schedules_list, name="schedules_list"),
|
||||||
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||||
|
|||||||
Reference in New Issue
Block a user