(ops) Complete native production install and update flow #9
54
README.md
54
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 <known-good-commit-or-tag>
|
||||
sudo scripts/update-systemd
|
||||
sudo -u pobsync pobsync-manage check_pobsync_install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
24
deploy/bin/pobsync-manage
Normal file
24
deploy/bin/pobsync-manage
Normal 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" "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -504,10 +507,23 @@ 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 "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"
|
||||
|
||||
41
scripts/update-systemd
Executable file
41
scripts/update-systemd
Executable 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 \
|
||||
"$@"
|
||||
@@ -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.")
|
||||
@@ -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 <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(
|
||||
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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user