diff --git a/modules/mesh-delivery-builder/internal/build/choose.go b/modules/mesh-delivery-builder/internal/build/choose.go new file mode 100644 index 0000000..c4cf30d --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/choose.go @@ -0,0 +1,63 @@ +package build + +import ( + "fmt" + "sort" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// ChooseNodes picks up to want build nodes for a language (ADR 0478 clause 3): the nodes whose toolchains key reports the +// version the nodes agree on, the ones with the fewest steps the builder has running there first, a tie broken by the +// node's name. It answers the toolchain they agree on. When nothing is reported, or the nodes report different versions +// (so the indexer agreed none), it fails naming the language and why. The build module's health is not read here: a node +// whose module refuses on its toolchain refuses the step, and the build fails naming it. +func ChooseNodes(language string, entries []delivery.NodeToolchain, running map[string]int, want int) ([]string, delivery.Toolchain2, error) { + tc, none := delivery.AgreedToolchain(entries) + if none != "" { + return nil, delivery.Toolchain2{}, fmt.Errorf("no %s build node qualifies: %s", language, none) + } + nodes := make([]string, 0, len(entries)) + seen := map[string]bool{} + for _, e := range entries { + if !seen[e.Node] { + seen[e.Node] = true + nodes = append(nodes, e.Node) + } + } + sort.Slice(nodes, func(i, j int) bool { + if running[nodes[i]] != running[nodes[j]] { + return running[nodes[i]] < running[nodes[j]] + } + return nodes[i] < nodes[j] + }) + if len(nodes) > want { + nodes = nodes[:want] + } + return nodes, tc, nil +} + +// Agree is the one digest every node built an artifact to (ADR 0478 clause 4), or an error naming each node and its +// digest when they differ. One node's digest agrees with itself. +func Agree(artifact string, byNode map[string]string) (string, error) { + nodes := make([]string, 0, len(byNode)) + for n := range byNode { + nodes = append(nodes, n) + } + sort.Strings(nodes) + var first string + for i, n := range nodes { + if i == 0 { + first = byNode[n] + continue + } + if byNode[n] != first { + msg := fmt.Sprintf("the nodes built %s to different digests:", artifact) + for _, n := range nodes { + msg += fmt.Sprintf(" %s %s;", n, byNode[n]) + } + return "", fmt.Errorf("%s", msg[:len(msg)-1]) + } + } + return first, nil +} diff --git a/modules/mesh-delivery-builder/internal/build/choose_test.go b/modules/mesh-delivery-builder/internal/build/choose_test.go new file mode 100644 index 0000000..c085dcd --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/choose_test.go @@ -0,0 +1,63 @@ +package build + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func tcs(language, version string, nodes ...string) []delivery.NodeToolchain { + var out []delivery.NodeToolchain + for _, n := range nodes { + out = append(out, delivery.NodeToolchain{Node: n, Toolchain: delivery.Toolchain{Language: language, Version: version, Recipe: "1"}}) + } + return out +} + +func TestChooseTakesTheAgreedNodesWithFewestRunningStepsTiesByName(t *testing.T) { // 1607/AC4, ADR 0478 clause 3 + entries := tcs("go", "go1.27.1", "novox", "ace", "g14", "shanks") + nodes, tc, err := ChooseNodes("go", entries, map[string]int{"novox": 3, "ace": 1, "g14": 1, "shanks": 0}, 2) + if err != nil { + t.Fatal(err) + } + if len(nodes) != 2 || nodes[0] != "shanks" || nodes[1] != "ace" { + t.Errorf("nodes = %v, want [shanks ace]: fewest steps first, a tie by name", nodes) + } + if tc.Version != "go1.27.1" || tc.Recipe != "1" || tc.Language != "go" { + t.Errorf("toolchain = %+v", tc) + } +} + +func TestChooseWithOneNodeAnswersOneNode(t *testing.T) { // 1607/AC4 + nodes, _, err := ChooseNodes("go", tcs("go", "go1.27.1", "novox"), nil, 2) + if err != nil || len(nodes) != 1 || nodes[0] != "novox" { + t.Errorf("nodes = %v, err %v, want [novox]", nodes, err) + } +} + +func TestChooseFailsNamingTheLanguageAndTheVersionsWhenNoNodeQualifies(t *testing.T) { // 1607/AC4 + _, _, err := ChooseNodes("typescript", nil, nil, 2) + if err == nil || !strings.Contains(err.Error(), "typescript") || !strings.Contains(err.Error(), delivery.ToolchainNoReport) { + t.Errorf("no report: error = %v, want it to name typescript and %q", err, delivery.ToolchainNoReport) + } + entries := append(tcs("go", "go1.27.1", "novox"), tcs("go", "go1.26.0", "ace")...) + _, _, err = ChooseNodes("go", entries, nil, 2) + if err == nil || !strings.Contains(err.Error(), "go1.27.1") || !strings.Contains(err.Error(), "go1.26.0") || !strings.Contains(err.Error(), "go") { + t.Errorf("disagreeing nodes: error = %v, want both versions named", err) + } +} + +func TestAgreeNeedsEqualDigestsAndNamesBothNodesWhenTheyDiffer(t *testing.T) { // 1607/AC4, ADR 0478 clause 4 + d, err := Agree("tool", map[string]string{"ace": digest, "novox": digest}) + if err != nil || d != digest { + t.Errorf("Agree = %q, %v, want %q", d, err, digest) + } + _, err = Agree("tool", map[string]string{"ace": digest, "novox": "sha256:" + hex64('9')}) + if err == nil || !strings.Contains(err.Error(), "ace") || !strings.Contains(err.Error(), "novox") || !strings.Contains(err.Error(), digest) || !strings.Contains(err.Error(), hex64('9')) || !strings.Contains(err.Error(), "tool") { + t.Errorf("differing digests: error = %v, want the artifact, both nodes and both digests", err) + } + if d, err := Agree("tool", map[string]string{"ace": digest}); err != nil || d != digest { + t.Errorf("one node: %q, %v", d, err) + } +}