Opens the two designed seams (roadmap §8.4): _artifactResolve accepts type:"app" (slug validated, the installed-app gate skipped — presence is the collision policy's call), and payload.kind:"bundle" gets its own APPLY flow. The download core (sha256 pin vs the signed index + minisig + refuse-unsigned) is factored into _artifactDownloadVerified, shared by ops and bundle payloads. A bundle add: fetch → quarantine-validate → place in the definition tree (staging + one rename, manager funnel) → lpRegenWebui → verify the app surfaced in apps.json → applied-record with a precise undo → History. The validator is fail-closed (traversal/absolute paths, links/devices, single top-level dir == slug, charset, size/entry caps, set-id strip, config TITLE+CATEGORY + compose present, bash -n every .sh) because the definition tree is live-sourced on every CLI start — nothing lands there before trust + quarantine pass. Collision policy: installed-live refused, local definitions win, registry-owned re-add = reversible definition update (prior tree packed into the undo). Revert removes/restores the definition (refused while installed) and regens. Apps never auto-apply (type filter kept + publisher forces auto:false). New verb: libreportal app add <slug|artifact-id> (app_add task; resolves by slug via appAddFromRegistry, ambiguity refused). Also fixes the second half of the sigstate-propagation bug class: artifactApply captured $(_artifactResolve) in a subshell, stranding _ART_INDEX/_ART_APP/_ART_SCOPE AND the LP_INDEX_SIGSTATE the apply gate enforces — on a signed box every apply would have refused as unsigned. Resolve now assigns globals (_ART_JSON) and is called directly. Source-and-mock harness: 46/46 (resolve gates, 14 validator refusals, happy add, collision matrix, definition-update round-trip, revert semantics, postcheck + record-failure rollbacks, apply-auto exclusion, app add verb). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
878 lines
47 KiB
Bash
878 lines
47 KiB
Bash
#!/bin/bash
|
|
#
|
|
# Artifact APPLY pipeline -- Phase 2 of the unified distribution primitive
|
|
# (docs/roadmap/updates-and-distribution.md section 8.3). The MUTATING side: it
|
|
# takes a verified artifact from the signed index and applies it reversibly, and
|
|
# can revert it. Runs ONLY under the task system (cli_artifact_commands.sh
|
|
# enqueues; the processor re-invokes with LIBREPORTAL_TASK_EXEC=1).
|
|
#
|
|
# Design contracts (all enforced below, fail-closed):
|
|
# * Trust core == the release anchor: the index is minisign-verified against the
|
|
# root-owned footprint key by lpFetchIndex; APPLY additionally REFUSES unless
|
|
# that verification actually happened (LP_INDEX_SIGSTATE==verified) and the
|
|
# payload itself is sha256-pinned by the signed index AND minisig-verified.
|
|
# The publishers map + role gate stops a community key claiming official.
|
|
# * 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.
|
|
# * No op VALUE/PATH may carry a shell/quote/sed metacharacter (defense in depth
|
|
# even against a compromised-but-signed payload): _artifactSafeScalar + the
|
|
# _artifactPathAllowed charset gate enforce the "no code-exec" contract.
|
|
# * ALL-OR-NOTHING: every op is dry-prechecked first; one failed precondition
|
|
# skips the whole artifact untouched (recorded, so coverage gaps are visible).
|
|
# * Two-tier reversibility: every op records a precise inverse (the revert path);
|
|
# a best-effort snapshot is also taken. Rollback/revert NEVER report success
|
|
# when an inverse op failed -- they record an explicit "*-incomplete" state.
|
|
# * Mutations write only through the de-sudo funnels, path-aware (container tree
|
|
# -> runFileOp/runFileWrite; manager-owned configs/ -> runInstallOp/Write),
|
|
# never raw sudo. The install tree (our own code) is off-limits to hotfixes.
|
|
|
|
# --- 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
|
|
}
|
|
|
|
# Reject a scalar that could inject when it flows into a shell/sed/sourced-config
|
|
# context. Op values come from a signed payload, but the "no code-exec" contract
|
|
# must hold even for a compromised-but-signed payload (defense in depth).
|
|
# Bans: double-quote, single-quote, backslash, $, backtick, and any whitespace
|
|
# control char (newline/CR/tab).
|
|
_artifactSafeScalar() {
|
|
case "$1" in
|
|
*'"'*|*"'"*|*'\'*|*'$'*|*'`'*) return 1 ;;
|
|
esac
|
|
[[ "$1" == *$'\n'* || "$1" == *$'\r'* || "$1" == *$'\t'* ]] && return 1
|
|
return 0
|
|
}
|
|
|
|
# Path-aware writer/remover: container tree -> runFileWrite/runFileOp (install user
|
|
# in rootless); manager-owned (configs/) -> runInstallWrite/runInstallOp. A
|
|
# system-scope hotfix patches configs/, which the container funnel can't write.
|
|
_artifactWriteFile() { # stdin -> $1
|
|
local path="$1"
|
|
if [[ -n "${containers_dir:-}" && "$path" == "${containers_dir%/}/"* ]]; then runFileWrite "$path"
|
|
else runInstallWrite "$path"; fi
|
|
}
|
|
_artifactRmFile() {
|
|
local path="$1"
|
|
if [[ -n "${containers_dir:-}" && "$path" == "${containers_dir%/}/"* ]]; then runFileOp rm -f "$path"
|
|
else runInstallOp rm -f "$path"; fi
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# RESOLVE -- fetch+verify the index, pull out one artifact, check the gates.
|
|
# Sets globals: _ART_JSON (the artifact), _ART_INDEX (verified index json),
|
|
# _ART_TYPE, _ART_SCOPE, _ART_APP; LP_INDEX_SIGSTATE via lpFetchIndexInto.
|
|
# MUST be called directly, never as var="$(_artifactResolve …)" -- a command
|
|
# substitution strands every one of those globals (incl. the sigstate the
|
|
# apply gate enforces) in the subshell. Non-zero = refuse; 2 = not-applicable.
|
|
# ----------------------------------------------------------------------------
|
|
_artifactResolve() {
|
|
local id="$1"
|
|
lpFetchIndexInto _ART_INDEX || { 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")"
|
|
case "$type" in
|
|
hotfix|app) _ART_TYPE="$type" ;;
|
|
*) isError "artifact: type '$type' is not supported by this build (only 'hotfix' and 'app')."; return 1 ;;
|
|
esac
|
|
|
|
# --- 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_TYPE" == "app" ]]; then
|
|
# For type:"app" the target IS the thing being added -- presence on the
|
|
# box is the bundle collision policy's call, not an applicability gate.
|
|
# The slug must be a safe shell-identifier folder name (CFG_<SLUG^^>_*).
|
|
if [[ ! "$_ART_APP" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]]; then
|
|
isError "artifact: app artifact '$id' has a missing/unsafe applies_when.app slug -- refusing."; return 1
|
|
fi
|
|
elif [[ "$_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
|
|
|
|
_ART_JSON="$art"
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# PAYLOAD download core -- fetch $1 to $4, sha256-pin against the SIGNED index's
|
|
# value ($2), minisig-verify against $3 (sig lands at $4.minisig). REFUSES an
|
|
# unsigned payload (signing-not-activated) -- this is the apply path.
|
|
# ----------------------------------------------------------------------------
|
|
_artifactDownloadVerified() {
|
|
local url="$1" want_sha="$2" sig_url="$3" dest="$4"
|
|
local base got pstate sf="$dest.minisig"
|
|
base="$(lpReleaseBaseUrl)"
|
|
[[ -n "$url" && -n "$want_sha" ]] || { isError "artifact: payload missing url/sha256 -- refusing."; return 1; }
|
|
case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac
|
|
if ! _lpDownload "$url" "$dest" 2>/dev/null; then isError "artifact: payload download failed."; return 1; fi
|
|
got="$(_lpSha256 "$dest")"
|
|
if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; 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
|
|
pstate="$(lpVerifyMinisig "$dest" "$sf")" || return 1
|
|
if [[ "$pstate" != "verified" ]]; then
|
|
isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Fetch + verify an ops payload (payload.kind:"ops" -- the hotfix body).
|
|
# Echoes the verified payload JSON; non-zero on any failure.
|
|
_artifactFetchPayload() {
|
|
local art="$1" url want_sha sig_url tmp pf kind
|
|
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
|
|
if [[ "$kind" != "ops" ]]; then
|
|
isError "artifact: payload.kind '$kind' has no ops interpreter -- refusing."; 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")"
|
|
tmp="$(mktemp -d)"; pf="$tmp/payload.json"
|
|
_artifactDownloadVerified "$url" "$want_sha" "$sig_url" "$pf" || { rm -rf "$tmp"; return 1; }
|
|
cat "$pf"; rm -rf "$tmp"
|
|
}
|
|
|
|
# Fetch + verify a bundle payload (payload.kind:"bundle" -- an app definition
|
|
# tarball) into $2 (caller-owned workdir). Echoes the tarball path.
|
|
_artifactFetchBundle() {
|
|
local art="$1" workdir="$2" url want_sha sig_url kind tb
|
|
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
|
|
if [[ "$kind" != "bundle" ]]; then
|
|
isError "artifact: payload.kind '$kind' is not a bundle -- refusing."; 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")"
|
|
tb="$workdir/bundle.tar.gz"
|
|
_artifactDownloadVerified "$url" "$want_sha" "$sig_url" "$tb" || return 1
|
|
printf '%s' "$tb"
|
|
}
|
|
|
|
# --- 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)
|
|
# Also enforces a safe-filename charset so a path can never carry a shell/quote
|
|
# metacharacter into a funnel (belt-and-braces with the runFileWrite argv fix).
|
|
_artifactPathAllowed() {
|
|
local path="$1" scope="$2" app="$3" real root
|
|
[[ "$path" == *".."* ]] && return 1
|
|
[[ "$path" =~ ^[A-Za-z0-9._/@:+-]+$ ]] || 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/comment 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+$//'
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# BUNDLE (payload.kind:"bundle", type:"app") -- add an app DEFINITION to the
|
|
# App Center catalog: verify + quarantine-validate the tarball, place it in the
|
|
# definition tree (install_containers_dir), regen so it surfaces as a normal
|
|
# installable app.
|
|
#
|
|
# ORDERING CONTRACT (why this section is paranoid): the container scan
|
|
# live-sources every install_containers_dir/<app>/*.sh on EVERY CLI start, so
|
|
# placing a definition IS deferred code execution. Nothing may land there until
|
|
# (a) the trust gates upstream have passed (official publisher role at resolve,
|
|
# index sha256 pin + payload minisig in _artifactDownloadVerified) AND (b)
|
|
# every quarantine check below has passed. Community publishers are refused at
|
|
# resolve time; their host-script quarantine tier is a deferred phase.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
# Validate tarball $1 for app slug $2 by extracting into quarantine dir $3.
|
|
# Fail-closed on ANY irregularity; writes nothing outside $3.
|
|
_artifactBundleValidate() {
|
|
local tarball="$1" slug="$2" qdir="$3"
|
|
local max_tar=$((5*1024*1024)) max_extracted=$((20*1024*1024)) max_entries=400
|
|
|
|
[[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]] || { isError "bundle: unsafe app slug '$slug'"; return 1; }
|
|
case " template libreportal tools scripts resources " in
|
|
*" $slug "*) isError "bundle: slug '$slug' is reserved"; return 1 ;;
|
|
esac
|
|
|
|
local size; size="$(stat -c %s "$tarball" 2>/dev/null || echo 0)"
|
|
(( size > 0 && size <= max_tar )) || { isError "bundle: tarball size ${size}B outside limits (max ${max_tar}B)"; return 1; }
|
|
|
|
# Listing pass -- names, entry types, declared sizes. Nothing extracted yet,
|
|
# so a crafted archive can't touch the disk before it is judged.
|
|
local names; names="$(tar -tzf "$tarball" 2>/dev/null)" || { isError "bundle: unreadable tarball"; return 1; }
|
|
[[ -n "$names" ]] || { isError "bundle: empty tarball"; return 1; }
|
|
local count; count="$(wc -l <<<"$names")"
|
|
(( count <= max_entries )) || { isError "bundle: $count entries (max $max_entries)"; return 1; }
|
|
local n
|
|
while IFS= read -r n; do
|
|
[[ "$n" =~ ^[A-Za-z0-9._/@:+-]+$ ]] || { isError "bundle: entry '$n' has unsafe characters"; return 1; }
|
|
[[ "$n" == /* ]] && { isError "bundle: absolute path '$n'"; return 1; }
|
|
[[ "$n" == ".."* || "$n" == *"/../"* || "$n" == *"/.." ]] && { isError "bundle: path traversal in '$n'"; return 1; }
|
|
[[ "$n" == "$slug" || "$n" == "$slug/"* ]] || { isError "bundle: entry '$n' outside the '$slug/' top-level dir"; return 1; }
|
|
done <<<"$names"
|
|
|
|
local badtype; badtype="$(tar -tvzf "$tarball" 2>/dev/null | awk '$1 !~ /^[d-]/ {print; exit}')"
|
|
[[ -z "$badtype" ]] || { isError "bundle: non-regular entry (link/device/fifo): $badtype"; return 1; }
|
|
local total; total="$(tar -tvzf "$tarball" 2>/dev/null | awk '{s+=$3} END{print s+0}')"
|
|
(( total <= max_extracted )) || { isError "bundle: declared extracted size ${total}B exceeds max ${max_extracted}B"; return 1; }
|
|
|
|
tar -xzf "$tarball" --no-same-owner --no-same-permissions -C "$qdir" 2>/dev/null || { isError "bundle: extraction failed"; return 1; }
|
|
|
|
# Re-check on disk (belt-and-braces with the listing pass), drop set-id bits.
|
|
local special; special="$(find "$qdir" \( -type l -o -type p -o -type s -o -type b -o -type c \) -print -quit 2>/dev/null)"
|
|
[[ -z "$special" ]] || { isError "bundle: non-regular file after extraction: $special"; return 1; }
|
|
chmod -R u-s,g-s "$qdir" 2>/dev/null || true
|
|
|
|
# The drop-in contract (docs/contributing/development.md): <slug>.config with
|
|
# TITLE + CATEGORY (parsed line-wise, NEVER sourced) and a compose template.
|
|
local cfg="$qdir/$slug/$slug.config" upper="${slug^^}"
|
|
[[ -f "$cfg" ]] || { isError "bundle: $slug.config missing"; return 1; }
|
|
grep -q "^CFG_${upper}_TITLE=" "$cfg" 2>/dev/null || { isError "bundle: CFG_${upper}_TITLE missing from $slug.config"; return 1; }
|
|
grep -q "^CFG_${upper}_CATEGORY=" "$cfg" 2>/dev/null || { isError "bundle: CFG_${upper}_CATEGORY missing from $slug.config"; return 1; }
|
|
[[ -f "$qdir/$slug/docker-compose.yml" ]] || { isError "bundle: docker-compose.yml missing"; return 1; }
|
|
|
|
# A syntax-broken .sh would break every future CLI start via the live-source.
|
|
local sh
|
|
while IFS= read -r sh; do
|
|
bash -n "$sh" 2>/dev/null || { isError "bundle: $sh fails bash -n -- refusing"; return 1; }
|
|
done < <(find "$qdir/$slug" -type f -name '*.sh' 2>/dev/null)
|
|
|
|
return 0
|
|
}
|
|
|
|
# The applied-record (if any) that owns app $1's definition -- echoes the record
|
|
# path. A definition WITHOUT one is local (hand-made / shipped with the install)
|
|
# and always wins over the catalog.
|
|
_artifactBundleOwner() {
|
|
local slug="$1" dir f
|
|
dir="$(_artifactAppliedDir)"
|
|
compgen -G "$dir/*.json" >/dev/null 2>&1 || return 1
|
|
for f in "$dir"/*.json; do
|
|
if [[ "$(jq -r 'select(.type=="app") | .app // empty' "$f" 2>/dev/null)" == "$slug" ]]; then
|
|
printf '%s' "$f"; return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# The APPLY flow for a bundle. RESOLVE/trust/sigstate ran in artifactApply;
|
|
# everything after differs from ops (no live app to snapshot or compose-up --
|
|
# the undo log alone is the reversibility anchor, and the target is the
|
|
# definition tree, written only through the manager funnel).
|
|
_artifactApplyBundleFlow() {
|
|
local id="$1" art="$2" serial="$3"
|
|
local slug="$_ART_APP"
|
|
local live_dir="${containers_dir%/}/$slug"
|
|
local def_root="${install_containers_dir%/}"
|
|
local def_dir="$def_root/$slug"
|
|
|
|
# --- collision policy: local state always beats the catalog ---
|
|
if [[ -d "$live_dir" ]]; then
|
|
isError "app '$slug' is installed -- updating a live app is the updater's job. Uninstall first to re-add its definition."
|
|
updaterRecordHistory "$slug" "app" "" "$id" "refused-installed" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
local owner_rec="" prior_b64=""
|
|
if [[ -d "$def_dir" ]]; then
|
|
if ! owner_rec="$(_artifactBundleOwner "$slug")"; then
|
|
isError "a local definition for '$slug' already exists -- local definitions win; not overwriting."
|
|
updaterRecordHistory "$slug" "app" "" "$id" "refused-local-definition" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
# Registry-owned -> definition update: pack the prior tree so the undo
|
|
# restores it precisely. Definitions are KB-scale; cap the record at ~1MB.
|
|
prior_b64="$(tar -czf - -C "$def_root" "$slug" 2>/dev/null | base64 -w0)"
|
|
if [[ -z "$prior_b64" ]] || (( ${#prior_b64} > 1400000 )); then
|
|
isError "existing '$slug' definition could not be snapshotted for undo (missing or too large) -- refusing."
|
|
updaterRecordHistory "$slug" "app" "" "$id" "refused-prior-snapshot" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# --- fetch + quarantine-validate (nothing placed yet) ---
|
|
local work; work="$(mktemp -d)"
|
|
local tb
|
|
if ! tb="$(_artifactFetchBundle "$art" "$work")"; then
|
|
rm -rf "$work"
|
|
updaterRecordHistory "$slug" "app" "" "$id" "verify-failed" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
local qdir="$work/quarantine"; mkdir -p "$qdir"
|
|
if ! _artifactBundleValidate "$tb" "$slug" "$qdir"; then
|
|
rm -rf "$work"
|
|
isNotice "Bundle validation failed -- nothing was written."
|
|
updaterRecordHistory "$slug" "app" "" "$id" "rejected-bundle" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
|
|
# --- undo log first (the reversibility anchor) ---
|
|
local undo_file; undo_file="$(mktemp)"
|
|
if [[ -n "$prior_b64" ]]; then
|
|
jq -cn --arg app "$slug" --arg b "$prior_b64" '{op:"restore-app-definition", app:$app, tar_b64:$b}' >> "$undo_file"
|
|
else
|
|
jq -cn --arg app "$slug" '{op:"remove-app-definition", app:$app}' >> "$undo_file"
|
|
fi
|
|
|
|
# --- place: stage INSIDE the definition tree, then one rename (atomic vs
|
|
# the regen poll -- a half-copied definition is never scanned) ---
|
|
local staging="$def_root/.staging.$slug.$$" placed=1
|
|
runInstallOp rm -rf "$staging" 2>/dev/null || true
|
|
runInstallOp cp -a "$qdir/$slug" "$staging" || placed=0
|
|
if [[ "$placed" == "1" && -d "$def_dir" ]]; then
|
|
runInstallOp rm -rf "$def_dir" || placed=0
|
|
fi
|
|
if [[ "$placed" == "1" ]]; then
|
|
runInstallOp mv "$staging" "$def_dir" || placed=0
|
|
fi
|
|
rm -rf "$work"
|
|
|
|
# --- regen + verify the app actually surfaced in the App Center data ---
|
|
if [[ "$placed" == "1" ]]; then
|
|
isNotice "Definition placed -- refreshing the App Center catalog..."
|
|
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
|
|
local apps_json="${containers_dir%/}/libreportal/frontend/data/apps/generated/apps.json"
|
|
jq -e --arg app "$slug" '.apps[]? | select((.command // "") | endswith(" " + $app))' "$apps_json" >/dev/null 2>&1 || placed=0
|
|
fi
|
|
|
|
if [[ "$placed" != "1" ]]; then
|
|
isNotice "Bundle placement/verify failed -- rolling back..."
|
|
local rb_rc=0
|
|
_artifactReplayUndoFile "$undo_file" "$slug" "reverse" || rb_rc=1
|
|
runInstallOp rm -rf "$staging" 2>/dev/null || true
|
|
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
|
|
rm -f "$undo_file"
|
|
if [[ "$rb_rc" -eq 0 ]]; then
|
|
updaterRecordHistory "$slug" "app" "" "$id" "rolled-back" "$id" "$serial" ""
|
|
isError "App bundle $id failed and was rolled back cleanly."
|
|
else
|
|
updaterRecordHistory "$slug" "app" "" "$id" "rollback-incomplete" "$id" "$serial" ""
|
|
isError "App bundle $id failed AND rollback was incomplete -- MANUAL cleanup of $def_dir may be needed (see History)."
|
|
fi
|
|
return 1
|
|
fi
|
|
|
|
# --- record (same contract as ops: no persisted undo trail, no change) ---
|
|
if ! _artifactWriteRecord "$id" "$art" "$serial" "$undo_file"; then
|
|
isError "Could not persist the applied-record -- rolling back to stay reversible."
|
|
_artifactReplayUndoFile "$undo_file" "$slug" "reverse" || true
|
|
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
|
|
rm -f "$undo_file"
|
|
updaterRecordHistory "$slug" "app" "" "$id" "record-failed-rolled-back" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
# A definition update retires the prior owning record -- one owner per app.
|
|
if [[ -n "$owner_rec" && "$owner_rec" != "$(_artifactRecordFile "$id")" ]]; then
|
|
runFileOp rm -f "$owner_rec" 2>/dev/null || true
|
|
fi
|
|
rm -f "$undo_file"
|
|
_artifactRegenAppliedManifest
|
|
updaterRecordHistory "$slug" "app" "" "$id" "applied" "$id" "$serial" "$id"
|
|
isSuccessful "App '$slug' added to the App Center catalog. Install it with: libreportal app install $slug (undo: libreportal artifact revert $id)"
|
|
return 0
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# 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 value expect cur f
|
|
key="$(jq -r '.key // empty' <<<"$op_json")"
|
|
value="$(jq -r '.value // empty' <<<"$op_json")"
|
|
[[ "$key" =~ ^CFG_[A-Z0-9_]+$ ]] || { isError "op set-config-key: bad key '$key'"; return 1; }
|
|
_artifactSafeScalar "$value" || { isError "op set-config-key: value has an unsafe character (quote/\$/backtick/backslash/newline)"; return 1; }
|
|
f="$(findConfigFileForOption "$key" 2>/dev/null)"
|
|
{ [[ -n "$f" ]] && grep -q "^$key=" "$f" 2>/dev/null; } || { isError "op set-config-key: key '$key' is not present -- skipping (edit-only, no create)"; 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=" "$f" 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 image cur
|
|
from="$(jq -r '.from // empty' <<<"$op_json")"
|
|
image="$(jq -r '.image // empty' <<<"$op_json")"
|
|
{ _artifactSafeScalar "$from" && _artifactSafeScalar "$image"; } || { isError "op set-compose-image: image/from has an unsafe character"; return 1; }
|
|
[[ "$image" =~ ^[A-Za-z0-9._:/@-]+$ ]] || { isError "op set-compose-image: '$image' is not a valid image reference"; return 1; }
|
|
[[ -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 cb
|
|
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; }
|
|
cb="$(jq -r '.content_b64 // empty' <<<"$op_json")"
|
|
{ [[ -n "$cb" ]] && printf '%s' "$cb" | base64 -d >/dev/null 2>&1; } || { isError "op patch-file: content_b64 missing or not valid base64"; return 1; }
|
|
;;
|
|
set-data-file)
|
|
local path cb
|
|
path="$(jq -r '.path // empty' <<<"$op_json")"
|
|
_artifactPathAllowed "$path" "$scope" "$app" || { isError "op set-data-file: path '$path' not in the allowlist"; return 1; }
|
|
cb="$(jq -r '.content_b64 // empty' <<<"$op_json")"
|
|
{ [[ -n "$cb" ]] && printf '%s' "$cb" | base64 -d >/dev/null 2>&1; } || { isError "op set-data-file: content_b64 missing or not valid base64"; 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 (compact JSON) to $4 (the
|
|
# undo log). Pre-images are captured BEFORE the mutation. 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)
|
|
# Lossless undo: snapshot the WHOLE config file (exact bytes) and emit
|
|
# a restore-file inverse. Avoids the lossy "re-parse the prior scalar"
|
|
# trap (quotes / trailing #). LIFO replay handles multiple ops/file.
|
|
local key value f prior_b64
|
|
key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")"
|
|
f="$(findConfigFileForOption "$key" 2>/dev/null)"
|
|
{ [[ -n "$f" && -f "$f" ]]; } || { isError "op set-config-key: config file for $key not found"; return 1; }
|
|
prior_b64="$(base64 -w0 < "$f" 2>/dev/null)"
|
|
updateConfigOption "$key" "$value" || return 1
|
|
jq -cn --arg p "$f" --arg b "$prior_b64" '{op:"restore-file", path:$p, existed:true, content_b64:$b}' >> "$undo_file"
|
|
;;
|
|
set-compose-image)
|
|
local image cur f esc_img
|
|
image="$(jq -r '.image' <<<"$op_json")"
|
|
f="${containers_dir%/}/$app/docker-compose.yml"
|
|
cur="$(_artifactComposeImage "$app")"
|
|
esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
|
|
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 | _artifactWriteFile "$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).
|
|
# Inverse ops are idempotent (re-applying the same restore is safe), so a retried
|
|
# revert cannot corrupt state.
|
|
# ----------------------------------------------------------------------------
|
|
_artifactOpUndo() {
|
|
local op_json="$1" app="$2" op
|
|
op="$(jq -r '.op // empty' <<<"$op_json")"
|
|
case "$op" in
|
|
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 | _artifactWriteFile "$path" || return 1
|
|
else
|
|
_artifactRmFile "$path" || return 1
|
|
fi
|
|
;;
|
|
remove-app-definition)
|
|
# Inverse of a fresh bundle add. Refuses while the app is installed
|
|
# live -- a live app depends on its definition (uninstall first).
|
|
local slug live def
|
|
slug="$(jq -r '.app // empty' <<<"$op_json")"
|
|
[[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]] || { isError "undo: unsafe app slug '$slug'"; return 1; }
|
|
live="${containers_dir%/}/$slug"; def="${install_containers_dir%/}/$slug"
|
|
if [[ -d "$live" ]]; then
|
|
isError "undo: app '$slug' is INSTALLED -- uninstall it before removing its definition."; return 1
|
|
fi
|
|
[[ -d "$def" ]] && { runInstallOp rm -rf "$def" || return 1; }
|
|
;;
|
|
restore-app-definition)
|
|
# Inverse of a registry definition update: put the packed prior
|
|
# definition tree back (unpack to a temp, then swap into place).
|
|
local slug tarb64 def rstage
|
|
slug="$(jq -r '.app // empty' <<<"$op_json")"
|
|
tarb64="$(jq -r '.tar_b64 // empty' <<<"$op_json")"
|
|
{ [[ "$slug" =~ ^[a-z0-9][a-z0-9_]{0,31}$ && -n "$tarb64" ]]; } || { isError "undo: bad restore-app-definition record"; return 1; }
|
|
def="${install_containers_dir%/}/$slug"
|
|
rstage="$(mktemp -d)"
|
|
if ! printf '%s' "$tarb64" | base64 -d 2>/dev/null | tar -xzf - --no-same-owner --no-same-permissions -C "$rstage" 2>/dev/null \
|
|
|| [[ ! -d "$rstage/$slug" ]]; then
|
|
isError "undo: could not unpack the prior '$slug' definition"; rm -rf "$rstage"; return 1
|
|
fi
|
|
runInstallOp rm -rf "$def" 2>/dev/null || true
|
|
runInstallOp cp -a "$rstage/$slug" "$def" || { rm -rf "$rstage"; return 1; }
|
|
rm -rf "$rstage"
|
|
;;
|
|
*) isError "undo: unknown inverse op '$op'"; return 1 ;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# Replay every inverse op in $1; "reverse" applies them LIFO (for rollback).
|
|
# Returns non-zero if ANY inverse op failed (so the caller never claims success).
|
|
_artifactReplayUndoFile() {
|
|
local file="$1" app="$2" order="$3" rc=0
|
|
[[ -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" || rc=1; }; done
|
|
else
|
|
local l; for l in "${lines[@]}"; do [[ -n "$l" ]] && { _artifactOpUndo "$l" "$app" || rc=1; }; done
|
|
fi
|
|
return $rc
|
|
}
|
|
|
|
# Persist the applied-record (metadata + the undo log as a JSON array). Returns
|
|
# the write's exit status so the caller can react to a failed persist.
|
|
_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")"
|
|
return ${PIPESTATUS[1]}
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# artifactApply <id> -- the full pipeline. 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 artifact: $id"
|
|
|
|
# 0. RESOLVE + gates (direct call -- the resolve globals + sigstate must
|
|
# land in THIS shell, not a substitution subshell)
|
|
local art rc
|
|
_ART_JSON=""
|
|
_artifactResolve "$id"; rc=$?
|
|
art="$_ART_JSON"
|
|
if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "${_ART_TYPE:-hotfix}" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi
|
|
[[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "${_ART_TYPE:-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")"
|
|
|
|
# GATE: never mutate from an index that was accepted only because signing
|
|
# wasn't activated (dev/placeholder key). The read path tolerates that; apply
|
|
# must not.
|
|
if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then
|
|
isError "artifact: the index is not signature-verified (signing not activated) -- refusing to APPLY."
|
|
updaterRecordHistory "$app" "${_ART_TYPE:-hotfix}" "" "$id" "refused-unsigned" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
isNotice "$title (type=${_ART_TYPE}, scope=$scope${app:+, app=$app})"
|
|
|
|
# 4-bundle. type:"app" rides payload.kind:"bundle" through its own flow --
|
|
# RESOLVE/trust/sigstate above are shared; everything after differs (no live
|
|
# app to snapshot or compose-up; the definition tree is the target). The
|
|
# type/kind pairing is strict: any other combination is refused.
|
|
local kind; kind="$(jq -r '.payload.kind // "ops"' <<<"$art")"
|
|
if [[ "$_ART_TYPE" == "app" || "$kind" == "bundle" ]]; then
|
|
if [[ "$_ART_TYPE" != "app" || "$kind" != "bundle" ]]; then
|
|
isError "artifact: type '$_ART_TYPE' + payload.kind '$kind' is not a supported combination."
|
|
updaterRecordHistory "$app" "${_ART_TYPE:-hotfix}" "" "$id" "rejected" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
_artifactApplyBundleFlow "$id" "$art" "$serial"
|
|
return $?
|
|
fi
|
|
|
|
# 4. PAYLOAD (fetch + sha256-pin + minisig-verify; refuses unsigned)
|
|
local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; }
|
|
|
|
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; the undo log is the guaranteed 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
|
|
|
|
# 7. bring the app up / regenerate system config so changes take effect
|
|
if [[ "$applied_ok" == "1" ]]; then
|
|
if [[ "$scope" == "app" ]]; then
|
|
dockerComposeUp "$app" >/dev/null 2>&1 || applied_ok=0
|
|
elif declare -F webuiGenerateSystemConfigs >/dev/null 2>&1; then
|
|
webuiGenerateSystemConfigs >/dev/null 2>&1 || applied_ok=0
|
|
fi
|
|
fi
|
|
|
|
if [[ "$applied_ok" != "1" ]]; then
|
|
# 8. AUTO-ROLLBACK -- replay the undo log LIFO; snapshot is the fallback.
|
|
isNotice "Apply failed -- rolling back..."
|
|
local rb_rc=0
|
|
_artifactReplayUndoFile "$undo_file" "$app" "reverse" || rb_rc=1
|
|
if [[ "$scope" == "app" ]]; then restoreAppStart "$app" latest "" >/dev/null 2>&1 || true; dockerComposeUp "$app" >/dev/null 2>&1 || true; fi
|
|
rm -f "$undo_file"
|
|
if [[ "$rb_rc" -eq 0 ]]; then
|
|
updaterRecordHistory "$app" "hotfix" "" "$id" "rolled-back" "$id" "$serial" ""
|
|
isError "Hotfix $id failed and was rolled back cleanly."
|
|
else
|
|
updaterRecordHistory "$app" "hotfix" "" "$id" "rollback-incomplete" "$id" "$serial" ""
|
|
isError "Hotfix $id failed AND auto-rollback was incomplete -- MANUAL recovery needed (see History / the app's snapshot)."
|
|
fi
|
|
return 1
|
|
fi
|
|
|
|
# 9. RECORD (applied-record with the undo log). If we can't persist the undo
|
|
# trail, roll back rather than leave an un-revertable change.
|
|
if ! _artifactWriteRecord "$id" "$art" "$serial" "$undo_file"; then
|
|
isError "Could not persist the applied-record -- rolling back to stay reversible."
|
|
_artifactReplayUndoFile "$undo_file" "$app" "reverse" || true
|
|
[[ "$scope" == "app" ]] && dockerComposeUp "$app" >/dev/null 2>&1 || true
|
|
rm -f "$undo_file"
|
|
updaterRecordHistory "$app" "hotfix" "" "$id" "record-failed-rolled-back" "$id" "$serial" ""
|
|
return 1
|
|
fi
|
|
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
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# artifactRevert <id> -- replay the applied-record's undo log (LIFO), then bring
|
|
# the app up / regen config. Only removes the record + reports success if every
|
|
# inverse op succeeded; otherwise keeps the record for retry and reports honestly.
|
|
# ----------------------------------------------------------------------------
|
|
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; }
|
|
|
|
local rec; rec="$(cat "$rf")"
|
|
local rtype; rtype="$(jq -r '.type // "hotfix"' <<<"$rec")"
|
|
isHeader "Reverting ${rtype}: $id"
|
|
local app; app="$(jq -r '.app // empty' <<<"$rec")"
|
|
local serial; serial="$(jq -r '.serial // empty' <<<"$rec")"
|
|
|
|
local undo_file; undo_file="$(mktemp)"
|
|
jq -c '.undo[]?' <<<"$rec" > "$undo_file" 2>/dev/null
|
|
local rv_rc=0
|
|
_artifactReplayUndoFile "$undo_file" "$app" "reverse" || rv_rc=1
|
|
rm -f "$undo_file"
|
|
|
|
# Bring-up: an app record changed the DEFINITION tree (regen the catalog);
|
|
# a hotfix changed a live app (compose up) or system configs (regen those).
|
|
if [[ "$rtype" == "app" ]]; then
|
|
{ declare -F lpRegenWebui >/dev/null 2>&1 && lpRegenWebui force >/dev/null 2>&1; } || true
|
|
elif [[ -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
|
|
|
|
if [[ "$rv_rc" -eq 0 ]]; then
|
|
runFileOp rm -f "$rf" 2>/dev/null || true
|
|
_artifactRegenAppliedManifest
|
|
updaterRecordHistory "$app" "$rtype" "$id" "" "reverted" "$id" "$serial" ""
|
|
isSuccessful "${rtype^} $id reverted."
|
|
return 0
|
|
fi
|
|
updaterRecordHistory "$app" "$rtype" "$id" "" "revert-incomplete" "$id" "$serial" "$id"
|
|
isError "Revert of $id was incomplete -- the applied-record is kept so you can retry: libreportal artifact revert $id (MANUAL recovery may be needed)."
|
|
return 1
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# artifactApplyAuto -- enqueue apply tasks for the auto-eligible hotfixes, gated
|
|
# by CFG_HOTFIX_AUTO (security-breakage|all|off). Called from `updater check`.
|
|
# Only acts on a VERIFIED-signed index; only artifacts with auto==true, in the
|
|
# severity policy, applicable, and not already applied. Each apply is enqueued as
|
|
# its own task (visible in the task log + History) -- never applied inline here.
|
|
# ----------------------------------------------------------------------------
|
|
artifactApplyAuto() {
|
|
_artifactNeedJq || return 0
|
|
local policy="${CFG_HOTFIX_AUTO:-security-breakage}"
|
|
[[ "$policy" == "off" ]] && { isNotice "Hotfix auto-apply is off (CFG_HOTFIX_AUTO=off)."; return 0; }
|
|
|
|
local index; lpFetchIndexInto index || { isNotice "artifact apply-auto: no index available."; return 0; }
|
|
if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then
|
|
isNotice "artifact apply-auto: index is unsigned (signing not activated) — not auto-applying."; return 0
|
|
fi
|
|
|
|
local ids id art sev app installed enqueued=0
|
|
# type=="hotfix" is a hard filter: apps are ALWAYS a deliberate user action
|
|
# (the publisher also forces auto:false on them -- belt and braces).
|
|
ids="$(printf '%s' "$index" | jq -r '.artifacts[]? | select(.auto==true and (.type=="hotfix")) | .id' 2>/dev/null)"
|
|
while IFS= read -r id; do
|
|
[[ -z "$id" ]] && continue
|
|
art="$(printf '%s' "$index" | jq -ce --arg id "$id" '.artifacts[]?|select(.id==$id)' 2>/dev/null)"
|
|
[[ -n "$art" ]] || continue
|
|
sev="$(jq -r '.severity // "tweak"' <<<"$art")"
|
|
case "$policy" in
|
|
all) : ;;
|
|
*) [[ "$sev" == "security" || "$sev" == "breakage" ]] || continue ;; # security-breakage (default)
|
|
esac
|
|
# skip if already applied
|
|
[[ -f "$(_artifactRecordFile "$id")" ]] && continue
|
|
# skip if app-scoped but the app isn't installed (applicable gate; full
|
|
# gates re-checked at apply time)
|
|
app="$(jq -r '.applies_when.app // empty' <<<"$art")"
|
|
[[ -n "$app" && ! -d "${containers_dir%/}/$app" ]] && continue
|
|
cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" ""
|
|
enqueued=$((enqueued + 1))
|
|
done <<< "$ids"
|
|
|
|
if (( enqueued > 0 )); then isSuccessful "Queued $enqueued auto-hotfix(es) for apply (policy: $policy)."
|
|
else isNotice "No new auto-hotfixes to apply (policy: $policy)."; fi
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# appAddFromRegistry <slug-or-artifact-id> -- the `libreportal app add` verb.
|
|
# Resolves a type:"app" artifact by exact id or by its applies_when.app slug,
|
|
# then runs the standard apply pipeline (which re-fetches + re-verifies).
|
|
# ----------------------------------------------------------------------------
|
|
appAddFromRegistry() {
|
|
local arg="$1"
|
|
_artifactNeedJq || return 1
|
|
[[ -n "$arg" ]] || { isError "app add: need an app slug or artifact id."; return 1; }
|
|
[[ "$arg" =~ ^[A-Za-z0-9._-]+$ ]] || { isError "app add: argument has unsafe characters."; return 1; }
|
|
local index; lpFetchIndexInto index || { isError "app add: could not fetch/verify the artifact index."; return 1; }
|
|
local ids; ids="$(printf '%s' "$index" | jq -r --arg a "$arg" \
|
|
'[.artifacts[]? | select(.type=="app" and (.id==$a or (.applies_when.app // "")==$a)) | .id] | unique | .[]' 2>/dev/null)"
|
|
if [[ -z "$ids" ]]; then
|
|
isError "app add: no app '$arg' in the catalog. Browse it with: libreportal artifact index"; return 1
|
|
fi
|
|
if (( $(wc -l <<<"$ids") > 1 )); then
|
|
isError "app add: '$arg' matches more than one artifact -- use an exact id: $(tr '\n' ' ' <<<"$ids")"; return 1
|
|
fi
|
|
artifactApply "$ids"
|
|
}
|