carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package certwarm is the wire shape of the cert warm's durable outcome
2// records (#299 §3e): control/cert-warm/<host>.json, written by whichever
3// box holds the issuer lease, read by the console.
4//
5// The record exists so that "the platform tried to get this hostname a
6// certificate and it is not working" can be SAID. Without it the warm's
7// attempt counts live only in the issuer's process memory: they reset on
8// every fleet roll and on every failover, so a domain that has been failing
9// for a week is indistinguishable from one attached ninety seconds ago, and
10// the console has no honest way to show anything but "issuing-cert" forever.
11//
12// One record per host, and ONLY while that host is failing. A successful
13// warm deletes the record, so absence is the healthy case and the prefix
14// holds nothing at all in a deployment where every domain is fine. Two
15// things follow, both load-bearing:
16//
17// - The console's read is cheap by construction. Listing this prefix costs
18// one object per BROKEN host, not one per domain, so folding it into the
19// domains list does not reintroduce the per-claim cost the per-host door
20// (#328) exists to avoid.
21// - "No record" is never evidence of success on its own. It is also what a
22// host that has never been warmed looks like, and what a wedged issuer
23// leaves behind. Readers must treat absence as "nothing to report", and
24// must age-bound what they do find — see Fresh.
25//
26// Unlike internal/edgesfile, this package owns the shape for BOTH sides
27// rather than mirroring a writer that lives elsewhere. The reasoning there
28// was that internal/host's edges-file writer was under heavy development and
29// re-plumbing it onto a shared type would have fought it for no gain. Here
30// writer and reader are being introduced together, in one change, so there
31// is no incumbent to fight and no reason to maintain two decoders of one
32// shape.
33package certwarm
34
35import (
36 "encoding/json"
37 "fmt"
38 "strings"
39 "time"
40)
41
42// Prefix is the flat prefix every record lives under. Inside control/, which
43// the fleet door's aclRead allowlist denies by default — see
44// TestDoorNeverServesCertMaterial, which pins this prefix with the cert
45// material it sits beside.
46const Prefix = "control/cert-warm/"
47
48// V is the wire version this build writes. A reader refuses V<1 (a record
49// with no version is not one of ours) and accepts anything higher, on the
50// same additive-wire rule the edges files use: a newer issuer may add
51// fields, and an older console must degrade to "no verdict" rather than
52// fail the whole fold.
53const V = 1
54
55// Failure classes. Stable machine words — a client may branch on them, so
56// they are part of the wire and are never re-spelled.
57const (
58 // ClassRateLimited: the CA refused because the account's budget is
59 // spent. Materially different from every other failure because it is
60 // nobody's mistake and it will clear on its own, and because the wait is
61 // long enough (12h) that a member deserves to be told rather than shown
62 // a spinner.
63 ClassRateLimited = "rate-limited"
64 // ClassOther: everything else — a failed authorization, a CA outage, a
65 // network error. Deliberately NOT subdivided: the CA's own text is
66 // carried in Error, and inventing a taxonomy over strings we do not
67 // control would go stale the first time the CA rewords a problem
68 // document.
69 ClassOther = "other"
70)
71
72// Key is the bucket key for host's record.
73//
74// The host is used verbatim. Callers hand it the same normalized form the
75// registry and the edges files store — lowercased and punycoded — because a
76// record written under one spelling and read under another is a record that
77// silently does not exist.
78func Key(host string) string { return Prefix + host + ".json" }
79
80// HostFromKey is Key's inverse, for walking a listing. It returns "" for a
81// key outside the prefix or without the .json suffix, so a stray object
82// under the prefix is skipped rather than decoded as a hostname.
83func HostFromKey(key string) string {
84 rest, ok := strings.CutPrefix(key, Prefix)
85 if !ok {
86 return ""
87 }
88 host, ok := strings.CutSuffix(rest, ".json")
89 if !ok || host == "" || strings.Contains(host, "/") {
90 return ""
91 }
92 return host
93}
94
95// Record is one host's warm outcome. Every timestamp is unix seconds.
96type Record struct {
97 V int `json:"v"`
98 Host string `json:"host"`
99 // Attempts is how many consecutive warm attempts have failed since the
100 // last success. It is the field the whole package exists for: it is
101 // carried in the bucket rather than in memory precisely so that a
102 // failover does not reset it to one and restart the clock on a domain
103 // that has been broken for days.
104 Attempts int `json:"attempts"`
105 // FirstFailedAt is when the current run of failures began — preserved
106 // across writes, so "failing since" survives an issuer moving.
107 FirstFailedAt int64 `json:"first_failed_at"`
108 // LastFailedAt is the most recent failure.
109 LastFailedAt int64 `json:"last_failed_at"`
110 // NextAttemptAt is the backoff floor: when the issuer will try again.
111 // Carried because it is the difference between "we have given up" and
112 // "we are waiting out a rate limit", which is the whole of what a member
113 // wants to know, and because Fresh needs it (see there).
114 NextAttemptAt int64 `json:"next_attempt_at"`
115 // Class is one of the Class* constants.
116 Class string `json:"class"`
117 // Error is the CA's or the client's own text, trimmed to ErrorMax. Shown
118 // to operators, never parsed.
119 Error string `json:"error,omitempty"`
120 // WrittenAt is when this record was written. A stamp without its age
121 // cannot be judged — see Fresh.
122 WrittenAt int64 `json:"written_at"`
123}
124
125// ErrorMax bounds the carried error text. A CA problem document can be long,
126// and an unbounded string from a remote party landing in a bucket object
127// that the console renders is exactly the shape that turns one bad response
128// into a large object written every hour.
129const ErrorMax = 512
130
131// Grace is how long past its own next-attempt time a record stays
132// authoritative.
133//
134// The age bound exists because a wedged or stopped issuer keeps its records
135// sitting in the bucket, frozen: without a bound the console would keep
136// reporting "we tried 40 times and stopped" long after nothing is trying at
137// all, which is a worse lie than saying nothing. Judged against
138// NextAttemptAt rather than WrittenAt because the two differ by design — a
139// rate-limited host is deliberately not retried for twelve hours, and a
140// bound on write time alone would call that healthy silence "stale" every
141// time.
142//
143// The limit this accepts, stated plainly: an issuer that wedges immediately
144// after writing a rate-limited record is believed for up to fourteen hours.
145// Closing that would need a per-pass heartbeat object, which is a second
146// key and a second writer-table row to buy a sharper edge on an already
147// rare case.
148const Grace = 2 * time.Hour
149
150// Fresh reports whether this record still describes what the issuer is
151// doing. A stale record is not deleted and not an error — it is simply not
152// used as evidence, and the reader falls back to whatever it would have said
153// with no record at all.
154func (r Record) Fresh(now time.Time) bool {
155 deadline := r.WrittenAt
156 if r.NextAttemptAt > deadline {
157 deadline = r.NextAttemptAt
158 }
159 if deadline == 0 {
160 return false
161 }
162 return now.Before(time.Unix(deadline, 0).Add(Grace))
163}
164
165// Parse decodes one record.
166func Parse(body []byte) (Record, error) {
167 var r Record
168 if err := json.Unmarshal(body, &r); err != nil {
169 return Record{}, fmt.Errorf("cert-warm record: %w", err)
170 }
171 if r.V < V {
172 return Record{}, fmt.Errorf("cert-warm record: unsupported version %d", r.V)
173 }
174 return r, nil
175}
176
177// Encode serializes a record, stamping the version and the written-at time
178// so no caller can forget either.
179func (r Record) Encode(now time.Time) ([]byte, error) {
180 r.V = V
181 r.WrittenAt = now.Unix()
182 if len(r.Error) > ErrorMax {
183 r.Error = r.Error[:ErrorMax]
184 }
185 if r.Class == "" {
186 r.Class = ClassOther
187 }
188 return json.Marshal(r)
189}
190