(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:
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
42
src/pobsync_backend/tests/test_self_check.py
Normal file
42
src/pobsync_backend/tests/test_self_check.py
Normal 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.")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user