# Conflicts: # modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildended.go # modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go # modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go # modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go # modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go # modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go # modules/mesh-delivery-assigner/internal/assign/wants_test.go # modules/mesh-delivery-assigner/manifest_test.go # modules/mesh-delivery-assigner/module.json
348 lines
17 KiB
Go
348 lines
17 KiB
Go
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.<group>.<heads>, 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")
|
|
}
|
|
}
|