package assigngraph import ( "context" "encoding/json" "errors" "fmt" "strings" "testing" "time" mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "git.novox.be/novox/mesh-sdk/go/seatdef" ) type memObjects map[string][]byte func (m memObjects) Put(_ context.Context, name string, data []byte) error { m[name] = data return nil } func (m memObjects) Get(_ context.Context, name string) ([]byte, error) { d, ok := m[name] if !ok { return nil, ErrNoObject } return d, nil } func (m memObjects) Names(context.Context) ([]string, error) { out := []string{} for n := range m { out = append(out, n) } return out, nil } func half(branch string, revision int64, entries ...meshcatalog.CatalogEntry) meshcatalogclient.Half { h := meshcatalogclient.Half{Revision: meshcatalog.CatalogRevision{Kind: "trunk", Branch: branch, Revision: revision, Source: revision + 10}, Entries: map[string]meshcatalog.CatalogEntry{}} for _, e := range entries { h.Entries[e.Module] = e h.Revision.Entries = append(h.Revision.Entries, meshcatalog.EntryRef{Module: e.Module, Digest: "sha256:" + e.Module + e.Fp}) } return h } // Design 47 *Two graphs, one source of truth* (lines 1353-1366, 1416-1418) and *An acting part's new version acts first* // (lines 1835-1836): each entry one module version on a node from the catalog graph of the branch its placement names, // its edges provisioning and acting, a revision computed again from its inputs equal to the one kept. func TestTheAssignmentGraphIsComputedFromItsInputsAndKept(t *testing.T) { ctx := context.Background() in := Inputs{ Halves: map[string]meshcatalogclient.Half{"main": half("main", 7, meshcatalog.CatalogEntry{Module: "node-engine", Fp: "src2:e1", Acts: []meshcatalog.ActingRule{{Seat: "node-engine", Step: "compose", On: "every", Bound: "30m"}}}, meshcatalog.CatalogEntry{Module: "build-go", Fp: "src2:b1", Acts: []meshcatalog.ActingRule{{Seat: "node-build-go", Step: "build", On: "go"}}}, meshcatalog.CatalogEntry{Module: "postgres", Fp: "src2:p1", Provides: []string{"database"}}, meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1", Requires: []string{"database", "secrets"}, Toolchains: map[string]meshcatalog.BuildEdge{"go": {Version: "1"}}}, )}, Placements: map[string]mma.Assignments{ "anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "node-engine"}, {Module: "postgres"}, {Module: "build-go"}, {Module: "web", Bindings: map[string]mma.Provider{"database": {Node: "anchor", Module: "postgres"}}}}}, "laptop": {Node: "laptop", Modules: []mma.PlacedModule{{Module: "node-engine"}, {Module: "web", Build: "src2:w0"}, {Module: "photos", Branch: "beta"}, {Module: "ghost"}}}, }, FirstBranch: func(module string) string { if module == "ghost" { return "" } return "main" }, } c := Compute(in) byNode := map[string]meshwants.AssignmentEntry{} for _, ref := range c.Revision.Entries { byNode[ref.Node+"/"+ref.Module] = c.Entries[ref.Digest] } webA, webL := byNode["anchor/web"], byNode["laptop/web"] if len(c.Revision.Entries) != 6 || len(c.Revision.Catalogs) != 1 || c.Revision.Catalogs[0] != (meshwants.CatalogOf{Branch: "main", Revision: 7, Source: 17}) { t.Fatalf("revision %+v", c.Revision) } if webA.Branch != "main" || webA.Fp != "src2:w1" || webA.Catalog != "sha256:websrc2:w1" || webL.Fp != "src2:w0" { t.Errorf("web's entries: %+v, %+v (the placement's build is the version on laptop)", webA, webL) } want := []meshwants.Edge{ {Kind: EdgeActing, Node: "anchor", Module: "build-go", Fp: "src2:b1", Seat: "node-build-go", Step: "build"}, {Kind: EdgeActing, Node: "anchor", Module: "node-engine", Fp: "src2:e1", Seat: "node-engine", Step: "compose", Bound: "30m"}, {Kind: EdgeProvisioning, Node: "anchor", Module: "postgres", Fp: "src2:p1", Provision: "database"}, } if fmt.Sprint(webA.Edges) != fmt.Sprint(want) { t.Errorf("web on anchor's edges:\n got %+v\nwant %+v", webA.Edges, want) } // laptop's web: its own node's node-engine composes it, the build module placed once builds it, and with no binding or // pin its database is the only provider's (design 47 G §357). if e := webL.Edges; len(e) != 3 || e[0].Module != "build-go" || e[0].Node != "anchor" || e[1].Module != "node-engine" || e[1].Node != "laptop" || e[2] != (meshwants.Edge{Kind: EdgeProvisioning, Node: "anchor", Module: "postgres", Fp: "src2:p1", Provision: "database"}) { t.Errorf("web on laptop's edges: %+v", e) } // Its secrets no placed module provides: held on both nodes, naming no candidate. if h := webA.Held; len(h) != 1 || h[0].Requirement != "secrets" || len(h[0].Candidates) != 0 { t.Errorf("web on anchor is held on %+v", h) } if e := byNode["anchor/postgres"].Edges; len(e) != 1 || e[0].Module != "node-engine" { t.Errorf("postgres builds no Go, so only the node-engine acts on it: %+v", e) } if len(byNode["anchor/node-engine"].Edges) != 0 { t.Errorf("an acting part acts on itself: %+v", byNode["anchor/node-engine"].Edges) } if strings.Join(c.Uncomputed, "|") != "laptop/ghost: no watched branch: its placement names none and no registration of it is known|"+ "laptop/photos: the catalog graph of the branch beta is not held" { t.Errorf("uncomputed %q", c.Uncomputed) } // Kept once; computed again from the same inputs it is the same revision, kept no second time. o := memObjects{} at := time.Date(2026, 11, 10, 18, 0, 0, 0, time.UTC) rev, kept, err := Keep(ctx, o, c, "catalog-revision", at) if err != nil || !kept || rev.Revision != 1 || rev.Cause != "catalog-revision" || len(o) != 7 { t.Fatalf("kept %v %+v (%v), objects %d", kept, rev, err, len(o)) } if again, kept, err := Keep(ctx, o, Compute(in), "placement-changed", at); err != nil || kept || again.Revision != 1 { t.Errorf("recomputed from the same inputs: kept %v, %+v, %v", kept, again, err) } // A placement change moves laptop's web to the catalog's version: revision 2, one entry more stored, the rest shared. in.Placements["laptop"].Modules[1].Build = "" rev2, kept, err := Keep(ctx, o, Compute(in), "placement-changed", at) if err != nil || !kept || rev2.Revision != 2 || len(o) != 9 || rev2.Placements == rev.Placements { t.Fatalf("revision 2 %v %+v (%v), objects %d", kept, rev2, err, len(o)) } // The verbs read only what is kept. r, err := Revision(ctx, o, meshwants.RevisionInput{}) if err != nil || r.Revision.Revision != 2 { t.Errorf("revision = %+v, %v", r, err) } if _, err := Revision(ctx, o, meshwants.RevisionInput{Group: "g", Heads: "h"}); !errors.Is(err, ErrNoCandidate) { t.Errorf("a candidate's revision: %v", err) } e, err := Entry(ctx, o, meshwants.EntryInput{Revision: 1, Node: "laptop", Module: "web"}) if err != nil || e.Entry.Fp != "src2:w0" || !strings.HasPrefix(e.Digest, "sha256:") { t.Errorf("entry = %+v, %v", e, err) } d, err := Difference(ctx, o, meshwants.DifferenceInput{From: 1, To: 2}) if err != nil || len(d.Changes) != 1 || fmt.Sprint(d.Changes[0]) != fmt.Sprint(meshwants.Change{Node: "laptop", Module: "web", From: "src2:w0", To: "src2:w1"}) || strings.Join(d.Nodes, ",") != "laptop" { t.Errorf("difference = %+v, %v", d, err) } if _, err := Difference(ctx, o, meshwants.DifferenceInput{To: 2}); err == nil { t.Error("a difference with one revision answered") } if ev := Event(rev2); ev.Revision != 2 || len(ev.Catalogs) != 1 || ev.Kind != "trunk" { t.Errorf("event %+v", ev) } } // Design 47 G §357, §708, §709 and A-167, A-229: a requirement points to the named seat's holder, else a pin, else the // provider on the consumer's node, else the only provider; otherwise the entry is held, naming the candidates (and the // unheld seat), and nothing is guessed. An optional requirement nothing provides is left absent; a requirement the // manifest's contributes names gets a contribution edge to the same provider. func TestARequirementPointsByTheOrderOrIsHeld(t *testing.T) { for _, c := range []struct { name string kv []string // the nodes kv is placed on pin bool // web's node pins kv on laptop seat string // the seat delivering cache: "" none, "held" held by kv on server, "unheld" held by none optional bool // web requires cache optionally contributes bool // web's manifest contributes to cache edge string // the node of the provisioning edge, "" for none held string // the candidates' nodes of the hold, "-" for no hold heldSeat string // the seat the hold names }{ {name: "the provider on the consumer's node", kv: []string{"anchor", "laptop"}, edge: "anchor", held: "-"}, {name: "the only provider", kv: []string{"laptop"}, edge: "laptop", held: "-"}, {name: "a pin before the node's own", kv: []string{"anchor", "laptop"}, pin: true, edge: "laptop", held: "-"}, {name: "two providers elsewhere: held", kv: []string{"laptop", "server"}, held: "laptop,server"}, {name: "none: held naming none", held: ""}, {name: "the named seat's holder before the provider on the node", kv: []string{"anchor", "server"}, seat: "held", edge: "server", held: "-"}, {name: "a delivering seat no placement holds: held naming it", kv: []string{"anchor"}, seat: "unheld", held: "", heldSeat: "cache-seat"}, {name: "an optional requirement nothing provides: absent", optional: true, held: "-"}, {name: "an optional requirement with two providers elsewhere: held", optional: true, kv: []string{"laptop", "server"}, held: "laptop,server"}, {name: "a contributed requirement: a contribution edge too", kv: []string{"laptop"}, contributes: true, edge: "laptop", held: "-"}, } { t.Run(c.name, func(t *testing.T) { web := meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1", Requires: []string{"cache"}, Manifest: map[string]any{"module": "web", "version": "1", "requires": []any{"cache"}}} if c.optional { web.Manifest["requires"] = []any{map[string]any{"name": "cache", "optional": true}} } if c.contributes { web.Contributes = []string{"cache"} } h := half("main", 3, meshcatalog.CatalogEntry{Module: "kv", Fp: "src2:k1", Provides: []string{"cache"}}, web) defs := map[string]seatdef.Definition{} if c.seat != "" { h.Revision.Definitions = []meshcatalog.CatalogRevisionDefinitionsItem{{Seat: "cache-seat", Digest: "sha256:d"}} defs["cache-seat"] = seatdef.Definition{Seat: "cache-seat", Scope: seatdef.ScopeMesh, Delivers: "cache"} } pl := map[string]mma.Assignments{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}}} if c.pin { pl["anchor"] = mma.Assignments{Node: "anchor", Modules: pl["anchor"].Modules, Pins: map[string]mma.Provider{"cache": {Node: "laptop", Module: "kv"}}} } for _, n := range c.kv { a := pl[n] a.Node, a.Modules = n, append(a.Modules, mma.PlacedModule{Module: "kv"}) pl[n] = a } if c.seat == "held" { a := pl["server"] a.Holds = append(a.Holds, mma.Holding{Claim: "cache-seat", Module: "kv"}) pl["server"] = a } out := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": h}, Placements: pl, FirstBranch: func(string) string { return "main" }, Definitions: defs}) var got meshwants.AssignmentEntry for _, ref := range out.Revision.Entries { if ref.Node == "anchor" && ref.Module == "web" { got = out.Entries[ref.Digest] } } edge, contribution := "", "" for _, e := range got.Edges { if e.Kind == EdgeProvisioning && e.Provision == "cache" { edge = e.Node } if e.Kind == EdgeContribution && e.Provision == "cache" { contribution = e.Node + "/" + e.Module } } held, heldSeat := "-", "" if len(got.Held) == 1 && got.Held[0].Requirement == "cache" { var nodes []string for _, cand := range got.Held[0].Candidates { nodes = append(nodes, cand.Node) } held, heldSeat = strings.Join(nodes, ","), got.Held[0].Seat } if edge != c.edge || held != c.held || heldSeat != c.heldSeat || len(got.Held) > 1 { t.Errorf("web points to %q, held on %q seat %q (%+v); want %q, %q, %q", edge, held, heldSeat, got.Held, c.edge, c.held, c.heldSeat) } if want := map[bool]string{true: c.edge + "/kv"}[c.contributes]; contribution != want { t.Errorf("web's contribution edge points to %q, want %q", contribution, want) } }) } } // Design 47 line 1385: a whole assignment graph revision of about 120 modules on four nodes is under the bus's 256 KiB // value limit. func TestARevisionOf120ModulesOnFourNodesFitsOneValue(t *testing.T) { var entries []meshcatalog.CatalogEntry placements := map[string]mma.Assignments{} for i := range 120 { entries = append(entries, meshcatalog.CatalogEntry{Module: fmt.Sprintf("module-%03d", i), Fp: "src2:" + strings.Repeat("a", 64)}) } for _, node := range []string{"anchor", "home", "desk", "laptop"} { a := mma.Assignments{Node: node} for _, e := range entries { a.Modules = append(a.Modules, mma.PlacedModule{Module: e.Module}) } placements[node] = a } h := half("main", 1, entries...) for i := range h.Revision.Entries { h.Revision.Entries[i].Digest = "sha256:" + strings.Repeat("b", 64) } c := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": h}, Placements: placements, FirstBranch: func(string) string { return "main" }}) raw, _ := json.Marshal(c.Revision) if len(c.Revision.Entries) != 480 || len(raw) >= 256<<10 { t.Errorf("%d entries in %d bytes", len(c.Revision.Entries), len(raw)) } } func (m memObjects) Delete(_ context.Context, name string) error { delete(m, name) return nil } // A-201, A-235, §186, §189: a run's candidate assignment graph is kept apart from trunk's under candidate.., served by // revision, entry and difference against trunk, and dropped by its group alone. func TestARunsCandidateGraphIsKeptApartServedAndDropped(t *testing.T) { ctx := context.Background() at := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC) placed := map[string]mma.Assignments{"ace": {Modules: []mma.PlacedModule{{Module: "web"}}}} first := func(string) string { return "main" } o := memObjects{} trunk := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": half("main", 3, meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1"})}, Placements: placed, FirstBranch: first}) if _, _, err := Keep(ctx, o, trunk, "catalog-revision", at); err != nil { t.Fatal(err) } cand := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": half("main", 4, meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w2"})}, Placements: placed, FirstBranch: first}) if _, _, err := KeepCandidate(ctx, o, cand, "", "h1", "catalog-revision", at); err == nil { t.Error("a candidate naming no group was kept") } rev, fresh, err := KeepCandidate(ctx, o, cand, "run-7", "h1", "catalog-revision", at) if err != nil || !fresh || rev.Kind != meshcatalogclient.KindCandidate || rev.Group != "run-7" || rev.Revision != 1 { t.Fatalf("kept %+v fresh %v: %v", rev, fresh, err) } if _, ok := o[CandidateObject("run-7", "h1")]; !ok { t.Fatalf("the candidate is not under candidate.run-7.h1: %v", o) } if _, fresh, _ := KeepCandidate(ctx, o, cand, "run-7", "h1", "catalog-revision", at); fresh { t.Error("the same candidate kept again is a new revision") } newest, has, err := Newest(ctx, o) if err != nil || !has || newest.Kind != meshcatalogclient.KindTrunk || newest.Revision != 1 { t.Errorf("trunk's newest is %+v (%v, %v): the candidate is not kept apart", newest, has, err) } for _, in := range []meshwants.RevisionInput{{Group: "run-7"}, {Group: "run-7", Heads: "h1"}} { a, err := Revision(ctx, o, in) if err != nil || a.Revision.Group != "run-7" { t.Errorf("revision %+v answered %+v: %v", in, a.Revision, err) } } if _, err := Revision(ctx, o, meshwants.RevisionInput{Group: "run-8"}); !errors.Is(err, ErrNoCandidate) { t.Errorf("an unkept candidate answered %v", err) } e, err := Entry(ctx, o, meshwants.EntryInput{Group: "run-7", Node: "ace", Module: "web"}) if err != nil || e.Entry.Fp != "src2:w2" { t.Errorf("the candidate's entry is %+v: %v", e.Entry, err) } d, err := Difference(ctx, o, meshwants.DifferenceInput{Group: "run-7"}) if err != nil || len(d.Changes) != 1 || d.Changes[0].From != "src2:w1" || d.Changes[0].To != "src2:w2" { t.Errorf("the candidate against trunk is %+v: %v", d, err) } if _, err := Difference(ctx, o, meshwants.DifferenceInput{}); err == nil { t.Error("difference with none of from, to and group is not refused") } if _, _, err := KeepCandidate(ctx, o, cand, "run-9", "h2", "catalog-revision", at); err != nil { t.Fatal(err) } if n, err := DropCandidate(ctx, o, "run-7"); err != nil || n != 1 { t.Errorf("dropped %d: %v", n, err) } if _, err := ReadCandidate(ctx, o, "run-7", ""); !errors.Is(err, ErrNoCandidate) { t.Errorf("the dropped candidate reads: %v", err) } if _, err := ReadCandidate(ctx, o, "run-9", ""); err != nil { t.Errorf("another run's candidate went with it: %v", err) } if _, has, _ := Newest(ctx, o); !has { t.Error("dropping a candidate dropped trunk") } } // Design 47 G §512, §186, A-242: the difference orders its changes itself: each change names the changes it is ordered after // with the kind of the edge, and Tiers holds the changes in that order, an acting part and a provider before what they act on. func TestTheDifferenceFillsItsTiersAndEachChangesAfter(t *testing.T) { ctx := context.Background() build := func(ne, pg, web string) Computed { return Compute(Inputs{ Halves: map[string]meshcatalogclient.Half{"main": half("main", 7, meshcatalog.CatalogEntry{Module: "node-engine", Fp: ne, Acts: []meshcatalog.ActingRule{{Seat: "node-engine", Step: "compose", On: "every"}}}, meshcatalog.CatalogEntry{Module: "postgres", Fp: pg, Provides: []string{"database"}}, meshcatalog.CatalogEntry{Module: "web", Fp: web, Requires: []string{"database"}}, )}, Placements: map[string]mma.Assignments{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "node-engine"}, {Module: "postgres"}, {Module: "web", Bindings: map[string]mma.Provider{"database": {Node: "anchor", Module: "postgres"}}}}}}, FirstBranch: func(string) string { return "main" }, }) } o := memObjects{} at := time.Date(2026, 11, 12, 1, 0, 0, 0, time.UTC) if _, _, err := Keep(ctx, o, build("src2:e1", "src2:p1", "src2:w1"), "catalog-revision", at); err != nil { t.Fatal(err) } if _, _, err := Keep(ctx, o, build("src2:e2", "src2:p2", "src2:w2"), "catalog-revision", at); err != nil { t.Fatal(err) } d, err := Difference(ctx, o, meshwants.DifferenceInput{From: 1, To: 2}) if err != nil || len(d.Changes) != 3 { t.Fatalf("difference %+v, %v", d, err) } after := map[string]string{} for _, c := range d.Changes { var a []string for _, x := range c.After { a = append(a, x.Kind+":"+x.Node+"/"+x.Module) } after[c.Module] = strings.Join(a, ",") } if after["node-engine"] != "" || after["postgres"] != "acting:anchor/node-engine" || after["web"] != "acting:anchor/node-engine,provisioning:anchor/postgres" { t.Errorf("after = %v", after) } var tiers []string for _, tier := range d.Tiers { var ms []string for _, r := range tier { ms = append(ms, r.Module) } tiers = append(tiers, strings.Join(ms, ",")) } if strings.Join(tiers, "|") != "node-engine|postgres|web" { t.Errorf("tiers = %v", tiers) } }