LibrePortal/scripts/cli/commands/updater/cli_updater_commands.sh
librelad 2d24a764a8 refactor(storage): route elevation tests and the WebUI tree through paths.sh
Two mechanical sweeps, no behaviour change on a single-root install.

The 14 `[[ "$p" == "$containers_dir"* ]]` prefix tests that decide
manager-vs-container-user elevation become pathIsContainerData, so a file
on a second storage root is no longer misclassified as manager-owned —
which would have written it with the wrong owner and failed later, far
from the cause. The 65 references to the WebUI's own tree become
webuiDir(), which is pinned to the primary root by design.

Two traps found while doing it:

run_privileged.sh is sourced directly by init.sh without paths.sh, so it
needs a fallback. Defining one named pathIsContainerData was wrong:
generate_function_manifest.sh indexes top-level definitions, and the
resulting autoload stub would have shadowed the real multi-root
implementation with the primary-only fallback — silently classifying
every file on a second disk as manager-owned, which is exactly the bug
this sweep exists to prevent. Renamed to _runCfgIsContainerPath, which
delegates when the real one is loaded.

setup_lock.sh built its path in a top-level assignment, so it was
evaluated at source time and needed the file flagged eager. Made it a
function instead: the path resolves on call, and the file drops off
LP_EAGER_FILES entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:04:19 +01:00

371 lines
20 KiB
Bash

#!/bin/bash
# App Updater command handler — `libreportal updater <sub>`
# ---------------------------------------------------------------------------
# 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 <app> 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 <app> 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 <app> [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 <app> [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 <app> --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 <app> [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 <app>"; 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 (<slug>-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="$(webuiDir)/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_<APP>_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 <slug>-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="$(webuiDir)/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
}