diff --git a/scripts/deploy-stack.sh b/scripts/deploy-stack.sh index 099b0fc..7247ea9 100755 --- a/scripts/deploy-stack.sh +++ b/scripts/deploy-stack.sh @@ -131,7 +131,13 @@ if [ -x "$OPS_LOG" ] && [ "${DEPLOY_NO_CLAIM:-0}" != 1 ]; then "$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 ;; + 0) CLAIMED=1 ;; + 10) # We only refreshed a claim this agent already held for a wider + # operation. Do NOT release it on the way out — dropping someone's + # multi-step claim mid-operation is exactly the exposure the claim + # exists to prevent. + CLAIMED=0 + echo "note: $HOST/$STACK was already claimed by you — leaving that claim in place." ;; 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 ;; diff --git a/scripts/ops-log b/scripts/ops-log index 932281f..e4292fd 100755 --- a/scripts/ops-log +++ b/scripts/ops-log @@ -54,6 +54,8 @@ EXIT CODES 0 success / claim acquired / nothing to report 2 usage error 3 claim refused (held by another agent) + 10 claim REFRESHED — you already held it. The caller did NOT acquire it and + must NOT release it on the way out (see deploy-stack.sh). 4 audit found unlogged changes 5 audit could not reach every host (incomplete — NOT the same as clean) """ @@ -246,6 +248,17 @@ def cmd_claim(args) -> int: _drop_claim(args.host, args.target) record(args.host, "claim-broken", args.target, outcome="changed" if state == "theirs" else "ok", detail=detail) + if state == "mine": + # Already ours: leave the holder file ALONE. A sub-tool refreshing a + # claim would otherwise overwrite the reason and TTL the original + # claimant chose — so a 45-minute "rollout in progress" becomes + # "deploy-stack.sh ", and whoever gets refused reads the + # wrong story about why. Report 10 and change nothing. + if not args.quiet: + print(f"already claimed by you: {args.host}/{args.target} " + f"({(holder or {}).get('why') or 'no reason given'}, " + f"{holder_age(holder)})") + return 10 d = CLAIMS_DIR / claim_key(args.host, args.target) try: d.mkdir(parents=True, exist_ok=False) # atomic: this IS the acquire @@ -269,6 +282,12 @@ def cmd_claim(args) -> int: if not args.quiet: print(f"claimed {args.host}/{args.target} for {args.ttl} " f"({holder['why'] or 'no reason given'})") + # Exit 10 when this only REFRESHED a claim the caller already held. A tool + # that claims-then-releases around its own work must not drop a longer + # claim wrapping a multi-step operation. Measured 2026-09-19: a 45-minute + # operation claim on nh3-docker/althing-post-office was silently released + # by deploy-stack.sh's exit trap partway through the rollout it was + # protecting. return 0