carlos / platform Public

Merge pull request #366 from carlosframework/cert-sweep

domains: the Settings page's state, the claim-fold cache, and the cert sweep (#299 §3f, #173, #243)
Paul Campbell pushed by paul@keymail.dev 5ef69c6b071004142cd2e6ed906c0ddc7999cbec parent c5e3328parent b509e1c
17 files changed, +1363 −62
  • docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md +32 −5
  • internal/console/apidomains.go +1 −1
  • internal/console/appsettings.go +32 −7
  • internal/console/appsettings_test.go +113 −0
  • internal/console/server.go +7 −2
  • internal/console/templates/app_settings.html +8 −6
  • internal/console/view.go +46 −0
  • internal/domains/aliases.go +7 −2
  • internal/domains/aliases_test.go +20 −20
  • internal/domains/claimcache.go +136 −0
  • internal/domains/claimcache_test.go +374 −0
  • internal/domains/claims.go +35 −14
  • internal/edge/certsweep.go +194 −0
  • internal/edge/certsweep_test.go +321 −0
  • internal/edge/certwarm.go +19 −3
  • internal/host/agent.go +2 −2
  • internal/host/edgesfile.go +16 −0
diff --git a/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md b/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md
index 0870d9ffa..9d1020a2e 100644
--- a/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md
+++ b/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md
@@ -695,11 +695,38 @@ alternative to `issuing-cert`. The vocabulary honesty test now produces it
from an input the platform can actually produce, which is the same bar every
other state clears.
-§3f remains open. Consequence stated plainly: `control/certs/` entries for
-detached hosts linger until something else removes them. That is not a
-correctness or safety problem — §3f's whole ruling was that the sweep must
-key on EXPIRY rather than detachment, so nothing is being deleted wrongly by
-not running it.
+**§3f is built (2026-08-29)**, in `internal/edge/certsweep.go`, and it took
+#243's spent-token litter with it — one issuer-side pass over
+`control/certs/`, two predicates, because the two kinds of litter are not the
+same kind of thing:
+
+- **Spent challenge tokens** (#243) go on AGE alone. A validation's whole
+ life is seconds and autocert's order context is five minutes, so the
+ one-hour TTL is generous by two orders of magnitude — deliberately, since
+ deleting a token out from under a live validation burns the order.
+- **Certificates** go only on their own leaf's expiry, plus a month. §3f's
+ ruling is unchanged and is the reason the file opens with it: a sweep keyed
+ "no claim exists for this host" would delete every platform host's cert,
+ every wildcard PEM and the account keys, and a registry lag could take a
+ live host down fleet-wide through the mirror.
+
+Three bounds the spec did not name:
+
+- **A parse floor.** Nothing younger than sixty days is read at all. autocert
+ renews at ~30 days remaining on a 90-day certificate, so a healthy entry's
+ object stamp never gets that old — which makes the steady-state cost one
+ listing and zero reads, instead of the per-object N+1 this would otherwise
+ be. An entry that IS that old is one nothing is renewing, which is exactly
+ the population worth reading.
+- **Anything unreadable is left alone.** A sweep that deletes what it cannot
+ parse is a sweep that deletes tomorrow's format.
+- **Fifty deletions per pass.** A bug in the predicate removes at most fifty
+ objects before somebody reads the log.
+
+The gate check moved above the empty-warm-set return in `pass()`: token
+litter and dead certs exist in a deployment with no custom domains at all,
+and the old ordering would have meant such a deployment never swept. A
+non-issuer still sweeps nothing.
### The shared reader is a READER, not the wire-shape owner
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 5d78f5b13..6991285a0 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -677,7 +677,7 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
// cross-account uniqueness gate), so the tenant boundary is this filter
// and nothing else: every row that reaches the answer matched BOTH this
// account's sqid and this app.
- claims, err := domains.ListClaims(ctx, s.cfg.Releases)
+ claims, err := s.claims.List(ctx, s.cfg.Releases)
if err != nil {
log.Printf("console: api domains list %s/%s: %v", acct.Sqid, app, err)
jsonError(w, http.StatusBadGateway, "could not list the hostnames")
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index a8f2ae177..88a16d976 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -38,7 +38,6 @@ import (
"strings"
"time"
- "github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
@@ -406,10 +405,23 @@ func (s *Server) mailCard(ctx context.Context, acct *Account, app string) *MailC
}
// settingsDomains is the Settings page's own Domains & TLS rows: routed
-// (the AppView's own DomainView slice, untouched) plus a placeholder row
-// for any of this app's domain claims that routed has no entry for yet.
-// Best-effort like the rest of this file — a claim-list read failure is
-// logged and the page falls back to routed alone, never a hard error.
+// (the AppView's own DomainView slice) plus a placeholder row for any of
+// this app's domain claims that routed has no entry for yet — and, for every
+// claim-backed row, the derived lifecycle state.
+//
+// The state is the point. Until it was threaded here the Settings page was
+// the last first-party surface still telling the pre-#299 story: it read
+// THIS box's own registry copy of a route and printed a DNS word and a TLS
+// word, while `carlos domains list` — reading the fleet-wide sweep — could
+// be saying something else about the same hostname at the same moment. Both
+// now come from domainState(), over the same inputs.
+//
+// Best-effort like the rest of this file. A claim-list read failure is
+// logged and the page falls back to routed alone; a readings or warm-record
+// failure leaves the state empty and the row renders exactly as it did
+// before. Neither is ever a hard error — a settings page that 500s because a
+// telemetry prefix was briefly unreadable would be a worse answer than one
+// that shows a little less.
func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, routed []DomainView) []DomainView {
out := append([]DomainView(nil), routed...)
if s.cfg.Releases == nil {
@@ -419,20 +431,33 @@ func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string,
for i, d := range out {
have[strings.ToLower(d.Domain)] = i
}
- claims, err := domains.ListClaims(ctx, s.cfg.Releases)
+ claims, err := s.claims.List(ctx, s.cfg.Releases)
if err != nil {
log.Printf("console: settings claims %s/%s: %v", acct.Sqid, app, err)
return out
}
+ // Folded ONCE for the page, not once per row: readings is every live
+ // box's alias stamps, warm is one object per hostname currently failing
+ // to get a certificate (usually none at all).
+ now := time.Now()
+ readings := aliasReadings(ctx, s.cfg.Releases, now)
+ warm := warmRecords(ctx, s.cfg.Releases)
+ state := func(host string) (st, reason, detail string) {
+ reading, seen := readings[host]
+ return domainState(reading, seen, warm[host], now)
+ }
for _, c := range claims {
if c.Account != acct.Sqid || c.App != app {
continue
}
if i, ok := have[strings.ToLower(c.Host)]; ok {
out[i].Claimed = true
+ out[i].State, out[i].Reason, out[i].Detail = state(out[i].Domain)
continue
}
- out = append(out, claimDomainView(c.Host, s.dnsTargets()))
+ dv := claimDomainView(c.Host, s.dnsTargets())
+ dv.State, dv.Reason, dv.Detail = state(c.Host)
+ out = append(out, dv)
}
return out
}
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index 5c01b0b21..f4b6edde1 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -12,6 +12,9 @@ package console
import (
"context"
"errors"
+ "github.com/carlosframework/platform/internal/certwarm"
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/status"
"net/http"
"net/url"
"strings"
@@ -1439,3 +1442,113 @@ func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) {
t.Fatalf("a provisioner-less console still suggests a command that would 404:\n%s", body)
}
}
+
+// The Settings page was the last first-party surface still telling the
+// pre-#299 story: it read THIS box's registry copy and printed a DNS word,
+// while `carlos domains list` read the fleet-wide sweep and could be saying
+// something else about the same hostname at the same moment. Both must now
+// come from domainState over the same inputs.
+func TestSettingsDomainsCarryTheSameStateTheAPIDoorDerives(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Now()
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("fixture account %s: %v", fx.sqid, err)
+ }
+ if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
+ Host: "www.customer.test", Account: fx.sqid, App: "kass",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := status.WriteBox(ctx, fx.rel, status.Box{
+ GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
+ Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
+ "www.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()},
+ }},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ got := fx.srv.settingsDomains(ctx, acct, "kass", nil)
+ if len(got) != 1 {
+ t.Fatalf("rows = %+v", got)
+ }
+ wantState, wantReason, wantDetail := domainState(
+ status.AliasReading{Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()},
+ true, nil, now)
+ if got[0].State != wantState || got[0].Reason != wantReason || got[0].Detail != wantDetail {
+ t.Errorf("row = state %q reason %q detail %q; want %q / %q / %q — the page and the CLI must derive one verdict",
+ got[0].State, got[0].Reason, got[0].Detail, wantState, wantReason, wantDetail)
+ }
+ if got[0].Status() != wantState {
+ t.Errorf("Status() = %q, want the derived state %q", got[0].Status(), wantState)
+ }
+}
+
+// A failing certificate reaches the page, through the same warm-outcome
+// record the API doors read (#299 §3e). This is the surface a member is most
+// likely to be looking at when it happens.
+func TestSettingsDomainsShowAFailedCertificate(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Now()
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("fixture account %s: %v", fx.sqid, err)
+ }
+ if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
+ Host: "broken.customer.test", Account: fx.sqid, App: "kass",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := status.WriteBox(ctx, fx.rel, status.Box{
+ GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1",
+ Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{
+ "broken.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "pointing", DNSCheckedAt: now.Unix()},
+ }},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ body, err := certwarm.Record{Host: "broken.customer.test", Attempts: domainFailedAttempts,
+ Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()}.Encode(now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(ctx, certwarm.Key("broken.customer.test"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ got := fx.srv.settingsDomains(ctx, acct, "kass", nil)
+ if len(got) != 1 || got[0].State != StateFailed {
+ t.Fatalf("rows = %+v, want one row in state %q", got, StateFailed)
+ }
+ // And it EXPLAINS. A one-word "failed" with no sentence under it is the
+ // worst version of this row.
+ if got[0].Explain() == "" {
+ t.Error("a failed certificate row carries no explanation")
+ }
+}
+
+// Explain is what keeps the page readable: a working domain gets no
+// paragraph, because a sentence repeated under every healthy row trains
+// people to skip the one under the broken row.
+func TestDomainViewExplainStaysQuietWhenThereIsNothingToSay(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ d DomainView
+ want string
+ }{
+ {"live says nothing", DomainView{State: StateLive, Detail: "served over HTTPS"}, ""},
+ {"a problem says its detail", DomainView{State: StateMisdirected, Detail: "points elsewhere"}, "points elsewhere"},
+ {"a problem with records says both",
+ DomainView{State: StatePendingDNS, Detail: "no record yet", Guidance: "Point an A record."},
+ "no record yet Point an A record."},
+ // A row the shared view builder produced, with no fleet fold behind
+ // it, must render exactly as it always has.
+ {"no state falls back to guidance", DomainView{Guidance: "Point an A record."}, "Point an A record."},
+ {"no state and no guidance", DomainView{}, ""},
+ } {
+ if got := tc.d.Explain(); got != tc.want {
+ t.Errorf("%s: Explain() = %q, want %q", tc.name, got, tc.want)
+ }
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 5ec66f115..1acdcf8bd 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -588,8 +588,13 @@ type Config struct {
// Server is the console's http.Handler.
type Server struct {
- cfg Config
- store *Store
+ cfg Config
+ store *Store
+ // claims folds the deployment's domain claims for the read paths — the
+ // Settings render and the domains list door — reading only the claims
+ // whose bucket stamp moved since the last fold (#173). Process-lifetime
+ // and safe for concurrent handlers; see domains.ClaimCache.
+ claims domains.ClaimCache
km *Keymail
mux *http.ServeMux
instances *instanceFetcher
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 75f72e3e8..5dafd1603 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -37,16 +37,16 @@ bucket keys and stays.</p>
{{if .Settings.Domains}}
<div class="tablewrap">
<table>
- <tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr>
+ <tr><th>Domain</th><th>Type</th><th>Status</th><th>Certificate</th></tr>
{{range $i, $d := .Settings.Domains}}
<tr>
<td><code>{{$d.Domain}}</code></td>
<td>{{$d.Type}}</td>
- <td>{{if $d.DNS}}{{$d.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td>
+ <td>{{with $d.Status}}{{.}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td>
<td>{{if $d.TLS}}{{$d.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td>
</tr>
- {{if $d.Guidance}}
- <tr><td colspan="4" class="small">{{$d.Guidance}}</td></tr>
+ {{with $d.Explain}}
+ <tr><td colspan="4" class="small">{{.}}</td></tr>
{{end}}
{{if and $.IsOwner $d.Claimed}}
<tr><td colspan="4" class="small">
@@ -78,8 +78,10 @@ at its platform domain; attaching adds addresses, it never removes one.</p>{{end
{{end}}
<button type="submit" class="btn-sm">Attach domain</button>
</form>
-<p class="small">Point the domain's A record at this deployment's edge;
-the certificate is minted on the first request and renews on its own.</p>
+<p class="small">Point the domain's DNS at this deployment — once it is
+attached, the row above says exactly what to create. The platform obtains the
+certificate itself and renews it, so nothing has to visit the domain
+first.</p>
{{end}}
{{if and .Settings .Settings.MismatchTarget}}
diff --git a/internal/console/view.go b/internal/console/view.go
index c0c2a9926..ec65561ed 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -128,6 +128,17 @@ func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 }
type DomainView struct {
Domain, Type, DNS, TLS string
Guidance string
+ // State, Reason and Detail are the derived lifecycle words (#299 §2a) —
+ // the SAME three domainState() gives `carlos domains list` and the API
+ // doors, from the same fleet-wide readings, so the console page and the
+ // CLI can never tell a member two stories about one hostname. That was
+ // the whole complaint #299 opened with.
+ //
+ // Empty on a row that has not been through settingsDomains: the shared
+ // view builder (domainView, below) folds one box's own registry copy and
+ // has no fleet fold to derive a state from. Readers must treat "" as
+ // "this build did not derive one here", never as a verdict.
+ State, Reason, Detail string
// Claimed is Settings-only (settingsDomains, appsettings.go): true when
// this row corresponds to a domain claim this app holds — routed or not
// — which is what the Settings page uses to decide whether a detach
@@ -136,6 +147,41 @@ type DomainView struct {
Claimed bool
}
+// Status is the one word the Domains table shows: the derived lifecycle
+// state where there is one, and otherwise the raw DNS word this box
+// stamped. Never both — a Status column and a DNS column side by side would
+// be two answers to one question, which is what #299 was opened about.
+func (d DomainView) Status() string {
+ if d.State != "" {
+ return d.State
+ }
+ return d.DNS
+}
+
+// Explain is the sentence under a Domains row, or "" for no sentence.
+//
+// A healthy row gets none: "live" beside a current renewal date says
+// everything, and a paragraph repeating it under every working domain is
+// noise that trains people to skip the paragraph under the broken one.
+// Everything else gets the state's own detail — the same sentence the CLI
+// prints, verbatim — followed by the DNS instructions when there are any.
+//
+// A row with no state at all keeps exactly today's behaviour: guidance
+// alone. That is the platform-domain rows and anything the shared view
+// builder produced without a fleet fold.
+func (d DomainView) Explain() string {
+ if d.State == "" {
+ return d.Guidance
+ }
+ if d.State == StateLive || d.Detail == "" {
+ return ""
+ }
+ if d.Guidance == "" {
+ return d.Detail
+ }
+ return d.Detail + " " + d.Guidance
+}
+
// DNSTargets is where this deployment tells a member to point a custom
// domain. Two facts, threaded together because the guidance is one
// sentence and needs both: the edge addresses an A record names, and —
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
index 77b23ce7b..21530eb4c 100644
--- a/internal/domains/aliases.go
+++ b/internal/domains/aliases.go
@@ -40,8 +40,13 @@ const maxWildcardDomains = 32
// no instance record to read. nil means "nothing known" and is PERMISSIVE by
// contract — a bucket the fold could not read must never break ordinary
// attach.
-func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time, deploymentRoutes map[string]bool) error {
- claims, err := ListClaims(ctx, s)
+// cache, when non-nil, folds the claims through a ClaimCache instead of
+// re-reading every claim body. This pass runs on the fast-converge leg — a
+// seconds cadence, on every serial bump — so the difference is one bucket
+// call against one plus a GET per claim in the deployment, every few
+// seconds, on every box. nil is legal and means today's uncached fold.
+func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time, deploymentRoutes map[string]bool, cache *ClaimCache) error {
+ claims, err := cache.List(ctx, s)
if err != nil {
return err
}
diff --git a/internal/domains/aliases_test.go b/internal/domains/aliases_test.go
index 2507395f8..d51f64c4c 100644
--- a/internal/domains/aliases_test.go
+++ b/internal/domains/aliases_test.go
@@ -77,7 +77,7 @@ func TestReconcileAliasesMaterializes(t *testing.T) {
putClaim(t, s, Claim{
Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
})
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -103,7 +103,7 @@ func TestReconcileAliasesOwnershipGate(t *testing.T) {
putClaim(t, s, Claim{
Host: "tickets.example.com", Account: "bab", App: "somethingelse", Route: "canary/live",
})
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -129,7 +129,7 @@ func TestReconcileAliasesOwnershipGateAccountMismatch(t *testing.T) {
putClaim(t, s, Claim{
Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
})
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -150,7 +150,7 @@ func TestReconcileAliasesRoutesWin(t *testing.T) {
putClaim(t, s, Claim{
Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
})
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -168,7 +168,7 @@ func TestReconcileAliasesMissingRouteSkips(t *testing.T) {
putClaim(t, s, Claim{
Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
})
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -192,7 +192,7 @@ func TestReconcileAliasesRemovesOrphans(t *testing.T) {
t.Fatal(err)
}
// No claim backs orphan.example.com.
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -213,7 +213,7 @@ func TestReconcileAliasesRouteless(t *testing.T) {
putClaim(t, s, Claim{
Host: "tickets.example.com", Account: "bab", App: "kass",
})
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -233,7 +233,7 @@ func TestReconcileAliasesIdempotent(t *testing.T) {
putClaim(t, s, Claim{
Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
})
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
first, err := reg.ListAliases()
@@ -247,7 +247,7 @@ func TestReconcileAliasesIdempotent(t *testing.T) {
// Second pass, later clock: CreatedAt must be preserved, not bumped.
later := func() time.Time { return time.Unix(1755000000+3600, 0) }
- if err := ReconcileAliases(context.Background(), s, reg, later, nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, later, nil, nil); err != nil {
t.Fatal(err)
}
second, err := reg.ListAliases()
@@ -284,7 +284,7 @@ func TestReconcileWildcardCreatedAtStable(t *testing.T) {
Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z",
Wildcard: true, Catchall: true})
- if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000, 0) }, nil, nil); err != nil {
t.Fatal(err)
}
first, err := reg.ListWildcardDomains()
@@ -300,7 +300,7 @@ func TestReconcileWildcardCreatedAtStable(t *testing.T) {
// not bumped — a bump here would move Fingerprint's wildcard_domains
// section every poll and force a reload→refresh loop with nothing
// actually changed.
- if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000+3600, 0) }, nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, func() time.Time { return time.Unix(1000+3600, 0) }, nil, nil); err != nil {
t.Fatal(err)
}
second, err := reg.ListWildcardDomains()
@@ -335,7 +335,7 @@ func TestReconcileDerivesWildcardState(t *testing.T) {
putClaim(t, s, Claim{Host: "bad host.example", Account: "bab", App: "woodstar-relay",
Route: "woodstar-relay.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true, Catchall: true})
- if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil {
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil, nil); err != nil {
t.Fatal(err)
}
@@ -359,7 +359,7 @@ func TestReconcileDerivesWildcardState(t *testing.T) {
// Claim removed => derived state removed (pass-owned).
deleteClaim(t, s, "woodstar.app")
- if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(2000, 0) }, nil); err != nil {
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(2000, 0) }, nil, nil); err != nil {
t.Fatal(err)
}
wds, _ = reg.ListWildcardDomains()
@@ -392,7 +392,7 @@ func TestReconcileWildcardHostCollisionMismatchedTenant(t *testing.T) {
Route: "primary.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z",
Wildcard: true, Catchall: true})
- if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil {
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil, nil); err != nil {
t.Fatal(err)
}
wds, err := reg.ListWildcardDomains()
@@ -427,7 +427,7 @@ func TestReconcileWildcardCap(t *testing.T) {
Route: "primary.bab.oncarlos.com", CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true})
}
- if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil {
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil, nil); err != nil {
t.Fatal(err)
}
wds, err := reg.ListWildcardDomains()
@@ -454,7 +454,7 @@ func TestReconcileWildcardEmptyRouteClaim(t *testing.T) {
putClaim(t, s, Claim{Host: "story.example", Account: "bab", App: "story",
CreatedAt: "2026-08-13T00:00:00Z", Wildcard: true, Catchall: true})
- if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil); err != nil {
+ if err := ReconcileAliases(ctx, s, reg, func() time.Time { return time.Unix(1000, 0) }, nil, nil); err != nil {
t.Fatal(err)
}
wds, _ := reg.ListWildcardDomains()
@@ -491,7 +491,7 @@ func TestReconcileAliasesRefusesAHostAnotherBoxServesAsARoute(t *testing.T) {
// go.tito.io is a real, non-alias route on some OTHER box in the fleet.
deploymentRoutes := map[string]bool{"go.tito.io": true}
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -524,7 +524,7 @@ func TestReconcileAliasesRemovesAnAliasThatNowCollidesWithADeploymentRoute(t *te
deploymentRoutes := map[string]bool{"go.tito.io": true}
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -553,7 +553,7 @@ func TestReconcileAliasesStillMaterializesWhenTheRouteSetIsUnknown(t *testing.T)
Host: "shop.customer.com", Account: "bab", App: "kass", Route: "canary/live",
})
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), nil, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
@@ -593,7 +593,7 @@ func TestReconcileAliasesStillDerivesTheCatchAllForAFirstRouteClaim(t *testing.T
// The claimant's own box publishes woodstar.app as a REAL route.
deploymentRoutes := map[string]bool{"woodstar.app": true}
- if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes); err != nil {
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow(), deploymentRoutes, nil); err != nil {
t.Fatal(err)
}
aliases, err := reg.ListAliases()
diff --git a/internal/domains/claimcache.go b/internal/domains/claimcache.go
new file mode 100644
index 000000000..b64c5f882
--- /dev/null
+++ b/internal/domains/claimcache.go
@@ -0,0 +1,136 @@
+package domains
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ClaimCache makes the claim fold cost one bucket call in the steady state
+// instead of one plus a GET per claim (#173, #165).
+//
+// The problem it solves. ListClaims lists domains/ and then reads every
+// object under it, and three things do that repeatedly: every app Settings
+// render, every `carlos domains list`, and every box's ReconcileAliases pass
+// — the last one on a five-minute floor, ahead of the adopt leg inside a
+// two-second convergence promise. At tens of claims that is invisible. At
+// the hundreds titogo's custom domains are heading for it is a slow page and
+// a steady drip of bucket reads on every box in the fleet, forever.
+//
+// The fix needs no new object and no new writer, because the listing already
+// carries the answer: S3 returns LastModified with every ListObjectsV2 entry
+// and DirStore has the file mtime, both of which release.ModLister already
+// surfaces and List throws away. A claim whose last-modified time has not
+// moved has not changed, so its body does not need re-reading. The steady
+// state is therefore ONE ListMod and zero GETs, and the first pass after a
+// claim changes reads exactly the claims that changed.
+//
+// This is a cache with no TTL and no invalidation call, which is the point:
+// it is not guessing about staleness, it is being told. Two exact rules do
+// all the work — an entry is reused only when its stamp is unchanged, and an
+// entry whose key has left the listing is dropped, which is the detach path.
+//
+// A store that cannot answer ListMod (the fleet store door's client, every
+// test fake) degrades to a plain ListClaims with no caching at all. That is
+// deliberate: a fallback that cached on time instead would be a different,
+// weaker contract wearing the same name, and the one place it would be used
+// is the one place — a customer fleet box — where a stale claim set is
+// hardest to notice.
+//
+// Safe for concurrent use. The zero value is ready, and a nil *ClaimCache
+// is legal and means "no caching" — so a caller that has no cache to offer
+// passes nil rather than every call site growing a branch.
+type ClaimCache struct {
+ mu sync.Mutex
+ entries map[string]cachedClaim
+}
+
+type cachedClaim struct {
+ mod time.Time
+ claim Claim
+ // skipped records that this key was listed but produced no claim for a
+ // DETERMINISTIC reason — unparseable, or a schema newer than this
+ // binary. Cached like a hit so a permanently undecodable object does not
+ // earn a GET on every pass forever. A key whose READ failed is never
+ // cached at all: see readClaim's firm return.
+ skipped bool
+}
+
+// modSettle is how recently an object may have been written before its
+// stamp stops being trustworthy as an unchanged-marker.
+//
+// S3 last-modified has one-second granularity. Without this bound there is a
+// real lost update: read a claim at T and cache stamp T, have it rewritten
+// later in the same second T, and every later listing shows stamp T — which
+// this cache would read as "unchanged" forever. Refusing to cache anything
+// stamped within the last two seconds closes it, at the cost of re-reading a
+// just-changed claim once or twice more. Claims change on human timescales,
+// so that cost is approximately never paid.
+const modSettle = 2 * time.Second
+
+// List folds the deployment's claims, reading only the ones that changed.
+//
+// The returned slice is fresh on every call and safe for the caller to keep;
+// Claim is a value type with no reference fields, so cached entries cannot
+// be mutated through it.
+func (c *ClaimCache) List(ctx context.Context, s release.Store) ([]Claim, error) {
+ lister, ok := s.(release.ModLister)
+ if c == nil || !ok {
+ return ListClaims(ctx, s)
+ }
+ listed, err := lister.ListMod(ctx, ClaimsPrefix)
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ next := make(map[string]cachedClaim, len(listed))
+ claims := make([]Claim, 0, len(listed))
+ for _, km := range listed {
+ if hit, ok := c.entries[km.Key]; ok && hit.mod.Equal(km.Mod) {
+ // Carried forward WITHOUT re-checking modSettle: the bound
+ // applies when an entry is admitted, so anything already in the
+ // map was settled when it went in.
+ next[km.Key] = hit
+ if !hit.skipped {
+ claims = append(claims, hit.claim)
+ }
+ continue
+ }
+ claim, ok, firm := readClaim(ctx, s, km.Key)
+ if ok {
+ claims = append(claims, claim)
+ }
+ if !firm {
+ // The read itself failed. Remembering that would hide a good
+ // claim until something moved its stamp — and nothing edits a
+ // settled claim, so that is indistinguishable from forever.
+ continue
+ }
+ if now.Sub(km.Mod) < modSettle {
+ // Too fresh to trust as an unchanged-marker. Used, not kept.
+ continue
+ }
+ next[km.Key] = cachedClaim{mod: km.Mod, claim: claim, skipped: !ok}
+ }
+ // Whatever is not in next is not in the bucket: replacing the map whole
+ // IS the eviction, so a detached claim leaves the cache on the same pass
+ // it leaves the listing, with no separate invalidation to forget.
+ c.entries = next
+ return claims, nil
+}
+
+// Reset drops everything. Nothing in this package needs it; it exists so a
+// caller whose store changed underneath it (a test, a reconfiguration) can
+// say so explicitly rather than construct a new cache and lose the field.
+func (c *ClaimCache) Reset() {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.entries = nil
+}
diff --git a/internal/domains/claimcache_test.go b/internal/domains/claimcache_test.go
new file mode 100644
index 000000000..985dffad1
--- /dev/null
+++ b/internal/domains/claimcache_test.go
@@ -0,0 +1,374 @@
+package domains
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// countingStore counts the GETs the fold spends. That count IS the property
+// under test — the whole point of the cache is that a claim whose bucket
+// stamp has not moved costs nothing to fold again.
+type countingStore struct {
+ release.Store
+ gets int
+}
+
+func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.gets++
+ return c.Store.Get(ctx, key)
+}
+
+func (c *countingStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
+ return c.Store.(release.ModLister).ListMod(ctx, prefix)
+}
+
+func seedClaim(t *testing.T, s release.Store, host, app string) {
+ t.Helper()
+ if _, _, err := AttachClaim(context.Background(), s, Claim{Host: host, Account: "acct", App: app}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// backdate moves a claim's bucket stamp out of the modSettle window, which
+// is what a claim written more than a couple of seconds ago looks like. Real
+// claims change on human timescales; a test cannot afford to sleep for them.
+func backdate(t *testing.T, dir, host string) {
+ t.Helper()
+ old := time.Now().Add(-time.Hour)
+ if err := os.Chtimes(dir+"/"+ClaimKey(host), old, old); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestClaimCacheReReadsNothingWhenNothingChanged(t *testing.T) {
+ dir := t.TempDir()
+ s := &countingStore{Store: release.NewDirStore(dir)}
+ ctx := context.Background()
+ for _, h := range []string{"a.customer.test", "b.customer.test", "c.customer.test"} {
+ seedClaim(t, s, h, "kass")
+ backdate(t, dir, h)
+ }
+ s.gets = 0
+
+ var c ClaimCache
+ first, err := c.List(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(first) != 3 {
+ t.Fatalf("first fold = %+v, want 3 claims", first)
+ }
+ cold := s.gets
+ if cold != 3 {
+ t.Fatalf("cold fold spent %d gets, want one per claim", cold)
+ }
+
+ s.gets = 0
+ second, err := c.List(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(second) != 3 {
+ t.Fatalf("warm fold = %+v, want the same 3 claims", second)
+ }
+ if s.gets != 0 {
+ t.Errorf("warm fold spent %d gets, want 0 — an unmoved stamp must cost nothing", s.gets)
+ }
+}
+
+// The cache must be invisible: what it returns has to be what the uncached
+// fold returns, claim for claim, or a hostname appears on one read path and
+// vanishes on another.
+func TestClaimCacheAgreesWithTheUncachedFold(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ ctx := context.Background()
+ for _, h := range []string{"a.customer.test", "b.customer.test"} {
+ seedClaim(t, s, h, "kass")
+ backdate(t, dir, h)
+ }
+ var c ClaimCache
+ if _, err := c.List(ctx, s); err != nil { // prime
+ t.Fatal(err)
+ }
+ cached, err := c.List(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ plain, err := ListClaims(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cached) != len(plain) {
+ t.Fatalf("cached fold has %d claims, uncached has %d", len(cached), len(plain))
+ }
+ byHost := map[string]Claim{}
+ for _, p := range plain {
+ byHost[p.Host] = p
+ }
+ for _, got := range cached {
+ want, ok := byHost[got.Host]
+ if !ok {
+ t.Errorf("cached fold invented %s", got.Host)
+ continue
+ }
+ if got != want {
+ t.Errorf("%s: cached %+v, uncached %+v", got.Host, got, want)
+ }
+ }
+}
+
+// A detached claim leaves the listing, and leaving the listing IS the
+// eviction — there is no separate invalidation call to forget to make.
+func TestClaimCacheDropsADetachedClaim(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ ctx := context.Background()
+ seedClaim(t, s, "a.customer.test", "kass")
+ seedClaim(t, s, "b.customer.test", "kass")
+ backdate(t, dir, "a.customer.test")
+ backdate(t, dir, "b.customer.test")
+
+ var c ClaimCache
+ if got, err := c.List(ctx, s); err != nil || len(got) != 2 {
+ t.Fatalf("first fold = %+v, %v", got, err)
+ }
+ if _, err := DetachClaim(ctx, s, "b.customer.test", "acct", "kass"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := c.List(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Host != "a.customer.test" {
+ t.Fatalf("after detach = %+v, want only a.customer.test", got)
+ }
+}
+
+// A changed claim is re-read, and only it. This is the case a cache gets
+// wrong: serving a stale body because the listing said the key is still
+// there.
+func TestClaimCacheReReadsOnlyWhatChanged(t *testing.T) {
+ dir := t.TempDir()
+ s := &countingStore{Store: release.NewDirStore(dir)}
+ ctx := context.Background()
+ seedClaim(t, s, "a.customer.test", "kass")
+ seedClaim(t, s, "b.customer.test", "kass")
+ backdate(t, dir, "a.customer.test")
+ backdate(t, dir, "b.customer.test")
+
+ var c ClaimCache
+ if _, err := c.List(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetClaimCapabilities(ctx, s, "b.customer.test", "acct", "kass", true, false); err != nil {
+ t.Fatal(err)
+ }
+ backdate(t, dir, "b.customer.test") // settled, but at a DIFFERENT stamp
+ s.gets = 0
+ got, err := c.List(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.gets != 1 {
+ t.Errorf("spent %d gets, want exactly 1 — only the changed claim", s.gets)
+ }
+ for _, cl := range got {
+ if cl.Host == "b.customer.test" && !cl.Wildcard {
+ t.Error("the changed claim came back stale — the cache served a body the bucket no longer holds")
+ }
+ }
+}
+
+// THE lost update the modSettle bound exists for. S3 stamps have one-second
+// granularity, so a claim read and then rewritten inside the same second
+// carries an unchanged-looking stamp. Caching that read would serve the old
+// body forever, and "forever" is not an exaggeration: nothing else would
+// ever move the stamp.
+func TestClaimCacheRefusesToTrustAStampItJustSaw(t *testing.T) {
+ dir := t.TempDir()
+ s := &countingStore{Store: release.NewDirStore(dir)}
+ ctx := context.Background()
+ seedClaim(t, s, "a.customer.test", "kass") // written just now: inside modSettle
+
+ var c ClaimCache
+ if _, err := c.List(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ s.gets = 0
+ if _, err := c.List(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ if s.gets != 1 {
+ t.Errorf("spent %d gets, want 1 — a stamp from the last two seconds must not be trusted as unchanged", s.gets)
+ }
+}
+
+// A store that cannot answer ListMod — the fleet store door's client, every
+// test fake — degrades to the plain fold. Not to a weaker cache: a customer
+// fleet box is the hardest place to notice a stale claim set.
+func TestClaimCacheDegradesOnAStoreWithNoStamps(t *testing.T) {
+ s := stamplessStore{release.NewDirStore(t.TempDir())}
+ ctx := context.Background()
+ seedClaim(t, s, "a.customer.test", "kass")
+ var c ClaimCache
+ got, err := c.List(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("fold = %+v, want the claim", got)
+ }
+ // Nothing was cached, so a second fold still sees a change made between
+ // the two — which is the whole point of degrading rather than guessing.
+ seedClaim(t, s, "b.customer.test", "kass")
+ if got, err := c.List(ctx, s); err != nil || len(got) != 2 {
+ t.Fatalf("second fold = %+v, %v; want both claims", got, err)
+ }
+}
+
+// stamplessStore hides DirStore's ListMod behind a plain Store, the way the
+// fleet door's client and the package's test fakes do.
+type stamplessStore struct{ inner release.Store }
+
+func (s stamplessStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ return s.inner.Get(ctx, key)
+}
+func (s stamplessStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ return s.inner.Put(ctx, key, body, ifMatch)
+}
+func (s stamplessStore) List(ctx context.Context, prefix string) ([]string, error) {
+ return s.inner.List(ctx, prefix)
+}
+func (s stamplessStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ return s.inner.ListDirs(ctx, prefix)
+}
+func (s stamplessStore) Delete(ctx context.Context, key string) error {
+ return s.inner.Delete(ctx, key)
+}
+
+// A nil cache is legal and means "no caching", so a caller with none to
+// offer passes nil rather than every call site growing a branch.
+func TestNilClaimCacheFoldsWithoutCaching(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ seedClaim(t, s, "a.customer.test", "kass")
+ var c *ClaimCache
+ got, err := c.List(ctx, s)
+ if err != nil || len(got) != 1 {
+ t.Fatalf("nil cache fold = %+v, %v", got, err)
+ }
+ c.Reset() // must not panic
+}
+
+// A transient read failure must not be remembered. Remembering it would
+// hide a perfectly good claim until something moved its bucket stamp — and
+// nothing edits a settled claim, so for that hostname "until" means never.
+// A decode refusal IS remembered, because the same bytes refuse the same way
+// every pass and re-reading them forever buys nothing.
+func TestClaimCacheNeverRemembersATransientReadFailure(t *testing.T) {
+ dir := t.TempDir()
+ inner := release.NewDirStore(dir)
+ s := &flakyStore{Store: inner}
+ ctx := context.Background()
+ seedClaim(t, inner, "a.customer.test", "kass")
+ backdate(t, dir, "a.customer.test")
+
+ var c ClaimCache
+ s.failNext = true
+ got, err := c.List(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("fold during the blip = %+v, want nothing readable", got)
+ }
+ // The blip is over. The claim must come back on the very next fold,
+ // with no stamp change to prompt it.
+ got, err = c.List(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Host != "a.customer.test" {
+ t.Fatalf("after the blip = %+v, want the claim back — a read failure was cached", got)
+ }
+}
+
+type flakyStore struct {
+ release.Store
+ failNext bool
+}
+
+func (f *flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if f.failNext {
+ f.failNext = false
+ return nil, "", errFlaky
+ }
+ return f.Store.Get(ctx, key)
+}
+
+func (f *flakyStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
+ return f.Store.(release.ModLister).ListMod(ctx, prefix)
+}
+
+var errFlaky = errors.New("bucket blip")
+
+// An object that will never decode is remembered, so a deployment carrying
+// one piece of litter under domains/ does not pay a GET for it on every
+// fold, on every box, forever.
+func TestClaimCacheRemembersAnUndecodableObject(t *testing.T) {
+ dir := t.TempDir()
+ s := &countingStore{Store: release.NewDirStore(dir)}
+ ctx := context.Background()
+ if _, err := s.Put(ctx, ClaimKey("junk.customer.test"), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ backdate(t, dir, "junk.customer.test")
+ var c ClaimCache
+ if got, err := c.List(ctx, s); err != nil || len(got) != 0 {
+ t.Fatalf("fold = %+v, %v; want nothing", got, err)
+ }
+ s.gets = 0
+ if _, err := c.List(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ if s.gets != 0 {
+ t.Errorf("spent %d gets re-reading an object that will never decode", s.gets)
+ }
+}
+
+// The entry map is replaced whole rather than merged, so a deployment that
+// churns through hostnames does not accumulate an entry per hostname it has
+// ever held. Reaching into the field is white-box on purpose: there is no
+// behaviour to observe here, only unbounded growth, and unbounded growth
+// with no test is how a leak reaches production.
+func TestClaimCacheDoesNotGrowWithChurnedHostnames(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ ctx := context.Background()
+ var c ClaimCache
+ for i, host := range []string{"one.customer.test", "two.customer.test", "three.customer.test"} {
+ seedClaim(t, s, host, "kass")
+ backdate(t, dir, host)
+ if _, err := c.List(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := DetachClaim(ctx, s, host, "acct", "kass"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.List(ctx, s); err != nil {
+ t.Fatal(err)
+ }
+ c.mu.Lock()
+ n := len(c.entries)
+ c.mu.Unlock()
+ if n != 0 {
+ t.Fatalf("after churning %d hostname(s) the cache holds %d entries, want 0", i+1, n)
+ }
+ }
+}
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
index 7cdb7dacc..ec8c8533e 100644
--- a/internal/domains/claims.go
+++ b/internal/domains/claims.go
@@ -179,25 +179,46 @@ func ListClaims(ctx context.Context, s release.Store) ([]Claim, error) {
}
var claims []Claim
for _, key := range keys {
- raw, _, err := s.Get(ctx, key)
- if err != nil {
- log.Printf("domains: claim %s: %v", key, err)
- continue
- }
- var c Claim
- if err := json.Unmarshal(raw, &c); err != nil {
- log.Printf("domains: claim %s: unparseable: %v", key, err)
- continue
+ if c, ok, _ := readClaim(ctx, s, key); ok {
+ claims = append(claims, c)
}
- if c.Schema > 1 {
- log.Printf("domains: claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema)
- continue
- }
- claims = append(claims, c)
}
return claims, nil
}
+// readClaim reads and decodes one claim object. Every refusal is a skip and
+// never an error to the caller: one unparseable or too-new object under the
+// prefix must not take down the whole fold.
+//
+// The two failure kinds are told apart, and the distinction is load-bearing
+// for ClaimCache. A DECODE refusal (unparseable, or a schema this binary
+// does not understand) is deterministic — the same bytes will refuse the
+// same way next pass — so it is safe to remember. A READ failure is a
+// network blip and is not: remembering it would hide a perfectly good claim
+// until something unrelated moved its bucket stamp, which for a claim that
+// nobody edits is indistinguishable from forever. So a read failure reports
+// firm=false and the cache declines to keep it.
+//
+// Shared with ClaimCache so the two folds cannot drift into different ideas
+// of what counts as a claim — which would show up as a hostname that appears
+// on a cached read and vanishes on an uncached one.
+func readClaim(ctx context.Context, s release.Store, key string) (c Claim, ok, firm bool) {
+ raw, _, err := s.Get(ctx, key)
+ if err != nil {
+ log.Printf("domains: claim %s: %v", key, err)
+ return Claim{}, false, false
+ }
+ if err := json.Unmarshal(raw, &c); err != nil {
+ log.Printf("domains: claim %s: unparseable: %v", key, err)
+ return Claim{}, false, true
+ }
+ if c.Schema > 1 {
+ log.Printf("domains: claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema)
+ return Claim{}, false, true
+ }
+ return c, true, true
+}
+
// hostLabelRe mirrors provision.labelRe (which itself mirrors
// domains.accountNameRe — see that var's comment) — pinned HERE rather than
// imported from internal/provision, because internal/provision/reconcile.go
diff --git a/internal/edge/certsweep.go b/internal/edge/certsweep.go
new file mode 100644
index 000000000..c4a710823
--- /dev/null
+++ b/internal/edge/certsweep.go
@@ -0,0 +1,194 @@
+// Cert sweep (#299 §3f, #243): the issuer removes cert-prefix entries that
+// are provably dead — and nothing else.
+//
+// Two kinds of litter accumulate under control/certs/, for two different
+// reasons, and they need two different predicates:
+//
+// - SPENT CHALLENGE TOKENS (#243). The issuer writes an HTTP-01 response
+// or a tls-alpn-01 token cert, validates, and deletes it — but the
+// delete is housekeeper-gated, so a stand-down or a crash mid-order
+// leaves the token behind permanently. A validation's whole life is
+// seconds, so AGE is a complete predicate here.
+// - CERTS FOR HOSTS NOBODY SERVES ANY MORE. A detached custom domain's
+// cert is never republished (ownsCertName refuses) and never deleted,
+// so the object lingers, the mirror serves it to matching SNI until it
+// expires, and the expired object then lingers forever.
+//
+// THE PREDICATE FOR THE SECOND IS EXPIRY, NEVER DETACHMENT, and the reason
+// is worth stating at length because the tempting version of this sweep is
+// catastrophic. A sweep keyed "no claim exists for this host" would delete
+// every non-claim-backed cert in the deployment — every platform host, every
+// wildcard PEM, the account keys — and worse, a registry lag or a stale
+// edges read would take a LIVE host's certs down fleet-wide through the
+// mirror. This repo's standing rule is that inference never destroys owner
+// property (internal/domains/reconcile.go §1c), and a certificate is the
+// member's property.
+//
+// So: an entry is deleted only when its own leaf expired long ago. That
+// dissolves the "the mirror serves it until expiry" concern by
+// construction, and it makes a wrong deletion harmless — the entry was
+// already dead, and anything still wanted is re-minted by the warm.
+//
+// Account keys are never touched, by name and again by shape: they are the
+// issuer's identity at the CA, and deleting one costs the deployment its
+// ACME registration.
+package edge
+
+import (
+ "context"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ // tokenLitterTTL is how long a spent challenge token may linger before
+ // the sweep takes it (#243). A validation completes in seconds and
+ // autocert's own order context is five minutes, so an hour is generous
+ // by two orders of magnitude — deliberately, because deleting a token
+ // out from under a live validation burns the order.
+ tokenLitterTTL = time.Hour
+ // certSweepParseAge is the pre-filter: an object younger than this is
+ // never even read, let alone deleted.
+ //
+ // It is what keeps the sweep from being the per-object N+1 it would
+ // otherwise be. autocert renews at ~30 days remaining on a 90-day
+ // certificate, so a HEALTHY entry is rewritten roughly every 60 days and
+ // its object stamp never gets this old. An entry that does is one
+ // nothing is renewing — which is exactly the population worth reading.
+ // Steady-state cost: one listing, zero reads.
+ certSweepParseAge = 60 * 24 * time.Hour
+ // certSweepGrace is how long a leaf must have been EXPIRED before its
+ // entry is removed. A month, because there is no hurry whatsoever: the
+ // cost of keeping a dead certificate is bytes, and the cost of removing
+ // a live one is an outage.
+ certSweepGrace = 30 * 24 * time.Hour
+ // certSweepInterval is how often the expiry half runs — the half that
+ // costs reads. A certificate dead for a month can wait a day.
+ certSweepInterval = 24 * time.Hour
+ // certSweepMinInterval is the floor for the whole sweep, token half
+ // included. It matches the warm's own quiet-path cadence, so an ordinary
+ // pass sweeps and a capped-drain pass (which comes back every minute
+ // until a backlog clears) does not.
+ certSweepMinInterval = warmInterval
+ // certSweepDeletesPerPass bounds the damage any single pass can do, in
+ // either direction: a bug here deletes at most this many objects before
+ // somebody sees the log, and a genuine backlog drains over days rather
+ // than in one burst against the bucket.
+ certSweepDeletesPerPass = 50
+)
+
+// sweep removes provably dead entries under the cert prefix. Issuer-gated by
+// its caller; see certWarm.pass.
+//
+// Never returns an error. Every failure is logged and skipped: a sweep is
+// housekeeping, and housekeeping that can fail a pass would take the warm
+// down with it.
+func (c *certWarm) sweep(ctx context.Context, now time.Time) {
+ if c.store == nil {
+ return
+ }
+ // Not on a capped-drain pass. When a first-time backlog trips
+ // warmOrdersPerPass, pass() comes back every minute until it clears, and
+ // housekeeping does not need doing sixty times an hour — the token TTL
+ // is an hour and the expiry half is daily. Without this the drain would
+ // spend a listing of the whole cert prefix per minute for nothing.
+ if !c.lastSweep.IsZero() && now.Sub(c.lastSweep) < certSweepMinInterval {
+ return
+ }
+ lister, ok := c.store.(release.ModLister)
+ if !ok {
+ // No stamps, no age, no sweep. Guessing at an object's age from its
+ // contents is exactly the inference this file refuses to make.
+ return
+ }
+ ctx, cancel := context.WithTimeout(ctx, certSweepTimeout)
+ defer cancel()
+ entries, err := lister.ListMod(ctx, certPrefix)
+ if err != nil {
+ log.Printf("edge: cert sweep: could not list %s: %v", certPrefix, err)
+ return
+ }
+ c.lastSweep = now
+ expiryDue := now.Sub(c.lastExpirySweep) >= certSweepInterval
+ deletes := 0
+ for _, e := range entries {
+ if deletes >= certSweepDeletesPerPass {
+ log.Printf("edge: cert sweep: stopping at %d deletions this pass — the rest go next pass", deletes)
+ break
+ }
+ name, ok := strings.CutPrefix(e.Key, certPrefix)
+ if !ok || name == "" {
+ continue
+ }
+ // Account material, first and unconditionally. Never by age, never
+ // by expiry, never at all.
+ if accountKeyName(name) {
+ continue
+ }
+ switch {
+ case isChallengeToken(name):
+ if now.Sub(e.Mod) < tokenLitterTTL {
+ continue
+ }
+ if c.deleteEntry(ctx, e.Key, "spent challenge token") {
+ deletes++
+ }
+ case expiryDue && now.Sub(e.Mod) >= certSweepParseAge:
+ if c.sweepExpired(ctx, e.Key, name, now) {
+ deletes++
+ }
+ }
+ }
+ if expiryDue {
+ c.lastExpirySweep = now
+ }
+}
+
+// certSweepTimeout budgets one sweep. It runs on the warm's own goroutine,
+// so a hung bucket must not hold the next warm pass hostage.
+const certSweepTimeout = 2 * time.Minute
+
+// isChallengeToken reports whether name is validation scratch rather than a
+// serving artifact — an HTTP-01 response body or a tls-alpn-01 token cert.
+func isChallengeToken(name string) bool {
+ return strings.HasSuffix(name, httpTokenSuffix) || strings.HasSuffix(name, alpnTokenSuffix)
+}
+
+// sweepExpired reads one long-untouched entry and removes it only if its own
+// leaf says it is long dead.
+//
+// Everything that is not a confident yes is a no. An entry that will not
+// read, will not parse, or carries no leaf is LEFT ALONE — it may be a shape
+// a newer build publishes, and a sweep that deletes what it cannot
+// understand is a sweep that deletes tomorrow's format.
+func (c *certWarm) sweepExpired(ctx context.Context, key, name string, now time.Time) bool {
+ raw, _, err := c.store.Get(ctx, key)
+ if err != nil {
+ log.Printf("edge: cert sweep: could not read %s: %v", key, err)
+ return false
+ }
+ cert, err := parseCertEntry(raw)
+ if err != nil || cert.Leaf == nil {
+ log.Printf("edge: cert sweep: %s does not parse as a certificate, leaving it alone", key)
+ return false
+ }
+ if now.Sub(cert.Leaf.NotAfter) < certSweepGrace {
+ return false
+ }
+ return c.deleteEntry(ctx, key, "certificate expired "+cert.Leaf.NotAfter.UTC().Format("2006-01-02")+" for "+name)
+}
+
+// deleteEntry removes one object and says so. Every deletion under this
+// prefix is logged with its reason — there is no such thing as a quiet
+// delete of cert material.
+func (c *certWarm) deleteEntry(ctx context.Context, key, why string) bool {
+ if err := c.store.Delete(ctx, key); err != nil {
+ log.Printf("edge: cert sweep: could not delete %s: %v", key, err)
+ return false
+ }
+ log.Printf("edge: cert sweep: removed %s — %s", key, why)
+ return true
+}
diff --git a/internal/edge/certsweep_test.go b/internal/edge/certsweep_test.go
new file mode 100644
index 000000000..406cff142
--- /dev/null
+++ b/internal/edge/certsweep_test.go
@@ -0,0 +1,321 @@
+package edge
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func sweepWarm(t *testing.T, s release.Store) *certWarm {
+ t.Helper()
+ return newCertWarm(func() []string { return nil }, nil, nil, nil, s)
+}
+
+// seedCertEntry writes one cert-prefix object and backdates its stamp, which
+// is how an object that has been sitting in the bucket for months looks.
+func seedCertEntry(t *testing.T, dir string, s release.Store, name string, body []byte, age time.Duration) {
+ t.Helper()
+ if _, err := s.Put(context.Background(), certKey(name), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ when := time.Now().Add(-age)
+ if err := os.Chtimes(dir+"/"+certKey(name), when, when); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func certGone(t *testing.T, s release.Store, name string) bool {
+ t.Helper()
+ _, _, err := s.Get(context.Background(), certKey(name))
+ return errors.Is(err, os.ErrNotExist)
+}
+
+func expiredPEM(t *testing.T, host string, expiredAgo time.Duration) []byte {
+ t.Helper()
+ cert, key := selfSignedLeaf(t, host, time.Now().Add(-expiredAgo))
+ pem, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pem
+}
+
+// #243: a spent token whose gated delete never ran. Age is a complete
+// predicate — a validation's whole life is seconds.
+func TestSweepTakesSpentChallengeTokens(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ w := sweepWarm(t, s)
+ seedCertEntry(t, dir, s, "abc123"+httpTokenSuffix, []byte("key-authorization"), 2*time.Hour)
+ seedCertEntry(t, dir, s, "www.customer.test"+alpnTokenSuffix, []byte("whatever"), 2*time.Hour)
+ // A token from a validation that may still be running must survive.
+ seedCertEntry(t, dir, s, "fresh"+httpTokenSuffix, []byte("key-authorization"), time.Minute)
+
+ w.sweep(context.Background(), time.Now())
+
+ if !certGone(t, s, "abc123"+httpTokenSuffix) {
+ t.Error("a spent HTTP-01 token survived the sweep")
+ }
+ if !certGone(t, s, "www.customer.test"+alpnTokenSuffix) {
+ t.Error("a spent tls-alpn-01 token cert survived the sweep")
+ }
+ if certGone(t, s, "fresh"+httpTokenSuffix) {
+ t.Error("a token from a possibly-live validation was deleted — that burns the order")
+ }
+}
+
+// §3f's whole ruling. The predicate is the leaf's own expiry and nothing
+// else: a cert for a host nobody serves any more goes ONLY once it is long
+// dead, and a current cert is untouchable however old its object is.
+func TestSweepDeletesOnlyLongExpiredCertificates(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ w := sweepWarm(t, s)
+ old := certSweepParseAge + 24*time.Hour
+
+ seedCertEntry(t, dir, s, "dead.customer.test", expiredPEM(t, "dead.customer.test", certSweepGrace+24*time.Hour), old)
+ // Expired, but not long enough ago. Patience costs bytes; haste costs
+ // an outage.
+ seedCertEntry(t, dir, s, "recent.customer.test", expiredPEM(t, "recent.customer.test", time.Hour), old)
+ // A CURRENT cert whose object happens to be ancient — a CA with long
+ // certs, or a renewal that has simply not been needed.
+ cert, key := selfSignedLeaf(t, "live.customer.test", time.Now().Add(365*24*time.Hour))
+ livePEM, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seedCertEntry(t, dir, s, "live.customer.test", livePEM, old)
+
+ w.sweep(context.Background(), time.Now())
+
+ if !certGone(t, s, "dead.customer.test") {
+ t.Error("a certificate that expired a month ago survived")
+ }
+ if certGone(t, s, "recent.customer.test") {
+ t.Error("a certificate that expired an hour ago was deleted — the grace period is not being applied")
+ }
+ if certGone(t, s, "live.customer.test") {
+ t.Fatal("A CURRENT CERTIFICATE WAS DELETED. The predicate is expiry, never object age.")
+ }
+}
+
+// The account keys are the issuer's identity at the CA. They are written
+// once and never rewritten, so they are ALWAYS older than the parse floor —
+// which is exactly why they are excluded by name before anything else is
+// considered. Deleting one costs the deployment its ACME registration.
+func TestSweepNeverTouchesAccountKeys(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ w := sweepWarm(t, s)
+ ancient := 5 * 365 * 24 * time.Hour
+ seedCertEntry(t, dir, s, autocertAccountKeyName, []byte("account key material"), ancient)
+ seedCertEntry(t, dir, s, wildcardAccountKeyName, []byte("account key material"), ancient)
+
+ w.sweep(context.Background(), time.Now())
+
+ for _, name := range []string{autocertAccountKeyName, wildcardAccountKeyName} {
+ if certGone(t, s, name) {
+ t.Fatalf("ACCOUNT KEY %s WAS DELETED — the deployment has lost its ACME registration", name)
+ }
+ }
+}
+
+// A sweep that deletes what it cannot understand is a sweep that deletes
+// tomorrow's format. Anything that will not read, will not parse, or has no
+// leaf is left exactly where it is.
+func TestSweepLeavesWhatItCannotUnderstand(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ w := sweepWarm(t, s)
+ old := certSweepParseAge + 24*time.Hour
+ seedCertEntry(t, dir, s, "future-shape.customer.test", []byte("a format this build has never seen"), old)
+
+ w.sweep(context.Background(), time.Now())
+
+ if certGone(t, s, "future-shape.customer.test") {
+ t.Error("an entry this build could not parse was deleted")
+ }
+}
+
+// The pre-filter is what keeps this from being an N+1: nothing younger than
+// the parse floor is even read. A healthy deployment renews every ~60 days,
+// so the steady-state cost is one listing and zero reads.
+func TestSweepReadsNothingYoungerThanTheParseFloor(t *testing.T) {
+ dir := t.TempDir()
+ s := &bucketGetCounter{Store: release.NewDirStore(dir)}
+ w := sweepWarm(t, s)
+ for _, host := range []string{"a.customer.test", "b.customer.test", "c.customer.test"} {
+ seedCertEntry(t, dir, s, host, expiredPEM(t, host, time.Hour), 10*24*time.Hour)
+ }
+ before := s.gets()
+ w.sweep(context.Background(), time.Now())
+ if n := s.gets() - before; n != 0 {
+ t.Errorf("sweep spent %d get(s) on entries younger than the parse floor, want 0", n)
+ }
+}
+
+// The expiry half runs daily, not hourly: it is the half that costs reads,
+// and a certificate that has been dead for a month can wait a day. The token
+// half runs every pass, because it needs no reads at all.
+func TestSweepRunsTheExpiryHalfDailyAndTheTokenHalfEveryPass(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ w := sweepWarm(t, s)
+ old := certSweepParseAge + 24*time.Hour
+ now := time.Now()
+
+ seedCertEntry(t, dir, s, "dead-one.customer.test", expiredPEM(t, "dead-one.customer.test", certSweepGrace+24*time.Hour), old)
+ w.sweep(context.Background(), now)
+ if !certGone(t, s, "dead-one.customer.test") {
+ t.Fatal("the first sweep did not run the expiry half")
+ }
+
+ // An hour later, a second dead cert appears and a token goes stale.
+ seedCertEntry(t, dir, s, "dead-two.customer.test", expiredPEM(t, "dead-two.customer.test", certSweepGrace+24*time.Hour), old)
+ seedCertEntry(t, dir, s, "tok"+httpTokenSuffix, []byte("key-authorization"), 2*time.Hour)
+ w.sweep(context.Background(), now.Add(time.Hour))
+ if certGone(t, s, "dead-two.customer.test") {
+ t.Error("the expiry half ran again an hour later — it is meant to be daily")
+ }
+ if !certGone(t, s, "tok"+httpTokenSuffix) {
+ t.Error("the token half did not run on the second pass — it is meant to run every pass")
+ }
+
+ // A day later it comes back round.
+ w.sweep(context.Background(), now.Add(certSweepInterval+time.Minute))
+ if !certGone(t, s, "dead-two.customer.test") {
+ t.Error("the expiry half did not run again after a day")
+ }
+}
+
+// A store with no last-modified stamps gives the sweep no age to judge by,
+// and guessing an object's age from its contents is exactly the inference
+// this file refuses to make. It does nothing at all rather than something
+// approximate.
+func TestSweepStandsDownWithoutStamps(t *testing.T) {
+ dir := t.TempDir()
+ inner := release.NewDirStore(dir)
+ w := sweepWarm(t, stamplessEdgeStore{inner})
+ seedCertEntry(t, dir, inner, "tok"+httpTokenSuffix, []byte("key-authorization"), 30*24*time.Hour)
+
+ w.sweep(context.Background(), time.Now())
+
+ if certGone(t, inner, "tok"+httpTokenSuffix) {
+ t.Error("a store with no stamps still had entries deleted")
+ }
+}
+
+type stamplessEdgeStore struct{ inner release.Store }
+
+func (s stamplessEdgeStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ return s.inner.Get(ctx, key)
+}
+func (s stamplessEdgeStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ return s.inner.Put(ctx, key, body, ifMatch)
+}
+func (s stamplessEdgeStore) List(ctx context.Context, prefix string) ([]string, error) {
+ return s.inner.List(ctx, prefix)
+}
+func (s stamplessEdgeStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ return s.inner.ListDirs(ctx, prefix)
+}
+func (s stamplessEdgeStore) Delete(ctx context.Context, key string) error {
+ return s.inner.Delete(ctx, key)
+}
+
+// A bug in the predicate deletes at most this many objects before somebody
+// reads the log, and a genuine backlog drains over days rather than in one
+// burst against the bucket.
+func TestSweepBoundsHowMuchOnePassCanDelete(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ w := sweepWarm(t, s)
+ total := certSweepDeletesPerPass + 5
+ for i := 0; i < total; i++ {
+ seedCertEntry(t, dir, s, string(rune('a'+i%26))+string(rune('a'+i/26))+".tok"+httpTokenSuffix,
+ []byte("key-authorization"), 2*time.Hour)
+ }
+ w.sweep(context.Background(), time.Now())
+ left, err := s.List(context.Background(), certPrefix)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(left) != total-certSweepDeletesPerPass {
+ t.Errorf("%d entries left after one pass, want %d — the per-pass bound is not holding",
+ len(left), total-certSweepDeletesPerPass)
+ }
+}
+
+// The sweep's work exists whether or not this deployment has custom
+// domains: spent challenge tokens and long-expired certificates are not the
+// warm SET's business. A pass over an empty set still sweeps — the gate check
+// deliberately comes before the empty-set return in pass().
+func TestPassWithNoWarmSetStillSweeps(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ w := sweepWarm(t, s)
+ seedCertEntry(t, dir, s, "orphan"+httpTokenSuffix, []byte("key-authorization"), 2*time.Hour)
+
+ if d := w.pass(); d != warmInterval {
+ t.Fatalf("pass over an empty set returned %s", d)
+ }
+ if !certGone(t, s, "orphan"+httpTokenSuffix) {
+ t.Error("a deployment with no custom domains never sweeps its own token litter")
+ }
+}
+
+// A non-issuer sweeps nothing. Deleting cert material is the sharpest thing
+// this package does, and a box that is not the issuer has no authority to
+// do it.
+func TestSweepNeverRunsOnANonIssuer(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(dir)
+ w := newCertWarm(func() []string { return nil }, nil, nil,
+ func() (context.Context, bool) { return nil, false }, s)
+ seedCertEntry(t, dir, s, "orphan"+httpTokenSuffix, []byte("key-authorization"), 2*time.Hour)
+
+ w.pass()
+
+ if certGone(t, s, "orphan"+httpTokenSuffix) {
+ t.Error("a stood-down box deleted cert material")
+ }
+}
+
+// A first-time backlog trips warmOrdersPerPass and pass() comes back every
+// minute until it clears. Housekeeping must not ride along sixty times an
+// hour: the token TTL is an hour and the expiry half is daily, so a listing
+// of the whole cert prefix per minute would be spent on nothing.
+func TestSweepDoesNotRunOnEveryCappedDrainPass(t *testing.T) {
+ dir := t.TempDir()
+ s := &listCounter{Store: release.NewDirStore(dir)}
+ w := sweepWarm(t, s)
+ now := time.Now()
+
+ w.sweep(context.Background(), now)
+ if s.lists != 1 {
+ t.Fatalf("first sweep listed %d time(s), want 1", s.lists)
+ }
+ w.sweep(context.Background(), now.Add(time.Minute))
+ w.sweep(context.Background(), now.Add(2*time.Minute))
+ if s.lists != 1 {
+ t.Errorf("sweep listed %d times across three passes a minute apart, want 1", s.lists)
+ }
+ w.sweep(context.Background(), now.Add(certSweepMinInterval+time.Second))
+ if s.lists != 2 {
+ t.Errorf("sweep listed %d times, want 2 — an ordinary pass an hour later must sweep", s.lists)
+ }
+}
+
+type listCounter struct {
+ release.Store
+ lists int
+}
+
+func (l *listCounter) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
+ l.lists++
+ return l.Store.(release.ModLister).ListMod(ctx, prefix)
+}
diff --git a/internal/edge/certwarm.go b/internal/edge/certwarm.go
index de905e723..3e4700a74 100644
--- a/internal/edge/certwarm.go
+++ b/internal/edge/certwarm.go
@@ -115,6 +115,13 @@ type certWarm struct {
warmed map[string]bool
lastStandDownLog time.Time
+ // lastSweep and lastExpirySweep pace the cert sweep (certsweep.go).
+ // Deliberately NOT guarded by mu, unlike everything else on this struct:
+ // they are touched only from sweep(), which is only ever reached from
+ // pass(), which only ever runs on run()'s single goroutine. A mutex here
+ // would suggest a concurrency that does not exist.
+ lastSweep time.Time
+ lastExpirySweep time.Time
}
func newCertWarm(hosts func() []string, getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), pointsHere func(string) bool, issuer IssuerGate, store release.Store) *certWarm {
@@ -147,16 +154,21 @@ func (c *certWarm) run() {
// pass warms every host in the set once and returns how long to wait.
func (c *certWarm) pass() time.Duration {
hosts := c.hosts()
- if len(hosts) == 0 {
- return warmInterval
- }
// Stand down before ANY work: unlike the wildcard manager there is no
// cache to load first, so a non-issuer has nothing useful to do here.
+ // The gate comes BEFORE the empty-set check, not after, because the
+ // sweep at the bottom of this pass has work to do in a deployment with
+ // no custom domains at all — spent challenge tokens and long-expired
+ // certs are not the warm set's business.
ctx, ok := c.issuerGate()
if !ok {
c.logStandDown(len(hosts))
return warmInterval
}
+ if len(hosts) == 0 {
+ c.sweep(ctx, time.Now())
+ return warmInterval
+ }
// The durable outcome records (#299 §3e), read ONCE per pass. Two things
// come out of this read and neither is available in memory: the attempt
// count a previous issuer accumulated (so a failover does not restart the
@@ -223,6 +235,10 @@ func (c *certWarm) pass() time.Duration {
}
}
c.prune(ctx, records, inSet)
+ // Housekeeping last: it is the least urgent thing this pass does, and
+ // running it after the orders means a slow bucket delays litter removal
+ // rather than certificate issuance (certsweep.go).
+ c.sweep(ctx, time.Now())
if capped {
return warmCappedDelay
}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 0eab657ac..395fa43ab 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -1472,7 +1472,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
// cache would need its own staleness rule, and a STALE route set
// is the one thing a security guard must not have.
deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now())
- if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes); err != nil {
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes, &aliasClaimCache); err != nil {
log.Printf("agent: fast converge alias pass: %v", err)
}
if err := release.Pass(ctx, reg, store, pub, optDir, restart, opts...); err != nil {
@@ -1952,7 +1952,7 @@ func instancesLoop(reg *registry.Registry) {
}
runMovesPass(ctx, store)
deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now())
- if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes); err != nil {
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes, &aliasClaimCache); err != nil {
log.Printf("agent: instances alias pass: %v", err)
}
if !isFleetBox {
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index 9c4e86bdb..faad8b53f 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -14,6 +14,8 @@ import (
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+
+ "github.com/carlosframework/platform/internal/domains"
)
// The multi-region ingress spec (2026-08-20, Part 3, closes the design half
@@ -404,3 +406,17 @@ func edgesFileLoop(reg *registry.Registry) {
}
}
}
+
+// aliasClaimCache is the claim fold both alias legs share (the fast-converge
+// leg in fullPass and the five-minute instances pass). Process-lifetime and
+// package-level for the same reason withheldAliasSaid above is: there is one
+// agent per process, and both legs are asking about the same deployment's
+// claims, so two caches would double the cold reads and halve the hit rate
+// for nothing.
+//
+// The fast leg is why this exists at all. It runs on every serial bump — a
+// seconds cadence — and ListClaims is one List plus a GET per claim in the
+// WHOLE deployment. At the hundreds of custom domains titogo is heading for,
+// that is a steady drip of bucket reads on every box, sitting ahead of the
+// adopt leg inside a two-second convergence promise. See domains.ClaimCache.
+var aliasClaimCache domains.ClaimCache