#!/usr/bin/env bash # wake-install.sh — A10 of the wake canon (EPIC #892, W7): the idempotent, # fail-closed COMPONENT INSTALLER for the wake component. # # This is the ENFORCEMENT-PATH installer (#869 publish-gate discipline): every # path is fail-closed, idempotent, and never silently falls through. It is driven # by `framework/install.sh --component wake`, and every subcommand is also invoked # directly by the red-first harness (test-wake-install.sh). # # CONTRACT ANCHORS (PACKAGING-PLAN §3/§5 + CONVERGED-DESIGN): # (i) Idempotent component install + Gate A. The wake component ships its own # manifest.txt as VERSION METADATA ONLY. Path-ownership stays the SINGLE # SSOT framework-manifest.txt (consumed by BOTH bash + TS). The component # file set is INTERSECTED-AND-VALIDATED against framework-manifest # ownership: the wake manifest MUST NOT independently authorize any # write/prune outside framework-manifest ownership. Re-running is a no-op. # (iii) Blank-reset idiom on the LEGACY mosaic-heartbeat@.timer cadence # drop-in during the §5 overlap->retire lifecycle: SNAPSHOT the legacy # unit; write any per-class fallback-cadence drop-in in the blank-reset # form (an empty OnUnitActiveSec= reset line before the new value, so # exactly ONE OnUnitActiveUSec results); on §4-vector pass, REMOVE the # legacy mosaic-heartbeat@ units (retire-LAST). Post-apply verify = exactly # one OnUnitActiveUSec. # (iv) snapshot-guard: block any reap / clean-checkout of a deployed unit # WITHOUT a snapshot first (the deployed-from-uncommitted failure class # must not recur). Fail-closed: no snapshot => refuse to reap. # (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a): the # operator's W6 alarm-sink target (resolved BY NAME via load_credentials) # must be CONFIGURED + reachable at install; the W3/W7 HMAC key must # resolve BY NAME. Missing/unreachable => FAIL LOUD. This installer ships # NO endpoint value and NO secret, and echoes neither. # # Operator-agnostic (framework firewall): no operator paths/names/secrets/hosts. # All state via XDG/env; the HMAC key + alarm endpoint are resolved BY NAME. set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ─── paths (all overridable so the harness can isolate every run) ──────────── # Source framework tree (the checkout being installed FROM). WAKE_INSTALL_SOURCE="${WAKE_INSTALL_SOURCE:-$(cd "$SCRIPT_DIR/../.." && pwd)}" # Target mosaic home (installed INTO). WAKE_INSTALL_TARGET="${WAKE_INSTALL_TARGET:-${MOSAIC_HOME:-$HOME/.config/mosaic}}" # framework-manifest.txt SSOT (Gate A). Override lets the harness prove a # de-authorizing manifest makes the install REFUSE (no write outside ownership). WAKE_INSTALL_MANIFEST="${WAKE_INSTALL_MANIFEST:-$WAKE_INSTALL_SOURCE/framework-manifest.txt}" # systemd user unit dir the legacy timer / new units are deployed under. 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}" # systemd user unit NAME this installer deploys + links (overridable for the harness). WAKE_UNIT_NAME="${WAKE_UNIT_NAME:-mosaic-wake.service}" # The framework-shipped canon FALLBACK WAKE units (F7, #925): a low-frequency # SAFETY drain (oneshot service) driven by a per-class cadence timer, INDEPENDENT # of the event-driven detector. Both are framework-owned via the existing # `systemd/**` glob in framework-manifest.txt (Gate A) — NO new owned path, NO # second ownership authority (#869 additive). Overridable for the harness. WAKE_FALLBACK_TIMER_NAME="${WAKE_FALLBACK_TIMER_NAME:-mosaic-wake-fallback.timer}" WAKE_FALLBACK_SERVICE_NAME="${WAKE_FALLBACK_SERVICE_NAME:-mosaic-wake-fallback.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 _C_G='' _C_Y='' _C_R='' _C_0='' fi 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 # ═══════════════════════════════════════════════════════════════════════════════ # The wake component's candidate file set, as source-relative paths. It is DERIVED # (from the shipped filesystem under tools/wake/ + the two cross-subtree files the # component owns) — the wake VERSION manifest authorizes NOTHING here. Every # candidate is intersected-and-validated against framework-manifest ownership in # wi_install before a single byte is written (Gate A). _wi_component_candidates() { local src="$WAKE_INSTALL_SOURCE" abs if [[ -d "$src/tools/wake" ]]; then while IFS= read -r -d '' abs; do printf '%s\n' "${abs#"$src"/}" done < <(find "$src/tools/wake" -type f -print0 | sort -z) fi # Cross-subtree files that logically belong to the wake component but live in # shared framework subtrees. Listed here for ENUMERATION only — ownership is # still decided solely by framework-manifest.txt in wi_install. printf '%s\n' "systemd/user/mosaic-wake.service" # The canon FALLBACK WAKE units (F7, #925). Both resolve framework-owned via the # existing `systemd/**` glob — enumerated here so wi_install COPIES them; still # Gate-A-validated against the framework-manifest SSOT before any byte is written. printf '%s\n' "systemd/user/$WAKE_FALLBACK_TIMER_NAME" printf '%s\n' "systemd/user/$WAKE_FALLBACK_SERVICE_NAME" printf '%s\n' "defaults/wake-watch-list.schema.json" } # wi_install — idempotent component install. Gate A: refuse to write any candidate # the framework-manifest SSOT does not own. Idempotent: an unchanged file is # skipped (no rewrite, no mtime churn), so a second run produces no diff. wi_install() { local src="$WAKE_INSTALL_SOURCE" dst="$WAKE_INSTALL_TARGET" # Load + validate the SSOT ownership manifest (fail-closed on missing/empty). manifest_load "$WAKE_INSTALL_MANIFEST" || { wi_fail "framework-manifest.txt failed to load ($WAKE_INSTALL_MANIFEST) — refusing to install (fail-closed)." return 1 } local rel written=0 skipped=0 missing=0 # PASS 1 — Gate A validation FIRST, before any write. If ANY candidate resolves # outside framework-manifest ownership, refuse the WHOLE install (no partial # write): the wake component must never author a write the SSOT does not own. while IFS= read -r rel; do [[ -n "$rel" ]] || continue if ! manifest_is_framework "$rel"; then wi_fail "Gate A VIOLATION — wake candidate '$rel' is NOT framework-owned per framework-manifest.txt. The wake component manifest must not authorize a write outside framework-manifest ownership. Refusing the entire component install (fail-closed)." return 1 fi done < <(_wi_component_candidates) # PASS 2 — copy. Every path is already proven framework-owned above. while IFS= read -r rel; do [[ -n "$rel" ]] || continue if [[ ! -f "$src/$rel" ]]; then # A cross-subtree candidate that isn't shipped in this source is not an # error (it may not exist yet); note and continue. tools/wake/** entries # always exist because they were enumerated from the filesystem. missing=$((missing + 1)) continue fi if [[ -f "$dst/$rel" ]] && cmp -s "$src/$rel" "$dst/$rel"; then skipped=$((skipped + 1)) continue fi [[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}" cp "$src/$rel" "$dst/$rel" case "$rel" in *.sh) chmod +x "$dst/$rel" 2>/dev/null || true ;; esac written=$((written + 1)) 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 # (#925) A10 canon step: place the canon FALLBACK WAKE units (timer + oneshot # service) into the user systemd search path and validate they resolve + parse. # Same link-back-to-SSOT, idempotent, fail-closed pattern as the detector unit # above. The per-class cadence drop-in (blank-reset) and the F7 proven-live gate # are separate steps (write-fallback-cadence / reset-verify-retire). wi_link_fallback_units || return 1 wi_validate_fallback_units || 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 } # ═══════════════════════════════════════════════════════════════════════════════ # (#925) canon FALLBACK WAKE units — link + validate + per-class cadence + F7 gate # ═══════════════════════════════════════════════════════════════════════════════ # ADDITIVE / #869: exactly as for the detector unit, the link target lives OUTSIDE # mosaic home, so it is NOT a framework-manifest path. Ownership of the SSOT units # stays the single `systemd/**` glob in framework-manifest.txt — NO new owned path, # NO second ownership authority. The link points BACK to the mosaic-home SSOT copy. # _wi_link_one UNIT — idempotently place ONE deployed unit into the user systemd # search path (symlink to the mosaic-home SSOT copy). Shared idiom with the # detector's wi_link_systemd_unit; an already-correct link / byte-identical file is # left untouched, a stale entry is replaced. Fail-closed on a missing SSOT copy. _wi_link_one() { local unit="$1" 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" 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 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_link_fallback_units — link BOTH canon fallback units (timer + oneshot service) # into the user systemd search path so `systemctl --user` can resolve+enable them. wi_link_fallback_units() { _wi_link_one "$WAKE_FALLBACK_TIMER_NAME" || return 1 _wi_link_one "$WAKE_FALLBACK_SERVICE_NAME" || return 1 return 0 } # _wi_validate_one UNIT KIND — post-install validate that ONE unit resolves in the # user systemd search path AND is well-formed for its KIND. KIND=timer requires # [Timer] + a base OnUnitActiveSec (the blank-reset target); KIND=service requires # [Service] + ExecStart. As with the detector, the AUTHORITATIVE floor is # path + well-formedness (the installer often runs with no live user manager); a # live `systemctl --user cat` probe runs ONLY opportunistically behind the guard. _wi_validate_one() { local unit="$1" kind="$2" local link="$WAKE_SYSTEMD_USER_DIR/$unit" resolved if [[ ! -e "$link" ]]; then wi_fail "fallback-validate — '$unit' is NOT in the user systemd search path ($link). systemctl --user cannot resolve it, so the canon fallback wake cannot be enabled/started. Run 'wake-install.sh link-fallback-units' (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 "fallback-validate — '$unit' search-path entry ($link) does not dereference to a readable unit file. FAIL LOUD (fail-closed)." return 1 fi local -a miss=() grep -q '^\[Unit\]' "$resolved" || miss+=("[Unit]") case "$kind" in timer) grep -q '^\[Timer\]' "$resolved" || miss+=("[Timer]") grep -q '^\[Install\]' "$resolved" || miss+=("[Install]") grep -q '^OnUnitActiveSec=' "$resolved" || miss+=("OnUnitActiveSec=") ;; service) grep -q '^\[Service\]' "$resolved" || miss+=("[Service]") grep -q '^ExecStart=' "$resolved" || miss+=("ExecStart=") ;; *) wi_fail "fallback-validate — internal: unknown unit kind '$kind'."; return 2 ;; esac if [[ ${#miss[@]} -ne 0 ]]; then wi_fail "fallback-validate — '$unit' ($resolved) is malformed: missing ${miss[*]}. Refusing to certify an ill-formed unit (fail-closed)." return 1 fi 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 "fallback-validate — 'systemctl --user cat $unit' resolves (live user manager confirmed)." else wi_warn "fallback-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 "fallback-validate — '$unit' resolves in the user systemd search path ($link -> $resolved, well-formed $kind)." return 0 } # wi_validate_fallback_units — both canon fallback units resolve + are well-formed. wi_validate_fallback_units() { _wi_validate_one "$WAKE_FALLBACK_TIMER_NAME" timer || return 1 _wi_validate_one "$WAKE_FALLBACK_SERVICE_NAME" service || return 1 return 0 } # wi_write_fallback_cadence CADENCE — write the per-class cadence for the fallback # TIMER as a BLANK-RESET drop-in (mosaic-wake-fallback.timer.d/cadence.conf), then # verify exactly ONE effective OnUnitActiveUSec. CADENCE is the operator's per-class # `fallback_cadence` from the watch-list (config, not code). Same blank-reset idiom # as the legacy-timer cadence: an empty OnUnitActiveSec= reset line CLEARS the base # value, then the new value sets exactly one — so the base placeholder in the shipped # timer can never accumulate into a second effective cadence. wi_write_fallback_cadence() { local cadence="${1:-}" [[ -n "$cadence" ]] || { wi_fail "write-fallback-cadence: CADENCE (per-class fallback_cadence) required."; return 2; } local timer="$WAKE_FALLBACK_TIMER_NAME" wi_write_blank_reset_dropin "$WAKE_SYSTEMD_USER_DIR/$timer.d/cadence.conf" "$cadence" || return 1 wi_verify_single_active "$timer" || { wi_fail "write-fallback-cadence — blank-reset verify failed for '$timer'; the fallback cadence did not collapse to exactly one OnUnitActiveUSec (fail-closed)." return 1 } wi_ok "write-fallback-cadence — '$timer' cadence set to $cadence (blank-reset, exactly one OnUnitActiveUSec)." return 0 } # wi_fallback_proven_live — the F7 PRECONDITION check (replacement-before-retirement, # #925). The legacy reap MUST NOT proceed unless the canon fallback wake is live + # proven-firing, so there is never a coverage gap. Layered, fail-closed: # FLOOR (always, even with no live user manager): both fallback units resolve in # the user systemd search path AND are well-formed (SCHEDULABLE/enabled floor) — # i.e. the fallback is INSTALLED and CAN fire. A missing/ill-formed unit => REFUSE. # LIVE (opportunistic, WAKE_VERIFY_USE_SYSTEMCTL=1 + a reachable user manager, # mirroring #913): additionally require the TIMER be ENABLED and PROVEN-FIRING — # LastTriggerUSec is set (it has fired at least once) OR NextElapse is armed # (it is scheduled to fire). A dead/never-armed timer => REFUSE. # Returns 0 iff the fallback is proven live to the strongest tier available. wi_fallback_proven_live() { local timer="$WAKE_FALLBACK_TIMER_NAME" service="$WAKE_FALLBACK_SERVICE_NAME" # FLOOR — installed + well-formed (schedulable). Reuses the validate above. if ! wi_validate_fallback_units >/dev/null 2>&1; then wi_fail "F7 fallback-proven-live — the canon FALLBACK WAKE ('$timer' + '$service') is NOT installed/schedulable in the user systemd search path. Replacement-before-retirement (F7) FORBIDS reaping the legacy timer without a live fallback (fail-closed). Run 'wake-install.sh install' + 'write-fallback-cadence ' first." return 1 fi # LIVE — opportunistic proven-firing when a real user manager is reachable. if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then local enabled last next enabled="$(systemctl --user is-enabled "$timer" 2>/dev/null || true)" if [[ "$enabled" != "enabled" && "$enabled" != "static" ]]; then wi_fail "F7 fallback-proven-live — '$timer' is not enabled (systemctl --user is-enabled => '${enabled:-unknown}'). A disabled fallback cannot fire; refusing the legacy reap (F7, fail-closed)." return 1 fi last="$(systemctl --user show "$timer" -p LastTriggerUSec --value 2>/dev/null || true)" next="$(systemctl --user show "$timer" -p NextElapseUSecRealtime --value 2>/dev/null || true)" # Proven-firing: it has already fired (LastTrigger set) OR it is armed to fire # (NextElapse set). A timer that is neither is dead => refuse. if _wi_usec_set "$last" || _wi_usec_set "$next"; then wi_ok "F7 fallback-proven-live — '$timer' is enabled and proven-firing (LastTrigger='${last:-n/a}', NextElapse='${next:-n/a}'). Legacy reap is unblocked (F7 satisfied)." return 0 fi wi_fail "F7 fallback-proven-live — '$timer' is enabled but has NEITHER fired (LastTriggerUSec unset) NOR is armed (NextElapseUSecRealtime unset): it is not proven-firing. Refusing the legacy reap (F7, fail-closed) — start the timer and confirm it fires first." return 1 fi wi_ok "F7 fallback-proven-live — canon FALLBACK WAKE is installed + schedulable (no live user manager to probe firing; schedulable/enabled floor satisfied, mirroring #913). Legacy reap is unblocked at the schedulable floor." return 0 } # _wi_usec_set VALUE — rc 0 iff VALUE is a set systemd USec timestamp: non-empty, # not the "unset" sentinels systemd prints for a never-fired / never-armed timer. _wi_usec_set() { local v="$1" [[ -n "$v" ]] || return 1 case "$v" in 0|n/a|'-'|'') return 1 ;; *) return 0 ;; esac } # ═══════════════════════════════════════════════════════════════════════════════ # (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a) # ═══════════════════════════════════════════════════════════════════════════════ # Resolve the credential store the same way load_credentials / sign.sh do, WITHOUT # ever echoing a resolved value. Sourcing credentials.sh only sets # MOSAIC_CREDENTIALS_FILE; we never call load_credentials for a secret here. _wi_cred_file() { local cred_lib="$SCRIPT_DIR/../_lib/credentials.sh" if [[ -f "$cred_lib" ]]; then # shellcheck source=../_lib/credentials.sh disable=SC1091 . "$cred_lib" fi printf '%s' "${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}" } # wi_validate_hmac_key — the W3/W7 HMAC key MUST resolve BY NAME # (.wake.hmac_keys.) in the operator credential store. Missing => FAIL LOUD: # the installer must never deploy a config that would emit UNSIGNED wakes. Only a # boolean/verdict is printed — the key material is NEVER echoed. wi_validate_hmac_key() { command -v jq >/dev/null 2>&1 || { wi_fail "jq is required for install-validate."; return 3; } local name="${WAKE_HMAC_KEY_NAME:-default}" cred_file present cred_file="$(_wi_cred_file)" if [[ ! -f "$cred_file" ]]; then wi_fail "HMAC-key validate — credential store not found ($cred_file): cannot resolve key name '$name'. Refusing to deploy a config that would emit UNSIGNED wakes (fail-closed)." return 1 fi # jq -e sets exit status; we capture ONLY presence, never the value. if present="$(jq -re --arg n "$name" '(.wake.hmac_keys[$n] // "") | if . == "" then empty else "present" end' "$cred_file" 2>/dev/null)" && [[ "$present" == present ]]; then wi_ok "HMAC key '$name' resolves by-name in the credential store (value NOT read/echoed)." return 0 fi wi_fail "HMAC-key validate — key name '$name' is NOT configured in the credential store (.wake.hmac_keys). A missing key would emit UNSIGNED wakes. FAIL LOUD (fail-closed)." return 1 } # wi_validate_alarm_target — the operator's W6 alarm-sink target must be # CONFIGURED (WAKE_ALARM_SINK_CMD set) and REACHABLE (a probe payload through the # pluggable adapter exits 0). The adapter resolves its endpoint BY NAME internally # (load_credentials); this installer ships/writes NO endpoint value. An # unconfigured OR unreachable target FAILS LOUD — no silent no-alarm host (G1/G2a). wi_validate_alarm_target() { if [[ -z "${WAKE_ALARM_SINK_CMD:-}" ]]; then wi_fail "alarm-target validate — WAKE_ALARM_SINK_CMD is UNSET: no off-host alarm route is configured. A host with no alarm sink is a silent no-alarm host. FAIL LOUD (G1/G2a, fail-closed)." return 1 fi # Reachability probe: send a benign install-probe payload through the adapter. # A non-zero exit = UNREACHABLE target => fail loud. Adapter output is # discarded so no resolved endpoint value can leak into installer output. local probe='{"kind":"wake-install-probe"}' if ! printf '%s\n' "$probe" | sh -c "$WAKE_ALARM_SINK_CMD" >/dev/null 2>&1; then wi_fail "alarm-target validate — the alarm sink is UNREACHABLE (WAKE_ALARM_SINK_CMD probe exited non-zero): the alarm cannot route to a human/other-host. FAIL LOUD (G1/G2a, fail-closed)." return 1 fi wi_ok "alarm-sink target is configured + reachable (endpoint resolved by-name by the adapter; NOT read/echoed here)." return 0 } # wi_validate_targets — both gates. Either failure fails the whole validate loud. wi_validate_targets() { local rc=0 wi_validate_hmac_key || rc=1 wi_validate_alarm_target || rc=1 if [[ "$rc" -ne 0 ]]; then wi_fail "install-validate FAILED — refusing to enable the wake service with an unconfigured signing key or a silent no-alarm host (fail-closed)." return 1 fi wi_ok "install-validate PASSED — HMAC key + alarm sink are both fail-closed-ready." return 0 } # ═══════════════════════════════════════════════════════════════════════════════ # (iv) snapshot-guard — never reap a deployed unit without a snapshot first # ═══════════════════════════════════════════════════════════════════════════════ _wi_snapshot_path() { printf '%s/%s' "$WAKE_SNAPSHOT_DIR" "$1"; } # wi_snapshot_unit UNIT — retain a byte copy (+ the whole .d drop-in tree) of a # deployed unit BEFORE any reap/clean-checkout. Idempotent (re-snapshotting an # unchanged unit just refreshes it). Private (0700/0600): a unit can reference # operator paths. wi_snapshot_unit() { local unit="$1" [[ -n "$unit" ]] || { wi_fail "snapshot-unit: UNIT name required."; return 2; } local src="$WAKE_SYSTEMD_USER_DIR/$unit" snap snap="$(_wi_snapshot_path "$unit")" if [[ ! -e "$src" ]]; then wi_fail "snapshot-unit — deployed unit '$unit' not found under $WAKE_SYSTEMD_USER_DIR; nothing to snapshot." return 1 fi local old_umask; old_umask="$(umask)"; umask 077 mkdir -p "$(dirname "$snap")" cp "$src" "$snap" # Capture the drop-in dir too (cadence drop-ins live in .d/). if [[ -d "$WAKE_SYSTEMD_USER_DIR/$unit.d" ]]; then rm -rf "$snap.d"; mkdir -p "$snap.d" cp -a "$WAKE_SYSTEMD_USER_DIR/$unit.d/." "$snap.d/" fi umask "$old_umask" wi_ok "snapshot-unit — '$unit' snapshotted to $snap (reap is now unblocked for this unit)." return 0 } # wi_has_snapshot UNIT — rc 0 iff a snapshot for UNIT exists. wi_has_snapshot() { [[ -f "$(_wi_snapshot_path "$1")" ]] } # wi_reap_unit UNIT — remove a deployed unit (+ its .d drop-ins). FAIL-CLOSED: a # reap with NO prior snapshot is REFUSED. This is the guard against the # deployed-from-uncommitted failure class recurring: you cannot clean-checkout / # reap a deployed-but-uncommitted unit until it is snapshotted. wi_reap_unit() { local unit="$1" [[ -n "$unit" ]] || { wi_fail "reap-unit: UNIT name required."; return 2; } if ! wi_has_snapshot "$unit"; then wi_fail "snapshot-guard — REFUSING to reap '$unit': no snapshot exists (a reap/clean-checkout of a deployed-but-uncommitted unit without a snapshot is the exact failure class this guard forbids). Run 'wake-install.sh snapshot-unit $unit' first." return 1 fi rm -f "$WAKE_SYSTEMD_USER_DIR/$unit" rm -rf "$WAKE_SYSTEMD_USER_DIR/$unit.d" wi_ok "reap-unit — '$unit' reaped (snapshot retained at $(_wi_snapshot_path "$unit"))." return 0 } # ═══════════════════════════════════════════════════════════════════════════════ # (iii) blank-reset idiom + exactly-one-OnUnitActiveUSec verify + retire # ═══════════════════════════════════════════════════════════════════════════════ # wi_write_blank_reset_dropin DROPIN_FILE INTERVAL — write a [Timer] cadence # drop-in in the BLANK-RESET form: an empty OnUnitActiveSec= reset line CLEARS any # previously accumulated (list-valued) cadence, then the new value sets exactly # one. This is byte-identical to the installer-7 idiom and guarantees exactly ONE # effective OnUnitActiveUSec after systemd merges the base unit + all drop-ins. wi_write_blank_reset_dropin() { local file="$1" interval="$2" [[ -n "$file" && -n "$interval" ]] || { wi_fail "blank-reset: DROPIN_FILE and INTERVAL required."; return 2; } mkdir -p "$(dirname "$file")" # The empty reset line MUST precede the new value (order is load-bearing). cat >"$file" < $interval)." return 0 } # _wi_effective_onunitactive UNIT_FILE DROPIN_DIR — echo, one per line, the # EFFECTIVE OnUnitActiveSec values after simulating systemd's list-merge: process # the base unit then every drop-in in the .d dir in lexical (systemd) order; an # EMPTY assignment RESETS the accumulated list, a non-empty one APPENDS. Mirrors # `systemctl show -p OnUnitActiveUSec` semantics without needing a live manager. _wi_effective_onunitactive() { local unit_file="$1" dropin_dir="$2" local -a eff=() _wi_merge_file() { local f="$1" line val [[ -f "$f" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do line="${line#"${line%%[![:space:]]*}"}" case "$line" in OnUnitActiveSec=*) val="${line#OnUnitActiveSec=}" val="${val#"${val%%[![:space:]]*}"}" val="${val%"${val##*[![:space:]]}"}" if [[ -z "$val" ]]; then eff=() # empty assignment RESETS the list else eff+=("$val") # non-empty APPENDS fi ;; esac done <"$f" } _wi_merge_file "$unit_file" if [[ -d "$dropin_dir" ]]; then local d while IFS= read -r d; do [[ -n "$d" ]] && _wi_merge_file "$d" done < <(find "$dropin_dir" -maxdepth 1 -type f -name '*.conf' | LC_ALL=C sort) fi local v for v in ${eff[@]+"${eff[@]}"}; do printf '%s\n' "$v"; done } # wi_verify_single_active UNIT — post-apply verify: exactly ONE effective # OnUnitActiveUSec. Prefers a live `systemctl --user show` when available + # loaded; otherwise falls back to the deterministic merge simulation above. # rc 0 = exactly one; rc 1 = zero or more-than-one (fail loud). wi_verify_single_active() { local unit="$1" [[ -n "$unit" ]] || { wi_fail "verify-single: UNIT name required."; return 2; } local count="" if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then # Live path: systemd already merged base+drop-ins. One line, one value. local shown if shown="$(systemctl --user show "$unit" -p OnUnitActiveUSec --value 2>/dev/null)"; then # --value prints the merged value (may be a space-joined list if >1). # shellcheck disable=SC2086 set -- $shown count=$# fi fi if [[ -z "$count" ]]; then count="$(_wi_effective_onunitactive "$WAKE_SYSTEMD_USER_DIR/$unit" "$WAKE_SYSTEMD_USER_DIR/$unit.d" | grep -c .)" fi if [[ "$count" -eq 1 ]]; then wi_ok "verify-single — '$unit' resolves EXACTLY ONE OnUnitActiveUSec (blank-reset idiom held)." return 0 fi wi_fail "verify-single — '$unit' resolves $count OnUnitActiveUSec values (expected exactly 1). The blank-reset idiom did not collapse the cadence. FAIL LOUD." return 1 } # wi_reset_verify_retire AGENT [--interval V] [--vector-passed] — the §5 # reset->verify->retire lifecycle for the legacy mosaic-heartbeat@ timer. # This is the TESTABLE acceptance path: # 1. SNAPSHOT the legacy timer (snapshot-guard precondition for any later reap). # 2. IF --interval is given, write the fallback-cadence drop-in in blank-reset # form, then VERIFY exactly-one-OnUnitActiveUSec. # 3. ONLY on --vector-passed (the §4-vector pass) REMOVE the legacy units, and # only via the snapshot-guarded reap (retire-LAST). wi_reset_verify_retire() { local agent="" interval="" vector_passed=0 agent="${1:-}"; shift || true [[ -n "$agent" ]] || { wi_fail "reset-verify-retire: AGENT required."; return 2; } while [[ $# -gt 0 ]]; do case "$1" in --interval) interval="${2:-}"; shift 2 ;; --vector-passed) vector_passed=1; shift ;; *) wi_fail "reset-verify-retire: unknown option '$1'."; return 2 ;; esac done local legacy_timer="mosaic-heartbeat@$agent.timer" local legacy_service="mosaic-heartbeat@$agent.service" # 1) SNAPSHOT FIRST — unconditionally, before touching or retiring anything. wi_snapshot_unit "$legacy_timer" || { wi_fail "reset-verify-retire — could not snapshot the legacy timer '$legacy_timer'; refusing to proceed (snapshot-guard, fail-closed)." return 1 } # 2) blank-reset the fallback cadence (if a per-class fallback timer is used), # then verify exactly one effective OnUnitActiveUSec. if [[ -n "$interval" ]]; then wi_write_blank_reset_dropin "$WAKE_SYSTEMD_USER_DIR/$legacy_timer.d/interval.conf" "$interval" || return 1 wi_verify_single_active "$legacy_timer" || { wi_fail "reset-verify-retire — blank-reset verify failed for '$legacy_timer'; refusing to retire on an unverified cadence (fail-closed)." return 1 } fi # 3) RETIRE LAST — only on the §4-vector pass, and only via the snapshot-guarded # reap. Without --vector-passed the legacy units are LEFT RUNNING (overlap). if [[ "$vector_passed" -eq 1 ]]; then # F7 PRECONDITION (#925) — replacement-before-retirement. The reap MUST NOT # proceed unless the canon FALLBACK WAKE is proven live (installed + schedulable # at the floor; enabled + proven-firing when a live user manager is probeable). # This encodes F7 into the installer, not operator memory: there is never a # window where the legacy net is reaped before its replacement is carrying load. if [[ "${WAKE_REQUIRE_FALLBACK_LIVE:-1}" == "1" ]]; then wi_fallback_proven_live || { wi_fail "reset-verify-retire — REFUSING to reap the legacy '$agent' heartbeat: the canon FALLBACK WAKE is not proven live (F7 replacement-before-retirement, fail-closed). The legacy net stays UP until the fallback is carrying load." return 1 } fi wi_reap_unit "$legacy_timer" || return 1 # The paired service may not be independently deployed; reap it best-effort # but still snapshot-guarded if present. if [[ -e "$WAKE_SYSTEMD_USER_DIR/$legacy_service" ]]; then wi_snapshot_unit "$legacy_service" && wi_reap_unit "$legacy_service" || return 1 fi wi_ok "reset-verify-retire — legacy '$agent' heartbeat units RETIRED (post §4-vector pass, snapshot-guarded)." else wi_ok "reset-verify-retire — overlap phase: cadence reset+verified, legacy '$agent' units LEFT RUNNING (retire withheld until §4-vector pass)." fi return 0 } # ═══════════════════════════════════════════════════════════════════════════════ # CLI dispatch — only when executed directly, never when sourced. # ═══════════════════════════════════════════════════════════════════════════════ 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 "$@" ;; link-fallback-units) wi_link_fallback_units "$@" ;; validate-fallback-units) wi_validate_fallback_units "$@" ;; write-fallback-cadence) wi_write_fallback_cadence "$@" ;; fallback-proven-live) wi_fallback_proven_live "$@" ;; validate-targets) wi_validate_targets "$@" ;; validate-hmac-key) wi_validate_hmac_key "$@" ;; validate-alarm-target) wi_validate_alarm_target "$@" ;; snapshot-unit) wi_snapshot_unit "$@" ;; reap-unit) wi_reap_unit "$@" ;; blank-reset) wi_write_blank_reset_dropin "$@" ;; verify-single) wi_verify_single_active "$@" ;; reset-verify-retire) wi_reset_verify_retire "$@" ;; -h | --help | help | '') cat >&2 <<'EOF' Usage: wake-install.sh [args] Commands: 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. link-fallback-units Link the canon fallback wake timer+service into the search path (#925). validate-fallback-units Validate the fallback timer+service resolve + are well-formed (#925). write-fallback-cadence CADENCE Write the per-class fallback timer cadence as a blank-reset drop-in (#925). fallback-proven-live F7 gate: the canon fallback wake is proven live (schedulable floor / firing) (#925). 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. snapshot-unit UNIT Snapshot a deployed unit (snapshot-guard precondition). reap-unit UNIT Reap a deployed unit; REFUSES without a snapshot. blank-reset DROPIN_FILE INTERVAL Write a blank-reset cadence drop-in. verify-single UNIT Verify exactly one effective OnUnitActiveUSec. reset-verify-retire AGENT [--interval V] [--vector-passed] The §5 reset->verify->retire lifecycle. EOF [[ "$cmd" == -h || "$cmd" == --help || "$cmd" == help ]] && exit 0 exit 2 ;; *) wi_fail "unknown command '$cmd'" exit 2 ;; esac fi