From 90f28410ce8940b77df085c4c11e5e52b1435e1f Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 19:11:57 +0200 Subject: [PATCH] (feature) Add host doctor checks and Django log viewer Add host-level checks for address, enabled state, SSH credential selection, and backup directory readiness, and show them on the host detail page. Create host backup directories during host creation and prefill new hosts from the default global config. Add a staff-only logs view backed by journalctl with filtering by pobsync unit, priority, and message text. Improve runtime checks for gunicorn in virtualenv installs and ensure the native installer grants the service user access to the backup root. --- README.md | 2 + scripts/install-systemd | 2 +- src/pobsync_backend/host_ops.py | 69 ++++++++++++++++ src/pobsync_backend/self_check.py | 11 ++- .../templates/pobsync_backend/base.html | 1 + .../pobsync_backend/host_detail.html | 30 +++++++ .../templates/pobsync_backend/logs.html | 52 ++++++++++++ src/pobsync_backend/tests/test_views.py | 74 +++++++++++++++++ src/pobsync_backend/views.py | 79 +++++++++++++++++++ src/pobsync_server/urls.py | 1 + 10 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 src/pobsync_backend/host_ops.py create mode 100644 src/pobsync_backend/templates/pobsync_backend/logs.html diff --git a/README.md b/README.md index 5653078..3683a87 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,11 @@ The UI includes: - schedule editing - manual backup queueing - snapshot discovery +- host checks for backup directories and SSH readiness - SQL retention planning and apply flow - Django-managed SSH keys - `/self-check/` for runtime checks +- `/logs/` for filtered pobsync service logs ## SSH Keys diff --git a/scripts/install-systemd b/scripts/install-systemd index 19628d9..4fc5a99 100755 --- a/scripts/install-systemd +++ b/scripts/install-systemd @@ -344,7 +344,7 @@ else fi run_step "Prepare directories" mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT" -run_step "Set state directory permissions" chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync +run_step "Set state directory permissions" chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT" run_step "Set private directory modes" chmod 0750 /var/lib/pobsync /var/log/pobsync if [ "$SOURCE_DIR" != "$APP_DIR" ]; then diff --git a/src/pobsync_backend/host_ops.py b/src/pobsync_backend/host_ops.py new file mode 100644 index 0000000..d290bd8 --- /dev/null +++ b/src/pobsync_backend/host_ops.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from pobsync.snapshot_meta import resolve_host_root + +from .models import GlobalConfig, HostConfig +from .self_check import SelfCheck + + +HOST_BACKUP_SUBDIRS = ("scheduled", "manual", ".incomplete") + + +def ensure_host_directories(host: HostConfig, global_config: GlobalConfig | None = None) -> Path: + global_config = global_config or GlobalConfig.objects.get(name="default") + host_root = resolve_host_root(global_config.backup_root, host.host) + for subdir in HOST_BACKUP_SUBDIRS: + (host_root / subdir).mkdir(parents=True, exist_ok=True) + return host_root + + +def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = None) -> list[SelfCheck]: + checks: list[SelfCheck] = [] + try: + global_config = global_config or GlobalConfig.objects.get(name="default") + except GlobalConfig.DoesNotExist: + return [SelfCheck("Host global config", "failed", "Default global config does not exist.")] + + checks.append( + SelfCheck( + "Host enabled", + "ok" if host.enabled else "warning", + "Host is enabled." if host.enabled else "Host is disabled.", + ) + ) + checks.append( + SelfCheck( + "Host address", + "ok" if host.address.strip() else "failed", + host.address.strip() or "Host address is empty.", + ) + ) + + credential = host.ssh_credential or global_config.default_ssh_credential + checks.append( + SelfCheck( + "Host SSH credential", + "ok" if credential else "warning", + str(credential) if credential else "No host or global SSH credential selected.", + ) + ) + + host_root = resolve_host_root(global_config.backup_root, host.host) + checks.append(_host_path_check("Host backup root", host_root, must_exist=True, must_be_writable=True)) + for subdir in HOST_BACKUP_SUBDIRS: + checks.append(_host_path_check(f"Host directory: {subdir}", host_root / subdir, must_exist=True, must_be_writable=True)) + return checks + + +def _host_path_check(name: str, path: Path, *, must_exist: bool, must_be_writable: bool) -> SelfCheck: + if must_exist and not path.exists(): + return SelfCheck(name, "failed", f"{path} does not exist.") + target = path if path.exists() else path.parent + if not target.exists(): + return SelfCheck(name, "failed", f"{target} does not exist.") + if must_be_writable and not os.access(target, os.W_OK): + return SelfCheck(name, "failed", f"{target} is not writable by this process.") + return SelfCheck(name, "ok", str(path)) diff --git a/src/pobsync_backend/self_check.py b/src/pobsync_backend/self_check.py index 325a7e5..10c5376 100644 --- a/src/pobsync_backend/self_check.py +++ b/src/pobsync_backend/self_check.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import shutil import subprocess +import sys from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -125,7 +126,7 @@ def _path_check( def _binary_checks() -> list[SelfCheck]: checks = [] - for binary in ("rsync", "ssh", "ssh-keygen", "gunicorn"): + for binary in ("rsync", "ssh", "ssh-keygen"): path = shutil.which(binary) checks.append( SelfCheck( @@ -134,6 +135,14 @@ def _binary_checks() -> list[SelfCheck]: path or f"{binary} was not found in PATH.", ) ) + gunicorn_path = shutil.which("gunicorn") or Path(sys.executable).parent / "gunicorn" + checks.append( + SelfCheck( + "Binary: gunicorn", + "ok" if Path(gunicorn_path).exists() else "failed", + str(gunicorn_path) if Path(gunicorn_path).exists() else "gunicorn was not found in PATH or next to Python.", + ) + ) return checks diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 83ecccd..4f26055 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -174,6 +174,7 @@ Admin SSH Keys Self Check + Logs Status API {{ request.user.username }} diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 49751e7..0d0aabc 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -70,6 +70,36 @@ +
+

