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)

This commit is contained in:
2026-10-22 19:05:04 +02:00
parent 1963d84a7a
commit a8387ebad8
5 changed files with 281 additions and 0 deletions
@@ -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)
}
}
+2
View File
@@ -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":
+2
View File
@@ -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 <node>` previews a node's declaration; it sends nothing.
"plan": func([]string) bool { return true },
// `plans` lists and `plans <id>` shows one; `plans stop|close|go` acts.
+204
View File
@@ -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:<hex>` 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
}
+46
View File
@@ -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)
}
}
}