A push naming one machine reached the verb without it and pushed every machine behind (hq issue 244). The controller now refuses any argument a verb does not declare, any it composed its command line without, and a switch that is not true or false; a push that names no machine says first that it is the whole mesh. Tests walk every served verb: no argument is ever ignored, and every flag of a verb's command, read from the source, is in its schema or accounted for. plan gains files, push behind, builds and plans limit.
268 lines
15 KiB
Go
268 lines
15 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
|
// its answer (novox/hq ADR 0132, design 33 §2).
|
|
//
|
|
// **A name alone is not callable by something that has never seen the mesh before**, which is the
|
|
// whole population a tool surface exists for. So a seat's protocol carries the definition, in the
|
|
// form an agent protocol already uses — a JSON schema for the input — so nothing translates between
|
|
// a seat's idea of an argument and the caller's.
|
|
//
|
|
// A manifest may still write a bare verb name (`"serves": ["price"]`); that is a Verb with only a
|
|
// name, and the module's runtime answers `tools` with the rest. The two forms read into one type so
|
|
// nothing downstream cares which was written.
|
|
type Verb struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Input map[string]any `json:"input,omitempty"`
|
|
Output map[string]any `json:"output,omitempty"`
|
|
}
|
|
|
|
func (v *Verb) UnmarshalJSON(raw []byte) error {
|
|
trimmed := bytes.TrimSpace(raw)
|
|
if len(trimmed) > 0 && trimmed[0] == '"' {
|
|
var name string
|
|
if err := json.Unmarshal(trimmed, &name); err != nil {
|
|
return err
|
|
}
|
|
*v = Verb{Name: name}
|
|
return nil
|
|
}
|
|
// Strictly, like the manifest around it: a misspelt key in a tool's definition would otherwise
|
|
// describe a tool nobody can call and refuse nothing.
|
|
type plain Verb
|
|
var p plain
|
|
decoder := json.NewDecoder(bytes.NewReader(trimmed))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&p); err != nil {
|
|
return fmt.Errorf("a served verb is a name or {name, description, input, output}: %w", err)
|
|
}
|
|
if p.Name == "" {
|
|
return fmt.Errorf("a served verb has no name: %s", trimmed)
|
|
}
|
|
*v = Verb(p)
|
|
return nil
|
|
}
|
|
|
|
// VerbNames are the names alone, for the grants and the checks that care about nothing else.
|
|
func VerbNames(verbs []Verb) []string {
|
|
out := make([]string, 0, len(verbs))
|
|
for _, v := range verbs {
|
|
out = append(out, v.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
|
|
const ControllerSeatName = "mesh-controller"
|
|
|
|
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
|
|
//
|
|
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
|
|
// is a command the controller's binary already answers, run by the holder of the seat with the
|
|
// arguments below and answered with what the command printed. A verb here is a contract every future
|
|
// holder must implement, which is why the list is short and made of what an operator asks weekly.
|
|
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
|
|
var ControllerVerbs = []Verb{
|
|
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
|
|
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
|
|
Input: schema(nil, nil)},
|
|
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
|
|
"machines are behind what the mesh would send them.",
|
|
Input: schema(nil, nil)},
|
|
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
|
|
Input: schema(nil, nil)},
|
|
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
|
|
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
|
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
|
|
"and which machines run it.",
|
|
Input: schema(nil, nil)},
|
|
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
|
|
Input: schema(nil, nil)},
|
|
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one; " +
|
|
"or, given a build's id, everything the build machine said while building it, line by line, from the bus.",
|
|
Input: schema(map[string]string{
|
|
"module": "one module's name; every module when absent",
|
|
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
|
|
"limit": "how many builds to list (default 20); not with log",
|
|
}, nil)},
|
|
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
|
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.",
|
|
Input: schema(map[string]string{
|
|
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
|
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
|
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
|
|
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
|
|
"repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules",
|
|
"paths": "with repository: the files the merge would change, comma-separated, from the repository's root",
|
|
"modules": "with repository: or the modules it would change, comma-separated",
|
|
"limit": "how many plans to list (default 10); only when listing",
|
|
}, nil)},
|
|
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " +
|
|
"or, with files, the files it would be given.",
|
|
Input: schema(map[string]string{
|
|
"node": "the machine's name",
|
|
"files": "\"true\": the files this machine would be given, instead of the declaration as JSON",
|
|
}, []string{"node"}, "files")},
|
|
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
|
|
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207).",
|
|
Input: schema(map[string]string{"node": "the machine's name",
|
|
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
|
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
|
|
Input: schema(map[string]string{"node": "the machine's name",
|
|
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
|
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
|
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
|
Input: schema(map[string]string{
|
|
"node": "the machine's name",
|
|
"provision": "the provision, as the consumer requires it",
|
|
"from": "the node the chosen provider runs on",
|
|
"module": "the module providing it there",
|
|
}, []string{"node", "provision", "from", "module"})},
|
|
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
|
|
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
|
|
{Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " +
|
|
"WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright.",
|
|
Input: schema(map[string]string{
|
|
"node": "the machine's name; without it, every machine that is behind",
|
|
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
|
|
}, nil, "behind")},
|
|
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " +
|
|
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
|
|
"name: made anew and the machine sent, so the module starts again on it — only for a secret its " +
|
|
"definition says it reads at start; a value given to the mesh, or one the module applies to a backend, is refused with the reason.",
|
|
Input: schema(map[string]string{
|
|
"provision": "a pair credential: the provision whose credential to replace",
|
|
"consumer": "with provision: only the holder on this machine (optional)",
|
|
"node": "an own secret: the machine",
|
|
"module": "an own secret: the module",
|
|
"secret": "an own secret: its name in the module's definition",
|
|
}, nil)},
|
|
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
|
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
|
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
|
Input: schema(map[string]string{
|
|
"node": "the machine that runs the module",
|
|
"module": "the module's name",
|
|
}, []string{"node", "module"})},
|
|
{Name: "settings", Description: "Set what an assignment is configured with: a module's settings for the whole mesh, " +
|
|
"or for one machine. Replaces that layer whole — what it does not name, it no longer sets — and takes effect " +
|
|
"at the next push. With clear, removes the layer and the module is back to what its definition says.",
|
|
Input: schema(map[string]string{
|
|
"module": "the module's name",
|
|
"values": "the settings as a JSON object, for set",
|
|
"node": "one machine; the whole mesh when absent",
|
|
"clear": "\"true\" to remove the layer instead of setting it; not with values",
|
|
}, []string{"module"}, "clear")},
|
|
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
|
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
|
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
|
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
|
Input: schema(map[string]string{
|
|
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
|
}, []string{"command"})},
|
|
// The build queue, controlled by hand (novox/hq ADR 0219). Every verb that drops an ask leaves a
|
|
// failed outcome for it, so a plan waiting on it fails visibly instead of hanging.
|
|
{Name: "queue", Description: "Every ask in the build queue: waiting, in flight (on which machine, for how long), " +
|
|
"and dead (handed out as often as allowed and never settled) — each with its id, repository, path, ref and when it was asked.",
|
|
Input: schema(nil, nil)},
|
|
{Name: "cancel", Description: "Drop one waiting or dead ask from the build queue; its outcome is recorded failed, " +
|
|
"cancelled by hand, and a plan that asked for it fails. One in flight is refused: `kill` ends it where it runs.",
|
|
Input: schema(map[string]string{"id": "the ask's build id, as `queue` lists it"}, []string{"id"})},
|
|
{Name: "clear", Description: "Cancel every waiting ask in the build queue — and with dead, every dead one too — each " +
|
|
"recorded failed, cancelled by hand. Never touches one in flight.",
|
|
Input: schema(map[string]string{"dead": "\"true\" to cancel the dead asks as well"}, nil, "dead")},
|
|
{Name: "rebuild", Description: "Ask a module's current source again under a new id — the branch it follows — or, given a " +
|
|
"build's id, that build's repository, path and ref. A module a plan holds unbuilt or failed joins that plan. Answers the new id.",
|
|
Input: schema(map[string]string{"what": "a module's name, or a build's id"}, []string{"what"})},
|
|
{Name: "replay", Description: "Ask a recorded build's repository and path again at the commit it built, under a new id. " +
|
|
"A dry run unless register: nothing recorded or registered. Registering is refused when a newer build of the module " +
|
|
"is registered — it would roll the older commit out (novox/hq issue 207) — unless older says so.",
|
|
Input: schema(map[string]string{
|
|
"id": "the build's id",
|
|
"register": "\"true\" to register what it builds",
|
|
"older": "\"true\", with register: even though a newer build of the module is registered",
|
|
}, []string{"id"}, "register", "older")},
|
|
{Name: "kill", Description: "End a build where it runs: the machine that took it stops its commands and containers and " +
|
|
"announces it failed, killed by hand — settled, never handed to another machine.",
|
|
Input: schema(map[string]string{"id": "the build's id"}, []string{"id"})},
|
|
{Name: "pause", Description: "The build seat's holder on one machine — or every holder — takes no new build until resumed; " +
|
|
"a build running finishes. Kept across a restart of the holder. A plan waiting on a paused seat says so and is not late.",
|
|
Input: schema(map[string]string{"node": "one machine; every holder when absent"}, nil)},
|
|
{Name: "resume", Description: "The build seat's holder on one machine — or every holder — takes builds again.",
|
|
Input: schema(map[string]string{"node": "one machine; every holder when absent"}, nil)},
|
|
{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 (owner/name)",
|
|
"path": "the module's directory inside it (optional)",
|
|
"ref": "the branch, tag or commit to build (optional)",
|
|
}, []string{"repository"})},
|
|
}
|
|
|
|
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
|
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The
|
|
// switches are the properties that are "true" or "false" and nothing else, said in the schema as an
|
|
// enum so a caller sees it and the verb can refuse any other word rather than read it as false.
|
|
//
|
|
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
|
// name is refused when the verb is called, never passed over.
|
|
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
|
isSwitch := map[string]bool{}
|
|
for _, s := range switches {
|
|
if _, declared := properties[s]; !declared {
|
|
panic("a switch that is not a property: " + s)
|
|
}
|
|
isSwitch[s] = true
|
|
}
|
|
props := map[string]any{}
|
|
for name, description := range properties {
|
|
p := map[string]any{"type": "string", "description": description}
|
|
if isSwitch[name] {
|
|
p["enum"] = []string{"true", "false"}
|
|
}
|
|
props[name] = p
|
|
}
|
|
out := map[string]any{"type": "object", "properties": props}
|
|
if len(required) > 0 {
|
|
out["required"] = required
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unpromised is what a claim says it serves and the seat's protocol never promised.
|
|
func unpromised(serves []string, promised []Verb) []string {
|
|
has := map[string]bool{}
|
|
for _, v := range promised {
|
|
has[v.Name] = true
|
|
}
|
|
var extra []string
|
|
for _, s := range serves {
|
|
if !has[s] {
|
|
extra = append(extra, s)
|
|
}
|
|
}
|
|
return extra
|
|
}
|
|
|
|
// unservedVerbs is what a seat promises and a claimant's offer for it does not answer.
|
|
func unservedVerbs(tools []string, promised []Verb) []string {
|
|
has := map[string]bool{}
|
|
for _, t := range tools {
|
|
has[t] = true
|
|
}
|
|
var missing []string
|
|
for _, v := range promised {
|
|
if !has[v.Name] {
|
|
missing = append(missing, v.Name)
|
|
}
|
|
}
|
|
return missing
|
|
}
|