diff --git a/README.md b/README.md index 8a31590..527c817 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,16 @@ 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 +sudo -u pobsync pobsync-manage check_pobsync_install ``` The UI includes: @@ -168,16 +178,52 @@ 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 +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 diff --git a/deploy/bin/pobsync-manage b/deploy/bin/pobsync-manage new file mode 100644 index 0000000..4499b2f --- /dev/null +++ b/deploy/bin/pobsync-manage @@ -0,0 +1,24 @@ +#!/bin/sh +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 + exit 1 +fi + +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/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/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}" < /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 +535,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 +590,5 @@ 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" +echo " sudo -u $SERVICE_USER pobsync-manage check_pobsync_install" 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 \ + "$@" 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/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 c985e2e..28e0361 100644 --- a/src/pobsync_backend/tests/test_self_check.py +++ b/src/pobsync_backend/tests/test_self_check.py @@ -1,11 +1,16 @@ 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.test import SimpleTestCase +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import SimpleTestCase, override_settings -from pobsync_backend.self_check import _systemd_checks +from pobsync_backend.self_check import SelfCheck, _install_checks, _sqlite_database_check, _systemd_checks class SystemdSelfCheckTests(SimpleTestCase): @@ -40,3 +45,92 @@ 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 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() + 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()) 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")