The mutating side of the unified distribution primitive (spec §8.3). Hotfixes can now be applied and reverted, first-party, through the task system. New scripts/cli/commands/artifact/cli_artifact_apply.sh: - artifactApply <id>: resolve+gate (applies_when / min_lp / max_lp / max_footprint / publishers-map role) → fetch+verify payload (sha256 pinned by the signed index + minisig) → dry-precheck ALL ops (all-or-nothing) → best- effort snapshot → apply each op recording a precise inverse → bring app up → auto-rollback (replay undo LIFO, snapshot fallback) → applied-record + History. - artifactRevert <id>: replay the applied-record's undo log (LIFO). - Bounded, CLOSED op vocabulary (no run-script/exec, ever): set-config-key, set-compose-image, patch-file-if-checksum-matches, set-data-file. An unsupported op rejects the whole artifact at precheck (fail-closed). - Write-target firewall: scope:app → containers/<app>/ only; scope:system → configs/ only; the install tree (our code) is off-limits to hotfixes (fork 1). Drift guards (expect_current / checksum) skip cleanly rather than clobber. - Two-tier trust: index minisig-verified vs the footprint key (lpFetchIndex) covers the envelope; payload sha256-pinned + minisig-verified; publishers-map role gate (a non-official publisher can't claim official). Community per- artifact-key sigs are gated off until that tier is enabled. cli_artifact_commands.sh: apply/revert via the task system (artifact_apply / artifact_revert types — no allowlist needed), + read-only `applied` list. cli_updater_commands.sh: - FIX verified safety bug: updaterApplyApp/RollbackApp called `libreportal backup app "$app"` and `... restore latest`, which parse the app name as the ACTION, hit the dispatcher's `*)` default (exits 0) — so updates ran with NO snapshot and rollback was a silent no-op. Call backupAppStart / restoreAppStart directly. - FIX updaterRecordHistory jq-silent-skip: was `command -v jq || return 0` (silently dropped the audit entry). Now fail-closed with a brace-agnostic bash-native prepend fallback; extended with artifact_id/serial/undo_id. fetch.sh: add _lpJsonEsc (shared JSON-escape for the jq-free fallbacks). Regenerated source arrays + lazy-load manifest for the new file/functions. Unit-tested 31/31: every op apply+precheck+undo round-trip, the path-allowlist firewall (incl. .. traversal + install-tree + cross-app rejection), all-or- nothing abort, unsupported-op rejection, and the History bash-native fallback (records + preserves prior entries without jq). A full signed-apply e2e needs minisign + the signing key (Phase 5 make_hotfix.sh). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
200 lines
9.3 KiB
Bash
200 lines
9.3 KiB
Bash
#!/bin/bash
|
|
|
|
# App Updater command handler — `libreportal updater <sub>`
|
|
# ---------------------------------------------------------------------------
|
|
# 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 <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
|
|
# 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
|
|
#
|
|
# 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 <app>"; 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 <app>"; 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. Call the backup
|
|
# function directly (we already run under LIBREPORTAL_TASK_EXEC): the CLI form
|
|
# `backup app "$app"` parsed the app name as the ACTION, hit the dispatcher's
|
|
# `*)` default (a notice that exits 0), so the `if !` guard passed and the app
|
|
# was updated with NO snapshot — and rollback below was a no-op that reported
|
|
# success. backupAppStart is the real entry point and returns 0/1 honestly.
|
|
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"
|
|
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 restore engine (latest snapshot for this app). Call the
|
|
# function directly — the old `backup app "$app" restore latest` CLI form was
|
|
# malformed (parsed as action="$app") so it silently did nothing yet exited 0.
|
|
if restoreAppStart "$app" 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. The "nothing silent" guarantee depends on this
|
|
# 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.
|
|
updaterRecordHistory()
|
|
{
|
|
local app="$1" action="$2" from="$3" to="$4" result="$5"
|
|
local artifact_id="${6:-}" serial="${7:-}" undo_id="${8:-}"
|
|
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"
|
|
|
|
if command -v jq >/dev/null 2>&1; then
|
|
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]' \
|
|
"$f" > "$tmp" 2>/dev/null; then
|
|
runFileWrite "$f" < "$tmp"; rm -f "$tmp"; return 0
|
|
fi
|
|
rm -f "$tmp"
|
|
isError "updaterRecordHistory: jq write failed for $f — using bash fallback"
|
|
fi
|
|
|
|
# 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")\"}"
|
|
local cur inner
|
|
cur="$(cat "$f" 2>/dev/null)"
|
|
inner="${cur#*[}"; inner="${inner%]*}"
|
|
inner="$(printf '%s' "$inner" | tr -d '\n' | sed -E 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
|
local newcontent
|
|
if [[ -z "$inner" ]]; then newcontent="{ \"entries\": [$entry] }"
|
|
else newcontent="{ \"entries\": [$entry, $inner] }"; fi
|
|
local tmp2; tmp2="$(mktemp)"; printf '%s\n' "$newcontent" > "$tmp2"
|
|
runFileWrite "$f" < "$tmp2"; rm -f "$tmp2"
|
|
return 0
|
|
}
|