feat(updater): per-app UPDATE_TYPE, automatic by default

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_<APP>_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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-11 16:22:04 +01:00
parent 48075d0551
commit cdeb2d1658
45 changed files with 372 additions and 35 deletions

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -9,6 +9,7 @@
# HOST_SERVICES = all units; feeds the Services + Logs tabs
# HOST_LOG_FILES = <unit>|<path>,... 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
#

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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')
+ `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
});
// 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')
+ `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
});
} 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 {
? `<span class="ov-status ov-status-update">↑ Update available</span>`
: (a.scanned ? `<span class="ov-status ov-status-ok">✓ Up to date</span>` : `<span class="ov-status ov-status-unknown">• Unscanned</span>`);
const sev = a.worstSeverity ? `<span class="updater-badge sev-${a.worstSeverity}">${a.worstSeverity}</span>` : '';
// 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'
? `<span class="updater-badge updater-badge-unknown" title="This app updates only when you press Update">manual</span>`
: '';
const updBtn = a.update_available
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${slug}">Update</button>`
: '';
return `<div class="updater-row ov-row" data-app="${slug}">
<div class="updater-row-head ov-row-head" data-overview-action="toggle" data-app="${slug}" role="button" tabindex="0" aria-expanded="false" aria-controls="ov-detail-${slug}">
<img class="ov-row-icon" src="/core/icons/apps/${slug}.svg" alt="" onerror="this.style.display='none'">
<span class="updater-row-name">${name}</span> ${status} ${sev}
<span class="updater-row-name">${name}</span> ${status} ${sev} ${pol}
<span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span>
<span class="ov-row-actions">${updBtn}<button class="ov-details-btn" data-overview-action="toggle" data-app="${slug}" tabindex="-1" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6,9 12,15 18,9"></polyline></svg><span>Details</span></button></span>
</div>

View File

@ -515,7 +515,7 @@ class UpdaterPage {
const rows = entries.map(e => `
<div class="updater-row">
<div class="updater-row-main"><span class="updater-row-name">${this.escape(e.app)}</span>
<span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span></div>
<span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span>${this.triggerBadge(e)}</div>
<div class="updater-row-ver">${this.escape(e.from || '')}${e.to ? ` <span class="updater-arrow">→</span> ${this.escape(e.to)}` : ''}</div>
<div class="updater-row-actions">${this.fmtRel(e.ts)}</div>
</div>`).join('');
@ -539,8 +539,15 @@ class UpdaterPage {
const badge = a.update_available
? `<span class="updater-badge updater-badge-update">update available</span>`
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
// What happens next, in the app's own words — the Updates setting on this
// 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 <strong>manual</strong> — this app updates only when you press Update.'
: 'Set to <strong>automatic</strong> — new builds install on their own, after a recovery snapshot.';
versionSection = `<div class="updater-detail-section"><h4>Version</h4>
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div></div>`;
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div>
<div class="updater-detail-row"><span class="updater-detail-meta">${policyLine}</span></div></div>`;
}
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 ? `<div class="updater-detail-section"><h4>History</h4>${entries.map((e) => `
<div class="updater-detail-row"><span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span>
<div class="updater-detail-row"><span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span>${this.triggerBadge(e)}
<span class="updater-detail-meta">${this.escape(e.from || '')}${e.to ? `${this.escape(e.to)}` : ''}</span>
<span class="updater-detail-meta">${this.fmtRel(e.ts)}</span></div>`).join('')}</div>` : '';
return `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`;
}
// "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'
? ' <span class="updater-badge updater-badge-unknown">automatic</span>'
: '';
}
empty(msg, withCheck) {
return `<div class="updater-empty">${this.escape(msg)}${withCheck ? `<div><button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>` : ''}</div>`;
}
@ -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 `<div class="updater-autocheck${off}"><span class="updater-autocheck-dot"></span>` +
`<span class="updater-autocheck-text">Checked automatically · last checked <strong>${last}</strong>${nextBit}</span>` +
`<span class="updater-autocheck-text">Checked automatically · last checked <strong>${last}</strong>${nextBit}${autoBit}</span>` +
`<button class="updater-btn updater-autocheck-btn" data-updater-action="check" title="Check for updates now">↻ Check now</button></div>`;
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 — P0P3 (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_<APP>_VERSION` is adopted, not rejected.** The tagging system (`scripts/config/tags/processors/tags_processor_app_config_values.sh`) already derives compose values from `CFG_<APP>_<KEY>` 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_<APP>_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_<APP>_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/<app>.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_<APP>_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_<APP>_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_<APP>_VERSION``<APP>_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.

View File

@ -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"

View File

@ -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_<APP>_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/<app>; the same digest is skipped
# forever after. Without this a broken upstream build would be re-attempted
# (and rolled back) on every scan, snapshotting the app each time. A newer
# build changes the digest and is attempted normally; the Update button
# stays available for a manual retry of the skipped one.
_updaterAutoGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
_updaterAutoDir() { echo "$(_updaterAutoGenDir)/auto"; }
_updaterAutoStamp() { echo "$(_updaterAutoDir)/$1.digest"; } # $1=app
# Effective update policy for one app -> "auto" | "manual".
# 1. master switch off -> manual (everything, no exceptions)
# 2. per-app CFG_<APP>_UPDATE_TYPE
# 3. unset / unrecognised -> auto (the documented default)
# App configs are sourced globally (sourceScanFiles app_configs), so the per-app
# key is read by indirect expansion exactly like backupResolveStrategy does.
updaterAppPolicy()
{
local app="$1"
[[ -n "$app" ]] || { echo manual; return 0; }
[[ "${CFG_UPDATER_AUTO:-true}" == "true" ]] || { echo manual; return 0; }
local key="CFG_${app^^}_UPDATE_TYPE"
case "$(printf '%s' "${!key-}" | tr 'A-Z' 'a-z')" in
manual|off|false) echo manual ;;
*) echo auto ;;
esac
}
# True when an updater task for this app is already queued or in flight, so a
# scan that lands while the previous update is still running doesn't stack a
# second one. Cheap: one jq over the task files, and only for apps that got
# this far (update available + policy auto).
updaterAutoTaskPending()
{
local app="$1" dir="${containers_dir%/}/libreportal/frontend/data/tasks"
command -v jq >/dev/null 2>&1 || return 1
local files=( "$dir"/task_*.json )
[[ -e "${files[0]}" ]] || return 1
local n
n="$(jq -rs --arg a "$app" '
[ .[] | select((.app // "") == $a
and ((.type // "") | startswith("updater_"))
and ((.status // "") == "queued" or (.status // "") == "pending"
or (.status // "") == "running")) ] | length
' "${files[@]}" 2>/dev/null)"
[[ "${n:-0}" -gt 0 ]]
}
# updaterApplyAuto — enqueue an `updater apply` task for every app that has an
# update available and is set to auto. Called from `updater check` right after
# the scan that produced updates.json, so it always acts on fresh data.
updaterApplyAuto()
{
if [[ "${CFG_UPDATER_AUTO:-true}" != "true" ]]; then
isNotice "Automatic app updates are off (CFG_UPDATER_AUTO=false)."
return 0
fi
if ! command -v jq >/dev/null 2>&1; then
isNotice "Automatic app updates need jq to read the scan results — skipping (updates are still listed in the WebUI)."
return 0
fi
local upd; upd="$(_updaterAutoGenDir)/updates.json"
[[ -f "$upd" ]] || return 0
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
}

View File

@ -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 <app> 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 <app> 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 <app>"; return 1; fi
if [[ -z "$app" ]]; then isError "Usage: libreportal updater apply <app> [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_<APP>_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%]*}"

View File

@ -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_<APP>_UPDATE_TYPE=auto (the default) — the same snapshot-first apply"
echo "as pressing Update, recorded in History as automatic. Set an app to"
echo "'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 ""
}

View File

@ -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"

View File

@ -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 "$@"; }

View File

@ -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",

View File

@ -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