From cdeb2d16585ade3cb434b716311f899f74abb195 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 11 Aug 2026 16:22:04 +0100 Subject: [PATCH] feat(updater): per-app UPDATE_TYPE, automatic by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__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 --- configs/webui/webui_updater | 1 + containers/adguard/adguard.config | 2 + containers/authelia/authelia.config | 2 + containers/bookstack/bookstack.config | 2 + containers/crowdsec/crowdsec.config | 2 + containers/dashy/dashy.config | 2 + containers/focalboard/focalboard.config | 2 + containers/gitea/gitea.config | 2 + containers/gluetun/gluetun.config | 2 + containers/grafana/grafana.config | 2 + containers/headscale/headscale.config | 2 + containers/invidious/invidious.config | 2 + containers/ipinfo/ipinfo.config | 2 + containers/jellyfin/jellyfin.config | 2 + containers/jitsimeet/jitsimeet.config | 2 + .../apps/overview/js/overview-manager.js | 48 +++++-- .../components/updater/js/updater-page.js | 35 ++++- .../libreportal_catalog.config | 2 + containers/linkding/linkding.config | 2 + containers/mastodon/mastodon.config | 2 + containers/moneyapp/moneyapp.config | 2 + containers/navidrome/navidrome.config | 2 + containers/nextcloud/nextcloud.config | 2 + containers/ollama/ollama.config | 2 + containers/onlyoffice/onlyoffice.config | 2 + containers/owncloud/owncloud.config | 2 + containers/pihole/pihole.config | 2 + containers/prometheus/prometheus.config | 2 + containers/searxng/searxng.config | 2 + containers/speedtest/speedtest.config | 2 + containers/traefik/traefik.config | 2 + containers/trilium/trilium.config | 2 + containers/trivy/trivy.config | 2 + containers/unbound/unbound.config | 2 + containers/vaultwarden/vaultwarden.config | 2 + containers/wireguard/wireguard.config | 2 + docs/roadmap/app-version-updater-and-cve.md | 21 ++- .../commands/artifact/cli_artifact_apply.sh | 5 +- .../cli/commands/updater/cli_updater_auto.sh | 124 ++++++++++++++++++ .../commands/updater/cli_updater_commands.sh | 52 ++++++-- .../commands/updater/cli_updater_header.sh | 6 + scripts/source/files/arrays/files_cli.sh | 1 + .../source/files/arrays/function_manifest.sh | 18 +++ .../webui_create_app_field_mappings.sh | 11 ++ .../generators/updater/webui_updater_scan.sh | 19 ++- 45 files changed, 372 insertions(+), 35 deletions(-) create mode 100644 scripts/cli/commands/updater/cli_updater_auto.sh diff --git a/configs/webui/webui_updater b/configs/webui/webui_updater index 69e39f2..3e3bfaf 100644 --- a/configs/webui/webui_updater +++ b/configs/webui/webui_updater @@ -5,3 +5,4 @@ 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_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] diff --git a/containers/adguard/adguard.config b/containers/adguard/adguard.config index 59e5de7..27acb3d 100755 --- a/containers/adguard/adguard.config +++ b/containers/adguard/adguard.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -15,6 +16,7 @@ CFG_ADGUARD_APP_NAME=adguard CFG_ADGUARD_BACKUP=true CFG_ADGUARD_BACKUP_STRATEGY=auto +CFG_ADGUARD_UPDATE_TYPE=auto CFG_ADGUARD_COMPOSE_FILE=default CFG_ADGUARD_HEALTHCHECK=true CFG_ADGUARD_AUTHELIA=false diff --git a/containers/authelia/authelia.config b/containers/authelia/authelia.config index 65a2c1a..2092a92 100755 --- a/containers/authelia/authelia.config +++ b/containers/authelia/authelia.config @@ -6,6 +6,7 @@ # REQUIRES = comma-separated install prerequisites (see scripts/checks/requirements/check_app_install.sh) # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -18,6 +19,7 @@ CFG_AUTHELIA_APP_NAME=authelia CFG_AUTHELIA_REQUIRES="domain,traefik" CFG_AUTHELIA_BACKUP=true CFG_AUTHELIA_BACKUP_STRATEGY=auto +CFG_AUTHELIA_UPDATE_TYPE=auto CFG_AUTHELIA_COMPOSE_FILE=default CFG_AUTHELIA_HEALTHCHECK=true CFG_AUTHELIA_AUTHELIA=false diff --git a/containers/bookstack/bookstack.config b/containers/bookstack/bookstack.config index 9f7d40c..581ea1d 100644 --- a/containers/bookstack/bookstack.config +++ b/containers/bookstack/bookstack.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -19,6 +20,7 @@ CFG_BOOKSTACK_APP_NAME=bookstack CFG_BOOKSTACK_MULTI_INSTANCE=true CFG_BOOKSTACK_BACKUP=true CFG_BOOKSTACK_BACKUP_STRATEGY=auto +CFG_BOOKSTACK_UPDATE_TYPE=auto CFG_BOOKSTACK_COMPOSE_FILE=default CFG_BOOKSTACK_HEALTHCHECK=true CFG_BOOKSTACK_AUTHELIA=false diff --git a/containers/crowdsec/crowdsec.config b/containers/crowdsec/crowdsec.config index d9ef1d1..b6ed2ac 100644 --- a/containers/crowdsec/crowdsec.config +++ b/containers/crowdsec/crowdsec.config @@ -9,6 +9,7 @@ # HOST_SERVICES = all units; feeds the Services + Logs tabs # HOST_LOG_FILES = |,... mapping for the log viewer # BACKUP = include in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed; ships the official CrowdSec Grafana dashboards) # PROMETHEUS_LISTEN = address CrowdSec's metrics endpoint binds to; must be reachable from the Prometheus container (default: all interfaces, port 6060 — keep the :6060 port) # @@ -20,6 +21,7 @@ CFG_CROWDSEC_HOST_SERVICES=crowdsec.service,crowdsec-firewall-bouncer.service CFG_CROWDSEC_HOST_LOG_FILES="crowdsec.service|/var/log/crowdsec.log,crowdsec-firewall-bouncer.service|/var/log/crowdsec-firewall-bouncer.log" CFG_CROWDSEC_BACKUP=true CFG_CROWDSEC_BACKUP_STRATEGY=auto +CFG_CROWDSEC_UPDATE_TYPE=auto CFG_CROWDSEC_MONITORING=false CFG_CROWDSEC_PROMETHEUS_LISTEN=0.0.0.0:6060 # diff --git a/containers/dashy/dashy.config b/containers/dashy/dashy.config index f040346..048e49d 100755 --- a/containers/dashy/dashy.config +++ b/containers/dashy/dashy.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_DASHY_APP_NAME=dashy CFG_DASHY_BACKUP=true CFG_DASHY_BACKUP_STRATEGY=auto +CFG_DASHY_UPDATE_TYPE=auto CFG_DASHY_COMPOSE_FILE=default CFG_DASHY_HEALTHCHECK=true CFG_DASHY_AUTHELIA=false diff --git a/containers/focalboard/focalboard.config b/containers/focalboard/focalboard.config index 1cd00f1..9332212 100755 --- a/containers/focalboard/focalboard.config +++ b/containers/focalboard/focalboard.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_FOCALBOARD_APP_NAME=focalboard CFG_FOCALBOARD_BACKUP=true CFG_FOCALBOARD_BACKUP_STRATEGY=auto +CFG_FOCALBOARD_UPDATE_TYPE=auto CFG_FOCALBOARD_COMPOSE_FILE=default CFG_FOCALBOARD_HEALTHCHECK=true CFG_FOCALBOARD_AUTHELIA=false diff --git a/containers/gitea/gitea.config b/containers/gitea/gitea.config index 2b8c08d..bde7163 100755 --- a/containers/gitea/gitea.config +++ b/containers/gitea/gitea.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -14,6 +15,7 @@ CFG_GITEA_APP_NAME=gitea CFG_GITEA_BACKUP=true CFG_GITEA_BACKUP_STRATEGY=auto +CFG_GITEA_UPDATE_TYPE=auto CFG_GITEA_COMPOSE_FILE=default CFG_GITEA_HEALTHCHECK=true CFG_GITEA_AUTHELIA=false diff --git a/containers/gluetun/gluetun.config b/containers/gluetun/gluetun.config index d80a8a5..68afca7 100644 --- a/containers/gluetun/gluetun.config +++ b/containers/gluetun/gluetun.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -13,6 +14,7 @@ CFG_GLUETUN_APP_NAME=gluetun CFG_GLUETUN_BACKUP=true CFG_GLUETUN_BACKUP_STRATEGY=auto +CFG_GLUETUN_UPDATE_TYPE=auto CFG_GLUETUN_COMPOSE_FILE=default CFG_GLUETUN_HEALTHCHECK=true CFG_GLUETUN_AUTHELIA=false diff --git a/containers/grafana/grafana.config b/containers/grafana/grafana.config index 849c016..b6531d9 100755 --- a/containers/grafana/grafana.config +++ b/containers/grafana/grafana.config @@ -6,6 +6,7 @@ # REQUIRES = comma-separated install prerequisites (see scripts/checks/requirements/check_app_install.sh) # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -15,6 +16,7 @@ CFG_GRAFANA_APP_NAME=grafana CFG_GRAFANA_REQUIRES="prometheus" CFG_GRAFANA_BACKUP=true CFG_GRAFANA_BACKUP_STRATEGY=auto +CFG_GRAFANA_UPDATE_TYPE=auto CFG_GRAFANA_COMPOSE_FILE=default CFG_GRAFANA_HEALTHCHECK=true CFG_GRAFANA_AUTHELIA=false diff --git a/containers/headscale/headscale.config b/containers/headscale/headscale.config index 5c82bf8..a57354d 100755 --- a/containers/headscale/headscale.config +++ b/containers/headscale/headscale.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # BASIC_AUTH_PASS = password for the headscale-ui basic auth; auto-generated, fed to the compose via HEADSCALE_BASIC_AUTH_PASS_TAG # MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed) @@ -12,6 +13,7 @@ CFG_HEADSCALE_APP_NAME=headscale CFG_HEADSCALE_BACKUP=true CFG_HEADSCALE_BACKUP_STRATEGY=auto +CFG_HEADSCALE_UPDATE_TYPE=auto CFG_HEADSCALE_COMPOSE_FILE=default CFG_HEADSCALE_HEALTHCHECK=true CFG_HEADSCALE_BASIC_AUTH_PASS=RANDOMIZEDPASSWORD1 diff --git a/containers/invidious/invidious.config b/containers/invidious/invidious.config index 2698c6d..5253729 100755 --- a/containers/invidious/invidious.config +++ b/containers/invidious/invidious.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_INVIDIOUS_APP_NAME=invidious CFG_INVIDIOUS_BACKUP=false CFG_INVIDIOUS_BACKUP_STRATEGY=auto +CFG_INVIDIOUS_UPDATE_TYPE=auto CFG_INVIDIOUS_COMPOSE_FILE=default CFG_INVIDIOUS_HEALTHCHECK=false CFG_INVIDIOUS_AUTHELIA=false diff --git a/containers/ipinfo/ipinfo.config b/containers/ipinfo/ipinfo.config index 388ce20..6cc4008 100755 --- a/containers/ipinfo/ipinfo.config +++ b/containers/ipinfo/ipinfo.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_IPINFO_APP_NAME=ipinfo CFG_IPINFO_BACKUP=false CFG_IPINFO_BACKUP_STRATEGY=auto +CFG_IPINFO_UPDATE_TYPE=auto CFG_IPINFO_COMPOSE_FILE=default CFG_IPINFO_HEALTHCHECK=true CFG_IPINFO_AUTHELIA=false diff --git a/containers/jellyfin/jellyfin.config b/containers/jellyfin/jellyfin.config index df15d75..693f13c 100755 --- a/containers/jellyfin/jellyfin.config +++ b/containers/jellyfin/jellyfin.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_JELLYFIN_APP_NAME=jellyfin CFG_JELLYFIN_BACKUP=true CFG_JELLYFIN_BACKUP_STRATEGY=auto +CFG_JELLYFIN_UPDATE_TYPE=auto CFG_JELLYFIN_COMPOSE_FILE=default CFG_JELLYFIN_HEALTHCHECK=true CFG_JELLYFIN_AUTHELIA=false diff --git a/containers/jitsimeet/jitsimeet.config b/containers/jitsimeet/jitsimeet.config index 512ca92..732e5ff 100755 --- a/containers/jitsimeet/jitsimeet.config +++ b/containers/jitsimeet/jitsimeet.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_JITSIMEET_APP_NAME=jitsimeet CFG_JITSIMEET_BACKUP=true CFG_JITSIMEET_BACKUP_STRATEGY=auto +CFG_JITSIMEET_UPDATE_TYPE=auto CFG_JITSIMEET_COMPOSE_FILE=default CFG_JITSIMEET_HEALTHCHECK=true CFG_JITSIMEET_AUTHELIA=false 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 1c1c6cc..badbf62 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -401,15 +401,37 @@ class OverviewManager { if (!scanned) { rows.push({ hue: 'updates', icon: '⬆️', kind: 'none', text: 'Updates — not scanned yet' }); } else if (pending.length) { - const names = pending.map((a) => a.displayName || a.name); - const list = names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''); - rows.push({ - hue: 'updates', icon: '⬆️', kind: 'warn', - text: `${pending.length} update${pending.length === 1 ? '' : 's'} available`, - sub: `${esc(list)} — a recovery snapshot is taken before each update`, - actions: goto('updates', 'Review', 'updates') - + ``, - }); + // Only the apps set to manual actually want a decision — the rest install + // themselves on the next scan, so parking them on this board (and, since + // the board opens on "Needs action", in the user's face) would be crying + // 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. + const autoScan = !(up.updates && Number(up.updates.scan_interval_minutes) === 0); + const waiting = pending.filter((a) => !autoScan || a.update_type === 'manual'); + const selfing = pending.length - waiting.length; + const listOf = (arr) => { + const names = arr.map((a) => a.displayName || a.name); + return names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''); + }; + if (waiting.length) { + rows.push({ + hue: 'updates', icon: '⬆️', kind: 'warn', + text: `${waiting.length} update${waiting.length === 1 ? '' : 's'} waiting for you`, + sub: `${esc(listOf(waiting))} — a recovery snapshot is taken before each update` + + (selfing ? ` · ${selfing} more will install automatically` : ''), + actions: goto('updates', 'Review', 'updates') + + ``, + }); + } else { + rows.push({ + hue: 'updates', icon: '⬆️', kind: 'ok', + text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically`, + sub: `${esc(listOf(pending))} — each is snapshotted first, and rolled back if it fails`, + actions: goto('updates', 'Review', 'updates'), + }); + } } else { rows.push({ hue: 'updates', icon: '⬆️', kind: 'ok', @@ -558,13 +580,19 @@ class OverviewManager { ? `↑ Update available` : (a.scanned ? `✓ Up to date` : `• Unscanned`); const sev = a.worstSeverity ? `${a.worstSeverity}` : ''; + // 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. + const pol = a.update_type === 'manual' + ? `manual` + : ''; const updBtn = a.update_available ? `` : ''; return `
diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index 2fecc0f..9bb0b15 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -515,7 +515,7 @@ class UpdaterPage { const rows = entries.map(e => `
${this.escape(e.app)} - ${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}
+ ${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}${this.triggerBadge(e)}
${this.escape(e.from || '')}${e.to ? ` ${this.escape(e.to)}` : ''}
${this.fmtRel(e.ts)}
`).join(''); @@ -539,8 +539,15 @@ class UpdaterPage { const badge = a.update_available ? `update available` : (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. + 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.'; versionSection = `

Version

-
${badge} ${cur}${avail ? ` ${avail}` : ''}
`; +
${badge} ${cur}${avail ? ` ${avail}` : ''}
+
${policyLine}
`; } const cves = a.cves || []; const appLabel = this.escape((window.getAppDisplayName ? window.getAppDisplayName(a.name) : null) || a.displayName || a.name || 'the app'); @@ -580,13 +587,22 @@ class UpdaterPage { const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8); const history = entries.length ? `

History

${entries.map((e) => ` -
${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''} +
${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}${this.triggerBadge(e)} ${this.escape(e.from || '')}${e.to ? ` → ${this.escape(e.to)}` : ''} ${this.fmtRel(e.ts)}
`).join('')}
` : ''; return `
${versionSection}${security}${recovery}${history}
`; } + // "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. + triggerBadge(e) { + return e && e.trigger === 'auto' + ? ' automatic' + : ''; + } + empty(msg, withCheck) { return `
${this.escape(msg)}${withCheck ? `
` : ''}
`; } @@ -636,8 +652,19 @@ class UpdaterPage { } else if (gen && iv > 0) { nextBit = ` · next check ${this.fmtRelFuture(Date.parse(gen) + iv * 60000)}`; } + // Say plainly whether found updates install themselves. The scan carries each + // app's resolved policy, so this counts what will actually happen rather than + // 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. + 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 autoBit = ' · updates wait for you'; return `
` + - `Checked automatically · last checked ${last}${nextBit}` + + `Checked automatically · last checked ${last}${nextBit}${autoBit}` + `
`; } } diff --git a/containers/libreportal_catalog/libreportal_catalog.config b/containers/libreportal_catalog/libreportal_catalog.config index 3afaafb..cde38bd 100644 --- a/containers/libreportal_catalog/libreportal_catalog.config +++ b/containers/libreportal_catalog/libreportal_catalog.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -13,6 +14,7 @@ CFG_LIBREPORTAL_CATALOG_APP_NAME=libreportal_catalog CFG_LIBREPORTAL_CATALOG_BACKUP=true CFG_LIBREPORTAL_CATALOG_BACKUP_STRATEGY=auto +CFG_LIBREPORTAL_CATALOG_UPDATE_TYPE=auto CFG_LIBREPORTAL_CATALOG_COMPOSE_FILE=default CFG_LIBREPORTAL_CATALOG_HEALTHCHECK=false CFG_LIBREPORTAL_CATALOG_AUTHELIA=false diff --git a/containers/linkding/linkding.config b/containers/linkding/linkding.config index 8685308..50c4d3c 100755 --- a/containers/linkding/linkding.config +++ b/containers/linkding/linkding.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in the docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_LINKDING_APP_NAME=linkding CFG_LINKDING_BACKUP=true CFG_LINKDING_BACKUP_STRATEGY=auto +CFG_LINKDING_UPDATE_TYPE=auto CFG_LINKDING_COMPOSE_FILE=default CFG_LINKDING_HEALTHCHECK=true CFG_LINKDING_AUTHELIA=false diff --git a/containers/mastodon/mastodon.config b/containers/mastodon/mastodon.config index 8b8ac03..ccbd85c 100755 --- a/containers/mastodon/mastodon.config +++ b/containers/mastodon/mastodon.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in the docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_MASTODON_APP_NAME=mastodon CFG_MASTODON_BACKUP=true CFG_MASTODON_BACKUP_STRATEGY=auto +CFG_MASTODON_UPDATE_TYPE=auto CFG_MASTODON_COMPOSE_FILE=default CFG_MASTODON_HEALTHCHECK=true CFG_MASTODON_AUTHELIA=false diff --git a/containers/moneyapp/moneyapp.config b/containers/moneyapp/moneyapp.config index 36b209c..0c66083 100644 --- a/containers/moneyapp/moneyapp.config +++ b/containers/moneyapp/moneyapp.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -14,6 +15,7 @@ CFG_MONEYAPP_APP_NAME=moneyapp CFG_MONEYAPP_REQUIRES="" CFG_MONEYAPP_BACKUP=true CFG_MONEYAPP_BACKUP_STRATEGY=auto +CFG_MONEYAPP_UPDATE_TYPE=auto CFG_MONEYAPP_COMPOSE_FILE=default CFG_MONEYAPP_HEALTHCHECK=true CFG_MONEYAPP_AUTHELIA=false diff --git a/containers/navidrome/navidrome.config b/containers/navidrome/navidrome.config index 91119b4..62357bc 100644 --- a/containers/navidrome/navidrome.config +++ b/containers/navidrome/navidrome.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_NAVIDROME_APP_NAME=navidrome CFG_NAVIDROME_BACKUP=true CFG_NAVIDROME_BACKUP_STRATEGY=auto +CFG_NAVIDROME_UPDATE_TYPE=auto CFG_NAVIDROME_COMPOSE_FILE=default CFG_NAVIDROME_HEALTHCHECK=true CFG_NAVIDROME_AUTHELIA=false diff --git a/containers/nextcloud/nextcloud.config b/containers/nextcloud/nextcloud.config index 83f22f2..af73b93 100755 --- a/containers/nextcloud/nextcloud.config +++ b/containers/nextcloud/nextcloud.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in the docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -16,6 +17,7 @@ CFG_NEXTCLOUD_APP_NAME=nextcloud CFG_NEXTCLOUD_BACKUP=true CFG_NEXTCLOUD_BACKUP_STRATEGY=auto +CFG_NEXTCLOUD_UPDATE_TYPE=auto CFG_NEXTCLOUD_COMPOSE_FILE=default CFG_NEXTCLOUD_HEALTHCHECK=true CFG_NEXTCLOUD_AUTHELIA=false diff --git a/containers/ollama/ollama.config b/containers/ollama/ollama.config index 340dbfb..6821003 100755 --- a/containers/ollama/ollama.config +++ b/containers/ollama/ollama.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in the docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -13,6 +14,7 @@ CFG_OLLAMA_APP_NAME=ollama CFG_OLLAMA_BACKUP=true CFG_OLLAMA_BACKUP_STRATEGY=auto +CFG_OLLAMA_UPDATE_TYPE=auto CFG_OLLAMA_COMPOSE_FILE=default CFG_OLLAMA_HEALTHCHECK=true CFG_OLLAMA_AUTHELIA=false diff --git a/containers/onlyoffice/onlyoffice.config b/containers/onlyoffice/onlyoffice.config index 70eb362..aff7987 100755 --- a/containers/onlyoffice/onlyoffice.config +++ b/containers/onlyoffice/onlyoffice.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in the docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_ONLYOFFICE_APP_NAME=onlyoffice CFG_ONLYOFFICE_BACKUP=true CFG_ONLYOFFICE_BACKUP_STRATEGY=auto +CFG_ONLYOFFICE_UPDATE_TYPE=auto CFG_ONLYOFFICE_COMPOSE_FILE=default CFG_ONLYOFFICE_HEALTHCHECK=true CFG_ONLYOFFICE_AUTHELIA=false diff --git a/containers/owncloud/owncloud.config b/containers/owncloud/owncloud.config index 4410f04..2a8c42f 100755 --- a/containers/owncloud/owncloud.config +++ b/containers/owncloud/owncloud.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in the docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_OWNCLOUD_APP_NAME=owncloud CFG_OWNCLOUD_BACKUP=true CFG_OWNCLOUD_BACKUP_STRATEGY=auto +CFG_OWNCLOUD_UPDATE_TYPE=auto CFG_OWNCLOUD_COMPOSE_FILE=default CFG_OWNCLOUD_HEALTHCHECK=true CFG_OWNCLOUD_AUTHELIA=false diff --git a/containers/pihole/pihole.config b/containers/pihole/pihole.config index bc2a697..62bf8f0 100755 --- a/containers/pihole/pihole.config +++ b/containers/pihole/pihole.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -13,6 +14,7 @@ CFG_PIHOLE_APP_NAME=pihole CFG_PIHOLE_BACKUP=true CFG_PIHOLE_BACKUP_STRATEGY=auto +CFG_PIHOLE_UPDATE_TYPE=auto CFG_PIHOLE_COMPOSE_FILE=default CFG_PIHOLE_HEALTHCHECK=true CFG_PIHOLE_AUTHELIA=false diff --git a/containers/prometheus/prometheus.config b/containers/prometheus/prometheus.config index 75d648e..2317627 100755 --- a/containers/prometheus/prometheus.config +++ b/containers/prometheus/prometheus.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_PROMETHEUS_APP_NAME=prometheus CFG_PROMETHEUS_BACKUP=true CFG_PROMETHEUS_BACKUP_STRATEGY=auto +CFG_PROMETHEUS_UPDATE_TYPE=auto CFG_PROMETHEUS_COMPOSE_FILE=default CFG_PROMETHEUS_HEALTHCHECK=true CFG_PROMETHEUS_AUTHELIA=false diff --git a/containers/searxng/searxng.config b/containers/searxng/searxng.config index c7d1496..ed4d6fd 100755 --- a/containers/searxng/searxng.config +++ b/containers/searxng/searxng.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_SEARXNG_APP_NAME=searxng CFG_SEARXNG_BACKUP=false CFG_SEARXNG_BACKUP_STRATEGY=auto +CFG_SEARXNG_UPDATE_TYPE=auto CFG_SEARXNG_COMPOSE_FILE=default CFG_SEARXNG_HEALTHCHECK=true CFG_SEARXNG_AUTHELIA=false diff --git a/containers/speedtest/speedtest.config b/containers/speedtest/speedtest.config index 8439980..8e8f262 100755 --- a/containers/speedtest/speedtest.config +++ b/containers/speedtest/speedtest.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_SPEEDTEST_APP_NAME=speedtest CFG_SPEEDTEST_BACKUP=false CFG_SPEEDTEST_BACKUP_STRATEGY=auto +CFG_SPEEDTEST_UPDATE_TYPE=auto CFG_SPEEDTEST_COMPOSE_FILE=default CFG_SPEEDTEST_HEALTHCHECK=false CFG_SPEEDTEST_AUTHELIA=false diff --git a/containers/traefik/traefik.config b/containers/traefik/traefik.config index 0403583..a5532fc 100755 --- a/containers/traefik/traefik.config +++ b/containers/traefik/traefik.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -13,6 +14,7 @@ CFG_TRAEFIK_APP_NAME=traefik CFG_TRAEFIK_BACKUP=true CFG_TRAEFIK_BACKUP_STRATEGY=auto +CFG_TRAEFIK_UPDATE_TYPE=auto CFG_TRAEFIK_COMPOSE_FILE=default CFG_TRAEFIK_HEALTHCHECK=true CFG_TRAEFIK_AUTHELIA=false diff --git a/containers/trilium/trilium.config b/containers/trilium/trilium.config index 2f428f7..5e29eb7 100755 --- a/containers/trilium/trilium.config +++ b/containers/trilium/trilium.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_TRILIUM_APP_NAME=trilium CFG_TRILIUM_BACKUP=true CFG_TRILIUM_BACKUP_STRATEGY=auto +CFG_TRILIUM_UPDATE_TYPE=auto CFG_TRILIUM_COMPOSE_FILE=default CFG_TRILIUM_HEALTHCHECK=true CFG_TRILIUM_AUTHELIA=false diff --git a/containers/trivy/trivy.config b/containers/trivy/trivy.config index c20bdfc..f434819 100644 --- a/containers/trivy/trivy.config +++ b/containers/trivy/trivy.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -12,6 +13,7 @@ CFG_TRIVY_APP_NAME=trivy CFG_TRIVY_BACKUP=false CFG_TRIVY_BACKUP_STRATEGY=auto +CFG_TRIVY_UPDATE_TYPE=auto CFG_TRIVY_COMPOSE_FILE=default CFG_TRIVY_HEALTHCHECK=true CFG_TRIVY_AUTHELIA=false diff --git a/containers/unbound/unbound.config b/containers/unbound/unbound.config index e86527a..b528fb9 100755 --- a/containers/unbound/unbound.config +++ b/containers/unbound/unbound.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -13,6 +14,7 @@ CFG_UNBOUND_APP_NAME=unbound CFG_UNBOUND_BACKUP=true CFG_UNBOUND_BACKUP_STRATEGY=auto +CFG_UNBOUND_UPDATE_TYPE=auto CFG_UNBOUND_COMPOSE_FILE=default CFG_UNBOUND_HEALTHCHECK=true CFG_UNBOUND_AUTHELIA=false diff --git a/containers/vaultwarden/vaultwarden.config b/containers/vaultwarden/vaultwarden.config index 2e63312..ed92aa6 100755 --- a/containers/vaultwarden/vaultwarden.config +++ b/containers/vaultwarden/vaultwarden.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -13,6 +14,7 @@ CFG_VAULTWARDEN_APP_NAME=vaultwarden CFG_VAULTWARDEN_BACKUP=true CFG_VAULTWARDEN_BACKUP_STRATEGY=auto +CFG_VAULTWARDEN_UPDATE_TYPE=auto CFG_VAULTWARDEN_COMPOSE_FILE=default CFG_VAULTWARDEN_HEALTHCHECK=false CFG_VAULTWARDEN_AUTHELIA=false diff --git a/containers/wireguard/wireguard.config b/containers/wireguard/wireguard.config index 3252d4d..5def2c4 100755 --- a/containers/wireguard/wireguard.config +++ b/containers/wireguard/wireguard.config @@ -5,6 +5,7 @@ # APP_NAME = name of application for use in scripts # COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is # BACKUP = if true, include this application in backup operations +# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update # HEALTHCHECK = if true, default docker health checks for that container will be enabled # AUTHELIA = if true, use Authelia authentication, if false turned off. # HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote @@ -14,6 +15,7 @@ CFG_WIREGUARD_APP_NAME=wireguard CFG_WIREGUARD_SUBNET= CFG_WIREGUARD_BACKUP=true CFG_WIREGUARD_BACKUP_STRATEGY=auto +CFG_WIREGUARD_UPDATE_TYPE=auto CFG_WIREGUARD_COMPOSE_FILE=default CFG_WIREGUARD_HEALTHCHECK=true CFG_WIREGUARD_AUTHELIA=false diff --git a/docs/roadmap/app-version-updater-and-cve.md b/docs/roadmap/app-version-updater-and-cve.md index 98adb75..ddf6264 100644 --- a/docs/roadmap/app-version-updater-and-cve.md +++ b/docs/roadmap/app-version-updater-and-cve.md @@ -1,6 +1,6 @@ # LibrePortal — Per-App Version Updater & CVE Scanning (Roadmap / Design) -**Status:** Design — **not built** (except the Trivy/CVE half, shipped 2026-07-16). Fills the two deliberately-unwired hooks in `webui_updater_scan.sh`. · **Audience:** us, future-self · **Scope:** real "update available" detection, per-app version identity (pick/track/revert), and the vulnerability scanner behind `/apps/overview/updates` · **Origin:** "the updates system is currently not set up" conversation 2026-07-14. Companion to [updates-and-distribution.md](updates-and-distribution.md) — that doc owns the *signed artifact/hotfix pipe*; this one owns the *generators* it always treated as pre-existing. +**Status:** Largely built — P0–P3 (2026-07-17), the Trivy/CVE half (2026-07-16), and the auto-update policy of §6 (2026-08-11). Fills the two deliberately-unwired hooks in `webui_updater_scan.sh`. · **Audience:** us, future-self · **Scope:** real "update available" detection, per-app version identity (pick/track/revert), and the vulnerability scanner behind `/apps/overview/updates` · **Origin:** "the updates system is currently not set up" conversation 2026-07-14. Companion to [updates-and-distribution.md](updates-and-distribution.md) — that doc owns the *signed artifact/hotfix pipe*; this one owns the *generators* it always treated as pre-existing. > **Revised 2026-07-17 — config-first identity + version types + anchor fix.** Three changes from the original direction, after auditing the compose corpus (33 apps, 50 image lines) and the `#LIBREPORTAL|…|…` tagging system: > 1. **`CFG__VERSION` is adopted, not rejected.** The tagging system (`scripts/config/tags/processors/tags_processor_app_config_values.sh`) already derives compose values from `CFG__` config — so a version config makes the config *the* source and the image tag *derived*, not a second source of truth. The original §8 rejection assumed no such mechanism. See revised §2 / §8. @@ -21,6 +21,7 @@ Most of the updater is **already built and working**. What's missing is narrow: | `history.json` (fail-closed from→to audit trail) | ✅ real | | Signed hotfix channel (fetch/verify/apply/undo, `CFG_HOTFIX_AUTO`) | ✅ real | | Auto-scan scheduling (`updater check auto`, 30-min throttle) | ✅ real | +| Auto-**apply** policy (`CFG__UPDATE_TYPE`, default auto) | ✅ real (2026-08-11, §6) | | WebUI — fleet Updates tab, per-app Updates tab, Security/Recovery/History renderers, Update/Roll-back buttons wired to tasks | ✅ real, waiting on data | | **"Update available" detection** | ❌ stub — `update_available` is hard-coded `false` (`webui_updater_scan.sh:55-58` is a marked hook) | | **CVE data** (`cves.json`) | ❌ stub — written empty once, then never rewritten (the `[ ! -f ]` guard at `:82-89` must go) | @@ -130,9 +131,19 @@ So: **trivy ships as a normal catalog app** (`containers/trivy/`), marked **reco - **Not alarmist** (house rule): the Security tab lists everything, but the per-app chip/badge only fires for **critical/high with a fix available** — "your box has 400 unfixable medium CVEs" red badges are noise, not signal. - **Tie-in with updates:** a CVE whose `fixed_in` is satisfied by the available build marks that update as a *security update* — the Security filter chip and severity sort already exist in the UI, they just start meaning something. -## 6. Auto-update policy (deliberately later) +## 6. Auto-update policy — **BUILT 2026-08-11** (per-app, not severity-split) -Once detection + pinned apply are trustworthy, add `CFG_UPDATER_AUTO=off|security|all` (default **off**): `security` auto-enqueues `updater_apply` only for security updates (mirrors `CFG_HOTFIX_AUTO`'s severity-split precedent, and like `artifactApplyAuto` it only *enqueues tasks*). Not part of the initial build — auto-updating before the revert story is proven live would be backwards. +Shipped a shape the original sketch didn't consider: the policy is **per app**, not one fleet-wide severity rule. + +- **`CFG__UPDATE_TYPE=auto|manual`, default `auto`** — in all 33 app config templates (not `libreportal`, whose own updates are a different subsystem). Reaches existing installs through the normal template reconcile, and shows up on the app's Configure page as *Updates* (field mapping `UPDATE_TYPE`, category general). +- **`CFG_UPDATER_AUTO=true|false`, default `true`** — the master switch in `configs/webui/webui_updater`. It can only ever make things *more* manual, so "stop auto-updating everything" is one flip, not 33 edits. Precedence deliberately mirrors `backupResolveStrategy` (per-app override on top of a global default); `updaterAppPolicy` is the single resolver, used by both the enqueuer and the scan. +- **`updaterApplyAuto`** (`cli/commands/updater/cli_updater_auto.sh`), called at the end of `updater check`, enqueues the ordinary `updater_apply` task for every update-available app whose policy is auto — never applies inline, exactly like `artifactApplyAuto`. So an automatic update is byte-for-byte the manual path: snapshot → pull → up → auto-rollback on failure, one task log, one History entry, same Roll back button. +- **No retry storms.** Each attempt stamps the target digest under `generated/auto/.digest`; the same digest is never auto-attempted twice. A broken upstream build is tried once, rolled back, then left alone (the Update button still offers a manual retry) until a *newer* build changes the digest. In-flight tasks are also skipped, so a scan landing mid-update can't stack a second one. +- **Tracked, not silent.** `updates.json` carries each app's resolved `update_type`, and History entries carry `trigger: manual|auto`. The WebUI reads both: the fleet list says whether updates install themselves, rows that opted out carry a `manual` chip, and automatic History entries are labelled. The Overview board treats an auto app's pending update as a *quiet* line, not a "needs action" one — it doesn't need a decision. + +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. ## 7. Build phases (each independently shippable) @@ -142,13 +153,13 @@ Once detection + pinned apply are trustworthy, add `CFG_UPDATER_AUTO=off|securit 3. **P3 — Pinned apply/revert.** ✅ *done 2026-07-17.* `updaterApplyApp` records exact `repo:tag@sha256:…` from→to and un-pins before pulling; `updaterRollbackApp` re-pins the anchor to the pre-update build's digest before `up` (closes "new code on old data"). History carries digest refs. *(Helpers unit-tested; end-to-end apply/revert not yet exercised on a live install with a pending update.)* 4. **P4 — CVE scanner.** The trivy app (catalog + recommended + first-install list) plus the updater's scan step through it; `cves.json` for real, guard dropped, totals + severity chips live, security-update tie-in. The app itself can ship ahead of the rest of P4. 5. **P5 — Polish.** Per-app critical-CVE / update chip on the app header (pattern exists for improvements), remote version-label enrichment, "N services" aggregation in rows. -6. **P6 — Auto-update policy** (§6), only after P3 has soaked on a real install. +6. **P6 — Auto-update policy.** ✅ *done 2026-08-11.* Per-app `CFG__UPDATE_TYPE` (default auto) under the `CFG_UPDATER_AUTO` master switch; `updaterApplyAuto` enqueues the normal apply task, one attempt per target digest, policy + trigger surfaced in `updates.json`/`history.json` and the WebUI. See the rewritten §6. *(Landed ahead of P3's live soak at the maintainer's call — the snapshot-first apply and the one-shot digest stamp are what make that acceptable.)* ## 8. Rejected alternatives (for the record) - **~~`CFG__VERSION` config vars~~ — ADOPTED 2026-07-17 (see §2).** Original objection: "second source of truth vs the compose file, collides with the `_TAG` namespace, doesn't solve floating `latest`." Reassessed: the tagging system makes the config *the* source (compose tag derived from it, like every other config value), and `CFG__VERSION` → `_VERSION_TAG` fits the naming convention with no collision. The only surviving point — *doesn't detect floating `latest` alone* — is real and is exactly why the **digest fingerprint** rides alongside it; config = channel, digest = build. - **Pure "compare version numbers" (no digest)** — the intuitive model, but most upstreams don't publish a comparable version and there is no universal "latest version of X" API. Would require per-app curated version-source adapters (where to look, how to parse/compare) — permanent, fragile curation. The digest engine works for all apps automatically; version numbers are display enrichment on top (§2, *two types*). - **Pin semver tags across all templates** — permanent curation burden, inconsistent upstreams, still blind to republished tags. -- **Watchtower-style auto-pull of latest** — maximum freshness, zero visibility, no revert; the exact opposite of "monitor, update deliberately, roll back." +- **Watchtower-style auto-pull of latest** — maximum freshness, zero visibility, no revert; the exact opposite of "monitor, update deliberately, roll back." Note §6's auto default is *not* this: it is the same deliberate, snapshotted, revertible apply the button runs, scheduled — visible in Tasks and History, per-app switchable, and never retried blindly. - **Docker Scout / hosted scanners** — requires accounts / sends data off-box; against the ethos. - **A new top-level "Versions" area** — unnecessary; the fleet Updates tab + per-app Updates tab already are that surface, they just need real data. diff --git a/scripts/cli/commands/artifact/cli_artifact_apply.sh b/scripts/cli/commands/artifact/cli_artifact_apply.sh index c17da99..f51d2a0 100644 --- a/scripts/cli/commands/artifact/cli_artifact_apply.sh +++ b/scripts/cli/commands/artifact/cli_artifact_apply.sh @@ -846,7 +846,10 @@ artifactApplyAuto() { # gates re-checked at apply time) app="$(jq -r '.applies_when.app // empty' <<<"$art")" [[ -n "$app" && ! -d "${containers_dir%/}/$app" ]] && continue - cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" "" + # --detach: this runs inside the task processor's own poll, so following + # the new task in the foreground would wait for a task the (single- + # threaded) processor can't start until we return. + cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" "--detach" enqueued=$((enqueued + 1)) done <<< "$ids" diff --git a/scripts/cli/commands/updater/cli_updater_auto.sh b/scripts/cli/commands/updater/cli_updater_auto.sh new file mode 100644 index 0000000..80e61f0 --- /dev/null +++ b/scripts/cli/commands/updater/cli_updater_auto.sh @@ -0,0 +1,124 @@ +#!/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__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/; 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__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 +} diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh index 0feb9e3..2222947 100644 --- a/scripts/cli/commands/updater/cli_updater_commands.sh +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -4,10 +4,13 @@ # --------------------------------------------------------------------------- # Dispatched automatically by cli_initialize.sh (category -> cliHandleUpdaterCommands). # Subcommands (the features/updater WebUI buttons route to these as tasks): -# check refresh the version/CVE data (runs the WebUI generator) +# check refresh the version/CVE data (runs the WebUI generator), +# then enqueue the updates for apps set to UPDATE_TYPE=auto +# (cli_updater_auto.sh owns that decision) # apply update one app — DISASTER-RECOVERY FIRST: snapshot the app # via the backup engine, then pull + recreate; on failure, -# roll back to the snapshot automatically +# roll back to the snapshot automatically. A trailing `auto` +# marks it as policy-driven for History; the work is identical. # apply-all [a,b] apply to a comma-list (or every update-available app) # rollback restore the app's most recent pre-update snapshot # @@ -62,14 +65,27 @@ cliHandleUpdaterCommands() source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null fi declare -F artifactApplyAuto >/dev/null 2>&1 && artifactApplyAuto + # App images: enqueue the updates for apps set to UPDATE_TYPE=auto. + # Runs LAST so it acts on the updates.json the scan above just wrote. + if ! declare -F updaterApplyAuto >/dev/null 2>&1; then + source "$install_scripts_dir/cli/commands/updater/cli_updater_auto.sh" 2>/dev/null + fi + declare -F updaterApplyAuto >/dev/null 2>&1 && updaterApplyAuto ;; "apply"|"now") - if [[ -z "$app" ]]; then isError "Usage: libreportal updater apply "; return 1; fi + if [[ -z "$app" ]]; then isError "Usage: libreportal updater apply [auto]"; return 1; fi + # Optional 4th word marks an automatic (policy-driven) update, so + # History can say who pressed the button. Anything else = manual. + local trigger="manual"; [[ "$initial_command4" == "auto" ]] && trigger="auto" if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then - updaterApplyApp "$app" + updaterApplyApp "$app" "$trigger" else - cliTaskRun "libreportal updater apply $app" "updater_apply" "$app" "" + # Carry the marker into the queued command so the task that + # actually runs still knows who asked for it. + local apply_cmd="libreportal updater apply $app" + [[ "$trigger" == "auto" ]] && apply_cmd="$apply_cmd auto" + cliTaskRun "$apply_cmd" "updater_apply" "$app" "" fi ;; @@ -143,10 +159,18 @@ updaterLastUpdateFrom() updaterApplyApp() { local app="$1" + # "auto" when the updater's own policy enqueued this (CFG__UPDATE_TYPE), + # "manual" when a person pressed Update. Recorded in History; changes nothing + # about how the update is applied — both take the snapshot, both can roll back. + local trigger="${2:-manual}" local app_dir="$containers_dir/$app" if [[ ! -d "$app_dir" ]]; then isError "App '$app' is not installed."; return 1; fi - isHeader "Updating $app (a recovery snapshot is taken first)" + if [[ "$trigger" == "auto" ]]; then + isHeader "Automatically updating $app (a recovery snapshot is taken first)" + else + isHeader "Updating $app (a recovery snapshot is taken first)" + fi # 1. DISASTER RECOVERY — snapshot before touching anything. Call the backup # function directly (we already run under LIBREPORTAL_TASK_EXEC): the CLI form @@ -157,7 +181,7 @@ updaterApplyApp() isNotice "Snapshotting $app before update…" if ! backupAppStart "$app" >/dev/null 2>&1; then isNotice "Pre-update snapshot did not complete cleanly — continuing is risky; aborting $app update." - updaterRecordHistory "$app" "update" "" "" "aborted-no-snapshot" + updaterRecordHistory "$app" "update" "" "" "aborted-no-snapshot" "" "" "" "$trigger" return 1 fi @@ -176,7 +200,7 @@ updaterApplyApp() local after_ref; after_ref="$(updaterPrimaryImage "$app" "$app_dir/docker-compose.yml")"; after_ref="${after_ref%%@*}" local after_dig; after_dig="$(updaterRefDigest "$after_ref")" local after="$after_ref${after_dig:+@$after_dig}" - updaterRecordHistory "$app" "update" "$before" "$after" "ok" + updaterRecordHistory "$app" "update" "$before" "$after" "ok" "" "" "" "$trigger" isSuccessful "$app updated. Rollback point retained." webuiUpdaterScan >/dev/null 2>&1 || true return 0 @@ -185,7 +209,7 @@ updaterApplyApp() # 4. Failure -> automatic rollback. isNotice "Update of $app failed — rolling back to the pre-update snapshot…" updaterRollbackApp "$app" "auto" - updaterRecordHistory "$app" "update" "$before" "" "rolled-back" + updaterRecordHistory "$app" "update" "$before" "" "rolled-back" "" "" "" "$trigger" return 1 } @@ -250,11 +274,12 @@ updaterComposePull() # actually recording, so it is FAIL-CLOSED, not best-effort: with jq we prepend + # cap to 200; WITHOUT jq we fall back to a brace-agnostic bash-native prepend # (no 200-cap, the one thing jq bought) rather than silently dropping the entry. -# Args 6-8 are optional and carry the artifact channel's metadata. +# Args 6-8 are optional and carry the artifact channel's metadata; arg 9 records +# whether a person or the auto-update policy started it (manual|auto). updaterRecordHistory() { local app="$1" action="$2" from="$3" to="$4" result="$5" - local artifact_id="${6:-}" serial="${7:-}" undo_id="${8:-}" + local artifact_id="${6:-}" serial="${7:-}" undo_id="${8:-}" trigger="${9:-manual}" local f="$containers_dir/libreportal/frontend/data/updater/generated/history.json" local ts; ts="$(date -Iseconds 2>/dev/null || date)" [ -f "$f" ] || printf '{ "entries": [] }\n' | runFileWrite "$f" @@ -263,7 +288,8 @@ updaterRecordHistory() local tmp; tmp="$(mktemp)" if jq --arg ts "$ts" --arg app "$app" --arg action "$action" --arg from "$from" --arg to "$to" \ --arg result "$result" --arg aid "$artifact_id" --arg serial "$serial" --arg undo "$undo_id" \ - '.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result, artifact_id:$aid, serial:$serial, undo_id:$undo}] + (.entries // []))[0:200]' \ + --arg trigger "$trigger" \ + '.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result, artifact_id:$aid, serial:$serial, undo_id:$undo, trigger:$trigger}] + (.entries // []))[0:200]' \ "$f" > "$tmp" 2>/dev/null; then runFileWrite "$f" < "$tmp"; rm -f "$tmp"; return 0 fi @@ -274,7 +300,7 @@ updaterRecordHistory() # jq absent or failed — bash-native, brace-agnostic prepend. History entries # are flat (scalar fields only), so splicing on the outer [ ... ] is safe. local entry - entry="{\"ts\":\"$(_lpJsonEsc "$ts")\",\"app\":\"$(_lpJsonEsc "$app")\",\"action\":\"$(_lpJsonEsc "$action")\",\"from\":\"$(_lpJsonEsc "$from")\",\"to\":\"$(_lpJsonEsc "$to")\",\"result\":\"$(_lpJsonEsc "$result")\",\"artifact_id\":\"$(_lpJsonEsc "$artifact_id")\",\"serial\":\"$(_lpJsonEsc "$serial")\",\"undo_id\":\"$(_lpJsonEsc "$undo_id")\"}" + entry="{\"ts\":\"$(_lpJsonEsc "$ts")\",\"app\":\"$(_lpJsonEsc "$app")\",\"action\":\"$(_lpJsonEsc "$action")\",\"from\":\"$(_lpJsonEsc "$from")\",\"to\":\"$(_lpJsonEsc "$to")\",\"result\":\"$(_lpJsonEsc "$result")\",\"artifact_id\":\"$(_lpJsonEsc "$artifact_id")\",\"serial\":\"$(_lpJsonEsc "$serial")\",\"undo_id\":\"$(_lpJsonEsc "$undo_id")\",\"trigger\":\"$(_lpJsonEsc "$trigger")\"}" local cur inner cur="$(cat "$f" 2>/dev/null)" inner="${cur#*[}"; inner="${inner%]*}" diff --git a/scripts/cli/commands/updater/cli_updater_header.sh b/scripts/cli/commands/updater/cli_updater_header.sh index 59d5bdd..31b4f4b 100644 --- a/scripts/cli/commands/updater/cli_updater_header.sh +++ b/scripts/cli/commands/updater/cli_updater_header.sh @@ -19,4 +19,10 @@ cliShowUpdaterHelp() echo "touching the app, so any update is reversible. These commands back the" echo "WebUI Updates page (features/updater); actions run through the task system." 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 "" } diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 95949fe..1ad8326 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -49,6 +49,7 @@ cli_scripts=( "cli/commands/system/cli_system_header.sh" "cli/commands/update/cli_update_commands.sh" "cli/commands/update/cli_update_header.sh" + "cli/commands/updater/cli_updater_auto.sh" "cli/commands/updater/cli_updater_commands.sh" "cli/commands/updater/cli_updater_header.sh" "cli/commands/validation/cli_validation_commands.sh" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 8ca5345..4f5ce7c 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -916,6 +916,12 @@ declare -gA LP_FN_MAP=( [updaterAllServiceImages]="webui/data/generators/updater/webui_updater_scan.sh" [updaterApplyAll]="cli/commands/updater/cli_updater_commands.sh" [updaterApplyApp]="cli/commands/updater/cli_updater_commands.sh" + [updaterApplyAuto]="cli/commands/updater/cli_updater_auto.sh" + [updaterAppPolicy]="cli/commands/updater/cli_updater_auto.sh" + [_updaterAutoDir]="cli/commands/updater/cli_updater_auto.sh" + [_updaterAutoGenDir]="cli/commands/updater/cli_updater_auto.sh" + [_updaterAutoStamp]="cli/commands/updater/cli_updater_auto.sh" + [updaterAutoTaskPending]="cli/commands/updater/cli_updater_auto.sh" [updaterClassifyTag]="webui/data/generators/updater/webui_updater_scan.sh" [_updaterCleanImageRef]="webui/data/generators/updater/webui_updater_scan.sh" [updaterComposePull]="cli/commands/updater/cli_updater_commands.sh" @@ -1922,6 +1928,12 @@ declare -gA LP_FN_ROOT=( [updaterAllServiceImages]="scripts" [updaterApplyAll]="scripts" [updaterApplyApp]="scripts" + [updaterApplyAuto]="scripts" + [updaterAppPolicy]="scripts" + [_updaterAutoDir]="scripts" + [_updaterAutoGenDir]="scripts" + [_updaterAutoStamp]="scripts" + [updaterAutoTaskPending]="scripts" [updaterClassifyTag]="scripts" [_updaterCleanImageRef]="scripts" [updaterComposePull]="scripts" @@ -2961,6 +2973,12 @@ updateHostIPToWhitelist() { unset -f updateHostIPToWhitelist; __lpAutoload "${in updaterAllServiceImages() { unset -f updaterAllServiceImages; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterAllServiceImages "$@"; } updaterApplyAll() { unset -f updaterApplyAll; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterApplyAll "$@"; } updaterApplyApp() { unset -f updaterApplyApp; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterApplyApp "$@"; } +updaterApplyAuto() { unset -f updaterApplyAuto; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; updaterApplyAuto "$@"; } +updaterAppPolicy() { unset -f updaterAppPolicy; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; updaterAppPolicy "$@"; } +_updaterAutoDir() { unset -f _updaterAutoDir; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; _updaterAutoDir "$@"; } +_updaterAutoGenDir() { unset -f _updaterAutoGenDir; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; _updaterAutoGenDir "$@"; } +_updaterAutoStamp() { unset -f _updaterAutoStamp; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; _updaterAutoStamp "$@"; } +updaterAutoTaskPending() { unset -f updaterAutoTaskPending; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; updaterAutoTaskPending "$@"; } updaterClassifyTag() { unset -f updaterClassifyTag; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterClassifyTag "$@"; } _updaterCleanImageRef() { unset -f _updaterCleanImageRef; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; _updaterCleanImageRef "$@"; } updaterComposePull() { unset -f updaterComposePull; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterComposePull "$@"; } 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 455e276..8e4c246 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 @@ -173,6 +173,17 @@ PORTEOF ], "default": "auto" }, + "UPDATE_TYPE": { + "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.", + "options": [ + {"value": "auto", "label": "Automatic (recommended)"}, + {"value": "manual", "label": "Manual — I'll press Update"} + ], + "default": "auto" + }, "MONITORING": { "category": "features", "label": "Export metrics to Grafana", diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index 776bd42..2688d37 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -201,6 +201,19 @@ webuiUpdaterScan() { svcs="$(jq -cs '.' "$sobjs" 2>/dev/null || echo '[]')"; rm -f "$sobjs" fi + # The app's effective update policy (auto|manual) travels WITH its version + # state so the WebUI can label every row without a second data source — + # and so what the UI shows is what the enqueuer will actually do (both + # read updaterAppPolicy). Falls back to the documented default when the + # decision file hasn't been loaded (jq-less/degraded paths never lie here). + local policy="auto" + if declare -F updaterAppPolicy >/dev/null 2>&1; then + policy="$(updaterAppPolicy "$app")" + elif [ -f "$install_scripts_dir/cli/commands/updater/cli_updater_auto.sh" ]; then + source "$install_scripts_dir/cli/commands/updater/cli_updater_auto.sh" 2>/dev/null + declare -F updaterAppPolicy >/dev/null 2>&1 && policy="$(updaterAppPolicy "$app")" + fi + if [ "$have_jq" = "1" ]; then jq -cn \ --arg name "$app" --arg displayName "$app" --arg type "$vtype" \ @@ -209,18 +222,20 @@ 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" \ '{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, scanned:true,last_checked:$last_checked,services:$services}' \ >> "$objs" 2>/dev/null else # jq-less fallback: minimal but valid object - printf '{"name":"%s","displayName":"%s","current_image":"%s","current_version":"%s","update_available":false,"scanned":true,"last_checked":"%s"}\n' \ + printf '{"name":"%s","displayName":"%s","current_image":"%s","current_version":"%s","update_available":false,"update_type":"%s","scanned":true,"last_checked":"%s"}\n' \ "$app" "$app" "$(printf '%s' "$anchor" | sed 's/"/\\"/g')" \ - "$(printf '%s' "$cur_ver" | sed 's/"/\\"/g')" "$now" >> "$objs" + "$(printf '%s' "$cur_ver" | sed 's/"/\\"/g')" "$policy" "$now" >> "$objs" fi done [ "$do_registry" = "1" ] && touch "$reg_stamp" 2>/dev/null || true