LibrePortal/scripts/cli/commands/updater/cli_updater_auto.sh
librelad cdeb2d1658 feat(updater): per-app UPDATE_TYPE, automatic by default
Adds the decision half of the app updater. Detection (P2) and the
snapshot-first apply/revert (P3) were already real, but nothing ever
pressed the button — every update waited for a click.

  CFG_<APP>_UPDATE_TYPE=auto|manual   per app, default auto (33 templates)
  CFG_UPDATER_AUTO=true|false         master switch, default true

updaterAppPolicy resolves the two the way backupResolveStrategy already
resolves backup strategy: the global switch can only make things more
manual. updaterApplyAuto runs at the end of `updater check` and enqueues
the ordinary updater_apply task for each auto app that has an update —
never applies inline, so an automatic update is the same code path, task
log, History entry and Roll back button as a manual one.

Safety: each attempt stamps its target digest under generated/auto/, so a
build that fails is rolled back and then left alone rather than retried on
every scan; in-flight updater tasks are skipped so scans can't stack.

Tracked end to end: updates.json carries each app's resolved update_type,
History entries carry trigger=manual|auto. The WebUI says whether updates
install themselves, chips only the apps that opted out, labels automatic
history, and — since an auto app's pending update needs no decision — keeps
it off the Overview board's "Needs action" view.

Also fixes artifactApplyAuto enqueueing without --detach: it runs inside
the single-threaded task processor's own poll, so following the new task in
the foreground waits for a task that cannot start until it returns.

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

125 lines
6.0 KiB
Bash

#!/bin/bash
# Automatic app updates — policy resolution + the auto-apply enqueuer.
# ---------------------------------------------------------------------------
# The decision half of the updater: `webui_updater_scan.sh` answers "is a new
# build available?", `updaterApplyApp` answers "how do I install it safely?",
# and this file answers "should I install it without being asked?".
#
# Policy is per app, with a fleet-wide master switch:
# CFG_<APP>_UPDATE_TYPE = auto | manual (per app, default auto)
# CFG_UPDATER_AUTO = true | false (master switch, default true)
# Precedence mirrors the established backup-strategy shape (per-app override on
# top of a global default): the master switch can only ever make things MORE
# manual, so "turn auto-updates off" is one flip, not 33 config edits.
#
# What it deliberately does NOT do:
# * It never applies anything inline. Like artifactApplyAuto, it only enqueues
# the ordinary `updater_apply` task, so an automatic update is the exact
# same code path (snapshot -> pull -> up -> auto-rollback on failure), with
# the same task log, the same History entry, and the same Roll back button
# as a hand-pressed Update. The only difference is who pressed it, which is
# recorded as the history entry's `trigger`.
# * It never retries a build that already failed. Each auto-attempt stamps the
# target digest under generated/auto/<app>; the same digest is skipped
# forever after. Without this a broken upstream build would be re-attempted
# (and rolled back) on every scan, snapshotting the app each time. A newer
# build changes the digest and is attempted normally; the Update button
# stays available for a manual retry of the skipped one.
_updaterAutoGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
_updaterAutoDir() { echo "$(_updaterAutoGenDir)/auto"; }
_updaterAutoStamp() { echo "$(_updaterAutoDir)/$1.digest"; } # $1=app
# Effective update policy for one app -> "auto" | "manual".
# 1. master switch off -> manual (everything, no exceptions)
# 2. per-app CFG_<APP>_UPDATE_TYPE
# 3. unset / unrecognised -> auto (the documented default)
# App configs are sourced globally (sourceScanFiles app_configs), so the per-app
# key is read by indirect expansion exactly like backupResolveStrategy does.
updaterAppPolicy()
{
local app="$1"
[[ -n "$app" ]] || { echo manual; return 0; }
[[ "${CFG_UPDATER_AUTO:-true}" == "true" ]] || { echo manual; return 0; }
local key="CFG_${app^^}_UPDATE_TYPE"
case "$(printf '%s' "${!key-}" | tr 'A-Z' 'a-z')" in
manual|off|false) echo manual ;;
*) echo auto ;;
esac
}
# True when an updater task for this app is already queued or in flight, so a
# scan that lands while the previous update is still running doesn't stack a
# second one. Cheap: one jq over the task files, and only for apps that got
# this far (update available + policy auto).
updaterAutoTaskPending()
{
local app="$1" dir="${containers_dir%/}/libreportal/frontend/data/tasks"
command -v jq >/dev/null 2>&1 || return 1
local files=( "$dir"/task_*.json )
[[ -e "${files[0]}" ]] || return 1
local n
n="$(jq -rs --arg a "$app" '
[ .[] | select((.app // "") == $a
and ((.type // "") | startswith("updater_"))
and ((.status // "") == "queued" or (.status // "") == "pending"
or (.status // "") == "running")) ] | length
' "${files[@]}" 2>/dev/null)"
[[ "${n:-0}" -gt 0 ]]
}
# updaterApplyAuto — enqueue an `updater apply` task for every app that has an
# update available and is set to auto. Called from `updater check` right after
# the scan that produced updates.json, so it always acts on fresh data.
updaterApplyAuto()
{
if [[ "${CFG_UPDATER_AUTO:-true}" != "true" ]]; then
isNotice "Automatic app updates are off (CFG_UPDATER_AUTO=false)."
return 0
fi
if ! command -v jq >/dev/null 2>&1; then
isNotice "Automatic app updates need jq to read the scan results — skipping (updates are still listed in the WebUI)."
return 0
fi
local upd; upd="$(_updaterAutoGenDir)/updates.json"
[[ -f "$upd" ]] || return 0
local auto_dir; auto_dir="$(_updaterAutoDir)"
[[ -d "$auto_dir" ]] || runFileOp mkdir -p "$auto_dir" 2>/dev/null
local app dig stamp enqueued=0 skipped=0
while IFS=$'\t' read -r app dig; do
[[ -n "$app" ]] || continue
[[ "$(updaterAppPolicy "$app")" == "auto" ]] || continue
# An update we already queued may still be waiting its turn — that is
# in-flight, not stuck, so it must be tested BEFORE the attempted-digest
# stamp (which the enqueue already wrote) or it would be miscounted as a
# failure below.
updaterAutoTaskPending "$app" && continue
# Already auto-attempted this exact build? (failed last time — see header)
stamp="$(_updaterAutoStamp "$app")"
if [[ -n "$dig" && -f "$stamp" ]] && [[ "$(cat "$stamp" 2>/dev/null)" == "$dig" ]]; then
skipped=$((skipped + 1))
continue
fi
# Stamp BEFORE enqueueing: if the update fails and rolls back, the stamp
# is what stops the next scan from trying the same build again. A crash
# between stamp and enqueue costs one skipped auto-update, never a loop.
printf '%s' "$dig" | runFileWrite "$stamp" 2>/dev/null || true
cliTaskRun "libreportal updater apply $app auto" "updater_apply" "$app" "--detach"
enqueued=$((enqueued + 1))
done < <(jq -r '.apps[]? | select(.update_available == true)
| "\(.name)\t\(.available_digest // "")"' "$upd" 2>/dev/null)
if (( enqueued > 0 )); then
isSuccessful "Queued $enqueued automatic app update(s) — each is snapshotted before it is applied."
elif (( skipped > 0 )); then
isNotice "$skipped app(s) have an update that a previous automatic attempt could not apply — update them from the WebUI to see why."
fi
return 0
}