From 66c79f997e3dc8628fe4a5de61f5bdf16cea2a41 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 11 Aug 2026 21:06:27 +0100 Subject: [PATCH] feat(updater): install window, honest Check-now, failed-auto surfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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 --- configs/general/general_basic | 2 +- configs/webui/webui_updater | 3 +- .../apps/overview/js/overview-manager.js | 21 ++++++++-- .../components/updater/js/updater-page.js | 25 +++++++++-- docs/roadmap/app-version-updater-and-cve.md | 9 +++- .../cli/commands/updater/cli_updater_auto.sh | 42 ++++++++++++++++++- .../commands/updater/cli_updater_commands.sh | 6 +++ .../commands/updater/cli_updater_header.sh | 8 ++-- .../source/files/arrays/function_manifest.sh | 3 ++ .../webui_create_app_field_mappings.sh | 2 +- .../generators/config/webui_update_config.sh | 8 ++++ .../generators/updater/webui_updater_scan.sh | 31 +++++++++++--- 12 files changed, 139 insertions(+), 21 deletions(-) diff --git a/configs/general/general_basic b/configs/general/general_basic index af6b046..47c8458 100755 --- a/configs/general/general_basic +++ b/configs/general/general_basic @@ -3,5 +3,5 @@ # @icon ๐Ÿท๏ธ # ================================================================================ 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] diff --git a/configs/webui/webui_updater b/configs/webui/webui_updater index 3e3bfaf..b3770eb 100644 --- a/configs/webui/webui_updater +++ b/configs/webui/webui_updater @@ -3,6 +3,7 @@ # @icon ๐Ÿ”„ # ================================================================================ 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_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. diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index badbf62..4d479e4 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -407,9 +407,12 @@ class OverviewManager { // wolf. They still get a quiet line so nothing is hidden. // With automatic scanning off nothing is found unattended, so an "auto" // 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 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 listOf = (arr) => { const names = arr.map((a) => a.displayName || a.name); @@ -425,9 +428,14 @@ class OverviewManager { + ``, }); } 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({ 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`, 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 โ€” // 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. + // 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' ? `manual` - : ''; + : (failed + ? `auto failed` + : ''); const updBtn = a.update_available ? `` : ''; diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index 9bb0b15..cc09f05 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -541,10 +541,15 @@ class UpdaterPage { : (a.scanned ? `up to date` : `unscanned`); // 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 - // 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' ? 'Set to manual โ€” this app updates only when you press Update.' - : 'Set to automatic โ€” new builds install on their own, after a recovery snapshot.'; + : (this.autoAttemptFailed(a) + ? 'Set to automatic, 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 automatic โ€” new builds install on their own, after a recovery snapshot.'); versionSection = `

Version

