LibrePortal/scripts/cli/commands/updater/cli_updater_commands.sh
librelad 0679fd65b2 feat(updater): stepped upgrade engine — climbs a ladder, verifying each rung
Ties the ladder and the verifiers together behind a new verb:

  libreportal updater upgrade <app> [version] [--dry-run]

Per rung, and every part is load-bearing:

  snapshot (fail-closed) -> set version -> pull -> up -> VERIFY -> next

On failure anywhere: restore THIS rung's snapshot, put the version back,
stop, and leave the app on the last version it actually verified at. The
ladder never continues past a doubt.

A snapshot PER RUNG rather than one at the start, because upstream
migrations are usually one-way — Nextcloud 32's schema cannot be undone
by putting the 31 image back. The recovery guarantee is "restore the
snapshot from sixty seconds ago", which only holds if every rung has one.

Two gates before anything moves. An app with no <app>_upgrade_verify is
refused outright: the generic health check cannot see a half-finished
migration, so laddering on it would be a guess wearing a safety label.
And a ladder that cannot be computed end to end refuses rather than
attempting a partial climb.

`updater upgrade` is a separate verb from `apply` on purpose: apply moves
you WITHIN a release line (and may be automatic), upgrade moves you
BETWEEN lines and is always a deliberate act. Dry runs execute inline so
the plan is instant to read.

updaterSetAnchorVersion rewrites the image tag AND its version sentinel
together — updating only the image would leave the sentinel advertising
the old version, and the next config regeneration would silently revert
the app.

Tested with stubs against the real code paths: the no-verifier gate holds
and changes nothing; a dry run has zero side effects; the happy path
snapshots at each current version before moving; a verify failure on rung
2 of 3 stops with the app on rung 1, restored, and never touches rung 3;
a failed snapshot moves no version and pulls nothing; a container that
will not start is rolled back.

NOT yet exercised on a live install — no app here needs a ladder. The
first real run should be a dry run.

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

352 lines
19 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
;;
"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"
[[ "$upgrade_target" == "--dry-run" ]] && { upgrade_mode="--dry-run"; upgrade_target=""; }
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="$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_<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="$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
}