Host Check

+
+
OK
{{ host_check_summary.ok }}
+
Warnings
{{ host_check_summary.warning }}
+
Failed
{{ host_check_summary.failed }}
+
Skipped
{{ host_check_summary.skipped }}
+
+ + + + + + + + + + + {% for check in host_checks %} + + + + + + + {% endfor %} + +
StatusCheckMessageDetail
{{ check.status }}{{ check.name }}{{ check.message }}{{ check.detail }}
+
+

Backup Control

diff --git a/src/pobsync_backend/templates/pobsync_backend/logs.html b/src/pobsync_backend/templates/pobsync_backend/logs.html new file mode 100644 index 0000000..ba5adae --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/logs.html @@ -0,0 +1,52 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Logs | pobsync{% endblock %} + +{% block content %} +

Logs

+ +
+ Back to dashboard +
+ +
+

Filter

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Messages

+ {% if error %} +

{{ error }}

+ {% else %} +
{% for line in lines %}{{ line }}
+{% empty %}No log messages matched the current filter.
+{% endfor %}
+ {% endif %} +
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 8f6cf91..c4a9275 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -1,5 +1,6 @@ from __future__ import annotations +import subprocess from datetime import datetime, timezone from pathlib import Path from tempfile import TemporaryDirectory @@ -95,6 +96,27 @@ class ViewTests(TestCase): self.assertContains(response, "Database connection") self.assertContains(response, "POBSYNC_HOME") + def test_logs_view_renders_filtered_journal_messages(self) -> None: + self.client.force_login(self.staff_user) + completed = subprocess.CompletedProcess( + args=["journalctl"], + returncode=0, + stdout="2026-05-19 pobsync-worker.service failed backup\n2026-05-19 pobsync-web.service started\n", + stderr="", + ) + + with patch("pobsync_backend.views.shutil.which", return_value="/usr/bin/journalctl"), patch( + "pobsync_backend.views.subprocess.run", return_value=completed + ) as run: + response = self.client.get(reverse("logs"), {"unit": "pobsync-worker.service", "priority": "0..3", "q": "failed"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Logs") + self.assertContains(response, "failed backup") + self.assertNotContains(response, "started") + self.assertIn("-u", run.call_args.args[0]) + self.assertIn("pobsync-worker.service", run.call_args.args[0]) + def test_ssh_credentials_view_creates_key(self) -> None: self.client.force_login(self.staff_user) @@ -352,6 +374,57 @@ class ViewTests(TestCase): self.assertEqual(host.rsync_extra_args, ["--numeric-ids"]) self.assertEqual(host.retention_weekly, 4) + def test_create_host_config_uses_global_defaults_and_prepares_directories(self) -> None: + self.client.force_login(self.staff_user) + credential = SshCredential.objects.create(name="global-key", private_key="PRIVATE KEY") + with TemporaryDirectory() as tmp: + backup_root = Path(tmp) / "backups" + GlobalConfig.objects.create( + name="default", + backup_root=str(backup_root), + default_ssh_credential=credential, + ssh_user="backup", + ssh_port=2222, + default_source_root="/srv", + retention_daily=3, + retention_weekly=2, + retention_monthly=1, + retention_yearly=0, + ) + + get_response = self.client.get(reverse("create_host_config")) + self.assertContains(get_response, 'value="backup"') + self.assertContains(get_response, 'value="2222"') + self.assertContains(get_response, 'value="/srv"') + + response = self.client.post( + reverse("create_host_config"), + { + "host": "web-01", + "address": "web-01.example.test", + "enabled": "on", + "ssh_credential": str(credential.id), + "ssh_user": "backup", + "ssh_port": "2222", + "source_root": "/srv", + "includes": "", + "excludes_add": "", + "excludes_replace": "", + "rsync_extra_args": "", + "retention_daily": "3", + "retention_weekly": "2", + "retention_monthly": "1", + "retention_yearly": "0", + }, + follow=True, + ) + + self.assertRedirects(response, reverse("host_detail", args=["web-01"])) + self.assertContains(response, "prepared") + self.assertTrue((backup_root / "web-01" / "scheduled").is_dir()) + self.assertTrue((backup_root / "web-01" / "manual").is_dir()) + self.assertTrue((backup_root / "web-01" / ".incomplete").is_dir()) + def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None: self.client.force_login(self.staff_user) GlobalConfig.objects.create(name="default", backup_root="/backups") @@ -378,6 +451,7 @@ class ViewTests(TestCase): self.assertContains(response, "Backup Control") self.assertContains(response, "Queue dry-run") self.assertContains(response, "Queue backup") + self.assertContains(response, "Host Check") self.assertContains(response, "ready") self.assertContains(response, "Snapshot Discovery") self.assertContains(response, reverse("queue_manual_backup", args=[host.host])) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index df49719..b0b5837 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import shutil +import subprocess from pathlib import Path from django.contrib import messages @@ -22,6 +24,7 @@ from .forms import ( ScheduleConfigForm, SshCredentialForm, ) +from .host_ops import collect_host_checks, ensure_host_directories from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential from .retention import run_sql_retention_apply, run_sql_retention_plan from .self_check import collect_self_checks, summarize_self_checks @@ -72,6 +75,12 @@ def self_check(request): ) +@staff_member_required +def logs(request): + context = _log_context(request) + return render(request, "pobsync_backend/logs.html", context) + + @staff_member_required def ssh_credentials(request): context = { @@ -152,6 +161,13 @@ def create_host_config(request): form = CreateHostConfigForm(request.POST) if form.is_valid(): host_config = form.save() + try: + host_root = ensure_host_directories(host_config) + except Exception as exc: + messages.warning(request, f"Host config created, but backup directories could not be prepared: {exc}") + else: + messages.success(request, f"Host config created for {host_config.host}; prepared {host_root}.") + return redirect("host_detail", host=host_config.host) messages.success(request, f"Host config created for {host_config.host}.") return redirect("host_detail", host=host_config.host) else: @@ -176,10 +192,13 @@ def host_detail(request, host: str): status__in=[BackupRun.Status.QUEUED, BackupRun.Status.RUNNING] ).order_by("created_at", "id").first() has_global_config = GlobalConfig.objects.filter(name="default").exists() + host_checks = collect_host_checks(host_config) context = { "host": host_config, "schedule": _schedule_for_host(host_config), "discovery": inspect_snapshot_discovery(host=host_config), + "host_checks": host_checks, + "host_check_summary": summarize_self_checks(host_checks), "manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)), "can_queue_backup": host_config.enabled and has_global_config, "has_global_config": has_global_config, @@ -414,6 +433,19 @@ def _default_global_initial() -> dict[str, object]: def _default_host_initial() -> dict[str, object]: + global_config = GlobalConfig.objects.filter(name="default").first() + if global_config is not None: + return { + "enabled": True, + "ssh_credential": global_config.default_ssh_credential, + "ssh_user": global_config.ssh_user, + "ssh_port": global_config.ssh_port, + "source_root": global_config.default_source_root, + "retention_daily": global_config.retention_daily, + "retention_weekly": global_config.retention_weekly, + "retention_monthly": global_config.retention_monthly, + "retention_yearly": global_config.retention_yearly, + } return { "enabled": True, "retention_daily": 14, @@ -435,3 +467,50 @@ def _default_manual_backup_initial(host_config: HostConfig) -> dict[str, object] def _pretty_json(value: object) -> str: return json.dumps(value or {}, indent=2, sort_keys=True) + + +def _log_context(request) -> dict[str, object]: + units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service") + priorities = { + "": "All", + "0..3": "Errors", + "4": "Warnings", + "5": "Notices", + "6": "Info", + "7": "Debug", + } + selected_unit = request.GET.get("unit", "") + priority = request.GET.get("priority", "0..4") + query = request.GET.get("q", "").strip() + lines = [] + error = "" + + if shutil.which("journalctl") is None: + error = "journalctl is not available in this runtime." + else: + command = ["journalctl", "--no-pager", "-n", "300", "-o", "short-iso"] + if selected_unit in units: + command.extend(["-u", selected_unit]) + else: + for unit in units: + command.extend(["-u", unit]) + if priority: + command.extend(["-p", priority]) + result = subprocess.run(command, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10) + if result.returncode != 0: + error = result.stderr.strip() or "Could not read journal logs." + else: + lines = result.stdout.splitlines() + if query: + lowered_query = query.lower() + lines = [line for line in lines if lowered_query in line.lower()] + + return { + "units": units, + "priorities": priorities, + "selected_unit": selected_unit, + "selected_priority": priority, + "query": query, + "lines": lines, + "error": error, + } diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index b5754e6..20d3ad7 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -9,6 +9,7 @@ from pobsync_backend import api, views urlpatterns = [ path("", views.dashboard, name="dashboard"), path("self-check/", views.self_check, name="self_check"), + path("logs/", views.logs, name="logs"), path("config/global/", views.edit_global_config, name="edit_global_config"), path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"), path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),