nox works without the controller: each command calls the seat or tool that owns its job (issue 1126) #593
@@ -42,3 +42,5 @@ modules/mesh-config/cmd/mesh-config/mesh-config
|
||||
modules/forticlient/cmd/forticlient-tools/forticlient-tools
|
||||
modules/networkmanager/uplink-tools
|
||||
checks/words/words
|
||||
modules/mesh-cli/mesh-cli
|
||||
modules/mesh-cli/cmd/mesh-cli/mesh-cli
|
||||
|
||||
+46
-38
@@ -1,12 +1,16 @@
|
||||
# mesh-cli
|
||||
|
||||
The operator's command on every node (novox/hq ADR 0272): `mesh-cli <command line>` asks the controller to run one
|
||||
of its command lines, from wherever the operator is. In interactive zsh it is also `nox`.
|
||||
The operator's command on every node: `mesh-cli <command line>`, in interactive zsh also `nox`. Each line calls the
|
||||
seat verb or the module tool that owns its job, through this machine's console, and prints the owner's answer.
|
||||
|
||||
```
|
||||
nox status
|
||||
nox settings show zsh --node <node>
|
||||
nox settings set zsh '{"execute":"withhold"}' --node <node>
|
||||
nox conditions --severity urgent
|
||||
nox deliveries
|
||||
nox assign <node> <module> --why <text>
|
||||
nox issue <number>
|
||||
nox call <node>/node-service-manager.status '{"unit":"plex"}'
|
||||
nox help
|
||||
```
|
||||
|
||||
## What it places
|
||||
@@ -14,57 +18,61 @@ nox settings set zsh '{"execute":"withhold"}' --node <node>
|
||||
| What | Where | How |
|
||||
|---|---|---|
|
||||
| the program | `/usr/local/bin/mesh-cli` | the `program` bundle, unpacked by an `archive` resource: the node-engine moves the one file in, refuses to write over a file it did not place, and takes out only that file when the module goes |
|
||||
| the console's address | `/etc/mesh-cli/console` | a file resource from `files/console`: `http://127.0.0.1:${bound:mcp-endpoint:port}/mcp`, from the `mcp-endpoint` the module requires |
|
||||
| the alias `nox` | the zsh module's mesh block in `~/.zshrc` | a `shell` contribution in the `normal` slot (ADR 0204) |
|
||||
|
||||
No link and no second copy. `/usr/local/bin` is on every account's `PATH` already, so the module contributes no
|
||||
environment. The alias is interactive zsh only: a script, `sudo`, a cron line and any document meant to work
|
||||
everywhere say `mesh-cli`. In interactive zsh the alias hides any other `nox` (Arch's `python-nox` installs
|
||||
`/usr/bin/nox`); `command nox` still reaches it.
|
||||
No link and no second copy. `/usr/local/bin` is on every account's `PATH` already. The alias is interactive zsh only:
|
||||
a script, `sudo`, a cron line and any document meant to work everywhere say `mesh-cli`. In interactive zsh the alias
|
||||
hides any other `nox` (Arch's `python-nox` installs `/usr/bin/nox`); `command nox` still reaches it.
|
||||
|
||||
## How it is answered
|
||||
|
||||
`mesh-cli` holds no credential and reads no file of the mesh. It writes one request to the node-engine's socket
|
||||
(the SDK's `go/cli`: the line, as words) and prints the one answer.
|
||||
`mesh-cli` holds no credential and has no bus account of its own (so its manifest declares no `invokes`: an account
|
||||
nothing dials would only be minted and kept). It calls `mesh_call` on this machine's console, the mesh MCP server the
|
||||
module node-tools serves on loopback (novox/hq ADR 0152: whoever is on the machine owns the mesh there), and the console
|
||||
puts the call on the bus, as every agent session on the machine does. The console's address is read from
|
||||
`/etc/mesh-cli/console`, or `MESH_CONSOLE` when set, or node-tools' declared port when the file is not there yet.
|
||||
|
||||
1. The node-engine reads which account is asking from the kernel, never from the program, and asks the controller
|
||||
on its own node's subject.
|
||||
2. The controller judges (ADR 0272 §4):
|
||||
- **the controller's terminal**: the node's operator account, on the control-node. The line runs as the
|
||||
operator typed it there.
|
||||
- **an ordinary call**: the node's operator account on any other node, where agents may run as that account.
|
||||
It meets every refusal of the generic `command` verb, and a change made at the terminal alone is refused with
|
||||
the reason.
|
||||
- **refused**: any other account, root included. Run it as yourself, not under `sudo`.
|
||||
3. A line still running after ten seconds is followed by its call until it ends. `mesh-cli` prints standard
|
||||
output and standard error as the command wrote them, says why a line was not the terminal or was refused, says
|
||||
when the output was cut to fit one bus message, and exits with the command's status. When no answer comes in
|
||||
time it says the line may have run and names its call; "nothing ran" is said only of a line no controller
|
||||
took.
|
||||
- **Who judges.** The seat that owns the job: a change of placement is a proposal the operator approves
|
||||
(`mesh-placement.propose`), a config value is set with its why (`mesh-config.config`), a silence or a hand act is
|
||||
recorded as one. Until novox/hq issue 1126 the controller ran these lines and judged the account (ADR 0272); with
|
||||
the controller unassigned, that judgement went with it.
|
||||
- **The map.** Every line the controller ran, and the seat verb or tool that does its job now, or none, is
|
||||
`commands.go`'s table (and hq's `.work/1126/nox-map.md` while issue 1126 runs). A line no seat or module owns says
|
||||
that it was the controller's, that the controller is unassigned, and what to use instead, and exits 1 having called
|
||||
nothing. A line nox cannot read says how it is written and exits 2.
|
||||
- **Any address.** `nox call <address> [json]` calls any seat verb or tool; `nox search <words>` and
|
||||
`nox describe <address>` find one and its arguments.
|
||||
- **What is printed.** The owner's answer as the console gave it (JSON), on standard output with exit 0; an answer the
|
||||
console marks an error (a refusal, nothing serving, no answer within its bound) on standard error with exit 1. A
|
||||
console that does not answer is said as nothing having been called; no answer in time is said as a call that may
|
||||
have run.
|
||||
- **`nox status`** is the delivery view's (`mesh-delivery-view.view_status`). When the view does not answer, nox says
|
||||
so first, shows the open conditions and the deliveries under their own addresses, and exits 1: they are never
|
||||
passed off as the status.
|
||||
- **`nox enrol open --key <file>`** runs here and asks nothing of the mesh (ADR 0587 §8).
|
||||
|
||||
A process that runs for a verb (it carries `MESH_VERB`) is refused before anything is asked: a verb is never the
|
||||
A process that runs for a verb (it carries `MESH_VERB`) is refused before anything is called: a verb is never the
|
||||
operator.
|
||||
|
||||
## What it does not do yet
|
||||
|
||||
- **No standard input.** A command that reads one gets nothing, and fails saying so.
|
||||
- **No local files.** A path in a line names a file where the controller runs, read by the controller's account.
|
||||
Give settings inline: `nox settings set <module> "$(cat settings.json)" --node <node>`.
|
||||
- **No streaming.** The answer arrives when the command ends, within the controller's bound on a call (five
|
||||
minutes). Interrupting `mesh-cli` stops the wait, not the command.
|
||||
- **The `MESH_VERB` refusal is a courtesy.** A verb's process is refused before asking, but the gate is the
|
||||
controller: it judges the account the node-engine vouches for, whatever the program says.
|
||||
- **No other node is the terminal.** Phase 2 of ADR 0272 adds a node whose agents run under an account of their
|
||||
own (ADR 0266), judged unable to become root.
|
||||
- **No formatting beyond `nox nodes`.** Answers are the owners' JSON.
|
||||
- **Lines with no owner.** Accepting a secret from a person, making or setting the operator key, opening a sealed
|
||||
secret locally, registering a module from another repository and forgetting a module have no seat verb yet; nox
|
||||
names what to use meanwhile.
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...`: the module places exactly one file (one archive resource, one Go bundle compiled to the binary
|
||||
alone); the line is asked as given and the answer printed with its exit; a reason, a refusal, a cut and an outcome not known
|
||||
are said and a refusal never exits 0; a verb's process is refused before asking; an engine that does not answer
|
||||
is said.
|
||||
`go test ./...`: every owned line calls its owner's address with the arguments it reads, against a stand-in console
|
||||
answering MCP `tools/call` as node-tools does; a line with no owner or not read calls nothing and says why; an error
|
||||
answer, a silent console and a verb's process are said; the status's fallback is labelled and exits 1; the console's
|
||||
address is found; `enrol open` opens a sealed token locally; the module places the program and the console's address
|
||||
and nothing else.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `/usr/local/bin`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/mesh-cli`: the program's; it holds the console's address alone.
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
package main
|
||||
|
||||
// Every command line nox takes, and the seat verb or module tool that owns its job now that the controller is
|
||||
// unassigned (novox/hq issue 1126; the map is hq's .work/1126/nox-map.md). A line no seat or module owns says so and
|
||||
// names what to use instead: it never fails silently, and it never pretends to have run.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// line is a command line's words after the command's own: positionals and --flags, in any order.
|
||||
type line struct {
|
||||
pos []string
|
||||
flags map[string]string
|
||||
}
|
||||
|
||||
// boolFlags are the flags that take no value.
|
||||
var boolFlags = map[string]bool{"json": true, "confirm": true, "collected": true, "brief": true, "all": true,
|
||||
"reasoning": true, "clear": true}
|
||||
|
||||
func parseLine(args []string) (line, error) {
|
||||
l := line{flags: map[string]string{}}
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if !strings.HasPrefix(a, "--") || a == "--" {
|
||||
l.pos = append(l.pos, a)
|
||||
continue
|
||||
}
|
||||
name, value, has := strings.Cut(strings.TrimPrefix(a, "--"), "=")
|
||||
if boolFlags[name] {
|
||||
if has {
|
||||
return l, usageErr("--%s takes no value", name)
|
||||
}
|
||||
l.flags[name] = "true"
|
||||
continue
|
||||
}
|
||||
if !has {
|
||||
if i+1 >= len(args) {
|
||||
return l, usageErr("--%s needs a value", name)
|
||||
}
|
||||
i++
|
||||
value = args[i]
|
||||
}
|
||||
l.flags[name] = value
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l line) has(flag string) bool { return l.flags[flag] != "" }
|
||||
|
||||
// usageError is a line nox cannot read: nothing was called.
|
||||
type usageError struct{ msg string }
|
||||
|
||||
func (u usageError) Error() string { return u.msg }
|
||||
|
||||
func usageErr(format string, a ...any) error { return usageError{fmt.Sprintf(format, a...)} }
|
||||
|
||||
// call is what one line asks: an address and its arguments.
|
||||
type call struct {
|
||||
Address string
|
||||
Args map[string]any
|
||||
}
|
||||
|
||||
// command is one of nox's lines.
|
||||
type command struct {
|
||||
words []string
|
||||
usage string
|
||||
does string
|
||||
// build reads the line and answers the call; nil for a line no seat owns.
|
||||
build func(l line) (call, error)
|
||||
// allowed are the flags the line reads; any other is refused before anything is called.
|
||||
allowed []string
|
||||
// instead says, for a line with no owner, what to use; empty for one that is owned.
|
||||
instead string
|
||||
// render shapes a successful answer for reading; nil prints it as the console gave it.
|
||||
render func(text string) string
|
||||
}
|
||||
|
||||
// gone is the reason every unowned line gives.
|
||||
const gone = "this was the controller's, and the controller is unassigned (novox/hq issue 1126). Nothing was called"
|
||||
|
||||
// commands is every line, longest words first within a word.
|
||||
var commands = []command{
|
||||
// What runs where.
|
||||
{words: []string{"status"}, does: "what is wrong, what is quiet, what is moving (the delivery view)",
|
||||
build: fixed("mesh-delivery-view.view_status")},
|
||||
{words: []string{"nodes"}, does: "the nodes placement holds", build: fixed("mesh-placement.placement"),
|
||||
render: renderNodes},
|
||||
{words: []string{"node", "list"}, does: "the nodes placement holds", build: fixed("mesh-placement.placement"),
|
||||
render: renderNodes},
|
||||
{words: []string{"node", "show"}, usage: "<node>", does: "one node's placement: what it runs and its decided fields",
|
||||
build: nodeArg("mesh-placement.placement")},
|
||||
{words: []string{"node", "public-domain"}, usage: "<node> [<domain> | --clear] [--why <text>]",
|
||||
does: "the domain a node composes its routed names under; with a domain or --clear, proposed to placement",
|
||||
allowed: []string{"clear", "why"}, build: publicDomain},
|
||||
{words: []string{"node", "add"}, instead: "a new machine joins through the seat mesh-enrolment: nox enrol issue <node> --why <text>"},
|
||||
{words: []string{"node", "networks"}, instead: "no seat keeps a node's routed networks yet; mesh-placement.placement shows what a node decides"},
|
||||
{words: []string{"plan"}, usage: "<node>", does: "what the assigner wants the node to run", build: optNode("mesh-delivery-view.assigner_wants")},
|
||||
{words: []string{"haves"}, usage: "[<node>]", does: "what each node runs, as its node-engine said", build: optNode("mesh-delivery-view.node_haves")},
|
||||
{words: []string{"switch"}, does: "what each node runs, as its node-engine said", build: fixed("mesh-delivery-view.node_haves")},
|
||||
{words: []string{"composition"}, usage: "<node> <module>", does: "one module's composition on a node beside what runs",
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) != 2 {
|
||||
return call{}, usageErr("give the node and the module")
|
||||
}
|
||||
return call{l.pos[0] + "/node-engine.composition", map[string]any{"module": l.pos[1]}}, nil
|
||||
}},
|
||||
{words: []string{"seats"}, does: "who holds each seat, and where", build: func(line) (call, error) {
|
||||
return call{"mesh-placement.placement", map[string]any{"seats": "true"}}, nil
|
||||
}},
|
||||
{words: []string{"seat"}, instead: "a seat is held by placing its module: nox assign <node> <module> --why <text> (mesh-placement.propose)"},
|
||||
{words: []string{"assign"}, usage: "<node> <module>... --why <text>", does: "propose placing modules on a node",
|
||||
allowed: []string{"why"}, build: placeModules("place")},
|
||||
{words: []string{"unassign"}, usage: "<node> <module>... --why <text>", does: "propose taking modules off a node",
|
||||
allowed: []string{"why"}, build: placeModules("take-off")},
|
||||
{words: []string{"pin"}, usage: "<node> <provision> <from-node> <module> --why <text>", does: "propose which provider a node gets a provision from",
|
||||
allowed: []string{"why"}, build: func(l line) (call, error) {
|
||||
if len(l.pos) != 4 {
|
||||
return call{}, usageErr("give the node, the provision, the provider's node and its module")
|
||||
}
|
||||
return propose(l, map[string]any{"op": "pin", "node": l.pos[0], "provision": l.pos[1],
|
||||
"provider": map[string]any{"node": l.pos[2], "module": l.pos[3]}})
|
||||
}},
|
||||
{words: []string{"unpin"}, usage: "<node> <provision> --why <text>", does: "propose putting that question back",
|
||||
allowed: []string{"why"}, build: func(l line) (call, error) {
|
||||
if len(l.pos) != 2 {
|
||||
return call{}, usageErr("give the node and the provision")
|
||||
}
|
||||
return propose(l, map[string]any{"op": "unpin", "node": l.pos[0], "provision": l.pos[1]})
|
||||
}},
|
||||
{words: []string{"proposals"}, usage: "[<id>]", does: "the open proposals of placement", build: optArg("mesh-placement.proposals", "id")},
|
||||
{words: []string{"overlay", "show"}, does: "every node's placement, with its tunnel", build: fixed("mesh-placement.placement")},
|
||||
{words: []string{"overlay"}, instead: "a node's tunnel is a decided field of its placement: mesh-placement.propose with op decide"},
|
||||
|
||||
// Deliveries and builds.
|
||||
{words: []string{"deliveries"}, usage: "[--state s] [--repository owner/repo] [--group g] [--all]",
|
||||
does: "every delivery not final, and those that ended in the last day",
|
||||
allowed: []string{"state", "repository", "group", "all"}, build: func(l line) (call, error) {
|
||||
return call{"mesh-delivery.deliveries", pick(l, "state", "repository", "group", "all")}, nil
|
||||
}},
|
||||
{words: []string{"delivery"}, usage: "<id>", does: "one delivery or group whole", build: needArg("mesh-delivery.show", "id")},
|
||||
{words: []string{"groups"}, usage: "[--all]", does: "every delivery group", allowed: []string{"all"},
|
||||
build: func(l line) (call, error) { return call{"mesh-delivery.groups", pick(l, "all")}, nil }},
|
||||
{words: []string{"stalled"}, does: "every delivery held past its state's bound", build: fixed("mesh-delivery.stalled")},
|
||||
{words: []string{"checks"}, usage: "<owner/repo> [--number n | --commit c]", does: "what the mesh's checks said of a head",
|
||||
allowed: []string{"number", "commit"}, build: func(l line) (call, error) {
|
||||
if len(l.pos) != 1 {
|
||||
return call{}, usageErr("give the repository, owner/repo")
|
||||
}
|
||||
a := pick(l, "number", "commit")
|
||||
a["repository"] = l.pos[0]
|
||||
return call{"mesh-delivery.checks", a}, nil
|
||||
}},
|
||||
{words: []string{"try"}, usage: "<owner/repo> <head> [--level l] [--trunk t] [--tests t]",
|
||||
does: "try a pushed head before its pull request is opened", allowed: []string{"level", "trunk", "tests"},
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) != 2 {
|
||||
return call{}, usageErr("give the repository and the head")
|
||||
}
|
||||
a := pick(l, "level", "trunk", "tests")
|
||||
a["repository"], a["head"] = l.pos[0], l.pos[1]
|
||||
return call{"mesh-delivery-checker.try", a}, nil
|
||||
}},
|
||||
{words: []string{"builds"}, usage: "[--since <RFC 3339 time>]", does: "every build that ended since (the last day by default)",
|
||||
allowed: []string{"since"}, build: func(l line) (call, error) {
|
||||
since := l.flags["since"]
|
||||
if since == "" {
|
||||
since = now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return call{"mesh-delivery-builder.builder_builds_since", map[string]any{"since": since}}, nil
|
||||
}},
|
||||
{words: []string{"upgrade"}, usage: "[<module>] [--policy roll|together|record|default --why <text>]",
|
||||
does: "whether each module's new builds wait for a person; with --policy, a person's choice",
|
||||
allowed: []string{"policy", "why"}, build: func(l line) (call, error) {
|
||||
if len(l.pos) > 1 {
|
||||
return call{}, usageErr("give at most one module; rolling out is the delivery's: nox deliveries")
|
||||
}
|
||||
a := pick(l, "policy", "why")
|
||||
if len(l.pos) == 1 {
|
||||
a["module"] = l.pos[0]
|
||||
}
|
||||
if a["policy"] != nil && (a["module"] == nil || a["why"] == nil) {
|
||||
return call{}, usageErr("a policy needs the module and --why")
|
||||
}
|
||||
return call{"mesh-wants.upgrade", a}, nil
|
||||
}},
|
||||
{words: []string{"bus"}, does: "what a bus upgrade would do", build: fixed("mesh-wants.bus")},
|
||||
|
||||
// Conditions, the self-check, the healers and hand acts.
|
||||
{words: []string{"conditions", "show"}, usage: "<key>", does: "one condition whole", build: needArg("mesh-conditions.conditions", "key")},
|
||||
{words: []string{"conditions", "silence"}, usage: "<key> --for <30m|4h|2d> --why <text> [--cause <word>]",
|
||||
does: "send no message for it a while; a hand act", allowed: []string{"for", "why", "cause"},
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) != 1 || !l.has("for") || !l.has("why") {
|
||||
return call{}, usageErr("give the key, --for and --why")
|
||||
}
|
||||
a := pick(l, "for", "why", "cause")
|
||||
a["key"] = l.pos[0]
|
||||
return call{"mesh-conditions.silence", a}, nil
|
||||
}},
|
||||
{words: []string{"conditions", "history"}, usage: "[--days N] [--key K] [--limit N]", does: "every raising, change and clearing lately",
|
||||
allowed: []string{"days", "key", "limit"}, build: func(l line) (call, error) {
|
||||
if l.has("key") {
|
||||
return call{"mesh-conditions.conditions_history", pick(l, "key", "limit")}, nil
|
||||
}
|
||||
a := pick(l, "days")
|
||||
a["history"] = "true"
|
||||
return call{"mesh-conditions.conditions", a}, nil
|
||||
}},
|
||||
{words: []string{"conditions"}, usage: "[--scope S] [--severity S] [--machine M]", does: "what is wrong now, urgent first",
|
||||
allowed: []string{"scope", "severity", "machine"}, build: func(l line) (call, error) {
|
||||
if len(l.pos) > 0 {
|
||||
return call{}, usageErr("%q is not a conditions line", l.pos[0])
|
||||
}
|
||||
return call{"mesh-conditions.conditions", pick(l, "scope", "severity", "machine")}, nil
|
||||
}},
|
||||
{words: []string{"doctor", "probes"}, does: "the self-check's probes", build: fixed("mesh-self-check.probes")},
|
||||
{words: []string{"doctor", "run"}, does: "run every probe now", build: func(line) (call, error) {
|
||||
return call{"mesh-self-check.doctor", map[string]any{"run": true}}, nil
|
||||
}},
|
||||
{words: []string{"doctor", "probe"}, usage: "<id>", does: "one probe's verdict whole", build: needArg("mesh-self-check.doctor", "probe")},
|
||||
{words: []string{"doctor", "signals"}, does: "the watchman's signals and their ages", build: fixed("mesh-watchdogs.signals")},
|
||||
{words: []string{"doctor"}, does: "the self-check's last verdict", build: fixed("mesh-self-check.doctor")},
|
||||
{words: []string{"healers"}, usage: "[--days N]", does: "the healers, what they did lately, and their brake",
|
||||
allowed: []string{"days"}, build: func(l line) (call, error) {
|
||||
a, err := numbers(pick(l, "days"), "days")
|
||||
return call{"mesh-healers.healers", a}, err
|
||||
}},
|
||||
{words: []string{"hand-act", "record"}, usage: "<what> --why <text> [--cause <word>] [--condition <key>]",
|
||||
does: "record an act done by hand", allowed: []string{"why", "cause", "condition"},
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) == 0 || !l.has("why") {
|
||||
return call{}, usageErr("give what was done and --why")
|
||||
}
|
||||
a := pick(l, "why", "cause", "condition")
|
||||
a["what"] = strings.Join(l.pos, " ")
|
||||
return call{"mesh-hand-acts.hand-act", a}, nil
|
||||
}},
|
||||
{words: []string{"hand-acts"}, usage: "[--days N] [--cause c] [--who w]", does: "what was done by hand lately",
|
||||
allowed: []string{"days", "cause", "who"}, build: func(l line) (call, error) {
|
||||
a, err := numbers(pick(l, "days", "cause", "who"), "days")
|
||||
return call{"mesh-hand-acts.hand-acts", a}, err
|
||||
}},
|
||||
{words: []string{"stuck"}, does: "what stopped moving, as the watchman holds it", build: fixed("mesh-watchdogs.stuck")},
|
||||
{words: []string{"dead-letters"}, usage: "[--consumer c]", does: "what a consumer gave up on", allowed: []string{"consumer"},
|
||||
build: func(l line) (call, error) { return call{"mesh-watchdogs.dead-letters", pick(l, "consumer")}, nil }},
|
||||
|
||||
// Config, secrets, the vault, artifacts, licences, modules.
|
||||
{words: []string{"settings", "show"}, usage: "<module> [--node n]", does: "a module's config values",
|
||||
allowed: []string{"node"}, build: func(l line) (call, error) {
|
||||
if len(l.pos) != 1 {
|
||||
return call{}, usageErr("give the module")
|
||||
}
|
||||
a := pick(l, "node")
|
||||
a["action"], a["module"] = "list", l.pos[0]
|
||||
return call{"mesh-config.config", a}, nil
|
||||
}},
|
||||
{words: []string{"settings", "set"}, usage: "<module> <key> <value> --node n --why <text>",
|
||||
does: "set one of a module's config values on one node", allowed: []string{"node", "why"},
|
||||
build: func(l line) (call, error) { return configChange(l, "set") }},
|
||||
{words: []string{"settings", "clear"}, usage: "<module> <key> --node n --why <text>",
|
||||
does: "clear one of a module's config values on one node", allowed: []string{"node", "why"},
|
||||
build: func(l line) (call, error) { return configChange(l, "clear") }},
|
||||
{words: []string{"config"}, usage: "[list|set|clear|history|propose|proposals] [--module m] [--node n] [--key k] [--value v] [--why w]",
|
||||
does: "the mesh's config values, at any level", allowed: []string{"module", "node", "key", "value", "why", "limit", "proposal", "pull", "clear"},
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) > 1 {
|
||||
return call{}, usageErr("give at most one action")
|
||||
}
|
||||
a := pick(l, "module", "node", "key", "why", "proposal", "pull", "clear", "limit")
|
||||
if len(l.pos) == 1 {
|
||||
a["action"] = l.pos[0]
|
||||
}
|
||||
if v, ok := l.flags["value"]; ok {
|
||||
a["value"] = jsonOrText(v)
|
||||
}
|
||||
if c, ok := a["clear"]; ok {
|
||||
a["clear"] = c == "true"
|
||||
}
|
||||
a, err := numbers(a, "limit")
|
||||
return call{"mesh-config.config", a}, err
|
||||
}},
|
||||
{words: []string{"secret", "export"}, does: "every secret the vault holds, as its copy sealed to the operator key",
|
||||
build: fixed("mesh-vault.secret_vault_export")},
|
||||
{words: []string{"secret", "accept"}, instead: "mesh-vault.accept takes a value from the controller alone, and no seat takes one from a person yet; a module's own secret is asked at a desk (mesh-vault.secret-ask)"},
|
||||
{words: []string{"secret", "recover"}, instead: "opening a sealed copy locally is not built yet (beside nox enrol open); nox secret export gives the sealed copies"},
|
||||
{words: []string{"secret"}, instead: "nox secret export; mesh-vault.rotate makes one anew"},
|
||||
{words: []string{"operator", "key", "show"}, does: "what needs a person's eye in the vault, the operator key among it", build: fixed("mesh-vault.vault_status")},
|
||||
{words: []string{"operator"}, instead: "no seat sets or makes the operator key yet; nox operator key show reads it (mesh-vault.vault_status)"},
|
||||
{words: []string{"rotate"}, usage: "[--node n --module m --name s | --provider n --provision p] --why <text>",
|
||||
does: "make a secret anew", allowed: []string{"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "why"},
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) == 1 {
|
||||
l.flags["provision"] = l.pos[0]
|
||||
} else if len(l.pos) > 1 {
|
||||
return call{}, usageErr("give the secret by its flags")
|
||||
}
|
||||
if !l.has("why") {
|
||||
return call{}, usageErr("give --why")
|
||||
}
|
||||
return call{"mesh-vault.rotate", pick(l, "node", "module", "name", "provider", "provision", "consumer", "consumer_module", "why")}, nil
|
||||
}},
|
||||
{words: []string{"identity", "show"}, does: "the vault's signing key and authority", build: fixed("mesh-vault.vault_signers")},
|
||||
{words: []string{"retire", "approve"}, usage: "<consumer>... --why <text>", does: "retire the set waiting for a person",
|
||||
allowed: []string{"why"}, build: retirement("mesh-vault.provisioner_retire_approve")},
|
||||
{words: []string{"retire", "reject"}, usage: "<consumer>... --why <text>", does: "keep the set waiting for a person active",
|
||||
allowed: []string{"why"}, build: retirement("mesh-vault.provisioner_retire_reject")},
|
||||
{words: []string{"retire"}, does: "every retirement waiting for a person", build: fixed("mesh-vault.provisioner_retirement")},
|
||||
{words: []string{"cleanup", "delete"}, usage: "<consumer> --why <text> --confirm", does: "delete one retired holder from the ledger",
|
||||
allowed: []string{"why", "confirm"}, build: func(l line) (call, error) {
|
||||
if len(l.pos) != 1 || !l.has("why") {
|
||||
return call{}, usageErr("give the consumer and --why")
|
||||
}
|
||||
a := pick(l, "why")
|
||||
a["consumer"] = l.pos[0]
|
||||
// The vault deletes only when confirm repeats the holder's name (mesh-vault retirement.go); without
|
||||
// --confirm it answers what it would delete.
|
||||
if l.has("confirm") {
|
||||
a["confirm"] = l.pos[0]
|
||||
}
|
||||
return call{"mesh-vault.provisioner_delete", a}, nil
|
||||
}},
|
||||
{words: []string{"cleanup"}, does: "every retired holder per provider", build: fixed("mesh-vault.provisioner_retirement")},
|
||||
{words: []string{"artifacts"}, usage: "[--collected]", does: "every artifact the mesh made: kept and why, or eligible",
|
||||
allowed: []string{"collected"}, build: func(l line) (call, error) {
|
||||
return call{"mesh-artifact-sweep.artifacts", pick(l, "collected")}, nil
|
||||
}},
|
||||
{words: []string{"collection"}, does: "every artifact the mesh made: kept and why, or eligible", build: fixed("mesh-artifact-sweep.artifacts")},
|
||||
{words: []string{"collect"}, usage: "[--confirm --why <text>] [--most n]", does: "the sweep: a dry run, or with --confirm the sweep now",
|
||||
allowed: []string{"confirm", "why", "most"}, build: func(l line) (call, error) {
|
||||
if l.has("confirm") && !l.has("why") {
|
||||
return call{}, usageErr("--confirm needs --why")
|
||||
}
|
||||
return call{"mesh-artifact-sweep.collect", pick(l, "confirm", "why", "most")}, nil
|
||||
}},
|
||||
{words: []string{"images"}, usage: "<node>", does: "every container image a machine's wants and haves name", build: needArg("mesh-artifact-sweep.images", "node")},
|
||||
{words: []string{"mirrors"}, does: "the bases the mesh copied into its store", build: fixed("mesh-artifact-sweep.mirrors")},
|
||||
{words: []string{"licence", "list"}, does: "every licence the manager holds", build: fixed("anthropic-licence-manager.licences")},
|
||||
{words: []string{"licence", "use"}, usage: "<consumer> <licence>", does: "move a node's agent to another licence",
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) != 2 {
|
||||
return call{}, usageErr("give the consumer (a node) and the licence")
|
||||
}
|
||||
return call{"anthropic-licence-manager.switch", map[string]any{"consumer": l.pos[0], "licence": l.pos[1]}}, nil
|
||||
}},
|
||||
{words: []string{"licence", "refresh"}, usage: "[<licence>]", does: "refresh a licence now, or every due one", build: optArg("anthropic-licence-manager.refresh", "licence")},
|
||||
{words: []string{"licence", "key"}, does: "the manager's public key", build: fixed("anthropic-licence-manager.public-key")},
|
||||
{words: []string{"licence"}, instead: "anthropic-licence-manager.adopt takes an API key from a file on the manager's node; nox licence list|use|refresh|key"},
|
||||
{words: []string{"module", "list"}, does: "every module the indexer holds", build: fixed("mesh-delivery-view.indexer_modules")},
|
||||
{words: []string{"module", "show"}, usage: "<module>", does: "what the indexer holds of one module", build: needArg("mesh-delivery-view.indexer_module", "module")},
|
||||
{words: []string{"module", "moved"}, usage: "<module> --why <text>", does: "take a registered module in now", allowed: []string{"why"},
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) < 1 {
|
||||
return call{}, usageErr("give the module")
|
||||
}
|
||||
return call{"mesh-delivery-indexer.indexer_take_in", map[string]any{"module": l.pos[0], "why": l.flags["why"]}}, nil
|
||||
}},
|
||||
{words: []string{"module"}, instead: "the indexer takes modules in from their repositories; nox module list|show|moved, and nothing forgets one by hand yet"},
|
||||
{words: []string{"register"}, instead: "no seat registers a module from another repository yet (novox/hq ADR 0501 §4); nox module moved <module> takes a registered one in"},
|
||||
{words: []string{"account", "issue"}, usage: "<user>", does: "mint one bus account the grants part wants, now", build: needArg("mesh-vault.issue-account", "user")},
|
||||
|
||||
// Issues and records.
|
||||
{words: []string{"issues"}, usage: "[<words>] [--status s] [--limit n] [--owner o]", does: "the issues, newest first, or those that match",
|
||||
allowed: []string{"status", "limit", "owner", "severity", "needs"}, build: func(l line) (call, error) {
|
||||
a := pick(l, "status", "owner", "severity", "needs", "limit")
|
||||
if len(l.pos) > 0 {
|
||||
a["search"] = strings.Join(l.pos, " ")
|
||||
}
|
||||
a, err := numbers(a, "limit")
|
||||
return call{"issue-tracker.list", a}, err
|
||||
}},
|
||||
{words: []string{"issue"}, usage: "<number> [--brief]", does: "one issue whole", allowed: []string{"brief"},
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) != 1 {
|
||||
return call{}, usageErr("give the issue's number")
|
||||
}
|
||||
n, err := strconv.Atoi(l.pos[0])
|
||||
if err != nil || n < 1 {
|
||||
return call{}, usageErr("%q is not an issue's number", l.pos[0])
|
||||
}
|
||||
a := map[string]any{"number": n}
|
||||
if l.has("brief") {
|
||||
a["brief"] = true
|
||||
}
|
||||
return call{"issue-tracker.get", a}, nil
|
||||
}},
|
||||
{words: []string{"kb", "ask"}, usage: "<question>", does: "what holds about something, and why", build: func(l line) (call, error) {
|
||||
if len(l.pos) == 0 {
|
||||
return call{}, usageErr("give the question")
|
||||
}
|
||||
return call{"knowledge-base.ask", map[string]any{"question": strings.Join(l.pos, " ")}}, nil
|
||||
}},
|
||||
{words: []string{"kb", "get"}, usage: "<number> [--kind research] [--reasoning]", does: "one record by its number",
|
||||
allowed: []string{"kind", "reasoning"}, build: func(l line) (call, error) {
|
||||
if len(l.pos) != 1 {
|
||||
return call{}, usageErr("give the record's number")
|
||||
}
|
||||
n, err := strconv.Atoi(l.pos[0])
|
||||
if err != nil {
|
||||
return call{}, usageErr("%q is not a record's number", l.pos[0])
|
||||
}
|
||||
a := pick(l, "kind")
|
||||
a["number"] = n
|
||||
if l.has("reasoning") {
|
||||
a["reasoning"] = true
|
||||
}
|
||||
return call{"knowledge-base.get", a}, nil
|
||||
}},
|
||||
{words: []string{"progress"}, usage: "[<design>|mesh] [--detail minimal|detailed]", does: "how far the designs have come",
|
||||
allowed: []string{"detail", "since"}, build: func(l line) (call, error) {
|
||||
a := pick(l, "detail", "since")
|
||||
a["of"] = "mesh"
|
||||
if len(l.pos) == 1 {
|
||||
a["of"] = l.pos[0]
|
||||
if _, err := strconv.Atoi(l.pos[0]); err == nil {
|
||||
a["of"] = "design " + l.pos[0]
|
||||
}
|
||||
}
|
||||
return call{"knowledge-base.progress", a}, nil
|
||||
}},
|
||||
|
||||
// Enrolment (the seat mesh-enrolment; `enrol open` is local, in main.go).
|
||||
{words: []string{"enrol", "issue"}, usage: "<node> --why <text> [--expires 1h]", does: "a one-time token for a new machine, on the operator's Approve",
|
||||
allowed: []string{"why", "expires"}, build: enrolIssue},
|
||||
{words: []string{"token", "issue"}, usage: "--new <node> --why <text>", does: "the same as nox enrol issue",
|
||||
allowed: []string{"new", "node", "why", "expires", "adopted"}, build: func(l line) (call, error) {
|
||||
node := l.flags["new"]
|
||||
if node == "" {
|
||||
node = l.flags["node"]
|
||||
}
|
||||
if node != "" {
|
||||
l.pos = append([]string{node}, l.pos...)
|
||||
}
|
||||
return enrolIssue(l)
|
||||
}},
|
||||
{words: []string{"enrol", "tokens"}, usage: "[<node>]", does: "every open enrolment, or one", build: optArg("mesh-enrolment.tokens", "node")},
|
||||
{words: []string{"enrol", "revoke"}, usage: "<node> --why <text>", does: "end an enrolment at any stage", allowed: []string{"why"},
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) != 1 || !l.has("why") {
|
||||
return call{}, usageErr("give the node and --why")
|
||||
}
|
||||
return call{"mesh-enrolment.revoke", map[string]any{"node": l.pos[0], "why": l.flags["why"]}}, nil
|
||||
}},
|
||||
{words: []string{"enrol", "again"}, usage: "<node>", does: "raise an enrolment's ask again", build: needArg("mesh-enrolment.again", "node")},
|
||||
|
||||
// Any address.
|
||||
{words: []string{"call"}, usage: "<address> [<json arguments>]", does: "call any seat verb or module tool, e.g. ace/node-service-manager.status",
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) < 1 || len(l.pos) > 2 {
|
||||
return call{}, usageErr("give the address and at most one JSON object of arguments")
|
||||
}
|
||||
return addressCall(l.pos[0], l.pos[1:])
|
||||
}},
|
||||
{words: []string{"ask"}, usage: "<module> <tool> [<json arguments>]", does: "call one of a module's tools",
|
||||
build: func(l line) (call, error) {
|
||||
if len(l.pos) < 2 || len(l.pos) > 3 {
|
||||
return call{}, usageErr("give the module, the tool and at most one JSON object of arguments")
|
||||
}
|
||||
return addressCall(l.pos[0]+"."+l.pos[1], l.pos[2:])
|
||||
}},
|
||||
}
|
||||
|
||||
// unowned are the controller's own lines that no seat or module took over, and what to use where there is anything.
|
||||
var unowned = map[string]string{
|
||||
"migrate": "", "prepare": "", "serve": "", "declare": "", "broker": "", "board": "", "api": "", "facts": "",
|
||||
"rehearse": "", "take": "", "converge": "", "adopt": "", "move-placement": "", "move-config": "", "durations": "",
|
||||
"data": "each machine's backups: nox call <node>/node-backup.backed-up",
|
||||
"forget-checks": "nox checks <owner/repo>", "queue": "nox builds; nox stuck", "cancel": "nox deliveries",
|
||||
"clear": "nox deliveries", "kill": "nox deliveries", "replay": "nox dead-letters",
|
||||
"pause": "nox call <node>/node-build-go.pause (or node-build-node, node-build-image)",
|
||||
"resume": "nox call <node>/node-build-go.resume (or node-build-node, node-build-image)",
|
||||
"push": "a merge is delivered by mesh-delivery: nox deliveries; one version on one machine is mesh-delivery-assigner.assigner_put",
|
||||
"deliver": "nox deliveries", "plans": "nox deliveries", "delivery-stop": "nox stalled",
|
||||
"delivery-backups-again": "nox deliveries", "delivery-walks": "nox deliveries", "delivery-order": "nox deliveries",
|
||||
"rollout": "nox upgrade <module>; delivery is mesh-delivery's: nox deliveries",
|
||||
}
|
||||
|
||||
// find answers the command whose words begin the line, the longest first, and what follows them.
|
||||
func find(args []string) (*command, []string) {
|
||||
var best *command
|
||||
for i := range commands {
|
||||
c := &commands[i]
|
||||
if len(c.words) > len(args) || (best != nil && len(c.words) <= len(best.words)) {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
for j, w := range c.words {
|
||||
if args[j] != w {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return best, args[len(best.words):]
|
||||
}
|
||||
|
||||
// read builds the call of a line for a command that has an owner.
|
||||
func (c *command) read(args []string) (call, error) {
|
||||
l, err := parseLine(args)
|
||||
if err != nil {
|
||||
return call{}, err
|
||||
}
|
||||
allowed := map[string]bool{"json": true}
|
||||
for _, f := range c.allowed {
|
||||
allowed[f] = true
|
||||
}
|
||||
for f := range l.flags {
|
||||
if !allowed[f] {
|
||||
return call{}, usageErr("--%s is not a flag of this line", f)
|
||||
}
|
||||
}
|
||||
return c.build(l)
|
||||
}
|
||||
|
||||
// now is the clock; a variable so a test can say.
|
||||
var now = time.Now
|
||||
|
||||
func fixed(address string) func(line) (call, error) {
|
||||
return func(l line) (call, error) {
|
||||
if len(l.pos) > 0 {
|
||||
return call{}, usageErr("this line takes no %q", l.pos[0])
|
||||
}
|
||||
return call{address, map[string]any{}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func needArg(address, name string) func(line) (call, error) {
|
||||
return func(l line) (call, error) {
|
||||
if len(l.pos) != 1 {
|
||||
return call{}, usageErr("give the %s", name)
|
||||
}
|
||||
return call{address, map[string]any{name: l.pos[0]}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func optArg(address, name string) func(line) (call, error) {
|
||||
return func(l line) (call, error) {
|
||||
switch len(l.pos) {
|
||||
case 0:
|
||||
return call{address, map[string]any{}}, nil
|
||||
case 1:
|
||||
return call{address, map[string]any{name: l.pos[0]}}, nil
|
||||
}
|
||||
return call{}, usageErr("give at most one %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func nodeArg(address string) func(line) (call, error) { return needArg(address, "node") }
|
||||
|
||||
func optNode(address string) func(line) (call, error) { return optArg(address, "node") }
|
||||
|
||||
// pick is the line's flags named, as string arguments.
|
||||
func pick(l line, names ...string) map[string]any {
|
||||
a := map[string]any{}
|
||||
for _, n := range names {
|
||||
if v, ok := l.flags[n]; ok {
|
||||
a[n] = v
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// numbers turns the named arguments into numbers; the call's address is left to the caller.
|
||||
func numbers(a map[string]any, names ...string) (map[string]any, error) {
|
||||
for _, n := range names {
|
||||
v, ok := a[n].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return a, usageErr("--%s takes a number, not %q", n, v)
|
||||
}
|
||||
a[n] = i
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// propose is a change of placement with its why.
|
||||
func propose(l line, changes ...map[string]any) (call, error) {
|
||||
if !l.has("why") {
|
||||
return call{}, usageErr("a change of placement needs --why; the operator is asked to approve it")
|
||||
}
|
||||
list := make([]any, len(changes))
|
||||
for i, c := range changes {
|
||||
list[i] = c
|
||||
}
|
||||
return call{"mesh-placement.propose", map[string]any{"why": l.flags["why"], "changes": list}}, nil
|
||||
}
|
||||
|
||||
func placeModules(op string) func(line) (call, error) {
|
||||
return func(l line) (call, error) {
|
||||
if len(l.pos) < 2 {
|
||||
return call{}, usageErr("give the node and at least one module")
|
||||
}
|
||||
var changes []map[string]any
|
||||
for _, m := range l.pos[1:] {
|
||||
changes = append(changes, map[string]any{"op": op, "node": l.pos[0], "module": m})
|
||||
}
|
||||
return propose(l, changes...)
|
||||
}
|
||||
}
|
||||
|
||||
func publicDomain(l line) (call, error) {
|
||||
switch {
|
||||
case len(l.pos) == 1 && !l.has("clear"):
|
||||
return call{"mesh-placement.placement", map[string]any{"node": l.pos[0]}}, nil
|
||||
case len(l.pos) == 1:
|
||||
return propose(l, map[string]any{"op": "decide", "node": l.pos[0], "field": "public-domain", "value": nil})
|
||||
case len(l.pos) == 2 && !l.has("clear"):
|
||||
return propose(l, map[string]any{"op": "decide", "node": l.pos[0], "field": "public-domain", "value": l.pos[1]})
|
||||
}
|
||||
return call{}, usageErr("give the node, and a domain or --clear")
|
||||
}
|
||||
|
||||
func configChange(l line, action string) (call, error) {
|
||||
want := 3
|
||||
if action == "clear" {
|
||||
want = 2
|
||||
}
|
||||
if len(l.pos) == 2 && action == "set" && strings.HasPrefix(strings.TrimSpace(l.pos[1]), "{") {
|
||||
return call{}, usageErr("a whole settings file is not set at once any more: set each key, " +
|
||||
"nox settings set <module> <key> <value> --node <node> --why <text> (mesh-config.config)")
|
||||
}
|
||||
if len(l.pos) != want || !l.has("node") || !l.has("why") {
|
||||
return call{}, usageErr("give the module, the key%s, --node and --why", map[bool]string{true: " and the value"}[action == "set"])
|
||||
}
|
||||
a := map[string]any{"action": action, "module": l.pos[0], "key": l.pos[1], "node": l.flags["node"], "why": l.flags["why"]}
|
||||
if action == "set" {
|
||||
a["value"] = jsonOrText(l.pos[2])
|
||||
}
|
||||
return call{"mesh-config.config", a}, nil
|
||||
}
|
||||
|
||||
// jsonOrText is a value as JSON when it reads as JSON (a number, true, an object), else the text itself.
|
||||
func jsonOrText(v string) any {
|
||||
var out any
|
||||
if err := json.Unmarshal([]byte(v), &out); err == nil {
|
||||
return out
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func retirement(address string) func(line) (call, error) {
|
||||
return func(l line) (call, error) {
|
||||
if len(l.pos) == 0 || !l.has("why") {
|
||||
return call{}, usageErr("give the consumers waiting and --why")
|
||||
}
|
||||
consumers := make([]any, len(l.pos))
|
||||
for i, c := range l.pos {
|
||||
consumers[i] = c
|
||||
}
|
||||
return call{address, map[string]any{"consumers": consumers, "why": l.flags["why"]}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func enrolIssue(l line) (call, error) {
|
||||
if len(l.pos) != 1 || !l.has("why") {
|
||||
return call{}, usageErr("give the new machine's name and --why")
|
||||
}
|
||||
a := pick(l, "why", "expires")
|
||||
a["node"] = l.pos[0]
|
||||
return call{"mesh-enrolment.issue", a}, nil
|
||||
}
|
||||
|
||||
func addressCall(address string, rest []string) (call, error) {
|
||||
a := map[string]any{}
|
||||
if len(rest) == 1 {
|
||||
if err := json.Unmarshal([]byte(rest[0]), &a); err != nil {
|
||||
return call{}, usageErr("the arguments are not a JSON object: %v", err)
|
||||
}
|
||||
}
|
||||
return call{address, a}, nil
|
||||
}
|
||||
|
||||
// renderNodes shapes placement's keys as one line per node: its name, address and site.
|
||||
func renderNodes(text string) string {
|
||||
var p struct {
|
||||
Keys map[string]struct {
|
||||
Placement struct {
|
||||
Decided map[string]any `json:"decided"`
|
||||
Modules []any `json:"modules"`
|
||||
} `json:"placement"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
// The console says after the answer which machine answered it ("answered by ace"): kept, below the lines.
|
||||
dec := json.NewDecoder(strings.NewReader(text))
|
||||
if err := dec.Decode(&p); err != nil || len(p.Keys) == 0 {
|
||||
return text
|
||||
}
|
||||
after := ""
|
||||
if rest, err := io.ReadAll(dec.Buffered()); err == nil {
|
||||
after = strings.TrimSpace(string(rest))
|
||||
}
|
||||
names := make([]string, 0, len(p.Keys))
|
||||
for n := range p.Keys {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
for _, n := range names {
|
||||
k := p.Keys[n]
|
||||
fmt.Fprintf(&b, "%-10s %-15v %-8v %d modules\n", n, orDash(k.Placement.Decided["address"]),
|
||||
orDash(k.Placement.Decided["site"]), len(k.Placement.Modules))
|
||||
}
|
||||
if after != "" {
|
||||
b.WriteString(after + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func orDash(v any) any {
|
||||
if v == nil || v == "" {
|
||||
return "-"
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
// How mesh-cli reaches the mesh now that the controller is unassigned (novox/hq issue 1126): through this machine's
|
||||
// console, the mesh MCP server the module node-tools serves on loopback (provision mcp-endpoint, ADR 0152: the
|
||||
// machine's login is the authority). mesh-cli calls `mesh_call` with a seat verb's or a module tool's address, and the
|
||||
// console puts the call on the bus, the way every agent session on the machine does. mesh-cli holds no credential and
|
||||
// keeps no bus account of its own.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// consoleFile is where the module writes the console's address, from the mcp-endpoint it requires; a variable so a
|
||||
// test can name its own.
|
||||
var consoleFile = "/etc/mesh-cli/console"
|
||||
|
||||
// defaultConsole is the console's address when the file is not there yet: the port node-tools declares it serves.
|
||||
const defaultConsole = "http://127.0.0.1:4270/mcp"
|
||||
|
||||
// callWithin bounds one call: the console's own bound on a call is 30 seconds, and a wait is longer.
|
||||
var callWithin = 2 * time.Minute
|
||||
|
||||
// maxAnswer bounds what is read of the console's answer.
|
||||
const maxAnswer = 16 << 20
|
||||
|
||||
// consoleAddress is where the console listens, and where that was read from, for what is said when it does not answer.
|
||||
func consoleAddress() (string, string) {
|
||||
if v := strings.TrimSpace(os.Getenv("MESH_CONSOLE")); v != "" {
|
||||
return v, "MESH_CONSOLE"
|
||||
}
|
||||
if raw, err := os.ReadFile(consoleFile); err == nil {
|
||||
if v := strings.TrimSpace(string(raw)); v != "" && !strings.Contains(v, "${") {
|
||||
return v, consoleFile
|
||||
}
|
||||
}
|
||||
return defaultConsole, "the console's declared port (" + consoleFile + " is not there)"
|
||||
}
|
||||
|
||||
// answer is what the console said of one call: its text, and whether the console marked it an error (a refusal, a
|
||||
// tool's failure, nothing serving, no answer in time).
|
||||
type answer struct {
|
||||
Text string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// errNoConsole is a console that did not answer at all: nothing was called.
|
||||
var errNoConsole = errors.New("no console")
|
||||
|
||||
// consoleTool asks the console one of its own tools (mesh_call, mesh_search, mesh_describe) and answers what it said.
|
||||
func consoleTool(ctx context.Context, tool string, arguments map[string]any) (answer, error) {
|
||||
url, from := consoleAddress()
|
||||
body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": tool, "arguments": arguments}})
|
||||
if err != nil {
|
||||
return answer{}, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, callWithin)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return answer{}, fmt.Errorf("the console's address %q (from %s) is not usable: %v", url, from, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return answer{}, ctx.Err()
|
||||
}
|
||||
return answer{}, fmt.Errorf("%w: nothing answers at %s (from %s), so nothing was called. The console is the "+
|
||||
"module node-tools on this machine: `systemctl status` of its unit, or call from another machine: %v",
|
||||
errNoConsole, url, from, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswer))
|
||||
if err != nil {
|
||||
return answer{}, fmt.Errorf("the console's answer could not be read: %v; the call may have run", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return answer{}, fmt.Errorf("the console at %s answered %s: %s", url, resp.Status, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return decodeAnswer(raw)
|
||||
}
|
||||
|
||||
// decodeAnswer reads the console's JSON-RPC answer, given as JSON or as a server-sent event's data.
|
||||
func decodeAnswer(raw []byte) (answer, error) {
|
||||
payload := bytes.TrimSpace(raw)
|
||||
if !bytes.HasPrefix(payload, []byte("{")) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(raw))
|
||||
sc.Buffer(make([]byte, 0, 64<<10), maxAnswer)
|
||||
payload = nil
|
||||
for sc.Scan() {
|
||||
if d, ok := strings.CutPrefix(sc.Text(), "data:"); ok {
|
||||
payload = []byte(strings.TrimSpace(d))
|
||||
}
|
||||
}
|
||||
}
|
||||
var rpc struct {
|
||||
Result *struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &rpc); err != nil {
|
||||
return answer{}, fmt.Errorf("the console's answer is not one mesh-cli reads (%v); the call may have run", err)
|
||||
}
|
||||
if rpc.Error != nil {
|
||||
return answer{Text: rpc.Error.Message, IsError: true}, nil
|
||||
}
|
||||
if rpc.Result == nil {
|
||||
return answer{}, errors.New("the console answered nothing; the call may have run")
|
||||
}
|
||||
var parts []string
|
||||
for _, c := range rpc.Result.Content {
|
||||
if c.Type == "text" || c.Type == "" {
|
||||
parts = append(parts, c.Text)
|
||||
}
|
||||
}
|
||||
return answer{Text: strings.Join(parts, "\n"), IsError: rpc.Result.IsError}, nil
|
||||
}
|
||||
|
||||
// meshCall calls one address through the console.
|
||||
func meshCall(ctx context.Context, address string, args map[string]any) (answer, error) {
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
return consoleTool(ctx, "mesh_call", map[string]any{"address": address, "arguments": args})
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
|
||||
// 774/S51/AC1, AC2 (novox/hq ADR 0587 §6, §8): a token sealed to a test operator key (as mesh-vault seals it: the text
|
||||
// of the token, anonymous box, base64) is opened with the private half and printed as a token the engine's parser reads
|
||||
// (base64url of version 1 JSON with node, broker, fingerprint, signer and secret), and nothing is asked of the engine's
|
||||
// socket, which here is a path nobody listens on; the wrong key prints nothing and says the token is not sealed to it.
|
||||
// (base64url of version 1 JSON with node, broker, fingerprint, signer and secret), and nothing is asked of the console, which
|
||||
// here is an address nobody listens on; the wrong key prints nothing and says the token is not sealed to it.
|
||||
func TestEnrolOpenOpensASealedTokenLocallyAndOnlyWithItsKey(t *testing.T) {
|
||||
t.Setenv("MESH_VERB", "")
|
||||
token := base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"node":"new1","broker":"tls://10.77.0.1:4222",` +
|
||||
@@ -37,9 +37,9 @@ func TestEnrolOpenOpensASealedTokenLocallyAndOnlyWithItsKey(t *testing.T) {
|
||||
}
|
||||
return p
|
||||
}
|
||||
before, beforeIn := socket, standardInput
|
||||
socket = filepath.Join(dir, "nobody-listens.sock")
|
||||
t.Cleanup(func() { socket, standardInput = before, beforeIn })
|
||||
beforeIn := standardInput
|
||||
t.Setenv("MESH_CONSOLE", "http://127.0.0.1:1/mcp") // nothing listens: enrol open asks nothing of the mesh
|
||||
t.Cleanup(func() { standardInput = beforeIn })
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
args []string
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// Command mesh-cli is the operator's command on every node (novox/hq ADR 0272): `mesh-cli <command line>` asks the
|
||||
// controller to run one of its command lines, and prints what it said. In interactive zsh it is also `nox`.
|
||||
// Command mesh-cli is the operator's command on every node: `mesh-cli <command line>`, in interactive zsh also `nox`.
|
||||
//
|
||||
// It holds no credential and reads no file of the mesh. It asks the node-engine on this machine over a local
|
||||
// socket; the engine reads which account is asking from the kernel and asks the controller. The controller decides:
|
||||
// on the control-node, from the operator's own account, the line runs as the controller's terminal; elsewhere it is
|
||||
// an ordinary call, and a change made at the terminal alone is refused with the reason; from any other account
|
||||
// nothing runs.
|
||||
// Each line calls the seat verb or the module tool that owns its job (novox/hq issue 1126: the controller that ran
|
||||
// these lines before is unassigned), through this machine's console, the mesh MCP server on loopback (console.go).
|
||||
// A line no seat or module owns says so and names what to use instead. `enrol open` runs here and asks nothing.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -15,16 +12,14 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/cli"
|
||||
)
|
||||
|
||||
// socket is where the node-engine answers; a variable so a test can name its own.
|
||||
var socket = cli.Socket
|
||||
|
||||
// standardInput is mesh-cli's own standard input, and whether it is a terminal (or a device such as /dev/null),
|
||||
// which is never read; a variable so a test can say.
|
||||
// standardInput is mesh-cli's own standard input, and whether it is a terminal (or a device such as /dev/null); a
|
||||
// variable so a test can say.
|
||||
var standardInput = func() (io.Reader, bool) {
|
||||
info, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
@@ -33,39 +28,6 @@ var standardInput = func() (io.Reader, bool) {
|
||||
return os.Stdin, info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// carried is what mesh-cli carries of its standard input for the line: what a pipe or a file held, at most
|
||||
// cli.MaxStdin, for the controller's terminal alone (a secret given with `secret accept … --from -`). A terminal is
|
||||
// never read: the line is asked without. More than the bound is refused, and nothing is asked.
|
||||
func carried() ([]byte, error) {
|
||||
in, terminal := standardInput()
|
||||
if terminal {
|
||||
return nil, nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(in, cli.MaxStdin+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("standard input could not be read (%v); nothing was asked", err)
|
||||
}
|
||||
if len(body) > cli.MaxStdin {
|
||||
return nil, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", cli.MaxStdin)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
const usage = `mesh-cli — the controller's command lines, from this node (novox/hq ADR 0272)
|
||||
|
||||
mesh-cli <command line> e.g. mesh-cli status, mesh-cli settings show zsh --node <node>
|
||||
nox <command line> the same, in interactive zsh
|
||||
|
||||
nox enrol open --key <file> opens a sealed enrolment token here, asking nothing of the mesh (ADR 0587)
|
||||
|
||||
Run it as yourself, never under sudo: the node-engine tells the controller which account asks.
|
||||
What a pipe or a file gives its standard input (at most 64 KiB) goes with the line to the controller's
|
||||
terminal alone, e.g. a secret: mesh-cli secret accept <node> <module> <name> --from - < file
|
||||
On the control-node, from its operator's account, the line runs as the controller's terminal.
|
||||
Anywhere else it is an ordinary call, and a terminal-only change is refused with the reason.
|
||||
'mesh-cli help' asks the controller for its own list of command lines.
|
||||
`
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
code := run(ctx, os.Args[1:], os.Stdout, os.Stderr)
|
||||
@@ -74,56 +36,178 @@ func main() {
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||
fmt.Fprint(stderr, usage())
|
||||
if len(args) == 0 {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// A verb's own process carries the verb it runs for; a verb is never a person, so it is never the terminal.
|
||||
// A verb's own process carries the verb it runs for; a verb is never a person.
|
||||
if verb := os.Getenv("MESH_VERB"); verb != "" {
|
||||
fmt.Fprintf(stderr, "mesh-cli: this process runs for the verb %q, and a verb is never the operator. "+
|
||||
"Nothing was asked\n", verb)
|
||||
"Nothing was called\n", verb)
|
||||
return 1
|
||||
}
|
||||
switch args[0] {
|
||||
case "version":
|
||||
fmt.Fprintln(stdout, version())
|
||||
return 0
|
||||
case "search":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(stderr, "mesh-cli: give the words: nox search <words>")
|
||||
return 2
|
||||
}
|
||||
return show(ctx, "mesh_search", map[string]any{"query": strings.Join(args[1:], " ")}, nil, stdout, stderr)
|
||||
case "describe":
|
||||
if len(args) != 2 {
|
||||
fmt.Fprintln(stderr, "mesh-cli: give one address: nox describe <address>")
|
||||
return 2
|
||||
}
|
||||
return show(ctx, "mesh_describe", map[string]any{"address": args[1]}, nil, stdout, stderr)
|
||||
}
|
||||
// `enrol open` is local: it opens a sealed enrolment token with the operator's private key and asks nothing of the
|
||||
// node-engine or the mesh (novox/hq ADR 0587 §8).
|
||||
// mesh (novox/hq ADR 0587 §8).
|
||||
if len(args) >= 2 && args[0] == "enrol" && args[1] == "open" {
|
||||
in, terminal := standardInput()
|
||||
return enrolOpen(args[2:], in, terminal, stdout, stderr)
|
||||
}
|
||||
in, err := carried()
|
||||
c, rest := find(args)
|
||||
if c == nil {
|
||||
if instead, ok := unowned[args[0]]; ok {
|
||||
return noOwner(args[0], instead, stderr)
|
||||
}
|
||||
fmt.Fprintf(stderr, "mesh-cli: %q is not a command. nox help lists them; nox search <words> finds a seat verb "+
|
||||
"or a tool, and nox call <address> calls it\n", args[0])
|
||||
return 2
|
||||
}
|
||||
if c.build == nil {
|
||||
return noOwner(strings.Join(c.words, " "), c.instead, stderr)
|
||||
}
|
||||
k, err := c.read(rest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "mesh-cli: %v\n", err)
|
||||
return 1
|
||||
fmt.Fprintf(stderr, "mesh-cli: %v. Nothing was called\n nox %s %s\n", err, strings.Join(c.words, " "), c.usage)
|
||||
return 2
|
||||
}
|
||||
a, err := cli.AskWith(ctx, socket, cli.Request{Line: args, Stdin: in})
|
||||
if err != nil {
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
fmt.Fprintln(stderr, "mesh-cli: stopped waiting; the line may still be running on the controller")
|
||||
return 130
|
||||
}
|
||||
fmt.Fprintf(stderr, "mesh-cli: %v\n", err)
|
||||
return 1
|
||||
if k.Address == "mesh-delivery-view.view_status" {
|
||||
return status(ctx, stdout, stderr)
|
||||
}
|
||||
_, _ = stdout.Write(a.Stdout)
|
||||
_, _ = stderr.Write(a.Stderr)
|
||||
if a.Cut {
|
||||
fmt.Fprintln(stderr, "mesh-cli: the output was longer than one bus message, and was cut there")
|
||||
}
|
||||
if !a.Terminal && a.Why != "" {
|
||||
fmt.Fprintln(stderr, "mesh-cli: "+a.Why)
|
||||
}
|
||||
if a.Unknown != "" {
|
||||
// Asked, and what came of it not known: never said as nothing having run.
|
||||
fmt.Fprintln(stderr, "mesh-cli: "+a.Unknown)
|
||||
if a.Exit == 0 {
|
||||
return 1
|
||||
return show(ctx, "mesh_call", map[string]any{"address": k.Address, "arguments": k.Args}, c.render, stdout, stderr)
|
||||
}
|
||||
|
||||
// noOwner says a line has no owner since the controller went, and what to use.
|
||||
func noOwner(what, instead string, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "mesh-cli: nox %s: %s.\n", what, gone)
|
||||
if instead != "" {
|
||||
fmt.Fprintf(stderr, " instead: %s\n", instead)
|
||||
} else {
|
||||
fmt.Fprintln(stderr, " no seat or module does this now; nox search <words> finds what does nearby")
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// show calls one of the console's tools and prints what it answered: an answer marked an error on standard error, with
|
||||
// exit 1.
|
||||
func show(ctx context.Context, tool string, args map[string]any, render func(string) string, stdout, stderr io.Writer) int {
|
||||
a, err := consoleTool(ctx, tool, args)
|
||||
if err != nil {
|
||||
return failed(err, stderr)
|
||||
}
|
||||
if a.IsError {
|
||||
fmt.Fprintln(stderr, "mesh-cli: "+strings.TrimRight(a.Text, "\n"))
|
||||
return 1
|
||||
}
|
||||
text := a.Text
|
||||
if render != nil {
|
||||
text = render(text)
|
||||
}
|
||||
fmt.Fprintln(stdout, strings.TrimRight(text, "\n"))
|
||||
return 0
|
||||
}
|
||||
|
||||
// failed says why a call did not come back, and never as nothing having run unless nothing answered.
|
||||
func failed(err error, stderr io.Writer) int {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
fmt.Fprintln(stderr, "mesh-cli: stopped waiting; the call may still run")
|
||||
return 130
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
fmt.Fprintln(stderr, "mesh-cli: no answer in time; the call may have run. Check before repeating a change")
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stderr, "mesh-cli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// status is the delivery view's status. When the view does not answer, that is said first and loudly, and what the
|
||||
// condition keeper and mesh-delivery hold is shown in its place, each under its address, with exit 1: never shown as
|
||||
// the status.
|
||||
func status(ctx context.Context, stdout, stderr io.Writer) int {
|
||||
a, err := meshCall(ctx, "mesh-delivery-view.view_status", nil)
|
||||
if err == nil && !a.IsError {
|
||||
fmt.Fprintln(stdout, strings.TrimRight(a.Text, "\n"))
|
||||
return 0
|
||||
}
|
||||
if errors.Is(err, errNoConsole) || errors.Is(err, context.Canceled) {
|
||||
return failed(err, stderr)
|
||||
}
|
||||
why := a.Text
|
||||
if err != nil {
|
||||
why = err.Error()
|
||||
}
|
||||
fmt.Fprintf(stderr, "mesh-cli: the status (mesh-delivery-view.view_status) did not answer: %s\n"+
|
||||
"mesh-cli: what follows is not the status: the open conditions and the deliveries, read on their own\n",
|
||||
strings.TrimRight(why, "\n"))
|
||||
for _, address := range []string{"mesh-conditions.conditions", "mesh-delivery.deliveries"} {
|
||||
fmt.Fprintf(stdout, "== %s\n", address)
|
||||
show(ctx, "mesh_call", map[string]any{"address": address, "arguments": map[string]any{}}, nil, stdout, stderr)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// version is what this binary was built from.
|
||||
func version() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "mesh-cli (unknown build)"
|
||||
}
|
||||
v := "mesh-cli " + info.Main.Version
|
||||
for _, s := range info.Settings {
|
||||
if s.Key == "vcs.revision" {
|
||||
v += " " + s.Value
|
||||
}
|
||||
}
|
||||
if a.Refused != "" {
|
||||
fmt.Fprintln(stderr, "mesh-cli: "+a.Refused)
|
||||
if a.Exit == 0 {
|
||||
return 1
|
||||
return v
|
||||
}
|
||||
|
||||
// usage is every line nox takes, grouped by its first word.
|
||||
func usage() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`mesh-cli: the operator's command (nox in interactive zsh)
|
||||
|
||||
Each line calls the seat verb or module tool that owns the job, through this machine's console (the mesh MCP
|
||||
server on loopback). The controller that ran these lines before is unassigned (novox/hq issue 1126).
|
||||
|
||||
`)
|
||||
lines := make([]string, 0, len(commands))
|
||||
for _, c := range commands {
|
||||
if c.build == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" nox %s %s\n %s", strings.Join(c.words, " "), c.usage, c.does))
|
||||
}
|
||||
return a.Exit
|
||||
sort.Strings(lines)
|
||||
b.WriteString(strings.Join(lines, "\n"))
|
||||
b.WriteString(`
|
||||
nox search <words>
|
||||
find a seat verb or a module tool
|
||||
nox describe <address>
|
||||
one address's arguments
|
||||
nox enrol open --key <file>
|
||||
open an enrolment token sealed to the operator key, here; asks nothing of the mesh
|
||||
nox version
|
||||
|
||||
Every answer is the owner's own, as JSON. A line the controller ran that no seat owns says so and what to use.
|
||||
`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -3,153 +3,285 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/cli"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal unless a test
|
||||
// says otherwise.
|
||||
// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal.
|
||||
func init() { standardInput = func() (io.Reader, bool) { return nil, true } }
|
||||
|
||||
// lastStdin is the standard input the engine was last given.
|
||||
var lastStdin []byte
|
||||
// asked is one call the stand-in console took: the console's tool, and for mesh_call the address and its arguments.
|
||||
type asked struct {
|
||||
Tool string
|
||||
Address string
|
||||
Args string
|
||||
}
|
||||
|
||||
// engine answers one connection on a socket of the test's own as the node-engine would, and says what it was asked.
|
||||
func engine(t *testing.T, answer cli.Answer) <-chan []string {
|
||||
// console is a stand-in for this machine's console: it answers MCP tools/call over HTTP as node-tools does, with the
|
||||
// answer the test gives per address (an error when it starts with "!"), and records what it was asked.
|
||||
func console(t *testing.T, answers map[string]string) *[]asked {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "cli.sock")
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Skipf("no unix socket here: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
before := socket
|
||||
socket = path
|
||||
t.Cleanup(func() { socket = before })
|
||||
asked := make(chan []string, 1)
|
||||
lastStdin = nil
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
var got []asked
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/mcp" || r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
req, err := cli.ReadRequest(conn)
|
||||
if err != nil {
|
||||
return
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments struct {
|
||||
Address string `json:"address"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
Query string `json:"query"`
|
||||
} `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
lastStdin = req.Stdin
|
||||
asked <- req.Line
|
||||
_ = cli.WriteAnswer(conn, answer)
|
||||
}()
|
||||
return asked
|
||||
}
|
||||
|
||||
// The line is asked as given, what the controller printed is printed, and its exit is mesh-cli's.
|
||||
func TestTheLineIsAskedAndTheAnswerIsPrinted(t *testing.T) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Method != "tools/call" {
|
||||
t.Errorf("the console was asked %v %v", req.Method, err)
|
||||
}
|
||||
a := req.Params.Arguments
|
||||
args, _ := json.Marshal(a.Arguments)
|
||||
got = append(got, asked{req.Params.Name, a.Address, string(args)})
|
||||
key := a.Address
|
||||
if req.Params.Name != "mesh_call" {
|
||||
key = req.Params.Name
|
||||
}
|
||||
text, ok := answers[key]
|
||||
if !ok {
|
||||
text = `{"ok":true}`
|
||||
}
|
||||
isError := strings.HasPrefix(text, "!")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{
|
||||
"content": []any{map[string]any{"type": "text", "text": strings.TrimPrefix(text, "!")},
|
||||
map[string]any{"type": "text", "text": "answered by ace"}}, "isError": isError}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("MESH_CONSOLE", srv.URL+"/mcp")
|
||||
t.Setenv("MESH_VERB", "")
|
||||
asked := engine(t, cli.Answer{Stdout: []byte("out\n"), Stderr: []byte("err\n"), Exit: 3, Terminal: true, Why: "the terminal"})
|
||||
return &got
|
||||
}
|
||||
|
||||
func runLine(args ...string) (int, string, string) {
|
||||
var out, errOut bytes.Buffer
|
||||
code := run(context.Background(), []string{"settings", "set", "zsh", `{"execute":"withhold"}`}, &out, &errOut)
|
||||
if line := <-asked; strings.Join(line, "|") != `settings|set|zsh|{"execute":"withhold"}` {
|
||||
t.Fatalf("asked %q", line)
|
||||
}
|
||||
if code != 3 || out.String() != "out\n" || errOut.String() != "err\n" {
|
||||
t.Fatalf("exit %d, stdout %q, stderr %q", code, out.String(), errOut.String())
|
||||
code := run(context.Background(), args, &out, &errOut)
|
||||
return code, out.String(), errOut.String()
|
||||
}
|
||||
|
||||
// Every owned line calls the seat verb or module tool that owns its job, with the arguments the owner reads
|
||||
// (novox/hq issue 1126; hq .work/1126/nox-map.md), and prints its answer.
|
||||
func TestEachLineCallsItsOwner(t *testing.T) {
|
||||
now = func() time.Time { return time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC) }
|
||||
t.Cleanup(func() { now = time.Now })
|
||||
for _, c := range []struct {
|
||||
line string
|
||||
address string
|
||||
args string
|
||||
}{
|
||||
{"node show ace", "mesh-placement.placement", `{"node":"ace"}`},
|
||||
{"node public-domain ace", "mesh-placement.placement", `{"node":"ace"}`},
|
||||
{"node public-domain ace example.org --why moved", "mesh-placement.propose", `{"changes":[{"field":"public-domain","node":"ace","op":"decide","value":"example.org"}],"why":"moved"}`},
|
||||
{"plan ace", "mesh-delivery-view.assigner_wants", `{"node":"ace"}`},
|
||||
{"haves", "mesh-delivery-view.node_haves", `{}`},
|
||||
{"composition ace plex", "ace/node-engine.composition", `{"module":"plex"}`},
|
||||
{"seats", "mesh-placement.placement", `{"seats":"true"}`},
|
||||
{"assign ace plex sonarr --why=media", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"place"},{"module":"sonarr","node":"ace","op":"place"}],"why":"media"}`},
|
||||
{"unassign ace plex --why gone", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"take-off"}],"why":"gone"}`},
|
||||
{"pin ace postgres-database novox postgres --why one", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"pin","provider":{"module":"postgres","node":"novox"},"provision":"postgres-database"}],"why":"one"}`},
|
||||
{"unpin ace postgres-database --why back", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"unpin","provision":"postgres-database"}],"why":"back"}`},
|
||||
{"deliveries --state merged --json", "mesh-delivery.deliveries", `{"state":"merged"}`},
|
||||
{"delivery novox/mesh-sdk@dff0ce2abb21", "mesh-delivery.show", `{"id":"novox/mesh-sdk@dff0ce2abb21"}`},
|
||||
{"checks novox/mesh-catalog --number 591", "mesh-delivery.checks", `{"number":"591","repository":"novox/mesh-catalog"}`},
|
||||
{"try novox/mesh-catalog abc123 --level unit", "mesh-delivery-checker.try", `{"head":"abc123","level":"unit","repository":"novox/mesh-catalog"}`},
|
||||
{"builds", "mesh-delivery-builder.builder_builds_since", `{"since":"2026-11-01T10:00:00Z"}`},
|
||||
{"upgrade nats --policy record --why bus", "mesh-wants.upgrade", `{"module":"nats","policy":"record","why":"bus"}`},
|
||||
{"bus", "mesh-wants.bus", `{}`},
|
||||
{"conditions --severity urgent", "mesh-conditions.conditions", `{"severity":"urgent"}`},
|
||||
{"conditions show machine.g14.root-not-free", "mesh-conditions.conditions", `{"key":"machine.g14.root-not-free"}`},
|
||||
{"conditions silence k1 --for 4h --why known", "mesh-conditions.silence", `{"for":"4h","key":"k1","why":"known"}`},
|
||||
{"conditions history --days 3", "mesh-conditions.conditions", `{"days":"3","history":"true"}`},
|
||||
{"conditions history --key k1", "mesh-conditions.conditions_history", `{"key":"k1"}`},
|
||||
{"doctor", "mesh-self-check.doctor", `{}`},
|
||||
{"doctor run", "mesh-self-check.doctor", `{"run":true}`},
|
||||
{"doctor probe D6", "mesh-self-check.doctor", `{"probe":"D6"}`},
|
||||
{"healers --days 2", "mesh-healers.healers", `{"days":2}`},
|
||||
{"hand-act record restarted the view --why stuck --cause view", "mesh-hand-acts.hand-act", `{"cause":"view","what":"restarted the view","why":"stuck"}`},
|
||||
{"hand-acts --days 3", "mesh-hand-acts.hand-acts", `{"days":3}`},
|
||||
{"settings show zsh --node g14", "mesh-config.config", `{"action":"list","module":"zsh","node":"g14"}`},
|
||||
{"settings set zsh execute withhold --node g14 --why test", "mesh-config.config", `{"action":"set","key":"execute","module":"zsh","node":"g14","value":"withhold","why":"test"}`},
|
||||
{"settings clear zsh execute --node g14 --why test", "mesh-config.config", `{"action":"clear","key":"execute","module":"zsh","node":"g14","why":"test"}`},
|
||||
{"config history --key k --limit 5", "mesh-config.config", `{"action":"history","key":"k","limit":5}`},
|
||||
{"secret export", "mesh-vault.secret_vault_export", `{}`},
|
||||
{"operator key show", "mesh-vault.vault_status", `{}`},
|
||||
{"rotate --node ace --module plex --name token --why leak", "mesh-vault.rotate", `{"module":"plex","name":"token","node":"ace","why":"leak"}`},
|
||||
{"retire approve c1 c2 --why done", "mesh-vault.provisioner_retire_approve", `{"consumers":["c1","c2"],"why":"done"}`},
|
||||
{"cleanup delete ace.plex --why done --confirm", "mesh-vault.provisioner_delete", `{"confirm":"ace.plex","consumer":"ace.plex","why":"done"}`},
|
||||
{"cleanup delete ace.plex --why dry", "mesh-vault.provisioner_delete", `{"consumer":"ace.plex","why":"dry"}`},
|
||||
{"collect --confirm --why space", "mesh-artifact-sweep.collect", `{"confirm":"true","why":"space"}`},
|
||||
{"images ace", "mesh-artifact-sweep.images", `{"node":"ace"}`},
|
||||
{"licence use g14 work", "anthropic-licence-manager.switch", `{"consumer":"g14","licence":"work"}`},
|
||||
{"module show plex", "mesh-delivery-view.indexer_module", `{"module":"plex"}`},
|
||||
{"issues apostrophe bug --status open --limit 5", "issue-tracker.list", `{"limit":5,"search":"apostrophe bug","status":"open"}`},
|
||||
{"issue 1126 --brief", "issue-tracker.get", `{"brief":true,"number":1126}`},
|
||||
{"kb ask what holds about the merge window", "knowledge-base.ask", `{"question":"what holds about the merge window"}`},
|
||||
{"kb get 587 --reasoning", "knowledge-base.get", `{"number":587,"reasoning":true}`},
|
||||
{"progress 47", "knowledge-base.progress", `{"of":"design 47"}`},
|
||||
{"enrol issue new1 --why laptop --expires 2h", "mesh-enrolment.issue", `{"expires":"2h","node":"new1","why":"laptop"}`},
|
||||
{"token issue --new new1 --why laptop", "mesh-enrolment.issue", `{"node":"new1","why":"laptop"}`},
|
||||
{"enrol tokens", "mesh-enrolment.tokens", `{}`},
|
||||
{"call ace/node-service-manager.status {\"unit\":\"plex\"}", "ace/node-service-manager.status", `{"unit":"plex"}`},
|
||||
{"ask mesh-catalog catalog_modules", "mesh-catalog.catalog_modules", `{}`},
|
||||
} {
|
||||
got := console(t, nil)
|
||||
args := strings.Fields(c.line)
|
||||
if strings.HasPrefix(c.line, "call ") {
|
||||
args = []string{"call", "ace/node-service-manager.status", `{"unit":"plex"}`}
|
||||
}
|
||||
code, out, errOut := runLine(args...)
|
||||
if code != 0 || len(*got) != 1 || (*got)[0].Tool != "mesh_call" || (*got)[0].Address != c.address || (*got)[0].Args != c.args {
|
||||
t.Errorf("nox %s: exit %d, asked %+v, stderr %q; want %s %s", c.line, code, *got, errOut, c.address, c.args)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(out, `"ok":true`) {
|
||||
t.Errorf("nox %s printed %q, not the owner's answer", c.line, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not the terminal, refused, or cut: each is said, and a refusal never exits 0.
|
||||
func TestWhyARefusalAndACutAreSaid(t *testing.T) {
|
||||
t.Setenv("MESH_VERB", "")
|
||||
engine(t, cli.Answer{Why: "not the controller's terminal: agents on laptop may run as operator",
|
||||
Refused: "set at the controller's terminal only", Cut: true})
|
||||
var out, errOut bytes.Buffer
|
||||
code := run(context.Background(), []string{"settings", "set", "x", "{}"}, &out, &errOut)
|
||||
said := errOut.String()
|
||||
if code == 0 || !strings.Contains(said, "not the controller's terminal") || !strings.Contains(said, "terminal only") ||
|
||||
!strings.Contains(said, "cut") {
|
||||
t.Fatalf("exit %d, said %q", code, said)
|
||||
// A line the controller ran that no seat owns says so and what to use, exits 1 and calls nothing; a line nox cannot
|
||||
// read says how it is written, exits 2 and calls nothing.
|
||||
func TestALineWithNoOwnerOrNotReadCallsNothingAndSaysWhy(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
line string
|
||||
code int
|
||||
err string
|
||||
}{
|
||||
{"push ace", 1, "mesh-delivery-assigner.assigner_put"},
|
||||
{"migrate", 1, "no seat or module does this now"},
|
||||
{"secret accept ace plex token", 1, "mesh-vault.secret-ask"},
|
||||
{"node add new1", 1, "nox enrol issue"},
|
||||
{"pause", 1, "node-build-go.pause"},
|
||||
{"operator key make", 1, "operator key show"},
|
||||
{"assign ace plex", 2, "needs --why"},
|
||||
{"issue twelve", 2, "not an issue's number"},
|
||||
{"conditions --bogus x", 2, "--bogus is not a flag"},
|
||||
{"settings set zsh {\"execute\":\"withhold\"} --node g14 --why x", 2, "set each key"},
|
||||
{"healers --days soon", 2, "takes a number"},
|
||||
{"frobnicate", 2, "is not a command"},
|
||||
} {
|
||||
got := console(t, nil)
|
||||
args := strings.Fields(c.line)
|
||||
code, _, errOut := runLine(args...)
|
||||
if code != c.code || len(*got) != 0 || !strings.Contains(errOut, c.err) {
|
||||
t.Errorf("nox %s: exit %d, asked %+v, stderr %q; want exit %d saying %q", c.line, code, *got, errOut, c.code, c.err)
|
||||
}
|
||||
if c.code == 1 && !strings.Contains(errOut, "issue 1126") {
|
||||
t.Errorf("nox %s does not say the controller is unassigned: %q", c.line, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A verb's own process is never the operator: refused before anything is asked.
|
||||
func TestAVerbsProcessIsRefusedBeforeAsking(t *testing.T) {
|
||||
t.Setenv("MESH_VERB", "command")
|
||||
before := socket
|
||||
socket = filepath.Join(t.TempDir(), "nothing-listens.sock")
|
||||
t.Cleanup(func() { socket = before })
|
||||
var out, errOut bytes.Buffer
|
||||
if code := run(context.Background(), []string{"status"}, &out, &errOut); code != 1 ||
|
||||
!strings.Contains(errOut.String(), "never the operator") {
|
||||
t.Fatalf("exit %d, said %q", code, errOut.String())
|
||||
// What the console says is printed as said: an answer it marks an error on standard error with exit 1, a console that
|
||||
// does not answer as nothing called, a verb's process refused before anything is called; nodes are one line each.
|
||||
func TestAnswersRefusalsAndSilenceAreSaid(t *testing.T) {
|
||||
got := console(t, map[string]string{"mesh-enrolment.tokens": "!nothing serves seat:mesh-enrolment.tokens"})
|
||||
if code, out, errOut := runLine("enrol", "tokens"); code != 1 || out != "" || !strings.Contains(errOut, "nothing serves") {
|
||||
t.Errorf("an error answer: exit %d, stdout %q, stderr %q", code, out, errOut)
|
||||
}
|
||||
placement := `{"keys":{"novox":{"placement":{"decided":{"address":"10.10.0.1","site":"hub"},"modules":[{},{}]}},` +
|
||||
`"ace":{"placement":{"decided":{"address":"10.10.0.2"},"modules":[{}]}}}}`
|
||||
console(t, map[string]string{"mesh-placement.placement": placement})
|
||||
if code, out, _ := runLine("nodes"); code != 0 || !strings.HasPrefix(out, "ace ") ||
|
||||
!strings.Contains(out, "10.10.0.1") || !strings.Contains(out, "2 modules") || !strings.HasSuffix(out, "answered by ace\n") {
|
||||
t.Errorf("nodes: exit %d, %q", code, out)
|
||||
}
|
||||
got = console(t, map[string]string{"mesh_search": `{"matches":[]}`})
|
||||
if code, out, _ := runLine("search", "restart", "a", "unit"); code != 0 || (*got)[0].Tool != "mesh_search" || !strings.Contains(out, "matches") {
|
||||
t.Errorf("search: exit %d, asked %+v, %q", code, *got, out)
|
||||
}
|
||||
t.Setenv("MESH_CONSOLE", "http://127.0.0.1:1/mcp")
|
||||
if code, _, errOut := runLine("conditions"); code != 1 || !strings.Contains(errOut, "nothing was called") {
|
||||
t.Errorf("no console: exit %d, %q", code, errOut)
|
||||
}
|
||||
t.Setenv("MESH_VERB", "some.verb")
|
||||
if code, _, errOut := runLine("conditions"); code != 1 || !strings.Contains(errOut, "a verb is never the operator") {
|
||||
t.Errorf("a verb's process: exit %d, %q", code, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// An engine that does not answer is said, with where it was asked.
|
||||
func TestAnEngineThatDoesNotAnswerIsSaid(t *testing.T) {
|
||||
t.Setenv("MESH_VERB", "")
|
||||
before := socket
|
||||
socket = filepath.Join(t.TempDir(), "nothing-listens.sock")
|
||||
t.Cleanup(func() { socket = before })
|
||||
var out, errOut bytes.Buffer
|
||||
if code := run(context.Background(), []string{"status"}, &out, &errOut); code != 1 ||
|
||||
!strings.Contains(errOut.String(), "does not answer") {
|
||||
t.Fatalf("exit %d, said %q", code, errOut.String())
|
||||
// The status is the delivery view's. When the view does not answer, that is said first, the conditions and the
|
||||
// deliveries are shown under their own addresses, and the exit is 1: they are never passed off as the status.
|
||||
func TestStatusIsTheViewsAndItsSilenceIsSaid(t *testing.T) {
|
||||
got := console(t, map[string]string{"mesh-delivery-view.view_status": `{"well":true}`})
|
||||
if code, out, _ := runLine("status"); code != 0 || len(*got) != 1 || !strings.Contains(out, `"well":true`) {
|
||||
t.Errorf("status: exit %d, asked %+v, %q", code, *got, out)
|
||||
}
|
||||
got = console(t, map[string]string{"mesh-delivery-view.view_status": "!mesh-delivery-view.view_status did not answer within 30s",
|
||||
"mesh-conditions.conditions": `{"conditions":[]}`, "mesh-delivery.deliveries": `{"deliveries":[]}`})
|
||||
code, out, errOut := runLine("status")
|
||||
if code != 1 || len(*got) != 3 || !strings.Contains(errOut, "did not answer") || !strings.Contains(errOut, "not the status") ||
|
||||
!strings.Contains(out, "== mesh-conditions.conditions") || !strings.Contains(out, "== mesh-delivery.deliveries") {
|
||||
t.Errorf("status, the view silent: exit %d, asked %+v, stdout %q, stderr %q", code, *got, out, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// An outcome not known is said as such — the line may have run — and never as nothing having run.
|
||||
func TestAnOutcomeNotKnownIsSaid(t *testing.T) {
|
||||
t.Setenv("MESH_VERB", "")
|
||||
engine(t, cli.NotKnown("call-4", "no answer within 6m0s"))
|
||||
var out, errOut bytes.Buffer
|
||||
code := run(context.Background(), []string{"push", "laptop"}, &out, &errOut)
|
||||
if code == 0 || !strings.Contains(errOut.String(), "may have run") || !strings.Contains(errOut.String(), "call-4") {
|
||||
t.Fatalf("exit %d, said %q", code, errOut.String())
|
||||
// The console's address comes from the file the module writes from its mcp-endpoint; MESH_CONSOLE overrides it; with
|
||||
// neither, the port node-tools declares. An answer given as a server-sent event is read like one given as JSON.
|
||||
func TestTheConsoleIsFoundAndItsAnswerRead(t *testing.T) {
|
||||
t.Setenv("MESH_CONSOLE", "")
|
||||
dir := t.TempDir()
|
||||
before := consoleFile
|
||||
consoleFile = filepath.Join(dir, "console")
|
||||
t.Cleanup(func() { consoleFile = before })
|
||||
if url, _ := consoleAddress(); url != defaultConsole {
|
||||
t.Errorf("no file: %s", url)
|
||||
}
|
||||
if err := os.WriteFile(consoleFile, []byte("http://127.0.0.1:4999/mcp\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if url, from := consoleAddress(); url != "http://127.0.0.1:4999/mcp" || from != consoleFile {
|
||||
t.Errorf("the file: %s from %s", url, from)
|
||||
}
|
||||
t.Setenv("MESH_CONSOLE", "http://127.0.0.1:5000/mcp")
|
||||
if url, _ := consoleAddress(); url != "http://127.0.0.1:5000/mcp" {
|
||||
t.Errorf("the variable: %s", url)
|
||||
}
|
||||
a, err := decodeAnswer([]byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}],\"isError\":true}}\n\n"))
|
||||
if err != nil || a.Text != "hi" || !a.IsError {
|
||||
t.Errorf("an event: %+v %v", a, err)
|
||||
}
|
||||
if _, err := decodeAnswer([]byte("<html>")); err == nil {
|
||||
t.Error("a page that is no answer was read as one")
|
||||
}
|
||||
}
|
||||
|
||||
// A pipe's or a file's standard input goes with the line, whole; a terminal's is never read; more than the bound is
|
||||
// refused and nothing is asked (novox/hq ADR 0259 §10).
|
||||
func TestStandardInputGoesWithTheLineAndATerminalIsNeverRead(t *testing.T) {
|
||||
t.Setenv("MESH_VERB", "")
|
||||
was := standardInput
|
||||
t.Cleanup(func() { standardInput = was })
|
||||
secret := "123456789:the-bot-token\n"
|
||||
|
||||
standardInput = func() (io.Reader, bool) { return strings.NewReader(secret), false }
|
||||
asked := engine(t, cli.Answer{Terminal: true})
|
||||
var out, errOut bytes.Buffer
|
||||
if code := run(context.Background(), []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
|
||||
&out, &errOut); code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, errOut.String())
|
||||
}
|
||||
<-asked
|
||||
if string(lastStdin) != secret {
|
||||
t.Fatalf("the engine was given %q", lastStdin)
|
||||
}
|
||||
if strings.Contains(out.String()+errOut.String(), "bot-token") {
|
||||
t.Fatal("mesh-cli printed what it carried")
|
||||
}
|
||||
|
||||
standardInput = func() (io.Reader, bool) { return strings.NewReader("typed"), true }
|
||||
asked = engine(t, cli.Answer{})
|
||||
_ = run(context.Background(), []string{"status"}, &out, &errOut)
|
||||
<-asked
|
||||
if len(lastStdin) != 0 {
|
||||
t.Fatalf("a terminal's input was read: %q", lastStdin)
|
||||
}
|
||||
|
||||
standardInput = func() (io.Reader, bool) { return bytes.NewReader(make([]byte, cli.MaxStdin+1)), false }
|
||||
errOut.Reset()
|
||||
if code := run(context.Background(), []string{"secret", "accept", "a", "b", "c", "--from", "-"}, &out, &errOut); code == 0 ||
|
||||
!strings.Contains(errOut.String(), "nothing was asked") {
|
||||
t.Fatalf("more than the bound was asked: %d %s", code, errOut.String())
|
||||
// Every command's line is in the help, and none is defined twice.
|
||||
func TestEveryLineIsInTheHelpOnce(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
help := usage()
|
||||
for _, c := range commands {
|
||||
w := strings.Join(c.words, " ")
|
||||
if seen[w] {
|
||||
t.Errorf("nox %s is defined twice", w)
|
||||
}
|
||||
seen[w] = true
|
||||
if c.build != nil && !strings.Contains(help, "nox "+w+" ") {
|
||||
t.Errorf("nox %s is not in the help", w)
|
||||
}
|
||||
if c.build == nil && c.instead == "" {
|
||||
t.Errorf("nox %s has neither an owner nor an instead", w)
|
||||
}
|
||||
if _, dup := unowned[c.words[0]]; dup && len(c.words) == 1 {
|
||||
t.Errorf("nox %s is both owned and unowned", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **The archive places exactly one file, /usr/local/bin/mesh-cli** (review of novox/hq ADR 0272): one archive
|
||||
// **The archive places exactly one file, /usr/local/bin/mesh-cli**, and the console's address is one file more (review of novox/hq ADR 0272): one archive
|
||||
// resource, unpacking one artifact into /usr/local/bin, and that artifact a compiled Go bundle — the builder writes
|
||||
// such a bundle as its binary alone (`go build -o <out>/<binary>`), named mesh-cli — with nothing the runtime would
|
||||
// load or put beside it. A second file in the bundle, or a second resource writing there, fails here.
|
||||
func TestTheArchivePlacesExactlyOneFile(t *testing.T) {
|
||||
func TestTheArchivePlacesExactlyOneFileAndTheConsolesAddressOne(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -24,12 +24,24 @@ func TestTheArchivePlacesExactlyOneFile(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Resources) != 1 {
|
||||
t.Fatalf("the module declares %d resources; it places one file", len(m.Resources))
|
||||
// Beside the program, one directory and one file: the console's address, from the mcp-endpoint it requires
|
||||
// (novox/hq issue 1126). Nothing else.
|
||||
var r map[string]any
|
||||
for _, res := range m.Resources {
|
||||
switch {
|
||||
case res["type"] == "archive":
|
||||
if r != nil {
|
||||
t.Fatalf("two archives: %v", res)
|
||||
}
|
||||
r = res
|
||||
case res["type"] == "directory" && res["path"] == "/etc/mesh-cli":
|
||||
case res["type"] == "file" && res["path"] == consoleFile && res["template"] == "files/console":
|
||||
default:
|
||||
t.Fatalf("the module declares %v beside the program and the console's address", res)
|
||||
}
|
||||
}
|
||||
r := m.Resources[0]
|
||||
if r["type"] != "archive" || r["path"] != "/usr/local/bin" {
|
||||
t.Fatalf("its resource is %v", r)
|
||||
if len(m.Resources) != 3 || r == nil || r["path"] != "/usr/local/bin" {
|
||||
t.Fatalf("its resources are %v", m.Resources)
|
||||
}
|
||||
// The build has a second artifact, the files archive the controller reads the templates back from; it
|
||||
// places no file. The artifact the resource unpacks is the one that does.
|
||||
@@ -66,3 +78,22 @@ func TestTheArchivePlacesExactlyOneFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The console's address is written from the mcp-endpoint the module requires, as claude-code writes it, so it follows
|
||||
// the port node-tools is bound on.
|
||||
func TestTheConsolesAddressIsBoundFromTheEndpoint(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Requires []string `json:"requires"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil || len(m.Requires) != 1 || m.Requires[0] != "mcp-endpoint" {
|
||||
t.Fatalf("it requires %v (%v)", m.Requires, err)
|
||||
}
|
||||
tpl, err := os.ReadFile("../../files/console")
|
||||
if err != nil || string(tpl) != "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\n" {
|
||||
t.Fatalf("files/console is %q (%v)", tpl, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
http://127.0.0.1:${bound:mcp-endpoint:port}/mcp
|
||||
@@ -2,14 +2,6 @@ module mesh-cli
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21
|
||||
|
||||
require golang.org/x/crypto v0.57.0
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
)
|
||||
require golang.org/x/sys v0.48.0 // indirect
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 h1:4I4ayo0fZYP4PBfEX1sGDDd6arnw5IQJySJ3jcFobPM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"module": "mesh-cli",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"requires": [
|
||||
"mcp-endpoint"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "program",
|
||||
@@ -9,6 +12,20 @@
|
||||
"artifact": "program",
|
||||
"path": "/usr/local/bin",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "console-dir",
|
||||
"type": "directory",
|
||||
"path": "/etc/mesh-cli",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "console",
|
||||
"type": "file",
|
||||
"path": "/etc/mesh-cli/console",
|
||||
"mode": "0644",
|
||||
"template": "files/console"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
|
||||
Reference in New Issue
Block a user