carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The housekeeper: the ONE election for this deployment's global duties
2// (spec 2026-08-20-multi-region-ingress-design.md, Part 1). A deployment now
3// has N platform boxes converging one control bucket, and the rule the writer
4// map states is "data that is per-box gets per-box keys; duties that are
5// deployment-global get a lease". The releases retention prune is the first
6// such duty to move behind it — five concurrent pruners racing a promote is a
7// bug class the spec refuses to have — and Part 2's cert issuance is the next.
8//
9// The mechanism is deliberately NOT new. It is internal/lease.Acquire, the
10// same conditional-write lock (litestream's lss3.Leaser writing
11// <prefix>/lock.json) the status publisher has elected with since the fleet
12// status page shipped — see statusLoop's lease.Acquire call in agent.go and
13// the bucket-writer audit's §1. The spec's own words: "The status publisher's
14// CAS-lease is the one election mechanism, reused; no second election pattern
15// may be introduced." So there is no store-CAS lease here, no
16// control/housekeeper.json record, and no bespoke TTL.
17//
18// Two things DO differ from the status election, both on purpose:
19//
20// - It is long-lived, not per-tick. The status loop acquires and releases
21// inside one 5-minute tick because publishing is a single act; the
22// housekeeper's duties are continuous and Task 4's edge cert gate has to
23// be answerable at any instant, so this box acquires once and RENEWS
24// (lease.Acquire starts the renewer itself) for as long as it can, and
25// housekeeperHeld() is a non-blocking read of that state.
26// - It leases in the DEPLOYMENT bucket at a fixed key —
27// control/housekeeper-lease/lock.json — not under CARLOS_STATUS_PREFIX.
28// The duties act on the deployment bucket's own control plane
29// (releases/**, later control/certs/**), and that bucket is the one
30// linearizable home the spec pins the control plane to.
31//
32// The accepted price is lease.LeaseTTL, 15 minutes: a HARD-crashed
33// housekeeper strands the lease until expiry, so handover can stall up to
34// ~15m. Nothing here cares. The prune is idempotent and not urgent (a policy
35// written now is promised "within ~10 minutes", and the loop is a 5-minute
36// tick anyway), and cert renewals run ~30 days early.
37//
38// A GRACEFUL stand-down releases immediately, and that path has to be
39// REACHABLE, not merely written: a `systemctl restart carlos-agent` is not a
40// crash, and if it left the lock behind, every ordinary platform roll would
41// cost the deployment up to ~15m (+ a retry) with no housekeeper — including
42// a single-box deployment, which would be inert against its own lock, and
43// from Part 2 that gap is "no cert issuer" after every deploy. So
44// startHousekeeper returns a stop func and RunAgent calls it in the same
45// post-drain block that shuts the activator's children down, beside the
46// comment about not orphaning leases until TTL. That is the same principle.
47//
48// The hold owns a context, not just a flag. Every duty runs under a context
49// derived from the holding, cancelled the instant the hold ends — a prune is
50// a full list+delete sweep and Part 2's ACME order takes seconds to minutes,
51// so a duty that kept running after a lost renewal would stretch the window
52// where two boxes act on deployment-global state from housekeeperPoll to
53// housekeeperPoll plus however long the duty runs. Duties must therefore
54// honour their context; the ones here already do (every store call takes it).
55package host
56
57import (
58 "context"
59 "errors"
60 "log"
61 "math/rand/v2"
62 "os"
63 "sync/atomic"
64 "time"
65
66 "github.com/carlosframework/platform/internal/edge"
67 "github.com/carlosframework/platform/internal/lease"
68)
69
70// housekeeperLeasePrefix is the deployment-bucket prefix the election's lock
71// lands under: the leaser writes <prefix>/lock.json, so the object is
72// control/housekeeper-lease/lock.json. Under control/ because it is control
73// plane, and a fixed key because there is exactly one housekeeper per
74// deployment — the same "the bucket IS the scope" property the status-lease
75// relies on (one control bucket per deployment, forever).
76const housekeeperLeasePrefix = "control/housekeeper-lease"
77
78// housekeeperRetry spaces a losing contender's attempts. Each attempt is one
79// conditional PUT against the lock (a GET first when a lock exists), so this
80// is the same request-cost trade internal/lease's LeaseTTL comment weighs:
81// 5 minutes is 288 attempts/day/box, in step with the status election's own
82// per-tick contention, and bounds takeover after a hard crash at roughly the
83// 15m TTL plus one retry.
84const housekeeperRetry = 5 * time.Minute
85
86// housekeeperStopWait bounds how long a graceful stand-down may take before
87// the agent stops waiting for it. The release itself gets 5s
88// (releaseHousekeeperLease) and a holder's poll tick is instant, so this is
89// generous; it exists only so a wedged S3 call cannot hold a restart open.
90const housekeeperStopWait = 10 * time.Second
91
92// housekeeperPoll is how often a HOLDER checks that renewal is still winning.
93// Renewal itself runs inside internal/lease (TTL/3 = 5m); this only decides
94// how quickly a box that has LOST the lease stops believing it holds it —
95// which is exactly how long a stale holder could keep running duties after a
96// takeover. 30 seconds is cheap (a mutexed field read, no I/O at all).
97const housekeeperPoll = 30 * time.Second
98
99// heldLease is the slice of *lease.Held this file uses: renewal health and
100// release. An interface only so the election transport is injectable — the
101// lock's own semantics are internal/lease's to test, against a real
102// S3-compatible store.
103type heldLease interface {
104 Err() error
105 Release(ctx context.Context) error
106}
107
108// leaseAcquirer is lease.Acquire's shape, as a seam.
109type leaseAcquirer func(ctx context.Context, bucket, prefix, region string) (heldLease, error)
110
111// acquireDeploymentLease is the production acquirer. The region is
112// deliberately empty: internal/lease's resolveRegion then answers from the
113// AWS default chain (AWS_REGION / profile), which is precisely what
114// release.NewS3Store signs for against this same bucket — a housekeeper lease
115// signing for a different region than the store it guards would be a split
116// waiting to happen. No new env var: the deployment bucket has never had a
117// region variable of its own, and inventing one here would be a second answer
118// to a question the chain already answers.
119//
120// The nil-into-interface hazard is why this is a func and not a method value:
121// lease.Acquire returns a typed nil *lease.Held alongside its error, which
122// would arrive as a NON-nil heldLease if handed straight back.
123func acquireDeploymentLease(ctx context.Context, bucket, prefix, region string) (heldLease, error) {
124 h, err := lease.Acquire(ctx, bucket, prefix, region)
125 if err != nil {
126 return nil, err
127 }
128 return h, nil
129}
130
131// housekeeperMode is this box's relationship to the election, decided once
132// from the environment.
133type housekeeperMode int
134
135const (
136 // housekeeperNever: this box must never run deployment-global duties —
137 // a fleet box (a customer's machine; the platform's releases are not
138 // its business, and the store door would refuse the deletes anyway), or
139 // a box with no deployment store at all.
140 housekeeperNever housekeeperMode = iota
141 // housekeeperSole: a CARLOS_DEPLOYMENT_DIR deployment. There is no
142 // bucket to hold a lock in, and a directory store is one box by
143 // construction, so this box IS the housekeeper — anything else would
144 // silently stop dev/test deployments pruning at all. Such a deployment
145 // publishes no status, so retention reads this box's own build-state
146 // report in its place (settings spec rev 11 §4.4, ruling R-T9-SOLE):
147 // one box, so its own report is every box's.
148 housekeeperSole
149 // housekeeperElect: a bucket-backed platform box. Contend.
150 housekeeperElect
151)
152
153// holding is one continuous stretch of this box being the housekeeper. Its
154// context is what every duty runs under, so ending the holding ends the
155// duties: there is no state where this box believes it does not hold the
156// lease while work it started as holder is still running.
157type holding struct {
158 ctx context.Context
159 cancel context.CancelFunc
160}
161
162// housekeeper is the long-lived acquire/renew/stand-down lifecycle plus the
163// state every gated caller reads.
164type housekeeper struct {
165 mode housekeeperMode
166 reason string // the one line run() logs when it arms
167 bucket string
168 acquire leaseAcquirer
169 retry time.Duration
170 poll time.Duration
171
172 // cur is the current holding, or nil when this box is not the
173 // housekeeper. ONE atomic, deliberately: a separate "held" flag beside
174 // it would have an instant where the two disagree, and both the duty
175 // gate and Task 4's cert gate read this on live paths.
176 cur atomic.Pointer[holding]
177}
178
179// newHousekeeper decides the mode from the environment. Nothing is acquired
180// and no holding is begun here — start() does both.
181func newHousekeeper(getenv func(string) string, acquire leaseAcquirer) *housekeeper {
182 hk := &housekeeper{acquire: acquire, retry: housekeeperRetry, poll: housekeeperPoll}
183 switch {
184 // Deliberately the bare CARLOS_FLEET test that retentionLoop and
185 // instancesLoop already use, for the same reason retentionLoop states:
186 // a fleet box whose LABEL is misconfigured is still a fleet box.
187 case shouldSkipPublisherElection(getenv):
188 hk.mode = housekeeperNever
189 hk.reason = "fleet box — deployment-global duties are a platform-box job; never contending"
190 case getenv("CARLOS_DEPLOYMENT_BUCKET") != "":
191 // The bucket openDeployStore already adopts from — the duties act on
192 // its contents, so the lock guarding them belongs in it.
193 hk.mode = housekeeperElect
194 hk.bucket = getenv("CARLOS_DEPLOYMENT_BUCKET")
195 case getenv("CARLOS_DEPLOYMENT_DIR") != "":
196 hk.mode = housekeeperSole
197 hk.reason = "directory-backed deployment — this box is its own housekeeper (no bucket to elect in)"
198 default:
199 hk.mode = housekeeperNever
200 hk.reason = "no deployment store configured — never contending"
201 }
202 return hk
203}
204
205// held reports whether this box currently holds the lease. Cheap and
206// non-blocking by contract: it is read on a duty tick and, from Part 2, on
207// the edge's certificate path.
208func (hk *housekeeper) held() bool {
209 _, ok := hk.gate()
210 return ok
211}
212
213// gate is held() plus the context the caller's work must run under: valid
214// only while the holding lasts, cancelled the moment it ends. A duty that
215// takes this context cannot outlive the authority it started under.
216func (hk *housekeeper) gate() (context.Context, bool) {
217 if hk == nil {
218 return nil, false
219 }
220 h := hk.cur.Load()
221 if h == nil {
222 return nil, false
223 }
224 return h.ctx, true
225}
226
227// beginHolding marks this box the housekeeper for as long as parent lives.
228func (hk *housekeeper) beginHolding(parent context.Context) {
229 ctx, cancel := context.WithCancel(parent)
230 hk.cur.Store(&holding{ctx: ctx, cancel: cancel})
231}
232
233// endHolding stops this box being the housekeeper: the gate closes FIRST (no
234// new duty can start) and only then is the context cancelled (whatever is
235// already running unwinds). Idempotent — the stop func calls it after the
236// loop has usually done it already.
237func (hk *housekeeper) endHolding() {
238 if h := hk.cur.Swap(nil); h != nil {
239 h.cancel()
240 }
241}
242
243// start arms this housekeeper and returns the stand-down: cancel the loop,
244// wait for it to release and exit, and close the gate. Calling it is what
245// makes the graceful release reachable — see the file header.
246func (hk *housekeeper) start(parent context.Context) (stop func()) {
247 ctx, cancel := context.WithCancel(parent)
248 // A lone box is the housekeeper from the instant it is configured, not
249 // from whenever a goroutine is scheduled: no acquisition is involved, so
250 // there is nothing to wait for.
251 if hk.mode == housekeeperSole {
252 hk.beginHolding(ctx)
253 }
254 done := make(chan struct{})
255 go func() {
256 defer close(done)
257 hk.run(ctx)
258 }()
259 return func() {
260 // Close the gate BEFORE cancelling: cancel() only reaches the
261 // holding's context by propagation (it is a child of this ctx via
262 // beginHolding), so between cancel() and the run loop noticing and
263 // calling endHolding() itself there is a window where hk.cur is
264 // still non-nil over an already-cancelled context — held() reads
265 // true against dead work. Calling endHolding() first closes hk.cur
266 // synchronously, before ctx is even touched, so that window never
267 // opens. The run path's own endHolding() call (in hold()) stays and
268 // is idempotent — this is belt and braces for a path that doesn't
269 // go through hold() (housekeeperSole) or that races it.
270 hk.endHolding()
271 cancel()
272 select {
273 case <-done:
274 case <-time.After(housekeeperStopWait):
275 // Never wedge a shutdown on this: the lock expires on its own
276 // TTL, which is exactly the crash behaviour we already accept.
277 log.Printf("agent: housekeeper: stand-down did not complete within %s — the lease will expire on its TTL instead", housekeeperStopWait)
278 }
279 hk.endHolding()
280 }
281}
282
283// run is the election loop; it returns only when ctx ends (or immediately, on
284// a box that never contends).
285//
286// NOT holding the lease is a normal, quiet state — it is what N-1 boxes are
287// in, permanently. Contention (*lease.HeldError) is therefore never logged,
288// exactly as statusLoop swallows it: a line per retry per box, forever, would
289// bury the log. A real acquire failure (credentials, a broken bucket) IS
290// logged, same as any other tick failure.
291func (hk *housekeeper) run(ctx context.Context) {
292 if hk.mode != housekeeperElect {
293 log.Printf("agent: housekeeper: %s", hk.reason)
294 return
295 }
296 log.Printf("agent: housekeeper: contending for s3://%s/%s/lock.json (lease TTL %s)",
297 hk.bucket, housekeeperLeasePrefix, lease.LeaseTTL)
298 for ctx.Err() == nil {
299 held, err := hk.acquire(ctx, hk.bucket, housekeeperLeasePrefix, "")
300 if err != nil {
301 var heldErr *lease.HeldError
302 if !errors.As(err, &heldErr) && ctx.Err() == nil {
303 log.Printf("agent: housekeeper: lease acquire: %v", err)
304 }
305 if !sleepCtx(ctx, hk.retry) {
306 return
307 }
308 continue
309 }
310 log.Printf("agent: housekeeper: lease acquired — this box runs the deployment's global duties")
311 hk.beginHolding(ctx)
312 hk.hold(ctx, held)
313 }
314}
315
316// hold blocks while this box is the housekeeper, and ends the holding BEFORE
317// it gives the lease up either way — closing the gate so no duty starts
318// against a lease this box has decided to let go of, and cancelling any duty
319// already in flight so none outlives the authority it began under.
320//
321// A failed renewal means leadership is lost (internal/lease stops renewing on
322// the first failure, precisely so a re-try can't fence a claimant who took
323// over after expiry). The lease is then NOT released: our etag is stale, so
324// the release would just fail a precondition, and the TTL is what hands the
325// lock over. A graceful stand-down (ctx ended: process shutting down) DOES
326// release, so the next box takes over in seconds instead of ~15 minutes.
327func (hk *housekeeper) hold(ctx context.Context, held heldLease) {
328 for {
329 if !sleepCtx(ctx, hk.poll) {
330 hk.endHolding()
331 releaseHousekeeperLease(held)
332 return
333 }
334 if err := held.Err(); err != nil {
335 hk.endHolding()
336 log.Printf("agent: housekeeper: lease renewal failed — standing down: %v", err)
337 return
338 }
339 }
340}
341
342// releaseHousekeeperLease hands the lock back on a graceful stand-down. The
343// release gets its OWN budget, never the caller's: by the time this runs the
344// agent's context is already dead, and a release under a dead context leaves
345// the lock stranded until TTL — the exact trap internal/lease's
346// Replicator.Close names, where it cost a wake up to a full TTL of 503s.
347func releaseHousekeeperLease(held heldLease) {
348 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
349 defer cancel()
350 if err := held.Release(ctx); err != nil {
351 log.Printf("agent: housekeeper: lease release: %v", err)
352 }
353}
354
355// sleepCtx waits for d, reporting false if ctx ended first.
356func sleepCtx(ctx context.Context, d time.Duration) bool {
357 t := time.NewTimer(d)
358 defer t.Stop()
359 select {
360 case <-ctx.Done():
361 return false
362 case <-t.C:
363 return true
364 }
365}
366
367// theHousekeeper is the process's one election, published by
368// startHousekeeper. A pointer rather than a bare flag so the loop owns its own
369// state and nothing else can set "held" from the side; nil (never armed)
370// reads as not held.
371var theHousekeeper atomic.Pointer[housekeeper]
372
373// housekeeperHeld reports whether THIS box currently holds the deployment's
374// housekeeper lease. Cheap, non-blocking, and safe before (or without)
375// startHousekeeper: false. This is the read for callers that only need the
376// answer — Part 2's edge cert gate. Anything that then goes and DOES work
377// wants housekeeperGate instead, so the work is bound to the holding.
378func housekeeperHeld() bool { return theHousekeeper.Load().held() }
379
380// housekeeperGate is housekeeperHeld plus the context the duty must run
381// under — the gate every deployment-global duty consults.
382func housekeeperGate() (context.Context, bool) { return theHousekeeper.Load().gate() }
383
384// housekeeperContends reports whether this box takes part in the election at
385// all — mode sole or elect, decided once from the environment. The edge's
386// cert-backfill eligibility (edge.WithIssuerContender, #318): a fleet box
387// grandfathered with the bucket env is agent-run and gated, but mode
388// "never" — its disk holds a customer's certs, never this deployment's to
389// publish.
390func housekeeperContends() bool {
391 // Nil-safe like held()/gate(): several agent loops start before
392 // startHousekeeper, and a caller from one of them should read "does not
393 // contend", not panic.
394 hk := theHousekeeper.Load()
395 return hk != nil && hk.mode != housekeeperNever
396}
397
398// startHousekeeper arms the election for this process and returns its
399// stand-down. Called once from RunAgent, before the loops whose duties it
400// gates; the returned func MUST be called on the way out (RunAgent's
401// post-drain block) or a restart strands the lock until its TTL.
402func startHousekeeper() (stop func()) {
403 hk := newHousekeeper(os.Getenv, acquireDeploymentLease)
404 theHousekeeper.Store(hk)
405 return hk.start(context.Background())
406}
407
408// housekeeperDuty is one deployment-global job: a name for the log and the
409// work. Today's set is the releases retention prune; Part 2 adds certificate
410// issuance.
411type housekeeperDuty struct {
412 name string
413 run func(context.Context) error
414}
415
416// runHousekeeperDuties runs one tick of the given duties — but only on the
417// box holding the lease, and only under the context of THAT holding. A
418// non-holder does nothing and says nothing: that is the normal state of every
419// box but one, on every tick, forever.
420//
421// The gate is consulted once per tick, not once per duty: within a tick the
422// context is what carries a mid-flight stand-down, and re-reading the gate
423// between duties would just replace one cancellation mechanism with two.
424//
425// Per-duty isolation, the shape retentionPass already uses for per-app
426// errors: one duty's failure is logged and the next still runs.
427func runHousekeeperDuties(gate func() (context.Context, bool), duties ...housekeeperDuty) {
428 ctx, ok := gate()
429 if !ok {
430 return
431 }
432 for _, d := range duties {
433 // The holding can end mid-tick. What is left is not this box's to
434 // run, and the duty that was interrupted stood down — it did not
435 // fail, so it gets no error line either.
436 if ctx.Err() != nil {
437 return
438 }
439 if err := d.run(ctx); err != nil && ctx.Err() == nil {
440 log.Printf("agent: housekeeper: %s: %v", d.name, err)
441 }
442 }
443}
444
445// mintMeshKeyLoop mints the deployment's mesh key (multi-region backhaul
446// spec, Part 3 Task 2) — a housekeeper duty, but deliberately its OWN loop
447// rather than a duty folded into retentionLoop's runHousekeeperDuties call
448// (agent.go's retentionLoop, gated on prune's cadence).
449//
450// The plan first drafted the mint as riding retentionLoop's tick; review
451// caught the trap: retentionLoop returns early — never arming its loop at
452// all, so runHousekeeperDuties is never even called — on a box missing
453// CARLOS_RELEASE_PUBKEY (release-signature verification, something wholly
454// unrelated to the mesh key). Critically, a box in that state still WINS
455// the housekeeper election (newHousekeeper/hk.run don't consult
456// CARLOS_RELEASE_PUBKEY at all), so the one box holding the lease would
457// simply never mint, and no other box would ever try either — the mesh key
458// would silently never exist, and Tasks 3/4's hop auth would never arm,
459// fleet-wide, with no error anywhere. This loop is gated on nothing but
460// deployment-store presence (shouldSkipPublisherElection, openDeployStore),
461// mirroring edgesfile.go's edgesFileLoop shape exactly, so minting is
462// available on every housekeeper independent of every other loop's
463// preconditions.
464//
465// mintMeshKey itself is create-only and idempotent (mesh.go's own comment):
466// every tick after the deployment's first successful mint finds the key
467// already there and does nothing, silently.
468func mintMeshKeyLoop() {
469 ctx := context.Background()
470 if shouldSkipPublisherElection(os.Getenv) {
471 log.Printf("agent: mesh key mint loop is a platform-box job — skipped on a fleet box")
472 return
473 }
474 store, err := openDeployStore(ctx)
475 if err != nil {
476 log.Printf("agent: mesh key mint loop disabled: %v", err)
477 return
478 }
479 log.Printf("agent: mesh key mint loop armed (5min + jitter) — mints only while this box holds the housekeeper lease")
480 mint := housekeeperDuty{"mesh key mint", func(ctx context.Context) error {
481 return edge.MintMeshKey(ctx, store)
482 }}
483 delay := time.Minute
484 for {
485 time.Sleep(delay)
486 delay = 5*time.Minute + rand.N(time.Minute)
487 runHousekeeperDuties(housekeeperGate, mint)
488 }
489}
490