carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Task 10 of the customer-fleets plan: the two-box fleet acceptance test —
2// an edge and TWO fake fleet boxes dialing REAL reverse channels
3// ("pi/box-a", "pi/box-b", authenticated out of the registry), driven
4// through the whole failure sequence the design spec promises:
5//
6// (a) a bound route rides its bound box's channel;
7// (b) the bound box goes dark -> the survivor serves (rule 2), wakes,
8// wins the lapsed lease, publishes a takeover binding, and the
9// platform's ReconcileFleetBindings restamps Route.FleetBox to it;
10// (c) the dead box comes back, LOSES the reclaim (lease.HeldError) and
11// must not steal routing back;
12// (d) rotate/detach: rotation refuses NEW dials with the old token
13// within one fingerprint poll but does NOT evict the live session;
14// detach closes the channel outright;
15// (e) both boxes dark -> 503 + Retry-After: 2.
16//
17// It is `package edge_test`, not `package edge`, and that is forced, not
18// stylistic: this test has to drive the REAL binding writer and reconciler
19// (internal/host), and internal/host imports internal/edge — the cycle the
20// tunnelclient package's own doc comment calls out. An external test package
21// may import a package that depends on the package under test, so the file
22// stays where the plan puts it (internal/edge/) and pays for it only in a
23// few small helpers duplicated from tunnel_e2e_test.go (startFleetBox,
24// waitChannel) that cannot be shared across the package boundary.
25//
26// Hermetic and fast (~10s): loopback httptest edge, real h2 reverse channels
27// via the production tunnelclient.Loop, a DirStore for the platform's
28// deployment bucket, and an in-test model of the per-host S3 lease. Only the
29// LEASE is modeled — see fakeFleetLease — because internal/lease speaks to S3
30// and its short-TTL seam (acquireTTL) is package-private; the live-S3
31// counterpart is TestDarkHostCannotReclaimAfterFailover in
32// internal/lease/lease_test.go. Everything else here is the real thing.
33package edge_test
34
35import (
36 "context"
37 "crypto/sha256"
38 "encoding/hex"
39 "errors"
40 "fmt"
41 "io"
42 "net/http"
43 "net/http/httptest"
44 "net/url"
45 "path/filepath"
46 "sync"
47 "testing"
48 "time"
49
50 "github.com/carlosframework/platform/internal/edge"
51 "github.com/carlosframework/platform/internal/host"
52 "github.com/carlosframework/platform/internal/lease"
53 "github.com/carlosframework/platform/internal/registry"
54 "github.com/carlosframework/platform/internal/release"
55 "github.com/carlosframework/platform/internal/tunnelclient"
56)
57
58const (
59 e2eFleet = "pi"
60 e2eAppHost = "shop.example"
61
62 // e2eDialBound is how long a fake box gets to complete its first dial (or
63 // a re-dial after a reap) — the same promise tunnel_e2e_test.go's
64 // reconnect bound makes, loose enough that a loaded CI box cannot flake
65 // it.
66 e2eDialBound = 5 * time.Second
67 // e2eConvergeBound is the registry->edge convergence promise: the edge
68 // polls registry.Fingerprint every 2s (Edge.Watch) and reloads routes AND
69 // fleet channel auth on the same tick, so any registry write — a rotated
70 // token, a detached box, a restamped FleetBox — is live within one poll.
71 // 5s is that 2s plus room for a slow reload; a regression to "only on
72 // restart" (or a fingerprint that misses fleet_boxes.rev) fails here.
73 e2eConvergeBound = 5 * time.Second
74 // e2eLeaseTTL models internal/lease's LeaseTTL, compressed from 15
75 // minutes to 2 seconds so a whole go-dark -> lapse -> takeover sequence
76 // fits in a test. The renewer below refreshes at a quarter of it, so a
77 // box that is ALIVE holds its lease indefinitely and a box that has died
78 // (its renewer stopped) lapses within one TTL — which is exactly the
79 // timing property the failover depends on.
80 e2eLeaseTTL = 2 * time.Second
81)
82
83// --- the modeled half: the per-host single-writer lease -----------------------
84
85// fakeFleetLease models internal/lease's per-host S3 lease: at most one box
86// holds a given host at a time, a live hold refuses everyone else with the
87// SAME typed *lease.HeldError the real Acquire surfaces (the error the
88// activator actually reacts to), and a hold lapses TTL after its last
89// renewal. It is a model rather than the real thing because internal/lease
90// talks to S3 and its short-TTL seam is package-private; what this test needs
91// from it is the ARBITRATION — who may publish a takeover binding — not the
92// S3 conditional-write mechanics, which lease_test.go already pins against a
93// live bucket.
94type fakeFleetLease struct {
95 ttl time.Duration
96 mu sync.Mutex
97 held map[string]fakeLeaseHold
98}
99
100type fakeLeaseHold struct {
101 box string
102 expires time.Time
103}
104
105func newFakeFleetLease(ttl time.Duration) *fakeFleetLease {
106 return &fakeFleetLease{ttl: ttl, held: make(map[string]fakeLeaseHold)}
107}
108
109// acquire claims host for box, or fails with a *lease.HeldError (wrapped, as
110// lease.acquireTTL wraps it) if a DIFFERENT box's hold is still live. A box
111// re-acquiring its own live hold succeeds, mirroring the real leaser.
112func (l *fakeFleetLease) acquire(host, box string) error {
113 l.mu.Lock()
114 defer l.mu.Unlock()
115 if cur, ok := l.held[host]; ok && cur.box != box && time.Now().Before(cur.expires) {
116 return fmt.Errorf("acquire lease: %w", &lease.HeldError{Owner: cur.box, ExpiresAt: cur.expires})
117 }
118 l.held[host] = fakeLeaseHold{box: box, expires: time.Now().Add(l.ttl)}
119 return nil
120}
121
122// startRenewer is internal/lease's startRenewer: a live holder keeps pushing
123// its expiry out until it stops. Stopping it is how this test says "that box
124// just died" — no renewals, so the hold lapses one TTL later and a survivor
125// can take over, which is the design spec's failure sequence exactly.
126func (l *fakeFleetLease) startRenewer(host, box string) func() {
127 stop := make(chan struct{})
128 done := make(chan struct{})
129 go func() {
130 defer close(done)
131 t := time.NewTicker(l.ttl / 4)
132 defer t.Stop()
133 for {
134 select {
135 case <-stop:
136 return
137 case <-t.C:
138 l.mu.Lock()
139 if cur, ok := l.held[host]; ok && cur.box == box {
140 l.held[host] = fakeLeaseHold{box: box, expires: time.Now().Add(l.ttl)}
141 }
142 l.mu.Unlock()
143 }
144 }
145 }()
146 var once sync.Once
147 return func() {
148 once.Do(func() { close(stop) })
149 <-done
150 }
151}
152
153// --- helpers ------------------------------------------------------------------
154
155func e2eHashToken(s string) string {
156 sum := sha256.Sum256([]byte(s))
157 return hex.EncodeToString(sum[:])
158}
159
160// startFleetBox runs the PRODUCTION host-side dial loop (the same one
161// `carlos agent` runs on a Pi) against edgeURL under a fleet channel name,
162// and returns a stop func that cancels it and waits — "this box's agent
163// died". Mirrors tunnel_e2e_test.go's startFakeRemoteHost, which lives in
164// `package edge` and so cannot be reached from here.
165func startFleetBox(t *testing.T, edgeURL, name, token string, handler http.Handler) func() {
166 t.Helper()
167 ctx, cancel := context.WithCancel(context.Background())
168 var wg sync.WaitGroup
169 wg.Add(1)
170 go func() {
171 defer wg.Done()
172 tunnelclient.Loop(ctx, tunnelclient.Config{
173 URL: edgeURL, Name: name, Token: token, Handler: handler,
174 Logf: func(format string, args ...any) { t.Logf("fake box "+name+": "+format, args...) },
175 })
176 }()
177 var once sync.Once
178 return func() {
179 once.Do(func() {
180 cancel()
181 wg.Wait() // no t.Logf after the test returns
182 })
183 }
184}
185
186// namedFleetHandler answers every request with the box's own label, so a
187// response body names the box that actually served it — the only way to tell
188// which channel a request rode.
189func namedFleetHandler(name string) http.Handler {
190 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191 io.WriteString(w, name)
192 })
193}
194
195func waitChannel(t *testing.T, hub *edge.TunnelHub, name, want string, within time.Duration) {
196 t.Helper()
197 deadline := time.Now().Add(within)
198 for time.Now().Before(deadline) {
199 if hub.State(name) == want {
200 return
201 }
202 time.Sleep(20 * time.Millisecond)
203 }
204 t.Fatalf("channel %s state = %q, want %q within %s", name, hub.State(name), want, within)
205}
206
207// e2eGet is one client request for the fleet-placed hostname.
208func e2eGet(t *testing.T, edgeURL, host string) (int, string) {
209 t.Helper()
210 resp, err := e2eDo(edgeURL, host, 10*time.Second)
211 if err != nil {
212 t.Fatalf("GET %s (host %s): %v", edgeURL, host, err)
213 }
214 defer resp.Body.Close()
215 body, err := io.ReadAll(resp.Body)
216 if err != nil {
217 t.Fatalf("read body: %v", err)
218 }
219 return resp.StatusCode, string(body)
220}
221
222func e2eDo(edgeURL, host string, timeout time.Duration) (*http.Response, error) {
223 req, err := http.NewRequest("GET", edgeURL+"/", nil)
224 if err != nil {
225 return nil, err
226 }
227 req.Host = host
228 c := &http.Client{Timeout: timeout}
229 return c.Do(req)
230}
231
232// waitBody polls until the fleet host answers 200 with want, which is how the
233// edge's convergence on a registry change (a restamped FleetBox, applied by
234// the 2s Watch poll rebuilding the route's proxy) is observed from outside.
235func waitBody(t *testing.T, edgeURL, host, want string, within time.Duration) {
236 t.Helper()
237 deadline := time.Now().Add(within)
238 var lastCode int
239 var lastBody string
240 for time.Now().Before(deadline) {
241 lastCode, lastBody = e2eGet(t, edgeURL, host)
242 if lastCode == http.StatusOK && lastBody == want {
243 return
244 }
245 time.Sleep(100 * time.Millisecond)
246 }
247 t.Fatalf("host %s answered %d %q, want 200 %q within %s", host, lastCode, lastBody, want, within)
248}
249
250// registerProbe asks the hub's /register endpoint the auth question ALONE: it
251// presents a channel name and bearer token but deliberately omits the
252// Connection/Upgrade headers, so an ACCEPTED credential answers 400 (the
253// wire-contract gate, which sits after auth on purpose) and a REJECTED one
254// answers 401. That distinction is the whole point — it reads the hub's live
255// credential table without hijacking anything, so probing rotation cannot
256// disturb the very session whose survival this test also asserts. A probe
257// that completed a real handshake would replace the live channel and prove
258// the opposite of what it measured.
259func registerProbe(t *testing.T, edgeURL, hubHost, name, token string) int {
260 t.Helper()
261 req, err := http.NewRequest("GET", edgeURL+"/register", nil)
262 if err != nil {
263 t.Fatalf("build probe: %v", err)
264 }
265 req.Host = hubHost
266 req.Header.Set("Carlos-Tunnel-Name", name)
267 req.Header.Set("Authorization", "Bearer "+token)
268 resp, err := http.DefaultClient.Do(req)
269 if err != nil {
270 t.Fatalf("probe %s: %v", name, err)
271 }
272 defer resp.Body.Close()
273 io.Copy(io.Discard, resp.Body)
274 return resp.StatusCode
275}
276
277func waitProbe(t *testing.T, edgeURL, hubHost, name, token string, want int, within time.Duration) time.Duration {
278 t.Helper()
279 start := time.Now()
280 deadline := start.Add(within)
281 var last int
282 for time.Now().Before(deadline) {
283 last = registerProbe(t, edgeURL, hubHost, name, token)
284 if last == want {
285 return time.Since(start)
286 }
287 time.Sleep(100 * time.Millisecond)
288 }
289 t.Fatalf("register probe for %s answered %d, want %d within %s", name, last, want, within)
290 return 0
291}
292
293// --- the test -----------------------------------------------------------------
294
295// TestTwoBoxFleetEndToEnd is the plan's Task 10 acceptance evidence. The legs
296// run in sequence against ONE world (each depends on the state the previous
297// one left), so it is a single function rather than subtests: a failure means
298// the fleet stopped behaving at that step, and nothing after it is meaningful.
299//
300// Box naming is load-bearing. The fallback rule (rule 2) picks
301// ConnectedOfFleet's SORTED FIRST entry, so "box-a" is what a
302// fallback-only implementation would always answer:
303//
304// - Leg (a) binds the route to box-b (sorts LAST) while both boxes are
305// connected — only rule 1 can answer "box-b" there.
306// - Leg (c) then re-connects box-b while the route is bound to box-a: if
307// leg (b)'s restamp had never happened (FleetBox still box-b), or if the
308// returning box had stolen the binding back, rule 1 would answer "box-b".
309// Insisting on "box-a" with BOTH boxes connected is what makes the
310// restamp and the lost reclaim visible rather than assumed.
311func TestTwoBoxFleetEndToEnd(t *testing.T) {
312 ctx := context.Background()
313 const tokenA, tokenB = "tok-a", "tok-b"
314
315 // --- the platform box: registry, edge, hub, 2s watcher --------------------
316 // Deliberately NOT closed at cleanup: Edge.Watch may still be mid-poll when
317 // the test returns (it exits within one 2s tick), and closing the registry
318 // under it would race a live reload for no benefit — the temp dir goes away
319 // either way.
320 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
321 if err != nil {
322 t.Fatalf("open registry: %v", err)
323 }
324 if err := reg.UpsertFleet(registry.Fleet{Name: e2eFleet, Account: "amadan", Bucket: "pi-data"}); err != nil {
325 t.Fatalf("UpsertFleet: %v", err)
326 }
327 for label, tok := range map[string]string{"box-a": tokenA, "box-b": tokenB} {
328 if err := reg.AddFleetBox(registry.FleetBox{
329 Fleet: e2eFleet, Label: label, TokenHash: e2eHashToken(tok), Status: "active",
330 }); err != nil {
331 t.Fatalf("AddFleetBox(%s): %v", label, err)
332 }
333 }
334 // The app: placed on the fleet, bound to box-b (see the naming note above).
335 if err := reg.Upsert(registry.Route{
336 Host: e2eAppHost, Kind: "instance", Status: "ready",
337 Region: registry.PlacementTarget(e2eFleet), FleetBox: "box-b",
338 }); err != nil {
339 t.Fatalf("upsert route: %v", err)
340 }
341
342 e, err := edge.New(reg, nil)
343 if err != nil {
344 t.Fatalf("edge.New: %v", err)
345 }
346 srv := httptest.NewServer(e)
347 t.Cleanup(srv.Close)
348 u, err := url.Parse(srv.URL)
349 if err != nil {
350 t.Fatal(err)
351 }
352 // One listener carries both app traffic and channel registration; the hub
353 // answers on the listener's own hostname (production: CARLOS_TUNNEL_HOST).
354 hub := edge.NewTunnelHub(u.Hostname(), nil)
355 e.EnableTunnels(hub)
356 if err := e.Reload(); err != nil {
357 t.Fatalf("Reload (wire fleet channel auth): %v", err)
358 }
359 // The production watcher, not a hand-driven Reload: every convergence
360 // bound below is therefore measuring the real 2s fingerprint poll.
361 stopWatch := make(chan struct{})
362 t.Cleanup(func() { close(stopWatch) })
363 go e.Watch(stopWatch)
364
365 // --- the fleet: two boxes on real reverse channels ------------------------
366 stopA := startFleetBox(t, srv.URL, e2eFleet+"/box-a", tokenA, namedFleetHandler("box-a"))
367 t.Cleanup(stopA)
368 stopB := startFleetBox(t, srv.URL, e2eFleet+"/box-b", tokenB, namedFleetHandler("box-b"))
369 t.Cleanup(stopB)
370 waitChannel(t, hub, e2eFleet+"/box-a", "connected", e2eDialBound)
371 waitChannel(t, hub, e2eFleet+"/box-b", "connected", e2eDialBound)
372
373 // The platform's deployment bucket: where fleet boxes publish bindings and
374 // where the platform box reads them back. One store, both roles — the
375 // object IS the only channel a box has to say who is serving what.
376 store := release.NewDirStore(t.TempDir())
377
378 // box-b is the current holder: it owns the host's lease and has published
379 // the takeover binding its last wake produced.
380 leases := newFakeFleetLease(e2eLeaseTTL)
381 if err := leases.acquire(e2eAppHost, "box-b"); err != nil {
382 t.Fatalf("box-b initial lease: %v", err)
383 }
384 stopRenewB := leases.startRenewer(e2eAppHost, "box-b")
385 t.Cleanup(stopRenewB)
386 if err := host.WriteFleetBinding(ctx, store, host.FleetBinding{
387 Host: e2eAppHost, Fleet: e2eFleet, Box: "box-b", BoundAt: time.Now().UnixMilli(),
388 }, true); err != nil {
389 t.Fatalf("box-b binding: %v", err)
390 }
391
392 // --- (a) a bound route rides its bound box's channel ----------------------
393 if code, body := e2eGet(t, srv.URL, e2eAppHost); code != http.StatusOK || body != "box-b" {
394 t.Fatalf("(a) got %d %q, want 200 %q — the BOUND box, which sorts last; a fallback-only edge would answer box-a", code, body, "box-b")
395 }
396 t.Log("(a) bound route rode the bound box's channel")
397
398 // --- (b) the bound box goes dark ------------------------------------------
399 // Its agent stops (power, Wi-Fi, a reboot) — so it renews nothing and
400 // answers nothing.
401 stopRenewB()
402 stopB()
403
404 // The lease is what stops a split brain, and it does not lapse the instant
405 // the box dies: the survivor's very next wake attempt must LOSE while
406 // box-b's hold is still live, with the typed error the activator keys off.
407 err = leases.acquire(e2eAppHost, "box-a")
408 if err == nil {
409 t.Fatal("(b) box-a claimed the lease while box-b's hold was still live — that is a split brain, two boxes serving one host")
410 }
411 var held *lease.HeldError
412 if !errors.As(err, &held) {
413 t.Fatalf("(b) contested acquire returned %T (%v), want a *lease.HeldError", err, err)
414 }
415
416 // T4 CARRY (observation, not a gate): the edge does not learn a channel
417 // died from the channel dying. Nothing unregisters a dead conn except
418 // pingLoop — for a fleet channel that is fleetPingEvery (5s) +
419 // fleetPingTimeout (5s), so up to ~10s of black hole (spec D7 narrowed it
420 // from the static channels' ~25s) in which the hub still reports the box
421 // connected and rule 1 keeps dispatching to a corpse instead of falling
422 // over to the healthy survivor. That window IS the venue-Wi-Fi failure
423 // mode: an app stays down for up to ~10s after its box drops off the
424 // network, even though another box could have served it immediately.
425 // Observed here without paying the 10s: the assertions below are that the
426 // channel is STILL registered and the request does NOT reach box-a.
427 if got := hub.State(e2eFleet + "/box-b"); got != "connected" {
428 t.Fatalf("(b) hub state for the dead box = %q, want %q — if the hub now reaps eagerly, the black-hole note above is stale and this test should be updated to assert the better behavior", got, "connected")
429 }
430 resp, err := e2eDo(srv.URL, e2eAppHost, time.Second)
431 if err == nil {
432 defer resp.Body.Close()
433 body, _ := io.ReadAll(resp.Body)
434 if resp.StatusCode == http.StatusOK && string(body) == "box-a" {
435 t.Fatal("(b) the request fell over to box-a before the dead channel was reaped — good news, but it means the ~10s black-hole window this test documents is gone and the comment above needs rewriting")
436 }
437 t.Logf("(b) black-hole window: dead-but-registered channel answered %d %q (no fallover)", resp.StatusCode, string(body))
438 } else {
439 t.Logf("(b) black-hole window: dead-but-registered channel hung until the client gave up (%v)", err)
440 }
441
442 // pingLoop's reap, on demand — CloseChannel is exactly what it calls, so
443 // the rest of the sequence runs at the speed the fleet would see once the
444 // ping has fired, not 10s later.
445 hub.CloseChannel(e2eFleet + "/box-b")
446 waitChannel(t, hub, e2eFleet+"/box-b", "", time.Second)
447
448 // Rule 2: bound box dark, so any connected box of the fleet serves. The
449 // app is up again without any registry write at all.
450 if code, body := e2eGet(t, srv.URL, e2eAppHost); code != http.StatusOK || body != "box-a" {
451 t.Fatalf("(b) after the bound box went dark: got %d %q, want 200 %q (the fallback rule)", code, body, "box-a")
452 }
453 t.Log("(b) fell over to the survivor while still bound to the dead box")
454
455 // The survivor wakes the app locally: it can only claim the lease once
456 // box-b's hold LAPSES (nothing renews it — the box is dark), and only that
457 // win authorizes the takeover binding.
458 acquireDeadline := time.Now().Add(e2eLeaseTTL + 3*time.Second)
459 var won bool
460 for time.Now().Before(acquireDeadline) {
461 if err := leases.acquire(e2eAppHost, "box-a"); err == nil {
462 won = true
463 break
464 }
465 time.Sleep(50 * time.Millisecond)
466 }
467 if !won {
468 t.Fatalf("(b) box-a never won the lease after box-b's hold should have lapsed (TTL %s)", e2eLeaseTTL)
469 }
470 stopRenewA := leases.startRenewer(e2eAppHost, "box-a")
471 t.Cleanup(stopRenewA)
472
473 // The wake-success publish (fleetBindingHook's shape: takeover=true,
474 // millisecond BoundAt) followed by the platform's reconcile. In production
475 // the reconcile rides the agent's instancesLoop; calling it directly is
476 // what makes this test deterministic instead of loop-timed.
477 if err := host.WriteFleetBinding(ctx, store, host.FleetBinding{
478 Host: e2eAppHost, Fleet: e2eFleet, Box: "box-a", BoundAt: time.Now().UnixMilli(),
479 }, true); err != nil {
480 t.Fatalf("(b) box-a takeover binding: %v", err)
481 }
482 if err := host.ReconcileFleetBindings(ctx, store, reg); err != nil {
483 t.Fatalf("(b) ReconcileFleetBindings: %v", err)
484 }
485 rt, err := reg.Get(e2eAppHost)
486 if err != nil {
487 t.Fatalf("(b) read route: %v", err)
488 }
489 if rt.FleetBox != "box-a" {
490 t.Fatalf("(b) Route.FleetBox = %q, want box-a — the takeover binding never made it back into routing", rt.FleetBox)
491 }
492 t.Log("(b) survivor won the lapsed lease, published its takeover binding, and the reconcile restamped FleetBox")
493
494 // --- (c) the dead box returns and loses the reclaim -----------------------
495 stopB2 := startFleetBox(t, srv.URL, e2eFleet+"/box-b", tokenB, namedFleetHandler("box-b"))
496 t.Cleanup(stopB2)
497 waitChannel(t, hub, e2eFleet+"/box-b", "connected", e2eDialBound)
498
499 // Its activator tries to resume serving the host it used to own. box-a's
500 // renewer is keeping that lease alive, so the revenant loses — this is the
501 // HeldError path, the one thing standing between "box-b rebooted" and "two
502 // boxes writing one SQLite database".
503 err = leases.acquire(e2eAppHost, "box-b")
504 if err == nil {
505 t.Fatal("(c) the returning box reclaimed a lease the survivor is actively renewing — split brain")
506 }
507 if !errors.As(err, &held) {
508 t.Fatalf("(c) contested reclaim returned %T (%v), want a *lease.HeldError", err, err)
509 }
510 // No wake success means no takeover write. What box-b's agent DOES run on
511 // startup is the non-stealing resident pass (publishResidentFleetBindings'
512 // shape: takeover=false) over the route still sitting in its local
513 // registry — and that must not claim the binding back.
514 if err := host.WriteFleetBinding(ctx, store, host.FleetBinding{
515 Host: e2eAppHost, Fleet: e2eFleet, Box: "box-b", BoundAt: time.Now().UnixMilli(),
516 }, false); err != nil {
517 t.Fatalf("(c) box-b startup pass: %v", err)
518 }
519 if err := host.ReconcileFleetBindings(ctx, store, reg); err != nil {
520 t.Fatalf("(c) ReconcileFleetBindings: %v", err)
521 }
522 rt, err = reg.Get(e2eAppHost)
523 if err != nil {
524 t.Fatalf("(c) read route: %v", err)
525 }
526 if rt.FleetBox != "box-a" {
527 t.Fatalf("(c) Route.FleetBox = %q, want box-a — the returning box stole routing back without holding the lease", rt.FleetBox)
528 }
529 // And end to end, with BOTH boxes connected: the survivor serves. Anything
530 // that went wrong above — a restamp that never happened, a binding stolen
531 // back — shows up here as "box-b", because rule 1 would point at box-b in
532 // either case. Polled, because the restamp only reaches the request path
533 // through the edge's 2s fingerprint poll.
534 waitBody(t, srv.URL, e2eAppHost, "box-a", e2eConvergeBound)
535 t.Log("(c) the returning box lost the reclaim; routing stayed on the survivor with both boxes connected")
536
537 // --- (d) rotation is asymmetric, and converges within one poll ------------
538 // Baseline first, so the probe's two answers are known to be
539 // distinguishable BEFORE the rotation: box-a's current token authorizes
540 // (400 = authorized, then refused for the missing Upgrade headers).
541 if code := registerProbe(t, srv.URL, u.Host, e2eFleet+"/box-a", tokenA); code != http.StatusBadRequest {
542 t.Fatalf("(d) probe baseline = %d, want 400 (credential accepted, wire contract refused)", code)
543 }
544 const rotatedA = "tok-a2"
545 if err := reg.SetFleetBoxToken(e2eFleet, "box-a", e2eHashToken(rotatedA)); err != nil {
546 t.Fatalf("(d) rotate box-a: %v", err)
547 }
548 // Half one: a NEW dial with the OLD token is refused, within one poll.
549 took := waitProbe(t, srv.URL, u.Host, e2eFleet+"/box-a", tokenA, http.StatusUnauthorized, e2eConvergeBound)
550 t.Logf("(d) rotation reached the edge's auth table in %s (bound %s)", took, e2eConvergeBound)
551 // ...and the NEW token is what authorizes now — so the 401 above is a
552 // rotation, not the channel having vanished from auth altogether (which is
553 // what a detach does, and which would close the live session below).
554 if code := registerProbe(t, srv.URL, u.Host, e2eFleet+"/box-a", rotatedA); code != http.StatusBadRequest {
555 t.Fatalf("(d) probe with the rotated token = %d, want 400 (the new credential must authorize)", code)
556 }
557 // Half two, the asymmetry that is the whole design decision: the ALREADY
558 // LIVE session — still running on the old token, because box-a's agent has
559 // not been told its credential changed — keeps carrying traffic. If
560 // rotation closed it, box-a's loop would re-dial with the stale token,
561 // fail 401 forever, and this request would be answered by box-b instead.
562 if code, body := e2eGet(t, srv.URL, e2eAppHost); code != http.StatusOK || body != "box-a" {
563 t.Fatalf("(d) after rotating box-a's token: got %d %q, want 200 %q — rotation must refuse new dials, never evict a live session", code, body, "box-a")
564 }
565 t.Log("(d) rotation refused the old token's next dial and left the live session serving")
566
567 // Detach is the OTHER half of that asymmetry: it revokes now. box-b (idle,
568 // connected, holding nothing) is taken out of the fleet, and its channel
569 // must be closed by the same Watch->Reload->SetDynamicAuth path.
570 if err := reg.SetFleetBoxStatus(e2eFleet, "box-b", "detached"); err != nil {
571 t.Fatalf("(d) detach box-b: %v", err)
572 }
573 waitChannel(t, hub, e2eFleet+"/box-b", "", e2eConvergeBound)
574 // And it STAYS down: its agent re-dials every few hundred ms and is
575 // refused every time, because a detached box is gone from the auth table
576 // entirely. A one-shot close that let the box straight back in would pass
577 // the line above and fail here. 20x100ms (2s, M2 final-review widening
578 // from 10x100ms/1s) gives a slow CI runner's re-dial loop more than one
579 // chance to prove it stays refused, not just that it hasn't reconnected
580 // yet.
581 for i := 0; i < 20; i++ {
582 if got := hub.State(e2eFleet + "/box-b"); got != "" {
583 t.Fatalf("(d) detached box reconnected (state %q) — detach must revoke the credential, not just close the socket", got)
584 }
585 time.Sleep(100 * time.Millisecond)
586 }
587 // Detaching one box did not disturb the other.
588 if code, body := e2eGet(t, srv.URL, e2eAppHost); code != http.StatusOK || body != "box-a" {
589 t.Fatalf("(d) after detaching box-b: got %d %q, want 200 %q", code, body, "box-a")
590 }
591 t.Log("(d) detach closed the channel and kept it closed")
592
593 // --- (e) both boxes dark: 503 + Retry-After -------------------------------
594 stopA()
595 hub.CloseChannel(e2eFleet + "/box-a") // pingLoop's reap again
596 waitChannel(t, hub, e2eFleet+"/box-a", "", time.Second)
597 if names := hub.ConnectedOfFleet(e2eFleet); len(names) != 0 {
598 t.Fatalf("(e) fleet still has connected channels %v — the dark case is not set up", names)
599 }
600 dark, err := e2eDo(srv.URL, e2eAppHost, 10*time.Second)
601 if err != nil {
602 t.Fatalf("(e) GET against a dark fleet: %v", err)
603 }
604 defer dark.Body.Close()
605 if dark.StatusCode != http.StatusServiceUnavailable {
606 t.Fatalf("(e) dark fleet answered %d, want 503 (not a 502 that reads like a broken backend)", dark.StatusCode)
607 }
608 if got := dark.Header.Get("Retry-After"); got != "2" {
609 t.Fatalf("(e) Retry-After = %q, want %q", got, "2")
610 }
611 t.Log("(e) dark fleet answered 503 with Retry-After: 2")
612}
613