feat(distribution): Phase 2 — artifact apply/revert pipeline + ops interpreter

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>
This commit is contained in:
librelad 2026-05-31 20:01:11 +01:00
parent a18d34fcfb
commit 2df4e28a85
7 changed files with 604 additions and 24 deletions

View File

@ -0,0 +1,441 @@
#!/bin/bash
#
# Artifact APPLY pipeline — Phase 2 of the unified distribution primitive
# (docs/roadmap/updates-and-distribution.md §8.3). The MUTATING side: it takes a
# verified artifact from the signed index and applies it reversibly, then can
# revert it. Runs ONLY under the task system (cli_artifact_commands.sh enqueues;
# the processor re-invokes with LIBREPORTAL_TASK_EXEC=1, which is when these run).
#
# Design contracts (all enforced below, fail-closed):
# * The trust core is the same anchor as releases — the index is minisign-
# verified against the root-owned footprint key by lpFetchIndex (source/
# artifacts.sh); the payload is sha256-pinned by that signed index and itself
# minisign-verified. The publishers map + role gate makes a community key
# unable to masquerade as official (registry-ready; first-party-only today).
# * The op vocabulary is a CLOSED allowlist (no run-script/exec/shell, ever).
# An unsupported op rejects the WHOLE artifact at validation, before any write.
# * ALL-OR-NOTHING: every op is dry-prechecked first; one failed precondition
# skips the whole artifact untouched (recorded as a skip, so coverage gaps
# are visible — a customised box may legitimately miss a fix).
# * Two-tier reversibility: every op records a precise inverse into a per-id
# applied-record (the revert path); a best-effort recovery snapshot is also
# taken when a backup location exists (the dirty-op fallback).
# * Mutations write only through the de-sudo funnels (runFileOp / runInstallOp /
# updateConfigOption), never raw sudo. The install tree (our own code) is
# off-limits to hotfixes — code fixes ride signed releases (fork 1).
# --- paths -------------------------------------------------------------------
_artifactGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
_artifactAppliedDir() { echo "$(_artifactGenDir)/applied"; }
_artifactRecordFile() { echo "$(_artifactAppliedDir)/$1.json"; } # $1=id
# Require jq for the apply path. The TRUST core (index sig, payload sha256+sig) is
# jq-free; only walking the bounded op list / envelope fields needs structured
# parsing, and apply is a heavy, rare, privileged path where requiring jq is fine.
_artifactNeedJq() {
command -v jq >/dev/null 2>&1 && return 0
isError "artifact: jq is required to apply/revert artifacts (the op interpreter needs it) — refusing."
return 1
}
# ----------------------------------------------------------------------------
# RESOLVE — fetch+verify the index, pull out one artifact, check the gates.
# Echoes the artifact JSON on success; non-zero (nothing usable) otherwise.
# Sets globals: _ART_INDEX (verified index json), _ART_SCOPE, _ART_APP.
# ----------------------------------------------------------------------------
_artifactResolve() {
local id="$1"
_ART_INDEX="$(lpFetchIndex)" || { isError "artifact: could not fetch/verify the index."; return 1; }
local art; art="$(printf '%s' "$_ART_INDEX" | jq -ce --arg id "$id" '.artifacts[]? | select(.id==$id)' 2>/dev/null)"
[[ -n "$art" ]] || { isError "artifact: id '$id' not found in the signed index."; return 1; }
local type; type="$(jq -r '.type // empty' <<<"$art")"
if [[ "$type" != "hotfix" ]]; then
isError "artifact: type '$type' is not supported by this build (only 'hotfix')."; return 1
fi
# --- trust: publishers map + role gate ---
local pub trust role
pub="$(jq -r '.publisher // empty' <<<"$art")"
trust="$(jq -r '.trust // "official"' <<<"$art")"
role="$(printf '%s' "$_ART_INDEX" | jq -r --arg p "$pub" '.publishers[$p].role // empty' 2>/dev/null)"
if [[ -z "$pub" || -z "$role" ]]; then
isError "artifact: publisher '$pub' is not in the index publishers map — refusing."; return 1
fi
if [[ "$trust" == "official" && "$role" != "official" ]]; then
isError "artifact: '$id' claims trust=official but publisher '$pub' has role '$role' — refusing."; return 1
fi
if [[ "$role" != "official" ]]; then
# Community/custom would require the artifact's own canonical-bytes signature
# against the publisher's key. Not enabled in this first-party-only build.
isError "artifact: publisher role '$role' (non-official) is not enabled yet — refusing."; return 1
fi
# --- gates (applies_when) ---
_ART_APP="$(jq -r '.applies_when.app // empty' <<<"$art")"
_ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app"
if [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then
isNotice "artifact: '$id' targets '$_ART_APP' which is not installed — not applicable."; return 2
fi
local minlp maxlp maxfp curlp curfp
minlp="$(jq -r '.applies_when.min_lp // empty' <<<"$art")"
maxlp="$(jq -r '.applies_when.max_lp // empty' <<<"$art")"
maxfp="$(jq -r '.applies_when.max_footprint // empty' <<<"$art")"
curlp="${CFG_LIBREPORTAL_VERSION:-$(cat "$script_dir/version" 2>/dev/null | tr -dc '0-9.')}"
if [[ -n "$minlp" && -n "$curlp" ]] && lpVersionGt "$minlp" "$curlp"; then
isNotice "artifact: '$id' needs LibrePortal >= $minlp (have ${curlp:-?}) — not applicable."; return 2
fi
if [[ -n "$maxlp" && -n "$curlp" ]] && lpVersionGt "$curlp" "$maxlp"; then
isNotice "artifact: '$id' applies only up to LibrePortal $maxlp (have $curlp) — not applicable."; return 2
fi
if [[ -n "$maxfp" ]]; then
curfp="$(lpInstalledFootprintVersion)"
if (( curfp > maxfp )); then
isNotice "artifact: '$id' applies only up to footprint $maxfp (have $curfp) — not applicable."; return 2
fi
fi
printf '%s' "$art"
}
# ----------------------------------------------------------------------------
# PAYLOAD — download, sha256-pin (from the signed index), minisig-verify.
# Echoes the verified payload JSON; non-zero on any failure.
# ----------------------------------------------------------------------------
_artifactFetchPayload() {
local art="$1" base channel url want_sha sig_url tmp pf sf got kind
base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)"
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
if [[ "$kind" != "ops" ]]; then
isError "artifact: payload.kind '$kind' is not supported by this build (only 'ops')."; return 1
fi
url="$(jq -r '.payload.url // empty' <<<"$art")"
want_sha="$(jq -r '.payload.sha256 // empty' <<<"$art")"
sig_url="$(jq -r '.payload.sig // empty' <<<"$art")"
[[ -n "$url" && -n "$want_sha" ]] || { isError "artifact: payload missing url/sha256 — refusing."; return 1; }
tmp="$(mktemp -d)"; pf="$tmp/payload.json"; sf="$tmp/payload.sig"
# url/sig may be channel-relative ("stable/payloads/..") or absolute.
case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac
if ! _lpDownload "$url" "$pf" 2>/dev/null; then isError "artifact: payload download failed."; rm -rf "$tmp"; return 1; fi
got="$(_lpSha256 "$pf")"
if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH — refusing."; rm -rf "$tmp"; return 1; fi
if [[ -n "$sig_url" ]]; then
case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac
_lpDownload "$sig_url" "$sf" 2>/dev/null || true
fi
if ! lpVerifyMinisig "$pf" "$sf" >/dev/null; then rm -rf "$tmp"; return 1; fi
cat "$pf"; rm -rf "$tmp"
}
# --- path allowlist (the write-target firewall) -----------------------------
# scope:app -> only under $containers_dir/<app>/
# scope:system -> only under $configs_dir/ (the install/code tree is OFF-LIMITS
# to hotfixes — code rides signed releases; fork 1)
_artifactPathAllowed() {
local path="$1" scope="$2" app="$3" real root
[[ "$path" == *".."* ]] && return 1
real="$(realpath -m -- "$path" 2>/dev/null)"; [[ -n "$real" ]] || return 1
if [[ "$scope" == "app" ]]; then
root="$(realpath -m -- "${containers_dir%/}/$app" 2>/dev/null)"
[[ "$real" == "$root/"* ]] && return 0
return 1
fi
root="$(realpath -m -- "${configs_dir%/}" 2>/dev/null)"
[[ -n "$root" && "$real" == "$root/"* ]] && return 0
return 1
}
# current image of an app's compose (first image: line), quotes stripped.
_artifactComposeImage() {
local app="$1" f="${containers_dir%/}/$1/docker-compose.yml"
[[ -f "$f" ]] || return 1
grep -m1 -E '^\s*image:' "$f" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g; s/\s+#.*$//; s/\s+$//'
}
# ----------------------------------------------------------------------------
# OP PRECHECK — pure read; returns 0 if the op can be applied as-is, else 1 with
# a reason on stderr. This is the dry-run that enforces all-or-nothing.
# ----------------------------------------------------------------------------
_artifactOpPrecheck() {
local op_json="$1" scope="$2" app="$3" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-config-key)
local key expect cur
key="$(jq -r '.key // empty' <<<"$op_json")"
[[ "$key" =~ ^CFG_[A-Z0-9_]+$ ]] || { isError "op set-config-key: bad key '$key'"; return 1; }
expect="$(jq -r 'if has("expect_current") then .expect_current else "NONE" end' <<<"$op_json")"
if [[ "$expect" != $'NONE' ]]; then
cur="$(grep -m1 "^$key=" "$(findConfigFileForOption "$key" 2>/dev/null)" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")"
[[ "$cur" == "$expect" ]] || { isError "op set-config-key: $key is '$cur', expected '$expect' (drift)"; return 1; }
fi
;;
set-compose-image)
local from cur
from="$(jq -r '.from // empty' <<<"$op_json")"
[[ -d "${containers_dir%/}/$app" ]] || { isError "op set-compose-image: app '$app' not installed"; return 1; }
cur="$(_artifactComposeImage "$app")"
[[ -n "$from" && "$cur" == "$from" ]] || { isError "op set-compose-image: image is '$cur', expected '$from' (drift)"; return 1; }
;;
patch-file-if-checksum-matches)
local path want got
path="$(jq -r '.path // empty' <<<"$op_json")"
_artifactPathAllowed "$path" "$scope" "$app" || { isError "op patch-file: path '$path' not in the allowlist"; return 1; }
[[ -f "$path" ]] || { isError "op patch-file: '$path' does not exist"; return 1; }
want="$(jq -r '.expect_sha256 // empty' <<<"$op_json")"
got="$(_lpSha256 "$path")"
[[ -n "$want" && "$got" == "$want" ]] || { isError "op patch-file: '$path' sha mismatch (drift) — skipping"; return 1; }
;;
set-data-file)
local path
path="$(jq -r '.path // empty' <<<"$op_json")"
_artifactPathAllowed "$path" "$scope" "$app" || { isError "op set-data-file: path '$path' not in the allowlist"; return 1; }
;;
*)
isError "op '$op' is not supported by this build — rejecting the whole artifact (fail-closed)."
return 1
;;
esac
return 0
}
# ----------------------------------------------------------------------------
# OP APPLY — mutate, and APPEND the precise inverse op (as compact JSON) to the
# file named by $4 (the undo log). Returns non-zero on failure.
# ----------------------------------------------------------------------------
_artifactOpApply() {
local op_json="$1" scope="$2" app="$3" undo_file="$4" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-config-key)
local key value cur f
key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")"
f="$(findConfigFileForOption "$key" 2>/dev/null)"
cur="$(grep -m1 "^$key=" "$f" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")"
updateConfigOption "$key" "$value" || return 1
jq -cn --arg k "$key" --arg v "$cur" '{op:"set-config-key", key:$k, value:$v}' >> "$undo_file"
;;
set-compose-image)
local image cur f esc_cur esc_img
image="$(jq -r '.image' <<<"$op_json")"
f="${containers_dir%/}/$app/docker-compose.yml"
cur="$(_artifactComposeImage "$app")"
esc_cur="$(printf '%s' "$cur" | sed -e 's/[\/&]/\\&/g')"
esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
# Replace the first image: line's value, preserving indentation.
runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1
jq -cn --arg img "$cur" '{op:"set-compose-image", image:$img}' >> "$undo_file"
;;
patch-file-if-checksum-matches|set-data-file)
local path existed prior_b64 content_b64
path="$(jq -r '.path' <<<"$op_json")"
content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")"
[[ -n "$content_b64" ]] || { isError "op $op: missing content_b64"; return 1; }
if [[ -f "$path" ]]; then existed=true; prior_b64="$(base64 -w0 < "$path" 2>/dev/null)"; else existed=false; prior_b64=""; fi
if ! printf '%s' "$content_b64" | base64 -d 2>/dev/null | runFileWrite "$path"; then
isError "op $op: write to '$path' failed"; return 1
fi
jq -cn --arg p "$path" --arg e "$existed" --arg b "$prior_b64" \
'{op:"restore-file", path:$p, existed:($e=="true"), content_b64:$b}' >> "$undo_file"
;;
*)
isError "op '$op': no apply handler (should have been rejected at precheck)"; return 1
;;
esac
return 0
}
# ----------------------------------------------------------------------------
# OP UNDO — apply one inverse op recorded by _artifactOpApply (the revert path).
# ----------------------------------------------------------------------------
_artifactOpUndo() {
local op_json="$1" app="$2" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-config-key)
local key value; key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")"
updateConfigOption "$key" "$value" || return 1
;;
set-compose-image)
local image f esc_img; image="$(jq -r '.image' <<<"$op_json")"
f="${containers_dir%/}/$app/docker-compose.yml"
esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1
;;
restore-file)
local path existed content_b64
path="$(jq -r '.path' <<<"$op_json")"; existed="$(jq -r '.existed' <<<"$op_json")"
content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")"
if [[ "$existed" == "true" ]]; then
printf '%s' "$content_b64" | base64 -d 2>/dev/null | runFileWrite "$path" || return 1
else
runFileOp rm -f "$path" || return 1
fi
;;
*) isError "undo: unknown inverse op '$op'"; return 1 ;;
esac
return 0
}
# ----------------------------------------------------------------------------
# artifactApply <id> — the full pipeline (steps 0-9). Idempotent-ish: a
# re-apply re-prechecks (drift guards skip already-applied ops cleanly).
# ----------------------------------------------------------------------------
artifactApply() {
local id="$1"
_artifactNeedJq || return 1
[[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; }
isHeader "Applying hotfix: $id"
# 0. RESOLVE + gates
local art rc
art="$(_artifactResolve "$id")"; rc=$?
if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "hotfix" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi
[[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "hotfix" "" "$id" "rejected" "$id" "" ""; return 1; }
local app="$_ART_APP" scope="$_ART_SCOPE" serial title
serial="$(_lpJsonNum "$_ART_INDEX" index_serial)"
title="$(jq -r '.title // empty' <<<"$art")"
isNotice "$title (scope=$scope${app:+, app=$app})"
# 4. PAYLOAD (fetch+verify)
local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; }
# collect ops
local ops_count; ops_count="$(jq '.ops | length' <<<"$payload" 2>/dev/null || echo 0)"
if ! [[ "$ops_count" =~ ^[0-9]+$ ]] || (( ops_count == 0 )); then
isError "artifact: payload has no ops."; updaterRecordHistory "$app" "hotfix" "" "$id" "empty" "$id" "$serial" ""; return 1
fi
# 6a. DRY-PRECHECK ALL (all-or-nothing)
local i op_json
for (( i=0; i<ops_count; i++ )); do
op_json="$(jq -c ".ops[$i]" <<<"$payload")"
if ! _artifactOpPrecheck "$op_json" "$scope" "$app"; then
isNotice "Precheck failed — skipping the whole hotfix untouched (no changes made)."
updaterRecordHistory "$app" "hotfix" "" "$id" "skipped-precheck" "$id" "$serial" ""
return 0
fi
done
# 5. SNAPSHOT (best-effort safety net; undo log is the guaranteed precise path)
if [[ "$scope" == "app" ]]; then
isNotice "Recovery snapshot of $app"
backupAppStart "$app" >/dev/null 2>&1 || isNotice "No snapshot taken (no backup location?) — relying on the precise undo log."
else
backupSystemConfig >/dev/null 2>&1 || isNotice "No system-config snapshot taken — relying on the precise undo log."
fi
# 6b. APPLY (collect inverse ops into the undo log)
local undo_file; undo_file="$(mktemp)"
local applied_ok=1
for (( i=0; i<ops_count; i++ )); do
op_json="$(jq -c ".ops[$i]" <<<"$payload")"
if ! _artifactOpApply "$op_json" "$scope" "$app" "$undo_file"; then applied_ok=0; break; fi
done
# bring the app up / regenerate system config so changes take effect
if [[ "$scope" == "app" ]]; then
dockerComposeUp "$app" >/dev/null 2>&1 || applied_ok=0
else
declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true
fi
if [[ "$applied_ok" != "1" ]]; then
# 8. AUTO-ROLLBACK — replay the undo log LIFO, then the snapshot as fallback.
isNotice "Apply failed — rolling back…"
_artifactReplayUndoFile "$undo_file" "$app" "reverse"
[[ "$scope" == "app" ]] && { restoreAppStart "$app" latest "" >/dev/null 2>&1 || true; dockerComposeUp "$app" >/dev/null 2>&1 || true; }
rm -f "$undo_file"
updaterRecordHistory "$app" "hotfix" "" "$id" "rolled-back" "$id" "$serial" ""
isError "Hotfix $id failed and was rolled back."
return 1
fi
# 9. RECORD (applied-record with the undo log) + History + serial high-water
_artifactWriteRecord "$id" "$art" "$serial" "$undo_file"
rm -f "$undo_file"
_artifactRegenAppliedManifest
updaterRecordHistory "$app" "hotfix" "" "$id" "applied" "$id" "$serial" "$id"
isSuccessful "Hotfix $id applied. Reversible via: libreportal artifact revert $id"
return 0
}
# Replay every inverse op in $1; "reverse" applies them LIFO (for rollback).
_artifactReplayUndoFile() {
local file="$1" app="$2" order="$3"
[[ -s "$file" ]] || return 0
local lines; mapfile -t lines < "$file"
if [[ "$order" == "reverse" ]]; then
local n=${#lines[@]} j
for (( j=n-1; j>=0; j-- )); do [[ -n "${lines[j]}" ]] && _artifactOpUndo "${lines[j]}" "$app" || true; done
else
local l; for l in "${lines[@]}"; do [[ -n "$l" ]] && _artifactOpUndo "$l" "$app" || true; done
fi
}
# Persist the applied-record (metadata + the undo log as a JSON array).
_artifactWriteRecord() {
local id="$1" art="$2" serial="$3" undo_file="$4"
local dir; dir="$(_artifactAppliedDir)"
runFileOp mkdir -p "$dir" 2>/dev/null || true
local ts; ts="$(date -Iseconds 2>/dev/null || date)"
local undo_arr="[]"
[[ -s "$undo_file" ]] && undo_arr="$(jq -cs '.' < "$undo_file" 2>/dev/null || echo '[]')"
local rec
rec="$(jq -cn --argjson art "$art" --arg ts "$ts" --arg serial "$serial" --argjson undo "$undo_arr" \
'{id:$art.id, type:$art.type, version:($art.version//1), app:($art.applies_when.app//null),
severity:($art.severity//"tweak"), title:($art.title//""), why:($art.why//""),
applied_at:$ts, serial:$serial, undo:$undo}')"
printf '%s' "$rec" | runFileWrite "$(_artifactRecordFile "$id")"
}
# Rebuild artifacts_applied.json (the WebUI-read manifest) from applied/*.json.
_artifactRegenAppliedManifest() {
local dir; dir="$(_artifactAppliedDir)"
local out; out="$(_artifactGenDir)/artifacts_applied.json"
local body="[]"
if compgen -G "$dir/*.json" >/dev/null 2>&1; then
body="$(jq -cs '[.[] | {id,type,app,severity,title,why,applied_at,serial,version}]' "$dir"/*.json 2>/dev/null || echo '[]')"
fi
local ts; ts="$(date -Iseconds 2>/dev/null || date)"
printf '%s' "$(jq -cn --argjson a "$body" --arg ts "$ts" '{generated_at:$ts, applied:$a}')" | runFileWrite "$out"
}
# ----------------------------------------------------------------------------
# artifactRevert <id> — replay the applied-record's undo log (LIFO), then bring
# the app up / regen config. Removes the record + logs to History.
# ----------------------------------------------------------------------------
artifactRevert() {
local id="$1"
_artifactNeedJq || return 1
[[ -n "$id" ]] || { isError "artifactRevert: no id"; return 1; }
local rf; rf="$(_artifactRecordFile "$id")"
[[ -f "$rf" ]] || { isError "artifact: '$id' is not recorded as applied — nothing to revert."; return 1; }
isHeader "Reverting hotfix: $id"
local rec; rec="$(cat "$rf")"
local app; app="$(jq -r '.app // empty' <<<"$rec")"
local serial; serial="$(jq -r '.serial // empty' <<<"$rec")"
# Replay the undo ops in reverse order (LIFO).
local undo_file; undo_file="$(mktemp)"
jq -c '.undo[]?' <<<"$rec" > "$undo_file" 2>/dev/null
_artifactReplayUndoFile "$undo_file" "$app" "reverse"
rm -f "$undo_file"
if [[ -n "$app" ]]; then dockerComposeUp "$app" >/dev/null 2>&1 || true
else declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true; fi
runFileOp rm -f "$rf" 2>/dev/null || true
_artifactRegenAppliedManifest
updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" ""
isSuccessful "Hotfix $id reverted."
return 0
}

View File

@ -4,17 +4,18 @@
# ---------------------------------------------------------------------------
# Dispatched automatically by cli_initialize.sh (category -> cliHandleArtifactCommands).
#
# This is PHASE 1 of the unified distribution primitive: the READ side. It fetches
# and verifies the team-signed artifact index (hotfixes today; apps/themes/
# components later — all the same envelope) and lists what's available. It makes
# NO changes to the system, so — like `updater check` — it runs directly rather
# than through the task system. The state-changing `apply`/`rollback` verbs (which
# DO route through tasks → snapshot → declarative ops → rollback → History) arrive
# in Phase 2. See docs/roadmap/updates-and-distribution.md.
# The unified distribution primitive. The READ side (`index`/`applied`) fetches +
# verifies the team-signed artifact index (hotfixes today; apps/themes/components
# later — all one envelope) and lists what's available/applied; it changes nothing,
# so — like `updater check` — it runs directly. The state-changing `apply`/`revert`
# verbs route through the TASK system (snapshot → bounded declarative ops →
# auto-rollback → History), never a mutating API. See docs/roadmap/updates-and-
# distribution.md and cli_artifact_apply.sh.
cliHandleArtifactCommands()
{
local sub="$initial_command2"
local id="$initial_command3"
# Lazy-loader gap: ensure the read primitives are defined. These are new
# files; the array/manifest regen self-heals them on deploy, but this covers
@ -24,17 +25,62 @@ cliHandleArtifactCommands()
source "$install_scripts_dir/source/fetch.sh" 2>/dev/null
source "$install_scripts_dir/source/artifacts.sh" 2>/dev/null
fi
# The apply pipeline lives in a sibling file — source it on demand too.
if ! declare -F artifactApply >/dev/null 2>&1; then
source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null
fi
case "$sub" in
""|"index"|"list")
artifactListIndex
;;
"applied")
artifactListApplied
;;
"apply")
if [[ -z "$id" ]]; then isError "Usage: libreportal artifact apply <id>"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
artifactApply "$id"
else
cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" ""
fi
;;
"revert"|"rollback")
if [[ -z "$id" ]]; then isError "Usage: libreportal artifact revert <id>"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
artifactRevert "$id"
else
cliTaskRun "libreportal artifact revert $id" "artifact_revert" "$id" ""
fi
;;
*)
cliShowArtifactHelp
;;
esac
}
# List the applied hotfixes (read-only) from the per-id applied records.
artifactListApplied()
{
isHeader "Applied hotfixes"
local dir; dir="${containers_dir%/}/libreportal/frontend/data/updater/generated/applied"
if ! compgen -G "$dir/*.json" >/dev/null 2>&1; then
isSuccessful "0 hotfixes applied."
return 0
fi
local n=0 f id title app
for f in "$dir"/*.json; do
n=$((n+1))
if command -v jq >/dev/null 2>&1; then
id="$(jq -r '.id' "$f" 2>/dev/null)"; title="$(jq -r '.title // ""' "$f" 2>/dev/null)"; app="$(jq -r '.app // ""' "$f" 2>/dev/null)"
echo "$id${app:+ [$app]}$title"
else
echo "$(basename "$f" .json)"
fi
done
isSuccessful "$n hotfix(es) applied. Revert with: libreportal artifact revert <id>"
}
# Fetch + verify the signed index and print a human summary. Read-only.
artifactListIndex()
{

View File

@ -8,13 +8,17 @@ cliShowArtifactHelp()
echo ""
echo "Available Artifact Commands:"
echo ""
echo " libreportal artifact index - Fetch + verify the signed artifact index and list what's available"
echo " libreportal artifact index - Fetch + verify the signed index; list what's available"
echo " libreportal artifact applied - List the hotfixes currently applied to this install"
echo " libreportal artifact apply <id> - Apply a hotfix (snapshot + reversible declarative ops)"
echo " libreportal artifact revert <id> - Revert a previously-applied hotfix (replays its undo log)"
echo ""
echo "An 'artifact' is anything LibrePortal pulls from the outside and applies"
echo "reversibly — a hotfix today; apps / themes / components later. They share"
echo "one team-signed catalog (index.json) on the same channel as the version"
echo "check. This read side verifies the catalog against the root-owned signing"
echo "key; the apply pipeline (snapshot → declarative ops → rollback → History)"
echo "lands in a later phase. See docs/roadmap/updates-and-distribution.md."
echo "check, verified against the root-owned signing key. apply/revert run through"
echo "the task system (never a mutating API): each apply dry-prechecks a bounded,"
echo "declarative op list (no scripts), snapshots, applies, records a precise undo,"
echo "and auto-rolls-back on failure. See docs/roadmap/updates-and-distribution.md."
echo ""
}

View File

@ -76,9 +76,14 @@ updaterApplyApp()
isHeader "Updating $app (a recovery snapshot is taken first)"
# 1. DISASTER RECOVERY — snapshot before touching anything.
# 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 ! libreportal backup app "$app" >/dev/null 2>&1; then
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
@ -125,8 +130,10 @@ 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
# 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."
@ -150,19 +157,43 @@ updaterComposePull()
fi
}
# Append an entry to history.json (best-effort; needs jq, skips silently if absent).
# 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"
command -v jq >/dev/null 2>&1 || return 0
[ -f "$f" ] || printf '{ "entries": [] }\n' | runFileWrite "$f"
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" \
'.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result}] + (.entries // []))[0:200]' \
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"
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
}

View File

@ -30,6 +30,9 @@ _lpDownload() { # _lpDownload <url> <outfile|->
_lpSha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1; fi; }
_lpJsonStr() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E 's/.*"([^"]*)"$/\1/'; }
_lpJsonNum() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*[0-9]+" | head -1 | grep -oE '[0-9]+$'; }
# JSON-escape a scalar for safe inline embedding (backslash first, then quote).
# Used by the jq-free fallbacks (history fallback, artifact applied-records).
_lpJsonEsc() { local s="${1//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; }
# Verify a downloaded file against a detached minisig using the ROOT-OWNED
# footprint public key (/usr/local/lib/libreportal/libreportal.pub). The key is

View File

@ -10,6 +10,7 @@ cli_scripts=(
"cli/commands/app/cli_app_header.sh"
"cli/commands/app/cli_app_restore.sh"
"cli/commands/app/cli_app_tool_list.sh"
"cli/commands/artifact/cli_artifact_apply.sh"
"cli/commands/artifact/cli_artifact_commands.sh"
"cli/commands/artifact/cli_artifact_header.sh"
"cli/commands/backup/cli_backup_commands.sh"

View File

@ -101,7 +101,24 @@ declare -gA LP_FN_MAP=(
[appUpdateSpecifics_nextcloud]="nextcloud/scripts/nextcloud_update_specifics.sh"
[appUpdateSpecifics_pihole]="pihole/scripts/pihole_update_specifics.sh"
[appWebuiRefresh_gluetun]="gluetun/scripts/gluetun_providers.sh"
[_artifactAppliedDir]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactApply]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactComposeImage]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactFetchPayload]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactGenDir]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactListApplied]="cli/commands/artifact/cli_artifact_commands.sh"
[artifactListIndex]="cli/commands/artifact/cli_artifact_commands.sh"
[_artifactNeedJq]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactOpApply]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactOpPrecheck]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactOpUndo]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactPathAllowed]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactRecordFile]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactRegenAppliedManifest]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactReplayUndoFile]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactResolve]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactRevert]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactWriteRecord]="cli/commands/artifact/cli_artifact_apply.sh"
[atomicWriteWebUI]="webui/data/utils/webui_atomic_write.sh"
[authAdapter_adguard_setPassword]="adguard/scripts/adguard_auth.sh"
[authAdapter_bookstack_createUser]="bookstack/scripts/bookstack_auth.sh"
@ -569,6 +586,7 @@ declare -gA LP_FN_MAP=(
[lpIndexArtifactIds]="source/artifacts.sh"
[lpIndexTop]="source/artifacts.sh"
[lpInstalledFootprintVersion]="source/fetch.sh"
[_lpJsonEsc]="source/fetch.sh"
[_lpJsonNum]="source/fetch.sh"
[_lpJsonStr]="source/fetch.sh"
[lpRegen]="webui/webui_regen.sh"
@ -1020,7 +1038,24 @@ declare -gA LP_FN_ROOT=(
[appUpdateSpecifics_nextcloud]="containers"
[appUpdateSpecifics_pihole]="containers"
[appWebuiRefresh_gluetun]="containers"
[_artifactAppliedDir]="scripts"
[artifactApply]="scripts"
[_artifactComposeImage]="scripts"
[_artifactFetchPayload]="scripts"
[_artifactGenDir]="scripts"
[artifactListApplied]="scripts"
[artifactListIndex]="scripts"
[_artifactNeedJq]="scripts"
[_artifactOpApply]="scripts"
[_artifactOpPrecheck]="scripts"
[_artifactOpUndo]="scripts"
[_artifactPathAllowed]="scripts"
[_artifactRecordFile]="scripts"
[_artifactRegenAppliedManifest]="scripts"
[_artifactReplayUndoFile]="scripts"
[_artifactResolve]="scripts"
[artifactRevert]="scripts"
[_artifactWriteRecord]="scripts"
[atomicWriteWebUI]="scripts"
[authAdapter_adguard_setPassword]="containers"
[authAdapter_bookstack_createUser]="containers"
@ -1488,6 +1523,7 @@ declare -gA LP_FN_ROOT=(
[lpIndexArtifactIds]="scripts"
[lpIndexTop]="scripts"
[lpInstalledFootprintVersion]="scripts"
[_lpJsonEsc]="scripts"
[_lpJsonNum]="scripts"
[_lpJsonStr]="scripts"
[lpRegen]="scripts"
@ -1959,7 +1995,24 @@ appUpdateSpecifics_libreportal() { source "${install_containers_dir}libreportal/
appUpdateSpecifics_nextcloud() { source "${install_containers_dir}nextcloud/scripts/nextcloud_update_specifics.sh"; appUpdateSpecifics_nextcloud "$@"; }
appUpdateSpecifics_pihole() { source "${install_containers_dir}pihole/scripts/pihole_update_specifics.sh"; appUpdateSpecifics_pihole "$@"; }
appWebuiRefresh_gluetun() { source "${install_containers_dir}gluetun/scripts/gluetun_providers.sh"; appWebuiRefresh_gluetun "$@"; }
_artifactAppliedDir() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactAppliedDir "$@"; }
artifactApply() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactApply "$@"; }
_artifactComposeImage() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactComposeImage "$@"; }
_artifactFetchPayload() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactFetchPayload "$@"; }
_artifactGenDir() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactGenDir "$@"; }
artifactListApplied() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_commands.sh"; artifactListApplied "$@"; }
artifactListIndex() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_commands.sh"; artifactListIndex "$@"; }
_artifactNeedJq() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactNeedJq "$@"; }
_artifactOpApply() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactOpApply "$@"; }
_artifactOpPrecheck() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactOpPrecheck "$@"; }
_artifactOpUndo() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactOpUndo "$@"; }
_artifactPathAllowed() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactPathAllowed "$@"; }
_artifactRecordFile() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactRecordFile "$@"; }
_artifactRegenAppliedManifest() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactRegenAppliedManifest "$@"; }
_artifactReplayUndoFile() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactReplayUndoFile "$@"; }
_artifactResolve() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactResolve "$@"; }
artifactRevert() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactRevert "$@"; }
_artifactWriteRecord() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactWriteRecord "$@"; }
atomicWriteWebUI() { source "${install_scripts_dir}webui/data/utils/webui_atomic_write.sh"; atomicWriteWebUI "$@"; }
authAdapter_adguard_setPassword() { source "${install_containers_dir}adguard/scripts/adguard_auth.sh"; authAdapter_adguard_setPassword "$@"; }
authAdapter_bookstack_createUser() { source "${install_containers_dir}bookstack/scripts/bookstack_auth.sh"; authAdapter_bookstack_createUser "$@"; }
@ -2427,6 +2480,7 @@ _lpFetchTool() { source "${install_scripts_dir}source/fetch.sh"; _lpFetchTool "$
lpIndexArtifactIds() { source "${install_scripts_dir}source/artifacts.sh"; lpIndexArtifactIds "$@"; }
lpIndexTop() { source "${install_scripts_dir}source/artifacts.sh"; lpIndexTop "$@"; }
lpInstalledFootprintVersion() { source "${install_scripts_dir}source/fetch.sh"; lpInstalledFootprintVersion "$@"; }
_lpJsonEsc() { source "${install_scripts_dir}source/fetch.sh"; _lpJsonEsc "$@"; }
_lpJsonNum() { source "${install_scripts_dir}source/fetch.sh"; _lpJsonNum "$@"; }
_lpJsonStr() { source "${install_scripts_dir}source/fetch.sh"; _lpJsonStr "$@"; }
lpRegen() { source "${install_scripts_dir}webui/webui_regen.sh"; lpRegen "$@"; }