carlos / platform Public

Merge pull request #320 from carlosframework/nonissuer-cert-backfill

certsync: backfill on every contending box; identity settle stamps (#318)
Paul Campbell pushed by paul@keymail.dev 9f3cf41e9a931b76fdd3767ec3d4b5a7cc12061a parent 65c6956parent d0b727d
5 files changed, +279 −75
  • internal/edge/certbackfill_test.go +122 −26
  • internal/edge/certsync.go +124 −49
  • internal/edge/edge.go +14 −0
  • internal/host/agent.go +5 −0
  • internal/host/housekeeper.go +14 −0
diff --git a/internal/edge/certbackfill_test.go b/internal/edge/certbackfill_test.go
index 30f7e6e6e..b86725c4c 100644
--- a/internal/edge/certbackfill_test.go
+++ b/internal/edge/certbackfill_test.go
@@ -1,6 +1,7 @@
package edge
import (
+ "bytes"
"context"
"errors"
"io"
@@ -23,6 +24,7 @@ import (
func issuerMirror(t *testing.T, s release.Store, local autocert.Cache, held bool) *certMirror {
t.Helper()
m := newCertMirror(s, local)
+ m.mayBackfill = func() bool { return true } // an agent-run box
m.issuing = func() bool { return held }
// The registry: every *.example.com name except "gone".
m.allowed = func(host string) bool {
@@ -128,60 +130,153 @@ func (c *putCountingStore) Put(ctx context.Context, key string, body []byte, ifM
return c.Store.Put(ctx, key, body, ifMatch)
}
-// A non-issuer's disk is never the truth: the writer map gives the prefix
-// to the housekeeper alone.
-func TestBackfillIsIssuerOnly(t *testing.T) {
+// #318: certs publish from ANY agent-run box, lease or no lease — the
+// rung-1 rehearsal found the issuer-only rule left every cert minted on a
+// box that never held the lease sitting on that box's disk alone, exactly
+// the bytes a failover loses. Only the agent-run predicate bars a mirror:
+// a hand-run edge, and a mirror built without an Edge, still never publish.
+func TestBackfillRunsOnANonIssuer(t *testing.T) {
+ log.SetOutput(io.Discard)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
s := release.NewDirStore(t.TempDir())
local := autocert.DirCache(t.TempDir())
putLocal(t, local, "a.example.com", hostCertPEM(t, "a.example.com", time.Now().Add(time.Hour)))
- m := issuerMirror(t, s, local, false)
- m.backfill(context.Background())
- if _, ok := bucketHas(t, s, "a.example.com"); ok {
- t.Fatal("a non-issuer published")
- }
-
- m2 := newCertMirror(s, local) // issuing nil: never the issuer
+ m2 := newCertMirror(s, local) // mayBackfill nil: no Edge, never publishes
m2.backfill(context.Background())
if _, ok := bucketHas(t, s, "a.example.com"); ok {
t.Fatal("a gate-less mirror published")
}
- // The Edge wiring is STRICT: a box with a store but no agent gate is
- // ungated for serving (issuerGate says held) yet is nobody's issuer.
+ m := issuerMirror(t, s, local, false) // agent-run, NOT holding the lease
+ m.backfill(context.Background())
+ if _, ok := bucketHas(t, s, "a.example.com"); !ok {
+ t.Fatal("an agent-run non-issuer did not publish its own cert")
+ }
+
+ // The Edge wiring: eligibility is issuer-gate present AND contending
+ // in the election; a store-carrying edge with no agent (hand-run) may
+ // never backfill, and neither may an agent-run box whose housekeeper
+ // mode is "never" (the grandfathered fleet box).
e := &Edge{staticStore: s}
m3 := e.newCertMirror(local)
+ if m3.mayBackfill() {
+ t.Fatal("a hand-run edge may backfill")
+ }
+ if m3.issuing() {
+ t.Fatal("an ungated edge counts as the issuer")
+ }
+ e.issuer = func() (context.Context, bool) { return context.Background(), false }
+ if m3.mayBackfill() {
+ t.Fatal("an agent-run box that does not contend may backfill (fleet box)")
+ }
+ e.contends = func() bool { return false }
+ if m3.mayBackfill() {
+ t.Fatal("a contends()==false box may backfill (fleet box)")
+ }
+ e.contends = func() bool { return true }
+ if !m3.mayBackfill() {
+ t.Fatal("an agent-run contender that does not hold the lease may not backfill")
+ }
if m3.issuing() {
- t.Fatal("an ungated edge counts as the issuer for backfill")
+ t.Fatal("a non-holding edge counts as the issuer")
}
e.issuer = func() (context.Context, bool) { return context.Background(), true }
if !m3.issuing() {
t.Fatal("a holding edge is not the issuer")
}
+ // And end-to-end through backfill itself: the hand-run shape (gate
+ // present through serving-side wiring is irrelevant — no contender
+ // predicate) walks nothing and publishes nothing.
+ e2 := &Edge{staticStore: s}
+ putLocal(t, local, "handrun.example.com", hostCertPEM(t, "handrun.example.com", time.Now().Add(time.Hour)))
+ m4 := e2.newCertMirror(local)
+ m4.allowed = func(string) bool { return true }
+ m4.backfill(context.Background())
+ if _, ok := bucketHas(t, s, "handrun.example.com"); ok {
+ t.Fatal("a hand-run edge published through backfill")
+ }
}
-// The gate is re-read per entry: a holding that ends mid-pass stops the
-// pass where it stands.
-func TestBackfillStopsWhenTheGateIsLostMidPass(t *testing.T) {
+// Seeding the deployment's ACME identity stays single-writer: a non-issuer
+// never offers its own key to an empty bucket — and the unseeded key is NOT
+// settled, so the same box publishes it on the first pass where it holds.
+func TestBackfillAccountKeySeedsOnlyFromTheIssuer(t *testing.T) {
+ log.SetOutput(io.Discard)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
s := release.NewDirStore(t.TempDir())
local := autocert.DirCache(t.TempDir())
- later := time.Now().Add(time.Hour)
- for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
- putLocal(t, local, h, hostCertPEM(t, h, later))
+ putLocal(t, local, "acme_account+key", []byte("MINE"))
+
+ held := false
+ m := issuerMirror(t, s, local, false)
+ m.issuing = func() bool { return held }
+ m.backfill(context.Background())
+ if _, ok := bucketHas(t, s, "acme_account+key"); ok {
+ t.Fatal("a non-issuer seeded the account key")
+ }
+ held = true // the same process later wins the lease: same stamps, same memo
+ m.backfill(context.Background())
+ if got, _ := bucketHas(t, s, "acme_account+key"); string(got) != "MINE" {
+ t.Fatalf("the new issuer did not seed the account key it holds: %q", got)
}
+}
+
+// #318's repair loop: another box clobbers the bucket with an OLDER leaf
+// after this box settled against the newer one. The stamp carries the
+// bucket copy's identity, so the settled box un-settles on its next poll
+// and republishes the newer cert — the clobber is transient, not durable.
+func TestBackfillRepairsAnOlderLeafClobber(t *testing.T) {
var buf strings.Builder
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
- m := issuerMirror(t, s, local, true)
- calls := 0
- m.issuing = func() bool { calls++; return calls <= 2 } // the top check, then one entry
+ s := release.NewDirStore(t.TempDir())
+ local := autocert.DirCache(t.TempDir())
+ newer := hostCertPEM(t, "a.example.com", time.Now().Add(48*time.Hour))
+ older := hostCertPEM(t, "a.example.com", time.Now().Add(1*time.Hour))
+ putLocal(t, local, "a.example.com", newer)
+
+ m := issuerMirror(t, s, local, false)
m.backfill(context.Background())
- keys, _ := s.List(context.Background(), certPrefix)
- if len(keys) != 1 {
- t.Fatalf("published %v after the gate was lost; want exactly one", keys)
+ if got, _ := bucketHas(t, s, "a.example.com"); !bytes.Equal(got, newer) {
+ t.Fatal("setup: newer cert not published")
+ }
+ if err := m.Refresh(context.Background()); err != nil {
+ t.Fatal(err)
}
+ m.backfill(context.Background()) // settles: disk == bucket == newer
if !strings.Contains(buf.String(), "backfilled 1 cache entry") {
- t.Fatalf("a pass cut short by the gate did not report what it published:\n%s", buf.String())
+ t.Fatalf("the publishing pass did not report itself:\n%s", buf.String())
+ }
+
+ // Another box lands the older leaf (its view was stale).
+ putBucket(t, s, "a.example.com", older)
+ if err := m.Refresh(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ m.backfill(context.Background())
+ if got, _ := bucketHas(t, s, "a.example.com"); !bytes.Equal(got, newer) {
+ t.Fatal("the settled box did not repair an older-leaf clobber")
+ }
+}
+
+// A wildcard PEM on a non-issuer's disk publishes like any owned cert —
+// bounded by the manager's parent set exactly as on the issuer.
+func TestBackfillWildcardFromANonIssuer(t *testing.T) {
+ log.SetOutput(io.Discard)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+ s := release.NewDirStore(t.TempDir())
+ local := autocert.DirCache(t.TempDir())
+ putLocal(t, local, "wildcard-acct.example.com.pem", hostCertPEM(t, "*.acct.example.com", time.Now().Add(time.Hour)))
+ putLocal(t, local, "wildcard-other.example.com.pem", hostCertPEM(t, "*.other.example.com", time.Now().Add(time.Hour)))
+
+ m := issuerMirror(t, s, local, false)
+ m.backfill(context.Background())
+ if _, ok := bucketHas(t, s, "wildcard-acct.example.com.pem"); !ok {
+ t.Fatal("a non-issuer did not publish the wildcard PEM for a current parent")
+ }
+ if _, ok := bucketHas(t, s, "wildcard-other.example.com.pem"); ok {
+ t.Fatal("a wildcard PEM outside the parent set published")
}
}
@@ -427,6 +522,7 @@ func TestCertMirrorLoopBackfillsOnTheIssuer(t *testing.T) {
local := autocert.DirCache(t.TempDir())
putLocal(t, local, "a.example.com", hostCertPEM(t, "a.example.com", time.Now().Add(time.Hour)))
m := newCertMirror(s, local)
+ m.mayBackfill = func() bool { return true }
m.issuing = func() bool { return true }
m.allowed = func(string) bool { return true }
stop := make(chan struct{})
diff --git a/internal/edge/certsync.go b/internal/edge/certsync.go
index dcb41069a..8dbca3f72 100644
--- a/internal/edge/certsync.go
+++ b/internal/edge/certsync.go
@@ -30,6 +30,7 @@ package edge
import (
"bytes"
"context"
+ "crypto/sha256"
"crypto/tls"
"crypto/x509"
"errors"
@@ -124,7 +125,10 @@ var errNotIssuer = errors.New("acme: not available here")
// directly, and the door is the agent's client. A grandfathered fleet box
// that still carries the bucket env therefore reads control/certs/ over
// its own IAM, and is gated AND mirrored like any other non-issuer — a
-// transition state, resolved by the rollout dropping that env;
+// transition state, resolved by the rollout dropping that env. It also
+// never BACKFILLS (#318): mayBackfill requires contending in the
+// election, and a fleet box's housekeeper mode is "never" — its disk
+// holds a customer's certs, not this deployment's;
// - boxes with neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR;
// - boxes with a bucket but no CARLOS_RELEASE_PUBKEY, where
// enableStaticFromEnv leaves staticStore nil.
@@ -269,11 +273,12 @@ func (p *publishingCache) Put(ctx context.Context, key string, data []byte) erro
// serving.
//
// It is gated like Put, and that is a deliberate trade: an issuer that
-// stands down mid-order leaves its token behind in the bucket, because the
-// writer map gives control/certs/** to the housekeeper alone and a box that
-// is no longer the housekeeper does not get to delete there. The spec's
-// answer to that litter is a TTL stamp swept from the mirror side, not a
-// second writer.
+// stands down mid-order leaves its token behind in the bucket, because
+// ISSUANCE artifacts stay the housekeeper's to write and un-write (#318
+// widened only the backfill of settled cert entries, never tokens), and a
+// box that is no longer the housekeeper does not get to delete there. The
+// spec's answer to that litter is a TTL stamp swept from the mirror side,
+// not a second writer.
func (p *publishingCache) Delete(ctx context.Context, key string) error {
err := p.inner.Delete(ctx, key)
if !p.publishes(key) {
@@ -334,11 +339,15 @@ func publishableCertName(name string) bool {
// same reason (release.Store.Put with an empty ifMatch is create-only, so a
// renewal would otherwise lose to its own previous cert).
//
-// Last-writer-wins is the DESIGN here, not a compromise: one gated issuer
-// means there is normally no second writer at all, and the transient
-// double-holder the housekeeper's poll window allows writes an equally valid
-// cert for the same name. Whichever lands last is servable, and the next
-// renewal converges the pair.
+// Last-writer-wins is the DESIGN here, not a compromise. On the issuance
+// path, the transient double-holder the housekeeper's poll window allows
+// writes an equally valid cert for the same name, and whichever lands last
+// is servable. On the backfill path (#318: any contending box), a writer
+// racing a stale view can land an OLDER leaf — repaired, not prevented:
+// fileStamp's bucket-identity half un-settles the box holding the newer
+// one, whose next pass republishes it. A freshness check inside this
+// function would still race (check-then-put), so the repair loop is the
+// guarantee, and it converges because the NotAfter compare is monotone.
func putCert(ctx context.Context, s release.Store, name string, body []byte) error {
key := certKey(name)
for attempt := 0; attempt < 3; attempt++ {
@@ -361,9 +370,10 @@ func putCert(ctx context.Context, s release.Store, name string, body []byte) err
// ---- the mirror: every edge serves every cert --------------------------------
//
// certMirror is the serving half of the pair at the top of this file, and the
-// reason the issuance gate above is safe to shut. It POLLS control/certs/ (it
-// never writes there — the writer map gives that prefix to the housekeeper
-// alone) and keeps the whole prefix in memory, so any edge can answer a
+// reason the issuance gate above is safe to shut. It POLLS control/certs/
+// (its serving side never writes there; the one writer it carries is the
+// backfill below, bounded to cert entries this box owns — #318) and keeps
+// the whole prefix in memory, so any edge can answer a
// handshake for a name some OTHER edge issued, and can answer an HTTP-01
// challenge for an order some OTHER edge is running.
//
@@ -425,9 +435,16 @@ type certMirror struct {
// material on every poll.
local autocert.Cache
- // issuing reports whether this box holds the issuer gate RIGHT NOW — the
- // backfill's switch (see backfill). nil = never the issuer, which is the
- // safe default for a mirror built without an Edge.
+ // mayBackfill reports whether this box may publish its disk's certs at
+ // all — the backfill's switch (see backfill): true on a box run under an
+ // agent (whose election makes it an accountable member of the
+ // deployment), false on a hand-run `carlos edge` and nil — never — for a
+ // mirror built without an Edge.
+ mayBackfill func() bool
+ // issuing reports whether this box holds the issuer gate RIGHT NOW —
+ // what remains single-writer inside backfill: seeding the ACME account
+ // key (#318 widened cert publishing to every agent-run box). nil = never
+ // the issuer, the safe default for a mirror built without an Edge.
issuing func() bool
// wildcardParents is the wildcard manager's current parent set, the
// bound on which wildcard PEMs backfill may publish. nil = no manager =
@@ -522,8 +539,21 @@ func (e *Edge) newCertMirror(local autocert.Cache) *certMirror {
// exists for a name that passed it — now that the policy admits the
// deployment's remote-homed routes too (#277).
m.allowed = e.deploymentHost
- // Strict: an ungated box (no agent, e.issuer nil) is "held" for serving
- // but is nobody's issuer, and must not publish its disk.
+ // Backfill runs on any box that CONTENDS in the deployment's issuer
+ // election (#318): contending is what makes the box an accountable
+ // member of the deployment, whether or not it currently holds the
+ // lease. Two box classes are excluded, each for its own reason: a
+ // hand-run `carlos edge` with a bucket but no agent is "held" for
+ // serving and must NOT dump its disk into the deployment's prefix
+ // (e.issuer nil, and no contender predicate was ever handed down); and
+ // a grandfathered FLEET box still carrying the bucket env is agent-run
+ // with a gate func, but its housekeeper mode is "never" — its disk
+ // holds a customer's certs, which are not this deployment's to
+ // replicate onto every platform edge (WithIssuerContender, and the
+ // armCertIssuance doc's grandfathered-fleet-box paragraph).
+ m.mayBackfill = func() bool { return e.issuer != nil && e.contends != nil && e.contends() }
+ // Strict: only the box holding the lease RIGHT NOW may seed the
+ // deployment's ACME account identity (see backfill's account-key rule).
m.issuing = func() bool {
if e.issuer == nil {
return false
@@ -788,8 +818,8 @@ func (m *certMirror) run(stop <-chan struct{}) {
}
// backfill publishes what this box's cache holds and the bucket does not —
-// the issuer's half of "every edge serves every cert" for material minted
-// BEFORE publishing existed (#291).
+// every box's half of "every edge serves every cert" for material minted
+// BEFORE publishing existed (#291), on whichever box's disk it sits (#318).
//
// publishingCache publishes on Put, which is to say on issuance and renewal.
// A deployment that arms a second edge after a year of single-box service
@@ -805,13 +835,27 @@ func (m *certMirror) run(stop <-chan struct{}) {
//
// Rules, each doing work:
//
-// - Only while holding the issuer gate — the STRICT gate (m.issuing), not
-// the serving-side "held or ungated" read: a hand-run `carlos edge`
-// with a bucket but no agent is ungated for serving and must NOT dump
-// its disk into the deployment's prefix. The gate is re-read per entry:
-// a pass over fifty certs can outlive a holding, and the writer map
-// gives control/certs/** to the housekeeper alone. Losing it ends the
-// pass but still reports what the pass did.
+// - Only on a box that contends in the election, NOT only on the issuer
+// (m.mayBackfill: agent-run AND contending in the issuer election).
+// The 2026-08-27 rung-1 rehearsal (#318) found the issuer-only rule
+// left every cert minted on a box that never held the lease since
+// #291 sitting on that box's disk alone — exactly the bytes a
+// failover loses with the box. Cert entries in control/certs/** are
+// therefore multi-writer now, kept convergent by the freshness rules
+// below (bytes-equal settles; an older leaf never displaces a newer
+// one), and the writer map's housekeeper-only line for this prefix is
+// amended to "any contending box, certs only". A concurrent writer
+// racing a stale view CAN transiently land an older leaf over a newer
+// one; the identity half of fileStamp is what makes that transient —
+// the box holding the newer leaf un-settles on the changed bucket sum
+// and republishes next pass. The serving-side "held
+// or ungated" read is still the wrong predicate: a hand-run `carlos
+// edge` with a bucket but no agent is ungated for serving and must
+// NOT dump its disk into the deployment's prefix — m.mayBackfill is
+// false exactly there. One consequence to hold in mind when a cert
+// PURGE ever lands: any box's mirrored on-disk copy now resurrects a
+// deleted bucket entry on its next pass, so a purge must tombstone or
+// it will not stick.
// - Only a DirCache is walked. The mirror holds the cache as an
// autocert.Cache, and that interface cannot enumerate; the on-disk
// layout is the only backend that exists (certcache.go) and the one
@@ -826,11 +870,19 @@ func (m *certMirror) run(stop <-chan struct{}) {
// its leaf must cover it. Account keys are their own shape.
// - Challenge tokens never publish: they are seconds long, and a copy of
// a spent one is litter every edge would go on serving.
-// - Account keys publish only when the bucket has none. The deployment's
-// ACME identity is whichever key was published first (the mirror seeds
-// it onto every box, and a takeover issuer orders under it); a box's
-// own older key must never displace it, or the deployment would swap
-// accounts on every failover.
+// - Account keys publish only when the bucket has none, and only from
+// the box holding the STRICT issuer gate (m.issuing) — the one
+// backfill decision that stays single-writer. The deployment's ACME
+// identity is whichever key was published first (the mirror seeds it
+// onto every box, and a takeover issuer orders under it); a box's own
+// older key must never displace it, or the deployment would swap
+// accounts on every failover — and N agent boxes racing an EMPTY
+// bucket with their own keys would leave certs ordered under one
+// account stored beside another's key, which is why the widened cert
+// rule above does not extend here. A non-issuer's unseeded key is
+// deliberately NOT settled: the box may hold the lease on a later
+// pass, and a settled stamp would sit on exactly the pass that could
+// finally publish it.
// - A cert-shaped entry publishes when the bucket lacks it, or holds an
// older leaf (NotAfter earlier than this box's) — the renewed-before-
// the-gate case. Anything that does not parse, or has already expired,
@@ -840,7 +892,7 @@ func (m *certMirror) run(stop <-chan struct{}) {
// first-and-every-tenth cadence: a write-denied bucket must not say so
// every 30 seconds forever.
func (m *certMirror) backfill(ctx context.Context) {
- if m.issuing == nil || !m.issuing() || m.s == nil {
+ if m.mayBackfill == nil || !m.mayBackfill() || m.s == nil {
return
}
dir, ok := m.local.(autocert.DirCache)
@@ -874,18 +926,17 @@ func (m *certMirror) backfill(ctx context.Context) {
if err != nil {
continue
}
- stamp := fileStamp{size: info.Size(), mtime: info.ModTime().UnixNano(), inBucket: inBucket}
+ stamp := fileStamp{size: info.Size(), mtime: info.ModTime().UnixNano(), bucketSum: bucketSum(remote, inBucket)}
// Settled at this stamp on an earlier pass — in sync with the
// bucket, or not this deployment's to publish — and nothing has
- // moved since (the bucket's side is part of the stamp: an entry
- // that was in sync but has since left the bucket re-reads). This
- // is what keeps steady state at a listing plus a stat per entry.
+ // moved since. The bucket copy's IDENTITY is part of the stamp: an
+ // entry that was in sync but has since left the bucket, or been
+ // overwritten by another box, re-reads — that re-read is the #318
+ // repair path (see fileStamp). This is what keeps steady state at
+ // a listing plus a stat per entry.
if m.settledAt(name) == stamp {
continue
}
- if !m.issuing() {
- break
- }
body, err := os.ReadFile(filepath.Join(string(dir), name))
if err != nil {
failed++
@@ -904,6 +955,12 @@ func (m *certMirror) backfill(ctx context.Context) {
m.markSettled(name, stamp)
continue
}
+ if m.issuing == nil || !m.issuing() {
+ // Only the elected issuer seeds the deployment's ACME
+ // identity (the rule above). Not settled, deliberately —
+ // see the same rule for why.
+ continue
+ }
} else {
cert, err := parseCertEntry(body)
if err != nil || !now.Before(cert.Leaf.NotAfter) || !m.ownsCertName(name, cert) {
@@ -961,15 +1018,33 @@ func (m *certMirror) ownsCertName(name string, cert *tls.Certificate) bool {
}
// fileStamp is the state a backfill pass last settled an entry at: the
-// file's size+mtime and whether the bucket held the name. Not a hash:
-// DirCache writes are atomic renames, so a changed body is a changed
-// stamp (nanosecond mtime, so a same-size rewrite within one second on a
-// coarse filesystem is the only alias, and autocert never rewrites a name
-// that fast).
+// file's size+mtime, and the IDENTITY of the bucket's copy (a sha256 of
+// its bytes; zero = absent), not merely its presence. The local half is
+// not a hash: DirCache writes are atomic renames, so a changed body is a
+// changed stamp (nanosecond mtime, so a same-size rewrite within one
+// second on a coarse filesystem is the only alias, and autocert never
+// rewrites a name that fast). The bucket half MUST be identity: with
+// cert entries multi-writer (#318), another box racing a stale view can
+// land an older leaf over a newer one this box already settled against —
+// a presence bool would leave the settled box blind to the clobber
+// forever (its file unmoved, the name still "in bucket"), while the sum
+// changes with the clobber, un-settles exactly the box holding the newer
+// leaf, and its next pass republishes it. Convergence within a poll or
+// two, newest leaf wins.
type fileStamp struct {
- size int64
- mtime int64
- inBucket bool
+ size int64
+ mtime int64
+ bucketSum [sha256.Size]byte
+}
+
+// bucketSum is fileStamp's bucket-identity half. The zero value means
+// "absent", which cannot collide with a real entry: nothing under
+// control/certs/ is ever zero bytes, and sha256 of any body is nonzero.
+func bucketSum(body []byte, inBucket bool) [sha256.Size]byte {
+ if !inBucket {
+ return [sha256.Size]byte{}
+ }
+ return sha256.Sum256(body)
}
func (m *certMirror) settledAt(name string) fileStamp {
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index d50ce448c..d126eeb48 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -176,6 +176,10 @@ type Edge struct {
// every single-box deployment's behaviour to date: mint as always.
// Serving is never gated by it.
issuer IssuerGate
+ // contends reports whether this box takes part in the issuer election
+ // at all (WithIssuerContender) — the cert backfill's eligibility, a
+ // coarser question than the gate above answers. nil = does not contend.
+ contends func() bool
// box is this box's own label — the SAME derivation its edges file is
// published under (internal/host's metricsBoxLabel), handed down through
// WithBoxLabel for the reason the issuer gate is: that package imports
@@ -214,6 +218,16 @@ type RunOption func(*Edge)
// as it always has. See IssuerGate (certsync.go).
func WithIssuerGate(g IssuerGate) RunOption { return func(e *Edge) { e.issuer = g } }
+// WithIssuerContender tells the edge whether this box CONTENDS in the
+// deployment's issuer election at all — a different question from holding
+// it right now (the gate above answers that, moment to moment). The cert
+// backfill (certsync.go, #318) is allowed exactly on contenders: an
+// agent-run box whose housekeeper mode is "never" (a fleet box grandfathered
+// with the bucket env) carries a gate func that can never be held, and its
+// disk holds a customer's certs — not this deployment's to replicate. nil —
+// no agent handed one down — reads as "does not contend".
+func WithIssuerContender(c func() bool) RunOption { return func(e *Edge) { e.contends = c } }
+
// WithBoxLabel hands the edge this box's own label, the one its edges file is
// published under (internal/host's metricsBoxLabel — the same call the edges
// publisher makes, so the two can never disagree about which file is ours).
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 298afdb68..7729df454 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -405,6 +405,11 @@ func RunAgent(args []string) {
// stops being the housekeeper. Armed above, so this closure is live
// and correct by the time the edge reads it.
edge.WithIssuerGate(housekeeperGate),
+ // Whether this box contends in that election AT ALL — the cert
+ // backfill's eligibility (#318). Mode is decided once from the
+ // environment, so the closure is stable; see housekeeperContends
+ // for the fleet-box exclusion it exists to state.
+ edge.WithIssuerContender(housekeeperContends),
// This box's own label, for the home map's own-box exclusion
// (internal/edge/homemap.go). metricsBoxLabel is the SAME call
// edgesFileLoop publishes under, deliberately: the map excludes our
diff --git a/internal/host/housekeeper.go b/internal/host/housekeeper.go
index baf4442c4..cbd4502fb 100644
--- a/internal/host/housekeeper.go
+++ b/internal/host/housekeeper.go
@@ -378,6 +378,20 @@ func housekeeperHeld() bool { return theHousekeeper.Load().held() }
// under — the gate every deployment-global duty consults.
func housekeeperGate() (context.Context, bool) { return theHousekeeper.Load().gate() }
+// housekeeperContends reports whether this box takes part in the election at
+// all — mode sole or elect, decided once from the environment. The edge's
+// cert-backfill eligibility (edge.WithIssuerContender, #318): a fleet box
+// grandfathered with the bucket env is agent-run and gated, but mode
+// "never" — its disk holds a customer's certs, never this deployment's to
+// publish.
+func housekeeperContends() bool {
+ // Nil-safe like held()/gate(): several agent loops start before
+ // startHousekeeper, and a caller from one of them should read "does not
+ // contend", not panic.
+ hk := theHousekeeper.Load()
+ return hk != nil && hk.mode != housekeeperNever
+}
+
// startHousekeeper arms the election for this process and returns its
// stand-down. Called once from RunAgent, before the loops whose duties it
// gates; the returned func MUST be called on the way out (RunAgent's