carlos / platform Public

host: housekeeper via the one lease mechanism; prune single-holder

The deployment's global duties get a single elected box (spec
2026-08-20-multi-region-ingress-design.md, Part 1), and the election is
internal/lease.Acquire — the same conditional-write lock the status
publisher has used since the fleet status page shipped. No second
election pattern: no store-CAS lease, no control/housekeeper.json, no
bespoke TTL. The lock lands at control/housekeeper-lease/lock.json in the
deployment bucket, held long-term and renewed by internal/lease, with
housekeeperHeld() a cheap atomic read for the duty ticks (and, from Part
2, the edge's cert-issuance gate).

The releases retention prune is the first duty to move behind it: N boxes
pruning releases/** concurrently is the bug class the spec refuses to
have. Fleet boxes never contend (the shouldSkipPublisherElection
predicate the retention loop already used); a CARLOS_DEPLOYMENT_DIR
deployment is its own housekeeper, so dev/test boxes keep pruning; losing
the election is silent, exactly as the status tick swallows contention.
domains.Reconcile's every-box PurgeApp is deliberately untouched (audit
finding §3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 45a2ea659c7ed9dc71d0a995f6c5c5561c8386c4 parent 5c1ab41
3 files changed, +737 −4
  • internal/host/agent.go +30 −4
  • internal/host/housekeeper.go +303 −0
  • internal/host/housekeeper_test.go +404 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index b7498b778..ec245302d 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -97,6 +97,16 @@
// page's entire config surface is documented in one place rather than
// split across this file and the tofu module.
//
+// The status-lease above is one of TWO elections this process runs, and the
+// only per-tick one. The other is the HOUSEKEEPER lease (housekeeper.go):
+// the same internal/lease mechanism, held long-term, at the fixed
+// deployment-bucket key control/housekeeper-lease/lock.json, electing the one
+// box that performs this deployment's global duties — today the releases
+// retention prune (retentionLoop), from Part 2 of the multi-region spec
+// certificate issuance. It reads CARLOS_DEPLOYMENT_BUCKET (or
+// CARLOS_DEPLOYMENT_DIR, which makes the box its own housekeeper) and the
+// same CARLOS_FLEET guard, and adds no env var of its own.
+//
// Domains config surface (read by domainsLoop / domainsTickConfig):
//
// - PLATFORM_APPS_DOMAIN — the deployment's platform apps domain
@@ -303,6 +313,11 @@ func RunAgent(args []string) {
go statusLoop(reg)
go domainsLoop(reg, peekAcmeCache(args))
go instancesLoop(reg)
+ // The deployment-global election, armed BEFORE the loops it gates so a
+ // single-box deployment is holding well before their first tick (a
+ // minute away) — see housekeeper.go. Not a `go` of its own: it publishes
+ // the process's housekeeper and spawns its loop itself.
+ startHousekeeper()
go retentionLoop(reg)
go registrySnapshotLoop(reg)
// The sweep reads the registry only through this closure — the stamped
@@ -1546,6 +1561,16 @@ func instancesLoop(reg *registry.Registry) {
// deployment config or a missing/unparseable release pubkey disables the
// loop with one log line — fail closed, domainsLoop's auto-purge shape: a
// box that cannot verify pointers must not delete anything.
+//
+// Since the multi-region writer map (spec
+// 2026-08-20-multi-region-ingress-design.md, Part 1) the pass itself is a
+// HOUSEKEEPER DUTY: it runs only on the one box holding the deployment's
+// housekeeper lease. releases/** is deployment-global state, and N boxes
+// pruning it concurrently is the bug class the spec refuses to have. Every
+// other box still arms this loop and still ticks — it just prunes nothing,
+// silently, until the day it wins the lease (housekeeper.go). On a
+// single-box deployment the holder is always this box, so the behaviour is
+// unchanged.
func retentionLoop(reg *registry.Registry) {
ctx := context.Background()
// Platform boxes only (spec F6). Retention prunes the PLATFORM's release
@@ -1576,14 +1601,15 @@ func retentionLoop(reg *registry.Registry) {
log.Printf("agent: retention loop disabled: %v — retention policies will not be enforced by this box", err)
return
}
- log.Printf("agent: retention loop armed (5min + jitter)")
+ log.Printf("agent: retention loop armed (5min + jitter) — prunes only while this box holds the housekeeper lease")
+ prune := housekeeperDuty{"retention pass", func(ctx context.Context) error {
+ return retentionPass(ctx, store, pub, reg)
+ }}
delay := time.Minute
for {
time.Sleep(delay)
delay = 5*time.Minute + rand.N(time.Minute)
- if err := retentionPass(ctx, store, pub, reg); err != nil {
- log.Printf("agent: retention pass: %v", err)
- }
+ runHousekeeperDuties(ctx, housekeeperHeld, prune)
}
}
diff --git a/internal/host/housekeeper.go b/internal/host/housekeeper.go
new file mode 100644
index 000000000..9ff5cf7ec
--- /dev/null
+++ b/internal/host/housekeeper.go
@@ -0,0 +1,303 @@
+// The housekeeper: the ONE election for this deployment's global duties
+// (spec 2026-08-20-multi-region-ingress-design.md, Part 1). A deployment now
+// has N platform boxes converging one control bucket, and the rule the writer
+// map states is "data that is per-box gets per-box keys; duties that are
+// deployment-global get a lease". The releases retention prune is the first
+// such duty to move behind it — five concurrent pruners racing a promote is a
+// bug class the spec refuses to have — and Part 2's cert issuance is the next.
+//
+// The mechanism is deliberately NOT new. It is internal/lease.Acquire, the
+// same conditional-write lock (litestream's lss3.Leaser writing
+// <prefix>/lock.json) the status publisher has elected with since the fleet
+// status page shipped — see statusLoop's lease.Acquire call in agent.go and
+// the bucket-writer audit's §1. The spec's own words: "The status publisher's
+// CAS-lease is the one election mechanism, reused; no second election pattern
+// may be introduced." So there is no store-CAS lease here, no
+// control/housekeeper.json record, and no bespoke TTL.
+//
+// Two things DO differ from the status election, both on purpose:
+//
+// - It is long-lived, not per-tick. The status loop acquires and releases
+// inside one 5-minute tick because publishing is a single act; the
+// housekeeper's duties are continuous and Task 4's edge cert gate has to
+// be answerable at any instant, so this box acquires once and RENEWS
+// (lease.Acquire starts the renewer itself) for as long as it can, and
+// housekeeperHeld() is a non-blocking read of that state.
+// - It leases in the DEPLOYMENT bucket at a fixed key —
+// control/housekeeper-lease/lock.json — not under CARLOS_STATUS_PREFIX.
+// The duties act on the deployment bucket's own control plane
+// (releases/**, later control/certs/**), and that bucket is the one
+// linearizable home the spec pins the control plane to.
+//
+// The accepted price is lease.LeaseTTL, 15 minutes: a HARD-crashed
+// housekeeper strands the lease until expiry, so handover can stall up to
+// ~15m. Nothing here cares. The prune is idempotent and not urgent (a policy
+// written now is promised "within ~10 minutes", and the loop is a 5-minute
+// tick anyway), and cert renewals run ~30 days early. A GRACEFUL stand-down
+// releases immediately.
+package host
+
+import (
+ "context"
+ "errors"
+ "log"
+ "os"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/lease"
+)
+
+// housekeeperLeasePrefix is the deployment-bucket prefix the election's lock
+// lands under: the leaser writes <prefix>/lock.json, so the object is
+// control/housekeeper-lease/lock.json. Under control/ because it is control
+// plane, and a fixed key because there is exactly one housekeeper per
+// deployment — the same "the bucket IS the scope" property the status-lease
+// relies on (one control bucket per deployment, forever).
+const housekeeperLeasePrefix = "control/housekeeper-lease"
+
+// housekeeperRetry spaces a losing contender's attempts. Each attempt is one
+// conditional PUT against the lock (a GET first when a lock exists), so this
+// is the same request-cost trade internal/lease's LeaseTTL comment weighs:
+// 5 minutes is 288 attempts/day/box, in step with the status election's own
+// per-tick contention, and bounds takeover after a hard crash at roughly the
+// 15m TTL plus one retry.
+const housekeeperRetry = 5 * time.Minute
+
+// housekeeperPoll is how often a HOLDER checks that renewal is still winning.
+// Renewal itself runs inside internal/lease (TTL/3 = 5m); this only decides
+// how quickly a box that has LOST the lease stops believing it holds it —
+// which is exactly how long a stale holder could keep running duties after a
+// takeover. 30 seconds is cheap (a mutexed field read, no I/O at all).
+const housekeeperPoll = 30 * time.Second
+
+// heldLease is the slice of *lease.Held this file uses: renewal health and
+// release. An interface only so the election transport is injectable — the
+// lock's own semantics are internal/lease's to test, against a real
+// S3-compatible store.
+type heldLease interface {
+ Err() error
+ Release(ctx context.Context) error
+}
+
+// leaseAcquirer is lease.Acquire's shape, as a seam.
+type leaseAcquirer func(ctx context.Context, bucket, prefix, region string) (heldLease, error)
+
+// acquireDeploymentLease is the production acquirer. The region is
+// deliberately empty: internal/lease's resolveRegion then answers from the
+// AWS default chain (AWS_REGION / profile), which is precisely what
+// release.NewS3Store signs for against this same bucket — a housekeeper lease
+// signing for a different region than the store it guards would be a split
+// waiting to happen. No new env var: the deployment bucket has never had a
+// region variable of its own, and inventing one here would be a second answer
+// to a question the chain already answers.
+//
+// The nil-into-interface hazard is why this is a func and not a method value:
+// lease.Acquire returns a typed nil *lease.Held alongside its error, which
+// would arrive as a NON-nil heldLease if handed straight back.
+func acquireDeploymentLease(ctx context.Context, bucket, prefix, region string) (heldLease, error) {
+ h, err := lease.Acquire(ctx, bucket, prefix, region)
+ if err != nil {
+ return nil, err
+ }
+ return h, nil
+}
+
+// housekeeperMode is this box's relationship to the election, decided once
+// from the environment.
+type housekeeperMode int
+
+const (
+ // housekeeperNever: this box must never run deployment-global duties —
+ // a fleet box (a customer's machine; the platform's releases are not
+ // its business, and the store door would refuse the deletes anyway), or
+ // a box with no deployment store at all.
+ housekeeperNever housekeeperMode = iota
+ // housekeeperSole: a CARLOS_DEPLOYMENT_DIR deployment. There is no
+ // bucket to hold a lock in, and a directory store is one box by
+ // construction, so this box IS the housekeeper — anything else would
+ // silently stop dev/test deployments pruning at all.
+ housekeeperSole
+ // housekeeperElect: a bucket-backed platform box. Contend.
+ housekeeperElect
+)
+
+// housekeeper is the long-lived acquire/renew/stand-down lifecycle plus the
+// flag every gated caller reads.
+type housekeeper struct {
+ mode housekeeperMode
+ reason string // the one line run() logs when it arms
+ bucket string
+ acquire leaseAcquirer
+ retry time.Duration
+ poll time.Duration
+
+ flag atomic.Bool
+}
+
+// newHousekeeper decides the mode from the environment. Nothing is acquired
+// here — run() does the work — EXCEPT the sole-housekeeper case, which is a
+// pure fact about the deployment shape and is therefore true from the moment
+// the box is configured, not from whenever a goroutine happens to be
+// scheduled.
+func newHousekeeper(getenv func(string) string, acquire leaseAcquirer) *housekeeper {
+ hk := &housekeeper{acquire: acquire, retry: housekeeperRetry, poll: housekeeperPoll}
+ switch {
+ // Deliberately the bare CARLOS_FLEET test that retentionLoop and
+ // instancesLoop already use, for the same reason retentionLoop states:
+ // a fleet box whose LABEL is misconfigured is still a fleet box.
+ case shouldSkipPublisherElection(getenv):
+ hk.mode = housekeeperNever
+ hk.reason = "fleet box — deployment-global duties are a platform-box job; never contending"
+ case getenv("CARLOS_DEPLOYMENT_BUCKET") != "":
+ // The bucket openDeployStore already adopts from — the duties act on
+ // its contents, so the lock guarding them belongs in it.
+ hk.mode = housekeeperElect
+ hk.bucket = getenv("CARLOS_DEPLOYMENT_BUCKET")
+ case getenv("CARLOS_DEPLOYMENT_DIR") != "":
+ hk.mode = housekeeperSole
+ hk.reason = "directory-backed deployment — this box is its own housekeeper (no bucket to elect in)"
+ hk.flag.Store(true)
+ default:
+ hk.mode = housekeeperNever
+ hk.reason = "no deployment store configured — never contending"
+ }
+ return hk
+}
+
+// held reports whether this box currently holds the lease. Cheap and
+// non-blocking by contract: it is read on a duty tick and, from Part 2, on
+// the edge's certificate path.
+func (hk *housekeeper) held() bool {
+ if hk == nil {
+ return false
+ }
+ return hk.flag.Load()
+}
+
+// run is the election loop; it returns only when ctx ends (or immediately, on
+// a box that never contends).
+//
+// NOT holding the lease is a normal, quiet state — it is what N-1 boxes are
+// in, permanently. Contention (*lease.HeldError) is therefore never logged,
+// exactly as statusLoop swallows it: a line per retry per box, forever, would
+// bury the log. A real acquire failure (credentials, a broken bucket) IS
+// logged, same as any other tick failure.
+func (hk *housekeeper) run(ctx context.Context) {
+ if hk.mode != housekeeperElect {
+ log.Printf("agent: housekeeper: %s", hk.reason)
+ return
+ }
+ log.Printf("agent: housekeeper: contending for s3://%s/%s/lock.json (lease TTL %s)",
+ hk.bucket, housekeeperLeasePrefix, lease.LeaseTTL)
+ for ctx.Err() == nil {
+ held, err := hk.acquire(ctx, hk.bucket, housekeeperLeasePrefix, "")
+ if err != nil {
+ var heldErr *lease.HeldError
+ if !errors.As(err, &heldErr) && ctx.Err() == nil {
+ log.Printf("agent: housekeeper: lease acquire: %v", err)
+ }
+ if !sleepCtx(ctx, hk.retry) {
+ return
+ }
+ continue
+ }
+ log.Printf("agent: housekeeper: lease acquired — this box runs the deployment's global duties")
+ hk.flag.Store(true)
+ hk.hold(ctx, held)
+ }
+}
+
+// hold blocks while this box is the housekeeper, and clears the flag BEFORE
+// it gives the lease up either way — a duty must never start against a lease
+// this box has already decided to let go of.
+//
+// A failed renewal means leadership is lost (internal/lease stops renewing on
+// the first failure, precisely so a re-try can't fence a claimant who took
+// over after expiry). The lease is then NOT released: our etag is stale, so
+// the release would just fail a precondition, and the TTL is what hands the
+// lock over. A graceful stand-down (ctx ended: process shutting down) DOES
+// release, so the next box takes over in seconds instead of ~15 minutes.
+func (hk *housekeeper) hold(ctx context.Context, held heldLease) {
+ for {
+ if !sleepCtx(ctx, hk.poll) {
+ hk.flag.Store(false)
+ releaseHousekeeperLease(held)
+ return
+ }
+ if err := held.Err(); err != nil {
+ hk.flag.Store(false)
+ log.Printf("agent: housekeeper: lease renewal failed — standing down: %v", err)
+ return
+ }
+ }
+}
+
+// releaseHousekeeperLease hands the lock back on a graceful stand-down. The
+// release gets its OWN budget, never the caller's: by the time this runs the
+// agent's context is already dead, and a release under a dead context leaves
+// the lock stranded until TTL — the exact trap internal/lease's
+// Replicator.Close names, where it cost a wake up to a full TTL of 503s.
+func releaseHousekeeperLease(held heldLease) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := held.Release(ctx); err != nil {
+ log.Printf("agent: housekeeper: lease release: %v", err)
+ }
+}
+
+// sleepCtx waits for d, reporting false if ctx ended first.
+func sleepCtx(ctx context.Context, d time.Duration) bool {
+ t := time.NewTimer(d)
+ defer t.Stop()
+ select {
+ case <-ctx.Done():
+ return false
+ case <-t.C:
+ return true
+ }
+}
+
+// theHousekeeper is the process's one election, published by
+// startHousekeeper. A pointer rather than a bare flag so the loop owns its own
+// state and nothing else can set "held" from the side; nil (never armed)
+// reads as not held.
+var theHousekeeper atomic.Pointer[housekeeper]
+
+// housekeeperHeld reports whether THIS box currently holds the deployment's
+// housekeeper lease — the gate every deployment-global duty consults. Cheap,
+// non-blocking, and safe before (or without) startHousekeeper: false.
+func housekeeperHeld() bool { return theHousekeeper.Load().held() }
+
+// startHousekeeper arms the election for this process. Called once from
+// RunAgent, before the loops whose duties it gates.
+func startHousekeeper() {
+ hk := newHousekeeper(os.Getenv, acquireDeploymentLease)
+ theHousekeeper.Store(hk)
+ go hk.run(context.Background())
+}
+
+// housekeeperDuty is one deployment-global job: a name for the log and the
+// work. Today's set is the releases retention prune; Part 2 adds certificate
+// issuance.
+type housekeeperDuty struct {
+ name string
+ run func(context.Context) error
+}
+
+// runHousekeeperDuties runs one tick of the given duties — but only on the
+// box holding the lease. A non-holder does nothing and says nothing: that is
+// the normal state of every box but one, on every tick, forever.
+//
+// Per-duty isolation, the shape retentionPass already uses for per-app
+// errors: one duty's failure is logged and the next still runs.
+func runHousekeeperDuties(ctx context.Context, held func() bool, duties ...housekeeperDuty) {
+ if !held() {
+ return
+ }
+ for _, d := range duties {
+ if err := d.run(ctx); err != nil {
+ log.Printf("agent: housekeeper: %s: %v", d.name, err)
+ }
+ }
+}
diff --git a/internal/host/housekeeper_test.go b/internal/host/housekeeper_test.go
new file mode 100644
index 000000000..69aa4176b
--- /dev/null
+++ b/internal/host/housekeeper_test.go
@@ -0,0 +1,404 @@
+package host
+
+// What is testable here without a live S3 is the GATING — who contends, who
+// runs duties, and how the box behaves when it loses or never wins. The lock
+// semantics themselves (a second claimant gets a *lease.HeldError; a dark
+// holder cannot reclaim after failover) belong to internal/lease and are
+// covered there against a real S3-compatible store — internal/lease's own
+// fake (endpoint_test.go's newFakeS3) answers every GET with NoSuchKey, so it
+// proves where the traffic goes and nothing about contention. This file
+// deliberately invents no second fake S3: the election transport is injected
+// as a seam instead.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/lease"
+)
+
+// ---- helpers ---------------------------------------------------------------
+
+// lockedBuffer is an io.Writer safe for the election goroutine and the test
+// body to touch at once — log output is the assertion in the "stays quiet"
+// tests, so it has to be read while the loop is still running.
+type lockedBuffer struct {
+ mu sync.Mutex
+ b strings.Builder
+}
+
+func (l *lockedBuffer) Write(p []byte) (int, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.Write(p)
+}
+
+func (l *lockedBuffer) String() string {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.String()
+}
+
+// captureRacyLog redirects the standard logger for one test, through a
+// mutexed buffer: unlike this package's other captureLog, the writer here is
+// shared with a live election goroutine.
+func captureRacyLog(t *testing.T) *lockedBuffer {
+ t.Helper()
+ buf := &lockedBuffer{}
+ log.SetOutput(buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+ return buf
+}
+
+// quietLog silences the standard logger for a test that doesn't assert on it.
+func quietLog(t *testing.T) {
+ t.Helper()
+ log.SetOutput(io.Discard)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+}
+
+// fakeLease stands in for *lease.Held: renewal health is a func the test
+// flips, and releases are counted.
+type fakeLease struct {
+ mu sync.Mutex
+ err error
+ released int
+}
+
+func (f *fakeLease) fail(err error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.err = err
+}
+
+func (f *fakeLease) Err() error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.err
+}
+
+func (f *fakeLease) Release(context.Context) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.released++
+ return nil
+}
+
+func (f *fakeLease) releases() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.released
+}
+
+type acquireCall struct{ bucket, prefix, region string }
+
+// acquirer records every acquisition attempt and answers from a per-attempt
+// func, so a test can script "contended, contended, won".
+type acquirer struct {
+ mu sync.Mutex
+ calls []acquireCall
+ answer func(n int) (heldLease, error)
+}
+
+func (a *acquirer) acquire(_ context.Context, bucket, prefix, region string) (heldLease, error) {
+ a.mu.Lock()
+ a.calls = append(a.calls, acquireCall{bucket, prefix, region})
+ n := len(a.calls)
+ answer := a.answer
+ a.mu.Unlock()
+ return answer(n)
+}
+
+func (a *acquirer) count() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return len(a.calls)
+}
+
+func (a *acquirer) first() acquireCall {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.calls[0]
+}
+
+// contended is what a losing contender sees, wrapped exactly as
+// lease.Acquire wraps it.
+func contended() error {
+ return fmt.Errorf("acquire lease: %w", &lease.HeldError{Owner: "other-box", ExpiresAt: time.Now().Add(time.Minute)})
+}
+
+func waitFor(t *testing.T, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for %s", what)
+}
+
+// runElection starts hk.run in the background with fast timings and returns a
+// stop func that cancels it and waits for the loop to exit.
+func runElection(t *testing.T, hk *housekeeper) func() {
+ t.Helper()
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ hk.run(ctx)
+ }()
+ stop := func() {
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(3 * time.Second):
+ t.Error("election loop did not exit after cancel")
+ }
+ }
+ t.Cleanup(stop)
+ return stop
+}
+
+func fastHousekeeper(getenv func(string) string, a *acquirer) *housekeeper {
+ hk := newHousekeeper(getenv, a.acquire)
+ hk.retry = time.Millisecond
+ hk.poll = time.Millisecond
+ return hk
+}
+
+// ---- duty gating -----------------------------------------------------------
+
+func TestRunHousekeeperDutiesRunsEachDutyOncePerTickWhenHeld(t *testing.T) {
+ quietLog(t)
+ a, b := 0, 0
+ runHousekeeperDuties(context.Background(), func() bool { return true },
+ housekeeperDuty{"a", func(context.Context) error { a++; return nil }},
+ housekeeperDuty{"b", func(context.Context) error { b++; return nil }},
+ )
+ if a != 1 || b != 1 {
+ t.Fatalf("duty runs = (%d, %d), want (1, 1)", a, b)
+ }
+}
+
+func TestRunHousekeeperDutiesSkipsQuietlyWhenNotHeld(t *testing.T) {
+ buf := captureRacyLog(t)
+ ran := 0
+ runHousekeeperDuties(context.Background(), func() bool { return false },
+ housekeeperDuty{"retention pass", func(context.Context) error { ran++; return nil }},
+ )
+ if ran != 0 {
+ t.Fatalf("duty ran %d times on a non-holder, want 0", ran)
+ }
+ // Not holding the lease is the NORMAL state on every box but one, every
+ // tick, forever: a log line here would bury the log.
+ if out := buf.String(); out != "" {
+ t.Fatalf("non-holder logged %q, want silence", out)
+ }
+}
+
+func TestRunHousekeeperDutiesLogsOneDutysErrorAndRunsTheRest(t *testing.T) {
+ buf := captureRacyLog(t)
+ later := 0
+ runHousekeeperDuties(context.Background(), func() bool { return true },
+ housekeeperDuty{"retention pass", func(context.Context) error { return errors.New("boom") }},
+ housekeeperDuty{"later duty", func(context.Context) error { later++; return nil }},
+ )
+ if later != 1 {
+ t.Fatalf("later duty ran %d times, want 1 — one duty's error must not stop the next", later)
+ }
+ out := buf.String()
+ if !strings.Contains(out, "retention pass") || !strings.Contains(out, "boom") {
+ t.Fatalf("log = %q, want the failing duty's name and error", out)
+ }
+}
+
+// ---- who contends ----------------------------------------------------------
+
+func TestHousekeeperFleetBoxNeverAttemptsAcquisition(t *testing.T) {
+ quietLog(t)
+ a := &acquirer{answer: func(int) (heldLease, error) { return &fakeLease{}, nil }}
+ hk := fastHousekeeper(envFunc(map[string]string{
+ "CARLOS_FLEET": "acme",
+ "CARLOS_DEPLOYMENT_BUCKET": "dep-bucket",
+ }), a)
+
+ // run returns immediately on a fleet box — nothing to loop over.
+ hk.run(context.Background())
+
+ if a.count() != 0 {
+ t.Fatalf("fleet box attempted acquisition %d times, want 0", a.count())
+ }
+ if hk.held() {
+ t.Fatal("fleet box reports holding the housekeeper lease")
+ }
+}
+
+func TestHousekeeperWithNoDeploymentStoreNeverHolds(t *testing.T) {
+ quietLog(t)
+ a := &acquirer{answer: func(int) (heldLease, error) { return &fakeLease{}, nil }}
+ hk := fastHousekeeper(envFunc(map[string]string{}), a)
+
+ hk.run(context.Background())
+
+ if a.count() != 0 {
+ t.Fatalf("storeless box attempted acquisition %d times, want 0", a.count())
+ }
+ if hk.held() {
+ t.Fatal("storeless box reports holding the housekeeper lease")
+ }
+}
+
+func TestHousekeeperDirDeploymentIsItsOwnHousekeeper(t *testing.T) {
+ quietLog(t)
+ a := &acquirer{answer: func(int) (heldLease, error) { return &fakeLease{}, nil }}
+ hk := fastHousekeeper(envFunc(map[string]string{"CARLOS_DEPLOYMENT_DIR": t.TempDir()}), a)
+
+ hk.run(context.Background())
+
+ if a.count() != 0 {
+ t.Fatalf("dir deployment attempted acquisition %d times, want 0 — there is no bucket to elect in", a.count())
+ }
+ if !hk.held() {
+ t.Fatal("dir deployment does not hold: a directory-backed deployment is one box by construction and must keep pruning")
+ }
+}
+
+// ---- the election ----------------------------------------------------------
+
+func TestHousekeeperAcquiresTheDeploymentLeaseAndHolds(t *testing.T) {
+ quietLog(t)
+ a := &acquirer{answer: func(int) (heldLease, error) { return &fakeLease{}, nil }}
+ hk := fastHousekeeper(envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "dep-bucket"}), a)
+ runElection(t, hk)
+
+ waitFor(t, "the lease to be held", hk.held)
+
+ got := a.first()
+ want := acquireCall{bucket: "dep-bucket", prefix: housekeeperLeasePrefix, region: ""}
+ if got != want {
+ t.Fatalf("acquire call = %+v, want %+v", got, want)
+ }
+ if housekeeperLeasePrefix != "control/housekeeper-lease" {
+ t.Fatalf("lease prefix = %q, want control/housekeeper-lease (lock.json lands under it)", housekeeperLeasePrefix)
+ }
+}
+
+func TestHousekeeperLosingTheElectionIsQuietAndKeepsTrying(t *testing.T) {
+ buf := captureRacyLog(t)
+ a := &acquirer{answer: func(int) (heldLease, error) { return nil, contended() }}
+ hk := fastHousekeeper(envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "dep-bucket"}), a)
+ // The arming line is not what this test is about; drop everything logged
+ // before the loop starts contending.
+ runElection(t, hk)
+
+ waitFor(t, "several acquisition attempts", func() bool { return a.count() >= 3 })
+
+ if hk.held() {
+ t.Fatal("a losing contender reports holding the lease")
+ }
+ // Contention is normal on every tick a box doesn't win: an error loop
+ // here would be a line every retry, forever, on N-1 boxes.
+ for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
+ if strings.Contains(line, "lease acquire") {
+ t.Fatalf("contention logged as a failure: %q", line)
+ }
+ }
+}
+
+func TestHousekeeperLogsARealAcquireFailure(t *testing.T) {
+ buf := captureRacyLog(t)
+ a := &acquirer{answer: func(int) (heldLease, error) { return nil, errors.New("no credentials") }}
+ hk := fastHousekeeper(envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "dep-bucket"}), a)
+ runElection(t, hk)
+
+ waitFor(t, "the failure to be logged", func() bool { return strings.Contains(buf.String(), "no credentials") })
+
+ if hk.held() {
+ t.Fatal("a box that could not acquire reports holding the lease")
+ }
+}
+
+func TestHousekeeperStandsDownWhenRenewalFails(t *testing.T) {
+ quietLog(t)
+ first := &fakeLease{}
+ third := &fakeLease{}
+ // The takeover, scripted: this box holds, loses renewal, and finds the
+ // lock already taken by whoever won it after expiry — for as long as the
+ // test says so. That middle state is what makes "stood down" observable
+ // at all: if a lease were waiting on the very next attempt, the box would
+ // be holding again within a retry and a housekeeper that never cleared
+ // its flag would look identical to one that did.
+ var freed atomic.Bool
+ a := &acquirer{answer: func(n int) (heldLease, error) {
+ switch {
+ case n == 1:
+ return first, nil
+ case !freed.Load():
+ return nil, contended()
+ default:
+ return third, nil
+ }
+ }}
+ hk := fastHousekeeper(envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "dep-bucket"}), a)
+ runElection(t, hk)
+
+ waitFor(t, "the first lease to be held", hk.held)
+
+ // Renewal fails: leadership is lost, so this box must stop reporting
+ // itself as the housekeeper — a stale holder still running duties (or,
+ // from Part 2, still issuing certs) against a lease another box now owns
+ // is the whole reason this check exists.
+ first.fail(errors.New("renew: precondition failed"))
+ waitFor(t, "the stand-down", func() bool { return !hk.held() })
+ waitFor(t, "a fresh acquisition attempt", func() bool { return a.count() > 1 })
+
+ // A lost lease is NOT released: our etag is stale, and the TTL is what
+ // hands it over.
+ if first.releases() != 0 {
+ t.Fatalf("released a lost lease %d times, want 0", first.releases())
+ }
+
+ // And it recovers on its own once the lock frees up again.
+ freed.Store(true)
+ waitFor(t, "the lease to be won back", hk.held)
+ if third.releases() != 0 {
+ t.Fatalf("released the recovered lease %d times, want 0 while still holding it", third.releases())
+ }
+}
+
+func TestHousekeeperReleasesTheLeaseOnShutdown(t *testing.T) {
+ quietLog(t)
+ held := &fakeLease{}
+ a := &acquirer{answer: func(int) (heldLease, error) { return held, nil }}
+ hk := fastHousekeeper(envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "dep-bucket"}), a)
+ stop := runElection(t, hk)
+
+ waitFor(t, "the lease to be held", hk.held)
+ stop()
+
+ if hk.held() {
+ t.Fatal("still reports holding the lease after shutdown")
+ }
+ if held.releases() != 1 {
+ t.Fatalf("releases = %d, want 1 — a graceful stand-down hands the lease over immediately", held.releases())
+ }
+}
+
+// ---- the process-wide flag -------------------------------------------------
+
+func TestHousekeeperHeldIsFalseBeforeAnyElectionIsArmed(t *testing.T) {
+ if housekeeperHeld() {
+ t.Fatal("housekeeperHeld() is true with no housekeeper armed")
+ }
+}