From 076e0ae2592b4c3715d6636f4a24e8e353643718 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 01:27:04 +0200 Subject: [PATCH] A build is taken in where its outcome is heard, and the build tool answers at once (issue 176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console's `build` tool answered "no build machine answered within 0s", handed a forge path to git as written, and a build heard afterwards was recorded and never registered: recording and registration lived only in the waiting caller, and the tool did not wait. Now one function takes a build's outcome in — records it, parses the manifest, refuses a definition naming an installation, registers the module with its source as the seat and path the request carried — and both the waiting command and the daemon that follows the role's `built` event call it. `build --wait 0` asks and returns with the id; `builds --log ` follows it. The seat verb says `--self` for a repository given without a scheme. --- cmd/mesh-builder/main.go | 2 +- cmd/mesh-controller/build.go | 107 +++++++++++++++----------- cmd/mesh-controller/build_test.go | 65 ++++++++++++++++ cmd/mesh-controller/main.go | 17 +++- cmd/mesh-controller/seatverbs.go | 6 ++ cmd/mesh-controller/seatverbs_test.go | 19 ++++- internal/catalogue/verbs.go | 5 +- internal/link/build.go | 14 ++++ internal/link/builds.go | 6 ++ internal/link/builds_nats.go | 14 ++++ 10 files changed, 205 insertions(+), 50 deletions(-) create mode 100644 cmd/mesh-controller/build_test.go diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index 76b5855..f7d6946 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -166,7 +166,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri result := link.BuildResult{ ID: request.ID, Repository: request.Repository, Path: request.Path, - Ref: request.Ref, On: on, + Ref: request.Ref, On: on, Source: request.Source, } what := "building " + request.Repository if request.Path != "" { diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 3f5c74e..0b1d0ab 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -436,72 +436,91 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti } defer ask.Close() + if wait == 0 { + // Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the + // controller takes it in — records the build, registers the module — whether or not anybody + // is still here. A tool call cannot hold a connection for the minutes a build takes; it + // follows the build by its id instead. + if err := ask.Ask(ctx, request); err != nil { + return err + } + fmt.Printf("asked, not waited for: `builds --log %s` follows it as it runs, and `builds` "+ + "shows what came of it; the module is registered when the outcome comes\n", request.ID) + return nil + } + result, err := ask.Submit(ctx, request, wait) if err != nil { return err } - // Kept before it is judged. A failed build that leaves no trace is indistinguishable from one - // nobody asked for, and the difference is the whole of whether somebody should be looking at - // something. open, err := openStores(ctx) if err != nil { return err } defer open.Close() - inv := open.inventory - kept := buildFrom(result) - if err := inv.RecordBuild(ctx, kept); err != nil { + manifest, kept, err := takeIn(ctx, open.inventory, result) + if err != nil { return err } - - if result.Failed != "" { - // The builder's own words. Wrapping them in something about the control plane would put - // two explanations between a person and a build log. - return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed) - } - // Said as recorded: what each artifact is, not where this builder happened to push it. for _, made := range kept.Made { fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference) } - - // Parsed with the same parser a hand-written manifest goes through. A second path would be a - // second thing to disagree about what a manifest is. The manifest as recorded, so the catalogue - // holds references by digest and path and every declaration composes the store's address in. - manifest, err := catalogue.ParseManifest(kept.Manifest) - if err != nil { - return fmt.Errorf("%s built %s and what came back is not a manifest: %w", - result.On, result.Repository, err) - } - - // Recorded with where it came from, so "is this current?" is answerable without building it - // again (novox/hq ADR 0009). **For a source on a seat, as the path and the seat, never the URL - // just cloned** (ADR 0111): the URL is where the forge runs today, and recording it would put - // the forge's address back into every module built from it. The build log above keeps the URL, - // because that is what was cloned. - recorded := inventory.Source{ - Repository: result.Repository, Path: result.Path, Ref: result.Ref, - BuiltFrom: result.Commit, Head: result.Commit, - } - if source.Seat != "" { - recorded.Repository, recorded.Seat = source.Repository, source.Seat - } - // The build is kept; the module is not. A definition naming an installation is refused where - // it would enter the catalogue, and the build log says which build it was. - if err := namesNoInstallation(manifest); err != nil { - return fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w", - result.On, result.Repository, short(result.Commit), err) - } - if err := inv.RegisterModule(ctx, manifest, recorded); err != nil { - return err - } fmt.Printf("\n%s %s, built on %s from %s\n", manifest.Module, manifest.Version, result.On, short(result.Commit)) fmt.Printf(" run `assign %s` to put it somewhere\n", manifest.Module) return nil } +// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and +// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's +// result reaches the catalogue whether or not the asker was still listening. +// +// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one +// nobody asked for, and the difference is the whole of whether somebody should be looking at +// something. Then parsed with the same parser a hand-written manifest goes through — a second path +// would be a second thing to disagree about what a manifest is — and registered with where it came +// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111), +// which the request carried and the outcome echoes. A definition naming an installation is refused +// here, where it would enter the catalogue; the build stays recorded and the refusal says which. +// +// Idempotent: the same outcome taken in twice registers the same module twice, which is one row +// written with the same values. +func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) ( + catalogue.Manifest, inventory.Build, error) { + kept := buildFrom(result) + if err := inv.RecordBuild(ctx, kept); err != nil { + return catalogue.Manifest{}, kept, err + } + if result.Failed != "" { + // The builder's own words. Wrapping them in something about the control plane would put + // two explanations between a person and a build log. + return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s", + result.On, result.Repository, result.Failed) + } + manifest, err := catalogue.ParseManifest(kept.Manifest) + if err != nil { + return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w", + result.On, result.Repository, err) + } + recorded := inventory.Source{ + Repository: result.Repository, Path: result.Path, Ref: result.Ref, + BuiltFrom: result.Commit, Head: result.Commit, + } + if result.Source != nil && result.Source.Seat != "" { + recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat + } + if err := namesNoInstallation(manifest); err != nil { + return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w", + result.On, result.Repository, short(result.Commit), err) + } + if err := inv.RegisterModule(ctx, manifest, recorded); err != nil { + return manifest, kept, err + } + return manifest, kept, nil +} + // buildAndShow builds and prints the manifest without recording anything. func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error { repository, err := cloneFrom(ctx, source) diff --git a/cmd/mesh-controller/build_test.go b/cmd/mesh-controller/build_test.go new file mode 100644 index 0000000..a39ef6e --- /dev/null +++ b/cmd/mesh-controller/build_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/link" +) + +// A build's outcome is taken in the same way whoever hears it (novox/hq issue 176): recorded, and +// the module registered with its source as the seat and path when the request said so — never the +// URL. A definition naming an installation is recorded and not registered; a failure is recorded +// and said. +func TestABuildHeardIsRecordedAndRegistered(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"}) + m, _, err := takeIn(ctx, open.inventory, link.BuildResult{ + ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop", + Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest, + Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"}, + }) + if err != nil { + t.Fatal(err) + } + if m.Module != "shop" { + t.Fatalf("registered %q", m.Module) + } + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + t.Fatal(err) + } + if _, held := shelf["shop"]; !held { + t.Fatal("the module a heard build produced is not in the catalogue") + } + src, err := open.inventory.SourceOf(ctx, "shop") + if err != nil || src.Seat != "git" || src.Repository != "novox/shop" || src.BuiltFrom != "abcdef0123" { + t.Fatalf("the source is the seat and the path, never the URL: %+v %v", src, err) + } + builds, err := open.inventory.Builds(ctx, "shop", 5) + if err != nil || len(builds) != 1 || builds[0].ID != "b-1" { + t.Fatalf("the build is not recorded once: %v %v", builds, err) + } + + named, _ := json.Marshal(map[string]any{"module": "idp", "version": "1", "resources": []any{ + map[string]any{"id": "server", "type": "container", "image": "x@sha256:aa", + "env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}}}) + _, _, err = takeIn(ctx, open.inventory, link.BuildResult{ + ID: "b-2", Repository: "/r", On: "anchor", Commit: "0123456789", Manifest: named}) + if err == nil || !strings.Contains(err.Error(), "does not register it") { + t.Fatalf("a definition naming an installation was taken in: %v", err) + } + if shelf, _ := open.inventory.Catalogue(ctx); shelf["idp"].Module != "" { + t.Fatal("the refused module was registered anyway") + } + if builds, _ := open.inventory.Builds(ctx, "idp", 5); len(builds) != 1 { + t.Fatalf("the refused build was not recorded: %v", builds) + } + + _, _, err = takeIn(ctx, open.inventory, link.BuildResult{ID: "b-3", Repository: "/r", On: "anchor", Failed: "no compiler"}) + if err == nil || !strings.Contains(err.Error(), "no compiler") { + t.Fatalf("a failure is said in the builder's words: %v", err) + } +} diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index da8a23b..66f3a96 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -241,6 +241,21 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) { } } +// Built is the daemon hearing a build's outcome on the bus — its own asking, an announcement's, or +// a tool's that did not wait (novox/hq issue 176) — and taking it in: recorded, and the module +// registered, the same as the waiting command does. Said either way, so the daemon's log tells what +// became of a build nobody was watching. func (b builds) Built(ctx context.Context, result link.BuildResult) error { - return b.inv.RecordBuild(ctx, buildFrom(result)) + manifest, _, err := takeIn(ctx, b.inv, result) + switch { + case err != nil && result.Failed != "": + fmt.Printf("%s: %v\n", result.ID, err) + return nil + case err != nil: + fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err) + return nil + } + fmt.Printf("%s: %s %s registered, built on %s from %s\n", + result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit)) + return nil } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 69b06d2..55b31bd 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -91,7 +91,13 @@ func argvFor(verb string, args map[string]any) ([]string, error) { if err := need("repository"); err != nil { return nil, err } + // Not waited for: a tool call cannot hold a connection for the minutes a build takes; the + // daemon takes the outcome in when it comes and the id follows the build (issue 176). A + // repository given without a scheme is a path on the forge holding the git seat. argv := []string{"build", str("repository"), "--wait", "0"} + if !strings.Contains(str("repository"), "://") && !strings.HasPrefix(str("repository"), "git@") { + argv = append(argv, "--self") + } if p := str("path"); p != "" { argv = append(argv, "--path", p) } diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 8343040..d9b071f 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -42,6 +42,20 @@ func TestBuildsWithAnIdReadsThatBuildsLog(t *testing.T) { } } +// The build tool takes a repository as a URL or as its path on the forge holding the git seat, and +// says which it was given, so the command reads the path as a seat source rather than handing it to +// git as written (novox/hq issue 176). And it never waits: the id follows the build. +func TestTheBuildToolTellsAForgePathFromAURL(t *testing.T) { + argv, _ := argvFor("build", map[string]any{"repository": "novox/mesh-catalog", "path": "modules/x"}) + if line := strings.Join(argv, " "); !strings.Contains(line, "--self") || !strings.Contains(line, "--wait 0") { + t.Fatalf("a forge path is a seat source, not waited for; got %q", line) + } + argv, _ = argvFor("build", map[string]any{"repository": "https://example.tld/o/r.git"}) + if line := strings.Join(argv, " "); strings.Contains(line, "--self") { + t.Fatalf("a URL is cloned as given; got %q", line) + } +} + // A required argument missing is refused in the verb's own words, before anything runs. func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) { if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) { @@ -52,14 +66,15 @@ func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) { } } -// A push and a build are sent, not waited for: the asker reads status for what happened. +// A push and a build are sent, not waited for: the asker reads status, or the build's log by its +// id, for what happened. A repository given as a forge path is said to be one (issue 176). func TestActsDoNotBlockTheCall(t *testing.T) { argv, _ := argvFor("push", map[string]any{"node": "one"}) if strings.Join(argv, " ") != "push one --wait 0" { t.Fatalf("push waits: %v", argv) } argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"}) - if strings.Join(argv, " ") != "build novox/x --wait 0 --path modules/x" { + if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" { t.Fatalf("build: %v", argv) } } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 725d9c5..f3fe796 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -99,9 +99,10 @@ var ControllerVerbs = []Verb{ Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})}, {Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.", Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)}, - {Name: "build", Description: "Have the build machine build a repository and record what came out.", + {Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " + + "`builds` with that id follows it line by line, and the module is registered when the outcome comes.", Input: schema(map[string]string{ - "repository": "the repository's URL, or its path on the forge holding the git seat", + "repository": "the repository's URL, or its path on the forge holding the git seat (owner/name)", "path": "the module's directory inside it (optional)", "ref": "the branch, tag or commit to build (optional)", }, []string{"repository"})}, diff --git a/internal/link/build.go b/internal/link/build.go index fb02299..14c38ee 100644 --- a/internal/link/build.go +++ b/internal/link/build.go @@ -49,6 +49,17 @@ type BuildRequest struct { // which forge holds the seat here. A builder handed no base for a seat a context names refuses // the build and says so. Seats map[string]string `json:"seats,omitempty"` + // Source is the repository as the mesh records it when it lives on a seat's holder — the seat + // and the path on it, never the URL just composed (novox/hq ADR 0111). Carried with the + // asking and echoed in the outcome, so whoever hears the outcome can register the module with + // its true source, whether or not they were the one who asked (novox/hq issue 176). + Source *SourceOnSeat `json:"source,omitempty"` +} + +// SourceOnSeat names a repository by the seat whose holder serves it and its path there. +type SourceOnSeat struct { + Seat string `json:"seat"` + Repository string `json:"repository"` } // BuildResult is what a builder says back. @@ -101,6 +112,9 @@ type BuildResult struct { // Failed is why, when it did. Failed string `json:"failed,omitempty"` + + // Source is the request's, echoed: the seat form of the repository, for whoever registers. + Source *SourceOnSeat `json:"source,omitempty"` } // ReadRepository is a repository a build read source from besides the module's own, at the branch, diff --git a/internal/link/builds.go b/internal/link/builds.go index 1396e3a..28465fe 100644 --- a/internal/link/builds.go +++ b/internal/link/builds.go @@ -78,6 +78,12 @@ type Builders interface { // need different remedies, which is why the message distinguishes them. Submit(ctx context.Context, request BuildRequest, wait time.Duration) (BuildResult, error) + // Ask submits one build and does not wait: the outcome is the role's event, heard and taken in + // by the controller whether or not anybody waited (novox/hq issue 176). For a caller that + // cannot hold a connection for the minutes a build takes — a tool call — and follows the build + // by its id instead. + Ask(ctx context.Context, request BuildRequest) error + // Close lets go of whatever was dialled. Close() } diff --git a/internal/link/builds_nats.go b/internal/link/builds_nats.go index 4e2b63d..786f783 100644 --- a/internal/link/builds_nats.go +++ b/internal/link/builds_nats.go @@ -43,6 +43,20 @@ func (b *natsBuilds) Close() { } } +// Ask publishes the work and returns; see Builders. +func (b *natsBuilds) Ask(ctx context.Context, request BuildRequest) error { + body, err := json.Marshal(request) + if err != nil { + return err + } + publish, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if _, err := b.js.Context().Publish(BuildWork(), body, nats.Context(publish)); err != nil { + return fmt.Errorf("cannot submit a build: %w", err) + } + return nil +} + func (b *natsBuilds) Submit(ctx context.Context, request BuildRequest, wait time.Duration) (BuildResult, error) {