(feature) Add full native installer and self-check page

Expand the systemd installer so it can perform a complete native
installation with sensible defaults: copy the checkout into the target
app directory, create runtime directories, write the environment file,
install dependencies, configure systemd units, and optionally configure
nginx.

Add a staff-only Django self-check page that verifies runtime settings,
required binaries, writable paths, database connectivity, global config
state, and systemd service status when available.

Document installer overrides and expose the self-check from the main
navigation.
This commit is contained in:
2026-05-19 16:05:03 +02:00
parent b93e19a7c8
commit 372a857f15
8 changed files with 370 additions and 13 deletions

View File

@@ -131,14 +131,29 @@ Install OS packages first:
apt install python3 python3-venv rsync openssh-client apt install python3 python3-venv rsync openssh-client
``` ```
Clone or update the app at `/opt/pobsync/app`, then run: From a checked-out copy of this repository, run:
``` ```
cd /opt/pobsync/app
sudo scripts/install-systemd sudo scripts/install-systemd
``` ```
The installer creates: By default the installer copies the checkout to `/opt/pobsync/app`, creates `/opt/pobsync/venv`, writes
`/etc/pobsync/pobsync.env`, creates `/var/lib/pobsync` and `/backups`, installs dependencies, runs migrations, collects
static files, and starts the services.
Common overrides:
```
sudo scripts/install-systemd \
--app-dir /opt/pobsync/app \
--backup-root /mnt/backups/pobsync \
--allowed-hosts backup.example.com,localhost,127.0.0.1 \
--csrf-trusted-origins https://backup.example.com
```
Use `--force-env` when you intentionally want the installer to rewrite an existing `/etc/pobsync/pobsync.env`.
The installer creates or updates:
- `pobsync-web.service` for Gunicorn on `127.0.0.1:8010` - `pobsync-web.service` for Gunicorn on `127.0.0.1:8010`
- `pobsync-worker.service` for queued backup runs - `pobsync-worker.service` for queued backup runs
@@ -167,12 +182,14 @@ systemctl status pobsync-web pobsync-worker pobsync-scheduler
journalctl -u pobsync-worker -f journalctl -u pobsync-worker -f
``` ```
The Django UI also has a staff-only `/self-check/` page that verifies runtime settings, required binaries, writable
paths, database connectivity, global config state, and systemd service state when systemd is available.
Update an existing native install: Update an existing native install:
``` ```
cd /opt/pobsync/app
git pull git pull
sudo scripts/install-systemd sudo scripts/install-systemd --app-dir /opt/pobsync/app
``` ```
Use an existing reverse proxy by forwarding to `http://127.0.0.1:8010`. To install a simple nginx site file as a Use an existing reverse proxy by forwarding to `http://127.0.0.1:8010`. To install a simple nginx site file as a

View File

