Framework install machinery for the wake component (EPIC #892 W7, the last build slice). ADDITIVE per #869: adds an `install.sh --component wake` early dispatch that never enters the full-framework sync, never alters framework-manifest ownership behavior, and touches nothing the #869 install-ordering-guard covers (no runtime-asset linking, no lease-enforcement hook wiring). (i) Idempotent component-manifest install + Gate A (wake-install.sh install): the wake manifest.txt is VERSION METADATA ONLY; the component file set is INTERSECTED-AND-VALIDATED against the single SSOT framework-manifest.txt. A candidate the SSOT does not own is REFUSED fail-closed with no partial write. Re-running writes zero files (no diff). (ii) systemd/user/mosaic-wake.service — the long-lived detector daemon (detector.sh run). Per-class SLO lives inside the daemon, not a systemd interval; it is a SERVICE not a timer, so blank-reset does not apply. (iii) blank-reset idiom on the legacy mosaic-heartbeat@<agent>.timer cadence drop-in during the §5 overlap->retire lifecycle (empty OnUnitActiveSec= reset before the new value => exactly one OnUnitActiveUSec), with a reset->verify->retire acceptance path (retire LAST, only on §4-vector pass). (iv) snapshot-guard — a reap/clean-checkout of a deployed unit is REFUSED without a prior snapshot (the deployed-from-uncommitted failure class). (v) fail-closed alarm-target + HMAC-key install-validation (G1/G2a): the operator W6 alarm sink must be configured + reachable and the W3/W7 HMAC key must resolve BY NAME; missing/unreachable => FAIL LOUD. The installer ships/writes NO endpoint value and NO secret, and echoes neither. Red-first harness test-wake-install.sh (6 groups) wired into test:framework-shell; Gate-A parity extended to prove bash+TS both resolve the wake component paths framework-owned. wake component manifest bumped 0.5.0 -> 0.6.0. Part of #892 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158NZqN2n2ymKFeJAZ4GUCb
445 lines
23 KiB
Bash
Executable File
445 lines
23 KiB
Bash
Executable File
#!/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@<agent>.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}"
|
|
|
|
# shellcheck source=../_lib/manifest.sh disable=SC1091
|
|
. "$SCRIPT_DIR/../_lib/manifest.sh"
|
|
|
|
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; }
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# (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"
|
|
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)."
|
|
# Machine-readable summary for the harness (idempotency assertion keys off it).
|
|
printf 'wake-install: written=%s skipped=%s missing=%s\n' "$written" "$skipped" "$missing"
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# (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.<name>) 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 <unit>.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" <<EOF
|
|
[Timer]
|
|
OnUnitActiveSec=
|
|
OnUnitActiveSec=$interval
|
|
EOF
|
|
wi_ok "blank-reset drop-in written: $file (OnUnitActiveSec reset -> $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@<agent> 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
|
|
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 "$@" ;;
|
|
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 <command> [args]
|
|
|
|
Commands:
|
|
install Idempotent component-manifest install + Gate A.
|
|
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
|