package main import ( "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/conditions" ) // novox/hq issue 273, ADR 0232: a consumer of a provision that keeps its data moves only by a pin. func storeManifests() []catalogue.Manifest { return []catalogue.Manifest{ {Module: "store", Version: "1", Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}, Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}, Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, Grants: map[string]string{"postgres-database": "/var/lib/mesh/store/grants"}}, {Module: "resolver", Version: "1", Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}, {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, {Module: "board", Version: "1", Requires: []string{"postgres-database"}}, } } func need(t *testing.T, plan catalogue.Resolution, consumer, provision string) catalogue.Needed { t.Helper() for _, n := range plan.Needs { if n.For == consumer && n.Name == provision { return n } } t.Fatalf("no %s for %s: %+v", provision, consumer, plan.Needs) return catalogue.Needed{} } // The incident through the stores: the laptop runs its own store and a consumer of it, the anchor's // store holds the mesh's seat. The consumer stays beside its data, the resolver follows its seat, the // binding is recorded as sent, and a pin — only a pin — moves it, said before the push that carries it. func TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) { open := aMesh(t) ctx := t.Context() inv := open.inventory if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { t.Fatal(err) } for _, m := range storeManifests() { register(t, open, m) } assignAll := func(pairs ...[2]string) { for _, a := range pairs { if _, err := assign(ctx, open, a[0], a[1]); err != nil { t.Fatalf("assign %s %s: %v", a[0], a[1], err) } } } // The anchor's store and resolver hold the mesh's seats, on record; the laptop runs its own of each. assignAll([2]string{"anchor", "store"}, [2]string{"anchor", "resolver"}) for _, seat := range [][2]string{{"mesh-store", "store"}, {"mesh-dns-resolver", "resolver"}} { if err := inv.HoldSeat(ctx, seat[0], catalogue.ScopeMesh, "anchor", seat[1]); err != nil { t.Fatal(err) } } assignAll([2]string{"laptop", "store"}, [2]string{"laptop", "resolver"}, [2]string{"laptop", "network"}, [2]string{"laptop", "board"}) plan, _, err := planFor(ctx, open, "laptop") if err != nil { t.Fatal(err) } if n := need(t, plan, "board", "postgres-database"); n.From != "laptop" || n.Module != "store" || !n.KeepsData { t.Fatalf("the consumer was bound to %s/%s (keeps data: %v); its data is beside it", n.From, n.Module, n.KeepsData) } if n := need(t, plan, "network", "wildcard-resolution"); n.From != "anchor" || n.KeepsData { t.Fatalf("the resolver was bound to %s (keeps data: %v); its seat is held on anchor (issue 258)", n.From, n.KeepsData) } // Sent, and recorded: only the binding to data. bindings := boundToData(plan, nil) if len(bindings) != 1 || bindings[0].Provider != (catalogue.Chosen{Node: "laptop", Module: "store"}) { t.Fatalf("recorded %+v", bindings) } if err := inv.RecordBindings(ctx, "laptop", bindings); err != nil { t.Fatal(err) } if found, err := probeBindings(ctx, &doctor{open: open}); err != nil || len(found) != 0 { t.Fatalf("a mesh bound where it was sent: %+v, %v", found, err) } // The laptop's store taken away: refused, not moved to the anchor's empty one. if err := inv.Unassign(ctx, "laptop", "store"); err != nil { t.Fatal(err) } if _, _, err := planFor(ctx, open, "laptop"); err == nil || !unresolvable(err) || !strings.Contains(err.Error(), "board on laptop is bound to laptop/store") || !strings.Contains(err.Error(), "pin laptop postgres-database anchor store") { t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err) } // A person pins the anchor's: it moves, said before it is sent, and the record keeps where it was. if err := inv.PinProvision(ctx, "laptop", "postgres-database", "anchor", "store"); err != nil { t.Fatal(err) } plan, _, err = planFor(ctx, open, "laptop") if err != nil { t.Fatal(err) } if n := need(t, plan, "board", "postgres-database"); n.From != "anchor" { t.Fatalf("pinned to the anchor and bound to %s", n.From) } found, err := probeBindings(ctx, &doctor{open: open}) if err != nil { t.Fatal(err) } if len(found) != 1 || found[0].Kind != kindBindingMoving || found[0].Severity != conditions.Warning || !strings.Contains(found[0].Summary, "board's postgres-database moves from laptop/store to anchor/store") { t.Fatalf("a pinned move is not said before it is sent: %+v", found) } if err := inv.RecordBindings(ctx, "laptop", boundToData(plan, nil)); err != nil { t.Fatal(err) } all, err := inv.Bindings(ctx) if err != nil || len(all) != 1 || all[0].MovedFrom != "laptop/store" { t.Fatalf("%+v, %v", all, err) } if found, err := probeBindings(ctx, &doctor{open: open}); err != nil || len(found) != 0 { t.Fatalf("a move sent is still said: %+v, %v", found, err) } } // What one machine's resolution says against its record. func TestBindingFindingsSayAKeptMoveAndAMoveNothingAskedFor(t *testing.T) { home, anchor := catalogue.Chosen{Node: "home", Module: "store"}, catalogue.Chosen{Node: "anchor", Module: "store"} plan := catalogue.Resolution{Node: "laptop", Kept: []catalogue.KeptBinding{{Machine: "laptop", Consumer: "board", Provision: "postgres-database", Bound: home, Would: anchor}}, Needs: []catalogue.Needed{ {Name: "postgres-database", For: "board", From: "home", Module: "store", KeepsData: true}, {Name: "postgres-database", For: "game", From: "anchor", Module: "store", KeepsData: true}, {Name: "wildcard-resolution", For: "network", From: "anchor", Module: "resolver"}, }} bound := map[string]map[string]catalogue.Chosen{ "board": {"postgres-database": home}, "game": {"postgres-database": home}, "network": {"wildcard-resolution": {Node: "home", Module: "resolver"}}, } found := linted(bindingFindings(plan, bound, nil)) if len(found) != 2 { t.Fatalf("found %+v", found) } kept, moved := found[0], found[1] if kept.Kind != kindBindingKept || kept.Severity != conditions.Urgent || kept.Machine != "laptop" || !strings.Contains(kept.Summary, "would move board's postgres-database from home/store to anchor/store") || !strings.Contains(kept.Summary, "its data is on home/store") || !strings.Contains(kept.Summary, "`pin laptop postgres-database anchor store` to confirm a move (and move the data first)") { t.Errorf("kept: %+v", kept) } if moved.Kind != kindBindingMoved || moved.Severity != conditions.Urgent || !strings.Contains(moved.Summary, "game's postgres-database would be sent anchor/store") { t.Errorf("moved: %+v", moved) } if kept.Key() == moved.Key() { t.Error("two consumers, one condition") } } // A push says the move it refused, and raises its condition at once. func TestAPushSaysAKeptMoveAndRaisesItsCondition(t *testing.T) { k, _ := withConditionsInMemory(t) reportKept(t.Context(), catalogue.Resolution{Node: "laptop", Kept: []catalogue.KeptBinding{{Machine: "laptop", Consumer: "board", Provision: "postgres-database", Bound: catalogue.Chosen{Node: "home", Module: "store"}, Would: catalogue.Chosen{Node: "anchor", Module: "store"}}}}) open, err := k.Open(t.Context()) if err != nil { t.Fatal(err) } if len(open) != 1 || open[0].Kind != kindBindingKept || open[0].Severity != conditions.Urgent || open[0].Source != probeBindingsID { t.Fatalf("raised %+v", open) } }