feat(secrets): rotation lock for vault read-modify-write cycles (P8, 2/3; stacked on #98) #99

Merged
jfraeys merged 1 commit from jfraeys/feat/rotation-lock into main 2026-10-13 13:03:18 -04:00
Owner

Merge #98 first (stacked: both add a lint step; until #98 merges the diff also shows its commit).

Nothing serialised two vault rewrites, so two runs (a rotation and a vault-set, or two rotations) could silently lose one update. Plan P8: an OS-level-style lock, stale detection after crashes, documented manual recovery, never delete a lock owned by a live process.

  • scripts/lib/rotation_lock.py (+ rotation-lock.sh for bash): mkdir lock (stock macOS has no flock) with an owner record: pid, host, user, start time, command (key names only, never values). Live owner on this host: refused (exit 3) with who/since/what, and break refuses too. Dead owner on this host (crash, kill -9): treated as stale and cleared on the next run with a [WARN] (atomic rename first, so two racers cannot both clear it). Owner on another host or an ownerless lock: refused (3 / 4), break --force after you checked. Children of the owner re-enter via ROTATION_LOCK_HELD and never release the parent's lock.
  • Wired into sopsvault.upsert/write (held across the read and the write: no lost update; busy lock = a readable error) and scripts/rotate-secret.sh (not on --dry-run; its EXIT trap also releases).
  • scripts/tests/test-rotation-lock.sh, added to the lint workflow, with real processes: live owner refused and named, break and break --force refuse a live owner, kill -9 then stale recovery with the warning, re-entrancy (child passes, child release is a no-op, a stranger is refused), Python API nesting, foreign-host lock (only --force clears), ownerless lock, 8 racers with exactly 1 winner and 7 refused, and the real callers (sopsvault.upsert, rotate-secret.sh) refusing a busy lock without touching secrets/vault.sops.yml (checksum compared). 31 checks pass; shellcheck -S error clean on the new and changed shell files.
  • Docs: docs/secrets-management-sops.md (table of situations, manual recovery), scripts/CLAUDE.md (new vault writers must take the lock).

Not done here: the monthly workflow and the manifest data (PR 3).

🤖 Generated with Claude Code

**Merge #98 first** (stacked: both add a lint step; until #98 merges the diff also shows its commit). Nothing serialised two vault rewrites, so two runs (a rotation and a vault-set, or two rotations) could silently lose one update. Plan P8: an OS-level-style lock, stale detection after crashes, documented manual recovery, never delete a lock owned by a live process. - `scripts/lib/rotation_lock.py` (+ `rotation-lock.sh` for bash): `mkdir` lock (stock macOS has no flock) with an owner record: pid, host, user, start time, command (key names only, never values). Live owner on this host: refused (exit 3) with who/since/what, and `break` refuses too. Dead owner on this host (crash, kill -9): treated as stale and cleared on the next run with a [WARN] (atomic rename first, so two racers cannot both clear it). Owner on another host or an ownerless lock: refused (3 / 4), `break --force` after you checked. Children of the owner re-enter via ROTATION_LOCK_HELD and never release the parent's lock. - Wired into `sopsvault.upsert`/`write` (held across the read and the write: no lost update; busy lock = a readable error) and `scripts/rotate-secret.sh` (not on --dry-run; its EXIT trap also releases). - `scripts/tests/test-rotation-lock.sh`, added to the lint workflow, with real processes: live owner refused and named, `break` and `break --force` refuse a live owner, kill -9 then stale recovery with the warning, re-entrancy (child passes, child release is a no-op, a stranger is refused), Python API nesting, foreign-host lock (only --force clears), ownerless lock, 8 racers with exactly 1 winner and 7 refused, and the real callers (`sopsvault.upsert`, `rotate-secret.sh`) refusing a busy lock without touching secrets/vault.sops.yml (checksum compared). 31 checks pass; shellcheck -S error clean on the new and changed shell files. - Docs: `docs/secrets-management-sops.md` (table of situations, manual recovery), `scripts/CLAUDE.md` (new vault writers must take the lock). Not done here: the monthly workflow and the manifest data (PR 3). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(secrets): one expiry classifier and a manifest-only expiry report
All checks were successful
Lint / lint (pull_request) Successful in 1m13s
Secret scan / scan (pull_request) Successful in 14s
44ba182f43
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
feat(secrets): rotation lock for vault read-modify-write cycles
All checks were successful
Lint / lint (pull_request) Successful in 1m58s
Secret scan / scan (pull_request) Successful in 12s
b35100b7dc
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfraeys/infra!99
No description provided.