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>
165 lines
8.1 KiB
Bash
165 lines
8.1 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
|
|
}
|