From 372a857f154fe0a85e2837cf618a8e7943beddc9 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 16:05:03 +0200 Subject: [PATCH] (feature) Add full native installer and self-check page Expand the systemd installer so it can perform a complete native installation with sensible defaults: copy the checkout into the target app directory, create runtime directories, write the environment file, install dependencies, configure systemd units, and optionally configure nginx. Add a staff-only Django self-check page that verifies runtime settings, required binaries, writable paths, database connectivity, global config state, and systemd service status when available. Document installer overrides and expose the self-check from the main navigation. --- README.md | 27 ++- scripts/install-systemd | 84 +++++++- src/pobsync_backend/self_check.py | 200 ++++++++++++++++++ .../templates/pobsync_backend/base.html | 4 + .../templates/pobsync_backend/self_check.html | 42 ++++ src/pobsync_backend/tests/test_views.py | 11 + src/pobsync_backend/views.py | 14 ++ src/pobsync_server/urls.py | 1 + 8 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 src/pobsync_backend/self_check.py create mode 100644 src/pobsync_backend/templates/pobsync_backend/self_check.html diff --git a/README.md b/README.md index efe37c6..600a153 100644 --- a/README.md +++ b/README.md @@ -131,14 +131,29 @@ Install OS packages first: apt install python3 python3-venv rsync openssh-client ``` -Clone or update the app at `/opt/pobsync/app`, then run: +From a checked-out copy of this repository, run: ``` -cd /opt/pobsync/app sudo scripts/install-systemd ``` -The installer creates: +By default the installer copies the checkout to `/opt/pobsync/app`, creates `/opt/pobsync/venv`, writes +`/etc/pobsync/pobsync.env`, creates `/var/lib/pobsync` and `/backups`, installs dependencies, runs migrations, collects +static files, and starts the services. + +Common overrides: + +``` +sudo scripts/install-systemd \ + --app-dir /opt/pobsync/app \ + --backup-root /mnt/backups/pobsync \ + --allowed-hosts backup.example.com,localhost,127.0.0.1 \ + --csrf-trusted-origins https://backup.example.com +``` + +Use `--force-env` when you intentionally want the installer to rewrite an existing `/etc/pobsync/pobsync.env`. + +The installer creates or updates: - `pobsync-web.service` for Gunicorn on `127.0.0.1:8010` - `pobsync-worker.service` for queued backup runs @@ -167,12 +182,14 @@ systemctl status pobsync-web pobsync-worker pobsync-scheduler journalctl -u pobsync-worker -f ``` +The Django UI also has a staff-only `/self-check/` page that verifies runtime settings, required binaries, writable +paths, database connectivity, global config state, and systemd service state when systemd is available. + Update an existing native install: ``` -cd /opt/pobsync/app git pull -sudo scripts/install-systemd +sudo scripts/install-systemd --app-dir /opt/pobsync/app ``` Use an existing reverse proxy by forwarding to `http://127.0.0.1:8010`. To install a simple nginx site file as a diff --git a/scripts/install-systemd b/scripts/install-systemd index be385fe..da2c0bf 100755 --- a/scripts/install-systemd +++ b/scripts/install-systemd @@ -1,17 +1,67 @@ #!/bin/sh set -eu -APP_DIR=${POBSYNC_APP_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)} +SOURCE_DIR=${POBSYNC_SOURCE_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)} +APP_DIR=${POBSYNC_APP_DIR:-/opt/pobsync/app} VENV_DIR=${POBSYNC_VENV_DIR:-/opt/pobsync/venv} ENV_FILE=${POBSYNC_ENV_FILE:-/etc/pobsync/pobsync.env} SERVICE_USER=${POBSYNC_SERVICE_USER:-pobsync} SERVICE_GROUP=${POBSYNC_SERVICE_GROUP:-pobsync} INSTALL_EXTRAS=${POBSYNC_INSTALL_EXTRAS:-} 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} +WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010} +FORCE_ENV=0 WITH_NGINX=0 while [ "$#" -gt 0 ]; do case "$1" in + --source-dir) + SOURCE_DIR=$2 + shift 2 + ;; + --app-dir) + APP_DIR=$2 + shift 2 + ;; + --venv-dir) + VENV_DIR=$2 + shift 2 + ;; + --env-file) + ENV_FILE=$2 + shift 2 + ;; + --service-user) + SERVICE_USER=$2 + shift 2 + ;; + --service-group) + SERVICE_GROUP=$2 + shift 2 + ;; + --backup-root) + BACKUP_ROOT=$2 + shift 2 + ;; + --allowed-hosts) + ALLOWED_HOSTS=$2 + shift 2 + ;; + --csrf-trusted-origins) + CSRF_TRUSTED_ORIGINS=$2 + shift 2 + ;; + --web-bind) + WEB_BIND=$2 + shift 2 + ;; + --force-env) + FORCE_ENV=1 + shift + ;; --with-nginx) WITH_NGINX=1 shift @@ -47,6 +97,11 @@ if ! command -v ssh >/dev/null 2>&1; then exit 1 fi +if [ ! -f "$SOURCE_DIR/manage.py" ]; then + echo "Source directory does not look like a pobsync checkout: $SOURCE_DIR" >&2 + exit 1 +fi + if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then groupadd --system "$SERVICE_GROUP" fi @@ -55,28 +110,39 @@ if ! id "$SERVICE_USER" >/dev/null 2>&1; then useradd --system --home /var/lib/pobsync --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER" fi -mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" +mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT" chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync chmod 0750 /var/lib/pobsync /var/log/pobsync +if [ "$SOURCE_DIR" != "$APP_DIR" ]; then + rsync -a --delete \ + --exclude .git \ + --exclude .venv \ + --exclude __pycache__ \ + --exclude .pytest_cache \ + --exclude .mypy_cache \ + --exclude var \ + "$SOURCE_DIR"/ "$APP_DIR"/ +fi + python3 -m venv "$VENV_DIR" "$VENV_DIR/bin/python" -m pip install --upgrade pip "$VENV_DIR/bin/python" -m pip install -e "$APP_DIR$INSTALL_EXTRAS" -if [ ! -f "$ENV_FILE" ]; then +if [ ! -f "$ENV_FILE" ] || [ "$FORCE_ENV" -eq 1 ]; then secret=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))") cat > "$ENV_FILE" < list[SelfCheck]: + checks: list[SelfCheck] = [] + checks.extend(_django_checks()) + checks.extend(_path_checks()) + checks.extend(_binary_checks()) + checks.extend(_database_checks()) + checks.extend(_config_checks()) + checks.extend(_systemd_checks()) + return checks + + +def summarize_self_checks(checks: list[SelfCheck]) -> dict[str, int]: + return { + "ok": sum(1 for check in checks if check.status == "ok"), + "warning": sum(1 for check in checks if check.status == "warning"), + "failed": sum(1 for check in checks if check.status == "failed"), + "skipped": sum(1 for check in checks if check.status == "skipped"), + } + + +def _django_checks() -> list[SelfCheck]: + checks = [ + SelfCheck( + "Django debug", + "warning" if settings.DEBUG else "ok", + "DEBUG is enabled." if settings.DEBUG else "DEBUG is disabled.", + ), + SelfCheck( + "Django secret key", + "failed" if settings.SECRET_KEY == "dev-only-change-me" else "ok", + "Default development secret key is still active." + if settings.SECRET_KEY == "dev-only-change-me" + else "Secret key is configured.", + ), + SelfCheck( + "Allowed hosts", + "ok" if settings.ALLOWED_HOSTS else "failed", + ", ".join(settings.ALLOWED_HOSTS) if settings.ALLOWED_HOSTS else "No allowed hosts configured.", + ), + ] + return checks + + +def _path_checks() -> list[SelfCheck]: + checks = [] + checks.append(_path_check("POBSYNC_HOME", Path(settings.POBSYNC_HOME), must_be_absolute=True, must_be_writable=True)) + checks.append( + _path_check( + "POBSYNC_BACKUP_ROOT", + Path(settings.POBSYNC_BACKUP_ROOT), + must_be_absolute=True, + must_exist=True, + must_be_writable=True, + ) + ) + checks.append( + _path_check( + "Static root", + Path(settings.STATIC_ROOT), + must_be_absolute=True, + must_exist=False, + must_be_writable=True, + ) + ) + db_settings = settings.DATABASES["default"] + if db_settings["ENGINE"] == "django.db.backends.sqlite3": + checks.append( + _path_check( + "SQLite directory", + Path(str(db_settings["NAME"])).parent, + must_be_absolute=True, + must_exist=True, + must_be_writable=True, + ) + ) + return checks + + +def _path_check( + name: str, + path: Path, + *, + must_be_absolute: bool, + must_exist: bool = False, + must_be_writable: bool, +) -> SelfCheck: + if must_be_absolute and not path.is_absolute(): + return SelfCheck(name, "failed", f"{path} is not absolute.") + 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)) + + +def _binary_checks() -> list[SelfCheck]: + checks = [] + for binary in ("rsync", "ssh", "ssh-keygen", "gunicorn"): + path = shutil.which(binary) + checks.append( + SelfCheck( + f"Binary: {binary}", + "ok" if path else "failed", + path or f"{binary} was not found in PATH.", + ) + ) + return checks + + +def _database_checks() -> list[SelfCheck]: + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + except Exception as exc: + return [SelfCheck("Database connection", "failed", f"{type(exc).__name__}: {exc}")] + return [SelfCheck("Database connection", "ok", settings.DATABASES["default"]["ENGINE"])] + + +def _config_checks() -> list[SelfCheck]: + try: + global_config = GlobalConfig.objects.get(name="default") + except GlobalConfig.DoesNotExist: + return [SelfCheck("Global config", "warning", "Default global config has not been created yet.")] + + status: CheckStatus = "ok" + message = "Default global config exists." + if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT: + status = "warning" + message = "Global config backup root differs from runtime POBSYNC_BACKUP_ROOT." + return [ + SelfCheck( + "Global config", + status, + message, + f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}", + ) + ] + + +def _systemd_checks() -> list[SelfCheck]: + if not Path("/run/systemd/system").exists() or shutil.which("systemctl") is None: + return [ + SelfCheck( + "Systemd services", + "skipped", + "systemd is not available in this runtime.", + "This is expected inside Docker.", + ) + ] + + checks = [] + for service in ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service"): + result = subprocess.run( + ["systemctl", "is-active", service], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + ) + active_state = result.stdout.strip() or result.stderr.strip() + checks.append( + SelfCheck( + service, + "ok" if result.returncode == 0 else "failed", + active_state, + ) + ) + return checks diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 560153d..83ecccd 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -75,9 +75,12 @@ white-space: nowrap; } .status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; } + .status.ok { color: var(--success); border-color: #a7d8b9; background: #edf8f1; } .status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; } .status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; } + .status.warning { color: var(--running); border-color: #e7cf8a; background: #fff8df; } .status.queued { color: var(--link); border-color: #b5cdea; background: #eef6ff; } + .status.skipped { color: var(--muted); background: #f7f9fb; } .stack { display: grid; gap: 4px; } .stack.spaced { margin-bottom: 14px; } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } @@ -170,6 +173,7 @@ pobsync Admin SSH Keys + Self Check Status API {{ request.user.username }} diff --git a/src/pobsync_backend/templates/pobsync_backend/self_check.html b/src/pobsync_backend/templates/pobsync_backend/self_check.html new file mode 100644 index 0000000..6bcb385 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/self_check.html @@ -0,0 +1,42 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Self Check | pobsync{% endblock %} + +{% block content %} +

