diff --git a/packages/mosaic/framework/tools/wake/manifest.txt b/packages/mosaic/framework/tools/wake/manifest.txt index 58f18388..e15e2c56 100644 --- a/packages/mosaic/framework/tools/wake/manifest.txt +++ b/packages/mosaic/framework/tools/wake/manifest.txt @@ -98,7 +98,7 @@ # seen-ledger nor observed_seq, so the source is re-enumerated next # cycle — no obligation loss). Files changed: _wake-common.sh, # store.sh, detector.sh (+ tests). -# 0.6.6 #924 digest.sh dead-letter QUARANTINE alarm — G2a fix (dragon-lin +# 0.6.6 #924 digest.sh dead-letter QUARANTINE alarm — G2a fix (wake-pilot # cure-verification follow-up on #920/PR #922). The #920 per-entry # quarantine alarm was stderr/journal-LOCAL only; a dead-lettered # entry is STORE-ACCOUNTED (§2.3) so the reconciler never re-flags @@ -122,8 +122,34 @@ # wedge — #920's core property is preserved). digest.sh is the # ONLY file changed; store.sh/beacon.sh/reconcile.sh are # UNCHANGED (beacon.sh's adapter contract is reused, not modified). +# 0.6.7 #913 wake-install.sh installer ADOPTION-GAP fixes (wake-pilot, +# non-blocking, ADDITIVE per #869). (a) DEP-CHECK: the installer +# sourced _lib/manifest.sh (the shared framework-manifest reader it +# needs for Gate A) unconditionally, so an older host seed that +# predates that helper aborted with a bare, obscure +# `source: No such file or directory`. It now checks the library +# FIRST and FAILS LOUD naming the missing file + the remedy (re-seed +# the framework, then retry --component wake) — the dependency is +# genuinely required (Gate A cannot be skipped on an enforcement +# path), so it fails loud rather than degrading. (b) SYSTEMD SEARCH +# PATH: wi_install copies mosaic-wake.service under mosaic home +# (systemd/user/, framework-owned) but `systemctl --user` searches +# ~/.config/systemd/user/, so the unit was invisible and could not be +# enabled/started. install now LINKS the unit into the user systemd +# search path (symlink -> the mosaic-home SSOT copy, so upgrades +# propagate) and VALIDATES it resolves (search-path entry exists, +# dereferences to a readable, well-formed unit; an opportunistic +# `systemctl --user cat` probe runs only behind a guard, since the +# installer may run where no user manager is live). Both steps are +# idempotent (a re-install neither duplicates nor breaks the link). +# #869 ADDITIVE: the link target lives OUTSIDE mosaic home, so it is +# not a framework-manifest path; ownership of the SSOT unit stays +# systemd/** in the single framework-manifest.txt authority — NO new +# owned path, NO second ownership authority. framework-manifest.txt, +# the install-ordering-guard, and the manifest parity contract are all +# UNCHANGED. Only wake-install.sh (+ test-wake-install.sh) changed. component=wake -version=0.6.6 +version=0.6.7 # Watch-list schema this component consumes, and the INCLUSIVE range of # schema_version values it supports. A wake-watch-list.json whose schema_version @@ -183,7 +209,11 @@ schema_max=1 # timer, the snapshot-guard (no reap without a snapshot), and # fail-closed alarm-target + HMAC-key install-validation (the # installer wires + install-validates the beacon target that -# beacon.sh's fail-loud primitive is designed for). (W7) +# beacon.sh's fail-loud primitive is designed for). Fails loud +# if the required _lib/manifest.sh helper is absent (older host +# seed) instead of a bare source error, and LINKS the unit into +# the user systemd search path + post-install-validates it +# resolves (#913). (W7, #913) # Companion (framework subtree, not under tools/wake/): systemd/user/mosaic-wake.service # — the long-lived detector daemon unit (per-class SLO lives in # the daemon, NOT a systemd interval). (W7) diff --git a/packages/mosaic/framework/tools/wake/test-wake-install.sh b/packages/mosaic/framework/tools/wake/test-wake-install.sh index 3d89e4ef..0df05fe3 100755 --- a/packages/mosaic/framework/tools/wake/test-wake-install.sh +++ b/packages/mosaic/framework/tools/wake/test-wake-install.sh @@ -43,6 +43,12 @@ command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not available" >&2; exit 0; } TMP_ROOT="$(mktemp -d)" trap 'rm -rf "$TMP_ROOT"' EXIT +# Isolation default: no group may ever touch the operator's REAL user systemd dir +# (~/.config/systemd/user). The (b/#913) search-path link step defaults there, so +# pin an isolated per-run dir here; groups that need their own re-export it. +export WAKE_SYSTEMD_USER_DIR="$TMP_ROOT/systemd-user-default" +mkdir -p "$WAKE_SYSTEMD_USER_DIR" + FAILFILE="$TMP_ROOT/failures" : >"$FAILFILE" pass=0 @@ -248,6 +254,67 @@ else ) && ok fi +# ── (a #913) missing _lib/manifest.sh dependency — FAIL LOUD, not a bare source error ── + +echo "== I9: dep-check — a host seed missing _lib/manifest.sh FAILS LOUD (names dep + remedy), not a bare source error ==" +( + # Simulate an OLDER host seed that predates the shared manifest reader: copy the + # framework tree the installer needs, but OMIT tools/_lib/manifest.sh. Running the + # copied wake-install.sh reproduces the adoption gap on a not-yet-updated host. + FAKE="$(fresh i9-fw)" + mkdir -p "$FAKE/tools/wake" "$FAKE/tools/_lib" "$FAKE/systemd/user" + cp "$FRAMEWORK_ROOT/tools/wake/"* "$FAKE/tools/wake/" 2>/dev/null || true + # Copy _lib EXCEPT manifest.sh — the exact file older seeds lack. + for f in "$FRAMEWORK_ROOT/tools/_lib/"*; do + [ -e "$f" ] || continue + case "$(basename "$f")" in manifest.sh) continue ;; esac + cp "$f" "$FAKE/tools/_lib/" 2>/dev/null || true + done + cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake.service" "$FAKE/systemd/user/" 2>/dev/null || true + cp "$FRAMEWORK_ROOT/framework-manifest.txt" "$FAKE/" 2>/dev/null || true + [ ! -e "$FAKE/tools/_lib/manifest.sh" ] || fail_msg "I9: fixture setup — manifest.sh must be absent to simulate an older seed" + WI_FAKE="$FAKE/tools/wake/wake-install.sh" + TGT="$(fresh i9-target)" + out="$(WAKE_INSTALL_SOURCE="$FAKE" WAKE_INSTALL_TARGET="$TGT" bash "$WI_FAKE" install 2>&1)"; rc=$? + [ "$rc" -ne 0 ] || fail_msg "I9: install MUST fail when _lib/manifest.sh is missing (rc=$rc)" + echo "$out" | grep -qi 'manifest.sh' || fail_msg "I9: the failure must NAME the missing library (manifest.sh) [$out]" + echo "$out" | grep -qiE 'REMEDY|mosaic update|re-seed|framework install' \ + || fail_msg "I9: the failure must give an actionable REMEDY, not just an error [$out]" + # It must be a CLEAR fail-loud, NOT the obscure bare `source: No such file or directory`. + echo "$out" | grep -qi 'No such file or directory' \ + && fail_msg "I9: must not fail with a bare source error (obscure) [$out]" + # Positive control: with the helper present (real framework root) install succeeds. + TGT_OK="$(fresh i9-target-ok)" + WAKE_INSTALL_SOURCE="$FRAMEWORK_ROOT" WAKE_INSTALL_TARGET="$TGT_OK" \ + bash "$WI" install >/dev/null 2>&1 || fail_msg "I9: with _lib/manifest.sh present the install must succeed" +) && ok + +# ── (b #913) mosaic-wake.service lands IN the user systemd search path + validates ── + +echo "== I10: systemd search-path — install links mosaic-wake.service into the search path; validate catches a miss; idempotent ==" +( + TGT="$(fresh i10-target)" + UD="$(fresh i10-systemd)" # isolated stand-in for ~/.config/systemd/user + export WAKE_INSTALL_SOURCE="$FRAMEWORK_ROOT" + export WAKE_INSTALL_TARGET="$TGT" + export WAKE_SYSTEMD_USER_DIR="$UD" + bash "$WI" install >/dev/null 2>&1 || fail_msg "I10: install must succeed" + # (a) the unit is resolvable in the user systemd search path. + [ -e "$UD/mosaic-wake.service" ] || fail_msg "I10: mosaic-wake.service must be linked into the user systemd search path ($UD)" + out="$(bash "$WI" validate-systemd-path 2>&1)"; rc=$? + [ "$rc" -eq 0 ] || fail_msg "I10: post-install validate must confirm the unit resolves (rc=$rc) [$out]" + # (b) NEGATIVE CONTROL: not-in-search-path is CAUGHT (fail loud, names the miss). + rm -f "$UD/mosaic-wake.service" + out2="$(bash "$WI" validate-systemd-path 2>&1)"; rc2=$? + [ "$rc2" -ne 0 ] || fail_msg "I10: validate must FAIL when the unit is not in the search path (rc=$rc2) [$out2]" + echo "$out2" | grep -qi 'search path' || fail_msg "I10: validate failure must name the search-path miss [$out2]" + # (c) idempotent re-install: exactly one search-path entry, still resolvable. + bash "$WI" install >/dev/null 2>&1 || fail_msg "I10: re-run install must succeed" + n="$(find "$UD" -maxdepth 1 -name 'mosaic-wake.service' | grep -c .)" + [ "$n" -eq 1 ] || fail_msg "I10: re-install must not duplicate the search-path entry (found $n)" + bash "$WI" validate-systemd-path >/dev/null 2>&1 || fail_msg "I10: re-install must keep the unit resolvable" +) && ok + echo if [ -s "$FAILFILE" ]; then echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2 diff --git a/packages/mosaic/framework/tools/wake/wake-install.sh b/packages/mosaic/framework/tools/wake/wake-install.sh index 948d0666..0253ccb7 100755 --- a/packages/mosaic/framework/tools/wake/wake-install.sh +++ b/packages/mosaic/framework/tools/wake/wake-install.sh @@ -49,9 +49,14 @@ WAKE_SYSTEMD_USER_DIR="${WAKE_SYSTEMD_USER_DIR:-$HOME/.config/systemd/user}" # Retained snapshot store for the snapshot-guard (iv) — outside any repo. WAKE_SNAPSHOT_DIR="${WAKE_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake/unit-snapshots}" -# shellcheck source=../_lib/manifest.sh disable=SC1091 -. "$SCRIPT_DIR/../_lib/manifest.sh" +# systemd user unit NAME this installer deploys + links (overridable for the harness). +WAKE_UNIT_NAME="${WAKE_UNIT_NAME:-mosaic-wake.service}" +# The shared framework-manifest reader this installer needs for Gate A ownership +# validation. Overridable so the red-first harness can point it at a missing path +# to prove the fail-loud without disturbing the real tree (#913a). +WAKE_MANIFEST_LIB="${WAKE_MANIFEST_LIB:-$SCRIPT_DIR/../_lib/manifest.sh}" +# ─── output helpers (defined BEFORE the dependency check so it can fail loud) ── if [[ -t 2 ]]; then _C_G='\033[0;32m' _C_Y='\033[0;33m' _C_R='\033[0;31m' _C_0='\033[0m' else @@ -61,6 +66,28 @@ wi_ok() { printf " ${_C_G}OK${_C_0} %s\n" "$1" >&2; } wi_warn() { printf " ${_C_Y}WARN${_C_0} %s\n" "$1" >&2; } wi_fail() { printf " ${_C_R}FAIL${_C_0} %s\n" "$1" >&2; } +# ─── required framework library — fail LOUD if a stale host seed lacks it (#913a) ── +# The wake component installer sources the shared framework-manifest reader +# (_lib/manifest.sh) for Gate A ownership validation. Host seeds that predate that +# helper would otherwise abort here with a bare, obscure +# `source: No such file or directory` and no guidance. This dependency is GENUINELY +# REQUIRED (Gate A cannot be skipped on an enforcement path), so fail LOUD — naming +# the missing file AND the remedy — instead of degrading: a not-yet-updated host +# must be told exactly how to become installable. +if [[ ! -r "$WAKE_MANIFEST_LIB" ]]; then + wi_fail "wake-install: required framework library missing — $WAKE_MANIFEST_LIB" + { + printf ' %s\n' "This host's framework seed predates the shared manifest reader (_lib/manifest.sh)" + printf ' %s\n' "that the wake component installer needs for Gate A ownership validation (#913)." + printf ' %s\n' "REMEDY: re-seed the framework first, then retry the component install:" + printf ' %s\n' " mosaic update # or: bash /install.sh" + printf ' %s\n' " install.sh --component wake" + } >&2 + exit 1 +fi +# shellcheck source=../_lib/manifest.sh disable=SC1091 +. "$WAKE_MANIFEST_LIB" + # ═══════════════════════════════════════════════════════════════════════════════ # (i) Idempotent component-manifest install + Gate A # ═══════════════════════════════════════════════════════════════════════════════ @@ -128,10 +155,106 @@ wi_install() { done < <(_wi_component_candidates) wi_ok "wake component install: $written written, $skipped unchanged, $missing not-shipped (Gate A: all writes framework-owned)." + + # (b #913) A10 canon step: place the deployed unit into the USER SYSTEMD SEARCH + # PATH and validate it resolves. wi_install copies the unit under mosaic home + # (systemd/user/, framework-owned) — but `systemctl --user` searches + # ~/.config/systemd/user/, so without this link the service can never be + # enabled/started. Both steps are idempotent; either failing fails the install. + wi_link_systemd_unit || return 1 + wi_validate_systemd_path || return 1 + # Machine-readable summary for the harness (idempotency assertion keys off it). printf 'wake-install: written=%s skipped=%s missing=%s\n' "$written" "$skipped" "$missing" } +# ═══════════════════════════════════════════════════════════════════════════════ +# (b #913) systemd user-search-path link + post-install validate +# ═══════════════════════════════════════════════════════════════════════════════ +# ADDITIVE / #869: the link target (~/.config/systemd/user/) lives OUTSIDE +# mosaic home, so it is not a framework-manifest path (the manifest is +# mosaic-home-relative). Ownership of the SSOT unit stays `systemd/**` in the single +# framework-manifest.txt authority — this step adds NO new owned path and creates NO +# second ownership authority. The link points BACK to the mosaic-home SSOT copy, so +# a later framework upgrade of the unit propagates with no re-link. + +# wi_link_systemd_unit — idempotently place the deployed wake unit into the user +# systemd search path so `systemctl --user` can resolve it. Symlinks to the +# mosaic-home SSOT copy installed by wi_install. Idempotent: an already-correct link +# (or a byte-identical regular file) is left untouched; a stale entry is replaced. +wi_link_systemd_unit() { + local unit="$WAKE_UNIT_NAME" + local ssot="$WAKE_INSTALL_TARGET/systemd/user/$unit" + local link="$WAKE_SYSTEMD_USER_DIR/$unit" + if [[ ! -f "$ssot" ]]; then + wi_fail "systemd-link — the deployed unit is missing at $ssot; run 'wake-install.sh install' first (fail-closed)." + return 1 + fi + mkdir -p "$WAKE_SYSTEMD_USER_DIR" + # Idempotent no-op if the search-path entry already resolves to the SSOT copy. + if [[ -L "$link" ]]; then + if [[ "$(readlink "$link")" == "$ssot" ]]; then + wi_ok "systemd-link — '$unit' already linked into the search path ($link -> $ssot)." + return 0 + fi + elif [[ -f "$link" ]] && cmp -s "$ssot" "$link"; then + wi_ok "systemd-link — '$unit' already present in the search path ($link, byte-identical)." + return 0 + fi + # Replace whatever is there (stale link / old copy) with a fresh symlink to SSOT. + if ! ln -sfn "$ssot" "$link"; then + wi_fail "systemd-link — could not link '$unit' into the user systemd search path ($link). FAIL LOUD (fail-closed)." + return 1 + fi + wi_ok "systemd-link — '$unit' linked into the user systemd search path ($link -> $ssot)." + return 0 +} + +# wi_validate_systemd_path — post-install validate that the unit RESOLVES in the +# user systemd search path. The installer may run where the user systemd manager is +# NOT live (containers, no login session), so the AUTHORITATIVE check is +# path + well-formedness: the search-path entry exists, dereferences to a readable +# file, and parses as a unit ([Unit]/[Service]/[Install] + an ExecStart). A live +# `systemctl --user cat` probe runs ONLY opportunistically behind a guard. +wi_validate_systemd_path() { + local unit="$WAKE_UNIT_NAME" + local link="$WAKE_SYSTEMD_USER_DIR/$unit" resolved + if [[ ! -e "$link" ]]; then + wi_fail "systemd-validate — '$unit' is NOT in the user systemd search path ($link). systemctl --user cannot resolve it, so the wake service cannot be enabled/started. Run 'wake-install.sh link-systemd-unit' (part of install) (fail-closed)." + return 1 + fi + if [[ -L "$link" ]]; then + resolved="$(readlink -f "$link" 2>/dev/null || true)" + else + resolved="$link" + fi + if [[ -z "$resolved" || ! -r "$resolved" ]]; then + wi_fail "systemd-validate — '$unit' search-path entry ($link) does not dereference to a readable unit file. FAIL LOUD (fail-closed)." + return 1 + fi + # Well-formed check: a wake detector unit must carry the core sections + ExecStart. + local -a miss=() + grep -q '^\[Unit\]' "$resolved" || miss+=("[Unit]") + grep -q '^\[Service\]' "$resolved" || miss+=("[Service]") + grep -q '^\[Install\]' "$resolved" || miss+=("[Install]") + grep -q '^ExecStart=' "$resolved" || miss+=("ExecStart=") + if [[ ${#miss[@]} -ne 0 ]]; then + wi_fail "systemd-validate — '$unit' ($resolved) is malformed: missing ${miss[*]}. Refusing to certify an ill-formed unit (fail-closed)." + return 1 + fi + # Opportunistic live probe ONLY when explicitly requested AND a user manager is + # reachable — never a hard requirement (the installer often runs without one). + if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then + if systemctl --user cat "$unit" >/dev/null 2>&1; then + wi_ok "systemd-validate — 'systemctl --user cat $unit' resolves (live user manager confirmed)." + else + wi_warn "systemd-validate — file is in the search path + well-formed, but 'systemctl --user cat $unit' did not resolve (no live user manager / not daemon-reloaded). Non-fatal: run 'systemctl --user daemon-reload' in a live session." + fi + fi + wi_ok "systemd-validate — '$unit' resolves in the user systemd search path ($link -> $resolved, well-formed)." + return 0 +} + # ═══════════════════════════════════════════════════════════════════════════════ # (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a) # ═══════════════════════════════════════════════════════════════════════════════ @@ -409,6 +532,8 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then cmd="${1:-}"; shift || true case "$cmd" in install) wi_install "$@" ;; + link-systemd-unit) wi_link_systemd_unit "$@" ;; + validate-systemd-path) wi_validate_systemd_path "$@" ;; validate-targets) wi_validate_targets "$@" ;; validate-hmac-key) wi_validate_hmac_key "$@" ;; validate-alarm-target) wi_validate_alarm_target "$@" ;; @@ -422,7 +547,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then Usage: wake-install.sh [args] Commands: - install Idempotent component-manifest install + Gate A. + install Idempotent component-manifest install + Gate A, + then link the unit into the user systemd search + path and validate it resolves (b, #913). + link-systemd-unit Link mosaic-wake.service into ~/.config/systemd/user/. + validate-systemd-path Validate the unit resolves in the user systemd search path. validate-targets Fail-closed HMAC-key + alarm-target validate (v). validate-hmac-key HMAC key resolves by-name, else fail loud. validate-alarm-target Alarm sink configured + reachable, else fail loud.