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 +
+ {% csrf_token %} + +
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"),