@@ -1,17 +1,67 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
APP_DIR=${POBSYNC_APP_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)} SOURCE_DIR=${POBSYNC_SOURCE_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
APP_DIR=${POBSYNC_APP_DIR:-/opt/pobsync/app}
VENV_DIR=${POBSYNC_VENV_DIR:-/opt/pobsync/venv} VENV_DIR=${POBSYNC_VENV_DIR:-/opt/pobsync/venv}
ENV_FILE=${POBSYNC_ENV_FILE:-/etc/pobsync/pobsync.env} ENV_FILE=${POBSYNC_ENV_FILE:-/etc/pobsync/pobsync.env}
SERVICE_USER=${POBSYNC_SERVICE_USER:-pobsync} SERVICE_USER=${POBSYNC_SERVICE_USER:-pobsync}
SERVICE_GROUP=${POBSYNC_SERVICE_GROUP:-pobsync} SERVICE_GROUP=${POBSYNC_SERVICE_GROUP:-pobsync}
INSTALL_EXTRAS=${POBSYNC_INSTALL_EXTRAS:-} INSTALL_EXTRAS=${POBSYNC_INSTALL_EXTRAS:-}
SERVER_NAME=${POBSYNC_SERVER_NAME:-_} SERVER_NAME=${POBSYNC_SERVER_NAME:-_}
ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1}
CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-}
BACKUP_ROOT=${POBSYNC_BACKUP_ROOT:-/backups}
WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010}
FORCE_ENV=0
WITH_NGINX=0 WITH_NGINX=0
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
--source-dir)
SOURCE_DIR=$2
shift 2
;;
--app-dir)
APP_DIR=$2
shift 2
;;
--venv-dir)
VENV_DIR=$2
shift 2
;;
--env-file)
ENV_FILE=$2
shift 2
;;
--service-user)
SERVICE_USER=$2
shift 2
;;
--service-group)
SERVICE_GROUP=$2
shift 2
;;
--backup-root)
BACKUP_ROOT=$2
shift 2
;;
--allowed-hosts)
ALLOWED_HOSTS=$2
shift 2
;;
--csrf-trusted-origins)
CSRF_TRUSTED_ORIGINS=$2
shift 2
;;
--web-bind)
WEB_BIND=$2
shift 2
;;
--force-env)
FORCE_ENV=1
shift
;;
--with-nginx) --with-nginx)
WITH_NGINX=1 WITH_NGINX=1
shift shift
@@ -47,6 +97,11 @@ if ! command -v ssh >/dev/null 2>&1; then
exit 1 exit 1
fi fi
if [ ! -f "$SOURCE_DIR/manage.py" ]; then
echo "Source directory does not look like a pobsync checkout: $SOURCE_DIR" >&2
exit 1
fi
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
groupadd --system "$SERVICE_GROUP" groupadd --system "$SERVICE_GROUP"
fi fi
@@ -55,28 +110,39 @@ if ! id "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --home /var/lib/pobsync --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER" useradd --system --home /var/lib/pobsync --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER"
fi fi
mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT"
chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
chmod 0750 /var/lib/pobsync /var/log/pobsync chmod 0750 /var/lib/pobsync /var/log/pobsync
if [ "$SOURCE_DIR" != "$APP_DIR" ]; then
rsync -a --delete \
--exclude .git \
--exclude .venv \
--exclude __pycache__ \
--exclude .pytest_cache \
--exclude .mypy_cache \
--exclude var \
"$SOURCE_DIR"/ "$APP_DIR"/
fi
python3 -m venv "$VENV_DIR" python3 -m venv "$VENV_DIR"
"$VENV_DIR/bin/python" -m pip install --upgrade pip "$VENV_DIR/bin/python" -m pip install --upgrade pip
"$VENV_DIR/bin/python" -m pip install -e "$APP_DIR$INSTALL_EXTRAS" "$VENV_DIR/bin/python" -m pip install -e "$APP_DIR$INSTALL_EXTRAS"
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ] || [ "$FORCE_ENV" -eq 1 ]; then
secret=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))") secret=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
cat > "$ENV_FILE" <<EOF cat > "$ENV_FILE" <<EOF
POBSYNC_DJANGO_DEBUG=0 POBSYNC_DJANGO_DEBUG=0
POBSYNC_DJANGO_SECRET_KEY=$secret POBSYNC_DJANGO_SECRET_KEY=$secret
POBSYNC_DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 POBSYNC_DJANGO_ALLOWED_HOSTS=$ALLOWED_HOSTS
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS= POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=$CSRF_TRUSTED_ORIGINS
POBSYNC_HOME=/var/lib/pobsync POBSYNC_HOME=/var/lib/pobsync
POBSYNC_BACKUP_ROOT=/backups POBSYNC_BACKUP_ROOT=$BACKUP_ROOT
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_WEB_BIND=127.0.0.1:8010 POBSYNC_WEB_BIND=$WEB_BIND
POBSYNC_GUNICORN_WORKERS=2 POBSYNC_GUNICORN_WORKERS=2
POBSYNC_GUNICORN_TIMEOUT=120 POBSYNC_GUNICORN_TIMEOUT=120
POBSYNC_WORKER_INTERVAL=15 POBSYNC_WORKER_INTERVAL=15
@@ -84,7 +150,9 @@ POBSYNC_SCHEDULER_INTERVAL=60
EOF EOF
chmod 0640 "$ENV_FILE" chmod 0640 "$ENV_FILE"
chown "root:$SERVICE_GROUP" "$ENV_FILE" chown "root:$SERVICE_GROUP" "$ENV_FILE"
echo "Created $ENV_FILE. Edit allowed hosts and backup root before exposing the service." echo "Wrote $ENV_FILE."
else
echo "Keeping existing $ENV_FILE. Use --force-env to rewrite it."
fi fi
install_unit() { install_unit() {

View File

@@ -0,0 +1,200 @@
from __future__ import annotations
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from django.conf import settings
from django.db import connection
from .models import GlobalConfig
CheckStatus = Literal["ok", "warning", "failed", "skipped"]
@dataclass(frozen=True)
class SelfCheck:
name: str
status: CheckStatus
message: str
detail: str = ""
def collect_self_checks() -> list[SelfCheck]:
checks: list[SelfCheck] = []
checks.extend(_django_checks())
checks.extend(_path_checks())
checks.extend(_binary_checks())
checks.extend(_database_checks())
checks.extend(_config_checks())
checks.extend(_systemd_checks())
return checks
def summarize_self_checks(checks: list[SelfCheck]) -> dict[str, int]:
return {
"ok": sum(1 for check in checks if check.status == "ok"),
"warning": sum(1 for check in checks if check.status == "warning"),
"failed": sum(1 for check in checks if check.status == "failed"),
"skipped": sum(1 for check in checks if check.status == "skipped"),
}
def _django_checks() -> list[SelfCheck]:
checks = [
SelfCheck(
"Django debug",
"warning" if settings.DEBUG else "ok",
"DEBUG is enabled." if settings.DEBUG else "DEBUG is disabled.",
),
SelfCheck(
"Django secret key",
"failed" if settings.SECRET_KEY == "dev-only-change-me" else "ok",
"Default development secret key is still active."
if settings.SECRET_KEY == "dev-only-change-me"
else "Secret key is configured.",
),
SelfCheck(
"Allowed hosts",
"ok" if settings.ALLOWED_HOSTS else "failed",
", ".join(settings.ALLOWED_HOSTS) if settings.ALLOWED_HOSTS else "No allowed hosts configured.",
),
]
return checks
def _path_checks() -> list[SelfCheck]:
checks = []
checks.append(_path_check("POBSYNC_HOME", Path(settings.POBSYNC_HOME), must_be_absolute=True, must_be_writable=True))
checks.append(
_path_check(
"POBSYNC_BACKUP_ROOT",
Path(settings.POBSYNC_BACKUP_ROOT),
must_be_absolute=True,
must_exist=True,
must_be_writable=True,
)
)
checks.append(
_path_check(
"Static root",
Path(settings.STATIC_ROOT),
must_be_absolute=True,
must_exist=False,
must_be_writable=True,
)
)
db_settings = settings.DATABASES["default"]
if db_settings["ENGINE"] == "django.db.backends.sqlite3":
checks.append(
_path_check(
"SQLite directory",
Path(str(db_settings["NAME"])).parent,
must_be_absolute=True,
must_exist=True,
must_be_writable=True,
)
)
return checks
def _path_check(
name: str,
path: Path,
*,
must_be_absolute: bool,
must_exist: bool = False,
must_be_writable: bool,
) -> SelfCheck:
if must_be_absolute and not path.is_absolute():
return SelfCheck(name, "failed", f"{path} is not absolute.")
if must_exist and not path.exists():
return SelfCheck(name, "failed", f"{path} does not exist.")
target = path if path.exists() else path.parent
if not target.exists():
return SelfCheck(name, "failed", f"{target} does not exist.")
if must_be_writable and not os.access(target, os.W_OK):
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
return SelfCheck(name, "ok", str(path))
def _binary_checks() -> list[SelfCheck]:
checks = []
for binary in ("rsync", "ssh", "ssh-keygen", "gunicorn"):
path = shutil.which(binary)
checks.append(
SelfCheck(
f"Binary: {binary}",
"ok" if path else "failed",
path or f"{binary} was not found in PATH.",
)
)
return checks
def _database_checks() -> list[SelfCheck]:
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
except Exception as exc:
return [SelfCheck("Database connection", "failed", f"{type(exc).__name__}: {exc}")]
return [SelfCheck("Database connection", "ok", settings.DATABASES["default"]["ENGINE"])]
def _config_checks() -> list[SelfCheck]:
try:
global_config = GlobalConfig.objects.get(name="default")
except GlobalConfig.DoesNotExist:
return [SelfCheck("Global config", "warning", "Default global config has not been created yet.")]
status: CheckStatus = "ok"
message = "Default global config exists."
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
status = "warning"
message = "Global config backup root differs from runtime POBSYNC_BACKUP_ROOT."
return [
SelfCheck(
"Global config",
status,
message,
f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}",
)
]
def _systemd_checks() -> list[SelfCheck]:
if not Path("/run/systemd/system").exists() or shutil.which("systemctl") is None:
return [
SelfCheck(
"Systemd services",
"skipped",
"systemd is not available in this runtime.",
"This is expected inside Docker.",
)
]
checks = []
for service in ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service"):
result = subprocess.run(
["systemctl", "is-active", service],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
active_state = result.stdout.strip() or result.stderr.strip()
checks.append(
SelfCheck(
service,
"ok" if result.returncode == 0 else "failed",
active_state,
)
)
return checks

View File

@@ -75,9 +75,12 @@
white-space: nowrap; white-space: nowrap;
} }
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; } .status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
.status.ok { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; } .status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; } .status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.status.warning { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.status.queued { color: var(--link); border-color: #b5cdea; background: #eef6ff; } .status.queued { color: var(--link); border-color: #b5cdea; background: #eef6ff; }
.status.skipped { color: var(--muted); background: #f7f9fb; }
.stack { display: grid; gap: 4px; } .stack { display: grid; gap: 4px; }
.stack.spaced { margin-bottom: 14px; } .stack.spaced { margin-bottom: 14px; }
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@@ -170,6 +173,7 @@
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong> <strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
<a href="{% url 'admin:index' %}">Admin</a> <a href="{% url 'admin:index' %}">Admin</a>
<a href="{% url 'ssh_credentials' %}">SSH Keys</a> <a href="{% url 'ssh_credentials' %}">SSH Keys</a>
<a href="{% url 'self_check' %}">Self Check</a>
<a href="/api/status/">Status API</a> <a href="/api/status/">Status API</a>
<span class="spacer"></span> <span class="spacer"></span>
<span class="muted">{{ request.user.username }}</span> <span class="muted">{{ request.user.username }}</span>

View File

@@ -0,0 +1,42 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Self Check | pobsync{% endblock %}
{% block content %}
<h1>Self Check</h1>
<section class="actions" aria-label="Self check actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
<section class="grid" aria-label="Self check summary">
<div class="metric"><div class="label">OK</div><div class="value">{{ summary.ok }}</div></div>
<div class="metric"><div class="label">Warnings</div><div class="value">{{ summary.warning }}</div></div>
<div class="metric"><div class="label">Failed</div><div class="value">{{ summary.failed }}</div></div>
<div class="metric"><div class="label">Skipped</div><div class="value">{{ summary.skipped }}</div></div>
</section>
<section class="panel">
<h2>Checks</h2>
<table>
<thead>
<tr>
<th>Status</th>
<th>Check</th>
<th>Message</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for check in checks %}
<tr>
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
<td>{{ check.name }}</td>
<td>{{ check.message }}</td>
<td class="muted">{{ check.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -83,6 +83,17 @@ class ViewTests(TestCase):
self.assertContains(response, reverse("create_host_config")) self.assertContains(response, reverse("create_host_config"))
self.assertContains(response, "Add first host") self.assertContains(response, "Add first host")
def test_self_check_renders_runtime_checks(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.get(reverse("self_check"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Self Check")
self.assertContains(response, "Django debug")
self.assertContains(response, "Database connection")
self.assertContains(response, "POBSYNC_HOME")
def test_ssh_credentials_view_creates_key(self) -> None: def test_ssh_credentials_view_creates_key(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)

View File

@@ -24,6 +24,7 @@ from .forms import (
) )
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
from .retention import run_sql_retention_apply, run_sql_retention_plan from .retention import run_sql_retention_apply, run_sql_retention_plan
from .self_check import collect_self_checks, summarize_self_checks
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
@@ -58,6 +59,19 @@ def dashboard(request):
return render(request, "pobsync_backend/dashboard.html", context) return render(request, "pobsync_backend/dashboard.html", context)
@staff_member_required
def self_check(request):
checks = collect_self_checks()
return render(
request,
"pobsync_backend/self_check.html",
{
"checks": checks,
"summary": summarize_self_checks(checks),
},
)
@staff_member_required @staff_member_required
def ssh_credentials(request): def ssh_credentials(request):
context = { context = {

View File

@@ -8,6 +8,7 @@ from pobsync_backend import api, views
urlpatterns = [ urlpatterns = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("self-check/", views.self_check, name="self_check"),
path("config/global/", views.edit_global_config, name="edit_global_config"), path("config/global/", views.edit_global_config, name="edit_global_config"),
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"), path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"), path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),