Self Check

+ +
+ Back to dashboard +
+ +
+
OK
{{ summary.ok }}
+
Warnings
{{ summary.warning }}
+
Failed
{{ summary.failed }}
+
Skipped
{{ summary.skipped }}
+
+ +
+

Checks

+ + + + + + + + + + + {% for check in checks %} + + + + + + + {% endfor %} + +
StatusCheckMessageDetail
{{ check.status }}{{ check.name }}{{ check.message }}{{ check.detail }}
+
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index d0efdf0..5aa0223 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -83,6 +83,17 @@ class ViewTests(TestCase): self.assertContains(response, reverse("create_host_config")) self.assertContains(response, "Add first host") + def test_self_check_renders_runtime_checks(self) -> None: + self.client.force_login(self.staff_user) + + response = self.client.get(reverse("self_check")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Self Check") + self.assertContains(response, "Django debug") + self.assertContains(response, "Database connection") + self.assertContains(response, "POBSYNC_HOME") + def test_ssh_credentials_view_creates_key(self) -> None: self.client.force_login(self.staff_user) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 4305268..7af508a 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -24,6 +24,7 @@ from .forms import ( ) 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 from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery @@ -58,6 +59,19 @@ def dashboard(request): return render(request, "pobsync_backend/dashboard.html", context) +@staff_member_required +def self_check(request): + checks = collect_self_checks() + return render( + request, + "pobsync_backend/self_check.html", + { + "checks": checks, + "summary": summarize_self_checks(checks), + }, + ) + + @staff_member_required def ssh_credentials(request): context = { diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 5b17567..b5754e6 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -8,6 +8,7 @@ from pobsync_backend import api, views urlpatterns = [ path("", views.dashboard, name="dashboard"), + path("self-check/", views.self_check, name="self_check"), 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"),