|
|
|
|
@ -69,11 +69,9 @@ _artifactRmFile() {
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
# 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.
|
|
|
|
|
# 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.)
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
_artifactResolve() {
|
|
|
|
|
local id="$1"
|
|
|
|
|
@ -83,10 +81,9 @@ _artifactResolve() {
|
|
|
|
|
[[ -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
|
|
|
|
|
if [[ "$type" != "hotfix" ]]; then
|
|
|
|
|
isError "artifact: type '$type' is not supported by this build (only 'hotfix')."; return 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# --- trust: publishers map + role gate ---
|
|
|
|
|
local pub trust role
|
|
|
|
|
@ -109,14 +106,7 @@ _artifactResolve() {
|
|
|
|
|
_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
|
|
|
|
|
if [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then
|
|
|
|
|
isNotice "artifact: '$id' targets '$_ART_APP' which is not installed -- not applicable."; return 2
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
@ -138,66 +128,43 @@ _artifactResolve() {
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
_ART_JSON="$art"
|
|
|
|
|
printf '%s' "$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.
|
|
|
|
|
# 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.
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
_artifactDownloadVerified() {
|
|
|
|
|
local url="$1" want_sha="$2" sig_url="$3" dest="$4"
|
|
|
|
|
local base got pstate sf="$dest.minisig"
|
|
|
|
|
base="$(lpReleaseBaseUrl)"
|
|
|
|
|
_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")"
|
|
|
|
|
[[ -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" "$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 ! _lpDownload "$url" "$pf" 2>/dev/null; then isError "artifact: payload download failed."; rm -rf "$tmp"; return 1; fi
|
|
|
|
|
got="$(_lpSha256 "$pf")"
|
|
|
|
|
if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH -- refusing."; rm -rf "$tmp"; return 1; fi
|
|
|
|
|
if [[ -n "$sig_url" ]]; then
|
|
|
|
|
case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac
|
|
|
|
|
_lpDownload "$sig_url" "$sf" 2>/dev/null || true
|
|
|
|
|
fi
|
|
|
|
|
pstate="$(lpVerifyMinisig "$dest" "$sf")" || return 1
|
|
|
|
|
pstate="$(lpVerifyMinisig "$pf" "$sf")" || { rm -rf "$tmp"; return 1; }
|
|
|
|
|
if [[ "$pstate" != "verified" ]]; then
|
|
|
|
|
isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; return 1
|
|
|
|
|
isError "artifact: refusing to APPLY an unsigned payload (signing not activated / footprint key missing)."; rm -rf "$tmp"; 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
|
|
|
|
|
@ -226,209 +193,6 @@ _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.
|
|
|
|
|
@ -561,35 +325,6 @@ _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
|
|
|
|
|
@ -649,16 +384,13 @@ artifactApply() {
|
|
|
|
|
_artifactNeedJq || return 1
|
|
|
|
|
[[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; }
|
|
|
|
|
|
|
|
|
|
isHeader "Applying artifact: $id"
|
|
|
|
|
isHeader "Applying hotfix: $id"
|
|
|
|
|
|
|
|
|
|
# 0. RESOLVE + gates (direct call -- the resolve globals + sigstate must
|
|
|
|
|
# land in THIS shell, not a substitution subshell)
|
|
|
|
|
# 0. RESOLVE + gates
|
|
|
|
|
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; }
|
|
|
|
|
art="$(_artifactResolve "$id")"; rc=$?
|
|
|
|
|
if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "hotfix" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi
|
|
|
|
|
[[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "hotfix" "" "$id" "rejected" "$id" "" ""; return 1; }
|
|
|
|
|
|
|
|
|
|
local app="$_ART_APP" scope="$_ART_SCOPE" serial title
|
|
|
|
|
serial="$(_lpJsonNum "$_ART_INDEX" index_serial)"
|
|
|
|
|
@ -669,25 +401,10 @@ 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" "${_ART_TYPE:-hotfix}" "" "$id" "refused-unsigned" "$id" "$serial" ""
|
|
|
|
|
updaterRecordHistory "$app" "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
|
|
|
|
|
isNotice "$title (scope=$scope${app:+, app=$app})"
|
|
|
|
|
|
|
|
|
|
# 4. PAYLOAD (fetch + sha256-pin + minisig-verify; refuses unsigned)
|
|
|
|
|
local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; }
|
|
|
|
|
@ -779,9 +496,8 @@ 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")"
|
|
|
|
|
|
|
|
|
|
@ -791,21 +507,17 @@ artifactRevert() {
|
|
|
|
|
_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
|
|
|
|
|
if [[ -n "$app" ]]; then dockerComposeUp "$app" >/dev/null 2>&1 || true
|
|
|
|
|
else declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true; fi
|
|
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" ""
|
|
|
|
|
isSuccessful "Hotfix $id reverted."
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
updaterRecordHistory "$app" "$rtype" "$id" "" "revert-incomplete" "$id" "$serial" "$id"
|
|
|
|
|
updaterRecordHistory "$app" "hotfix" "$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
|
|
|
|
|
}
|
|
|
|
|
@ -828,8 +540,6 @@ 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
|
|
|
|
|
@ -853,25 +563,3 @@ 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"
|
|
|
|
|
}
|
|
|
|
|
|