feat(ops-log): attribute host changes across two agents sharing one identity
infra-ops and infra-hermes act as the same OS identity and dockerd does not
log exec per caller, so host-side changes carry no fingerprint. Git cannot
close the gap either: every commit here is attributed to Vuong Hoang by
convention, which is correct for authorship and useless for attribution.
On 2026-09-18 a second session edited the searxng stack mid-deploy, crash-
looping fleet search for ~4 minutes, and the author was unidentifiable.
scripts/ops-log records one line per host-changing action and holds a
lightweight claim so two agents do not deploy the same stack at once.
Four design questions, settled:
* Central on nh3-dev, not per-host and not the post office. Both agents
run as the same unix user there, so one file is shared with zero
provisioning. Per-host needs a writable path on ~25 heterogeneous boxes
and stores "we changed host Y" on host Y. journald looked free but shows
an unprivileged reader only their own _UID, which would have split the
log silently between the infra-ops and lkraven halves of the fleet.
* The claim is advisory and enforced in the tooling. deploy-stack.sh
refuses a foreign claim across the diff, the prompt and the apply -- the
whole review window, which is where the collision happened. Acquire is
mkdir, so it is atomic rather than probably-fine. Stale claims auto-break
and the break is recorded.
* Writers are automatic. deploy-stack.sh and elway record themselves; a log
that depends on remembering is the same class of instrument as a health
check that passes in both states.
* There is a detector. `ops-log audit` asks each host what changed on disk
and compares it to the newest log line for that stack, covering the
manual ssh-and-edit path the automatic writers structurally cannot.
ops-log being absent or broken never blocks a deploy; only a live foreign
claim does. `ops-log baseline` marks the 136 stacks that predate the
instrument so the detector starts from today rather than reporting the whole
fleet forever and training us to ignore it.
An unreachable host reports INCOMPLETE and exit 5, never clean.
This commit is contained in:
+55
-1
@@ -28,6 +28,19 @@
|
||||
# Optional environment:
|
||||
# DEPLOY_DEST_STACK=<name> retain a legacy remote stack directory/project
|
||||
# DEPLOY_SUDO=1 use passwordless sudo for remote files and rsync
|
||||
# DEPLOY_NO_CLAIM=1 skip the ops-log claim (see below — use sparingly)
|
||||
# DEPLOY_CLAIM_TTL=<dur> how long the claim stays live (default 30m)
|
||||
#
|
||||
# OPS LOG + CLAIM (added 2026-09-19)
|
||||
# `infra-ops` and `infra-hermes` are two agents sharing ONE OS identity, so
|
||||
# host-side changes are otherwise fingerprint-less. Before touching the
|
||||
# host this script claims <host>/<stack> via scripts/ops-log and holds the
|
||||
# claim across the diff, the y/N prompt and the apply — that whole window is
|
||||
# where the 2026-09-18 searxng collision happened, not just the rsync. It
|
||||
# then records what it pushed.
|
||||
# A REFUSAL (another agent holds the claim) is fatal. ops-log being absent
|
||||
# or broken is NOT: the deploy path must not gain a new single point of
|
||||
# failure just because it grew an audit trail.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -76,7 +89,7 @@ for a in "$@"; do
|
||||
--yes|-y) ASSUME_YES=1 ;;
|
||||
--compose) DO_CONF=0 ;;
|
||||
--conf) DO_COMPOSE=0 ;;
|
||||
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
|
||||
-h|--help) sed -n '2,43p' "$0"; exit 0 ;;
|
||||
-*) echo "error: unknown flag $a" >&2; exit 2 ;;
|
||||
*)
|
||||
if [ -z "$HOST" ]; then HOST="$a"
|
||||
@@ -101,6 +114,31 @@ case "$DEST_STACK" in
|
||||
esac
|
||||
[[ "$DEST_STACK" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] || { echo "invalid stack name: $DEST_STACK" >&2; exit 2; }
|
||||
|
||||
# --------- Claim the stack before any remote work. --------------------
|
||||
OPS_LOG="$SCRIPT_DIR/ops-log"
|
||||
CLAIMED=0
|
||||
release_claim() {
|
||||
if [ "$CLAIMED" -eq 1 ]; then
|
||||
"$OPS_LOG" release "$HOST" "$STACK" -q >/dev/null 2>&1 || true
|
||||
CLAIMED=0
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
trap release_claim EXIT
|
||||
|
||||
if [ -x "$OPS_LOG" ] && [ "${DEPLOY_NO_CLAIM:-0}" != 1 ]; then
|
||||
claim_rc=0
|
||||
"$OPS_LOG" claim "$HOST" "$STACK" --ttl "${DEPLOY_CLAIM_TTL:-30m}" \
|
||||
--why "deploy-stack.sh $HOST $STACK" -q || claim_rc=$?
|
||||
case "$claim_rc" in
|
||||
0) CLAIMED=1 ;;
|
||||
3) echo "error: refused — see the claim above. Wait for the holder, coordinate" >&2
|
||||
echo " on althing, or override with DEPLOY_NO_CLAIM=1 if it is dead." >&2
|
||||
exit 3 ;;
|
||||
*) echo "warning: ops-log claim failed (exit $claim_rc) — deploying UNCLAIMED." >&2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
resolve_target() {
|
||||
# ssh-target file wins when present (may carry user@ or non-default port);
|
||||
# /etc/hosts + ssh_config is the fallback.
|
||||
@@ -315,4 +353,20 @@ for entry in "${PAIRS[@]}"; do
|
||||
"$src" "$dest"
|
||||
done
|
||||
|
||||
# --------- Record what we just did. -----------------------------------
|
||||
# Counted from the dry-run itemize, which is what the operator actually
|
||||
# reviewed and approved — not re-derived after the fact.
|
||||
if [ -x "$OPS_LOG" ]; then
|
||||
summary=""
|
||||
for entry in "${PAIRS[@]}"; do
|
||||
IFS='|' read -r kind _ _ <<<"$entry"
|
||||
n_ch=$(grep -c . <<<"${CHANGED_FILES_BY_KIND[$kind]:-}" || true)
|
||||
n_del=$(grep -c . <<<"${DELETED_FILES_BY_KIND[$kind]:-}" || true)
|
||||
summary+="${summary:+, }$kind ${n_ch:-0} changed/${n_del:-0} deleted"
|
||||
done
|
||||
[ "$DEST_STACK" != "$STACK" ] && summary+=" (remote dir $DEST_STACK)"
|
||||
"$OPS_LOG" record --host "$HOST" --action deploy-stack --target "$STACK" \
|
||||
--outcome changed --detail "$summary" -q || true
|
||||
fi
|
||||
|
||||
echo "done."
|
||||
|
||||
Reference in New Issue
Block a user