The assigner reads which edges are left out from the graph, so one place decides the cycle; the whole graph is judged, a module past the graph's cap included, before the first GraphMost are written. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
512 lines
24 KiB
Go
512 lines
24 KiB
Go
package index
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/process"
|
|
|
|
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
|
|
|
"mesh-delivery-indexer/internal/fingerprint"
|
|
)
|
|
|
|
// The fixtures here are mesh-catalog a67f0196's manifests (testdata, captured from that commit) with the module renamed (and,
|
|
// where a test needs one, a build.on entry added as the controller's own test has it, standing_on_test.go:22): the manifest
|
|
// parser, not a hand-written manifest, reads them.
|
|
|
|
func renamed(t *testing.T, from, to string) string {
|
|
t.Helper()
|
|
raw := catalogueManifest(t, from)
|
|
out := strings.Replace(raw, `"module": "`+from+`"`, `"module": "`+to+`"`, 1)
|
|
if out == raw {
|
|
t.Fatalf("%s's manifest has no module line", from)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func standingOn(t *testing.T, raw, base string) string {
|
|
t.Helper()
|
|
out := strings.Replace(raw, `"build": {`, `"build": {
|
|
"on": [{"arg": "RUNTIME_BASE", "module": "`+base+`", "artifact": "runtime"}],`, 1)
|
|
if out == raw {
|
|
t.Fatal("the manifest has no build")
|
|
}
|
|
return out
|
|
}
|
|
|
|
const buildAgentRepo = "novox/mesh-controller"
|
|
|
|
// build-agent's trunk builds as `mesh-controller.builds` showed them on 2026-10-21 (ids, times and source fingerprints
|
|
// as listed; the repository is the clone URL the record names, the ref "main"; the inputs are the shape of aInputs, which
|
|
// the indexer does not read for this): the older one at 13:03 and the newer at 15:03 (issue 1727).
|
|
const (
|
|
agentSrcX = "src1:80e5fb129a98754a85fa3a43b1dded12dddedd25823d12dbe8783287faa0c59c"
|
|
agentSrcY = "src1:44ed217d74a7c7e38400bb0bf32358ed96502cb385d24f1435fe971ad44c7b88"
|
|
agentCloneURL = "http://novox.internal:20000/novox/mesh-controller.git"
|
|
)
|
|
|
|
func agentBuild(id, src, at string) [2]any {
|
|
when, _ := time.Parse(time.RFC3339, at)
|
|
raw, _ := delivery.EncodeBuildLogEntry(delivery.BuildLogEntry{Event: delivery.BuildEndedEvent, Module: "build-agent", FP: src, Repository: agentCloneURL,
|
|
Directory: "modules/build-agent", Commit: "cada7e3c", Node: "n1", At: when, Outcome: delivery.BuildDone})
|
|
return [2]any{"build-agent", raw}
|
|
}
|
|
|
|
type mod struct {
|
|
name, manifest string
|
|
}
|
|
|
|
// merged applies one merge of the catalogue carrying these modules (every one a Go bundle's go.sum, a TypeScript bundle's
|
|
// package-lock.json) and returns the indexer.
|
|
// graphOf is the graph node the indexer holds of module.
|
|
func graphOf(t *testing.T, ix *Indexer, module string) graph.Node {
|
|
t.Helper()
|
|
v, ok := ix.Graph.(*memStore).vals[module]
|
|
if !ok {
|
|
t.Fatalf("graph.%s is not held", module)
|
|
}
|
|
g, err := graph.Decode(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
func newFormIndexer(t *testing.T, mods ...mod) (*Indexer, *memStore) {
|
|
t.Helper()
|
|
ix, store, f := newFormIndexerUnmerged(t, mods...)
|
|
if err := ix.Apply(context.Background(), f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// build-agent holds no fingerprint of today's form, as live (no merge of the controller is applied; issue 1727).
|
|
return ix, store
|
|
}
|
|
|
|
// newFormIndexerUnmerged is the indexer and the catalogue merge of these modules, not yet applied.
|
|
func newFormIndexerUnmerged(t *testing.T, mods ...mod) (*Indexer, *memStore, delivery.Forge) {
|
|
t.Helper()
|
|
var recs [][2]any
|
|
trees, manifests := map[string]string{}, map[string]string{}
|
|
locks := map[string]map[string]string{}
|
|
for i, m := range mods {
|
|
dir := "modules/" + m.name
|
|
recs = append(recs, rec(t, "build-"+m.name, m.name, catalogueRepo, dir, "main", "", "2026-10-19T09:00:00Z", aInputs))
|
|
trees[dir], manifests[dir] = "tree-"+m.name, m.manifest
|
|
locks[dir] = map[string]string{"go.sum": avahiGoSum, "package-lock.json": strings.Repeat("c", 63) + string(rune('a'+i))}
|
|
}
|
|
recs = append(recs, agentBuild("build-1792580585452696147", agentSrcX, "2026-10-21T13:03:00Z"))
|
|
ix, store, _ := manifestsIndexer(t, recs...)
|
|
return ix, store, withManifests(mergedMsg(catalogueRepo, trees), manifests, locks)
|
|
}
|
|
|
|
// toolchainValue is a toolchains value as the build module's writer encodes it (mesh-sdk go/delivery EncodeToolchain).
|
|
func toolchainValue(t *testing.T, language, version, recipe string) []byte {
|
|
t.Helper()
|
|
b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: language, Version: version, Recipe: recipe, At: time.Now()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func reportToolchain(t *testing.T, ix *Indexer, module, node, language, version string) {
|
|
t.Helper()
|
|
reportToolchainRecipe(t, ix, module, node, language, version, "1")
|
|
}
|
|
|
|
func reportToolchainRecipe(t *testing.T, ix *Indexer, module, node, language, version, recipe string) {
|
|
t.Helper()
|
|
key, err := delivery.ToolchainKey(node, language)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, recipe), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 1444/AC5
|
|
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
|
|
if m, _ := moduleOf(t, mods, "avahi"); m.None2 != delivery.ToolchainNoReport {
|
|
t.Run("a recipe change builds a Go module at once and again once the agreement moves", aRecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves)
|
|
t.Fatalf("before: none2 = %q", m.None2)
|
|
}
|
|
// The release that last merged avahi: its source revision names avahi's commit, so Src (and FPSrc) are 7.
|
|
merged, _ := moduleOf(t, mods, "avahi")
|
|
src := delivery.Source{Group: "g", Heads: map[string]string{merged.Repository: merged.Commit}}
|
|
if err := ix.ApplySource(context.Background(), 7, src); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m, _ := moduleOf(t, mods, "avahi"); m.Src != 7 || m.FPSrc != 7 {
|
|
t.Fatalf("before: src %d fp-src %d, want 7 and 7", m.Src, m.FPSrc)
|
|
}
|
|
if g := graphOf(t, ix, "avahi"); g.None != delivery.ToolchainNoReport || g.Toolchains != nil {
|
|
t.Errorf("before: graph.avahi = %+v, want no toolchain edge and the reason", g)
|
|
}
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1")
|
|
m, _ := moduleOf(t, mods, "avahi")
|
|
// L3 (ADR 0603 §2, §3): the agreement moving rewrites the module's toolchain edge, and again when it moves on.
|
|
if g := graphOf(t, ix, "avahi"); g.FP != m.FP2 || g.Toolchains["go"].Version != "go1.27.1" || g.Toolchains["go"].Module != "mesh-build-go" {
|
|
t.Errorf("after: graph.avahi = %+v, want the toolchain edge of go1.27.1", g)
|
|
}
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.28.0")
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.28.0")
|
|
if g := graphOf(t, ix, "avahi"); g.Toolchains["go"].Version != "go1.28.0" {
|
|
t.Errorf("moved on: graph.avahi = %+v, want the toolchain edge of go1.28.0", g)
|
|
}
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1")
|
|
m, _ = moduleOf(t, mods, "avahi")
|
|
if m.None2 != "" || m.FP2 == "" {
|
|
t.Errorf("none2 = %q fp2 = %q, want a fingerprint: no builder input is waited for (ADR 0530 §1)", m.None2, m.FP2)
|
|
}
|
|
// ADR 0601 §1 (review round 2): a fingerprint moved with no merge has no release that carries it: fp-src 0, not known,
|
|
// though the source revision is still the release's at the module's own Src; a source revision heard again stamps nothing.
|
|
if m.FPSrc != 0 {
|
|
t.Errorf("a fingerprint moved by a recompute: fp-src %d, want 0", m.FPSrc)
|
|
}
|
|
if err := ix.ApplySource(context.Background(), 8, src); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again, _ := moduleOf(t, mods, "avahi"); again.FPSrc != 0 {
|
|
t.Errorf("a source revision stamped the recomputed fingerprint: fp-src %d", again.FPSrc)
|
|
}
|
|
}
|
|
|
|
// ADR 0603 §3 (D9): a source revision that changes the Go build module's recipe and a Go module builds the module at once, with
|
|
// the recipe the nodes run, and again once the agreement moves to the new one: the module never waits and never loses its
|
|
// fingerprint between the two, and its toolchain edge names the recipe each was computed with.
|
|
func aRecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves(t *testing.T) {
|
|
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
|
|
for _, node := range []string{"novox", "ace"} {
|
|
reportToolchain(t, ix, "mesh-build-go", node, "go", "go1.27.1")
|
|
}
|
|
first, _ := moduleOf(t, mods, "avahi")
|
|
if first.FP2 == "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "1" {
|
|
t.Fatalf("with the running recipe: fp2 %q, graph %+v", first.FP2, graphOf(t, ix, "avahi"))
|
|
}
|
|
// The release carrying the new recipe is merged, but the nodes still run the old one: one node reporting the new recipe is
|
|
// no strict majority, so the fingerprint stays, nothing waits.
|
|
reportToolchainRecipe(t, ix, "mesh-build-go", "novox", "go", "go1.27.1", "2")
|
|
if held, _ := moduleOf(t, mods, "avahi"); held.FP2 != first.FP2 || held.None2 != "" {
|
|
t.Errorf("one node on the new recipe: fp2 %q none2 %q, want the fingerprint of the running recipe, %q", held.FP2, held.None2, first.FP2)
|
|
}
|
|
reportToolchainRecipe(t, ix, "mesh-build-go", "ace", "go", "go1.27.1", "2")
|
|
again, _ := moduleOf(t, mods, "avahi")
|
|
if again.FP2 == "" || again.FP2 == first.FP2 || again.None2 != "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "2" {
|
|
t.Errorf("the agreement moved: fp2 %q (was %q) none2 %q graph %+v, want a second fingerprint at recipe 2", again.FP2, first.FP2, again.None2, graphOf(t, ix, "avahi"))
|
|
}
|
|
}
|
|
|
|
func TestNodesReportingDifferentVersionsLeaveTheReasonNamingBoth(t *testing.T) { // 1444/AC6
|
|
// Both reports are read before the module's first fingerprint is computed (a fingerprint computed before they
|
|
// disagree is kept: TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith).
|
|
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
|
|
for node, version := range map[string]string{"novox": "go1.27.1", "ace": "go1.26.0"} {
|
|
key, _ := delivery.ToolchainKey(node, "go")
|
|
ix.PutToolchain("mesh-build-go", key, toolchainValue(t, "go", version, "1"), false)
|
|
}
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, _ := moduleOf(t, mods, "avahi")
|
|
if !strings.Contains(m.None2, "builders disagree") || !strings.Contains(m.None2, "go1.27.1") || !strings.Contains(m.None2, "go1.26.0") {
|
|
t.Errorf("none2 = %q, want 'builders disagree' naming both versions", m.None2)
|
|
}
|
|
}
|
|
|
|
// builderModules are the builder, a base, a module on the base (named so that it is stored before its base) and a module
|
|
// whose base is a TypeScript module with no toolchain reported.
|
|
func builderModules(t *testing.T) []mod {
|
|
return []mod{
|
|
{"mesh-delivery-builder", renamed(t, "avahi", "mesh-delivery-builder")},
|
|
{"mesh-tools", renamed(t, "avahi", "mesh-tools")},
|
|
{"avahi", standingOn(t, catalogueManifest(t, "avahi"), "mesh-tools")},
|
|
{"other", standingOn(t, renamed(t, "avahi", "other"), "ts-base")},
|
|
{"ts-base", renamed(t, "audit-logger", "ts-base")},
|
|
}
|
|
}
|
|
|
|
func goInputs(t *testing.T, ix *Indexer, mods *memStore, module, builder string, bases ...string) fingerprint.Inputs {
|
|
t.Helper()
|
|
return fingerprint.Inputs{Module: module, Tree: "tree-" + module, Builder: builder, Bases: bases,
|
|
Locks: map[string]string{"tools": avahiGoSum},
|
|
Toolchains: map[string]delivery.Toolchain2{"tools": {Language: "go", Version: "go1.27.1", Recipe: "1"}}}
|
|
}
|
|
|
|
func fp2(t *testing.T, in fingerprint.Inputs) string {
|
|
t.Helper()
|
|
fp, none := fingerprint.Of(in)
|
|
if none != "" {
|
|
t.Fatal(none)
|
|
}
|
|
return fp
|
|
}
|
|
|
|
// 1887/I1, ADR 0530 §1: bases are computed before the modules taking them, and no module's fingerprint takes the builder,
|
|
// the builder's own included: a trunk build of build-agent (or of the builder) moves nothing.
|
|
func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // 1444/AC7, AC8
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
builderFP := fp2(t, goInputs(t, ix, mods, "mesh-delivery-builder", ""))
|
|
toolsFP := fp2(t, goInputs(t, ix, mods, "mesh-tools", ""))
|
|
want := map[string]string{"mesh-delivery-builder": builderFP, "mesh-tools": toolsFP, "avahi": fp2(t, goInputs(t, ix, mods, "avahi", "", toolsFP))}
|
|
for name, fp := range want {
|
|
if m, _ := moduleOf(t, mods, name); m.FP2 != fp || m.None2 != "" || m.Inputs2 == nil || m.Inputs2.Builder != "" {
|
|
t.Errorf("%s: fp2 = %q none2 = %q inputs %+v, want %s with no builder input", name, m.FP2, m.None2, m.Inputs2, fp)
|
|
}
|
|
}
|
|
if m, _ := moduleOf(t, mods, "other"); m.FP2 != "" || !strings.Contains(m.None2, "ts-base") || !strings.Contains(m.None2, "base") {
|
|
t.Errorf("other: fp2 = %q none2 = %q, want none2 naming the base ts-base", m.FP2, m.None2)
|
|
}
|
|
// L3 (ADR 0603 §2): the module graph holds each module's edges as this recompute computed them.
|
|
goTool := map[string]graph.Toolchain{"go": {Module: "mesh-build-go", Version: "go1.27.1", Recipe: "1"}}
|
|
if g := graphOf(t, ix, "avahi"); g.FP != want["avahi"] || !reflect.DeepEqual(g.Bases, []string{"mesh-tools"}) ||
|
|
!reflect.DeepEqual(g.Toolchains, goTool) || g.Repository != catalogueRepo || g.Path != "modules/avahi" {
|
|
t.Errorf("graph.avahi = %+v", g)
|
|
}
|
|
if g := graphOf(t, ix, "other"); g.FP != "" || !strings.Contains(g.None, "ts-base") || !reflect.DeepEqual(g.Bases, []string{"ts-base"}) {
|
|
t.Errorf("graph.other = %+v, want its base and its reason", g)
|
|
}
|
|
graph := ix.Graph.(*memStore)
|
|
written := graph.writes
|
|
if err := ix.Recompute(context.Background()); err != nil || graph.writes != written {
|
|
t.Errorf("a recompute that moves nothing wrote the graph %d times (%v)", graph.writes-written, err)
|
|
}
|
|
// A module the modules state no longer holds (a merge dropped it, issue 1900) leaves the graph at the next recompute.
|
|
delete(mods.vals, "other")
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, held := graph.vals["other"]; held {
|
|
t.Error("graph.other is still held after its module went")
|
|
}
|
|
if _, held := graph.vals["avahi"]; !held {
|
|
t.Error("graph.avahi went with another module")
|
|
}
|
|
// Review finding 3 (ADR 0603 §2): a graph state not on the bus yet (process.ErrNotYet), or one whose listing fails, never
|
|
// fails a recompute; the first writes nothing and says so, the second skips the deletion pass and says so.
|
|
graph.vals["ghost"], graph.revs["ghost"] = []byte("x"), 1
|
|
var said []string
|
|
ix.Logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }
|
|
for _, row := range []struct {
|
|
name string
|
|
err error
|
|
say string
|
|
}{
|
|
{"not on the bus yet", process.ErrNotYet, "not on the bus yet"},
|
|
{"a listing that fails", errors.New("the bus is slow"), "are not deleted"},
|
|
} {
|
|
said, graph.keysErr = nil, row.err
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Errorf("%s: the recompute failed: %v", row.name, err)
|
|
}
|
|
if _, held := graph.vals["ghost"]; !held || !strings.Contains(strings.Join(said, "\n"), row.say) {
|
|
t.Errorf("%s: ghost held %v, log %q, want it kept and %q said", row.name, held, said, row.say)
|
|
}
|
|
}
|
|
graph.keysErr = nil
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, held := graph.vals["ghost"]; held {
|
|
t.Error("graph.ghost is still held once the graph can be listed")
|
|
}
|
|
a := agentBuild("build-1792587792086548572", agentSrcY, "2026-10-21T15:03:00Z")
|
|
_ = ix.Builds.Put(a[1].([]byte))
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, fp := range want {
|
|
if m, _ := moduleOf(t, mods, name); m.FP2 != fp {
|
|
t.Errorf("%s moved with a build of build-agent: fp2 = %q, want %s", name, m.FP2, fp)
|
|
}
|
|
}
|
|
// ADR 0614 §2, §3: the graph names what each module invokes and serves, and leaves out, over the whole graph, the
|
|
// verb edges of two modules calling each other, from either side; a third calling one of them keeps its edge.
|
|
invoking := func(name, invokes string) mod {
|
|
raw := renamed(t, "avahi", name)
|
|
return mod{name, strings.Replace(raw, `"version": "1",`, `"version": "1", "invokes": [`+invokes+`],`, 1)}
|
|
}
|
|
vx, _ := newFormIndexer(t, invoking("caller", `"callee.avahi_status"`), invoking("callee", `"caller.avahi_browse", "*"`),
|
|
invoking("third", `"callee.avahi_status"`))
|
|
for name, want := range map[string][2][]string{
|
|
"caller": {{"callee.avahi_status"}, {"callee.avahi_status"}},
|
|
"callee": {{"caller.avahi_browse"}, {"caller.avahi_browse"}},
|
|
"third": {{"callee.avahi_status"}, nil},
|
|
} {
|
|
if g := graphOf(t, vx, name); !reflect.DeepEqual(g.Invokes, want[0]) || !reflect.DeepEqual(g.LeftOut, want[1]) ||
|
|
!slices.Contains(g.Serves, name+".avahi_status") {
|
|
t.Errorf("graph.%s = %+v, want it to invoke %v and leave out %v", name, g, want[0], want[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestABuildHeardBeforeAnyToolchainIsReadDropsNoFingerprint(t *testing.T) { // 1727 (review: a restart flapped every FP2)
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
before, _ := moduleOf(t, mods, "mesh-tools")
|
|
if before.FP2 == "" {
|
|
t.Fatalf("mesh-tools has no fp2 to begin with: %+v", before)
|
|
}
|
|
// a restarted indexer has read no toolchains bucket yet
|
|
ix.mu.Lock()
|
|
ix.toolchains = nil
|
|
ix.mu.Unlock()
|
|
if err := ix.RecomputeAtStart(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m, _ := moduleOf(t, mods, "mesh-tools"); m.FP2 != before.FP2 || m.None2 != "" {
|
|
t.Errorf("mesh-tools: fp2 = %q none2 = %q, want fp2 %q kept", m.FP2, m.None2, before.FP2)
|
|
}
|
|
}
|
|
|
|
func TestAToolchainEntryChangedToTheSameValuesWritesNothing(t *testing.T) { // 1444/AC9
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
before := mods.writes
|
|
time.Sleep(2 * time.Millisecond)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") // the same version and recipe, a later time
|
|
if mods.writes != before {
|
|
t.Errorf("modules written %d times after the same values, want none", mods.writes-before)
|
|
}
|
|
}
|
|
|
|
func TestModulesThatStandOnEachOtherAreNamedAsACycle(t *testing.T) { // 1444 (the indexer has no graph: a cycle is a reason)
|
|
ix, mods := newFormIndexer(t,
|
|
mod{"aa", standingOn(t, renamed(t, "avahi", "aa"), "bb")},
|
|
mod{"bb", standingOn(t, renamed(t, "avahi", "bb"), "aa")})
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
for _, name := range []string{"aa", "bb"} {
|
|
if m, _ := moduleOf(t, mods, name); m.FP2 != "" || !strings.Contains(m.None2, "cycle") {
|
|
t.Errorf("%s: fp2 = %q none2 = %q, want a reason naming the cycle", name, m.FP2, m.None2)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAMergeWritesEachModuleOnceWithItsFinalValueAndAReplayWritesNothing(t *testing.T) { // 1444 (review finding 8)
|
|
ctx := context.Background()
|
|
ix, mods, f := newFormIndexerUnmerged(t, mod{"avahi", catalogueManifest(t, "avahi")})
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") // agreed before the merge is heard
|
|
if err := ix.Apply(ctx, f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, _ := moduleOf(t, mods, "avahi")
|
|
if mods.writes != 1 || m.FP2 == "" {
|
|
t.Errorf("writes = %d fp2 = %q after one merge, want one write with its fingerprint", mods.writes, m.FP2)
|
|
}
|
|
if err := ix.Apply(ctx, f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mods.writes != 1 {
|
|
t.Errorf("writes = %d after the same merge again, want still 1", mods.writes)
|
|
}
|
|
}
|
|
|
|
func TestAMergeKeepsAFingerprintTheModuleStillHas(t *testing.T) { // 1444 (review finding 8: a merge never blanks FP2)
|
|
ctx := context.Background()
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
before, _ := moduleOf(t, mods, "mesh-tools")
|
|
if before.FP2 == "" {
|
|
t.Fatalf("mesh-tools has no fp2: %+v", before)
|
|
}
|
|
w := mods.writes
|
|
// the same catalogue merged again at a later commit: nothing moved
|
|
_, _, f := newFormIndexerUnmerged(t, builderModules(t)...)
|
|
f.Merge = otherMerge
|
|
if err := ix.Apply(ctx, f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// and the commit stays the one the fingerprint first appeared at (the operator, 2026-10-25)
|
|
if after, _ := moduleOf(t, mods, "mesh-tools"); after.FP2 != before.FP2 || after.Commit != before.Commit || mods.writes != w {
|
|
t.Errorf("fp2 = %q at %.8s (was %q at %.8s), writes %d more", after.FP2, after.Commit, before.FP2, before.Commit, mods.writes-w)
|
|
}
|
|
}
|
|
|
|
func TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith(t *testing.T) { // 1615/AC3
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
if m, _ := moduleOf(t, mods, "avahi"); m.Toolchains != nil {
|
|
t.Fatalf("before a toolchain is reported: toolchains = %+v, want none", m.Toolchains)
|
|
}
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
m, _ := moduleOf(t, mods, "avahi")
|
|
want := map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}}
|
|
if m.FP2 == "" || !reflect.DeepEqual(m.Toolchains, want) {
|
|
t.Fatalf("avahi: fp2 %q toolchains %+v, want %+v", m.FP2, m.Toolchains, want)
|
|
}
|
|
if o, _ := moduleOf(t, mods, "other"); o.FP2 != "" || o.Toolchains != nil {
|
|
t.Errorf("a module with no fingerprint carries toolchains %+v", o.Toolchains)
|
|
}
|
|
// An upgrade moves the fingerprint and the toolchains together, with no merge.
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.2")
|
|
n, _ := moduleOf(t, mods, "avahi")
|
|
if n.FP2 == m.FP2 || n.Toolchains["go"].Version != "go1.27.2" {
|
|
t.Errorf("after the upgrade: fp2 %q (was %q) toolchains %+v, want the new version with the new fingerprint", n.FP2, m.FP2, n.Toolchains)
|
|
}
|
|
// Issue 1585 (ADR 0573 §2): a module whose go.sum names mesh-sdk takes mesh-sdk's latest published version into its
|
|
// fingerprint and keeps it beside its toolchains; a mesh-sdk trunk merge moves it with no merge of its own, and a module
|
|
// using no package is not moved. With no published version held, it has none, naming the package.
|
|
const sdk = "git.novox.be/novox/mesh-sdk/go"
|
|
_, _, uses := newFormIndexerUnmerged(t, builderModules(t)...)
|
|
uses.Packages = map[string][]string{"modules/avahi": {sdk}}
|
|
if err := ix.Apply(context.Background(), uses); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u, _ := moduleOf(t, mods, "avahi"); u.FP2 != "" || u.None2 != fingerprint.NoPackageVersion+sdk {
|
|
t.Errorf("no published mesh-sdk: fp2 %q none2 %q", u.FP2, u.None2)
|
|
}
|
|
tools, _ := moduleOf(t, mods, "mesh-tools")
|
|
sdkMerge := func(commit, tree string) delivery.Module {
|
|
t.Helper()
|
|
f := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 3, Head: h1,
|
|
Merge: commit, Root: tree, Trees: map[string]string{"": tree}, Manifests: map[string]string{"": `{"module":"mesh-sdk","version":"1"}`}}
|
|
if err := ix.Apply(context.Background(), f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, _ := moduleOf(t, mods, "avahi")
|
|
if a.FP2 == "" || !reflect.DeepEqual(a.Packages, delivery.PackageVersions{sdk: commit}) {
|
|
t.Fatalf("after mesh-sdk's merge %.8s: fp2 %q none2 %q packages %v", commit, a.FP2, a.None2, a.Packages)
|
|
}
|
|
if o, _ := moduleOf(t, mods, "mesh-tools"); o.FP2 != tools.FP2 || o.Packages != nil {
|
|
t.Errorf("mesh-tools uses no package and moved: fp2 %q (was %q) packages %v", o.FP2, tools.FP2, o.Packages)
|
|
}
|
|
return a
|
|
}
|
|
first := sdkMerge(strings.Repeat("1", 40), "tree-sdk-1")
|
|
second := sdkMerge(strings.Repeat("2", 40), "tree-sdk-2")
|
|
if second.FP2 == first.FP2 {
|
|
t.Errorf("a mesh-sdk trunk merge kept avahi's fingerprint %q", first.FP2)
|
|
}
|
|
n = second
|
|
// Nodes that disagree keep the fingerprint already computed, with its toolchains (issue 1940, 2026-10-25 20:56: a
|
|
// stale report blanked every module and the controller's take-in refused modules already built); only a new
|
|
// fingerprint needs the agreement: a merge that moves the module's tree gives it none, naming the disagreement.
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.26.0")
|
|
if d, _ := moduleOf(t, mods, "avahi"); d.FP2 != n.FP2 || !reflect.DeepEqual(d.Toolchains, n.Toolchains) || d.None2 != "" {
|
|
t.Errorf("disagreeing nodes: fp2 %q toolchains %+v none2 %q, want the fingerprint kept (%q) with %+v", d.FP2, d.Toolchains, d.None2, n.FP2, n.Toolchains)
|
|
}
|
|
_, _, f := newFormIndexerUnmerged(t, builderModules(t)...)
|
|
f.Trees["modules/avahi"] = "tree-avahi-moved"
|
|
if err := ix.Apply(context.Background(), f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d, _ := moduleOf(t, mods, "avahi"); d.FP2 != "" || d.Toolchains != nil || !strings.HasPrefix(d.None2, "builders disagree") {
|
|
t.Errorf("a new tree while nodes disagree: fp2 %q toolchains %+v none2 %q, want no fingerprint and the disagreement", d.FP2, d.Toolchains, d.None2)
|
|
}
|
|
// They agree again: the new tree gets its fingerprint.
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.2")
|
|
if a, _ := moduleOf(t, mods, "avahi"); a.FP2 == "" || a.FP2 == n.FP2 {
|
|
t.Errorf("agreed again: fp2 %q (was %q), want a new fingerprint", a.FP2, n.FP2)
|
|
}
|
|
}
|