#!/bin/bash # App Updater command handler — `libreportal updater ` # --------------------------------------------------------------------------- # Dispatched automatically by cli_initialize.sh (category -> cliHandleUpdaterCommands). # Subcommands (the features/updater WebUI buttons route to these as tasks): # check refresh the version/CVE data (runs the WebUI generator), # then enqueue the updates for apps set to UPDATE_TYPE=auto # (cli_updater_auto.sh owns that decision) # apply update one app — DISASTER-RECOVERY FIRST: snapshot the app # via the backup engine, then pull + recreate; on failure, # roll back to the snapshot automatically. A trailing `auto` # marks it as policy-driven for History; the work is identical. # apply-all [a,b] apply to a comma-list (or every update-available app) # rollback restore the app's most recent pre-update snapshot # # State-changing subcommands use the standard task-exec split: invoked normally # they enqueue a task (so the WebUI + CLI share locking + the audit trail); # the task processor re-invokes them with LIBREPORTAL_TASK_EXEC=1 to do the work. cliHandleUpdaterCommands() { local sub="$initial_command2" local app="$initial_command3" case "$sub" in ""|"check") # `check auto` — the task processor's idle poll calls this every # ~60s; self-throttle on the age of the generated updates.json so a # full scan only happens once per CFG_UPDATER_SCAN_INTERVAL minutes # (0 disables automatic scans). A manual check / post-update rescan # rewrites updates.json, which resets this clock for free. A missing # file means never scanned -> run now (first scan needs no click). if [[ "$app" == "auto" ]]; then local scan_interval="${CFG_UPDATER_SCAN_INTERVAL:-30}" [[ "$scan_interval" =~ ^[0-9]+$ ]] || scan_interval=30 (( scan_interval == 0 )) && return 0 local scan_file="${containers_dir%/}/libreportal/frontend/data/updater/generated/updates.json" if [[ -f "$scan_file" ]]; then local _now _last; _now=$(date +%s); _last=$(stat -c '%Y' "$scan_file" 2>/dev/null || echo 0) (( _now - _last < scan_interval * 60 )) && return 0 fi fi # An explicit check (the Check-now button, or `updater check` by # hand) forces a live registry lookup. Without this the button # silently reused the 6-hour digest cache, so "Check now" could not # find a build the user knows just shipped. The daemon's background # `check auto` stays throttled. [[ "$app" != "auto" ]] && export UPDATER_REGISTRY_FORCE=1 # Quick + safe — just regenerates the read-only data files. Source # the generator explicitly if the lazy loader hasn't mapped it yet # (new file; the array regen self-heals it on deploy, this covers # the gap before that). if ! declare -F webuiUpdaterScan >/dev/null 2>&1; then source "$install_scripts_dir/webui/data/generators/updater/webui_updater_scan.sh" 2>/dev/null fi webuiUpdaterScan # Hotfix channel: refresh the signed artifact index for the WebUI, then # auto-apply the eligible signed hotfixes (gated by CFG_HOTFIX_AUTO). if ! declare -F webuiArtifactScan >/dev/null 2>&1; then source "$install_scripts_dir/webui/data/generators/updater/webui_artifact_scan.sh" 2>/dev/null fi declare -F webuiArtifactScan >/dev/null 2>&1 && webuiArtifactScan # Registry catalog: refresh the App Center's marketplace data # (the type:"app" rows of the same signed index). if ! declare -F webuiRegistryCatalogScan >/dev/null 2>&1; then source "$install_scripts_dir/webui/data/generators/apps/webui_registry_scan.sh" 2>/dev/null fi declare -F webuiRegistryCatalogScan >/dev/null 2>&1 && webuiRegistryCatalogScan if ! declare -F artifactApplyAuto >/dev/null 2>&1; then source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null fi declare -F artifactApplyAuto >/dev/null 2>&1 && artifactApplyAuto # App images: enqueue the updates for apps set to UPDATE_TYPE=auto. # Runs LAST so it acts on the updates.json the scan above just wrote. if ! declare -F updaterApplyAuto >/dev/null 2>&1; then source "$install_scripts_dir/cli/commands/updater/cli_updater_auto.sh" 2>/dev/null fi declare -F updaterApplyAuto >/dev/null 2>&1 && updaterApplyAuto # App versions: step apps set to auto onto the next published # release, one rung and once a day. Runs after the in-line updates # so an app that just took a rebuild is already skipped as pending. declare -F updaterUpgradeAuto >/dev/null 2>&1 && updaterUpgradeAuto ;; "apply"|"now") if [[ -z "$app" ]]; then isError "Usage: libreportal updater apply [auto]"; return 1; fi # Optional 4th word marks an automatic (policy-driven) update, so # History can say who pressed the button. Anything else = manual. local trigger="manual"; [[ "$initial_command4" == "auto" ]] && trigger="auto" if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then updaterApplyApp "$app" "$trigger" else # Carry the marker into the queued command so the task that # actually runs still knows who asked for it. local apply_cmd="libreportal updater apply $app" [[ "$trigger" == "auto" ]] && apply_cmd="$apply_cmd auto" cliTaskRun "$apply_cmd" "updater_apply" "$app" "" fi ;; "upgrade") # Stepped, cross-version upgrade for apps that cannot skip a # release. Separate verb from `apply` on purpose: apply moves you # within a line, upgrade moves you between lines, and only the # latter needs a ladder, a verifier and a snapshot per step. if [[ -z "$app" ]]; then isError "Usage: libreportal updater upgrade [version] [--dry-run]"; return 1; fi local upgrade_target="$initial_command4" upgrade_mode="$initial_command5" # Any flag in the version slot is a flag, not a version. Without # this, `upgrade --detach` treated "--detach" as the target # and refused with "no safe path to --detach" — it failed safe, but # blaming the version for a misplaced flag is a poor way to say # "that flag isn't supported here". # --auto marks a climb the updater scheduled for itself, so History # can say so. It is the ONLY difference from the button: same # engine, same snapshot-and-verify per rung, same rollback. case "$upgrade_mode" in --auto) export UPDATER_UPGRADE_TRIGGER=auto; upgrade_mode="" ;; esac case "$upgrade_target" in --dry-run) upgrade_mode="--dry-run"; upgrade_target="" ;; --auto) export UPDATER_UPGRADE_TRIGGER=auto; upgrade_target="" ;; --*) isError "Unknown option '$upgrade_target'. Usage: libreportal updater upgrade [version] [--dry-run]"; return 1 ;; esac for _f in cli_updater_ladder cli_updater_verify cli_updater_upgrade; do declare -F updaterUpgradeApp >/dev/null 2>&1 || \ source "$install_scripts_dir/cli/commands/updater/${_f}.sh" 2>/dev/null done # A dry run touches nothing, so it runs inline — making the plan # instant to read instead of arriving via the task log. if [[ "$upgrade_mode" == "--dry-run" || "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then updaterUpgradeApp "$app" "$upgrade_target" "$upgrade_mode" else cliTaskRun "libreportal updater upgrade $app $upgrade_target" "updater_upgrade" "$app" "" fi ;; "apply-all") local list="$app" # optional comma-list in $initial_command3 if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then updaterApplyAll "$list" else cliTaskRun "libreportal updater apply-all $list" "updater_apply_all" "updater" "" fi ;; "rollback") if [[ -z "$app" ]]; then isError "Usage: libreportal updater rollback "; return 1; fi if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then updaterRollbackApp "$app" else cliTaskRun "libreportal updater rollback $app" "updater_rollback" "$app" "" fi ;; *) cliShowUpdaterHelp ;; esac } # Digest (sha256:…) of a locally-present image ref; "" if absent. Rootless-aware. updaterRefDigest() { local ref="$1" out out="$(dockerCommandRun "docker inspect --format '{{index .RepoDigests 0}}' $ref" 2>/dev/null | tr -d '\r' | head -1)" out="${out##*@}"; case "$out" in sha256:*) printf '%s' "$out";; esac } # Rewrite the app's ANCHOR (-service) image line to $newref, preserving the # original indent and any trailing ` #LIBREPORTAL|…` version sentinel. Targets the # anchor by service name (not the first image line — some apps list a companion # first), so it's correct for ollama et al. updaterSetAnchorRef() { local app="$1" newref="$2" local compose="${containers_dir%/}/$app/docker-compose.yml" [ -f "$compose" ] || return 1 local tmp; tmp="$(mktemp)" awk -v s="${app//_/-}-service" -v ref="$newref" ' !done && seen && /^[[:space:]]*image:/ { match($0,/^[[:space:]]*/); ind=substr($0,1,RLENGTH) cmt=""; if (match($0,/#.*/)) cmt=" " substr($0,RSTART) print ind "image: " ref cmt; done=1; next } $0 ~ ("^[[:space:]]*" s ":") { seen=1 } { print } ' "$compose" > "$tmp" || { rm -f "$tmp"; return 1; } runFileWrite "$compose" < "$tmp"; local rc=$? rm -f "$tmp"; return $rc } # The image ref (repo:tag@sha256:…) the app ran BEFORE its last successful update # — i.e. the roll-back target. Read from history.json's most recent update/ok. updaterLastUpdateFrom() { local app="$1" hist="$containers_dir/libreportal/frontend/data/updater/generated/history.json" [ -f "$hist" ] && command -v jq >/dev/null 2>&1 || return 0 jq -r --arg a "$app" 'first(.entries[]? | select(.app==$a and .action=="update" and .result=="ok") | .from) // ""' "$hist" 2>/dev/null } # Update one app with disaster-recovery: snapshot -> pull -> recreate -> verify, # auto-rolling-back on failure. Uses existing primitives (the backup CLI for the # snapshot, docker compose for the image swap) so it shares their locking/logging. updaterApplyApp() { # `_upd_app`, not `app`: bash is dynamically scoped, so a callee that uses an # undeclared `app` (a while-read loop leaves it EMPTY at EOF) reaches up and # overwrites OUR local. That is not hypothetical — the backup dashboard # generator, which runs at the end of the snapshot below, did exactly that, # and the update then ran against an empty app name. The generator is fixed; # this name makes the update immune to the next one. local _upd_app="$1" # "auto" when the updater's own policy enqueued this (CFG__UPDATE_TYPE), # "manual" when a person pressed Update. Recorded in History; changes nothing # about how the update is applied — both take the snapshot, both can roll back. local trigger="${2:-manual}" local app_dir="$containers_dir/$_upd_app" if [[ ! -d "$app_dir" ]]; then isError "App '$_upd_app' is not installed."; return 1; fi if [[ "$trigger" == "auto" ]]; then isHeader "Automatically updating $_upd_app (a recovery snapshot is taken first)" else isHeader "Updating $_upd_app (a recovery snapshot is taken first)" fi # 1. DISASTER RECOVERY — snapshot before touching anything. Call the backup # function directly (we already run under LIBREPORTAL_TASK_EXEC): the CLI form # `backup app "$app"` parsed the app name as the ACTION, hit the dispatcher's # `*)` default (a notice that exits 0), so the `if !` guard passed and the app # was updated with NO snapshot — and rollback below was a no-op that reported # success. backupAppStart is the real entry point and returns 0/1 honestly. isNotice "Snapshotting $_upd_app before update…" if ! backupAppStart "$_upd_app" >/dev/null 2>&1; then isNotice "Pre-update snapshot did not complete cleanly — continuing is risky; aborting $_upd_app update." updaterRecordHistory "$_upd_app" "update" "" "" "aborted-no-snapshot" "" "" "" "$trigger" return 1 fi # 2. Capture the current ANCHOR image + its running digest, so from->to is an # exact build reference (repo:tag@sha256:…) even for a floating tag. Anchor is # the -service image (updaterPrimaryImage), NOT the first line. If a # prior rollback pinned a digest, unpin it first so we track the channel again. local anchor; anchor="$(updaterPrimaryImage "$_upd_app" "$app_dir/docker-compose.yml")" case "$anchor" in *@sha256:*) updaterSetAnchorRef "$_upd_app" "${anchor%%@*}"; anchor="${anchor%%@*}";; esac local before_dig; before_dig="$(updaterRefDigest "$anchor")" local before="$anchor${before_dig:+@$before_dig}" # 3. Pull + recreate (uses the real, install-type-aware compose helpers). isNotice "Pulling new image(s) for $_upd_app…" if updaterComposePull "$_upd_app" && dockerComposeUp "$_upd_app" >/dev/null 2>&1; then local after_ref; after_ref="$(updaterPrimaryImage "$_upd_app" "$app_dir/docker-compose.yml")"; after_ref="${after_ref%%@*}" local after_dig; after_dig="$(updaterRefDigest "$after_ref")" local after="$after_ref${after_dig:+@$after_dig}" updaterRecordHistory "$_upd_app" "update" "$before" "$after" "ok" "" "" "" "$trigger" isSuccessful "$_upd_app updated. Rollback point retained." webuiUpdaterScan >/dev/null 2>&1 || true return 0 fi # 4. Failure -> automatic rollback. isNotice "Update of $_upd_app failed — rolling back to the pre-update snapshot…" updaterRollbackApp "$_upd_app" "auto" updaterRecordHistory "$_upd_app" "update" "$before" "" "rolled-back" "" "" "" "$trigger" return 1 } updaterApplyAll() { local list="$1" failures=0 if [[ -z "$list" ]]; then isNotice "No app list given; nothing to do (the WebUI passes the update-available apps)." return 0 fi local IFS=',' local app # local: don't leak the loop var into callers for app in $list; do [[ -z "$app" ]] && continue updaterApplyApp "$app" || failures=$((failures+1)) done [[ $failures -gt 0 ]] && isNotice "$failures app(s) failed and were rolled back." || isSuccessful "All requested apps updated." } # Roll an app back to its most recent snapshot. $2='auto' suppresses the header # (called from the failure path of an apply). updaterRollbackApp() { # `_upd_app` for the same dynamic-scoping reason as updaterApplyApp: the # restore engine below is a deep call chain, and this function still needs # the app's name after it returns. local _upd_app="$1" mode="$2" [[ "$mode" != "auto" ]] && isHeader "Rolling $_upd_app back to its pre-update snapshot" # Re-pin the ANCHOR image to the exact build the app ran before the last # update, so `up` below runs the OLD code — not the current channel head. # Without this, restoring the data snapshot but recreating on the new `latest` # image is "new code on old data", the hole a floating tag makes invisible. # (Preserves the version sentinel; apply un-pins it again on the next update.) local prev_ref; prev_ref="$(updaterLastUpdateFrom "$_upd_app")" if [[ -n "$prev_ref" && "$prev_ref" == *@sha256:* ]]; then updaterSetAnchorRef "$_upd_app" "$prev_ref" && isNotice "Pinned $_upd_app back to its pre-update build." fi # Delegate to the restore engine (latest snapshot for this app). Call the # function directly — the old `backup app "$app" restore latest` CLI form was # malformed (parsed as action="$app") so it silently did nothing yet exited 0. if restoreAppStart "$_upd_app" latest "" >/dev/null 2>&1; then dockerComposeUp "$_upd_app" >/dev/null 2>&1 || true [[ "$mode" != "auto" ]] && updaterRecordHistory "$_upd_app" "rollback" "" "" "rolled-back" isSuccessful "$_upd_app restored from its pre-update snapshot." return 0 fi isError "Could not roll $_upd_app back automatically — restore manually from the Backups page." return 1 } # Force a fresh image pull for an app (mirrors up_app.sh's install-type split). # dockerComposeUp uses --quiet-pull which won't re-fetch a moved tag, so we pull # explicitly first to actually pick up a new image. updaterComposePull() { local app="$1" dir="${containers_dir%/}/$1" [ -d "$dir" ] || return 1 if [[ "$CFG_DOCKER_INSTALL_TYPE" == "rootless" ]]; then dockerCommandRunInstallUser "cd $dir && docker compose pull" >/dev/null 2>&1 else ( cd "$dir" && docker compose pull >/dev/null 2>&1 ) fi } # Append an entry to history.json. The "nothing silent" guarantee depends on this # actually recording, so it is FAIL-CLOSED, not best-effort: with jq we prepend + # cap to 200; WITHOUT jq we fall back to a brace-agnostic bash-native prepend # (no 200-cap, the one thing jq bought) rather than silently dropping the entry. # Args 6-8 are optional and carry the artifact channel's metadata; arg 9 records # whether a person or the auto-update policy started it (manual|auto). updaterRecordHistory() { local app="$1" action="$2" from="$3" to="$4" result="$5" local artifact_id="${6:-}" serial="${7:-}" undo_id="${8:-}" trigger="${9:-manual}" local f="$containers_dir/libreportal/frontend/data/updater/generated/history.json" local ts; ts="$(date -Iseconds 2>/dev/null || date)" [ -f "$f" ] || printf '{ "entries": [] }\n' | runFileWrite "$f" if command -v jq >/dev/null 2>&1; then local tmp; tmp="$(mktemp)" if jq --arg ts "$ts" --arg app "$app" --arg action "$action" --arg from "$from" --arg to "$to" \ --arg result "$result" --arg aid "$artifact_id" --arg serial "$serial" --arg undo "$undo_id" \ --arg trigger "$trigger" \ '.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result, artifact_id:$aid, serial:$serial, undo_id:$undo, trigger:$trigger}] + (.entries // []))[0:200]' \ "$f" > "$tmp" 2>/dev/null; then runFileWrite "$f" < "$tmp"; rm -f "$tmp"; return 0 fi rm -f "$tmp" isError "updaterRecordHistory: jq write failed for $f — using bash fallback" fi # jq absent or failed — bash-native, brace-agnostic prepend. History entries # are flat (scalar fields only), so splicing on the outer [ ... ] is safe. local entry entry="{\"ts\":\"$(_lpJsonEsc "$ts")\",\"app\":\"$(_lpJsonEsc "$app")\",\"action\":\"$(_lpJsonEsc "$action")\",\"from\":\"$(_lpJsonEsc "$from")\",\"to\":\"$(_lpJsonEsc "$to")\",\"result\":\"$(_lpJsonEsc "$result")\",\"artifact_id\":\"$(_lpJsonEsc "$artifact_id")\",\"serial\":\"$(_lpJsonEsc "$serial")\",\"undo_id\":\"$(_lpJsonEsc "$undo_id")\",\"trigger\":\"$(_lpJsonEsc "$trigger")\"}" local cur inner cur="$(cat "$f" 2>/dev/null)" inner="${cur#*[}"; inner="${inner%]*}" inner="$(printf '%s' "$inner" | tr -d '\n' | sed -E 's/^[[:space:]]*//; s/[[:space:]]*$//')" local newcontent if [[ -z "$inner" ]]; then newcontent="{ \"entries\": [$entry] }" else newcontent="{ \"entries\": [$entry, $inner] }"; fi local tmp2; tmp2="$(mktemp)"; printf '%s\n' "$newcontent" > "$tmp2" runFileWrite "$f" < "$tmp2"; rm -f "$tmp2" return 0 }