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)

This commit is contained in:
2026-10-23 15:13:17 +02:00
parent 72186815e3
commit 505694c424
6 changed files with 337 additions and 8 deletions
+3
View File
@@ -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).
+84 -1
View File
@@ -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
}
+3 -1
View File
@@ -313,7 +313,9 @@ var usageText = `mesh-controller — the control plane
operator key set <public> [--replace] tell the mesh which operator key to seal to
operator key show the operator key, and what it can recover
register <module> <owner>/<repository> <path> 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 <artifact>=<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 [<module>] 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
+136 -6
View File
@@ -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 <artifact>=<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 <module> <owner>/<repository> <path> (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", "<artifact>=<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 <module> <owner>/<repository> <path> [--reference <artifact>=<reference> ... --why <text>] " +
"(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 <artifact>=<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 <artifact>=<reference>", 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 {
+82
View File
@@ -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)
}
}