eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Unit + in-process integration tests for the activator mesh (mesh.go): HRW
4// determinism and minimal redistribution, membership staleness, the
5// owner-vs-proxy routing decision, proxy header/Host semantics, drain
6// ordering (heartbeat first), the due-sweep ownership gate, and the mesh-off
7// tripwire. Two "boxes" here are two activator+router pairs sharing an
8// in-memory heartbeat store — the store IS the bucket, which is the whole
9// membership design. The real-S3, real-process version (proxy-to-owner and
10// the kill-a-box failover, drain and hard-kill variants) is in
11// mesh_live_test.go.
12
13import (
14 "context"
15 "fmt"
16 "io"
17 "net"
18 "net/http"
19 "net/http/httptest"
20 "net/http/httputil"
21 "net/url"
22 "path/filepath"
23 "sync"
24 "sync/atomic"
25 "testing"
26 "time"
27
28 "lchat/internal/activator"
29)
30
31// --- fakes ---------------------------------------------------------------------
32
33// fakeMeshStore is an in-memory stand-in for the S3 heartbeat prefix. Shared
34// between boxes it plays the bucket; rec (optional) feeds a fakeBackend's
35// order log so drain tests can pin event ordering.
36type fakeMeshStore struct {
37 mu sync.Mutex
38 beats map[string]time.Time
39 regions map[string]string // peer -> advertised region (like the real body's "region")
40 regionsCall int // how many times Regions was invoked — proves the beat gate
41 now func() time.Time
42 rec func(ev string)
43}
44
45func newFakeMeshStore(now func() time.Time) *fakeMeshStore {
46 return &fakeMeshStore{beats: make(map[string]time.Time), regions: make(map[string]string), now: now}
47}
48
49func (s *fakeMeshStore) Put(_ context.Context, peer, region string) error {
50 s.mu.Lock()
51 s.beats[peer] = s.now()
52 if region != "" {
53 s.regions[peer] = region // mirror the real store: region omitted when empty
54 }
55 rec := s.rec
56 s.mu.Unlock()
57 if rec != nil {
58 rec("hb-put")
59 }
60 return nil
61}
62
63func (s *fakeMeshStore) List(context.Context) ([]activator.Heartbeat, error) {
64 s.mu.Lock()
65 defer s.mu.Unlock()
66 out := make([]activator.Heartbeat, 0, len(s.beats))
67 for peer, seen := range s.beats {
68 out = append(out, activator.Heartbeat{Peer: peer, LastSeen: seen})
69 }
70 return out, nil
71}
72
73func (s *fakeMeshStore) Regions(context.Context) (map[string]string, error) {
74 s.mu.Lock()
75 defer s.mu.Unlock()
76 s.regionsCall++
77 out := make(map[string]string, len(s.regions))
78 for peer, region := range s.regions {
79 out[peer] = region
80 }
81 return out, nil
82}
83
84func (s *fakeMeshStore) regionsCalls() int {
85 s.mu.Lock()
86 defer s.mu.Unlock()
87 return s.regionsCall
88}
89
90func (s *fakeMeshStore) Delete(_ context.Context, peer string) error {
91 s.mu.Lock()
92 delete(s.beats, peer)
93 delete(s.regions, peer)
94 rec := s.rec
95 s.mu.Unlock()
96 if rec != nil {
97 rec("hb-delete")
98 }
99 return nil
100}
101
102func (s *fakeMeshStore) setBeat(peer string, at time.Time) {
103 s.mu.Lock()
104 defer s.mu.Unlock()
105 s.beats[peer] = at
106}
107
108// setRegion advertises a peer's placement region, as if its heartbeat body
109// carried it — the fake counterpart of the real box PUTting with a region.
110func (s *fakeMeshStore) setRegion(peer, region string) {
111 s.mu.Lock()
112 defer s.mu.Unlock()
113 s.regions[peer] = region
114}
115
116func (s *fakeMeshStore) hasBeat(peer string) bool {
117 s.mu.Lock()
118 defer s.mu.Unlock()
119 _, ok := s.beats[peer]
120 return ok
121}
122
123// hostOwnedBy generates deterministic host names until one's HRW owner is the
124// wanted box — tests can't hardcode hosts because box identities (httptest
125// ports) vary per run.
126func hostOwnedBy(boxes []string, owner string) string {
127 for i := 0; ; i++ {
128 h := fmt.Sprintf("space-%d.test", i)
129 if hrwOwner(h, boxes) == owner {
130 return h
131 }
132 }
133}
134
135// meshTestBox is one in-process "box": activator (fake backend) + router +
136// front door + a live unix-socket listener standing in for any woken instance
137// (it echoes the box's name and records what the backend saw).
138type meshTestBox struct {
139 name string
140 act *activatorSvc
141 b *fakeBackend
142 reg *Registry
143 rt *router
144 front *httptest.Server
145 mesh *meshSvc
146 sock string
147
148 seenHost atomic.Value // last Host the backend instance saw
149 fwdLeaked atomic.Bool // the forwarded marker reached the backend
150}
151
152func (box *meshTestBox) self() string { return box.front.URL }
153
154func newMeshTestBox(t *testing.T, name string, store meshStore, clk *fakeClock, domains []string) *meshTestBox {
155 t.Helper()
156 box := &meshTestBox{name: name, b: &fakeBackend{}}
157
158 reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
159 if err != nil {
160 t.Fatalf("open registry: %v", err)
161 }
162 box.reg = reg
163 box.act = newActivator(hibernateConfig{Bucket: "test-bucket", Idle: time.Minute}, box.b, reg)
164 box.act.now = clk.now
165
166 box.sock = tmpSock(t)
167 ln, err := net.Listen("unix", box.sock)
168 if err != nil {
169 t.Fatalf("listen unix: %v", err)
170 }
171 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172 box.seenHost.Store(r.Host)
173 if r.Header.Get(meshForwardedHeader) != "" {
174 box.fwdLeaked.Store(true)
175 }
176 io.WriteString(w, name)
177 })}
178 go srv.Serve(ln)
179 t.Cleanup(func() { srv.Close() })
180
181 box.rt, err = newRouter(reg, box.act)
182 if err != nil {
183 t.Fatalf("new router: %v", err)
184 }
185 box.front = httptest.NewServer(box.rt)
186 t.Cleanup(box.front.Close)
187
188 box.mesh = newMesh(box.front.URL, store, domains)
189 box.mesh.now = clk.now
190 box.mesh.reg = reg // region-scoped placement reads home_region from the registry
191 box.act.mesh = box.mesh
192 return box
193}
194
195// addHost registers a hibernating route for host on this box (pointing at its
196// stand-in instance socket) and reloads the router.
197func (box *meshTestBox) addHost(t *testing.T, host string) {
198 t.Helper()
199 if err := box.reg.upsert(Route{Host: host, Kind: "instance", Socket: box.sock, Hibernate: true}); err != nil {
200 t.Fatal(err)
201 }
202 if err := box.rt.reload(); err != nil {
203 t.Fatalf("reload: %v", err)
204 }
205}
206
207// getVia is proxyGet plus arbitrary headers.
208func getVia(t *testing.T, base, host string, hdr map[string]string) (string, int) {
209 t.Helper()
210 req, err := http.NewRequest(http.MethodGet, base+"/", nil)
211 if err != nil {
212 t.Fatalf("new request: %v", err)
213 }
214 req.Host = host
215 for k, v := range hdr {
216 req.Header.Set(k, v)
217 }
218 resp, err := http.DefaultClient.Do(req)
219 if err != nil {
220 t.Fatalf("request: %v", err)
221 }
222 defer resp.Body.Close()
223 b, _ := io.ReadAll(resp.Body)
224 return string(b), resp.StatusCode
225}
226
227// --- rendezvous hashing ----------------------------------------------------------
228
229// hrwOwner is a pure function: same answer for any ordering of the member
230// set, on every box, forever — that's what lets placement need no directory.
231func TestHRWOwnerDeterministic(t *testing.T) {
232 boxes := []string{"http://a", "http://b", "http://c"}
233 perms := [][]string{
234 {"http://a", "http://b", "http://c"},
235 {"http://c", "http://a", "http://b"},
236 {"http://b", "http://c", "http://a"},
237 }
238 owned := map[string]int{}
239 for i := 0; i < 300; i++ {
240 h := fmt.Sprintf("host-%d.test", i)
241 want := hrwOwner(h, boxes)
242 for _, p := range perms {
243 if got := hrwOwner(h, p); got != want {
244 t.Fatalf("owner of %s depends on member order: %q vs %q", h, got, want)
245 }
246 }
247 owned[want]++
248 }
249 // The hash must actually spread load — every box owns a healthy share.
250 for _, b := range boxes {
251 if owned[b] < 50 {
252 t.Fatalf("box %s owns only %d/300 hosts — distribution is broken: %v", b, owned[b], owned)
253 }
254 }
255 if hrwOwner("x.test", []string{"http://solo"}) != "http://solo" {
256 t.Fatal("single box does not own everything")
257 }
258 if hrwOwner("x.test", nil) != "" {
259 t.Fatal("empty member set should own nothing")
260 }
261}
262
263// The property that makes membership churn cheap: adding a box moves only the
264// ~1/N of hosts it steals (all TO the joiner), removing one moves only the
265// leaver's hosts (scattered over survivors) — everyone else stays put, so a
266// scale event never stampedes the fleet.
267func TestHRWRedistributionMinimal(t *testing.T) {
268 four := []string{"http://a", "http://b", "http://c", "http://d"}
269 five := append(append([]string{}, four...), "http://e")
270 const n = 1000
271
272 before := make(map[string]string, n)
273 for i := 0; i < n; i++ {
274 h := fmt.Sprintf("host-%d.test", i)
275 before[h] = hrwOwner(h, four)
276 }
277
278 // Join: every moved host must have moved onto the new box.
279 moved := 0
280 for h, was := range before {
281 now := hrwOwner(h, five)
282 if now != was {
283 moved++
284 if now != "http://e" {
285 t.Fatalf("join moved %s from %s to %s — only the joiner may steal", h, was, now)
286 }
287 }
288 }
289 if moved < n/10 || moved > n/2 {
290 t.Fatalf("join moved %d/%d hosts, want ~1/5 (a fifth box should steal about its share)", moved, n)
291 }
292
293 // Leave: only the leaver's hosts move.
294 three := []string{"http://a", "http://c", "http://d"}
295 for h, was := range before {
296 now := hrwOwner(h, three)
297 if was == "http://b" {
298 if now == "http://b" {
299 t.Fatalf("%s still owned by the removed box", h)
300 }
301 } else if now != was {
302 t.Fatalf("leave moved %s from %s to %s — hosts not owned by the leaver must not move", h, was, now)
303 }
304 }
305}
306
307// --- membership --------------------------------------------------------------
308
309// Staleness: a heartbeat older than staleAfter means a dead box — judged at
310// read time, so a box that dies between polls drops out on the clock, not on
311// the next LIST. Self is always a member (a box counts itself even before its
312// first PUT lands), and garbage records never enter the hash.
313func TestMeshMembershipStaleness(t *testing.T) {
314 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
315 store := newFakeMeshStore(clk.now)
316 m := newMesh("http://a", store, nil)
317 m.now = clk.now
318
319 // Before any beat: membership of one — the degenerate single box.
320 if got := m.liveBoxes(); len(got) != 1 || got[0] != "http://a" {
321 t.Fatalf("liveBoxes before any beat = %v, want just self", got)
322 }
323
324 store.setBeat("http://b", clk.now())
325 store.setBeat("this is not a url", clk.now()) // a foreign/garbage record
326 m.beat()
327 if !store.hasBeat("http://a") {
328 t.Fatal("beat did not PUT our own heartbeat")
329 }
330 live := m.liveBoxes()
331 if len(live) != 2 {
332 t.Fatalf("liveBoxes = %v, want self+b (garbage excluded)", live)
333 }
334
335 // b goes quiet: past staleAfter it is dead — with NO new list needed.
336 clk.advance(m.staleAfter + time.Second)
337 if got := m.liveBoxes(); len(got) != 1 || got[0] != "http://a" {
338 t.Fatalf("liveBoxes with a stale peer = %v, want just self", got)
339 }
340}
341
342// The routing decision: proxy only when the HRW owner is another LIVE box;
343// own it (wake locally) when the owner is us — or dead, because a stale owner
344// is out of the member set and the hash has already reassigned. The lease
345// arbitrates if this view is ever wrong; the decision only places.
346func TestMeshRoutingDecision(t *testing.T) {
347 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
348 store := newFakeMeshStore(clk.now)
349 m := newMesh("http://a", store, nil)
350 m.now = clk.now
351 store.setBeat("http://b", clk.now())
352 m.beat()
353
354 boxes := []string{"http://a", "http://b"}
355 theirs := hostOwnedBy(boxes, "http://b")
356 ours := hostOwnedBy(boxes, "http://a")
357
358 if peer, remote := m.remoteOwner(theirs); !remote || peer != "http://b" {
359 t.Fatalf("remoteOwner(%s) = %q, %v; want b, true", theirs, peer, remote)
360 }
361 if _, remote := m.remoteOwner(ours); remote {
362 t.Fatalf("remoteOwner(%s) says proxy a host we own", ours)
363 }
364 if m.owns(theirs) || !m.owns(ours) {
365 t.Fatal("owns() disagrees with remoteOwner()")
366 }
367
368 // The owner dies: its hosts fall through to a local wake.
369 clk.advance(m.staleAfter + time.Second)
370 if _, remote := m.remoteOwner(theirs); remote {
371 t.Fatal("remoteOwner still proxies to a stale (dead) box — its hosts must wake locally")
372 }
373 if !m.owns(theirs) {
374 t.Fatal("survivor does not own the dead box's host")
375 }
376}
377
378// --- domain scoping ------------------------------------------------------------
379
380// inScope's matching rule: exact domain or a proper subdomain of one, case-
381// insensitive (hosts and configured domains alike); nothing else.
382func TestMeshInScope(t *testing.T) {
383 m := newMesh("http://a", newFakeMeshStore(time.Now), []string{"11ch.at", "11relay.com"})
384 cases := []struct {
385 host string
386 want bool
387 }{
388 {"11ch.at", true}, // the bare domain itself
389 {"abc.11ch.at", true}, // a subdomain
390 {"ABC.11CH.AT", true}, // case-insensitive
391 {"11relay.com", true},
392 {"abc.11relay.com", true},
393 {"family.luan.chat", false}, // a different domain entirely
394 {"evil11ch.at", false}, // must not match on a bare suffix
395 {"11ch.at.evil.com", false}, // must not match as a prefix
396 }
397 for _, c := range cases {
398 if got := m.inScope(c.host); got != c.want {
399 t.Errorf("inScope(%q) = %v, want %v", c.host, got, c.want)
400 }
401 }
402}
403
404// An out-of-scope host always resolves locally, regardless of what HRW would
405// otherwise say — it's never proxied away, and this box always reports owning
406// it (the sweep's due-wake gate must never skip it). Domain scoping exists
407// because a registry row for an out-of-scope host is only ever replicated to
408// the box(es) it already lives on (control.go's registerInstance): a host in
409// scope legitimately has rows on every mesh member and needs real HRW
410// placement; a host out of scope does not, and hashing it elsewhere would
411// just 404 there.
412func TestMeshDomainScopingBypassesHRW(t *testing.T) {
413 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
414 store := newFakeMeshStore(clk.now)
415 m := newMesh("http://a", store, []string{"11ch.at"})
416 m.now = clk.now
417 store.setBeat("http://b", clk.now())
418 m.beat()
419
420 boxes := []string{"http://a", "http://b"}
421 // A host under the in-scope domain that genuinely hashes to the peer.
422 // hostOwnedBy's "space-N.test" hosts fall under no configured domain, so
423 // build one under 11ch.at directly.
424 var inScopeTheirs string
425 for i := 0; ; i++ {
426 h := fmt.Sprintf("space-%d.11ch.at", i)
427 if hrwOwner(h, boxes) == "http://b" {
428 inScopeTheirs = h
429 break
430 }
431 }
432 if peer, remote := m.remoteOwner(inScopeTheirs); !remote || peer != "http://b" {
433 t.Fatalf("in-scope host: remoteOwner = %q, %v; want b, true", peer, remote)
434 }
435
436 // The same host name, but under a domain the mesh isn't scoped to. Find
437 // one that HRW would (incorrectly, if honored) assign to the peer.
438 var outOfScope string
439 for i := 0; ; i++ {
440 h := fmt.Sprintf("family-%d.luan.chat", i)
441 if hrwOwner(h, boxes) == "http://b" {
442 outOfScope = h
443 break
444 }
445 }
446 if _, remote := m.remoteOwner(outOfScope); remote {
447 t.Fatalf("out-of-scope host %q was proxied away — domain scoping did not apply", outOfScope)
448 }
449 if !m.owns(outOfScope) {
450 t.Fatalf("out-of-scope host %q: owns() = false — the due-wake gate would wrongly skip it", outOfScope)
451 }
452}
453
454// --- region placement (docs/multi-edge.md, Phase 1) -----------------------------
455
456// candidatesFor scopes HRW to the live boxes whose region matches a space's
457// home_region, with an all-boxes fallback that keeps pre-region behaviour
458// byte-identical. One setup, four rules the task pins:
459// (a) a space pinned to a region routes to that region's box;
460// (b) a space with empty home_region falls back to all-boxes HRW (== today);
461// (c) a home_region no live box advertises falls back to all-boxes;
462// (d) a peer that advertises no region is treated as agnostic (only ever a
463// fallback candidate, never a region match).
464func TestMeshRegionPlacement(t *testing.T) {
465 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
466 store := newFakeMeshStore(clk.now)
467 reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
468 if err != nil {
469 t.Fatalf("open registry: %v", err)
470 }
471
472 // Three live boxes: self in "ie", a peer in "fi", and an agnostic peer that
473 // advertises no region at all (rule d).
474 m := newMesh("http://a", store, []string{"11ch.at"})
475 m.now = clk.now
476 m.reg = reg
477 m.region = "ie"
478 store.setBeat("http://b", clk.now())
479 store.setRegion("http://b", "fi")
480 store.setBeat("http://c", clk.now()) // agnostic peer — no setRegion
481 m.beat() // our PUT records self's region; List+Regions load the view
482
483 all := []string{"http://a", "http://b", "http://c"}
484 if got := m.liveBoxes(); !sameSet(got, all) {
485 t.Fatalf("liveBoxes = %v, want %v", got, all)
486 }
487
488 // A helper to register an in-scope space with a given home_region.
489 pin := func(host, region string) {
490 if err := reg.upsert(Route{Host: host, Kind: "instance", Socket: "/x.sock", Hibernate: true, HomeRegion: region}); err != nil {
491 t.Fatalf("upsert %s: %v", host, err)
492 }
493 }
494
495 // (a) Pinned to "fi" → only the fi box is a candidate, so it owns and every
496 // other box proxies to it, regardless of what bare HRW over all three says.
497 pin("fi-space.11ch.at", "fi")
498 if got := m.candidatesFor("fi-space.11ch.at"); !sameSet(got, []string{"http://b"}) {
499 t.Fatalf("home_region fi: candidates = %v, want [http://b]", got)
500 }
501 if m.owns("fi-space.11ch.at") {
502 t.Fatal("box a wrongly owns an fi-pinned space")
503 }
504 if peer, remote := m.remoteOwner("fi-space.11ch.at"); !remote || peer != "http://b" {
505 t.Fatalf("remoteOwner(fi-space) = %q,%v; want http://b,true", peer, remote)
506 }
507
508 // Self-region match: a space pinned to our own "ie" is owned locally.
509 pin("ie-space.11ch.at", "ie")
510 if got := m.candidatesFor("ie-space.11ch.at"); !sameSet(got, []string{"http://a"}) {
511 t.Fatalf("home_region ie: candidates = %v, want [http://a]", got)
512 }
513 if !m.owns("ie-space.11ch.at") {
514 t.Fatal("box a does not own an ie-pinned space it is the region for")
515 }
516
517 // (b) Empty home_region (a host with no row at all is empty too) → all boxes,
518 // and the chosen owner is identical to bare HRW over the whole live set.
519 noRegion := "plain.11ch.at" // no reg row → homeRegionOf ""
520 if got := m.candidatesFor(noRegion); !sameSet(got, all) {
521 t.Fatalf("empty home_region: candidates = %v, want all %v", got, all)
522 }
523 if hrwOwner(noRegion, m.candidatesFor(noRegion)) != hrwOwner(noRegion, m.liveBoxes()) {
524 t.Fatal("empty home_region must place identically to pre-region HRW")
525 }
526
527 // (c) A home_region no live box advertises → fall back to all boxes.
528 pin("de-space.11ch.at", "de")
529 if got := m.candidatesFor("de-space.11ch.at"); !sameSet(got, all) {
530 t.Fatalf("unmatched home_region de: candidates = %v, want all %v", got, all)
531 }
532
533 // (d) The agnostic peer "http://c" is never a region match (it advertised
534 // none), yet is present in every all-boxes fallback above — already asserted
535 // by the sameSet(all) checks that include it.
536}
537
538// TestMeshRegionAgnosticUnchanged pins the core back-compat promise directly: a
539// mesh with no region set and no registry (exactly how the existing unit tests
540// and today's fleet run) has candidatesFor == liveBoxes for every host, so
541// owns/remoteOwner cannot diverge from pre-region behaviour.
542func TestMeshRegionAgnosticUnchanged(t *testing.T) {
543 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
544 store := newFakeMeshStore(clk.now)
545 m := newMesh("http://a", store, []string{"11ch.at"})
546 m.now = clk.now // no region, no reg — the pre-region shape
547 store.setBeat("http://b", clk.now())
548 m.beat()
549
550 for i := 0; i < 50; i++ {
551 host := fmt.Sprintf("space-%d.11ch.at", i)
552 if !sameSet(m.candidatesFor(host), m.liveBoxes()) {
553 t.Fatalf("region-agnostic mesh scoped %s away from the full live set", host)
554 }
555 }
556}
557
558// TestMeshBeatRegionFetchGate proves the zero-cost-when-inert promise: a box
559// with no region declared (prod today) fetches NO peer regions on a beat, so
560// region placement adds zero S3 requests over pre-region behaviour; a box that
561// declares a region does fetch them.
562func TestMeshBeatRegionFetchGate(t *testing.T) {
563 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
564
565 // Agnostic box: beats never touch Regions.
566 store := newFakeMeshStore(clk.now)
567 agnostic := newMesh("http://a", store, []string{"11ch.at"})
568 agnostic.now = clk.now // no region
569 agnostic.beat()
570 agnostic.beat()
571 if got := store.regionsCalls(); got != 0 {
572 t.Fatalf("agnostic box made %d Regions call(s) across two beats, want 0 — an inert feature must cost nothing", got)
573 }
574
575 // Region-scoped box: each beat fetches peer regions.
576 store2 := newFakeMeshStore(clk.now)
577 scoped := newMesh("http://b", store2, []string{"11ch.at"})
578 scoped.now = clk.now
579 scoped.region = "ie"
580 scoped.beat()
581 scoped.beat()
582 if got := store2.regionsCalls(); got != 2 {
583 t.Fatalf("region-scoped box made %d Regions call(s) across two beats, want 2", got)
584 }
585}
586
587// sameSet compares two peer slices ignoring order (liveBoxes/candidatesFor draw
588// from a map, so order is not defined).
589func sameSet(a, b []string) bool {
590 if len(a) != len(b) {
591 return false
592 }
593 seen := make(map[string]int, len(a))
594 for _, x := range a {
595 seen[x]++
596 }
597 for _, y := range b {
598 seen[y]--
599 }
600 for _, n := range seen {
601 if n != 0 {
602 return false
603 }
604 }
605 return true
606}
607
608// meshFromEnv reads LCHAT_MESH_REGION (optional): set = this box's placement
609// region, unset = region-agnostic (the empty string, today's behaviour).
610func TestMeshFromEnvRegion(t *testing.T) {
611 t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal")
612 t.Setenv("LCHAT_MESH_DOMAINS", "11ch.at")
613 t.Setenv("LCHAT_MESH_TOKEN", "shared-secret")
614
615 t.Setenv("LCHAT_MESH_REGION", "")
616 m, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"})
617 if err != nil || m == nil {
618 t.Fatalf("unset region: %v, %v", m, err)
619 }
620 if m.region != "" {
621 t.Fatalf("region = %q, want \"\" (agnostic) when LCHAT_MESH_REGION is unset", m.region)
622 }
623
624 t.Setenv("LCHAT_MESH_REGION", " ie ")
625 m, err = meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"})
626 if err != nil || m == nil {
627 t.Fatalf("set region: %v, %v", m, err)
628 }
629 if m.region != "ie" {
630 t.Fatalf("region = %q, want the trimmed \"ie\"", m.region)
631 }
632}
633
634// meshFromEnv refuses to start the mesh without an explicit domain allowlist —
635// failing loudly beats silently letting HRW assign a host to a box that has
636// no registry row for it (see the inScope doc comment).
637func TestMeshFromEnvRequiresDomains(t *testing.T) {
638 t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal")
639 t.Setenv("LCHAT_MESH_TOKEN", "shared-secret")
640 t.Setenv("LCHAT_MESH_DOMAINS", "")
641 if _, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"}); err == nil {
642 t.Fatal("mesh without LCHAT_MESH_DOMAINS should be a config error")
643 }
644
645 t.Setenv("LCHAT_MESH_DOMAINS", "11ch.at, 11relay.com")
646 m, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"})
647 if err != nil || m == nil {
648 t.Fatalf("valid domains: %v, %v", m, err)
649 }
650 if len(m.domains) != 2 || m.domains[0] != "11ch.at" || m.domains[1] != "11relay.com" {
651 t.Fatalf("domains = %v, want the parsed, trimmed, lowercased list", m.domains)
652 }
653}
654
655// meshFromEnv refuses to start the mesh without a shared route-replication
656// secret — an unauthenticated /api/mesh/route would let any caller hand this
657// box arbitrary routes (see meshreplicate.go).
658func TestMeshFromEnvRequiresToken(t *testing.T) {
659 t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal")
660 t.Setenv("LCHAT_MESH_DOMAINS", "11ch.at")
661 t.Setenv("LCHAT_MESH_TOKEN", "")
662 if _, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"}); err == nil {
663 t.Fatal("mesh without LCHAT_MESH_TOKEN should be a config error")
664 }
665
666 t.Setenv("LCHAT_MESH_TOKEN", "shared-secret")
667 m, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"})
668 if err != nil || m == nil {
669 t.Fatalf("valid token: %v, %v", m, err)
670 }
671 if m.token != "shared-secret" {
672 t.Fatalf("token = %q, want it read through", m.token)
673 }
674}
675
676// --- the proxy hop -------------------------------------------------------------
677
678// Scheme/Host/SNI semantics of the peer hop: the instance hostname survives
679// (the peer routes by Host); for https peers the outgoing URL keeps the
680// instance host so SNI and cert verification run against a name the peer can
681// actually prove (its wildcard/per-host cert), while the dialer pins TCP to
682// the peer's address; for http peers the URL targets the peer directly. The
683// forwarded marker rides every hop. InsecureSkipVerify appears nowhere.
684func TestPeerProxyRewrite(t *testing.T) {
685 rewrite := func(p *httputil.ReverseProxy, host string) *http.Request {
686 in := httptest.NewRequest("GET", "http://placeholder/api/x", nil)
687 in.Host = host
688 in.RemoteAddr = "192.0.2.7:4711"
689 out := in.Clone(context.Background())
690 p.Rewrite(&httputil.ProxyRequest{In: in, Out: out})
691 return out
692 }
693
694 httpsPeer, _ := url.Parse("https://box-b.internal")
695 out := rewrite(newPeerProxy("http://self", httpsPeer), "space.11ch.at")
696 if out.URL.Scheme != "https" || out.URL.Host != "space.11ch.at" || out.Host != "space.11ch.at" {
697 t.Fatalf("https hop: scheme=%s urlHost=%s host=%s — SNI/verification must target the instance host", out.URL.Scheme, out.URL.Host, out.Host)
698 }
699 if got := peerDialAddr(httpsPeer); got != "box-b.internal:443" {
700 t.Fatalf("https dial addr = %q, want the peer's address", got)
701 }
702 if out.Header.Get(meshForwardedHeader) != "http://self" {
703 t.Fatal("forwarded marker missing — the one-hop loop guard would not hold")
704 }
705
706 httpPeer, _ := url.Parse("http://10.0.0.5:9000")
707 out = rewrite(newPeerProxy("http://self", httpPeer), "space.11ch.at")
708 if out.URL.Scheme != "http" || out.URL.Host != "10.0.0.5:9000" || out.Host != "space.11ch.at" {
709 t.Fatalf("http hop: scheme=%s urlHost=%s host=%s — dial the peer, Host stays the instance", out.URL.Scheme, out.URL.Host, out.Host)
710 }
711 if got := peerDialAddr(httpPeer); got != "10.0.0.5:9000" {
712 t.Fatalf("http dial addr = %q", got)
713 }
714 bare, _ := url.Parse("http://box-c.internal")
715 if got := peerDialAddr(bare); got != "box-c.internal:80" {
716 t.Fatalf("default http port: %q", got)
717 }
718}
719
720// Two boxes, one shared "bucket": a request landing on the non-owner is
721// reverse-proxied to the owner (which wakes the instance), the instance
722// hostname survives the hop, the forwarded marker never reaches a backend,
723// a request already carrying the marker is served where it stands (one hop
724// max), and a host with a live local entry stays local even when the hash
725// points elsewhere.
726func TestMeshProxyToOwner(t *testing.T) {
727 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
728 store := newFakeMeshStore(clk.now)
729 box1 := newMeshTestBox(t, "box1", store, clk, nil)
730 box2 := newMeshTestBox(t, "box2", store, clk, nil)
731 // Beat both (twice for box1, whose first beat predates box2's PUT) so
732 // each box sees the full membership.
733 box1.mesh.beat()
734 box2.mesh.beat()
735 box1.mesh.beat()
736
737 boxes := []string{box1.self(), box2.self()}
738 host := hostOwnedBy(boxes, box2.self())
739 box1.addHost(t, host)
740 box2.addHost(t, host)
741
742 // (a) Request to the NON-owner: proxied to the owner and served there.
743 if body, code := proxyGet(t, box1.front.URL, host); code != 200 || body != "box2" {
744 t.Fatalf("non-owner request: got %d %q, want 200 box2 (proxied to owner)", code, body)
745 }
746 if acq, _, _ := box2.b.counts(); acq != 1 {
747 t.Fatalf("owner acquires = %d, want 1 (the wake happened on the owner)", acq)
748 }
749 if acq, _, starts := box1.b.counts(); acq != 0 || starts != 0 {
750 t.Fatalf("non-owner touched its own activator (acquires=%d starts=%d)", acq, starts)
751 }
752 if got, _ := box2.seenHost.Load().(string); got != host {
753 t.Fatalf("owner backend saw Host %q, want %q — the hostname must survive the hop", got, host)
754 }
755 if box2.fwdLeaked.Load() {
756 t.Fatal("the forwarded marker leaked through to the backend")
757 }
758
759 // (b) Request straight to the owner: local, no extra wake.
760 if body, code := proxyGet(t, box2.front.URL, host); code != 200 || body != "box2" {
761 t.Fatalf("owner request: got %d %q", code, body)
762 }
763 if acq, _, _ := box2.b.counts(); acq != 1 {
764 t.Fatalf("owner re-acquired for a host it already has awake (acquires=%d)", acq)
765 }
766
767 // (c) One hop max: a request already carrying the forwarded marker is
768 // served where it stands — here the non-owner, which wakes it locally
769 // (the lease would arbitrate a real conflict; the fake just observes).
770 var host2 string // a second box2-owned host, distinct from the first
771 for i := 0; ; i++ {
772 if h := fmt.Sprintf("space-b-%d.test", i); hrwOwner(h, boxes) == box2.self() {
773 host2 = h
774 break
775 }
776 }
777 box1.addHost(t, host2)
778 box2.addHost(t, host2)
779 if body, code := getVia(t, box1.front.URL, host2, map[string]string{meshForwardedHeader: "http://elsewhere"}); code != 200 || body != "box1" {
780 t.Fatalf("forwarded request: got %d %q, want 200 box1 (served in place, never re-proxied)", code, body)
781 }
782 if acq, _, _ := box1.b.counts(); acq != 1 {
783 t.Fatalf("non-owner acquires = %d after forwarded request, want 1", acq)
784 }
785
786 // (d) Live-local-entry rule: host2 is now awake on box1, so an ordinary
787 // request to box1 stays local despite the hash pointing at box2 — an
788 // awake instance finishes its life where it is.
789 if body, code := proxyGet(t, box1.front.URL, host2); code != 200 || body != "box1" {
790 t.Fatalf("live-entry request: got %d %q, want 200 box1 (local entry wins over the hint)", code, body)
791 }
792 if acq, _, _ := box2.b.counts(); acq != 1 {
793 t.Fatalf("owner was woken for a host living on its peer (acquires=%d)", acq)
794 }
795}
796
797// --- mesh off: the tripwire ------------------------------------------------------
798
799// With the mesh off (LCHAT_MESH_SELF unset → act.mesh nil) the activator is
800// byte-identical to the single-box shape: a host whose HRW owner WOULD be a
801// peer is woken locally, nothing is proxied, and no heartbeat is ever
802// written. The same box with only the mesh flipped on proxies the same-shaped
803// request — proving the nil check is the single divergence point.
804func TestMeshOffTripwire(t *testing.T) {
805 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
806 store := newFakeMeshStore(clk.now)
807 box1 := newMeshTestBox(t, "box1", store, clk, nil)
808 box2 := newMeshTestBox(t, "box2", store, clk, nil)
809 box1.act.mesh = nil // the tripwire: box1 is mesh-off
810 box2.mesh.beat() // box2 participates; its heartbeat is in the store
811
812 boxes := []string{box1.self(), box2.self()}
813 host := hostOwnedBy(boxes, box2.self())
814 box1.addHost(t, host)
815 box2.addHost(t, host)
816
817 if body, code := proxyGet(t, box1.front.URL, host); code != 200 || body != "box1" {
818 t.Fatalf("mesh-off request: got %d %q, want 200 box1 (woken locally, never proxied)", code, body)
819 }
820 if acq, _, _ := box1.b.counts(); acq != 1 {
821 t.Fatalf("mesh-off box acquires = %d, want 1", acq)
822 }
823 if acq, _, _ := box2.b.counts(); acq != 0 {
824 t.Fatalf("peer was touched by a mesh-off box (acquires=%d)", acq)
825 }
826 if store.hasBeat(box1.self()) {
827 t.Fatal("mesh-off box wrote a heartbeat")
828 }
829
830 // Contrast: flip the mesh on — the ONLY change — and the same-shaped
831 // request (a fresh host, since the first now has a live local entry)
832 // proxies to the owner.
833 box1.act.mesh = box1.mesh
834 box1.mesh.beat()
835 var host2 string
836 for i := 0; ; i++ {
837 h := fmt.Sprintf("contrast-%d.test", i)
838 if hrwOwner(h, boxes) == box2.self() {
839 host2 = h
840 break
841 }
842 }
843 box1.addHost(t, host2)
844 box2.addHost(t, host2)
845 if body, code := proxyGet(t, box1.front.URL, host2); code != 200 || body != "box2" {
846 t.Fatalf("mesh-on contrast: got %d %q, want 200 box2 (proxied)", code, body)
847 }
848}
849
850// meshFromEnv: unset = off (nil, nil — the zero-change default); set without
851// the hibernation bucket = a loud config error; malformed URLs rejected.
852func TestMeshFromEnv(t *testing.T) {
853 // A valid domain allowlist and token throughout —
854 // TestMeshFromEnvRequiresDomains/TestMeshFromEnvRequiresToken cover their
855 // missing cases on their own.
856 t.Setenv("LCHAT_MESH_DOMAINS", "11ch.at")
857 t.Setenv("LCHAT_MESH_TOKEN", "shared-secret")
858
859 t.Setenv("LCHAT_MESH_SELF", "")
860 if m, err := meshFromEnv(&hibernateConfig{Bucket: "b"}); err != nil || m != nil {
861 t.Fatalf("unset: got %v, %v; want nil, nil (mesh off)", m, err)
862 }
863
864 t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal")
865 if _, err := meshFromEnv(nil); err == nil {
866 t.Fatal("mesh without a hibernation bucket should be a config error")
867 }
868
869 for _, bad := range []string{"box-a.internal", "ftp://box-a", "https://box-a/path"} {
870 t.Setenv("LCHAT_MESH_SELF", bad)
871 if _, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"}); err == nil {
872 t.Fatalf("LCHAT_MESH_SELF %q should be rejected", bad)
873 }
874 }
875
876 t.Setenv("LCHAT_MESH_SELF", "https://box-a.internal/")
877 m, err := meshFromEnv(&hibernateConfig{Bucket: "b", Region: "eu-west-1"})
878 if err != nil || m == nil {
879 t.Fatalf("valid self: %v, %v", m, err)
880 }
881 if m.self != "https://box-a.internal" {
882 t.Fatalf("self = %q, want the trailing slash trimmed", m.self)
883 }
884}
885
886// --- drain ordering ---------------------------------------------------------------
887
888// The drain contract, order-pinned: the heartbeat is deleted FIRST — before
889// the next-due probe, before the child stops, before the final checkpoint and
890// lease release — so peers take over the moment the leases free up. Same code
891// path for deploys, box failure drills, and scale-down.
892func TestMeshDrainDeletesHeartbeatFirst(t *testing.T) {
893 a, b, clk, _ := testActivator(t, time.Hour)
894 store := newFakeMeshStore(clk.now)
895 store.rec = b.record // interleave heartbeat ops with the backend's order log
896 m := newMesh("http://box-a", store, nil)
897 m.now = clk.now
898 m.interval = time.Hour // the loop must never beat on its own mid-test
899 a.mesh = m
900 m.start()
901
902 release, err := a.beginRequest(context.Background(), testRoute("drain.example.com"))
903 if err != nil {
904 t.Fatalf("beginRequest: %v", err)
905 }
906 release()
907
908 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
909 defer cancel()
910 a.shutdown(ctx)
911
912 if store.hasBeat("http://box-a") {
913 t.Fatal("drain left our heartbeat behind")
914 }
915 ev := b.events()
916 del := indexOf(ev, "hb-delete")
917 if del == -1 {
918 t.Fatalf("no heartbeat delete in %v", ev)
919 }
920 if put := indexOf(ev, "hb-put"); put == -1 || put > del {
921 t.Fatalf("heartbeat lifecycle out of order: %v", ev)
922 }
923 for _, later := range []string{"nextDue", "stop:instance"} {
924 if i := indexOf(ev, later); i == -1 || i < del {
925 t.Fatalf("drain order wrong: %q at %d, hb-delete at %d — the heartbeat must go first (%v)", later, i, del, ev)
926 }
927 }
928}
929
930// --- due-time sweeps under the mesh -----------------------------------------------
931
932// Only the HRW owner due-wakes a host; a non-owner skips it every tick. When
933// the owner dies, staleness reassigns ownership and the next sweep fires the
934// wake here — failover for schedules is the same eventual path as requests.
935func TestMeshDueSweepOnlyOwner(t *testing.T) {
936 a, b, clk, reg := testActivator(t, 10*time.Second)
937 store := newFakeMeshStore(clk.now)
938 m := newMesh("http://box-a", store, nil)
939 m.now = clk.now
940 a.mesh = m
941 store.setBeat("http://box-b", clk.now())
942 m.beat()
943
944 host := hostOwnedBy([]string{"http://box-a", "http://box-b"}, "http://box-b")
945 route := testRoute(host)
946 if err := reg.upsert(route); err != nil {
947 t.Fatal(err)
948 }
949 if err := reg.setNextDue(host, clk.now().Add(-time.Second).Unix()); err != nil {
950 t.Fatal(err)
951 }
952
953 // The peer owns it: our sweeps must not wake it, however overdue.
954 for i := 0; i < 3; i++ {
955 a.sweepOnce()
956 }
957 if _, _, starts := b.counts(); starts != 0 {
958 t.Fatalf("starts=%d — a non-owner due-woke a host", starts)
959 }
960
961 // The owner dies (heartbeat goes stale): ownership reassigns to us on the
962 // next read, and the sweep fires the wake.
963 clk.advance(m.staleAfter + time.Second)
964 a.sweepOnce()
965 waitFor(t, "post-failover due wake", func() bool { return a.entryAwake(host) })
966 if _, _, starts := b.counts(); starts != 1 {
967 t.Fatalf("starts=%d after owner death, want 1", starts)
968 }
969}
970