carlos / platform Public

certsync: backfill only for election contenders; bucket-identity settle stamps (review round)

Two review findings, both fixed rather than argued:

- mayBackfill required only an agent-handed gate func, which a
  grandfathered fleet box (bucket env still present, housekeeper mode
  "never") also has — its disk holds a customer's certs, and one pass
  would have replicated them into the platform's control/certs/ and onto
  every edge. Eligibility is now "contends in the election": a new
  WithIssuerContender option wired from housekeeperContends (mode sole or
  elect). Hand-run edges have no predicate at all and stay excluded.

- fileStamp recorded bucket PRESENCE, so a box that settled against the
  newer leaf went blind when a stale-view writer clobbered it with an
  older one — durably, since nobody's stamp moved. The stamp now carries
  the bucket copy's sha256; the clobber changes the sum, un-settles
  exactly the box holding the newer leaf, and its next pass republishes.
  Last-writer-wins stays the write discipline (a freshness check inside
  putCert would still race); the repair loop is the guarantee, and it
  converges because the NotAfter compare is monotone.

Also: the three single-writer-era comments the review flagged now
describe the multi-writer rules; tests cover the fleet-box exclusion
end-to-end, the older-leaf repair, the success log line, and a
non-issuer publishing a wildcard PEM inside the parent set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7d3e76198bb5e53d9771d37aaa29697be6f0a7a2 parent eb8afa4
5 files changed, +186 −38
  • internal/edge/certbackfill_test.go +83 −3
  • internal/edge/certsync.go +76 −35
  • internal/edge/edge.go +14 −0
  • internal/host/agent.go +5 −0
  • internal/host/housekeeper.go +8 −0
diff --git a/internal/edge/certbackfill_test.go b/internal/edge/certbackfill_test.go
index 5fbc36e9c..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"
@@ -153,8 +154,10 @@ func TestBackfillRunsOnANonIssuer(t *testing.T) {
t.Fatal("an agent-run non-issuer did not publish its own cert")
}
- // The Edge wiring: agent-run is e.issuer != nil, independent of held;
- // a store-carrying edge with no agent (hand-run) may never backfill.
+ // 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() {
@@ -164,8 +167,16 @@ func TestBackfillRunsOnANonIssuer(t *testing.T) {
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 non-holder may not backfill")
+ t.Fatal("an agent-run contender that does not hold the lease may not backfill")
}
if m3.issuing() {
t.Fatal("a non-holding edge counts as the issuer")
@@ -174,6 +185,17 @@ func TestBackfillRunsOnANonIssuer(t *testing.T) {
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")
+ }
}
// Seeding the deployment's ACME identity stays single-writer: a non-issuer
@@ -200,6 +222,64 @@ func TestBackfillAccountKeySeedsOnlyFromTheIssuer(t *testing.T) {
}
}
+// #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) })
+ 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())
+ 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("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")
+ }
+}
+
// The other cert shapes ride the same freshness path as plain hosts.
func TestBackfillWildcardAndRSAThroughTheFreshnessPath(t *testing.T) {
log.SetOutput(io.Discard)
diff --git a/internal/edge/certsync.go b/internal/edge/certsync.go
index 0e24c8297..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.
//
@@ -529,13 +539,19 @@ 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
- // Backfill runs on any AGENT-run box (#318): the election in the process
- // is what makes the box an accountable member of the deployment, whether
- // or not it currently holds the lease. A hand-run `carlos edge` with a
- // bucket but no agent is "held" for serving and must still NOT dump its
- // disk into the deployment's prefix — that is this predicate, not the
- // lease.
- m.mayBackfill = func() bool { return e.issuer != nil }
+ // 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 {
@@ -819,7 +835,8 @@ func (m *certMirror) run(stop <-chan struct{}) {
//
// Rules, each doing work:
//
-// - Only on an agent-run box (m.mayBackfill), NOT only on the issuer.
+// - 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
@@ -827,7 +844,11 @@ func (m *certMirror) run(stop <-chan struct{}) {
// 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 agent-run box, certs only". The serving-side "held
+// 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
@@ -905,12 +926,14 @@ 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
}
@@ -995,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..e43461f5e 100644
--- a/internal/host/housekeeper.go
+++ b/internal/host/housekeeper.go
@@ -378,6 +378,14 @@ 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 { return theHousekeeper.Load().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