fix(wake): #925 framework-ship canon fallback-wake (systemd timer + schema bound + A10 install/validate) — F7 out of the box (#931)
Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #931.
This commit is contained in:
@@ -148,13 +148,43 @@
|
||||
# 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.
|
||||
# 0.6.8 #925 — framework-ship the canon-side FALLBACK WAKE (F7 replacement-
|
||||
# before-retirement) so hosts get it OUT OF THE BOX rather than hand-
|
||||
# wiring it per host. ADDITIVE, #869 / Gate-A/B discipline:
|
||||
# (1) new framework units systemd/user/mosaic-wake-fallback.{timer,
|
||||
# service}: a LOW-FREQUENCY SAFETY drain (oneshot service running the
|
||||
# canon drain `digest.sh render --from-store`) fired by a per-class
|
||||
# cadence timer, INDEPENDENT of the event-driven detector, so a stalled
|
||||
# detector/daemon can never SILENTLY STARVE delivery. Both units are
|
||||
# framework-owned via the EXISTING `systemd/**` glob in framework-
|
||||
# manifest.txt (Gate A) — NO new owned path, NO second ownership
|
||||
# authority. (2) an OPTIONAL, additive per-class `fallback_cadence`
|
||||
# bound in wake-watch-list.schema.json (config, not code). It is
|
||||
# backward-compatible within schema_version 1, so [schema_min,
|
||||
# schema_max] stays [1,1] and the detector's Gate B range check is
|
||||
# UNCHANGED (an out-of-range schema_version still fails loud). (3) A10
|
||||
# install/wire: wi_install enumerates + LINKS + validates the two units
|
||||
# into the user systemd search path (idempotent, fail-closed, same
|
||||
# link-to-SSOT pattern as the detector unit); write-fallback-cadence
|
||||
# writes the per-class cadence as a BLANK-RESET drop-in (exactly one
|
||||
# effective OnUnitActiveUSec). (4) F7 install-validate: the §5 legacy
|
||||
# reap now REFUSES unless the canon fallback wake is proven live
|
||||
# (installed + schedulable floor always; enabled + proven-firing when a
|
||||
# live user manager is probeable, mirroring #913's opportunistic
|
||||
# WAKE_VERIFY_USE_SYSTEMCTL pattern) — F7 is encoded in the installer,
|
||||
# not operator memory. framework-manifest.txt, the install-ordering-
|
||||
# guard, and the manifest parity contract are all UNCHANGED.
|
||||
component=wake
|
||||
version=0.6.7
|
||||
version=0.6.8
|
||||
|
||||
# Watch-list schema this component consumes, and the INCLUSIVE range of
|
||||
# schema_version values it supports. A wake-watch-list.json whose schema_version
|
||||
# falls outside [schema_min, schema_max] is rejected by the component (fail-loud),
|
||||
# never silently coerced.
|
||||
#
|
||||
# #925: the OPTIONAL per-class `fallback_cadence` bound is ADDITIVE and backward-
|
||||
# compatible — an existing schema_version-1 watch-list stays valid (the field is
|
||||
# omittable), so the supported range is UNCHANGED at [1, 1] and Gate B is intact.
|
||||
schema=wake-watch-list
|
||||
schema_min=1
|
||||
schema_max=1
|
||||
@@ -217,3 +247,11 @@ schema_max=1
|
||||
# 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)
|
||||
# Companion (framework subtree, not under tools/wake/):
|
||||
# systemd/user/mosaic-wake-fallback.timer + mosaic-wake-fallback.service
|
||||
# — the canon FALLBACK WAKE (F7): a per-class cadence timer firing
|
||||
# a oneshot SAFETY drain (digest.sh render --from-store),
|
||||
# INDEPENDENT of the detector, so a stalled detector cannot starve
|
||||
# delivery. Owned via the existing systemd/** glob; the per-class
|
||||
# cadence is a blank-reset drop-in; the §5 reap is F7-gated on this
|
||||
# being proven live. (W7, #925)
|
||||
|
||||
@@ -398,6 +398,53 @@ EOF
|
||||
"$STORE" consume --upto 6 >/dev/null 2>&1 || fail_msg "D8: CONSUMED 6 must succeed over the contiguous prefix after the delta"
|
||||
) && ok
|
||||
|
||||
echo "== D9: schema (#925) — optional per-class fallback_cadence is BACKWARD-COMPAT (in-range accepted; out-of-range still FAILS LOUD, Gate B intact) =="
|
||||
(
|
||||
WAKE_STATE_HOME="$(fresh_state d9)"
|
||||
export WAKE_STATE_HOME
|
||||
unset WAKE_AGENT
|
||||
stub="$TMP_ROOT/d9stub"
|
||||
mkdir -p "$stub"
|
||||
make_stub "$stub"
|
||||
export WAKE_DETECTOR_SOURCE_CMD="$stub/adapter.sh"
|
||||
printf 'SHA-D9\n' >"$stub/repo_r1"
|
||||
printf '## LANE-X\nx\n' >"$stub/board_file_b1"
|
||||
# A watch-list that EXERCISES the new optional additive field: a per-class
|
||||
# `fallback_cadence` under an slos tier, at schema_version 1. Because the field is
|
||||
# OPTIONAL + additive, an existing schema_version-1 watch-list stays valid and the
|
||||
# detector's Gate B range [schema_min, schema_max] is UNCHANGED — the new field
|
||||
# must NOT push the watch-list out of range.
|
||||
wl="$TMP_ROOT/d9.json"
|
||||
write_fc_watchlist() {
|
||||
local file="$1" ver="$2"
|
||||
cat >"$file" <<EOF
|
||||
{
|
||||
"schema_version": $ver,
|
||||
"slos": {
|
||||
"actionable-tier": { "class": "actionable", "fallback_bound": "30m", "fallback_cadence": "1h" }
|
||||
},
|
||||
"repos": [{ "id": "r1", "remote": "example/repo", "class": "digest" }],
|
||||
"board_files": [{ "id": "b1", "path": "BOARD.md", "anchor": "## LANE-X" }],
|
||||
"watches": [
|
||||
{ "lane": "lane-x", "sources": [
|
||||
{ "kind": "repo", "id": "r1" },
|
||||
{ "kind": "board_file", "id": "b1" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
EOF
|
||||
}
|
||||
# (a) in-range (schema_version 1) WITH fallback_cadence -> ACCEPTED.
|
||||
write_fc_watchlist "$wl" 1
|
||||
export WAKE_WATCH_LIST="$wl"
|
||||
"$DET" poll-once >/dev/null 2>&1 || fail_msg "D9: an in-range watch-list carrying the optional fallback_cadence must be ACCEPTED (additive/backward-compatible)"
|
||||
# (b) out-of-range (schema_version 999) WITH the SAME field -> STILL FAILS LOUD.
|
||||
write_fc_watchlist "$wl" 999
|
||||
err="$("$DET" poll-once 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "D9: the new field must NOT weaken Gate B — an out-of-range schema_version must still FAIL LOUD (rc=$rc)"
|
||||
echo "$err" | grep -qi 'range' || fail_msg "D9: the out-of-range failure must still state it is out of the supported range [$err]"
|
||||
) && ok
|
||||
|
||||
echo
|
||||
if [ -s "$FAILFILE" ]; then
|
||||
echo "wake detector harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
||||
|
||||
@@ -193,13 +193,19 @@ echo "== I6: reset->verify->retire — overlap keeps legacy running; only §4-ve
|
||||
export WAKE_SNAPSHOT_DIR="$SNAP"
|
||||
TIMER="mosaic-heartbeat@c.timer"
|
||||
printf '[Timer]\nOnUnitActiveSec=15min\n' >"$UD/$TIMER"
|
||||
# F7 (#925): the vector-passed reap now REFUSES unless the canon FALLBACK WAKE is
|
||||
# proven live (replacement-before-retirement). Provision it at the schedulable
|
||||
# floor so the lifecycle path under test still reaches the reap. (I11 is the
|
||||
# dedicated F7 refuse/allow control.)
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.timer" "$UD/mosaic-wake-fallback.timer"
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service" "$UD/mosaic-wake-fallback.service"
|
||||
# (a) overlap phase (NO --vector-passed): reset+verify, legacy LEFT RUNNING.
|
||||
out="$(bash "$WI" reset-verify-retire c --interval 30min 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "I6: overlap reset-verify must succeed (rc=$rc) [$out]"
|
||||
[ -f "$UD/$TIMER" ] || fail_msg "I6: overlap phase must LEAVE the legacy timer running (retire withheld)"
|
||||
[ -f "$SNAP/$TIMER" ] || fail_msg "I6: the legacy timer must be snapshotted before any retire"
|
||||
echo "$out" | grep -qi 'LEFT RUNNING' || fail_msg "I6: overlap must announce retire is withheld [$out]"
|
||||
# (b) §4-vector pass: retire LAST, snapshot-guarded.
|
||||
# (b) §4-vector pass: retire LAST, snapshot-guarded (fallback proven live above).
|
||||
out2="$(bash "$WI" reset-verify-retire c --interval 30min --vector-passed 2>&1)"; rc2=$?
|
||||
[ "$rc2" -eq 0 ] || fail_msg "I6: vector-passed retire must succeed (rc=$rc2) [$out2]"
|
||||
[ ! -f "$UD/$TIMER" ] || fail_msg "I6: on §4-vector pass the legacy timer must be RETIRED"
|
||||
@@ -315,6 +321,111 @@ echo "== I10: systemd search-path — install links mosaic-wake.service into the
|
||||
bash "$WI" validate-systemd-path >/dev/null 2>&1 || fail_msg "I10: re-install must keep the unit resolvable"
|
||||
) && ok
|
||||
|
||||
# ── (#925) canon FALLBACK WAKE (F7 replacement-before-retirement) ──────────────
|
||||
|
||||
echo "== I11: F7 — the legacy reap REFUSES unless the canon fallback wake is proven live =="
|
||||
(
|
||||
UD="$(fresh i11-units)"; SNAP="$(fresh i11-snap)"
|
||||
export WAKE_SYSTEMD_USER_DIR="$UD"
|
||||
export WAKE_SNAPSHOT_DIR="$SNAP"
|
||||
TIMER="mosaic-heartbeat@f.timer"
|
||||
printf '[Timer]\nOnUnitActiveSec=15min\n' >"$UD/$TIMER"
|
||||
# (a) fallback-proven-live is the F7 gate primitive: with NO fallback installed,
|
||||
# it must REFUSE (schedulable floor not met).
|
||||
out0="$(bash "$WI" fallback-proven-live 2>&1)"; rc0=$?
|
||||
[ "$rc0" -ne 0 ] || fail_msg "I11: fallback-proven-live must FAIL when the fallback wake is not installed (rc=$rc0)"
|
||||
echo "$out0" | grep -qi 'F7' || fail_msg "I11: the refusal must name the F7 precondition [$out0]"
|
||||
# (b) NEGATIVE — vector-passed reap with NO live fallback must be REFUSED and the
|
||||
# legacy timer LEFT RUNNING (never a coverage gap).
|
||||
out="$(bash "$WI" reset-verify-retire f --interval 30min --vector-passed 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "I11: reap must be REFUSED without a proven-live fallback (rc=$rc)"
|
||||
echo "$out" | grep -qi 'FALLBACK WAKE is not proven live' || fail_msg "I11: refusal must name the un-proven fallback [$out]"
|
||||
[ -f "$UD/$TIMER" ] || fail_msg "I11: a refused reap must LEAVE the legacy timer running (F7 — no coverage gap)"
|
||||
# (c) POSITIVE — provision the fallback at the schedulable floor, then the reap
|
||||
# proceeds (F7 satisfied) and the legacy timer is retired.
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.timer" "$UD/mosaic-wake-fallback.timer"
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service" "$UD/mosaic-wake-fallback.service"
|
||||
bash "$WI" fallback-proven-live >/dev/null 2>&1 || fail_msg "I11: fallback-proven-live must PASS once the fallback units are schedulable"
|
||||
out2="$(bash "$WI" reset-verify-retire f --interval 30min --vector-passed 2>&1)"; rc2=$?
|
||||
[ "$rc2" -eq 0 ] || fail_msg "I11: with a proven-live fallback the reap must succeed (rc=$rc2) [$out2]"
|
||||
[ ! -f "$UD/$TIMER" ] || fail_msg "I11: with F7 satisfied the legacy timer must be RETIRED"
|
||||
) && ok
|
||||
|
||||
echo "== I12: fallback drain — a STALLED detector still gets delivery via the canon fallback drain =="
|
||||
(
|
||||
H="$(fresh i12-state)"
|
||||
export WAKE_STATE_HOME="$H"
|
||||
unset WAKE_AGENT
|
||||
STORE="$SCRIPT_DIR/store.sh"; DIGEST="$SCRIPT_DIR/digest.sh"
|
||||
# A pending obligation lands in the durable inbox — but the detector daemon is
|
||||
# STALLED/absent (we never run detector.sh). Enqueue directly to the store.
|
||||
seq="$(bash "$STORE" enqueue --class digest \
|
||||
--locators '{"kind":"repo","id":"STALLED-DRAIN-MARK","path":"BOARD.md","observed_hash":"deadbeef"}' 2>/dev/null)"
|
||||
[ -n "$seq" ] || fail_msg "I12: store enqueue must allocate a seq"
|
||||
# The canon fallback drain is EXACTLY the shipped unit's ExecStart: digest render
|
||||
# --from-store. Firing it (as the timer would) must surface the pending obligation
|
||||
# even with no detector running — no starvation of the drain.
|
||||
out="$(bash "$DIGEST" render --from-store 2>/dev/null)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "I12: the canon fallback drain must exit 0 (rc=$rc)"
|
||||
echo "$out" | grep -q 'STALLED-DRAIN-MARK' \
|
||||
|| fail_msg "I12: the fallback drain must DELIVER the pending obligation a stalled detector left (no delivery starvation) [$out]"
|
||||
# Bind the invariant to the SHIPPED unit: its ExecStart must be this canon drain.
|
||||
UNIT="$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service"
|
||||
grep -qE '^ExecStart=.*digest\.sh render --from-store' "$UNIT" \
|
||||
|| fail_msg "I12: mosaic-wake-fallback.service ExecStart must fire the canon drain (digest.sh render --from-store)"
|
||||
) && ok
|
||||
|
||||
echo "== I13: install links + validates the canon fallback timer+service into the search path (idempotent) =="
|
||||
(
|
||||
TGT="$(fresh i13-target)"
|
||||
UD="$(fresh i13-systemd)"
|
||||
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 "I13: install must succeed"
|
||||
# (a) both fallback units resolve in the user systemd search path.
|
||||
[ -e "$UD/mosaic-wake-fallback.timer" ] || fail_msg "I13: fallback timer must be linked into the search path"
|
||||
[ -e "$UD/mosaic-wake-fallback.service" ] || fail_msg "I13: fallback service must be linked into the search path"
|
||||
bash "$WI" validate-fallback-units >/dev/null 2>&1 || fail_msg "I13: validate-fallback-units must pass after install"
|
||||
# (b) NEGATIVE CONTROL: a missing fallback unit is CAUGHT (fail loud, names the miss).
|
||||
rm -f "$UD/mosaic-wake-fallback.timer"
|
||||
out="$(bash "$WI" validate-fallback-units 2>&1)"; rc=$?
|
||||
[ "$rc" -ne 0 ] || fail_msg "I13: validate must FAIL when a fallback unit is not in the search path (rc=$rc) [$out]"
|
||||
echo "$out" | grep -qi 'search path' || fail_msg "I13: validate failure must name the search-path miss [$out]"
|
||||
# (c) idempotent re-install: exactly one entry per unit, still resolvable.
|
||||
bash "$WI" install >/dev/null 2>&1 || fail_msg "I13: re-run install must succeed"
|
||||
nt="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.timer' | grep -c .)"
|
||||
ns="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.service' | grep -c .)"
|
||||
[ "$nt" -eq 1 ] && [ "$ns" -eq 1 ] || fail_msg "I13: re-install must not duplicate the fallback entries (timer=$nt service=$ns)"
|
||||
bash "$WI" validate-fallback-units >/dev/null 2>&1 || fail_msg "I13: re-install must keep the fallback units resolvable"
|
||||
) && ok
|
||||
|
||||
echo "== I14: per-class fallback cadence — blank-reset yields EXACTLY ONE OnUnitActiveUSec (negative control leaks two) =="
|
||||
(
|
||||
UD="$(fresh i14-units)"
|
||||
export WAKE_SYSTEMD_USER_DIR="$UD"
|
||||
TIMER="mosaic-wake-fallback.timer"
|
||||
# The shipped timer carries the base OnUnitActiveSec placeholder (=1h).
|
||||
cp "$FRAMEWORK_ROOT/systemd/user/$TIMER" "$UD/$TIMER"
|
||||
grep -q '^OnUnitActiveSec=1h' "$UD/$TIMER" || fail_msg "I14: shipped fallback timer must carry a base OnUnitActiveSec placeholder"
|
||||
# write-fallback-cadence writes the per-class cadence in BLANK-RESET form.
|
||||
out="$(bash "$WI" write-fallback-cadence 30min 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 0 ] || fail_msg "I14: write-fallback-cadence must succeed + verify single (rc=$rc) [$out]"
|
||||
echo "$out" | grep -qi 'exactly one' || fail_msg "I14: the cadence write must confirm exactly-one OnUnitActiveUSec [$out]"
|
||||
[ -f "$UD/$TIMER.d/cadence.conf" ] || fail_msg "I14: the per-class cadence drop-in must be written under <timer>.d/"
|
||||
# Assert the blank-reset FORM: an empty `OnUnitActiveSec=` reset line IMMEDIATELY
|
||||
# FOLLOWED by the new value. Portable across GNU and BusyBox grep (Alpine CI) —
|
||||
# `grep -z` (NUL-data) is a GNU-only extension BusyBox grep does NOT support, so
|
||||
# match the reset line and require the value on the very next line (-A1) instead.
|
||||
grep -A1 '^OnUnitActiveSec=$' "$UD/$TIMER.d/cadence.conf" | grep -qx 'OnUnitActiveSec=30min' \
|
||||
|| fail_msg "I14: the drop-in must use the blank-reset form (empty reset line, then the value)"
|
||||
bash "$WI" verify-single "$TIMER" >/dev/null 2>&1 || fail_msg "I14: base(1h)+blank-reset(30min) must resolve exactly one OnUnitActiveUSec"
|
||||
# NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> base 1h + 30min -> two -> fail.
|
||||
printf '[Timer]\nOnUnitActiveSec=30min\n' >"$UD/$TIMER.d/cadence.conf"
|
||||
out2="$(bash "$WI" verify-single "$TIMER" 2>&1)"; rc2=$?
|
||||
[ "$rc2" -ne 0 ] || fail_msg "I14: without the reset line two cadences must leak (verify should FAIL) [$out2]"
|
||||
) && ok
|
||||
|
||||
echo
|
||||
if [ -s "$FAILFILE" ]; then
|
||||
echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
||||
|
||||
@@ -51,6 +51,13 @@ WAKE_SNAPSHOT_DIR="${WAKE_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mo
|
||||
|
||||
# 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).
|
||||
@@ -108,6 +115,11 @@ _wi_component_candidates() {
|
||||
# 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"
|
||||
}
|
||||
|
||||
@@ -164,6 +176,14 @@ wi_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"
|
||||
}
|
||||
@@ -255,6 +275,183 @@ wi_validate_systemd_path() {
|
||||
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 <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)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -512,6 +709,17 @@ wi_reset_verify_retire() {
|
||||
# 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.
|
||||
@@ -534,6 +742,10 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
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 "$@" ;;
|
||||
@@ -552,6 +764,10 @@ Commands:
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user