diff --git a/README.md b/README.md
index 3683a87..1c3eeef 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,7 @@ The UI includes:
- manual backup queueing
- snapshot discovery
- host checks for backup directories and SSH readiness
+- host directory preparation for new or existing hosts
- SQL retention planning and apply flow
- Django-managed SSH keys
- `/self-check/` for runtime checks
diff --git a/scripts/install-systemd b/scripts/install-systemd
index 4fc5a99..422dc8e 100755
--- a/scripts/install-systemd
+++ b/scripts/install-systemd
@@ -12,6 +12,10 @@ SERVER_NAME=${POBSYNC_SERVER_NAME:-_}
ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1}
CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-}
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}
FORCE_ENV=0
INSTALL_OS_PACKAGES=1
@@ -55,6 +59,7 @@ while [ "$#" -gt 0 ]; do
;;
--backup-root)
BACKUP_ROOT=$2
+ BACKUP_ROOT_EXPLICIT=1
shift 2
;;
--allowed-hosts)
@@ -133,6 +138,16 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1
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() {
label=$1
shift
@@ -343,9 +358,18 @@ else
note_step "Create service user" "OK"
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 "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
run_step "Sync application files" rsync -a --delete \
diff --git a/src/pobsync_backend/self_check.py b/src/pobsync_backend/self_check.py
index 10c5376..d354c57 100644
--- a/src/pobsync_backend/self_check.py
+++ b/src/pobsync_backend/self_check.py
@@ -206,4 +206,24 @@ def _systemd_checks() -> list[SelfCheck]:
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
diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html
index 0d0aabc..43a2d5a 100644
--- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html
+++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html
@@ -13,6 +13,10 @@
Plan retention
Edit schedule
+
diff --git a/src/pobsync_backend/tests/test_self_check.py b/src/pobsync_backend/tests/test_self_check.py
new file mode 100644
index 0000000..c985e2e
--- /dev/null
+++ b/src/pobsync_backend/tests/test_self_check.py
@@ -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.")
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py
index c4a9275..5f8e6aa 100644
--- a/src/pobsync_backend/tests/test_views.py
+++ b/src/pobsync_backend/tests/test_views.py
@@ -452,12 +452,28 @@ class ViewTests(TestCase):
self.assertContains(response, "Queue dry-run")
self.assertContains(response, "Queue backup")
self.assertContains(response, "Host Check")
+ self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
self.assertContains(response, "ready")
self.assertContains(response, "Snapshot Discovery")
self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
+ 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:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/backups")
diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py
index b0b5837..22a6b40 100644
--- a/src/pobsync_backend/views.py
+++ b/src/pobsync_backend/views.py
@@ -217,6 +217,19 @@ def host_detail(request, host: str):
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
@require_POST
def queue_manual_backup(request, host: str):
diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py
index 20d3ad7..7ea2a71 100644
--- a/src/pobsync_server/urls.py
+++ b/src/pobsync_server/urls.py
@@ -17,6 +17,7 @@ urlpatterns = [
path("hosts/new/", views.create_host_config, name="create_host_config"),
path("hosts//", views.host_detail, name="host_detail"),
path("hosts//config/", views.edit_host_config, name="edit_host_config"),
+ path("hosts//prepare-directories/", views.prepare_host_directories, name="prepare_host_directories"),
path("hosts//queue-backup/", views.queue_manual_backup, name="queue_manual_backup"),
path("hosts//discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
path("hosts//retention-apply/", views.apply_host_retention, name="apply_host_retention"),