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:
vh
2026-09-19 05:05:43 -07:00
parent 4d826e17e3
commit ffe7b24935
7 changed files with 960 additions and 22 deletions
+52
View File
@@ -737,6 +737,54 @@ def print_summary(step_results: list[StepResult], verify_results: list[StepResul
return exit_code
# ─── ops log ───────────────────────────────────────────────────────────────
#
# `infra-ops` and `infra-hermes` are two agents behind one OS identity, so a
# change made over ssh is otherwise fingerprint-less. elway is the sanctioned
# way to CHANGE things on a host, which makes it the right place to record
# them — automatically, because a log you have to remember to write is the
# same class of instrument as a health check that passes in both states.
# See scripts/ops-log and docs/pfi/ops-log.md.
OPS_LOG_BIN = Path(__file__).resolve().parent / "ops-log"
def record_to_ops_log(host: str, playbook: Optional[str], adhoc: Optional[str],
step_results: list, verify_results: list) -> None:
"""Best-effort. A failure here must never change elway's exit code or
output — the audit trail is not allowed to become a new failure mode on
the path that fixes things."""
if not OPS_LOG_BIN.is_file():
return
tallies = []
for phase, res in (("steps", step_results), ("verify", verify_results)):
if not res:
continue
t = _tally(res)
tallies.append(f"{phase} {t['ok']} ok/{t['changed']} changed/"
f"{t['failed']} failed/{t['skipped']} skipped")
all_res = step_results + verify_results
if any(r.state == "failed" for r in all_res):
outcome = "failed"
elif any(r.state == "changed" for r in all_res):
outcome = "changed"
else:
outcome = "ok"
target = Path(playbook).stem if playbook else "ad-hoc"
detail = "; ".join(tallies) or "no steps ran"
if adhoc:
# Truncated: the ops log is an index of what happened, not a
# transcript — the full command is in the elway run log.
detail += f" | {adhoc[:160]}"
try:
subprocess.run(
[str(OPS_LOG_BIN), "record", "--host", host, "--action", "elway",
"--target", target, "--outcome", outcome, "--detail", detail, "-q"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
except Exception:
pass
# ─── CLI ───────────────────────────────────────────────────────────────────
@@ -874,6 +922,10 @@ def main(argv: Optional[list[str]] = None) -> int:
dry_run=args.dry_run,
)
rc = print_summary(step_results, verify_results)
if not args.dry_run:
record_to_ops_log(args.host, args.playbook,
args.shell or args.upload, step_results,
verify_results)
if perm_log is not None:
print(DIM(f"log saved: {perm_log}"))
return rc