feat(artifact): app bundle applier + libreportal app add — the marketplace verb

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>
This commit is contained in:
librelad 2026-07-03 21:14:14 +01:00
parent e04fdbf64f
commit 4a1aa43083
5 changed files with 411 additions and 49 deletions

View File

@ -55,6 +55,35 @@ cliHandleAppCommands()
fi
;;
"add")
# Add an app DEFINITION from the signed registry catalog (the
# marketplace verb -- docs/roadmap/updates-and-distribution.md §8).
# Mutating, so it routes through the task system like install.
if [[ -z "$app_name" ]]; then isError "Usage: libreportal app add <app|artifact-id>"; return 1; fi
if [[ ! "$app_name" =~ ^[A-Za-z0-9._-]+$ ]]; then isError "app add: argument has unsafe characters"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
# Lazy-loader gap: the registry read + apply pipeline live in
# their own files; mirror the artifact handler's checked source.
if ! declare -F lpFetchIndex >/dev/null 2>&1; then
local _f
for _f in source/fetch.sh source/artifacts.sh; do
if [[ ! -f "$install_scripts_dir/$_f" ]] || ! source "$install_scripts_dir/$_f"; then
isError "app add: failed to load the read pipeline ($_f) — try: libreportal regen"; return 1
fi
done
fi
if ! declare -F appAddFromRegistry >/dev/null 2>&1; then
local _af="cli/commands/artifact/cli_artifact_apply.sh"
if [[ ! -f "$install_scripts_dir/$_af" ]] || ! source "$install_scripts_dir/$_af"; then
isError "app add: failed to load the apply pipeline ($_af) — try: libreportal regen"; return 1
fi
fi
appAddFromRegistry "$app_name"
else
cliTaskRun "libreportal app add $app_name" "app_add" "$app_name" ""
fi
;;
"uninstall")
# Optional `--delete-images` flag (in any of the trailing
# positions) tells the uninstall to also remove the app's

View File

@ -16,6 +16,9 @@ cliShowAppHelp()
echo " - Install / reinstall the specified app."
echo " On reinstall, IPs and ports are preserved by default."
echo " Pass --reset-network to re-randomize them."
echo " libreportal app add [name*] - Add an app definition from the signed registry"
echo " catalog (marketplace). It then installs like any"
echo " other app. Browse: libreportal artifact index"
echo " libreportal app uninstall [name*] - Uninstall the specified app"
echo ""
echo " libreportal app start [name*] - Start the specified app (Must be installed)"

View File

