#!/usr/bin/env bash
# Vloud public bootstrap — `curl -fsSL https://install.vloud.app | sudo bash`.
#
# Modes (auto-detected; explicit flags override):
#
#   (default, no flag)     — auto.  If /opt/vloud/packages/server/dist
#                            exists, switches to upgrade mode.  Otherwise
#                            runs fresh install.
#   --upgrade              — explicit upgrade. Preserves /etc/vloud.env,
#                            /var/lib/vloud, SQLite DB, generated nginx
#                            vhosts, SSL certs, pm2 deployments. Swaps
#                            binaries atomically via /opt/vloud-staging-<ts>,
#                            health-checks the new engine, rolls back on
#                            failure.
#   --repair               — re-emit systemd units, daemon-reload, restart
#                            services. No state touched. For when the
#                            install dir is fine but systemd got tangled.
#   --doctor / --health    — read-only health check. Prints services,
#                            ports, version, DB integrity. No changes.
#   --force-clean-install  — explicit destructive reinstall.  Stops
#                            services, removes /opt/vloud, /etc/vloud.env,
#                            /var/lib/vloud, /etc/systemd/system/vloud*,
#                            then runs fresh install. Last resort.
#   --uninstall            — remove the Vloud engine + state WITHOUT
#                            reinstalling. Same removals as force-clean
#                            (engine, units, /opt/vloud, /var/lib/vloud,
#                            /etc/vloud*, sudoers, engine nginx vhosts);
#                            keeps the OS, SSH, and daemon-stack packages.
#                            Prompts for a typed "UNINSTALL"; add --yes to
#                            skip the prompt (unattended).
#   --fresh                — explicit fresh install. Refuses to run if
#                            /opt/vloud exists (use --force-clean-install
#                            for the destructive path).
#   --restore=<bundle>     — cold-restore a platform backup onto THIS host
#                            (install the same engine version first). Hands
#                            off to scripts/vloud-restore.sh, which stops
#                            the services, verifies + decrypts the bundle and
#                            asks for a typed "RESTORE" (--yes skips it).
#
# Release policy (F-06 — the version floor):
#   --version=<v>          install exactly <v>; the SIGNED payload must name it.
#   --rollback [--to <v|slot|path>]
#                          restore a RETAINED /opt/vloud.pre-upgrade-<ts> release
#                          that verifies against its SHA256SUMS. Arbitrary
#                          directories are refused.
#   --recovery-marker=<file>
#                          the only way past the floor (older than installed,
#                          below the signed min_supported): a release-key-signed
#                          marker naming one target version. Never defaults on.
#                          See docs/operations/release-recovery-marker.md.
#   Refused unless a marker authorises it: a version lower than the installed
#   one, lower than the signed channel index's min_supported, or revoked.
#
# Optional add-ons (combine with any install mode):
#
#   --with-dotnet          — install the ASP.NET Core RUNTIME, so a pipeline
#                            can deploy a .NET application with
#                            `runtime: dotnet`. Opt-in on purpose: it is a few
#                            hundred megabytes, most hosts never run a .NET
#                            app, and the engine NEVER installs a runtime
#                            implicitly during a deploy.
#
#   --acme-email=<addr>    — the Let's Encrypt contact e-mail, written to
#                            /etc/vloud.env as VLOUD_ACME_EMAIL (also read
#                            from the environment). Must be a real mailbox:
#                            *.local/.invalid/example.* and noreply@ are
#                            refused. Without it the engine falls back to
#                            its ACME-contact setting or the owner's e-mail
#                            and, if neither is usable, refuses to request
#                            certificates until one is set (SSL page).
#
# Curl-pipe-bash:
#   curl -fsSL https://install.vloud.app | sudo bash
#   curl -fsSL https://install.vloud.app | sudo bash -s -- --upgrade
#   curl -fsSL https://install.vloud.app | sudo bash -s -- --repair
#   curl -fsSL https://install.vloud.app | sudo bash -s -- --doctor
#   curl -fsSL https://install.vloud.app | sudo bash -s -- --force-clean-install
#   curl -fsSL https://install.vloud.app | sudo bash -s -- --uninstall
#   curl -fsSL https://install.vloud.app | sudo bash -s -- --uninstall --yes
#   curl -fsSL https://install.vloud.app | sudo bash -s -- --upgrade --with-dotnet
#
# Fresh-install phases (auto / --fresh / --force-clean-install):
#   1. Pre-flight (OS / arch / disk / RAM / outbound network)
#   2. Apt install (curl, nginx, node 20, sqlite3, dnsutils, certbot, php-fpm)
#   3. Vloud release download + extract to /opt/vloud
#   4. Configure: vloud user, /etc/vloud.env (with trial state), systemd unit
#   5. Start engine + emit dashboard URL + trial status
#   6. Daemon stack (Phase 1) — postfix/dovecot/rspamd/fail2ban/bind/ftp/
#      firewall/tenant-slices/storage-quota via bootstrap-daemon-stack.sh.
#      Default ON; cPanel hosts skip by default; per-daemon opt-out env vars.
#
# Idempotent: every step gates on existing state.  Re-runs are safe.
# Zero-interaction: defaults everywhere, no prompts.
# Logs: /var/log/vloud/install.log
set -euo pipefail

# ─── Defaults (operator can override via env) ───
VLOUD_INSTALL_DIR=${VLOUD_INSTALL_DIR:-/opt/vloud}
VLOUD_RELEASE_URL=${VLOUD_RELEASE_URL:-https://install.vloud.app/releases/latest/vloud-latest.tar.gz}
VLOUD_PORT=${VLOUD_PORT:-2544}
VLOUD_BIND_HOST=${VLOUD_BIND_HOST:-127.0.0.1}
VLOUD_TRIAL_DAYS=${VLOUD_TRIAL_DAYS:-30}
# Let's Encrypt account contact (--acme-email). Empty = not provided here.
VLOUD_ACME_EMAIL=${VLOUD_ACME_EMAIL:-}
# 0.6.0c — hoisted from the fresh-install env-phase so that
# `do_reconcile_env_file()` (called from --upgrade + --repair via
# the mode dispatcher) can reference it without tripping `set -u`.
# Operator can override via env if pointing at a private license
# server (e.g. for offline / behind-firewall installs).
VLOUD_LICENSE_SERVER_URL_DEFAULT=${VLOUD_LICENSE_SERVER_URL_DEFAULT:-https://license.vloud.app}
ENV_FILE=/etc/vloud.env
LOG_DIR=/var/log/vloud
LOG_FILE=$LOG_DIR/install.log
SYSTEMD_UNIT=/etc/systemd/system/vloud.service
SYSTEMD_SLICE=/etc/systemd/system/vloud.slice
SYSTEMD_WORKER=/etc/systemd/system/vloud-job-worker.service
SYSTEMD_SCHEDULER=/etc/systemd/system/vloud-scheduler.service
# Phase E (2026-05-14): auto-rollback unit fired by vloud.service's
# OnFailure when the engine crash-loops past StartLimitBurst.
SYSTEMD_ROLLBACK=/etc/systemd/system/vloud-rollback.service

# ─── Lifecycle progress + apply-run correlation (PR1) ───
#
# Engine-orchestrated upgrades (POST /api/v1/updates/apply →
# executePlan → spawns bootstrap.sh) pass --apply-run-id=<n> and
# --version=<v>; bootstrap.sh emits append-only JSONL events to
# VLOUD_PROGRESS_FILE so the engine can resolve update_status
# without parsing logs. Operator-run bootstrap.sh (curl-pipe-bash)
# omits these flags and behaves identically to pre-PR1 — every new
# code path is conditional on the flags being set.
VLOUD_PROGRESS_FILE=${VLOUD_PROGRESS_FILE:-/var/lib/vloud/.upgrade-progress}
VLOUD_PROGRESS_RETAIN_BYTES=${VLOUD_PROGRESS_RETAIN_BYTES:-262144}  # 256 KiB before rotation
VLOUD_PROGRESS_STALE_DAYS=${VLOUD_PROGRESS_STALE_DAYS:-30}          # how long rotated logs live
VLOUD_APPLY_RUN_ID=${VLOUD_APPLY_RUN_ID:-}
VLOUD_TARGET_VERSION=${VLOUD_TARGET_VERSION:-}
VLOUD_ROLLBACK_TO=${VLOUD_ROLLBACK_TO:-}
# Phase E (2026-05-14): set to 1 when systemd's vloud-rollback.service
# fires us via OnFailure. Currently informational only.
VLOUD_ROLLBACK_AUTOMATIC=${VLOUD_ROLLBACK_AUTOMATIC:-}
VLOUD_ASSUME_YES=${VLOUD_ASSUME_YES:-0}

# ─── 1. Pre-flight ───
# Root check is mode-aware — --doctor is read-only and runs fine as
# any user that can read systemd state + reach 127.0.0.1:2544. The
# four mutating modes (fresh, upgrade, repair, force-clean) need root.
_NEEDS_ROOT=1
for _a in "$@"; do
  case "$_a" in
    --doctor|--health|--help|-h) _NEEDS_ROOT=0 ;;
  esac
done
if (( _NEEDS_ROOT == 1 )) && [[ $EUID -ne 0 ]]; then
  echo "ERROR: bootstrap.sh must be run as root for this mode (curl -fsSL ... | sudo bash)" >&2
  echo "       (--doctor / --help do not require root)" >&2
  exit 1
fi
unset _NEEDS_ROOT _a

if [[ -f /etc/os-release ]]; then
  . /etc/os-release
else
  echo "ERROR: /etc/os-release missing — unsupported distro" >&2
  exit 1
fi
# OS-family detection. Vloud supports the Debian family (Ubuntu/Debian)
# and the RHEL family (AlmaLinux/Rocky/RHEL 9). OS_FAMILY drives every
# package-manager / firewall / SELinux branch below — see the
# install_packages / ensure_rhel_repos / configure_selinux helpers.
case "$ID" in
  ubuntu|debian)        OS_FAMILY=debian ;;
  almalinux|rocky|rhel) OS_FAMILY=rhel ;;
  *)
    case " ${ID_LIKE:-} " in
      *" rhel "*|*" fedora "*|*" centos "*) OS_FAMILY=rhel ;;
      *" debian "*)                         OS_FAMILY=debian ;;
      *) echo "ERROR: Vloud bootstrap supports Ubuntu/Debian and AlmaLinux/Rocky/RHEL 9 only (you have $ID)" >&2; exit 1 ;;
    esac ;;
esac
export OS_FAMILY
# Major version (e.g. 9 from 9.3) — used for EPEL/Remi repo URLs on RHEL.
OS_VERSION_MAJOR=${VERSION_ID%%.*}
export OS_VERSION_MAJOR
# Web-server group that owns ACME / docroot dirs: www-data on Debian,
# nginx on the RHEL family (matches OsAdapter.paths.httpGroup engine-side).
if [[ "$OS_FAMILY" == rhel ]]; then HTTP_GROUP=nginx; else HTTP_GROUP=www-data; fi
export HTTP_GROUP

# Remember whether we are on a terminal NOW: after the tee below stdout is a pipe.
VLOUD_LOGO_COLOR=0
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then VLOUD_LOGO_COLOR=1; fi

# Logging tee — set up before any output so we capture everything.
mkdir -p "$LOG_DIR"
exec > >(tee -a "$LOG_FILE") 2>&1

CYAN='\033[1;36m'; GREEN='\033[1;32m'; YELLOW='\033[1;33m'; RED='\033[1;31m'; RESET='\033[0m'
say()  { printf "${CYAN}▸${RESET} %s\n" "$1"; }
ok()   { printf "  ${GREEN}✓${RESET} %s\n" "$1"; }
warn() { printf "  ${YELLOW}⚠${RESET} %s\n" "$1"; }
fail() { printf "  ${RED}✗${RESET} %s\n" "$1" >&2; }
die()  { fail "$1"; emit_progress die failed "$1"; exit "${2:-1}"; }

# >>> vloud-logo
# >>> vloud-logo-art
# Generated by scripts/brand/gen-installer-logo.mjs from the Vloud horizontal logo (SVG); do not edit by hand.
# 78 x 26 pixels (two pixel rows per text row), 32 colours; entry 0 is the dark plate (#050b18).
_VLOUD_LOGO_CH='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
_VLOUD_LOGO_TC=('5;11;24' '4;21;52' '3;36;88' '0;34;148' '1;38;144' '2;68;137' '0;54;185' '1;72;196' '0;80;230' '0;98;245' '1;130;218' '0;123;248' '0;119;250' '0;154;250' '5;15;32' '13;24;37' '12;81;108' '12;140;190' '18;187;244' '16;188;250' '51;56;68' '93;98;108' '33;155;175' '34;208;245' '23;201;250' '38;216;251' '132;136;144' '61;219;240' '160;164;172' '191;194;200' '226;229;234' '242;245;249')
_VLOUD_LOGO_256=(233 234 17 18 18 24 25 26 26 27 32 33 33 33 233 234 23 31 39 39 237 241 31 45 45 45 245 81 248 251 254 255)
_VLOUD_LOGO_ROWS=(
  '000000000000000000000000000000000000000000000000000000000000000000000000000000'
  '0000000000000000015HHHG2000000000000000000000000000000000000000000000000000000'
  '0000000000000002ADDDJJJOH20000000000000000000000000000000000000000000000000000'
  '000000000000005CDDDDDJJOOOG000000000000000000000000000000000000000000000000000'
  '000000000000059CBDDDDJJJOONG00000000000000000000000000000000000000000000000000'
  '000000000000488CCBDDADJJOOPP20000000000000000000000000000000000000000000000000'
  '00000000000E7889C710015IOOPPM0000000000000000000000000000000000000000000000000'
  '000000001555G26840000002IOPPP10000000000000000000KL0000000000000000000000FQL00'
  '0000001HDDDDDAG4000000005PPPPG000000000000000000FVU0000000000000000000000LVT00'
  '000002766ADDDDD2000000002PIDDJIHG000000000000000FVU0000000000000000000000LVT00'
  '0000477633ADDDDAE0000000GDDDDJJOPME00000QSF000LSKVU00LSTSK0FSS000QSK0KSTSQVT00'
  '00028877336DDDDD5000000E7BDDDJJONPM00000QVS00KVUFVU0SVVUVVLKVU000TVKLVVUVVVT00'
  '000888773447DDDCC1000005CBDDDJJOOPPG0000FUVK0TVLFVULVTF0LVUKVU000TVLVVK0FTVT00'
  '002887761001DBCCC600001CCBDD72GIONPN00000QVQKVUEFVUQVL000TVLVU000TVQVS000LVT00'
  '0048877100005CCC9810007CCCDA0002OOPP20000FVUTVL0FVUQVL000TVLVUE00UVQVT000QVS00'
  '004877600000E9C9886005CCCCD20000HOOOG00000SVVUE0FVUKVULKSVTEUVSLSVUFUVSKLUVL00'
  '00477760000002988772199CCC500000HOOOG00000KVVL00FVU0LUVVVTF0KUVVVUK0KUVVVVQ000'
  '00277771000000788764699CC9E00000AJOO2000000KK0000LK00FLLK0000FLLLF000ELLLF0000'
  '00E77764E00000287636999CC2000005DDJIE00000000000000000000000000000000000000000'
  '0004763342111104633899997E222GHDDDD5000000000000000000000000000000000000000000'
  '000E33333333332133789999179CDDDDDDAE000000000000000000000000000000000000000000'
  '0000E4333333333E27889995299CBDDDDA10000000000000000000000000000000000000000000'
  '00000024333333320688897E899CCDDA5E00000000000000000000000000000000000000000000'
  '00000000E11111110026620E222222100000000000000000000000000000000000000000000000'
  '000000000000000000000000000000000000000000000000000000000000000000000000000000'
  '000000000000000000000000000000000000000000000000000000000000000000000000000000'
)
# <<< vloud-logo-art

# The Vloud logo (the blue cloud mark and the "vloud" wordmark, from the horizontal-dark SVG), drawn with half blocks: each text row
# shows two pixel rows (foreground = upper pixel, background = lower pixel) over the dark plate the art carries, so it looks right
# on light and dark terminals. Shown when the installer starts and again above the final summary. Colour only when stdout was a
# terminal at the very start (before the log tee turns it into a pipe) and NO_COLOR is unset; truecolor terminals get the exact
# colours, others the nearest xterm-256 ones; no colour or a non-UTF-8 locale gets a plain wordmark instead.
vloud_logo() {
  local mode=none utf8=0
  if [[ "${VLOUD_LOGO_COLOR:-0}" == 1 ]]; then
    if [[ "${COLORTERM:-}" == *truecolor* || "${COLORTERM:-}" == *24bit* ]]; then mode=tc; else mode=256; fi
  fi
  case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) utf8=1 ;; esac
  echo
  if [[ "$mode" != none ]] && (( utf8 )); then
    local rows=${#_VLOUD_LOGO_ROWS[@]} y x t b ti bi tprev bprev line rt rb prefix
    for (( y = 0; y + 1 < rows; y += 2 )); do
      line=' '; tprev=''; bprev=''
      rt=${_VLOUD_LOGO_ROWS[y]}; rb=${_VLOUD_LOGO_ROWS[y+1]}
      for (( x = 0; x < ${#rt}; x++ )); do
        t=${rt:x:1}; b=${rb:x:1}
        if [[ "$t" != "$tprev" || "$b" != "$bprev" ]]; then
          prefix=${_VLOUD_LOGO_CH%%"$t"*}; ti=${#prefix}
          prefix=${_VLOUD_LOGO_CH%%"$b"*}; bi=${#prefix}
          if [[ "$mode" == tc ]]; then
            line+=$'\033[38;2;'"${_VLOUD_LOGO_TC[ti]}"';48;2;'"${_VLOUD_LOGO_TC[bi]}"'m'
          else
            line+=$'\033[38;5;'"${_VLOUD_LOGO_256[ti]}"';48;5;'"${_VLOUD_LOGO_256[bi]}"'m'
          fi
          tprev=$t; bprev=$b
        fi
        line+='▀'
      done
      printf '%s\033[0m\n' "$line"
    done
    printf ' \033[2mCloud, Simplified.\033[0m\n\n'
  else
    printf '  Vloud\n  Cloud, Simplified.\n\n'
  fi
}
# <<< vloud-logo

# >>> vloud-summary-helpers
# The login to put in the "ssh -L" hint of the final summary: whoever ran the installer (sudo keeps that in SUDO_USER), otherwise the
# login of the terminal, otherwise root. It used to print a literal <user> that the operator had to guess.
vloud_ssh_user() {
  local u="${SUDO_USER:-}"
  if [[ -z "$u" || "$u" == root ]]; then u=$(logname 2>/dev/null || true); fi
  [[ -n "$u" ]] || u=root
  printf '%s' "$u"
}

# The "Trial:" line of the final summary. Right after the install the engine has not yet confirmed its trial with the Vloud server (it
# registers within about a minute) and reports a temporary 7-day window with detail "pending_jws_token". That is not the real trial, so
# say so and show the real length instead of "7-day trial".   args: state days_remaining detail trial_days
vloud_trial_line() {
  local state="${1:-}" days="${2:-}" detail="${3:-}" total="${4:-30}" unit=days
  if [[ "$state" == active && "$detail" == pending_jws_token ]]; then
    printf '%s-day trial (the engine confirms it with the Vloud server within about a minute)' "$total"
  elif [[ "$state" == active && "$days" =~ ^[0-9]+$ ]]; then
    [[ "$days" == 1 ]] && unit=day
    printf '%s %s left of your %s-day trial' "$days" "$unit" "$total"
  else
    printf 'trial state: %s' "${state:-unknown}"
  fi
}
# <<< vloud-summary-helpers

# ─── OS-family package / firewall / SELinux abstraction ───
# Every package-manager touch goes through these so the Debian path
# stays byte-identical to the historical apt commands while the RHEL
# path uses dnf. Keep the debian branch EXACTLY as the inline commands
# were — this is a refactor, not a behaviour change for Ubuntu.

pkg_update() {
  case "$OS_FAMILY" in
    debian) DEBIAN_FRONTEND=noninteractive apt-get update -qq ;;
    rhel)   dnf -y -q makecache >/dev/null 2>&1 || true ;;
  esac
}

# Packages this run refused to install because they would have removed
# something, and packages skipped because the host already had them. Reported
# at the end of the install so the operator sees what was left alone.
VLOUD_PKGS_REFUSED=()
VLOUD_PKGS_ALREADY=()

# What would installing these packages REMOVE from this host?
#
# Echoes the package names apt/dnf plans to remove; empty output means the
# transaction is purely additive.
pkg_plan_removals() {
  case "$OS_FAMILY" in
    debian)
      DEBIAN_FRONTEND=noninteractive apt-get install -s -y "$@" 2>/dev/null \
        | awk '/^Remv /{print $2}'
      ;;
    rhel)
      # `--assumeno` prints the transaction table and exits without applying it.
      dnf install --assumeno "$@" 2>/dev/null \
        | awk '/^Removing|^Obsoleting/{f=1;next} /^$|^Transaction Summary/{f=0} f&&NF{print $1}'
      ;;
  esac
}

# Install packages WITHOUT removing anything and WITHOUT disturbing what
# already works.
#
# ## The incident this exists to prevent
#
# This function used to be a bare `apt-get install -y "$@"`. On a clean VPS
# that is fine. On a server that is already doing a job it is not, and on
# 2026-08-27 it did real damage to a live host:
#
#   - `mariadb-server` CONFLICTS with `mysql-server`. apt resolved that
#     conflict exactly as instructed — it REMOVED MySQL 8.0, installed MariaDB
#     10.11, and pointed mysql.service at MariaDB with a brand-new empty
#     datadir. `-y` meant nothing ever asked.
#   - Re-installing nginx, php-fpm, redis and bind that were ALREADY installed
#     and running triggered a mass stop/start. nginx lost the race for its own
#     :443 socket, failed with EADDRINUSE, and — having no Restart= policy —
#     stayed down. The site was refusing connections for 14 minutes.
#   - certbot renewals failed as collateral, because the HTTP-01 challenge
#     needs the web server that was down.
#
# An installer is not entitled to any of that. Two rules now:
#
#   1. NEVER remove a package. If installing X would remove Y, X is dropped and
#      the operator is told. Vloud coming up without an optional component is
#      recoverable; a deleted database server is not.
#   2. NEVER re-install what is already there. A package that is present is
#      left completely alone, so nothing gets reconfigured and nothing gets
#      restarted out from under a running service.
#
# Both rules are deliberately conservative in the same direction: when in
# doubt, change nothing and say so.
install_packages() {
  [[ $# -gt 0 ]] || return 0

  # Rule 2 — drop anything the host already has.
  local wanted=() p
  for p in "$@"; do
    if pkg_installed "$p"; then
      VLOUD_PKGS_ALREADY+=("$p")
    else
      wanted+=("$p")
    fi
  done
  [[ ${#wanted[@]} -gt 0 ]] || return 0

  # Rule 1 — would this transaction remove anything?
  local removals
  removals=$(pkg_plan_removals "${wanted[@]}" | sort -u | tr '\n' ' ')
  if [[ -n "${removals// /}" ]]; then
    # Something in the set is destructive. Find out WHICH, one at a time, so a
    # single bad package does not cost the operator all the others. This path
    # is rare, so the extra simulations do not matter.
    local safe=() bad
    for p in "${wanted[@]}"; do
      bad=$(pkg_plan_removals "$p" | sort -u | tr '\n' ' ')
      if [[ -n "${bad// /}" ]]; then
        VLOUD_PKGS_REFUSED+=("$p")
        warn "refusing to install '$p': it would REMOVE ${bad% }"
        warn "  leaving the existing package(s) in place. Vloud will run without '$p'."
      else
        safe+=("$p")
      fi
    done
    # `${safe[@]+...}` guard: expanding an EMPTY array under `set -u` is an
    # error on bash 3.2, and an installer that aborts here would leave the host
    # half-configured — the opposite of the point.
    wanted=(${safe[@]+"${safe[@]}"})
    # Every requested package was refused: nothing was installed, so report
    # FAILURE. Callers chain alternatives on that — `install_packages
    # aspnetcore-runtime-8.0 || install_packages dotnet-runtime-8.0` must fall
    # through to the second option, not believe the first one succeeded.
    # A partial success still returns 0: the set as a whole made progress, and
    # the top-level call runs under `set -e`, where failing it would abort the
    # whole install over one optional component.
    [[ ${#wanted[@]} -gt 0 ]] || return 1
  fi

  case "$OS_FAMILY" in
    # --no-remove is a second, independent belt: if the simulation and the real
    # run ever disagree (a repo changed underneath us), apt aborts rather than
    # deleting something.
    debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-remove "${wanted[@]}" ;;
    rhel)   dnf install -y -q "${wanted[@]}" ;;
  esac
}

# Print what install_packages left alone. Called once, after the package phase.
report_package_decisions() {
  if [[ ${#VLOUD_PKGS_ALREADY[@]} -gt 0 ]]; then
    ok "left ${#VLOUD_PKGS_ALREADY[@]} already-installed package(s) untouched (no restarts): ${VLOUD_PKGS_ALREADY[*]}"
  fi
  if [[ ${#VLOUD_PKGS_REFUSED[@]} -gt 0 ]]; then
    warn "NOT installed, because installing them would have removed existing software:"
    warn "  ${VLOUD_PKGS_REFUSED[*]}"
    warn "  This host keeps what it had. If you want the Vloud-preferred package,"
    warn "  migrate the data yourself first, then install it by hand."
  fi
}

pkg_installed() { # pkg_installed NAME
  case "$OS_FAMILY" in
    # `dpkg -s` alone is not enough: it also succeeds for a package that was
    # REMOVED but left its config behind (status "deinstall ok config-files").
    # install_packages skips whatever this reports as present, so a loose test
    # here would silently skip installing something the host does not actually
    # have.
    debian) dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null | grep -x installed >/dev/null ;;
    rhel)   rpm -q "$1" >/dev/null 2>&1 ;;
  esac
}

# Which PHP does this host actually have? Echoes a bare version
# ("8.3", "8.5") or returns 1 when the repos offer no PHP-FPM at all.
#
# Debian family only — the RHEL branch installs unversioned names from
# the Remi module, where one php-fpm service serves whatever version
# the module selected.
#
# Requires `pkg_update` to have run: apt-cache reads the local package
# lists, so on a never-updated host it would see nothing and this
# would wrongly report no PHP.
# The user the engine actually runs as — read from the unit, never
# assumed. This is the single source of truth for who must own
# /opt/vloud/packages: the engine drops CAP_DAC_OVERRIDE, so being uid 0
# grants it no permission bypass and the database must be owned by
# exactly this user.
#
# Order: the live unit (what systemd will really execute) → the unit
# shipped in the release → root, which is what packaging/systemd
# pins today.
engine_service_user() {
  local u=""
  if command -v systemctl >/dev/null 2>&1; then
    u=$(systemctl show vloud -p User --value 2>/dev/null)
  fi
  if [[ -z "$u" ]]; then
    u=$(sed -n 's/^User=//p' /etc/systemd/system/vloud.service 2>/dev/null | head -1)
  fi
  if [[ -z "$u" ]]; then
    u=$(sed -n 's/^User=//p' "$VLOUD_INSTALL_DIR/packaging/systemd/vloud.service" 2>/dev/null | head -1)
  fi
  [[ -z "$u" ]] && u=root
  # A unit naming a user that does not exist would leave the tree
  # unwritable in a way chown cannot fix; fall back rather than that.
  id -u "$u" >/dev/null 2>&1 || u=root
  printf '%s' "$u"
}

detect_php_version() {
  local preferred=8.3 found
  if apt-cache show "php${preferred}-fpm" >/dev/null 2>&1; then
    echo "$preferred"; return 0
  fi
  # Versioned packages only — the unversioned `php-fpm` metapackage
  # also matches a loose pattern, and it carries no version to build
  # `/etc/php/<v>/fpm/pool.d` paths from.
  found="$(apt-cache search --names-only '^php[0-9]+\.[0-9]+-fpm$' 2>/dev/null \
            | sed -n 's/^php\([0-9][0-9]*\.[0-9][0-9]*\)-fpm .*/\1/p' \
            | sort -V | tail -1)"
  [[ -n "$found" ]] || return 1
  echo "$found"
}

# RHEL needs extra repos for packages Debian ships in base: EPEL
# (certbot, whois, fail2ban, clamav), Remi (PHP 8.3), and CRB/PowerTools
# (build -devel deps). No-op on Debian.
ensure_rhel_repos() {
  [[ "$OS_FAMILY" == rhel ]] || return 0
  local v="$OS_VERSION_MAJOR"
  if ! pkg_installed epel-release; then
    say "enabling EPEL"
    dnf install -y -q "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${v}.noarch.rpm" \
      || dnf install -y -q epel-release \
      || warn "EPEL setup failed — certbot/whois/fail2ban may be unavailable"
  fi
  # CRB (RHEL 9) / PowerTools (older) — needed by several EPEL -devel deps.
  dnf config-manager --set-enabled crb        >/dev/null 2>&1 \
    || dnf config-manager --set-enabled powertools >/dev/null 2>&1 || true
  # Remi for PHP 8.3 (Vloud standardises on 8.3 across both families).
  if ! pkg_installed remi-release; then
    say "enabling Remi (PHP 8.3)"
    dnf install -y -q "https://rpms.remirepo.net/enterprise/remi-release-${v}.rpm" \
      || warn "Remi setup failed — PHP 8.3 may be unavailable"
  fi
  dnf module reset  -y php          >/dev/null 2>&1 || true
  dnf module enable -y "php:remi-8.3" >/dev/null 2>&1 \
    || warn "could not enable php:remi-8.3 dnf module"
}

# Best-effort open a host port. ufw on Debian, firewalld on RHEL. Only
# acts when the firewall is actually active so we never silently turn a
# firewall on under the operator.
fw_open_port() { # fw_open_port PORT [tcp|udp]
  local port="$1" proto="${2:-tcp}"
  case "$OS_FAMILY" in
    rhel)
      if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
        firewall-cmd --permanent --add-port="${port}/${proto}" >/dev/null 2>&1 || true
      fi ;;
    debian)
      if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep "Status: active" >/dev/null; then
        ufw allow "${port}/${proto}" >/dev/null 2>&1 || true
      fi ;;
  esac
}

fw_reload() {
  if [[ "$OS_FAMILY" == rhel ]] && command -v firewall-cmd >/dev/null 2>&1 \
     && systemctl is-active --quiet firewalld; then
    firewall-cmd --reload >/dev/null 2>&1 || true
  fi
}

# Minimum SELinux config for the engine to start under enforcing mode:
# allow nginx → engine proxy, label the engine port, restore contexts on
# the state dir. No-op on Debian and when SELinux is disabled/absent.
configure_selinux() {
  [[ "$OS_FAMILY" == rhel ]] || return 0
  command -v getenforce >/dev/null 2>&1 || return 0
  local mode; mode="$(getenforce 2>/dev/null || echo Disabled)"
  [[ "$mode" == "Enforcing" || "$mode" == "Permissive" ]] || return 0
  say "configuring SELinux for the engine (current mode: $mode)"
  # nginx must be allowed to connect to the upstream engine socket.
  setsebool -P httpd_can_network_connect 1 2>/dev/null \
    || warn "setsebool httpd_can_network_connect failed"
  # Label the engine's loopback port as an http port so nginx can proxy.
  if command -v semanage >/dev/null 2>&1; then
    semanage port -a -t http_port_t -p tcp "$VLOUD_PORT" 2>/dev/null \
      || semanage port -m -t http_port_t -p tcp "$VLOUD_PORT" 2>/dev/null || true
  else
    warn "semanage missing (install policycoreutils-python-utils) — engine port may be blocked under enforcing SELinux"
  fi
  # Restore default contexts on the engine state tree.
  [[ -d /var/lib/vloud ]] && restorecon -Rv /var/lib/vloud >/dev/null 2>&1 || true
  ok "SELinux prepared (httpd_can_network_connect=on, port $VLOUD_PORT labelled http_port_t)"
}

# ─── Lifecycle progress JSONL emitter ───
#
# Append one JSON line per lifecycle event to VLOUD_PROGRESS_FILE.
# Schema (stable):
#
#   { "ts":  "<iso8601-z>",
#     "apply_run_id": <int|null>,
#     "phase": "upgrade" | "rollback" | "fresh" | "repair",
#     "stage": "<short-tag>",
#     "status": "started" | "ok" | "warn" | "failed",
#     "version_from": "<string|null>",
#     "version_to":   "<string|null>",
#     "msg": "<human-readable>" }
#
# Terminal markers emit with stage="terminal" so the engine can
# resolve apply_run state without parsing every intermediate line.
#
# - Append-only; never rewrites prior events
# - Rotates to ${VLOUD_PROGRESS_FILE}.<ts> when >RETAIN_BYTES
# - Stale rotated files older than STALE_DAYS are GC'd
# - Safe to call BEFORE the progress file directory exists (will
#   try to create it; failure is non-fatal — bootstrap.sh must
#   never abort because of progress logging)
# - Safe to call even when VLOUD_APPLY_RUN_ID is unset (engine
#   never reads those rows; operator-run upgrades still emit them
#   for the install.log forensics value)
emit_progress() {
  local stage="${1:-unknown}"
  local status="${2:-ok}"
  local msg="${3:-}"

  # Defensive: never let progress emission take down the script.
  set +e
  (
    set +e
    local dir; dir=$(dirname "$VLOUD_PROGRESS_FILE")
    [[ -d "$dir" ]] || install -d -m 0755 "$dir" 2>/dev/null

    # Rotate if oversize.
    if [[ -f "$VLOUD_PROGRESS_FILE" ]]; then
      local sz; sz=$(stat -c '%s' "$VLOUD_PROGRESS_FILE" 2>/dev/null || echo 0)
      if (( sz > VLOUD_PROGRESS_RETAIN_BYTES )); then
        local ts; ts=$(date -u +%Y%m%dT%H%M%SZ)
        mv "$VLOUD_PROGRESS_FILE" "${VLOUD_PROGRESS_FILE}.${ts}" 2>/dev/null
      fi
    fi

    # GC stale rotated files (best-effort).
    find "$(dirname "$VLOUD_PROGRESS_FILE")" -maxdepth 1 -type f \
        -name "$(basename "$VLOUD_PROGRESS_FILE").*" \
        -mtime "+${VLOUD_PROGRESS_STALE_DAYS}" -delete 2>/dev/null

    local ts_now; ts_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    # Escape backslashes + quotes in msg (no jq dependency).
    local msg_esc="${msg//\\/\\\\}"
    msg_esc="${msg_esc//\"/\\\"}"
    # JSONify nullable apply_run_id (raw number when set, JSON null otherwise).
    local arid; arid="${VLOUD_APPLY_RUN_ID:-null}"
    [[ -z "$arid" ]] && arid=null
    [[ "$arid" != "null" ]] && ! [[ "$arid" =~ ^[0-9]+$ ]] && arid=null
    # Phase is derived from MODE; default 'upgrade' until MODE is set.
    local phase="${MODE:-upgrade}"
    local vfrom="${VLOUD_VERSION_FROM:-null}"
    local vto="${VLOUD_VERSION_TO:-null}"
    [[ "$vfrom" != "null" ]] && vfrom="\"$vfrom\""
    [[ "$vto"   != "null" ]] && vto="\"$vto\""

    printf '{"ts":"%s","apply_run_id":%s,"phase":"%s","stage":"%s","status":"%s","version_from":%s,"version_to":%s,"msg":"%s"}\n' \
      "$ts_now" "$arid" "$phase" "$stage" "$status" "$vfrom" "$vto" "$msg_esc" \
      >> "$VLOUD_PROGRESS_FILE" 2>/dev/null
  ) || true
  set -e
}

# Mark a terminal status for the current apply_run. Engine watchers
# resolve the run state from the most recent terminal event.
emit_progress_terminal() {
  local status="${1:-ok}"
  local msg="${2:-}"
  emit_progress terminal "$status" "$msg"
}

vloud_logo
say "Vloud bootstrap starting on $PRETTY_NAME"
ok  "logging to $LOG_FILE"

# ─── Release-verification helpers (hoisted before mode dispatch) ───
#
# The build-release.sh pipeline substitutes the literal placeholder
# `__VLOUD_RELEASE_PUBKEY_PEM__` with the real PEM at release time. If
# the placeholder remains, $VLOUD_RELEASE_PUBKEY_PEM must be set OR
# VLOUD_INSECURE_SKIP_VERIFY=1 + VLOUD_TESTING=1 must both be set.
# These three primitives (constant + b64url_decode + verify_release_chain)
# are used by BOTH the fresh-install flow (phase 3 below) AND the
# upgrade flow (do_upgrade_flow above the dispatch). They must be
# defined here so both can call them.
# ─── corepack shims (hoisted before mode dispatch) ───
#
# REQUIRED for pipeline builds, not a nicety.
#
# Node ships corepack, but without `corepack enable` the only way to reach pnpm
# or yarn is `corepack pnpm …`. A pipeline step can be written that way, but the
# package.json scripts it then runs cannot: a script like
#
#     "build": "next build && pnpm run postbuild:standalone"
#
# invokes BARE `pnpm`, which is not on PATH, and the build dies at the second
# command with `sh: 1: pnpm: not found` — after a full successful compile, so it
# reads as a build failure rather than a missing binary.
#
# Called from ALL THREE flows. Fresh install is not enough: this is host-level
# setup outside $VLOUD_INSTALL_DIR, so an existing host that upgrades into a
# release with this fix would otherwise never get the shim, and --repair is
# where an operator goes to fix exactly this class of thing.
#
# Best-effort by design: a host whose corepack cannot write its shim directory
# still installs and still runs Vloud — it just cannot run pnpm/yarn package
# scripts, which is worth a warning and not a refusal.
ensure_corepack_shims() {
  if ! command -v corepack >/dev/null 2>&1; then
    warn "corepack is not present in this Node install — pnpm/yarn package scripts will not resolve"
    return 0
  fi
  # --install-directory keeps the shims on the default PATH of a pipeline step,
  # which does not inherit a login shell's PATH. Fall back to plain enable for
  # Node builds that reject the flag.
  if corepack enable --install-directory /usr/local/bin >/dev/null 2>&1 \
     || corepack enable >/dev/null 2>&1; then
    if command -v pnpm >/dev/null 2>&1; then
      ok "corepack shims enabled (pnpm on PATH)"
    else
      ok "corepack shims enabled"
    fi
  else
    warn "corepack enable failed — package scripts that call bare 'pnpm' or 'yarn' will not resolve"
  fi
  return 0
}

# ─── Pipeline CI host units (C05-T4, CR-T05, C05-T6) ───
#
# Host-level systemd state for pipeline builds. Called from the fresh install,
# do_upgrade_flow AND do_repair_flow: it lives outside the install tree, so the
# atomic swap never refreshes it, and a host upgrading INTO this release would
# otherwise never get it (the bootstrap-upgrade-flow gap).
#
# Values are read from /etc/vloud.env, where an operator changes them; the
# installer's own environment wins for a one-off run. Re-running any flow
# re-renders everything below from the current values.
_vloud_env_value() { # _vloud_env_value KEY [DEFAULT]
  local key="$1" dflt="${2:-}" v=""
  if [[ -n "${!key:-}" ]]; then printf '%s' "${!key}"; return 0; fi
  if [[ -f "$ENV_FILE" ]]; then
    v=$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" 2>/dev/null | tail -n 1 | cut -d= -f2-) || true
    v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
  fi
  printf '%s' "${v:-$dflt}"
}

install_pipeline_host_units() {
  local unit_dir="${VLOUD_SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
  local drain cpu_quota mem_high mem_max ci_slice cache_root
  drain=$(_vloud_env_value VLOUD_PIPELINE_DRAIN_SECONDS 600)
  cpu_quota=$(_vloud_env_value VLOUD_CI_CPU_QUOTA 400%)
  mem_high=$(_vloud_env_value VLOUD_CI_MEMORY_HIGH 7G)
  mem_max=$(_vloud_env_value VLOUD_CI_MEMORY_MAX 8G)
  ci_slice=$(_vloud_env_value VLOUD_CI_SLICE 0)
  cache_root=$(_vloud_env_value VLOUD_PIPELINE_CACHE_ROOT /var/lib/vloud/pipeline-cache)

  # A malformed value must not produce a unit systemd rejects — that would
  # take the worker down with it. Fall back to the default and say so.
  if [[ ! "$drain" =~ ^[0-9]+$ ]]; then
    warn "VLOUD_PIPELINE_DRAIN_SECONDS=$drain is not a whole number of seconds — using 600"
    drain=600
  fi
  if [[ ! "$cpu_quota" =~ ^[0-9]+%$ ]]; then
    warn "VLOUD_CI_CPU_QUOTA=$cpu_quota is not a percentage like 400% — using 400%"
    cpu_quota=400%
  fi
  if [[ ! "$mem_high" =~ ^([0-9]+[KMGT]?|[0-9]+%|infinity)$ ]]; then
    warn "VLOUD_CI_MEMORY_HIGH=$mem_high is not a systemd memory size — using 7G"
    mem_high=7G
  fi
  if [[ ! "$mem_max" =~ ^([0-9]+[KMGT]?|[0-9]+%|infinity)$ ]]; then
    warn "VLOUD_CI_MEMORY_MAX=$mem_max is not a systemd memory size — using 8G"
    mem_max=8G
  fi

  # (1) The job worker's stop behaviour (C05-T4).
  #
  # On SIGTERM the worker stops claiming and waits up to
  # VLOUD_PIPELINE_DRAIN_SECONDS for its builds. That only works if systemd
  # lets the steps live that long:
  #   KillMode=mixed      SIGTERM goes to the worker process ALONE; the default
  #                       control-group mode signalled every step at the same
  #                       moment, which is how 21 runs were lost to restarts.
  #                       SIGKILL still reaches the whole cgroup at the end.
  #   TimeoutStopSec      the drain plus 60 s for the worker to close each
  #                       stopped run as runner_lost and exit.
  # The base unit carries the same values for the default drain; this drop-in
  # tracks an operator's VLOUD_PIPELINE_DRAIN_SECONDS.
  install -d -m 0755 "$unit_dir/vloud-job-worker.service.d"
  cat > "$unit_dir/vloud-job-worker.service.d/20-pipeline-drain.conf" <<EOF
# Rendered by bootstrap.sh (C05-T4) on install, upgrade and repair — edit
# VLOUD_PIPELINE_DRAIN_SECONDS in $ENV_FILE and re-run bootstrap.sh --repair
# instead of this file.
[Service]
KillMode=mixed
TimeoutStopSec=$((drain + 60))s
EOF

  # (2) The CI slice (CR-T05). Canonical copy: packaging/systemd/vloud-ci.slice.
  #
  # Installing it changes nothing on its own — an empty slice caps nothing.
  # Builds only move into it when VLOUD_CI_SLICE=1: pipeline steps as
  # transient scopes (services/vcs/pipeline/exec-identity.ts) and the engine
  # build runner through the drop-in below. That is an operator's switch for a
  # quiet window, because it caps CI throughput on purpose.
  cat > "$unit_dir/vloud-ci.slice" <<EOF
# Rendered by bootstrap.sh (CR-T05). Canonical: packaging/systemd/vloud-ci.slice.
# Limits come from VLOUD_CI_CPU_QUOTA / VLOUD_CI_MEMORY_HIGH / VLOUD_CI_MEMORY_MAX
# in $ENV_FILE; re-run bootstrap.sh --repair after changing them.
[Unit]
Description=Vloud CI builds — resource ceiling for pipeline steps and the build runner
Documentation=https://vloud.app/docs
Before=slices.target

[Slice]
CPUAccounting=true
CPUQuota=$cpu_quota
CPUWeight=50
MemoryAccounting=true
MemoryHigh=$mem_high
MemoryMax=$mem_max
IOAccounting=true
IOWeight=50
TasksAccounting=true
EOF

  # (3) The engine build runner (scripts/setup-build-runner.sh installs it on
  # the hosts that build Vloud releases). Only when the slice is switched on,
  # and removed again when it is switched off.
  local br_dropin="$unit_dir/vloud-build-runner.service.d/20-vloud-ci-slice.conf"
  if [[ "$ci_slice" == "1" && -f "$unit_dir/vloud-build-runner.service" ]]; then
    install -d -m 0755 "$unit_dir/vloud-build-runner.service.d"
    cat > "$br_dropin" <<EOF
# Rendered by bootstrap.sh (CR-T05) because VLOUD_CI_SLICE=1 in $ENV_FILE.
[Service]
Slice=vloud-ci.slice
Nice=10
IOSchedulingClass=idle
EOF
    ok "vloud-build-runner runs in vloud-ci.slice (restart it to apply)"
  elif [[ -f "$br_dropin" ]]; then
    rm -f "$br_dropin"
    ok "vloud-build-runner no longer confined to vloud-ci.slice (VLOUD_CI_SLICE is off)"
  fi

  # (4) The dependency-cache rest tree (C05-T6). root:root 0700 so the CI
  # account cannot even traverse it: a cache is only reachable while the
  # engine has moved it into the run that owns it (dep-cache.ts).
  install -d -m 0700 -o root -g root "$cache_root"

  systemctl daemon-reload 2>/dev/null || true
  if [[ "$ci_slice" == "1" ]]; then
    ok "pipeline host units: drain ${drain}s (KillMode=mixed), vloud-ci.slice ON (CPU $cpu_quota, memory $mem_high/$mem_max)"
  else
    ok "pipeline host units: drain ${drain}s (KillMode=mixed), vloud-ci.slice installed but off (VLOUD_CI_SLICE=0)"
  fi
}

# ─── pm2 unit ordering (CR-T10, 2026-09-26) ───
#
# `pm2 startup` writes pm2-<user>.service with After=network.target and
# nothing else, so at boot pm2 resurrects every application BEFORE the
# services they connect to: PostgreSQL, Redis, Docker (SQL Server runs there)
# and Vloud Storage. The apps then crash-loop, pm2 gives up on them, and the
# site stays down after a reboot until someone restarts it by hand.
#
# A drop-in per pm2 unit adds After= and Wants= for each of those that exists
# on this host (Wants=, not Requires=: one failed dependency must not keep
# every unrelated app down), plus a restart back-off so a dependency that is
# slow to come up does not leave the daemon in `failed`. The file name is the
# one the live host's hand-made drop-ins use (10-deps.conf), so this rendered
# copy replaces them in place.
#
# Idempotent and non-disruptive: identical content is not rewritten, and the
# only service action is `daemon-reload` — ordering applies from the next
# start; a running pm2 is never restarted here. Called from all three flows.
_vloud_unit_known() { # _vloud_unit_known UNIT — systemd has a unit file for it
  systemctl cat "$1" >/dev/null 2>&1
}

_vloud_pm2_dep_units() {
  local conf dir cluster ver mode u
  # PostgreSQL, one unit per cluster that starts automatically.
  for conf in /etc/postgresql/*/*/postgresql.conf; do
    [[ -f "$conf" ]] || continue
    dir=$(dirname "$conf"); cluster=$(basename "$dir"); ver=$(basename "$(dirname "$dir")")
    mode=auto
    if [[ -f "$dir/start.conf" ]]; then
      mode=$(grep -vE '^[[:space:]]*(#|$)' "$dir/start.conf" 2>/dev/null | head -n 1 | tr -d '[:space:]' || true)
    fi
    [[ "$mode" == auto || -z "$mode" ]] && printf '%s\n' "postgresql@${ver}-${cluster}.service"
  done
  for u in /var/lib/pgsql/*/data/PG_VERSION; do
    [[ -f "$u" ]] || continue
    ver=$(basename "$(dirname "$(dirname "$u")")")
    printf '%s\n' "postgresql-${ver}.service"
  done
  [[ -f /var/lib/pgsql/data/PG_VERSION ]] && printf '%s\n' postgresql.service
  printf '%s\n' redis-server.service redis.service docker.service vloud-storage.service
}

install_pm2_unit_dropins() {
  local unit_dir="${VLOUD_SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
  local units=() deps=() f u d body changed=0 sdver
  for f in "$unit_dir"/pm2-*.service; do
    [[ -f "$f" ]] && units+=("$(basename "$f" .service)")
  done
  if (( ${#units[@]} == 0 )); then
    return 0
  fi
  while IFS= read -r d; do
    [[ -n "$d" ]] || continue
    _vloud_unit_known "$d" || continue
    # Cycle guard: a dependency that is itself ordered after, or pulls in, a
    # pm2 unit would make systemd drop one side of the loop at boot.
    # (grep without -q: under pipefail an early exit would SIGPIPE systemctl.)
    if systemctl show -p After -p Requires -p Wants --value "$d" 2>/dev/null | grep 'pm2-' >/dev/null; then
      warn "$d is ordered after a pm2 unit — not adding it to the pm2 ordering (would be a cycle)"
      continue
    fi
    deps+=("$d")
  done < <(_vloud_pm2_dep_units | awk '!seen[$0]++')

  # RestartSteps / RestartMaxDelaySec need systemd >= 254; older systemd
  # warns about unknown keys on every daemon-reload, so they are left out.
  sdver=$(systemctl --version 2>/dev/null | awk 'NR==1 {print $2}' || true)
  [[ "$sdver" =~ ^[0-9]+$ ]] || sdver=0

  body="# Rendered by bootstrap.sh (CR-T10) on install, upgrade and repair — re-run
# bootstrap.sh --repair after adding PostgreSQL, Redis, Docker or Vloud Storage.
# Start PM2 apps only after their backing services; back off restarts so a slow
# dependency does not leave the daemon failed. Wants= (not Requires=) so one
# failed dependency does not block unrelated apps.
[Unit]"
  if (( ${#deps[@]} > 0 )); then
    body+="
After=${deps[*]}
Wants=${deps[*]}"
  fi
  body+="

[Service]
RestartSec=5s"
  if (( sdver >= 254 )); then
    body+="
RestartSteps=6
RestartMaxDelaySec=120s"
  fi

  for u in "${units[@]}"; do
    f="$unit_dir/$u.service.d/10-deps.conf"
    if [[ -f "$f" ]] && [[ "$(cat "$f")" == "$body" ]]; then
      continue
    fi
    if ! install -d -m 0755 "$unit_dir/$u.service.d" \
       || ! printf '%s\n' "$body" > "$f.vloud-tmp" \
       || ! chmod 0644 "$f.vloud-tmp" \
       || ! mv -f "$f.vloud-tmp" "$f"; then
      rm -f "$f.vloud-tmp" 2>/dev/null || true
      warn "could not write $f — $u keeps its previous ordering"
      continue
    fi
    changed=1
  done
  if (( changed )); then
    systemctl daemon-reload 2>/dev/null || true
    ok "pm2 units (${units[*]}) start after: ${deps[*]:-nothing extra on this host} (applies from their next start)"
  fi
  return 0
}

# ─── BIND: no zone transfers to strangers (C14-T13, 2026-09-26) ───
#
# BIND's default `allow-transfer` is ANY: every zone Vloud serves could be
# AXFR'd by anyone who asks — the live host's log shows digcub-lab.com and
# vloud.app transferred in full to an unknown address. Vloud's DNS cluster
# syncs over HTTPS, never AXFR, so nothing of ours needs it.
#
# Only where Vloud manages BIND (its include sentinel is present). The
# operator's options are respected: an allow-transfer already in the options
# block is left exactly as it is (that is a configured secondary). One is
# added only when there is none — `{ none; }`, or the addresses in
# VLOUD_BIND_SECONDARIES when set. `also-notify` without an allow-transfer
# means secondaries the operator has not listed; that is reported, not guessed.
# named-checkconf validates the result BEFORE named is told to reload; on
# failure the previous file is restored. Idempotent: a second run finds the
# statement and changes nothing. Called from all three flows.
_vloud_bind_options_has() { # _vloud_bind_options_has FILE KEYWORD — inside `options { … }`, uncommented
  awk -v kw="$2" '
    { line = $0; sub(/\/\/.*/, "", line); sub(/#.*/, "", line) }
    !inopt && line ~ /^[[:space:]]*options[[:space:]]*\{/ { inopt = 1; depth = 0 }
    inopt {
      if (line ~ ("(^|[[:space:];{])" kw "([[:space:]{;]|$)")) found = 1
      depth += gsub(/\{/, "{", line) - gsub(/\}/, "}", line)
      if (depth <= 0) inopt = 0
    }
    END { exit found ? 0 : 1 }
  ' "$1"
}

ensure_bind_no_transfer() {
  local opts main include sentinel='// === Vloud-managed (effector: dns.zone) ==='
  if [[ "${OS_FAMILY:-debian}" == rhel ]]; then
    opts=/etc/named.conf; main=/etc/named.conf; include=/etc/named.conf
  else
    opts=/etc/bind/named.conf.options; main=/etc/bind/named.conf; include=/etc/bind/named.conf.local
  fi
  opts="${VLOUD_BIND_OPTIONS_FILE:-$opts}"; main="${VLOUD_BIND_MAIN_CONF:-$main}"; include="${VLOUD_BIND_INCLUDE_TARGET:-$include}"
  [[ -f "$opts" && -f "$include" ]] || return 0
  grep -qF "$sentinel" "$include" 2>/dev/null || return 0   # not Vloud's BIND

  if _vloud_bind_options_has "$opts" allow-transfer; then
    ok "BIND: allow-transfer already set in $opts (kept as configured)"
    return 0
  fi
  local secondaries acl
  secondaries=$(_vloud_env_value VLOUD_BIND_SECONDARIES "")
  secondaries=$(printf '%s' "$secondaries" | tr ',;' '  ' | xargs 2>/dev/null || true)
  if [[ -z "$secondaries" ]] && _vloud_bind_options_has "$opts" also-notify; then
    warn "BIND: $opts notifies secondaries (also-notify) but sets no allow-transfer — zone transfers stay open; set VLOUD_BIND_SECONDARIES and re-run bootstrap.sh --repair"
    return 0
  fi
  if [[ -n "$secondaries" ]]; then
    local ip
    acl=""
    for ip in $secondaries; do
      if [[ ! "$ip" =~ ^[0-9A-Fa-f:.]+(/[0-9]{1,3})?$ ]]; then
        warn "BIND: VLOUD_BIND_SECONDARIES has '$ip', which is not an address — allow-transfer left unchanged"
        return 0
      fi
      acl+="$ip; "
    done
    acl="{ ${acl}}"
  else
    acl="{ none; }"
  fi

  local line
  line=$(grep -nE '^[[:space:]]*options[[:space:]]*\{' "$opts" | head -n 1 | cut -d: -f1 || true)
  if [[ -z "$line" ]]; then
    warn "BIND: no 'options {' line found in $opts — allow-transfer not added (set it by hand)"
    return 0
  fi
  local tmp
  if ! tmp=$(mktemp "${opts}.vloud-XXXXXX"); then
    warn "BIND: could not stage $opts — allow-transfer not added"
    return 0
  fi
  awk -v n="$line" -v acl="$acl" '
    { print }
    NR == n {
      print "\t// Vloud (C14-T13): no zone transfers except to listed secondaries."
      print "\tallow-transfer " acl ";"
    }
  ' "$opts" > "$tmp"
  if ! cp -p "$opts" "$opts.vloud-prev"; then
    rm -f "$tmp"; warn "BIND: could not back up $opts — allow-transfer not added"; return 0
  fi
  # Written in place (not renamed over) so the file keeps its owner, mode
  # and SELinux label.
  cat "$tmp" > "$opts"; rm -f "$tmp"
  if command -v named-checkconf >/dev/null 2>&1 && ! named-checkconf "$main" >/dev/null 2>&1; then
    cat "$opts.vloud-prev" > "$opts"
    warn "BIND: named-checkconf rejected allow-transfer in $opts — previous file restored, named untouched"
    return 0
  fi
  if systemctl is-active --quiet named 2>/dev/null || systemctl is-active --quiet bind9 2>/dev/null; then
    rndc reconfig >/dev/null 2>&1 || warn "BIND: rndc reconfig failed — allow-transfer applies at the next named restart"
  fi
  ok "BIND: allow-transfer $acl added to $opts"
  return 0
}

# ─── journald persistence (P2-OPS-12) ───
#
# The Vloud units log to the journal only. Where the journal is volatile (no /var/log/journal, Storage=auto)
# it vanishes at reboot - the moment after a crash loop or a bad upgrade when it is needed. Installs the
# packaged drop-in (Storage=persistent + a size/age bound) and creates /var/log/journal. Called from the same
# three flows as install_logrotate_config, for the same reason: it is host-level state the atomic swap never
# refreshes. An operator's own explicit Storage= is respected (the step is skipped), and every failure is a
# warning: a host without systemd-journald, or a read-only /etc, still runs Vloud.
install_journald_persistence() {
  local _src="$VLOUD_INSTALL_DIR/packaging/journald/vloud.conf"
  local _dir=/etc/systemd/journald.conf.d _dst=/etc/systemd/journald.conf.d/50-vloud.conf
  if [[ ! -f "$_src" ]]; then
    warn "packaging/journald/vloud.conf missing in install tree; journal persistence left as it was"
    return 0
  fi
  command -v journalctl >/dev/null 2>&1 || { warn "journalctl not found; skipping journald persistence"; return 0; }
  # An explicit choice by the operator (main file or another drop-in) wins.
  local _f _chosen=""
  for _f in /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf; do
    [[ -f "$_f" && "$_f" != "$_dst" ]] || continue
    if grep -Eq '^[[:space:]]*Storage=(volatile|none|persistent)[[:space:]]*$' "$_f" 2>/dev/null; then _chosen="$_f"; break; fi
  done
  if [[ -n "$_chosen" ]]; then
    ok "journald Storage= already set in $_chosen - leaving it"
    return 0
  fi
  local _changed=0
  if ! cmp -s "$_src" "$_dst" 2>/dev/null; then
    if install -d -m 0755 "$_dir" && install -m 0644 -o root -g root "$_src" "$_dst"; then
      _changed=1
      ok "$_dst installed (persistent journal, 1G / 30 days)"
    else
      warn "could not write $_dst - the journal stays as the distro configured it"
      return 0
    fi
  fi
  if [[ ! -d /var/log/journal ]]; then
    if install -d -m 2755 -o root -g systemd-journal /var/log/journal 2>/dev/null || install -d -m 2755 /var/log/journal; then
      _changed=1
      ok "/var/log/journal created"
    else
      warn "could not create /var/log/journal - the journal will stay volatile"
      return 0
    fi
    systemd-tmpfiles --create --prefix /var/log/journal >/dev/null 2>&1 || true
  fi
  if (( _changed )); then
    # Restart picks up the drop-in; the flush moves what is already in /run/log/journal to disk.
    systemctl restart systemd-journald >/dev/null 2>&1 || warn "could not restart systemd-journald - the change applies at next boot"
    journalctl --flush >/dev/null 2>&1 || true
  fi
  return 0
}

# ─── logrotate (A3 2026-05-10; C08-T12 2026-09-26) ───
#
# Called from ALL THREE flows, for the same reason as ensure_corepack_shims:
# /etc/logrotate.d is host-level state outside $VLOUD_INSTALL_DIR, so the
# atomic swap never refreshes it. Before this ran on --upgrade, a host kept
# whatever file its FIRST install wrote — the live 0.9.x host had silently
# drifted from packaging/logrotate/vloud and had no rotation for the pm2 logs
# that had grown to 800+ MB on a 91%-full root filesystem.
#
# Three files, three rules:
#   vloud        always installed (/var/log/vloud + /root/.pm2/logs).
#   vloud-nginx  installed only when no other config already rotates
#                /var/log/nginx/*.log — every distro nginx package does, and a
#                file matched by two stanzas is a logrotate error
#                ("duplicate log entry") that fails logrotate.service nightly.
#                Removed again when such a config appears.
#   vloud-pm2    the hand-installed stop-gap from C08-T12b (identified by its
#                marker). Its /root/.pm2 stanza is now the packaged one, so
#                that stanza is dropped (the file goes entirely when nothing
#                else is left in it); any other pm2 homes it rotates are kept.
#
# Best-effort: a host without logrotate still runs Vloud; a duplicate found by
# the final dry run is reported, never "fixed" by editing an operator's file.
# Every step that can fail is checked here rather than left to `set -e`: in
# do_upgrade_flow this runs AFTER the atomic swap, where an abort (a full
# root filesystem failing `install` or `mktemp`) would leave the new tree in
# place with its services never started and no rollback.
install_logrotate_config() {
  local _src="$VLOUD_INSTALL_DIR/packaging/logrotate"
  if [[ ! -f "$_src/vloud" ]]; then
    warn "packaging/logrotate/vloud missing in install tree; skipping logrotate"
    return 0
  fi
  if ! install -d -m 0755 /etc/logrotate.d \
     || ! install -m 0644 -o root -g root "$_src/vloud" /etc/logrotate.d/vloud; then
    warn "could not write /etc/logrotate.d/vloud — log rotation left as it was"
    return 0
  fi
  ok "/etc/logrotate.d/vloud installed"

  local _covered="" _f
  for _f in /etc/logrotate.d/*; do
    [[ -f "$_f" ]] || continue
    case "$(basename "$_f")" in vloud|vloud-*|*.dpkg-*|*.rpm*) continue ;; esac
    if grep -Eq '^[[:space:]]*/var/log/nginx/\*(\.log|log)([[:space:]{]|$)' "$_f" 2>/dev/null; then
      _covered="$_f"
      break
    fi
  done
  if [[ -n "$_covered" ]]; then
    if [[ -f /etc/logrotate.d/vloud-nginx ]]; then
      if rm -f /etc/logrotate.d/vloud-nginx; then
        ok "removed /etc/logrotate.d/vloud-nginx — $_covered already rotates /var/log/nginx"
      else
        warn "could not remove /etc/logrotate.d/vloud-nginx (duplicates $_covered)"
      fi
    else
      ok "nginx logs rotated by $_covered — vloud-nginx stanza not needed"
    fi
  elif [[ -f "$_src/vloud-nginx" ]]; then
    if install -m 0644 -o root -g root "$_src/vloud-nginx" /etc/logrotate.d/vloud-nginx; then
      ok "/etc/logrotate.d/vloud-nginx installed (no distro rotation for /var/log/nginx)"
    else
      warn "could not write /etc/logrotate.d/vloud-nginx"
    fi
  fi

  if [[ -f /etc/logrotate.d/vloud-pm2 ]] && grep -q 'C08-T12b' /etc/logrotate.d/vloud-pm2 2>/dev/null; then
    local _tmp=""
    # The stop-gap is only rewritten or removed once its filtered copy was
    # written in full; a failed mktemp/awk must not read as "nothing left".
    if _tmp=$(mktemp /tmp/vloud-logrotate.XXXXXX) && awk '
      /^[[:space:]]*\/root\/\.pm2\/logs\/\*\.log[[:space:]]*\{/ { skip = 1 }
      !skip { print }
      skip && /^[[:space:]]*\}/ { skip = 0 }
    ' /etc/logrotate.d/vloud-pm2 > "$_tmp"; then
      if grep -Eq '^[[:space:]]*/[^[:space:]]+.*\{[[:space:]]*$' "$_tmp"; then
        if install -m 0644 -o root -g root "$_tmp" /etc/logrotate.d/vloud-pm2; then
          ok "vloud-pm2 stop-gap: /root/.pm2 stanza dropped (now in /etc/logrotate.d/vloud)"
        else
          warn "could not rewrite the vloud-pm2 stop-gap; logrotate will report its /root/.pm2 stanza as a duplicate"
        fi
      elif rm -f /etc/logrotate.d/vloud-pm2; then
        ok "vloud-pm2 stop-gap removed (superseded by /etc/logrotate.d/vloud)"
      else
        warn "could not remove the vloud-pm2 stop-gap; logrotate will report it as a duplicate"
      fi
    else
      warn "could not filter the vloud-pm2 stop-gap (mktemp/awk failed); left as it was"
    fi
    if [[ -n "$_tmp" ]]; then rm -f "$_tmp" || true; fi
  fi

  # Dry run: logrotate -d parses every config and touches nothing.
  if command -v logrotate >/dev/null 2>&1; then
    local _dups
    _dups=$(logrotate -d /etc/logrotate.conf 2>&1 | grep -E 'duplicate log entry' | head -5 || true)
    if [[ -n "$_dups" ]]; then
      warn "logrotate reports duplicate log entries (another config claims the same files):"
      printf '%s\n' "$_dups" | sed 's/^/    /'
    fi
  fi
  return 0
}

# ─── nginx request-log format (hoisted before mode dispatch) ───
#
# One conf.d drop-in gives every vhost a request log with the host, timing and
# request id: `log_format vloud_main`, a SECOND http-level
# `access_log /var/log/nginx/vloud_main.log vloud_main;`, and the map the app
# vhosts read X-Correlation-Id from. Without it the only log is access.log in
# the default combined format — no $host, no $request_time — and the engine can
# derive no per-domain request rate, error rate or latency at all. access.log is
# left exactly as it was: awstats parses it as combined.
#
# Called from BOTH the fresh install and do_upgrade_flow: it is host-level setup
# outside $VLOUD_INSTALL_DIR, and --upgrade dispatches before the fresh linear
# phases, so an upgraded host would otherwise never get it.
#
# Safety, in order:
#   - skipped when nginx is not installed or conf.d does not exist;
#   - skipped when VLOUD_SKIP_NGINX_LOG_FORMAT=1 (operator opt-out);
#   - skipped when `nginx -t` ALREADY fails — a reload would not happen anyway,
#     and adding a file to a broken config only muddies what broke it;
#   - rolled back (previous content restored, or the file removed) when
#     `nginx -t` fails with it in place;
#   - reloaded (graceful) only when nginx is active — a box where Caddy fronts
#     and nginx is masked must not have `systemctl reload nginx` fail the step;
#   - identical content is a no-op: no write, no reload.
#
# The file is rotated by the distro's own /etc/logrotate.d/nginx
# (`/var/log/nginx/*.log` on Debian, `/var/log/nginx/*log` on RHEL); naming it
# again in /etc/logrotate.d/vloud would be a duplicate entry logrotate rejects.
install_nginx_log_format() {
  local src="$VLOUD_INSTALL_DIR/packaging/nginx/00-vloud-log-format.conf"
  local dst=/etc/nginx/conf.d/00-vloud-log-format.conf
  if [[ "${VLOUD_SKIP_NGINX_LOG_FORMAT:-0}" == 1 ]]; then
    warn "VLOUD_SKIP_NGINX_LOG_FORMAT=1 — not installing the nginx request-log format"
    return 0
  fi
  if ! command -v nginx >/dev/null 2>&1 || [[ ! -d /etc/nginx/conf.d ]]; then
    return 0
  fi
  if [[ ! -f "$src" ]]; then
    warn "packaging/nginx/00-vloud-log-format.conf missing in install tree; skipping"
    return 0
  fi
  if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
    ok "nginx request-log format already current"
    return 0
  fi
  if ! nginx -t >/dev/null 2>&1; then
    warn "nginx -t already fails on this host — not adding the request-log format (fix nginx, then re-run)"
    return 0
  fi
  local backup=""
  if [[ -f "$dst" ]]; then
    backup=$(mktemp /tmp/vloud-log-format.XXXXXX)
    cp -p "$dst" "$backup"
  fi
  install -m 0644 -o root -g root "$src" "$dst"
  # Capture the rejection NOW, with the file in place: after the rollback
  # `nginx -t` passes again and would only print "syntax is ok". Captured into
  # a variable rather than piped, so a failing `nginx -t` cannot trip
  # `set -o pipefail` and abort the whole install/upgrade here.
  local test_out=""
  if ! test_out=$(nginx -t 2>&1); then
    if [[ -n "$backup" ]]; then
      install -m 0644 -o root -g root "$backup" "$dst"
    else
      rm -f "$dst"
    fi
    rm -f "$backup"
    warn "nginx rejected the request-log format — rolled back; per-domain request metrics stay off"
    while IFS= read -r l; do warn "  $l"; done < <(printf '%s\n' "$test_out" | tail -3)
    return 0
  fi
  rm -f "$backup"
  if systemctl is-active --quiet nginx 2>/dev/null; then
    systemctl reload nginx 2>/dev/null \
      || warn "nginx reload failed — the request-log format applies at the next reload"
  fi
  ok "nginx request-log format installed ($dst)"
  return 0
}

# ─── Per-tenant Redis host plumbing (hoisted before mode dispatch) ───
#
# The engine's per-tenant Redis (services/redis-supervisor.ts) renders
# /etc/vloud/redis/<slug>.conf, hands /var/lib/vloud/redis/<slug> to the
# `vloud-redis` account and starts vloud-redis@<slug>.service. None of the host
# side of that was ever installed — docs/runbook/redis-provisioning.md listed it
# as manual steps — so every instance a tenant created failed at
# `systemctl start` with "Unit not found".
#
# Called from ALL THREE flows (fresh, --upgrade, --repair): this is host-level
# state outside $VLOUD_INSTALL_DIR, and --upgrade exits before the fresh linear
# phases, so a host upgrading into this release would otherwise never get it.
#
# Idempotent, and starts nothing. Instances are started by the engine when a
# tenant creates one; enabling the target only brings enabled instances back
# after a reboot. An instance already running keeps running across an upgrade —
# refreshing the template and a daemon-reload do not restart it.
#
# Best-effort: without redis-server (VLOUD_BOOTSTRAP_NO_REDIS=1) this warns and
# installs nothing, and the engine fails each instance with the reason.
ensure_tenant_redis_plumbing() {
  if [[ ! -x /usr/bin/redis-server ]]; then
    warn "redis-server not found at /usr/bin/redis-server — per-tenant Redis instances stay unavailable"
    return 0
  fi
  local pkg_dir="$VLOUD_INSTALL_DIR/packaging/systemd" u eu
  for u in vloud-redis@.service vloud-redis.target; do
    if [[ -f "$pkg_dir/$u" ]]; then
      install -m 0644 -o root -g root "$pkg_dir/$u" "/etc/systemd/system/$u"
    else
      warn "packaging/systemd/$u missing from the install tree — not refreshed"
    fi
  done
  # The account the unit runs as. It owns nothing but the instances' data dirs:
  # not root, and not a tenant's system user, because one tenant's compromised
  # Redis must not be able to read another tenant's data dir.
  if ! getent group vloud-redis >/dev/null 2>&1; then
    groupadd --system vloud-redis
  fi
  if ! id -u vloud-redis >/dev/null 2>&1; then
    useradd --system --no-create-home --home-dir /nonexistent \
      --shell /usr/sbin/nologin --gid vloud-redis vloud-redis
    ok "created vloud-redis system user"
  fi
  passwd -l vloud-redis >/dev/null 2>&1 || true
  # Owned by the engine's User= (root on a normal install, vloud on a sudo-mode
  # one) so the supervisor can write into both; group vloud-redis so the
  # instances can read/traverse and nobody else can.
  #   /etc/vloud/redis  2750 — setgid: every conf the engine writes lands in
  #                     group vloud-redis. The engine writes them 0640 because
  #                     they carry the ACL password hashes. The unit does not
  #                     rely on reading them by path — /etc/vloud is 0750
  #                     root:root after a fresh install — systemd opens the
  #                     conf as root and passes it on stdin (StandardInput=).
  #   /var/lib/vloud/redis 0750 — each instance's own dir below it is chowned
  #                     vloud-redis:vloud-redis 0750 by the supervisor.
  eu=$(engine_service_user)
  install -d -m 2750 -o "$eu" -g vloud-redis /etc/vloud/redis
  install -d -m 0750 -o "$eu" -g vloud-redis /var/lib/vloud/redis
  # RHEL + SELinux: the unit runs /usr/bin/redis-server, which transitions to
  # redis_t. Give the Vloud paths the labels the stock redis policy writes to,
  # and the instance port window (16379-16999) the redis port type — 16379 is
  # already redis_port_t (cluster bus). Best-effort and VM-unvalidated, like
  # the rest of configure_selinux (docs/os-support/IMPLEMENTATION-STATUS.md).
  if [[ "$OS_FAMILY" == rhel ]] && command -v semanage >/dev/null 2>&1 \
     && command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then
    semanage fcontext -a -t redis_conf_t '/etc/vloud/redis(/.*)?' 2>/dev/null \
      || semanage fcontext -m -t redis_conf_t '/etc/vloud/redis(/.*)?' 2>/dev/null || true
    semanage fcontext -a -t redis_var_lib_t '/var/lib/vloud/redis(/.*)?' 2>/dev/null \
      || semanage fcontext -m -t redis_var_lib_t '/var/lib/vloud/redis(/.*)?' 2>/dev/null || true
    semanage fcontext -a -t redis_var_run_t '/run/vloud-redis-[^/]+(/.*)?' 2>/dev/null \
      || semanage fcontext -m -t redis_var_run_t '/run/vloud-redis-[^/]+(/.*)?' 2>/dev/null || true
    semanage port -a -t redis_port_t -p tcp 16380-16999 2>/dev/null \
      || semanage port -m -t redis_port_t -p tcp 16380-16999 2>/dev/null || true
    restorecon -R /etc/vloud/redis /var/lib/vloud/redis >/dev/null 2>&1 || true
  fi
  systemctl daemon-reload >/dev/null 2>&1 || true
  systemctl enable vloud-redis.target >/dev/null 2>&1 \
    || warn "could not enable vloud-redis.target — tenant Redis instances will not come back after a reboot"
  ok "per-tenant Redis plumbing in place (vloud-redis@.service, vloud-redis user, /etc/vloud/redis, /var/lib/vloud/redis)"
  return 0
}

# ─── App launcher (C07-T13; hoisted before mode dispatch) ───
#
# /usr/local/libexec/vloud/vloud-envexec starts a deployed app from a 0600
# environment file, AFTER setpriv has dropped to the app's uid: the pm2 entry
# (ecosystem file, /root/.pm2/dump.pm2, the process argv) carries only the
# file's path, never a value. A tenant account's apps ALWAYS start through it
# on a root engine (`env -i KEY=VALUE …` put every value on a command line any
# local user can read), and the engine refuses to start one without it; the
# instance's own account uses it when the app_envexec_launcher tweak says so.
# Needs python3 (APT_BUILD / the RHEL equivalent already install it).
#
# Called from ALL THREE flows (fresh, --upgrade, --repair): it lives outside
# the swapped install tree, and --upgrade exits before the fresh linear
# phases. Idempotent; replaces the file with the release's copy.
# D-MAIL-05: the only thing sudoers lets write the Postfix/Dovecot map files. Root-owned so the engine user cannot edit it.
# Idempotent; refreshed on every install/upgrade/repair together with the sudoers fragment that grants it.
install_mailmap_wrapper() {
  local src="$VLOUD_INSTALL_DIR/scripts/libexec/vloud-mailmap-file"
  if [[ ! -f "$src" ]]; then
    warn "scripts/libexec/vloud-mailmap-file missing from the install tree - mail map wrapper not refreshed"
    return 0
  fi
  install -d -m 0755 -o root -g root /usr/local/libexec/vloud
  install -m 0755 -o root -g root "$src" /usr/local/libexec/vloud/vloud-mailmap-file
  ok "mail map wrapper installed to /usr/local/libexec/vloud/vloud-mailmap-file"
  return 0
}

install_app_launcher() {
  local src="$VLOUD_INSTALL_DIR/scripts/vloud-envexec.py"
  if [[ ! -f "$src" ]]; then
    warn "scripts/vloud-envexec.py missing from the install tree — app launcher not refreshed"
    return 0
  fi
  install -d -m 0755 -o root -g root /usr/local/libexec/vloud
  install -m 0755 -o root -g root "$src" /usr/local/libexec/vloud/vloud-envexec
  ok "app launcher installed to /usr/local/libexec/vloud/vloud-envexec"
  return 0
}

# ─── Tenant static-site root (hoisted before mode dispatch) ───
#
# A tenant account's static site is published under VLOUD_TENANT_STATIC_DIR
# (default /var/www/vloud-sites/<account>/<project>), never under the panel's
# own document root (/var/www/vloud): a tenant's HTML there would be served
# from the panel's origin, and script in it would act with the operator's
# session (services/deployment-engine.ts). The engine only mkdir'd the root
# on the first tenant deploy — with whatever owner/mode/SELinux label that
# happened to give — so a host that never deployed one had no such directory
# for nginx, and one that did had it unlabelled on RHEL.
#
# The root: owned by the engine's User= (root on a normal install; the engine
# creates <account>/ beneath it and hands <account>/<project>/ to the tenant),
# 0755 so the proxy (www-data / nginx / caddy) can traverse to the files, and
# on RHEL + SELinux labelled httpd_sys_content_t (a custom path outside
# /var/www gets its own fcontext rule).
#
# Called from ALL THREE flows (fresh, --upgrade, --repair): host-level state
# outside the swapped tree. Idempotent; changes only the root itself, never
# anything a tenant published under it.
tenant_static_root() {
  local dir="${VLOUD_TENANT_STATIC_DIR:-}"
  if [[ -z "$dir" && -f "${ENV_FILE:-/etc/vloud.env}" ]]; then
    dir=$(sed -n 's/^VLOUD_TENANT_STATIC_DIR=//p' "${ENV_FILE:-/etc/vloud.env}" 2>/dev/null | tail -1 | tr -d "\"'")
  fi
  printf '%s' "${dir:-/var/www/vloud-sites}"
}

ensure_tenant_static_root() {
  local dir eu
  dir=$(tenant_static_root)
  case "$dir" in
    /*) ;;
    *) warn "VLOUD_TENANT_STATIC_DIR=$dir is not an absolute path — tenant static root not prepared"; return 0 ;;
  esac
  if [[ -L "$dir" ]]; then
    warn "$dir is a symlink — refusing to prepare it as the tenant static root"
    return 0
  fi
  eu=$(engine_service_user)
  # The parent must be traversable by the proxy as well (/var/www is 0755 on
  # every supported distro; created the same way when it is missing).
  [[ -d "$(dirname "$dir")" ]] || install -d -m 0755 -o root -g root "$(dirname "$dir")"
  install -d -m 0755 -o "$eu" -g "$eu" "$dir"
  if [[ "${OS_FAMILY:-debian}" == rhel ]] && command -v semanage >/dev/null 2>&1 \
     && command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then
    semanage fcontext -a -t httpd_sys_content_t "${dir}(/.*)?" 2>/dev/null \
      || semanage fcontext -m -t httpd_sys_content_t "${dir}(/.*)?" 2>/dev/null || true
    restorecon -R "$dir" >/dev/null 2>&1 || true
  fi
  ok "tenant static-site root ready ($dir, 0755 $eu:$eu)"
  return 0
}

# ─── Engine Redis authentication (opt-in; hoisted before mode dispatch) ───
#
# redis-cli against the engine's Redis. Once the engine has its own ACL user
# (ensure_engine_redis_auth), /etc/vloud.env carries REDIS_URL with it, and
# after the default user is disabled a bare `redis-cli ping` only gets NOAUTH —
# so every probe in this script goes through here. The password travels in
# REDISCLI_AUTH, never in argv.
vloud_redis_cli() {
  local url
  url=$(grep -E '^REDIS_URL=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
  if [[ "$url" =~ ^rediss?://([^:@/]*):([^@/]+)@ ]]; then
    REDISCLI_AUTH="${BASH_REMATCH[2]}" redis-cli --user "${BASH_REMATCH[1]:-default}" "$@"
  else
    redis-cli "$@"
  fi
}

# Persist ACL changes made with ACL SETUSER: into the aclfile when Redis uses
# one, else into redis.conf via CONFIG REWRITE (the Debian unit allows writes to
# /etc/redis). Returns non-zero when neither worked.
_vloud_redis_persist_acl() {
  local aclfile
  aclfile=$(vloud_redis_cli CONFIG GET aclfile 2>/dev/null | sed -n 2p || true)
  if [[ -n "$aclfile" ]]; then
    vloud_redis_cli ACL SAVE 2>/dev/null | grep '^OK$' >/dev/null
  else
    vloud_redis_cli CONFIG REWRITE 2>/dev/null | grep '^OK$' >/dev/null
  fi
}

# OFF unless the operator asks: VLOUD_REDIS_AUTH=1 in the environment of this
# run, or already recorded in /etc/vloud.env by an earlier one.
#
# The shared Redis ships `user default on nopass +@all`, so every local uid can
# reach the engine's root-consumed deploy/provision/ssl queues. This is step 1
# of closing that (services/redis-shared-acl.ts has the whole sequence):
#
#   - create ACL user `vloud-engine` (+@all) with a random password, stored as
#     its SHA-256 (`#<hash>`) so the password never appears in argv;
#   - persist it (CONFIG REWRITE / ACL SAVE). If that fails the user is
#     deleted again: a user that disappears at the next Redis restart would
#     lock the engine out;
#   - write REDIS_URL=redis://vloud-engine:<pw>@127.0.0.1:6379/0 and
#     VLOUD_REDIS_AUTH=1 into /etc/vloud.env. Every engine client already reads
#     REDIS_URL; the services pick it up at the restart that follows. Note the
#     rate-limit plugin starts keeping Redis counters (vloud:rl:*) once
#     REDIS_URL is set.
#
# The default user stays ON — tenant apps, PM2 apps and CI still connect
# without credentials — unless VLOUD_REDIS_AUTH_LOCK_DEFAULT=1 is ALSO given.
# Turning it off breaks every client that has not been moved onto its own ACL
# user first, so that is an owner-window step, never automatic.
ensure_engine_redis_auth() {
  local want="${VLOUD_REDIS_AUTH:-}"
  [[ -n "$want" ]] || want=$(grep -E '^VLOUD_REDIS_AUTH=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
  [[ "$want" == "1" ]] || return 0
  if [[ "${VLOUD_BOOTSTRAP_NO_REDIS:-0}" == "1" ]] || ! command -v redis-cli >/dev/null 2>&1; then
    warn "VLOUD_REDIS_AUTH=1 but no Redis on this host — skipped"
    return 0
  fi
  [[ -f "$ENV_FILE" ]] || { warn "VLOUD_REDIS_AUTH=1 but $ENV_FILE does not exist yet — skipped"; return 0; }

  local cur_url
  cur_url=$(grep -E '^REDIS_URL=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
  if [[ "$cur_url" =~ ^rediss?://[^:@/]+:[^@/]+@ ]]; then
    if vloud_redis_cli ping 2>/dev/null | grep '^PONG$' >/dev/null; then
      ok "engine Redis credentials in $ENV_FILE authenticate"
    else
      warn "REDIS_URL in $ENV_FILE does not authenticate — the engine cannot reach Redis; fix it before restarting"
      return 0
    fi
  else
    # No REDIS_URL, or one that names no ACL user (an operator's bare
    # redis://127.0.0.1:6379/N). Keeping the latter would leave the engine on
    # the default user, which the lock below would then cut off. Its
    # host/port/db are kept: the engine's queues live there.
    local pass sha tmp rest
    rest="127.0.0.1:6379/0"
    if [[ -n "$cur_url" ]]; then
      rest="${cur_url#*://}"
      rest="${rest#*@}"
    fi
    pass=$(openssl rand -hex 32)
    sha=$(printf '%s' "$pass" | sha256sum | cut -d' ' -f1)
    if ! vloud_redis_cli ACL SETUSER vloud-engine reset on "#$sha" '~*' '&*' '+@all' 2>/dev/null | grep '^OK$' >/dev/null; then
      warn "could not create the vloud-engine Redis ACL user — Redis left as it was"
      return 0
    fi
    if ! _vloud_redis_persist_acl; then
      vloud_redis_cli ACL DELUSER vloud-engine >/dev/null 2>&1 || true
      warn "could not persist the vloud-engine Redis ACL user (CONFIG REWRITE / ACL SAVE) — removed it again; Redis left as it was"
      return 0
    fi
    tmp=$(mktemp /etc/vloud.env.XXXXXX)
    grep -vE '^(REDIS_URL|VLOUD_REDIS_AUTH)=' "$ENV_FILE" > "$tmp" || true
    printf 'VLOUD_REDIS_AUTH=1\nREDIS_URL=redis://vloud-engine:%s@%s\n' "$pass" "$rest" >> "$tmp"
    install -m 0640 -o root -g vloud "$tmp" "$ENV_FILE"
    rm -f "$tmp"
    if vloud_redis_cli ping 2>/dev/null | grep '^PONG$' >/dev/null; then
      ok "engine Redis ACL user vloud-engine created; REDIS_URL written to $ENV_FILE"
    else
      # Never go on to the lock with an engine that cannot authenticate.
      warn "vloud-engine was created but does not authenticate — check 'redis-cli ACL LIST'"
      return 0
    fi
  fi

  if [[ "${VLOUD_REDIS_AUTH_LOCK_DEFAULT:-0}" == "1" ]]; then
    if vloud_redis_cli ACL SETUSER default off 2>/dev/null | grep '^OK$' >/dev/null && _vloud_redis_persist_acl; then
      ok "Redis default user disabled — unauthenticated clients now get NOAUTH"
    else
      warn "could not disable the Redis default user"
    fi
  fi
  return 0
}

# ─── NodeSource 20.x (hoisted before mode dispatch) ───
#
# Point apt at Node 20 and make sure it actually WINS. Both halves matter, and
# the second is where this used to fail silently.
#
# ## Why an origin pin is not enough
#
# The old code wrote the 20.x repo and then pinned:
#
#     Package: nodejs
#     Pin: origin deb.nodesource.com
#     Pin-Priority: 1001
#
# On a host that already had NodeSource configured for a DIFFERENT major, that
# pin cannot do its job. Both repos share the origin `deb.nodesource.com`, so
# the pin matches BOTH equally and apt falls back to its normal rule: install
# the highest version available. That is 22.x, every time.
#
# Worse, the pre-existing repo is usually in the newer deb822 format
# (`nodesource.sources`), while we write the one-line `nodesource.list`. They
# are different files, so writing ours did not replace theirs — it just added a
# second NodeSource repo alongside it. Observed on a real host: our
# `nodesource.list` said node_20.x, and `apt-cache madison nodejs` offered
# nothing but 22.x.
#
# So: disable every other NodeSource source first, and pin by VERSION, which is
# the only expression of "20, not 22" that survives two same-origin repos.
configure_nodesource20() {
  install -d -m 0755 /etc/apt/keyrings
  curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
    | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg --yes 2>/dev/null

  # Move aside any OTHER NodeSource source, whatever its filename or format.
  # Renamed rather than deleted: it is the operator's file, and .disabled is
  # both inert to apt and trivially reversible.
  local f
  for f in /etc/apt/sources.list.d/*; do
    [[ -f "$f" ]] || continue
    [[ "$f" == /etc/apt/sources.list.d/nodesource.list ]] && continue
    [[ "$f" == *.disabled-by-vloud ]] && continue
    if grep -qs "deb.nodesource.com" "$f" && ! grep -qs "node_20\.x" "$f"; then
      mv -f "$f" "$f.disabled-by-vloud"
      warn "disabled competing NodeSource repo $(basename "$f") (it offered a Node major other than 20)"
    fi
  done

  echo 'deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main' \
    > /etc/apt/sources.list.d/nodesource.list

  # Pin by VERSION, not origin — see the note above. Priority >1000 is the only
  # band that lets apt install a LOWER version than the one already present,
  # which is exactly what repairing a Node 22 host requires.
  #
  # THE FILENAME IS PART OF THE FIX. apt reads /etc/apt/preferences.d in
  # alphabetical order and applies the FIRST entry that matches a package —
  # not the highest priority among all matches. NodeSource's own installer
  # ships /etc/apt/preferences.d/nodejs:
  #
  #     Package: nodejs
  #     Pin: origin deb.nodesource.com
  #     Pin-Priority: 600
  #
  # That matches our node_20.x repo too (same origin), and "nodejs" sorts
  # before "vloud-nodesource", so it won and our 1001 was never applied.
  # Measured on a real host: the pin file was present and correct, and
  # `apt-cache policy nodejs` still reported 600 for 20.20.2 — not enough to
  # downgrade from 22, so the install aborted. Renaming the file to sort first
  # changed the same host to "1 downgraded / Inst nodejs (20.20.2)".
  rm -f /etc/apt/preferences.d/vloud-nodesource   # pre-0.9.41 name, sorted too late
  cat > /etc/apt/preferences.d/00-vloud-nodesource <<'PREF'
Package: nodejs
Pin: version 20.*
Pin-Priority: 1001
PREF
  # NOT -qq. On agent-work-01 this step silently failed to fetch the node_20.x
  # index — /var/lib/apt/lists/ had no node_20 entry afterwards — and because
  # the error was suppressed, the install aborted a moment later saying Node 20
  # was unavailable, with nothing anywhere explaining why. A refresh that fails
  # must say so.
  if ! apt-get update -o Dpkg::Use-Pty=0 >/tmp/vloud-apt-update.log 2>&1; then
    warn "apt-get update reported errors while configuring the Node 20 repo:"
    grep -iE '^(E|W):' /tmp/vloud-apt-update.log | head -5 | while IFS= read -r l; do
      warn "  $l"
    done
  fi
  # Say plainly whether the repo we just configured is actually reachable,
  # because everything downstream depends on it.
  if ! apt-cache madison nodejs 2>/dev/null | grep -E '\|\s*20\.' >/dev/null; then
    warn "the node_20.x repository is configured but apt still sees no 20.x package"
    grep -iE 'nodesource' /tmp/vloud-apt-update.log | head -3 | while IFS= read -r l; do
      warn "  $l"
    done
  fi
}

# Can this host actually GET Node 20?
#
# Called in preflight, because the answer decides whether the install may
# proceed AT ALL — and the old code asked it far too late. The Node assertion
# used to run after the package phase had already installed and restarted the
# whole LAMP stack, so a host that could not reach Node 20 was left with its
# services restarted, its packages swapped, and no Vloud: all of the damage,
# none of the benefit. Ask first, break nothing.
#
# Returns 0 when Node 20 is already installed or is offered by apt.
node20_obtainable() {
  local cur
  if cur=$(node --version 2>/dev/null) && [[ "${cur#v}" == 20.* ]]; then
    return 0
  fi
  [[ "$OS_FAMILY" == debian ]] || return 0   # RHEL path handles its own module
  configure_nodesource20
  apt-cache madison nodejs 2>/dev/null | grep -E '\|\s*20\.' >/dev/null
}

# ─── Protecting what the host already runs (hoisted before mode dispatch) ───

# Is there already a database server here?
#
# Named, explicit and separate from the generic never-remove rule in
# install_packages, because this is the specific outcome that hurt: apt
# resolved `mariadb-server`'s Conflicts: by DELETING MySQL 8.0 and standing
# MariaDB up on an empty datadir. The generic rule prevents that now, but a
# database server is worth refusing by name rather than by side effect — and
# saying so in words the operator can act on.
existing_database_server() {
  local u id
  for u in mysql mysqld mariadb; do
    if systemctl is-active --quiet "$u.service" 2>/dev/null; then
      # Resolve the alias. On a MariaDB host mysql.service is an ALIAS for
      # mariadb.service and reports active, so naming the probe rather than the
      # unit tells the operator their box runs "mysql" when it runs MariaDB —
      # exactly the confusion this whole change exists to prevent.
      id=$(systemctl show -p Id --value "$u.service" 2>/dev/null)
      echo "${id:-$u.service}"
      return 0
    fi
  done
  for u in mysql-server mysql-server-8.0 mariadb-server percona-server-server; do
    if pkg_installed "$u"; then echo "$u"; return 0; fi
  done
  return 1
}

# Remove our preferred DB package from a package list when the host already has
# a database server. Echoes the filtered list.
drop_db_if_present() {
  local existing p out=()
  if ! existing=$(existing_database_server); then printf '%s\n' "$@"; return 0; fi
  for p in "$@"; do
    case "$p" in
      mariadb-server|mysql-server|postgresql-server)
        # >&2 is load-bearing: this function returns the filtered PACKAGE LIST
        # on stdout, and warn() prints to stdout. Without the redirect the
        # warning text is captured as part of the list and handed to apt as
        # package names.
        warn "not installing '$p': this host already runs a database server ($existing)" >&2
        warn "  Vloud will use the existing server. Nothing is removed, replaced or reinitialised." >&2
        ;;
      *) out+=("$p") ;;
    esac
  done
  printf '%s\n' ${out[@]+"${out[@]}"}
}

# ── Service-integrity net ────────────────────────────────────────────────────
#
# The installer must not take down anything it did not install. It has no code
# that stops a shared service — nginx is only ever `reload`ed, and only after
# `nginx -t` passes — yet a live site still went dark for 14 minutes, because
# apt reconfiguring an already-installed nginx restarted it into a port clash
# it never recovered from (no Restart= policy, so systemd gave up after one
# try).
#
# Being right in principle was not enough, so this verifies it in practice:
# snapshot what is running before, check it after, and put back anything that
# stopped. An installer that knocks over a production service and does not
# notice is the failure mode; noticing and fixing it is cheap.
VLOUD_SERVICES_BEFORE=""

snapshot_running_services() {
  VLOUD_SERVICES_BEFORE=$(systemctl list-units --type=service --state=running \
    --no-legend --no-pager 2>/dev/null | awk '{print $1}' | grep -v '^vloud' | sort)
}

verify_services_survived() {
  [[ -n "$VLOUD_SERVICES_BEFORE" ]] || return 0
  local now casualties u
  now=$(systemctl list-units --type=service --state=running \
    --no-legend --no-pager 2>/dev/null | awk '{print $1}' | sort)
  casualties=$(comm -23 <(printf '%s\n' "$VLOUD_SERVICES_BEFORE") <(printf '%s\n' "$now"))
  [[ -n "$casualties" ]] || { ok "every service that was running before the install is still running"; return 0; }

  for u in $casualties; do
    # A unit in `activating` is mid-restart, not down. Some hosts carry units
    # that crash-loop permanently (observed: two at ~1.2M restarts), and those
    # flip in and out of `running` constantly — reporting them as casualties of
    # OUR install is noise, and noise is how a real casualty gets ignored.
    # Give it a moment and re-check before saying anything.
    sleep 1
    case "$(systemctl is-active "$u" 2>/dev/null)" in
      active|activating|reloading) continue ;;
    esac
    fail "$u was RUNNING before this install and is not running now — restarting it"
    systemctl reset-failed "$u" 2>/dev/null || true
    if systemctl start "$u" 2>/dev/null && systemctl is-active --quiet "$u" 2>/dev/null; then
      ok "  restarted $u"
    else
      fail "  could NOT restart $u — investigate with 'journalctl -u $u -n 50'"
    fi
  done
}

EMBEDDED_RELEASE_PUBKEY_PEM='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAWkdy6RSETT2bSnm7T1fRDe8sr7keuU5EO/Wvk2gFXSM=
-----END PUBLIC KEY-----'

# ─── BEGIN release-policy ───────────────────────────────────────────────────
#
# Everything between BEGIN/END release-policy is extracted verbatim by the
# regression suite (scripts/tests/run-all.sh) and executed against fake
# releases in a temp prefix.  Keep it free of top-level side effects other than
# defining functions/variables, and keep every host-mutating step behind a
# function so the tests can run the real bodies.
#
# What lives here (F-06 / F-14 / F-21 of the licensing-security remediation):
#
#   1. signed-metadata verification (release JWS, channel index, recovery marker)
#   2. the version floor: payload.version/channel must match what is being
#      installed; a lower version than the installed one, a version below the
#      signed `min_supported`, or a `revoked[]` version is REFUSED — for
#      --version, --to and VLOUD_RELEASE_URL alike, because the check runs on
#      the SIGNED payload, never on the URL
#   3. the operator-authorised emergency path: a release-key-signed recovery
#      marker (see docs/operations/release-recovery-marker.md)
#   4. rollback-slot validation (only a retained, verified tree under the
#      managed releases root), the licensing-exit guard for automatic rollback,
#      flock, trap cleanup, the interrupted-swap recovery and the atomic swap
#
# Wire formats (shared with the licence server and the engine):
#   release   JWS EdDSA  typ vloud-release+jws           payload {artifact,version,channel,sha256,sums_sha256?,iat}
#   index     JWS EdDSA  typ vloud-release-index+jws     payload {channel,latest,min_supported,revoked[],iat,exp}
#   recovery  JWS EdDSA  typ vloud-release-recovery+jws  payload {recovery,action,target_version,jti,iat,exp,machine_id?,reason}
#   kid       release-v1 | release-v2  (the pinned RELEASE key — never the licence key)

# Exit codes this block adds (the older codes 3-9 keep their meaning).
VLOUD_EXIT_POLICY=10        # refused by release policy (floor / downgrade / revoked / recovery)
VLOUD_EXIT_LICENSING=11     # new release left in place: the engine refused to start for a licensing reason
VLOUD_EXIT_LOCKED=75        # another upgrade/rollback holds the lock (EX_TEMPFAIL)

# Engine exit statuses that mean "licensing refused me" (integrity-bootstrap.ts
# 11-15, heartbeat-client.ts 16 = signed revocation past its grace, boot-guards.ts /
# index.ts 21 = bypass-flag / cloud-endpoint refusal, reserved 22). The dist-tree integrity refusal is
# NOT in this list (exit 25, services/vloud-update-rollback.ts): a bad tree is cured by a rollback.  These must
# NEVER feed an automatic rollback: the previous release may not enforce what
# this one does.  Keep in sync with RestartPreventExitStatus= in the unit files
# (scripts/tests/run-all.sh asserts it).
VLOUD_LICENSING_EXIT_CODES="11 12 13 14 15 16 21 22"

VLOUD_RELEASE_KIDS="release-v1 release-v2"
VLOUD_RELEASE_STATE_DIR=${VLOUD_RELEASE_STATE_DIR:-/var/lib/vloud}
VLOUD_RECOVERY_MARKER=${VLOUD_RECOVERY_MARKER:-}

# ── cleanup registry (one EXIT trap for every temp file / staging dir) ──────
VLOUD_CLEANUP_PATHS=()
vloud_register_cleanup() { VLOUD_CLEANUP_PATHS+=("$@"); }
vloud_unregister_cleanup() {
  local drop="$1" p; local -a keep=()
  for p in ${VLOUD_CLEANUP_PATHS[@]+"${VLOUD_CLEANUP_PATHS[@]}"}; do
    [[ "$p" == "$drop" ]] || keep+=("$p")
  done
  VLOUD_CLEANUP_PATHS=(${keep[@]+"${keep[@]}"})
}
vloud_run_cleanup() {
  local rc=$? p
  # P2-OPS-14: a recovery marker is burned only by a run that succeeded.
  (( rc == 0 )) && declare -F vloud_recovery_commit_used >/dev/null && vloud_recovery_commit_used
  for p in ${VLOUD_CLEANUP_PATHS[@]+"${VLOUD_CLEANUP_PATHS[@]}"}; do
    [[ -n "$p" && ( -e "$p" || -L "$p" ) ]] && rm -rf -- "$p" 2>/dev/null
  done
  VLOUD_CLEANUP_PATHS=()
  return 0
}
trap vloud_run_cleanup EXIT

vloud_mktemp() { mktemp "${TMPDIR:-/tmp}/vloud-$1-XXXXXX"; }

# ── lock (F-21: concurrent upgrade / rollback) ──────────────────────────────
# fd 9 stays open for the life of the process; the kernel drops the lock on any
# exit, including SIGKILL, so a crashed run can never wedge the next one.
vloud_acquire_upgrade_lock() {
  local lock="${VLOUD_UPGRADE_LOCK:-/var/lock/vloud-upgrade.lock}"
  command -v flock >/dev/null 2>&1 || die "flock (util-linux) is required to serialise upgrades" 1
  mkdir -p "$(dirname "$lock")" 2>/dev/null || true
  exec 9>"$lock" || die "cannot open the upgrade lock $lock" "$VLOUD_EXIT_LOCKED"
  if ! flock -n 9; then
    emit_progress lock failed "another upgrade or rollback holds $lock"
    die "another vloud upgrade/rollback is already running (lock $lock) — wait for it to finish" "$VLOUD_EXIT_LOCKED"
  fi
}

# ── small JSON helpers (signed payloads are compact, canonical JSON) ────────
_vloud_json_str() { printf '%s' "$1" | sed -n 's/.*"'"$2"'":"\([^"]*\)".*/\1/p' | head -n1; }
_vloud_json_num() { printf '%s' "$1" | sed -n 's/.*"'"$2"'":\(-\{0,1\}[0-9][0-9]*\)\([,}].*\)\{0,1\}$/\1/p' | head -n1; }
_vloud_json_arr() { printf '%s' "$1" | sed -n 's/.*"'"$2"'":\[\([^]]*\)\].*/\1/p' | head -n1 | tr -d '" ' | tr ',' ' '; }
_vloud_json_is_true() { [[ "$1" == *"\"$2\":true"* ]]; }

b64url_decode() {
  local s="$1"
  s="${s//-/+}"; s="${s//_//}"
  local pad=$(( (4 - ${#s} % 4) % 4 ))
  while (( pad-- > 0 )); do s+="="; done
  printf '%s' "$s" | base64 -d
}

# ── version ordering (semver precedence + the engine's "0.6.0d-beta" tail) ──
# Sets VLOUD_CMP to -1 / 0 / 1 (no subshell, so callers can use it under set -e).
# Mirrors compareVersions() in services/vloud-release-policy.ts — both are run
# against scripts/tests/fixtures/version-vectors.txt.
vloud_ver_cmp() {
  local LC_ALL=C
  local a="${1#v}" b="${2#v}"
  a="${a%%+*}"; b="${b%%+*}"
  VLOUD_CMP=0
  [[ "$a" == "$b" ]] && return 0
  local acore="${a%%-*}" bcore="${b%%-*}" apre="" bpre=""
  [[ "$a" == *-* ]] && apre="${a#*-}"
  [[ "$b" == *-* ]] && bpre="${b#*-}"
  local -a pa pb qa qb
  IFS=. read -r -a pa <<<"$acore"
  IFS=. read -r -a pb <<<"$bcore"
  local n=${#pa[@]} i x y xn yn xs ys
  (( ${#pb[@]} > n )) && n=${#pb[@]}
  for (( i=0; i<n; i++ )); do
    x="${pa[i]:-0}"; y="${pb[i]:-0}"
    xn="${x%%[!0-9]*}"; yn="${y%%[!0-9]*}"
    xs="${x#"$xn"}";   ys="${y#"$yn"}"
    xn=$((10#${xn:-0})); yn=$((10#${yn:-0}))
    if (( xn != yn )); then
      if (( xn < yn )); then VLOUD_CMP=-1; else VLOUD_CMP=1; fi
      return 0
    fi
    if [[ "$xs" != "$ys" ]]; then
      if [[ "$xs" < "$ys" ]]; then VLOUD_CMP=-1; else VLOUD_CMP=1; fi
      return 0
    fi
  done
  # same core: a release outranks any pre-release of it
  [[ -z "$apre" && -z "$bpre" ]] && return 0
  if [[ -z "$apre" ]]; then VLOUD_CMP=1;  return 0; fi
  if [[ -z "$bpre" ]]; then VLOUD_CMP=-1; return 0; fi
  IFS=. read -r -a qa <<<"$apre"
  IFS=. read -r -a qb <<<"$bpre"
  n=${#qa[@]}; (( ${#qb[@]} > n )) && n=${#qb[@]}
  for (( i=0; i<n; i++ )); do
    if (( i >= ${#qa[@]} )); then VLOUD_CMP=-1; return 0; fi
    if (( i >= ${#qb[@]} )); then VLOUD_CMP=1;  return 0; fi
    x="${qa[i]}"; y="${qb[i]}"
    if [[ "$x" =~ ^[0-9]+$ && "$y" =~ ^[0-9]+$ ]]; then
      if (( 10#$x != 10#$y )); then
        if (( 10#$x < 10#$y )); then VLOUD_CMP=-1; else VLOUD_CMP=1; fi
        return 0
      fi
    elif [[ "$x" =~ ^[0-9]+$ ]]; then VLOUD_CMP=-1; return 0
    elif [[ "$y" =~ ^[0-9]+$ ]]; then VLOUD_CMP=1;  return 0
    elif [[ "$x" != "$y" ]]; then
      if [[ "$x" < "$y" ]]; then VLOUD_CMP=-1; else VLOUD_CMP=1; fi
      return 0
    fi
  done
  return 0
}
vloud_ver_lt() { vloud_ver_cmp "$1" "$2"; (( VLOUD_CMP < 0 )); }

vloud_valid_version() { [[ "$1" =~ ^[0-9A-Za-z][0-9A-Za-z._+-]{0,63}$ ]]; }
vloud_valid_channel() { [[ "$1" =~ ^[a-z][a-z0-9._-]{0,31}$ ]]; }

# Version of the tree being replaced (empty on a fresh install).
vloud_installed_version() {
  local v=""
  [[ -f "$VLOUD_INSTALL_DIR/VERSION" ]] && v=$(tr -d '[:space:]' <"$VLOUD_INSTALL_DIR/VERSION" 2>/dev/null || true)
  vloud_valid_version "$v" && printf '%s' "$v"
  return 0
}

# ── the pinned release public key ───────────────────────────────────────────
# Sets VLOUD_PUBKEY_FILE (a temp file, removed at exit).  Returns 0 when a key
# is available, 2 in the explicit test-skip mode, and dies otherwise.
vloud_release_pubkey_file() {
  [[ -n "${VLOUD_PUBKEY_FILE:-}" && -f "$VLOUD_PUBKEY_FILE" ]] && return 0
  local pem
  if [[ -n "${VLOUD_RELEASE_PUBKEY_PEM:-}" ]]; then
    pem="$VLOUD_RELEASE_PUBKEY_PEM"
  elif [[ "$EMBEDDED_RELEASE_PUBKEY_PEM" != "__VLOUD_RELEASE_PUBKEY_PEM__" ]]; then
    pem="$EMBEDDED_RELEASE_PUBKEY_PEM"
  else
    if [[ "${VLOUD_INSECURE_SKIP_VERIFY:-0}" == "1" && "${VLOUD_TESTING:-0}" == "1" ]]; then
      warn "VERIFICATION SKIPPED — VLOUD_INSECURE_SKIP_VERIFY=1 set (test mode)"
      return 2
    fi
    die "release public key not configured.  Set VLOUD_RELEASE_PUBKEY_PEM or use a release-built bootstrap.sh.  To skip for testing: VLOUD_INSECURE_SKIP_VERIFY=1 VLOUD_TESTING=1."
  fi
  VLOUD_PUBKEY_FILE=$(vloud_mktemp release-key)
  vloud_register_cleanup "$VLOUD_PUBKEY_FILE"
  printf '%s' "$pem" > "$VLOUD_PUBKEY_FILE"
  return 0
}

# The engine verifies the signed update manifest / channel index under the SAME pinned release key
# (it never trusts the licence key for that). Persist the key this installer carries where the engine reads
# it: root-owned, world-readable. Re-written on install, upgrade and repair, so a rotation rides the next
# release's bootstrap. A SEA release also embeds it as an asset (that copy wins inside the binary).
VLOUD_RELEASE_PUBKEY_FILE=${VLOUD_RELEASE_PUBKEY_FILE:-/etc/vloud/release-v1.pub.pem}
vloud_install_release_pubkey() {
  local rc=0
  vloud_release_pubkey_file || rc=$?
  (( rc == 0 )) || return 0                     # test-skip mode / no key: nothing to install
  local dir; dir=$(dirname "$VLOUD_RELEASE_PUBKEY_FILE")
  mkdir -p "$dir" 2>/dev/null || { warn "could not create $dir — the engine will not have a release key to verify update manifests"; return 0; }
  local tmp="$VLOUD_RELEASE_PUBKEY_FILE.tmp.$$"
  if cp "$VLOUD_PUBKEY_FILE" "$tmp" && chmod 0644 "$tmp" && mv -f "$tmp" "$VLOUD_RELEASE_PUBKEY_FILE"; then
    ok "release public key installed: $VLOUD_RELEASE_PUBKEY_FILE"
  else
    rm -f "$tmp"; warn "could not write $VLOUD_RELEASE_PUBKEY_FILE"
  fi
  return 0
}

# ── strict JWS verification (alg/typ/kid required, EdDSA only) ──────────────
# vloud_jws_verify <compact-jws> <expected-typ>
# On success: JWS_KID, JWS_PAYLOAD (decoded JSON) are set, returns 0.
# On failure: JWS_ERR says why, returns 1.  Never calls die: callers decide.
vloud_jws_verify() {
  local jws="$1" want_typ="$2"
  JWS_ERR=""; JWS_KID=""; JWS_PAYLOAD=""
  jws="${jws//[$'\n\r ']/}"
  if ! [[ "$jws" =~ ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]]; then
    JWS_ERR="not a JWS compact serialization"; return 1
  fi
  local h p s header_json alg typ kid
  IFS='.' read -r h p s <<<"$jws"
  header_json=$(b64url_decode "$h" 2>/dev/null) || { JWS_ERR="header is not base64url"; return 1; }
  alg=$(_vloud_json_str "$header_json" alg)
  typ=$(_vloud_json_str "$header_json" typ)
  kid=$(_vloud_json_str "$header_json" kid)
  if [[ "$alg" != "EdDSA" ]]; then JWS_ERR="alg must be EdDSA (got '$alg')"; return 1; fi
  if [[ "$typ" != "$want_typ" ]]; then JWS_ERR="typ must be $want_typ (got '$typ')"; return 1; fi
  if [[ -z "$kid" ]] || ! [[ " $VLOUD_RELEASE_KIDS " == *" $kid "* ]]; then
    JWS_ERR="kid '$kid' is not a release key id"; return 1
  fi
  local sig_bin sign_input rc=0
  sig_bin=$(vloud_mktemp sig); sign_input=$(vloud_mktemp signin)
  vloud_register_cleanup "$sig_bin" "$sign_input"
  if ! b64url_decode "$s" > "$sig_bin" 2>/dev/null; then JWS_ERR="signature is not base64url"; return 1; fi
  printf '%s' "$h.$p" > "$sign_input"
  openssl pkeyutl -verify -pubin -inkey "$VLOUD_PUBKEY_FILE" -rawin \
    -in "$sign_input" -sigfile "$sig_bin" >/dev/null 2>&1 || rc=$?
  rm -f "$sig_bin" "$sign_input"
  if (( rc != 0 )); then JWS_ERR="signature INVALID"; return 1; fi
  JWS_PAYLOAD=$(b64url_decode "$p" 2>/dev/null) || { JWS_ERR="payload is not base64url"; return 1; }
  JWS_KID="$kid"
  return 0
}

# ── the signed channel index ────────────────────────────────────────────────
# IDX_* describe the accepted index; IDX_PRESENT=0 means "no index in force"
# (the floor is then null — the default that keeps legacy fleets working).
IDX_PRESENT=0; IDX_CHANNEL=""; IDX_LATEST=""; IDX_MIN_SUPPORTED=""; IDX_REVOKED=""; IDX_IAT=0; IDX_EXP=0; IDX_SOURCE=""
IDX_LOADED_FOR=""

vloud_index_cache_file() { printf '%s/release-index-%s.jws' "$VLOUD_RELEASE_STATE_DIR" "$1"; }

# vloud_index_parse <jws> <channel> <allow-expired 0|1>  → 0 accepted, 1 rejected (IDX_ERR)
vloud_index_parse() {
  local jws="$1" channel="$2" allow_expired="${3:-0}"
  IDX_ERR=""
  vloud_release_pubkey_file || { IDX_ERR="no release key"; return 1; }
  if ! vloud_jws_verify "$jws" "vloud-release-index+jws"; then IDX_ERR="$JWS_ERR"; return 1; fi
  local pl="$JWS_PAYLOAD" ch iat exp now
  ch=$(_vloud_json_str "$pl" channel)
  iat=$(_vloud_json_num "$pl" iat); exp=$(_vloud_json_num "$pl" exp)
  if [[ "$ch" != "$channel" ]]; then IDX_ERR="index is for channel '$ch', not '$channel'"; return 1; fi
  if [[ -z "$iat" || -z "$exp" ]]; then IDX_ERR="index lacks iat/exp"; return 1; fi
  now=$(date -u +%s)
  if (( iat > now + 600 )); then IDX_ERR="index iat is in the future"; return 1; fi
  if (( exp <= now )) && [[ "$allow_expired" != "1" ]]; then IDX_ERR="index expired"; return 1; fi
  local min latest revoked v
  min=$(_vloud_json_str "$pl" min_supported)
  latest=$(_vloud_json_str "$pl" latest)
  revoked=$(_vloud_json_arr "$pl" revoked)
  if [[ -n "$min" ]] && ! vloud_valid_version "$min"; then IDX_ERR="min_supported is not a version"; return 1; fi
  for v in $revoked; do vloud_valid_version "$v" || { IDX_ERR="revoked[] holds a non-version"; return 1; }; done
  IDX_CHANNEL="$ch"; IDX_LATEST="$latest"; IDX_MIN_SUPPORTED="$min"; IDX_REVOKED="$revoked"
  IDX_IAT="$iat"; IDX_EXP="$exp"
  return 0
}

# Fetch candidates for the index, one per line.
vloud_index_candidates() {
  local channel="$1" origin
  [[ -n "${VLOUD_RELEASE_INDEX_FILE:-}" ]] && printf '%s\n' "${VLOUD_RELEASE_INDEX_FILE}"
  [[ -n "${VLOUD_RELEASE_INDEX_URL:-}" ]] && printf '%s\n' "${VLOUD_RELEASE_INDEX_URL//%CHANNEL%/$channel}"
  origin=$(printf '%s' "${VLOUD_RELEASE_URL:-}" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')
  [[ -n "$origin" ]] && printf '%s/%s/index.jws\n' "$origin" "$channel"
  [[ -n "${VLOUD_LICENSE_SERVER_URL_DEFAULT:-}" ]] && \
    printf '%s/v1/updates/index?channel=%s\n' "${VLOUD_LICENSE_SERVER_URL_DEFAULT%/}" "$channel"
  return 0
}

# Load (once per channel) the freshest valid index: fetched copy if it verifies
# and is not older than the last accepted one, else the sticky local cache.
vloud_release_index_load() {
  local channel="$1"
  [[ "$IDX_LOADED_FOR" == "$channel" ]] && return 0
  IDX_PRESENT=0; IDX_SOURCE=""; IDX_MIN_SUPPORTED=""; IDX_REVOKED=""; IDX_LATEST=""; IDX_IAT=0
  IDX_LOADED_FOR="$channel"
  vloud_release_pubkey_file || return 0   # test-skip mode: no policy

  local cache cached_iat=0 cached_jws=""
  cache=$(vloud_index_cache_file "$channel")
  if [[ -f "$cache" ]]; then
    cached_jws=$(<"$cache")
    if vloud_index_parse "$cached_jws" "$channel" 1; then cached_iat=$IDX_IAT; else cached_jws=""; fi
  fi

  local cand body jws best_jws="" best_src="" best_iat=0
  while IFS= read -r cand; do
    [[ -n "$cand" ]] || continue
    body=""
    if [[ "$cand" == /* || "$cand" == file://* ]]; then
      local f="${cand#file://}"; [[ -f "$f" ]] && body=$(<"$f")
    else
      body=$(curl -fsSL --max-time 20 "$cand" 2>/dev/null) || body=""
    fi
    [[ -n "$body" ]] || continue
    jws="$body"
    if [[ "${body:0:1}" == "{" ]]; then
      jws=$(_vloud_json_str "$body" index); [[ -n "$jws" ]] || jws=$(_vloud_json_str "$body" jws)
      [[ -n "$jws" ]] || jws=$(_vloud_json_str "$body" index_jws)
    fi
    if ! vloud_index_parse "$jws" "$channel" 0; then
      warn "release index from $cand rejected: $IDX_ERR"
      continue
    fi
    if (( IDX_IAT < cached_iat )); then
      warn "release index from $cand is OLDER than the last accepted one (iat $IDX_IAT < $cached_iat) — ignored (replay)"
      continue
    fi
    if (( IDX_IAT >= best_iat )); then best_jws="$jws"; best_src="$cand"; best_iat=$IDX_IAT; fi
  done < <(vloud_index_candidates "$channel")

  if [[ -n "$best_jws" ]]; then
    vloud_index_parse "$best_jws" "$channel" 0 || true
    IDX_PRESENT=1; IDX_SOURCE="$best_src"
    if mkdir -p "$VLOUD_RELEASE_STATE_DIR" 2>/dev/null; then
      printf '%s' "$best_jws" > "$cache.tmp.$$" 2>/dev/null && chmod 0600 "$cache.tmp.$$" 2>/dev/null \
        && mv -f "$cache.tmp.$$" "$cache" 2>/dev/null || rm -f "$cache.tmp.$$" 2>/dev/null
    fi
    return 0
  fi
  if [[ -n "$cached_jws" ]]; then
    # nothing fresher is reachable: a stale index still proves a FLOOR
    vloud_index_parse "$cached_jws" "$channel" 1 || true
    IDX_PRESENT=1; IDX_SOURCE="cache"
    warn "no fresh release index reachable — enforcing the last accepted one (iat $IDX_IAT)"
    return 0
  fi
  if [[ "${VLOUD_RELEASE_INDEX_REQUIRED:-0}" == "1" ]]; then
    die "no signed release index available for channel '$channel' and VLOUD_RELEASE_INDEX_REQUIRED=1" "$VLOUD_EXIT_POLICY"
  fi
  IDX_PRESENT=0
  return 0
}

# ── the recovery marker (operator-authorised emergency path) ────────────────
vloud_local_machine_id() {
  local id="${VLOUD_MACHINE_ID:-}"
  if [[ -z "$id" && -f "${ENV_FILE:-/etc/vloud.env}" ]]; then
    id=$(sed -n 's/^VLOUD_MACHINE_ID=//p' "${ENV_FILE:-/etc/vloud.env}" | head -n1 | tr -d "\"' ")
  fi
  if [[ -z "$id" && -r /etc/machine-id ]]; then
    id=$(printf 'vloud:%s' "$(tr -d '[:space:]' </etc/machine-id)" | sha256sum | cut -c1-32)
  fi
  printf '%s' "$id"
}

VLOUD_RECOVERY_PENDING_JTIS=()
# Record every marker claimed in this run as used. Called from the EXIT trap on exit status 0 only.
vloud_recovery_commit_used() {
  local jti used="$VLOUD_RELEASE_STATE_DIR/.recovery-markers-used"
  [[ ${#VLOUD_RECOVERY_PENDING_JTIS[@]} -gt 0 ]] || return 0
  if mkdir -p "$VLOUD_RELEASE_STATE_DIR" 2>/dev/null; then
    for jti in "${VLOUD_RECOVERY_PENDING_JTIS[@]}"; do
      printf '%s\n' "$jti" >> "$used" 2>/dev/null || true
    done
    chmod 0600 "$used" 2>/dev/null || true
  fi
  VLOUD_RECOVERY_PENDING_JTIS=()
  return 0
}

# vloud_recovery_authorizes <target-version> <downgrade|rollback>
# 0 when VLOUD_RECOVERY_MARKER holds a valid, unexpired, unused marker naming
# exactly this target and action.  Sets RECOVERY_NOTE either way.
vloud_recovery_authorizes() {
  local target="$1" action="$2"
  RECOVERY_NOTE=""
  [[ -n "${VLOUD_RECOVERY_MARKER:-}" ]] || { RECOVERY_NOTE="no recovery marker supplied"; return 1; }
  [[ -f "$VLOUD_RECOVERY_MARKER" ]] || { RECOVERY_NOTE="recovery marker $VLOUD_RECOVERY_MARKER not found"; return 1; }
  vloud_release_pubkey_file || { RECOVERY_NOTE="no release key to verify the marker"; return 1; }
  if ! vloud_jws_verify "$(<"$VLOUD_RECOVERY_MARKER")" "vloud-release-recovery+jws"; then
    RECOVERY_NOTE="recovery marker rejected: $JWS_ERR"; return 1
  fi
  local pl="$JWS_PAYLOAD" iat exp now jti mach
  _vloud_json_is_true "$pl" recovery || { RECOVERY_NOTE="recovery marker lacks recovery:true"; return 1; }
  [[ "$(_vloud_json_str "$pl" action)" == "$action" ]] \
    || { RECOVERY_NOTE="recovery marker is not for action '$action'"; return 1; }
  [[ "$(_vloud_json_str "$pl" target_version)" == "$target" ]] \
    || { RECOVERY_NOTE="recovery marker does not name target version $target"; return 1; }
  iat=$(_vloud_json_num "$pl" iat); exp=$(_vloud_json_num "$pl" exp); jti=$(_vloud_json_str "$pl" jti)
  [[ -n "$iat" && -n "$exp" && -n "$jti" ]] || { RECOVERY_NOTE="recovery marker lacks iat/exp/jti"; return 1; }
  now=$(date -u +%s)
  (( exp > now )) || { RECOVERY_NOTE="recovery marker expired"; return 1; }
  (( iat <= now + 600 )) || { RECOVERY_NOTE="recovery marker iat is in the future"; return 1; }
  (( exp - iat <= 604800 )) || { RECOVERY_NOTE="recovery marker lifetime exceeds 7 days"; return 1; }
  mach=$(_vloud_json_str "$pl" machine_id)
  if [[ -n "$mach" && "$mach" != "$(vloud_local_machine_id)" ]]; then
    RECOVERY_NOTE="recovery marker is bound to another machine"; return 1
  fi
  local used="$VLOUD_RELEASE_STATE_DIR/.recovery-markers-used" p
  if [[ -f "$used" ]] && grep -qxF "$jti" "$used"; then
    RECOVERY_NOTE="recovery marker $jti was already used"; return 1
  fi
  # Single use WITHIN this run: a second authorisation with the same marker is refused.
  for p in ${VLOUD_RECOVERY_PENDING_JTIS[@]+"${VLOUD_RECOVERY_PENDING_JTIS[@]}"}; do
    [[ "$p" == "$jti" ]] && { RECOVERY_NOTE="recovery marker $jti was already used"; return 1; }
  done
  # P2-OPS-14: the marker is only CLAIMED here. It is recorded as used when the run exits 0
  # (vloud_run_cleanup -> vloud_recovery_commit_used), so a recovery that fails afterwards (disk, lock,
  # health gate) can be retried with the same marker until it expires. Single-use still holds for any
  # action that succeeded. A run killed between the swap and its exit leaves the marker unburned; it
  # is still bound to one action, one target version, one machine and its <= 7 d lifetime.
  VLOUD_RECOVERY_PENDING_JTIS+=("$jti")
  RECOVERY_NOTE="authorised by signed recovery marker $jti ($(_vloud_json_str "$pl" reason))"
  return 0
}

# ── the floor ───────────────────────────────────────────────────────────────
# vloud_enforce_release_floor <version> <channel> <installed|""> <downgrade|rollback>
# Dies with VLOUD_EXIT_POLICY unless the target passes (or a recovery marker
# authorises it).  A revoked version is never allowed, marker or not: lifting a
# revocation is done by publishing a new index.
vloud_enforce_release_floor() {
  local version="$1" channel="$2" installed="$3" action="${4:-downgrade}"
  vloud_valid_version "$version" || die "signed release version '$version' is not a valid version — refusing" "$VLOUD_EXIT_POLICY"
  vloud_valid_channel "$channel" || die "signed release channel '$channel' is not valid — refusing" "$VLOUD_EXIT_POLICY"
  vloud_release_index_load "$channel"

  local v
  if (( IDX_PRESENT )); then
    for v in $IDX_REVOKED; do
      if [[ "$v" == "$version" ]]; then
        emit_progress floor failed "revoked: $version"
        die "release $version is REVOKED in the signed $channel index — refusing" "$VLOUD_EXIT_POLICY"
      fi
    done
  fi

  local why=""
  if (( IDX_PRESENT )) && [[ -n "$IDX_MIN_SUPPORTED" ]] && vloud_ver_lt "$version" "$IDX_MIN_SUPPORTED"; then
    why="release $version is below the signed minimum supported version $IDX_MIN_SUPPORTED ($channel)"
  fi
  if [[ -n "$installed" ]] && vloud_ver_lt "$version" "$installed"; then
    why="${why:+$why; }release $version is OLDER than the installed $installed (downgrade)"
  fi
  [[ -n "$why" ]] || return 0

  if vloud_recovery_authorizes "$version" "$action"; then
    warn "$why — proceeding: $RECOVERY_NOTE"
    emit_progress floor warn "recovery marker: $why"
    return 0
  fi
  emit_progress floor failed "$why"
  die "$why. Refusing. (${RECOVERY_NOTE}.  Operator-authorised emergencies need a release-key-signed recovery marker: --recovery-marker=<file>, see docs/operations/release-recovery-marker.md)" "$VLOUD_EXIT_POLICY"
}

# ── release verification chain ──────────────────────────────────────────────
# verify_release_chain <tarball> <sha-file> <sig-file> [expected-artifact]
# Sets VLOUD_VERIFIED_{VERSION,CHANNEL,IAT,SUMS_SHA256}.  Also honours
# $VLOUD_TARGET_VERSION (--version): the signed payload must name that version.
verify_release_chain() {
  local tarball="$1" checksum="$2" signature="$3"
  local expected_artifact="${4:-$(basename "$tarball")}"
  VLOUD_VERIFIED_VERSION=""; VLOUD_VERIFIED_CHANNEL=""; VLOUD_VERIFIED_IAT=""; VLOUD_VERIFIED_SUMS_SHA256=""

  vloud_release_pubkey_file || return 0   # explicit test-skip mode (warned)

  if ! ( cd "$(dirname "$tarball")" && sha256sum -c "$(basename "$checksum")" >/dev/null 2>&1 ); then
    die "release tarball sha256 MISMATCH — refusing install"
  fi

  if ! vloud_jws_verify "$(<"$signature")" "vloud-release+jws"; then
    die "release signature rejected: $JWS_ERR — refusing install"
  fi
  local payload_json="$JWS_PAYLOAD" kid="$JWS_KID"
  local payload_sha payload_artifact actual_sha pver pchan piat psums
  payload_sha=$(_vloud_json_str "$payload_json" sha256)
  payload_artifact=$(_vloud_json_str "$payload_json" artifact)
  pver=$(_vloud_json_str "$payload_json" version)
  pchan=$(_vloud_json_str "$payload_json" channel)
  piat=$(_vloud_json_num "$payload_json" iat)
  psums=$(_vloud_json_str "$payload_json" sums_sha256)
  actual_sha=$(sha256sum "$tarball" | awk '{print $1}')
  if [[ "$payload_sha" != "$actual_sha" ]]; then
    die "release signed sha256 ($payload_sha) does not match tarball ($actual_sha) — refusing install"
  fi
  if [[ "$payload_artifact" != "$expected_artifact" ]]; then
    die "release signed artifact ($payload_artifact) does not match expected artifact ($expected_artifact) — refusing install"
  fi
  # F-06: the signed version/channel are the authority — the URL, the file name
  # and --version are only requests.
  if [[ -z "$pver" || -z "$pchan" ]]; then
    die "release signature carries no version/channel — cannot apply the version floor; refusing install" "$VLOUD_EXIT_POLICY"
  fi
  if [[ -n "${VLOUD_TARGET_VERSION:-}" && "$pver" != "$VLOUD_TARGET_VERSION" ]]; then
    die "release signature is for version $pver but --version=$VLOUD_TARGET_VERSION was requested — refusing install" "$VLOUD_EXIT_POLICY"
  fi
  local action=downgrade
  [[ "${MODE:-}" == rollback ]] && action=rollback
  vloud_enforce_release_floor "$pver" "$pchan" "$(vloud_installed_version)" "$action"

  VLOUD_VERIFIED_VERSION="$pver"; VLOUD_VERIFIED_CHANNEL="$pchan"
  VLOUD_VERIFIED_IAT="$piat"; VLOUD_VERIFIED_SUMS_SHA256="$psums"
  ok "release verified: $expected_artifact (kid=$kid version=$pver channel=$pchan)"
}

# Root SHA256SUMS of an extracted tree, anchored to the signed payload.
# Legacy trees (no root SHA256SUMS) are left to do_verify_sha256sums.
vloud_verify_release_sums() {
  local tree="$1" want="${VLOUD_VERIFIED_SUMS_SHA256:-}"
  local sums="$tree/SHA256SUMS"
  if [[ ! -f "$sums" ]]; then
    [[ -z "$want" ]] || die "release signature pins SHA256SUMS ($want) but the tree has none — refusing" "$VLOUD_EXIT_POLICY"
    return 0
  fi
  if [[ -n "$want" ]]; then
    local got; got=$(sha256sum "$sums" | awk '{print $1}')
    [[ "$got" == "$want" ]] || die "SHA256SUMS ($got) does not match the digest the release signature pins ($want) — refusing" "$VLOUD_EXIT_POLICY"
  fi
  if ! ( cd "$tree" && sha256sum -c --quiet SHA256SUMS >/dev/null 2>&1 ); then
    emit_progress verify-sha256sums failed "mismatch against root SHA256SUMS"
    die "per-file integrity FAILED against the root SHA256SUMS in $tree — refusing swap" 9
  fi
  ok "root SHA256SUMS verified${want:+ (digest pinned by the release signature)}"
}

# ── staging: download → verify → extract → sums ─────────────────────────────
# vloud_stage_release <url> <staging-dir> : the one place a release is fetched
# for an upgrade.  On any failure the staging dir is removed (also by the EXIT
# trap) and the script dies with the failing step's code.
vloud_stage_release() {
  local url="$1" staging="$2" tarball sha sig
  install -d -m 0755 "$staging"
  vloud_register_cleanup "$staging"
  tarball=$(vloud_mktemp upgrade); sha="$tarball.sha256"; sig="$tarball.sig"
  vloud_register_cleanup "$tarball" "$sha" "$sig"
  emit_progress downloading started "$url"
  curl -fsSL --max-time 600 -o "$tarball" "$url" || die "release download failed: $url" 4
  if [[ "${VLOUD_INSECURE_SKIP_VERIFY:-0}" != "1" ]]; then
    curl -fsSL --max-time 60 -o "$sha" "$url.sha256" || die "release sha256 download failed: $url.sha256" 4
    curl -fsSL --max-time 60 -o "$sig" "$url.sig"    || die "release sig download failed: $url.sig" 4
    # Rewrite the sha256 line to match the on-disk mktemp filename.
    local hex; hex=$(awk '{print $1}' "$sha")
    printf '%s  %s\n' "$hex" "$(basename "$tarball")" > "$sha"
    emit_progress verifying started "verify_release_chain"
    verify_release_chain "$tarball" "$sha" "$sig" "$(basename "${url%%\?*}")"
    emit_progress verifying ok "manifest signature + sha256 verified"
  else
    [[ "${VLOUD_TESTING:-0}" == "1" ]] || die "VLOUD_INSECURE_SKIP_VERIFY=1 requires VLOUD_TESTING=1 (refusing on production host)" 4
    warn "VERIFICATION SKIPPED — VLOUD_INSECURE_SKIP_VERIFY=1 (test mode)"
    emit_progress verifying warn "skipped (test mode)"
  fi
  tar -xzf "$tarball" -C "$staging" --strip-components=1 || die "release extraction failed" 4
  rm -f "$tarball" "$sha" "$sig"
  ok "release extracted to $staging"
  emit_progress downloading ok "extracted to $staging"
  vloud_verify_release_sums "$staging"
  vloud_record_verified_release "$staging"
}

# What the signature said about this tree, kept INSIDE the tree: it becomes the
# slot's identity (version/channel for the rollback floor) when it is replaced.
vloud_record_verified_release() {
  local tree="$1"
  [[ -n "${VLOUD_VERIFIED_VERSION:-}" ]] || return 0
  printf '{"version":"%s","channel":"%s","iat":%s,"sums_sha256":"%s"}\n' \
    "$VLOUD_VERIFIED_VERSION" "$VLOUD_VERIFIED_CHANNEL" "${VLOUD_VERIFIED_IAT:-0}" "${VLOUD_VERIFIED_SUMS_SHA256:-}" \
    > "$tree/.vloud-release.json"
  if mkdir -p "$VLOUD_RELEASE_STATE_DIR" 2>/dev/null; then
    cp -f "$tree/.vloud-release.json" "$VLOUD_RELEASE_STATE_DIR/installed-channel.json" 2>/dev/null || true
  fi
  return 0
}

# ── where a tree keeps its engine database (legacy dist layout vs SEA layout) ─
# Both layouts run the engine with packages/server as its working directory, so
# the database is packages/server/vloud.db either way.
vloud_tree_is_sea() { [[ -f "$1/vloud-engine" ]]; }
# Directory holding the engine DB: the legacy engine runs with packages/server as
# its working directory, the SEA engine with the install root (its migrations and
# web assets sit beside the binary).
vloud_tree_db_dir() { if vloud_tree_is_sea "$1"; then printf '%s' "$1"; else printf '%s/packages/server' "$1"; fi; }
# An install exists in either layout (the auto-mode / --fresh guards used to look
# only for the legacy dist and would have re-extracted over a SEA install).
vloud_install_present() { [[ -d "$VLOUD_INSTALL_DIR/packages/server/dist" || -f "$VLOUD_INSTALL_DIR/vloud-engine" ]]; }

# ── atomic swap (F-21) ──────────────────────────────────────────────────────
vloud_mv_exchange_supported() { local h; h=$(mv --help 2>&1) || true; [[ "$h" == *--exchange* ]]; }

# vloud_swap_trees <live> <new> <slot>
#   0  swapped: <live> holds the new tree, <slot> the old one
#   1  nothing changed (the caller still owns <new>)
#   2  live tree is the new one but the old one could not be renamed into <slot>
#      (it sits at <new>'s path) — the caller must NOT delete <new>
# With `mv --exchange` (coreutils >= 9.5, renameat2 RENAME_EXCHANGE) the swap
# itself is a single atomic syscall.  Without it the two renames are guarded:
# a failure of the second restores the first.
vloud_swap_trees() {
  local live="$1" new="$2" slot="$3"
  [[ -d "$live" && -d "$new" ]] || return 1
  [[ ! -e "$slot" && ! -L "$slot" ]] || { fail "rollback slot $slot already exists"; return 1; }
  if vloud_mv_exchange_supported; then
    mv -T --exchange "$new" "$live" || return 1
    mv -T "$new" "$slot" || { fail "swapped, but could not move the previous tree into $slot (it is at $new)"; return 2; }
    return 0
  fi
  mv -T "$live" "$slot" || return 1
  if ! mv -T "$new" "$live"; then
    if mv -T "$slot" "$live"; then
      fail "could not move $new into place — previous tree restored"
      return 1
    fi
    fail "CRITICAL: $live is missing and could not be restored from $slot — run: mv $slot $live"
    return 2
  fi
  return 0
}

# A previous run killed between the two renames leaves no live tree but a slot.
vloud_recover_interrupted_swap() {
  [[ -e "$VLOUD_INSTALL_DIR" ]] && return 0
  local newest
  newest=$(ls -d "${VLOUD_INSTALL_DIR}".pre-upgrade-* 2>/dev/null | sort | tail -n1 || true)
  [[ -n "$newest" && -d "$newest" ]] || return 0
  warn "$VLOUD_INSTALL_DIR is missing — an earlier upgrade was interrupted mid-swap; restoring $newest"
  mv -T "$newest" "$VLOUD_INSTALL_DIR" || die "could not restore $newest to $VLOUD_INSTALL_DIR" 5
  emit_progress swap-recover ok "restored $newest"
}

# Staging dirs of crashed runs.  Safe: callers hold the upgrade lock.
vloud_cleanup_stale_staging() {
  local d
  for d in "${VLOUD_INSTALL_DIR}"-staging-*; do
    [[ -e "$d" ]] && { warn "removing stale staging dir $d"; rm -rf -- "$d"; }
  done
  return 0
}

# ── slot metadata + rollback slot validation (F-06/F-21) ────────────────────
vloud_slot_sums_digest() { # digest over every SHA256SUMS file a tree carries
  local tree="$1" f out=""
  for f in SHA256SUMS packages/server/dist/SHA256SUMS packages/web/dist/SHA256SUMS; do
    [[ -f "$tree/$f" ]] && out+="$f:$(sha256sum "$tree/$f" | awk '{print $1}')"$'\n'
  done
  [[ -n "$out" ]] && printf '%s' "$out" | sha256sum | awk '{print $1}'
  return 0
}

# Where a tree keeps the engine's SQL migrations (SEA: beside the binary; legacy:
# inside the compiled server dist).  Prints nothing when the tree has none.
vloud_tree_migrations_dir() {
  local t="$1"
  if [[ -d "$t/db/migrations" ]]; then printf '%s' "$t/db/migrations"
  elif [[ -d "$t/packages/server/dist/db/migrations" ]]; then printf '%s/packages/server/dist/db/migrations' "$t"
  fi
  return 0
}
# The migrations a tree ships, one name per line, sorted.  Dotfiles are never
# migrations (AppleDouble twins; same rule as db/migration-files.ts).
vloud_tree_migration_names() {
  local d; d=$(vloud_tree_migrations_dir "$1")
  [[ -n "$d" ]] || return 0
  ( cd "$d" && for f in *.sql; do [[ -e "$f" && "$f" != .* ]] && printf '%s\n' "$f"; done ) | LC_ALL=C sort
  return 0
}

# P2-OPS-05 — schema/code compatibility for a rollback.  --rollback carries the
# LIVE database into the older tree, and engine migrations are forward-only, so
# a slot that lacks migrations the live tree ships would run old code on a
# schema it has never seen (new NOT NULL columns, rebuilt tables).  The live
# tree has run every migration the DB holds, so "live ships migrations the slot
# does not" is the test; it needs no sqlite3 binary.  Prints the problem and
# returns 1 when the slot is not provably schema-compatible; 0 when it is.
vloud_rollback_schema_problem() {
  local live="$1" slot="$2" ldir sdir missing n
  ldir=$(vloud_tree_migrations_dir "$live"); sdir=$(vloud_tree_migrations_dir "$slot")
  if [[ -z "$ldir" ]]; then return 0; fi   # nothing recorded for the live tree: nothing to compare
  if [[ -z "$sdir" ]]; then
    printf 'rollback slot %s ships no migrations directory, so it cannot be shown to match the live database schema' "$slot"
    return 1
  fi
  missing=$(comm -23 <(vloud_tree_migration_names "$live") <(vloud_tree_migration_names "$slot"))
  [[ -z "$missing" ]] && return 0
  n=$(printf '%s\n' "$missing" | grep -c .)
  printf 'the live database schema is NEWER than the rollback slot: %s migration(s) applied by the live release are unknown to the slot (latest: %s). Old code on a newer schema can fail unpredictably, and the older tree has no down-migrations' \
    "$n" "$(printf '%s\n' "$missing" | tail -n1)"
  return 1
}

# Written into the OLD tree as it becomes a slot, so a later --rollback can tell
# a tree this installer retained from a directory somebody dropped in /opt.
# max_migration records the newest schema the tree knows (P2-OPS-05).
vloud_write_slot_meta() {
  local slot="$1" version="$2" digest maxm
  digest=$(vloud_slot_sums_digest "$slot")
  maxm=$(vloud_tree_migration_names "$slot" | tail -n1)
  printf '{"slot_version":"%s","retained_at":"%s","sums_digest":"%s","max_migration":"%s","by":"bootstrap"}\n' \
    "$version" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$digest" "$maxm" > "$slot/.vloud-slot.json" 2>/dev/null || true
}

# The managed releases root is the parent of the live install dir.
vloud_slot_name_ok() {
  local base; base=$(basename "$VLOUD_INSTALL_DIR")
  [[ "$1" =~ ^${base//./\\.}\.(pre-upgrade|pre-rollback)-[0-9]{8}T[0-9]{6}Z$ ]]
}

# vloud_validate_rollback_slot <path>   (dies VLOUD_EXIT_POLICY on refusal)
# Sets VLOUD_SLOT_VERSION.
vloud_validate_rollback_slot() {
  local slot="$1" root real
  root=$(cd "$(dirname "$VLOUD_INSTALL_DIR")" 2>/dev/null && pwd -P) || die "managed releases root is missing" "$VLOUD_EXIT_POLICY"
  [[ -n "$slot" && -d "$slot" && ! -L "$slot" ]] || die "rollback target '$slot' is not a retained release directory" "$VLOUD_EXIT_POLICY"
  real=$(cd "$slot" 2>/dev/null && pwd -P) || die "rollback target '$slot' is unreadable" "$VLOUD_EXIT_POLICY"
  [[ "$(dirname "$real")" == "$root" ]] \
    || die "rollback target '$slot' is outside the managed releases root $root — refusing" "$VLOUD_EXIT_POLICY"
  vloud_slot_name_ok "$(basename "$real")" \
    || die "rollback target '$(basename "$real")' is not a retained ${VLOUD_INSTALL_DIR##*/}.pre-upgrade-<ts> release — refusing" "$VLOUD_EXIT_POLICY"
  # Owned by whoever is running the installer (root in production): a tree
  # another account could have written is not a trustworthy rollback target.
  [[ "$(stat -c %u "$real")" == "$EUID" ]] || die "rollback target $real is not owned by the installing user (uid $EUID) — refusing" "$VLOUD_EXIT_POLICY"
  local mode; mode=$(stat -c %a "$real")
  (( (8#$mode & 8#002) == 0 )) || die "rollback target $real is world-writable — refusing" "$VLOUD_EXIT_POLICY"

  # Integrity: every SHA256SUMS the slot carries must verify, and the sums files
  # themselves must be the ones recorded when the slot was retained.
  local meta="$real/.vloud-slot.json" f verified=0
  if [[ -f "$meta" ]]; then
    local want got
    want=$(_vloud_json_str "$(<"$meta")" sums_digest); got=$(vloud_slot_sums_digest "$real")
    [[ -z "$want" || "$want" == "$got" ]] \
      || die "rollback slot $real: its SHA256SUMS changed since it was retained — refusing" "$VLOUD_EXIT_POLICY"
  fi
  for f in "$real" "$real/packages/server/dist" "$real/packages/web/dist"; do
    if [[ -f "$f/SHA256SUMS" ]]; then
      if ( cd "$f" && sha256sum -c --quiet --ignore-missing SHA256SUMS >/dev/null 2>&1 ); then
        verified=$((verified+1))
      else
        emit_progress rollback-verify failed "mismatch in $f/SHA256SUMS"
        die "rollback slot integrity FAILED in $f — refusing" "$VLOUD_EXIT_POLICY"
      fi
    fi
  done

  VLOUD_SLOT_VERSION=""
  [[ -f "$real/VERSION" ]] && VLOUD_SLOT_VERSION=$(tr -d '[:space:]' <"$real/VERSION")
  vloud_valid_version "$VLOUD_SLOT_VERSION" || VLOUD_SLOT_VERSION=""

  # Policy: a tree that cannot prove its integrity, or that sits below the
  # floor, needs the signed recovery marker (action=rollback, named version).
  local why=""
  if (( verified == 0 )); then why="rollback slot $real carries no SHA256SUMS to verify"; fi
  if [[ -n "$VLOUD_SLOT_VERSION" ]]; then
    # The channel comes from files only (never an env var: it selects WHICH
    # signed floor applies): the slot's own record, its release manifest, the
    # live tree's record, then the state file the last install wrote.
    local ch="" cf
    for cf in "$real/.vloud-release.json" "$real/release-manifest.json" \
              "$VLOUD_INSTALL_DIR/.vloud-release.json" "$VLOUD_RELEASE_STATE_DIR/installed-channel.json"; do
      [[ -f "$cf" ]] || continue
      ch=$(_vloud_json_str "$(cat "$cf" 2>/dev/null)" channel)
      [[ -n "$ch" ]] && break
    done
    if vloud_valid_channel "$ch"; then
      vloud_release_index_load "$ch"
      if (( IDX_PRESENT )); then
        local v
        for v in $IDX_REVOKED; do
          [[ "$v" == "$VLOUD_SLOT_VERSION" ]] && die "rollback slot version $v is REVOKED in the signed $ch index — refusing" "$VLOUD_EXIT_POLICY"
        done
        if [[ -n "$IDX_MIN_SUPPORTED" ]] && vloud_ver_lt "$VLOUD_SLOT_VERSION" "$IDX_MIN_SUPPORTED"; then
          why="${why:+$why; }rollback slot version $VLOUD_SLOT_VERSION is below the signed minimum supported version $IDX_MIN_SUPPORTED"
        fi
      fi
    fi
  else
    why="${why:+$why; }rollback slot has no readable VERSION"
  fi
  # P2-OPS-05: the live DB is carried into the slot, so the slot's code must know
  # every migration the live release applied.  Folded into the same `why` so ONE
  # signed recovery marker (single-use) covers every reason.
  local schema_why=""
  if ! schema_why=$(vloud_rollback_schema_problem "$VLOUD_INSTALL_DIR" "$real"); then
    emit_progress rollback-verify failed "schema: $schema_why"
    why="${why:+$why; }$schema_why"
  fi
  [[ -n "$why" ]] || return 0
  if [[ -n "$VLOUD_SLOT_VERSION" ]] && vloud_recovery_authorizes "$VLOUD_SLOT_VERSION" rollback; then
    warn "$why — proceeding: $RECOVERY_NOTE"
    return 0
  fi
  emit_progress rollback-verify failed "$why"
  die "$why. Refusing. (${RECOVERY_NOTE:-no recovery marker}; see docs/operations/release-recovery-marker.md)" "$VLOUD_EXIT_POLICY"
}

# vloud_resolve_rollback_slot : VLOUD_ROLLBACK_TO is empty (newest slot), a
# version, a slot name, or a path — but whatever it is, the result is validated
# by vloud_validate_rollback_slot, so only a retained slot can ever come out.
# Sets VLOUD_RESOLVED_SLOT.
vloud_resolve_rollback_slot() {
  local to="${VLOUD_ROLLBACK_TO:-}" root slot="" d
  root=$(dirname "$VLOUD_INSTALL_DIR")
  VLOUD_RESOLVED_SLOT=""
  if [[ -z "$to" ]]; then
    slot=$(find "$root" -maxdepth 1 -type d -name "$(basename "$VLOUD_INSTALL_DIR").pre-upgrade-*" 2>/dev/null | sort -r | head -n1)
    [[ -n "$slot" ]] || die "no .pre-upgrade-* slots available — nothing to roll back to" 3
  elif [[ "$to" == */* ]]; then
    slot="$to"                                   # explicit path — validated below
  elif [[ -d "$root/$to" ]]; then
    slot="$root/$to"                             # slot directory name
  else
    while IFS= read -r d; do                     # version label
      if [[ -f "$d/VERSION" && "$(tr -d '[:space:]' <"$d/VERSION")" == "$to" ]]; then slot="$d"; break; fi
    done < <(find "$root" -maxdepth 1 -type d \( -name "$(basename "$VLOUD_INSTALL_DIR").pre-upgrade-*" -o -name "$(basename "$VLOUD_INSTALL_DIR").pre-rollback-*" \) 2>/dev/null | sort -r)
    [[ -n "$slot" ]] || die "no rollback slot matches --to '$to'" 3
  fi
  vloud_validate_rollback_slot "$slot"
  VLOUD_RESOLVED_SLOT="$slot"
}

# ── licensing-exit guard (F-06) ─────────────────────────────────────────────
vloud_last_engine_exit_status() {
  systemctl show vloud.service -p ExecMainStatus --value 2>/dev/null | head -n1
}
vloud_exit_is_licensing() { [[ " $VLOUD_LICENSING_EXIT_CODES " == *" ${1:-x} "* ]]; }

# Automatic rollback must never be a way around a licensing refusal.
vloud_guard_automatic_rollback() {
  local st; st=$(vloud_last_engine_exit_status || true)
  if vloud_exit_is_licensing "$st"; then
    emit_progress rollback-refused failed "engine exit $st is a licensing refusal"
    die "refusing AUTOMATIC rollback: vloud.service last exited with status $st, a licensing refusal. Rolling back would only start an older release that may not enforce it. Fix the licence (vloud-rescue license-status) or run an operator rollback explicitly." "$VLOUD_EXIT_POLICY"
  fi
}

# vloud_handle_unhealthy_upgrade <rollback-slot>
# The new release did not become healthy.  A licensing refusal (engine exit
# 11-15/21/22) is the new release doing its job: restoring the previous tree
# would run a release that may not enforce it, so the new release STAYS and the
# operator is told why.  Anything else is a broken release: restore the slot.
# Never returns.
vloud_handle_unhealthy_upgrade() {
  local rollback="$1" eng_exit
  emit_progress healthcheck failed "engine did not become healthy in 30s"
  do_dump_failure_journal
  eng_exit=$(vloud_last_engine_exit_status || true)
  if vloud_exit_is_licensing "$eng_exit"; then
    emit_progress auto-rollback skipped "engine exit $eng_exit is a licensing refusal"
    fail "the new release refused to start for a LICENSING reason (engine exit $eng_exit) — NOT rolling back to an older release"
    fail "inspect with: vloud-rescue license-status; fix the licence, then: systemctl start vloud.service"
    fail "an operator can still roll back explicitly: bootstrap.sh --rollback (it applies the version floor)"
    die "upgrade installed ${VLOUD_VERSION_TO:-the new release} but the engine refused to start (licensing exit $eng_exit); previous release kept at $rollback" "$VLOUD_EXIT_LICENSING"
  fi
  fail "new version failed health-check — rolling back"
  emit_progress auto-rollback started "restoring $rollback"
  do_stop_services
  rm -rf "$VLOUD_INSTALL_DIR"
  mv -T "$rollback" "$VLOUD_INSTALL_DIR"
  vloud_retarget_units
  systemctl daemon-reload 2>/dev/null || true
  if ! do_start_services_with_healthcheck; then
    do_dump_failure_journal
    emit_progress auto-rollback failed "rollback restart failed"
    die "rollback also failed — manual intervention required. See ${LOG_FILE:-the install log} and journalctl -u vloud. To recover: systemctl reset-failed vloud.service && systemctl start vloud.service" 5
  fi
  emit_progress auto-rollback ok "previous version restored"
  die "upgrade aborted, previous version restored" 6
}
# ── licensing bypass switches in /etc/vloud.env (F-02 follow-up) ─────────────
# A release engine REFUSES TO START (exit 21) when any of these is set
# (packages/server/src/license/boot-guards.ts REFUSED_ENV — keep this list equal to it). Older installers
# advertised VLOUD_REGISTRATION_DISABLE / VLOUD_LICENSE_PULL_DISABLE as "offline mode", so an upgraded host can
# carry them in /etc/vloud.env and would crash-loop on the first boot of the new release. On upgrade/repair the
# file is scrubbed: the refused lines are removed (after a backup of the original), the operator is told which
# NAMES were removed (never the values), and air-gapped hosts are pointed at the signed offline licence
# (activation_mode 'offline', ADR-014), which needs neither switch.
VLOUD_REFUSED_ENV_FLAGS="VLOUD_DEV_LICENSE VLOUD_HEARTBEAT_STUB VLOUD_SKIP_INTEGRITY VLOUD_ACTIVATE_STUB VLOUD_LICENSE_PULL_DISABLE VLOUD_REGISTRATION_DISABLE"
VLOUD_REFUSED_ENV_VALUES="VLOUD_LICENSE_PUBKEY_PIN VLOUD_LICENSE_CLAIMS_JSON"
# refused even by a development build, under NODE_ENV=production
VLOUD_REFUSED_ENV_LEGACY="VLOUD_DEV_LICENSE VLOUD_HEARTBEAT_STUB VLOUD_SKIP_INTEGRITY"

# Will the engine in <tree> refuse the bypass switches? A SEA tree always does. A legacy tree does when its
# compiled license/build-profile.js says anything but 'development'; a tree with no such file predates the
# profile (its engine does not refuse them) and must not have the operator's file rewritten on its behalf.
vloud_tree_is_release_build() {
  local tree="$1" bp
  vloud_tree_is_sea "$tree" && return 0
  bp="$tree/packages/server/dist/license/build-profile.js"
  [[ -f "$bp" ]] || return 1
  grep -qE "BUILD_PROFILE *= *['\"]development['\"]" "$bp" && return 1
  return 0
}

# vloud_scrub_refused_env <env file to edit in place> <release tree> [<file to back up first; default the same file>]
# Prints nothing with values in it. Sets VLOUD_SCRUBBED_ENV (array of names). Returns 0 even when nothing changed.
vloud_scrub_refused_env() {
  local file="$1" tree="$2" backup_of="${3:-$1}"
  VLOUD_SCRUBBED_ENV=()
  [[ -f "$file" ]] || return 0
  local release=0 node_prod=0 url_fixed=0 line name val lc tmp
  vloud_tree_is_release_build "$tree" && release=1
  # NODE_ENV as the file sets it (last assignment wins, like systemd's EnvironmentFile)
  while IFS= read -r line || [[ -n "$line" ]]; do
    if [[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?NODE_ENV[[:space:]]*=(.*)$ ]]; then
      val=${BASH_REMATCH[2]}; val=${val//[[:space:]\"\']/}
      [[ "$val" == production ]] && node_prod=1 || node_prod=0
    fi
  done < "$file"
  (( release == 1 || node_prod == 1 )) || return 0

  tmp="$file.scrub.$$"
  : > "$tmp" || return 0
  while IFS= read -r line || [[ -n "$line" ]]; do
    if [[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$ ]]; then
      name=${BASH_REMATCH[2]}; val=${BASH_REMATCH[3]}
      val=${val#"${val%%[![:space:]]*}"}; val=${val%"${val##*[![:space:]]}"}
      val=${val#[\"\']}; val=${val%[\"\']}
      val=${val#"${val%%[![:space:]]*}"}; val=${val%"${val##*[![:space:]]}"}
      lc=${val,,}
      if [[ " $VLOUD_REFUSED_ENV_FLAGS " == *" $name "* ]] && [[ "$lc" == 1 || "$lc" == true || "$lc" == yes || "$lc" == on ]] \
         && { (( release == 1 )) || [[ " $VLOUD_REFUSED_ENV_LEGACY " == *" $name "* ]]; }; then
        VLOUD_SCRUBBED_ENV+=("$name"); continue
      fi
      if [[ " $VLOUD_REFUSED_ENV_VALUES " == *" $name "* ]] && [[ -n "$val" ]] && (( release == 1 )); then
        VLOUD_SCRUBBED_ENV+=("$name"); continue
      fi
      # A release engine talks to https://license.vloud.app ONLY (services/cloud-endpoint.ts) and exits 21 for any other
      # value (http, another host, credentials). The reconcile step keeps an operator's "custom" URL, so an upgraded host
      # that pointed at a self-hosted / staging server would crash-loop on the first boot of the release. Point it at
      # the only endpoint the release can use; the original line is in the backup. (This forces the REAL cloud: it can
      # never loosen anything.)
      if [[ "$name" == VLOUD_LICENSE_SERVER_URL ]] && [[ -n "$val" ]] && (( release == 1 )) \
         && ! [[ "${val,,}" =~ ^https://license\.vloud\.app/?$ ]]; then
        printf 'VLOUD_LICENSE_SERVER_URL=https://license.vloud.app\n' >> "$tmp"
        url_fixed=1; continue
      fi
    fi
    printf '%s\n' "$line" >> "$tmp"
  done < "$file"

  if (( ${#VLOUD_SCRUBBED_ENV[@]} == 0 && url_fixed == 0 )); then rm -f "$tmp"; return 0; fi
  local bak="$backup_of.pre-scrub-$(date -u +%Y%m%dT%H%M%SZ)"
  if ! cp -p "$backup_of" "$bak" 2>/dev/null; then
    # no backup, no edit: never delete an operator's line without a copy of the file it was in
    rm -f "$tmp"; warn "could not back up $backup_of — leaving the licensing bypass switch(es) ${VLOUD_SCRUBBED_ENV[*]} in place; the engine will refuse to start until they are removed"
    VLOUD_SCRUBBED_ENV=(); return 0
  fi
  chmod --reference="$file" "$tmp" 2>/dev/null || chmod 0640 "$tmp"
  mv -f "$tmp" "$file"
  if (( ${#VLOUD_SCRUBBED_ENV[@]} > 0 )); then
    warn "REMOVED licensing bypass switch(es) from $(basename "$backup_of"): ${VLOUD_SCRUBBED_ENV[*]}"
    warn "  a release engine refuses to start (exit 21) with them set; air-gapped / offline hosts use a signed OFFLINE licence instead (no switch needed)"
  fi
  if (( url_fixed == 1 )); then
    warn "CORRECTED VLOUD_LICENSE_SERVER_URL in $(basename "$backup_of") to https://license.vloud.app (a release engine talks to that host only and exits 21 otherwise)"
  fi
  warn "  original kept at $bak (root-only; values were not printed)"
  emit_progress env-scrub warn "removed ${VLOUD_SCRUBBED_ENV[*]:-nothing}$( (( url_fixed == 1 )) && echo ' corrected VLOUD_LICENSE_SERVER_URL')" 2>/dev/null || true
  return 0
}

# ── engine health verdict (P2-OPS-01) ───────────────────────────────────────
# /api/health answers 503 when a required dependency (DB read/write, Redis
# outside safe-coexist) is down, but the installer does not trust the status code
# alone: an engine that is listening yet reports "degraded" must not pass the
# upgrade gate (that is what lets the auto-rollback fire).  Both must hold:
# HTTP 200 and a top-level "status":"healthy" in the body.  (Nested checks use
# "ok"/"optional", so the match cannot be satisfied by a sub-check.)
# vloud_health_accepts <http_code> <body>
vloud_health_accepts() {
  [[ "${1:-}" == "200" ]] || return 1
  grep -Eq '"status"[[:space:]]*:[[:space:]]*"healthy"' <<<"${2:-}"
}
# vloud_probe_engine_health <max-time-seconds>  → 0 when accepted; sets
# VLOUD_HEALTH_CODE / VLOUD_HEALTH_BODY for the failure message.
vloud_probe_engine_health() {
  local out
  out=$(curl -s -w $'\n%{http_code}' --max-time "${1:-2}" "http://127.0.0.1:$VLOUD_PORT/api/health" 2>/dev/null || true)
  VLOUD_HEALTH_CODE="${out##*$'\n'}"; [[ "$VLOUD_HEALTH_CODE" =~ ^[0-9]{3}$ ]] || VLOUD_HEALTH_CODE=000
  VLOUD_HEALTH_BODY="${out%$'\n'*}"
  vloud_health_accepts "$VLOUD_HEALTH_CODE" "$VLOUD_HEALTH_BODY"
}

# ─── END release-policy ─────────────────────────────────────────────────────

# ─── BEGIN engine-units ─────────────────────────────────────────────────────
# Unit rendering that depends on the release FORMAT (legacy dist tree vs SEA
# binary).  A SEA tarball has no dist/workers/*.js: the job worker and the
# scheduler are the SAME binary started with VLOUD_SEA_ROLE=<role> (the SEA
# entry dispatches on it, see build/scripts/build-sea-engine.ts).

# vloud_engine_unit_exec <role> <format>  → the three unit lines for that role
vloud_engine_unit_exec() {
  local role="$1" fmt="$2"
  if [[ "$fmt" == sea ]]; then
    printf 'WorkingDirectory=%s\n' "$VLOUD_INSTALL_DIR"
    [[ "$role" == engine ]] || printf 'Environment=VLOUD_SEA_ROLE=%s\n' "$role"
    printf 'ExecStart=%s/vloud-engine\n' "$VLOUD_INSTALL_DIR"
  else
    printf 'WorkingDirectory=%s/packages/server\n' "$VLOUD_INSTALL_DIR"
    case "$role" in
      engine)     printf 'ExecStart=/usr/bin/node dist/index.js\n' ;;
      job-worker) printf 'ExecStart=/usr/bin/node dist/workers/job-worker.js\n' ;;
      scheduler)  printf 'ExecStart=/usr/bin/node dist/workers/scheduler.js\n' ;;
    esac
  fi
}

# Make the installed units match the tree now in $VLOUD_INSTALL_DIR and make
# sure vloud.service never restarts on a licensing refusal.  Idempotent; used by
# the upgrade and rollback flows (units live outside the swapped tree, so a
# format change — legacy <-> SEA — would otherwise leave them pointing at a
# binary that is not there).
vloud_retarget_units() {
  local fmt=legacy role unit
  vloud_tree_is_sea "$VLOUD_INSTALL_DIR" && fmt=sea
  for role in engine job-worker scheduler; do
    case "$role" in
      engine)     unit="$SYSTEMD_UNIT" ;;
      job-worker) unit="$SYSTEMD_WORKER" ;;
      scheduler)  unit="$SYSTEMD_SCHEDULER" ;;
    esac
    [[ -f "$unit" ]] || continue
    local exec_lines wd env_line start
    exec_lines=$(vloud_engine_unit_exec "$role" "$fmt")
    wd=$(printf '%s\n' "$exec_lines" | sed -n 's/^WorkingDirectory=//p')
    start=$(printf '%s\n' "$exec_lines" | sed -n 's/^ExecStart=//p')
    env_line=$(printf '%s\n' "$exec_lines" | sed -n 's/^Environment=//p')
    # shellcheck disable=SC2016
    sed -i -e '/^Environment=VLOUD_SEA_ROLE=/d' \
           -e "s|^WorkingDirectory=.*|WorkingDirectory=${wd}|" \
           -e "s|^ExecStart=.*|ExecStart=${start}|" "$unit"
    if [[ -n "$env_line" ]]; then
      sed -i "/^WorkingDirectory=/a Environment=${env_line}" "$unit"
    fi
  done
  vloud_ensure_restart_prevent "$SYSTEMD_UNIT"
  return 0
}

# F-06: licensing refusals must not be retried by systemd (each retry is a
# chance for OnFailure to roll back to an older, possibly unenforcing release).
vloud_ensure_restart_prevent() {
  local unit="$1" want="RestartPreventExitStatus=$VLOUD_LICENSING_EXIT_CODES"
  [[ -f "$unit" ]] || return 0
  if grep -q '^RestartPreventExitStatus=' "$unit"; then
    sed -i "s|^RestartPreventExitStatus=.*|${want}|" "$unit"
  else
    sed -i "/^Restart=on-failure/a ${want}" "$unit"
  fi
  return 0
}
# ─── END engine-units ───────────────────────────────────────────────────────

# ─── BEGIN acme-email ───────────────────────────────────────────────────────
# The ACME contact. Mirrors packages/server/src/services/acme-config.ts (the
# engine re-validates and ignores an unusable value, so this only keeps an
# obviously wrong address out of /etc/vloud.env). Prints the reason and returns
# 1 when the address cannot be used; 0 when it can.
vloud_acme_email_problem() {
  local e="${1:-}" lp domain tld
  [[ -n "$e" ]] || { echo "it is empty"; return 1; }
  if [[ ! "$e" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]] || [[ "$e" == *..* ]]; then
    echo "it is not a valid e-mail address"; return 1
  fi
  lp="${e%@*}"; lp="${lp,,}"; domain="${e##*@}"; domain="${domain,,}"; tld="${domain##*.}"
  case "$tld" in
    local|localdomain|localhost|invalid|test|example|internal)
      echo "@$domain can never receive mail (.$tld is a reserved name)"; return 1 ;;
  esac
  case ".$domain." in
    *.example.*) echo "@$domain is an example domain"; return 1 ;;
  esac
  case "$lp" in
    noreply|no-reply|no_reply|no.reply|donotreply|do-not-reply|do_not_reply|do.not.reply|changeme|change-me|nobody)
      echo "$lp@ is a placeholder mailbox, not someone who reads certificate notices"; return 1 ;;
  esac
  return 0
}

# Mode gate for an unusable VLOUD_ACME_EMAIL. Only the modes that WRITE the env file (fresh / upgrade / repair /
# force-clean, i.e. auto) refuse it; --rollback, --doctor, --uninstall and --restore never read it, and a bad value
# inherited from the environment must not block them (an operator rolling back a broken release is the case that
# matters). Warns (and clears the value) for those modes; for the others returns 1 with the reason in
# VLOUD_ACME_GATE_MSG. Not run in a subshell: it must be able to clear VLOUD_ACME_EMAIL.
vloud_acme_email_gate() {
  local mode="${1:-}" reason
  [[ -n "${VLOUD_ACME_EMAIL:-}" ]] || return 0
  reason=$(vloud_acme_email_problem "$VLOUD_ACME_EMAIL") && return 0
  case "$mode" in
    rollback|doctor|uninstall|restore)
      warn "ignoring VLOUD_ACME_EMAIL '$VLOUD_ACME_EMAIL' ($reason); this mode does not use it"
      VLOUD_ACME_EMAIL=""
      return 0 ;;
  esac
  VLOUD_ACME_GATE_MSG="--acme-email '$VLOUD_ACME_EMAIL' is not usable: $reason"
  return 1
}

# Interactive only: a terminal on stdin AND stdout, not --yes, not
# VLOUD_NONINTERACTIVE. A curl-pipe-bash install has no tty on stdin and never
# prompts. Enter (or 60 s) skips; an unusable answer is reported and skipped.
vloud_prompt_acme_email() {
  [[ -z "${VLOUD_ACME_EMAIL:-}" ]] || return 0
  [[ "${VLOUD_ASSUME_YES:-0}" != 1 && -z "${VLOUD_NONINTERACTIVE:-}" && -t 0 && -t 1 ]] || return 0
  local answer reason
  read -r -t 60 -p "  Let's Encrypt contact e-mail (a real mailbox; Enter to skip): " answer || answer=""
  [[ -n "$answer" ]] || return 0
  if reason=$(vloud_acme_email_problem "$answer"); then VLOUD_ACME_EMAIL="$answer"
  else warn "not using '$answer': $reason"; fi
}

# Write VLOUD_ACME_EMAIL into an env file being assembled (<file>): an explicit
# value replaces the stored one; no value leaves whatever the file has.
vloud_apply_acme_email() {
  local f="$1"
  if [[ -n "${VLOUD_ACME_EMAIL:-}" ]]; then
    if grep -q '^VLOUD_ACME_EMAIL=' "$f"; then
      sed -i -E "s|^VLOUD_ACME_EMAIL=.*|VLOUD_ACME_EMAIL=${VLOUD_ACME_EMAIL}|" "$f"
    else
      printf 'VLOUD_ACME_EMAIL=%s\n' "$VLOUD_ACME_EMAIL" >> "$f"
    fi
  fi
  if ! grep -qE '^VLOUD_ACME_EMAIL=.+' "$f"; then
    warn "no ACME contact e-mail set: certificate issuance will refuse to run until one is configured (re-run with --acme-email you@yourdomain.tld, or set it on the SSL page)"
  fi
}
# ─── END acme-email ─────────────────────────────────────────────────────────

# ─── Mode detection + dispatch ───
#
# This block decides what kind of install we're running. After this,
# the script either dispatches to a mode-specific function (and exits)
# or falls through to the linear fresh-install phases 1-6 below.
MODE="${VLOUD_INSTALL_MODE:-}"
# Opt-in runtimes. Empty unless the operator asked for them.
VLOUD_WITH_DOTNET="${VLOUD_WITH_DOTNET:-0}"
for arg in "$@"; do
  case "$arg" in
    --upgrade)              MODE=upgrade ;;
    --rollback)             MODE=rollback ;;
    --repair)               MODE=repair ;;
    --doctor|--health)      MODE=doctor ;;
    --force-clean-install)  MODE=force-clean ;;
    --uninstall)            MODE=uninstall ;;
    --yes|-y)               VLOUD_ASSUME_YES=1 ;;
    --fresh)                MODE=fresh ;;
    # Cold restore of a platform bundle (scripts/vloud-restore.sh).
    --restore=*)            MODE=restore; VLOUD_RESTORE_BUNDLE="${arg#--restore=}" ;;
    --restore)              MODE=restore ;;
    # OPTIONAL .NET runtime for VloudDeploy@1 `runtime: dotnet` apps.
    #
    # Opt-in on purpose: the ASP.NET Core runtime is a few hundred megabytes,
    # most hosts will never run a .NET app, and the engine NEVER installs a
    # runtime implicitly during a deploy — a deploy that apt-installs an SDK on
    # a shared host is a surprise, not a feature. A dotnet deploy on a host
    # without it fails with an actionable message naming this flag.
    --with-dotnet)          VLOUD_WITH_DOTNET=1 ;;
    # Let's Encrypt contact (written to /etc/vloud.env as VLOUD_ACME_EMAIL).
    --acme-email=*)         VLOUD_ACME_EMAIL="${arg#--acme-email=}" ;;
    --acme-email)           shift ; VLOUD_ACME_EMAIL="${1:-}" ; continue ;;
    # PR1: per-version targeting. When absent, behaviour is unchanged
    # (resolve to install.vloud.app/releases/latest/…).
    --version=*)            VLOUD_TARGET_VERSION="${arg#--version=}" ;;
    --version)              shift ; VLOUD_TARGET_VERSION="${1:-}" ; continue ;;
    # PR1: rollback target. When set with MODE=rollback, restore that
    # specific .pre-upgrade-* slot; otherwise restore the newest.
    --to=*)                 VLOUD_ROLLBACK_TO="${arg#--to=}" ;;
    --to)                   shift ; VLOUD_ROLLBACK_TO="${1:-}" ; continue ;;
    # PR1: engine apply-run correlation. Tags every progress event
    # so the engine can match JSONL rows to its apply-run row.
    --apply-run-id=*)       VLOUD_APPLY_RUN_ID="${arg#--apply-run-id=}" ;;
    --apply-run-id)         shift ; VLOUD_APPLY_RUN_ID="${1:-}" ; continue ;;
    # F-06: the ONLY way past the version floor — a release-key-signed
    # recovery marker (docs/operations/release-recovery-marker.md). It names
    # one target version + action, expires, and is single-use.
    --recovery-marker=*)    VLOUD_RECOVERY_MARKER="${arg#--recovery-marker=}" ;;
    --recovery-marker)      shift ; VLOUD_RECOVERY_MARKER="${1:-}" ; continue ;;
    # Phase E (2026-05-14): explicit "this rollback was fired by
    # systemd, not by an operator click". Accepted but currently
    # behaves identically to plain --rollback. Mostly here so the
    # systemd unit's ExecStart doesn't get rejected as unknown args.
    --automatic)            VLOUD_ROLLBACK_AUTOMATIC=1 ;;
    --help|-h)
      sed -n '2,94p' "$0" | sed 's/^# \{0,1\}//'
      exit 0
      ;;
    *)
      # `--restore <bundle>`: the bundle is the next bare argument.
      if [[ $MODE == restore && -z ${VLOUD_RESTORE_BUNDLE:-} && $arg != -* ]]; then
        VLOUD_RESTORE_BUNDLE="$arg"
      fi
      # Otherwise ignore unknown args here; phase scripts may consume their own.
      ;;
  esac
done

# An explicit ACME contact (flag or environment) must be usable; refuse early rather than write a
# placeholder that every certificate request would then trip over.
VLOUD_ACME_GATE_MSG=""
if ! vloud_acme_email_gate "$MODE"; then
  die "$VLOUD_ACME_GATE_MSG" 2
fi

# --version / --to end up in URLs and paths: only a plain version string may.
if [[ -n "$VLOUD_TARGET_VERSION" ]] && ! vloud_valid_version "$VLOUD_TARGET_VERSION"; then
  die "--version '$VLOUD_TARGET_VERSION' is not a valid version string" 2
fi

# PR1: when --version=<v> is passed, override the default URL with
# the per-version path. Operators who already set VLOUD_RELEASE_URL
# explicitly (private mirror / offline-mirror customers) win.
#
# The release blob stores per-version artifacts under a "v"-prefixed
# directory (releases/v<version>/…) — matching the git tag (v*) the CI
# publishes from and the signed manifest's `image` URL. The tarball
# FILE itself is unprefixed (vloud-<version>.tar.gz). Without the "v"
# on the directory the versioned URL 404s and every fleet self-update
# dies at download (only the unversioned latest/ path worked).
if [[ -n "$VLOUD_TARGET_VERSION" ]] && [[ "$VLOUD_RELEASE_URL" == "https://install.vloud.app/releases/latest/vloud-latest.tar.gz" ]]; then
  VLOUD_RELEASE_URL="https://install.vloud.app/releases/v${VLOUD_TARGET_VERSION}/vloud-${VLOUD_TARGET_VERSION}.tar.gz"
fi

# Auto-detect if no explicit mode.
if [[ -z "$MODE" ]]; then
  if vloud_install_present; then
    MODE=upgrade
    say "existing install detected at $VLOUD_INSTALL_DIR — auto-mode → UPGRADE"
    warn "running upgrade (preserves DB + config). Use --force-clean-install to wipe."
  else
    MODE=fresh
    say "no existing install — auto-mode → FRESH INSTALL"
  fi
fi

# ── mode helpers (defined here so dispatch below can use them) ──

# Backup critical state to /var/lib/vloud/backups/<label>-<ts>.tar.gz
# AND copy /etc/vloud.env. Idempotent + non-destructive.
# Is the pre-upgrade state backup switched on?
#
# OFF unless the operator turned it on, deliberately. The archive is the whole
# of /var/lib/vloud — ~10 GB on a host with real apps — written with no space
# check, so two upgrades in one evening cost 20 GB and put the host below the
# deploy floor that exists to prevent exactly that. Deploys then refused
# immediately after an upgrade had "succeeded".
#
# Read straight out of the engine's SQLite file rather than through its API,
# because this runs mid-upgrade when the engine may be stopped. Any failure —
# no sqlite3 binary, no database yet on a fresh install, an unreadable row —
# answers "off", which is also the default.
state_backup_enabled() {
  local db=/opt/vloud/packages/server/vloud.db
  command -v sqlite3 >/dev/null 2>&1 || return 1
  [[ -f $db ]] || return 1
  local v
  v=$(sqlite3 "$db" "SELECT value FROM system_settings WHERE key='upgrade.state_backup_enabled';" 2>/dev/null) || return 1
  [[ $v == "true" ]]
}

# Would writing the archive push the host under the deploy floor?
#
# The floor is what disk-budget.ts reserves so a deploy can never fill the
# disk: max(VLOUD_DISK_FLOOR_BYTES, min(10% of the fs, VLOUD_DISK_FLOOR_MAX_BYTES)).
# The backup was the one large writer that ignored it. Estimating the archive
# as the current size of the state tree is deliberately pessimistic — it
# compresses — and pessimistic is the correct direction for a refusal.
state_backup_would_breach_floor() {
  local avail_kb floor_gib=20 est_kb
  avail_kb=$(df -Pk /var/lib/vloud 2>/dev/null | awk 'NR==2{print $4}') || return 1
  [[ -n ${avail_kb:-} ]] || return 1

  # Estimate from the LAST archive, not from the tree.
  #
  # The tree is ~90 GB on this host and the archive is 9.7 GB — it is tarred
  # with exclusions and gzipped, so sizing the estimate off `du` overstates it
  # by roughly nine times and would refuse the backup on a host with ample
  # room. The previous archive is the only honest predictor of the next one.
  #
  # With no previous archive (a first upgrade) fall back to a quarter of the
  # tree: still generous against the measured ~16% ratio, but not so large
  # that it refuses unconditionally.
  est_kb=$(ls -1t /var/lib/vloud/backups/*.tar.gz 2>/dev/null | head -1 \
            | xargs -r du -sk 2>/dev/null | awk '{print $1}')
  if [[ -z ${est_kb:-} ]]; then
    local tree_kb
    tree_kb=$(du -sk /var/lib/vloud 2>/dev/null | awk '{print $1}')
    [[ -n ${tree_kb:-} ]] || return 1
    est_kb=$(( tree_kb / 4 ))
  fi

  local floor_kb=$(( floor_gib * 1024 * 1024 ))
  (( avail_kb - est_kb < floor_kb ))
}

do_backup_state() {
  local label="${1:-pre-upgrade}"
  local ts; ts=$(date -u +%Y%m%dT%H%M%SZ)
  local backup_dir=/var/lib/vloud/backups
  mkdir -p "$backup_dir"
  local backup="$backup_dir/$label-$ts.tar.gz"

  # Progress goes to STDERR, deliberately.
  #
  # This function returns the archive path on stdout, so every caller writes
  # `do_backup_state ... >/dev/null` — which silenced the progress lines too.
  # The result, measured on a live host: seven minutes of complete silence
  # immediately after "PostgreSQL ready", with the operator reasonably
  # concluding the upgrade had hung. Interrupting an upgrade at this point is
  # genuinely dangerous, so the silence was the bug, not the duration.
  say "backing up critical state → $backup" >&2
  say "this archives the engine's state tree and can take a few minutes on a host with large apps" >&2
  emit_progress backup started "$backup"

  local sources=()
  local db_transform=()
  local snap_dir=""
  [[ -f $ENV_FILE ]]                                    && sources+=("$ENV_FILE")
  [[ -d /etc/vloud ]]                                   && sources+=("/etc/vloud")
  # The engine DB is a LIVE WAL database: tarring the file copies a main
  # file that is missing every page still in vloud.db-wal (and can tear
  # mid-checkpoint), because this runs before the services stop. Archive a
  # consistent online-backup snapshot instead, stored under the DB's own
  # path so anything that reads these archives finds it where it always was.
  local live_db="$VLOUD_INSTALL_DIR/packages/server/vloud.db"
  if [[ -f $live_db ]]; then
    # A failed mktemp (full /var/tmp) must not abort the upgrade under set -e:
    # it just means falling back to the live file + WAL below.
    snap_dir=$(mktemp -d /var/tmp/vloud-state-XXXXXX 2>/dev/null) || snap_dir=""
    if [[ -n $snap_dir ]] && snapshot_engine_db "$live_db" "$snap_dir/vloud.db"; then
      sources+=("$snap_dir/vloud.db")
      db_transform=(--transform="s|^${snap_dir#/}/vloud.db\$|${live_db#/}|")
    else
      warn "could not take a consistent snapshot of $live_db; archiving the live file and its WAL" >&2
      sources+=("$live_db")
      [[ -f $live_db-wal ]] && sources+=("$live_db-wal")
    fi
  fi
  [[ -d /var/lib/vloud ]]                               && sources+=("/var/lib/vloud")
  # nginx vhosts the engine generated (vloud-*.conf in sites-enabled, excluding system app names)
  while IFS= read -r f; do sources+=("$f"); done < <(
    ls /etc/nginx/sites-enabled/vloud-*.conf 2>/dev/null |
    grep -vE '/vloud-(app-|admin|commercial-|marketing|portal|license)' || true
  )

  if [[ ${#sources[@]} -gt 0 ]]; then
    # CRITICAL: exclude the backups dir itself. /var/lib/vloud is a
    # source, and it CONTAINS $backup_dir — without this exclude each
    # new pre-upgrade backup swallows every previous one, so the
    # archives grow exponentially (observed 7M → 24M → … → 8.2G) and
    # eventually fill the disk, which then fails the very next upgrade
    # at the backup stage ("N MB free on /opt"). Also skip large,
    # regenerable/live runtime data that has no business in a config +
    # DB state snapshot (mongodb is a live DB; db-snapshots are derived).
    #
    # node_modules and artifacts are excluded for the same reason, and it
    # is not a small saving: measured on the reference host, node_modules
    # accounted for 5.4 GB of a 6.1 GB apps tree and artifacts for a further
    # 5.0 GB — the bulk of a 2.5 GB archive and most of its seven minutes.
    # Both are rebuilt by a redeploy, and neither is touched by an engine
    # upgrade: the swap replaces /opt/vloud, while this tree stays put. So
    # archiving them bought nothing and cost the whole upgrade window.
    local excludes=(
      --exclude="$backup_dir"
      --exclude=/var/lib/vloud/mongodb
      --exclude=/var/lib/vloud/db-snapshots
      --exclude='node_modules'
      --exclude=/var/lib/vloud/artifacts
    )

    # gzip is single-threaded. pigz is already a dependency on these hosts
    # and uses every core, which on a 12-core box is the difference between
    # minutes and seconds for the same archive.
    local compressor=(gzip)
    if command -v pigz >/dev/null 2>&1; then compressor=(pigz); fi

    local started; started=$(date +%s)
    if tar -cf - --ignore-failed-read "${excludes[@]}" "${db_transform[@]}" "${sources[@]}" 2>/dev/null \
         | "${compressor[@]}" > "$backup"; then
      :
    else
      warn "backup partial — some files unreadable" >&2
    fi
    [[ -n $snap_dir ]] && rm -rf "$snap_dir"
    local elapsed=$(( $(date +%s) - started ))
    ok "backup: $backup ($(du -h "$backup" 2>/dev/null | awk '{print $1}')) in ${elapsed}s" >&2
    emit_progress backup ok "$backup"

    _prune_state_backups "$backup_dir" "$label"
  else
    warn "nothing to back up (no existing state)" >&2
  fi

  echo "$backup"
}

# Consistent copy of a live SQLite database: `sqlite3 .backup` (the online
# backup API — WAL pages included, readers and writers never blocked), else
# the engine's own better-sqlite3 doing the same, else fail so the caller
# falls back to copying the file WITH its WAL. The copy must pass
# PRAGMA integrity_check before it is used.
snapshot_engine_db() {
  local src="$1" dest="$2"
  rm -f "$dest"
  if command -v sqlite3 >/dev/null 2>&1; then
    sqlite3 "$src" ".timeout 30000" ".backup '$dest'" >/dev/null 2>&1 || rm -f "$dest"
  fi
  if [[ ! -s $dest ]] && command -v node >/dev/null 2>&1 \
     && [[ -d $VLOUD_INSTALL_DIR/packages/server/node_modules/better-sqlite3 ]]; then
    SRC="$src" DEST="$dest" node -e '
      const D = require(process.argv[1]);
      const db = new D(process.env.SRC, { readonly: true, fileMustExist: true });
      db.backup(process.env.DEST).then(() => { db.close(); }, (e) => { console.error(e.message); process.exit(1); });
    ' "$VLOUD_INSTALL_DIR/packages/server/node_modules/better-sqlite3" >/dev/null 2>&1 || rm -f "$dest"
  fi
  [[ -s $dest ]] || return 1
  chmod 0600 "$dest"
  if command -v sqlite3 >/dev/null 2>&1; then
    local r; r=$(sqlite3 "$dest" 'PRAGMA integrity_check' 2>&1 | head -1)
    [[ $r == ok ]] || { rm -f "$dest"; return 1; }
  fi
  return 0
}

# Keep the newest few state archives and delete the rest.
#
# Nothing ever removed these. Measured on the reference host: 19 GB of
# pre-upgrade tarballs at 2.5 GB each, on a disk that was 91% full — every
# upgrade added another and brought the next one closer to failing at the
# "needs ~1.5 GB for the staging tree" guard. Retaining a couple of
# generations is the point of the backup; retaining all of them is what
# eventually prevents an upgrade entirely.
_prune_state_backups() {
  local dir="$1" label="$2"
  [[ -d "$dir" ]] || return 0

  # Retention: the environment first, then $ENV_FILE, then the default.
  #
  # The environment variable ALONE is not enough. It is inherited only by an
  # engine-driven upgrade, where this script is a child of vloud.service and
  # picks up its EnvironmentFile. A manual
  # `curl -fsSL https://install.vloud.app | sudo bash -s -- --upgrade` gets a
  # clean environment and silently fell back to 3 -- so the setting looked
  # applied right up until the upgrade where it mattered. Reading the file
  # directly covers both, the same way the licence URL is read further down.
  local keep="${VLOUD_KEEP_STATE_BACKUPS:-}"
  if [[ -z "$keep" && -f "$ENV_FILE" ]]; then
    keep=$(grep -E '^[[:space:]]*VLOUD_KEEP_STATE_BACKUPS=' "$ENV_FILE" 2>/dev/null \
             | tail -1 | cut -d= -f2- \
             | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"//' -e 's/"$//')
  fi
  # A non-numeric or absent value must not become "delete everything".
  [[ "$keep" =~ ^[0-9]+$ ]] || keep=3
  # 0 would delete the archive this very run just wrote.
  if (( keep < 1 )); then keep=1; fi

  local old
  # Newest first, drop the ones we keep, delete the remainder. Sorted by
  # name, which is safe because the timestamp is a fixed-width UTC stamp.
  old=$(ls -1 "$dir"/"$label"-*.tar.gz 2>/dev/null | sort -r | tail -n +$((keep + 1)))
  [[ -n "$old" ]] || return 0

  local freed=0 f
  while IFS= read -r f; do
    [[ -f "$f" ]] || continue
    freed=$(( freed + $(du -m "$f" 2>/dev/null | awk '{print $1}') ))
    rm -f "$f"
  done <<< "$old"

  [[ $freed -gt 0 ]] && ok "pruned older $label archives, freed ${freed} MB (kept newest $keep)" >&2
  return 0
}

# Stop vloud services gracefully. Idempotent.
#
# NOTE: the engine spawns THIS bootstrap as a child of vloud.service.
# Surviving the `systemctl stop vloud.service` below is handled by the
# unit's KillMode=process (see the [Service] block written in
# do_emit_systemd_units) — systemd then signals only the engine's main
# PID on stop, never the whole control group, so this upgrade process
# lives on to finish the swap and restart. Do NOT revert KillMode to
# the default control-group or engine-driven upgrades will kill
# themselves here (dashboard stuck "Installing…").
#
# The job worker is stopped FIRST, while the engine is still serving: it is no
# longer PartOf=vloud.service, and on SIGTERM it waits (bounded by
# VLOUD_PIPELINE_DRAIN_SECONDS, default 600) for in-flight pipeline builds to
# finish before exiting. Stopping the engine first would put the panel down for
# the length of that wait.
do_stop_services() {
  say "stopping vloud services"
  local u
  for u in vloud-job-worker.service vloud-scheduler.service vloud.service; do
    if systemctl is-active --quiet "$u" 2>/dev/null; then
      if [[ "$u" == vloud-job-worker.service ]]; then
        say "  waiting for in-flight pipeline builds (up to $(_vloud_env_value VLOUD_PIPELINE_DRAIN_SECONDS 600)s; a build still running then is re-queued)"
      fi
      systemctl stop "$u" 2>/dev/null && ok "stopped $u" || warn "could not stop $u"
    fi
  done
}

# Start vloud services. Reports health within 30s.
#
# Critical: `systemctl reset-failed` BEFORE each start. Without this,
# a previous failed-start cascade (e.g. the new engine crash-looped
# during an upgrade attempt) leaves the unit in `start-limit-hit`.
# systemd then refuses every subsequent start with "Start request
# repeated too quickly" — including the rollback restart, which is
# exactly the cascading-failure mode that bit the 0.5.1-beta upgrade
# on 188.245.113.223 (2026-05-13).
#
# We also no longer swallow stderr on `systemctl start` — the actual
# systemd refusal message is the single most useful piece of
# forensics when a start fails, and `2>/dev/null` was hiding it.
do_start_services_with_healthcheck() {
  say "starting vloud services"
  systemctl reset-failed vloud.service vloud-job-worker.service vloud-scheduler.service 2>/dev/null || true
  systemctl daemon-reload
  systemctl start vloud.service             || warn "vloud.service start returned $?"
  systemctl start vloud-job-worker.service  || warn "vloud-job-worker.service start returned $?"
  systemctl start vloud-scheduler.service   || warn "vloud-scheduler.service start returned $?"
  local i
  for i in {1..15}; do
    if vloud_probe_engine_health 2; then
      ok "engine health-check passed in ${i}s"
      return 0
    fi
    sleep 2
  done
  fail "engine did not pass health-check within 30s (last: HTTP ${VLOUD_HEALTH_CODE:-000} ${VLOUD_HEALTH_BODY:0:200}) — see journalctl -u vloud"
  return 1
}

# Dump the last lines of vloud.service's journal so operators have
# forensics in the install log when a start failed. Cheap; only runs
# on failure paths.
do_dump_failure_journal() {
  say "vloud.service journal (last 40 lines) — for forensics:"
  journalctl -u vloud.service -n 40 --no-pager 2>&1 | sed 's/^/  | /'
}

# Per-file integrity verification (PR1).
#
# The release tarball SHA is already covered by the signed manifest
# (verify_release_chain). This adds a defence-in-depth check: if the
# release build pipeline shipped a dist/SHA256SUMS file, verify every
# extracted file against it. Catches the (uncommon but plausible)
# case where the tarball decompressed but a single file was corrupted
# OR the build pipeline shipped an inconsistent tarball.
#
# Missing SHA256SUMS is NOT fatal — older releases don't ship it,
# and offline/private-mirror customers may strip it. Operator gets
# a `warn` instead of an abort.
do_verify_sha256sums() {
  local staging_root="$1"
  local checked=0 missing=0
  for sums in \
      "$staging_root/packages/server/dist/SHA256SUMS" \
      "$staging_root/packages/web/dist/SHA256SUMS"; do
    if [[ -f "$sums" ]]; then
      local sums_dir; sums_dir=$(dirname "$sums")
      if ( cd "$sums_dir" && sha256sum -c --quiet --ignore-missing SHA256SUMS >/dev/null 2>&1 ); then
        ok "per-file integrity OK: $sums"
        checked=$((checked+1))
      else
        emit_progress verify-sha256sums failed "mismatch in $sums"
        die "per-file integrity FAILED in $sums_dir — refusing swap" 9
      fi
    else
      missing=$((missing+1))
    fi
  done
  if (( checked == 0 )) && (( missing > 0 )); then
    warn "release ships no SHA256SUMS — per-file integrity SKIPPED (manifest signature still in force)"
    emit_progress verify-sha256sums warn "no SHA256SUMS in tarball"
  else
    emit_progress verify-sha256sums ok "verified=$checked"
  fi
}

# ABI preflight: confirm the staging tree's native modules can load
# against the host's Node ABI. If the release-runtime.json's
# node_module_version differs from the host's, attempt `npm rebuild`
# inside the staging tree (better-sqlite3's prebuild-install will
# fetch a binary matching the host's Node). Only proceed if the
# rebuild succeeds AND a post-rebuild require() of better-sqlite3
# loads cleanly.
#
# Called from do_upgrade_flow BEFORE the atomic swap so a mismatch
# never reaches the running system.
do_abi_preflight_or_rebuild() {
  local staging="$1"
  if vloud_tree_is_sea "$staging"; then
    # A SEA release IS its own Node runtime (the vloud-engine binary embeds it)
    # and ships its native modules built for that runtime: the host's Node and
    # npm play no part, so there is nothing to rebuild or to ABI-check here.
    chmod 0755 "$staging/vloud-engine" 2>/dev/null || true
    ok "SEA release: embedded Node runtime — host ABI preflight not applicable"
    return 0
  fi
  local runtime_json="$staging/release-runtime.json"
  local local_nmv
  local_nmv=$(node -e 'process.stdout.write(String(process.versions.modules))')

  if [[ ! -f "$runtime_json" ]]; then
    warn "release missing release-runtime.json — older tarball, doing best-effort rebuild"
  else
    local expected_nmv
    expected_nmv=$(sed -n 's/.*"node_module_version":[[:space:]]*\([0-9]*\).*/\1/p' "$runtime_json" | head -1)
    if [[ -n "$expected_nmv" && "$expected_nmv" == "$local_nmv" ]]; then
      ok "ABI match: tarball NMV=$expected_nmv = host NMV=$local_nmv"
      # Still run a load-test below; a matching NMV is necessary but
      # not sufficient (e.g. glibc / arch could still bite).
    else
      warn "ABI mismatch: tarball NMV=${expected_nmv:-?} host NMV=$local_nmv — rebuilding native modules in staging"
    fi
  fi

  # Rebuild every native module in staging against the host's Node.
  # better-sqlite3 + bcrypt are the canonical ones; sharp is optional.
  # We run npm rebuild over all of them and let npm decide which need
  # work. Quiet output unless something fails.
  say "rebuilding native bindings against host Node $(node --version) (NMV=$local_nmv)"
  if ! ( cd "$staging/packages/server" && \
         npm rebuild --no-audit --no-fund 2>&1 ) | tail -10 | sed 's/^/  | /'; then
    fail "npm rebuild failed in staging — see lines above"
    return 1
  fi

  # Post-rebuild verification: load better-sqlite3 in the staging tree
  # and open an in-memory DB. If the .node file is still ABI-wrong,
  # this throws and we return 1 — caller aborts before swap.
  if ! ( cd "$staging/packages/server" && \
         node -e '
           try {
             const Db = require("better-sqlite3");
             const db = new Db(":memory:");
             db.prepare("SELECT 1").get();
             db.close();
             process.stdout.write("ok");
           } catch (e) {
             process.stderr.write("LOAD_FAILED: " + e.message + "\n");
             process.exit(1);
           }
         ' >/dev/null ); then
    fail "better-sqlite3 still fails to load after rebuild — release is incompatible with this host"
    return 1
  fi
  ok "ABI preflight passed — native modules load cleanly against host Node"
  return 0
}

# ── --doctor ──────────────────────────────────────────────────
do_doctor_flow() {
  say "DOCTOR: read-only health check"
  echo
  printf "  %-22s active=%s enabled=%s\n" "vloud"            "$(systemctl is-active vloud 2>/dev/null)"            "$(systemctl is-enabled vloud 2>/dev/null)"
  printf "  %-22s active=%s enabled=%s\n" "vloud-job-worker" "$(systemctl is-active vloud-job-worker 2>/dev/null)" "$(systemctl is-enabled vloud-job-worker 2>/dev/null)"
  printf "  %-22s active=%s enabled=%s\n" "vloud-scheduler"  "$(systemctl is-active vloud-scheduler 2>/dev/null)"  "$(systemctl is-enabled vloud-scheduler 2>/dev/null)"
  printf "  %-22s active=%s\n"             "nginx"           "$(systemctl is-active nginx 2>/dev/null)"
  printf "  %-22s active=%s\n"             "named"           "$(systemctl is-active named 2>/dev/null)"
  echo
  say "port 2544 listener"
  ss -lntp 2>/dev/null | grep ':2544\b' | head -3 || warn "no listener on :2544"
  echo
  say "engine health"
  local health
  health=$(curl -s --max-time 4 "http://127.0.0.1:$VLOUD_PORT/api/health" 2>&1 || echo "(timeout)")
  echo "  $health"
  echo
  say "engine version"
  curl -s --max-time 4 "http://127.0.0.1:$VLOUD_PORT/api/system/version" 2>&1 | head -c 400; echo
  echo
  say "DB integrity"
  if [[ -f $VLOUD_INSTALL_DIR/packages/server/vloud.db ]]; then
    if command -v sqlite3 >/dev/null 2>&1; then
      local r; r=$(sqlite3 "$VLOUD_INSTALL_DIR/packages/server/vloud.db" 'PRAGMA integrity_check' 2>&1 | head -1)
      echo "  $r"
    else
      warn "sqlite3 not installed"
    fi
  else
    warn "DB not found at $VLOUD_INSTALL_DIR/packages/server/vloud.db"
  fi
  echo
  say "disk usage"
  df -h "$VLOUD_INSTALL_DIR" / 2>/dev/null | awk 'NR==1 || /\// {print "  "$0}' | head -3
  echo
  ok "DOCTOR done — no changes made"
}

# ── --repair ──────────────────────────────────────────────────
do_repair_flow() {
  say "REPAIR: re-emit systemd units + restart"
  if [[ ! -d "$VLOUD_INSTALL_DIR/packages/server/dist" ]]; then
    die "no install at $VLOUD_INSTALL_DIR — repair needs an existing install; run --fresh first" 3
  fi

  # Repair is the operator's "make this host correct again" button, so
  # it reinstates missing runtime packages too — not just systemd units.
  # Idempotent; no state is touched.
  ensure_system_packages

  # Re-emit systemd units from the install tree. The packaged units
  # live under $VLOUD_INSTALL_DIR/packaging/systemd/.
  local pkg_dir=$VLOUD_INSTALL_DIR/packaging/systemd
  if [[ -d $pkg_dir ]]; then
    for u in vloud.service vloud-job-worker.service vloud-scheduler.service vloud.slice; do
      if [[ -f "$pkg_dir/$u" ]]; then
        cp -f "$pkg_dir/$u" "/etc/systemd/system/$u"
        ok "wrote /etc/systemd/system/$u"
      fi
    done
  else
    warn "packaging/systemd/ not found in install tree — using existing unit files"
  fi
  # Match the units to the release format (legacy dist vs SEA) + F-06 restart guard.
  vloud_retarget_units
  vloud_install_release_pubkey
  install_pipeline_host_units
  # pm2 units start after their backing services — see install_pm2_unit_dropins.
  install_pm2_unit_dropins
  # No AXFR for strangers on a Vloud-managed BIND — see ensure_bind_no_transfer.
  ensure_bind_no_transfer
  # Per-tenant Redis template, account and dirs — see ensure_tenant_redis_plumbing.
  ensure_tenant_redis_plumbing
  # Engine Redis ACL user — opt-in (VLOUD_REDIS_AUTH=1); see ensure_engine_redis_auth.
  ensure_engine_redis_auth
  # The app launcher tenant apps start through — see install_app_launcher.
  install_app_launcher
  install_mailmap_wrapper
  # Where tenant static sites are published — see ensure_tenant_static_root.
  ensure_tenant_static_root
  # 0.6.0b — repair flow now also reconciles env-file + license-
  # pubkey + nginx default vhost. Repair is the operator's "make it
  # right again" lever; previously it only re-emitted systemd units,
  # which left the broken state from a partial 0.6.0 → 0.6.0a
  # upgrade in place.
  if ! do_reconcile_env_file 1; then
    die "env-file reconcile failed — refusing to restart services in known-broken state" 7
  fi

  # host-level build tooling — see ensure_corepack_shims.
  ensure_corepack_shims
  # host-level log rotation — see install_logrotate_config.
  install_logrotate_config
  install_journald_persistence

  # license pubkey (mirrors do_upgrade_flow)
  if [[ -n "${VLOUD_LICENSE_SERVER_URL_DEFAULT:-}" ]]; then
    install -d -m 0755 /etc/vloud
    TMP_PEM=$(mktemp /tmp/vloud-license-pubkey.XXXXXX.pem)
    if curl -fsSL --max-time 15 -o "$TMP_PEM" "$VLOUD_LICENSE_SERVER_URL_DEFAULT/v1/license-pubkey" \
       && grep -q 'BEGIN PUBLIC KEY' "$TMP_PEM"; then
      install -m 0644 -o root -g root "$TMP_PEM" /etc/vloud/license-v1.pub.pem
      ok "license-v1.pub.pem refreshed"
    fi
    rm -f "$TMP_PEM"
  fi

  # 2026-05-14: --repair heals known-bad ownership state. The
  # /var/lib/vloud parent was created as root:root by pre-2026-05-14
  # bootstraps and silently broke engine writes. Heal here so the
  # operator's "fix this host" knob actually fixes it.
  if id -u vloud >/dev/null 2>&1 && [[ -d /var/lib/vloud ]]; then
    cur_owner=$(stat -c '%U:%G' /var/lib/vloud 2>/dev/null)
    if [[ "$cur_owner" != "vloud:vloud" ]]; then
      say "healing /var/lib/vloud ownership: $cur_owner → vloud:vloud"
      chown vloud:vloud /var/lib/vloud 2>/dev/null
      chmod 0751 /var/lib/vloud 2>/dev/null
      [[ -d /var/lib/vloud/staging ]] && \
        chown -R vloud:vloud /var/lib/vloud/staging 2>/dev/null
      ok "/var/lib/vloud ownership healed"
    fi
  fi

  systemctl daemon-reload
  do_stop_services
  do_start_services_with_healthcheck
  do_post_upgrade_health_summary || true
  # 2026-05-14: --repair is the operator's "fix this host" knob —
  # verify all runtime deps before declaring success.
  do_verify_dependencies || warn "post-repair verify-dependencies reported failures (operator action recommended)"
  ok "REPAIR done"
}

# ── --upgrade ─────────────────────────────────────────────────
# 0.6.0b — env-file reconciliation used by BOTH fresh-install and
# upgrade flows. Pre-0.6.0b the env-write happened only inside the
# fresh-install linear phases, so `--upgrade` skipped it entirely
# and engines ended up on 0.6.0a binary with stale (or missing)
# VLOUD_LICENSE_SERVER_URL. That's what bit 188.245.113.223's
# first 0.6.0a upgrade.
#
# Idempotent. Always writes the file atomically via mktemp + install.
# Refuses to continue (returns non-zero) if a required var couldn't
# be written — fail-closed semantics so the caller surfaces a clear
# error instead of silently entering a broken state.
do_reconcile_env_file() {
  local force_url="${1:-1}"   # default: ALWAYS rewrite the cloud URL
  local TMP
  TMP=$(mktemp /etc/vloud.env.XXXXXX)

  # Resolve identity inputs. machine_id derived the same way fresh-install does.
  local raw_mid
  if [[ -f /etc/machine-id && -s /etc/machine-id ]]; then
    raw_mid=$(cat /etc/machine-id)
  else
    raw_mid=$(head -c 32 /dev/urandom | sha256sum | cut -d' ' -f1)
  fi
  local mid; mid=$(printf 'vloud:%s' "$raw_mid" | sha256sum | cut -d' ' -f1 | head -c 32)

  if [[ -f "$ENV_FILE" ]]; then
    cp "$ENV_FILE" "$TMP"
  fi

  # Local upsert: add the line only if the key is entirely absent.
  _upsert_env() {
    local k="$1" v="$2"
    if grep -q "^$k=" "$TMP"; then return; fi
    printf '%s=%s\n' "$k" "$v" >> "$TMP"
  }

  # Local rewrite: replace the value of key (or insert if missing).
  _set_env() {
    local k="$1" v="$2"
    if grep -q "^$k=" "$TMP"; then
      # Use a delimiter the URL can't contain so sed doesn't choke on '/'.
      sed -i -E "s|^$k=.*|$k=$v|" "$TMP"
    else
      printf '%s=%s\n' "$k" "$v" >> "$TMP"
    fi
  }

  # Stable bootstrap fields — upsert only (preserve existing values).
  _upsert_env PORT                  "$VLOUD_PORT"
  _upsert_env NODE_ENV              production
  _upsert_env VLOUD_BIND_HOST       "$VLOUD_BIND_HOST"
  _upsert_env VLOUD_JWT_SECRET      "$(openssl rand -hex 64)"
  _upsert_env VLOUD_SUDO_FALLBACK   0
  _upsert_env VLOUD_MACHINE_ID      "$mid"
  _upsert_env VLOUD_INSTALL_ID      "$(head -c 16 /dev/urandom | xxd -p)"
  _upsert_env VLOUD_TRIAL_EXPIRES_AT \
              "$(date -u -d "+${VLOUD_TRIAL_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)"
  # Cloud email relay — enables OTP verification during onboarding.
  # Customer engines relay OTP emails through license.vloud.app instead
  # of requiring local SMTP or Resend API key configuration.
  _upsert_env VLOUD_EMAIL_RELAY_ENABLED 1
  _upsert_env VLOUD_EMAIL_RELAY_URL     "$VLOUD_LICENSE_SERVER_URL_DEFAULT"
  if [[ "${COEXIST:-0}" -eq 1 ]]; then
    _upsert_env VLOUD_COEXIST 1
    _upsert_env VLOUD_SAFE_COEXIST 1
    _upsert_env VLOUD_CUTOVER_MODE 0
  fi

  # Cloud URL — ALWAYS rewrite to the current default unless the
  # operator has set a non-default value AND force_url=0.
  #
  # The 0.6.0a default lives in VLOUD_LICENSE_SERVER_URL_DEFAULT
  # (https://license.vloud.app). Earlier defaults
  # (localhost / adminpanel.vloud.app) were broken, so we rewrite
  # them. Custom operator URLs (anything NOT matching the broken
  # set AND NOT matching the current default) are preserved.
  if [[ "$force_url" -eq 1 ]]; then
    _set_env VLOUD_LICENSE_SERVER_URL "$VLOUD_LICENSE_SERVER_URL_DEFAULT"
  else
    local existing
    existing=$(grep -E '^VLOUD_LICENSE_SERVER_URL=' "$TMP" | head -1 | cut -d= -f2- || true)
    if [[ -z "$existing" ]] \
       || echo "$existing" | grep -E '^https?://(127\.0\.0\.1|localhost|adminpanel\.vloud\.app)' >/dev/null; then
      _set_env VLOUD_LICENSE_SERVER_URL "$VLOUD_LICENSE_SERVER_URL_DEFAULT"
    fi
  fi

  # Let's Encrypt contact: an explicit --acme-email / VLOUD_ACME_EMAIL replaces the stored value; none
  # given leaves the file as it is (and warns when it has none).
  vloud_apply_acme_email "$TMP"

  # Fail-closed verification: every required var must be present + non-empty.
  local required=(PORT NODE_ENV VLOUD_MACHINE_ID VLOUD_INSTALL_ID
                  VLOUD_LICENSE_SERVER_URL VLOUD_JWT_SECRET)
  local missing=()
  for k in "${required[@]}"; do
    local v
    v=$(grep -E "^$k=" "$TMP" | head -1 | cut -d= -f2- || true)
    if [[ -z "$v" ]]; then missing+=("$k"); fi
  done
  if [[ ${#missing[@]} -gt 0 ]]; then
    rm -f "$TMP"
    fail "env-file reconcile FAILED — missing required vars: ${missing[*]}"
    return 1
  fi

  # The new release refuses to start with a licensing bypass switch set; remove the ones an older installer
  # advertised (VLOUD_REGISTRATION_DISABLE / VLOUD_LICENSE_PULL_DISABLE ...). Backs up the original first.
  vloud_scrub_refused_env "$TMP" "$VLOUD_INSTALL_DIR" "$ENV_FILE"

  install -m 0640 -o root -g vloud "$TMP" "$ENV_FILE"
  rm -f "$TMP"
  ok "$ENV_FILE reconciled (VLOUD_LICENSE_SERVER_URL=$VLOUD_LICENSE_SERVER_URL_DEFAULT)"
  return 0
}

# 0.6.0b — post-upgrade health summary. Probes the engine's
# /api/license/state to confirm sync_status is non-disabled and
# the cloud URL is reachable. Non-zero exit signals broken state
# to the caller; do_upgrade_flow surfaces it as a warn (not a
# rollback trigger — the binary may still be perfectly serviceable
# for non-cloud workflows).
# Final dependency-and-readiness gate. Runs at the END of every
# install flow (fresh, upgrade, repair). Asserts every prerequisite
# the engine relies on is actually present + healthy. Fails LOUD
# with a clear diagnostic per missing/broken item — the install is
# considered complete only when this passes.
#
# 2026-05-14: added after the fresh-host validation found that
# bootstrap was silently skipping Redis. This gate would have caught
# that pre-release. Treat any new runtime dep as a required addition
# here.
do_verify_dependencies() {
  say "verifying runtime dependencies"
  emit_progress verify-deps started ""
  local failures=0
  local warnings=0

  # Binaries on PATH.
  local need_bin=(node npm curl openssl sqlite3 nginx redis-cli sudo systemctl)
  for b in "${need_bin[@]}"; do
    if ! command -v "$b" >/dev/null 2>&1; then
      fail "missing binary: $b"
      failures=$((failures+1))
    fi
  done

  # Node ABI sanity (we already enforced 20.x earlier; double-check).
  if command -v node >/dev/null; then
    local nmv
    nmv=$(node -e 'process.stdout.write(String(process.versions.modules))' 2>/dev/null)
    if [[ "$nmv" != "115" ]]; then
      fail "Node ABI is NMV=$nmv, expected 115 (Node 20.x)"
      failures=$((failures+1))
    fi
  fi

  # Required services. nginx is intentionally NOT hard-required here:
  # per the 0.7.0+ architecture VLoud binds 127.0.0.1:${VLOUD_PORT} and
  # does NOT claim port 80 — nginx is a gateway the operator wires up
  # later (panel domain). So nginx being inactive (e.g. another web
  # server such as Caddy/Apache already owns :80) must NOT fail a fresh
  # install when the engine itself is healthy. It's checked warn-only
  # after this loop.
  local need_service=(redis-server vloud vloud-job-worker vloud-scheduler)
  for s in "${need_service[@]}"; do
    if [[ "$s" == redis-server ]] && [[ "${VLOUD_BOOTSTRAP_NO_REDIS:-0}" == "1" ]]; then
      continue
    fi
    if [[ "$s" == vloud-job-worker || "$s" == vloud-scheduler ]] && [[ "${VLOUD_BOOTSTRAP_NO_REDIS:-0}" == "1" ]]; then
      continue
    fi
    # redis service name varies across Ubuntu generations.
    if [[ "$s" == redis-server ]]; then
      if ! systemctl is-active --quiet redis-server.service 2>/dev/null \
         && ! systemctl is-active --quiet redis.service 2>/dev/null; then
        fail "redis-server.service / redis.service is not active"
        failures=$((failures+1))
      fi
      continue
    fi
    if ! systemctl is-active --quiet "$s.service" 2>/dev/null; then
      fail "$s.service is not active"
      failures=$((failures+1))
    fi
  done

  # nginx — warn-only. The engine is fully functional on :${VLOUD_PORT}
  # without it; nginx only matters once a panel/hosting domain is
  # configured. If it isn't active, surface WHY (commonly another web
  # server already holds port 80) with an actionable next step, but do
  # not mark the install incomplete.
  if command -v nginx >/dev/null 2>&1 && ! systemctl is-active --quiet nginx.service 2>/dev/null; then
    local _p80=""
    if command -v ss >/dev/null 2>&1; then
      _p80=$(ss -ltnHp 'sport = :80' 2>/dev/null | grep -oE 'users:\(\("[^"]+"' | sed -E 's/.*"([^"]+)".*/\1/' | head -1)
    fi
    if [[ -n "$_p80" && "$_p80" != nginx ]]; then
      warn "nginx is not active — port 80 is already held by '$_p80'. VLoud runs on http://<server-ip>:${VLOUD_PORT} regardless; to serve it on 80/443 either point '$_p80' at 127.0.0.1:${VLOUD_PORT}, or stop it and 'systemctl enable --now nginx'."
    else
      warn "nginx is not active — VLoud is reachable at http://<server-ip>:${VLOUD_PORT}. Start nginx ('systemctl enable --now nginx') once you configure a panel domain."
    fi
    warnings=$((warnings+1))
  fi

  # Engine HTTP readiness — bypassed when explicitly skipped (e.g.
  # repair flow on a half-broken host that doesn't pass yet).
  if [[ "${VLOUD_VERIFY_SKIP_HTTP:-0}" != "1" ]]; then
    if ! vloud_probe_engine_health 5; then
      fail "engine /api/health returned HTTP ${VLOUD_HEALTH_CODE} ${VLOUD_HEALTH_BODY:0:200} (expected 200 with status healthy)"
      failures=$((failures+1))
    fi
  fi

  # Redis client ping.
  if [[ "${VLOUD_BOOTSTRAP_NO_REDIS:-0}" != "1" ]]; then
    if ! vloud_redis_cli ping 2>/dev/null | grep '^PONG$' >/dev/null; then
      fail "redis-cli ping did not return PONG"
      failures=$((failures+1))
    fi
  fi

  # Required files.
  # SEA binary or legacy dist — at least one must exist.
  local engine_ok=0
  [[ -f "$VLOUD_INSTALL_DIR/vloud-engine" ]] && engine_ok=1
  [[ -f "$VLOUD_INSTALL_DIR/packages/server/dist/index.js" ]] && engine_ok=1
  [[ "$engine_ok" -eq 0 ]] && { fail "missing engine entrypoint (vloud-engine or dist/index.js)"; failures=$((failures+1)); }
  local need_file=(/etc/vloud.env /etc/vloud/license-v1.pub.pem $VLOUD_INSTALL_DIR/VERSION
                   $VLOUD_INSTALL_DIR/scripts/bootstrap.sh)
  for f in "${need_file[@]}"; do
    if [[ ! -e "$f" ]]; then
      fail "missing file: $f"
      failures=$((failures+1))
    fi
  done

  # Required listening ports. The 2544 / 6379 / 80 trio is the engine
  # contract. ss is in iproute2 (in coreutils dependency chain).
  local need_port=("$VLOUD_PORT" 80)
  if [[ "${VLOUD_BOOTSTRAP_NO_REDIS:-0}" != "1" ]]; then
    need_port+=(6379)
  fi
  for p in "${need_port[@]}"; do
    if ! ss -lnt 2>/dev/null | awk -v port=":$p$" '$4 ~ port { found=1 } END { exit !found }'; then
      fail "no process listening on port $p"
      failures=$((failures+1))
    fi
  done

  # File ownership — REPAIR, then verify, then fail if still wrong.
  #
  # The DB must be vloud:vloud, and so must its parent directory: the
  # database runs in WAL mode, so SQLite has to open vloud.db-shm even
  # to READ. Wrong ownership means every query behind /api/setup/state
  # dies with SQLITE_READONLY_DIRECTORY and the first-run wizard shows
  # a bare HTTP 500.
  #
  # This used to be a warning. An upgrade that left the tree root-owned
  # therefore printed "✓ Vloud is up." over an engine that could not
  # read its own database, and the operator was left to run chown by
  # hand. The installer's contract is one command → working system, so
  # fix it here and only fail if the fix did not take.
  if [[ -f "$VLOUD_INSTALL_DIR/packages/server/vloud.db" ]]; then
    local want owner dir_owner
    want=$(engine_service_user)
    owner=$(stat -c '%U:%G' "$VLOUD_INSTALL_DIR/packages/server/vloud.db" 2>/dev/null)
    dir_owner=$(stat -c '%U:%G' "$VLOUD_INSTALL_DIR/packages/server" 2>/dev/null)
    if [[ "$owner" != "$want:$want" || "$dir_owner" != "$want:$want" ]]; then
      warn "DB ownership is $owner (dir $dir_owner) but the engine runs as $want — repairing"
      chown -R "$want:$want" "$VLOUD_INSTALL_DIR/packages" 2>/dev/null || true
      [[ -d "$VLOUD_INSTALL_DIR/node_modules" ]] &&
        chown -R "$want:$want" "$VLOUD_INSTALL_DIR/node_modules" 2>/dev/null || true
      owner=$(stat -c '%U:%G' "$VLOUD_INSTALL_DIR/packages/server/vloud.db" 2>/dev/null)
      dir_owner=$(stat -c '%U:%G' "$VLOUD_INSTALL_DIR/packages/server" 2>/dev/null)
      if [[ "$owner" != "$want:$want" || "$dir_owner" != "$want:$want" ]]; then
        fail "DB ownership is $owner (dir $dir_owner) and could not be repaired — the engine cannot write its database"
        failures=$((failures+1))
      else
        ok "repaired DB ownership → $want:$want"
        # The engine is already running against the old permissions.
        systemctl restart vloud >/dev/null 2>&1 || true
        sleep 3
      fi
    fi
  fi

  # Prove it: a read through the engine's own API, which is what the
  # wizard calls. Ownership can look right and still fail (SELinux,
  # a stale mount), and this check is the difference between "the
  # install reported success" and "the install works".
  if [[ -f "$VLOUD_INSTALL_DIR/packages/server/vloud.db" ]]; then
    local setup_code
    for _ in 1 2 3 4 5; do
      setup_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
        "http://127.0.0.1:${VLOUD_PORT}/api/setup/state" 2>/dev/null || echo 000)
      [[ "$setup_code" == "200" ]] && break
      sleep 2
    done
    if [[ "$setup_code" != "200" ]]; then
      fail "engine cannot serve /api/setup/state (HTTP $setup_code) — the first-run wizard will not load"
      fail "check: journalctl -u vloud -n 50 | grep -i sqlite"
      failures=$((failures+1))
    else
      ok "first-run wizard reachable (/api/setup/state → 200)"
    fi
  fi

  # /var/lib/vloud writable by vloud user.
  if ! sudo -u vloud test -w /var/lib/vloud 2>/dev/null; then
    warn "/var/lib/vloud is not writable by user 'vloud' (state-write paths will fail)"
    warnings=$((warnings+1))
  fi

  if [[ $failures -gt 0 ]]; then
    emit_progress verify-deps failed "$failures dependency check(s) failed"
    fail "$failures dependency check(s) failed — install is INCOMPLETE"
    fail "see 'systemctl status vloud vloud-job-worker vloud-scheduler redis-server' + 'journalctl -u vloud' for diagnostics"
    return 1
  fi
  if [[ $warnings -gt 0 ]]; then
    warn "$warnings non-fatal dependency warning(s) above"
  fi
  emit_progress verify-deps ok "all dependencies healthy"
  ok "all runtime dependencies present + healthy"
  return 0
}

do_post_upgrade_health_summary() {
  echo
  say "post-upgrade health summary"
  printf "  %-28s %s\n" "engine /api/health" \
    "$(curl -s --max-time 4 "http://127.0.0.1:${VLOUD_PORT}/api/health" 2>/dev/null | head -c 200)"

  local ver
  ver=$(curl -s --max-time 4 "http://127.0.0.1:${VLOUD_PORT}/api/system/version" 2>/dev/null \
        | sed -n 's/.*"display":"\([^"]*\)".*/\1/p')
  printf "  %-28s %s\n" "engine version" "${ver:-?}"

  local env_url
  env_url=$(grep -E '^VLOUD_LICENSE_SERVER_URL=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-)
  printf "  %-28s %s\n" "/etc/vloud.env URL" "${env_url:-(missing)}"

  local pubkey_state="missing"
  [[ -s /etc/vloud/license-v1.pub.pem ]] && pubkey_state="present ($(wc -c </etc/vloud/license-v1.pub.pem) bytes)"
  printf "  %-28s %s\n" "/etc/vloud/license-v1.pub.pem" "$pubkey_state"

  local cloud_reach="unreachable"
  if [[ -n "$env_url" ]]; then
    if curl -fsS --max-time 5 "$env_url/v1/license-pubkey" >/dev/null 2>&1; then
      cloud_reach="ok"
    fi
  fi
  printf "  %-28s %s\n" "cloud /v1/license-pubkey" "$cloud_reach"

  echo
  if [[ "$env_url" == "$VLOUD_LICENSE_SERVER_URL_DEFAULT" \
        && "$pubkey_state" != "missing" \
        && "$cloud_reach" == "ok" ]]; then
    ok "cloud sync prerequisites OK — engine should register within ~60s"
    return 0
  fi
  warn "cloud sync prerequisites NOT FULLY MET. See /var/log/vloud/install.log."
  return 1
}

do_upgrade_flow() {
  say "UPGRADE: atomic swap with rollback"
  emit_progress upgrade-start started "target=${VLOUD_TARGET_VERSION:-latest}"
  # F-21: one upgrade/rollback at a time; heal a swap an earlier run died in;
  # drop staging trees of crashed runs (safe: we hold the lock).
  vloud_acquire_upgrade_lock
  vloud_recover_interrupted_swap
  vloud_cleanup_stale_staging
  if [[ ! -d "$VLOUD_INSTALL_DIR/packages/server/dist" ]] && [[ ! -f "$VLOUD_INSTALL_DIR/vloud-engine" ]]; then
    die "no existing install at $VLOUD_INSTALL_DIR — use --fresh instead" 3
  fi

  # Runtime packages FIRST, before touching the install.
  #
  # An upgrade must be able to install dependencies a newer release
  # introduced. Without this the upgrade path only ever swapped code:
  # MariaDB and Docker were added to the package lists and every
  # already-installed host still had neither, silently, because nothing
  # on this path ever ran apt. Idempotent — already-present packages
  # are a no-op — and it runs before the swap so a package failure
  # aborts while the existing install is still intact.
  ensure_system_packages

  # Capture current version for progress events. Read from the
  # installed tree's VERSION file (canonical) before we touch anything.
  if [[ -f "$VLOUD_INSTALL_DIR/VERSION" ]]; then
    VLOUD_VERSION_FROM=$(cat "$VLOUD_INSTALL_DIR/VERSION" 2>/dev/null | tr -d '[:space:]')
  fi
  [[ -n "$VLOUD_TARGET_VERSION" ]] && VLOUD_VERSION_TO="$VLOUD_TARGET_VERSION"

  local ts; ts=$(date -u +%Y%m%dT%H%M%SZ)
  local staging="${VLOUD_INSTALL_DIR}-staging-$ts"
  local rollback="${VLOUD_INSTALL_DIR}.pre-upgrade-$ts"

  # 1. Backup state BEFORE touching anything — but only if asked to.
  # do_backup_state emits its own backup started/ok progress, naming the
  # archive; a second bare pair here would report the stage twice.
  if state_backup_enabled; then
    if state_backup_would_breach_floor; then
      # Refuse the BACKUP, not the upgrade. An upgrade that cannot be taken is
      # a host stuck on an old release, which is worse than one without a
      # rollback tarball — and the previous release stays in
      # /opt/vloud.pre-upgrade-* either way, so rolling back is still possible.
      warn "skipping the pre-upgrade state backup: writing it would take /var/lib/vloud below the deploy floor"
      warn "free space or turn the backup off in Settings → System Updates; the upgrade continues"
      emit_progress backup skipped "insufficient_space"
    else
      do_backup_state "pre-upgrade" >/dev/null
    fi
  else
    say "pre-upgrade state backup is off (Settings → System Updates) — skipping"
    emit_progress backup skipped "disabled"
  fi

  # 2. Download + verify the new release into staging (a fresh dir
  #    next to /opt/vloud, NOT inside it, so we can atomic-mv on success
  #    and rm-rf on failure).
  # Refuse to start an upgrade without room for the staging tree.
  # tar failing halfway through leaves a half-extracted install and a
  # dead engine; a clear refusal beforehand is far kinder.
  local _free_mb
  _free_mb=$(df -Pm "$(dirname "$VLOUD_INSTALL_DIR")" 2>/dev/null | awk 'NR==2{print $4}')
  if [[ -n "$_free_mb" && "$_free_mb" -lt 1500 ]]; then
    die "only ${_free_mb} MB free on $(dirname "$VLOUD_INSTALL_DIR") — an upgrade needs ~1.5 GB for the staging tree.
       Free space and retry. Old rollback trees are the usual culprit:
         sudo du -sh ${VLOUD_INSTALL_DIR}.pre-upgrade-* 2>/dev/null
         sudo rm -rf \$(ls -d ${VLOUD_INSTALL_DIR}.pre-upgrade-* | sort | head -n -2)" 5
  fi

  say "downloading new release to $staging"
  # download -> verify (signature, version floor, revocation) -> extract ->
  # root SHA256SUMS; any failure removes $staging (EXIT trap) and dies.
  vloud_stage_release "$VLOUD_RELEASE_URL" "$staging"
  [[ -n "${VLOUD_VERIFIED_VERSION:-}" ]] && VLOUD_VERSION_TO="$VLOUD_VERIFIED_VERSION"

  # 2a. Per-file integrity (PR1) — defence-in-depth on top of the
  #     signed manifest's tarball-SHA. Catches single-file corruption
  #     in the staging dir before we swap it into /opt/vloud.
  do_verify_sha256sums "$staging"

  # 2b. ABI preflight — refuse to swap if the release's prebuilt
  #     native modules can't load against the host's Node. The
  #     0.5.1-beta upgrade against 188.245.113.223 (2026-05-13)
  #     shipped better-sqlite3 built for NODE_MODULE_VERSION 127 to
  #     a host with NODE_MODULE_VERSION 115, the new engine couldn't
  #     boot, and the resulting systemd start-limit-hit cascade
  #     poisoned the rollback. Fail-closed BEFORE the swap.
  emit_progress abi-preflight started ""
  do_abi_preflight_or_rebuild "$staging" || {
    rm -rf "$staging"
    emit_progress abi-preflight failed "rebuild failed; swap refused"
    die "ABI preflight failed — refusing to swap; existing install untouched" 8
  }
  emit_progress abi-preflight ok ""

  # 3. Stop services BEFORE the swap so they don't half-read the new tree.
  emit_progress stop-services started "waits up to $(_vloud_env_value VLOUD_PIPELINE_DRAIN_SECONDS 600)s for in-flight pipeline builds"
  do_stop_services
  emit_progress stop-services ok ""

  # 4. Atomic swap: rename current → rollback, staging → current.
  say "swapping $VLOUD_INSTALL_DIR ↔ $rollback (atomic)"
  emit_progress swap started "$VLOUD_INSTALL_DIR ↔ $rollback"
  local swap_rc=0
  vloud_swap_trees "$VLOUD_INSTALL_DIR" "$staging" "$rollback" || swap_rc=$?
  case "$swap_rc" in
    0) vloud_unregister_cleanup "$staging" ;;
    # live is the new tree but the old one is still at $staging: keep it.
    2) vloud_unregister_cleanup "$staging"
       emit_progress swap failed "previous tree left at $staging"
       die "swap half-completed; the previous release is at $staging — see the log" 5 ;;
    *) emit_progress swap failed "nothing changed"
       die "could not swap the new release into place — existing install untouched" 5 ;;
  esac
  vloud_write_slot_meta "$rollback" "${VLOUD_VERSION_FROM:-}"
  emit_progress swap ok ""

  # 5. Re-link state: copy the live DB from rollback into the new tree.
  #    (legacy trees keep it in packages/server/, SEA trees beside the binary —
  #    an upgrade may cross formats in either direction.)
  local _odb _ndb
  _odb=$(vloud_tree_db_dir "$rollback"); _ndb=$(vloud_tree_db_dir "$VLOUD_INSTALL_DIR")
  if [[ -f "$_odb/vloud.db" ]]; then
    install -d "$_ndb"
    cp -a "$_odb/vloud.db"     "$_ndb/vloud.db"
    [[ -f "$_odb/vloud.db-wal" ]] && cp -a "$_odb/vloud.db-wal" "$_ndb/vloud.db-wal"
    [[ -f "$_odb/vloud.db-shm" ]] && cp -a "$_odb/vloud.db-shm" "$_ndb/vloud.db-shm"
    ok "preserved DB from rollback tree"
  fi
  # Note: /etc/vloud.env was NEVER touched — it's outside $VLOUD_INSTALL_DIR.
  # Same for /var/lib/vloud (engine state, geoip, snapshots).

  # 5a. CRITICAL: normalize ownership of the swapped-in tree. The
  # staging dir was created by `tar -xzf` + `npm install` running as
  # root, so after `mv staging -> /opt/vloud` everything is root:root.
  # The cp -a above preserves vloud:vloud on the DB FILE, but the
  # PARENT DIR (/opt/vloud/packages/server/) stays root:root 755.
  # SQLite in WAL mode needs to CREATE vloud.db-wal / vloud.db-shm
  # in that parent dir at boot — without group-write or vloud
  # ownership, it fails with SQLITE_READONLY_DIRECTORY and background
  # workers (monitor collector, guardian, migration worker, ssl
  # worker) crash on every write. This bit 0.5.1 → 0.5.3 upgrades.
  #
  # Ownership MUST match the unit's User=, and is derived from it —
  # never hardcoded on either side.
  #
  # The engine runs as root but WITHOUT CAP_DAC_OVERRIDE:
  # packaging/systemd/vloud.service pins
  # `CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID`.
  # Being uid 0 therefore grants no permission bypass at all — ordinary
  # DAC checks apply, so a root-run engine cannot write a vloud-owned
  # 0644 database. It fails with SQLITE_READONLY ("attempt to write a
  # readonly database"), which surfaces as HTTP 500 on the public
  # /api/setup/state and a first-run wizard that will not load.
  #
  # Hardcoding either half is how this broke before: the units moved to
  # User=root while do_verify_dependencies still expected vloud:vloud.
  # Read the effective user from the unit so the two cannot disagree.
  local engine_user
  engine_user=$(engine_service_user)
  chown -R "$engine_user:$engine_user" "$VLOUD_INSTALL_DIR/packages" 2>/dev/null \
    || warn "chown of packages/ partial"
  if [[ -d "$VLOUD_INSTALL_DIR/node_modules" ]]; then
    chown -R "$engine_user:$engine_user" "$VLOUD_INSTALL_DIR/node_modules" 2>/dev/null || true
  fi
  chmod 0755 "$VLOUD_INSTALL_DIR/packages/server" 2>/dev/null || true
  ok "normalized ownership: $VLOUD_INSTALL_DIR/packages → $engine_user:$engine_user"

  if id -u vloud >/dev/null 2>&1; then

    # 2026-05-14: heal /var/lib/vloud parent ownership. Pre-this-date
    # bootstraps created the dir as root:root (bug at line ~1778),
    # which silently broke engine state-writes — caught by the
    # do_verify_dependencies gate during fresh-host validation. Always
    # reconcile on upgrade so hosts on the buggy old bootstrap heal
    # automatically on the next upgrade tick. Subdirs (backups,
    # crontabs, acme) keep their own ownership.
    if [[ -d /var/lib/vloud ]]; then
      cur_owner=$(stat -c '%U:%G' /var/lib/vloud 2>/dev/null)
      if [[ "$cur_owner" != "vloud:vloud" ]]; then
        say "healing /var/lib/vloud ownership: $cur_owner → vloud:vloud"
        chown vloud:vloud /var/lib/vloud 2>/dev/null
        chmod 0751 /var/lib/vloud 2>/dev/null
        # Re-stamp the engine-owned subtrees (staging/) just in case
        # they were also clobbered. backups/, crontabs/, acme/ keep
        # their canonical owners (root:root + root:www-data).
        for d in staging; do
          [[ -d /var/lib/vloud/$d ]] && \
            chown -R vloud:vloud "/var/lib/vloud/$d" 2>/dev/null
        done
        ok "/var/lib/vloud ownership healed"
      fi
    fi
  else
    warn "vloud user not present — skipping ownership normalization (engine will fail to write)"
  fi

  # 5a-new. 0.6.0b — env-file reconciliation. The upgrade flow used
  # to skip this entirely (only the fresh-install linear phases
  # touched /etc/vloud.env), so 0.6.0 → 0.6.0a upgrades that
  # depended on a new env var landed broken. Fail-closed: if the
  # env file isn't sane post-reconcile, abort the upgrade (the
  # rollback tree is still on disk, so this is a recoverable point).
  if ! do_reconcile_env_file 1; then
    fail "env-file reconcile failed — refusing to start services"
    do_stop_services
    rm -rf "$VLOUD_INSTALL_DIR"
    mv "$rollback" "$VLOUD_INSTALL_DIR"
    do_start_services_with_healthcheck || die "rollback also failed after env-reconcile abort" 7
    die "upgrade aborted, previous version restored (env-reconcile failed)" 7
  fi

  # 5a-new. 0.6.0a — refresh the cloud license-signing pubkey.
  if [[ -n "${VLOUD_LICENSE_SERVER_URL_DEFAULT:-}" ]]; then
    say "refreshing /etc/vloud/license-v1.pub.pem from $VLOUD_LICENSE_SERVER_URL_DEFAULT"
    install -d -m 0755 /etc/vloud
    TMP_PEM=$(mktemp /tmp/vloud-license-pubkey.XXXXXX.pem)
    if curl -fsSL --max-time 15 -o "$TMP_PEM" "$VLOUD_LICENSE_SERVER_URL_DEFAULT/v1/license-pubkey" \
       && grep -q 'BEGIN PUBLIC KEY' "$TMP_PEM"; then
      install -m 0644 -o root -g root "$TMP_PEM" /etc/vloud/license-v1.pub.pem
      ok "license-v1.pub.pem refreshed"
    else
      warn "could not refresh license-v1.pub.pem; cloud-pull will retry"
    fi
    rm -f "$TMP_PEM"
  fi

  # 5a-release. The release public key the engine verifies signed manifests / the channel index with.
  vloud_install_release_pubkey

  # 5b. VLoud nginx reverse proxy.
  #
  # Architecture (0.7.0+): VLoud binds to 127.0.0.1:2544 ONLY.
  # It does NOT claim port 80 or default_server. nginx acts as a
  # gateway — the operator configures a panel domain during onboarding,
  # and VLoud generates a domain-specific vhost (no wildcard catch-all).
  #
  # For initial onboarding (before a domain is configured), the operator
  # accesses VLoud directly at http://SERVER-IP:2544/onboarding.
  #
  # Migration: if a vloud-default vhost exists from a pre-0.7.0 install,
  # remove it so VLoud no longer claims default_server. The operator
  # can re-enable it via Settings > Panel Domain if needed.
  if [[ -f /etc/nginx/sites-enabled/vloud-default ]]; then
    say "migrating: removing vloud-default (VLoud no longer claims port 80)"
    rm -f /etc/nginx/sites-enabled/vloud-default
    if nginx -t 2>&1 | grep -E 'test is successful|syntax is ok' >/dev/null; then
      systemctl reload nginx 2>/dev/null || true
      ok "vloud-default removed — access VLoud at http://<server-ip>:${VLOUD_PORT}"
    fi
  fi
  # The global request-log format (per-domain request metrics, request ids).
  # Host-level, so it has to be repeated here — see install_nginx_log_format.
  install_nginx_log_format

  # 5c. Sync systemd units from the new release into /etc/systemd/system/.
  # Without this, the engine keeps running under the OLD unit config
  # (e.g. User=vloud) even after an upgrade ships User=root.
  if [[ -d "$VLOUD_INSTALL_DIR/packaging/systemd" ]]; then
    for unit in vloud.service vloud-job-worker.service vloud-scheduler.service; do
      if [[ -f "$VLOUD_INSTALL_DIR/packaging/systemd/$unit" ]]; then
        cp "$VLOUD_INSTALL_DIR/packaging/systemd/$unit" "/etc/systemd/system/$unit"
      fi
    done
  fi
  # The packaged units describe the legacy dist layout; point the installed
  # ones at whatever format this release is (SEA binary + role env), and keep
  # RestartPreventExitStatus on the engine unit (F-06).
  vloud_retarget_units
  systemctl daemon-reload 2>/dev/null || true
  ok "systemd units synced from release"
  # Host-level pipeline units (drain drop-in, vloud-ci.slice, cache root). Not
  # in the swapped tree, so the fresh-install call alone would never reach an
  # upgraded host.
  install_pipeline_host_units
  # Same rationale: /etc/systemd/system/pm2-*.service.d and the BIND options
  # are host-level, outside the swap — see install_pm2_unit_dropins and
  # ensure_bind_no_transfer. Neither restarts pm2 or named.
  install_pm2_unit_dropins
  ensure_bind_no_transfer

  # 5d. Webmail provisioner helpers + sudoers grants from the NEW release.
  #
  # The fresh-install linear phases install the webmail helpers into
  # /usr/local/sbin and render /etc/sudoers.d/vloud from the release's
  # scripts/sudoers.d/vloud-locked — but --upgrade dispatches here and
  # exits before those phases, so without this an upgrade ships the new
  # engine yet leaves webmail unable to run and any grants added since the
  # installed version missing. ensure_system_packages() (called at the top
  # of this flow) already set SUDO_BIN / VISUDO_BIN, so bootstrap-sudoers.sh
  # validates with the correct visudo on sudo-rs hosts. Both are idempotent
  # and must land BEFORE the engine restarts below so it sees the grants.
  for _wm in install install-sogo uninstall; do
    _wm_src="$VLOUD_INSTALL_DIR/scripts/vloud-webmail-${_wm}.sh"
    [[ -f "$_wm_src" ]] && install -m 0755 -o root -g root "$_wm_src" "/usr/local/sbin/vloud-webmail-${_wm}"
  done
  # External-repo CI/CD host state — same rationale as the helpers above:
  # --upgrade dispatches here and exits BEFORE the fresh linear phases, so a
  # host upgrading THROUGH the release that introduced this feature would never
  # get the pipeline execution account or the artifact/release roots at all.
  # The engine then refuses every pipeline step (no vloud-ci) and every deploy
  # (no release root) on a box that looks perfectly healthy.
  #
  # Every line below is idempotent, so re-running an upgrade is a no-op.
  if ! getent group vloud-ci >/dev/null 2>&1; then
    groupadd --system vloud-ci && ok "created vloud-ci system group"
  fi
  if ! id -u vloud-ci >/dev/null 2>&1; then
    useradd --system --no-create-home --home-dir /nonexistent \
      --shell /usr/sbin/nologin --gid vloud-ci vloud-ci \
      && ok "created vloud-ci pipeline execution user"
    passwd -l vloud-ci >/dev/null 2>&1 || true
  fi
  # 0711 on the workspace root: the CI account must be able to TRAVERSE into
  # its own run directory but must not be able to LIST the root and discover
  # other runs.
  install -d -m 0711 -o root -g root /var/lib/vloud/pipeline-workspaces
  # Engine-owned. `vloud-ci` never writes here — the engine copies artifacts out
  # of the workspace after the step completes.
  install -d -m 0750 -o root -g root /var/lib/vloud/artifacts
  install -d -m 0751 -o root -g root /var/lib/vloud/apps

  # Per-tenant Redis host plumbing — same rationale: host-level state the
  # fresh phases install, so --upgrade must install it too, before the engine
  # restart below (its supervisor starts pending instances within one tick).
  ensure_tenant_redis_plumbing
  # Engine Redis ACL user — opt-in (VLOUD_REDIS_AUTH=1). Before the restart
  # below, which is what makes the engine services read the new REDIS_URL.
  ensure_engine_redis_auth
  # Where tenant static sites are published — host-level, same rationale.
  ensure_tenant_static_root

  # Caddy-fronted domain provisioner — same rationale as the webmail helpers:
  # --upgrade skips the fresh linear phases, so install it here too (before the
  # engine restart below) so a Caddy box gains domain serving on upgrade.
  _dom_up_src="$VLOUD_INSTALL_DIR/scripts/vloud-domain-apply.sh"
  [[ -f "$_dom_up_src" ]] && install -m 0755 -o root -g root "$_dom_up_src" "/usr/local/sbin/vloud-domain-apply"
  # Vloud apps page helper (install/uninstall/enable/disable) — same upgrade-flow rationale.
  _app_up_src="$VLOUD_INSTALL_DIR/scripts/vloud-app-manage.sh"
  [[ -f "$_app_up_src" ]] && install -m 0755 -o root -g root "$_app_up_src" "/usr/local/sbin/vloud-app-manage"
  # vloud-rescue on PATH — the break-glass CLI (reset-admin-password,
  # disable-2fa, license-status, activate-previous-release, …). Same
  # rationale again: a host that only ever upgrades never runs the fresh
  # phases, and this is the tool for the day the console or the licence
  # API is what broke. Re-installed every upgrade so it tracks the release.
  _rescue_up_src="$VLOUD_INSTALL_DIR/scripts/vloud-rescue.sh"
  [[ -f "$_rescue_up_src" ]] && install -m 0755 -o root -g root "$_rescue_up_src" "/usr/local/sbin/vloud-rescue"
  # App launcher that starts a deployed app from a 0600 environment file
  # (C07-T13) — tenant apps always, the instance's own when the
  # app_envexec_launcher tweak says so. Same rationale as the helpers above:
  # --upgrade skips the fresh linear phases. See install_app_launcher.
  install_app_launcher
  install_mailmap_wrapper
  # Mail host state — same rationale again. bootstrap-postfix.sh only runs in
  # the fresh daemon-stack phase, so without this an upgraded host keeps the
  # main.cf its FIRST install wrote forever (HELO <machine>.localdomain,
  # duplicate keys, no IPv4 preference) and never gets the app mail relay
  # helper. Both steps are idempotent, change nothing on a converged host, and
  # only run where Vloud manages Postfix (/etc/postfix/vloud). Sender
  # enforcement is re-applied only if the operator switched it on.
  _mp_up_src="$VLOUD_INSTALL_DIR/scripts/vloud-mail-policy.sh"
  [[ -f "$_mp_up_src" ]] && install -m 0755 -o root -g root "$_mp_up_src" "/usr/local/sbin/vloud-mail-policy"
  if [[ -d /etc/postfix/vloud ]] && command -v postconf >/dev/null 2>&1; then
    if [[ -f "$VLOUD_INSTALL_DIR/scripts/bootstrap-postfix-base.sh" ]]; then
      if VLOUD_INSTALL_DIR="$VLOUD_INSTALL_DIR" bash "$VLOUD_INSTALL_DIR/scripts/bootstrap-postfix-base.sh"; then
        ok "postfix base configuration + MTA identity refreshed"
      else
        warn "postfix base configuration reported a problem — mail keeps its previous configuration"
      fi
    fi
    if [[ -x /usr/local/sbin/vloud-mail-policy ]]; then
      /usr/local/sbin/vloud-mail-policy apply >/dev/null \
        && ok "app mail relay plumbing refreshed" \
        || warn "vloud-mail-policy apply reported a problem — app mail relay unavailable"
    fi
  fi
  # Native VCS helpers live outside the install dir and are NOT refreshed by
  # the atomic swap, so --upgrade must reinstall them here — the same rationale
  # as the webmail and domain helpers above. Without this an upgraded host keeps
  # whatever binary the previous release installed, and a host upgrading THROUGH
  # the release that introduced vloud-import-contain would never get it at all,
  # leaving Azure DevOps import permanently gated by I.G10.
  install -d -m 0755 -o root -g root /usr/local/libexec/vloud
  _vcs_up_src="$VLOUD_INSTALL_DIR/packages/server"
  if [[ -f "$_vcs_up_src/native/vloud-vcs-lock-supervisor/vloud-vcs-lock-supervisor" ]]; then
    install -m 0755 -o root -g root \
      "$_vcs_up_src/native/vloud-vcs-lock-supervisor/vloud-vcs-lock-supervisor" \
      /usr/local/libexec/vloud-vcs-lock-supervisor
  elif [[ -f "$_vcs_up_src/native/vloud-vcs-lock-supervisor/vloud-vcs-lock-supervisor.c" ]] && command -v cc >/dev/null 2>&1; then
    make -C "$_vcs_up_src/native/vloud-vcs-lock-supervisor" >/dev/null 2>&1 \
      && install -m 0755 -o root -g root \
        "$_vcs_up_src/native/vloud-vcs-lock-supervisor/vloud-vcs-lock-supervisor" \
        /usr/local/libexec/vloud-vcs-lock-supervisor
  fi
  if [[ -f "$_vcs_up_src/native/vloud-import-contain/vloud-import-contain" ]]; then
    install -m 0755 -o root -g root \
      "$_vcs_up_src/native/vloud-import-contain/vloud-import-contain" \
      /usr/local/libexec/vloud/vloud-import-contain
    ok "refreshed vloud-import-contain"
  elif [[ -f "$_vcs_up_src/native/vloud-import-contain/vloud-import-contain.c" ]] && command -v cc >/dev/null 2>&1; then
    if make -C "$_vcs_up_src/native/vloud-import-contain" >/dev/null 2>&1; then
      install -m 0755 -o root -g root \
        "$_vcs_up_src/native/vloud-import-contain/vloud-import-contain" \
        /usr/local/libexec/vloud/vloud-import-contain
      ok "compiled and refreshed vloud-import-contain"
    else
      warn "could not build vloud-import-contain — Azure DevOps import will stay closed (I.G10)"
    fi
  fi
  if [[ -f "$VLOUD_INSTALL_DIR/scripts/bootstrap-sudoers.sh" ]]; then
    if bash "$VLOUD_INSTALL_DIR/scripts/bootstrap-sudoers.sh" >/dev/null 2>&1; then
      ok "sudoers grants + webmail helpers refreshed from new release"
    else
      warn "sudoers re-render reported an issue — webmail/new grants may be missing"
    fi
  fi

  # Build tooling on PATH — same rationale as the helpers above: corepack
  # shims live in /usr/local/bin, outside the atomic swap, so a host upgrading
  # INTO the release that added this would otherwise never get them and its
  # pipeline builds would keep dying on `pnpm: not found`.
  ensure_corepack_shims

  # Log rotation — /etc/logrotate.d is outside the atomic swap too, and the
  # fresh flow was the only one that ever wrote it (see install_logrotate_config).
  install_logrotate_config
  install_journald_persistence

  # 6. Start + health-check. On failure, roll back.
  emit_progress healthcheck started "starting services + curl /api/health"
  if do_start_services_with_healthcheck; then
    # Prune old rollback trees. Each is a full copy of the install
  # (~207 MB with node_modules) and nothing used to remove them, so
  # every upgrade leaked one: twelve upgrades filled a 25 GB disk to
  # 100%, tar failed mid-extract and the engine died with SQLITE_FULL.
  # Keep the two most recent so a rollback is still possible.
  local _keep=2 _old
  mapfile -t _old < <(ls -d "${VLOUD_INSTALL_DIR}".pre-upgrade-* 2>/dev/null | sort | head -n "-${_keep}")
  if (( ${#_old[@]} > 0 )); then
    rm -rf "${_old[@]}" 2>/dev/null || true
    ok "pruned ${#_old[@]} old rollback tree(s), kept the newest $_keep"
  fi
  # Staging dirs are scratch — a failed run can leave one behind.
  rm -rf "${VLOUD_INSTALL_DIR}"-staging-* 2>/dev/null || true

  ok "upgrade succeeded — new version is live"
    emit_progress healthcheck ok "new version healthy"
    # Cleanup old rollback dirs older than 7 days.
    find /opt -maxdepth 1 -type d -name 'vloud.pre-upgrade-*' -mtime +7 -exec rm -rf {} + 2>/dev/null || true
    say "version after upgrade:"
    curl -s --max-time 4 "http://127.0.0.1:$VLOUD_PORT/api/system/version" 2>&1 | head -c 400; echo
    # 0.6.0b — surface cloud-sync prerequisites. Warn-only (non-fatal)
    # because a broken cloud-sync state is recoverable via --repair
    # and shouldn't trigger an automatic rollback.
    do_post_upgrade_health_summary || true
    # 2026-05-14: dependency gate at the END of the upgrade. Failure
    # here means the new version is up + serving but something in
    # the runtime environment regressed (redis disabled, worker
    # crash, port not listening). Surface loudly; the operator can
    # rollback via 'bootstrap.sh --rollback' if needed.
    do_verify_dependencies || warn "post-upgrade verify-dependencies reported failures"
  else
    # Unhealthy: roll back — unless the engine refused for a licensing reason.
    vloud_handle_unhealthy_upgrade "$rollback"
  fi

  ok "UPGRADE done"
}

# ─── --rollback: operator-triggered restore from .pre-upgrade-<ts>/ ───
#
# Two forms:
#   bootstrap.sh --rollback               → restore newest slot
#   bootstrap.sh --rollback --to <v|path> → restore matching slot
#
# Slot semantics: `/opt/vloud.pre-upgrade-<ts>/` directories are
# created by do_upgrade_flow's atomic swap. Each holds the FULL
# previous /opt/vloud tree. Operator picks one to restore; we
# reverse the swap.
#
# Safety:
#   - DB is preserved by copying the CURRENT live DB INTO the slot
#     before the swap (otherwise rolling back also rolls the DB
#     back, which is rarely what the operator wants).
#   - The pre-rollback tree is archived as
#     /opt/vloud.pre-rollback-<ts>/ so we can roll forward again
#     if the operator changes their mind.
#   - Healthcheck after restart; on failure, undo the rollback
#     (re-swap to the pre-rollback tree).
do_rollback_flow() {
  say "ROLLBACK: restore from .pre-upgrade-<ts>/ slot"
  emit_progress rollback-start started "to=${VLOUD_ROLLBACK_TO:-newest}"

  # F-21: one upgrade/rollback at a time; heal an interrupted swap first.
  vloud_acquire_upgrade_lock
  vloud_recover_interrupted_swap
  if [[ ! -d "$VLOUD_INSTALL_DIR" ]]; then
    die "no live install at $VLOUD_INSTALL_DIR — nothing to roll back from" 3
  fi

  # F-06: systemd's OnFailure rollback is refused when the engine exited with a
  # licensing status — it would start a possibly unenforcing older release.
  if [[ "${VLOUD_ROLLBACK_AUTOMATIC:-}" == "1" ]]; then
    vloud_guard_automatic_rollback
  fi

  # 1. Resolve the target slot.  --to may be a version, a slot name or a path,
  #    but only a RETAINED release directory under the managed releases root
  #    that verifies against its SHA256SUMS and clears the version floor can
  #    come out of this (vloud_validate_rollback_slot dies otherwise).
  vloud_resolve_rollback_slot
  local slot="$VLOUD_RESOLVED_SLOT"
  local slot_version="${VLOUD_SLOT_VERSION:-(unknown)}"
  say "rollback target: $slot (version: $slot_version)"
  emit_progress rollback-resolve ok "slot=$slot version=$slot_version"

  # Capture from/to for progress payloads.
  if [[ -f "$VLOUD_INSTALL_DIR/VERSION" ]]; then
    VLOUD_VERSION_FROM=$(tr -d '[:space:]' <"$VLOUD_INSTALL_DIR/VERSION")
  fi
  VLOUD_VERSION_TO="$slot_version"

  # 2. Pre-rollback backup.
  do_backup_state "pre-rollback" >/dev/null

  # 3. Slot integrity was verified (or recovery-authorised) in step 1.

  # 4. Stop services.
  emit_progress stop-services started "waits up to $(_vloud_env_value VLOUD_PIPELINE_DRAIN_SECONDS 600)s for in-flight pipeline builds"
  do_stop_services
  emit_progress stop-services ok ""

  # 5. Preserve the LIVE DB into the slot before swap. Without this,
  #    rolling back also rolls the DB back; that's a foot-gun.
  local _ldb _sdb
  _ldb=$(vloud_tree_db_dir "$VLOUD_INSTALL_DIR"); _sdb=$(vloud_tree_db_dir "$slot")
  if [[ -f "$_ldb/vloud.db" ]]; then
    install -d "$_sdb" 2>/dev/null || true
    cp -a "$_ldb/vloud.db" "$_sdb/vloud.db" 2>/dev/null || \
      warn "could not preserve live DB into slot (rollback will use slot's older DB)"
    for ext in -wal -shm; do
      [[ -f "$_ldb/vloud.db$ext" ]] && \
        cp -a "$_ldb/vloud.db$ext" "$_sdb/vloud.db$ext" 2>/dev/null
    done
    ok "preserved live DB into rollback slot"
  fi

  # 6. Reverse swap. Archive the current tree as pre-rollback-<ts>/
  #    so we can roll forward again if needed.
  local ts; ts=$(date -u +%Y%m%dT%H%M%SZ)
  local archive="${VLOUD_INSTALL_DIR}.pre-rollback-$ts"
  say "swapping $VLOUD_INSTALL_DIR → $archive ; $slot → $VLOUD_INSTALL_DIR"
  emit_progress swap started "rollback swap"
  local swap_rc=0
  vloud_swap_trees "$VLOUD_INSTALL_DIR" "$slot" "$archive" || swap_rc=$?
  if (( swap_rc != 0 )); then
    emit_progress swap failed "rollback swap rc=$swap_rc"
    die "rollback swap failed (rc=$swap_rc) — see the messages above" 5
  fi
  vloud_write_slot_meta "$archive" "${VLOUD_VERSION_FROM:-}"
  emit_progress swap ok ""

  # 7. Normalize ownership on the restored tree — to the unit's User=,
  #    for the CAP_DAC_OVERRIDE reason documented at the upgrade path.
  local rb_user
  rb_user=$(engine_service_user)
  chown -R "$rb_user:$rb_user" "$VLOUD_INSTALL_DIR/packages" 2>/dev/null || true
  if [[ -d "$VLOUD_INSTALL_DIR/node_modules" ]]; then
    chown -R "$rb_user:$rb_user" "$VLOUD_INSTALL_DIR/node_modules" 2>/dev/null || true
  fi
  chmod 0775 "$VLOUD_INSTALL_DIR/packages/server" 2>/dev/null || true
  # The units live outside the swapped tree: re-point them at the format of the
  # tree we just restored (legacy dist <-> SEA binary).
  vloud_retarget_units
  systemctl daemon-reload 2>/dev/null || true

  # 8. Start + healthcheck. On failure, undo (restore the archive).
  emit_progress healthcheck started ""
  if do_start_services_with_healthcheck; then
    ok "rollback succeeded — engine on version $slot_version"
    emit_progress healthcheck ok "engine healthy on $slot_version"
  else
    fail "post-rollback healthcheck failed — undoing rollback"
    emit_progress healthcheck failed "engine did not become healthy"
    do_dump_failure_journal
    do_stop_services
    rm -rf "$VLOUD_INSTALL_DIR"
    mv "$archive" "$VLOUD_INSTALL_DIR"
    if ! do_start_services_with_healthcheck; then
      emit_progress rollback-undo failed "could not restore pre-rollback tree"
      die "rollback failed AND undo failed — manual intervention required" 5
    fi
    emit_progress rollback-undo ok "pre-rollback tree restored"
    die "rollback aborted — engine restored to pre-rollback state" 6
  fi

  ok "ROLLBACK done"
}

# ── engine purge (shared by --force-clean-install and --uninstall) ──
# Removes the Vloud engine, its systemd units, install dir, state, DB,
# env, sudoers/logrotate, and the engine-generated nginx vhosts. Does
# NOT touch the OS, SSH access, or the installed daemon-stack packages
# (mail/db/etc.) — that fuller wipe is scripts/host-nuke.sh.
_vloud_purge_engine() {
  # Best-effort teardown: NEVER abort on a single failed step. The
  # installer runs under `set -euo pipefail`; a non-zero from any cleanup
  # command (a service already stopped, a file already gone, a
  # `daemon-reload` racing a concurrent daemon-stack install) would
  # otherwise exit the script mid-purge and strand /opt/vloud + the units
  # while the engine keeps running. Suspend errexit for the duration.
  set +e
  do_stop_services
  systemctl disable vloud.service vloud-job-worker.service vloud-scheduler.service 2>/dev/null || true
  # Per-tenant Redis instances live under /var/lib/vloud, removed below; stop
  # them first instead of leaving units crash-looping on a missing data dir.
  systemctl stop 'vloud-redis@*.service' vloud-redis.target 2>/dev/null || true
  systemctl disable vloud-redis.target 2>/dev/null || true
  rm -f /etc/systemd/system/vloud-redis@.service /etc/systemd/system/vloud-redis.target
  rm -rf /etc/systemd/system/vloud-redis.target.wants /etc/vloud/redis
  rm -f /etc/systemd/system/vloud.service \
        /etc/systemd/system/vloud-job-worker.service \
        /etc/systemd/system/vloud-scheduler.service \
        /etc/systemd/system/vloud.slice \
        /etc/systemd/system/vloud-ci.slice \
        /etc/systemd/system/vloud-build-runner.service.d/20-vloud-ci-slice.conf
  rm -rf /etc/systemd/system/vloud.service.d \
         /etc/systemd/system/vloud-job-worker.service.d \
         /etc/systemd/system/vloud-scheduler.service.d
  systemctl daemon-reload
  rm -rf "$VLOUD_INSTALL_DIR" "$ENV_FILE" /var/lib/vloud /var/log/vloud
  rm -f  /etc/sudoers.d/vloud /etc/logrotate.d/vloud /etc/logrotate.d/vloud-nginx
  # Engine-generated nginx vhosts only (preserve commercial + customer vhosts).
  while IFS= read -r f; do rm -f "$f"; done < <(
    ls /etc/nginx/sites-enabled/vloud-*.conf 2>/dev/null |
    grep -vE '/vloud-(app-|admin|commercial-|marketing|portal|license)' || true
  )
  # 0.5.3 default vhost (extensionless symlink, not in the .conf glob above).
  rm -f /etc/nginx/sites-enabled/vloud-default /etc/nginx/sites-available/vloud-default
  nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null
  set -e
}

# ── --force-clean-install (purge then fresh) ──────────────────
do_force_clean_purge() {
  say "FORCE-CLEAN: purging existing install before fresh"
  _vloud_purge_engine
  ok "purge complete — proceeding with fresh install"
}

# ── --uninstall (purge only — no reinstall) ───────────────────
# The curl-pipe uninstall one-liner:
#   curl -fsSL https://install.vloud.app | sudo bash -s -- --uninstall
# Requires a typed "UNINSTALL" confirmation (read from /dev/tty so it
# works through the curl pipe); add --yes for unattended.
do_uninstall_flow() {
  say "UNINSTALL: removing the Vloud engine + state (no reinstall)"
  say "  removes: engine, systemd units, $VLOUD_INSTALL_DIR, /var/lib/vloud (DB),"
  say "           $ENV_FILE, sudoers, logrotate, engine nginx vhosts"
  say "  keeps:   the OS, your SSH access, and installed daemon packages"
  if [[ "${VLOUD_ASSUME_YES:-0}" != "1" ]]; then
    # Read the confirmation from the controlling terminal (not stdin —
    # stdin is the curl pipe). Probe that /dev/tty can actually be
    # OPENED, not just that the device node has read perms.
    if { true < /dev/tty; } 2>/dev/null; then
      printf "Type UNINSTALL to proceed (or anything else to abort): " > /dev/tty
      read -r _ans < /dev/tty || _ans=""
      if [[ "$_ans" != "UNINSTALL" ]]; then
        warn "aborted — nothing was removed"
        exit 1
      fi
    else
      die "refusing --uninstall without confirmation (no terminal for the prompt). Re-run unattended: curl -fsSL https://install.vloud.app | sudo bash -s -- --uninstall --yes" 2
    fi
  fi
  _vloud_purge_engine
  ok "Vloud engine + state removed"
  say "reinstall any time:  curl -fsSL https://install.vloud.app | sudo bash"
  say "full host wipe (daemon stack + tenant users too): scripts/host-nuke.sh"
}

# ── System packages ───────────────────────────────────────────────────
#
# Wrapped in a function so the UPGRADE path can run it too.
#
# This block used to live inline in the linear fresh-install flow, and
# `upgrade)` dispatches to do_upgrade_flow and exits long before
# reaching it. The consequence: an upgrade NEVER installed packages, so
# any runtime dependency added by a later release was silently absent
# on every already-installed host. MariaDB and Docker were added to the
# lists and upgraded machines still had neither — no error, because
# nothing ever tried.
#
# apt/dnf install of an already-present package is a no-op, so calling
# this on every path is safe and idempotent.
# The Docker Compose v2 package has a different name per distro, and `docker-compose-plugin` (the name this
# installer used) exists ONLY in Docker's own apt repo, which the installer does not add. On stock Ubuntu
# 22.04 / 24.04 the package is `docker-compose-v2`; Debian 12 ships neither. Asking apt for a name it cannot
# find aborts the WHOLE package phase (E: Unable to locate package, exit 100), so every fresh install on an
# officially supported OS died here, before anything was installed. Pick a name the host's repositories
# actually offer; with none, install without it (marketplace deploys that call `docker compose` then fail
# with a clear "unknown command", which the warning below explains).
# Where Docker CLI plugins live. Overridable only so the shell test is hermetic on a host that really has Compose.
COMPOSE_PLUGIN_FILES=(/usr/libexec/docker/cli-plugins/docker-compose /usr/lib/docker/cli-plugins/docker-compose
                      /usr/local/lib/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose)

compose_already_present() {
  # `docker compose` answers: Compose v2 is there, from whatever package or install method put it there.
  if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then return 0; fi
  local p st f
  for p in docker-compose-plugin docker-compose-v2; do
    st="$(dpkg-query -W -f='${Status}' "$p" 2>/dev/null || true)"     # captured, not piped: no SIGPIPE under pipefail
    [[ "$st" == *"install ok installed"* ]] && return 0
  done
  for f in "${COMPOSE_PLUGIN_FILES[@]}"; do
    [[ -x "$f" ]] && return 0
  done
  return 1
}

# Exit status: 0 = the package name printed on stdout is the one to install; 2 = Compose is ALREADY on this host (install
# nothing: docker-compose-v2 and docker-compose-plugin both own /usr/libexec/docker/cli-plugins/docker-compose, so adding
# the second one aborts dpkg with "trying to overwrite" and kills the whole upgrade -- seen on a host that had Docker's own
# docker-compose-plugin); 1 = no Compose package is offered by this host's repositories.
pick_compose_package() {
  compose_already_present && return 2
  local p
  for p in docker-compose-v2 docker-compose-plugin; do
    if apt-cache show "$p" >/dev/null 2>&1; then
      printf '%s\n' "$p"
      return 0
    fi
  done
  return 1
}

ensure_system_packages() {
  say "installing system packages"
  pkg_update
  ensure_rhel_repos   # no-op on Debian; enables EPEL + Remi + CRB on RHEL

  if [[ "$OS_FAMILY" == debian ]]; then
    # Core: things the engine + bootstrap itself reach for via shell-out.
    # `git` and `util-linux` are Native VCS prerequisites (plan gate 0.G2).
    # git was previously absent from every package array on BOTH branches —
    # the only `git` token in this whole script was a comment — and util-linux
    # (which provides flock(1), used by the cross-process TEST harness and the
    # Phase 0 gates, never by a production lock path) was likewise undeclared.
    # Declaring them beats relying on them being Essential/@core in practice.
    PKG_CORE=(curl ca-certificates gnupg lsb-release jq tar gzip rsync
              openssh-client unzip whois dnsutils sqlite3 openssl
              git util-linux)

    # Runtime services the engine speaks to as a CLIENT (not the
    # daemon-stack, which is a separate concern — those are mail/DNS/FTP
    # servers the engine *manages* for tenants, installed later by
    # bootstrap-daemon-stack.sh).
    PKG_RUNTIME=(
      # Redis: BullMQ queues (workers/job-worker.ts) + scheduler pubsub
      # (workers/scheduler.ts). Both default to redis://127.0.0.1:6379.
      # vloud.service systemd unit lists `After=redis.service`. Without
      # redis the engine boots but workers crash-loop.
      redis-server
      # nginx: reverse proxy in front of the engine on :80 → :2544. The
      # release UI ships its SPA assets via nginx, not the engine itself.
      nginx
      # MariaDB: the tenant database server. Distinct from the engine's
      # own SQLite. The engine's health probe looks for /usr/sbin/mariadbd
      # (falling back to mysqld) and then mariadb.service — see
      # services/health-probes.ts — and the MySQL plugin provisions
      # per-account databases through it. Neither this nor Docker used to
      # be installed at all, so a shared-hosting install came up with no
      # database server and the panel reported MySQL stopped forever.
      mariadb-server
      # Docker: the runtime behind marketplace deploys and the
      # `docker` app-discovery source (services/app-discovery.ts shells
      # out to the docker CLI). docker.io is Ubuntu/Debian's own build —
      # no third-party repo, which keeps the install self-contained.
      docker.io
      # PostgreSQL: offered as a first-class module in the wizard
      # ("Per-tenant Postgres with role isolation") but never installed,
      # so selecting it did nothing and the capability reported "no
      # install mapping". MongoDB is deliberately NOT here — Ubuntu does
      # not package it at all, and the wizard already describes it as a
      # managed Docker container.
      postgresql
      # The Postgres CLIENT tools, separately from the server. pg_dump,
      # pg_restore and psql are what an import or migration actually runs,
      # and they are needed even when the server is remote — restoring a
      # managed cloud database into Vloud otherwise fails at the first
      # command. pg_dump also REFUSES to dump a server newer than itself,
      # so the client version is a real constraint, not a convenience.
      postgresql-client
    )

    # Compose v2, as the `docker compose` SUBCOMMAND. docker.io does not pull this in, and the v1
    # `docker-compose` binary is a DIFFERENT command: a project whose scripts call `docker compose`
    # fails with "docker: unknown command", which surfaces as exit 125 from whatever invoked it rather
    # than as a missing dependency. The package name depends on the distro (see pick_compose_package).
    COMPOSE_RC=0
    COMPOSE_PKG="$(pick_compose_package)" || COMPOSE_RC=$?
    case "$COMPOSE_RC" in
      0) PKG_RUNTIME+=("$COMPOSE_PKG") ;;
      2) : ;;   # Docker Compose is already installed (any package): leave it alone
      *)
        warn "no Docker Compose v2 package in this host's apt repositories (tried docker-compose-v2, docker-compose-plugin):"
        warn "  installing without it. 'docker compose' will be missing; add Docker's apt repository and install docker-compose-plugin to enable it."
        ;;
    esac

    # Build toolchain — kept on every host because better-sqlite3 and
    # node-pty fall back to source-compile when prebuilt-install can't
    # match the host's NMV. The toolchain is also needed by `npm install`
    # itself for postinstall scripts. ~150 MB.
    PKG_BUILD=(build-essential python3 python3-dev)

    # SSL automation. Vloud's hosting-accounts flow uses certbot for
    # Let's Encrypt issuance + renewal.
    PKG_SSL=(certbot)

    # PHP runtime for the hosting-accounts feature. Even on hosts where
    # the operator never deploys a PHP app, the PHP install costs ~80 MB
    # and saves an apt-install round-trip at first deploy time. Treat as
    # a known cost — there's a long-term TODO to defer this until the
    # first PHP app is provisioned.
    # PHP version is DETECTED, not pinned. Vloud targets 8.3 — what
    # Ubuntu 22.04/24.04 and Remi on RHEL 9 provide — but newer Ubuntu
    # releases carry no php8.3 packages at all. 26.04 (resolute) ships
    # 8.5 only, and the ondrej PPA publishes no resolute suite, so a
    # hardcoded 8.3 turned every install on that release into a wall of
    # "Unable to locate package php8.3-*" and left the host half-built.
    #
    # Order: prefer 8.3 when the host has it (the tested configuration),
    # otherwise take the newest phpX.Y-fpm the repos actually offer.
    PHP_VERSION="$(detect_php_version)" || die "No phpX.Y-fpm package is available from this host's apt repositories.
         Vloud needs PHP-FPM for the hosting-accounts feature.
         Check that the universe component is enabled:
           sudo add-apt-repository universe && sudo apt-get update" 9
    if [[ "$PHP_VERSION" != "8.3" ]]; then
      say "php8.3 unavailable on $PRETTY_NAME — using php$PHP_VERSION"
    fi
    PKG_HOSTING_PHP=(
      "php${PHP_VERSION}-fpm"       "php${PHP_VERSION}-cli"
      "php${PHP_VERSION}-mbstring"  "php${PHP_VERSION}-xml"
      "php${PHP_VERSION}-mysql"     "php${PHP_VERSION}-curl"
      "php${PHP_VERSION}-gd"        "php${PHP_VERSION}-zip"
      "php${PHP_VERSION}-intl"      "php${PHP_VERSION}-bcmath"
    )
  else
    # RHEL-family equivalents. Package-name deltas vs Debian:
    #   gnupg→gnupg2, openssh-client→openssh-clients, dnsutils→bind-utils,
    #   sqlite3→sqlite, lsb-release→redhat-lsb-core. semanage lives in
    #   policycoreutils-python-utils (needed by configure_selinux).
    # git + util-linux — same Native VCS prerequisite as the Debian branch
    # (plan gate 0.G2). Same package names on RHEL.
    PKG_CORE=(curl ca-certificates gnupg2 jq tar gzip rsync
              openssh-clients unzip whois bind-utils sqlite openssl
              policycoreutils-python-utils
              git util-linux)

    # redis (not redis-server); nginx same name. The redis-enable loop
    # below already probes both redis.service and redis-server.service.
    # redis (not redis-server) on RHEL; mariadb-server is the same name.
    # Docker is NOT in RHEL 9 base — podman-docker provides a `docker`
    # shim that satisfies app-discovery's CLI shell-out without pulling
    # in a third-party repo during bootstrap.
    # podman-compose gives RHEL the `docker compose` equivalent alongside
    # the podman-docker shim; postgresql supplies psql/pg_dump/pg_restore,
    # which this branch was missing for the same reason Debian was.
    PKG_RUNTIME=(redis nginx mariadb-server podman-docker podman-compose
                 postgresql-server postgresql)

    # Toolchain — gcc/gcc-c++/make replace build-essential; python3-devel
    # replaces python3-dev. Needed for the same native-module fallback.
    PKG_BUILD=(gcc gcc-c++ make python3 python3-devel)

    # certbot + the nginx plugin come from EPEL.
    PKG_SSL=(certbot python3-certbot-nginx)

    # PHP 8.3 from the Remi module enabled by ensure_rhel_repos.
    # php-mysqlnd replaces php-mysql; php-process provides posix/shmop.
    PKG_HOSTING_PHP=(php-fpm php-cli php-mbstring php-xml php-mysqlnd
                     php-gd php-zip php-intl php-bcmath php-process)
    # Unversioned on RHEL: one php-fpm service serves whatever the Remi
    # module selected, so the adapter's singleService path ignores this.
    # Still recorded for the engine's pool-path construction.
    PHP_VERSION=8.3
  fi
  export PHP_VERSION

  # Record what is running so we can prove we did not break it. Taken as late
  # as possible before the first package touches the system.
  snapshot_running_services

  # Never stand a second database server next to a working one.
  # Plain word-splitting rather than mapfile: package names never contain
  # whitespace, and this works on bash 3.2 too. The function's warnings go to
  # stderr, so they reach the operator (and the tee'd install log) instead of
  # being swallowed into this capture.
  PKG_RUNTIME=($(drop_db_if_present ${PKG_RUNTIME[@]+"${PKG_RUNTIME[@]}"}))

  install_packages "${PKG_CORE[@]}" ${PKG_RUNTIME[@]+"${PKG_RUNTIME[@]}"} "${PKG_BUILD[@]}" "${PKG_SSL[@]}" "${PKG_HOSTING_PHP[@]}"

  # rclone — OPTIONAL (docs/architecture/runtime-dependencies.md). It backs the
  # `rclone` backup destination type (Google Drive, MEGA, Dropbox, OneDrive,
  # Box, pCloud, WebDAV and ~60 more). Every other destination type works
  # without it, and the engine refuses an rclone destination at save time with
  # an actionable message, so this must never gate the install: a host on a
  # distro that has not packaged rclone still gets a working Vloud.
  install_packages rclone >/dev/null 2>&1 \
    || warn "rclone not installed; cloud backup destinations (Google Drive, MEGA, Dropbox, ...) will be unavailable until you install it"

  report_package_decisions
  verify_services_survived
  ok "system packages ready ($OS_FAMILY)"

  # ── Which sudo? ───────────────────────────────────────────────────────
  # Ubuntu 26.04 makes `sudo-rs` the default sudo. It rejects wildcards in
  # command arguments, which is the form all 32 of Vloud's daemon sudoers
  # rules use, so every daemon fragment fails `visudo -c` and the mail /
  # DNS / FTP / firewall / quota stack refuses to install.
  #
  # Classic sudo is still packaged, installed alongside under `.ws` names.
  # Detect and prefer it. Duplicated rather than sourced from
  # scripts/_os-family.sh because this script is curl-piped and has no
  # sibling files — keep the two in step.
  SUDO_BIN=/usr/bin/sudo
  if command -v sudo >/dev/null 2>&1 && sudo --version 2>&1 | grep -i 'sudo-rs' >/dev/null; then
    if [[ "$OS_FAMILY" == debian ]] && [[ ! -x /usr/bin/sudo.ws ]]; then
      install_packages sudo >/dev/null 2>&1 || true
    fi
    if [[ -x /usr/bin/sudo.ws && -x /usr/sbin/visudo.ws ]]; then
      SUDO_BIN=/usr/bin/sudo.ws
      export VISUDO_BIN=/usr/sbin/visudo.ws
      ok "sudo-rs detected — using classic sudo ($SUDO_BIN) for wildcard sudoers rules"
    else
      warn "sudo-rs is the only sudo available; daemon sudoers rules using wildcards will be rejected"
    fi
  fi
  export SUDO_BIN

  # Drop Vloud sudoers fragments that no longer parse.
  #
  # sudo evaluates /etc/sudoers.d as a unit: one unparseable file makes
  # every rule in the directory suspect, so a stale fragment breaks sudo
  # for rules that are themselves valid. Fragments written before the
  # sudo-rs switch use wildcards in command arguments and are already
  # dead weight. Each daemon script rewrites its own immediately after,
  # so removing them is not destructive.
  #
  # Mirrors prune_invalid_sudoers_fragments() in scripts/_os-family.sh;
  # duplicated because this script is curl-piped and has no siblings.
  # Checked with the SYSTEM visudo, not VISUDO_BIN: those differ on
  # 26.04, and it is the system parser that decides whether the operator's
  # own `sudo` works.
  local _sysvisudo; _sysvisudo="$(command -v visudo 2>/dev/null || true)"
  if [[ -d /etc/sudoers.d && -n "$_sysvisudo" ]]; then
    local _f
    for _f in /etc/sudoers.d/vloud-*; do
      [[ -f "$_f" ]] || continue
      if ! "$_sysvisudo" -c -f "$_f" >/dev/null 2>&1; then
        rm -f "$_f"
        warn "removed stale sudoers fragment $(basename "$_f") — it no longer parses"
      fi
    done
  fi

  # Redis MUST be enabled + running before we start the engine — the
  # vloud.service unit has Wants=/After=redis.service so systemd will
  # wait for it, but if redis is masked/disabled the wait turns into a
  # silent fail. Fail-fast here with an actionable diagnostic.
  say "enabling + starting redis"
  emit_progress install-redis started ""
  # Ubuntu 24.04 ships redis as redis-server.service. Older Ubuntus
  # used redis.service — try both for forward+back compat.
  REDIS_UNIT=
  for u in redis-server.service redis.service; do
    # `systemctl cat`, not `list-unit-files`: the listing misses a unit
    # registered moments earlier in the same apt run, so a FRESH install
    # that had just installed redis reported "neither redis-server.service
    # nor redis.service registered with systemd" and died — while the unit
    # was present. Same trap as enable_optional_unit above.
    if systemctl cat "$u" >/dev/null 2>&1; then
      REDIS_UNIT="$u"; break
    fi
  done
  if [[ -z "$REDIS_UNIT" ]]; then
    emit_progress install-redis failed "redis unit not registered after apt install"
    die "redis-server package installed but neither redis-server.service nor redis.service registered with systemd. Inspect: dpkg -l redis-server; systemctl list-unit-files | grep -i redis" 8
  fi
  systemctl enable --now "$REDIS_UNIT" >/dev/null 2>&1
  # Wait up to 15s for redis-cli ping → PONG. ping is the canonical
  # readiness probe; redis is in-process at that point so it's <100ms
  # usually but we budget for slow VMs.
  ready=0
  for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
    if vloud_redis_cli ping 2>/dev/null | grep '^PONG$' >/dev/null; then ready=1; break; fi
    sleep 1
  done
  if [[ "$ready" -ne 1 ]]; then
    emit_progress install-redis failed "redis-cli ping never returned PONG"
    fail "redis-cli ping did not return PONG within 15s — check 'journalctl -u $REDIS_UNIT'"
    die "redis readiness check failed" 8
  fi
  ok "redis ready ($REDIS_UNIT, port 6379)"

  # ── MariaDB + Docker ──────────────────────────────────────────────────
  # Installed above but not started by default on every distro. Enable
  # them so the panel reflects a working stack rather than a half-built
  # one. Non-fatal: unlike redis the engine boots fine without either —
  # tenant databases and container deploys are the features that degrade.
  enable_optional_unit() { # enable_optional_unit <label> <unit>...
    local label="$1"; shift
    local unit
    for unit in "$@"; do
      # Judge by outcome, not by `list-unit-files`. That listing misses
      # a unit registered moments earlier in the same apt run and misses
      # alias/generated units: PostgreSQL installed, started, and was
      # still reported "no service unit found (package missing?)" while
      # postgresql.service was demonstrably active.
      systemctl cat "$unit" >/dev/null 2>&1 || continue
      systemctl enable --now "$unit" >/dev/null 2>&1
      if [[ "$(systemctl is-active "$unit" 2>/dev/null)" == "active" ]]; then
        ok "$label ready ($unit)"
        return 0
      fi
      warn "$label: $unit did not become active — 'journalctl -u $unit' has the detail"
      return 1
    done
    warn "$label: no service unit found (package missing?) — feature unavailable"
    return 1
  }

  say "enabling + starting MariaDB"
  # Debian names it mariadb.service; some builds still ship mysql.service.
  enable_optional_unit "MariaDB" mariadb.service mysql.service mysqld.service || true

  say "enabling + starting Docker"
  enable_optional_unit "Docker" docker.service podman.socket || true

  say "enabling + starting PostgreSQL"
  enable_optional_unit "PostgreSQL" postgresql.service postgresql@18-main.service || true
}

# ─── Dispatch ───
# This is where we either run a mode-specific flow and exit, or fall
# through to the linear fresh-install phases below.
case "$MODE" in
  doctor)
    do_doctor_flow
    exit 0
    ;;
  repair)
    emit_progress dispatch started "repair flow"
    do_repair_flow
    rc=$?
    if [[ $rc -eq 0 ]]; then emit_progress_terminal ok "repair complete"; else emit_progress_terminal failed "repair exit=$rc"; fi
    exit $rc
    ;;
  upgrade)
    emit_progress dispatch started "upgrade flow target=${VLOUD_TARGET_VERSION:-latest}"
    do_upgrade_flow
    rc=$?
    if [[ $rc -eq 0 ]]; then emit_progress_terminal ok "upgrade complete"; else emit_progress_terminal failed "upgrade exit=$rc"; fi
    exit $rc
    ;;
  rollback)
    emit_progress dispatch started "rollback flow target=${VLOUD_ROLLBACK_TO:-newest}"
    do_rollback_flow
    rc=$?
    if [[ $rc -eq 0 ]]; then emit_progress_terminal ok "rollback complete"; else emit_progress_terminal failed "rollback exit=$rc"; fi
    exit $rc
    ;;
  uninstall)
    do_uninstall_flow
    exit 0
    ;;
  restore)
    [[ -n ${VLOUD_RESTORE_BUNDLE:-} ]] \
      || die "--restore needs the bundle: --restore=/path/to/full_instance-<ts>.tar.zst.gpg" 2
    [[ -f $VLOUD_INSTALL_DIR/scripts/vloud-restore.sh ]] \
      || die "no engine with scripts/vloud-restore.sh at $VLOUD_INSTALL_DIR — install first (the bundle's engine version or newer), then rerun with --restore" 2
    restore_args=(--stop-services)
    [[ ${VLOUD_ASSUME_YES:-0} == 1 ]] && restore_args+=(--yes)
    exec bash "$VLOUD_INSTALL_DIR/scripts/vloud-restore.sh" "${restore_args[@]}" "$VLOUD_RESTORE_BUNDLE"
    ;;
  force-clean)
    do_force_clean_purge
    # Fall through to fresh install below.
    ;;
  fresh)
    if vloud_install_present; then
      die "$VLOUD_INSTALL_DIR exists — refusing destructive --fresh. Use --upgrade or --force-clean-install." 7
    fi
    ;;
  *)
    die "unknown install mode: $MODE" 2
    ;;
esac
# (fresh / force-clean continue with phases 1-6 below)

# dpkg prints amd64/arm64 (Debian); uname -m prints x86_64/aarch64
# (RHEL has no dpkg). Normalise both to the Debian spelling.
ARCH=$(dpkg --print-architecture 2>/dev/null || uname -m)
case "$ARCH" in
  amd64|x86_64)  ARCH=amd64; ok "arch: $ARCH" ;;
  arm64|aarch64) ARCH=arm64; ok "arch: $ARCH" ;;
  *) die "unsupported architecture: $ARCH (need amd64/x86_64 or arm64/aarch64)" ;;
esac

# Disk check — engine + app data + DB needs ~3GB minimum.
DISK_FREE_GB=$(df -BG --output=avail / | tail -1 | tr -d 'G ')
if [[ "$DISK_FREE_GB" -lt 3 ]]; then
  die "/, only ${DISK_FREE_GB}GB free; need at least 3GB"
fi
ok "disk: ${DISK_FREE_GB}GB free"

# RAM check — 1GB hard floor, 2GB recommended.
MEM_MB=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
if [[ "$MEM_MB" -lt 1024 ]]; then
  warn "ram: ${MEM_MB}MB (1GB hard floor — engine may be unstable)"
elif [[ "$MEM_MB" -lt 2048 ]]; then
  warn "ram: ${MEM_MB}MB (2GB recommended)"
else
  ok "ram: ${MEM_MB}MB"
fi

# Outbound network — apt and the release tarball both need it.  We only
# warn on failure here; the apt step will surface the real error.
if curl -fsS --max-time 5 -o /dev/null -w '' https://deb.debian.org/ 2>/dev/null \
   || curl -fsS --max-time 5 -o /dev/null -w '' https://archive.ubuntu.com/ 2>/dev/null; then
  ok "outbound HTTPS reachable"
else
  warn "outbound HTTPS appears unreachable (apt + release download may fail)"
fi

# ── Node 20 must be OBTAINABLE, and we settle that BEFORE touching the host ──
#
# This check used to live after the package phase, and that ordering did real
# damage on 2026-08-27: the installer restarted the host's whole service stack,
# swapped its database server, and only THEN discovered it could not get Node
# 20 and aborted. The operator was left with every side effect of an install
# and no Vloud — strictly worse than if the script had never run.
#
# A prerequisite that can fail belongs before the first irreversible act, not
# after the last one. Nothing above this point has modified the system beyond
# apt metadata.
if [[ "$OS_FAMILY" == debian ]]; then
  # gpg and curl are needed to even ASK the question. Additive, tiny, and
  # install_packages leaves them alone if they are already present.
  install_packages ca-certificates curl gnupg >/dev/null 2>&1 || true
fi
if node20_obtainable; then
  ok "Node 20 available$( node --version >/dev/null 2>&1 && echo " (host has $(node --version))" )"
else
  die "Node 20.x is required and this host cannot obtain it: $(node --version 2>/dev/null || echo 'no node installed') is present and no 20.x candidate is available from apt.
  Nothing has been changed on this host — the check runs before any package or service is touched.
  Fix the Node source and re-run, e.g.:
    sudo apt-get install -y ca-certificates curl gnupg
    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt-get install -y nodejs" 7
fi

# Detect cPanel — set later in /etc/vloud.env so the engine knows to coexist.
if [[ -d /usr/local/cpanel || -d /var/cpanel ]]; then
  COEXIST=1
  SAFE_COEXIST=1
  cat <<'EOSAFE'

  ┌──────────────────────────────────────────────────────────────────────┐
  │  WHM/cPanel DETECTED — entering WHM SAFE COEXIST MODE (record-only)     │
  ├──────────────────────────────────────────────────────────────────────┤
  │  Vloud will NOT perform a normal clean-server install and will NOT     │
  │  touch your live panel. Service takeover is DISABLED until you finish  │
  │  the cutover prerequisites and enable cutover explicitly.              │
  │                                                                        │
  │  ALLOWED now:                                                          │
  │   • Phase-1 read-only audit (run as root to capture all gaps)          │
  │   • Phase-2 records-first, DB-only import into Vloud                   │
  │   • Operator UI/API on coexist ports (UI 9080/9443, API 127.0.0.1:9500)│
  │                                                                        │
  │  BLOCKED now:                                                          │
  │   • Binding 80/443/25/465/587/110/143/993/995/53/3306                  │
  │   • Writing nginx/apache/postfix/exim/dovecot/bind/powerdns configs    │
  │   • Provisioning MySQL on the shared engine                            │
  │   • Writing /home, DNS zones, CSF/firewall, or cron                    │
  │   • Restarting production services / creating OS users                 │
  │   • Shutting down WHM/cPanel or any live-service takeover              │
  └──────────────────────────────────────────────────────────────────────┘

EOSAFE
  ok "cPanel detected — WHM safe-coexist mode (engine binds port ${VLOUD_PORT} only)"
  # Refuse the normal clean-server provisioning path on a live panel unless the
  # operator explicitly opts into the record-only coexist install.
  if [[ "${VLOUD_CONFIRM_SAFE_COEXIST:-0}" != "1" ]]; then
    die "Refusing clean-server provisioning on a WHM/cPanel host. Re-run with VLOUD_CONFIRM_SAFE_COEXIST=1 to install Vloud in safe coexist (record-only) mode."
  fi
else
  COEXIST=0
  SAFE_COEXIST=0
  ok "cPanel not present"
fi

# Port checks — refuse if our port is taken by something we don't recognise.
if ss -ltn "sport = :$VLOUD_PORT" 2>/dev/null | tail -n +2 | grep . >/dev/null; then
  # Distinguish between "we are already running" (idempotent re-run) and
  # "something else is listening".
  if pgrep -f "node.*vloud.*dist/index" >/dev/null 2>&1; then
    ok "port $VLOUD_PORT in use by an existing Vloud engine — will restart"
  elif systemctl is-active --quiet vloud.service 2>/dev/null; then
    ok "port $VLOUD_PORT held by vloud.service — will restart"
  else
    die "port $VLOUD_PORT already bound by another process (set VLOUD_PORT= to override)"
  fi
fi

# ─── 2. Apt install ───
#
# Vloud's runtime dependencies, grouped by concern. Every package
# below is REQUIRED for a working engine on a fresh Ubuntu 24.04
# host — the installer contract is "single command → fully working
# system", which means we don't get to ask the operator to apt-install
# anything afterward. Each group has a comment explaining why; if you
# remove something, document the new contract first.
#
# 2026-05-14: added redis-server (gap caught in fresh-host validation
# — engine workers depend on redis but it wasn't being installed).
# See docs/operations/host-nuke-and-revalidate.md.

# Fresh-install flow runs it here, where the inline block used to be.
ensure_system_packages

emit_progress install-redis ok "$REDIS_UNIT ready"

# Node 20 LTS (NodeSource). Vloud standardizes on Node 20 — the
# release tarball ships better-sqlite3 + bcrypt prebuilt-binaries
# against NODE_MODULE_VERSION 115 (Node 20.x). A non-20 Node here
# means native modules will fail to load. We REINSTALL when the
# installed Node isn't 20.x — not just when it's missing — to repair
# hosts that were previously installed against Node 18 or Node 22.
need_node=0
if ! command -v node >/dev/null; then need_node=1
elif [[ $(node --version | sed -E 's/^v([0-9]+).*/\1/') -ne 20 ]]; then
  warn "found $(node --version) — Vloud requires Node 20.x, will reinstall"
  need_node=1
fi
if [[ "$need_node" -eq 1 ]]; then
  if [[ "$OS_FAMILY" == debian ]]; then
    configure_nodesource20
    # --allow-downgrades: the pin makes NodeSource's 20.x the candidate,
    # but apt still refuses to move a host DOWN from a distro-shipped 22
    # without being told to ("Packages were downgraded and -y was used
    # without --allow-downgrades"). Repairing such a host is precisely
    # the intent. No-op where Node is absent or already 20.
    apt-get install -y -qq --allow-downgrades nodejs
  else
    # RHEL: NodeSource ships an rpm setup script that wires the dnf repo.
    curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 \
      || warn "NodeSource rpm setup script failed — will try dnf module"
    dnf install -y -q nodejs \
      || { dnf module reset -y nodejs >/dev/null 2>&1; dnf module enable -y nodejs:20 >/dev/null 2>&1; dnf install -y -q nodejs; }
  fi
fi
# Post-install assertion. Feasibility was already settled in preflight, before
# anything was touched, so reaching this with the wrong major means the install
# itself misbehaved rather than the host being unsuitable — worth saying so,
# because the two have completely different remedies.
NODE_MAJOR=$(node --version | sed -E 's/^v([0-9]+).*/\1/')
if [[ "$NODE_MAJOR" -ne 20 ]]; then
  die "Node 20.x was available in preflight but the host is running $(node --version) after the install step. A NodeSource repo may have been re-added mid-install. Check /etc/apt/sources.list.d/ for a nodesource source other than nodesource.list." 7
fi
LOCAL_NMV=$(node -e 'process.stdout.write(String(process.versions.modules))')
ok "node $(node --version) (NODE_MODULE_VERSION=$LOCAL_NMV), npm $(npm --version)"

ensure_corepack_shims

# pm2 — NOT optional. It supervises every application the engine deploys; the
# systemd units here run the ENGINE, not the customer's apps.
if ! command -v pm2 >/dev/null; then
  npm install -g --silent pm2 2>/dev/null && ok "pm2 installed" || warn "pm2 install skipped"
fi

# Give pm2 its own systemd unit, and start it BEFORE the engine can use it.
#
# The daemon is started lazily by the first `pm2` command. That command comes
# from vloud-job-worker, so without this the daemon lands in the WORKER's
# cgroup — and systemd's default KillMode=control-group then kills it, and
# every application it supervises, every time the worker restarts. An engine
# upgrade restarts the worker, so upgrading Vloud took every deployed customer
# app offline until someone started them by hand.
#
# Observed on a live host: "pm2 has been killed by signal" in pm2.log at the
# exact second of the upgrade, and
#   God Daemon (/root/.pm2) -> 0::/vloud.slice/vloud-job-worker.service
#
# Starting pm2-root.service here means the daemon already exists when the
# worker first calls pm2, so pm2 talks to it over its RPC socket instead of
# spawning one inside our cgroup. Idempotent: safe on install and upgrade.
if command -v pm2 >/dev/null; then
  if ! systemctl is-active --quiet pm2-root 2>/dev/null; then
    pm2 startup systemd -u root --hp /root >/dev/null 2>&1 || true
    if systemctl list-unit-files 2>/dev/null | grep '^pm2-root\.service' >/dev/null; then
      systemctl enable --now pm2-root >/dev/null 2>&1 \
        && ok "pm2 runs under systemd (survives engine upgrades)" \
        || warn "pm2-root.service could not be started; apps may stop on upgrade"
    else
      warn "pm2 startup did not create pm2-root.service; apps may stop on upgrade"
    fi
  fi
fi

# ─── 3. Vloud release download + verification ───
#
# Install-time verification chain (matches vloud-commercial Slice 2):
#   1. Download tarball + .sha256 + .sig.
#   2. Verify sha256 matches.
#   3. Verify Ed25519 JWS over {artifact, version, channel, sha256}.
#   4. Cross-check payload sha256 + artifact name.
#   5. Only after all checks pass: tar -xzf.
#
# Refusal modes (refuse-never-warn):
#   - sha256 mismatch         → die with hash detail
#   - signature missing       → die with URL hint
#   - signature invalid       → die
#   - artifact-name mismatch  → die (prevents repurposed-sig attack)
#
# The release public key is supplied via $VLOUD_RELEASE_PUBKEY_PEM
# (env var) or, post-build-substitution, the embedded heredoc below.
# Production builds of bootstrap.sh substitute the placeholder
# `__VLOUD_RELEASE_PUBKEY_PEM__` with the real PEM at release time.
#
# ESCAPE HATCH for testing only: VLOUD_INSECURE_SKIP_VERIFY=1 skips
# step 3.  Refuses to run on Ubuntu/Debian production hosts unless
# the operator also sets VLOUD_TESTING=1 (so accidental skips on a
# real install loudly require both).

# (EMBEDDED_RELEASE_PUBKEY_PEM, b64url_decode, verify_release_chain
# were hoisted above the mode dispatcher in the 0.5.1 multi-mode
# installer rewrite. Both fresh + upgrade flows call them.)

say "fetching Vloud release"
if vloud_install_present; then
  ok "$VLOUD_INSTALL_DIR exists — keeping existing release (re-run safe)"
else
  # Stage next to the install dir, verify (signature, version floor, revocation,
  # SHA256SUMS), then move into place: a failed or interrupted install never
  # leaves a half-extracted $VLOUD_INSTALL_DIR behind.
  if [[ -e "$VLOUD_INSTALL_DIR" ]] && [[ -n "$(ls -A "$VLOUD_INSTALL_DIR" 2>/dev/null)" ]]; then
    die "$VLOUD_INSTALL_DIR exists, is not empty and holds no Vloud install — move it away first" 7
  fi
  vloud_acquire_upgrade_lock          # never race an upgrade/rollback/other install of the same host
  FRESH_STAGING="${VLOUD_INSTALL_DIR}-staging-$(date -u +%Y%m%dT%H%M%SZ)"
  vloud_stage_release "$VLOUD_RELEASE_URL" "$FRESH_STAGING"
  rmdir "$VLOUD_INSTALL_DIR" 2>/dev/null || true
  mv -T "$FRESH_STAGING" "$VLOUD_INSTALL_DIR" || die "could not move the verified release into $VLOUD_INSTALL_DIR" 5
  vloud_unregister_cleanup "$FRESH_STAGING"
  chmod 0755 "$VLOUD_INSTALL_DIR"
  ok "release installed to $VLOUD_INSTALL_DIR"
fi
vloud_install_release_pubkey

# Sanity — engine entrypoint must exist.
# SEA binary format (0.7.0+): vloud-engine at install root
# Legacy format (0.6.x): packages/server/dist/index.js
if [[ -f "$VLOUD_INSTALL_DIR/vloud-engine" ]]; then
  VLOUD_ENGINE_FORMAT=sea
  say "detected SEA binary release"
  chmod +x "$VLOUD_INSTALL_DIR/vloud-engine"
elif [[ -f "$VLOUD_INSTALL_DIR/packages/server/dist/index.js" ]]; then
  VLOUD_ENGINE_FORMAT=legacy
  say "detected legacy dist release"
else
  die "release missing both vloud-engine and dist/index.js — corrupt download or wrong layout"
fi

if [[ "$VLOUD_ENGINE_FORMAT" == "legacy" ]]; then
# Rebuild native bindings against the installed Node ABI. The release
# tarball is built on whatever Node version the packager used; if that
# differs from the customer's Node major, better-sqlite3's .node file
# fails with "Module did not self-register" at every db query.
# `npm rebuild` re-runs the install scripts which pull the matching
# prebuilt binary (or compile from source if no prebuilt exists). Cheap
# and idempotent — costs a few seconds when the binary already matches.
say "rebuilding native bindings against local Node $(node --version)"
if ( cd "$VLOUD_INSTALL_DIR/packages/server" \
     && npm rebuild better-sqlite3 --no-audit --no-fund 2>/dev/null \
        | grep 'rebuilt dependencies' >/dev/null ) \
   || ( cd "$VLOUD_INSTALL_DIR/packages/server" \
        && npm rebuild better-sqlite3 --no-audit --no-fund >/dev/null 2>&1 ); then
  ok "native bindings ABI-matched to local Node"
else
  warn "npm rebuild reported non-zero; install build-essential + python3 if a source compile is needed"
fi
fi  # end VLOUD_ENGINE_FORMAT == legacy

# ─── 4. Configure ───
say "configuring vloud system user + env file"

# vloud user (system, no shell).
if ! id -u vloud >/dev/null 2>&1; then
  useradd --system --home-dir /var/lib/vloud --shell /usr/sbin/nologin --user-group vloud
  ok "created vloud system user"
else
  ok "vloud user exists"
fi
# /var/lib/vloud is 0751, NOT 0750: the Git root below it is root:git, and the
# `git` service account is in no group that owns this directory. Without the
# world-execute bit the account cannot TRAVERSE to its own root, and every
# `git init --bare` fails with "could not initialize the bare repository".
# 0751 grants traverse only — not read — so the directory still cannot be
# listed, and each child keeps gating access with its own mode. This is the
# same pattern /var/lib/vloud/apps (0751) already uses.
install -d -m 0751 -o vloud -g vloud /var/lib/vloud
install -d -m 0750 -o vloud -g vloud /var/log/vloud

# ---------------------------------------------------------------------------
# Native VCS `git` service account (plan D11 / D11a, gate 0.G5).
#
# All tenant-controlled Git object processing runs as this account, never as
# root — the engine itself is User=root, so without a dedicated account every
# Git child would process tenant object data as uid 0.
#
# NOTE THE DELIBERATE DIVERGENCE FROM THE vloud ACCOUNT ABOVE: this one gets
# `--shell /bin/sh`, NOT `--shell /usr/sbin/nologin`. OpenSSH executes a forced
# command THROUGH the account's login shell, as `<shell> -c "<command>"`, so an
# account whose shell is nologin cannot reliably execute the forced VCS command
# at all — nologin prints its refusal and exits non-zero regardless of the
# argument it is handed. Requiring nologin AND a forced-command helper are
# mutually exclusive. Do not "fix" this back.
#
# The shell is therefore NOT the security boundary. Interactive access is
# prevented by confinement, in four independent layers:
#   1. the per-key authorized_keys forced command (the sole SSH dispatcher)
#   2. vloud-git-shell's strict parser, which never evaluates the client's
#      command through a shell
#   3. the `Match User git` restrictions in /etc/ssh/sshd_config.d/60-vloud-vcs.conf
#   4. key-only access: the account has no password
if ! getent group git >/dev/null 2>&1; then
  groupadd --system git
  ok "created git system group"
fi
if ! id -u git >/dev/null 2>&1; then
  useradd --system --home-dir /home/git --create-home --shell /bin/sh --gid git git
  ok "created git service user (shell /bin/sh — see the note above)"
else
  ok "git service user exists"
fi
# Key-only: lock the password so there is no non-key authentication path.
passwd -l git >/dev/null 2>&1 || true

install -d -m 0700 -o git -g git /home/git/.ssh
touch /home/git/.ssh/authorized_keys
chown git:git /home/git/.ssh/authorized_keys
chmod 0600 /home/git/.ssh/authorized_keys

# Persistent Git root (plan D10). Deliberately under /var/lib/vloud and NOT
# under /opt/vloud: the upgrade flow moves the install dir aside, copies only
# vloud.db forward, prunes rollback trees to two, and rm -rf's the install dir
# on rollback — so a Git root inside it would be orphaned on upgrade and
# destroyed by the third one, while the database still listed every repository.
install -d -m 0750 -o root -g git /var/lib/vloud/git
# .locks and the .spool ROOT are 0750 with NO group write: on Unix the
# permission to unlink or rename an entry comes from write+execute on its
# DIRECTORY, so a group-writable .spool would let the git identity remove
# capacity.ledger.lock despite its own 0660 mode.
install -d -m 0750 -o root -g git /var/lib/vloud/git/.locks
install -d -m 0750 -o root -g git /var/lib/vloud/git/.spool
install -d -m 0750 -o root -g git /var/lib/vloud/git/.trash
install -d -m 0750 -o root -g git /var/lib/vloud/git/.tmp
# The two per-push DATA directories ARE group-writable: the SSH receive owner
# runs as git and genuinely must create its own <push-id>.live and
# <push-id>.json inside them.
install -d -m 0770 -o root -g git /var/lib/vloud/git/.spool/live
install -d -m 0770 -o root -g git /var/lib/vloud/git/.spool/post-receive
install -d -m 0750 -o root -g git /etc/vloud/vcs-hooks
install -d -m 0755 -o root -g root /usr/local/lib/vloud-git-hooks

# ---------------------------------------------------------------------------
# Pipeline execution account (vloud-ci).
#
# Pipeline steps execute code from tenant repositories, and on a pull-request
# run that code may be written by anyone who can open one. It therefore gets a
# THIRD identity, distinct from both root and the git account:
#
#   * not root — the engine is User=root with NoNewPrivileges=false and
#     EnvironmentFile=/etc/vloud.env; a step inheriting that is a host takeover
#     plus every credential in that file.
#   * not git — that account owns EVERY tenant's bare repository under
#     /var/lib/vloud/git. A build running as git could read and rewrite other
#     accounts' source.
#
# This account owns nothing but its own per-run workspaces. It gets nologin
# (unlike the git account, which needs a real shell for the SSH forced command)
# because nothing ever logs in as it — the engine spawns its processes directly
# through setpriv.
#
# If this account is missing, the engine REFUSES to run pipeline steps rather
# than falling back to root. That refusal is the design, not a failure mode to
# work around.
if ! getent group vloud-ci >/dev/null 2>&1; then
  groupadd --system vloud-ci
  ok "created vloud-ci system group"
fi
if ! id -u vloud-ci >/dev/null 2>&1; then
  useradd --system --no-create-home --home-dir /nonexistent \
    --shell /usr/sbin/nologin --gid vloud-ci vloud-ci
  ok "created vloud-ci pipeline execution user"
else
  ok "vloud-ci pipeline execution user exists"
fi
passwd -l vloud-ci >/dev/null 2>&1 || true

# Workspace root. 0711 on the root itself: the CI account must be able to
# TRAVERSE into its own run directory but must not be able to LIST the root and
# discover other runs. Each run directory below it is 0700 and owned by the CI
# account only for the lifetime of that run.
install -d -m 0711 -o root -g root /var/lib/vloud/pipeline-workspaces

# Artifact and release roots for the external-repo CI/CD feature.
#
# Under /var/lib/vloud and NOT under $VLOUD_INSTALL_DIR: the upgrade flow moves
# the install directory aside and prunes rollback trees, so anything stored
# there is destroyed by the third upgrade. That is the same reasoning that moved
# the Git root, and it applies identically to build artifacts and to the release
# directories a live application is being served from.
#
# 0750 root:root — the artifact root is ENGINE-OWNED. `vloud-ci` must not be
# able to read another account's build output, and it has no business writing
# into either root at all: the engine copies artifacts out of the workspace
# AFTER the step completes.
install -d -m 0750 -o root -g root /var/lib/vloud/artifacts
install -d -m 0751 -o root -g root /var/lib/vloud/apps

# OPTIONAL: the ASP.NET Core runtime (--with-dotnet).
#
# Runtime only, never the SDK: a build happens in the pipeline (which may well
# use a container or a prebuilt artifact), and what a HOST needs in order to
# serve `dotnet MyApp.dll` is the runtime. Installing the SDK here would add
# gigabytes for a capability the deploy path does not use.
if [[ "$VLOUD_WITH_DOTNET" == "1" ]]; then
  say "installing the ASP.NET Core runtime (--with-dotnet)"
  case "$OS_FAMILY" in
    debian)
      # Debian/Ubuntu ship dotnet in the distro repositories from Debian 12 /
      # Ubuntu 22.04 onward, so no Microsoft feed is added — one fewer external
      # trust root on a control-plane host.
      install_packages aspnetcore-runtime-8.0 || \
        install_packages dotnet-runtime-8.0 || \
        warn "could not install the ASP.NET Core runtime from the distribution repositories"
      ;;
    rhel)
      install_packages aspnetcore-runtime-8.0 || \
        warn "could not install the ASP.NET Core runtime from the distribution repositories"
      ;;
  esac
  if command -v dotnet >/dev/null 2>&1; then
    ok "dotnet present: $(dotnet --version 2>/dev/null || echo 'version unknown')"
  else
    warn "dotnet is still not on PATH — .NET applications will fail their deploy preflight"
  fi
fi

# Trial bootstrap — once-per-install set of values.
# /etc/machine-id is the canonical Linux machine identity since systemd ~2014.
if [[ -f /etc/machine-id ]] && [[ -s /etc/machine-id ]]; then
  RAW_MACHINE_ID=$(cat /etc/machine-id)
else
  RAW_MACHINE_ID=$(head -c 32 /dev/urandom | sha256sum | cut -d' ' -f1)
fi
# Same prefix-and-truncate the engine uses, so env and DB-side machine_id
# agree from the very first call.
MACHINE_ID=$(printf 'vloud:%s' "$RAW_MACHINE_ID" | sha256sum | cut -d' ' -f1 | head -c 32)

ENV_TMP=$(mktemp /etc/vloud.env.XXXXXX)
trap 'rm -f "$ENV_TMP"' EXIT

# 0.6.0 — cloud license sync. Engine registers itself with the
# license-server + pulls its trial/license. Without this URL set
# correctly, the heartbeat client defaults to http://127.0.0.1:3000
# (a local stub) and the engine never reaches the cloud.
#
# 0.6.0a fix: the URL is the LICENSE-SERVER hostname
# (license.vloud.app), NOT the admin-dashboard hostname
# (adminpanel.vloud.app). Pre-fix installs pointed at the admin
# dashboard and registration silently swallowed every POST as a
# SPA-HTML 200 because adminpanel.vloud.app has no /v1/* route.
#
# 0.6.0c: VLOUD_LICENSE_SERVER_URL_DEFAULT is now hoisted to the
# top of the script alongside VLOUD_INSTALL_DIR etc., so that
# `do_reconcile_env_file()` (reachable from --upgrade and --repair
# via the early mode dispatcher) can reference it without
# tripping `set -u`. Reference here kept as documentation.
: "${VLOUD_LICENSE_SERVER_URL_DEFAULT:?must be set in top-of-script defaults}"

if [[ ! -f "$ENV_FILE" ]]; then
  # First-time install — fresh env file with trial values.
  INSTALL_ID=$(head -c 16 /dev/urandom | xxd -p)
  TRIAL_EXPIRES_AT=$(date -u -d "+${VLOUD_TRIAL_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)
  cat > "$ENV_TMP" <<EOF
# Vloud engine environment.  Edit-don't-edit: changes to VLOUD_MACHINE_ID
# / VLOUD_INSTALL_ID / VLOUD_TRIAL_EXPIRES_AT without recomputing the HMAC
# in trial_state will mark the install as tampered.
PORT=$VLOUD_PORT
NODE_ENV=production
VLOUD_BIND_HOST=$VLOUD_BIND_HOST
VLOUD_JWT_SECRET=$(openssl rand -hex 64)
# D-SEC-07: /metrics needs this bearer token unless the scrape is direct loopback.
VLOUD_METRICS_BEARER_TOKEN=$(openssl rand -hex 32)
# 1, not 0. The engine runs as User=vloud (uid 997), so it has no
# privilege of its own; with this at 0 privilegedMode() returns
# "deferred" and every daemon bootstrap the wizard tries to run is
# refused with "no privilege to install (running as uid=997 without
# VLOUD_SUDO_FALLBACK)". The sudoers grant it needs is installed a few
# lines below — this just tells the engine it may use it. The upgrade
# path already upserts 1; fresh install disagreed.
VLOUD_SUDO_FALLBACK=1
VLOUD_MACHINE_ID=$MACHINE_ID
VLOUD_INSTALL_ID=$INSTALL_ID
VLOUD_TRIAL_EXPIRES_AT=$TRIAL_EXPIRES_AT
# 0.6.0: cloud license-server URL. Registration + license-pull workers
# POST/GET against this. Do NOT set VLOUD_REGISTRATION_DISABLE / VLOUD_LICENSE_PULL_DISABLE:
# a release engine refuses to start with them (exit 21). Air-gapped hosts use a signed
# OFFLINE licence (activation_mode 'offline') installed at /var/lib/vloud/license.dat.
VLOUD_LICENSE_SERVER_URL=$VLOUD_LICENSE_SERVER_URL_DEFAULT
# PHP-FPM version actually installed on this host. The engine builds
# service names (php<v>-fpm) and pool paths (/etc/php/<v>/fpm/…) from
# it. Set because it is NOT always 8.3 — Ubuntu 26.04 ships 8.5 and
# has no php8.3 packages, so an assumed 8.3 would name a unit and a
# directory that do not exist.
VLOUD_PHP_VERSION=$PHP_VERSION
# Which sudo the engine shells out to. Ubuntu 26.04's default sudo is
# sudo-rs, which cannot parse Vloud's wildcard-argument sudoers rules;
# classic sudo ships there as /usr/bin/sudo.ws. Must match the visudo
# used to validate the fragments — see scripts/_os-family.sh.
VLOUD_SUDO_BIN=$SUDO_BIN
EOF
  if [[ "$COEXIST" -eq 1 ]]; then
    echo "VLOUD_COEXIST=1" >> "$ENV_TMP"
    echo "VLOUD_SAFE_COEXIST=1" >> "$ENV_TMP"
    echo "VLOUD_CUTOVER_MODE=0" >> "$ENV_TMP"
  fi
  vloud_prompt_acme_email
  vloud_apply_acme_email "$ENV_TMP"
  install -m 0640 -o root -g vloud "$ENV_TMP" "$ENV_FILE"
  rm -f "$ENV_TMP"
  ok "wrote fresh $ENV_FILE (trial $VLOUD_TRIAL_DAYS days, cloud sync → adminpanel)"
else
  # Re-run path — preserve existing values, only fill gaps.
  cp "$ENV_FILE" "$ENV_TMP"
  upsert() {
    local k="$1" v="$2"
    if grep -q "^$k=" "$ENV_TMP"; then return; fi
    printf '%s=%s\n' "$k" "$v" >> "$ENV_TMP"
  }
  upsert PORT "$VLOUD_PORT"
  upsert NODE_ENV production
  upsert VLOUD_BIND_HOST "$VLOUD_BIND_HOST"
  upsert VLOUD_JWT_SECRET "$(openssl rand -hex 64)"
  upsert VLOUD_SUDO_FALLBACK 1
  upsert VLOUD_MACHINE_ID "$MACHINE_ID"
  upsert VLOUD_INSTALL_ID "$(head -c 16 /dev/urandom | xxd -p)"
  upsert VLOUD_TRIAL_EXPIRES_AT "$(date -u -d "+${VLOUD_TRIAL_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)"
  # Upgrades from before PHP detection have no VLOUD_PHP_VERSION. The
  # engine defaults to 8.3 without it, which is right for the hosts
  # that predate this and wrong for any host where 8.3 was never
  # available — fill it in with what is actually installed.
  upsert VLOUD_PHP_VERSION "$PHP_VERSION"
  upsert VLOUD_SUDO_BIN "$SUDO_BIN"
  if [[ "$COEXIST" -eq 1 ]]; then
    upsert VLOUD_COEXIST 1
    upsert VLOUD_SAFE_COEXIST 1
    upsert VLOUD_CUTOVER_MODE 0
  fi

  # 0.6.0: upsert VLOUD_LICENSE_SERVER_URL. Three rewrite cases:
  #
  #   1. Localhost (pre-0.6 default — engine never reached cloud).
  #   2. adminpanel.vloud.app (0.6.0 default — but adminpanel doesn't
  #      proxy /v1/* to the license-server; that was the 0.6.0a bug).
  #   3. Missing → just upsert.
  #
  # Rewriting case 2 in place is critical: every 0.6.0 install
  # bootstrap'd with adminpanel.vloud.app, and those engines never
  # successfully registered. The upgrade path corrects them.
  if grep -qE '^VLOUD_LICENSE_SERVER_URL=https?://(127\.0\.0\.1|localhost|adminpanel\.vloud\.app)' "$ENV_TMP"; then
    sed -i -E "s|^VLOUD_LICENSE_SERVER_URL=.*|VLOUD_LICENSE_SERVER_URL=$VLOUD_LICENSE_SERVER_URL_DEFAULT|" "$ENV_TMP"
    ok "rewrote VLOUD_LICENSE_SERVER_URL → $VLOUD_LICENSE_SERVER_URL_DEFAULT"
  else
    upsert VLOUD_LICENSE_SERVER_URL "$VLOUD_LICENSE_SERVER_URL_DEFAULT"
  fi

  vloud_apply_acme_email "$ENV_TMP"

  # an env file kept from an older install may carry licensing bypass switches the release engine refuses
  vloud_scrub_refused_env "$ENV_TMP" "$VLOUD_INSTALL_DIR" "$ENV_FILE"

  install -m 0640 -o root -g vloud "$ENV_TMP" "$ENV_FILE"
  rm -f "$ENV_TMP"
  ok "updated $ENV_FILE (preserving existing values; cloud sync → adminpanel)"
fi

# Source values for the summary later.
TRIAL_EXPIRES_AT=$(grep -oE '^VLOUD_TRIAL_EXPIRES_AT=.*' "$ENV_FILE" | cut -d= -f2-)

# Vloud-managed dirs.
#
# 2026-05-14: /var/lib/vloud (the PARENT) is owned by vloud:vloud
# because the engine writes state files directly there at runtime:
# .upgrade-progress (PR1 JSONL progress; bootstrap.sh writes as root,
# engine reads as vloud), license.dat, .tamper-events, .config-ok.
# bootstrap.sh's earlier (pre-2026-05-14) bug created this as
# root:root, which silently broke the engine's state writes; the
# do_verify_dependencies gate catches it now ("/var/lib/vloud not
# writable by user vloud"). Sub-directories below can be root-owned
# safely because the engine doesn't traverse into them — backups +
# crontabs are write-by-root-only (bootstrap.sh's do_backup_state +
# operator-provisioned cron).
# 0751, not 0750 — see the note at the first install of this directory: the
# `git` service account must be able to traverse here to reach the root:git
# Git root, and it is in no group that owns this path.
install -d -m 0751 -o vloud -g vloud /var/lib/vloud
install -d -m 0750 -o root  -g root  /var/lib/vloud/crontabs /var/lib/vloud/backups
install -d -m 0750 -o root  -g root  /etc/vloud /etc/vloud/ssl /etc/vloud/certs
install -d -m 0750 -o vloud -g vloud /var/lib/vloud/staging \
  /var/lib/vloud/staging/nginx /var/lib/vloud/staging/php \
  /var/lib/vloud/staging/ssl /var/lib/vloud/staging/fs \
  /var/lib/vloud/staging/mail /var/lib/vloud/staging/installers
install -d -m 0755 -o root -g "$HTTP_GROUP" /var/lib/vloud/acme \
  /var/lib/vloud/acme/.well-known /var/lib/vloud/acme/.well-known/acme-challenge
ok "/var/lib/vloud/* + /etc/vloud/* directories ready"

# 0.6.0a — fetch the active license-signing public key and install
# it at /etc/vloud/license-v1.pub.pem. The engine's license-pull
# worker uses this PEM to verify JWS issued by the cloud. Without
# it, every pull is rejected as "signature/shape mismatch" and the
# sidebar stays on "Pulling license…" forever.
#
# Idempotent: re-runs on upgrade fetch a fresh copy (the signing
# key rotates rarely, but when it does this is how customers pick
# up the new public counterpart without a manual scp).
LICENSE_PUBKEY_PATH=/etc/vloud/license-v1.pub.pem
LICENSE_PUBKEY_URL="${VLOUD_LICENSE_SERVER_URL_DEFAULT}/v1/license-pubkey"
say "fetching license-signing public key from $LICENSE_PUBKEY_URL"
TMP_PEM=$(mktemp /tmp/vloud-license-pubkey.XXXXXX.pem)
if curl -fsSL --max-time 15 -o "$TMP_PEM" "$LICENSE_PUBKEY_URL" \
   && grep -q 'BEGIN PUBLIC KEY' "$TMP_PEM"; then
  install -m 0644 -o root -g root "$TMP_PEM" "$LICENSE_PUBKEY_PATH"
  ok "$LICENSE_PUBKEY_PATH installed ($(wc -c < "$LICENSE_PUBKEY_PATH") bytes)"
else
  warn "could not fetch license public key — engine will retry on first pull but trial sync will fail until reachable"
fi
rm -f "$TMP_PEM"

# Sudoers fragment so the engine can run privileged verbs.  We DO this here
# so the bootstrap is fully end-to-end; the operator can drop the file
# afterwards if they want a more locked-down policy.
if [[ -f "$VLOUD_INSTALL_DIR/packages/vloud-agent/install/sudoers.d/vloud" ]]; then
  STAGE=$(mktemp /tmp/vloud-sudoers.XXXXXX)
  trap 'rm -f "$STAGE"' EXIT
  sed 's/__VLOUD_USER__/vloud/g' \
    "$VLOUD_INSTALL_DIR/packages/vloud-agent/install/sudoers.d/vloud" > "$STAGE"
  # Validate with the sudo implementation we actually chose — see
  # detect_sudo_impl. On 26.04 the canonical `visudo` is sudo-rs, which
  # rejects syntax classic sudo accepts.
  if "${VISUDO_BIN:-visudo}" -c -f "$STAGE" >/dev/null; then
    install -m 0440 -o root -g root "$STAGE" /etc/sudoers.d/vloud
    ok "sudoers fragment installed"
  else
    warn "sudoers template failed visudo -c — skipping"
  fi
  rm -f "$STAGE"
fi

# Webmail provisioner helpers → /usr/local/sbin. The engine shells out to
# these (via the scoped NOPASSWD grants above) when the operator clicks
# "Install webmail". They ship in the release tree as scripts/vloud-webmail-*.sh;
# install them under the exact names the engine + sudoers expect (no .sh).
# Root-owned + 0755 so a non-root engine can execute but not modify them.
for _wm in install install-sogo uninstall; do
  _src="$VLOUD_INSTALL_DIR/scripts/vloud-webmail-${_wm}.sh"
  if [[ -f "$_src" ]]; then
    install -m 0755 -o root -g root "$_src" "/usr/local/sbin/vloud-webmail-${_wm}"
  fi
done
ok "webmail provisioner helpers installed to /usr/local/sbin"

# Caddy-fronted domain provisioner → /usr/local/sbin. The engine shells out
# to this (via the scoped NOPASSWD grant) to serve user domains through Caddy
# on hosts where Caddy owns :80/:443. Same install convention as webmail.
_dom_src="$VLOUD_INSTALL_DIR/scripts/vloud-domain-apply.sh"
if [[ -f "$_dom_src" ]]; then
  install -m 0755 -o root -g root "$_dom_src" "/usr/local/sbin/vloud-domain-apply"
  ok "caddy domain provisioner installed to /usr/local/sbin"
fi

# Vloud apps page helper → /usr/local/sbin (scoped NOPASSWD grant, fixed app list).
_app_src="$VLOUD_INSTALL_DIR/scripts/vloud-app-manage.sh"
if [[ -f "$_app_src" ]]; then
  install -m 0755 -o root -g root "$_app_src" "/usr/local/sbin/vloud-app-manage"
  ok "vloud-app-manage helper installed to /usr/local/sbin"
fi

# vloud-rescue → /usr/local/sbin: the operator's break-glass CLI
# (`sudo vloud-rescue help`). do_upgrade_flow installs it too.
_rescue_src="$VLOUD_INSTALL_DIR/scripts/vloud-rescue.sh"
if [[ -f "$_rescue_src" ]]; then
  install -m 0755 -o root -g root "$_rescue_src" "/usr/local/sbin/vloud-rescue"
  ok "vloud-rescue installed to /usr/local/sbin"
fi

# App launcher (C07-T13) — see install_app_launcher. Mirrored in
# do_upgrade_flow and do_repair_flow.
install_app_launcher
install_mailmap_wrapper

# App mail relay + sender-enforcement helper → /usr/local/sbin. The daemon
# stack below (bootstrap-postfix.sh / bootstrap-dovecot.sh) runs its `apply`;
# the engine calls it through the scoped NOPASSWD grant. Same convention.
_mp_src="$VLOUD_INSTALL_DIR/scripts/vloud-mail-policy.sh"
if [[ -f "$_mp_src" ]]; then
  install -m 0755 -o root -g root "$_mp_src" "/usr/local/sbin/vloud-mail-policy"
  ok "mail policy helper installed to /usr/local/sbin"
fi

# Ownership of the engine tree — must match the unit's User=, because
# the engine drops CAP_DAC_OVERRIDE and so gets no permission bypass
# from being uid 0. Hardcoding `vloud` here while packaging/systemd
# ships User=root produced a fresh install whose engine could not write
# its own database (SQLITE_READONLY → HTTP 500 on /api/setup/state).
# We do NOT chown /opt/vloud as a whole because the operator may have
# other things there; the packages/ subtree only.
ENGINE_USER=$(engine_service_user)
chown -R "$ENGINE_USER:$ENGINE_USER" "$VLOUD_INSTALL_DIR/packages" "$VLOUD_INSTALL_DIR/node_modules" 2>/dev/null || true
ok "engine tree owned by $ENGINE_USER:$ENGINE_USER"

# ---------------------------------------------------------------------------
# Native VCS helpers (plan D37, D11a, Task 21/25).
#
# These live OUTSIDE the install dir on purpose. /opt/vloud is moved aside and
# replaced by every upgrade, and rm -rf'd on rollback — a helper installed
# inside it would vanish mid-upgrade while sshd was still configured to execute
# it, which is a broken SSH surface rather than a degraded one.
#
# All three are root-owned and NOT writable by the `git` account: the account
# they confine must never be able to replace the program that confines it.
# vloud-vcs-lock-supervisor is deliberately NOT setuid — sshd runs the forced
# command as `git`, and every permission decision in the lock protocol follows
# from the whole chain carrying git's credentials.
install -d -m 0755 -o root -g root /usr/local/libexec /usr/local/lib/vloud-git-hooks

VCS_SRC="$VLOUD_INSTALL_DIR/packages/server"

# The lock supervisor is compiled at release-build time; fall back to compiling
# here if the artifact shipped sources only.
if [[ -f "$VCS_SRC/native/vloud-vcs-lock-supervisor/vloud-vcs-lock-supervisor" ]]; then
  install -m 0755 -o root -g root \
    "$VCS_SRC/native/vloud-vcs-lock-supervisor/vloud-vcs-lock-supervisor" \
    /usr/local/libexec/vloud-vcs-lock-supervisor
  ok "installed vloud-vcs-lock-supervisor"
elif [[ -f "$VCS_SRC/native/vloud-vcs-lock-supervisor/vloud-vcs-lock-supervisor.c" ]] && command -v cc >/dev/null 2>&1; then
  if make -C "$VCS_SRC/native/vloud-vcs-lock-supervisor" >/dev/null 2>&1; then
    install -m 0755 -o root -g root \
      "$VCS_SRC/native/vloud-vcs-lock-supervisor/vloud-vcs-lock-supervisor" \
      /usr/local/libexec/vloud-vcs-lock-supervisor
    ok "compiled and installed vloud-vcs-lock-supervisor"
  else
    warn "could not build vloud-vcs-lock-supervisor — SSH Git access will stay disabled (Gate A false)"
  fi
else
  warn "vloud-vcs-lock-supervisor not present — SSH Git access will stay disabled (Gate A false)"
fi

# vloud-import-contain: the Azure-import child-subtree containment helper.
#
# It lives OUTSIDE the install dir for the same reason as the lock supervisor:
# /opt/vloud is moved aside by every upgrade and rm -rf'd on rollback, and a
# helper that vanished mid-upgrade while an import held live Git children would
# be a broken containment surface rather than a degraded one.
#
# root:root 0755 and deliberately NOT setuid. It runs as the job worker's
# identity and drops each spawned Git child to the `git` account itself, so it
# needs no elevated file permissions of its own — and the `git` account must not
# be able to write the program that confines it.
install -d -m 0755 -o root -g root /usr/local/libexec/vloud
if [[ -f "$VCS_SRC/native/vloud-import-contain/vloud-import-contain" ]]; then
  install -m 0755 -o root -g root \
    "$VCS_SRC/native/vloud-import-contain/vloud-import-contain" \
    /usr/local/libexec/vloud/vloud-import-contain
  ok "installed vloud-import-contain"
elif [[ -f "$VCS_SRC/native/vloud-import-contain/vloud-import-contain.c" ]] && command -v cc >/dev/null 2>&1; then
  if make -C "$VCS_SRC/native/vloud-import-contain" >/dev/null 2>&1; then
    install -m 0755 -o root -g root \
      "$VCS_SRC/native/vloud-import-contain/vloud-import-contain" \
      /usr/local/libexec/vloud/vloud-import-contain
    ok "compiled and installed vloud-import-contain"
  else
    warn "could not build vloud-import-contain — Azure DevOps import will stay closed (I.G10)"
  fi
else
  warn "vloud-import-contain not present — Azure DevOps import will stay closed (I.G10)"
fi

# vloud-git-shell: the per-key forced command target. A tiny wrapper so the
# authorized_keys line names a stable absolute path that does not move with the
# engine tree.
if [[ -f "$VCS_SRC/dist/services/vcs/git-shell.js" ]]; then
  cat > /usr/local/sbin/vloud-git-shell <<'VLOUD_GIT_SHELL_EOF'
#!/bin/sh
# Vloud Native VCS SSH forced-command helper. Generated by the installer.
#
# This program owns ZERO leases and creates ZERO durable state: it parses the
# client's request strictly, authorizes it over the private Unix socket, and
# starts vloud-vcs-lock-supervisor, which is the sole transport owner.
exec /usr/bin/node /opt/vloud/packages/server/dist/services/vcs/git-shell.js "$@"
VLOUD_GIT_SHELL_EOF
  chown root:root /usr/local/sbin/vloud-git-shell
  chmod 0755 /usr/local/sbin/vloud-git-shell
  ok "installed vloud-git-shell"
fi

# The shared receive hooks are rendered by the engine at boot (they embed the
# resolved node + runner paths), so only the directory is prepared here.
chown root:root /usr/local/lib/vloud-git-hooks
chmod 0755 /usr/local/lib/vloud-git-hooks

# A3 (2026-05-10) — logrotate config for non-journald log paths.
# journald rotates the engine's stdout/stderr automatically via its own
# config; these files cover /var/log/vloud/*.log, the hosted apps' pm2 logs,
# and (only where the distro does not) the nginx-directory logs. Shared with
# --upgrade and --repair — see install_logrotate_config.
say "installing logrotate config"
install_logrotate_config
install_journald_persistence
mkdir -p /var/log/vloud
chown vloud:vloud /var/log/vloud

# 0.7.0 architecture: VLoud does NOT claim port 80 or default_server.
#
# The engine binds to 127.0.0.1:$VLOUD_PORT only. nginx is a gateway
# that the operator configures with a panel domain during onboarding.
# Initial onboarding is accessed directly at http://SERVER-IP:$VLOUD_PORT.
#
# Migration from pre-0.7.0: if vloud-default exists, remove it.
if [[ -f /etc/nginx/sites-enabled/vloud-default ]]; then
  say "migrating: removing vloud-default (VLoud no longer claims port 80/default_server)"
  rm -f /etc/nginx/sites-enabled/vloud-default
  if nginx -t 2>&1 | grep -E 'test is successful|syntax is ok' >/dev/null; then
    systemctl reload nginx 2>/dev/null || true
  fi
  ok "vloud-default removed"
fi

# The global request-log format (per-domain request metrics, request ids).
# The upgrade flow installs it too — see install_nginx_log_format.
install_nginx_log_format
# Only reachable at the server's own address when the engine is bound
# to all interfaces; by default it is loopback-only. The final summary
# prints the working URL and how to reach it remotely.
if [[ "$VLOUD_BIND_HOST" == "0.0.0.0" || "$VLOUD_BIND_HOST" == "::" ]]; then
  say "VLoud panel accessible at http://<server-ip>:${VLOUD_PORT}"
else
  say "VLoud panel bound to ${VLOUD_BIND_HOST}:${VLOUD_PORT} (loopback-only by default)"
fi

# systemd units — D5. Three services (engine + worker + scheduler)
# under one slice. Canonical sources live in
# packaging/systemd/*.service in the engine repo; we template
# WorkingDirectory + Port here because $VLOUD_INSTALL_DIR /
# $VLOUD_PORT can differ per install. Matching changes to the
# canonical files MUST be reflected here (no shared templater
# yet — the indirection wasn't worth a sed pass).
say "installing systemd units"

cat > "$SYSTEMD_SLICE" <<EOF
[Unit]
Description=Vloud control plane (engine + workers)
Documentation=https://docs.vloud.sh/
DefaultDependencies=true
Before=vloud.service vloud-job-worker.service vloud-scheduler.service

[Slice]
EOF

cat > "$SYSTEMD_UNIT" <<EOF
[Unit]
Description=Vloud control plane (Fastify on $VLOUD_PORT)
Documentation=https://docs.vloud.sh/
After=network-online.target redis.service
Wants=network-online.target redis.service
StartLimitBurst=5
StartLimitIntervalSec=60s

[Service]
# Type=notify (A1, 2026-05-10) — engine boots through migrations +
# integrity chain + listen before ready. Mirrors
# packaging/systemd/vloud.service; keep these two in sync.
# NotifyAccess=main: only the engine itself may change the unit's state.
# Under =all a drill's scratch postmaster that inherited NOTIFY_SOCKET sent
# STOPPING=1 and got the engine SIGKILLed (C04-T11 N1). The engine's
# spawned systemd-notify is accepted because it sends with its parent's
# (the engine's) pid, which needs root + CAP_SYS_ADMIN (bounding set below)
# and ExecStart exec-ing the engine directly. See packaging/systemd/vloud.service.
Type=notify
NotifyAccess=main
TimeoutStartSec=120s
WatchdogSec=60s
User=root
Group=root
Slice=vloud.slice
$(vloud_engine_unit_exec engine "${VLOUD_ENGINE_FORMAT:-sea}")
EnvironmentFile=$ENV_FILE

Restart=on-failure
# F-06: a licensing refusal (integrity-bootstrap exits 11-15, 21 bypass-flag /
# dist-integrity refusal, 22 reserved) is a decision, not a crash: never retry
# it, and vloud-rollback.service refuses to run for it (vloud_guard_automatic_rollback).
RestartPreventExitStatus=$VLOUD_LICENSING_EXIT_CODES
RestartSec=3s
TimeoutStopSec=30s
KillSignal=SIGTERM
SendSIGKILL=yes
# KillMode=process (NOT the default control-group): engine-driven
# upgrades spawn bootstrap.sh --upgrade as a child of this unit, and
# bootstrap.sh do_stop_services() runs `systemctl stop vloud` mid-way
# to swap the tree. With control-group, that stop reaps the whole
# cgroup — including the running upgrade — so the swap never completes
# and the dashboard hangs on "Installing…". KillMode=process signals
# only the engine's main PID on stop, letting the upgrade child live
# to finish the swap and restart the engine. Keep in sync with
# packaging/systemd/vloud.service.
KillMode=process

# Phase E (2026-05-14): when the engine repeatedly fails to start
# (StartLimitBurst hit), trigger an automatic rollback via the
# vloud-rollback.service unit. The unit shells to
# bootstrap.sh --rollback --automatic, which restores the newest
# /opt/vloud.pre-upgrade-<ts>/ slot. Operator can suppress by
# touching /var/lib/vloud/.rollback-disabled (debugging mode).
StartLimitIntervalSec=120
StartLimitBurst=5
OnFailure=vloud-rollback.service

StandardOutput=journal
StandardError=journal
SyslogIdentifier=vloud

NoNewPrivileges=false
ProtectSystem=false
ProtectHome=false
# PrivateTmp=false (NOT true): engine-driven upgrades spawn bootstrap.sh
# as a child of this unit and it must OUTLIVE `systemctl stop vloud` to
# finish the swap (see KillMode=process above). With PrivateTmp=true,
# systemd destroys the service's private /tmp on stop, and the still-
# running upgrade then fails its next mktemp /tmp/... with ENOENT under
# `set -e` — dying right before it restarts the engine. Keep in sync
# with packaging/systemd/vloud.service.
PrivateTmp=false
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
ReadWritePaths=/var/lib/vloud /var/log/vloud
# Native VCS runtime directory (plan gate 0.G5 / §3.1).
#
# /run/vloud-vcs holds the cross-process slot files and the private VCS
# Unix socket. Both are NON-PERSISTENT by design: a slot file that survived
# a reboot while its lease did not would void mutual exclusion, so systemd
# recreates the directory empty at every boot and the engine creates the
# slot files once, before the write path is served.
#
# NOTE: this directive must exist in BOTH packaging/systemd/vloud.service
# and the bootstrap.sh heredoc unit — bootstrap writes its own units to
# /etc/systemd/system, so a directive added to only one will not reach a
# fresh install.
RuntimeDirectory=vloud-vcs
RuntimeDirectoryMode=0750
AmbientCapabilities=CAP_NET_BIND_SERVICE
# The engine runs as User=root (the intended model — see the long note in
# packaging/systemd/vloud.service). uid 0 alone grants nothing here: the
# CapabilityBoundingSet is what decides what root may do, and it must be
# wide enough for the daemon-provisioning the engine shells out to. An
# earlier fresh-install unit ran as User=vloud with a narrow set
# (NET_BIND_SERVICE SETUID SETGID); that broke mail-domain provisioning
# two ways — root-via-sudo couldn't traverse the vloud-owned 0750
# /var/lib/vloud to read staged DKIM keys (needs CAP_DAC_OVERRIDE), and
# the vloud+sudo model needs complete sudoers grants for EVERY privileged
# command (the DKIM chown/chmod/reload/postmap were ungranted → denied).
# User=root + direct-exec sidesteps the sudoers-completeness problem.
# CAP_SYS_PTRACE: the System Guardian reads /proc/<pid>/exe of other users'
# processes to tell a real daemon from a disguised /tmp binary; without it
# every lookup is EACCES (C02-T02).
# Keep this list in sync with packaging/systemd/vloud.service.
CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_FSETID CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_SYS_ADMIN CAP_SYS_RESOURCE CAP_AUDIT_WRITE CAP_MKNOD CAP_SYS_PTRACE

[Install]
WantedBy=multi-user.target
EOF

# NOT PartOf=vloud.service (C05-T4): an engine restart or hot-deploy used to
# stop this worker too, killing every pipeline build in flight. The worker
# owns no engine state; do_stop_services stops it explicitly, FIRST, and waits
# for its bounded drain. Keep in sync with packaging/systemd/vloud-job-worker.service.
cat > "$SYSTEMD_WORKER" <<EOF
[Unit]
Description=Vloud job worker (BullMQ consumer for deploys + SSL)
Documentation=https://docs.vloud.sh/
After=network-online.target redis-server.service redis.service vloud.service
Wants=network-online.target redis-server.service redis.service
StartLimitBurst=5
StartLimitIntervalSec=60s

[Service]
Type=simple
User=root
Group=root
Slice=vloud.slice
$(vloud_engine_unit_exec job-worker "${VLOUD_ENGINE_FORMAT:-sea}")
EnvironmentFile=$ENV_FILE

Restart=on-failure
RestartSec=3s
TimeoutStartSec=30s
# Pipeline drain (C05-T4): SIGTERM to the worker alone, so its build steps
# survive while it waits up to VLOUD_PIPELINE_DRAIN_SECONDS (600) for them;
# 660s = drain + 60. install_pipeline_host_units re-renders both in a drop-in
# from the operator's value.
KillMode=mixed
TimeoutStopSec=660s
KillSignal=SIGTERM
SendSIGKILL=yes

StandardOutput=journal
StandardError=journal
SyslogIdentifier=vloud-job-worker

NoNewPrivileges=false
ProtectSystem=false
ProtectHome=false
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
ReadWritePaths=/var/lib/vloud /var/log/vloud

[Install]
WantedBy=multi-user.target
EOF

cat > "$SYSTEMD_SCHEDULER" <<EOF
[Unit]
Description=Vloud scheduler (periodic job enqueuer + sweepers)
Documentation=https://docs.vloud.sh/
After=network-online.target redis-server.service redis.service vloud-job-worker.service
Wants=network-online.target redis-server.service redis.service
PartOf=vloud.service
StartLimitBurst=5
StartLimitIntervalSec=60s

[Service]
Type=simple
User=root
Group=root
Slice=vloud.slice
$(vloud_engine_unit_exec scheduler "${VLOUD_ENGINE_FORMAT:-sea}")
EnvironmentFile=$ENV_FILE

Restart=on-failure
RestartSec=3s
TimeoutStartSec=30s
TimeoutStopSec=30s
KillSignal=SIGTERM
SendSIGKILL=yes

StandardOutput=journal
StandardError=journal
SyslogIdentifier=vloud-scheduler

NoNewPrivileges=false
ProtectSystem=false
ProtectHome=false
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
ReadWritePaths=/var/lib/vloud /var/log/vloud

[Install]
WantedBy=multi-user.target
EOF

# Phase E (2026-05-14): vloud-rollback.service — triggered by
# vloud.service's OnFailure hook when the engine crash-loops past
# StartLimitBurst. Shells to bootstrap.sh --rollback --automatic
# which restores /opt/vloud.pre-upgrade-<ts>/ (the newest slot).
#
# The unit guards on the absence of /var/lib/vloud/.rollback-disabled
# so a debugging operator can suppress auto-rollback by touching that
# file. Re-enable by deleting it.
#
# Type=oneshot — fires, does its work, exits. Not persistent.
cat > "$SYSTEMD_ROLLBACK" <<EOF
[Unit]
Description=Vloud automatic rollback on repeated engine start-failure
Documentation=https://docs.vloud.sh/operations/install-upgrade-runbook.md
# Suppress when operator is debugging.
ConditionPathExists=!/var/lib/vloud/.rollback-disabled
# Avoid an infinite rollback loop: if rollback ITSELF starts firing
# repeatedly, stop trying after 3 attempts in 10 minutes.
StartLimitBurst=3
StartLimitIntervalSec=600

[Service]
Type=oneshot
User=root
# --automatic flag tells bootstrap.sh this is a programmatic invocation,
# not an operator click. (Currently identical to plain --rollback; the
# flag exists so operator-vs-systemd-driven rollbacks are
# distinguishable in the progress log.)
ExecStart=$VLOUD_INSTALL_DIR/scripts/bootstrap.sh --rollback --automatic

StandardOutput=journal
StandardError=journal
SyslogIdentifier=vloud-rollback
EOF

# Worker drain drop-in, the CI slice and the dependency-cache root — the same
# call do_upgrade_flow and do_repair_flow make.
install_pipeline_host_units
# pm2-root.service exists by now (started in the pm2 step above): order it
# after its backing services. And a Vloud-managed BIND refuses AXFR. Both are
# repeated by --upgrade and --repair.
install_pm2_unit_dropins
ensure_bind_no_transfer

systemctl daemon-reload
systemctl enable vloud.service vloud-job-worker.service vloud-scheduler.service >/dev/null 2>&1
# vloud-rollback.service is NOT enabled (don't start at boot) — it's
# triggered only by vloud.service's OnFailure hook.
ok "vloud.{service,job-worker,scheduler,rollback} installed + enabled"

# Re-normalise ownership now that the units EXIST.
#
# The earlier chown runs before this point, so engine_service_user()
# had no installed unit to read and fell back to the one in the release
# tree (packaging/systemd/vloud.service → User=root). This block then
# installs bootstrap's own units, which use User=vloud. The result was
# a tree owned by root:root under a service running as vloud, and the
# engine refused to boot: "BOOT REFUSED — SQLite disk…".
#
# Reading the unit that is actually installed makes the two agree
# regardless of which template won.
ENGINE_USER=$(engine_service_user)
chown -R "$ENGINE_USER:$ENGINE_USER" \
  "$VLOUD_INSTALL_DIR/packages" "$VLOUD_INSTALL_DIR/node_modules" 2>/dev/null || true
ok "ownership re-normalised to $ENGINE_USER:$ENGINE_USER (matches the installed unit)"

# Per-tenant Redis: unit template + target, the vloud-redis account and its
# dirs. After the engine units exist, because the dir owner follows the
# engine's User= (engine_service_user reads the installed unit).
ensure_tenant_redis_plumbing
# Engine Redis ACL user — opt-in (VLOUD_REDIS_AUTH=1); the env file exists by
# now and the engine has not started yet.
ensure_engine_redis_auth
# Where tenant static sites are published (owner follows the engine's User=,
# so after the units exist) — see ensure_tenant_static_root.
ensure_tenant_static_root

# ─── 4b. SELinux + firewall prep (RHEL family; no-op on Debian) ───
# Must run BEFORE the engine starts: under enforcing SELinux the engine
# can't bind its port and nginx can't proxy to it without these.
configure_selinux
if [[ "$OS_FAMILY" == rhel ]]; then
  # nginx serves the panel on 80/443; the engine itself stays loopback.
  fw_open_port 80 tcp
  fw_open_port 443 tcp
  fw_reload
fi

# ─── 5. Start engine + workers ───
# Order: engine first, then worker, then scheduler. PartOf= on the
# scheduler means a `systemctl restart vloud` cycles it too; the job
# worker is deliberately NOT PartOf (C05-T4 — an engine restart must not
# kill pipeline builds), so it is always restarted explicitly. Starting
# them individually here lets us surface failures distinctly (a worker
# boot loop won't get blamed on the engine).
say "starting engine"
systemctl restart vloud.service

HEALTHY=0
for i in $(seq 1 30); do
  if vloud_probe_engine_health 1; then
    HEALTHY=1; break
  fi
  sleep 1
done
[[ "$HEALTHY" -eq 1 ]] || die "engine did not become healthy on :$VLOUD_PORT within 30s — see journalctl -u vloud" 4

ok "engine healthy on :$VLOUD_PORT"

# Start the worker + scheduler now that the engine is up. We do
# this best-effort: if Redis isn't running on this host the worker
# unit's Requires=redis.service will refuse to start, and that's
# the right failure mode (BullMQ has no Redis fallback). Operators
# on Redis-less installs can disable these units.
say "starting workers"
# 2026-05-14: Redis is now installed + verified in step 2, so this
# is fail-fast instead of the previous warn-and-skip. If the operator
# explicitly opted out via VLOUD_BOOTSTRAP_NO_REDIS=1 (offline-only
# install variant — undocumented escape hatch), the workers go up
# disabled and the engine logs a degraded-mode warning at boot.
if [[ "${VLOUD_BOOTSTRAP_NO_REDIS:-0}" == "1" ]]; then
  warn "VLOUD_BOOTSTRAP_NO_REDIS=1 — workers NOT started (BullMQ paths disabled)"
elif systemctl is-active --quiet redis-server.service 2>/dev/null \
     || systemctl is-active --quiet redis.service 2>/dev/null; then
  if systemctl restart vloud-job-worker.service vloud-scheduler.service 2>/dev/null; then
    ok "vloud-job-worker + vloud-scheduler running"
  else
    do_dump_failure_journal
    die "worker or scheduler failed to start — check 'journalctl -u vloud-job-worker' / 'journalctl -u vloud-scheduler'" 9
  fi
else
  die "redis is not active — bootstrap should have ensured this in step 2. Inspect 'systemctl status redis-server' + 'journalctl -u redis-server'" 9
fi

# ─── 6. Phase 1 daemon stack ───
# Postfix / Dovecot / Rspamd / fail2ban / BIND / pure-ftpd / nftables /
# tenant.slice templates / storage-quota tools. Each child script is
# idempotent and short-circuits if the daemon is already configured.
# cPanel-detected hosts skip the stack by default (cPanel owns those
# services). Master skip: VLOUD_BOOTSTRAP_NO_DAEMON_STACK=1.
say "configuring Phase 1 daemon stack"

DAEMON_STACK_SCRIPT="$VLOUD_INSTALL_DIR/scripts/bootstrap-daemon-stack.sh"
# Fallback when bootstrap.sh is run from the source tree (e.g. dev test
# of the bootstrap pipeline before a tarball ships).
if [[ ! -x "$DAEMON_STACK_SCRIPT" ]]; then
  ALT="$(dirname "${BASH_SOURCE[0]}")/bootstrap-daemon-stack.sh"
  [[ -x "$ALT" ]] && DAEMON_STACK_SCRIPT="$ALT"
fi

if [[ ! -x "$DAEMON_STACK_SCRIPT" ]]; then
  warn "bootstrap-daemon-stack.sh missing — daemons can be installed manually later"
else
  # We pass through the cPanel detection result; the child script will
  # also detect on its own (defence in depth).
  if [[ "$COEXIST" -eq 1 ]] && [[ "${VLOUD_BOOTSTRAP_DAEMON_STACK:-0}" != "1" ]]; then
    warn "cPanel detected — daemon stack skipped (set VLOUD_BOOTSTRAP_DAEMON_STACK=1 to override)"
  else
    DAEMON_RC=0
    bash "$DAEMON_STACK_SCRIPT" || DAEMON_RC=$?
    case "$DAEMON_RC" in
      0) ok "daemon stack: all installed" ;;
      2) warn "daemon stack: one or more daemons failed (continued; see log above)" ;;
      *) warn "daemon stack: aborted (rc=$DAEMON_RC)" ;;
    esac
  fi
fi

# 2026-05-14: Final dependency gate. Bootstrap is considered complete
# only when every required runtime dep is present + healthy + reachable.
# Fresh-host validation on 188.245.113.223 found that bootstrap was
# silently producing engines without redis; this gate prevents that
# from ever shipping again. Failure here is fatal — install summary
# below is meaningless if deps don't pass.
if ! do_verify_dependencies; then
  die "install verification failed — see failures above. Bootstrap did NOT complete cleanly." 10
fi

# Trial status read-back.
TRIAL_JSON=$(curl -fsS "http://127.0.0.1:$VLOUD_PORT/api/install-trial/status" 2>/dev/null || echo '{}')
TRIAL_STATE=$(printf '%s' "$TRIAL_JSON" | grep -oE '"state":"[^"]+"' | cut -d'"' -f4 || echo unknown)
TRIAL_DAYS_LEFT=$(printf '%s' "$TRIAL_JSON" | grep -oE '"days_remaining":[0-9]+' | cut -d: -f2 || echo "?")
TRIAL_DETAIL=$(printf '%s' "$TRIAL_JSON" | grep -oE '"detail":"[^"]+"' | cut -d'"' -f4 || true)

# Public IP — hostname -I is reliable on cloud servers; ifconfig.io is the
# fallback for one-NIC nodes behind NAT.
PUBLIC_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [[ -z "$PUBLIC_IP" ]] || [[ "$PUBLIC_IP" == "127."* ]]; then
  PUBLIC_IP=$(curl -fsS --max-time 5 https://ifconfig.io 2>/dev/null || echo "<unknown>")
fi

# ─── Summary ───
vloud_logo
printf "${GREEN}═══════════════════════════════════════════════════════════════${RESET}\n"
printf "${GREEN}  ✓ Vloud is up.${RESET}\n"
printf "${GREEN}═══════════════════════════════════════════════════════════════${RESET}\n"
echo
echo "  Server IP:    $PUBLIC_IP"
# Print a URL that actually resolves. The engine binds $VLOUD_BIND_HOST,
# which defaults to 127.0.0.1 (M-04: no reason to expose the upstream
# directly). Advertising http://$PUBLIC_IP:$VLOUD_PORT regardless sent
# operators to an address that refuses every connection — and because
# nginx deliberately does not claim port 80 either, there was no
# working route to the first-run wizard from another machine at all.
if [[ "$VLOUD_BIND_HOST" == "0.0.0.0" || "$VLOUD_BIND_HOST" == "::" ]]; then
  echo "  Dashboard:    http://$PUBLIC_IP:$VLOUD_PORT/onboarding"
else
  echo "  Dashboard:    http://127.0.0.1:$VLOUD_PORT/onboarding   (on this server)"
  echo "                the engine listens on $VLOUD_BIND_HOST only, so the"
  echo "                server's LAN address will refuse the connection."
  echo
  echo "  From another machine, forward the port over SSH:"
  echo "                ssh -L $VLOUD_PORT:127.0.0.1:$VLOUD_PORT $(vloud_ssh_user)@$PUBLIC_IP"
  echo "                (the login you use to connect to this server; use your own if that is not it)"
  echo "                then open http://127.0.0.1:$VLOUD_PORT/onboarding"
  echo
  echo "  Or expose it on the network (trusted networks only):"
  echo "                sudo sed -i 's/^VLOUD_BIND_HOST=.*/VLOUD_BIND_HOST=0.0.0.0/' $ENV_FILE"
  echo "                sudo systemctl restart vloud"
fi
echo "  Trial:        $(vloud_trial_line "$TRIAL_STATE" "$TRIAL_DAYS_LEFT" "$TRIAL_DETAIL" "$VLOUD_TRIAL_DAYS")"
echo "  Coexist:      $( [[ "$COEXIST" -eq 1 ]] && echo "yes (cPanel detected)" || echo "no" )"
echo
echo "  Manage:       sudo systemctl {start,stop,restart,status} vloud"
echo "  Tail logs:    sudo journalctl -u vloud -f"
echo "  Env file:     $ENV_FILE  (mode 0640 root:vloud)"
echo "  Install dir:  $VLOUD_INSTALL_DIR"
echo
echo "  Open the dashboard URL above to run the first-run wizard."
echo

exit 0
