5 Commits

Author SHA1 Message Date
851f967f12 (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.
2026-05-20 01:44:51 +02:00
c97c595253 (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.
2026-05-20 01:37:07 +02:00
f5acdf2fff (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.
2026-05-20 01:33:07 +02:00
e6ed7954de (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.
2026-05-20 01:30:02 +02:00
73e6bb7285 (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.
2026-05-20 01:27:08 +02:00
14 changed files with 394 additions and 111 deletions

View File

@@ -43,6 +43,7 @@ The installer will, by default:
- copy the checkout to `/opt/pobsync/app` - copy the checkout to `/opt/pobsync/app`
- create `/opt/pobsync/venv` - create `/opt/pobsync/venv`
- write `/etc/pobsync/pobsync.env` if it does not exist - 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 - create `/var/lib/pobsync`, `/var/log/pobsync`, and the backup root
- install Python dependencies - install Python dependencies
- run migrations and collect static files - run migrations and collect static files
@@ -127,7 +128,16 @@ http://127.0.0.1:8010/
Create a superuser if needed: 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: The UI includes:
@@ -168,16 +178,52 @@ From a fresh checkout or the existing app directory:
``` ```
git pull 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 The updater is a thin wrapper around the installer for normal production deploys. It preserves the existing
app, Python dependencies, migrations, static files, and restarts the systemd services so new Django code is loaded. `/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: Then check:
``` ```
systemctl status pobsync-web pobsync-worker pobsync-scheduler 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 <known-good-commit-or-tag>
sudo scripts/update-systemd
sudo -u pobsync pobsync-manage check_pobsync_install
``` ```
## Development ## Development

24
deploy/bin/pobsync-manage Normal file
View File

@@ -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" "$@"

View File

@@ -7,6 +7,9 @@ POBSYNC_HOME=/var/lib/pobsync
POBSYNC_BACKUP_ROOT=/backups POBSYNC_BACKUP_ROOT=/backups
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3 POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static 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_WEB_BIND=127.0.0.1:8010
POBSYNC_GUNICORN_WORKERS=2 POBSYNC_GUNICORN_WORKERS=2

View File

@@ -9,6 +9,9 @@ User=@POBSYNC_USER@
Group=@POBSYNC_GROUP@ Group=@POBSYNC_GROUP@
WorkingDirectory=@POBSYNC_APP_DIR@ WorkingDirectory=@POBSYNC_APP_DIR@
EnvironmentFile=@POBSYNC_ENV_FILE@ 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}"' ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/python manage.py run_pobsync_scheduler --loop --interval "${POBSYNC_SCHEDULER_INTERVAL:-60}"'
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5

View File

@@ -9,6 +9,9 @@ User=@POBSYNC_USER@
Group=@POBSYNC_GROUP@ Group=@POBSYNC_GROUP@
WorkingDirectory=@POBSYNC_APP_DIR@ WorkingDirectory=@POBSYNC_APP_DIR@
EnvironmentFile=@POBSYNC_ENV_FILE@ 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 migrate --noinput
ExecStartPre=@POBSYNC_VENV_DIR@/bin/python manage.py collectstatic --noinput --clear 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}"' 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}"'

View File

@@ -9,6 +9,9 @@ User=@POBSYNC_USER@
Group=@POBSYNC_GROUP@ Group=@POBSYNC_GROUP@
WorkingDirectory=@POBSYNC_APP_DIR@ WorkingDirectory=@POBSYNC_APP_DIR@
EnvironmentFile=@POBSYNC_ENV_FILE@ 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}"' ExecStart=/bin/sh -c 'exec @POBSYNC_VENV_DIR@/bin/python manage.py run_pobsync_worker --loop --interval "${POBSYNC_WORKER_INTERVAL:-15}"'
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5

View File

@@ -74,12 +74,17 @@ sudo scripts/install-systemd
sudo scripts/install-systemd --non-interactive sudo scripts/install-systemd --non-interactive
sudo scripts/install-systemd --verbose sudo scripts/install-systemd --verbose
sudo scripts/install-systemd --create-superuser --superuser-username admin 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 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 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. 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 ## Migration Helpers
Import existing legacy YAML configs: Import existing legacy YAML configs:

View File

@@ -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}" <<EOF
#!/bin/sh
# managed-by=pobsync deploy
set -eu
PREFIX="${PREFIX}"
export PYTHONPATH="\${PREFIX}/lib"
export PYTHONUNBUFFERED=1
exec /usr/bin/python3 -m pobsync "\$@"
EOF
chmod 0755 "${WRAPPER}"
echo "OK"
echo "- deployed package to ${DST_PKG}"
echo "- wrote build info ${BUILD_FILE}"
echo "- installed entrypoint ${WRAPPER}"

View File

@@ -463,6 +463,9 @@ POBSYNC_BACKUP_ROOT=$BACKUP_ROOT
POBSYNC_TIME_ZONE=$TIME_ZONE POBSYNC_TIME_ZONE=$TIME_ZONE
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3 POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static 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_WEB_BIND=$WEB_BIND
POBSYNC_GUNICORN_WORKERS=2 POBSYNC_GUNICORN_WORKERS=2
@@ -504,10 +507,23 @@ install_units() {
run_step "Install systemd units" 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" \
-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
}
run_step "Install manage wrapper" install_manage_wrapper
run_step "Reload systemd" systemctl daemon-reload run_step "Reload systemd" systemctl daemon-reload
run_step "Run database migrations" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" migrate --noinput run_step "Run database migrations" /usr/local/bin/pobsync-manage 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 "Ensure default SSH key" /usr/local/bin/pobsync-manage 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 "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 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')") 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_USERNAME="$SUPERUSER_USERNAME" \
DJANGO_SUPERUSER_EMAIL="$SUPERUSER_EMAIL" \ DJANGO_SUPERUSER_EMAIL="$SUPERUSER_EMAIL" \
DJANGO_SUPERUSER_PASSWORD="$SUPERUSER_PASSWORD" \ 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 run_step "Finalize superuser permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
else else
note_step "Create Django superuser" "SKIPPED" note_step "Create Django superuser" "SKIPPED"
echo "No superuser password was provided; create one later with:" 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 fi
elif [ "$superuser_exists" != "yes" ]; then elif [ "$superuser_exists" != "yes" ]; then
note_step "Create Django superuser" "SKIPPED" note_step "Create Django superuser" "SKIPPED"
echo "No Django superuser exists yet. Create one with:" 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 else
note_step "Create Django superuser" "SKIPPED" note_step "Create Django superuser" "SKIPPED"
fi fi
@@ -574,3 +590,5 @@ echo
echo "Useful commands:" echo "Useful commands:"
echo " systemctl status pobsync-web pobsync-worker pobsync-scheduler" echo " systemctl status pobsync-web pobsync-worker pobsync-scheduler"
echo " journalctl -u pobsync-worker -f" echo " journalctl -u pobsync-worker -f"
echo " sudo -u $SERVICE_USER pobsync-manage check"
echo " sudo -u $SERVICE_USER pobsync-manage check_pobsync_install"

41
scripts/update-systemd Executable file
View File

@@ -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 \
"$@"

View File

@@ -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.")

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import pwd
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -28,6 +29,7 @@ class SelfCheck:
def collect_self_checks() -> list[SelfCheck]: def collect_self_checks() -> list[SelfCheck]:
checks: list[SelfCheck] = [] checks: list[SelfCheck] = []
checks.extend(_django_checks()) checks.extend(_django_checks())
checks.extend(_install_checks())
checks.extend(_path_checks()) checks.extend(_path_checks())
checks.extend(_binary_checks()) checks.extend(_binary_checks())
checks.extend(_database_checks()) checks.extend(_database_checks())
@@ -36,6 +38,10 @@ def collect_self_checks() -> list[SelfCheck]:
return checks 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]: def summarize_self_checks(checks: list[SelfCheck]) -> dict[str, int]:
return { return {
"ok": sum(1 for check in checks if check.status == "ok"), "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"] db_settings = settings.DATABASES["default"]
if db_settings["ENGINE"] == "django.db.backends.sqlite3": if db_settings["ENGINE"] == "django.db.backends.sqlite3":
sqlite_path = Path(str(db_settings["NAME"]))
checks.append( checks.append(
_path_check( _path_check(
"SQLite directory", "SQLite directory",
Path(str(db_settings["NAME"])).parent, sqlite_path.parent,
must_be_absolute=True, must_be_absolute=True,
must_exist=True, must_exist=True,
must_be_writable=True, must_be_writable=True,
) )
) )
checks.append(_sqlite_database_check(sqlite_path))
return checks 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 <service-user> 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( def _path_check(
name: str, name: str,
path: Path, path: Path,
@@ -178,7 +271,7 @@ def _config_checks() -> list[SelfCheck]:
def _systemd_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 [ return [
SelfCheck( SelfCheck(
"Systemd services", "Systemd services",

View File

@@ -1,11 +1,16 @@
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
from io import StringIO
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch 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): class SystemdSelfCheckTests(SimpleTestCase):
@@ -40,3 +45,92 @@ class SystemdSelfCheckTests(SimpleTestCase):
journal_check = next(check for check in checks if check.name == "Journal access") journal_check = next(check for check in checks if check.name == "Journal access")
self.assertEqual(journal_check.status, "failed") self.assertEqual(journal_check.status, "failed")
self.assertEqual(journal_check.message, "pobsync cannot read service logs.") 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())

View File

@@ -99,3 +99,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
POBSYNC_HOME = os.getenv("POBSYNC_HOME", "/opt/pobsync") POBSYNC_HOME = os.getenv("POBSYNC_HOME", "/opt/pobsync")
POBSYNC_BACKUP_ROOT = os.getenv("POBSYNC_BACKUP_ROOT", "/backups") 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")