From 73e6bb7285712162581c72a466e9a2128c27ea62 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Wed, 20 May 2026 01:27:08 +0200 Subject: [PATCH 1/5] (ops) Install pobsync-manage for native management commands Add a pobsync-manage wrapper that loads the native environment file before running Django management commands, so production commands use the same database and runtime settings as the systemd services. Install the wrapper from the systemd installer, use it for migrations, static collection, SSH key setup, and superuser creation, and document it in the README for operational commands. --- README.md | 11 ++++++++++- deploy/bin/pobsync-manage | 19 +++++++++++++++++++ scripts/install-systemd | 24 ++++++++++++++++++------ 3 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 deploy/bin/pobsync-manage diff --git a/README.md b/README.md index 8a31590..325739d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ The installer will, by default: - copy the checkout to `/opt/pobsync/app` - create `/opt/pobsync/venv` - write `/etc/pobsync/pobsync.env` if it does not exist +- install `pobsync-manage`, a Django management wrapper that loads `/etc/pobsync/pobsync.env` - create `/var/lib/pobsync`, `/var/log/pobsync`, and the backup root - install Python dependencies - run migrations and collect static files @@ -127,7 +128,15 @@ http://127.0.0.1:8010/ Create a superuser if needed: ``` -sudo -u pobsync /opt/pobsync/venv/bin/python /opt/pobsync/app/manage.py createsuperuser +sudo -u pobsync pobsync-manage createsuperuser +``` + +For other Django management commands on native installs, use `pobsync-manage` so the production environment file is +loaded before Django starts: + +``` +sudo -u pobsync pobsync-manage showmigrations pobsync_backend +sudo -u pobsync pobsync-manage check ``` The UI includes: diff --git a/deploy/bin/pobsync-manage b/deploy/bin/pobsync-manage new file mode 100644 index 0000000..f55c96a --- /dev/null +++ b/deploy/bin/pobsync-manage @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +APP_DIR="@POBSYNC_APP_DIR@" +VENV_DIR="@POBSYNC_VENV_DIR@" +ENV_FILE="@POBSYNC_ENV_FILE@" + +if [ ! -f "$ENV_FILE" ]; then + echo "pobsync environment file not found: $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +. "$ENV_FILE" +set +a + +cd "$APP_DIR" +exec "$VENV_DIR/bin/python" "$APP_DIR/manage.py" "$@" diff --git a/scripts/install-systemd b/scripts/install-systemd index 938d947..05cbc23 100755 --- a/scripts/install-systemd +++ b/scripts/install-systemd @@ -504,10 +504,21 @@ install_units() { run_step "Install systemd units" install_units +install_manage_wrapper() { + sed \ + -e "s|@POBSYNC_APP_DIR@|$APP_DIR|g" \ + -e "s|@POBSYNC_VENV_DIR@|$VENV_DIR|g" \ + -e "s|@POBSYNC_ENV_FILE@|$ENV_FILE|g" \ + "$APP_DIR/deploy/bin/pobsync-manage" > /usr/local/bin/pobsync-manage + chmod 0755 /usr/local/bin/pobsync-manage +} + +run_step "Install manage wrapper" install_manage_wrapper + run_step "Reload systemd" systemctl daemon-reload -run_step "Run database migrations" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" migrate --noinput -run_step "Ensure default SSH key" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" ensure_pobsync_ssh_key --name default --set-global-default -run_step "Collect static files" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" collectstatic --noinput --clear +run_step "Run database migrations" /usr/local/bin/pobsync-manage migrate --noinput +run_step "Ensure default SSH key" /usr/local/bin/pobsync-manage ensure_pobsync_ssh_key --name default --set-global-default +run_step "Collect static files" /usr/local/bin/pobsync-manage collectstatic --noinput --clear run_step "Finalize state permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync superuser_exists=$("$VENV_DIR/bin/python" -c "import os; os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pobsync_server.settings'); import django; django.setup(); from django.contrib.auth import get_user_model; print('yes' if get_user_model().objects.filter(is_superuser=True).exists() else 'no')") @@ -519,17 +530,17 @@ if [ "$CREATE_SUPERUSER" -eq 1 ]; then DJANGO_SUPERUSER_USERNAME="$SUPERUSER_USERNAME" \ DJANGO_SUPERUSER_EMAIL="$SUPERUSER_EMAIL" \ DJANGO_SUPERUSER_PASSWORD="$SUPERUSER_PASSWORD" \ - "$VENV_DIR/bin/python" "$APP_DIR/manage.py" createsuperuser --noinput + /usr/local/bin/pobsync-manage createsuperuser --noinput run_step "Finalize superuser permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync else note_step "Create Django superuser" "SKIPPED" echo "No superuser password was provided; create one later with:" - echo " sudo -u $SERVICE_USER $VENV_DIR/bin/python $APP_DIR/manage.py createsuperuser" + echo " sudo -u $SERVICE_USER pobsync-manage createsuperuser" fi elif [ "$superuser_exists" != "yes" ]; then note_step "Create Django superuser" "SKIPPED" echo "No Django superuser exists yet. Create one with:" - echo " sudo -u $SERVICE_USER $VENV_DIR/bin/python $APP_DIR/manage.py createsuperuser" + echo " sudo -u $SERVICE_USER pobsync-manage createsuperuser" else note_step "Create Django superuser" "SKIPPED" fi @@ -574,3 +585,4 @@ echo echo "Useful commands:" echo " systemctl status pobsync-web pobsync-worker pobsync-scheduler" echo " journalctl -u pobsync-worker -f" +echo " sudo -u $SERVICE_USER pobsync-manage check" From e6ed7954de14a55e06e373dd1ee211dda65cb4e6 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Wed, 20 May 2026 01:30:02 +0200 Subject: [PATCH 2/5] (ops) Add native update wrapper for production deploys Add scripts/update-systemd as a safer routine deploy entrypoint for native systemd installations. The wrapper keeps updates non-interactive, preserves the existing environment file, skips OS package installation, and avoids superuser creation prompts while still reusing the installer refresh flow. Document the update path in the README and capture the maintenance expectation in the development notes. --- README.md | 17 ++++++++++++++--- docs/development.md | 5 +++++ scripts/update-systemd | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100755 scripts/update-systemd diff --git a/README.md b/README.md index 325739d..00bc560 100644 --- a/README.md +++ b/README.md @@ -177,16 +177,27 @@ From a fresh checkout or the existing app directory: ``` git pull -sudo scripts/install-systemd --non-interactive +sudo scripts/update-systemd ``` -The installer preserves an existing `/etc/pobsync/pobsync.env` unless you pass `--force-env`. It refreshes the installed -app, Python dependencies, migrations, static files, and restarts the systemd services so new Django code is loaded. +The updater is a thin wrapper around the installer for normal production deploys. It preserves the existing +`/etc/pobsync/pobsync.env`, skips OS package installation, skips superuser creation, refreshes the installed app, updates +Python dependencies, runs migrations, collects static files, and restarts the systemd services so new Django code is +loaded. + +Use the full installer again when you intentionally want to change install-time settings, install OS packages, enable +nginx, or rewrite the environment file: + +``` +sudo scripts/install-systemd --non-interactive +sudo scripts/install-systemd --force-env +``` Then check: ``` systemctl status pobsync-web pobsync-worker pobsync-scheduler +sudo -u pobsync pobsync-manage check ``` ## Development diff --git a/docs/development.md b/docs/development.md index 99e2069..5d512a6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,12 +74,17 @@ sudo scripts/install-systemd sudo scripts/install-systemd --non-interactive sudo scripts/install-systemd --verbose sudo scripts/install-systemd --create-superuser --superuser-username admin +sudo scripts/update-systemd ``` The installer should print a short completion summary with the control panel URL, Self Check reminder, and service log commands. Keep normal output user-facing: pobsync step names with OK, FAILED, or SKIPPED. Full apt, pip, Django, and systemd output belongs behind `--verbose` or in the failed step output. +The updater is intentionally a small wrapper around the installer for routine production deploys. It should stay +non-interactive, preserve the existing environment file, skip OS package installation, skip superuser creation, and still +run the Django/runtime refresh steps needed after a code update. + ## Migration Helpers Import existing legacy YAML configs: diff --git a/scripts/update-systemd b/scripts/update-systemd new file mode 100755 index 0000000..5c4eb6e --- /dev/null +++ b/scripts/update-systemd @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +show_help() { + cat <<'EOF' +Usage: sudo scripts/update-systemd [options] + +Refresh an existing native pobsync systemd install from the current checkout. + +This is a thin, safer update wrapper around scripts/install-systemd. It keeps +the install non-interactive, preserves the existing environment file, skips +superuser creation, and skips OS package installation by default. + +Common options are forwarded to install-systemd, for example: + --source-dir PATH + --app-dir PATH + --venv-dir PATH + --env-file PATH + --service-user USER + --service-group GROUP + --install-extras mariadb + --verbose + +If OS packages need to be refreshed, run scripts/install-systemd directly. +EOF +} + +case "${1:-}" in + -h|--help) + show_help + exit 0 + ;; +esac + +exec "$SCRIPT_DIR/install-systemd" \ + --non-interactive \ + --no-install-os-packages \ + --no-create-superuser \ + "$@" From f5acdf2fff3afeacc925f3857ddc971d75702628 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Wed, 20 May 2026 01:33:07 +0200 Subject: [PATCH 3/5] (refactor) Remove obsolete source-copy deploy script Delete the old scripts/deploy path that installed pobsync into /opt/pobsync/lib with a standalone /opt/pobsync/bin wrapper. Production deployment is now owned by the native systemd installer and updater, so keeping the legacy deploy script would make the supported install story less clear and easier to misuse. --- scripts/deploy | 97 -------------------------------------------------- 1 file changed, 97 deletions(-) delete mode 100755 scripts/deploy diff --git a/scripts/deploy b/scripts/deploy deleted file mode 100755 index e65825e..0000000 --- a/scripts/deploy +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/sh -# Deploy pobsync runtime into /opt/pobsync without pip/venv. -# Copies python package sources into /opt/pobsync/lib and installs a stable entrypoint in /opt/pobsync/bin. - -set -eu - -PREFIX="/opt/pobsync" - -usage() { - echo "Usage: $0 [--prefix /opt/pobsync]" >&2 - exit 2 -} - -while [ $# -gt 0 ]; do - case "$1" in - --prefix) - [ $# -ge 2 ] || usage - PREFIX="$2" - shift 2 - ;; - -h|--help) - usage - ;; - *) - echo "Unknown arg: $1" >&2 - usage - ;; - esac -done - -# Determine repo root from this script location -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -REPO_ROOT="$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)" - -SRC_PKG="${REPO_ROOT}/src/pobsync" -if [ ! -d "${SRC_PKG}" ]; then - echo "ERROR: expected python package at ${SRC_PKG}" >&2 - exit 1 -fi - -BIN_DIR="${PREFIX}/bin" -LIB_DIR="${PREFIX}/lib" -DST_PKG="${LIB_DIR}/pobsync" -BUILD_FILE="${DST_PKG}/_build.txt" - -mkdir -p "${BIN_DIR}" "${LIB_DIR}" - -# Copy code into /opt/pobsync/lib/pobsync -# We use rsync if available (clean updates with --delete), otherwise fall back to cp -a. -if command -v rsync >/dev/null 2>&1; then - rsync -a --delete \ - --exclude '__pycache__/' \ - --exclude '*.pyc' \ - --exclude '*.pyo' \ - --exclude '*.pyd' \ - "${SRC_PKG}/" "${DST_PKG}/" -else - # Fallback: wipe + copy - rm -rf "${DST_PKG}" - mkdir -p "${DST_PKG}" - cp -a "${SRC_PKG}/." "${DST_PKG}/" -fi - -# Write build info (best-effort) -GIT_SHA="unknown" -if command -v git >/dev/null 2>&1 && [ -d "${REPO_ROOT}/.git" ]; then - GIT_SHA="$(cd "${REPO_ROOT}" && git rev-parse HEAD 2>/dev/null || echo unknown)" -fi - -NOW_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo unknown)" -{ - echo "deployed_at_utc=${NOW_UTC}" - echo "git_sha=${GIT_SHA}" - echo "repo_root=${REPO_ROOT}" -} > "${BUILD_FILE}" - -# Install stable entrypoint that always runs code from /opt/pobsync/lib -WRAPPER="${BIN_DIR}/pobsync" -cat > "${WRAPPER}" < Date: Wed, 20 May 2026 01:37:07 +0200 Subject: [PATCH 4/5] (ops) Add terminal self-check command for native installs Add check_pobsync_install so native deployments can run the same runtime diagnostics from the terminal that are available in the Django Self Check view. The command prints every check with status, returns a failing exit code when install-critical checks fail, supports fail-on-warning for stricter automation, and is documented in the installer output and README update flow. --- README.md | 2 + scripts/install-systemd | 1 + .../commands/check_pobsync_install.py | 44 ++++++++++++++++++ src/pobsync_backend/tests/test_self_check.py | 46 ++++++++++++++++++- 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/pobsync_backend/management/commands/check_pobsync_install.py diff --git a/README.md b/README.md index 00bc560..5c46739 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ loaded before Django starts: ``` sudo -u pobsync pobsync-manage showmigrations pobsync_backend sudo -u pobsync pobsync-manage check +sudo -u pobsync pobsync-manage check_pobsync_install ``` The UI includes: @@ -198,6 +199,7 @@ Then check: ``` systemctl status pobsync-web pobsync-worker pobsync-scheduler sudo -u pobsync pobsync-manage check +sudo -u pobsync pobsync-manage check_pobsync_install ``` ## Development diff --git a/scripts/install-systemd b/scripts/install-systemd index 05cbc23..f309834 100755 --- a/scripts/install-systemd +++ b/scripts/install-systemd @@ -586,3 +586,4 @@ echo "Useful commands:" echo " systemctl status pobsync-web pobsync-worker pobsync-scheduler" echo " journalctl -u pobsync-worker -f" echo " sudo -u $SERVICE_USER pobsync-manage check" +echo " sudo -u $SERVICE_USER pobsync-manage check_pobsync_install" diff --git a/src/pobsync_backend/management/commands/check_pobsync_install.py b/src/pobsync_backend/management/commands/check_pobsync_install.py new file mode 100644 index 0000000..20156f2 --- /dev/null +++ b/src/pobsync_backend/management/commands/check_pobsync_install.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError + +from pobsync_backend.self_check import collect_self_checks, summarize_self_checks + + +class Command(BaseCommand): + help = "Run pobsync runtime self checks for native installs and updates." + + def add_arguments(self, parser): + parser.add_argument( + "--fail-on-warning", + action="store_true", + help="Exit with an error when warnings are present.", + ) + + def handle(self, *args, **options): + checks = collect_self_checks() + summary = summarize_self_checks(checks) + + for check in checks: + line = f"[{check.status.upper()}] {check.name}: {check.message}" + if check.detail: + line = f"{line} ({check.detail})" + if check.status == "failed": + self.stderr.write(line) + elif check.status == "warning": + self.stderr.write(line) + else: + self.stdout.write(line) + + self.stdout.write( + "Summary: " + f"{summary['ok']} ok, " + f"{summary['warning']} warning(s), " + f"{summary['failed']} failed, " + f"{summary['skipped']} skipped" + ) + + if summary["failed"]: + raise CommandError("pobsync install self check failed.") + if options["fail_on_warning"] and summary["warning"]: + raise CommandError("pobsync install self check reported warnings.") diff --git a/src/pobsync_backend/tests/test_self_check.py b/src/pobsync_backend/tests/test_self_check.py index c985e2e..76dff6c 100644 --- a/src/pobsync_backend/tests/test_self_check.py +++ b/src/pobsync_backend/tests/test_self_check.py @@ -1,11 +1,14 @@ from __future__ import annotations import subprocess +from io import StringIO from unittest.mock import patch +from django.core.management import call_command +from django.core.management.base import CommandError from django.test import SimpleTestCase -from pobsync_backend.self_check import _systemd_checks +from pobsync_backend.self_check import SelfCheck, _systemd_checks class SystemdSelfCheckTests(SimpleTestCase): @@ -40,3 +43,44 @@ class SystemdSelfCheckTests(SimpleTestCase): 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.") + + +class CheckPobsyncInstallCommandTests(SimpleTestCase): + def test_command_prints_summary_for_successful_checks(self) -> None: + stdout = StringIO() + stderr = StringIO() + checks = [ + SelfCheck("Database connection", "ok", "django.db.backends.sqlite3"), + SelfCheck("Systemd services", "skipped", "systemd is not available in this runtime."), + ] + + with patch("pobsync_backend.management.commands.check_pobsync_install.collect_self_checks", return_value=checks): + call_command("check_pobsync_install", stdout=stdout, stderr=stderr) + + self.assertIn("[OK] Database connection", stdout.getvalue()) + self.assertIn("[SKIPPED] Systemd services", stdout.getvalue()) + self.assertIn("Summary: 1 ok, 0 warning(s), 0 failed, 1 skipped", stdout.getvalue()) + self.assertEqual(stderr.getvalue(), "") + + def test_command_fails_when_checks_fail(self) -> None: + stdout = StringIO() + stderr = StringIO() + checks = [ + SelfCheck("POBSYNC_BACKUP_ROOT", "failed", "/backups does not exist."), + ] + + with patch("pobsync_backend.management.commands.check_pobsync_install.collect_self_checks", return_value=checks): + with self.assertRaisesMessage(CommandError, "pobsync install self check failed."): + call_command("check_pobsync_install", stdout=stdout, stderr=stderr) + + self.assertIn("[FAILED] POBSYNC_BACKUP_ROOT", stderr.getvalue()) + self.assertIn("Summary: 0 ok, 0 warning(s), 1 failed, 0 skipped", stdout.getvalue()) + + def test_command_can_fail_on_warnings(self) -> None: + checks = [ + SelfCheck("Global config", "warning", "Default global config has not been created yet."), + ] + + with patch("pobsync_backend.management.commands.check_pobsync_install.collect_self_checks", return_value=checks): + with self.assertRaisesMessage(CommandError, "pobsync install self check reported warnings."): + call_command("check_pobsync_install", "--fail-on-warning", stdout=StringIO(), stderr=StringIO()) From 851f967f12e30c87d85c70d48413ce17b9851dd6 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Wed, 20 May 2026 01:44:51 +0200 Subject: [PATCH 5/5] (ops) Expand native install self checks and recovery docs Extend the runtime self check with native install diagnostics for the environment file, service user, backup root ownership, and SQLite database path. Export install metadata from the systemd units and pobsync-manage wrapper so custom env files and service users are visible to Django checks. Document restart, journal log inspection, and rollback steps in the README so production updates have a clear recovery path. --- README.md | 24 +++++ deploy/bin/pobsync-manage | 5 + deploy/pobsync.env.example | 3 + deploy/systemd/pobsync-scheduler.service | 3 + deploy/systemd/pobsync-web.service | 3 + deploy/systemd/pobsync-worker.service | 3 + scripts/install-systemd | 5 + src/pobsync_backend/self_check.py | 97 +++++++++++++++++++- src/pobsync_backend/tests/test_self_check.py | 54 ++++++++++- src/pobsync_server/settings.py | 3 + 10 files changed, 196 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5c46739..527c817 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,30 @@ sudo -u pobsync pobsync-manage check sudo -u pobsync pobsync-manage check_pobsync_install ``` +Restart services manually after environment or reverse proxy changes: + +``` +sudo systemctl restart pobsync-web pobsync-worker pobsync-scheduler +``` + +Inspect service logs with: + +``` +journalctl -u pobsync-web -n 100 --no-pager +journalctl -u pobsync-worker -f +journalctl -u pobsync-scheduler -n 100 --no-pager +``` + +Rollback to a previous revision by checking out the known-good commit or tag, then running the updater again: + +``` +git switch master +git pull +git checkout +sudo scripts/update-systemd +sudo -u pobsync pobsync-manage check_pobsync_install +``` + ## Development Development, Docker, maintainer tooling, and architecture notes live in: diff --git a/deploy/bin/pobsync-manage b/deploy/bin/pobsync-manage index f55c96a..4499b2f 100644 --- a/deploy/bin/pobsync-manage +++ b/deploy/bin/pobsync-manage @@ -4,6 +4,8 @@ set -eu APP_DIR="@POBSYNC_APP_DIR@" VENV_DIR="@POBSYNC_VENV_DIR@" ENV_FILE="@POBSYNC_ENV_FILE@" +SERVICE_USER="@POBSYNC_USER@" +SERVICE_GROUP="@POBSYNC_GROUP@" if [ ! -f "$ENV_FILE" ]; then echo "pobsync environment file not found: $ENV_FILE" >&2 @@ -14,6 +16,9 @@ set -a # shellcheck disable=SC1090 . "$ENV_FILE" set +a +export POBSYNC_ENV_FILE="$ENV_FILE" +export POBSYNC_SERVICE_USER="$SERVICE_USER" +export POBSYNC_SERVICE_GROUP="$SERVICE_GROUP" cd "$APP_DIR" exec "$VENV_DIR/bin/python" "$APP_DIR/manage.py" "$@" diff --git a/deploy/pobsync.env.example b/deploy/pobsync.env.example index 076b7c0..317d9a5 100644 --- a/deploy/pobsync.env.example +++ b/deploy/pobsync.env.example @@ -7,6 +7,9 @@ POBSYNC_HOME=/var/lib/pobsync POBSYNC_BACKUP_ROOT=/backups POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3 POBSYNC_STATIC_ROOT=/var/lib/pobsync/static +POBSYNC_ENV_FILE=/etc/pobsync/pobsync.env +POBSYNC_SERVICE_USER=pobsync +POBSYNC_SERVICE_GROUP=pobsync POBSYNC_WEB_BIND=127.0.0.1:8010 POBSYNC_GUNICORN_WORKERS=2 diff --git a/deploy/systemd/pobsync-scheduler.service b/deploy/systemd/pobsync-scheduler.service index a3a5dcf..ff07093 100644 --- a/deploy/systemd/pobsync-scheduler.service +++ b/deploy/systemd/pobsync-scheduler.service @@ -9,6 +9,9 @@ User=@POBSYNC_USER@ Group=@POBSYNC_GROUP@ WorkingDirectory=@POBSYNC_APP_DIR@ EnvironmentFile=@POBSYNC_ENV_FILE@ +Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@ +Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@ +Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@ ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/python manage.py run_pobsync_scheduler --loop --interval "${POBSYNC_SCHEDULER_INTERVAL:-60}"' Restart=on-failure RestartSec=5 diff --git a/deploy/systemd/pobsync-web.service b/deploy/systemd/pobsync-web.service index 3f99b0b..f07e353 100644 --- a/deploy/systemd/pobsync-web.service +++ b/deploy/systemd/pobsync-web.service @@ -9,6 +9,9 @@ User=@POBSYNC_USER@ Group=@POBSYNC_GROUP@ WorkingDirectory=@POBSYNC_APP_DIR@ EnvironmentFile=@POBSYNC_ENV_FILE@ +Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@ +Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@ +Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@ ExecStartPre=@POBSYNC_VENV_DIR@/bin/python manage.py migrate --noinput ExecStartPre=@POBSYNC_VENV_DIR@/bin/python manage.py collectstatic --noinput --clear ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/gunicorn pobsync_server.wsgi:application --bind "${POBSYNC_WEB_BIND:-127.0.0.1:8010}" --workers "${POBSYNC_GUNICORN_WORKERS:-2}" --timeout "${POBSYNC_GUNICORN_TIMEOUT:-120}"' diff --git a/deploy/systemd/pobsync-worker.service b/deploy/systemd/pobsync-worker.service index a1c4837..9a7efa1 100644 --- a/deploy/systemd/pobsync-worker.service +++ b/deploy/systemd/pobsync-worker.service @@ -9,6 +9,9 @@ User=@POBSYNC_USER@ Group=@POBSYNC_GROUP@ WorkingDirectory=@POBSYNC_APP_DIR@ EnvironmentFile=@POBSYNC_ENV_FILE@ +Environment=POBSYNC_ENV_FILE=@POBSYNC_ENV_FILE@ +Environment=POBSYNC_SERVICE_USER=@POBSYNC_USER@ +Environment=POBSYNC_SERVICE_GROUP=@POBSYNC_GROUP@ ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/python manage.py run_pobsync_worker --loop --interval "${POBSYNC_WORKER_INTERVAL:-15}"' Restart=on-failure RestartSec=5 diff --git a/scripts/install-systemd b/scripts/install-systemd index f309834..d77a521 100755 --- a/scripts/install-systemd +++ b/scripts/install-systemd @@ -463,6 +463,9 @@ POBSYNC_BACKUP_ROOT=$BACKUP_ROOT POBSYNC_TIME_ZONE=$TIME_ZONE POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3 POBSYNC_STATIC_ROOT=/var/lib/pobsync/static +POBSYNC_ENV_FILE=$ENV_FILE +POBSYNC_SERVICE_USER=$SERVICE_USER +POBSYNC_SERVICE_GROUP=$SERVICE_GROUP POBSYNC_WEB_BIND=$WEB_BIND POBSYNC_GUNICORN_WORKERS=2 @@ -509,6 +512,8 @@ install_manage_wrapper() { -e "s|@POBSYNC_APP_DIR@|$APP_DIR|g" \ -e "s|@POBSYNC_VENV_DIR@|$VENV_DIR|g" \ -e "s|@POBSYNC_ENV_FILE@|$ENV_FILE|g" \ + -e "s|@POBSYNC_USER@|$SERVICE_USER|g" \ + -e "s|@POBSYNC_GROUP@|$SERVICE_GROUP|g" \ "$APP_DIR/deploy/bin/pobsync-manage" > /usr/local/bin/pobsync-manage chmod 0755 /usr/local/bin/pobsync-manage } diff --git a/src/pobsync_backend/self_check.py b/src/pobsync_backend/self_check.py index d354c57..ed7a9ff 100644 --- a/src/pobsync_backend/self_check.py +++ b/src/pobsync_backend/self_check.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import pwd import shutil import subprocess import sys @@ -28,6 +29,7 @@ class SelfCheck: def collect_self_checks() -> list[SelfCheck]: checks: list[SelfCheck] = [] checks.extend(_django_checks()) + checks.extend(_install_checks()) checks.extend(_path_checks()) checks.extend(_binary_checks()) checks.extend(_database_checks()) @@ -36,6 +38,10 @@ def collect_self_checks() -> list[SelfCheck]: return checks +def _native_runtime_available() -> bool: + return Path("/run/systemd/system").exists() and shutil.which("systemctl") is not None + + def summarize_self_checks(checks: list[SelfCheck]) -> dict[str, int]: return { "ok": sum(1 for check in checks if check.status == "ok"), @@ -91,18 +97,105 @@ def _path_checks() -> list[SelfCheck]: ) db_settings = settings.DATABASES["default"] if db_settings["ENGINE"] == "django.db.backends.sqlite3": + sqlite_path = Path(str(db_settings["NAME"])) checks.append( _path_check( "SQLite directory", - Path(str(db_settings["NAME"])).parent, + sqlite_path.parent, must_be_absolute=True, must_exist=True, must_be_writable=True, ) ) + checks.append(_sqlite_database_check(sqlite_path)) return checks +def _install_checks() -> list[SelfCheck]: + if not _native_runtime_available() and not Path(settings.POBSYNC_ENV_FILE).exists(): + return [ + SelfCheck( + "Environment file", + "skipped", + "Native environment file is not configured in this runtime.", + "This is expected inside Docker or local development.", + ), + SelfCheck( + "Service user", + "skipped", + "Native service user check is not available in this runtime.", + "This is expected inside Docker or local development.", + ), + SelfCheck( + "Backup root owner", + "skipped", + "Native backup root ownership check is not available in this runtime.", + "This is expected inside Docker or local development.", + ), + ] + + checks = [_env_file_check(Path(settings.POBSYNC_ENV_FILE)), _service_user_check()] + checks.append(_backup_root_owner_check(Path(settings.POBSYNC_BACKUP_ROOT))) + return checks + + +def _env_file_check(path: Path) -> SelfCheck: + if not path.is_absolute(): + return SelfCheck("Environment file", "failed", f"{path} is not absolute.") + if not path.exists(): + return SelfCheck("Environment file", "failed", f"{path} does not exist.") + if not path.is_file(): + return SelfCheck("Environment file", "failed", f"{path} is not a regular file.") + if not os.access(path, os.R_OK): + return SelfCheck("Environment file", "failed", f"{path} is not readable by this process.") + return SelfCheck("Environment file", "ok", str(path)) + + +def _service_user_check() -> SelfCheck: + expected_user = settings.POBSYNC_SERVICE_USER + try: + current_user = pwd.getpwuid(os.geteuid()).pw_name + except KeyError: + return SelfCheck("Service user", "failed", f"Current uid {os.geteuid()} has no passwd entry.") + if current_user != expected_user: + return SelfCheck( + "Service user", + "warning", + f"Current process runs as {current_user}, expected {expected_user}.", + "Run terminal checks with sudo -u pobsync-manage check_pobsync_install.", + ) + return SelfCheck("Service user", "ok", current_user) + + +def _backup_root_owner_check(path: Path) -> SelfCheck: + if not path.exists(): + return SelfCheck("Backup root owner", "failed", f"{path} does not exist.") + expected_user = settings.POBSYNC_SERVICE_USER + try: + owner = pwd.getpwuid(path.stat().st_uid).pw_name + except KeyError: + return SelfCheck("Backup root owner", "warning", f"{path} owner uid {path.stat().st_uid} has no passwd entry.") + if owner != expected_user: + return SelfCheck( + "Backup root owner", + "warning", + f"{path} is owned by {owner}, expected {expected_user}.", + ) + return SelfCheck("Backup root owner", "ok", f"{path} owner={owner}") + + +def _sqlite_database_check(path: Path) -> SelfCheck: + if not path.is_absolute(): + return SelfCheck("SQLite database", "failed", f"{path} is not absolute.") + if not path.exists(): + return SelfCheck("SQLite database", "warning", f"{path} does not exist yet.") + if not path.is_file(): + return SelfCheck("SQLite database", "failed", f"{path} is not a regular file.") + if not os.access(path, os.R_OK | os.W_OK): + return SelfCheck("SQLite database", "failed", f"{path} is not readable and writable by this process.") + return SelfCheck("SQLite database", "ok", str(path)) + + def _path_check( name: str, path: Path, @@ -178,7 +271,7 @@ def _config_checks() -> list[SelfCheck]: def _systemd_checks() -> list[SelfCheck]: - if not Path("/run/systemd/system").exists() or shutil.which("systemctl") is None: + if not _native_runtime_available(): return [ SelfCheck( "Systemd services", diff --git a/src/pobsync_backend/tests/test_self_check.py b/src/pobsync_backend/tests/test_self_check.py index 76dff6c..28e0361 100644 --- a/src/pobsync_backend/tests/test_self_check.py +++ b/src/pobsync_backend/tests/test_self_check.py @@ -2,13 +2,15 @@ from __future__ import annotations import subprocess from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import patch from django.core.management import call_command from django.core.management.base import CommandError -from django.test import SimpleTestCase +from django.test import SimpleTestCase, override_settings -from pobsync_backend.self_check import SelfCheck, _systemd_checks +from pobsync_backend.self_check import SelfCheck, _install_checks, _sqlite_database_check, _systemd_checks class SystemdSelfCheckTests(SimpleTestCase): @@ -45,6 +47,54 @@ class SystemdSelfCheckTests(SimpleTestCase): self.assertEqual(journal_check.message, "pobsync cannot read service logs.") +class InstallSelfCheckTests(SimpleTestCase): + def test_install_checks_skip_native_paths_in_development_runtime(self) -> None: + with override_settings(POBSYNC_ENV_FILE="/missing/pobsync.env"), patch( + "pobsync_backend.self_check._native_runtime_available", + return_value=False, + ): + checks = _install_checks() + + self.assertEqual([check.status for check in checks], ["skipped", "skipped", "skipped"]) + self.assertEqual(checks[0].name, "Environment file") + self.assertEqual(checks[1].name, "Service user") + self.assertEqual(checks[2].name, "Backup root owner") + + def test_service_user_warns_when_current_user_differs(self) -> None: + with override_settings( + POBSYNC_ENV_FILE="/etc/pobsync/pobsync.env", + POBSYNC_SERVICE_USER="pobsync", + POBSYNC_BACKUP_ROOT="/backups", + ), patch("pobsync_backend.self_check._native_runtime_available", return_value=True), patch( + "pobsync_backend.self_check._env_file_check", + return_value=SelfCheck("Environment file", "ok", "/etc/pobsync/pobsync.env"), + ), patch( + "pobsync_backend.self_check._backup_root_owner_check", + return_value=SelfCheck("Backup root owner", "ok", "/backups owner=pobsync"), + ), patch( + "pobsync_backend.self_check.os.geteuid", + return_value=0, + ), patch( + "pobsync_backend.self_check.pwd.getpwuid", + ) as getpwuid: + getpwuid.return_value.pw_name = "root" + checks = _install_checks() + + service_user_check = next(check for check in checks if check.name == "Service user") + self.assertEqual(service_user_check.status, "warning") + self.assertIn("expected pobsync", service_user_check.message) + + def test_sqlite_database_check_reports_existing_database(self) -> None: + with TemporaryDirectory() as tmp: + db_path = Path(tmp) / "pobsync.sqlite3" + db_path.write_text("", encoding="utf-8") + + check = _sqlite_database_check(db_path) + + self.assertEqual(check.status, "ok") + self.assertEqual(check.name, "SQLite database") + + class CheckPobsyncInstallCommandTests(SimpleTestCase): def test_command_prints_summary_for_successful_checks(self) -> None: stdout = StringIO() diff --git a/src/pobsync_server/settings.py b/src/pobsync_server/settings.py index 24d49d4..b000ab9 100644 --- a/src/pobsync_server/settings.py +++ b/src/pobsync_server/settings.py @@ -99,3 +99,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" POBSYNC_HOME = os.getenv("POBSYNC_HOME", "/opt/pobsync") POBSYNC_BACKUP_ROOT = os.getenv("POBSYNC_BACKUP_ROOT", "/backups") +POBSYNC_ENV_FILE = os.getenv("POBSYNC_ENV_FILE", "/etc/pobsync/pobsync.env") +POBSYNC_SERVICE_USER = os.getenv("POBSYNC_SERVICE_USER", "pobsync") +POBSYNC_SERVICE_GROUP = os.getenv("POBSYNC_SERVICE_GROUP", "pobsync")