LibrePortal/scripts/cli/commands/updater/cli_updater_auto.sh
librelad 64344bc5dc feat(updater): step apps to the next version automatically, one rung a day
Two halves: the ladder could not climb the commonest versioning scheme,
and nothing ever climbed it on its own.

The ladder stepped by bumping a tag's LAST numeric component, so
v1.158.0 went v1.158.1, v1.158.2, … and never arrived at v1.159.0. It
then failed closed, refusing to build a path. Synapse publishes
v1.159.0 and no v1.158.1 at all, so Matrix could not be laddered by the
button either — three-part semver minor bumps were simply unreachable.
updaterNextRung now considers a bump of every component, keeps the
candidates that exist upstream and takes the smallest: the next release
by definition, whether it lands in the patch position or crosses into a
new major. Shape discipline is unchanged, so 31-fpm-alpine still never
becomes 31-apache, and each rung is still probed, so none can be
skipped. updaterTagBumpAt moves here from the scan, its natural home,
which also breaks a source cycle.

updaterUpgradeAuto then climbs at most ONE rung per app per calendar
day, inside the install window, for apps set to auto. One rung because a
ladder run unattended can be several migrations deep before anyone
looks, and "restore the snapshot from a minute ago" stops comforting
once four have stacked; one a day so there is time to notice. It crosses
a major if that is genuinely the next release — refusing would strand an
app on the last version of its line forever — but one step at a time,
never as a leap. Two stamps: the target rung (a failure is not retried
until something newer ships) and the day.

Every rung goes through updaterUpgradeApp unchanged, so GATE 1 still
refuses any app without a real verifier, and the per-rung contract is
identical to the button: snapshot fail-closed, set version, pull, up,
verify, restore that rung and stop on any failure. History now records
the trigger instead of hardcoding "manual", including on the rollback
paths. CFG_UPDATER_LADDER_AUTO gates the whole thing separately from
CFG_UPDATER_AUTO, because "keep my apps patched" and "move my apps
between versions unattended" are different appetites for risk.

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

