LibrePortal/scripts/cli/commands/artifact/cli_artifact_commands.sh
librelad 2df4e28a85 feat(distribution): Phase 2 — artifact apply/revert pipeline + ops interpreter
The mutating side of the unified distribution primitive (spec §8.3). Hotfixes
can now be applied and reverted, first-party, through the task system.

New scripts/cli/commands/artifact/cli_artifact_apply.sh:
- artifactApply <id>: resolve+gate (applies_when / min_lp / max_lp /
  max_footprint / publishers-map role) → fetch+verify payload (sha256 pinned by
  the signed index + minisig) → dry-precheck ALL ops (all-or-nothing) → best-
  effort snapshot → apply each op recording a precise inverse → bring app up →
  auto-rollback (replay undo LIFO, snapshot fallback) → applied-record + History.
- artifactRevert <id>: replay the applied-record's undo log (LIFO).
- Bounded, CLOSED op vocabulary (no run-script/exec, ever): set-config-key,
  set-compose-image, patch-file-if-checksum-matches, set-data-file. An
  unsupported op rejects the whole artifact at precheck (fail-closed).
- Write-target firewall: scope:app → containers/<app>/ only; scope:system →
  configs/ only; the install tree (our code) is off-limits to hotfixes (fork 1).
  Drift guards (expect_current / checksum) skip cleanly rather than clobber.
- Two-tier trust: index minisig-verified vs the footprint key (lpFetchIndex)
  covers the envelope; payload sha256-pinned + minisig-verified; publishers-map
  role gate (a non-official publisher can't claim official). Community per-
  artifact-key sigs are gated off until that tier is enabled.

cli_artifact_commands.sh: apply/revert via the task system (artifact_apply /
artifact_revert types — no allowlist needed), + read-only `applied` list.

cli_updater_commands.sh:
- FIX verified safety bug: updaterApplyApp/RollbackApp called `libreportal backup
  app "$app"` and `... restore latest`, which parse the app name as the ACTION,
  hit the dispatcher's `*)` default (exits 0) — so updates ran with NO snapshot
  and rollback was a silent no-op. Call backupAppStart / restoreAppStart directly.
- FIX updaterRecordHistory jq-silent-skip: was `command -v jq || return 0`
  (silently dropped the audit entry). Now fail-closed with a brace-agnostic
  bash-native prepend fallback; extended with artifact_id/serial/undo_id.

fetch.sh: add _lpJsonEsc (shared JSON-escape for the jq-free fallbacks).
Regenerated source arrays + lazy-load manifest for the new file/functions.

Unit-tested 31/31: every op apply+precheck+undo round-trip, the path-allowlist
firewall (incl. .. traversal + install-tree + cross-app rejection), all-or-
nothing abort, unsupported-op rejection, and the History bash-native fallback
(records + preserves prior entries without jq). A full signed-apply e2e needs
minisign + the signing key (Phase 5 make_hotfix.sh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 20:01:11 +01:00

124 lines
4.7 KiB
Bash

#!/bin/bash
# Artifact command handler — `libreportal artifact <sub>`
# ---------------------------------------------------------------------------
# Dispatched automatically by cli_initialize.sh (category -> cliHandleArtifactCommands).
#
# The unified distribution primitive. The READ side (`index`/`applied`) fetches +
# verifies the team-signed artifact index (hotfixes today; apps/themes/components
# later — all one envelope) and lists what's available/applied; it changes nothing,
# so — like `updater check` — it runs directly. The state-changing `apply`/`revert`
# verbs route through the TASK system (snapshot → bounded declarative ops →
# auto-rollback → History), never a mutating API. See docs/roadmap/updates-and-
# distribution.md and cli_artifact_apply.sh.
cliHandleArtifactCommands()
{
local sub="$initial_command2"
local id="$initial_command3"
# Lazy-loader gap: ensure the read primitives are defined. These are new
# files; the array/manifest regen self-heals them on deploy, but this covers
# the window before that (mirrors cli_updater_commands.sh sourcing its
# generator). artifacts.sh leans on fetch.sh helpers, so load both.
if ! declare -F lpFetchIndex >/dev/null 2>&1; then
source "$install_scripts_dir/source/fetch.sh" 2>/dev/null
source "$install_scripts_dir/source/artifacts.sh" 2>/dev/null
fi
# The apply pipeline lives in a sibling file — source it on demand too.
if ! declare -F artifactApply >/dev/null 2>&1; then
source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null
fi
case "$sub" in
""|"index"|"list")
artifactListIndex
;;
"applied")
artifactListApplied
;;
"apply")
if [[ -z "$id" ]]; then isError "Usage: libreportal artifact apply <id>"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
artifactApply "$id"
else
cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" ""
fi
;;
"revert"|"rollback")
if [[ -z "$id" ]]; then isError "Usage: libreportal artifact revert <id>"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
artifactRevert "$id"
else
cliTaskRun "libreportal artifact revert $id" "artifact_revert" "$id" ""
fi
;;
*)
cliShowArtifactHelp
;;
esac
}
# List the applied hotfixes (read-only) from the per-id applied records.
artifactListApplied()
{
isHeader "Applied hotfixes"
local dir; dir="${containers_dir%/}/libreportal/frontend/data/updater/generated/applied"
if ! compgen -G "$dir/*.json" >/dev/null 2>&1; then
isSuccessful "0 hotfixes applied."
return 0
fi
local n=0 f id title app
for f in "$dir"/*.json; do
n=$((n+1))
if command -v jq >/dev/null 2>&1; then
id="$(jq -r '.id' "$f" 2>/dev/null)"; title="$(jq -r '.title // ""' "$f" 2>/dev/null)"; app="$(jq -r '.app // ""' "$f" 2>/dev/null)"
echo "$id${app:+ [$app]}$title"
else
echo "$(basename "$f" .json)"
fi
done
isSuccessful "$n hotfix(es) applied. Revert with: libreportal artifact revert <id>"
}
# Fetch + verify the signed index and print a human summary. Read-only.
artifactListIndex()
{
isHeader "Artifact index ($(lpReleaseChannel))"
local json
if ! json="$(lpFetchIndex)"; then
isError "Could not fetch or verify the artifact index from $(lpArtifactIndexUrl)."
isNotice "Nothing is published yet, or the channel is unreachable. (This is expected before the first index ships.)"
return 1
fi
local serial generated_at
serial="$(_lpJsonNum "$json" index_serial)"
generated_at="$(lpIndexTop generated_at "$json")"
isNotice "Signed + verified. serial=${serial:-?} generated=${generated_at:-?}"
local ids; ids="$(lpIndexArtifactIds "$json")"
if [[ -z "$ids" ]]; then
isSuccessful "0 artifacts available — the index is empty (nothing to apply)."
return 0
fi
local n=0 id obj title type sev
while IFS= read -r id; do
[[ -z "$id" ]] && continue
n=$((n + 1))
obj="$(lpArtifactById "$json" "$id")"
if [[ -n "$obj" ]]; then
title="$(_lpJsonStr "$obj" title)"
type="$(_lpJsonStr "$obj" type)"
sev="$(_lpJsonStr "$obj" severity)"
echo " • [${type:-?}/${sev:-info}] $id${title:-}"
else
echo "$id"
fi
done <<< "$ids"
isSuccessful "$n artifact(s) available."
}