carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The relay's write side (relay spec §5): the writer lease that makes one
2// console the only process writing relay state, the operations that turn it
3// on and off for an app, the etag-first publish of the authority record every
4// relay box reads, and the five-minute reconcile that converges both derived
5// artefacts from the records whatever happened in between.
6//
7// THREE OBJECTS, ONE SOURCE OF TRUTH. `apps/<acct>/<app>/relay.json` (the
8// relay.Record) is the truth for an app. `control/relay.json` (the
9// relay.Authority) is DERIVED from every record and never patched:
10// publishing it is read-the-etag-first, list, build, CAS, and start over on
11// conflict, so a disable that lands during the listing makes the write fail
12// rather than quietly restore the app. `control/relay-issuer.json` is the
13// unsealed pointer saying WHICH staged master a region issues from; adding a
14// master to the console's secret issues nothing until that pointer moves.
15//
16// ONE WRITER, ENFORCED, AND THE WALK IS RESUMABLE. Every operation here
17// writes its intent to the record first (`op` bumped, `phase` set), then
18// writes config, then publishes, advancing `phase` after each step. That is
19// what makes an interrupted operation finishable: the reconcile does not look
20// for unfinished records, it re-derives what every record implies and fixes
21// whatever disagrees. The lease is the fence around all of it — a process
22// that loses a renewal stops before its next bucket write and abandons the
23// operation, and the next holder's reconcile finishes it.
24//
25// THE CONSOLE HOLDS MASTERS BUT OPENS NOTHING. relayMasters reads the
26// region's masters out of this console's OWN environment
27// (CARLOS_RELAY_MASTERS_<REGION>, delivered like every other console
28// variable) and derives an app key from them; the value it delivers is
29// SEALED with CARLOS_SECRETS_PUBKEY, which this console cannot open — the
30// same one-way street apiconfig.go's package comment describes, and the
31// reason a console with no sealing key refuses to enable rather than mint a
32// key it could never deliver.
33//
34// Spec: docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md §4, §5.
35package console
36
37import (
38 "context"
39 "crypto/rand"
40 "crypto/sha256"
41 "encoding/base64"
42 "encoding/hex"
43 "encoding/json"
44 "errors"
45 "fmt"
46 "log"
47 "maps"
48 "os"
49 "slices"
50 "sort"
51 "strings"
52 "sync"
53 "time"
54
55 "github.com/carlosframework/platform/internal/appconfig"
56 "github.com/carlosframework/platform/internal/provision"
57 "github.com/carlosframework/platform/internal/registry"
58 "github.com/carlosframework/platform/internal/relay"
59 "github.com/carlosframework/platform/internal/release"
60)
61
62const (
63 // relayWriterKey is the writer lease: {holder, expires_at}, CAS-written.
64 relayWriterKey = "control/relay-writer.json"
65 // relayIssuerKey is the issuer pointer: {"<region>": "<kid>"}. Unsealed
66 // on purpose — it names a key, it is not one.
67 relayIssuerKey = "control/relay-issuer.json"
68 // relayAckPrefix is where each armed relay writes its heartbeat.
69 relayAckPrefix = "control/relay-ack/"
70 // configAckPrefix is where each box's config sync writes a per-route
71 // config-adoption acknowledgement (spec §5, internal/appconfig/
72 // configack.go): configAckPrefix + "<box>/<route host>.json".
73 configAckPrefix = "control/config-ack/"
74
75 // The lease's two numbers, from spec §5: sixty seconds of validity,
76 // renewed every twenty. Three renewals inside one TTL means a single
77 // slow bucket round trip costs nothing, while a console that dies is
78 // replaced within a minute.
79 relayLeaseTTL = 60 * time.Second
80 relayLeaseRenew = 20 * time.Second
81 // relayLeaseWait bounds how long StartRelay waits for the lease before
82 // its first reconcile. A console that does not get it simply reconciles
83 // nothing — the holder is doing it.
84 relayLeaseWait = 5 * time.Second
85
86 // relayReconcileInterval is the convergence floor from spec §5: a stale
87 // config write is undone, an interrupted enable delivered, and a
88 // half-done deletion repaired, within this.
89 relayReconcileInterval = 5 * time.Minute
90
91 // relayHeartbeatFresh is how recent a relay's last SUCCESSFUL authority
92 // read must be for its heartbeat to count as a live answer — the same
93 // five minutes `carlos relay status` calls "stale" past.
94 relayHeartbeatFresh = 5 * time.Minute
95 // relayRetireAfter is the earliest a rotated-away master may be retired
96 // (spec §5, step 4): 28 hours, comfortably past the four-hour ceiling on
97 // any credential minted just before the pointer moved.
98 relayRetireAfter = 28 * time.Hour
99
100 // relayRouteMax caps the instance records one delivery check reads. An
101 // app with more routes than this has bigger questions than the relay.
102 relayRouteMax = 500
103
104 // relayVar is the one variable this whole file exists to deliver.
105 relayVar = "CARLOS_RELAY"
106)
107
108// The three phases of an operation's walk (spec §5). `done` is the only one
109// that means "nothing is owed".
110const (
111 relayPhaseConfig = "config"
112 relayPhaseAggregate = "aggregate"
113 relayPhaseDone = "done"
114)
115
116// The refusals callers (and, in Task 11, doors) match on. Each is a
117// configuration fact rather than a failure of the request, which is why they
118// are sentinels rather than sentences built at the call site: the door turns
119// them into a 503 naming the variable, and the CLI prints them verbatim.
120var (
121 errRelayNoBucket = errors.New("no deployment bucket on this host — this console cannot serve the relay")
122 errRelayNoRegions = errors.New(
123 "this deployment runs no relay (CARLOS_RELAY_REGIONS is empty) — ask the operator to arm a region before enabling it for an app")
124 errRelayNoSealKey = errors.New(
125 "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver a relay key, so it will not issue one")
126 // The commonest cause is not another console at all: it is THIS console
127 // having just restarted, leaving its own previous process's lease to
128 // expire. That takes up to relayLeaseTTL, so the message says to retry
129 // and names the wait rather than leaving an operator to guess whether
130 // the deployment is wedged (discussion #120).
131 errRelayNotHeld = errors.New(
132 "this console does not hold the relay writer lease. another console is driving the relay, " +
133 "or this one restarted and its previous lease has not expired yet. the operation was not started, " +
134 "so retry in up to a minute")
135 errRelayNotEnabled = errors.New("the relay is not enabled for this app")
136 errRelayUndelivered = errors.New("a route has no config delivery")
137 // errRelayUnknownHost is `carlos relay adopted --host` naming something
138 // that is not a route of this app — the door's 404.
139 errRelayUnknownHost = errors.New("no such route on this app")
140 // errRelayNotMemberRoute is `carlos relay adopted --host` naming a route
141 // the config sync restarts itself. Spec §5, step 5 gives a route exactly
142 // one way to establish its cutover, decided by shape: a unit or
143 // socket-exec route's cutover is the ack the config sync writes after it
144 // restarted the process, and a member's word would override an observed
145 // fact with a claim.
146 errRelayNotMemberRoute = errors.New("this route's cutover is observed, not declared")
147)
148
149// relayLease is control/relay-writer.json.
150type relayLease struct {
151 Holder string `json:"holder"`
152 ExpiresAt time.Time `json:"expires_at"`
153}
154
155// relayHolderID draws this process's lease identity: 16 random hex
156// characters, like an incarnation and for the same reason — two consoles
157// must never collide, and a counter or a hostname can.
158func relayHolderID() string {
159 var b [8]byte
160 if _, err := rand.Read(b[:]); err != nil {
161 panic("console: crypto/rand: " + err.Error())
162 }
163 return hex.EncodeToString(b[:])
164}
165
166// relayTime is every relay path's clock, seamed for the lease tests.
167func (s *Server) relayTime() time.Time {
168 if s.relayNow != nil {
169 return s.relayNow()
170 }
171 return time.Now()
172}
173
174// relayHold reports whether this process currently holds the writer lease.
175func (s *Server) relayHold() bool { return s.relayHeld.Load() }
176
177// requireRelayLease is the check every relay bucket write makes FIRST. A
178// process that lost its renewal stops here rather than completing a walk
179// whose next step another console has already started — spec §5's "stops
180// every relay write in flight before its next bucket write".
181func (s *Server) requireRelayLease() error {
182 if !s.relayHold() {
183 return errRelayNotHeld
184 }
185 return nil
186}
187
188// relayAcquire is one acquire-or-renew attempt, by CAS on the lease object.
189// Absent, expired, or already ours means we take it; anyone else's live lease
190// means we do not, and — this is the load-bearing half — we drop `held` so
191// any operation in flight in this process fails its next check.
192func (s *Server) relayAcquire(ctx context.Context) error {
193 if s.cfg.Releases == nil {
194 return errRelayNoBucket
195 }
196 now := s.relayTime()
197 body, etag, err := s.cfg.Releases.Get(ctx, relayWriterKey)
198 switch {
199 case errors.Is(err, os.ErrNotExist):
200 body, etag = nil, ""
201 case err != nil:
202 s.relayHeld.Store(false)
203 return err
204 }
205 var cur relayLease
206 if len(body) > 0 {
207 if err := json.Unmarshal(body, &cur); err != nil {
208 // An unparseable lease is not a live one: it names nobody this
209 // process can defer to. Taking it over with the etag we read is
210 // still a CAS, so a concurrent repair wins the race, not us.
211 log.Printf("console: relay writer lease is unreadable, taking it over: %v", err)
212 cur = relayLease{}
213 }
214 }
215 if cur.Holder != "" && cur.Holder != s.relayHolder && now.Before(cur.ExpiresAt) {
216 s.relayHeld.Store(false)
217 return fmt.Errorf("relay writer lease is held by %s until %s: %w",
218 cur.Holder, cur.ExpiresAt.UTC().Format(time.RFC3339), errRelayNotHeld)
219 }
220 next, err := json.Marshal(relayLease{Holder: s.relayHolder, ExpiresAt: now.Add(relayLeaseTTL)})
221 if err != nil {
222 s.relayHeld.Store(false)
223 return err
224 }
225 if _, err := s.cfg.Releases.Put(ctx, relayWriterKey, next, etag); err != nil {
226 // A lost CAS is another console taking the same lapsed lease. Held
227 // goes false either way: a write we did not land is a lease we do
228 // not hold.
229 s.relayHeld.Store(false)
230 return err
231 }
232 s.relayHeld.Store(true)
233 return nil
234}
235
236// relayWriterLoop acquires the lease now and renews it every twenty seconds
237// until ctx ends. It is the ONLY writer of s.relayHeld outside a test.
238//
239// A failed attempt is logged at most as a line, never fatal: the ordinary
240// steady state of a two-console deployment is one process failing this call
241// forever while the other holds the lease, and a deployment overlap must not
242// fill a log with alarm.
243func (s *Server) relayWriterLoop(ctx context.Context) {
244 t := time.NewTicker(relayLeaseRenew)
245 defer t.Stop()
246 held := false
247 for {
248 err := s.relayAcquire(ctx)
249 switch {
250 case err == nil && !held:
251 log.Printf("console: relay writer lease acquired (holder %s)", s.relayHolder)
252 held = true
253 case err != nil && held:
254 log.Printf("console: relay writer lease lost: %v", err)
255 held = false
256 case err != nil && !errors.Is(err, errRelayNotHeld) && !errors.Is(err, release.ErrCASConflict):
257 log.Printf("console: relay writer lease: %v", err)
258 }
259 select {
260 case <-ctx.Done():
261 return
262 case <-t.C:
263 }
264 }
265}
266
267// StartRelay runs the relay's two background loops: the writer lease, and the
268// converging reconcile every five minutes and once at start. Started from
269// cmd/console beside the other passes — never from NewServer, which stays
270// goroutine-free so constructing a Server in a test starts nothing.
271//
272// Gated on a configured region list rather than on holding the lease: a
273// deployment with no relay has nothing to converge and should not be reading
274// a lease object every twenty seconds forever.
275func (s *Server) StartRelay(ctx context.Context) {
276 if s.cfg.Releases == nil || len(s.relayRegions()) == 0 {
277 return
278 }
279 go s.relayWriterLoop(ctx)
280 // The first reconcile is the one that finishes whatever the previous
281 // holder abandoned, so it is worth a short wait for the lease rather
282 // than five minutes of drift.
283 s.relayAwaitLease(ctx, relayLeaseWait)
284 t := time.NewTicker(relayReconcileInterval)
285 defer t.Stop()
286 for {
287 if s.relayHold() {
288 if err := s.relayReconcile(ctx); err != nil {
289 log.Printf("console: relay reconcile: %v", err)
290 }
291 }
292 select {
293 case <-ctx.Done():
294 return
295 case <-t.C:
296 }
297 }
298}
299
300// relayAwaitLease waits up to d for the writer loop's first acquire.
301func (s *Server) relayAwaitLease(ctx context.Context, d time.Duration) {
302 deadline := time.Now().Add(d)
303 for !s.relayHold() && time.Now().Before(deadline) {
304 select {
305 case <-ctx.Done():
306 return
307 case <-time.After(25 * time.Millisecond):
308 }
309 }
310}
311
312// ---- masters and the issuer pointer -----------------------------------------
313
314// relayMastersEnv is the console variable holding one region's masters:
315// CARLOS_RELAY_MASTERS_EU_WEST_1 for eu-west-1 (uppercased, hyphens to
316// underscores, since variable names refuse hyphens — spec §4).
317func relayMastersEnv(region string) string {
318 return "CARLOS_RELAY_MASTERS_" + strings.ToUpper(strings.ReplaceAll(region, "-", "_"))
319}
320
321// relayStagedMaster is one `kid:key` pair out of that variable, in the order
322// the operator wrote them.
323type relayStagedMaster struct {
324 Kid string
325 Key []byte
326}
327
328// relayStagedMasters parses CARLOS_RELAY_MASTERS_<REGION> — read from the
329// process environment AT USE TIME rather than cached at boot, so an operator
330// who stages a master and restarts nothing still has it available to the next
331// rotate.
332//
333// Every defect is a refusal naming the variable: this is key material, and a
334// half-parsed list would silently issue from the wrong master.
335func relayStagedMasters(region string) ([]relayStagedMaster, error) {
336 name := relayMastersEnv(region)
337 raw := strings.TrimSpace(os.Getenv(name))
338 if raw == "" {
339 return nil, fmt.Errorf("this console holds no master for region %s (%s is unset) — ask the operator to stage one", region, name)
340 }
341 var out []relayStagedMaster
342 for _, pair := range strings.Split(raw, ",") {
343 pair = strings.TrimSpace(pair)
344 if pair == "" {
345 continue
346 }
347 kid, keyB64, ok := strings.Cut(pair, ":")
348 if !ok {
349 return nil, fmt.Errorf("%s holds an entry that is not <kid>:<base64 key>", name)
350 }
351 kid = strings.TrimSpace(kid)
352 if !relay.ValidKid(kid) {
353 return nil, fmt.Errorf("%s holds an invalid kid %q (1-8 lowercase letters or digits)", name, kid)
354 }
355 key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(keyB64))
356 if err != nil || len(key) != relay.MasterKeyLen {
357 return nil, fmt.Errorf("%s: master %s must be base64 of %d bytes", name, kid, relay.MasterKeyLen)
358 }
359 if slices.ContainsFunc(out, func(m relayStagedMaster) bool { return m.Kid == kid }) {
360 return nil, fmt.Errorf("%s names kid %s twice", name, kid)
361 }
362 out = append(out, relayStagedMaster{Kid: kid, Key: key})
363 }
364 if len(out) == 0 {
365 return nil, fmt.Errorf("%s is set but holds no <kid>:<base64 key> entry", name)
366 }
367 return out, nil
368}
369
370// relayIssuers reads the issuer pointer with its etag. Absent is an empty
371// map, not an error: a deployment that has never rotated has never written
372// one, and the fallback below (the first staged master) is what it means.
373func (s *Server) relayIssuers(ctx context.Context) (map[string]string, string, error) {
374 body, etag, err := s.cfg.Releases.Get(ctx, relayIssuerKey)
375 if errors.Is(err, os.ErrNotExist) {
376 return map[string]string{}, "", nil
377 }
378 if err != nil {
379 return nil, "", err
380 }
381 out := map[string]string{}
382 if err := json.Unmarshal(body, &out); err != nil {
383 return nil, "", fmt.Errorf("console: %s: %w", relayIssuerKey, err)
384 }
385 return out, etag, nil
386}
387
388// relayMasters answers which master a region ISSUES from right now: the kid
389// the issuer pointer names, or — when the pointer says nothing about this
390// region — the FIRST entry in the console's variable, which is what a
391// deployment that has never rotated has.
392//
393// A pointer naming a kid this console does not hold is an error rather than a
394// fallback. It means the operator moved the pointer without staging the key
395// here (or removed the key too early), and issuing from a different master
396// than the pointer names would mint credentials every relay refuses.
397func (s *Server) relayMasters(ctx context.Context, region string) (string, []byte, error) {
398 staged, err := relayStagedMasters(region)
399 if err != nil {
400 return "", nil, err
401 }
402 issuers, _, err := s.relayIssuers(ctx)
403 if err != nil {
404 return "", nil, err
405 }
406 kid, ok := issuers[region]
407 if !ok || kid == "" {
408 return staged[0].Kid, staged[0].Key, nil
409 }
410 for _, m := range staged {
411 if m.Kid == kid {
412 return m.Kid, m.Key, nil
413 }
414 }
415 return "", nil, fmt.Errorf("%s names kid %s as region %s's issuer, but %s does not hold it",
416 relayIssuerKey, kid, region, relayMastersEnv(region))
417}
418
419// relayRegionMasters gathers the issuing master for each of an app's regions
420// — everything relay.BuildConfig needs, and nothing it does not.
421func (s *Server) relayRegionMasters(ctx context.Context, regions []string) ([]relay.RegionMaster, error) {
422 out := make([]relay.RegionMaster, 0, len(regions))
423 for _, r := range regions {
424 kid, master, err := s.relayMasters(ctx, r)
425 if err != nil {
426 return nil, err
427 }
428 out = append(out, relay.RegionMaster{Region: r, Kid: kid, Master: master})
429 }
430 return out, nil
431}
432
433// ---- regions, hostnames, and the config value -------------------------------
434
435// relayRegions is the deployment's region list, normalized and deduped, in
436// the operator's order.
437func (s *Server) relayRegions() []string {
438 var out []string
439 for _, r := range s.cfg.RelayRegions {
440 r = strings.ToLower(strings.TrimSpace(r))
441 if r != "" && !slices.Contains(out, r) {
442 out = append(out, r)
443 }
444 }
445 return out
446}
447
448// relayDomain is the domain relay hostnames hang off. CARLOS_PLATFORM_DOMAIN
449// when the operator set it; otherwise the apps domain with a leading "apps."
450// label stripped, which is the one shape this console can derive safely
451// (apps.oncarlos.com -> oncarlos.com; oncarlos.com -> oncarlos.com).
452func (s *Server) relayDomain() string {
453 if d := strings.ToLower(strings.TrimSpace(s.cfg.PlatformDomain)); d != "" {
454 return d
455 }
456 d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
457 if label, rest, ok := strings.Cut(d, "."); ok && label == "apps" {
458 return rest
459 }
460 return d
461}
462
463// relayURLs is the ICE server list for one region (spec §2): the STUN
464// listener and its two TURN transports, all on one port.
465func (s *Server) relayURLs(region string) []string {
466 host := "relay." + region + "." + s.relayDomain() + ":3478"
467 return []string{
468 "stun:" + host,
469 "turn:" + host + "?transport=udp",
470 "turn:" + host + "?transport=tcp",
471 }
472}
473
474// relayReady is the three configuration facts every operation needs before it
475// touches anything: a bucket to write, a region to issue for, and a key to
476// seal with. Checked together, and BEFORE the first write, for storeSealKey's
477// reason: a key minted without delivery is worse than no key at all.
478func (s *Server) relayReady() error {
479 if s.cfg.Releases == nil {
480 return errRelayNoBucket
481 }
482 if len(s.relayRegions()) == 0 {
483 return errRelayNoRegions
484 }
485 if s.cfg.SecretsPub == "" {
486 return errRelayNoSealKey
487 }
488 return nil
489}
490
491// relayResolveRegions turns an app's request into the region list its record
492// carries: every armed region in the CONSOLE's order when the caller named
493// none, or the named subset — still in the console's order, since that order
494// is the one every browser tries them in.
495func (s *Server) relayResolveRegions(want []string) ([]string, error) {
496 armed := s.relayRegions()
497 if len(armed) == 0 {
498 return nil, errRelayNoRegions
499 }
500 var asked []string
501 for _, r := range want {
502 if r = strings.ToLower(strings.TrimSpace(r)); r != "" {
503 asked = append(asked, r)
504 }
505 }
506 if len(asked) == 0 {
507 return armed, nil
508 }
509 for _, r := range asked {
510 if !slices.Contains(armed, r) {
511 return nil, fmt.Errorf("this deployment runs no relay in %s — armed regions are %s",
512 r, strings.Join(armed, ", "))
513 }
514 }
515 out := make([]string, 0, len(asked))
516 for _, r := range armed {
517 if slices.Contains(asked, r) {
518 out = append(out, r)
519 }
520 }
521 return out, nil
522}
523
524// relayConfigValue builds the CARLOS_RELAY value for a record and the
525// username suffixes it issues, from the masters each region issues from now.
526func (s *Server) relayConfigValue(ctx context.Context, acct, app string, rec *relay.Record) (string, map[string]string, error) {
527 masters, err := s.relayRegionMasters(ctx, rec.Regions)
528 if err != nil {
529 return "", nil, err
530 }
531 return relay.BuildConfig(acct, app, rec, masters, s.relayURLs)
532}
533
534// relayExpectedSuffixes is what an enabled record SHOULD have issued, given
535// the masters in force right now — the reconcile's comparison value.
536//
537// Suffixes, not the sealed value: the console cannot open an envelope it
538// wrote, and two seals of identical plaintext differ anyway (each carries a
539// fresh ephemeral key). The suffix carries account, app, incarnation and kid
540// — every identity the value derives from — so a suffix set that matches is
541// a value that matches, and a rotation, a re-enable or a region change all
542// move it.
543func (s *Server) relayExpectedSuffixes(ctx context.Context, acct, app string, rec *relay.Record) (map[string]string, error) {
544 masters, err := s.relayRegionMasters(ctx, rec.Regions)
545 if err != nil {
546 return nil, err
547 }
548 out := make(map[string]string, len(masters))
549 for _, m := range masters {
550 out[m.Region] = relay.Suffix(acct, app, rec.Incarnation, m.Kid)
551 }
552 return out, nil
553}
554
555// relayEnvelopeDigest identifies a sealed envelope without opening it:
556// hex(sha256) of the envelope string as it sits in the bundle. It is what
557// makes "the bundle still holds exactly what we wrote" checkable by a console
558// that holds no decryption key — see relay.Record.IssuedDigest.
559func relayEnvelopeDigest(envelope string) string {
560 sum := sha256.Sum256([]byte(envelope))
561 return hex.EncodeToString(sum[:])
562}
563
564// relayConfigVar reads the app's delivered CARLOS_RELAY entry, if any.
565func (s *Server) relayConfigVar(ctx context.Context, acct, app string) (appconfig.Var, bool, error) {
566 m, err := appconfig.Get(ctx, s.cfg.Releases, acct, app)
567 if err != nil {
568 return appconfig.Var{}, false, err
569 }
570 v, ok := m.Vars[relayVar]
571 return v, ok, nil
572}
573
574// relayApplyConfig derives the app's key, seals it and writes it into the
575// DEFAULT config bundle, recording on the record which identities it just
576// issued. It mutates rec.IssuedSuffixes; the caller writes the record.
577func (s *Server) relayApplyConfig(ctx context.Context, acct, app string, rec *relay.Record) error {
578 if err := s.requireRelayLease(); err != nil {
579 return err
580 }
581 if s.cfg.SecretsPub == "" {
582 return errRelayNoSealKey
583 }
584 value, suffixes, err := s.relayConfigValue(ctx, acct, app, rec)
585 if err != nil {
586 return err
587 }
588 pub, err := appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY")
589 if err != nil {
590 return err
591 }
592 env, err := appconfig.Seal(pub, appconfig.Binding{Account: acct, App: app}, []byte(value))
593 if err != nil {
594 return err
595 }
596 if err := s.requireRelayLease(); err != nil {
597 return err
598 }
599 if _, err := appconfig.Apply(ctx, s.cfg.Releases, acct, app,
600 map[string]appconfig.Var{relayVar: {Kind: "secret", Envelope: env}}, nil); err != nil {
601 return err
602 }
603 rec.IssuedSuffixes = suffixes
604 rec.IssuedDigest = relayEnvelopeDigest(env)
605 return nil
606}
607
608// relayUnsetConfig removes CARLOS_RELAY from the app's default bundle, and
609// reports whether there was anything to remove. It READS first because an
610// unset-only Apply against an app with no bundle at all is an error
611// (appconfig.applyAt), and "this app has no config" is not a failed disable.
612func (s *Server) relayUnsetConfig(ctx context.Context, acct, app string) (bool, error) {
613 if err := s.requireRelayLease(); err != nil {
614 return false, err
615 }
616 _, present, err := s.relayConfigVar(ctx, acct, app)
617 if err != nil || !present {
618 return false, err
619 }
620 if err := s.requireRelayLease(); err != nil {
621 return false, err
622 }
623 n, err := appconfig.Apply(ctx, s.cfg.Releases, acct, app, nil, []string{relayVar})
624 return n > 0, err
625}
626
627// ---- the record walk --------------------------------------------------------
628
629// relayAppLock is the per-app mutex every operation and the reconcile's
630// per-record work takes. The lease keeps two CONSOLES apart; this keeps two
631// handlers inside one console apart, which the lease says nothing about.
632func (s *Server) relayAppLock(acct, app string) *sync.Mutex {
633 v, _ := s.relayAppLocks.LoadOrStore(acct+"/"+app, &sync.Mutex{})
634 return v.(*sync.Mutex)
635}
636
637// relayPutRecord is relay.PutRecord behind the lease check. EVERY record
638// write in this file goes through it rather than calling PutRecord directly:
639// a walk is several writes long, the lease can lapse between any two of them,
640// and spec §5's rule is a check before each bucket write, not one per
641// operation.
642func (s *Server) relayPutRecord(ctx context.Context, acct, app string, rec *relay.Record, etag string) (string, error) {
643 if err := s.requireRelayLease(); err != nil {
644 return "", err
645 }
646 return relay.PutRecord(ctx, s.cfg.Releases, acct, app, rec, etag)
647}
648
649// relayAppRecord is one app's record with the coordinates that name it and
650// the etag it was read at — the unit both the publish listing and the
651// reconcile walk over.
652type relayAppRecord struct {
653 Account string
654 App string
655 Record *relay.Record
656 Etag string
657}
658
659// relayListRecords walks every app in the bucket and returns the ones that
660// have a relay record. Two delimiter-aware listings (accounts, then their
661// apps) rather than one flat List of the whole `apps/` prefix: a deployment's
662// object count is dominated by releases, and this must not read them.
663//
664// A record that will not parse is logged and skipped, not fatal. The
665// alternative is one corrupt object stopping the aggregate from being
666// published for every other app in the deployment.
667func (s *Server) relayListRecords(ctx context.Context) ([]relayAppRecord, error) {
668 accts, err := s.cfg.Releases.ListDirs(ctx, "apps/")
669 if err != nil {
670 return nil, err
671 }
672 var out []relayAppRecord
673 for _, acctPrefix := range accts {
674 acct := strings.Trim(strings.TrimPrefix(acctPrefix, "apps/"), "/")
675 if !release.ValidAccount(acct) {
676 continue
677 }
678 apps, err := s.cfg.Releases.ListDirs(ctx, acctPrefix)
679 if err != nil {
680 return nil, err
681 }
682 for _, appPrefix := range apps {
683 app := strings.Trim(strings.TrimPrefix(appPrefix, acctPrefix), "/")
684 if !release.ValidApp(app) {
685 continue
686 }
687 rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, acct, app)
688 if err != nil {
689 log.Printf("console: relay record %s/%s: %v", acct, app, err)
690 continue
691 }
692 if rec == nil {
693 continue
694 }
695 out = append(out, relayAppRecord{Account: acct, App: app, Record: rec, Etag: etag})
696 }
697 }
698 sort.Slice(out, func(i, j int) bool {
699 if out[i].Account != out[j].Account {
700 return out[i].Account < out[j].Account
701 }
702 return out[i].App < out[j].App
703 })
704 return out, nil
705}
706
707// relayPublishOnce is ONE attempt at spec §5's publish sequence, from an etag
708// the CALLER read before it did anything else: list every record, build the
709// authority from the enabled ones whatever their phase, and write with that
710// etag. A conflict comes straight back — this is the function the retry loop
711// wraps, and the function a test can hand a deliberately stale etag.
712//
713// The etag being an argument is the whole design. Read after the listing, it
714// would let a disable that landed mid-listing be overwritten by an aggregate
715// built before it; read before, that same disable makes this write fail.
716func (s *Server) relayPublishOnce(ctx context.Context, etag string) error {
717 if err := s.requireRelayLease(); err != nil {
718 return err
719 }
720 recs, err := s.relayListRecords(ctx)
721 if err != nil {
722 return err
723 }
724 a := relay.Authority{WrittenAt: s.relayTime().UTC()}
725 for _, r := range recs {
726 if !r.Record.Enabled {
727 continue
728 }
729 a.Apps = append(a.Apps, relay.AuthorityApp{
730 Account: r.Account,
731 App: r.App,
732 Incarnation: r.Record.Incarnation,
733 Regions: r.Record.Regions,
734 })
735 }
736 body, err := json.MarshalIndent(a, "", " ")
737 if err != nil {
738 return err
739 }
740 if s.relayGated() {
741 return s.relayPublishGated(ctx, etag, append(body, '\n'))
742 }
743 if err := s.requireRelayLease(); err != nil {
744 return err
745 }
746 _, err = s.cfg.Releases.Put(ctx, relay.AuthorityKey, append(body, '\n'), etag)
747 return err
748}
749
750// relayPublishAggregate publishes control/relay.json: read the etag FIRST,
751// then the whole sequence, restarting it on conflict, four attempts. Then a
752// serial bump so relays notice in seconds.
753//
754// The bump is a LATENCY HINT, not the lease (spec §5): a relay that never
755// sees it still re-reads on its own timer, so a failed bump is a log line and
756// a successful publish, never a failed operation.
757func (s *Server) relayPublishAggregate(ctx context.Context) error {
758 if err := s.requireRelayLease(); err != nil {
759 return err
760 }
761 var lastErr error
762 for attempt := 0; attempt < 4; attempt++ {
763 _, etag, err := s.cfg.Releases.Get(ctx, relay.AuthorityKey)
764 switch {
765 case errors.Is(err, os.ErrNotExist):
766 etag = ""
767 case err != nil:
768 return err
769 }
770 err = s.relayPublishOnce(ctx, etag)
771 if err == nil {
772 if err := release.BumpSerial(ctx, s.cfg.Releases, s.relayTime()); err != nil {
773 log.Printf("console: relay serial bump (relays converge on their own timer anyway): %v", err)
774 }
775 return nil
776 }
777 if !errors.Is(err, release.ErrCASConflict) {
778 return err
779 }
780 lastErr = err
781 }
782 return fmt.Errorf("publishing %s: %w", relay.AuthorityKey, lastErr)
783}
784
785// ---- route delivery ---------------------------------------------------------
786
787// relayRoute is one of an app's routes as the delivery check sees it: enough
788// to say which of spec §5's three shapes it is, and nothing else.
789type relayRoute struct {
790 Host string
791 Kind string // instance | service | static ("" reads as instance)
792 Addr string // a TCP upstream this platform does not own
793 Socket string
794 Unit string
795 Environment string
796}
797
798// relayAppRoutes enumerates an app's routes from both places the console can
799// see them: the instance records it writes itself, and — for routes an
800// operator added directly on a box — the platform registry.
801//
802// Records first, registry second, deduped on host: a route that exists in
803// both is one route, and the record is the authority on its shape.
804func (s *Server) relayAppRoutes(ctx context.Context, acct, app string) ([]relayRoute, error) {
805 recs, truncated, err := provision.ListRecords(ctx, s.cfg.Releases, acct, app, relayRouteMax)
806 if err != nil {
807 return nil, err
808 }
809 // A truncated listing is a REFUSAL, not a partial answer. The one caller
810 // that matters is the delivery check, and a check that only saw the first
811 // 500 routes would enable the relay over an undeliverable route it never
812 // read — the exact state spec §5 refuses because it blocks retirement
813 // forever, arrived at silently.
814 if truncated {
815 return nil, fmt.Errorf("%s/%s has more than %d instance records — the relay cannot check them all, so it will not act on a partial list",
816 acct, app, relayRouteMax)
817 }
818 var out []relayRoute
819 seen := map[string]bool{}
820 for _, rec := range recs {
821 seen[rec.Host] = true
822 // Fold Addrs the way the registry branch does (routeAddr, below). A
823 // record picks ONE upstream shape — the legacy Addr or the Addrs set,
824 // never both (provision.ValidateShape) — so a multi-upstream external
825 // route carries Addr == "", and "no address" is exactly what the
826 // delivery check reads as deliverable. Left unfolded, such a route
827 // slips past the refusal enable exists to make, shows as shape
828 // "unknown" in status, and can be acknowledged with `carlos relay
829 // adopted`. What every caller here asks is "is there a process at the
830 // far end this platform never spawned", and the first upstream
831 // answers that as well as five.
832 addr := rec.Addr
833 if all := rec.AddrsOrDefault(); len(all) > 0 {
834 addr = all[0]
835 }
836 out = append(out, relayRoute{Host: rec.Host, Kind: rec.Kind, Addr: addr,
837 Unit: rec.Unit, Environment: rec.Environment})
838 }
839 if s.cfg.Registry == "" {
840 return out, nil
841 }
842 reg, err := openRegistry(s.cfg.Registry)
843 if err != nil {
844 // A registry this console cannot open is not a reason to refuse an
845 // enable: the records above are the platform's own writing, and the
846 // registry copy is the belt to their braces.
847 log.Printf("console: relay routes %s/%s: registry: %v", acct, app, err)
848 return out, nil
849 }
850 routes, err := reg.List()
851 if err != nil {
852 log.Printf("console: relay routes %s/%s: registry list: %v", acct, app, err)
853 return out, nil
854 }
855 for _, rt := range routes {
856 if rt.App != app || release.RouteAccount(rt) != acct || seen[rt.Host] {
857 continue
858 }
859 seen[rt.Host] = true
860 out = append(out, relayRoute{Host: rt.Host, Kind: rt.Kind, Addr: routeAddr(rt),
861 Socket: rt.Socket, Unit: rt.Unit, Environment: rt.Environment})
862 }
863 sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
864 return out, nil
865}
866
867// routeAddr folds a route's two upstream shapes into "does it have one".
868func routeAddr(rt registry.Route) string {
869 if rt.Addr != "" {
870 return rt.Addr
871 }
872 return rt.Addrs
873}
874
875// relayExecDelivery answers whether a route's BOUND bundle opts into file
876// delivery, applying the same precedence appconfig's own resolver does
877// (#195): the route's named bundle when it states something, the app default
878// otherwise. Absence is inheritance, never "off".
879func (s *Server) relayExecDelivery(ctx context.Context, acct, app, envName string) (bool, error) {
880 if envName != "" && appconfig.ValidEnvironmentName(envName) {
881 m, err := appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct, app, envName)
882 if err != nil {
883 return false, err
884 }
885 if m.ExecDelivery != "" {
886 return m.ExecDelivery == appconfig.ExecDeliveryFile, nil
887 }
888 }
889 m, err := appconfig.Get(ctx, s.cfg.Releases, acct, app)
890 if err != nil {
891 return false, err
892 }
893 return m.ExecDelivery == appconfig.ExecDeliveryFile, nil
894}
895
896// relayNoDelivery answers the ONE question three callers need about a route:
897// can the config sync put a relay key in front of its process at all?
898//
899// The three shapes: an `instance` route is the platform's own — it has a
900// socket or a unit and the config sync restarts it, so it is deliverable. A
901// `static` route runs no process at all and is ignored. Everything else is
902// deliverable only if it has a socket or a unit to bounce, or its bundle opts
903// into file delivery — a bare TCP address whose process this platform never
904// spawned is exactly the route the spec refuses.
905//
906// It lives in one function because the three callers must agree: enable
907// REFUSES such a route (below), `carlos relay status` NAMES it (relayReply,
908// apirelay.go), and `carlos relay adopted` refuses to record a cutover for
909// one (relayAdopted). Three copies of this switch would eventually let a
910// member acknowledge a key that had never been written for them.
911func (s *Server) relayNoDelivery(ctx context.Context, acct, app string, rt relayRoute) (bool, error) {
912 switch {
913 case rt.Kind == "static",
914 rt.Kind == "" || rt.Kind == "instance",
915 rt.Socket != "" || rt.Unit != "",
916 rt.Addr == "":
917 return false, nil
918 }
919 on, err := s.relayExecDelivery(ctx, acct, app, rt.Environment)
920 return !on, err
921}
922
923// relayDeliverable is enable's second refusal (spec §5, step 2): a route that
924// could never receive a rotated key must not be enabled, because it would
925// block every future retirement forever and nobody would find out until the
926// rotation stalled.
927func (s *Server) relayDeliverable(ctx context.Context, acct, app string) error {
928 routes, err := s.relayAppRoutes(ctx, acct, app)
929 if err != nil {
930 return err
931 }
932 for _, rt := range routes {
933 none, err := s.relayNoDelivery(ctx, acct, app, rt)
934 if err != nil {
935 return err
936 }
937 if none {
938 return fmt.Errorf("route %s is a TCP address this platform does not own and its config bundle does not opt into file delivery, "+
939 "so it could never pick up a rotated relay key — set it with `carlos env exec-delivery file` first: %w", rt.Host, errRelayUndelivered)
940 }
941 }
942 return nil
943}
944
945// ---- the operations ---------------------------------------------------------
946
947// relayEnable turns the relay on for one app and delivers its key (spec §5,
948// steps 3-7). The transition table decides what happens to the record; the
949// walk after it is always the same three steps, each recorded in `phase` so
950// the reconcile can finish it.
951//
952// A repeat enable of a FINISHED, identically-configured record is the one
953// path that writes nothing at all: no op bump, no config write, no new
954// envelope. Anything else — an unfinished operation, a region change, a
955// re-enable after a disable — walks.
956func (s *Server) relayEnable(ctx context.Context, acct *Account, app string, regions []string, by string) (*relay.Record, error) {
957 if err := s.relayReady(); err != nil {
958 return nil, err
959 }
960 want, err := s.relayResolveRegions(regions)
961 if err != nil {
962 return nil, err
963 }
964 mu := s.relayAppLock(acct.Sqid, app)
965 mu.Lock()
966 defer mu.Unlock()
967 var out *relay.Record
968 err = s.relayAppMutation(ctx, acct.Sqid, app, "relay-enable", func(ctx context.Context) error {
969 var err error
970 out, err = s.relayEnableWalk(ctx, acct, app, want, by)
971 return err
972 })
973 if err != nil {
974 return nil, err
975 }
976 return out, nil
977}
978
979// relayEnableWalk is relayEnable's body under the app's lock and permit.
980func (s *Server) relayEnableWalk(ctx context.Context, acct *Account, app string, want []string, by string) (*relay.Record, error) {
981 if err := s.requireRelayLease(); err != nil {
982 return nil, err
983 }
984 if err := s.relayDeliverable(ctx, acct.Sqid, app); err != nil {
985 return nil, err
986 }
987 rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app)
988 if err != nil {
989 return nil, err
990 }
991 now := s.relayTime().UTC()
992 switch {
993 case rec != nil && rec.Enabled && rec.Phase == relayPhaseDone && slices.Equal(rec.Regions, want):
994 return rec, nil
995 case rec != nil && rec.Enabled && slices.Equal(rec.Regions, want):
996 // An unfinished operation RESUMES rather than starting a new one:
997 // same incarnation, same op, and the walk below repeats the steps it
998 // may already have done, all of which are idempotent.
999 case rec != nil && rec.Enabled:
1000 // A region change keeps the incarnation — the app's identity did not
1001 // move, only where it may connect (spec §5's transition table).
1002 rec.Regions = want
1003 rec.Op++
1004 rec.Phase = relayPhaseConfig
1005 default:
1006 op := int64(0)
1007 if rec != nil {
1008 op = rec.Op
1009 }
1010 // A NEW incarnation for a first enable and for every re-enable after
1011 // a disable, so a key from before the disable can never be mistaken
1012 // for a live one. Cutovers go with it: they acknowledged identities
1013 // that no longer exist.
1014 rec = &relay.Record{
1015 Enabled: true, Incarnation: relay.NewIncarnation(), Regions: want,
1016 Op: op + 1, Phase: relayPhaseConfig, EnabledAt: now,
1017 }
1018 }
1019 if etag, err = s.relayPutRecord(ctx, acct.Sqid, app, rec, etag); err != nil {
1020 return nil, err
1021 }
1022 if err := s.relayApplyConfig(ctx, acct.Sqid, app, rec); err != nil {
1023 return nil, err
1024 }
1025 rec.Phase = relayPhaseAggregate
1026 if etag, err = s.relayPutRecord(ctx, acct.Sqid, app, rec, etag); err != nil {
1027 return nil, err
1028 }
1029 if err := s.relayPublishAggregate(ctx); err != nil {
1030 return nil, err
1031 }
1032 rec.Phase = relayPhaseDone
1033 if _, err = s.relayPutRecord(ctx, acct.Sqid, app, rec, etag); err != nil {
1034 return nil, err
1035 }
1036 log.Printf("console: relay enabled %s/%s in %s by %s (incarnation %s, op %d)",
1037 acct.Sqid, app, strings.Join(rec.Regions, ","), by, rec.Incarnation, rec.Op)
1038 return rec, nil
1039}
1040
1041// relayDisable is the same walk in reverse (spec §5): record the intent,
1042// remove the variable, publish an aggregate without the app, mark done. The
1043// incarnation stays in the record — it is what `status` reports the pending
1044// disable against — and a later enable draws a new one.
1045func (s *Server) relayDisable(ctx context.Context, acct *Account, app string, by string) (*relay.Record, error) {
1046 if s.cfg.Releases == nil {
1047 return nil, errRelayNoBucket
1048 }
1049 mu := s.relayAppLock(acct.Sqid, app)
1050 mu.Lock()
1051 defer mu.Unlock()
1052 var out *relay.Record
1053 err := s.relayAppMutation(ctx, acct.Sqid, app, "relay-disable", func(ctx context.Context) error {
1054 var err error
1055 out, err = s.relayDisableWalk(ctx, acct, app, by)
1056 return err
1057 })
1058 if err != nil {
1059 return nil, err
1060 }
1061 return out, nil
1062}
1063
1064// relayDisableWalk is relayDisable's body under the app's lock and permit.
1065func (s *Server) relayDisableWalk(ctx context.Context, acct *Account, app string, by string) (*relay.Record, error) {
1066 if err := s.requireRelayLease(); err != nil {
1067 return nil, err
1068 }
1069 rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app)
1070 if err != nil {
1071 return nil, err
1072 }
1073 if rec == nil {
1074 return nil, errRelayNotEnabled
1075 }
1076 if !rec.Enabled && rec.Phase == relayPhaseDone {
1077 return rec, nil // already off and finished: nothing owed
1078 }
1079 if rec.Enabled {
1080 rec.Enabled = false
1081 rec.Op++
1082 rec.Phase = relayPhaseConfig
1083 rec.DisabledAt = s.relayTime().UTC()
1084 // Nothing is issued any more. Cutovers stay: they are a historical
1085 // record of what a member acknowledged, and a re-enable clears them.
1086 rec.IssuedSuffixes = nil
1087 rec.IssuedDigest = ""
1088 if etag, err = s.relayPutRecord(ctx, acct.Sqid, app, rec, etag); err != nil {
1089 return nil, err
1090 }
1091 }
1092 if _, err := s.relayUnsetConfig(ctx, acct.Sqid, app); err != nil {
1093 return nil, err
1094 }
1095 rec.Phase = relayPhaseAggregate
1096 if etag, err = s.relayPutRecord(ctx, acct.Sqid, app, rec, etag); err != nil {
1097 return nil, err
1098 }
1099 if err := s.relayPublishAggregate(ctx); err != nil {
1100 return nil, err
1101 }
1102 rec.Phase = relayPhaseDone
1103 if _, err = s.relayPutRecord(ctx, acct.Sqid, app, rec, etag); err != nil {
1104 return nil, err
1105 }
1106 log.Printf("console: relay disabled %s/%s by %s (op %d)", acct.Sqid, app, by, rec.Op)
1107 return rec, nil
1108}
1109
1110// relayAdopted records a member's acknowledgement that a TCP-address route
1111// they restarted by hand now runs the identities the console last issued
1112// (spec §5, step 5).
1113//
1114// The identities are stamped from the record AT THIS MOMENT, not named by the
1115// caller, and they are what makes the ack meaningful: an acknowledgement
1116// given before a later rotation names the older suffixes and so does not
1117// count for the newer ones. One cutover per host — a second ack for the same
1118// route replaces the first.
1119func (s *Server) relayAdopted(ctx context.Context, acct *Account, app, host, by string) (*relay.Record, error) {
1120 if s.cfg.Releases == nil {
1121 return nil, errRelayNoBucket
1122 }
1123 mu := s.relayAppLock(acct.Sqid, app)
1124 mu.Lock()
1125 defer mu.Unlock()
1126 var out *relay.Record
1127 err := s.relayAppMutation(ctx, acct.Sqid, app, "relay-adopted", func(ctx context.Context) error {
1128 var err error
1129 out, err = s.relayAdoptedWalk(ctx, acct, app, host, by)
1130 return err
1131 })
1132 if err != nil {
1133 return nil, err
1134 }
1135 return out, nil
1136}
1137
1138// relayAdoptedWalk is relayAdopted's body under the app's lock and permit.
1139func (s *Server) relayAdoptedWalk(ctx context.Context, acct *Account, app, host, by string) (*relay.Record, error) {
1140 if err := s.requireRelayLease(); err != nil {
1141 return nil, err
1142 }
1143 rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app)
1144 if err != nil {
1145 return nil, err
1146 }
1147 if rec == nil || !rec.Enabled {
1148 return nil, errRelayNotEnabled
1149 }
1150 // The host must be one of the app's own routes. Cheap, and it is what
1151 // keeps the record bounded: without it, an ack naming anything at all
1152 // appends a cutover forever, and `status` would report identities against
1153 // routes that do not exist.
1154 routes, err := s.relayAppRoutes(ctx, acct.Sqid, app)
1155 if err != nil {
1156 return nil, err
1157 }
1158 i := slices.IndexFunc(routes, func(rt relayRoute) bool { return rt.Host == host })
1159 if i < 0 {
1160 return nil, fmt.Errorf("%s is not a route of this app: %w", host, errRelayUnknownHost)
1161 }
1162 // A route the config sync cannot reach has been sent NOTHING to adopt, so
1163 // an acknowledgement of it would be false — and worse than merely false:
1164 // a cutover is what the retirement of an old master is eventually checked
1165 // against, and one recorded here would say a process has a key it has
1166 // never seen. Such a route can only appear after an enable (enable itself
1167 // refuses it), which is exactly when a member might reach for this verb
1168 // to make the warning go away.
1169 if none, err := s.relayNoDelivery(ctx, acct.Sqid, app, routes[i]); err != nil {
1170 return nil, err
1171 } else if none {
1172 return nil, fmt.Errorf("%s has no config delivery, so no relay key has been written for it to adopt — "+
1173 "opt it into file delivery with `carlos env exec-delivery file` or move it to a unit: %w", host, errRelayUndelivered)
1174 }
1175 // And the route must be the ONE shape whose cutover a member establishes.
1176 // Spec §5, step 5: a unit or socket-exec route is restarted by the config
1177 // sync, so its cutover is the ack that sync writes — an observed fact,
1178 // with the identities the host actually rendered. Accepting a member's
1179 // word for such a route would stamp the identities the console ISSUED
1180 // over that observation, and a route whose restart silently failed would
1181 // read as cut over. The member verb exists only for the process this
1182 // platform cannot restart.
1183 switch shape := relayRouteShape(routes[i]); shape {
1184 case relayShapeFile: // the one shape a member acknowledges
1185 case relayShapeUnit, relayShapeSocket:
1186 return nil, fmt.Errorf("%s is a %s route: the platform restarts this route itself; its adoption is observed, not declared — "+
1187 "`carlos relay status` reports it as adopted once the config sync acknowledges the write: %w",
1188 host, shape, errRelayNotMemberRoute)
1189 default:
1190 return nil, fmt.Errorf("%s has no unit, no socket and no address, so this console cannot say what would run a relay key, "+
1191 "let alone that it has: %w", host, errRelayNotMemberRoute)
1192 }
1193 cut := relay.RouteCutover{
1194 Host: host,
1195 Suffixes: maps.Clone(rec.IssuedSuffixes),
1196 At: s.relayTime().UTC(),
1197 By: by,
1198 }
1199 replaced := false
1200 for i := range rec.Cutovers {
1201 if rec.Cutovers[i].Host == host {
1202 rec.Cutovers[i] = cut
1203 replaced = true
1204 break
1205 }
1206 }
1207 if !replaced {
1208 rec.Cutovers = append(rec.Cutovers, cut)
1209 }
1210 if _, err := s.relayPutRecord(ctx, acct.Sqid, app, rec, etag); err != nil {
1211 return nil, err
1212 }
1213 return rec, nil
1214}
1215
1216// ---- rotation ---------------------------------------------------------------
1217
1218// relayRotateResult is what `carlos relay rotate` prints: which kid the
1219// region now issues from, how many apps were re-issued, and the earliest the
1220// old master may be retired.
1221type relayRotateResult struct {
1222 Region string
1223 Kid string
1224 Apps int
1225 RetireNoEarlierThan time.Time
1226}
1227
1228// relayHeartbeats reads every armed relay's heartbeat.
1229func (s *Server) relayHeartbeats(ctx context.Context) ([]relay.Heartbeat, error) {
1230 keys, err := s.cfg.Releases.List(ctx, relayAckPrefix)
1231 if err != nil {
1232 return nil, err
1233 }
1234 var out []relay.Heartbeat
1235 for _, key := range keys {
1236 if !strings.HasSuffix(key, ".json") {
1237 continue
1238 }
1239 body, _, err := s.cfg.Releases.Get(ctx, key)
1240 if err != nil {
1241 if errors.Is(err, os.ErrNotExist) {
1242 continue
1243 }
1244 return nil, err
1245 }
1246 var hb relay.Heartbeat
1247 if err := json.Unmarshal(body, &hb); err != nil {
1248 log.Printf("console: relay heartbeat %s: %v", key, err)
1249 continue
1250 }
1251 out = append(out, hb)
1252 }
1253 return out, nil
1254}
1255
1256// relayConfigAcks reads every per-route config-adoption acknowledgement
1257// under configAckPrefix whose HOST — a key's basename, ConfigAckKey's own
1258// "<box>/<host>.json" shape — is in hosts. There is no per-host box list
1259// to narrow the LISTING by, the same limit relayHeartbeats accepts for
1260// control/relay-ack/, so this still lists every key under the prefix; but
1261// hosts filters that listing down to this app's own routes before a
1262// single Get is issued, so a status call costs one List plus a Get per
1263// THIS APP's route, never a Get per ack in the whole fleet (review
1264// finding 2026-09-07 — relayReply used to pass every ack for every app on
1265// the deployment through this filter instead of narrowing the read
1266// itself).
1267func (s *Server) relayConfigAcks(ctx context.Context, hosts map[string]bool) ([]appconfig.ConfigAck, error) {
1268 keys, err := s.cfg.Releases.List(ctx, configAckPrefix)
1269 if err != nil {
1270 return nil, err
1271 }
1272 var out []appconfig.ConfigAck
1273 for _, key := range keys {
1274 if !strings.HasSuffix(key, ".json") {
1275 continue
1276 }
1277 host := strings.TrimSuffix(key, ".json")
1278 if idx := strings.LastIndexByte(host, '/'); idx >= 0 {
1279 host = host[idx+1:]
1280 }
1281 if !hosts[host] {
1282 continue
1283 }
1284 body, _, err := s.cfg.Releases.Get(ctx, key)
1285 if err != nil {
1286 if errors.Is(err, os.ErrNotExist) {
1287 continue
1288 }
1289 return nil, err
1290 }
1291 var a appconfig.ConfigAck
1292 if err := json.Unmarshal(body, &a); err != nil {
1293 log.Printf("console: relay config ack %s: %v", key, err)
1294 continue
1295 }
1296 out = append(out, a)
1297 }
1298 return out, nil
1299}
1300
1301// relayRotate moves a region's issuer pointer to the master the operator
1302// staged, then re-issues every app enabled in that region (spec §5, step 4).
1303//
1304// IT REFUSES UNTIL EVERY LIVE RELAY IN THE REGION HAS THE NEW KID. That gate
1305// is the whole safety of a rotation: issuing from a master a box has not
1306// loaded mints credentials that box refuses, and every call in the region
1307// fails until it catches up. A heartbeat older than five minutes is not an
1308// answer — a dead relay must not be able to consent by silence — so a region
1309// with no fresh heartbeat at all refuses too.
1310//
1311// The new kid is the LAST staged entry that is not the current issuer, which
1312// is where an operator following the runbook appends it.
1313func (s *Server) relayRotate(ctx context.Context, region string) (relayRotateResult, error) {
1314 var res relayRotateResult
1315 if err := s.relayReady(); err != nil {
1316 return res, err
1317 }
1318 region = strings.ToLower(strings.TrimSpace(region))
1319 if !slices.Contains(s.relayRegions(), region) {
1320 return res, fmt.Errorf("this deployment runs no relay in %s — armed regions are %s",
1321 region, strings.Join(s.relayRegions(), ", "))
1322 }
1323 if err := s.requireRelayLease(); err != nil {
1324 return res, err
1325 }
1326 staged, err := relayStagedMasters(region)
1327 if err != nil {
1328 return res, err
1329 }
1330 current, issuerEtag, err := s.relayIssuers(ctx)
1331 if err != nil {
1332 return res, err
1333 }
1334 curKid := current[region]
1335 if curKid == "" {
1336 curKid = staged[0].Kid
1337 }
1338 // The new kid is the LAST staged entry, full stop — never "the last one
1339 // that is not the issuer". After a rotation the variable holds
1340 // "<old>,<new>" with the pointer on <new> for the 28 hours before the old
1341 // one may be retired, and a second rotate in that window must refuse, not
1342 // walk the pointer BACK onto the master about to be retired and re-issue
1343 // every app under it.
1344 next := staged[len(staged)-1].Kid
1345 if next == curKid {
1346 return res, fmt.Errorf("no new master is staged for %s: the last entry in %s is already the issuer",
1347 region, relayMastersEnv(region))
1348 }
1349 hbs, err := s.relayHeartbeats(ctx)
1350 if err != nil {
1351 return res, err
1352 }
1353 now := s.relayTime()
1354 live := 0
1355 for _, hb := range hbs {
1356 if hb.Region != region || now.Sub(hb.ReadAt) > relayHeartbeatFresh {
1357 continue
1358 }
1359 live++
1360 if !slices.Contains(hb.Kids, next) {
1361 return res, fmt.Errorf("relay %s in %s has not loaded kid %s yet (it has %s) — add the line to /etc/carlos/relay-masters there and try again",
1362 hb.Box, region, next, strings.Join(hb.Kids, ","))
1363 }
1364 }
1365 if live == 0 {
1366 return res, fmt.Errorf("no relay in %s has heartbeated in the last %s — rotating now would issue keys nothing can verify",
1367 region, relayHeartbeatFresh)
1368 }
1369 // The pointer moves FIRST, under CAS, and every key derived after this
1370 // line uses the new master. An interrupted rotation therefore leaves a
1371 // moved pointer and some apps still on the old kid, which is exactly what
1372 // the reconcile converges — and why the old kid stays accepted for 28
1373 // hours.
1374 current[region] = next
1375 body, err := json.MarshalIndent(current, "", " ")
1376 if err != nil {
1377 return res, err
1378 }
1379 if err := s.requireRelayLease(); err != nil {
1380 return res, err
1381 }
1382 if _, err := s.cfg.Releases.Put(ctx, relayIssuerKey, append(body, '\n'), issuerEtag); err != nil {
1383 return res, err
1384 }
1385 recs, err := s.relayListRecords(ctx)
1386 if err != nil {
1387 return res, err
1388 }
1389 reissued := 0
1390 for _, r := range recs {
1391 if !r.Record.Enabled || !slices.Contains(r.Record.Regions, region) {
1392 continue
1393 }
1394 if err := s.relayReissue(ctx, r); err != nil {
1395 // One app that cannot be re-issued must not stop the rest: the
1396 // pointer has already moved, so every remaining app is one the
1397 // reconcile would otherwise have to catch five minutes later.
1398 log.Printf("console: relay rotate %s: %s/%s: %v", region, r.Account, r.App, err)
1399 continue
1400 }
1401 reissued++
1402 }
1403 log.Printf("console: relay rotated %s to kid %s, re-issued %d app(s)", region, next, reissued)
1404 return relayRotateResult{Region: region, Kid: next, Apps: reissued,
1405 RetireNoEarlierThan: now.Add(relayRetireAfter).UTC()}, nil
1406}
1407
1408// relayReissue is a rotation's per-app operation: op bumped, config
1409// re-applied from the master in force now, aggregate published, done. The
1410// aggregate does not CHANGE on a rotation (the incarnation is untouched), but
1411// it is published anyway because the phase walk is what makes the operation
1412// resumable, and a publish is cheap next to a half-recorded rotation.
1413func (s *Server) relayReissue(ctx context.Context, r relayAppRecord) error {
1414 mu := s.relayAppLock(r.Account, r.App)
1415 mu.Lock()
1416 defer mu.Unlock()
1417 return s.relayAppMutation(ctx, r.Account, r.App, "relay-reissue", func(ctx context.Context) error {
1418 return s.relayReissueWalk(ctx, r)
1419 })
1420}
1421
1422// relayReissueWalk is relayReissue's body under the app's lock and permit.
1423func (s *Server) relayReissueWalk(ctx context.Context, r relayAppRecord) error {
1424 if err := s.requireRelayLease(); err != nil {
1425 return err
1426 }
1427 rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, r.Account, r.App)
1428 if err != nil {
1429 return err
1430 }
1431 if rec == nil || !rec.Enabled {
1432 return nil // disabled under us: the reconcile owns it now
1433 }
1434 rec.Op++
1435 rec.Phase = relayPhaseConfig
1436 if etag, err = s.relayPutRecord(ctx, r.Account, r.App, rec, etag); err != nil {
1437 return err
1438 }
1439 if err := s.relayApplyConfig(ctx, r.Account, r.App, rec); err != nil {
1440 return err
1441 }
1442 rec.Phase = relayPhaseAggregate
1443 if etag, err = s.relayPutRecord(ctx, r.Account, r.App, rec, etag); err != nil {
1444 return err
1445 }
1446 if err := s.relayPublishAggregate(ctx); err != nil {
1447 return err
1448 }
1449 rec.Phase = relayPhaseDone
1450 _, err = s.relayPutRecord(ctx, r.Account, r.App, rec, etag)
1451 return err
1452}
1453
1454// ---- the reconcile ----------------------------------------------------------
1455
1456// relayReconcile converges BOTH derived artefacts from every record, every
1457// five minutes and once at console start (spec §5).
1458//
1459// It deliberately does not look for unfinished records. For each enabled
1460// record it derives what the config should hold from the record and the
1461// masters in force, and re-applies when the config holds anything else; for
1462// each disabled record it removes the variable if present; and it ALWAYS
1463// rebuilds and publishes the aggregate, which is what repairs a deletion that
1464// removed a record but died before publishing.
1465//
1466// One app's failure is logged and skipped, never fatal. The aggregate publish
1467// is the last step and the only one whose failure is returned, because it is
1468// the one that speaks for every app at once.
1469func (s *Server) relayReconcile(ctx context.Context) error {
1470 if s.cfg.Releases == nil {
1471 return errRelayNoBucket
1472 }
1473 if err := s.requireRelayLease(); err != nil {
1474 return err
1475 }
1476 recs, err := s.relayListRecords(ctx)
1477 if err != nil {
1478 return err
1479 }
1480 // Records whose walk reached `aggregate` in this pass, with the etag to
1481 // mark them done once the publish below has landed.
1482 type pending struct {
1483 rec relayAppRecord
1484 etag string
1485 }
1486 var advanced []pending
1487 for _, r := range recs {
1488 etag, changed, err := s.relayConverge(ctx, r)
1489 if err != nil {
1490 log.Printf("console: relay reconcile %s/%s: %v", r.Account, r.App, err)
1491 continue
1492 }
1493 if changed {
1494 advanced = append(advanced, pending{rec: r, etag: etag})
1495 }
1496 }
1497 if err := s.relayPublishAggregate(ctx); err != nil {
1498 return err
1499 }
1500 for _, p := range advanced {
1501 if err := s.relayMarkDone(ctx, p.rec, p.etag); err != nil {
1502 log.Printf("console: relay reconcile %s/%s: marking done: %v", p.rec.Account, p.rec.App, err)
1503 }
1504 }
1505 return nil
1506}
1507
1508// relayConverge brings ONE record's config into agreement with it, returning
1509// the etag it left the record at and whether the record still needs marking
1510// done after the publish.
1511func (s *Server) relayConverge(ctx context.Context, r relayAppRecord) (string, bool, error) {
1512 mu := s.relayAppLock(r.Account, r.App)
1513 mu.Lock()
1514 defer mu.Unlock()
1515 var (
1516 etag string
1517 changed bool
1518 )
1519 err := s.relayAppMutation(ctx, r.Account, r.App, "relay-converge", func(ctx context.Context) error {
1520 var err error
1521 etag, changed, err = s.relayConvergeWalk(ctx, r)
1522 return err
1523 })
1524 if err != nil {
1525 return "", false, err
1526 }
1527 return etag, changed, nil
1528}
1529
1530// relayConvergeWalk is relayConverge's body under the app's lock and permit.
1531func (s *Server) relayConvergeWalk(ctx context.Context, r relayAppRecord) (string, bool, error) {
1532 if err := s.requireRelayLease(); err != nil {
1533 return "", false, err
1534 }
1535 // Re-read under the lock: the listing is a snapshot, and an operation may
1536 // have moved this record between it and here.
1537 rec, etag, err := relay.GetRecord(ctx, s.cfg.Releases, r.Account, r.App)
1538 if err != nil || rec == nil {
1539 return "", false, err
1540 }
1541 if rec.Enabled {
1542 want, err := s.relayExpectedSuffixes(ctx, r.Account, r.App, rec)
1543 if err != nil {
1544 return "", false, err
1545 }
1546 v, present, err := s.relayConfigVar(ctx, r.Account, r.App)
1547 if err != nil {
1548 return "", false, err
1549 }
1550 // Current means BOTH halves agree: the identities the record says it
1551 // issued are the ones the masters in force imply (a rotation,
1552 // re-enable or region change moves those), AND the bundle still holds
1553 // the exact envelope this console last wrote (a straggler write from
1554 // a superseded console, or a member setting CARLOS_RELAY themselves,
1555 // moves that). A record from before IssuedDigest existed has none, so
1556 // it re-applies once and then carries one.
1557 current := present && v.Kind == "secret" && v.Envelope != "" &&
1558 maps.Equal(rec.IssuedSuffixes, want) &&
1559 rec.IssuedDigest != "" && relayEnvelopeDigest(v.Envelope) == rec.IssuedDigest
1560 if current && rec.Phase == relayPhaseDone {
1561 return etag, false, nil
1562 }
1563 if !current {
1564 if err := s.relayApplyConfig(ctx, r.Account, r.App, rec); err != nil {
1565 return "", false, err
1566 }
1567 }
1568 } else {
1569 if _, err := s.relayUnsetConfig(ctx, r.Account, r.App); err != nil {
1570 return "", false, err
1571 }
1572 if rec.Phase == relayPhaseDone {
1573 return etag, false, nil
1574 }
1575 }
1576 rec.Phase = relayPhaseAggregate
1577 etag, err = s.relayPutRecord(ctx, r.Account, r.App, rec, etag)
1578 if err != nil {
1579 return "", false, err
1580 }
1581 return etag, true, nil
1582}
1583
1584// relayMarkDone is the last step of a reconciled record's walk, after the
1585// aggregate that carries it has been published.
1586func (s *Server) relayMarkDone(ctx context.Context, r relayAppRecord, etag string) error {
1587 mu := s.relayAppLock(r.Account, r.App)
1588 mu.Lock()
1589 defer mu.Unlock()
1590 return s.relayAppMutation(ctx, r.Account, r.App, "relay-mark-done", func(ctx context.Context) error {
1591 return s.relayMarkDoneWalk(ctx, r, etag)
1592 })
1593}
1594
1595// relayMarkDoneWalk is relayMarkDone's body under the app's lock and permit.
1596func (s *Server) relayMarkDoneWalk(ctx context.Context, r relayAppRecord, etag string) error {
1597 if err := s.requireRelayLease(); err != nil {
1598 return err
1599 }
1600 rec, cur, err := relay.GetRecord(ctx, s.cfg.Releases, r.Account, r.App)
1601 if err != nil || rec == nil {
1602 return err
1603 }
1604 if cur != etag {
1605 return nil // moved on under us; its own walk owns the phase now
1606 }
1607 rec.Phase = relayPhaseDone
1608 _, err = s.relayPutRecord(ctx, r.Account, r.App, rec, etag)
1609 return err
1610}
1611