bus-state is answered with no credential of a consumer's own, so ADR 0603 §5 counts the provider's own have: the assigner reads, per pass, what the running healthy builds on online nodes offer (their manifests through the builder's builds) and holds a consumer with a bus-state entry under the provisions' rule until one offers it, handed or not and with no provisioner key. The words name bus state, and the delivery view names the edge from them. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
255 lines
13 KiB
Go
255 lines
13 KiB
Go
package graph
|
|
|
|
import (
|
|
"errors"
|
|
"maps"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// TestGraphEdges: the module graph's cycle, dependents and providers, each from the edges alone (ADR 0603 §2, ADR 0604 §1).
|
|
func TestGraphEdges(t *testing.T) {
|
|
t.Run("a node's value", nodeValues)
|
|
fp := "src2:" + strings.Repeat("b", 64)
|
|
node := func(repository, path string, edit func(*Node)) Node {
|
|
n := Node{FP: fp, Repository: repository, Path: path}
|
|
if edit != nil {
|
|
edit(&n)
|
|
}
|
|
return n
|
|
}
|
|
gotool := map[string]Toolchain{"go": {Module: "build-go", Version: "go1", Recipe: "1"}}
|
|
// A base chain, a package and a build module: app stands on base, imports lib's package, is built by build-go.
|
|
g := Graph{
|
|
"build-go": node("novox/mesh-catalog", "modules/build-go", func(n *Node) { n.Toolchains = gotool }),
|
|
"lib": node("novox/lib", delivery.RootPath, nil),
|
|
"base": node("novox/mesh-catalog", "modules/base", nil),
|
|
"app": node("novox/mesh-catalog", "modules/app", func(n *Node) {
|
|
n.Bases, n.Packages, n.Toolchains = []string{"base"}, []string{"git.novox.be/novox/lib"}, gotool
|
|
}),
|
|
"top": node("novox/mesh-catalog", "modules/top", func(n *Node) { n.Bases = []string{"app"} }),
|
|
// A package published from inside a module's directory belongs to that module (ADR 0604 §1): users imports build-go's.
|
|
"users": node("novox/other", "modules/users", func(n *Node) {
|
|
n.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun"}
|
|
}),
|
|
"db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }),
|
|
"web": node("novox/mesh-catalog", "modules/web", func(n *Node) {
|
|
n.Requires, n.Awaits = []string{"database", "db"}, []string{"database"}
|
|
}),
|
|
}
|
|
for module, want := range map[string][]string{
|
|
"base": {"app", "top"},
|
|
"lib": {"app", "top"},
|
|
"build-go": {"app", "top", "users"}, // build-go's own toolchain edge reaches itself, which is left out
|
|
"top": nil,
|
|
"db": nil, // a provision edge is no build edge
|
|
} {
|
|
if got := g.Dependents(module); !reflect.DeepEqual(got, want) {
|
|
t.Errorf("dependents of %s = %v, want %v", module, got, want)
|
|
}
|
|
}
|
|
if got := g.Providers("database"); !reflect.DeepEqual(got, []string{"db"}) {
|
|
t.Errorf("providers of database = %v", got)
|
|
}
|
|
if got := g.Providers("db"); !reflect.DeepEqual(got, []string{"db"}) { // a module provides its own name
|
|
t.Errorf("providers of db = %v", got)
|
|
}
|
|
if got := g.Before("web"); !reflect.DeepEqual(got, []string{"db"}) { // web awaits database, which db provides
|
|
t.Errorf("web waits for %v", got)
|
|
}
|
|
if got := g.Before("top"); !reflect.DeepEqual(got, []string{"app"}) {
|
|
t.Errorf("top waits for %v", got)
|
|
}
|
|
if got := g.Requirers("db"); !reflect.DeepEqual(got, []string{"web"}) {
|
|
t.Errorf("requirers of db = %v", got)
|
|
}
|
|
for pkg, want := range map[string]string{
|
|
"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun": "build-go", // inside its directory
|
|
"git.novox.be/novox/mesh-catalog/modules/build-go": "build-go", // its directory
|
|
"git.novox.be/novox/mesh-catalog/modules/build-gone": "", // beside it, not inside
|
|
"git.novox.be/novox/lib/sub": "lib", // a root module publishes what is below it
|
|
} {
|
|
if got := g.Publisher(pkg); got != want {
|
|
t.Errorf("publisher of %s = %q, want %q", pkg, got, want)
|
|
}
|
|
}
|
|
if g.Publisher("git.novox.be/novox/lib") != "lib" || g.Publisher("git.novox.be/novox/mesh-sdk/go") != "" {
|
|
t.Error("a package's publisher is not the module at its repository and directory")
|
|
}
|
|
// From manifests alone (the check's graph): a requirement answered by a helper at mesh scope is awaited, an optional
|
|
// one is not, and two modules awaiting each other's provision are a cycle.
|
|
offer := func(name string) []catalogue.Offer {
|
|
return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
|
|
}
|
|
ms := map[string]catalogue.Manifest{
|
|
"db": {Module: "db", Provides: offer("database"), Requires: []string{"site", "logs"}, OptionalRequires: map[string]bool{"logs": true}},
|
|
"web": {Module: "web", Provides: offer("site"), Requires: []string{"database"}, Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "base"}}}},
|
|
}
|
|
mg := OfManifests(ms)
|
|
if db := mg["db"]; !reflect.DeepEqual(db.Requires, []string{"site"}) || !reflect.DeepEqual(db.Awaits, []string{"site"}) ||
|
|
!reflect.DeepEqual(mg["web"].Bases, []string{"base"}) || !reflect.DeepEqual(db.Provides, []string{"database"}) {
|
|
t.Errorf("the graph of manifests = %+v", mg)
|
|
}
|
|
if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) {
|
|
t.Errorf("the cycle of manifests = %v", got)
|
|
}
|
|
// The verb and bus-state edges (ADR 0614 §2, §3, §5, §6), from manifests alone: a caller waits for the module serving
|
|
// what it invokes, a bus-state entry requires bus-state of the bus module offering it, the builder's remove and the
|
|
// holder's builder_versions close a cycle and both are left out, read from either side, a package edge gives no order,
|
|
// and a module invoking its own tool is no wait.
|
|
builder := catalogue.Manifest{Module: "builder", Tools: []string{"builder_versions"}, Invokes: []string{"seat:artifact-store.remove"}}
|
|
store := catalogue.Manifest{Module: "store", Claims: []catalogue.Claim{{Name: "artifact-store", Scope: catalogue.ScopeMesh,
|
|
Serves: []string{"remove"}}}, Invokes: []string{"builder.builder_versions", "*"}}
|
|
verbs := map[string]catalogue.Manifest{
|
|
"builder": builder, "store": store,
|
|
"bus": {Module: "bus", Provides: []catalogue.Offer{{Name: BusState, Scope: catalogue.ScopeMesh}}},
|
|
"keeper": {Module: "keeper", BusState: []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}}},
|
|
"caller": {Module: "caller", Invokes: []string{"seat:artifact-store.remove", "caller.own", "nobody.serves"}, Tools: []string{"own"}},
|
|
}
|
|
vg := OfManifests(verbs)
|
|
for name, want := range map[string][]string{"caller": {"store"}, "keeper": {"bus"}, "builder": nil, "store": nil} {
|
|
if got := vg.Before(name); !reflect.DeepEqual(got, want) {
|
|
t.Errorf("%s waits for %v, want %v", name, got, want)
|
|
}
|
|
}
|
|
if b, s := vg["builder"], vg["store"]; !reflect.DeepEqual(b.LeftOut, []string{"seat:artifact-store.remove"}) ||
|
|
!reflect.DeepEqual(s.LeftOut, []string{"builder.builder_versions"}) ||
|
|
!reflect.DeepEqual(s.Serves, []string{"seat:artifact-store.remove"}) || !reflect.DeepEqual(s.Invokes, []string{"builder.builder_versions"}) {
|
|
t.Errorf("the builder's cycle is not left out from either side: %+v %+v", b, s)
|
|
}
|
|
if k := vg["keeper"]; !reflect.DeepEqual(k.Requires, []string{BusState}) || !reflect.DeepEqual(k.Awaits, []string{BusState}) {
|
|
t.Errorf("a bus-state entry is no requirement of bus-state: %+v", k)
|
|
}
|
|
if c := vg["caller"]; !reflect.DeepEqual(c.LeftOut, []string{"caller.own"}) || vg.Cycle() != nil {
|
|
t.Errorf("a module's own tool is a wait, or a left-out verb edge a cycle: %+v %v", c, vg.Cycle())
|
|
}
|
|
if got := OfManifests(map[string]catalogue.Manifest{"keeper": verbs["keeper"]})["keeper"]; got.Awaits != nil {
|
|
t.Errorf("bus-state with no module offering it is awaited: %+v", got)
|
|
}
|
|
// The words of a want held on a verb or a reader capability name their edge, and no other words do.
|
|
for words, want := range map[string]string{
|
|
VerbWaitWords("seat:mesh-catalog.modules", "artifact-store-tools", "g14"): "verb seat:mesh-catalog.modules",
|
|
ReaderWaitWords("g14", []string{"tool-runner", "bus-state"}, "node-tools", "src2:abcdef"): "reader capability tool-runner, bus-state",
|
|
BusStateWaitWords("keeper", "g14"): "bus state",
|
|
"the verb is served": "",
|
|
"the node-engine of g14 has not started": "",
|
|
} {
|
|
if got := EdgeOfWords(words); got != want {
|
|
t.Errorf("the edge of %q = %q, want %q", words, got, want)
|
|
}
|
|
}
|
|
for name, c := range map[string]struct {
|
|
edit func(Graph)
|
|
want []string
|
|
}{
|
|
"none: a toolchain on itself and a requirement not awaited order nothing": {func(Graph) {}, nil},
|
|
"bases": {func(g Graph) {
|
|
b := g["base"]
|
|
b.Bases = []string{"top"}
|
|
g["base"] = b
|
|
}, []string{"app", "base", "top", "app"}},
|
|
"provisions a -> b -> a": {func(g Graph) {
|
|
d := g["db"]
|
|
d.Requires, d.Awaits = []string{"site"}, []string{"site"}
|
|
g["db"] = d
|
|
w := g["web"]
|
|
w.Provides = []string{"site"}
|
|
g["web"] = w
|
|
}, []string{"db", "web", "db"}},
|
|
"a package cycle orders nothing": {func(g Graph) {
|
|
l := g["lib"]
|
|
l.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/app"}
|
|
g["lib"] = l
|
|
}, nil},
|
|
"a verb edge with a provision, before LeaveOut leaves it out": {func(g Graph) {
|
|
d := g["db"]
|
|
d.Invokes = []string{"web.page"}
|
|
g["db"] = d
|
|
w := g["web"]
|
|
w.Serves = []string{"web.page"}
|
|
g["web"] = w
|
|
}, []string{"db", "web", "db"}},
|
|
"a requirement not awaited is no edge": {func(g Graph) {
|
|
d := g["db"]
|
|
d.Requires = []string{"site"}
|
|
g["db"] = d
|
|
w := g["web"]
|
|
w.Provides = []string{"site"}
|
|
g["web"] = w
|
|
}, nil},
|
|
} {
|
|
h := Graph{}
|
|
for k, v := range g {
|
|
h[k] = v
|
|
}
|
|
c.edit(h)
|
|
if got := h.Cycle(); !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("%s: cycle = %v, want %v", name, got, c.want)
|
|
}
|
|
if h.LeaveOut(); h.Cycle() != nil && slices.ContainsFunc(slices.Collect(maps.Values(h)), func(n Node) bool { return len(n.Invokes) > 0 }) {
|
|
t.Errorf("%s: a cycle through a verb edge is left once its verb edges are left out: %v", name, h.Cycle())
|
|
}
|
|
}
|
|
}
|
|
|
|
// sampleNode is a graph node with every kind of edge, its lists unsorted.
|
|
func sampleNode() Node {
|
|
return Node{FP: "src2:" + strings.Repeat("a", 64), Repository: "novox/mesh-catalog", Path: "modules/gitea",
|
|
Bases: []string{"z-base", "a-base"}, Packages: []string{"git.novox.be/novox/mesh-sdk/go"},
|
|
Toolchains: map[string]Toolchain{"go": {Module: "build-go", Version: "go1.27.1", Recipe: "3"}},
|
|
Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"},
|
|
Invokes: []string{"seat:mesh-catalog.modules", "builder.builder_versions"}, Serves: []string{"seat:forge.pulls"},
|
|
LeftOut: []string{"builder.builder_versions"}}
|
|
}
|
|
|
|
// nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is
|
|
// refused naming why, and the state is the indexer's graph (ADR 0603 §2).
|
|
func nodeValues(t *testing.T) {
|
|
if Bucket != "mesh-delivery-indexer_graph" || Read != "mesh-delivery-indexer.graph" || History != 5 || State != "graph" {
|
|
t.Error("the graph's state differs from the decided layout")
|
|
}
|
|
g := sampleNode()
|
|
data, err := Encode(g)
|
|
if err != nil || !strings.Contains(string(data), `"v":1`) {
|
|
t.Fatalf("encode: %s %v", data, err)
|
|
}
|
|
if got, err := Decode(data); err != nil || !Same(got, g) || !slices.IsSorted(got.Bases) {
|
|
t.Errorf("round trip: %+v %v", got, err)
|
|
}
|
|
if Same(g, Node{None: "x", Repository: "a/b"}) {
|
|
t.Error("different graph nodes are the same")
|
|
}
|
|
for _, in := range []string{`{"none":"x","repository":"a/b"}`, `{"v":2,"none":"x","repository":"a/b"}`} {
|
|
if _, err := Decode([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
|
|
t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err)
|
|
}
|
|
}
|
|
for name, c := range map[string]struct {
|
|
change func(*Node)
|
|
word string
|
|
}{
|
|
"both": {func(g *Node) { g.None = "why" }, "both"},
|
|
"neither": {func(g *Node) { g.FP = "" }, "neither"},
|
|
"an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"},
|
|
"a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"},
|
|
"a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"},
|
|
"a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"},
|
|
"a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"},
|
|
"awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"},
|
|
"no repository": {func(g *Node) { g.Repository = "" }, "repositor"},
|
|
"leaving out what it does not invoke": {func(g *Node) { g.LeftOut = []string{"x.y"} }, "x.y"},
|
|
"an invoke of no verb": {func(g *Node) { g.Invokes = []string{"seat:nothing"} }, "seat:nothing"},
|
|
} {
|
|
g := sampleNode()
|
|
c.change(&g)
|
|
if _, err := Encode(g); err == nil || !strings.Contains(err.Error(), c.word) {
|
|
t.Errorf("a node with %s: %v, want it refused naming %q", name, err, c.word)
|
|
}
|
|
}
|
|
}
|