249 lines
12 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 and a daily install window:
# CFG_<APP>_UPDATE_TYPE = auto | manual (per app, default auto)
# CFG_UPDATER_AUTO = true | false (master switch, default true)
# CFG_UPDATER_WINDOW = HH:MM-HH:MM (host local time, default 06:00-08:00
# — right after the 05:00 backups;
# 'always' = any time)
# 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
# Is the automatic-update window open right now? CFG_UPDATER_WINDOW is
# HH:MM-HH:MM in the HOST's local time (the same clock cron and the daemon run
# on — deliberately NOT CFG_TIMEZONE, which only sets the containers' TZ), or
# 'always'. start > end crosses midnight (22:00-02:00); start == end means the
# window never closes. Scans are not gated by this — only the enqueue is, so
# detection stays current all day and pending updates simply wait.
# Malformed values FAIL CLOSED (hold updates): the user typed something trying
# to restrict when updates land, and ignoring it would violate that intent —
# the Updates page shows the raw window value, so a typo is visible, and the
# WebUI validator rejects bad values before they ever land in the config.
updaterInWindow()
{
local win="${CFG_UPDATER_WINDOW:-always}"
[[ "$win" == "always" ]] && return 0
[[ "$win" =~ ^([01][0-9]|2[0-3]):([0-5][0-9])-([01][0-9]|2[0-3]):([0-5][0-9])$ ]] || return 1
local now s e
now=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
s=$(( 10#${BASH_REMATCH[1]} * 60 + 10#${BASH_REMATCH[2]} ))
e=$(( 10#${BASH_REMATCH[3]} * 60 + 10#${BASH_REMATCH[4]} ))
if (( s < e )); then
(( now >= s && now < e ))
else
(( now >= s || now < e )) # wraps midnight; s == e -> whole day
fi
}
# 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
# The window gates the ENQUEUE only — the scan that got us here already ran.
# Quiet unless something is actually waiting, so the daemon's half-hourly
# no-op scans don't fill the log with window notices.
if ! updaterInWindow; then
local n_waiting
n_waiting="$(jq -r '[.apps[]? | select(.update_available == true)] | length' "$upd" 2>/dev/null)"
[[ "${n_waiting:-0}" -gt 0 ]] && \
isNotice "$n_waiting update(s) are waiting for the automatic update window (${CFG_UPDATER_WINDOW:-always})."
return 0
fi
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
}
# ---------------------------------------------------------------------------
# Automatic version LADDERING — at most one rung, at most once a day.
#
# updaterApplyAuto above moves an app WITHIN its version line (a rebuild of the
# tag it already tracks). This moves it BETWEEN lines, and it is the deliberately
# cautious half of that: it never plans a climb, only ever the single next
# published release, and it does that at most once per calendar day per app.
#
# Why one rung and not a ladder: a ladder run unattended can be several
# migrations deep before anyone looks, and "restore the snapshot from sixty
# seconds ago" stops being a comfort once four of them have stacked. One rung a
# day means the app is never more than a single version from a state that
# verified, and there is a day in which to notice. It crosses a major boundary
# if that is genuinely the next release, because refusing would strand an app
# on the last version of a line forever — but it gets there one step at a time,
# never as a leap.
#
# Every rung still goes through updaterUpgradeApp, so the contract is unchanged
# from the button: snapshot (fail-closed) -> set version -> pull -> up -> verify,
# and on any failure that rung is restored and the climb stops.
#
# Two stamps, both one-shot in different ways:
# <app>.rung the target last attempted — a rung that failed is not retried
# until a NEWER one is published (mirrors the .digest stamp).
# <app>.rungday the day a rung was last attempted — the once-a-day bound.
_updaterAutoRungStamp() { echo "$(_updaterAutoDir)/$1.rung"; }
_updaterAutoRungDay() { echo "$(_updaterAutoDir)/$1.rungday"; }
updaterUpgradeAuto()
{
[[ "${CFG_UPDATER_AUTO:-true}" == "true" ]] || return 0
# Its own switch as well as the master one: "keep my apps patched" and "move
# my apps between versions on their own" are different appetites for risk,
# and someone should be able to want the first without the second.
[[ "${CFG_UPDATER_LADDER_AUTO:-true}" == "true" ]] || return 0
command -v jq >/dev/null 2>&1 || return 0
local upd; upd="$(_updaterAutoGenDir)/updates.json"
[[ -f "$upd" ]] || return 0
updaterInWindow || return 0
local _f
for _f in cli_updater_ladder cli_updater_verify cli_updater_upgrade; do
declare -F updaterNextRung >/dev/null 2>&1 && break
source "$install_scripts_dir/cli/commands/updater/${_f}.sh" 2>/dev/null
done
declare -F updaterNextRung >/dev/null 2>&1 || return 0
local auto_dir; auto_dir="$(_updaterAutoDir)"
[[ -d "$auto_dir" ]] || runFileOp mkdir -p "$auto_dir" 2>/dev/null
local today; today="$(date +%F)"
local app channel image repo next stamp dayf enq=0
while IFS=$'\t' read -r app channel image; do
[[ -n "$app" && -n "$channel" && -n "$image" ]] || continue
[[ "$(updaterAppPolicy "$app")" == "auto" ]] || continue
# An in-flight updater task for this app: leave it alone rather than
# stacking a version move on top of an update that is still running.
updaterAutoTaskPending "$app" && continue
dayf="$(_updaterAutoRungDay "$app")"
[[ -f "$dayf" && "$(cat "$dayf" 2>/dev/null)" == "$today" ]] && continue
repo="$(updaterRepoTag "$image")"; repo="${repo%:*}"
next="$(updaterNextRung "$channel" "$repo")"
[[ -n "$next" ]] || continue
stamp="$(_updaterAutoRungStamp "$app")"
[[ -f "$stamp" && "$(cat "$stamp" 2>/dev/null)" == "$next" ]] && continue
# Stamp BEFORE enqueueing, same reasoning as the digest stamp: a crash
# between the two costs one skipped upgrade, never a loop.
printf '%s' "$next" | runFileWrite "$stamp" 2>/dev/null || true
printf '%s' "$today" | runFileWrite "$dayf" 2>/dev/null || true
cliTaskRun "libreportal updater upgrade $app $next --auto" "updater_upgrade" "$app" "--detach"
enq=$((enq + 1))
isNotice "$app: queued the next version step $channel$next (snapshotted and verified; one step only)."
done < <(jq -r '.apps[]? | select((.type // "") == "versioned")
| "\(.name)\t\(.channel // "")\t\(.current_image // "")"' "$upd" 2>/dev/null)
(( enq > 0 )) && isSuccessful "Queued $enq automatic version step(s) — one rung each, and no more today."
return 0
}