#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOURCE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" APP_DIR="/opt/tuxdmx" STAGE_DIR="/opt/tuxdmx.new" DATA_DIR="/var/lib/tuxdmx" BACKUP_DIR="/var/lib/tuxdmx/backups" DIAGNOSTICS_DIR="/var/lib/tuxdmx/diagnostics" UPLOADS_DIR="/var/lib/tuxdmx/uploads" FIXTURE_CACHE_DIR="/var/lib/tuxdmx/fixtures" LOG_DIR="/var/log/tuxdmx" CONFIG_DIR="/etc/tuxdmx" ENV_FILE="$CONFIG_DIR/tuxdmx.env" SERVICE_USER="tuxdmx" SERVICE_GROUP="tuxdmx" SYSTEMD_UNIT_NAME="tuxdmx.service" SYSTEMD_UNIT_PATH="/etc/systemd/system/$SYSTEMD_UNIT_NAME" SYSTEM_CONTROL_SUDOERS_PATH="/etc/sudoers.d/tuxdmx-control" SYSTEMD_WORKDIR="/opt/tuxdmx" PYTHON_BIN="${PYTHON_BIN:-python3}" NODE_REQUIRED_MAJOR="20" PNPM_REQUIRED_VERSION="10.28.2" SKIP_FRONTEND_BUILD="false" NON_INTERACTIVE="false" FORCE_OVERWRITE="false" APT_UPDATED="false" PNPM_BIN="" ROLLBACK_APP_ARCHIVE="" ROLLBACK_DATA_ARCHIVE="" ROLLBACK_CONFIG_ARCHIVE="" ROLLBACK_UNIT_FILE="" HAD_EXISTING_APP="false" HAD_EXISTING_DATA="false" HAD_EXISTING_CONFIG="false" HAD_EXISTING_UNIT="false" OLA_SERVICE_NAME="olad" OLA_SYSTEMD_UNIT="olad.service" readonly COMMON_PACKAGES=( python3 python3-venv python3-pip python3-dev sqlite3 sudo curl git jq zip unzip rsync ca-certificates nodejs npm ola alsa-utils usbutils ) readonly OLA_PYTHON_PACKAGES=( python3-ola ola-python ) readonly RUNTIME_ROOT_FILES=( ".env.example" "LICENSE" "NOTICE" "package.json" "pnpm-lock.yaml" "pnpm-workspace.yaml" "pyproject.toml" "README.md" "requirements.lock" "THIRD_PARTY_NOTICES.md" "tsconfig.json" "tsconfig.node.json" "vite.config.ts" ) readonly RUNTIME_DIRS=( "backend" "frontend" "scripts" "systemd" "test-data" ) log() { echo "[TuxDMX] $*" } fail() { echo "[TuxDMX] FEJL: $*" >&2 exit 1 } parse_common_args() { for arg in "$@"; do case "$arg" in --non-interactive) NON_INTERACTIVE="true" ;; --force) FORCE_OVERWRITE="true" ;; --skip-frontend-build) SKIP_FRONTEND_BUILD="true" ;; *) echo "Ukendt argument: $arg" >&2; exit 2 ;; esac done } require_root() { if [[ "${EUID}" -ne 0 ]]; then fail "Koer scriptet som root eller via sudo." fi } apt_update_once() { if [[ "$APT_UPDATED" == "true" ]]; then return fi apt-get update APT_UPDATED="true" } install_packages() { apt_update_once DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" } package_available() { apt-cache show "$1" >/dev/null 2>&1 } check_network_connectivity() { curl -fsS https://open-fixture-library.org >/dev/null } verify_debian_13_amd64() { source /etc/os-release local arch arch="$(dpkg --print-architecture)" [[ "${ID:-}" == "debian" ]] || fail "Debian 13 amd64 er paakraevet. Fundet ID=${ID:-ukendt}." [[ "${VERSION_ID:-}" == "13" ]] || fail "Debian 13 er paakraevet. Fundet VERSION_ID=${VERSION_ID:-ukendt}." [[ "$arch" == "amd64" ]] || fail "amd64 er paakraevet. Fundet arkitektur=$arch." } verify_pi_platform() { source /etc/os-release local arch arch="$(uname -m)" [[ "${ID:-}" =~ ^(debian|raspbian)$ ]] || fail "Pi-installation forventer Debian eller Raspberry Pi OS." [[ "$arch" =~ ^(aarch64|arm64|armv7l)$ ]] || log "Advarsel: forventet ARM-arkitektur, men fandt $arch." } verify_source_tree() { [[ -f "$SOURCE_ROOT/backend/app/main.py" ]] || fail "Source tree mangler backend/app/main.py." [[ -f "$SOURCE_ROOT/package.json" ]] || fail "Source tree mangler package.json." [[ -f "$SOURCE_ROOT/requirements.lock" ]] || fail "Source tree mangler requirements.lock." } confirm_install_overwrite() { if [[ -d "$APP_DIR" ]] && find "$APP_DIR" -mindepth 1 -print -quit >/dev/null 2>&1; then if [[ "$FORCE_OVERWRITE" == "true" ]]; then return fi if [[ "$NON_INTERACTIVE" == "true" ]]; then fail "Eksisterende installation fundet. Brug --force sammen med --non-interactive." fi read -r -p "Eksisterende installation fundet i $APP_DIR. Fortsaet? [y/N] " answer [[ "$answer" =~ ^[Yy]$ ]] || exit 1 fi } ensure_service_user() { if ! id "$SERVICE_USER" >/dev/null 2>&1; then useradd --system --home "$DATA_DIR" --create-home --groups dialout,plugdev,audio "$SERVICE_USER" fi usermod -a -G dialout,plugdev,audio "$SERVICE_USER" } ensure_directories() { install -d -o root -g "$SERVICE_GROUP" -m 0755 "$APP_DIR" install -d -o root -g "$SERVICE_GROUP" -m 0755 "$STAGE_DIR" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$DATA_DIR" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$BACKUP_DIR" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$DIAGNOSTICS_DIR" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$UPLOADS_DIR" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$FIXTURE_CACHE_DIR" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$LOG_DIR" install -d -o root -g "$SERVICE_GROUP" -m 0750 "$CONFIG_DIR" } ensure_backup_dir() { install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$BACKUP_DIR" } install_base_packages() { install_packages "${COMMON_PACKAGES[@]}" } install_ola_python_binding() { local installed="false" for candidate in "${OLA_PYTHON_PACKAGES[@]}"; do if package_available "$candidate"; then install_packages "$candidate" installed="true" break fi done if [[ "$installed" != "true" ]]; then fail "Kunne ikke finde Debian-pakke til OLA Python-binding (proevede: ${OLA_PYTHON_PACKAGES[*]})." fi if ! "$PYTHON_BIN" - <<'PY' from ola.ClientWrapper import ClientWrapper print(ClientWrapper.__name__) PY then fail "OLA Python-binding kunne ikke importeres med system-python." fi command -v olad >/dev/null 2>&1 || fail "olad blev ikke fundet efter installation af OLA." } ensure_node_toolchain() { local node_version local node_major local global_prefix node_version="$(node --version | sed 's/^v//')" node_major="${node_version%%.*}" [[ "$node_major" == "$NODE_REQUIRED_MAJOR" ]] || fail "Node 20 er paakraevet. Fundet $node_version." npm --version >/dev/null global_prefix="$(npm config get prefix)" npm install -g --force "pnpm@$PNPM_REQUIRED_VERSION" export PATH="$global_prefix/bin:$PATH" hash -r PNPM_BIN="$(command -v pnpm || true)" [[ -n "$PNPM_BIN" ]] || fail "pnpm blev ikke fundet efter installation." [[ "$("$PNPM_BIN" --version)" == "$PNPM_REQUIRED_VERSION" ]] \ || fail "Aktiv pnpm-version er ikke $PNPM_REQUIRED_VERSION." } require_source_frontend_artifacts() { [[ -f "$SOURCE_ROOT/frontend/dist/index.html" ]] \ || fail "--skip-frontend-build kraever eksisterende frontend/dist/index.html." } build_frontend() { if [[ "$SKIP_FRONTEND_BUILD" == "true" ]]; then require_source_frontend_artifacts return fi ensure_node_toolchain ( cd "$SOURCE_ROOT" "$PNPM_BIN" install --frozen-lockfile "$PNPM_BIN" build ) [[ -f "$SOURCE_ROOT/frontend/dist/index.html" ]] \ || fail "Frontend-build afsluttede uden frontend/dist/index.html." } cleanup_stage_dir() { rm -rf "$STAGE_DIR" install -d -o root -g "$SERVICE_GROUP" -m 0755 "$STAGE_DIR" } sync_directory() { local relative_path="$1" rsync -a --delete --chown=root:"$SERVICE_GROUP" \ --chmod=Du=rwx,Dg=rx,Do=rx,Fu=rw,Fg=r,Fo=r \ "$SOURCE_ROOT/$relative_path/" "$STAGE_DIR/$relative_path/" } sync_file() { local relative_path="$1" install -d -o root -g "$SERVICE_GROUP" -m 0755 "$(dirname "$STAGE_DIR/$relative_path")" rsync -a --chown=root:"$SERVICE_GROUP" \ --chmod=Fu=rw,Fg=r,Fo=r \ "$SOURCE_ROOT/$relative_path" "$STAGE_DIR/$relative_path" } sync_runtime_tree_to_stage() { local dir_path local file_path cleanup_stage_dir for dir_path in "${RUNTIME_DIRS[@]}"; do sync_directory "$dir_path" done for file_path in "${RUNTIME_ROOT_FILES[@]}"; do if [[ -f "$SOURCE_ROOT/$file_path" ]]; then sync_file "$file_path" fi done ln -sfn "$DATA_DIR" "$STAGE_DIR/data" find "$STAGE_DIR/scripts" -type f -name "*.sh" -exec chmod 0755 {} + [[ -f "$STAGE_DIR/backend/app/main.py" ]] || fail "Backend-layout er forkert: $STAGE_DIR/backend/app/main.py mangler." [[ -f "$STAGE_DIR/frontend/dist/index.html" ]] || fail "Kopieret frontend/dist/index.html mangler i stage." } create_or_refresh_venv() { log "Opretter virtualenv i stage" "$PYTHON_BIN" -m venv --system-site-packages "$STAGE_DIR/.venv" "$STAGE_DIR/.venv/bin/pip" install --upgrade pip "$STAGE_DIR/.venv/bin/pip" install -r "$STAGE_DIR/requirements.lock" run_runtime_pip_check } run_runtime_pip_check() { local output="" local status=0 local filtered="" output="$("$STAGE_DIR/.venv/bin/pip" check 2>&1)" || status=$? if [[ "$status" -eq 0 ]]; then return fi filtered="$(printf '%s\n' "$output" | grep -Ev '^apt-listchanges .+ requires debconf, which is not installed\.$' || true)" if [[ -z "${filtered//[[:space:]]/}" ]]; then log "Ignorerer kendt Debian pip check-advarsel fra apt-listchanges/debconf." return fi printf '%s\n' "$filtered" >&2 fail "pip check fejlede i runtime-venv." } write_default_env_file() { local secret secret="$("$STAGE_DIR/.venv/bin/python" - <<'PY' import secrets print(secrets.token_hex(32)) PY )" cat >"$ENV_FILE" <>"$ENV_FILE" } load_env_lines() { local line while IFS= read -r line; do [[ -z "$line" ]] && continue [[ "$line" =~ ^[[:space:]]*# ]] && continue printf '%s\0' "$line" done < "$ENV_FILE" } run_as_service_user() { local app_root="$1" shift local env_lines=() while IFS= read -r -d '' line; do env_lines+=("$line") done < <(load_env_lines) runuser -u "$SERVICE_USER" -- env \ "PYTHONPATH=$app_root/backend" \ "${env_lines[@]}" \ "$@" } verify_python_runtime() { run_as_service_user "$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c "import greenlet; print(greenlet.__version__)" run_as_service_user "$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c "import sqlalchemy; print(sqlalchemy.__version__)" run_as_service_user "$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c "import ola; print(ola.__file__)" } verify_stage_permissions_and_layout() { test -f "$STAGE_DIR/backend/app/main.py" || fail "Stage mangler backend/app/main.py." runuser -u "$SERVICE_USER" -- test -r "$STAGE_DIR/frontend/dist/index.html" runuser -u "$SERVICE_USER" -- "$PYTHON_BIN" -c \ "from pathlib import Path; print(Path('$STAGE_DIR/frontend/dist/index.html').read_text()[:20])" } verify_app_import() { run_as_service_user "$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c \ "import sys; sys.path.insert(0, '$STAGE_DIR/backend'); import app.main" } run_migrations() { log "Koerer database migration som $SERVICE_USER" run_as_service_user "$STAGE_DIR" \ "$STAGE_DIR/.venv/bin/python" -m alembic -c "$STAGE_DIR/backend/alembic.ini" upgrade head } verify_database_writable() { run_as_service_user "$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c \ "from pathlib import Path; db=Path('/var/lib/tuxdmx/tuxdmx.db'); print(db.exists()); wal=Path('/var/lib/tuxdmx/tuxdmx.db-wal'); wal.touch(exist_ok=True); print(wal.exists())" chown "$SERVICE_USER":"$SERVICE_GROUP" /var/lib/tuxdmx/tuxdmx.db* 2>/dev/null || true } install_systemd_unit() { install -m 0644 "$STAGE_DIR/systemd/tuxdmx.service" "$SYSTEMD_UNIT_PATH" systemctl daemon-reload enable_ola_service install_system_control_sudoers systemctl enable "$SYSTEMD_UNIT_NAME" } install_system_control_sudoers() { cat >"$SYSTEM_CONTROL_SUDOERS_PATH" <<'EOF' Defaults:tuxdmx !requiretty tuxdmx ALL=(root) NOPASSWD: /opt/tuxdmx/scripts/control-system.sh restart-service, /opt/tuxdmx/scripts/control-system.sh reboot-host EOF chmod 0440 "$SYSTEM_CONTROL_SUDOERS_PATH" visudo -cf "$SYSTEM_CONTROL_SUDOERS_PATH" >/dev/null } show_service_diagnostics() { systemctl status "$SYSTEMD_UNIT_NAME" --no-pager --full || true journalctl -u "$SYSTEMD_UNIT_NAME" -n 200 --no-pager || true } resolve_ola_service_name() { local unit_files unit_files="$(systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '{print $1}')" if grep -Fxq "olad.service" <<<"$unit_files"; then OLA_SERVICE_NAME="olad" OLA_SYSTEMD_UNIT="olad.service" return fi if grep -Fxq "ola.service" <<<"$unit_files"; then OLA_SERVICE_NAME="ola" OLA_SYSTEMD_UNIT="ola.service" return fi if [[ -x /etc/init.d/olad ]]; then OLA_SERVICE_NAME="olad" OLA_SYSTEMD_UNIT="olad.service" return fi if [[ -x /etc/init.d/ola ]]; then OLA_SERVICE_NAME="ola" OLA_SYSTEMD_UNIT="ola.service" fi } has_sysv_boot_link() { local service_name="$1" compgen -G "/etc/rc?.d/S??$service_name" >/dev/null } enable_ola_service() { resolve_ola_service_name systemctl enable "$OLA_SYSTEMD_UNIT" >/dev/null 2>&1 || true if [[ -x "/etc/init.d/$OLA_SERVICE_NAME" ]]; then update-rc.d "$OLA_SERVICE_NAME" defaults >/dev/null 2>&1 || true fi if [[ "$OLA_SERVICE_NAME" != "olad" && -x /etc/init.d/olad ]]; then update-rc.d olad defaults >/dev/null 2>&1 || true fi if [[ "$OLA_SERVICE_NAME" != "ola" && -x /etc/init.d/ola ]]; then update-rc.d ola defaults >/dev/null 2>&1 || true fi if systemctl is-enabled "$OLA_SYSTEMD_UNIT" >/dev/null 2>&1; then return fi if has_sysv_boot_link "$OLA_SERVICE_NAME"; then return fi fail "Kunne ikke registrere $OLA_SYSTEMD_UNIT til autostart." } show_ola_diagnostics() { systemctl status "$OLA_SYSTEMD_UNIT" --no-pager --full || true journalctl -u "$OLA_SYSTEMD_UNIT" -n 200 --no-pager || true } stop_existing_service_for_snapshot() { if systemctl list-unit-files "$SYSTEMD_UNIT_NAME" >/dev/null 2>&1; then systemctl stop "$SYSTEMD_UNIT_NAME" || true for _ in $(seq 1 15); do local state state="$(systemctl is-active "$SYSTEMD_UNIT_NAME" || true)" if [[ "$state" != "active" && "$state" != "activating" && "$state" != "deactivating" ]]; then break fi sleep 1 done fi } restart_and_healthcheck() { local max_checks=30 local check resolve_ola_service_name if ! systemctl restart "$OLA_SYSTEMD_UNIT"; then if ! service "$OLA_SERVICE_NAME" restart; then show_ola_diagnostics fail "Kunne ikke restarte $OLA_SYSTEMD_UNIT." fi fi if ! systemctl restart "$SYSTEMD_UNIT_NAME"; then show_service_diagnostics fail "Kunne ikke starte $SYSTEMD_UNIT_NAME." fi for check in $(seq 1 "$max_checks"); do local service_state service_state="$(systemctl is-active "$SYSTEMD_UNIT_NAME" || true)" if [[ "$service_state" == "failed" || "$service_state" == "deactivating" ]]; then show_service_diagnostics fail "tuxdmx.service gik i state '$service_state' under health check." fi if curl -fsS http://127.0.0.1:8000/api/v1/health >/dev/null; then systemctl is-active "$SYSTEMD_UNIT_NAME" >/dev/null return fi sleep 2 done show_service_diagnostics fail "Health check mod http://127.0.0.1:8000/api/v1/health fejlede." } make_archive() { local source_dir="$1" local archive_path="$2" if [[ ! -d "$source_dir" ]]; then return 1 fi install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$BACKUP_DIR" if [[ "$source_dir" == "$DATA_DIR" ]]; then tar \ --warning=no-file-changed \ --exclude="./backups" \ --exclude="./tuxdmx.json.log" \ -czf "$archive_path" -C "$source_dir" . return fi tar -czf "$archive_path" -C "$source_dir" . } restore_archive() { local archive_path="$1" local target_dir="$2" [[ -f "$archive_path" ]] || return rm -rf "$target_dir" install -d -o root -g "$SERVICE_GROUP" -m 0755 "$target_dir" tar -xzf "$archive_path" -C "$target_dir" } prepare_install_rollback() { local stamp stamp="$(date +%Y%m%d-%H%M%S)" ensure_backup_dir if [[ -d "$APP_DIR" ]] && find "$APP_DIR" -mindepth 1 -print -quit >/dev/null 2>&1; then HAD_EXISTING_APP="true" ROLLBACK_APP_ARCHIVE="$BACKUP_DIR/install-app-$stamp.tar.gz" make_archive "$APP_DIR" "$ROLLBACK_APP_ARCHIVE" fi if [[ -d "$DATA_DIR" ]] && find "$DATA_DIR" -mindepth 1 -print -quit >/dev/null 2>&1; then HAD_EXISTING_DATA="true" ROLLBACK_DATA_ARCHIVE="$BACKUP_DIR/install-data-$stamp.tar.gz" make_archive "$DATA_DIR" "$ROLLBACK_DATA_ARCHIVE" fi if [[ -d "$CONFIG_DIR" ]] && find "$CONFIG_DIR" -mindepth 1 -print -quit >/dev/null 2>&1; then HAD_EXISTING_CONFIG="true" ROLLBACK_CONFIG_ARCHIVE="$BACKUP_DIR/install-config-$stamp.tar.gz" make_archive "$CONFIG_DIR" "$ROLLBACK_CONFIG_ARCHIVE" fi if [[ -f "$SYSTEMD_UNIT_PATH" ]]; then HAD_EXISTING_UNIT="true" ROLLBACK_UNIT_FILE="$BACKUP_DIR/install-$SYSTEMD_UNIT_NAME.$stamp.bak" cp "$SYSTEMD_UNIT_PATH" "$ROLLBACK_UNIT_FILE" fi } prepare_update_rollback() { local stamp stamp="$(date +%Y%m%d-%H%M%S)" ensure_backup_dir ROLLBACK_APP_ARCHIVE="$BACKUP_DIR/update-app-$stamp.tar.gz" ROLLBACK_DATA_ARCHIVE="$BACKUP_DIR/update-data-$stamp.tar.gz" ROLLBACK_CONFIG_ARCHIVE="$BACKUP_DIR/update-config-$stamp.tar.gz" ROLLBACK_UNIT_FILE="$BACKUP_DIR/update-$SYSTEMD_UNIT_NAME.$stamp.bak" make_archive "$APP_DIR" "$ROLLBACK_APP_ARCHIVE" || fail "Kunne ikke oprette backup af eksisterende appmappe." make_archive "$DATA_DIR" "$ROLLBACK_DATA_ARCHIVE" || fail "Kunne ikke oprette backup af datamappe." make_archive "$CONFIG_DIR" "$ROLLBACK_CONFIG_ARCHIVE" || true if [[ -f "$SYSTEMD_UNIT_PATH" ]]; then cp "$SYSTEMD_UNIT_PATH" "$ROLLBACK_UNIT_FILE" fi } restore_rollbacks() { log "Fejl opdaget. Forsoger rollback." if [[ -n "$ROLLBACK_APP_ARCHIVE" && -f "$ROLLBACK_APP_ARCHIVE" ]]; then restore_archive "$ROLLBACK_APP_ARCHIVE" "$APP_DIR" fi if [[ -n "$ROLLBACK_DATA_ARCHIVE" && -f "$ROLLBACK_DATA_ARCHIVE" ]]; then restore_archive "$ROLLBACK_DATA_ARCHIVE" "$DATA_DIR" chown -R "$SERVICE_USER":"$SERVICE_GROUP" "$DATA_DIR" fi if [[ -n "$ROLLBACK_CONFIG_ARCHIVE" && -f "$ROLLBACK_CONFIG_ARCHIVE" ]]; then restore_archive "$ROLLBACK_CONFIG_ARCHIVE" "$CONFIG_DIR" chown -R root:"$SERVICE_GROUP" "$CONFIG_DIR" chmod 0750 "$CONFIG_DIR" [[ -f "$ENV_FILE" ]] && chmod 0640 "$ENV_FILE" fi if [[ -f "$ROLLBACK_UNIT_FILE" ]]; then install -m 0644 "$ROLLBACK_UNIT_FILE" "$SYSTEMD_UNIT_PATH" fi rm -rf "$STAGE_DIR" systemctl daemon-reload || true resolve_ola_service_name systemctl restart "$OLA_SYSTEMD_UNIT" || true systemctl restart "$SYSTEMD_UNIT_NAME" || true } promote_stage() { systemctl stop "$SYSTEMD_UNIT_NAME" || true rm -rf "$APP_DIR" mv "$STAGE_DIR" "$APP_DIR" chown -R root:"$SERVICE_GROUP" "$APP_DIR" } finalize_runtime_layout() { ln -sfn "$DATA_DIR" "$APP_DIR/data" } run_install_flow() { local platform_label="$1" require_root confirm_install_overwrite check_network_connectivity log "Starter installation for $platform_label" verify_source_tree install_base_packages install_ola_python_binding ensure_node_toolchain build_frontend stop_existing_service_for_snapshot prepare_install_rollback trap 'restore_rollbacks' ERR ensure_service_user ensure_directories sync_runtime_tree_to_stage create_or_refresh_venv ensure_env_file verify_python_runtime verify_stage_permissions_and_layout verify_app_import run_migrations verify_database_writable install_systemd_unit promote_stage finalize_runtime_layout restart_and_healthcheck trap - ERR log "Installation fuldfoert" } run_update_flow() { local platform_label="$1" require_root check_network_connectivity log "Starter update for $platform_label" verify_source_tree install_base_packages install_ola_python_binding ensure_node_toolchain build_frontend stop_existing_service_for_snapshot prepare_update_rollback trap 'restore_rollbacks' ERR ensure_service_user ensure_directories sync_runtime_tree_to_stage create_or_refresh_venv ensure_env_file verify_python_runtime verify_stage_permissions_and_layout verify_app_import run_migrations verify_database_writable install_systemd_unit promote_stage finalize_runtime_layout restart_and_healthcheck trap - ERR log "Update gennemfoert" }