feat(updater): install window, honest Check-now, failed-auto surfacing

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>
This commit is contained in:
librelad 2026-08-11 21:06:27 +01:00
parent 1c3af5a533
commit 66c79f997e
12 changed files with 139 additions and 21 deletions

View File

@ -3,5 +3,5 @@
# @icon 🏷️ # @icon 🏷️
# ================================================================================ # ================================================================================
CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance
CFG_TIMEZONE=Etc/UTC # System Timezone - Timezone for scheduled tasks and logging timestamps CFG_TIMEZONE=Etc/UTC # Container Timezone - Timezone handed to app containers (their TZ). Scheduled tasks — backups, update checks — follow the HOST's own clock, set with timedatectl, not this value.
CFG_INSTALL_LEVEL=beginner # Experience Level - Beginner hides technical detail and skips advanced setup steps. Advanced reveals everything by default. Set during the first-run wizard; can be flipped any time via the Advanced toggle in the WebUI. [beginner:Beginner — simple|advanced:Advanced — show everything] CFG_INSTALL_LEVEL=beginner # Experience Level - Beginner hides technical detail and skips advanced setup steps. Advanced reveals everything by default. Set during the first-run wizard; can be flipped any time via the Advanced toggle in the WebUI. [beginner:Beginner — simple|advanced:Advanced — show everything]

View File

@ -3,6 +3,7 @@
# @icon 🔄 # @icon 🔄
# ================================================================================ # ================================================================================
CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - Minutes between automatic app update/CVE/improvement scans. 0 disables. CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - Minutes between automatic app update/CVE/improvement scans. 0 disables.
CFG_UPDATER_REGISTRY_INTERVAL=360 # Registry Check Interval - Minutes between registry lookups for new image builds (the expensive step; the local scan still refreshes every scan). 0 = never (local-only). CFG_UPDATER_REGISTRY_INTERVAL=360 # Registry Check Interval - Minutes between registry lookups for new image builds (the expensive step; the local scan still refreshes every scan). 0 = only when you press Check now.
CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off] CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off]
CFG_UPDATER_AUTO=true # Automatic App Updates - Master switch for per-app automatic updates. Each app's own Update Type decides individually; turning this off makes every app manual. Every update snapshots the app first and rolls back on failure. [true:On|false:Off] CFG_UPDATER_AUTO=true # Automatic App Updates - Master switch for per-app automatic updates. Each app's own Update Type decides individually; turning this off makes every app manual. Every update snapshots the app first and rolls back on failure. [true:On|false:Off]
CFG_UPDATER_WINDOW=06:00-08:00 # Automatic Update Window - When automatic updates are allowed to install, in the host's local time (HH:MM-HH:MM; crosses midnight when start > end; 'always' = any time). Checks still run all day so the Updates page stays current — found updates simply wait for the window. Pressing Update yourself always works.

View File

