nox calls the seats that own each job, not the unassigned controller
The controller nox forwarded every line to is unassigned (issue 1126), so nox answered nothing but enrol open. Each line now calls the seat verb or module tool that owns its job through this machine's console (mesh_call); a line no seat owns says so and names what to use, never failing silently. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -0,0 +1,714 @@
|
||||
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"
|
||||
"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", "confirm")
|
||||
a["consumer"] = 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"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &p); err != nil || len(p.Keys) == 0 {
|
||||
return text
|
||||
}
|
||||
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))
|
||||
}
|
||||
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,282 @@ 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, "!")}}, "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"}`},
|
||||
{"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") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user