${badge} ${cur}${avail ? ` โ†’ ${avail}` : ''}
${policyLine}
`; @@ -594,6 +599,15 @@ class UpdaterPage { return `
${versionSection}${security}${recovery}${history}
`; } + // 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 โ€” // a hand-pressed update needs no explanation, and entries written before the // 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. // 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. + // 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; let autoBit = ''; if (iv === 0 || !this.apps.length) autoBit = ''; - else if (auto === this.apps.length) autoBit = ' ยท updates install automatically'; - else if (auto) autoBit = ` ยท ${auto} of ${this.apps.length} apps install updates 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${winBit}`; else autoBit = ' ยท updates wait for you'; return `
` + `Checked automatically ยท last checked ${last}${nextBit}${autoBit}` + diff --git a/docs/roadmap/app-version-updater-and-cve.md b/docs/roadmap/app-version-updater-and-cve.md index ddf6264..9079929 100644 --- a/docs/roadmap/app-version-updater-and-cve.md +++ b/docs/roadmap/app-version-updater-and-cve.md @@ -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. -**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 `/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) diff --git a/scripts/cli/commands/updater/cli_updater_auto.sh b/scripts/cli/commands/updater/cli_updater_auto.sh index 80e61f0..f93b2a9 100644 --- a/scripts/cli/commands/updater/cli_updater_auto.sh +++ b/scripts/cli/commands/updater/cli_updater_auto.sh @@ -6,9 +6,12 @@ # 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: +# Policy is per app, with a fleet-wide master switch and a daily install window: # CFG__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. @@ -31,6 +34,32 @@ _updaterAutoGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updat _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__UPDATE_TYPE @@ -85,6 +114,17 @@ updaterApplyAuto() 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 diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh index 93b3169..8f64619 100644 --- a/scripts/cli/commands/updater/cli_updater_commands.sh +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -41,6 +41,12 @@ cliHandleUpdaterCommands() (( _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 diff --git a/scripts/cli/commands/updater/cli_updater_header.sh b/scripts/cli/commands/updater/cli_updater_header.sh index 31b4f4b..d4f5f21 100644 --- a/scripts/cli/commands/updater/cli_updater_header.sh +++ b/scripts/cli/commands/updater/cli_updater_header.sh @@ -21,8 +21,10 @@ cliShowUpdaterHelp() echo "" echo "Automatic updates: each check enqueues the update for every app set to" echo "CFG__UPDATE_TYPE=auto (the default) โ€” the same snapshot-first apply" - echo "as pressing Update, recorded in History as automatic. Set an app to" - echo "'manual' on its Configure page to hold it back, or CFG_UPDATER_AUTO=false" - echo "to hold back every app. A build that fails is never auto-retried." + echo "as pressing Update, recorded in History as automatic. They install only" + echo "inside CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after" + 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 "" } diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index ed45d52..1818222 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -927,6 +927,7 @@ declare -gA LP_FN_MAP=( [updaterComposePull]="cli/commands/updater/cli_updater_commands.sh" [updaterDisplayVersion]="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" [updaterPrimaryImage]="webui/data/generators/updater/webui_updater_scan.sh" [updaterRecordHistory]="cli/commands/updater/cli_updater_commands.sh" @@ -1940,6 +1941,7 @@ declare -gA LP_FN_ROOT=( [updaterComposePull]="scripts" [updaterDisplayVersion]="scripts" [updaterInspectLocal]="scripts" + [updaterInWindow]="scripts" [updaterLastUpdateFrom]="scripts" [updaterPrimaryImage]="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 "$@"; } 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 "$@"; } +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 "$@"; } 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 "$@"; } diff --git a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh index 8e4c246..75a5675 100755 --- a/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh +++ b/scripts/webui/data/generators/categories/webui_create_app_field_mappings.sh @@ -177,7 +177,7 @@ PORTEOF "category": "general", "label": "Updates", "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": [ {"value": "auto", "label": "Automatic (recommended)"}, {"value": "manual", "label": "Manual โ€” I'll press Update"} diff --git a/scripts/webui/data/generators/config/webui_update_config.sh b/scripts/webui/data/generators/config/webui_update_config.sh index 748cb58..123715b 100755 --- a/scripts/webui/data/generators/config/webui_update_config.sh +++ b/scripts/webui/data/generators/config/webui_update_config.sh @@ -84,6 +84,14 @@ webuiValidateConfigValue() { isError " Invalid timezone format for $var_name" 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) # Validate CIDR format if ! echo "$var_value" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index 2688d37..e52851a 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -141,9 +141,16 @@ webuiUpdaterScan() { # (the "Check now" button) forces a live pull. local prev_json="$out_dir/updates.json" 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 [ "$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 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 @@ -214,6 +221,14 @@ webuiUpdaterScan() { declare -F updaterAppPolicy >/dev/null 2>&1 && policy="$(updaterAppPolicy "$app")" 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 jq -cn \ --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_digest "$avail_dig" --argjson update_available "$update_available" \ --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, current_image:$current_image,current_version:$current_version,current_digest:$current_digest, available_image:$available_image, available_version:(if $available_version=="" then null else $available_version end), available_digest:$available_digest,update_available:$update_available, update_type:$update_type, + auto_attempted_digest:(if $auto_attempted=="" then null else $auto_attempted end), scanned:true,last_checked:$last_checked,services:$services}' \ >> "$objs" 2>/dev/null else @@ -244,13 +260,18 @@ webuiUpdaterScan() { # the auto-scan cadence, so the display needs no separate config fetch. local scan_interval="${CFG_UPDATER_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)" 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 \ - || printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "apps": [] }\n' "$now" "$scan_interval" > "$tmp" + jq -s --arg now "$now" --argjson iv "$scan_interval" --arg win "$auto_window" \ + '{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 - { 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" fi rm -f "$objs"