#!/usr/bin/env bash
# Clusev updater — pull the latest code and re-run the idempotent installer (root required).
# One command to update an existing install:
#
#   sudo ./update.sh
#
# In order:
#   1. Mark the repo a safe git dir for root. install.sh chowns the tree to the `clusev` user,
#      so a root-run `git pull` would otherwise refuse with "dubious ownership".
#   2. git pull --ff-only, PINNED to the recorded release branch. Aborts cleanly on any conflict /
#      divergence — it NEVER discards local changes.
#   3. Verify the pulled HEAD's commit signature against a trusted maintainer key (opt-in via a
#      gitignored allowed-signers file) BEFORE re-exec/build — fail-closed when configured.
#   4. Self-update: if update.sh itself changed in the pull, re-exec the NEW copy once (guarded
#      against loops) so improvements to this very script take effect on the same run.
#   5. Hand off to ./install.sh — idempotent: rebuilds the image, restarts the stack, migrates,
#      refreshes the MOTD and fixes ownership. Run NON-interactively (stdin from /dev/null) so
#      it never re-prompts; secrets and the configured domain / ACME e-mail are preserved.
set -euo pipefail

cd "$(dirname "$0")"
REPO_DIR="$(pwd)"

# Never let a private-repo credential miss HANG the update: git must fail fast instead of blocking on
# an interactive username/password prompt (the systemd updater has no TTY, so a prompt would wait
# forever — the classic "update spins with no error"). Also abort a stalled transfer instead of
# hanging on it. The pull itself is additionally wrapped in `timeout` below.
export GIT_TERMINAL_PROMPT=0
export GIT_HTTP_LOW_SPEED_LIMIT="${GIT_HTTP_LOW_SPEED_LIMIT:-1000}"
export GIT_HTTP_LOW_SPEED_TIME="${GIT_HTTP_LOW_SPEED_TIME:-30}"

# set_stage: publish a coarse update stage for the in-browser progress screen (best-effort — the
# app container serves run/update-phase.json to it). A write failure must never fail the update.
# Written first as "fetch" so the screen leaves the time-guess behind the moment the pull begins,
# and so a stale stage from a previous update is reset.
set_stage() {
  # Symlink-safe write (mirror of run_write_json in the restart-sentinel): ./run is the
  # container-writable bind mount, so update-phase.json could be pre-planted as a symlink. Build the
  # temp at the REPO ROOT — same filesystem as ./run but not mounted into any container, so it can't
  # be tampered mid-write (no TOCTOU) — then rename(2) it over the target: an atomic same-fs rename
  # replaces the link itself with our regular file and never follows it.
  local d="$REPO_DIR/run" tmp
  mkdir -p "$d" 2>/dev/null || true
  tmp="$(mktemp "${REPO_DIR}/.phase.XXXXXX" 2>/dev/null)" || return 0
  printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; }
  chmod 644 "$tmp" 2>/dev/null || true
  mv -f "$tmp" "${d}/update-phase.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
  return 0
}
set_stage fetch

bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf '  %s\n' "$*"; }
die()  { printf '\033[31m  x %s\033[0m\n' "$*" >&2; exit 1; }

[ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo ./update.sh"
[ -f docker-compose.prod.yml ] || die "Keine Clusev-Installation hier (docker-compose.prod.yml fehlt)."
[ -x ./install.sh ] || [ -f ./install.sh ] || die "install.sh fehlt — Repo unvollstaendig."
command -v git >/dev/null 2>&1 || die "git ist nicht installiert."

bold "Clusev Update"
echo  "============="

# Steps 1–3 run only on the first pass; a re-exec (after a self-update) jumps straight to step 4.
if [ "${CLUSEV_UPDATE_REEXEC:-0}" != 1 ]; then
  # 1. let root operate on the clusev-owned working tree
  git config --global --add safe.directory "$REPO_DIR" 2>/dev/null || true

  # 2. fast-forward pull only — never auto-merge or throw away local edits.
  #    PIN the pull to the branch recorded at install time (install.sh writes CLUSEV_BUILD_BRANCH
  #    into .env from the then-current HEAD) instead of implicitly following whatever branch is
  #    checked out now — otherwise anyone able to `git checkout` in this tree (the unprivileged
  #    clusev user) could silently redirect the next ROOT update to an arbitrary branch/fork.
  #    Refuse when the checkout diverges from it; fall back to a plain pull only when no branch
  #    was recorded (an install predating this field).
  pin_branch="$(grep -m1 '^CLUSEV_BUILD_BRANCH=' .env 2>/dev/null | cut -d= -f2-)"
  pull_args=(--ff-only)
  if [ -n "$pin_branch" ]; then
    current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
    [ "$current_branch" = "$pin_branch" ] \
      || die "Ausgecheckter Branch '${current_branch}' weicht vom Installations-Branch '${pin_branch}' ab — Update abgebrochen. Mit 'git checkout ${pin_branch}' zuruecksetzen und erneut versuchen."
    pull_args=(--ff-only origin "$pin_branch")
  fi
  before_hash="$(sha256sum -- "$0" | cut -d' ' -f1)"
  info "Hole Aktualisierungen (git pull --ff-only) ..."
  timeout 300 git pull "${pull_args[@]}" \
    || die "git pull fehlgeschlagen — Timeout, fehlende Zugangsdaten zum privaten Repo, lokale Aenderungen oder divergierter Branch. Mit 'git status' und dem Repo-Zugriff (Token im origin-Remote) pruefen und erneut versuchen."
  after_hash="$(sha256sum -- "$0" | cut -d' ' -f1)"

  # 3. Verify the pulled HEAD is signed by a TRUSTED maintainer key BEFORE anything acts on it
  #    (the re-exec of this very script below, then the ROOT `docker compose build` in install.sh).
  #    The allowed-signers list lives in a GITIGNORED file — `.env` (CLUSEV_ALLOWED_SIGNERS, absolute
  #    path preferred) or the repo-root `.clusev-allowed-signers` — so a pulled or MITM'd tree can
  #    never swap the trusted key (git pull does not touch untracked/ignored files). This is the
  #    missing supply-chain control: the branch-pin above stops a local `git checkout` redirect, but
  #    only a signature stops a compromised ORIGIN / transport from feeding attacker commits to root.
  #    OPT-IN: with no signer list configured this is a loud no-op so existing installs keep updating;
  #    once configured, a bad OR absent signature FAILS CLOSED. Activate it by provisioning the
  #    maintainer public key + signing releases — see docs/…/self-update-signature-verification.md.
  # Read the .env value BARE (no quote-stripping) — force_kv/set_kv write unquoted values, same as
  # every other .env read here (CLUSEV_BUILD_BRANCH above, watch.sh); a quoted value simply won't
  # resolve to a file and so fails closed below.
  signers="$(grep -m1 '^CLUSEV_ALLOWED_SIGNERS=' .env 2>/dev/null | cut -d= -f2-)"
  if [ -n "$signers" ]; then
    # CLUSEV_ALLOWED_SIGNERS explicitly set => verification is REQUIRED. A missing/unreadable file
    # must FAIL CLOSED, never silently downgrade to "not configured" (that fail-open would let an
    # attacker who can remove the anchor skip verification entirely).
    [ -f "$signers" ] && [ -r "$signers" ] \
      || die "CLUSEV_ALLOWED_SIGNERS ist gesetzt (${signers}), aber die Datei fehlt oder ist unlesbar — Update abgebrochen. Signaturpruefung darf nicht stillschweigend ausfallen."
  elif [ -f "$REPO_DIR/.clusev-allowed-signers" ]; then
    signers="$REPO_DIR/.clusev-allowed-signers"
  fi
  if [ -n "$signers" ]; then
    # The trust anchor must NOT be repointable or swappable via a pull. Reject a SYMLINK first: `-f`
    # follows the link and `git ls-files` checks the LINK path (not its target), so an untracked
    # symlink to a tracked, pull-overwritable file would otherwise slip past the tracked-check below.
    if [ -L "$signers" ]; then
      die "Der allowed-signers Trust-Anchor (${signers}) ist ein Symlink — nicht erlaubt (koennte auf eine versionierte, per Pull austauschbare Datei zeigen). Bitte eine echte Datei verwenden."
    fi
    # And it must NOT be git-tracked (pull-overwritable): otherwise a malicious origin could ship its
    # OWN allowed-signers file in the very commit being verified and pass the check against the
    # attacker's key. Only untracked/gitignored or out-of-tree paths are trusted.
    if git ls-files --error-unmatch -- "$signers" >/dev/null 2>&1; then
      die "Der allowed-signers Trust-Anchor (${signers}) ist git-versioniert und damit ueber einen Pull austauschbar — Update abgebrochen. Die Datei muss untracked/gitignored sein (siehe .clusev-allowed-signers) oder ausserhalb des Arbeitsbaums liegen."
    fi
    info "Verifiziere Signatur des gepullten Commits (${signers}) ..."
    git -c gpg.ssh.allowedSignersFile="$signers" verify-commit HEAD \
      || die "Signaturpruefung fehlgeschlagen — HEAD ist NICHT von einem vertrauten Maintainer-Schluessel signiert. Update abgebrochen (moegliche Kompromittierung von Origin oder Transport). Der Stack laeuft unveraendert weiter."
    info "Signatur ok."
  else
    info "Hinweis: Commit-Signaturpruefung nicht konfiguriert (CLUSEV_ALLOWED_SIGNERS bzw. .clusev-allowed-signers) — uebersprungen."
  fi

  # 4. self-update: relaunch the new (now signature-verified) update.sh if it changed
  if [ "$before_hash" != "$after_hash" ]; then
    info "update.sh wurde aktualisiert — starte die neue Version ..."
    exec env CLUSEV_UPDATE_REEXEC=1 "$0" "$@"
  fi
fi

# Refresh the update source from the (possibly changed) clone origin before handing off.
[ -x ./scripts/set-repository-url.sh ] && ./scripts/set-repository-url.sh "$(pwd)" ./.env || true

# 5. apply via the idempotent installer, non-interactively (no prompts; config preserved)
info "Wende Update an (install.sh) ..."
exec ./install.sh </dev/null