@ -69,9 +69,11 @@ _artifactRmFile() {
# ----------------------------------------------------------------------------
# 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.
# (LP_INDEX_SIGSTATE is set by lpFetchIndex; artifactApply enforces it.)
# 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"
@ -81,9 +83,10 @@ _artifactResolve() {
[[ -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
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
@ -106,7 +109,14 @@ _artifactResolve() {
_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
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
@ -128,43 +138,66 @@ _artifactResolve() {
fi
fi
printf '%s' "$art"
_ART_JSON="$art"
}
# ----------------------------------------------------------------------------
# PAYLOAD -- download, sha256-pin (from the signed index), minisig-verify.
# REFUSES an unsigned payload (signing-not-activated) on the apply path.
# Echoes the verified payload JSON; non-zero on any failure.
# 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.
# ----------------------------------------------------------------------------
_artifactFetchPayload() {
local art="$1" base channel url want_sha sig_url tmp pf sf got kind pstate
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")"
_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; }
tmp="$(mktemp -d)"; pf="$tmp/payload.json"; sf="$tmp/payload.sig"
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 ! _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 "$pf" "$sf")" || { rm -rf "$tmp"; return 1; }
pstate="$(lpVerifyMinisig "$dest" "$sf")" || return 1
if [[ "$pstate" != "verified" ]]; then
isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; rm -rf "$tmp"; return 1
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
@ -193,6 +226,209 @@ _artifactComposeImage() {
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.
@ -325,6 +561,35 @@ _artifactOpUndo() {
_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
@ -384,13 +649,16 @@ artifactApply() {
_artifactNeedJq || return 1
[[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; }
isHeader "Applying hotfix: $id"
isHeader "Applying artifact: $id"
# 0. RESOLVE + gates
# 0. RESOLVE + gates (direct call -- the resolve globals + sigstate must
# land in THIS shell, not a substitution subshell)
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; }
_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)"
@ -401,10 +669,25 @@ artifactApply() {
# must not.
if [[ "$LP_INDEX_SIGSTATE" != "verified" ]]; then
isError "artifact: the index is not signature-verified (signing not activated) -- refusing to APPLY."
updaterRecordHistory "$app" "hotfix" "" "$id" "refused-unsigned" "$id" "$serial" ""
updaterRecordHistory "$app" "${_ART_TYPE:-hotfix}" "" "$id" "refused-unsigned" "$id" "$serial" ""
return 1
fi
isNotice "$title (scope=$scope${app:+, app=$app})"
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; }
@ -496,8 +779,9 @@ artifactRevert() {
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 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")"
@ -507,17 +791,21 @@ artifactRevert() {
_artifactReplayUndoFile "$undo_file" "$app" "reverse" || rv_rc=1
rm -f "$undo_file"
if [[ -n "$app" ]]; then dockerComposeUp "$app" >/dev/null 2>&1 || true
# 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" "hotfix" "$id" "" "reverted" "$id" "$serial" ""
isSuccessful "Hotfix $id reverted."
updaterRecordHistory "$app" "$rtype" "$id" "" "reverted" "$id" "$serial" ""
isSuccessful "${rtype^} $id reverted."
return 0
fi
updaterRecordHistory "$app" "hotfix" "$id" "" "revert-incomplete" "$id" "$serial" "$id"
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
}
@ -540,6 +828,8 @@ artifactApplyAuto() {
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
@ -563,3 +853,25 @@ artifactApplyAuto() {
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"
}

View File

@ -9,16 +9,16 @@ cliShowArtifactHelp()
echo "Available Artifact Commands:"
echo ""
echo " libreportal artifact index - Fetch + verify the signed index; list what's available"
echo " libreportal artifact applied - List the hotfixes currently applied to this install"
echo " libreportal artifact apply <id> - Apply a hotfix (snapshot + reversible declarative ops)"
echo " libreportal artifact revert <id> - Revert a previously-applied hotfix (replays its undo log)"
echo " libreportal artifact applied - List the artifacts currently applied to this install"
echo " libreportal artifact apply <id> - Apply an artifact (reversible; hotfix ops or app bundle)"
echo " libreportal artifact revert <id> - Revert a previously-applied artifact (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, 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 "reversibly — a hotfix (bounded declarative ops, no scripts) or an app"
echo "definition bundle from the registry catalog (themes / components later)."
echo "They share one team-signed catalog (index.json) on the same channel as the"
echo "version check, verified against the root-owned signing key. apply/revert run"
echo "through the task system (never a mutating API), record a precise undo, and"
echo "auto-roll-back on failure. See docs/roadmap/updates-and-distribution.md."
echo ""
}

View File

@ -15,6 +15,7 @@ declare -gA LP_FN_MAP=(
[adguard_install_message_data]="adguard/scripts/adguard_install_hooks.sh"
[adguard_install_post_start]="adguard/scripts/adguard_install_hooks.sh"
[adoptDockerSubnet]="checks/requirements/check_docker_network.sh"
[appAddFromRegistry]="cli/commands/artifact/cli_artifact_apply.sh"
[appAdguardApplyDnsUpdater]="adguard/tools/adguard_apply_dns_updater.sh"
[appAdguardResetPassword]="adguard/tools/adguard_reset_password.sh"
[appBookstackCreateAccount]="bookstack/tools/bookstack_create_account.sh"
@ -104,7 +105,12 @@ declare -gA LP_FN_MAP=(
[_artifactAppliedDir]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactApply]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactApplyAuto]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactApplyBundleFlow]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactBundleOwner]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactBundleValidate]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactComposeImage]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactDownloadVerified]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactFetchBundle]="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"
@ -974,6 +980,7 @@ declare -gA LP_FN_ROOT=(
[adguard_install_message_data]="containers"
[adguard_install_post_start]="containers"
[adoptDockerSubnet]="scripts"
[appAddFromRegistry]="scripts"
[appAdguardApplyDnsUpdater]="containers"
[appAdguardResetPassword]="containers"
[appBookstackCreateAccount]="containers"
@ -1063,7 +1070,12 @@ declare -gA LP_FN_ROOT=(
[_artifactAppliedDir]="scripts"
[artifactApply]="scripts"
[artifactApplyAuto]="scripts"
[_artifactApplyBundleFlow]="scripts"
[_artifactBundleOwner]="scripts"
[_artifactBundleValidate]="scripts"
[_artifactComposeImage]="scripts"
[_artifactDownloadVerified]="scripts"
[_artifactFetchBundle]="scripts"
[_artifactFetchPayload]="scripts"
[_artifactGenDir]="scripts"
[artifactListApplied]="scripts"
@ -1954,6 +1966,7 @@ acquireSingletonLock() { source "${install_scripts_dir}task/crontab_task_process
adguard_install_message_data() { source "${install_containers_dir}adguard/scripts/adguard_install_hooks.sh"; adguard_install_message_data "$@"; }
adguard_install_post_start() { source "${install_containers_dir}adguard/scripts/adguard_install_hooks.sh"; adguard_install_post_start "$@"; }
adoptDockerSubnet() { source "${install_scripts_dir}checks/requirements/check_docker_network.sh"; adoptDockerSubnet "$@"; }
appAddFromRegistry() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; appAddFromRegistry "$@"; }
appAdguardApplyDnsUpdater() { source "${install_containers_dir}adguard/tools/adguard_apply_dns_updater.sh"; appAdguardApplyDnsUpdater "$@"; }
appAdguardResetPassword() { source "${install_containers_dir}adguard/tools/adguard_reset_password.sh"; appAdguardResetPassword "$@"; }
appBookstackCreateAccount() { source "${install_containers_dir}bookstack/tools/bookstack_create_account.sh"; appBookstackCreateAccount "$@"; }
@ -2043,7 +2056,12 @@ appWebuiRefresh_gluetun() { source "${install_containers_dir}gluetun/scripts/glu
_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 "$@"; }
artifactApplyAuto() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactApplyAuto "$@"; }
_artifactApplyBundleFlow() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactApplyBundleFlow "$@"; }
_artifactBundleOwner() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactBundleOwner "$@"; }
_artifactBundleValidate() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactBundleValidate "$@"; }
_artifactComposeImage() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactComposeImage "$@"; }
_artifactDownloadVerified() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactDownloadVerified "$@"; }
_artifactFetchBundle() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactFetchBundle "$@"; }
_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 "$@"; }