diff --git a/cmd/mesh-controller/compose_equal_integration_test.go b/cmd/mesh-controller/compose_equal_integration_test.go new file mode 100644 index 00000000..d17249b3 --- /dev/null +++ b/cmd/mesh-controller/compose_equal_integration_test.go @@ -0,0 +1,245 @@ +//go:build integration + +package main + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/compose" + snapshot "github.com/novox/mesh-controller/internal/facts" +) + +// 1384: nothing shows the controller's composition and mesh-sdk's compose every node alike on the live facts. This test is +// that proof (novox/hq ADR 0453 clause 3): it reads the facts snapshot MESH_FACTS names (the live one in every check and +// every try, the build seat gives it), else one captured in testdata/facts-equal.json (taken with `facts show` and +// scrubbed as the snapshot is), else the snapshot of a small mesh of its own; raises it into throwaway stores as the merge +// gate does; and composes every machine both ways. +// +// (a) Foreseeing on the fresh store: the same error or none, the same validator problems, LeftOut and its reasons, Withheld, +// Unbound, Unplaced, HandedOnly and the own secrets named foreseen; and the ports mesh-sdk names foreseen are the ones the +// controller allocates when it sends. +// (b) the send: on two stores raised from the same snapshot, the controller's Allocating composition of every machine, and the +// adapter's compose, perform and compose again; the same ports, pair credentials and own secrets were made, by key. +// (c) after (b), Reading on one store, both ways: the declaration byte for byte. +// +// It sends nothing and opens no store but the throwaway ones. A difference names the machine, the comparison and the first +// field that differs; a machine both paths refuse alike is said, not failed. The test is deleted with the controller's +// composition (issue 1389). +func TestEveryMachineComposesAlikeBothWays(t *testing.T) { + ctx := t.Context() + admin := os.Getenv("MESH_TEST_POSTGRES") + if admin == "" { + t.Skip("no throwaway PostgreSQL (MESH_TEST_POSTGRES) to raise the mesh in") + } + f, source := equalityFacts(t) + t.Logf("the facts of %s (%s): %d machine(s)", f.Taken.Format("2006-01-02 15:04:05"), source, len(f.Machines)) + shelf, err := shelfOfFacts(f) + if err != nil { + t.Fatal(err) + } + sources := sourcesOfFacts(f) + var failures []string + + // (a) on the fresh store. mesh-sdk's composition first: it writes nothing, where the controller's makes every pair + // credential on the way. + fresh, dropFresh, err := throwawayStores(ctx, admin) + if err != nil { + t.Fatal(err) + } + if _, err := raiseFromFacts(ctx, fresh, f, shelf, sources); err != nil { + dropFresh() + t.Fatal(err) + } + sdkForeseen := map[string]composedBoth{} + foreseenPorts := map[string][]string{} + for _, m := range f.Machines { + in, err := readComposeInput(ctx, fresh) + if err != nil { + dropFresh() + t.Fatalf("%s: the adapter cannot read the mesh: %v", m.Name, err) + } + d, problems, cerr := compose.ComposeIn(in.Input, m.Name, compose.Foreseeing) + if cerr == nil { + foreseenPorts[m.Name] = append([]string(nil), d.ForeseenPorts...) + sort.Strings(foreseenPorts[m.Name]) + } + sdkForeseen[m.Name] = capturedFrom(sendableOf(d), problems, cerr) + } + oldForeseen := map[string]composedBoth{} + gens, gensErr := generators(ctx, fresh) + for _, m := range f.Machines { + if gensErr != nil { + oldForeseen[m.Name] = composedBoth{Err: gensErr.Error()} + continue + } + d, problems, err := composedAndValidated(ctx, fresh, m.Name, gens, Foreseeing) + oldForeseen[m.Name] = capturedFrom(d, problems, err) + } + dropFresh() + for _, m := range f.Machines { + failures = append(failures, bothWaysDiffer(m.Name, "(a) Foreseeing", oldForeseen[m.Name], sdkForeseen[m.Name], false)...) + } + + // (b) and (c): the controller's send of every machine, then both ways of reading, on one store. + oldStore, dropOld, err := throwawayStores(ctx, admin) + if err != nil { + t.Fatal(err) + } + if _, err := raiseFromFacts(ctx, oldStore, f, shelf, sources); err != nil { + dropOld() + t.Fatal(err) + } + oldGens, oldGensErr := generators(ctx, oldStore) + for _, m := range f.Machines { + if oldGensErr != nil { + break + } + _, _, _ = composedAndValidated(ctx, oldStore, m.Name, oldGens, Allocating) + } + oldMade, err := madeIn(ctx, oldStore) + if err != nil { + dropOld() + t.Fatal(err) + } + readOld, readSDK := map[string]composedBoth{}, map[string]composedBoth{} + for _, m := range f.Machines { + if oldGensErr != nil { + readOld[m.Name] = composedBoth{Err: oldGensErr.Error()} + } else { + d, problems, err := composedAndValidated(ctx, oldStore, m.Name, oldGens, Reading) + readOld[m.Name] = capturedFrom(d, problems, err) + } + d, problems, err := composeQuestion(ctx, oldStore, m.Name, compose.Reading, true) + readSDK[m.Name] = capturedFrom(d, problems, err) + } + dropOld() + for _, m := range f.Machines { + failures = append(failures, bothWaysDiffer(m.Name, "(c) Reading after the send", readOld[m.Name], readSDK[m.Name], true)...) + } + + sdkStore, dropSDK, err := throwawayStores(ctx, admin) + if err != nil { + t.Fatal(err) + } + if _, err := raiseFromFacts(ctx, sdkStore, f, shelf, sources); err != nil { + dropSDK() + t.Fatal(err) + } + sendRefused := map[string]string{} + for _, m := range f.Machines { + if _, err := composeSend(ctx, sdkStore, m.Name); err != nil { + sendRefused[m.Name] = err.Error() + } + } + sdkMade, err := madeIn(ctx, sdkStore) + dropSDK() + if err != nil { + t.Fatal(err) + } + failures = append(failures, madeDiffer("(b) the send", oldMade, sdkMade)...) + + // (a)'s ports: those mesh-sdk names foreseen, for each machine, are the ones the controller's send allocated to it. + for _, m := range f.Machines { + var allocated []string + for _, p := range oldMade.Ports { + if node, rest, ok := strings.Cut(p, " "); ok && node == m.Name { + module, port, _ := strings.Cut(strings.SplitN(rest, "->", 2)[0], ":") + allocated = append(allocated, module+":"+port) + } + } + sort.Strings(allocated) + // A port the snapshot already holds is not allocated by a send, and one a module is given is not allocated either: the + // ones mesh-sdk names must all be among those the send allocated. + for _, want := range foreseenPorts[m.Name] { + found := false + for _, got := range allocated { + found = found || got == want + } + if !found { + failures = append(failures, fmt.Sprintf("%s, (a) Foreseeing: mesh-sdk names the port %s foreseen and the "+ + "controller's send allocates none for it (it allocates %v)", m.Name, want, allocated)) + } + } + } + for machine, why := range sendRefused { + t.Logf("%s: mesh-sdk's send of it is refused: %s", machine, why) + } + for machine, c := range oldForeseen { + if c.Err != "" { + t.Logf("%s: the controller refuses it, and mesh-sdk alike: %s", machine, oneLine(c.Err)) + } + } + if len(failures) > 0 { + t.Fatalf("the two compositions differ on %d point(s):\n %s", len(failures), strings.Join(failures, "\n ")) + } + t.Logf("%d machine(s) compose alike both ways", len(f.Machines)) +} + +// equalityFacts is the snapshot the comparison composes: MESH_FACTS, else the one captured in testdata, else the facts of a +// mesh of its own (the same shape, small). Source says which. +func equalityFacts(t *testing.T) (snapshot.Facts, string) { + t.Helper() + ctx := t.Context() + if path := os.Getenv("MESH_FACTS"); path != "" { + f, err := readFacts(ctx, path) + if err != nil { + t.Fatalf("MESH_FACTS names %s and it is not a snapshot: %v", path, err) + } + return f, "MESH_FACTS " + path + } + if _, err := os.Stat("testdata/facts-equal.json"); err == nil { + f, err := readFacts(ctx, "testdata/facts-equal.json") + if err != nil { + t.Fatal(err) + } + return f, "captured in testdata/facts-equal.json" + } + open, _ := aMeshWithSecrets(t) + f, err := gatherFacts(ctx, open, "2.11.17") + if err != nil { + t.Fatal(err) + } + return f, "gathered from a mesh of the test's own" +} + +// madeIn is what a store holds that a send makes, by key and never by value. +func madeIn(ctx context.Context, open *stores) (made, error) { + var out made + inv := open.inventory + nodes, err := inv.Nodes(ctx) + if err != nil { + return out, err + } + for _, n := range nodes { + ports, err := inv.PortsFor(ctx, n.Name) + if err != nil { + return out, err + } + for _, p := range ports { + out.Ports = append(out.Ports, fmt.Sprintf("%s %s:%d->%d", n.Name, p.Module, p.Wanted, p.Machine)) + } + rows, err := inv.SecretsFrom(ctx, n.Name) + if err != nil { + return out, err + } + for _, s := range rows { + out.Pairs = append(out.Pairs, fmt.Sprintf("%s %s on %s from %s local=%s", s.Name, s.ConsumerModule, s.Consumer, n.Name, s.Local)) + } + } + own, err := inv.OwnSecretRows(ctx) + if err != nil { + return out, err + } + for _, r := range own { + out.OwnSecrets = append(out.OwnSecrets, fmt.Sprintf("%s %s/%s (%s)", r.Node, r.Module, r.Name, r.Origin)) + } + sort.Strings(out.Ports) + sort.Strings(out.Pairs) + sort.Strings(out.OwnSecrets) + return out, nil +} diff --git a/cmd/mesh-controller/compose_equal_test.go b/cmd/mesh-controller/compose_equal_test.go new file mode 100644 index 00000000..2b782992 --- /dev/null +++ b/cmd/mesh-controller/compose_equal_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + "testing" +) + +// The comparison of the controller's composition and mesh-sdk's (novox/hq ADR 0453 clause 3, design 47 R4b, issue 1384), +// the part of it that needs no store. The test that raises a mesh and composes every machine both ways is +// compose_equal_integration_test.go; both are deleted with the controller's own composition (issue 1389). + +// composedBoth is what one composition of a machine said, in the terms the two paths are compared in. +type composedBoth struct { + Err string + Problems []string + LeftOut map[string]string + Withheld []string + Unbound []string + Unplaced []string + Handed []string + // Foreseen are the own secrets the composition names foreseen, as `module/name`. + Foreseen []string + Body []byte +} + +// capturedFrom is a sendable, its validator problems and the error that came with it, as compared. +func capturedFrom(d sendable, problems []string, err error) composedBoth { + if err != nil { + return composedBoth{Err: err.Error()} + } + c := composedBoth{Problems: append([]string(nil), problems...), LeftOut: d.leftOutWhy, + Unplaced: append([]string(nil), d.unplaced...), Handed: append([]string(nil), d.handedOnly...), + Foreseen: append([]string(nil), d.foreseen...)} + for _, o := range d.withheld { + c.Withheld = append(c.Withheld, o.String()) + } + for _, u := range d.unbound { + c.Unbound = append(c.Unbound, u.String()) + } + sort.Strings(c.Problems) + sort.Strings(c.Withheld) + sort.Strings(c.Unbound) + sort.Strings(c.Unplaced) + sort.Strings(c.Handed) + sort.Strings(c.Foreseen) + c.Body, _ = d.Body() + return c +} + +// bothWaysDiffer says how two compositions of one machine differ, one line for each field that does, naming the machine and +// the comparison. A machine both refuse alike is no difference. withBody compares the declaration byte for byte and names +// the first resource that differs. +func bothWaysDiffer(machine, comparison string, old, sdk composedBoth, withBody bool) []string { + var out []string + say := func(field string, a, b any) { + out = append(out, fmt.Sprintf("%s, %s: %s differs: the controller's %v, mesh-sdk's %v", machine, comparison, field, a, b)) + } + if old.Err != sdk.Err { + say("the refusal", quoted(old.Err), quoted(sdk.Err)) + return out + } + if old.Err != "" { + return nil + } + for _, f := range []struct { + name string + old, sdk []string + }{ + {"the validator's problems", old.Problems, sdk.Problems}, + {"Withheld", old.Withheld, sdk.Withheld}, + {"Unbound", old.Unbound, sdk.Unbound}, + {"Unplaced", old.Unplaced, sdk.Unplaced}, + {"HandedOnly", old.Handed, sdk.Handed}, + {"the own secrets named foreseen", old.Foreseen, sdk.Foreseen}, + } { + if !sameStrings(f.old, f.sdk) { + say(f.name, f.old, f.sdk) + } + } + if !sameLeftOut(old.LeftOut, sdk.LeftOut) { + say("LeftOut and its reasons", old.LeftOut, sdk.LeftOut) + } + if withBody && !bytes.Equal(old.Body, sdk.Body) { + out = append(out, fmt.Sprintf("%s, %s: the declaration differs byte for byte; %s", machine, comparison, + firstDifferingResource(old.Body, sdk.Body))) + } + return out +} + +func quoted(s string) string { + if s == "" { + return "none" + } + return fmt.Sprintf("%q", s) +} + +func sameStrings(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) +} + +func sameLeftOut(a, b map[string]string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) +} + +// firstDifferingResource names the first resource of two declaration bodies that is not the same, and the first field of it. +func firstDifferingResource(a, b []byte) string { + var x, y struct { + Resources []map[string]any `json:"resources"` + } + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return "a body is not a declaration" + } + byID := func(rs []map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range rs { + out[fmt.Sprint(r["id"])] = r + } + return out + } + ids := map[string]bool{} + left, right := byID(x.Resources), byID(y.Resources) + for id := range left { + ids[id] = true + } + for id := range right { + ids[id] = true + } + var order []string + for id := range ids { + order = append(order, id) + } + sort.Strings(order) + for _, id := range order { + l, inLeft := left[id] + r, inRight := right[id] + switch { + case !inRight: + return fmt.Sprintf("resource %s is only in the controller's", id) + case !inLeft: + return fmt.Sprintf("resource %s is only in mesh-sdk's", id) + } + var fields []string + for k := range l { + fields = append(fields, k) + } + for k := range r { + if _, have := l[k]; !have { + fields = append(fields, k) + } + } + sort.Strings(fields) + for _, k := range fields { + if !reflect.DeepEqual(l[k], r[k]) { + return fmt.Sprintf("resource %s, field %s: the controller's %v, mesh-sdk's %v", id, k, l[k], r[k]) + } + } + } + if !reflect.DeepEqual(left, right) { + return "the envelopes differ" + } + return "the resources are the same; the envelope differs" +} + +// made is what a send made in a store, by key and never by value: the machine ports given, the pair credentials and the +// own secrets. +type made struct { + Ports []string + Pairs []string + OwnSecrets []string +} + +// madeDiffer says how what two sends made differs. +func madeDiffer(comparison string, old, sdk made) []string { + var out []string + for _, f := range []struct { + name string + old, sdk []string + }{ + {"the ports allocated", old.Ports, sdk.Ports}, + {"the pair credentials made", old.Pairs, sdk.Pairs}, + {"the own secrets made", old.OwnSecrets, sdk.OwnSecrets}, + } { + if !sameStrings(f.old, f.sdk) { + out = append(out, fmt.Sprintf("%s: %s differ: the controller's %s, mesh-sdk's %s", comparison, f.name, + strings.Join(f.old, ", "), strings.Join(f.sdk, ", "))) + } + } + return out +} + +// 1384/AC3: the comparison itself. A composition that differs by one setting's worth is named, with its machine and the +// resource and field that first differ; two that agree, and two that refuse alike, are not differences. It failed first, +// before bothWaysDiffer read the body: a changed setting went unseen. +func TestTheComparisonNamesTheMachineWhoseCompositionDiffers(t *testing.T) { + body := func(port string) []byte { + raw, err := json.Marshal(map[string]any{"declaration": 1, "resources": []map[string]any{ + {"id": "files.config", "type": "file", "content": "port=" + port}, + {"id": "files.dir", "type": "directory"}}}) + if err != nil { + t.Fatal(err) + } + return raw + } + same := composedBoth{Body: body("8080"), Foreseen: []string{"files/admin"}} + if got := bothWaysDiffer("laptop", "Reading", same, same, true); len(got) != 0 { + t.Fatalf("a composition differs from itself: %v", got) + } + changed := composedBoth{Body: body("8081"), Foreseen: []string{"files/admin"}} + got := bothWaysDiffer("laptop", "Reading", same, changed, true) + if len(got) != 1 || !strings.Contains(got[0], "laptop, Reading") || + !strings.Contains(got[0], "resource files.config, field content") || + !strings.Contains(got[0], "port=8080") || !strings.Contains(got[0], "port=8081") { + t.Fatalf("the changed setting is not named with its machine, resource and field: %v", got) + } + // A field of the lists. + other := composedBoth{Body: body("8080"), Foreseen: []string{"files/admin", "files/token"}} + if got := bothWaysDiffer("anchor", "Foreseeing", same, other, false); len(got) != 1 || + !strings.Contains(got[0], "anchor, Foreseeing: the own secrets named foreseen differs") { + t.Fatalf("a secret named foreseen on one path alone is not named: %v", got) + } + // Refused alike is said, not failed; refused differently is a difference. + a, b := composedBoth{Err: "files on laptop needs a credential"}, composedBoth{Err: "files on laptop needs a credential"} + if got := bothWaysDiffer("laptop", "Foreseeing", a, b, true); len(got) != 0 { + t.Fatalf("two compositions that refuse a machine alike differ: %v", got) + } + if got := bothWaysDiffer("laptop", "Foreseeing", a, composedBoth{Body: body("1")}, true); len(got) != 1 || + !strings.Contains(got[0], "the refusal differs") { + t.Fatalf("a machine one path refuses and the other composes is not named: %v", got) + } + // And what a send made. + if got := madeDiffer("the send", made{Ports: []string{"anchor files:80->8080"}}, made{}); len(got) != 1 || + !strings.Contains(got[0], "the ports allocated differ") { + t.Fatalf("a port allocated on one path alone is not named: %v", got) + } +}