From a8387ebad8a001b58424e00772f5cd14797166d9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 18:15:13 +0200 Subject: [PATCH] Name, before the switch, which modules it moves: the held build's digests against the builder's done key, images and packages said as what cannot be compared (issue 1851, A13) --- .../key_takein_integration_test.go | 27 +++ cmd/mesh-controller/main.go | 2 + cmd/mesh-controller/seatverbs.go | 2 + cmd/mesh-controller/switch_list.go | 204 ++++++++++++++++++ cmd/mesh-controller/switch_list_test.go | 46 ++++ 5 files changed, 281 insertions(+) create mode 100644 cmd/mesh-controller/switch_list.go create mode 100644 cmd/mesh-controller/switch_list_test.go diff --git a/cmd/mesh-controller/key_takein_integration_test.go b/cmd/mesh-controller/key_takein_integration_test.go index 1b68c58d..0c5d0e70 100644 --- a/cmd/mesh-controller/key_takein_integration_test.go +++ b/cmd/mesh-controller/key_takein_integration_test.go @@ -222,3 +222,30 @@ func TestAKeyWithTheHeldBuildsDigestsIsNoMove(t *testing.T) { t.Fatalf("A9: sudo is registered as %q, not with the held build's artifacts", shelf["sudo"].Version) } } + +// A13 against the store: the switch's list reads the build the mesh holds of each module and the builder's key of its +// trunk fingerprint, and names each module unmoved, moved or not known. +func TestTheSwitchsListReadsTheHeldBuildsAndTheKeys(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + inv := open.inventory + keys, trunk := aKeyFor("sudo", "1", fpOf('b'), "novox/mesh-catalog", "modules/sudo", "c0ffee0123456789") + k := keys.keys["sudo"] + if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-sudo-held", Module: "sudo", Repository: "novox/mesh-catalog", + Commit: "c0ffee0123456789", Made: []inventory.Artifact{{Name: "a", Kind: "archive", + Reference: k.Build.Artifacts[0].Reference}}}); err != nil { + t.Fatal(err) + } + reads := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@": trunk}, keys: map[string]builderkeys.Key{"sudo": k}} + lines, err := switchList(ctx, inv, reads) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, l := range lines { + got[l.Module] = l.Verdict + } + if got["sudo"] != switchUnmoved || got["mesh-host"] != switchUnknown { + t.Fatalf("A13: the switch's list is %+v", lines) + } +} diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index e56bcac1..9de2b094 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -73,6 +73,8 @@ func run() error { } switch args[0] { + case "switch": + return switchCommand(ctx, args[1:]) case "register": return registerCommand(ctx, args[1:]) case "build", "rebuild": diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 46485477..3b86f5e3 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -1714,6 +1714,8 @@ var commandReadForms = map[string]func(rest []string) bool{ "status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly, "hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly, "artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly, + // The switch's list reads the builder's keys and the builds held, and moves nothing (issue 1851, A13). + "switch": flagsOnly, // `plan ` previews a node's declaration; it sends nothing. "plan": func([]string) bool { return true }, // `plans` lists and `plans ` shows one; `plans stop|close|go` acts. diff --git a/cmd/mesh-controller/switch_list.go b/cmd/mesh-controller/switch_list.go new file mode 100644 index 00000000..b3ac0ac5 --- /dev/null +++ b/cmd/mesh-controller/switch_list.go @@ -0,0 +1,204 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-controller/internal/builderkeys" + "github.com/novox/mesh-controller/internal/inventory" +) + +// The switch's list (novox/hq ADR 0453 §6, ADR 0499; issue 1851, A13): which modules the switch to the builder's keys +// moves, read before R4e is proposed and while it is delivered. For each module the catalogue builds, the held build's +// artifacts are compared by digest with the builder's done key of the fingerprint the indexer's trunk value names: +// every digest the same is no move (the standing build, A9); one that differs moves. An image and a package are named +// with why they cannot be compared before the trunk builds them: an image of build-agent and one of mesh-build-image +// are not one byte-for-byte build, and a candidate's image is built again for the trunk (ADR 0498 §6); a package is +// published by its version, not its digest. It moves nothing. + +// The verdicts of the switch's list. +const ( + switchUnmoved = "unmoved" + switchMoves = "moves" + switchUncompared = "cannot be compared" + switchUnknown = "not known" +) + +// switchLine is one module's line of the switch's list. +type switchLine struct { + Module string `json:"module"` + Verdict string `json:"verdict"` + Why string `json:"why"` +} + +// switchReads is what the switch's list reads of the builder and the indexer. +type switchReads interface { + ModuleAt(ctx context.Context, module, commit string) (sdkdelivery.Module, error) + Build(ctx context.Context, module, fp string) (builderkeys.Key, error) +} + +// switchLineOf judges one module: its held build's artifacts against its key, as each artifact's kind allows. +func switchLineOf(module string, held []inventory.Artifact, trunk sdkdelivery.Module, k builderkeys.Key) switchLine { + line := switchLine{Module: module} + switch { + case trunk.FP2 == "": + line.Verdict, line.Why = switchUnknown, "the indexer wrote no fingerprint of the new form: "+orWords(trunk.None2, "none said") + return line + case !k.Done(): + line.Verdict, line.Why = switchUnknown, k.Says() + return line + case len(held) == 0: + line.Verdict, line.Why = switchMoves, "the mesh holds no build of it to compare" + return line + } + digests := map[string]string{} + for _, a := range held { + digests[a.Name] = digestOfReference(a.Reference) + } + var moved, uncompared []string + for _, a := range k.Build.Artifacts { + was, had := digests[a.Name] + delete(digests, a.Name) + switch { + case !had: + moved = append(moved, a.Name+" is new") + case was == a.Digest: + case a.Kind == catalogue.ArtifactImage: + uncompared = append(uncompared, a.Name+" is an image: build-agent's and mesh-build-image's are not one "+ + "byte-for-byte build, and a candidate's image is built again for the trunk (ADR 0498 §6)") + case a.Kind == "package": + uncompared = append(uncompared, a.Name+" is a package: it is published by its version, not its digest") + default: + moved = append(moved, fmt.Sprintf("%s %s -> %s", a.Name, shortDigest(was), shortDigest(a.Digest))) + } + } + for name := range digests { + moved = append(moved, name+" is gone") + } + sort.Strings(moved) + sort.Strings(uncompared) + switch { + case len(moved) > 0: + line.Verdict, line.Why = switchMoves, strings.Join(append(moved, uncompared...), "; ") + case len(uncompared) > 0: + line.Verdict, line.Why = switchUncompared, strings.Join(uncompared, "; ") + default: + line.Verdict, line.Why = switchUnmoved, "every artifact's digest is the held build's: registered with it, no move" + } + return line +} + +// digestOfReference is the `sha256:` a reference names; empty when it names none. +func digestOfReference(reference string) string { + for _, marker := range []string{"@sha256:", "/sha256:"} { + if _, after, ok := strings.Cut(reference, marker); ok { + return "sha256:" + after + } + } + return "" +} + +func shortDigest(d string) string { + d = strings.TrimPrefix(d, "sha256:") + if len(d) > 12 { + return d[:12] + } + if d == "" { + return "none" + } + return d +} + +// switchCommand is `switch [--json]`: the switch's list, read-only. +func switchCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("switch", flag.ContinueOnError) + asJSON := set.Bool("json", false, "answer the list as JSON") + if err := set.Parse(args); err != nil { + return err + } + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + keys, closeKeys, err := openBuilderKeys() + if err != nil { + return err + } + defer closeKeys() + lines, err := switchList(ctx, open.inventory, keys) + if err != nil { + return err + } + if *asJSON { + return json.NewEncoder(os.Stdout).Encode(lines) + } + counts := map[string]int{} + for _, l := range lines { + counts[l.Verdict]++ + fmt.Printf("%-28s %-18s %s\n", l.Module, l.Verdict, l.Why) + } + fmt.Printf("\n%d module(s): %d unmoved, %d move, %d cannot be compared, %d not known\n", len(lines), + counts[switchUnmoved], counts[switchMoves], counts[switchUncompared], counts[switchUnknown]) + return nil +} + +// switchList is the switch's line of every module the catalogue builds but the builder's own, by name. +func switchList(ctx context.Context, inv *inventory.Inventory, keys switchReads) ([]switchLine, error) { + entries, err := inv.Catalogued(ctx) + if err != nil { + return nil, err + } + var out []switchLine + for _, e := range entries { + m := e.Manifest.Module + if e.Provided || e.Source.Repository == "" || isBuildersOwn(m) { + continue + } + trunk, err := keys.ModuleAt(ctx, m, "") + if errors.Is(err, builderkeys.ErrNoValue) { + out = append(out, switchLine{Module: m, Verdict: switchUnknown, Why: "the indexer holds no trunk value of it"}) + continue + } + if err != nil { + return nil, err + } + var k builderkeys.Key + if trunk.FP2 != "" { + if k, err = keys.Build(ctx, m, trunk.FP2); err != nil { + return nil, err + } + } + held, err := heldArtifacts(ctx, inv, m, e.Source.BuiltFrom) + if err != nil { + return nil, err + } + out = append(out, switchLineOf(m, held, trunk, k)) + } + sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module }) + return out, nil +} + +// heldArtifacts are the artifacts of the build the mesh holds of a module: the newest that worked at the commit it is +// registered at. +func heldArtifacts(ctx context.Context, inv *inventory.Inventory, module, builtFrom string) ([]inventory.Artifact, error) { + builds, err := inv.Builds(ctx, module, 20) + if err != nil { + return nil, err + } + for _, b := range builds { + if b.Worked() && sameCommitID(b.Commit, builtFrom) { + return b.Made, nil + } + } + // empty-on-error: not an error; the mesh holds no build of that commit on record, which the line says + return nil, nil +} diff --git a/cmd/mesh-controller/switch_list_test.go b/cmd/mesh-controller/switch_list_test.go new file mode 100644 index 00000000..1055d032 --- /dev/null +++ b/cmd/mesh-controller/switch_list_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "strings" + "testing" + + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-controller/internal/builderkeys" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A13 (ADR 0453 §6; issue 1851): the switch's list names every module moved or unmoved and why: the held build's +// digests against the done key's; an image or a package as what cannot be compared; a key not done as not known. +func TestTheSwitchsListNamesWhatMovesAndWhy(t *testing.T) { + const d1, d2 = "sha256:" + "1111111111111111111111111111111111111111111111111111111111111111", + "sha256:" + "2222222222222222222222222222222222222222222222222222222222222222" + trunk := sdkdelivery.Module{FP2: "src2:" + strings.Repeat("a", 64)} + done := func(arts ...sdkdelivery.BuiltArtifact) builderkeys.Key { + return builderkeys.Key{Found: true, Fingerprint: trunk.FP2, Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, + Artifacts: arts}} + } + held := []inventory.Artifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://x/tools/blobs/" + d1}} + for _, c := range []struct { + what string + held []inventory.Artifact + trunk sdkdelivery.Module + key builderkeys.Key + verdict string + says string + }{ + {"the same digest", held, trunk, done(sdkdelivery.BuiltArtifact{Name: "tools", Kind: "bundle", Digest: d1}), switchUnmoved, "no move"}, + {"another digest", held, trunk, done(sdkdelivery.BuiltArtifact{Name: "tools", Kind: "bundle", Digest: d2}), switchMoves, "tools 111111111111 -> 222222222222"}, + {"an image", []inventory.Artifact{{Name: "img", Kind: "image", Reference: "artifact-store://x/img@" + d1}}, trunk, + done(sdkdelivery.BuiltArtifact{Name: "img", Kind: "image", Digest: d2}), switchUncompared, "img is an image"}, + {"a package", []inventory.Artifact{{Name: "lib", Kind: "package", Reference: "@novox/lib@1.2.0"}}, trunk, + done(sdkdelivery.BuiltArtifact{Name: "lib", Kind: "package", Digest: d2}), switchUncompared, "published by its version"}, + {"a key building", held, trunk, builderkeys.Key{Module: "x", Fingerprint: trunk.FP2, Found: true, + Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildBuilding}}, switchUnknown, "is building"}, + {"no fingerprint", held, sdkdelivery.Module{None2: "no lock"}, builderkeys.Key{}, switchUnknown, "no lock"}, + } { + l := switchLineOf("x", c.held, c.trunk, c.key) + if l.Verdict != c.verdict || !strings.Contains(l.Why, c.says) { + t.Errorf("%s: %+v, want %s saying %q", c.what, l, c.verdict, c.says) + } + } +}