From 853c63b18137e39344a291fd921a9fe0a205ba98 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 16:09:26 +0200 Subject: [PATCH] A merge of a base rebuilds what stands on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller knew which modules were built against which base artifacts and used it only when asked (build --on). A merge that rebuilt the runtime image left forty-two modules on the old image until somebody asked, twice, by hand (hq issue 186). The merge now takes every module standing on what moved, through every layer, into the same rebuild, in base order — the same rebuild the flag does, asked by the merge that made it necessary. --- cmd/mesh-controller/dependents_test.go | 52 ++++++++++++++++++++++++++ cmd/mesh-controller/upgrades.go | 48 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 cmd/mesh-controller/dependents_test.go diff --git a/cmd/mesh-controller/dependents_test.go b/cmd/mesh-controller/dependents_test.go new file mode 100644 index 0000000..48cf8d9 --- /dev/null +++ b/cmd/mesh-controller/dependents_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A merge that rebuilds a base rebuilds what stands on it, through every layer, and nothing else +// (novox/hq issue 186): the runtime image moving means every module built on it moves too, and a +// module built on one of those moves as well. +func TestAMergeOfABaseTakesWhatStandsOnItAlong(t *testing.T) { + entry := func(name string) inventory.Entry { + return inventory.Entry{Manifest: catalogue.Manifest{Module: name}} + } + entries := []inventory.Entry{entry("mesh-tools"), entry("shop"), entry("shop-plugin"), entry("postgres"), entry("unrelated")} + against := map[string][]string{ + "shop": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"}, + "shop-plugin": {catalogue.ArtifactStoreScheme + "shop/runtime@sha256:b"}, + "postgres": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"}, + "unrelated": {catalogue.ArtifactStoreScheme + "alpine/base@sha256:c"}, + } + got := dependentsOf([]inventory.Entry{entry("mesh-tools")}, entries, against) + var names []string + for _, e := range got { + names = append(names, e.Manifest.Module) + } + want := map[string]bool{"shop": true, "shop-plugin": true, "postgres": true} + if len(names) != len(want) { + t.Fatalf("rebuilt %v; wanted exactly the three that stand on the runtime, directly or through shop", names) + } + for _, n := range names { + if !want[n] { + t.Fatalf("%s was rebuilt and stands on nothing that moved (%v)", n, names) + } + } + // The dependents come in base order when the merge orders them: the runtime, then shop, then + // the plugin that stands on shop. + ordered := orderByBases(append([]inventory.Entry{entry("mesh-tools")}, got...), against) + pos := map[string]int{} + for i, e := range ordered { + pos[e.Manifest.Module] = i + } + if !(pos["mesh-tools"] < pos["shop"] && pos["shop"] < pos["shop-plugin"]) { + t.Fatalf("not in base order: %v", ordered) + } + // Nothing moved: nothing follows. + if more := dependentsOf(nil, entries, against); len(more) != 0 { + t.Fatalf("with nothing moved, %d module(s) were rebuilt", len(more)) + } +} diff --git a/cmd/mesh-controller/upgrades.go b/cmd/mesh-controller/upgrades.go index b058ecf..091ce02 100644 --- a/cmd/mesh-controller/upgrades.go +++ b/cmd/mesh-controller/upgrades.go @@ -299,6 +299,20 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error { if err != nil { return notNow(err) } + // And whatever stands on what moved. A base rebuilt without its dependents is a mesh half on + // the old image until somebody remembers to ask — on 2026-10-01 forty-two modules, twice, by + // hand (novox/hq issue 186). The relation is the one `build --on` reads; this is the same + // rebuild, asked by the merge that made it necessary, in base order. + standing := dependentsOf(moved, entries, against) + if len(standing) > 0 { + var on []string + for _, e := range standing { + on = append(on, e.Manifest.Module) + } + fmt.Printf(" %d module(s) stand on what moved and are rebuilt with it: %s\n", + len(standing), strings.Join(on, ", ")) + moved = append(moved, standing...) + } ordered := orderByBases(moved, against) names := make([]string, 0, len(ordered)) for _, e := range ordered { @@ -522,3 +536,37 @@ func isHistory(mergedAt string, seen time.Time) bool { } return at.Before(seen) } + +// dependentsOf is every catalogued module that stands on one of the moved modules, directly or +// through another dependent, and is not itself among them — in the catalogue's order, so the +// answer is the same each time. A module standing on nothing that moved is left alone: a merge +// rebuilds what it changed and what is built on top of that, not the catalogue. +func dependentsOf(moved, entries []inventory.Entry, against map[string][]string) []inventory.Entry { + bases := map[string]bool{} + for _, e := range moved { + bases[e.Manifest.Module] = true + } + var out []inventory.Entry + taken := map[string]bool{} + for grew := true; grew; { + grew = false + for _, e := range entries { + name := e.Manifest.Module + if bases[name] || taken[name] { + continue + } + for base := range bases { + if standsOnModule(e, base, against) { + taken[name] = true + out = append(out, e) + grew = true + break + } + } + } + for _, e := range out { + bases[e.Manifest.Module] = true + } + } + return out +} -- 2.54.0