carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The status serving class (routes with Kind=="status"): the fleet status
2// page's three published objects (see internal/status's PublishFleet),
3// served straight out of the deployment bucket behind a per-key,
4// short-lived in-memory cache. Modeled on static.go's staticSite but much
5// simpler: PublishFleet overwrites the same three keys in place every
6// publish cycle (no signed, versioned channel pointer to resolve), so there
7// is no "which version" question — just "read the key, cache it briefly,
8// serve it, and don't fall over when the bucket hiccups."
9//
10// The same host also publishes open ledgers (internal/ledger) under
11// /ledgers/<sqid>/<app>/..., path-for-key, gated per app by that app's
12// opt-in manifest — see serveLedger.
13package edge
14
15import (
16 "context"
17 "encoding/json"
18 "errors"
19 "log"
20 "net/http"
21 "os"
22 "path"
23 "slices"
24 "strings"
25 "sync"
26 "time"
27
28 "github.com/carlosframework/platform/internal/ledger"
29 "github.com/carlosframework/platform/internal/release"
30)
31
32// ledgerPrefix is the one path prefix on the status host that is NOT the
33// fixed three-object menu: open ledgers (internal/ledger) are served from
34// here, path-for-key, gated per app by that app's opt-in manifest.
35const ledgerPrefix = "/ledgers/"
36
37// statusCacheTTL is how long a fetched status object is trusted before the
38// handler re-reads the store — mirrors staticRecheck's "trust briefly, then
39// recheck" tradeoff, tuned loose because the backing data only changes every
40// 5 minutes (PublishFleet's publish cadence per the design spec).
41const statusCacheTTL = 60 * time.Second
42
43// statusRoutes is the fleet status page's fixed, allowlisted menu: the
44// public path a browser requests, mapped to the bucket key PublishFleet
45// wrote it under and the Content-Type to serve it with. Unlike static.go's
46// staticSite (an arbitrary extracted file tree), the status route never
47// serves anything outside this map — a request for any other path 404s,
48// same as an unlisted key would on a real file server.
49var statusRoutes = map[string]struct {
50 key string
51 ctype string
52}{
53 "/": {"status/index.html", "text/html; charset=utf-8"},
54 "/fleet.json": {"status/fleet.json", "application/json"},
55 "/status.css": {"status/status.css", "text/css; charset=utf-8"},
56}
57
58// statusCached is one fetched-and-cached bucket object.
59type statusCached struct {
60 body []byte
61 ctype string
62 fetched time.Time
63}
64
65// statusHandler serves the fleet status page's three files out of a
66// release.Store, cached per key. The cache is the point of this handler, not
67// an optimization bolted on afterward: the fleet status page exists so an
68// operator can tell whether the fleet is healthy, which means IT must not go
69// dark the instant the deployment bucket has a blip. So the contract is
70// asymmetric by design —
71//
72// - inside the TTL: always serve the cached copy, even if the store has
73// moved on (bounds bucket read volume/cost to once per key per TTL).
74// - outside the TTL with a store error and a cached copy in hand: still
75// serve the (now stale) cached copy rather than fail the request — the
76// status page outliving a transient bucket error is the whole point;
77// fleet.json's own staleness bands (internal/status/fleet.go) are what
78// tell the operator the DATA is old, so this handler doesn't need to
79// lie by omission (503) to be honest — the page itself says "stale".
80// - outside the TTL with a store error and NO cached copy (nothing has
81// ever been successfully fetched): 503. There is nothing honest left to
82// serve.
83// - one exception, for ledger objects only: outside the TTL with the
84// object GONE (os.ErrNotExist), drop the cached copy and fail. For a
85// ledger a deletion is the bucket's answer, not a blip: it is how an app
86// purge reaches this cache, and without it the purged app's public
87// ledger kept serving from every edge that had read it, to the end of
88// the process (discussion #219, item 2). The status page's own files
89// keep the stale-on-anything rule above; nothing deletes them on purpose.
90type statusHandler struct {
91 store release.Store
92 // ttl and now are both overridable by tests (a tiny ttl plus a fake
93 // clock lets a TTL-expiry test run in microseconds instead of sleeping
94 // 60 real seconds); production always gets statusCacheTTL and time.Now.
95 ttl time.Duration
96 now func() time.Time
97
98 mu sync.Mutex
99 cache map[string]statusCached
100 // misses is the negative half of the ledger gate: manifest key → when
101 // the "not servable" decision was taken. See markMiss.
102 misses map[string]time.Time
103}
104
105// statusMissCacheMax bounds the negative cache. It exists because the
106// positive cache's bound — "get only records an entry after a SUCCESSFUL
107// store read, so only objects that exist can grow it" — is exactly the
108// property a negative cache gives up: the prober picks the account and app
109// segments, so the set of manifest keys they can name is unbounded. 1024
110// apps' worth of decisions is far more than any real deployment publishes
111// and costs a few tens of KiB; past it the map is CLEARED rather than
112// evicted one entry at a time, because there is no access pattern here worth
113// approximating with an LRU — the honest read of a full map is "this is
114// being probed", and a probe's entries are the ones worth dropping.
115const statusMissCacheMax = 1024
116
117// newStatusHandler builds the handler for one status route. store is shared
118// with the static serving class (see EnableStatic's doc comment) — the
119// status page has no per-route config of its own (no app, no channel), so
120// unlike newStaticHandler this takes no registry.Route.
121func newStatusHandler(store release.Store) *statusHandler {
122 return &statusHandler{
123 store: store,
124 ttl: statusCacheTTL,
125 now: time.Now,
126 cache: make(map[string]statusCached),
127 misses: make(map[string]time.Time),
128 }
129}
130
131// missFresh reports whether the "not servable" decision for this manifest
132// key was taken recently enough to reuse.
133func (h *statusHandler) missFresh(key string) bool {
134 h.mu.Lock()
135 defer h.mu.Unlock()
136 at, ok := h.misses[key]
137 return ok && h.now().Sub(at) < h.ttl
138}
139
140// markMiss records that an app is not servable — no manifest, an unparseable
141// one, or public:false — for the same TTL the positive cache trusts a fetched
142// object. Publishing an app therefore takes effect within TWO ticks worst
143// case (~2×TTL), one typically: the two caches are stamped independently, so
144// a public:false manifest read at t=0 can still be serving from the positive
145// cache at t=59s and stamp a NEGATIVE entry then, which outlives it. Revoking
146// has the same shape from the other side. Both are bounded and small; the
147// alternative — invalidating across the two caches — would buy a second off a
148// 5-minute-cadence page.
149//
150// The bound is the point: see statusMissCacheMax.
151func (h *statusHandler) markMiss(key string) {
152 h.mu.Lock()
153 defer h.mu.Unlock()
154 if len(h.misses) >= statusMissCacheMax {
155 if _, ok := h.misses[key]; !ok {
156 clear(h.misses)
157 }
158 }
159 h.misses[key] = h.now()
160}
161
162func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
163 p := path.Clean(r.URL.Path)
164 if strings.HasPrefix(p, ledgerPrefix) {
165 h.serveLedger(w, r, p)
166 return
167 }
168 rt, ok := statusRoutes[p]
169 if !ok {
170 http.NotFound(w, r)
171 return
172 }
173 body, ctype, err := h.get(r.Context(), rt.key, rt.ctype)
174 if err != nil {
175 log.Printf("edge: status %s: %v", rt.key, err)
176 http.Error(w, "status page unavailable", http.StatusServiceUnavailable)
177 return
178 }
179 w.Header().Set("Content-Type", ctype)
180 w.Write(body)
181}
182
183// serveLedger maps /ledgers/<sqid>/<app>/... 1:1 onto the bucket key — that
184// identity is what lets `carlos ledger verify -url` walk a public chain over
185// HTTPS with the very keys the store uses, no URL scheme to keep in sync.
186//
187// Everything here is fail-closed: the ONLY way to get a body out of this
188// handler is an app whose manifest reads, parses, says public:true, and —
189// for anything below the app level — names that ledger in its Ledgers list.
190// No manifest, an unreadable one, an unparseable one, public:false, or a
191// ledger the manifest doesn't name all end in the same plain 404 as an app
192// that was never created: a caller must not be able to probe which apps
193// exist, let alone which of their ledgers do. A missing object under a
194// public app is a 404 too: the 503 "nothing honest left to serve" contract
195// above belongs to the fleet page's own three objects, which are the page,
196// not arbitrary reads.
197//
198// Manifest.Ledgers is read as an ALLOWLIST, not as a convenience index: an
199// account accrues ledgers privately (the console writes chains whether or
200// not anything is published), so "the app is public" cannot be allowed to
201// mean "every chain it has ever written is public". Publishing is per
202// ledger, by name; an empty list publishes nothing but the index itself.
203//
204// Ledger objects fall into two cache classes, because unlike the fleet
205// page's three fixed objects a public ledger has unboundedly many:
206//
207// - ledgers.json and HEAD.json are MUTABLE and few — one manifest per
208// public app, one HEAD per ledger — so they ride h.get's shared 60s
209// cache. Revoking public therefore takes at most one TTL, and a cached
210// public manifest keeps serving through a store outage exactly like any
211// other cached object (get's stale-on-error half). That map cannot be
212// grown by a stranger probing random sqids: get only writes an entry
213// AFTER a successful store read (see its tail), so it is bounded by what
214// actually exists. The REFUSALS a prober generates are remembered
215// separately, in the explicitly bounded negative cache (markMiss), so a
216// probe costs a bucket GET at most once per TTL per app rather than once
217// per request.
218// - entries/*.json and blobs/* are IMMUTABLE and unbounded in number
219// (an entry per append, blobs up to ledger.MaxBlob each), so they are
220// NOT cached in this process at all: a direct store read plus a
221// year-long immutable Cache-Control, which puts the caching in browsers
222// and CDNs where the memory isn't ours. That is deliberate: a public
223// chain walker must not be able to pin arbitrary edge memory by simply
224// reading every entry of a public ledger.
225func (h *statusHandler) serveLedger(w http.ResponseWriter, r *http.Request, p string) {
226 // path.Clean above already collapsed dot and empty segments; re-check
227 // them anyway, because this is the one place where a public request
228 // chooses the bucket key and "the cleaner surely handled it" is how
229 // traversal bugs get written. Note ServeHTTP sees the DECODED path, so
230 // %2e%2e arrives here as ".." and dies on this check (or, once Clean
231 // has resolved it out of the /ledgers/ prefix entirely, never reaches
232 // this function at all).
233 parts := strings.Split(strings.TrimPrefix(p, "/"), "/") // ["ledgers", sqid, app, ...]
234 if len(parts) < 4 {
235 http.NotFound(w, r)
236 return
237 }
238 for _, seg := range parts {
239 if seg == "" || seg == "." || seg == ".." {
240 http.NotFound(w, r)
241 return
242 }
243 }
244 obj, ok := ledgerShape(parts[3:])
245 if !ok {
246 http.NotFound(w, r)
247 return
248 }
249 sqid, app := parts[1], parts[2]
250
251 // The negative half of the gate, checked BEFORE the store: a miss is
252 // never recorded by get (it only caches successful reads), and
253 // status-kind responses never enter the shared response cache, so
254 // without this every probe at a made-up account/app costs one bucket GET
255 // — and the prober writes the path.
256 mkey := ledger.ManifestKey(sqid, app)
257 if h.missFresh(mkey) {
258 http.NotFound(w, r)
259 return
260 }
261 mb, _, err := h.get(r.Context(), mkey, "application/json")
262 if err != nil {
263 // Only a genuine not-found is REMEMBERED. That is what the negative
264 // cache is for: a prober names manifests that do not exist, and the
265 // store answers os.ErrNotExist (release.Store's contract). Any other
266 // error is a blip — a throttle, an expired credential — and stamping
267 // it would take a live public app dark for the full TTL because the
268 // bucket hiccuped once while this key's positive cache happened to be
269 // cold. This request still 404s (fail-closed: nothing readable said
270 // public); the next one retries.
271 if errors.Is(err, os.ErrNotExist) {
272 h.markMiss(mkey)
273 }
274 http.NotFound(w, r) // fail-closed: no readable manifest = not public
275 return
276 }
277 var m ledger.Manifest
278 if err := json.Unmarshal(mb, &m); err != nil || !m.Public {
279 h.markMiss(mkey)
280 http.NotFound(w, r)
281 return
282 }
283 // obj.ledger is "" only for ledgers.json, the app's OWN published index,
284 // which the app is publishing by setting public:true. Everything else
285 // names a ledger and must be on the allowlist.
286 if obj.ledger != "" && !slices.Contains(m.Ledgers, obj.ledger) {
287 http.NotFound(w, r)
288 return
289 }
290
291 key := strings.TrimPrefix(p, "/")
292 ctype := obj.ctype
293 // Sniffing is what would turn an opaque blob into something a browser
294 // executes in this origin's context; the ledger's bytes are arbitrary
295 // tenant content, so pin the declared type on every response, both
296 // classes.
297 w.Header().Set("X-Content-Type-Options", "nosniff")
298
299 if obj.immutable {
300 // Uncached class: straight to the store, and let the caller cache
301 // it forever — an entry key commits to its own bytes (the ts-sha
302 // key) and a blob key IS its sha256, so neither can ever change
303 // meaning. A store error here is the same plain 404 as a key that
304 // was never written; nothing is logged, for the same reason the
305 // rest of this handler logs nothing.
306 body, _, err := h.store.Get(r.Context(), key)
307 if err != nil {
308 http.NotFound(w, r)
309 return
310 }
311 w.Header().Set("Content-Type", ctype)
312 w.Header().Set("Cache-Control", ledgerImmutableCacheControl)
313 w.Write(body)
314 return
315 }
316
317 body, ctype, err := h.get(r.Context(), key, ctype)
318 if err != nil {
319 http.NotFound(w, r)
320 return
321 }
322 w.Header().Set("Content-Type", ctype)
323 w.Write(body)
324}
325
326// ledgerImmutableCacheControl is what entries and blobs are served with: a
327// year, immutable. Content-addressed keys make that safe, and it is what
328// keeps the caching of an unbounded object set in browsers and CDNs instead
329// of this process's memory. ledgers.json and HEAD.json deliberately get NO
330// Cache-Control — they are the mutable head of the chain and their freshness
331// is the 60s server-side TTL's job, not a client's.
332const ledgerImmutableCacheControl = "public, max-age=31536000, immutable"
333
334// ledgerObject is everything the path shape alone decides about a servable
335// ledger object — before any manifest is consulted.
336type ledgerObject struct {
337 // ctype comes from the SHAPE, never from the key's suffix: a blob is
338 // named by its sha256 and a tenant may well upload one whose sha ends
339 // ".json". Blobs are opaque bytes, and the shape is what knows that.
340 ctype string
341 // immutable selects the cache class documented on serveLedger.
342 immutable bool
343 // ledger is the ledger name this object belongs to, or "" for the
344 // app-level index (ledgers.json) — the allowlist gate keys off it.
345 ledger string
346}
347
348// ledgerShape is the object-shape allowlist inside one app's ledger subtree,
349// given the path segments AFTER ledgers/<sqid>/<app>/. The ledger primitive
350// writes exactly four shapes (internal/ledger/ledger.go's key builders) and
351// the edge publishes exactly those: living under a public app's prefix is
352// not, by itself, a reason to hand an object to the internet. Same allowlist
353// spirit as statusRoutes, one level down. One switch decides shape, content
354// type, cache class and owning ledger together, so those four cannot drift
355// apart as shapes are added.
356func ledgerShape(rest []string) (ledgerObject, bool) {
357 const jsonCT, blobCT = "application/json", "application/octet-stream"
358 switch {
359 case len(rest) == 1 && rest[0] == "ledgers.json": // ledgers/<sqid>/<app>/ledgers.json
360 return ledgerObject{ctype: jsonCT}, true
361 case len(rest) == 2 && rest[1] == "HEAD.json": // .../<ledger>/HEAD.json
362 return ledgerObject{ctype: jsonCT, ledger: rest[0]}, true
363 case len(rest) == 3 && rest[1] == "entries" && strings.HasSuffix(rest[2], ".json"):
364 return ledgerObject{ctype: jsonCT, immutable: true, ledger: rest[0]}, true
365 case len(rest) == 3 && rest[1] == "blobs": // .../<ledger>/blobs/<sha>
366 return ledgerObject{ctype: blobCT, immutable: true, ledger: rest[0]}, true
367 }
368 return ledgerObject{}, false
369}
370
371// get implements the cache contract documented on statusHandler: fresh cache
372// hit wins outright (no store touch at all — that's what bounds read
373// volume); otherwise it tries the store, falling back to a stale cached copy
374// on error, and only fails when there is nothing cached to fall back to or
375// a ledger object is gone.
376func (h *statusHandler) get(ctx context.Context, key, ctype string) ([]byte, string, error) {
377 h.mu.Lock()
378 c, cached := h.cache[key]
379 fresh := cached && h.now().Sub(c.fetched) < h.ttl
380 h.mu.Unlock()
381 if fresh {
382 return c.body, c.ctype, nil
383 }
384
385 body, _, err := h.store.Get(ctx, key)
386 if err != nil {
387 if errors.Is(err, os.ErrNotExist) && strings.HasPrefix(key, "ledgers/") {
388 if cached {
389 h.mu.Lock()
390 delete(h.cache, key)
391 h.mu.Unlock()
392 }
393 return nil, "", err
394 }
395 if cached {
396 log.Printf("edge: status %s: store read failed outside TTL (%v) — serving stale copy fetched at %s",
397 key, err, c.fetched.Format(time.RFC3339))
398 return c.body, c.ctype, nil
399 }
400 return nil, "", err
401 }
402
403 entry := statusCached{body: body, ctype: ctype, fetched: h.now()}
404 h.mu.Lock()
405 h.cache[key] = entry
406 h.mu.Unlock()
407 return body, ctype, nil
408}
409