diff --git a/cmd/mesh-controller/replay614_test.go b/cmd/mesh-controller/replay614_test.go new file mode 100644 index 00000000..bd256104 --- /dev/null +++ b/cmd/mesh-controller/replay614_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "reflect" + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// **A new SDK plans the toolchain, and then every bundle compiled in it** (novox/hq issue 614), reasoned +// as `mesh-controller.delivery-plan` reasons it: the modules registered as a build registers them — +// resolved, so carrying no `build` — their builds recorded with what they stood on and what they made, +// the graph read by theGraph and the change scoped by pullScope. On 2026-10-13 mesh-sdk 0.1.14 planned +// "builds mesh-sdk, sent nowhere": mesh-tools' `build.on` naming the SDK's package and the providers' +// TypeScript bundles were edges only while their manifests were unbuilt. +// +// **The replay of issue 614** (mesh-lab R614): written only with what the controller had before its fix — a +// store, modules registered and builds recorded, theGraph and pullScope — so it is laid over the commit before +// and fails there with the plan of that day, [[mesh-sdk]]. Needs a store: MESH_TEST_POSTGRES. +func TestReplay614ANewSDKPlansTheToolchainAndThenItsBundles(t *testing.T) { + inv := inventory.ForTest(t) + ctx := t.Context() + asked := time.Now().Add(-time.Hour) + digest := func(c string) string { return "@sha256:" + strings.Repeat(c, 64) } + register := func(m catalogue.Manifest, repository, path string, against []string, made []inventory.Artifact) { + t.Helper() + if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path, + Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil { + t.Fatal(err) + } + if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main", + Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Made: made, Asked: asked}); err != nil { + t.Fatal(err) + } + } + bundle := func(module, language string) catalogue.Manifest { + return catalogue.Manifest{Module: module, Version: "1", Bundles: []catalogue.Bundle{{ + Name: "code", Language: language, Source: catalogue.ArtifactStoreScheme + module + "/code" + digest("2"), + Entrypoints: []string{"tools/index.js"}}}} + } + register(catalogue.Manifest{Module: "build-agent", Version: "1", + Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}, + "novox/mesh-catalog", "modules/build-agent", nil, nil) + register(catalogue.Manifest{Module: "mesh-sdk", Version: "1"}, "novox/mesh-sdk", "", nil, + []inventory.Artifact{{Name: "lib", Kind: "package", Reference: "@novox/mesh-sdk@0.1.13"}}) + register(catalogue.Manifest{Module: "mesh-tools", Version: "1"}, "novox/mesh-tools", "", + []string{"@novox/mesh-sdk@0.1.13", catalogue.ArtifactStoreScheme + "upstream/docker.io/library/node" + digest("0")}, + []inventory.Artifact{{Name: "build", Kind: "image", Reference: catalogue.ArtifactStoreScheme + "mesh-tools/build" + digest("1")}}) + register(catalogue.Manifest{Module: "mesh-tools-go", Version: "1"}, "novox/mesh-tools", "go", nil, nil) + register(bundle("minio", "typescript"), "novox/mesh-catalog", "modules/minio", nil, nil) + register(bundle("umami", "typescript"), "novox/mesh-catalog", "modules/umami", nil, nil) + register(bundle("dnsmasq", "go"), "novox/mesh-catalog", "modules/dnsmasq", nil, nil) + + entries, read, edges, err := theGraph(ctx, inv) + if err != nil { + t.Fatal(err) + } + m := orderMember{ID: "novox/mesh-sdk@new", Repository: "novox/mesh-sdk", Head: "new", + Paths: []string{"src/provisioner/index.ts", "package.json"}} + s := pullScope(m.pull(), entries, read, edges) + want := [][]string{{"mesh-sdk"}, {"mesh-tools"}, {"minio", "umami"}} + if !reflect.DeepEqual(s.Reach.Plan.Tiers, want) { + t.Fatalf("a new SDK plans %v, want %v (edges %v)", s.Reach.Plan.Tiers, want, edges) + } + if !reflect.DeepEqual(s.Dependents, []string{"mesh-tools", "minio", "umami"}) { + t.Errorf("dependents %v", s.Dependents) + } +} diff --git a/internal/inventory/dependencies.go b/internal/inventory/dependencies.go index 26d07385..d3042d8e 100644 --- a/internal/inventory/dependencies.go +++ b/internal/inventory/dependencies.go @@ -58,7 +58,42 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) { if err != nil { return nil, err } - return dependenciesOf(entries, against, nil), nil + held, err := i.Held(ctx) + if err != nil { + return nil, err + } + return dependenciesWith(entries, against, held), nil +} + +// packagesHeld is, by package name, the module whose artifact the mesh holds as that package: a +// `package` artifact's reference is `@` (the builder's publishPackage), never an +// artifact-store reference, so a build that stood on it recorded the package's name and version. +func packagesHeld(held map[string]string) map[string]string { + out := map[string]string{} + for key, reference := range held { + module, _, ok := strings.Cut(key, "/") + if !ok { + continue + } + if name := packageName(reference); name != "" { + out[name] = module + } + } + return out +} + +// packageName is the name of a `@` package reference, or "" for anything else: a +// kept or pinned image (`…@sha256:…`), a URL, or a reference with no version. +func packageName(reference string) string { + if strings.HasPrefix(reference, catalogue.ArtifactStoreScheme) || strings.Contains(reference, "@sha256:") || + strings.Contains(reference, "://") { + return "" + } + at := strings.LastIndex(reference, "@") + if at <= 0 || at == len(reference)-1 { + return "" + } + return reference[:at] } // dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue. @@ -66,6 +101,20 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) { // `read` draws no edge (novox/hq ADR 0267 rule 4): what a build read moves it through its build source, in // the planner, never through the relation. func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge { + return dependenciesWith(entries, against, nil) +} + +// dependenciesWith is the relation with the artifacts the mesh holds, "/" to its +// reference (Held), so a package a build stood on is read as its module's. +// +// **A built manifest carries no `build`** (catalogue.Resolve drops it), so what a module declared it +// is built on is read from what is kept of it once it is built (novox/hq issue 614): the bases its +// newest build was handed (`against`) — an artifact-store reference, or a package's `@` +// — and the language of every bundle it compiled (`Bundles`). Read only from records the mesh already +// keeps, so it holds for every module built before it, and a controller rolled back to one without it +// reads the same records as before. +func dependenciesWith(entries []Entry, against map[string][]string, held map[string]string) []Edge { + packages := packagesHeld(held) known := map[string]bool{} var builders []string for _, e := range entries { @@ -111,6 +160,13 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][ } } } + // **And once it is built**, from the bundles its resolved manifest keeps (novox/hq issue 614): the + // `build` it was declared with is gone by then, and a toolchain that moved rebuilt none of them. + for _, b := range e.Manifest.Bundles { + if chain, err := builder.ToolchainFor(b.Language); err == nil { + add(name, chain.Base, EdgeStandsOn) + } + } for _, ref := range against[name] { if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok { // A copy of an upstream image is no module's artifact (novox/hq ADR 0257): its @@ -121,6 +177,13 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][ if base, _, found := strings.Cut(rest, "/"); found { add(name, base, EdgeStandsOn) } + continue + } + // A package base (`build.on` naming a module's `package` artifact) is recorded as the + // package's name and version (novox/hq issue 614): the module that publishes that package + // is what it stood on, whichever version the mesh holds now. + if base, ok := packages[packageName(ref)]; ok { + add(name, base, EdgeStandsOn) } } if e.Source.Repository != "" { diff --git a/internal/inventory/dependencies_test.go b/internal/inventory/dependencies_test.go index 39286193..689e10ff 100644 --- a/internal/inventory/dependencies_test.go +++ b/internal/inventory/dependencies_test.go @@ -1,6 +1,7 @@ package inventory import ( + "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" @@ -174,3 +175,74 @@ func TestACopyOfAnUpstreamImageIsNoEdge(t *testing.T) { } } } + +// **A built module keeps its build edges** (novox/hq issue 614). A manifest the mesh registers from a +// build carries no `build` (catalogue.Resolve drops it), so mesh-tools' `build.on` naming mesh-sdk's +// `lib` package and a provider's TypeScript bundle drew no edge once built, and a new SDK rebuilt +// neither. The package base is read from what the build was handed (`@novox/mesh-sdk@0.1.13`, the +// package's name whichever version the mesh holds now), the toolchain from the bundles it keeps. +func TestABuiltModuleKeepsItsBuildEdges(t *testing.T) { + built := func(name string, bundles ...catalogue.Bundle) Entry { + return Entry{Manifest: catalogue.Manifest{Module: name, Bundles: bundles}, + Source: Source{Repository: "http://forge/novox/" + name + ".git"}} + } + entries := []Entry{ + built("mesh-sdk"), + built("mesh-tools"), + built("mesh-tools-go"), + built("minio", catalogue.Bundle{Name: "code", Language: "typescript"}), + built("umami", catalogue.Bundle{Name: "code", Language: "TypeScript"}), + built("dnsmasq", catalogue.Bundle{Name: "tools", Language: "go"}), + built("plain"), + } + against := map[string][]string{ + "mesh-tools": {"@novox/mesh-sdk@0.1.13", + catalogue.ArtifactStoreScheme + "upstream/docker.io/library/node@sha256:" + strings.Repeat("0", 64)}, + // A package nobody in the mesh publishes is no edge. + "plain": {"left-pad@1.3.0"}, + } + held := map[string]string{ + "mesh-sdk/lib": "@novox/mesh-sdk@0.1.14", + "mesh-tools/runtime": catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:" + strings.Repeat("1", 64), + } + got := dependenciesWith(entries, against, held) + has := func(from, to string) bool { + for _, e := range got { + if e.From == from && e.To == to && e.Kind == EdgeStandsOn { + return true + } + } + return false + } + for _, want := range [][2]string{ + {"mesh-tools", "mesh-sdk"}, + {"minio", "mesh-tools"}, + {"umami", "mesh-tools"}, + {"dnsmasq", "mesh-tools-go"}, + } { + if !has(want[0], want[1]) { + t.Errorf("no %s stands-on %s: %+v", want[0], want[1], got) + } + } + for _, e := range got { + if e.From == "plain" && e.Kind != EdgeBuiltBy { + t.Errorf("an unknown package drew an edge: %+v", e) + } + } +} + +func TestAPackageNameIsReadOnlyFromAPackageReference(t *testing.T) { + for ref, want := range map[string]string{ + "@novox/mesh-sdk@0.1.14": "@novox/mesh-sdk", + "left-pad@1.3.0": "left-pad", + "@novox/mesh-sdk": "", + "novox.internal:5100/mesh-tools/build@sha256:" + strings.Repeat("0", 64): "", + catalogue.ArtifactStoreScheme + "mesh-tools/build@sha256:0": "", + "http://store/v2/x/blobs/sha256:0": "", + "": "", + } { + if got := packageName(ref); got != want { + t.Errorf("packageName(%q) = %q, want %q", ref, got, want) + } + } +} diff --git a/testdata/store-tests b/testdata/store-tests index 3e0e9706..a03fd9ee 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -45,3 +45,4 @@ ./cmd/mesh-controller TestReplay348AFaultFromBeforeTheSendFailsNoGate ./cmd/mesh-controller TestReplay239AModuleNameIsNotTakenOverByAnotherRepository ./cmd/mesh-controller TestAWalkThatMovedNothingSaysWhichModulesMatchedWhatRuns +./cmd/mesh-controller TestReplay614ANewSDKPlansTheToolchainAndThenItsBundles