Four fixes that make the auto-updater a trustworthy background system: * CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after the 05:00 backup cron; HH:MM-HH:MM wraps midnight, 'always' = any time). Gates only the enqueue — scans keep running all day, so the Updates page stays current and pending updates visibly wait for the window. Malformed values fail closed and are rejected by the WebUI validator. * "Check now" actually checks: an explicit `updater check` sets UPDATER_REGISTRY_FORCE=1. The flag existed but nothing ever set it, so the button silently reused the 6h digest cache and could not find a build the user knew had shipped. Force also overrides interval 0, which now means "manual-only" as documented in the roadmap. * Registry stamp moved from /tmp to <system>/logs: the task processor runs under PrivateTmp, so daemon and CLI each kept a separate 6h clock and the daemon's reset on every service restart. * A failed automatic attempt is no longer invisible: the scan emits auto_attempted_digest (the one-shot no-retry stamp), and when it matches the available build the UI stops promising an install that will never come — per-app detail explains, the fleet row gets an "auto failed" chip, and the Overview board counts it as needing you. Also corrects the CFG_TIMEZONE label: it sets the containers' TZ only; scheduled tasks follow the host clock (timedatectl), and the old "Timezone for scheduled tasks" wording promised a knob that never existed. The window + auto_window display state plainly WHEN updates land, answering "how does the user know when the next update happens". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
18 KiB
Bash
331 lines
18 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
|
|
;;
|
|
|
|
"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
|
|
}
|