A build is taken in where its outcome is heard, and the build tool answers at once (issue 176) #179
@@ -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 != "" {
|
||||
|
||||
@@ -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 <node> %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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"})},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user