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:
parent
a18d34fcfb
commit
2df4e28a85
441
scripts/cli/commands/artifact/cli_artifact_apply.sh
Normal file
441
scripts/cli/commands/artifact/cli_artifact_apply.sh
Normal 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 " | ||||