Merge pull request #356 from carlosframework/cert-warm-outcomes
warm outcomes: the platform can say a certificate FAILED (#299 §3e)
15 files changed,
+1128
−47
apimode.go+3 −2deploy.go+13 −0deploy_test.go+33 −0docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md+41 −11internal/certwarm/certwarm.go+189 −0internal/certwarm/certwarm_test.go+104 −0internal/console/apidomains.go+84 −4internal/console/apidomains_test.go+126 −0internal/console/domainstate.go+54 −8internal/console/domainstate_test.go+62 −8internal/edge/certsync.go+8 −1internal/edge/certwarm.go+179 −7internal/edge/certwarm_test.go+220 −3internal/edge/edge.go+1 −0internal/edge/storedoor_test.go+11 −3
diff --git a/apimode.go b/apimode.go| index 118bcc3de..59556c81b 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2526,8 +2526,9 @@ type apiDomainRecord struct { |
| Catchall bool `json:"catchall"` |
| DelegationStatus string `json:"delegation_status,omitempty"` |
| Guidance string `json:"guidance,omitempty"` |
| - // State is the door's single derived lifecycle word (#299): pending-dns, |
| - // misdirected, issuing-cert, live or unknown, with Reason (a stable |
| + // State is the door's single derived lifecycle word (#299): checking, |
| + // pending-dns, misdirected, issuing-cert, failed, live or unknown, with |
| + // Reason (a stable |
| // machine slug) and Detail (a human sentence). Every first-party surface |
| // reads THIS, not Status. |
| // |
diff --git a/deploy.go b/deploy.go| index 3f0eb4faa..0e40608ce 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -1023,6 +1023,18 @@ func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcc |
| return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+ |
| "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.", |
| host, app, acct.Seg), true |
| + case d.State == stateFailed: |
| + // DNS is right and the certificate is not — and that is ours to |
| + // fix, not theirs. The door already derived the honest sentence, |
| + // so it is repeated verbatim rather than paraphrased into a |
| + // second story about the same hostname. No "Create:" line: their |
| + // records are correct, and telling them otherwise would send |
| + // them to change the one thing that is working. |
| + msg := fmt.Sprintf("DNS for %s is pointing here, but we have not been able to obtain a certificate for it.", host) |
| + if d.Detail != "" { |
| + msg = fmt.Sprintf("DNS for %s is pointing here. %s", host, d.Detail) |
| + } |
| + return msg + fmt.Sprintf(" Watch with 'carlos domains list --app %s --account %s' — the shipped release is already promoted.", app, acct.Seg), true |
| case d.State == stateLive: |
| // DNS and certificate are both fine, so a connection failure is |
| // something else entirely and this function has nothing honest |
| @@ -1476,6 +1488,7 @@ const ( |
| statePendingDNS = "pending-dns" |
| stateMisdirected = "misdirected" |
| stateIssuingCert = "issuing-cert" |
| + stateFailed = "failed" |
| stateLive = "live" |
| stateChecking = "checking" |
| stateUnknown = "unknown" |
diff --git a/deploy_test.go b/deploy_test.go| index cd4665e72..a2f63d08a 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -2161,3 +2161,36 @@ func TestDeployPreflightAsksWhetherTheFlagWasTyped(t *testing.T) { |
| t.Fatal("no version flag typed, but flagWasSet said yes — the guard would never fire") |
| } |
| } |
| + |
| +// A deploy that ends with a "failed" domain must not print DNS guidance. |
| +// Their records are correct — that is precisely what "failed" means — so |
| +// "Create: ..." would send them to change the one part that is working, |
| +// while the actual problem (ours) went unnamed. |
| +func TestDeployDNSGuidanceOnAFailedCertificateNeverAsksForRecords(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]any{{ |
| + "host": "x.test", |
| + "state": "failed", |
| + "reason": "cert-failed", |
| + "detail": "DNS is correct, but we have not been able to obtain a certificate.", |
| + // A console that still stamps guidance alongside it must not |
| + // make this print "Create: ..." either. |
| + "guidance": "create a CNAME to edge.oncarlos.test", |
| + }}}) |
| + })) |
| + defer ts.Close() |
| + domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}) |
| + if !ok { |
| + t.Fatal("a failed certificate should be reported, not silently dropped") |
| + } |
| + if strings.Contains(msg, "Create:") || strings.Contains(msg, "CNAME") { |
| + t.Errorf("message asks the member to change DNS that is already correct: %q", msg) |
| + } |
| + if !strings.Contains(msg, "not been able to obtain a certificate") { |
| + t.Errorf("message does not name the actual problem: %q", msg) |
| + } |
| +} |
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 ecd86af12..0870d9ffa 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 |
| @@ -657,19 +657,49 @@ new `aclRead` allowlist entry and an update to |
| `TestDoorNeverServesCertMaterial` — none of which buy anything the existing |
| stamps do not already carry. |
| -### §3e and §3f are follow-ups, not part of this change |
| +### §3e and §3f were follow-ups; §3e is now built |
| §3e (per-host `control/cert-warm/<host>.json` CAS records) and §3f (the |
| -expiry-bounded cert sweep) are not built. The warm works without them: it |
| -orders, it renews, it backs off per host, and it logs. What is missing is |
| -durable observability of warm outcomes across a failover, and the pruning of |
| -certs for long-detached hosts. |
| - |
| -Consequence stated plainly: warm attempt counts live in process memory and |
| -reset when the issuer moves, and `control/certs/` entries for detached hosts |
| -linger until something else removes them. Neither is 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. |
| +expiry-bounded cert sweep) shipped after the first change, not with it. The |
| +warm worked without either: it ordered, it renewed, it backed off per host, |
| +and it logged. What was missing was durable observability of warm outcomes |
| +across a failover, and the pruning of certs for long-detached hosts. |
| + |
| +**§3e is built (2026-08-29).** `internal/certwarm` owns the wire shape for |
| +both sides, the issuer writes a record through the same CAS loop `putCert` |
| +uses (extracted as `putCAS`), and the console folds it into `domainState`. |
| +Three decisions the spec did not settle, decided here: |
| + |
| +- **A record exists only while a host is FAILING.** Success deletes it. That |
| + makes absence the healthy case, so the console's fold costs one object per |
| + BROKEN hostname rather than one per domain — which is what makes it safe |
| + to add to a list door that already pays one Get per claim (#173). The |
| + per-host door reads one key instead of listing, preserving the property |
| + #328 exists for. |
| +- **Freshness is judged against `next_attempt_at`, not `written_at`.** A |
| + rate-limited host is deliberately not retried for twelve hours, and a bound |
| + on write time alone would call that healthy silence stale every time. The |
| + accepted limit: an issuer that wedges immediately after writing a |
| + rate-limited record is believed for up to fourteen hours. Closing that |
| + needs a per-pass heartbeat object — a second key and a second writer-table |
| + row for a sharper edge on an already rare case. |
| +- **`failed` fires at three consecutive failures, or immediately on a rate |
| + limit.** Three is about three hours of trying. The rate-limit exception is |
| + because the retry is twelve hours away, and a member watching a "usually a |
| + minute or two" message for half a day is worse served than one told |
| + plainly what is happening. |
| + |
| +`failed` never overrides a customer-side condition (`misdirected`, |
| +`pending-dns`) and never overrides a live certificate: it is only ever the |
| +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. |
| ### The shared reader is a READER, not the wire-shape owner |
diff --git a/internal/certwarm/certwarm.go b/internal/certwarm/certwarm.go| new file mode 100644 |
| index 000000000..de2c55813 |
| --- /dev/null |
| +++ b/internal/certwarm/certwarm.go |
| @@ -0,0 +1,189 @@ |
| +// Package certwarm is the wire shape of the cert warm's durable outcome |
| +// records (#299 §3e): control/cert-warm/<host>.json, written by whichever |
| +// box holds the issuer lease, read by the console. |
| +// |
| +// The record exists so that "the platform tried to get this hostname a |
| +// certificate and it is not working" can be SAID. Without it the warm's |
| +// attempt counts live only in the issuer's process memory: they reset on |
| +// every fleet roll and on every failover, so a domain that has been failing |
| +// for a week is indistinguishable from one attached ninety seconds ago, and |
| +// the console has no honest way to show anything but "issuing-cert" forever. |
| +// |
| +// One record per host, and ONLY while that host is failing. A successful |
| +// warm deletes the record, so absence is the healthy case and the prefix |
| +// holds nothing at all in a deployment where every domain is fine. Two |
| +// things follow, both load-bearing: |
| +// |
| +// - The console's read is cheap by construction. Listing this prefix costs |
| +// one object per BROKEN host, not one per domain, so folding it into the |
| +// domains list does not reintroduce the per-claim cost the per-host door |
| +// (#328) exists to avoid. |
| +// - "No record" is never evidence of success on its own. It is also what a |
| +// host that has never been warmed looks like, and what a wedged issuer |
| +// leaves behind. Readers must treat absence as "nothing to report", and |
| +// must age-bound what they do find — see Fresh. |
| +// |
| +// Unlike internal/edgesfile, this package owns the shape for BOTH sides |
| +// rather than mirroring a writer that lives elsewhere. The reasoning there |
| +// was that internal/host's edges-file writer was under heavy development and |
| +// re-plumbing it onto a shared type would have fought it for no gain. Here |
| +// writer and reader are being introduced together, in one change, so there |
| +// is no incumbent to fight and no reason to maintain two decoders of one |
| +// shape. |
| +package certwarm |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Prefix is the flat prefix every record lives under. Inside control/, which |
| +// the fleet door's aclRead allowlist denies by default — see |
| +// TestDoorNeverServesCertMaterial, which pins this prefix with the cert |
| +// material it sits beside. |
| +const Prefix = "control/cert-warm/" |
| + |
| +// V is the wire version this build writes. A reader refuses V<1 (a record |
| +// with no version is not one of ours) and accepts anything higher, on the |
| +// same additive-wire rule the edges files use: a newer issuer may add |
| +// fields, and an older console must degrade to "no verdict" rather than |
| +// fail the whole fold. |
| +const V = 1 |
| + |
| +// Failure classes. Stable machine words — a client may branch on them, so |
| +// they are part of the wire and are never re-spelled. |
| +const ( |
| + // ClassRateLimited: the CA refused because the account's budget is |
| + // spent. Materially different from every other failure because it is |
| + // nobody's mistake and it will clear on its own, and because the wait is |
| + // long enough (12h) that a member deserves to be told rather than shown |
| + // a spinner. |
| + ClassRateLimited = "rate-limited" |
| + // ClassOther: everything else — a failed authorization, a CA outage, a |
| + // network error. Deliberately NOT subdivided: the CA's own text is |
| + // carried in Error, and inventing a taxonomy over strings we do not |
| + // control would go stale the first time the CA rewords a problem |
| + // document. |
| + ClassOther = "other" |
| +) |
| + |
| +// Key is the bucket key for host's record. |
| +// |
| +// The host is used verbatim. Callers hand it the same normalized form the |
| +// registry and the edges files store — lowercased and punycoded — because a |
| +// record written under one spelling and read under another is a record that |
| +// silently does not exist. |
| +func Key(host string) string { return Prefix + host + ".json" } |
| + |
| +// HostFromKey is Key's inverse, for walking a listing. It returns "" for a |
| +// key outside the prefix or without the .json suffix, so a stray object |
| +// under the prefix is skipped rather than decoded as a hostname. |
| +func HostFromKey(key string) string { |
| + rest, ok := strings.CutPrefix(key, Prefix) |
| + if !ok { |
| + return "" |
| + } |
| + host, ok := strings.CutSuffix(rest, ".json") |
| + if !ok || host == "" || strings.Contains(host, "/") { |
| + return "" |
| + } |
| + return host |
| +} |
| + |
| +// Record is one host's warm outcome. Every timestamp is unix seconds. |
| +type Record struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + // Attempts is how many consecutive warm attempts have failed since the |
| + // last success. It is the field the whole package exists for: it is |
| + // carried in the bucket rather than in memory precisely so that a |
| + // failover does not reset it to one and restart the clock on a domain |
| + // that has been broken for days. |
| + Attempts int `json:"attempts"` |
| + // FirstFailedAt is when the current run of failures began — preserved |
| + // across writes, so "failing since" survives an issuer moving. |
| + FirstFailedAt int64 `json:"first_failed_at"` |
| + // LastFailedAt is the most recent failure. |
| + LastFailedAt int64 `json:"last_failed_at"` |
| + // NextAttemptAt is the backoff floor: when the issuer will try again. |
| + // Carried because it is the difference between "we have given up" and |
| + // "we are waiting out a rate limit", which is the whole of what a member |
| + // wants to know, and because Fresh needs it (see there). |
| + NextAttemptAt int64 `json:"next_attempt_at"` |
| + // Class is one of the Class* constants. |
| + Class string `json:"class"` |
| + // Error is the CA's or the client's own text, trimmed to ErrorMax. Shown |
| + // to operators, never parsed. |
| + Error string `json:"error,omitempty"` |
| + // WrittenAt is when this record was written. A stamp without its age |
| + // cannot be judged — see Fresh. |
| + WrittenAt int64 `json:"written_at"` |
| +} |
| + |
| +// ErrorMax bounds the carried error text. A CA problem document can be long, |
| +// and an unbounded string from a remote party landing in a bucket object |
| +// that the console renders is exactly the shape that turns one bad response |
| +// into a large object written every hour. |
| +const ErrorMax = 512 |
| + |
| +// Grace is how long past its own next-attempt time a record stays |
| +// authoritative. |
| +// |
| +// The age bound exists because a wedged or stopped issuer keeps its records |
| +// sitting in the bucket, frozen: without a bound the console would keep |
| +// reporting "we tried 40 times and stopped" long after nothing is trying at |
| +// all, which is a worse lie than saying nothing. Judged against |
| +// NextAttemptAt rather than WrittenAt because the two differ by design — a |
| +// rate-limited host is deliberately not retried for twelve hours, and a |
| +// bound on write time alone would call that healthy silence "stale" every |
| +// time. |
| +// |
| +// The limit this accepts, stated plainly: an issuer that wedges immediately |
| +// after writing a rate-limited record is believed for up to fourteen hours. |
| +// Closing that would need a per-pass heartbeat object, which is a second |
| +// key and a second writer-table row to buy a sharper edge on an already |
| +// rare case. |
| +const Grace = 2 * time.Hour |
| + |
| +// Fresh reports whether this record still describes what the issuer is |
| +// doing. A stale record is not deleted and not an error — it is simply not |
| +// used as evidence, and the reader falls back to whatever it would have said |
| +// with no record at all. |
| +func (r Record) Fresh(now time.Time) bool { |
| + deadline := r.WrittenAt |
| + if r.NextAttemptAt > deadline { |
| + deadline = r.NextAttemptAt |
| + } |
| + if deadline == 0 { |
| + return false |
| + } |
| + return now.Before(time.Unix(deadline, 0).Add(Grace)) |
| +} |
| + |
| +// Parse decodes one record. |
| +func Parse(body []byte) (Record, error) { |
| + var r Record |
| + if err := json.Unmarshal(body, &r); err != nil { |
| + return Record{}, fmt.Errorf("cert-warm record: %w", err) |
| + } |
| + if r.V < V { |
| + return Record{}, fmt.Errorf("cert-warm record: unsupported version %d", r.V) |
| + } |
| + return r, nil |
| +} |
| + |
| +// Encode serializes a record, stamping the version and the written-at time |
| +// so no caller can forget either. |
| +func (r Record) Encode(now time.Time) ([]byte, error) { |
| + r.V = V |
| + r.WrittenAt = now.Unix() |
| + if len(r.Error) > ErrorMax { |
| + r.Error = r.Error[:ErrorMax] |
| + } |
| + if r.Class == "" { |
| + r.Class = ClassOther |
| + } |
| + return json.Marshal(r) |
| +} |
diff --git a/internal/certwarm/certwarm_test.go b/internal/certwarm/certwarm_test.go| new file mode 100644 |
| index 000000000..c77fb882e |
| --- /dev/null |
| +++ b/internal/certwarm/certwarm_test.go |
| @@ -0,0 +1,104 @@ |
| +package certwarm |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestKeyAndHostFromKeyRoundTrip(t *testing.T) { |
| + for _, host := range []string{"www.customer.com", "xn--bcher-kva.example", "a.b.c.example.org"} { |
| + if got := HostFromKey(Key(host)); got != host { |
| + t.Errorf("HostFromKey(Key(%q)) = %q", host, got) |
| + } |
| + } |
| +} |
| + |
| +// A listing under the prefix can contain anything an operator or a future |
| +// build put there. Everything that is not exactly one host record must come |
| +// back as "" so the caller skips it rather than treating it as a hostname — |
| +// a nested key decoded as a host would be pruned as "not in the warm set", |
| +// which is a delete of somebody else's object. |
| +func TestHostFromKeyRefusesEverythingElse(t *testing.T) { |
| + for _, key := range []string{ |
| + "control/certs/example.com", |
| + "control/cert-warm/", |
| + "control/cert-warm/.json", |
| + "control/cert-warm/nested/host.json", |
| + "control/cert-warm/host.txt", |
| + "cert-warm/host.json", |
| + "", |
| + } { |
| + if got := HostFromKey(key); got != "" { |
| + t.Errorf("HostFromKey(%q) = %q, want \"\"", key, got) |
| + } |
| + } |
| +} |
| + |
| +func TestEncodeStampsVersionAndTime(t *testing.T) { |
| + now := time.Unix(1_700_000_000, 0) |
| + body, err := Record{Host: "a.example"}.Encode(now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := Parse(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.V != V || got.WrittenAt != now.Unix() { |
| + t.Errorf("Encode = %+v; want v%d written_at %d", got, V, now.Unix()) |
| + } |
| + // An unset class must never reach the wire: a reader branching on it |
| + // would see "" and fall through every case. |
| + if got.Class != ClassOther { |
| + t.Errorf("Class = %q, want %q", got.Class, ClassOther) |
| + } |
| +} |
| + |
| +func TestEncodeTrimsRemoteErrorText(t *testing.T) { |
| + body, err := Record{Host: "a.example", Error: strings.Repeat("x", ErrorMax*3)}.Encode(time.Unix(1, 0)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := Parse(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got.Error) != ErrorMax { |
| + t.Errorf("Error length = %d, want %d — an unbounded CA string reached the bucket", len(got.Error), ErrorMax) |
| + } |
| +} |
| + |
| +// V<1 is refused (a record with no version is not one of ours); a HIGHER |
| +// version parses, because the wire is additive and an older console must |
| +// degrade to the fields it knows rather than fail the whole fold. |
| +func TestParseVersionBounds(t *testing.T) { |
| + if _, err := Parse([]byte(`{"host":"a.example"}`)); err == nil { |
| + t.Error("a record with no version parsed") |
| + } |
| + got, err := Parse([]byte(`{"v":99,"host":"a.example","attempts":4}`)) |
| + if err != nil { |
| + t.Fatalf("a newer version failed to parse: %v", err) |
| + } |
| + if got.Attempts != 4 { |
| + t.Errorf("Attempts = %d, want 4", got.Attempts) |
| + } |
| +} |
| + |
| +// Fresh is judged against the LATER of written-at and next-attempt, which is |
| +// what lets a deliberate twelve-hour rate-limit wait stay authoritative |
| +// while a wedged issuer's record eventually stops being believed. |
| +func TestFreshUsesTheNextAttemptDeadline(t *testing.T) { |
| + base := time.Unix(1_700_000_000, 0) |
| + rateLimited := Record{WrittenAt: base.Unix(), NextAttemptAt: base.Add(12 * time.Hour).Unix()} |
| + if !rateLimited.Fresh(base.Add(6 * time.Hour)) { |
| + t.Error("a record mid-way through its own backoff read as stale") |
| + } |
| + if rateLimited.Fresh(base.Add(12*time.Hour + Grace + time.Second)) { |
| + t.Error("a record past its next attempt plus grace still read as fresh — a wedged issuer would report as trying") |
| + } |
| + // No stamps at all is not evidence of anything. |
| + if (Record{}).Fresh(base) { |
| + t.Error("an unstamped record read as fresh") |
| + } |
| +} |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index d9595ecc9..5d78f5b13 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -46,6 +46,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/certwarm" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -684,6 +685,12 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| } |
| now := time.Now() |
| readings := aliasReadings(ctx, s.cfg.Releases, now) |
| + // The issuer's warm-outcome records (#299 §3e), also once per call. A |
| + // record exists only while a host is FAILING, so this listing is one |
| + // object per broken hostname in the deployment and nothing at all in a |
| + // deployment where every domain works — it does not reintroduce the |
| + // per-claim cost the per-host door exists to avoid. |
| + warm := warmRecords(ctx, s.cfg.Releases) |
| // Read ONCE per call, not per claim: dnsTargets folds the deployment's |
| // edge set, and a per-claim read would refold it for every hostname. |
| targets := s.dnsTargets() |
| @@ -715,7 +722,7 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| if delegation { |
| lookups++ |
| } |
| - out = append(out, s.domainItem(ctx, c, readings, targets, now, delegation)) |
| + out = append(out, s.domainItem(ctx, c, readings, warm[c.Host], targets, now, delegation)) |
| } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "domains": out, |
| @@ -738,7 +745,7 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| // delegation says whether to spend a live CNAME lookup on this row. The list |
| // door caps how many it will spend per call; the per-host door always can, |
| // because it is answering about exactly one host. |
| -func (s *Server) domainItem(ctx context.Context, c domains.Claim, readings map[string]status.AliasReading, targets DNSTargets, now time.Time, delegation bool) domainListItem { |
| +func (s *Server) domainItem(ctx context.Context, c domains.Claim, readings map[string]status.AliasReading, warm *certwarm.Record, targets DNSTargets, now time.Time, delegation bool) domainListItem { |
| item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending", |
| Wildcard: c.Wildcard, Catchall: c.Catchall} |
| reading, seen := readings[c.Host] |
| @@ -748,7 +755,7 @@ func (s *Server) domainItem(ctx context.Context, c domains.Claim, readings map[s |
| item.CertExpiresAt = reading.CertExpiresAt |
| item.CheckedAt = reading.DNSCheckedAt |
| } |
| - item.State, item.Reason, item.Detail = domainState(reading, seen, now) |
| + item.State, item.Reason, item.Detail = domainState(reading, seen, warm, now) |
| // "unresolved" means the lookup FAILED (our resolver, not the customer's |
| // record) — pointing them at "create this record" guidance would blame |
| // them for our outage. Only "" (no answer yet) and "elsewhere" (a real, |
| @@ -819,7 +826,9 @@ func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) { |
| } |
| now := time.Now() |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "domain": s.domainItem(ctx, *c, aliasReadings(ctx, s.cfg.Releases, now), s.dnsTargets(), now, c.Wildcard && s.delegationZone != ""), |
| + "domain": s.domainItem(ctx, *c, aliasReadings(ctx, s.cfg.Releases, now), |
| + warmRecord(ctx, s.cfg.Releases, c.Host), s.dnsTargets(), now, |
| + c.Wildcard && s.delegationZone != ""), |
| "deployment": s.domainCapability(ctx, acct.Sqid, app), |
| }) |
| } |
| @@ -876,6 +885,77 @@ func aliasReadings(ctx context.Context, s release.Store, now time.Time) map[stri |
| return readings |
| } |
| +// warmRecords folds the issuer's warm-outcome records (#299 §3e) into a |
| +// host-keyed map for the LIST door. |
| +// |
| +// A record exists only while a host is failing, so the cost is one object |
| +// per broken hostname in the deployment — usually none. That is the property |
| +// that makes it safe to fold here at all: the list door already pays one Get |
| +// per claim (#173), and a second per-claim read would have made a bad cost |
| +// worse rather than adding a fact. |
| +// |
| +// Every failure degrades to "no records". They explain a state; they never |
| +// gate one, and a domains list that 502s because a telemetry prefix was |
| +// briefly unreadable would be a worse answer than one that says |
| +// "issuing-cert". |
| +func warmRecords(ctx context.Context, s release.Store) map[string]*certwarm.Record { |
| + out := map[string]*certwarm.Record{} |
| + if s == nil { |
| + return out |
| + } |
| + keys, err := s.List(ctx, certwarm.Prefix) |
| + if err != nil { |
| + log.Printf("console: api domains: could not list %s: %v — certificate failures will read as in progress", certwarm.Prefix, err) |
| + return out |
| + } |
| + for _, key := range keys { |
| + host := certwarm.HostFromKey(key) |
| + if host == "" { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("console: api domains: could not read %s: %v", key, err) |
| + continue |
| + } |
| + rec, err := certwarm.Parse(body) |
| + if err != nil { |
| + // A record a NEWER issuer wrote in a shape this console cannot |
| + // read. Skipping it reports "issuing-cert", which is the right |
| + // direction to be wrong in. |
| + log.Printf("console: api domains: could not parse %s: %v", key, err) |
| + continue |
| + } |
| + out[host] = &rec |
| + } |
| + return out |
| +} |
| + |
| +// warmRecord reads ONE hostname's record, for the per-host door. |
| +// |
| +// Deliberately not warmRecords filtered afterwards: the per-host door's |
| +// whole reason to exist is that a refresh of one hostname costs one object, |
| +// and listing a prefix to answer about a single name would give that back. |
| +// An absent record is the ordinary case and is not an error. |
| +func warmRecord(ctx context.Context, s release.Store, host string) *certwarm.Record { |
| + if s == nil { |
| + return nil |
| + } |
| + body, _, err := s.Get(ctx, certwarm.Key(host)) |
| + if err != nil { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("console: api domains: could not read the warm record for %s: %v", host, err) |
| + } |
| + return nil |
| + } |
| + rec, err := certwarm.Parse(body) |
| + if err != nil { |
| + log.Printf("console: api domains: could not parse the warm record for %s: %v", host, err) |
| + return nil |
| + } |
| + return &rec |
| +} |
| + |
| // hostUnavailableMsg is the ONE refusal string behind every "you cannot have |
| // this hostname" answer this package gives: the claim already belongs to |
| // someone else, or a live box in this deployment already serves it as a real |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index f3cffd366..96c6c0da6 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -25,6 +25,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/certwarm" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -1694,3 +1695,128 @@ func TestAPIDomainGetRefusesATraversalHost(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The whole of §3e, end to end and through both doors: an issuer's outcome |
| +// record in the bucket is what turns a hostname that has been trying for |
| +// hours from "issuing-cert" into "failed". Both doors must reach the same |
| +// verdict — one reads the record by key, the other folds the prefix, and a |
| +// consumer refreshing a single host and comparing it against a listed row |
| +// must never see them disagree. |
| +func TestAPIDomainFailedStateComesFromTheWarmRecord(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-warm") |
| + ctx := context.Background() |
| + now := time.Now() |
| + attachDomain(t, fx, member, "broken.example.com", "go.tito.io") |
| + 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{ |
| + // DNS is right. No certificate. Without a record this is |
| + // "issuing-cert" and stays that way forever. |
| + "broken.example.com": {Route: "go.tito.io", DNSStatus: "pointing", DNSCheckedAt: now.Unix()}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + state := func(t *testing.T) (get, list string) { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "broken.example.com"), member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("get = %d %s", code, out) |
| + } |
| + var one struct { |
| + Domain struct{ State string } `json:"domain"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &one); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, listOut := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + var many struct { |
| + Domains []struct{ State string } `json:"domains"` |
| + } |
| + if err := json.Unmarshal([]byte(listOut), &many); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(many.Domains) != 1 { |
| + t.Fatalf("list = %+v", many.Domains) |
| + } |
| + return one.Domain.State, many.Domains[0].State |
| + } |
| + |
| + if get, list := state(t); get != StateIssuingCert || list != StateIssuingCert { |
| + t.Fatalf("with no record: get=%q list=%q, want both %q", get, list, StateIssuingCert) |
| + } |
| + |
| + writeWarm := func(t *testing.T, rec certwarm.Record) { |
| + t.Helper() |
| + body, err := rec.Encode(now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, etag, err := fx.rel.Get(ctx, certwarm.Key(rec.Host)) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(ctx, certwarm.Key(rec.Host), body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + // One failed attempt is ordinary and must not change the story. |
| + writeWarm(t, certwarm.Record{Host: "broken.example.com", Attempts: 1, |
| + Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()}) |
| + if get, list := state(t); get != StateIssuingCert || list != StateIssuingCert { |
| + t.Fatalf("after one failure: get=%q list=%q, want both %q", get, list, StateIssuingCert) |
| + } |
| + |
| + // At the threshold both doors say so. |
| + writeWarm(t, certwarm.Record{Host: "broken.example.com", Attempts: domainFailedAttempts, |
| + Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()}) |
| + if get, list := state(t); get != StateFailed || list != StateFailed { |
| + t.Fatalf("at the threshold: get=%q list=%q, want both %q", get, list, StateFailed) |
| + } |
| +} |
| + |
| +// A record for somebody ELSE's hostname must not reach this app's answer. |
| +// The cert-warm prefix is deployment-global (there is one issuer, and it |
| +// writes for every domain it warms), so the join has to be by exact host or |
| +// one tenant's certificate trouble shows up on another tenant's domain. |
| +func TestAPIDomainWarmRecordsJoinByExactHost(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-warm-iso") |
| + ctx := context.Background() |
| + now := time.Now() |
| + attachDomain(t, fx, member, "mine.example.com", "go.tito.io") |
| + 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{ |
| + "mine.example.com": {Route: "go.tito.io", DNSStatus: "pointing", DNSCheckedAt: now.Unix()}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := certwarm.Record{Host: "theirs.example.com", Attempts: 99, |
| + 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("theirs.example.com"), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + var list struct { |
| + Domains []struct { |
| + Host string `json:"host"` |
| + State string `json:"state"` |
| + } `json:"domains"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &list); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(list.Domains) != 1 || list.Domains[0].Host != "mine.example.com" { |
| + t.Fatalf("list = %+v", list.Domains) |
| + } |
| + if list.Domains[0].State != StateIssuingCert { |
| + t.Errorf("state = %q, want %q — another hostname's failure record was joined to this one", |
| + list.Domains[0].State, StateIssuingCert) |
| + } |
| +} |
diff --git a/internal/console/domainstate.go b/internal/console/domainstate.go| index 861452c89..b0920574d 100644 |
| --- a/internal/console/domainstate.go |
| +++ b/internal/console/domainstate.go |
| @@ -5,6 +5,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/certwarm" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -42,6 +43,18 @@ const ( |
| // few minutes after an attach, and the other is a fault. Collapsing them |
| // would make every successful attach open with the word "unknown". |
| StateChecking = "checking" |
| + // StateFailed — the platform tried to obtain a certificate for this |
| + // hostname and it is not working. |
| + // |
| + // Reachable only because the issuer now writes durable warm-outcome |
| + // records (#299 §3e, internal/certwarm). Before those existed the |
| + // attempt count lived in the issuer's process memory and reset on every |
| + // roll and every failover, so a domain broken for a week and one |
| + // attached ninety seconds ago looked identical and this word could not |
| + // be said honestly. It is the state's whole justification: the thing it |
| + // reports is "we have been trying and stopping for hours", which is a |
| + // claim about history, and history is what the records carry. |
| + StateFailed = "failed" |
| // StateUnknown — we have no verdict AND that is not routine: our own |
| // lookup failed, or a box reported something this build cannot read. |
| // Deliberately distinct from pending-dns: "we could not check" must |
| @@ -53,16 +66,27 @@ const ( |
| // lifecycle order. Published in the capability block so a client can tell |
| // which states a deployment might return without guessing from a doc. |
| // |
| -// "failed" is deliberately ABSENT. The design reserves it for "the platform |
| -// tried to obtain a certificate and stopped", which requires the per-host |
| -// warm-outcome records (§3e) that this build does not write — so nothing can |
| -// produce it, and advertising a state no deployment will ever return is the |
| -// same dishonesty that got "verifying" renamed. It is added by the change |
| -// that makes it reachable, not before. |
| +// "failed" was absent until the warm-outcome records (§3e) made it |
| +// reachable, on the rule this list exists to enforce: a deployment |
| +// advertises only the states it can actually return, because a state nobody |
| +// will ever see is the same dishonesty that got "verifying" renamed. It is |
| +// listed now because the issuer writes those records and domainState reads |
| +// them. |
| var domainStates = []string{ |
| - StateChecking, StatePendingDNS, StateMisdirected, StateIssuingCert, StateLive, StateUnknown, |
| + StateChecking, StatePendingDNS, StateMisdirected, StateIssuingCert, StateFailed, StateLive, StateUnknown, |
| } |
| +// domainFailedAttempts is how many consecutive failed warm attempts it takes |
| +// before a hostname is called "failed" rather than "issuing-cert". |
| +// |
| +// Three, because the warm retries hourly: three is about three hours of |
| +// trying, which is long past any transient the member could have caused and |
| +// long past a CA blip. One would be wrong in the other direction — a single |
| +// failed order is ordinary, and telling somebody their domain has failed |
| +// because the first attempt lost a race would make the word useless the |
| +// first week it shipped. |
| +const domainFailedAttempts = 3 |
| + |
| // domainState derives one hostname's state, a stable machine reason, and a |
| // human sentence. |
| // |
| @@ -70,7 +94,10 @@ var domainStates = []string{ |
| // distinction between "no box mentioned it" and "a box says there is no DNS |
| // record" is the difference between our problem and the customer's, so it is |
| // never collapsed. |
| -func domainState(reading status.AliasReading, seen bool, now time.Time) (state, reason, detail string) { |
| +// warm is this hostname's durable warm-outcome record, or nil when there is |
| +// none — and nil is the ordinary case, because a record exists only while a |
| +// host is failing. |
| +func domainState(reading status.AliasReading, seen bool, warm *certwarm.Record, now time.Time) (state, reason, detail string) { |
| if !seen { |
| return StateChecking, "no-reading", |
| "No edge has reported on this hostname yet. Checks run every few minutes, so this should settle shortly after attaching." |
| @@ -91,6 +118,25 @@ func domainState(reading status.AliasReading, seen bool, now time.Time) (state, |
| return StateLive, "serving", |
| "This hostname points here and is served over HTTPS with a current certificate." |
| } |
| + // DNS is right and there is no usable certificate. Ordinarily that |
| + // is the platform working; the warm record is the only thing that |
| + // can distinguish it from the platform failing, and only while it is |
| + // FRESH — a frozen record from a wedged issuer must not be read as |
| + // "still trying" OR as "gave up". |
| + if warm != nil && warm.Fresh(now) { |
| + switch { |
| + case warm.Class == certwarm.ClassRateLimited: |
| + // Reported on the first occurrence, not at the threshold. |
| + // The retry is twelve hours away, and a member watching a |
| + // "usually a minute or two" message for half a day is worse |
| + // served than one told plainly what is happening. |
| + return StateFailed, "cert-rate-limited", |
| + "Our certificate authority is rate-limiting this deployment, so the certificate for this hostname is waiting. It retries automatically — there is nothing to change on your side." |
| + case warm.Attempts >= domainFailedAttempts: |
| + return StateFailed, "cert-failed", |
| + "DNS is correct, but we have not been able to obtain a certificate for this hostname after several attempts. We keep retrying, and we are looking at it — you do not need to change anything." |
| + } |
| + } |
| return StateIssuingCert, "awaiting-certificate", |
| "DNS is correct. A certificate is being obtained for this hostname — usually a minute or two, and no action is needed." |
| } |
diff --git a/internal/console/domainstate_test.go b/internal/console/domainstate_test.go| index e74e04600..32872401e 100644 |
| --- a/internal/console/domainstate_test.go |
| +++ b/internal/console/domainstate_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/certwarm" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -28,7 +29,7 @@ func TestDomainStateVocabulary(t *testing.T) { |
| "pointing, live cert": {reading: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: future}, seen: true, wantState: StateLive, wantReason: "serving"}, |
| "a status we don't know": {reading: status.AliasReading{DNSStatus: "quantum"}, seen: true, wantState: StateUnknown, wantReason: "unrecognised-status"}, |
| } { |
| - state, reason, detail := domainState(tc.reading, tc.seen, now) |
| + state, reason, detail := domainState(tc.reading, tc.seen, nil, now) |
| if state != tc.wantState || reason != tc.wantReason { |
| t.Errorf("%s: (%q, %q), want (%q, %q)", name, state, reason, tc.wantState, tc.wantReason) |
| } |
| @@ -44,8 +45,8 @@ func TestDomainStateVocabulary(t *testing.T) { |
| // customer for our outage. |
| func TestDomainStateNeverBlamesTheCustomerForOurOutage(t *testing.T) { |
| now := time.Unix(1_000_000, 0) |
| - ourFault, _, ourDetail := domainState(status.AliasReading{DNSStatus: "unresolved"}, true, now) |
| - theirs, _, _ := domainState(status.AliasReading{DNSStatus: ""}, true, now) |
| + ourFault, _, ourDetail := domainState(status.AliasReading{DNSStatus: "unresolved"}, true, nil, now) |
| + theirs, _, _ := domainState(status.AliasReading{DNSStatus: ""}, true, nil, now) |
| if ourFault == theirs { |
| t.Fatalf("a failed lookup and a missing record both read %q", ourFault) |
| } |
| @@ -66,9 +67,18 @@ func TestDomainStateNeverBlamesTheCustomerForOurOutage(t *testing.T) { |
| func TestAdvertisedStatesAreExactlyTheReachableOnes(t *testing.T) { |
| now := time.Unix(1_000_000, 0) |
| produced := map[string]bool{} |
| + // The warm inputs are as real as the readings: a fresh record with the |
| + // attempt count the threshold names is exactly what the issuer writes, |
| + // so "failed" earns its place in the vocabulary the same way every other |
| + // state does — by being produced from an input the platform can produce. |
| + failing := &certwarm.Record{ |
| + Attempts: domainFailedAttempts, Class: certwarm.ClassOther, |
| + WrittenAt: now.Unix(), NextAttemptAt: now.Unix() + 3600, |
| + } |
| for _, tc := range []struct { |
| r status.AliasReading |
| seen bool |
| + warm *certwarm.Record |
| }{ |
| {seen: false}, |
| {r: status.AliasReading{DNSStatus: "unresolved"}, seen: true}, |
| @@ -76,8 +86,9 @@ func TestAdvertisedStatesAreExactlyTheReachableOnes(t *testing.T) { |
| {r: status.AliasReading{DNSStatus: "elsewhere"}, seen: true}, |
| {r: status.AliasReading{DNSStatus: "pointing"}, seen: true}, |
| {r: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: now.Unix() + 86400}, seen: true}, |
| + {r: status.AliasReading{DNSStatus: "pointing"}, seen: true, warm: failing}, |
| } { |
| - s, _, _ := domainState(tc.r, tc.seen, now) |
| + s, _, _ := domainState(tc.r, tc.seen, tc.warm, now) |
| produced[s] = true |
| } |
| for _, s := range domainStates { |
| @@ -90,9 +101,52 @@ func TestAdvertisedStatesAreExactlyTheReachableOnes(t *testing.T) { |
| t.Errorf("state %q is produced but not advertised", s) |
| } |
| } |
| - // The specific one this build must NOT claim. |
| - if slices.Contains(domainStates, "failed") { |
| - t.Error(`"failed" is advertised, but nothing writes the warm-outcome records that would produce it`) |
| +} |
| + |
| +// "failed" is a claim about HISTORY — that the platform has been trying and |
| +// failing for hours — so the only thing that may produce it is a record of |
| +// that history. Three ways of having no such record must all still read as |
| +// "issuing-cert", and each of them was a live bug shape before §3e: no |
| +// record at all (the ordinary case), a record from an issuer that has since |
| +// wedged, and a record that has not yet reached the threshold. |
| +func TestFailedIsOnlyEverSaidOnFreshEnoughEvidence(t *testing.T) { |
| + now := time.Unix(1_000_000, 0) |
| + pointing := status.AliasReading{DNSStatus: "pointing"} |
| + fresh := func(r certwarm.Record) *certwarm.Record { |
| + r.WrittenAt, r.NextAttemptAt = now.Unix(), now.Unix()+3600 |
| + return &r |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + warm *certwarm.Record |
| + want string |
| + }{ |
| + {"no record at all", nil, StateIssuingCert}, |
| + {"below the threshold", fresh(certwarm.Record{Attempts: domainFailedAttempts - 1}), StateIssuingCert}, |
| + {"at the threshold", fresh(certwarm.Record{Attempts: domainFailedAttempts}), StateFailed}, |
| + {"rate-limited on the first attempt", fresh(certwarm.Record{Attempts: 1, Class: certwarm.ClassRateLimited}), StateFailed}, |
| + {"stale record from a wedged issuer", &certwarm.Record{ |
| + Attempts: 40, WrittenAt: now.Add(-30 * 24 * time.Hour).Unix(), |
| + NextAttemptAt: now.Add(-30 * 24 * time.Hour).Unix(), |
| + }, StateIssuingCert}, |
| + } { |
| + if got, _, _ := domainState(pointing, true, tc.warm, now); got != tc.want { |
| + t.Errorf("%s: state = %q, want %q", tc.name, got, tc.want) |
| + } |
| + } |
| + // A live certificate outranks any record. The record is evidence about |
| + // an ORDER; the cert is evidence about the hostname, and a hostname that |
| + // is being served must never be told it has failed. |
| + served := status.AliasReading{DNSStatus: "pointing", CertExpiresAt: now.Unix() + 86400} |
| + if got, _, _ := domainState(served, true, fresh(certwarm.Record{Attempts: 99}), now); got != StateLive { |
| + t.Errorf("a served hostname with a stale failure record = %q, want %q", got, StateLive) |
| + } |
| + // A customer-side condition is never overwritten by our cert trouble: |
| + // telling somebody their certificate failed when their DNS points at |
| + // their old host would send them looking in the wrong place. |
| + elsewhere := status.AliasReading{DNSStatus: "elsewhere"} |
| + if got, _, _ := domainState(elsewhere, true, fresh(certwarm.Record{Attempts: 99}), now); got != StateMisdirected { |
| + t.Errorf("a misdirected hostname with a failure record = %q, want %q", got, StateMisdirected) |
| } |
| } |
| @@ -197,7 +251,7 @@ func TestLegacyStatusStaysDecoupledFromState(t *testing.T) { |
| now := time.Unix(1_000_000, 0) |
| // The exact divergence: a box HAS reported (so status is "live"), and |
| // what it reported is that the hostname points somewhere else. |
| - state, _, _ := domainState(status.AliasReading{DNSStatus: "elsewhere"}, true, now) |
| + state, _, _ := domainState(status.AliasReading{DNSStatus: "elsewhere"}, true, nil, now) |
| if state != StateMisdirected { |
| t.Fatalf("state = %q, want %q", state, StateMisdirected) |
| } |
diff --git a/internal/edge/certsync.go b/internal/edge/certsync.go| index 8dbca3f72..46c318f3a 100644 |
| --- a/internal/edge/certsync.go |
| +++ b/internal/edge/certsync.go |
| @@ -349,7 +349,14 @@ func publishableCertName(name string) bool { |
| // 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) |
| + return putCAS(ctx, s, certKey(name), body) |
| +} |
| + |
| +// putCAS is putCert's write, by key rather than by cert name — the same |
| +// read-etag-then-If-Match loop, shared so the cert warm's outcome records |
| +// (#299 §3e, certwarm.go) get the identical concurrency posture instead of a |
| +// second copy of it that can drift. |
| +func putCAS(ctx context.Context, s release.Store, key string, body []byte) error { |
| for attempt := 0; attempt < 3; attempt++ { |
| _, etag, err := s.Get(ctx, key) |
| switch { |
diff --git a/internal/edge/certwarm.go b/internal/edge/certwarm.go| index 018cbab10..de905e723 100644 |
| --- a/internal/edge/certwarm.go |
| +++ b/internal/edge/certwarm.go |
| @@ -36,6 +36,9 @@ import ( |
| "time" |
| "golang.org/x/crypto/acme" |
| + |
| + "github.com/carlosframework/platform/internal/certwarm" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| const ( |
| @@ -96,6 +99,12 @@ type certWarm struct { |
| pointsHere func(host string) bool |
| // issuer gates ORDERING, exactly as it gates the wildcard manager's. |
| issuer IssuerGate |
| + // store is the deployment bucket, for the durable outcome records |
| + // (#299 §3e, internal/certwarm). Nil disables recording entirely and |
| + // changes nothing else: warming, backoff and logging are unaffected, so |
| + // an agent-less `carlos edge` and every test that does not care keep |
| + // working exactly as before. |
| + store release.Store |
| mu sync.Mutex |
| // nextAttempt is the per-host backoff floor. |
| @@ -108,12 +117,13 @@ type certWarm struct { |
| lastStandDownLog time.Time |
| } |
| -func newCertWarm(hosts func() []string, getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), pointsHere func(string) bool, issuer IssuerGate) *certWarm { |
| +func newCertWarm(hosts func() []string, getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), pointsHere func(string) bool, issuer IssuerGate, store release.Store) *certWarm { |
| return &certWarm{ |
| hosts: hosts, |
| getCert: getCert, |
| pointsHere: pointsHere, |
| issuer: issuer, |
| + store: store, |
| nextAttempt: map[string]time.Time{}, |
| warmed: map[string]bool{}, |
| } |
| @@ -142,10 +152,22 @@ func (c *certWarm) pass() time.Duration { |
| } |
| // 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. |
| - if _, ok := c.issuerGate(); !ok { |
| + ctx, ok := c.issuerGate() |
| + if !ok { |
| c.logStandDown(len(hosts)) |
| 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 |
| + // clock on a domain that has been broken for days), and the set of hosts |
| + // that currently HAVE a record, which is what makes both the |
| + // delete-on-success and the prune below decidable without a Get per host. |
| + records := c.loadRecords(ctx) |
| + inSet := make(map[string]bool, len(hosts)) |
| + for _, h := range hosts { |
| + inSet[h] = true |
| + } |
| orders, capped := 0, false |
| for _, h := range hosts { |
| now := time.Now() |
| @@ -159,7 +181,8 @@ func (c *certWarm) pass() time.Duration { |
| // The gate is re-checked BETWEEN orders, not once per pass: a pass |
| // over a large set can outlive a holding, and every order after the |
| // holding ends is an order this box has no authority to place. |
| - if _, ok := c.issuerGate(); !ok { |
| + gctx, ok := c.issuerGate() |
| + if !ok { |
| c.logStandDown(len(hosts) - orders) |
| return warmInterval |
| } |
| @@ -179,7 +202,7 @@ func (c *certWarm) pass() time.Duration { |
| orders++ |
| } |
| if err := c.warmOne(h); err != nil { |
| - c.fail(h, err) |
| + c.fail(gctx, h, err, records[h]) |
| continue |
| } |
| c.mu.Lock() |
| @@ -190,7 +213,16 @@ func (c *certWarm) pass() time.Duration { |
| if first { |
| log.Printf("edge: cert warmed for %s", h) |
| } |
| + // A record exists only while a host is FAILING, so success removes |
| + // it. That is what makes "no record" the healthy case and keeps the |
| + // console's fold one object per broken host rather than one per |
| + // domain (internal/certwarm's package comment). |
| + if _, had := records[h]; had { |
| + c.clearRecord(gctx, h, "recovered") |
| + delete(records, h) |
| + } |
| } |
| + c.prune(ctx, records, inSet) |
| if capped { |
| return warmCappedDelay |
| } |
| @@ -204,19 +236,43 @@ func (c *certWarm) warmOne(host string) error { |
| return err |
| } |
| -// fail arms the per-host backoff, harder when the CA is rate-limiting. |
| -func (c *certWarm) fail(host string, err error) { |
| +// fail arms the per-host backoff, harder when the CA is rate-limiting, and |
| +// records the outcome durably. |
| +// |
| +// prior is this host's record as the pass read it (the zero Record when |
| +// there is none). The attempt count and the "failing since" stamp are |
| +// carried FORWARD from it rather than recomputed from memory — that is the |
| +// whole reason the record is in the bucket rather than on the heap. |
| +func (c *certWarm) fail(ctx context.Context, host string, err error, prior certwarm.Record) { |
| backoff := warmBackoff |
| + class := certwarm.ClassOther |
| var ae *acme.Error |
| if errors.As(err, &ae) && ae.ProblemType == "urn:ietf:params:acme:error:rateLimited" { |
| backoff = warmRateLimitBackoff |
| + class = certwarm.ClassRateLimited |
| log.Printf("edge: cert warm for %s rate-limited by the CA — backing off %s (the whole deployment shares one ACME account)", host, backoff) |
| } else { |
| log.Printf("edge: cert warm for %s: %v — retrying in %s", host, err, backoff) |
| } |
| + now := time.Now() |
| + next := now.Add(backoff) |
| c.mu.Lock() |
| - c.nextAttempt[host] = time.Now().Add(backoff) |
| + c.nextAttempt[host] = next |
| c.mu.Unlock() |
| + |
| + first := prior.FirstFailedAt |
| + if first == 0 { |
| + first = now.Unix() |
| + } |
| + c.writeRecord(ctx, certwarm.Record{ |
| + Host: host, |
| + Attempts: prior.Attempts + 1, |
| + FirstFailedAt: first, |
| + LastFailedAt: now.Unix(), |
| + NextAttemptAt: next.Unix(), |
| + Class: class, |
| + Error: err.Error(), |
| + }, now) |
| } |
| func (c *certWarm) logStandDown(hosts int) { |
| @@ -374,3 +430,119 @@ func warmResolver(host string) ([]string, error) { |
| defer cancel() |
| return net.DefaultResolver.LookupHost(ctx, host) |
| } |
| + |
| +// ---- the durable outcome records (#299 §3e) --------------------------------- |
| +// |
| +// Every one of these degrades to a no-op rather than to a failure. A bucket |
| +// blip must never stop the warm from warming: the records are how the |
| +// console explains a failure, not how the fix happens, and a pass that |
| +// cannot write one has still ordered the certificate. |
| + |
| +// warmStoreTimeout budgets the record I/O for one pass. Generous relative to |
| +// the work (a listing plus a handful of small objects) and tiny relative to |
| +// the hourly cadence, so a slow bucket delays a pass rather than wedging it. |
| +const warmStoreTimeout = 30 * time.Second |
| + |
| +// loadRecords reads every outcome record in the deployment. |
| +// |
| +// The cost is one object per BROKEN host, not one per domain — see the |
| +// package comment on internal/certwarm — so this stays a listing plus a |
| +// small handful of Gets in any deployment that is mostly working, and grows |
| +// only in one that is mostly not. |
| +// |
| +// A read failure returns what it has. Partial knowledge is the right answer |
| +// here in both directions: a host whose record failed to load is treated as |
| +// having none, so its attempt count restarts at one (an undercount, which |
| +// only ever delays the console reaching "failed") and its record is left |
| +// alone by the prune (never a delete on the strength of a read we could not |
| +// complete). |
| +func (c *certWarm) loadRecords(ctx context.Context) map[string]certwarm.Record { |
| + out := map[string]certwarm.Record{} |
| + if c.store == nil { |
| + return out |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, warmStoreTimeout) |
| + defer cancel() |
| + keys, err := c.store.List(ctx, certwarm.Prefix) |
| + if err != nil { |
| + log.Printf("edge: cert warm: could not list %s: %v — attempt counts fall back to this process's own", certwarm.Prefix, err) |
| + return out |
| + } |
| + for _, key := range keys { |
| + host := certwarm.HostFromKey(key) |
| + if host == "" { |
| + continue |
| + } |
| + body, _, err := c.store.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("edge: cert warm: could not read %s: %v", key, err) |
| + continue |
| + } |
| + rec, err := certwarm.Parse(body) |
| + if err != nil { |
| + // A record this build cannot read is left in place, not deleted: |
| + // it may be a NEWER issuer's, and a rolling fleet has both. |
| + log.Printf("edge: cert warm: could not parse %s: %v", key, err) |
| + continue |
| + } |
| + out[host] = rec |
| + } |
| + return out |
| +} |
| + |
| +// writeRecord publishes one host's outcome, CAS so a transient double-holder |
| +// cannot half-overwrite the other's record. |
| +func (c *certWarm) writeRecord(ctx context.Context, rec certwarm.Record, now time.Time) { |
| + if c.store == nil { |
| + return |
| + } |
| + body, err := rec.Encode(now) |
| + if err != nil { |
| + log.Printf("edge: cert warm: could not encode the record for %s: %v", rec.Host, err) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, warmStoreTimeout) |
| + defer cancel() |
| + if err := putCAS(ctx, c.store, certwarm.Key(rec.Host), body); err != nil { |
| + log.Printf("edge: cert warm: could not record the failure for %s: %v", rec.Host, err) |
| + } |
| +} |
| + |
| +// clearRecord removes one host's record. why names the occasion in the log — |
| +// "recovered" or "detached" — because those are opposite news and a single |
| +// deletion line would hide which one happened. |
| +func (c *certWarm) clearRecord(ctx context.Context, host, why string) { |
| + if c.store == nil { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, warmStoreTimeout) |
| + defer cancel() |
| + if err := c.store.Delete(ctx, certwarm.Key(host)); err != nil { |
| + log.Printf("edge: cert warm: could not clear the %s record for %s: %v", why, host, err) |
| + return |
| + } |
| + log.Printf("edge: cert warm: cleared the outcome record for %s (%s)", host, why) |
| +} |
| + |
| +// prune drops records for hosts that are no longer in the warm set — a |
| +// detached custom domain, or one whose route went away. |
| +// |
| +// This deliberately DOES delete on the strength of an inference ("not in the |
| +// set"), which §3f forbids for certificates. The distinction is ownership, |
| +// not caution: a certificate is the member's property and a wrong deletion |
| +// takes their site down, whereas a warm record is the platform's own note |
| +// about its own attempts and a wrong deletion costs an attempt count that |
| +// the next failure rebuilds. The prune is also fenced by pass()'s early |
| +// return on an empty set, so the one case that would delete EVERYTHING — |
| +// a warm set that briefly folds to nothing — never reaches here. |
| +func (c *certWarm) prune(ctx context.Context, records map[string]certwarm.Record, inSet map[string]bool) { |
| + if c.store == nil || len(records) == 0 { |
| + return |
| + } |
| + for host := range records { |
| + if inSet[host] { |
| + continue |
| + } |
| + c.clearRecord(ctx, host, "detached") |
| + } |
| +} |
diff --git a/internal/edge/certwarm_test.go b/internal/edge/certwarm_test.go| index 95aa55b75..26b4d8160 100644 |
| --- a/internal/edge/certwarm_test.go |
| +++ b/internal/edge/certwarm_test.go |
| @@ -4,12 +4,16 @@ import ( |
| "context" |
| "crypto/tls" |
| "errors" |
| + "os" |
| "strings" |
| "sync" |
| "testing" |
| "time" |
| "golang.org/x/crypto/acme" |
| + |
| + "github.com/carlosframework/platform/internal/certwarm" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // THE trap this whole file exists to avoid. autocert picks the key type from |
| @@ -138,6 +142,7 @@ func TestWarmReAsksForAlreadyWarmHostsEveryPass(t *testing.T) { |
| }, |
| func(string) bool { return true }, |
| nil, |
| + nil, |
| ) |
| for i := 0; i < 3; i++ { |
| w.pass() |
| @@ -159,6 +164,7 @@ func TestWarmRenewalReArmIgnoresTheDNSGate(t *testing.T) { |
| func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| func(string) bool { return pointing }, |
| nil, |
| + nil, |
| ) |
| w.pass() // first order: gate open, host becomes warmed |
| pointing = false |
| @@ -177,6 +183,7 @@ func TestWarmDoesNotOrderForAHostThatDoesNotPointHere(t *testing.T) { |
| func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| func(string) bool { return false }, |
| nil, |
| + nil, |
| ) |
| w.pass() |
| if calls != 0 { |
| @@ -193,6 +200,7 @@ func TestWarmStandsDownWhenNotTheIssuer(t *testing.T) { |
| func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| func(string) bool { return true }, |
| func() (context.Context, bool) { return nil, false }, |
| + nil, |
| ) |
| if d := w.pass(); d != warmInterval { |
| t.Fatalf("delay = %s", d) |
| @@ -207,9 +215,9 @@ func TestWarmStandsDownWhenNotTheIssuer(t *testing.T) { |
| // one, and every host shares the one ACME account's budget. |
| func TestWarmBacksOffHarderOnARateLimit(t *testing.T) { |
| rateLimited := &acme.Error{ProblemType: "urn:ietf:params:acme:error:rateLimited"} |
| - w := newCertWarm(nil, nil, nil, nil) |
| - w.fail("a.test", rateLimited) |
| - w.fail("b.test", errors.New("connection refused")) |
| + w := newCertWarm(nil, nil, nil, nil, nil) |
| + w.fail(context.Background(), "a.test", rateLimited, certwarm.Record{}) |
| + w.fail(context.Background(), "b.test", errors.New("connection refused"), certwarm.Record{}) |
| w.mu.Lock() |
| defer w.mu.Unlock() |
| limited, ordinary := time.Until(w.nextAttempt["a.test"]), time.Until(w.nextAttempt["b.test"]) |
| @@ -233,6 +241,7 @@ func TestWarmHonoursPerHostBackoff(t *testing.T) { |
| }, |
| func(string) bool { return true }, |
| nil, |
| + nil, |
| ) |
| w.pass() |
| w.pass() |
| @@ -258,6 +267,7 @@ func TestWarmCapsNewOrdersAndReturnsSoon(t *testing.T) { |
| func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| func(string) bool { return true }, |
| nil, |
| + nil, |
| ) |
| d := w.pass() |
| if calls != warmOrdersPerPass { |
| @@ -267,3 +277,210 @@ func TestWarmCapsNewOrdersAndReturnsSoon(t *testing.T) { |
| t.Fatalf("capped pass returned %s, want the short %s so the backlog drains", d, warmCappedDelay) |
| } |
| } |
| + |
| +// ---- the durable outcome records (#299 §3e) --------------------------------- |
| + |
| +func warmRecordIn(t *testing.T, s release.Store, host string) (certwarm.Record, bool) { |
| + t.Helper() |
| + body, _, err := s.Get(context.Background(), certwarm.Key(host)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return certwarm.Record{}, false |
| + } |
| + if err != nil { |
| + t.Fatalf("reading the record for %s: %v", host, err) |
| + } |
| + rec, err := certwarm.Parse(body) |
| + if err != nil { |
| + t.Fatalf("parsing the record for %s: %v", host, err) |
| + } |
| + return rec, true |
| +} |
| + |
| +// THE point of §3e. Attempt counts used to live in process memory, so every |
| +// fleet roll and every failover restarted the clock on a domain that had |
| +// been broken for days — and the console could never say more than |
| +// "issuing-cert". The count and the "failing since" stamp must be carried |
| +// forward from the bucket, not from the heap. |
| +func TestWarmCarriesAttemptCountsAcrossAFailover(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + newIssuer := func() *certWarm { |
| + return newCertWarm( |
| + func() []string { return []string{"bad.customer.test"} }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") }, |
| + func(string) bool { return true }, |
| + nil, |
| + s, |
| + ) |
| + } |
| + first := newIssuer() |
| + first.pass() |
| + rec, ok := warmRecordIn(t, s, "bad.customer.test") |
| + if !ok || rec.Attempts != 1 { |
| + t.Fatalf("after one pass: record %+v ok=%v; want attempts 1", rec, ok) |
| + } |
| + failingSince := rec.FirstFailedAt |
| + |
| + // A brand-new process, with no memory of the first one: exactly what a |
| + // roll or a lease handover produces. |
| + for i := 2; i <= 4; i++ { |
| + w := newIssuer() |
| + w.pass() |
| + rec, ok = warmRecordIn(t, s, "bad.customer.test") |
| + if !ok || rec.Attempts != i { |
| + t.Fatalf("failover %d: record %+v ok=%v; want attempts %d — the count reset", i, rec, ok, i) |
| + } |
| + } |
| + if rec.FirstFailedAt != failingSince { |
| + t.Errorf("first_failed_at moved from %d to %d — \"failing since\" must survive a failover", failingSince, rec.FirstFailedAt) |
| + } |
| +} |
| + |
| +// A rate limit is carried as its own class, and with the twelve-hour retry |
| +// time, because those are the two facts that make it explicable rather than |
| +// just another failure. |
| +func TestWarmRecordsTheRateLimitClassAndItsNextAttempt(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + w := newCertWarm( |
| + func() []string { return []string{"limited.customer.test"} }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + return nil, &acme.Error{ProblemType: "urn:ietf:params:acme:error:rateLimited"} |
| + }, |
| + func(string) bool { return true }, |
| + nil, |
| + s, |
| + ) |
| + w.pass() |
| + rec, ok := warmRecordIn(t, s, "limited.customer.test") |
| + if !ok { |
| + t.Fatal("no record written for a rate-limited host") |
| + } |
| + if rec.Class != certwarm.ClassRateLimited { |
| + t.Errorf("class = %q, want %q", rec.Class, certwarm.ClassRateLimited) |
| + } |
| + if until := time.Until(time.Unix(rec.NextAttemptAt, 0)); until < warmRateLimitBackoff-time.Minute { |
| + t.Errorf("next attempt in %s, want about %s", until, warmRateLimitBackoff) |
| + } |
| +} |
| + |
| +// A record exists only while a host is FAILING. Success removes it, which is |
| +// what makes absence the healthy case and keeps the console's fold one |
| +// object per broken host rather than one per domain. |
| +func TestWarmClearsTheRecordOnRecovery(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + broken := true |
| + w := newCertWarm( |
| + func() []string { return []string{"flaky.customer.test"} }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + if broken { |
| + return nil, errors.New("order failed") |
| + } |
| + return &tls.Certificate{}, nil |
| + }, |
| + func(string) bool { return true }, |
| + nil, |
| + s, |
| + ) |
| + w.pass() |
| + if _, ok := warmRecordIn(t, s, "flaky.customer.test"); !ok { |
| + t.Fatal("no record after the failure") |
| + } |
| + broken = false |
| + // The per-host backoff is in memory, so a fresh issuer is the honest way |
| + // to reach the next attempt without sleeping an hour. |
| + recovered := newCertWarm(w.hosts, w.getCert, w.pointsHere, nil, s) |
| + recovered.pass() |
| + if rec, ok := warmRecordIn(t, s, "flaky.customer.test"); ok { |
| + t.Errorf("record %+v survived recovery — absence must be the healthy case", rec) |
| + } |
| +} |
| + |
| +// A detached domain leaves the warm set, and its record goes with it. |
| +// Deleting on an inference is forbidden for CERTIFICATES (§3f) because those |
| +// are the member's property; a record of our own attempts is ours to drop. |
| +func TestWarmPrunesRecordsForHostsThatLeftTheSet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + hosts := []string{"gone.customer.test", "stays.customer.test"} |
| + w := newCertWarm( |
| + func() []string { return hosts }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") }, |
| + func(string) bool { return true }, |
| + nil, |
| + s, |
| + ) |
| + w.pass() |
| + for _, h := range hosts { |
| + if _, ok := warmRecordIn(t, s, h); !ok { |
| + t.Fatalf("no record for %s after the failure", h) |
| + } |
| + } |
| + hosts = []string{"stays.customer.test"} |
| + w.pass() |
| + if rec, ok := warmRecordIn(t, s, "gone.customer.test"); ok { |
| + t.Errorf("record %+v for a detached host survived the prune", rec) |
| + } |
| + if _, ok := warmRecordIn(t, s, "stays.customer.test"); !ok { |
| + t.Error("the prune took a record for a host still in the set") |
| + } |
| +} |
| + |
| +// The one prune case that would delete EVERY record is a warm set that |
| +// briefly folds to nothing — an edges-file read that came back empty, say. |
| +// pass() returns before any of this on an empty set, so it can never happen; |
| +// this pins that, because the guard is one `if` away from being refactored out. |
| +func TestWarmPruneNeverRunsOnAnEmptySet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + hosts := []string{"bad.customer.test"} |
| + w := newCertWarm( |
| + func() []string { return hosts }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") }, |
| + func(string) bool { return true }, |
| + nil, |
| + s, |
| + ) |
| + w.pass() |
| + hosts = nil |
| + w.pass() |
| + if _, ok := warmRecordIn(t, s, "bad.customer.test"); !ok { |
| + t.Error("an empty warm set pruned every record — a transient fold to nothing must never delete") |
| + } |
| +} |
| + |
| +// A non-issuer writes nothing at all. Two boxes both recording their own |
| +// view of a host would be exactly the whole-file overwrite §3e's per-host |
| +// CAS exists to avoid, with the added wrinkle that the non-issuer's view is |
| +// not even real — it places no orders. |
| +func TestWarmStandDownWritesNoRecords(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + w := newCertWarm( |
| + func() []string { return []string{"bad.customer.test"} }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") }, |
| + func(string) bool { return true }, |
| + func() (context.Context, bool) { return nil, false }, |
| + s, |
| + ) |
| + w.pass() |
| + if _, ok := warmRecordIn(t, s, "bad.customer.test"); ok { |
| + t.Error("a stood-down box wrote an outcome record") |
| + } |
| +} |
| + |
| +// The records are observability, never a precondition. A bucket that cannot |
| +// be listed or written must not stop the warm from warming: the certificate |
| +// is the job, the record only explains it. |
| +func TestWarmKeepsWarmingWhenTheRecordStoreIsBroken(t *testing.T) { |
| + s := &failingStore{Store: release.NewDirStore(t.TempDir()), putErr: errors.New("bucket down")} |
| + calls := 0 |
| + w := newCertWarm( |
| + func() []string { return []string{"www.customer.test"} }, |
| + func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return nil, errors.New("order failed") }, |
| + func(string) bool { return true }, |
| + nil, |
| + s, |
| + ) |
| + if d := w.pass(); d != warmInterval { |
| + t.Fatalf("delay = %s, want the ordinary %s", d, warmInterval) |
| + } |
| + if calls != 1 { |
| + t.Fatalf("GetCertificate calls = %d, want 1 — a broken record store stopped the warm", calls) |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index bb5cbf6bc..9c5a1bc42 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -2467,6 +2467,7 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| perHostCert, |
| dnsGate(warmResolver, edgeIPs), |
| e.issuerGate, |
| + store, |
| ) |
| go warm.run() |
| log.Printf("edge: cert warm on (%s pass) — custom domains are ordered and renewed proactively, not on first handshake", warmInterval) |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index c85458132..cfb98cf8f 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -536,7 +536,7 @@ func TestDoorNeverServesCertMaterial(t *testing.T) { |
| // The real Task 4/5 key shapes (certsync.go's certKey naming: bare host, |
| // the autocert account key, an HTTP-01 token response, a tls-alpn-01 |
| - // token cert, a wildcard account key) plus the three other control/ |
| + // token cert, a wildcard account key) plus the four other control/ |
| // entries the multi-region design reserves for the housekeeper alone |
| // (bucket-writer-audit.md, "Not yet built" table): the mesh key, its |
| // rotation successor (Task 2's dual-accept — control/edge-mesh-key.next, |
| @@ -552,8 +552,16 @@ func TestDoorNeverServesCertMaterial(t *testing.T) { |
| "control/edge-mesh-key", |
| "control/edge-mesh-key.next", |
| "control/housekeeper-lease/lock.json", |
| - } |
| - // Seed three of the eight with plausible-looking secret bytes: if the ACL |
| + // The cert warm's outcome records (#299 §3e, internal/certwarm). |
| + // They carry no key material, so the leak they would be is not the |
| + // same one as the entries above — it is the EXISTENCE oracle this |
| + // test's identical-shape check is really about. A door that answered |
| + // differently for a record that exists would let anyone with a fleet |
| + // token enumerate which of the deployment's custom domains are |
| + // currently failing to get a certificate, tenant boundary and all. |
| + "control/cert-warm/example.com.json", |
| + } |
| + // Seed three of the nine with plausible-looking secret bytes: if the ACL |
| // ever let a Get through, these are the ones that would leak real |
| // material instead of a "not found" — and mixing seeded with unseeded |
| // keys through the identical-shape check below is what proves the door |