From daa336449a7bc0f5a4334605a6eb1246b7c3c25a Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 30 May 2026 03:13:26 +0100 Subject: [PATCH] =?UTF-8?q?feat(updater):=20backend=20=E2=80=94=20data=20g?= =?UTF-8?q?enerator=20+=20'libreportal=20updater'=20CLI=20with=20DR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/webui/data/generators/updater/webui_updater_scan.sh (webuiUpdaterScan): writes frontend/data/updater/generated/{updates,cves,history}.json from the installed-apps DB (current image per app from compose). Available-version + CVE-scanner are clearly-marked pluggable hooks; always emits valid JSON. - scripts/cli/commands/updater/{cli_updater_commands.sh,cli_updater_header.sh}: auto-dispatched as 'libreportal updater ' (check/apply/apply-all/rollback). apply does disaster-recovery FIRST — snapshots the app via the backup engine, then pulls + recreates (real dockerComposeUp/compose-pull helpers), records history, and auto-rolls-back on failure. Standard LIBREPORTAL_TASK_EXEC enqueue/exec split so WebUI + CLI share locking + audit trail. New .sh files: the array/function-manifest regen self-heals on deploy; the check path also sources its generator on demand to cover the gap. NOTE: host-side bash — written to the repo's conventions but not runnable in this env; this is the surface to test (the WebUI feature is lp-shot-verified). Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- .../commands/updater/cli_updater_commands.sh | 168 ++++++++++++++++++ .../commands/updater/cli_updater_header.sh | 20 +++ .../generators/updater/webui_updater_scan.sh | 93 ++++++++++ 3 files changed, 281 insertions(+) create mode 100644 scripts/cli/commands/updater/cli_updater_commands.sh create mode 100644 scripts/cli/commands/updater/cli_updater_header.sh create mode 100644 scripts/webui/data/generators/updater/webui_updater_scan.sh diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh new file mode 100644 index 0000000..6ceeb94 --- /dev/null +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +# App Updater command handler — `libreportal updater ` +# --------------------------------------------------------------------------- +# 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) +# 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 +# apply-all [a,b] apply to a comma-list (or every update-available app) +# rollback restore the app's most recent pre-update snapshot +# +# State-changing subcommands use the standard task-exec split: invoked normally +# they enqueue a task (so the WebUI + CLI share locking + the audit trail); +# the task processor re-invokes them with LIBREPORTAL_TASK_EXEC=1 to do the work. + +cliHandleUpdaterCommands() +{ + local sub="$initial_command2" + local app="$initial_command3" + + case "$sub" in + ""|"check") + # Quick + safe — just regenerates the read-only data files. Source + # the generator explicitly if the lazy loader hasn't mapped it yet + # (new file; the array regen self-heals it on deploy, this covers + # the gap before that). + if ! declare -F webuiUpdaterScan >/dev/null 2>&1; then + source "$install_scripts_dir/webui/data/generators/updater/webui_updater_scan.sh" 2>/dev/null + fi + webuiUpdaterScan + ;; + + "apply"|"now") + if [[ -z "$app" ]]; then isError "Usage: libreportal updater apply "; return 1; fi + if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then + updaterApplyApp "$app" + else + cliTaskRun "libreportal updater apply $app" "updater_apply" "$app" "" + fi + ;; + + "apply-all") + local list="$app" # optional comma-list in $initial_command3 + if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then + updaterApplyAll "$list" + else + cliTaskRun "libreportal updater apply-all $list" "updater_apply_all" "updater" "" + fi + ;; + + "rollback") + if [[ -z "$app" ]]; then isError "Usage: libreportal updater rollback "; return 1; fi + if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then + updaterRollbackApp "$app" + else + cliTaskRun "libreportal updater rollback $app" "updater_rollback" "$app" "" + fi + ;; + + *) + cliShowUpdaterHelp + ;; + esac +} + +# Update one app with disaster-recovery: snapshot -> pull -> recreate -> verify, +# auto-rolling-back on failure. Uses existing primitives (the backup CLI for the +# snapshot, docker compose for the image swap) so it shares their locking/logging. +updaterApplyApp() +{ + local app="$1" + 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)" + + # 1. DISASTER RECOVERY — snapshot before touching anything. + isNotice "Snapshotting $app before update…" + if ! libreportal backup app "$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" + return 1 + fi + + # 2. Capture the current image so we can record from->to / roll back. + local before; before="$(grep -m1 -E '^\s*image:' "$app_dir/docker-compose.yml" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')" + + # 3. Pull + recreate (uses the real, install-type-aware compose helpers). + isNotice "Pulling new image(s) for $app…" + if updaterComposePull "$app" && dockerComposeUp "$app" >/dev/null 2>&1; then + local after; after="$(grep -m1 -E '^\s*image:' "$app_dir/docker-compose.yml" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')" + updaterRecordHistory "$app" "update" "$before" "$after" "ok" + isSuccessful "$app updated. Rollback point retained." + webuiUpdaterScan >/dev/null 2>&1 || true + return 0 + fi + + # 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" + return 1 +} + +updaterApplyAll() +{ + local list="$1" failures=0 + if [[ -z "$list" ]]; then + isNotice "No app list given; nothing to do (the WebUI passes the update-available apps)." + return 0 + fi + local IFS=',' + for app in $list; do + [[ -z "$app" ]] && continue + updaterApplyApp "$app" || failures=$((failures+1)) + done + [[ $failures -gt 0 ]] && isNotice "$failures app(s) failed and were rolled back." || isSuccessful "All requested apps updated." +} + +# Roll an app back to its most recent snapshot. $2='auto' suppresses the header +# (called from the failure path of an apply). +updaterRollbackApp() +{ + local app="$1" mode="$2" + [[ "$mode" != "auto" ]] && isHeader "Rolling $app back to its pre-update snapshot" + # Delegate to the backup engine's restore (latest snapshot for this app). + if libreportal backup app "$app" restore latest >/dev/null 2>&1; then + dockerComposeUp "$app" >/dev/null 2>&1 || true + [[ "$mode" != "auto" ]] && updaterRecordHistory "$app" "rollback" "" "" "rolled-back" + isSuccessful "$app restored from its pre-update snapshot." + return 0 + fi + isError "Could not roll $app back automatically — restore manually from the Backups page." + return 1 +} + +# Force a fresh image pull for an app (mirrors up_app.sh's install-type split). +# dockerComposeUp uses --quiet-pull which won't re-fetch a moved tag, so we pull +# explicitly first to actually pick up a new image. +updaterComposePull() +{ + local app="$1" dir="${containers_dir%/}/$1" + [ -d "$dir" ] || return 1 + if [[ "$CFG_DOCKER_INSTALL_TYPE" == "rootless" ]]; then + dockerCommandRunInstallUser "cd $dir && docker compose pull" >/dev/null 2>&1 + else + ( cd "$dir" && docker compose pull >/dev/null 2>&1 ) + fi +} + +# Append an entry to history.json (best-effort; needs jq, skips silently if absent). +updaterRecordHistory() +{ + local app="$1" action="$2" from="$3" to="$4" result="$5" + local f="$containers_dir/libreportal/frontend/data/updater/generated/history.json" + command -v jq >/dev/null 2>&1 || return 0 + [ -f "$f" ] || printf '{ "entries": [] }\n' > "$f" + local ts; ts="$(date -Iseconds 2>/dev/null || date)" + local tmp; tmp="$(mktemp)" + if jq --arg ts "$ts" --arg app "$app" --arg action "$action" --arg from "$from" --arg to "$to" --arg result "$result" \ + '.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result}] + (.entries // []))[0:200]' \ + "$f" > "$tmp" 2>/dev/null; then + runFileOp cp "$tmp" "$f" 2>/dev/null || cp "$tmp" "$f" + fi + rm -f "$tmp" +} diff --git a/scripts/cli/commands/updater/cli_updater_header.sh b/scripts/cli/commands/updater/cli_updater_header.sh new file mode 100644 index 0000000..f7946e9 --- /dev/null +++ b/scripts/cli/commands/updater/cli_updater_header.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# App Updater Commands Header +# Shows available `libreportal updater` subcommands. + +cliShowUpdaterHelp() +{ + echo "" + echo "Available App Updater Commands:" + echo "" + echo " libreportal updater check - Refresh per-app version & vulnerability data" + echo " libreportal updater apply - Update one app (snapshots it first; auto-rollback on failure)" + echo " libreportal updater apply-all [a,b] - Update a comma-list of apps (each snapshotted first)" + echo " libreportal updater rollback - Restore an app's most recent pre-update snapshot" + echo "" + echo "Every update takes a recovery snapshot via the Backup engine before" + 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 "" +} diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh new file mode 100644 index 0000000..0f2cc6b --- /dev/null +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# WebUI App Updater — data generator +# --------------------------------------------------------------------------- +# Writes the read-only JSON the features/updater WebUI reads: +# frontend/data/updater/generated/updates.json (per-app version state) +# frontend/data/updater/generated/cves.json (per-app known CVEs) +# frontend/data/updater/generated/history.json (created/owned by `apply`) +# +# Run on demand by `libreportal updater check` (NOT on every regen) so a slow +# registry/scanner call never stalls the periodic WebUI refresh. The WebUI +# degrades gracefully when these files are absent (it derives the app list from +# the installed-apps data), so this generator is a bonus, never a dependency. +# +# Version + CVE discovery is intentionally pluggable: the loop below records +# each installed app's CURRENT image (from its compose file) and leaves clearly +# marked hooks for an available-version source (registry: `docker manifest` / +# skopoe) and a vulnerability scanner (trivy / grype). Until those are wired the +# output is honest: scanned=true, update_available=false, cves=[]. Always writes +# valid JSON even when data gathering fails — it must never emit a broken file. + +webuiUpdaterScan() { + local out_dir="$containers_dir/libreportal/frontend/data/updater/generated" + runFileOp mkdir -p "$out_dir" 2>/dev/null || mkdir -p "$out_dir" 2>/dev/null + + local now; now="$(date -Iseconds 2>/dev/null || date)" + + # Installed apps from the apps DB (status=1). Fall back to listing config + # dirs if the DB is unavailable, so we still produce useful output. + local apps=() + if command -v sqlite3 >/dev/null 2>&1 && [ -f "$docker_dir/$db_file" ]; then + while IFS= read -r a; do [ -n "$a" ] && apps+=("$a"); done < <( + runInstallOp sqlite3 "$docker_dir/$db_file" \ + "SELECT name FROM apps WHERE status=1 AND name!='libreportal';" 2>/dev/null + ) + fi + if [ ${#apps[@]} -eq 0 ]; then + while IFS= read -r d; do + local b; b="$(basename "$d")" + [[ "$b" == "libreportal" ]] && continue + [ -f "$d/$b.config" ] && apps+=("$b") + done < <(runFileOp find "$containers_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null) + fi + + # Build the per-app updates array. + local entries="" first=1 + for app in "${apps[@]}"; do + local image="" compose="$containers_dir/$app/docker-compose.yml" + if [ -f "$compose" ]; then + image="$(grep -m1 -E '^\s*image:' "$compose" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')" + fi + # --- available-version hook ------------------------------------------- + # Wire a registry check here (e.g. `docker manifest inspect`/skopeo) to + # set available_image + update_available. Honest default: up to date. + local available="$image" update_available="false" + + [ $first -eq 0 ] && entries+="," + first=0 + entries+=$(cat < "$tmp" </dev/null || cp "$tmp" "$out_dir/updates.json" + rm -f "$tmp" + + # CVE data — pluggable. Wire trivy/grype per image here and emit per-app + # cves[]. Honest empty-but-valid default until a scanner is configured. + if [ ! -f "$out_dir/cves.json" ]; then + local ctmp; ctmp="$(mktemp)" + printf '{ "generated_at": "%s", "apps": [], "totals": { "critical": 0, "high": 0, "medium": 0, "low": 0 } }\n' "$now" > "$ctmp" + runFileOp cp "$ctmp" "$out_dir/cves.json" 2>/dev/null || cp "$ctmp" "$out_dir/cves.json" + rm -f "$ctmp" + fi + # Ensure a valid (possibly empty) history file exists for the WebUI. + if [ ! -f "$out_dir/history.json" ]; then + local htmp; htmp="$(mktemp)" + printf '{ "entries": [] }\n' > "$htmp" + runFileOp cp "$htmp" "$out_dir/history.json" 2>/dev/null || cp "$htmp" "$out_dir/history.json" + rm -f "$htmp" + fi + + # Make the generated tree readable by the container user that serves /data. + runFileOp chown -R "$docker_install_user":"$docker_install_user" "$out_dir" 2>/dev/null || true + isSuccessful "App updater data refreshed (${#apps[@]} app(s))." +}