carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "log"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "github.com/carlosframework/platform/internal/registry"
14 "github.com/carlosframework/platform/internal/release"
15)
16
17func testRegistry(t *testing.T) *registry.Registry {
18 t.Helper()
19 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
20 if err != nil {
21 t.Fatalf("open registry: %v", err)
22 }
23 t.Cleanup(func() { reg.Close() })
24 return reg
25}
26
27// --- Critical 1 (Opus review, fix round): channel-label identity -------------
28
29// TestPublishedBindingBoxMatchesTunnelChannelName is the reviewer-named test:
30// a published binding's Box, joined with its Fleet as "fleet/box", must
31// equal a channel name edge.fleetChannelFor's rule 1 would actually resolve
32// — that function's whole check is
33// `bound := fleet + "/" + boxLabel; hub.State(bound) == "connected"`, and a
34// box only ever shows up connected in the tunnel hub under the exact string
35// its own tunnel client presented as CARLOS_TUNNEL_NAME
36// (internal/host/tunnel_client.go's tunnelLoop dials with that env var
37// verbatim). This exercises the full pipeline this task wires —
38// resolveFleetIdentity -> fleetBindingHook -> WriteFleetBinding — and
39// asserts its published Box is dial-able, not just present.
40func TestPublishedBindingBoxMatchesTunnelChannelName(t *testing.T) {
41 const tunnelName = "pi/amber-gliding" // what this box's own tunnel client presents
42 id, err := resolveFleetIdentity(envFunc(map[string]string{
43 "CARLOS_FLEET": "pi",
44 "CARLOS_TUNNEL_NAME": tunnelName,
45 }))
46 if err != nil {
47 t.Fatalf("resolveFleetIdentity: %v", err)
48 }
49
50 ctx := context.Background()
51 s := release.NewDirStore(t.TempDir())
52 hook := fleetBindingHook(s, id.Fleet, id.Label)
53 hook(registry.Route{Host: "one.example.com", Kind: "instance"})
54
55 body, _, err := s.Get(ctx, fleetBindingKey(id.Fleet, "one.example.com"))
56 if err != nil {
57 t.Fatalf("Get: %v", err)
58 }
59 var b FleetBinding
60 if err := json.Unmarshal(body, &b); err != nil {
61 t.Fatalf("unmarshal: %v", err)
62 }
63
64 channel := b.Fleet + "/" + b.Box
65 if channel != tunnelName {
66 t.Fatalf("published binding's fleet/box = %q, want %q (this box's own CARLOS_TUNNEL_NAME) — a mismatch is a binding the edge could never dial", channel, tunnelName)
67 }
68}
69
70// --- WriteFleetBinding --------------------------------------------------------
71
72func TestWriteFleetBindingRoundTrips(t *testing.T) {
73 ctx := context.Background()
74 s := release.NewDirStore(t.TempDir())
75 b := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1700000000000}
76
77 if err := WriteFleetBinding(ctx, s, b, true); err != nil {
78 t.Fatalf("WriteFleetBinding: %v", err)
79 }
80
81 body, _, err := s.Get(ctx, "fleets/pi/bindings/one.example.com.json")
82 if err != nil {
83 t.Fatalf("Get: %v", err)
84 }
85 var got FleetBinding
86 if err := json.Unmarshal(body, &got); err != nil {
87 t.Fatalf("unmarshal: %v", err)
88 }
89 if got != b {
90 t.Fatalf("round-tripped binding = %+v, want %+v", got, b)
91 }
92}
93
94// A box republishes to the SAME key on every wake and once more at agent
95// startup — a bare create-only Put would fail every write after the first
96// with ErrCASConflict, which is exactly the bug the read-etag-then-Put dance
97// (mirroring status.WriteBox) exists to avoid.
98func TestWriteFleetBindingOverwritesExistingKey(t *testing.T) {
99 ctx := context.Background()
100 s := release.NewDirStore(t.TempDir())
101 first := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 100}
102 second := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 200}
103
104 if err := WriteFleetBinding(ctx, s, first, true); err != nil {
105 t.Fatalf("first write: %v", err)
106 }
107 if err := WriteFleetBinding(ctx, s, second, true); err != nil {
108 t.Fatalf("second write (must replace, not conflict): %v", err)
109 }
110
111 body, _, err := s.Get(ctx, "fleets/pi/bindings/one.example.com.json")
112 if err != nil {
113 t.Fatalf("Get: %v", err)
114 }
115 var got FleetBinding
116 if err := json.Unmarshal(body, &got); err != nil {
117 t.Fatalf("unmarshal: %v", err)
118 }
119 if got.BoundAt != 200 {
120 t.Fatalf("BoundAt = %d, want the second write's 200", got.BoundAt)
121 }
122}
123
124// Critical 2 (Opus review, fix round): a non-takeover write (the startup
125// pass's shape) must never steal a binding that already names a DIFFERENT
126// box — only a takeover=true (wake-success) write may do that.
127func TestWriteFleetBindingNonTakeoverNeverStealsAnotherBox(t *testing.T) {
128 ctx := context.Background()
129 s := release.NewDirStore(t.TempDir())
130 held := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "box-a", BoundAt: 100}
131 if err := WriteFleetBinding(ctx, s, held, true); err != nil {
132 t.Fatalf("seed box-a's binding: %v", err)
133 }
134
135 // box-b observes the SAME host resident locally (e.g. a stale local
136 // route not yet cleaned up) and tries a non-takeover (startup-pass)
137 // write with a LATER BoundAt — later in time must still lose, because
138 // takeover=false is a non-authoritative observation, not a lease.
139 attempt := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "box-b", BoundAt: 999}
140 if err := WriteFleetBinding(ctx, s, attempt, false); err != nil {
141 t.Fatalf("non-takeover write: %v", err)
142 }
143
144 body, _, err := s.Get(ctx, "fleets/pi/bindings/one.example.com.json")
145 if err != nil {
146 t.Fatalf("Get: %v", err)
147 }
148 var got FleetBinding
149 if err := json.Unmarshal(body, &got); err != nil {
150 t.Fatalf("unmarshal: %v", err)
151 }
152 if got.Box != "box-a" {
153 t.Fatalf("Box = %q, want box-a (non-takeover write must not steal)", got.Box)
154 }
155}
156
157// A takeover=true write (a real wake success) MAY claim a binding away from
158// a different box — this is the counterpart proving the non-stealing rule
159// above is specific to non-takeover callers, not a blanket "first box wins".
160func TestWriteFleetBindingTakeoverClaimsFromAnotherBox(t *testing.T) {
161 ctx := context.Background()
162 s := release.NewDirStore(t.TempDir())
163 held := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "box-a", BoundAt: 100}
164 if err := WriteFleetBinding(ctx, s, held, true); err != nil {
165 t.Fatalf("seed box-a's binding: %v", err)
166 }
167
168 claim := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "box-b", BoundAt: 200}
169 if err := WriteFleetBinding(ctx, s, claim, true); err != nil {
170 t.Fatalf("takeover write: %v", err)
171 }
172
173 body, _, err := s.Get(ctx, "fleets/pi/bindings/one.example.com.json")
174 if err != nil {
175 t.Fatalf("Get: %v", err)
176 }
177 var got FleetBinding
178 if err := json.Unmarshal(body, &got); err != nil {
179 t.Fatalf("unmarshal: %v", err)
180 }
181 if got.Box != "box-b" {
182 t.Fatalf("Box = %q, want box-b (a wake-success takeover must be able to claim)", got.Box)
183 }
184}
185
186// Fix round 2 (Opus re-review): the logical-clock guard must be scoped to
187// the SAME box only — BoundAt is each box's own wall clock, never a shared
188// one. A dead box A's binding stamped with a clock running ahead of
189// surviving box B's must not permanently block B's lease-backed takeover
190// write: box-a's stale binding here carries a BoundAt in the FUTURE
191// relative to box-b's claim, and the takeover must still go through,
192// because a cross-box BoundAt comparison is comparing skew, not recency.
193func TestWriteFleetBindingTakeoverIgnoresOtherBoxsFutureBoundAt(t *testing.T) {
194 ctx := context.Background()
195 s := release.NewDirStore(t.TempDir())
196 stale := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "box-a", BoundAt: 9_000_000_000_000}
197 if err := WriteFleetBinding(ctx, s, stale, true); err != nil {
198 t.Fatalf("seed box-a's (clock-ahead) binding: %v", err)
199 }
200
201 claim := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "box-b", BoundAt: 1_000_000_000_000}
202 if err := WriteFleetBinding(ctx, s, claim, true); err != nil {
203 t.Fatalf("takeover write: %v", err)
204 }
205
206 body, _, err := s.Get(ctx, "fleets/pi/bindings/one.example.com.json")
207 if err != nil {
208 t.Fatalf("Get: %v", err)
209 }
210 var got FleetBinding
211 if err := json.Unmarshal(body, &got); err != nil {
212 t.Fatalf("unmarshal: %v", err)
213 }
214 if got.Box != "box-b" {
215 t.Fatalf("Box = %q, want box-b — a lease-backed takeover must not be blocked by a DIFFERENT box's clock running ahead", got.Box)
216 }
217}
218
219// Important 5 (Opus review, fix round): a stored binding with a strictly
220// greater BoundAt must never be overwritten by an older one, even by the
221// SAME box, and even with takeover=true — a physical write race must not
222// let older information win just because its Put happens to land second.
223func TestWriteFleetBindingSkipsWhenStoredIsLogicallyNewer(t *testing.T) {
224 ctx := context.Background()
225 s := release.NewDirStore(t.TempDir())
226 newer := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 2000}
227 if err := WriteFleetBinding(ctx, s, newer, true); err != nil {
228 t.Fatalf("seed newer binding: %v", err)
229 }
230
231 older := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1000}
232 if err := WriteFleetBinding(ctx, s, older, true); err != nil {
233 t.Fatalf("write older binding: %v", err)
234 }
235
236 body, _, err := s.Get(ctx, "fleets/pi/bindings/one.example.com.json")
237 if err != nil {
238 t.Fatalf("Get: %v", err)
239 }
240 var got FleetBinding
241 if err := json.Unmarshal(body, &got); err != nil {
242 t.Fatalf("unmarshal: %v", err)
243 }
244 if got.BoundAt != 2000 {
245 t.Fatalf("BoundAt = %d, want 2000 (the older write must be skipped)", got.BoundAt)
246 }
247}
248
249// --- ReconcileFleetBindings ---------------------------------------------------
250
251func TestReconcileFleetBindingsStampsMatchingRoute(t *testing.T) {
252 ctx := context.Background()
253 s := release.NewDirStore(t.TempDir())
254 reg := testRegistry(t)
255 if err := reg.Upsert(registry.Route{Host: "one.example.com", Kind: "instance", Region: "fleet/pi"}); err != nil {
256 t.Fatalf("upsert: %v", err)
257 }
258 if err := WriteFleetBinding(ctx, s, FleetBinding{
259 Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1000,
260 }, true); err != nil {
261 t.Fatalf("WriteFleetBinding: %v", err)
262 }
263
264 if err := ReconcileFleetBindings(ctx, s, reg); err != nil {
265 t.Fatalf("ReconcileFleetBindings: %v", err)
266 }
267
268 rt, err := reg.Get("one.example.com")
269 if err != nil {
270 t.Fatalf("Get: %v", err)
271 }
272 if rt.FleetBox != "amber-gliding" {
273 t.Fatalf("FleetBox = %q, want amber-gliding", rt.FleetBox)
274 }
275}
276
277// A route with no published binding at all — a non-fleet route, or a fleet
278// route this platform box hasn't heard from yet — must be left completely
279// alone: ReconcileFleetBindings only ever calls SetFleetBox for a host it
280// found a MATCHING binding for.
281func TestReconcileFleetBindingsLeavesNonFleetRoutesAlone(t *testing.T) {
282 ctx := context.Background()
283 s := release.NewDirStore(t.TempDir())
284 reg := testRegistry(t)
285 if err := reg.Upsert(registry.Route{Host: "static.example.com", Kind: "static"}); err != nil {
286 t.Fatalf("upsert: %v", err)
287 }
288
289 if err := ReconcileFleetBindings(ctx, s, reg); err != nil {
290 t.Fatalf("ReconcileFleetBindings: %v", err)
291 }
292
293 rt, err := reg.Get("static.example.com")
294 if err != nil {
295 t.Fatalf("Get: %v", err)
296 }
297 if rt.FleetBox != "" {
298 t.Fatalf("FleetBox = %q, want empty (untouched)", rt.FleetBox)
299 }
300}
301
302// Important 4 (Opus review, fix round): a binding published under fleet
303// "eleven" must never stamp a route actually PLACED on fleet "pi" — even
304// with a far newer BoundAt than anything "pi" published. This is the v1
305// trust-boundary structural check that replaced an earlier "newest BoundAt
306// wins across fleets" reduction, which this scenario would have wrongly let
307// the mismatched, newer binding win.
308func TestReconcileFleetBindingsRejectsMismatchedFleetPlacement(t *testing.T) {
309 ctx := context.Background()
310 s := release.NewDirStore(t.TempDir())
311 reg := testRegistry(t)
312 if err := reg.Upsert(registry.Route{Host: "shared.example.com", Kind: "instance", Region: "fleet/pi"}); err != nil {
313 t.Fatalf("upsert: %v", err)
314 }
315 // A binding published under the WRONG fleet, naming the same host, with
316 // a much later BoundAt.
317 if err := WriteFleetBinding(ctx, s, FleetBinding{
318 Host: "shared.example.com", Fleet: "eleven", Box: "eleven-box", BoundAt: 9_000_000_000_000,
319 }, true); err != nil {
320 t.Fatalf("write eleven binding: %v", err)
321 }
322
323 if err := ReconcileFleetBindings(ctx, s, reg); err != nil {
324 t.Fatalf("ReconcileFleetBindings: %v", err)
325 }
326
327 rt, err := reg.Get("shared.example.com")
328 if err != nil {
329 t.Fatalf("Get: %v", err)
330 }
331 if rt.FleetBox != "" {
332 t.Fatalf("FleetBox = %q, want empty — a mismatched fleet's binding must never stamp this route", rt.FleetBox)
333 }
334}
335
336// The positive counterpart: a binding published under the route's OWN fleet
337// (Region "fleet/pi") DOES get applied, proving the rejection above is about
338// the mismatch specifically, not FleetPlacement/ReconcileFleetBindings being
339// broadly broken.
340func TestReconcileFleetBindingsAppliesMatchingFleetPlacement(t *testing.T) {
341 ctx := context.Background()
342 s := release.NewDirStore(t.TempDir())
343 reg := testRegistry(t)
344 if err := reg.Upsert(registry.Route{Host: "shared.example.com", Kind: "instance", Region: "fleet/pi"}); err != nil {
345 t.Fatalf("upsert: %v", err)
346 }
347 if err := WriteFleetBinding(ctx, s, FleetBinding{
348 Host: "shared.example.com", Fleet: "pi", Box: "pi-box", BoundAt: 1000,
349 }, true); err != nil {
350 t.Fatalf("write pi binding: %v", err)
351 }
352
353 if err := ReconcileFleetBindings(ctx, s, reg); err != nil {
354 t.Fatalf("ReconcileFleetBindings: %v", err)
355 }
356
357 rt, err := reg.Get("shared.example.com")
358 if err != nil {
359 t.Fatalf("Get: %v", err)
360 }
361 if rt.FleetBox != "pi-box" {
362 t.Fatalf("FleetBox = %q, want pi-box", rt.FleetBox)
363 }
364}
365
366// A corrupt binding object must be logged and skipped, never fatal — the
367// loadBoxes precedent (internal/status/fleet.go) — and must not stop the
368// pass from stamping every OTHER host's valid binding.
369func TestReconcileFleetBindingsSkipsCorruptJSON(t *testing.T) {
370 ctx := context.Background()
371 s := release.NewDirStore(t.TempDir())
372 reg := testRegistry(t)
373 if err := reg.Upsert(registry.Route{Host: "good.example.com", Kind: "instance", Region: "fleet/pi"}); err != nil {
374 t.Fatalf("upsert good: %v", err)
375 }
376 if err := reg.Upsert(registry.Route{Host: "bad.example.com", Kind: "instance", Region: "fleet/pi"}); err != nil {
377 t.Fatalf("upsert bad: %v", err)
378 }
379 if _, err := s.Put(ctx, "fleets/pi/bindings/bad.example.com.json", []byte("{ not json"), ""); err != nil {
380 t.Fatalf("seed corrupt object: %v", err)
381 }
382 if err := WriteFleetBinding(ctx, s, FleetBinding{
383 Host: "good.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1000,
384 }, true); err != nil {
385 t.Fatalf("WriteFleetBinding: %v", err)
386 }
387
388 if err := ReconcileFleetBindings(ctx, s, reg); err != nil {
389 t.Fatalf("ReconcileFleetBindings: %v", err)
390 }
391
392 good, err := reg.Get("good.example.com")
393 if err != nil {
394 t.Fatalf("Get good: %v", err)
395 }
396 if good.FleetBox != "amber-gliding" {
397 t.Fatalf("good.FleetBox = %q, want amber-gliding despite the corrupt sibling", good.FleetBox)
398 }
399 bad, err := reg.Get("bad.example.com")
400 if err != nil {
401 t.Fatalf("Get bad: %v", err)
402 }
403 if bad.FleetBox != "" {
404 t.Fatalf("bad.FleetBox = %q, want empty (corrupt binding never stamped)", bad.FleetBox)
405 }
406}
407
408// Already-stamped, matching FleetBox must not trigger a needless SetFleetBox
409// call — proven indirectly by asserting the value survives a second
410// reconcile pass unchanged.
411func TestReconcileFleetBindingsIdempotent(t *testing.T) {
412 ctx := context.Background()
413 s := release.NewDirStore(t.TempDir())
414 reg := testRegistry(t)
415 if err := reg.Upsert(registry.Route{Host: "one.example.com", Kind: "instance", Region: "fleet/pi"}); err != nil {
416 t.Fatalf("upsert: %v", err)
417 }
418 if err := WriteFleetBinding(ctx, s, FleetBinding{
419 Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1000,
420 }, true); err != nil {
421 t.Fatalf("WriteFleetBinding: %v", err)
422 }
423 for i := 0; i < 2; i++ {
424 if err := ReconcileFleetBindings(ctx, s, reg); err != nil {
425 t.Fatalf("ReconcileFleetBindings pass %d: %v", i, err)
426 }
427 }
428 rt, err := reg.Get("one.example.com")
429 if err != nil {
430 t.Fatalf("Get: %v", err)
431 }
432 if rt.FleetBox != "amber-gliding" {
433 t.Fatalf("FleetBox = %q, want amber-gliding", rt.FleetBox)
434 }
435}
436
437// --- publishResidentFleetBindings (agent-startup pass) ------------------------
438
439func TestPublishResidentFleetBindingsCoversEveryLocalRoute(t *testing.T) {
440 ctx := context.Background()
441 s := release.NewDirStore(t.TempDir())
442 reg := testRegistry(t)
443 if err := reg.Upsert(registry.Route{Host: "one.example.com", Kind: "instance"}); err != nil {
444 t.Fatalf("upsert one: %v", err)
445 }
446 if err := reg.Upsert(registry.Route{Host: "two.example.com", Kind: "instance"}); err != nil {
447 t.Fatalf("upsert two: %v", err)
448 }
449
450 publishResidentFleetBindings(s, "pi", "amber-gliding", reg)
451
452 for _, host := range []string{"one.example.com", "two.example.com"} {
453 body, _, err := s.Get(ctx, fleetBindingKey("pi", host))
454 if err != nil {
455 t.Fatalf("Get %s: %v", host, err)
456 }
457 var b FleetBinding
458 if err := json.Unmarshal(body, &b); err != nil {
459 t.Fatalf("unmarshal %s: %v", host, err)
460 }
461 if b.Host != host || b.Fleet != "pi" || b.Box != "amber-gliding" || b.BoundAt == 0 {
462 t.Fatalf("binding for %s = %+v, want host/fleet/box populated and a nonzero BoundAt", host, b)
463 }
464 }
465}
466
467// A registry with no routes at all must not error or panic — a startup pass
468// on a freshly-provisioned fleet box, before any host has been assigned.
469func TestPublishResidentFleetBindingsEmptyRegistryIsNoop(t *testing.T) {
470 s := release.NewDirStore(t.TempDir())
471 reg := testRegistry(t)
472 publishResidentFleetBindings(s, "pi", "amber-gliding", reg)
473 keys, err := s.List(context.Background(), "fleets/")
474 if err != nil {
475 t.Fatalf("List: %v", err)
476 }
477 if len(keys) != 0 {
478 t.Fatalf("keys = %v, want none written for an empty registry", keys)
479 }
480}
481
482// Critical 2 (Opus review, fix round): the missing test the reviewer named —
483// the startup pass must be non-stealing. A route resident locally on THIS
484// box (box-b) already has a binding published by a DIFFERENT box (box-a);
485// the startup pass must leave it alone rather than overwrite it, since it
486// has no lease behind it, unlike a wake-success write.
487func TestPublishResidentFleetBindingsDoesNotStealAnotherBoxsBinding(t *testing.T) {
488 ctx := context.Background()
489 s := release.NewDirStore(t.TempDir())
490 reg := testRegistry(t)
491 if err := reg.Upsert(registry.Route{Host: "one.example.com", Kind: "instance"}); err != nil {
492 t.Fatalf("upsert: %v", err)
493 }
494 if err := WriteFleetBinding(ctx, s, FleetBinding{
495 Host: "one.example.com", Fleet: "pi", Box: "box-a", BoundAt: 100,
496 }, true); err != nil {
497 t.Fatalf("seed box-a's binding: %v", err)
498 }
499
500 // box-b's startup pass encounters the same host resident in ITS OWN
501 // local registry (e.g. a route not yet cleaned up after a failover) and
502 // must not steal box-a's binding.
503 publishResidentFleetBindings(s, "pi", "box-b", reg)
504
505 body, _, err := s.Get(ctx, fleetBindingKey("pi", "one.example.com"))
506 if err != nil {
507 t.Fatalf("Get: %v", err)
508 }
509 var got FleetBinding
510 if err := json.Unmarshal(body, &got); err != nil {
511 t.Fatalf("unmarshal: %v", err)
512 }
513 if got.Box != "box-a" {
514 t.Fatalf("Box = %q, want box-a (the startup pass must not steal)", got.Box)
515 }
516}
517
518// --- fleetBindingHook (fire-and-forget wake publish) --------------------------
519
520// errPutStore fails every Put while otherwise behaving like a normal
521// DirStore (Get/List/ListDirs/Delete pass through) — enough to exercise
522// WriteFleetBinding's error path without a broken embedded nil Store.
523type errPutStore struct{ release.Store }
524
525func (errPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
526 return "", errors.New("boom: put refused")
527}
528
529func TestFleetBindingHookLogsErrorWithoutPanicking(t *testing.T) {
530 var buf bytes.Buffer
531 prev := log.Writer()
532 log.SetOutput(&buf)
533 defer log.SetOutput(prev)
534
535 store := errPutStore{Store: release.NewDirStore(t.TempDir())}
536 hook := fleetBindingHook(store, "pi", "amber-gliding")
537
538 hook(registry.Route{Host: "one.example.com", Kind: "instance"}) // must not panic even though every Put fails
539
540 if !strings.Contains(buf.String(), "fleet binding") {
541 t.Fatalf("expected the Put failure to be logged, got: %q", buf.String())
542 }
543}
544
545func TestFleetBindingHookWritesBinding(t *testing.T) {
546 ctx := context.Background()
547 s := release.NewDirStore(t.TempDir())
548 hook := fleetBindingHook(s, "pi", "amber-gliding")
549
550 hook(registry.Route{Host: "woken.example.com", Kind: "instance"})
551
552 body, _, err := s.Get(ctx, fleetBindingKey("pi", "woken.example.com"))
553 if err != nil {
554 t.Fatalf("Get: %v", err)
555 }
556 var b FleetBinding
557 if err := json.Unmarshal(body, &b); err != nil {
558 t.Fatalf("unmarshal: %v", err)
559 }
560 if b.Host != "woken.example.com" || b.Fleet != "pi" || b.Box != "amber-gliding" {
561 t.Fatalf("binding = %+v, want host/fleet/box populated from the hook's closure", b)
562 }
563}
564
565// fleetBindingHook must publish with takeover=true — a wake success is
566// lease-backed authority to claim a binding away from a different box.
567// Proven end-to-end here (rather than just trusting the source) since a
568// regression (accidentally passing false) would only show up as a silent
569// no-op against an existing binding, never a compile error.
570func TestFleetBindingHookTakesOverFromAnotherBox(t *testing.T) {
571 ctx := context.Background()
572 s := release.NewDirStore(t.TempDir())
573 if err := WriteFleetBinding(ctx, s, FleetBinding{
574 Host: "one.example.com", Fleet: "pi", Box: "old-box", BoundAt: 100,
575 }, true); err != nil {
576 t.Fatalf("seed old-box's binding: %v", err)
577 }
578
579 hook := fleetBindingHook(s, "pi", "new-box")
580 hook(registry.Route{Host: "one.example.com", Kind: "instance"})
581
582 body, _, err := s.Get(ctx, fleetBindingKey("pi", "one.example.com"))
583 if err != nil {
584 t.Fatalf("Get: %v", err)
585 }
586 var got FleetBinding
587 if err := json.Unmarshal(body, &got); err != nil {
588 t.Fatalf("unmarshal: %v", err)
589 }
590 if got.Box != "new-box" {
591 t.Fatalf("Box = %q, want new-box (OnWake's hook must take over)", got.Box)
592 }
593}
594
595// TestSidecarBindingIsNeverPublished: a fleet binding says "this box serves
596// this host". Nothing routes to a sidecar, so publishing one would be a lie
597// the platform-side reconciler then acts on — ReconcileFleetBindings stamps
598// SetFleetBox from it, and a fleet-placed route is exactly what the edge's
599// fleet dispatch arm binds. The filter therefore lives in the publisher's own
600// host selection, not in the reconciler that reads it back.
601func TestSidecarBindingIsNeverPublished(t *testing.T) {
602 got := residentBindingHosts([]registry.Route{
603 {Host: "app.example.com", Kind: "instance", Socket: "/run/carlos/a.sock"},
604 {Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Region: "fleet/eleven"},
605 })
606 if len(got) != 1 || got[0] != "app.example.com" {
607 t.Fatalf("published %v — a sidecar must never be advertised as served by a box", got)
608 }
609}
610
611// The end-to-end shape of the same refusal: the startup pass over a registry
612// holding both kinds must write a binding object for the instance and none
613// at all for the sidecar.
614func TestPublishResidentFleetBindingsSkipsSidecars(t *testing.T) {
615 ctx := context.Background()
616 s := release.NewDirStore(t.TempDir())
617 reg := testRegistry(t)
618 if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "instance"}); err != nil {
619 t.Fatalf("upsert instance: %v", err)
620 }
621 if err := reg.Upsert(registry.Route{Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Region: "fleet/eleven"}); err != nil {
622 t.Fatalf("upsert sidecar: %v", err)
623 }
624
625 publishResidentFleetBindings(s, "eleven", "box1", reg)
626
627 if _, _, err := s.Get(ctx, fleetBindingKey("eleven", "app.example.com")); err != nil {
628 t.Fatalf("instance binding missing: %v", err)
629 }
630 if _, _, err := s.Get(ctx, fleetBindingKey("eleven", "bot.eleven.bdm.sidecar.internal")); err == nil {
631 t.Fatalf("a sidecar binding was published — a box must never advertise a sidecar as served")
632 }
633}
634
635// --- Fix round 1: the other two ends of the same chain ------------------------
636
637// TestFleetBindingHookNeverPublishesSidecar covers the WAKE publish path,
638// which the startup-pass filter does not reach. It matters more than the
639// startup pass, not less: this hook writes with takeover=true, so an errant
640// sidecar binding here does not merely appear — it claims the host away from
641// whichever box legitimately held it.
642//
643// Deliberately not resting on "the activator never wakes a sidecar": that is
644// true of the activator today and it is a fact a later task on this same
645// branch removes, at which point the hole would open silently. An ordinary
646// instance host must still be published, so this asserts both directions.
647//
648// The kind arrives on the route the wake already holds — no registry read, so
649// no I/O failure can make this refusal fail open.
650func TestFleetBindingHookNeverPublishesSidecar(t *testing.T) {
651 ctx := context.Background()
652 s := release.NewDirStore(t.TempDir())
653
654 hook := fleetBindingHook(s, "pi", "box1")
655 hook(registry.Route{Host: "app.example.com", Kind: "instance", Region: "fleet/pi"})
656 hook(registry.Route{Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Region: "fleet/pi"})
657
658 if _, _, err := s.Get(ctx, fleetBindingKey("pi", "app.example.com")); err != nil {
659 t.Fatalf("instance binding missing from the wake publish: %v", err)
660 }
661 if _, _, err := s.Get(ctx, fleetBindingKey("pi", "bot.eleven.bdm.sidecar.internal")); err == nil {
662 t.Fatalf("the wake hook published a sidecar binding — with takeover semantics, no less")
663 }
664}
665
666// TestReconcileFleetBindingsNeverStampsSidecar covers the READING side. A
667// binding is an untrusted claim written by another machine that upgrades on
668// its own schedule: through a mixed-version roll, a fleet box still on an
669// older binary keeps publishing sidecar bindings, and this platform box is
670// what acts on them. SetFleetBox is the act that hands a fleet-placed route to
671// the edge's fleet dispatch arm, so it must refuse on kind independently of
672// whoever wrote the object.
673//
674// Both routes here are placed on the SAME fleet as their binding, so the
675// pre-existing placement check passes for both — the kind guard is the only
676// thing that can distinguish them.
677func TestReconcileFleetBindingsNeverStampsSidecar(t *testing.T) {
678 ctx := context.Background()
679 s := release.NewDirStore(t.TempDir())
680 reg := testRegistry(t)
681 const sidecarHost = "bot.eleven.bdm.sidecar.internal"
682 if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "instance", Region: "fleet/pi"}); err != nil {
683 t.Fatalf("upsert instance: %v", err)
684 }
685 if err := reg.Upsert(registry.Route{Host: sidecarHost, Kind: "sidecar", Region: "fleet/pi"}); err != nil {
686 t.Fatalf("upsert sidecar: %v", err)
687 }
688 // Both bindings written directly, as an older box's publisher would.
689 for _, host := range []string{"app.example.com", sidecarHost} {
690 if err := WriteFleetBinding(ctx, s, FleetBinding{
691 Host: host, Fleet: "pi", Box: "box1", BoundAt: 100,
692 }, true); err != nil {
693 t.Fatalf("seed binding for %s: %v", host, err)
694 }
695 }
696
697 if err := ReconcileFleetBindings(ctx, s, reg); err != nil {
698 t.Fatalf("ReconcileFleetBindings: %v", err)
699 }
700
701 rt, err := reg.Get("app.example.com")
702 if err != nil || rt == nil {
703 t.Fatalf("get instance route: %v", err)
704 }
705 if rt.FleetBox != "box1" {
706 t.Fatalf("instance FleetBox = %q, want box1 — ordinary routes must still be stamped", rt.FleetBox)
707 }
708 sc, err := reg.Get(sidecarHost)
709 if err != nil || sc == nil {
710 t.Fatalf("get sidecar route: %v", err)
711 }
712 if sc.FleetBox != "" {
713 t.Fatalf("sidecar FleetBox = %q, want empty — stamping it hands the row to the edge's fleet arm", sc.FleetBox)
714 }
715}
716