LibrePortal/scripts/cli/commands/artifact/cli_artifact_apply.sh
librelad 2df4e28a85 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>
2026-05-31 20:01:11 +01:00

442 lines
22 KiB
Bash
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
}