From e658a14296f9c86938f38a0a1f6727bfa7ac48fe Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 11:08:17 +0200 Subject: [PATCH] Read a built module's build edges from what is kept of it, so a new SDK rebuilds its toolchain and bundles (hq issue 614) A registered manifest carries no build, so mesh-tools' build.on naming the SDK's package and every bundle's toolchain were edges only before a module was first built: mesh-sdk 0.1.14 planned "sent nowhere". The relation now reads a package base from the recorded name@version against the package the mesh holds, and the toolchain from the bundles the resolved manifest keeps. Only records the mesh already keeps are read, so it holds for every module built before it and a rollback reads the same records. Replay R614. --- cmd/mesh-controller/replay614_test.go | 71 ++++++++++++++++++++++++ internal/inventory/dependencies.go | 65 +++++++++++++++++++++- internal/inventory/dependencies_test.go | 72 +++++++++++++++++++++++++ testdata/store-tests | 1 + 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 cmd/mesh-controller/replay614_test.go 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 -- 2.54.0