(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`
|
- 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
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_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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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_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
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
|
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",
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user