The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.
Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.
Three places needed judgement rather than substitution:
db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.
instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.
peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.
Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
881 lines
47 KiB
Bash
881 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 "$(webuiDir)/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 "$(appDir "$_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 -- "$(appDir "$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="$(appDir "$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="$(appDir "$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="$(webuiDir)/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 "$(appDir "$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="$(appDir "$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="$(appDir "$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="$(appDir "$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 "$(appDir "$app")" ]] && continue
|
|
# --detach: this runs inside the task processor's own poll, so following
|
|
# the new task in the foreground would wait for a task the (single-
|
|
# threaded) processor can't start until we return.
|
|
cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" "--detach"
|
|
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"
|
|
}
|