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>
371 lines
20 KiB
Bash
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
|
|
}
|