@ -407,9 +407,12 @@ class OverviewManager {
// wolf. They still get a quiet line so nothing is hidden. // wolf. They still get a quiet line so nothing is hidden.
// With automatic scanning off nothing is found unattended, so an "auto" // With automatic scanning off nothing is found unattended, so an "auto"
// app's update sits there until someone presses Check — that IS waiting // app's update sits there until someone presses Check — that IS waiting
// for the user, whatever the policy says. // for the user, whatever the policy says. Likewise a build the auto-
// updater already attempted and rolled back: it will not be retried, so
// it waits for a person too (updater-page's autoAttemptFailed).
const autoScan = !(up.updates && Number(up.updates.scan_interval_minutes) === 0); const autoScan = !(up.updates && Number(up.updates.scan_interval_minutes) === 0);
const waiting = pending.filter((a) => !autoScan || a.update_type === 'manual'); const waiting = pending.filter((a) =>
!autoScan || a.update_type === 'manual' || (up.autoAttemptFailed && up.autoAttemptFailed(a)));
const selfing = pending.length - waiting.length; const selfing = pending.length - waiting.length;
const listOf = (arr) => { const listOf = (arr) => {
const names = arr.map((a) => a.displayName || a.name); const names = arr.map((a) => a.displayName || a.name);
@ -425,9 +428,14 @@ class OverviewManager {
+ `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`, + `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
}); });
} else { } else {
// Say WHEN, not just that it happens — "installing automatically"
// with no time reads as "any second now", which a 06:00-08:00 window
// makes untrue for most of the day.
const win = up.updates && up.updates.auto_window;
const winBit = (win && win !== 'always') ? ` during the ${esc(win).replace('-', '')} window` : '';
rows.push({ rows.push({
hue: 'updates', icon: '⬆️', kind: 'ok', hue: 'updates', icon: '⬆️', kind: 'ok',
text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically`, text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically${winBit}`,
sub: `${esc(listOf(pending))} — each is snapshotted first, and rolled back if it fails`, sub: `${esc(listOf(pending))} — each is snapshotted first, and rolled back if it fails`,
actions: goto('updates', 'Review', 'updates'), actions: goto('updates', 'Review', 'updates'),
}); });
@ -583,9 +591,14 @@ class OverviewManager {
// Automatic is the default, so only the apps that opt OUT carry a marker — // Automatic is the default, so only the apps that opt OUT carry a marker —
// 30-odd "auto" chips would be wallpaper, one "manual" chip is information. // 30-odd "auto" chips would be wallpaper, one "manual" chip is information.
// The fleet-level statement lives in the auto-check line above the list. // The fleet-level statement lives in the auto-check line above the list.
// A failed automatic attempt is the other marker that earns a chip: that
// update will NOT retry itself, so the row must say a person is needed.
const failed = this.updater && this.updater.autoAttemptFailed && this.updater.autoAttemptFailed(a);
const pol = a.update_type === 'manual' const pol = a.update_type === 'manual'
? `<span class="updater-badge updater-badge-unknown" title="This app updates only when you press Update">manual</span>` ? `<span class="updater-badge updater-badge-unknown" title="This app updates only when you press Update">manual</span>`
: ''; : (failed
? `<span class="updater-badge sev-high" title="The automatic attempt failed and was rolled back — press Update to retry">auto failed</span>`
: '');
const updBtn = a.update_available const updBtn = a.update_available
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${slug}">Update</button>` ? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${slug}">Update</button>`
: ''; : '';

View File

@ -541,10 +541,15 @@ class UpdaterPage {
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`); : (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
// What happens next, in the app's own words — the Updates setting on this // What happens next, in the app's own words — the Updates setting on this
// app's Configure page decides, so say which way it is set rather than // app's Configure page decides, so say which way it is set rather than
// leaving "update available" to imply someone must act. // leaving "update available" to imply someone must act. One honest
// exception: an auto app whose available build was already attempted and
// rolled back will NOT retry (by design — one shot per build), so saying
// "installs on its own" there would promise an install that never comes.
const policyLine = a.update_type === 'manual' const policyLine = a.update_type === 'manual'
? 'Set to <strong>manual</strong> — this app updates only when you press Update.' ? 'Set to <strong>manual</strong> — this app updates only when you press Update.'
: 'Set to <strong>automatic</strong> — new builds install on their own, after a recovery snapshot.'; : (this.autoAttemptFailed(a)
? 'Set to <strong>automatic</strong>, but this build failed to apply and was rolled back — it won\'t be retried. Press Update to try again, or wait for the next build.'
: 'Set to <strong>automatic</strong> — new builds install on their own, after a recovery snapshot.');
versionSection = `<div class="updater-detail-section"><h4>Version</h4> versionSection = `<div class="updater-detail-section"><h4>Version</h4>
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div> <div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div>
<div class="updater-detail-row"><span class="updater-detail-meta">${policyLine}</span></div></div>`; <div class="updater-detail-row"><span class="updater-detail-meta">${policyLine}</span></div></div>`;
@ -594,6 +599,15 @@ class UpdaterPage {
return `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`; return `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`;
} }
// True when this app's AVAILABLE build is the one the auto-updater already
// attempted and rolled back (the one-shot no-retry stamp). Such an update is
// effectively manual now: it sits until a person retries or a newer build
// ships. Both this page and the Overview board branch on it.
autoAttemptFailed(a) {
return !!(a && a.update_available && a.update_type !== 'manual'
&& a.auto_attempted_digest && a.auto_attempted_digest === a.available_digest);
}
// "automatic" marker for a History entry. Only automatic runs are labelled — // "automatic" marker for a History entry. Only automatic runs are labelled —
// a hand-pressed update needs no explanation, and entries written before the // a hand-pressed update needs no explanation, and entries written before the
// trigger field existed carry none, so silence is also the honest default. // trigger field existed carry none, so silence is also the honest default.
@ -657,11 +671,14 @@ class UpdaterPage {
// restating a config value: all / some / none on automatic. // restating a config value: all / some / none on automatic.
// Nothing installs itself while the scan that finds updates is off, so with // Nothing installs itself while the scan that finds updates is off, so with
// iv === 0 the policy is moot and claiming otherwise would be a lie. // iv === 0 the policy is moot and claiming otherwise would be a lie.
// The window says WHEN: checks run all day, installs land inside it.
const win = this.updates && this.updates.auto_window;
const winBit = (win && win !== 'always') ? ` during ${this.escape(win).replace('-', '')}` : '';
const auto = this.apps.filter((a) => a.update_type !== 'manual').length; const auto = this.apps.filter((a) => a.update_type !== 'manual').length;
let autoBit = ''; let autoBit = '';
if (iv === 0 || !this.apps.length) autoBit = ''; if (iv === 0 || !this.apps.length) autoBit = '';
else if (auto === this.apps.length) autoBit = ' · updates install automatically'; else if (auto === this.apps.length) autoBit = ` · updates install automatically${winBit}`;
else if (auto) autoBit = ` · ${auto} of ${this.apps.length} apps install updates automatically`; else if (auto) autoBit = ` · ${auto} of ${this.apps.length} apps install updates automatically${winBit}`;
else autoBit = ' · updates wait for you'; else autoBit = ' · updates wait for you';
return `<div class="updater-autocheck${off}"><span class="updater-autocheck-dot"></span>` + return `<div class="updater-autocheck${off}"><span class="updater-autocheck-dot"></span>` +
`<span class="updater-autocheck-text">Checked automatically · last checked <strong>${last}</strong>${nextBit}${autoBit}</span>` + `<span class="updater-autocheck-text">Checked automatically · last checked <strong>${last}</strong>${nextBit}${autoBit}</span>` +

View File

@ -143,7 +143,14 @@ Shipped a shape the original sketch didn't consider: the policy is **per app**,
Why not the sketched `off|security|all` severity split: severity is a property of the *CVE data*, which is only as complete as Trivy's per-image scan, whereas "do I let this app move on its own?" is a property of the *app* — nextcloud and a DNS blocker are not the same risk. Per-app also composes with the CVE work instead of competing with it: a future `security` value can be added per app without changing the master switch or the enqueuer's shape. Why not the sketched `off|security|all` severity split: severity is a property of the *CVE data*, which is only as complete as Trivy's per-image scan, whereas "do I let this app move on its own?" is a property of the *app* — nextcloud and a DNS blocker are not the same risk. Per-app also composes with the CVE work instead of competing with it: a future `security` value can be added per app without changing the master switch or the enqueuer's shape.
**Still to soak:** the honest gap below is unchanged — apply/revert has been exercised in helper-level tests, not end-to-end on a live install with a real pending update. Auto-update inherits exactly that risk, which is why every attempt still snapshots first and why the digest stamp makes a bad build a one-time event rather than a loop. **Soaked 2026-08-11:** the first live end-to-end auto-update (trivy 0.72.0→0.73.0) ran on a real install. Its first attempt exposed and fixed a latent dynamic-scoping bug (a callee's undeclared `while read app` loop blanked the updater's `app` local mid-update — pre-existing, had also corrupted a manual update's History on 08-10); the retry completed cleanly with `trigger:auto` and exact digest refs.
**Refinements (2026-08-11), after the "hands-off background" review:**
- **Install window**`CFG_UPDATER_WINDOW` (HH:MM-HH:MM host local time, wraps midnight, `always`; default **06:00-08:00**, right after the 05:00 backup cron). Gates only the *enqueue* in `updaterApplyAuto`; scans keep their all-day cadence so detection stays current and pending updates visibly wait. Malformed values fail closed (hold updates) and are rejected by the WebUI validator; the raw value is surfaced in `updates.json` (`auto_window`) so the UI states when installs land. Ordering rationale: backups nightly at 05:00 → updates 06:00-08:00, and every update still takes its own pre-update snapshot regardless (fail-closed), so the window is about *quiet hours*, not safety.
- **Check now forces a live registry lookup**`updater check` (non-`auto`) sets `UPDATER_REGISTRY_FORCE=1`; previously the flag existed but nothing set it, so the button silently reused the 6h digest cache. Force also overrides `CFG_UPDATER_REGISTRY_INTERVAL=0`, making 0 mean "manual-only" as originally intended.
- **Registry stamp out of /tmp** — the task-processor unit runs with systemd `PrivateTmp`, so the /tmp stamp existed once per namespace (daemon vs CLI, daemon's resetting every restart). Now at `<system>/logs/updater_registry_checked`.
- **Failed-auto honesty** — the scan emits each app's `auto_attempted_digest` (the one-shot no-retry stamp). When it matches `available_digest`, the UI stops claiming "installs automatically": the per-app detail says the build failed and won't retry, the fleet row gets an `auto failed` chip, and the Overview board counts it as *waiting for you* (warn) instead of quietly-installing (ok).
- **`CFG_TIMEZONE` label corrected** — it only sets containers' TZ; scheduled tasks follow the host clock. The old "System Timezone - Timezone for scheduled tasks" label promised a knob that never existed.
## 7. Build phases (each independently shippable) ## 7. Build phases (each independently shippable)

View File

@ -6,9 +6,12 @@
# build available?", `updaterApplyApp` answers "how do I install it safely?", # build available?", `updaterApplyApp` answers "how do I install it safely?",
# and this file answers "should I install it without being asked?". # and this file answers "should I install it without being asked?".
# #
# Policy is per app, with a fleet-wide master switch: # 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_<APP>_UPDATE_TYPE = auto | manual (per app, default auto)
# CFG_UPDATER_AUTO = true | false (master switch, default true) # 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 # 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 # 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. # manual, so "turn auto-updates off" is one flip, not 33 config edits.
@ -31,6 +34,32 @@ _updaterAutoGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updat
_updaterAutoDir() { echo "$(_updaterAutoGenDir)/auto"; } _updaterAutoDir() { echo "$(_updaterAutoGenDir)/auto"; }
_updaterAutoStamp() { echo "$(_updaterAutoDir)/$1.digest"; } # $1=app _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". # Effective update policy for one app -> "auto" | "manual".
# 1. master switch off -> manual (everything, no exceptions) # 1. master switch off -> manual (everything, no exceptions)
# 2. per-app CFG_<APP>_UPDATE_TYPE # 2. per-app CFG_<APP>_UPDATE_TYPE
@ -85,6 +114,17 @@ updaterApplyAuto()
local upd; upd="$(_updaterAutoGenDir)/updates.json" local upd; upd="$(_updaterAutoGenDir)/updates.json"
[[ -f "$upd" ]] || return 0 [[ -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)" local auto_dir; auto_dir="$(_updaterAutoDir)"
[[ -d "$auto_dir" ]] || runFileOp mkdir -p "$auto_dir" 2>/dev/null [[ -d "$auto_dir" ]] || runFileOp mkdir -p "$auto_dir" 2>/dev/null

View File

@ -41,6 +41,12 @@ cliHandleUpdaterCommands()
(( _now - _last < scan_interval * 60 )) && return 0 (( _now - _last < scan_interval * 60 )) && return 0
fi fi
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 # Quick + safe — just regenerates the read-only data files. Source
# the generator explicitly if the lazy loader hasn't mapped it yet # the generator explicitly if the lazy loader hasn't mapped it yet
# (new file; the array regen self-heals it on deploy, this covers # (new file; the array regen self-heals it on deploy, this covers

View File

@ -21,8 +21,10 @@ cliShowUpdaterHelp()
echo "" echo ""
echo "Automatic updates: each check enqueues the update for every app set to" echo "Automatic updates: each check enqueues the update for every app set to"
echo "CFG_<APP>_UPDATE_TYPE=auto (the default) — the same snapshot-first apply" echo "CFG_<APP>_UPDATE_TYPE=auto (the default) — the same snapshot-first apply"
echo "as pressing Update, recorded in History as automatic. Set an app to" echo "as pressing Update, recorded in History as automatic. They install only"
echo "'manual' on its Configure page to hold it back, or CFG_UPDATER_AUTO=false" echo "inside CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after"
echo "to hold back every app. A build that fails is never auto-retried." echo "the nightly backups); checks still run all day. Set an app to 'manual'"
echo "on its Configure page to hold it back, or CFG_UPDATER_AUTO=false to"
echo "hold back every app. A build that fails is never auto-retried."
echo "" echo ""
} }

View File

@ -927,6 +927,7 @@ declare -gA LP_FN_MAP=(
[updaterComposePull]="cli/commands/updater/cli_updater_commands.sh" [updaterComposePull]="cli/commands/updater/cli_updater_commands.sh"
[updaterDisplayVersion]="webui/data/generators/updater/webui_updater_scan.sh" [updaterDisplayVersion]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterInspectLocal]="webui/data/generators/updater/webui_updater_scan.sh" [updaterInspectLocal]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterInWindow]="cli/commands/updater/cli_updater_auto.sh"
[updaterLastUpdateFrom]="cli/commands/updater/cli_updater_commands.sh" [updaterLastUpdateFrom]="cli/commands/updater/cli_updater_commands.sh"
[updaterPrimaryImage]="webui/data/generators/updater/webui_updater_scan.sh" [updaterPrimaryImage]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterRecordHistory]="cli/commands/updater/cli_updater_commands.sh" [updaterRecordHistory]="cli/commands/updater/cli_updater_commands.sh"
@ -1940,6 +1941,7 @@ declare -gA LP_FN_ROOT=(
[updaterComposePull]="scripts" [updaterComposePull]="scripts"
[updaterDisplayVersion]="scripts" [updaterDisplayVersion]="scripts"
[updaterInspectLocal]="scripts" [updaterInspectLocal]="scripts"
[updaterInWindow]="scripts"
[updaterLastUpdateFrom]="scripts" [updaterLastUpdateFrom]="scripts"
[updaterPrimaryImage]="scripts" [updaterPrimaryImage]="scripts"
[updaterRecordHistory]="scripts" [updaterRecordHistory]="scripts"
@ -2986,6 +2988,7 @@ _updaterCleanImageRef() { unset -f _updaterCleanImageRef; __lpAutoload "${instal
updaterComposePull() { unset -f updaterComposePull; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterComposePull "$@"; } updaterComposePull() { unset -f updaterComposePull; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterComposePull "$@"; }
updaterDisplayVersion() { unset -f updaterDisplayVersion; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterDisplayVersion "$@"; } updaterDisplayVersion() { unset -f updaterDisplayVersion; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterDisplayVersion "$@"; }
updaterInspectLocal() { unset -f updaterInspectLocal; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterInspectLocal "$@"; } updaterInspectLocal() { unset -f updaterInspectLocal; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterInspectLocal "$@"; }
updaterInWindow() { unset -f updaterInWindow; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; updaterInWindow "$@"; }
updaterLastUpdateFrom() { unset -f updaterLastUpdateFrom; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterLastUpdateFrom "$@"; } updaterLastUpdateFrom() { unset -f updaterLastUpdateFrom; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterLastUpdateFrom "$@"; }
updaterPrimaryImage() { unset -f updaterPrimaryImage; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterPrimaryImage "$@"; } updaterPrimaryImage() { unset -f updaterPrimaryImage; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterPrimaryImage "$@"; }
updaterRecordHistory() { unset -f updaterRecordHistory; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRecordHistory "$@"; } updaterRecordHistory() { unset -f updaterRecordHistory; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRecordHistory "$@"; }

View File

@ -177,7 +177,7 @@ PORTEOF
"category": "general", "category": "general",
"label": "Updates", "label": "Updates",
"type": "select", "type": "select",
"tooltip": "Automatic installs a new image build as soon as one is published — the app is snapshotted first and rolled back automatically if the update fails. Manual leaves it listed under Updates until you press Update.", "tooltip": "Automatic installs new image builds during the daily update window (set in Settings → Updater) — the app is snapshotted first and rolled back automatically if the update fails. Manual leaves it listed under Updates until you press Update.",
"options": [ "options": [
{"value": "auto", "label": "Automatic (recommended)"}, {"value": "auto", "label": "Automatic (recommended)"},
{"value": "manual", "label": "Manual — I'll press Update"} {"value": "manual", "label": "Manual — I'll press Update"}

View File

@ -84,6 +84,14 @@ webuiValidateConfigValue() {
isError " Invalid timezone format for $var_name" isError " Invalid timezone format for $var_name"
fi fi
;; ;;
CFG_UPDATER_WINDOW)
# 'always' or HH:MM-HH:MM (24h; start > end wraps midnight). The
# enqueuer fails CLOSED on a malformed value, so reject bad input
# here before it can silently hold every automatic update.
if ! echo "$var_value" | grep -qE '^always$|^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$'; then
isError " $var_name must be 'always' or HH:MM-HH:MM (e.g. 06:00-08:00)"
fi
;;
CFG_NETWORK_SUBNET) CFG_NETWORK_SUBNET)
# Validate CIDR format # Validate CIDR format
if ! echo "$var_value" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then if ! echo "$var_value" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then

View File

@ -141,9 +141,16 @@ webuiUpdaterScan() {
# (the "Check now" button) forces a live pull. # (the "Check now" button) forces a live pull.
local prev_json="$out_dir/updates.json" local prev_json="$out_dir/updates.json"
local reg_interval="${CFG_UPDATER_REGISTRY_INTERVAL:-360}" local reg_interval="${CFG_UPDATER_REGISTRY_INTERVAL:-360}"
local reg_stamp="/tmp/libreportal_updater_registry_checked" # Manager-owned persistent path, NOT /tmp: the task-processor service runs
# with systemd PrivateTmp, so a /tmp stamp existed once per namespace —
# the daemon and the CLI each kept their own 6h clock, and the daemon's
# reset on every service restart. logs/ is manager-writable in both.
local reg_stamp="${docker_dir:-/libreportal-system}/logs/updater_registry_checked"
local do_registry=1 local do_registry=1
[ "$reg_interval" = "0" ] && do_registry=0 [ "$reg_interval" = "0" ] && do_registry=0
# Forced (an explicit Check now): always a live lookup, even at interval 0
# — that setting means "only when I press the button", not "never".
[ -n "${UPDATER_REGISTRY_FORCE:-}" ] && do_registry=1
if [ -z "${UPDATER_REGISTRY_FORCE:-}" ] && [ "$do_registry" = "1" ] && [ -f "$reg_stamp" ]; then if [ -z "${UPDATER_REGISTRY_FORCE:-}" ] && [ "$do_registry" = "1" ] && [ -f "$reg_stamp" ]; then
local _rn _rl; _rn=$(date +%s); _rl=$(stat -c '%Y' "$reg_stamp" 2>/dev/null || echo 0) local _rn _rl; _rn=$(date +%s); _rl=$(stat -c '%Y' "$reg_stamp" 2>/dev/null || echo 0)
(( _rn - _rl < reg_interval * 60 )) && do_registry=0 (( _rn - _rl < reg_interval * 60 )) && do_registry=0
@ -214,6 +221,14 @@ webuiUpdaterScan() {
declare -F updaterAppPolicy >/dev/null 2>&1 && policy="$(updaterAppPolicy "$app")" declare -F updaterAppPolicy >/dev/null 2>&1 && policy="$(updaterAppPolicy "$app")"
fi fi
# The digest the auto-updater last ATTEMPTED for this app (its one-shot
# no-retry stamp). Exposed so the WebUI can tell "installing automatically"
# from "the automatic attempt failed and was rolled back — this one now
# waits for you". Without it a failed auto-update looked identical to a
# pending one, and the page promised an install that would never come.
local auto_attempted=""
[ -f "$out_dir/auto/$app.digest" ] && auto_attempted="$(cat "$out_dir/auto/$app.digest" 2>/dev/null)"
if [ "$have_jq" = "1" ]; then if [ "$have_jq" = "1" ]; then
jq -cn \ jq -cn \
--arg name "$app" --arg displayName "$app" --arg type "$vtype" \ --arg name "$app" --arg displayName "$app" --arg type "$vtype" \
@ -222,13 +237,14 @@ webuiUpdaterScan() {
--arg available_image "$anchor" --arg available_version "$avail_ver" \ --arg available_image "$anchor" --arg available_version "$avail_ver" \
--arg available_digest "$avail_dig" --argjson update_available "$update_available" \ --arg available_digest "$avail_dig" --argjson update_available "$update_available" \
--arg last_checked "$now" --argjson services "$svcs" \ --arg last_checked "$now" --argjson services "$svcs" \
--arg update_type "$policy" \ --arg update_type "$policy" --arg auto_attempted "$auto_attempted" \
'{name:$name,displayName:$displayName,type:$type,channel:$channel, '{name:$name,displayName:$displayName,type:$type,channel:$channel,
current_image:$current_image,current_version:$current_version,current_digest:$current_digest, current_image:$current_image,current_version:$current_version,current_digest:$current_digest,
available_image:$available_image, available_image:$available_image,
available_version:(if $available_version=="" then null else $available_version end), available_version:(if $available_version=="" then null else $available_version end),
available_digest:$available_digest,update_available:$update_available, available_digest:$available_digest,update_available:$update_available,
update_type:$update_type, update_type:$update_type,
auto_attempted_digest:(if $auto_attempted=="" then null else $auto_attempted end),
scanned:true,last_checked:$last_checked,services:$services}' \ scanned:true,last_checked:$last_checked,services:$services}' \
>> "$objs" 2>/dev/null >> "$objs" 2>/dev/null
else else
@ -244,13 +260,18 @@ webuiUpdaterScan() {
# the auto-scan cadence, so the display needs no separate config fetch. # the auto-scan cadence, so the display needs no separate config fetch.
local scan_interval="${CFG_UPDATER_SCAN_INTERVAL:-30}" local scan_interval="${CFG_UPDATER_SCAN_INTERVAL:-30}"
[[ "$scan_interval" =~ ^[0-9]+$ ]] || scan_interval=30 [[ "$scan_interval" =~ ^[0-9]+$ ]] || scan_interval=30
# The install window rides along so the WebUI can say WHEN automatic
# updates land, not just that they will. Raw value on purpose: a malformed
# window (which fails closed in the enqueuer) is then visible on the page.
local auto_window="${CFG_UPDATER_WINDOW:-always}"
local tmp; tmp="$(mktemp)" local tmp; tmp="$(mktemp)"
if [ "$have_jq" = "1" ]; then if [ "$have_jq" = "1" ]; then
jq -s --arg now "$now" --argjson iv "$scan_interval" '{generated_at:$now, scan_interval_minutes:$iv, apps:.}' "$objs" > "$tmp" 2>/dev/null \ jq -s --arg now "$now" --argjson iv "$scan_interval" --arg win "$auto_window" \
|| printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "apps": [] }\n' "$now" "$scan_interval" > "$tmp" '{generated_at:$now, scan_interval_minutes:$iv, auto_window:$win, apps:.}' "$objs" > "$tmp" 2>/dev/null \
|| printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "apps": [] }\n' "$now" "$scan_interval" "$auto_window" > "$tmp"
else else
{ printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "apps": [' "$now" "$scan_interval" { printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "apps": [' "$now" "$scan_interval" "$auto_window"
paste -sd, "$objs"; printf '] }\n'; } > "$tmp" paste -sd, "$objs"; printf '] }\n'; } > "$tmp"
fi fi
rm -f "$objs" rm -f "$objs"