diff --git a/go/compose/resolve_test.go b/go/compose/resolve_test.go new file mode 100644 index 0000000..f33eab4 --- /dev/null +++ b/go/compose/resolve_test.go @@ -0,0 +1,327 @@ +package compose + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/overlay" +) + +// storeManifests is mesh-controller's bindings_test.go storeManifests (cmd/mesh-controller at 6c2a8d62): a store holding the +// mesh-store seat, a resolver holding mesh-dns-resolver, a network that requires wildcard resolution, and a board that +// requires a database. +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{} +} + +// theStoreMesh is the mesh of mesh-controller's bindings_integration_test.go +// TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt: the anchor's store and resolver hold the mesh's seats on +// record; the laptop runs its own of each, and a network and a board. +func theStoreMesh(t *testing.T) Input { + t.Helper() + in := aMesh(t) + for _, m := range storeManifests() { + in.Catalogue[m.Module] = m + } + anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") + anchor.Assigned = append(anchor.Assigned, "store", "resolver") + laptop.Assigned = append(laptop.Assigned, "store", "resolver", "network", "board") + in.Holdings = []catalogue.Held{ + {Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "store", Site: "here"}, + {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}, + } + return in +} + +// 1362/AC7 and AC4 (bindings_integration_test.go): a consumer of a provision that keeps its data stays beside its data, one that +// does not follows the seat; the laptop's store taken away, the consumer bound there on record is refused, not moved to the +// anchor's empty one; a person's pin moves it. +func TestAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) { + in := theStoreMesh(t) + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.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, got.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: the binding to data. The laptop's store taken away: refused, not moved to the anchor's empty one. + laptop := nodeOf(t, &in, "laptop") + laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "laptop", Module: "store"}}} + laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} + _, err = Resolve(in, "laptop") + if err == nil || !IsUnresolvable(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, and the resolution says where it was bound (Kept is empty, as it moves). + laptop.Pins = map[string]catalogue.Chosen{"postgres-database": {Node: "anchor", Module: "store"}} + got, err = Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" { + t.Fatalf("pinned to the anchor and bound to %s", n.From) + } +} + +// 1362/AC4: a data-keeping consumer bound on record to a provider elsewhere is kept on that provider, and the resolution says +// so (Resolution.Kept) when the mesh would answer it otherwise. +func TestADataKeepingConsumerBoundElsewhereIsKeptThereAndSaid(t *testing.T) { + in := theStoreMesh(t) + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} // no store of its own + laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" || !n.KeepsData { + t.Fatalf("the consumer is bound to %s (keeps data: %v), not where it was sent", n.From, n.KeepsData) + } + if len(got.Plan.Kept) != 0 { + t.Errorf("a binding that was kept where it is is said as a kept move: %+v", got.Plan.Kept) + } + // And bound somewhere else on record while the mesh would answer from the anchor: kept, and said. + laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + home := Node{Name: "home", Place: Place{Site: "there", Address: "10.77.0.7"}, Assigned: []string{overlay.Name, "store"}, + Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}} + in.Nodes = append(in.Nodes, home) + got, err = Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := need(t, got.Plan, "board", "postgres-database"); n.From != "home" { + t.Errorf("the consumer was moved from where it was sent: %s", n.From) + } +} + +// A provider module whose offer says only a person gives its credential (ADR 0337), and a consumer of it. +func outsideObjects() catalogue.Manifest { + m := objects() + m.Provides[0].Credential = &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside} + return m +} + +func theObjectsMesh(t *testing.T, provider catalogue.Manifest) Input { + t.Helper() + in := aMesh(t) + in.Catalogue[provider.Module], in.Catalogue["files"] = provider, files() + nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, provider.Module) + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files") + nodeOf(t, &in, "anchor").SealingKey = "anchor-key" + nodeOf(t, &in, "laptop").SealingKey = "laptop-key" + return in +} + +func theFilesNeed(t *testing.T, got Resolved) catalogue.Needed { + t.Helper() + return need(t, got.Plan, "files", "s3-bucket") +} + +// 1362/AC1: a pair credential the Input names as existing with no value, and one it names not at all, are composed with a +// stand-in and named foreseen, and nothing is made; one with a value is composed with it and foreseen is empty. +func TestAPairCredentialNotHeldIsComposedWithAStandInAndNamedForeseen(t *testing.T) { + mk := func() Input { return theObjectsMesh(t, objects()) } + for name, creds := range map[string][]Credential{ + "no entry": nil, + "held with no value": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld}}, + "to be made": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}}, + } { + in := mk() + in.Credentials = creds + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if n := theFilesNeed(t, got); n.Sealed != catalogue.ForeseenSealed { + t.Errorf("%s: the credential is %q, not the stand-in", name, n.Sealed) + } + if len(got.Foreseen) != 1 || got.Foreseen[0] != "files on laptop needs s3-bucket from anchor" { + t.Errorf("%s: foreseen is %v", name, got.Foreseen) + } + } + in := mk() + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", + State: CredentialHeld, Sealed: "sealed-to-laptop"}} + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if n := theFilesNeed(t, got); n.Sealed != "sealed-to-laptop" || len(got.Foreseen) != 0 { + t.Errorf("a held credential: %q foreseen %v", n.Sealed, got.Foreseen) + } + unchanged(t, mk, func(in Input) { + if _, err := Resolve(in, "laptop"); err != nil { + t.Fatal(err) + } + }) +} + +// 1362/AC2: a credential only a person gives and nobody has is not given and not made: the consumer still resolves without it +// (novox/hq ADR 0337 clause 4), said with the controller's words; one given is composed with it. +func TestACredentialOnlyAPersonGivesIsNotMadeAndTheConsumerStillResolves(t *testing.T) { + for name, creds := range map[string][]Credential{ + "no entry": nil, + "to be made (never, for one issued outside)": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}}, + "not given": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven, + Why: "the value held was made by the mesh, which objects's software would refuse"}}, + } { + in := theObjectsMesh(t, outsideObjects()) + in.Credentials = creds + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if n := theFilesNeed(t, got); n.Sealed != "" { + t.Errorf("%s: a value stands in for what nobody gave: %q", name, n.Sealed) + } + if len(got.Foreseen) != 0 || len(got.NotGiven) != 1 || !strings.Contains(got.NotGiven[0], "files on laptop holds no s3-bucket from anchor: ") { + t.Errorf("%s: foreseen %v not given %v", name, got.Foreseen, got.NotGiven) + } + if len(got.Plan.Modules) == 0 { + t.Errorf("%s: the machine did not resolve", name) + } + } + in := theObjectsMesh(t, outsideObjects()) + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", + State: CredentialHeld, Sealed: "given-and-sealed"}} + got, err := Resolve(in, "laptop") + if err != nil || theFilesNeed(t, got).Sealed != "given-and-sealed" || len(got.NotGiven) != 0 { + t.Fatalf("a value a person gave: %+v %v", got.NotGiven, err) + } +} + +// 1362/AC3: a credential the send would refuse, a given value sealed to a key a machine no longer has, refuses the resolution with +// today's words, and is not a node whose set does not compose. +func TestACredentialTheSendWouldRefuseRefusesTheResolutionInTheSendsWords(t *testing.T) { + why := "files's \"s3-bucket\" credential from anchor was accepted from a person, and a sealing key at one end has changed since. " + + "The mesh cannot re-seal a value it does not hold: accept it again with `secret accept laptop files s3-bucket --provider anchor`" + in := theObjectsMesh(t, objects()) + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", + State: CredentialRefused, Why: why}} + _, err := Resolve(in, "laptop") + want := "files on laptop needs s3-bucket from anchor and no credential could be made for it: " + why + if err == nil || err.Error() != want { + t.Fatalf("got %v, want %q", err, want) + } + if IsUnresolvable(err) { + t.Error("a refused credential is a node whose set does not compose") + } +} + +// A record-answered need is filled with the licence key sealed to this holder, and the manager holder is handed the manager node's +// public sealing key (novox/hq ADR 0050) and no other. +func TestARecordAnsweredNeedGetsItsKeyAndTheManagerItsPublicKey(t *testing.T) { + in := aMesh(t) + in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "assistant") + laptop.SealingKey = "laptop-key" + in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, + Holders: []Holder{{Node: "laptop", Module: "assistant"}}, + Keys: map[Holder]string{{Node: "laptop", Module: "assistant"}: "key-sealed-to-laptop"}}} + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + n := need(t, got.Plan, "assistant", LicenceProvision) + if !n.ByRecord || n.Sealed != "key-sealed-to-laptop" || n.Manager || n.Serves["manager_public_key"] != nil { + t.Fatalf("the record-answered need is %+v", n) + } + in.Licences[0].Manager = Holder{Node: "laptop", Module: "assistant"} + got, err = Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + n = need(t, got.Plan, "assistant", LicenceProvision) + if !n.Manager || n.Serves["manager_public_key"] != "laptop-key" || n.Serves["model"] != "x" { + t.Fatalf("the manager is %+v", n) + } + if len(got.Foreseen) != 0 || len(got.NotGiven) != 0 { + t.Errorf("a record-answered need needs no pair credential: %v %v", got.Foreseen, got.NotGiven) + } +} + +// 1362/AC5: a node whose own set does not compose returns an error callers can tell from an unreadable Input. +func TestANodeWhoseOwnSetDoesNotComposeIsToldApartFromAnUnreadableInput(t *testing.T) { + in := aMesh(t) + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") + if _, err := Resolve(in, "laptop"); err == nil || !IsUnresolvable(err) { + t.Fatalf("a set that does not compose: %v", err) + } + if _, err := Resolve(in, "nowhere"); err == nil || IsUnresolvable(err) || !errors.Is(err, ErrNoSuchNode) { + t.Fatalf("a node the mesh does not have: %v", err) + } + in.Catalogue = nil + if _, err := Resolve(in, "anchor"); err == nil || IsUnresolvable(err) { + t.Fatalf("an Input without the catalogue: %v", err) + } +} + +// 1362/AC6: settings are returned for every resolved module that has layers in the Input, including one pulled in by another. +func TestSettingsAreReturnedForEveryResolvedModuleIncludingOneThatWasPulledIn(t *testing.T) { + in := aMesh(t) + in.Catalogue["lib"] = catalogue.Manifest{Module: "lib", Version: "1", Provides: catalogue.Offers("blob")} + in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"blob"}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "app") + laptop.Settings = map[string][]catalogue.Layer{ + "lib": {{From: "mesh", Values: map[string]any{"x": 1}}}, + "app": {{From: "laptop", Values: map[string]any{"y": 2}}}, + "other": {{From: "laptop", Values: map[string]any{"z": 3}}}, // not resolved here: no settings come of it + "empty": nil, + } + got, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + pulled := false + for _, m := range got.Plan.Modules { + pulled = pulled || m.Module == "lib" + } + if !pulled { + t.Fatalf("lib was not pulled in: %+v", got.Plan.Modules) + } + if len(got.Settings) != 2 || got.Settings["lib"][0].Values["x"] != 1 || got.Settings["app"][0].Values["y"] != 2 { + t.Errorf("settings are %+v", got.Settings) + } + // The settings returned are copies: changing them does not change the Input. + got.Settings["app"][0].From = "changed" + if laptop.Settings["app"][0].From != "laptop" { + t.Error("the settings returned share the Input's layers") + } +}