diff --git a/scripts/cli/commands/artifact/cli_artifact_apply.sh b/scripts/cli/commands/artifact/cli_artifact_apply.sh new file mode 100644 index 0000000..bfbd32f --- /dev/null +++ b/scripts/cli/commands/artifact/cli_artifact_apply.sh @@ -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// +# 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 — 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/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/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 — 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 +} diff --git a/scripts/cli/commands/artifact/cli_artifact_commands.sh b/scripts/cli/commands/artifact/cli_artifact_commands.sh index e029377..d527c95 100644 --- a/scripts/cli/commands/artifact/cli_artifact_commands.sh +++ b/scripts/cli/commands/artifact/cli_artifact_commands.sh @@ -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 "; 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 "; 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 " +} + # Fetch + verify the signed index and print a human summary. Read-only. artifactListIndex() { diff --git a/scripts/cli/commands/artifact/cli_artifact_header.sh b/scripts/cli/commands/artifact/cli_artifact_header.sh index 2e827d9..dcc1112 100644 --- a/scripts/cli/commands/artifact/cli_artifact_header.sh +++ b/scripts/cli/commands/artifact/cli_artifact_header.sh @@ -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 - Apply a hotfix (snapshot + reversible declarative ops)" + echo " libreportal artifact revert - 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 "" } diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh index 6478c7b..c57dd39 100644 --- a/scripts/cli/commands/updater/cli_updater_commands.sh +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -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)" - 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 - runFileWrite "$f" < "$tmp" + [ -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 - rm -f "$tmp" + + # 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 } diff --git a/scripts/source/fetch.sh b/scripts/source/fetch.sh index 3644e87..4464ad4 100644 --- a/scripts/source/fetch.sh +++ b/scripts/source/fetch.sh @@ -30,6 +30,9 @@ _lpDownload() { # _lpDownload _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 diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 229b4d9..ba59139 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -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" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 73756a7..1f03a6d 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -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 "$@"; }