fix(wake): #913 installer adoption gaps — _lib dep-check fail-loud + mosaic-wake.service systemd-search-path link+validate (#930)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: jason.woltje <jason@diversecanvas.com>
Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #930.
This commit is contained in:
2026-07-26 12:37:29 +00:00
committed by Mos
parent 347c1d57c1
commit 0ea41e848b
3 changed files with 232 additions and 6 deletions

View File

@@ -98,7 +98,7 @@
# seen-ledger nor observed_seq, so the source is re-enumerated next # seen-ledger nor observed_seq, so the source is re-enumerated next
# cycle — no obligation loss). Files changed: _wake-common.sh, # cycle — no obligation loss). Files changed: _wake-common.sh,
# store.sh, detector.sh (+ tests). # 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 # cure-verification follow-up on #920/PR #922). The #920 per-entry
# quarantine alarm was stderr/journal-LOCAL only; a dead-lettered # quarantine alarm was stderr/journal-LOCAL only; a dead-lettered
# entry is STORE-ACCOUNTED (§2.3) so the reconciler never re-flags # 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 # wedge — #920's core property is preserved). digest.sh is the
# ONLY file changed; store.sh/beacon.sh/reconcile.sh are # ONLY file changed; store.sh/beacon.sh/reconcile.sh are
# UNCHANGED (beacon.sh's adapter contract is reused, not modified). # 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 component=wake
version=0.6.6 version=0.6.7
# Watch-list schema this component consumes, and the INCLUSIVE range of # Watch-list schema this component consumes, and the INCLUSIVE range of
# schema_version values it supports. A wake-watch-list.json whose schema_version # 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 # timer, the snapshot-guard (no reap without a snapshot), and
# fail-closed alarm-target + HMAC-key install-validation (the # fail-closed alarm-target + HMAC-key install-validation (the
# installer wires + install-validates the beacon target that # 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 # Companion (framework subtree, not under tools/wake/): systemd/user/mosaic-wake.service
# — the long-lived detector daemon unit (per-class SLO lives in # — the long-lived detector daemon unit (per-class SLO lives in
# the daemon, NOT a systemd interval). (W7) # the daemon, NOT a systemd interval). (W7)

View File

@@ -43,6 +43,12 @@ command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not available" >&2; exit 0; }
TMP_ROOT="$(mktemp -d)" TMP_ROOT="$(mktemp -d)"
trap 'rm -rf "$TMP_ROOT"' EXIT 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="$TMP_ROOT/failures"
: >"$FAILFILE" : >"$FAILFILE"
pass=0 pass=0
@@ -248,6 +254,67 @@ else
) && ok ) && ok
fi 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 echo
if [ -s "$FAILFILE" ]; then if [ -s "$FAILFILE" ]; then
echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2 echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2

View File

@@ -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. # 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}" WAKE_SNAPSHOT_DIR="${WAKE_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake/unit-snapshots}"
# shellcheck source=../_lib/manifest.sh disable=SC1091 # systemd user unit NAME this installer deploys + links (overridable for the harness).
. "$SCRIPT_DIR/../_lib/manifest.sh" 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 if [[ -t 2 ]]; then
_C_G='\033[0;32m' _C_Y='\033[0;33m' _C_R='\033[0;31m' _C_0='\033[0m' _C_G='\033[0;32m' _C_Y='\033[0;33m' _C_R='\033[0;31m' _C_0='\033[0m'
else 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_warn() { printf " ${_C_Y}WARN${_C_0} %s\n" "$1" >&2; }
wi_fail() { printf " ${_C_R}FAIL${_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 <framework>/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 # (i) Idempotent component-manifest install + Gate A
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@@ -128,10 +155,106 @@ wi_install() {
done < <(_wi_component_candidates) done < <(_wi_component_candidates)
wi_ok "wake component install: $written written, $skipped unchanged, $missing not-shipped (Gate A: all writes framework-owned)." 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). # Machine-readable summary for the harness (idempotency assertion keys off it).
printf 'wake-install: written=%s skipped=%s missing=%s\n' "$written" "$skipped" "$missing" 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/<unit>) 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) # (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 cmd="${1:-}"; shift || true
case "$cmd" in case "$cmd" in
install) wi_install "$@" ;; install) wi_install "$@" ;;
link-systemd-unit) wi_link_systemd_unit "$@" ;;
validate-systemd-path) wi_validate_systemd_path "$@" ;;
validate-targets) wi_validate_targets "$@" ;; validate-targets) wi_validate_targets "$@" ;;
validate-hmac-key) wi_validate_hmac_key "$@" ;; validate-hmac-key) wi_validate_hmac_key "$@" ;;
validate-alarm-target) wi_validate_alarm_target "$@" ;; validate-alarm-target) wi_validate_alarm_target "$@" ;;
@@ -422,7 +547,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
Usage: wake-install.sh <command> [args] Usage: wake-install.sh <command> [args]
Commands: 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-targets Fail-closed HMAC-key + alarm-target validate (v).
validate-hmac-key HMAC key resolves by-name, else fail loud. validate-hmac-key HMAC key resolves by-name, else fail loud.
validate-alarm-target Alarm sink configured + reachable, else fail loud. validate-alarm-target Alarm sink configured + reachable, else fail loud.