carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The relay's write side (relay spec §5). What is pinned here, in the order
4// it would hurt if lost: an enable delivers a key AND publishes authority, or
5// it does neither; the aggregate is rebuilt from records with the etag read
6// FIRST, so a disable landing mid-listing fails the write instead of undoing
7// itself; the reconcile converges an interrupted enable, a stale config write
8// that outlived a disable, and an aggregate naming an app whose record is
9// gone; and one console at a time holds the writer lease, with the loser
10// stopping rather than writing.
11
12import (
13 "context"
14 "encoding/base64"
15 "encoding/json"
16 "errors"
17 "net/http"
18 "net/url"
19 "strings"
20 "sync"
21 "testing"
22 "time"
23
24 "github.com/carlosframework/platform/internal/appconfig"
25 "github.com/carlosframework/platform/internal/registry"
26 "github.com/carlosframework/platform/internal/relay"
27 "github.com/carlosframework/platform/internal/release"
28)
29
30// relayFixture is storeFixture (an account, the app titogo, a member's token)
31// plus everything the relay needs: one armed region, a sealing key whose
32// private half only the test holds, and a staged master in this console's own
33// environment. It leaves the server HOLDING the writer lease, because every
34// operation refuses without it and no test here is about that refusal.
35//
36// The private key comes back so a test can open the envelope the console
37// delivered and prove it is the value the record implies — the console cannot
38// do that, by design, so the assertion belongs on this side.
39func relayFixture(t *testing.T, code string) (*consoleFixture, string) {
40 t.Helper()
41 fx, _ := storeFixture(t, code)
42 return fx, armRelay(t, fx)
43}
44
45// armRelay gives any console fixture a relay: one armed region, a sealing key
46// whose private half comes back to the caller, a staged master, and the
47// writer lease held.
48func armRelay(t *testing.T, fx *consoleFixture) string {
49 t.Helper()
50 pub, priv, _ := sealedFor(t, "irrelevant")
51 fx.srv.cfg.SecretsPub = pub
52 fx.srv.cfg.RelayRegions = []string{"eu-west-1"}
53 fx.srv.cfg.PlatformDomain = "oncarlos.com"
54 t.Setenv(relayMastersEnv("eu-west-1"), "k1:"+relayTestMaster(1))
55 if err := fx.srv.relayAcquire(context.Background()); err != nil {
56 t.Fatalf("acquire the writer lease: %v", err)
57 }
58 return priv
59}
60
61// relayTestMaster is a deterministic 32-byte master, base64 as the console
62// variable carries it.
63func relayTestMaster(seed byte) string {
64 b := make([]byte, relay.MasterKeyLen)
65 for i := range b {
66 b[i] = seed*31 + byte(i)
67 }
68 return base64.StdEncoding.EncodeToString(b)
69}
70
71// account is the fixture's account as the operations take it.
72func (fx *consoleFixture) account(t *testing.T) *Account {
73 t.Helper()
74 a, err := fx.st.AccountBySqid(fx.sqid)
75 if err != nil {
76 t.Fatalf("account %s: %v", fx.sqid, err)
77 }
78 return a
79}
80
81// relayRecord reads the app's record straight out of the bucket.
82func relayRecord(t *testing.T, fx *consoleFixture, app string) *relay.Record {
83 t.Helper()
84 rec, _, err := relay.GetRecord(context.Background(), fx.rel, fx.sqid, app)
85 if err != nil {
86 t.Fatalf("read relay record: %v", err)
87 }
88 return rec
89}
90
91// relayAuthority reads the published authority record, an empty one when
92// nothing has been published.
93func relayAuthority(t *testing.T, fx *consoleFixture) *relay.Authority {
94 t.Helper()
95 a, _, err := relay.ReadAuthority(context.Background(), fx.rel)
96 if err != nil {
97 t.Fatalf("read authority: %v", err)
98 }
99 if a == nil {
100 return &relay.Authority{}
101 }
102 return a
103}
104
105// TestRelayEnableWritesRecordConfigAndAggregate is the whole happy path: the
106// record lands finished, the app's config carries a SEALED CARLOS_RELAY whose
107// plaintext is the value the record implies, and the authority every relay
108// box reads names the app at that incarnation.
109func TestRelayEnableWritesRecordConfigAndAggregate(t *testing.T) {
110 fx, priv := relayFixture(t, "code-relay-enable")
111 ctx := context.Background()
112
113 rec, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
114 if err != nil {
115 t.Fatal(err)
116 }
117 if !rec.Enabled || rec.Phase != relayPhaseDone || rec.Op != 1 || len(rec.Incarnation) != 16 {
118 t.Fatalf("record = %+v", rec)
119 }
120 if got := relayRecord(t, fx, "titogo"); got == nil || got.Phase != relayPhaseDone || got.Incarnation != rec.Incarnation {
121 t.Fatalf("stored record = %+v, want the returned one finished", got)
122 }
123
124 v, ok := appVars(t, fx, "titogo")[relayVar]
125 if !ok || v.Kind != "secret" || v.Envelope == "" {
126 t.Fatalf("%s = %+v, want a sealed secret", relayVar, v)
127 }
128 entries, err := relay.ParseConfig(openSecret(t, priv, v))
129 if err != nil {
130 t.Fatalf("delivered value: %v", err)
131 }
132 if len(entries) != 1 || entries[0].Region != "eu-west-1" {
133 t.Fatalf("entries = %+v, want one for the armed region", entries)
134 }
135 wantURLs := []string{
136 "stun:relay.eu-west-1.oncarlos.com:3478",
137 "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp",
138 "turn:relay.eu-west-1.oncarlos.com:3478?transport=tcp",
139 }
140 if strings.Join(entries[0].URLs, ",") != strings.Join(wantURLs, ",") {
141 t.Fatalf("urls = %v, want %v", entries[0].URLs, wantURLs)
142 }
143 wantSuffix := relay.Suffix(fx.sqid, "titogo", rec.Incarnation, "k1")
144 if entries[0].UsernameSuffix != wantSuffix || rec.IssuedSuffixes["eu-west-1"] != wantSuffix {
145 t.Fatalf("suffix = %q / recorded %q, want %q", entries[0].UsernameSuffix, rec.IssuedSuffixes["eu-west-1"], wantSuffix)
146 }
147
148 a := relayAuthority(t, fx)
149 x, ok := a.Lookup(fx.sqid, "titogo")
150 if !ok || x.Incarnation != rec.Incarnation || strings.Join(x.Regions, ",") != "eu-west-1" {
151 t.Fatalf("aggregate = %+v", a)
152 }
153}
154
155// TestRelayRepeatEnableKeepsIncarnationAndWritesNoConfig: enabling an app
156// that is already enabled with the same regions and nothing owed writes
157// NOTHING — no op bump, no new envelope, no generation touch. A member who
158// re-runs the command must not re-key their own app.
159func TestRelayRepeatEnableKeepsIncarnationAndWritesNoConfig(t *testing.T) {
160 fx, _ := relayFixture(t, "code-relay-repeat")
161 ctx := context.Background()
162
163 first, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
164 if err != nil {
165 t.Fatal(err)
166 }
167 before := appVars(t, fx, "titogo")[relayVar]
168 _, genBefore, err := fx.rel.Get(ctx, appconfig.GenKey(fx.sqid, "titogo"))
169 if err != nil {
170 t.Fatal(err)
171 }
172
173 second, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", []string{"eu-west-1"}, "alice@keymail.dev")
174 if err != nil {
175 t.Fatal(err)
176 }
177 if second.Incarnation != first.Incarnation || second.Op != first.Op {
178 t.Fatalf("second enable = %+v, want the same incarnation and op as %+v", second, first)
179 }
180 if after := appVars(t, fx, "titogo")[relayVar]; after.Envelope != before.Envelope {
181 t.Fatal("a repeat enable re-sealed the value — the app would re-key for nothing")
182 }
183 _, genAfter, err := fx.rel.Get(ctx, appconfig.GenKey(fx.sqid, "titogo"))
184 if err != nil {
185 t.Fatal(err)
186 }
187 if genAfter != genBefore {
188 t.Fatalf("config generation moved (%q -> %q) with no config write", genBefore, genAfter)
189 }
190}
191
192// TestRelayDisableThenEnableIsANewIncarnation: a disable takes the app out of
193// authority and takes the variable out of its config; the enable after it
194// draws a NEW incarnation, so a credential minted before the disable can
195// never be mistaken for a live one.
196func TestRelayDisableThenEnableIsANewIncarnation(t *testing.T) {
197 fx, _ := relayFixture(t, "code-relay-cycle")
198 ctx := context.Background()
199 acct := fx.account(t)
200
201 first, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev")
202 if err != nil {
203 t.Fatal(err)
204 }
205 off, err := fx.srv.relayDisable(ctx, acct, "titogo", "alice@keymail.dev")
206 if err != nil {
207 t.Fatal(err)
208 }
209 if off.Enabled || off.Phase != relayPhaseDone || off.Op != first.Op+1 {
210 t.Fatalf("disabled record = %+v", off)
211 }
212 if _, ok := appVars(t, fx, "titogo")[relayVar]; ok {
213 t.Fatalf("%s survived the disable", relayVar)
214 }
215 if _, ok := relayAuthority(t, fx).Lookup(fx.sqid, "titogo"); ok {
216 t.Fatal("the aggregate still names a disabled app")
217 }
218
219 again, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev")
220 if err != nil {
221 t.Fatal(err)
222 }
223 if again.Incarnation == first.Incarnation {
224 t.Fatalf("re-enable kept incarnation %s — a pre-disable key would still verify", again.Incarnation)
225 }
226 if again.Op != off.Op+1 {
227 t.Fatalf("op = %d after a re-enable, want %d (the counter never rewinds)", again.Op, off.Op+1)
228 }
229 if x, ok := relayAuthority(t, fx).Lookup(fx.sqid, "titogo"); !ok || x.Incarnation != again.Incarnation {
230 t.Fatalf("aggregate = %+v, want the new incarnation", x)
231 }
232}
233
234// relayOpStore records the ORDER of the store calls one publish makes — the
235// only way to prove the etag is read before the listing rather than after,
236// which is the whole safety of spec §5's publish sequence.
237type relayOpStore struct {
238 release.Store
239 mu sync.Mutex
240 ops []string
241}
242
243func (r *relayOpStore) note(op string) {
244 r.mu.Lock()
245 defer r.mu.Unlock()
246 r.ops = append(r.ops, op)
247}
248
249func (r *relayOpStore) seq() []string {
250 r.mu.Lock()
251 defer r.mu.Unlock()
252 return append([]string(nil), r.ops...)
253}
254
255func (r *relayOpStore) Get(ctx context.Context, key string) ([]byte, string, error) {
256 r.note("get " + key)
257 return r.Store.Get(ctx, key)
258}
259
260func (r *relayOpStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
261 r.note("listdirs " + prefix)
262 return r.Store.ListDirs(ctx, prefix)
263}
264
265func (r *relayOpStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
266 r.note("put " + key)
267 return r.Store.Put(ctx, key, body, ifMatch)
268}
269
270// TestRelayPublishAggregateIsEtagFirst pins the sequence itself: the
271// aggregate's etag is read BEFORE any record is listed, and a publish carrying
272// an etag that has since moved fails with ErrCASConflict rather than
273// overwriting whoever moved it. The retry then publishes the current truth.
274func TestRelayPublishAggregateIsEtagFirst(t *testing.T) {
275 fx, _ := relayFixture(t, "code-relay-etag")
276 ctx := context.Background()
277 acct := fx.account(t)
278
279 if _, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev"); err != nil {
280 t.Fatal(err)
281 }
282 if _, err := fx.srv.relayEnable(ctx, acct, "meet", nil, "alice@keymail.dev"); err != nil {
283 t.Fatal(err)
284 }
285
286 // The order, on a publish with nothing else going on.
287 rec := &relayOpStore{Store: fx.rel}
288 fx.srv.cfg.Releases = rec
289 if err := fx.srv.relayPublishAggregate(ctx); err != nil {
290 t.Fatal(err)
291 }
292 fx.srv.cfg.Releases = rec.Store
293 seq := rec.seq()
294 getAt, listAt := -1, -1
295 for i, op := range seq {
296 if getAt < 0 && op == "get "+relay.AuthorityKey {
297 getAt = i
298 }
299 if listAt < 0 && strings.HasPrefix(op, "listdirs apps/") {
300 listAt = i
301 }
302 }
303 if getAt < 0 || listAt < 0 || getAt > listAt {
304 t.Fatalf("publish sequence = %v, want the aggregate's etag read before the listing", seq)
305 }
306
307 // A stale etag must lose. Take one, then move the aggregate underneath it
308 // by disabling titogo's record directly and republishing.
309 _, stale, err := fx.rel.Get(ctx, relay.AuthorityKey)
310 if err != nil {
311 t.Fatal(err)
312 }
313 off, offEtag, err := relay.GetRecord(ctx, fx.rel, fx.sqid, "titogo")
314 if err != nil {
315 t.Fatal(err)
316 }
317 off.Enabled = false
318 if _, err := relay.PutRecord(ctx, fx.rel, fx.sqid, "titogo", off, offEtag); err != nil {
319 t.Fatal(err)
320 }
321 if err := fx.srv.relayPublishAggregate(ctx); err != nil {
322 t.Fatal(err)
323 }
324 if err := fx.srv.relayPublishOnce(ctx, stale); !errors.Is(err, release.ErrCASConflict) {
325 t.Fatalf("publish with a stale etag = %v, want ErrCASConflict", err)
326 }
327 if err := fx.srv.relayPublishAggregate(ctx); err != nil {
328 t.Fatal(err)
329 }
330 a := relayAuthority(t, fx)
331 if _, ok := a.Lookup(fx.sqid, "titogo"); ok {
332 t.Fatalf("aggregate = %+v, want the disabled app gone", a)
333 }
334 if _, ok := a.Lookup(fx.sqid, "meet"); !ok {
335 t.Fatalf("aggregate = %+v, want the still-enabled app kept", a)
336 }
337}
338
339// TestRelayReconcileFinishesInterruptedEnable: a console that wrote the
340// record and died before the config write leaves an enabled record at phase
341// `config`, no variable and no authority. The next holder's reconcile
342// delivers both and marks it done — without the member running anything.
343func TestRelayReconcileFinishesInterruptedEnable(t *testing.T) {
344 fx, _ := relayFixture(t, "code-relay-resume")
345 ctx := context.Background()
346 inc := relay.NewIncarnation()
347 if _, err := relay.PutRecord(ctx, fx.rel, fx.sqid, "titogo", &relay.Record{
348 Enabled: true, Incarnation: inc, Regions: []string{"eu-west-1"},
349 Op: 1, Phase: relayPhaseConfig, EnabledAt: time.Now().UTC(),
350 }, ""); err != nil {
351 t.Fatal(err)
352 }
353
354 if err := fx.srv.relayReconcile(ctx); err != nil {
355 t.Fatal(err)
356 }
357
358 if v, ok := appVars(t, fx, "titogo")[relayVar]; !ok || v.Kind != "secret" {
359 t.Fatalf("%s = %+v after the reconcile, want a delivered secret", relayVar, v)
360 }
361 if x, ok := relayAuthority(t, fx).Lookup(fx.sqid, "titogo"); !ok || x.Incarnation != inc {
362 t.Fatal("the reconcile did not publish the interrupted enable")
363 }
364 if got := relayRecord(t, fx, "titogo"); got.Phase != relayPhaseDone {
365 t.Fatalf("phase = %q after the reconcile, want done", got.Phase)
366 }
367}
368
369// TestRelayReconcileUndoesStaleConfigAfterDisable: a config write that lands
370// AFTER a disable — a losing writer's last act — is undone within one
371// reconcile. This is the convergence spec §5 promises for the case the phase
372// walk alone cannot cover, because the record is already finished.
373func TestRelayReconcileUndoesStaleConfigAfterDisable(t *testing.T) {
374 fx, _ := relayFixture(t, "code-relay-stale")
375 ctx := context.Background()
376 acct := fx.account(t)
377
378 if _, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev"); err != nil {
379 t.Fatal(err)
380 }
381 if _, err := fx.srv.relayDisable(ctx, acct, "titogo", "alice@keymail.dev"); err != nil {
382 t.Fatal(err)
383 }
384 // The straggler write.
385 if _, err := appconfig.Apply(ctx, fx.rel, fx.sqid, "titogo",
386 map[string]appconfig.Var{relayVar: {Kind: "secret", Envelope: "stale-envelope"}}, nil); err != nil {
387 t.Fatal(err)
388 }
389
390 if err := fx.srv.relayReconcile(ctx); err != nil {
391 t.Fatal(err)
392 }
393 if v, ok := appVars(t, fx, "titogo")[relayVar]; ok {
394 t.Fatalf("%s = %+v, want it removed again", relayVar, v)
395 }
396}
397
398// TestRelayReconcileRepairsDeletionWithoutRecord: an app whose record is gone
399// — deleted with the rest of its prefix by a walk that died before publishing
400// — disappears from authority on the next unconditional rebuild. That is why
401// the reconcile lists records rather than hunting for unfinished ones.
402func TestRelayReconcileRepairsDeletionWithoutRecord(t *testing.T) {
403 fx, _ := relayFixture(t, "code-relay-deleted")
404 ctx := context.Background()
405 body, err := json.Marshal(relay.Authority{
406 Apps: []relay.AuthorityApp{{Account: fx.sqid, App: "gone",
407 Incarnation: relay.NewIncarnation(), Regions: []string{"eu-west-1"}}},
408 WrittenAt: time.Now().UTC(),
409 })
410 if err != nil {
411 t.Fatal(err)
412 }
413 if _, err := fx.rel.Put(ctx, relay.AuthorityKey, body, ""); err != nil {
414 t.Fatal(err)
415 }
416
417 if err := fx.srv.relayReconcile(ctx); err != nil {
418 t.Fatal(err)
419 }
420 if _, ok := relayAuthority(t, fx).Lookup(fx.sqid, "gone"); ok {
421 t.Fatal("the aggregate still names an app with no record")
422 }
423}
424
425// TestRelayPurgeRepublishesTheAggregate: permanently deleting an app takes it
426// out of every relay's authority AT ONCE, not within five minutes. The
427// deletion walk sweeps the record; publishing right after is what makes the
428// app's credentials stop verifying while the operator is still watching.
429func TestRelayPurgeRepublishesTheAggregate(t *testing.T) {
430 fx, c, csrf := trashPageFixture(t)
431 armRelay(t, fx)
432 ctx := context.Background()
433
434 if _, err := fx.srv.relayEnable(ctx, fx.account(t), "kass", nil, "paul@keymail.dev"); err != nil {
435 t.Fatal(err)
436 }
437 if _, ok := relayAuthority(t, fx).Lookup(fx.sqid, "kass"); !ok {
438 t.Fatal("the enable did not publish")
439 }
440
441 status, _, body := postGated(t, fx, c, fx.u("/trash/delete"),
442 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
443 if status != http.StatusSeeOther {
444 t.Fatalf("purge = %d:\n%s", status, body)
445 }
446 if keys := appKeys(t, fx, "kass"); len(keys) != 0 {
447 t.Fatalf("the bucket still holds %v", keys)
448 }
449 // No reconcile anywhere in this test: the purge itself republished.
450 if _, ok := relayAuthority(t, fx).Lookup(fx.sqid, "kass"); ok {
451 t.Fatal("a purged app still holds relay authority")
452 }
453}
454
455// TestRelayEnableRefusesRouteWithoutDelivery: a TCP-address route this
456// platform does not own, whose bundle has not opted into file delivery, could
457// never pick up a rotated key — so it would block every future retirement
458// forever. Enable refuses it BY NAME, and writes nothing on the way out.
459func TestRelayEnableRefusesRouteWithoutDelivery(t *testing.T) {
460 fx, _ := relayFixture(t, "code-relay-delivery")
461 ctx := context.Background()
462 fx.addRoute(t, registry.Route{Host: "x.titogo.oncarlos.com", App: "titogo",
463 Kind: "service", Addr: "10.0.0.5:8080", Status: "ready"})
464
465 _, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
466 if !errors.Is(err, errRelayUndelivered) {
467 t.Fatalf("enable over an undeliverable route = %v, want a refusal", err)
468 }
469 if !strings.Contains(err.Error(), "x.titogo.oncarlos.com") {
470 t.Fatalf("error = %q, want the route named", err)
471 }
472 if rec := relayRecord(t, fx, "titogo"); rec != nil {
473 t.Fatalf("a refused enable left a record: %+v", rec)
474 }
475 if _, ok := appVars(t, fx, "titogo")[relayVar]; ok {
476 t.Fatal("a refused enable delivered a key")
477 }
478
479 // Opting the app in makes the same route deliverable.
480 if err := appconfig.SetExecDelivery(ctx, fx.rel, fx.sqid, "titogo", appconfig.ExecDeliveryFile); err != nil {
481 t.Fatal(err)
482 }
483 if _, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev"); err != nil {
484 t.Fatalf("enable after opting into file delivery: %v", err)
485 }
486}
487
488// TestRelayAdoptedRecordsTheIssuedIdentitiesAndRefusesAStranger: a member's
489// ack stamps the identities issued AT THAT MOMENT against their own route, so
490// an ack given before a later rotation cannot count for it — and a host that
491// is not a route of this app is refused rather than appended, which is what
492// keeps the record bounded.
493//
494// The route is a tcp-exec-file one because that is the only shape a member
495// may acknowledge at all: anything the config sync restarts itself has its
496// cutover observed (TestAPIRelayAdoptedRefusesARouteThePlatformRestarts).
497func TestRelayAdoptedRecordsTheIssuedIdentitiesAndRefusesAStranger(t *testing.T) {
498 fx, _ := relayFixture(t, "code-relay-adopted")
499 ctx := context.Background()
500 acct := fx.account(t)
501 fx.addRoute(t, registry.Route{Host: "titogo.oncarlos.com", App: "titogo",
502 Kind: "service", Status: "ready", Addr: "10.0.0.9:8080"})
503 if err := appconfig.SetExecDelivery(ctx, fx.rel, fx.sqid, "titogo", appconfig.ExecDeliveryFile); err != nil {
504 t.Fatal(err)
505 }
506
507 rec, err := fx.srv.relayEnable(ctx, acct, "titogo", nil, "alice@keymail.dev")
508 if err != nil {
509 t.Fatal(err)
510 }
511 if _, err := fx.srv.relayAdopted(ctx, acct, "titogo", "somewhere.else.example", "alice@keymail.dev"); !errors.Is(err, errRelayUnknownHost) {
512 t.Fatalf("adopted for a host that is not a route = %v, want a refusal", err)
513 }
514 after, err := fx.srv.relayAdopted(ctx, acct, "titogo", "titogo.oncarlos.com", "alice@keymail.dev")
515 if err != nil {
516 t.Fatal(err)
517 }
518 if len(after.Cutovers) != 1 || after.Cutovers[0].Host != "titogo.oncarlos.com" ||
519 after.Cutovers[0].Suffixes["eu-west-1"] != rec.IssuedSuffixes["eu-west-1"] {
520 t.Fatalf("cutovers = %+v, want one naming the issued identity", after.Cutovers)
521 }
522 // A second ack for the same route replaces the first rather than growing
523 // the record.
524 again, err := fx.srv.relayAdopted(ctx, acct, "titogo", "titogo.oncarlos.com", "alice@keymail.dev")
525 if err != nil || len(again.Cutovers) != 1 {
526 t.Fatalf("second ack = %+v (%v), want one cutover still", again.Cutovers, err)
527 }
528}
529
530// TestRelayRotateRefusesUntilHeartbeatsCarryKid: rotation's whole safety is
531// that the console will not issue from a master a live relay has not loaded.
532// Until the region's heartbeat lists the new kid, rotate refuses and the
533// issuer pointer does not move; once it does, every enabled app is re-issued
534// under the new kid.
535func TestRelayRotateRefusesUntilHeartbeatsCarryKid(t *testing.T) {
536 fx, _ := relayFixture(t, "code-relay-rotate")
537 ctx := context.Background()
538 t.Setenv(relayMastersEnv("eu-west-1"), "k1:"+relayTestMaster(1)+",k2:"+relayTestMaster(2))
539
540 before, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
541 if err != nil {
542 t.Fatal(err)
543 }
544 if !strings.HasSuffix(before.IssuedSuffixes["eu-west-1"], ":k1") {
545 t.Fatalf("issued %q, want the first staged master", before.IssuedSuffixes["eu-west-1"])
546 }
547 hb := relay.Heartbeat{Box: "box1", Region: "eu-west-1", ReadAt: time.Now(),
548 Listeners: []string{"udp:3478"}, Kids: []string{"k1"}}
549 if err := relay.WriteHeartbeat(ctx, fx.rel, hb); err != nil {
550 t.Fatal(err)
551 }
552
553 if _, err := fx.srv.relayRotate(ctx, "eu-west-1"); err == nil ||
554 !strings.Contains(err.Error(), "k2") || !strings.Contains(err.Error(), "box1") {
555 t.Fatalf("rotate before the relay loaded the kid = %v, want a refusal naming the box and kid", err)
556 }
557 if issuers, _, err := fx.srv.relayIssuers(ctx); err != nil || issuers["eu-west-1"] != "" {
558 t.Fatalf("issuer pointer = %v (%v) after a refused rotation, want it untouched", issuers, err)
559 }
560
561 hb.Kids = []string{"k1", "k2"}
562 if err := relay.WriteHeartbeat(ctx, fx.rel, hb); err != nil {
563 t.Fatal(err)
564 }
565 res, err := fx.srv.relayRotate(ctx, "eu-west-1")
566 if err != nil {
567 t.Fatal(err)
568 }
569 if res.Kid != "k2" || res.Apps != 1 {
570 t.Fatalf("rotate result = %+v, want kid k2 and one app re-issued", res)
571 }
572 // A SECOND rotate in the same window must refuse. The staged variable
573 // still holds "k1,k2" — the old master stays there for the 28 hours before
574 // it may be retired — and rotating "to the entry that is not the issuer"
575 // would walk the pointer back onto the master about to be retired.
576 if _, err := fx.srv.relayRotate(ctx, "eu-west-1"); err == nil ||
577 !strings.Contains(err.Error(), "already the issuer") {
578 t.Fatalf("re-rotate with nothing new staged = %v, want a refusal naming the last entry", err)
579 }
580 if issuers, _, err := fx.srv.relayIssuers(ctx); err != nil || issuers["eu-west-1"] != "k2" {
581 t.Fatalf("issuer pointer = %v (%v) after a refused re-rotate, want it still on k2", issuers, err)
582 }
583 if d := time.Until(res.RetireNoEarlierThan); d < 27*time.Hour || d > 29*time.Hour {
584 t.Fatalf("retire-no-earlier-than is %s away, want about 28h", d)
585 }
586 issuers, _, err := fx.srv.relayIssuers(ctx)
587 if err != nil || issuers["eu-west-1"] != "k2" {
588 t.Fatalf("issuer pointer = %v (%v), want k2", issuers, err)
589 }
590 after := relayRecord(t, fx, "titogo")
591 if !strings.HasSuffix(after.IssuedSuffixes["eu-west-1"], ":k2") {
592 t.Fatalf("issued %q after the rotation, want the new kid", after.IssuedSuffixes["eu-west-1"])
593 }
594 if after.Incarnation != before.Incarnation {
595 t.Fatal("a rotation changed the incarnation — every live allocation would end")
596 }
597 if after.Op != before.Op+1 || after.Phase != relayPhaseDone {
598 t.Fatalf("record = %+v, want one finished operation past %d", after, before.Op)
599 }
600
601 // Staging a THIRD master, with the pointer on the middle one, rotates
602 // forward to it — the last entry, not merely a different one.
603 t.Setenv(relayMastersEnv("eu-west-1"),
604 "k1:"+relayTestMaster(1)+",k2:"+relayTestMaster(2)+",k3:"+relayTestMaster(3))
605 hb.Kids = []string{"k1", "k2", "k3"}
606 if err := relay.WriteHeartbeat(ctx, fx.rel, hb); err != nil {
607 t.Fatal(err)
608 }
609 third, err := fx.srv.relayRotate(ctx, "eu-west-1")
610 if err != nil {
611 t.Fatal(err)
612 }
613 if third.Kid != "k3" {
614 t.Fatalf("rotate with k1,k2,k3 staged and the pointer on k2 = %q, want k3", third.Kid)
615 }
616 if !strings.HasSuffix(relayRecord(t, fx, "titogo").IssuedSuffixes["eu-west-1"], ":k3") {
617 t.Fatal("the app was not re-issued under the newest kid")
618 }
619}
620
621// TestRelayReconcileRepairsOverwrittenEnvelope: the bundle holding SOMETHING
622// under CARLOS_RELAY is not the same fact as it holding what this console
623// wrote. A straggler write from a superseded console, or a member setting the
624// variable themselves, leaves suffixes that still agree and a value that does
625// not — which is exactly what the record's issued digest catches.
626func TestRelayReconcileRepairsOverwrittenEnvelope(t *testing.T) {
627 fx, priv := relayFixture(t, "code-relay-envelope")
628 ctx := context.Background()
629
630 rec, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev")
631 if err != nil {
632 t.Fatal(err)
633 }
634 if rec.IssuedDigest == "" {
635 t.Fatal("enable recorded no issued digest")
636 }
637 // Someone else's envelope, under the same name, with the record untouched.
638 _, _, foreign := sealedFor(t, "not what the console issued")
639 if _, err := appconfig.Apply(ctx, fx.rel, fx.sqid, "titogo",
640 map[string]appconfig.Var{relayVar: {Kind: "secret", Envelope: foreign}}, nil); err != nil {
641 t.Fatal(err)
642 }
643
644 if err := fx.srv.relayReconcile(ctx); err != nil {
645 t.Fatal(err)
646 }
647
648 v := appVars(t, fx, "titogo")[relayVar]
649 if v.Envelope == foreign {
650 t.Fatal("the reconcile left an envelope this console did not write")
651 }
652 after := relayRecord(t, fx, "titogo")
653 if after.IssuedDigest != relayEnvelopeDigest(v.Envelope) {
654 t.Fatalf("record digest %q does not describe the bundle's envelope", after.IssuedDigest)
655 }
656 entries, err := relay.ParseConfig(openSecret(t, priv, v))
657 if err != nil || len(entries) != 1 || entries[0].UsernameSuffix != after.IssuedSuffixes["eu-west-1"] {
658 t.Fatalf("re-delivered value = %+v (%v), want the record's own identity", entries, err)
659 }
660}
661
662// TestRelayWriterLeaseExcludesSecondHolder: two consoles over one bucket, one
663// lease. The second is excluded until the first's minute lapses — and the
664// first, once it has been taken over, fails its next renewal and drops the
665// flag every relay write checks, which is what stops a superseded writer
666// finishing a walk the new holder has already started.
667func TestRelayWriterLeaseExcludesSecondHolder(t *testing.T) {
668 fx, _ := relayFixture(t, "code-relay-lease")
669 ctx := context.Background()
670
671 other := newConsoleFixture(t)
672 other.srv.cfg.Releases = fx.rel
673 other.srv.cfg.RelayRegions = []string{"eu-west-1"}
674
675 base := time.Now()
676 first, second := base, base.Add(time.Second)
677 fx.srv.relayNow = func() time.Time { return first }
678 other.srv.relayNow = func() time.Time { return second }
679
680 if err := fx.srv.relayAcquire(ctx); err != nil || !fx.srv.relayHold() {
681 t.Fatalf("first acquire = %v, held = %v", err, fx.srv.relayHold())
682 }
683 err := other.srv.relayAcquire(ctx)
684 if !errors.Is(err, errRelayNotHeld) {
685 t.Fatalf("second acquire inside the TTL = %v, want a refusal", err)
686 }
687 if other.srv.relayHold() {
688 t.Fatal("the second console thinks it holds the lease")
689 }
690 if err := other.srv.relayReconcile(ctx); !errors.Is(err, errRelayNotHeld) {
691 t.Fatalf("reconcile without the lease = %v, want a refusal", err)
692 }
693
694 // Past the expiry, the lease is takeable.
695 second = base.Add(relayLeaseTTL + time.Second)
696 if err := other.srv.relayAcquire(ctx); err != nil || !other.srv.relayHold() {
697 t.Fatalf("acquire after expiry = %v, held = %v", err, other.srv.relayHold())
698 }
699
700 // And the first console's next renewal fails, dropping its own flag.
701 first = base.Add(relayLeaseTTL + 2*time.Second)
702 if err := fx.srv.relayAcquire(ctx); err == nil {
703 t.Fatal("the superseded console renewed a lease it does not hold")
704 }
705 if fx.srv.relayHold() {
706 t.Fatal("the superseded console still believes it holds the lease")
707 }
708 if _, err := fx.srv.relayEnable(ctx, fx.account(t), "titogo", nil, "alice@keymail.dev"); !errors.Is(err, errRelayNotHeld) {
709 t.Fatalf("enable without the lease = %v, want a refusal", err)
710 }
711}
712