From 505694c424186fb1ed660e2ee19b2c1d4c76de7c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:13:17 +0200 Subject: [PATCH] Register a module at artifacts the store holds, by hand, recorded as a build and a hand act, so the controller can be delivered without the builder (issue 1888, C8a and C8b) --- cmd/mesh-controller/handacts.go | 3 + cmd/mesh-controller/key_takein.go | 85 +++++++++++++++- cmd/mesh-controller/main.go | 4 +- cmd/mesh-controller/register.go | 142 +++++++++++++++++++++++++-- cmd/mesh-controller/register_test.go | 82 ++++++++++++++++ internal/artifacts/store.go | 29 ++++++ 6 files changed, 337 insertions(+), 8 deletions(-) diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index 8fb90f96..97e34a25 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -64,6 +64,9 @@ var handActVerbs = []handActVerb{ {Verb: "plans stop", Decision: "the operator's answer to an ask is their decision, not a repair (ADR 0259)", DecidedFor: []string{conditions.CauseOperatorAnswer}}, {Verb: "plans close"}, + // A module registered at references by hand, because the builder had no key for it (ADR 0526 clause 3): a repair + // the builder should have made, so it counts. + {Verb: "register"}, // A walk started by a person instead of its delivery's owner (novox/hq ADR 0239): the owner down, or // not trusted with it — either is a repair the owner should have made. Unless the operator chose it on // a warrant (ADR 0259). diff --git a/cmd/mesh-controller/key_takein.go b/cmd/mesh-controller/key_takein.go index 67423633..2dd76ece 100644 --- a/cmd/mesh-controller/key_takein.go +++ b/cmd/mesh-controller/key_takein.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "path" + "sort" "strings" "time" @@ -171,6 +172,10 @@ type keyTake struct { Module string Trunk sdkdelivery.Module Ask registrationAsk + // Hand names, by artifact, the references the controller's terminal registers the module at instead of a key's + // (ADR 0526 clause 3, `register --reference`): the key, when the builder has one, supplies the artifacts not named + // here. Nil for every take-in but that one. + Hand map[string]string } // takeInKey takes a module in from the builder's key for the fingerprint its trunk value names: records the key as a @@ -234,7 +239,7 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) ( waits := func(format string, a ...any) (inventory.Build, catalogue.Manifest, error) { return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("%w: "+format, append([]any{errKeyWaits}, a...)...) } - if t.Trunk.FP2 == "" { + if t.Trunk.FP2 == "" && t.Hand == nil { why := t.Trunk.None2 if why == "" { why = "the indexer wrote no fingerprint of the new form" @@ -242,6 +247,12 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) ( return waits("%s at %.8s has no fingerprint to take a build by: %s", t.Module, t.Trunk.Commit, why) } k, err := keys.Build(ctx, t.Module, t.Trunk.FP2) + if t.Hand != nil { + // **Registered at a reference, no key needed** (ADR 0526 clause 3): the key the builder holds, if any, is the + // start; the references given replace or add its artifacts, and the key counts as done. + k = handKey(t, k) + err = nil + } if err != nil { return waits("%s's key cannot be read: %v", t.Module, err) } @@ -272,6 +283,11 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) ( return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("the key of %s names the manifest of %s", t.Module, manifest.Module) } + if t.Hand != nil { + if err := handCoversEvery(manifest, k, t.Hand); err != nil { + return inventory.Build{}, catalogue.Manifest{}, err + } + } built, err := builtOfKey(ctx, kc.archive, manifest, k) if err != nil { return waits("%v", err) @@ -308,6 +324,10 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) ( break } } + if t.Hand != nil { + // A build made by hand, said so: the id is its own, so it never reads as the builder's key. + kept.ID, kept.On = "hand-"+keyBuildID(k)+"-"+time.Now().UTC().Format("20060102T150405Z"), "hand" + } for _, a := range k.Build.Artifacts { kept.Made = append(kept.Made, inventory.Artifact{Name: a.Name, Kind: kindOf(manifest, a), Reference: catalogue.Recorded(a.Reference)}) @@ -458,3 +478,66 @@ func pathsBuildSource(m catalogue.Manifest, dir string) []string { } return out } + +// handKey is the key a registration at references is made from: the builder's key when it has one (its other artifacts +// stand), with each reference given in place of or beside the key's artifact of that name, and counted done. +func handKey(t keyTake, k builderkeys.Key) builderkeys.Key { + if !k.Found { + k = builderkeys.Key{Module: t.Module, Fingerprint: t.Trunk.FP2, Found: true} + } + build := k.Build + build.State, build.By, build.Error = sdkdelivery.BuildDone, "hand", "" + if build.Manifest == "" { + build.Manifest = t.Trunk.Manifest + } + build.Artifacts = nil + for _, a := range k.Build.Artifacts { + if _, replaced := t.Hand[a.Name]; !replaced { + build.Artifacts = append(build.Artifacts, a) + } + } + names := make([]string, 0, len(t.Hand)) + for name := range t.Hand { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + reference := t.Hand[name] + build.Artifacts = append(build.Artifacts, sdkdelivery.BuiltArtifact{Name: name, + Digest: handDigestOf.FindString(reference), Reference: reference}) + } + k.Build = build + return k +} + +// handCoversEvery refuses a registration at references that names an artifact the manifest does not declare, or leaves +// one it declares without a reference (the key's or a given one). +func handCoversEvery(m catalogue.Manifest, k builderkeys.Key, hand map[string]string) error { + declared := map[string]bool{} + if m.Build != nil { + for _, a := range m.Build.Artifacts { + declared[a.Name] = true + } + } + for name := range hand { + if !declared[name] { + return fmt.Errorf("--reference %s: %s declares no artifact of that name. Nothing was registered", name, m.Module) + } + } + have := map[string]bool{} + for _, a := range k.Build.Artifacts { + have[a.Name] = true + } + var missing []string + for name := range declared { + if !have[name] { + missing = append(missing, name) + } + } + sort.Strings(missing) + if len(missing) > 0 { + return fmt.Errorf("%s declares the artifact(s) %s and no --reference names them, nor does a key of the builder. "+ + "Nothing was registered", m.Module, strings.Join(missing, ", ")) + } + return nil +} diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 17189ffe..0e8a8b95 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -313,7 +313,9 @@ var usageText = `mesh-controller — the control plane operator key set [--replace] tell the mesh which operator key to seal to operator key show the operator key, and what it can recover register / register a module from a repository the catalogue does not - build, from the builder's done key of its fingerprint (ADR 0501 §4) + build, from the builder's done key of its fingerprint (ADR 0501 §4); with + --reference = (repeatable) and --why, at artifacts the store + holds, recorded as a build by hand (ADR 0526 §3) switch [--json] the switch's list: each module, the build it runs, and what the builder holds builds [] what has been built lately, and what came of it queue [--json] every ask in the build queue: waiting, in flight (where, how long), dead diff --git a/cmd/mesh-controller/register.go b/cmd/mesh-controller/register.go index 1c7a727b..0feeb002 100644 --- a/cmd/mesh-controller/register.go +++ b/cmd/mesh-controller/register.go @@ -3,11 +3,17 @@ package main import ( "context" "errors" + "flag" "fmt" + "regexp" + "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/artifacts" "github.com/novox/mesh-controller/internal/builderkeys" + "github.com/novox/mesh-controller/internal/inventory" ) // The terminal's exception becomes a registration, not a build (novox/hq ADR 0501 §4; issue 1851, A11b). @@ -17,16 +23,49 @@ import ( // indexer's trunk value of that module names — the registration a build asked at the terminal used to make for a // repository the catalogue does not build (ADR 0432 §3, ADR 0420 §7). No verb reaches it (terminalOnlyCommands), and it // asks no build: a key not done is waited for, and said. +// +// **With `--reference =`** (ADR 0526 clause 3; issue 1888), repeatable, the module is registered at +// references the artifact store holds instead of a builder's key: the hand path for a controller, builder or build module +// the builder cannot build (the R4g fallback). The references replace or add the artifacts of the builder's key when it +// has one; every artifact the manifest declares must end up named. It is recorded as a build by hand, so the store's +// sweep holds the blobs, and in the hand-act log with its `--why`. func registerCommand(ctx context.Context, args []string) error { - if len(args) != 3 { - return errors.New("register / (the path \".\" for a module at the repository's root)") + set := flag.NewFlagSet("register", flag.ContinueOnError) + references := map[string]string{} + var given []string + set.Func("reference", "=: register the module at this artifact, which the store holds, "+ + "instead of a builder's key (repeatable; needs --why)", func(v string) error { + artifact, reference, err := parseReference(v) + if err != nil { + return err + } + if _, twice := references[artifact]; twice { + return fmt.Errorf("--reference names %s twice", artifact) + } + references[artifact] = reference + given = append(given, v) + return nil + }) + hand := addHandActFlags(set) + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) != 3 { + return errors.New("register / [--reference = ... --why ] " + + "(the path \".\" for a module at the repository's root)") } if !startedAtTheTerminal() { return terminalRefusal("register is run at the controller's terminal only, never through a verb: it registers " + "a module from a repository the catalogue does not build, and whoever may call a verb includes agents " + - "(novox/hq ADR 0266, ADR 0501 §4). Nothing was done") + "(novox/hq ADR 0266, ADR 0501 §4, ADR 0526 §3). Nothing was done") } - module, repository, path := args[0], strings.Trim(args[1], "/"), strings.Trim(args[2], "/") + if len(references) > 0 { + if err := hand.require("register --reference"); err != nil { + return err + } + } + module, repository, path := positionals[0], strings.Trim(positionals[1], "/"), strings.Trim(positionals[2], "/") if path == sdkdelivery.RootPath { path = "" } @@ -51,8 +90,25 @@ func registerCommand(ctx context.Context, args []string) error { if err := sameModuleSource(module, repository, path, trunk); err != nil { return err } - manifest, kept, err := takeInKey(ctx, open.inventory, keys, keyTake{Module: module, Trunk: trunk, - Ask: registrationAsk{Terminal: true}}) + take := keyTake{Module: module, Trunk: trunk, Ask: registrationAsk{Terminal: true}} + if len(references) > 0 { + // Every reference is one the store holds, before anything is recorded: a registration at bytes that are not + // there would be sent to the machines and fail there (ADR 0526 clause 3). + if err := handStoreHoldsEvery(ctx, open.inventory, module, references); err != nil { + return err + } + take.Hand = references + } + // And written down before it is done, with its why (novox/hq to-be 45 §7): a placement by hand is not undone by the + // next push, and the log says who put it there. + if take.Hand != nil { + act := []string{module, repository, positionalPath(path)} + for _, g := range given { + act = append(act, "--reference", g) + } + hand.record(ctx, "register", act) + } + manifest, kept, err := takeInKey(ctx, open.inventory, keys, take) if errors.Is(err, errKeyWaits) { return fmt.Errorf("%s is not registered yet, and nothing was asked: %v. Run register again once its key is done", module, err) @@ -60,11 +116,85 @@ func registerCommand(ctx context.Context, args []string) error { if err != nil { return err } + if take.Hand != nil { + fmt.Printf("%s %s registered at the references given (%s at %.8s), as asked at the controller's terminal, "+ + "recorded as a build by hand; assigned nowhere\n", manifest.Module, manifest.Version, repository, kept.Commit) + return nil + } fmt.Printf("%s %s registered from the builder's key %s (%s at %.8s), as asked at the controller's terminal; "+ "assigned nowhere\n", manifest.Module, manifest.Version, short(kept.SourceFingerprint), repository, kept.Commit) return nil } +// positionalPath is the path as the command was given it: "." for a module at the repository's root. +func positionalPath(path string) string { + if path == "" { + return sdkdelivery.RootPath + } + return path +} + +// handDigestOf is the sha256 digest a recorded reference ends in. +var handDigestOf = regexp.MustCompile(`sha256:[0-9a-f]{64}$`) + +// parseReference reads one `--reference =`: a reference into the mesh's artifact store, by digest. +func parseReference(v string) (artifact, reference string, err error) { + artifact, reference, ok := strings.Cut(v, "=") + artifact, reference = strings.TrimSpace(artifact), strings.TrimSpace(reference) + if !ok || artifact == "" || reference == "" { + return "", "", fmt.Errorf("--reference %q is not =", v) + } + if _, ours := catalogue.InArtifactStore(reference); !ours { + return "", "", fmt.Errorf("--reference %s: %q is not a reference into the artifact store (%s...)", artifact, + reference, catalogue.ArtifactStoreScheme) + } + if !handDigestOf.MatchString(reference) { + return "", "", fmt.Errorf("--reference %s: %q names no digest (it ends in sha256:<64 hex digits>)", artifact, reference) + } + return artifact, reference, nil +} + +// handStoreHolds says whether the artifact store holds what a recorded reference names; a variable so a test needs no store. +var handStoreHolds = func(ctx context.Context, inv *inventory.Inventory, reference string) error { + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + address, err := artifactStoreAddress(ctx, inv, shelf, "") + if err != nil { + return err + } + if address == "" { + return errors.New("the artifact store is on no network the controller reaches") + } + return artifacts.Store{Address: address}.Holds(ctx, reference) +} + +// handStoreHoldsEvery refuses a reference that names another module's repository, or bytes the store does not hold. +func handStoreHoldsEvery(ctx context.Context, inv *inventory.Inventory, module string, references map[string]string) error { + names := make([]string, 0, len(references)) + for artifact := range references { + names = append(names, artifact) + } + sort.Strings(names) + for _, artifact := range names { + reference := references[artifact] + if path, _ := catalogue.InArtifactStore(reference); !strings.HasPrefix(path, module+"/") { + return fmt.Errorf("--reference %s: %s is not in %s's repository of the store. Nothing was registered", + artifact, reference, module) + } + if err := handStoreHolds(ctx, inv, reference); err != nil { + if errors.Is(err, artifacts.Gone) { + return fmt.Errorf("--reference %s: the artifact store does not hold %s. Nothing was registered", artifact, + reference) + } + return fmt.Errorf("--reference %s: the artifact store could not say whether it holds %s: %w. Nothing was "+ + "registered", artifact, reference, err) + } + } + return nil +} + // sameModuleSource refuses a registration whose repository or path is not the one the indexer's trunk value names: the // operator names exactly what is registered (ADR 0501 §4). func sameModuleSource(module, repository, path string, trunk sdkdelivery.Module) error { diff --git a/cmd/mesh-controller/register_test.go b/cmd/mesh-controller/register_test.go index 99b1e45f..fc319be6 100644 --- a/cmd/mesh-controller/register_test.go +++ b/cmd/mesh-controller/register_test.go @@ -7,6 +7,8 @@ import ( "testing" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-controller/internal/artifacts" + "github.com/novox/mesh-controller/internal/inventory" ) // A11b (ADR 0501 §4): the terminal's registration names exactly the module's repository and path the indexer holds, @@ -36,3 +38,83 @@ func TestTheTerminalsRegistrationNamesItsSourceExactlyAndNoVerbReachesIt(t *test t.Fatalf("register through a verb: %v", err) } } + +// C8 (ADR 0526 clause 3; issue 1888): a reference is one into the artifact store, by digest; the store must hold it. +func TestAReferenceToRegisterAtIsRefusedUnlessTheStoreHoldsIt(t *testing.T) { + good := "artifact-store://x/tools/blobs/" + shaOf("t") + if a, r, err := parseReference("tools=" + good); err != nil || a != "tools" || r != good { + t.Fatalf("a good reference: %q %q %v", a, r, err) + } + for _, bad := range []string{"tools", "=" + good, "tools=", "tools=docker.io/library/x@" + shaOf("t"), + "tools=artifact-store://x/tools/blobs/latest"} { + if _, _, err := parseReference(bad); err == nil { + t.Errorf("%q is taken", bad) + } + } + held := map[string]bool{good: true} + was := handStoreHolds + t.Cleanup(func() { handStoreHolds = was }) + handStoreHolds = func(_ context.Context, _ *inventory.Inventory, reference string) error { + if held[reference] { + return nil + } + return artifacts.Gone + } + if err := handStoreHoldsEvery(t.Context(), nil, "x", map[string]string{"tools": good}); err != nil { + t.Fatalf("a held reference is refused: %v", err) + } + gone := "artifact-store://x/tools/blobs/" + shaOf("other") + if err := handStoreHoldsEvery(t.Context(), nil, "x", map[string]string{"tools": gone}); err == nil || + !strings.Contains(err.Error(), "does not hold") { + t.Errorf("a digest the store does not hold: %v", err) + } + if err := handStoreHoldsEvery(t.Context(), nil, "x", map[string]string{"tools": "artifact-store://y/tools/blobs/" + shaOf("t")}); err == nil { + t.Error("another module's repository is taken") + } +} + +const handManifest = `{"module":"x","version":"1","tools":["x_read"], + "build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["dist/index.js"]}]}}` + +// C8: a module with no key at all is registered at the references given, recorded as a build by hand under an id of its +// own; an artifact the manifest does not declare, or a declared one nothing names, is refused. +func TestAModuleIsTakenInAtReferencesWithNoKey(t *testing.T) { + keys, trunk := aKeyOf(handManifest, 1) + keys.keys = nil + ref := "artifact-store://x/tools/blobs/" + shaOf("t") + kept, resolved, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{"tools": ref}}, keyContext{}) + if err != nil { + t.Fatal(err) + } + if kept.On != "hand" || !strings.HasPrefix(kept.ID, "hand-") || len(kept.Made) != 1 || kept.Made[0].Reference != ref || + kept.Made[0].Kind != "bundle" || len(resolved.Bundles) != 1 { + t.Errorf("the build by hand: %+v %+v", kept, resolved.Bundles) + } + if _, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{"nope": ref}}, + keyContext{}); err == nil || !strings.Contains(err.Error(), "declares no artifact") { + t.Errorf("an undeclared artifact: %v", err) + } + if _, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{}}, + keyContext{}); err == nil || !strings.Contains(err.Error(), "no --reference names them") { + t.Errorf("a declared artifact nothing names: %v", err) + } +} + +// C8: the builder's key, when there is one, supplies what the references do not name. +func TestAHandReferenceReplacesOneArtifactOfTheBuildersKey(t *testing.T) { + keys, trunk := aKeyOf(tsBundle, 2, + sdkdelivery.BuiltArtifact{Name: "tools", Kind: "bundle", Digest: shaOf("t"), Reference: "artifact-store://x/tools/blobs/" + shaOf("t")}, + sdkdelivery.BuiltArtifact{Name: "img", Kind: "image", Digest: shaOf("i"), Reference: "artifact-store://x/img@" + shaOf("i")}) + hand := "artifact-store://x/tools/blobs/" + shaOf("hand") + kept, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{"tools": hand}}, keyContext{}) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, m := range kept.Made { + got[m.Name] = m.Reference + } + if got["tools"] != hand || got["img"] != "artifact-store://x/img@"+shaOf("i") || len(got) != 2 { + t.Errorf("Made: %v", got) + } +} diff --git a/internal/artifacts/store.go b/internal/artifacts/store.go index e105f785..6f98e09c 100644 --- a/internal/artifacts/store.go +++ b/internal/artifacts/store.go @@ -291,3 +291,32 @@ func (s Store) HoldsManifest(ctx context.Context, reference string) (bool, error } return s.has(ctx, s.url(repository, kind, digest), anyManifest...) } + +// Holds is whether the store holds what a recorded reference names: the blob of an archive, the manifest of an image. +// Gone when it does not; asks and changes nothing (ADR 0526 clause 3: a registration by hand is refused at bytes that +// are not there). +func (s Store) Holds(ctx context.Context, reference string) error { + path, ours := catalogue.InArtifactStore(reference) + if !ours { + return fmt.Errorf("%w: %s", ErrNotOurs, reference) + } + repository, kind, digest, err := split(path) + if err != nil { + return err + } + if s.Address == "" { + return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference) + } + if kind == "blobs" { + _, err := s.blobSize(ctx, repository, digest) + return err + } + held, err := s.has(ctx, s.url(repository, kind, digest), anyManifest...) + if err != nil { + return err + } + if !held { + return Gone + } + return nil +}