diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 90cf5afe..f56411c3 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -694,8 +694,19 @@ func (a *verbArguments) commandLine() ([]string, error) { } return []string{"module", "issue", str("module"), "--node", str("node")}, nil case "build": - if err := need("repository"); err != nil { - return nil, err + // The command's three shapes (`build --on`, `build --behind`, `build `), one per + // call: each branch reads only its own argument, so another given beside it is refused as + // passed over rather than dropped. Asked and not waited for, the same as a single build. + if b := str("on"); b != "" { + return []string{"build", "--on", b, "--wait", "0"}, nil + } + if on("behind") { + return []string{"build", "--behind", "--wait", "0"}, nil + } + if a.given["repository"] == "" { + // No shape named: the command says its usage, which names all three — the answer the + // caller needs, and the same as `rotate` given neither of its shapes. + return []string{"build"}, nil } // 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 diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 35656dce..61415039 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -263,8 +263,6 @@ var accountedFlags = map[string]map[string]string{ "wait": "set by the verb to 0: the id follows the build (issue 176)", "self": "set by the verb from the repository's form: a path on the forge, or a URL", "dry-run": "withheld: a dry run answers only when the build ends, which a call cannot wait for; `command` reaches it", - "behind": "withheld: the named verb builds one named repository; `command` reaches the rest", - "on": "withheld: the named verb builds one named repository; `command` reaches the rest", }, "builds": {"n": "=limit"}, "plans": {"n": "=limit", "what-if": "=repository"}, diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 0124c7d9..85f3ab76 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -36,6 +36,52 @@ func TestTheBuildToolTellsAForgePathFromAURL(t *testing.T) { } } +// The build tool has the command's three shapes: a repository, a base whose dependents are rebuilt +// (`--on`), or everything behind its source (`--behind`) — each asked, not waited for, one per call. +// Given neither, the command line is what it was: nothing new reaches it. +func TestTheBuildToolTakesOnAndBehind(t *testing.T) { + cases := []struct { + args map[string]any + want string + }{ + {map[string]any{"on": "mesh-tools"}, "build --on mesh-tools --wait 0"}, + {map[string]any{"behind": "true"}, "build --behind --wait 0"}, + {map[string]any{"behind": true}, "build --behind --wait 0"}, + {map[string]any{"repository": "novox/x"}, "build novox/x --wait 0 --self"}, + {map[string]any{"repository": "novox/x", "behind": "false"}, "build novox/x --wait 0 --self"}, + {map[string]any{"repository": "novox/x", "path": "modules/x", "ref": "main"}, + "build novox/x --wait 0 --self --path modules/x --ref main"}, + } + for _, c := range cases { + argv, err := argvFor("build", c.args) + if err != nil { + t.Errorf("%v: %v", c.args, err) + continue + } + if got := strings.Join(argv, " "); got != c.want { + t.Errorf("%v became %q, not %q", c.args, got, c.want) + } + } + // Naming no shape runs the bare command, which answers its usage naming all three. + if argv, err := argvFor("build", map[string]any{}); err != nil || strings.Join(argv, " ") != "build" { + t.Errorf("a build naming nothing became %v, %v", argv, err) + } + // One shape per call: a second beside it is refused, not dropped, and so is half of one. + for _, refused := range []map[string]any{ + {"path": "modules/x"}, + {"on": "mesh-tools", "repository": "novox/x"}, + {"on": "mesh-tools", "behind": "true"}, + {"behind": "true", "repository": "novox/x"}, + {"behind": "true", "path": "modules/x"}, + {"on": "mesh-tools", "ref": "main"}, + {"behind": "yes"}, + } { + if argv, err := argvFor("build", refused); err == nil { + t.Errorf("%v was accepted as %v", refused, argv) + } + } +} + // `rotate` is one verb with two shapes (ADR 0114, issue 180): a pair credential by provision, or a // module's own secret by machine, module and name. func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) { diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 5e96e812..b99177e2 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -396,13 +396,22 @@ var ControllerVerbs = []Verb{ "machine": "one machine (optional)", "retired": "\"true\": only what is retired", }, nil, "retired")}, - {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.", + // Three shapes, as the command has them: one repository, every module standing on a base (on), or + // every module behind its source (behind). One per call; a second is refused as passed over. + {Name: "build", Description: "Have the build machine build a repository — or, instead, every module that " + + "stands on a base (on), or every module behind its source (behind). Answers at once with each build's id: " + + "`builds` with an id follows it line by line, and a module is registered when its outcome comes.", Input: schema(map[string]string{ - "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"})}, + "repository": "the repository's URL, or its path on the forge holding the git seat (owner/name); " + + "needed unless on or behind is given", + "path": "with repository: the module's directory inside it (optional)", + "ref": "with repository: the branch, tag or commit to build (optional)", + "on": "instead of a repository: a module's name. Every module the mesh holds that was built against " + + "its artifacts is built again from its own source, bases among them asked first — the rebuild a " + + "changed base needs, which behind does not see because their sources did not move", + "behind": "\"true\", instead of a repository: build every module the mesh holds whose source has moved " + + "past the commit it was built from, bases asked first", + }, nil, "behind")}, } // schema is a JSON schema for an object of string properties, which is every argument the verbs diff --git a/internal/inventory/seats_test.go b/internal/inventory/seats_test.go index 0c1f2a38..916be433 100644 --- a/internal/inventory/seats_test.go +++ b/internal/inventory/seats_test.go @@ -227,3 +227,48 @@ func TestASeatsVerbGainsTheArgumentsTheBinaryNames(t *testing.T) { } } } + +// **A stored row's build verb takes on and behind** (novox/hq ADR 0246: arguments added to a verb are +// additive): a row seeded before them described build with a repository it required, so the console +// would refuse both and a call naming neither would be refused for want of a repository. Re-seeding +// gives the row this binary's build. +func TestAStoredRowsBuildVerbTakesOnAndBehind(t *testing.T) { + inv := ForTest(t) + ctx := t.Context() + if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + old := `[{"name":"build","description":"Have the build machine build a repository.","input":{"type":"object", + "properties":{"repository":{"type":"string"},"path":{"type":"string"},"ref":{"type":"string"}}, + "required":["repository"]}}]` + if _, err := inv.store.Pool().Exec(ctx, `update seat set serves = $1 where name = $2`, + []byte(old), catalogue.ControllerSeatName); err != nil { + t.Fatal(err) + } + if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + seats, err := inv.Seats(ctx) + if err != nil { + t.Fatal(err) + } + var build catalogue.Verb + for _, s := range seats { + if s.Name == catalogue.ControllerSeatName { + for _, v := range s.Serves { + if v.Name == "build" { + build = v + } + } + } + } + props, _ := build.Input["properties"].(map[string]any) + for _, arg := range []string{"repository", "path", "ref", "on", "behind"} { + if _, has := props[arg]; !has { + t.Errorf("build in the row does not take %s: %v", arg, props) + } + } + if req, _ := build.Input["required"].([]any); len(req) != 0 { + t.Errorf("build in the row still requires %v, so on and behind alone are refused", req) + } +}