(bugfix) Grant service user backup and journal access

Update the native installer so the pobsync service user gets journal
read access when the host exposes systemd-journal or adm groups.

Apply ownership and private directory modes to the configured backup
root, and reuse the existing environment backup root on reinstall so
production updates do not fall back to /backups.

Add a self-check for journal access and a host detail action that can
prepare missing backup directories for existing host configurations.
This commit is contained in:
2026-05-19 19:25:05 +02:00
parent 90f28410ce
commit ccacad3d37
8 changed files with 122 additions and 1 deletions

View File

@@ -133,6 +133,7 @@ The UI includes:
- manual backup queueing - manual backup queueing
- snapshot discovery - snapshot discovery
- host checks for backup directories and SSH readiness - host checks for backup directories and SSH readiness
- host directory preparation for new or existing hosts
- SQL retention planning and apply flow - SQL retention planning and apply flow
- Django-managed SSH keys - Django-managed SSH keys
- `/self-check/` for runtime checks - `/self-check/` for runtime checks

View File

@@ -12,6 +12,10 @@ SERVER_NAME=${POBSYNC_SERVER_NAME:-_}
ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1} ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1}
CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-} CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-}
BACKUP_ROOT=${POBSYNC_BACKUP_ROOT:-/backups} BACKUP_ROOT=${POBSYNC_BACKUP_ROOT:-/backups}
BACKUP_ROOT_EXPLICIT=0
if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then
BACKUP_ROOT_EXPLICIT=1
fi
WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010} WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010}
FORCE_ENV=0 FORCE_ENV=0
INSTALL_OS_PACKAGES=1 INSTALL_OS_PACKAGES=1
@@ -55,6 +59,7 @@ while [ "$#" -gt 0 ]; do
;; ;;
--backup-root) --backup-root)
BACKUP_ROOT=$2 BACKUP_ROOT=$2
BACKUP_ROOT_EXPLICIT=1
shift 2 shift 2
;; ;;
--allowed-hosts) --allowed-hosts)
@@ -133,6 +138,16 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1 exit 1
fi fi
if [ -f "$ENV_FILE" ] && [ "$FORCE_ENV" -ne 1 ] && [ "$BACKUP_ROOT_EXPLICIT" -ne 1 ]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then
BACKUP_ROOT=$POBSYNC_BACKUP_ROOT
fi
fi
run_step() { run_step() {
label=$1 label=$1
shift shift
@@ -343,9 +358,18 @@ else
note_step "Create service user" "OK" note_step "Create service user" "OK"
fi fi
grant_journal_access() {
for group in systemd-journal adm; do
if getent group "$group" >/dev/null 2>&1; then
usermod -a -G "$group" "$SERVICE_USER"
fi
done
}
run_step "Grant journal access" grant_journal_access
run_step "Prepare directories" mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT" 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 "$BACKUP_ROOT" 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 run_step "Set private directory modes" chmod 0750 /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT"
if [ "$SOURCE_DIR" != "$APP_DIR" ]; then if [ "$SOURCE_DIR" != "$APP_DIR" ]; then
run_step "Sync application files" rsync -a --delete \ run_step "Sync application files" rsync -a --delete \

View File

@@ -206,4 +206,24 @@ def _systemd_checks() -> list[SelfCheck]:
active_state, active_state,
) )
) )
if shutil.which("journalctl") is not None:
result = subprocess.run(
["journalctl", "--no-pager", "-n", "1", "-u", "pobsync-web.service"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
journal_error = result.stderr.strip()
journal_denied = "No journal files were opened" in journal_error or "permission" in journal_error.lower()
has_journal_access = result.returncode == 0 and not journal_denied
checks.append(
SelfCheck(
"Journal access",
"ok" if has_journal_access else "failed",
"pobsync can read service logs." if has_journal_access else "pobsync cannot read service logs.",
journal_error,
)
)
return checks return checks

View File

@@ -13,6 +13,10 @@
</form> </form>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a> <a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a> <a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Prepare directories</button>
</form>
</section> </section>
<section class="grid" aria-label="Host summary"> <section class="grid" aria-label="Host summary">

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import subprocess
from unittest.mock import patch
from django.test import SimpleTestCase
from pobsync_backend.self_check import _systemd_checks
class SystemdSelfCheckTests(SimpleTestCase):
def test_journal_permission_hint_is_reported_as_failure(self) -> None:
def which(binary: str) -> str | None:
if binary in {"systemctl", "journalctl"}:
return f"/usr/bin/{binary}"
return None
active_result = subprocess.CompletedProcess(
args=["systemctl"],
returncode=0,
stdout="active\n",
stderr="",
)
journal_result = subprocess.CompletedProcess(
args=["journalctl"],
returncode=0,
stdout="",
stderr="No journal files were opened due to insufficient permissions.",
)
with patch("pobsync_backend.self_check.Path.exists", return_value=True), patch(
"pobsync_backend.self_check.shutil.which",
side_effect=which,
), patch(
"pobsync_backend.self_check.subprocess.run",
side_effect=[active_result, active_result, active_result, journal_result],
):
checks = _systemd_checks()
journal_check = next(check for check in checks if check.name == "Journal access")
self.assertEqual(journal_check.status, "failed")
self.assertEqual(journal_check.message, "pobsync cannot read service logs.")

View File

@@ -452,12 +452,28 @@ class ViewTests(TestCase):
self.assertContains(response, "Queue dry-run") self.assertContains(response, "Queue dry-run")
self.assertContains(response, "Queue backup") self.assertContains(response, "Queue backup")
self.assertContains(response, "Host Check") self.assertContains(response, "Host Check")
self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
self.assertContains(response, "ready") self.assertContains(response, "ready")
self.assertContains(response, "Snapshot Discovery") self.assertContains(response, "Snapshot Discovery")
self.assertContains(response, reverse("queue_manual_backup", args=[host.host])) self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id])) self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
def test_prepare_host_directories_action_creates_missing_directories(self) -> None:
self.client.force_login(self.staff_user)
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")
response = self.client.post(reverse("prepare_host_directories", args=[host.host]), follow=True)
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
self.assertContains(response, "Prepared backup directories")
self.assertTrue((backup_root / host.host / "scheduled").is_dir())
self.assertTrue((backup_root / host.host / "manual").is_dir())
self.assertTrue((backup_root / host.host / ".incomplete").is_dir())
def test_host_detail_surfaces_active_backup_run(self) -> None: def test_host_detail_surfaces_active_backup_run(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/backups") GlobalConfig.objects.create(name="default", backup_root="/backups")

View File

@@ -217,6 +217,19 @@ def host_detail(request, host: str):
return render(request, "pobsync_backend/host_detail.html", context) return render(request, "pobsync_backend/host_detail.html", context)
@staff_member_required
@require_POST
def prepare_host_directories(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
try:
host_root = ensure_host_directories(host_config)
except Exception as exc:
messages.error(request, f"Could not prepare backup directories for {host_config.host}: {exc}")
else:
messages.success(request, f"Prepared backup directories for {host_config.host}: {host_root}")
return redirect("host_detail", host=host_config.host)
@staff_member_required @staff_member_required
@require_POST @require_POST
def queue_manual_backup(request, host: str): def queue_manual_backup(request, host: str):

View File

@@ -17,6 +17,7 @@ urlpatterns = [
path("hosts/new/", views.create_host_config, name="create_host_config"), path("hosts/new/", views.create_host_config, name="create_host_config"),
path("hosts/<str:host>/", views.host_detail, name="host_detail"), path("hosts/<str:host>/", views.host_detail, name="host_detail"),
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"), path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
path("hosts/<str:host>/prepare-directories/", views.prepare_host_directories, name="prepare_host_directories"),
path("hosts/<str:host>/queue-backup/", views.queue_manual_backup, name="queue_manual_backup"), path("hosts/<str:host>/queue-backup/", views.queue_manual_backup, name="queue_manual_backup"),
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
path("hosts/<str:host>/retention-apply/", views.apply_host_retention, name="apply_host_retention"), path("hosts/<str:host>/retention-apply/", views.apply_host_retention, name="apply_host_retention"),