The controller asks no build of anybody (ADR 0501 §5, ADR 0525 clause 6, ADR 0503 §3). Every module of a walk is taken from the builder's done key; a key not done yet waits, named in the plan, and never fails it. - C2b/C2c: internal/builder and cmd/mesh-controller's asks go (askABuild, onlyTheBuildersOwn, buildOneAsked, takeIn from an outcome, buildAgentsOwn, the builder's say and its fallback, the daemon hearing an outcome). The server follows the retired seats' built subjects only to let go of them, because the installer's carried user list names them. - C2d: replay, queue, cancel, clear, kill, pause, resume, delivery-check, check-here and merge-gate are refused by name. - C3: the check queues (CHECKS_READY, CHECKS_OTHER) and their workers, the checks-running and plans-building buckets and their grants, the build queue and its hold (plans_building.go, queue.go, link/holds.go, builds_nats.go, checks_nats.go). - C4: merge_gate*.go, the pull request's repo-check path, the Checked event and its layers; a pull request asks the controller nothing but to tell mesh-config what its head declares. - C5: the builds bucket is written only by key_takein. - C10 (help and refusals): the terminal's help lists only what is served. Integration tests of the plan's tiers, sends, stops and retries now run on a stand-in builder with a done key for every module (walk_keys_scaffold_integration_test.go); the tests of the asks, the queue, the merge gate and the replays are deleted.
325 lines
16 KiB
Go
325 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// `builds` given a build's id no longer reads a log from the bus: the build seat is retired (ADR 0525 clause 7).
|
|
func TestBuildsWithAnIdIsRefusedForTheRetiredSeat(t *testing.T) {
|
|
if _, err := argvFor("builds", map[string]any{"log": "build-17"}); err == nil ||
|
|
!strings.Contains(err.Error(), "build seat is retired") {
|
|
t.Fatalf("builds with a log id: %v", err)
|
|
}
|
|
}
|
|
|
|
// `rotate` is one verb with two shapes (ADR 0114, issue 180): a pair credential by provision, or a
|
|
// module's own secret by machine, module and name.
|
|
func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
|
argv, _ := argvFor("rotate", map[string]any{"provision": "postgres-database", "consumer": "ace"})
|
|
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace" {
|
|
t.Fatalf("a pair credential: %v", argv)
|
|
}
|
|
argv, _ = argvFor("rotate", map[string]any{"provision": "postgres-database", "consumer": "ace", "module": "letta"})
|
|
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace --module letta" {
|
|
t.Fatalf("one consuming module's pair credential: %v", argv)
|
|
}
|
|
argv, _ = argvFor("rotate", map[string]any{"node": "ace", "module": "nodered", "secret": "api-token"})
|
|
if strings.Join(argv, " ") != "secret rotate ace nodered api-token" {
|
|
t.Fatalf("an own secret: %v", argv)
|
|
}
|
|
if _, err := argvFor("rotate", map[string]any{"node": "ace"}); err == nil || !strings.Contains(err.Error(), `"module"`) {
|
|
t.Fatalf("half an own secret is refused, naming what it lacks: %v", err)
|
|
}
|
|
if argv, _ := argvFor("rotate", nil); strings.Join(argv, " ") != "rotate" {
|
|
t.Fatalf("neither shape falls to the command's usage: %v", argv)
|
|
}
|
|
if _, err := argvFor("rotate", map[string]any{"provision": "p", "node": "ace", "module": "m", "secret": "s"}); err == nil {
|
|
t.Fatal("both shapes at once were taken, and one of them passed over")
|
|
}
|
|
}
|
|
|
|
// `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the
|
|
// controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given.
|
|
func TestTokenIsRefusedThroughAVerb(t *testing.T) {
|
|
for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} {
|
|
argv, err := argvFor("token", args)
|
|
if err == nil || !strings.Contains(err.Error(), "controller's terminal only") {
|
|
t.Fatalf("token %v: %v %v", args, argv, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
|
|
func TestSettingsSetsOrClearsALayer(t *testing.T) {
|
|
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
|
|
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --node ace` {
|
|
t.Fatalf("set on a machine: %v %v", argv, err)
|
|
}
|
|
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq", "clear": "true"})
|
|
if strings.Join(argv, " ") != "settings clear dnsmasq" {
|
|
t.Fatalf("clear for the mesh: %v", argv)
|
|
}
|
|
// Neither values nor clear reads the layer as it stands (novox/hq ADR 0217): what a caller reads
|
|
// before replacing it, where it used to fall to the command's usage.
|
|
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq"})
|
|
if strings.Join(argv, " ") != "settings show dnsmasq" {
|
|
t.Fatalf("a call with no values reads the layer: %v", argv)
|
|
}
|
|
}
|
|
|
|
// `issue` is `module issue` at a shell: the module and the machine, and nothing that would push. A
|
|
// module's bus account was mintable only from the controller's command line, so an agent working
|
|
// through the tools could not finish a rollout that gave a module one (novox/hq issue 191).
|
|
func TestIssueGivesAModuleItsAccountOnAMachine(t *testing.T) {
|
|
argv, err := argvFor("issue", map[string]any{"node": "ace", "module": "route-proxy"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(argv, " ") != "module issue route-proxy --node ace" {
|
|
t.Fatalf("issue runs %v", argv)
|
|
}
|
|
if _, err := argvFor("issue", map[string]any{"module": "route-proxy"}); err == nil {
|
|
t.Error("an account was issued without saying which machine reads it")
|
|
}
|
|
}
|
|
|
|
// A required argument missing is refused in the verb's own words, before anything runs.
|
|
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
|
|
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
|
|
t.Fatalf("node without a machine was accepted: %v", err)
|
|
}
|
|
if _, err := argvFor("no-such-verb", map[string]any{}); err == nil {
|
|
t.Fatal("a verb the seat does not serve was accepted")
|
|
}
|
|
}
|
|
|
|
// Acts do not block the call. A build is sent, not waited for: the asker reads the build's log by its id.
|
|
// A push answers its caller before it starts (answersFirst), so a named push waits for the machine's own
|
|
// answer without holding anybody, and the call's record says how it went (novox/hq issue 629). A
|
|
// repository given as a forge path is said to be one (issue 176).
|
|
func TestActsDoNotBlockTheCall(t *testing.T) {
|
|
argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"})
|
|
if strings.Join(argv, " ") != "push one --wait 0s --why w" || !answersFirst(argv) {
|
|
t.Fatalf("a named push with no setting waits, or holds its caller: %v", argv)
|
|
}
|
|
}
|
|
|
|
// A11a (ADR 0501 §5; issue 1851): the build, rebuild and replay --register verbs reach a command that refuses them by
|
|
// name, naming the replacement, whatever a table of before asked of them; the command lines refuse the same.
|
|
func TestTheRemovedVerbsAreRefusedByName(t *testing.T) {
|
|
for _, c := range []struct {
|
|
verb string
|
|
args map[string]any
|
|
said string
|
|
}{
|
|
{"build", map[string]any{"repository": "novox/x", "path": "modules/x"}, "`build` is removed"},
|
|
{"build", map[string]any{}, "`build` is removed"},
|
|
{"rebuild", map[string]any{"what": "gitea"}, "`rebuild` is removed"},
|
|
{"replay", map[string]any{"id": "build-1"}, "`replay` is removed"},
|
|
{"queue", map[string]any{}, "`queue` is removed"},
|
|
{"cancel", map[string]any{"id": "build-1"}, "`cancel` is removed"},
|
|
{"clear", map[string]any{"dead": "true"}, "`clear` is removed"},
|
|
{"kill", map[string]any{"id": "build-1"}, "`kill` is removed"},
|
|
{"pause", map[string]any{"node": "ace"}, "`pause` is removed"},
|
|
{"resume", map[string]any{}, "`resume` is removed"},
|
|
// 1318: delivery-plan and forget-checks go too.
|
|
{"delivery-plan", map[string]any{"repository": "novox/a", "paths": "x"}, "`delivery-plan` is removed"},
|
|
{"forget-checks", map[string]any{"owner": "novox", "repo": "a", "number": "7"}, "`forget-checks` is removed"},
|
|
} {
|
|
argv, err := argvFor(c.verb, c.args)
|
|
if err == nil || !strings.Contains(err.Error(), c.said) {
|
|
t.Errorf("%s %v: %v %v, want refused saying %q", c.verb, c.args, argv, err, c.said)
|
|
}
|
|
}
|
|
for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery-plan", "queue", "replay"} {
|
|
err := removedByName(removed)
|
|
if err == nil || !strings.Contains(err.Error(), "`"+removed+"` is removed") || !strings.Contains(err.Error(), "ADR 05") {
|
|
t.Errorf("%s: %v", removed, err)
|
|
}
|
|
}
|
|
if err := deliveryCommand(context.Background(), []string{"plan", "--repository", "novox/a"}); err == nil ||
|
|
!strings.Contains(err.Error(), "`delivery-plan` is removed") {
|
|
t.Errorf("delivery plan: %v", err)
|
|
}
|
|
}
|
|
|
|
// What `tools` answers is the seats' records, with each verb's schema.
|
|
|
|
// A JSON verb's answer is parsed from what the command wrote to standard output alone; a warning it
|
|
// printed beside the document does not take the document away. The test binary stands in for the
|
|
// controller: `-test.run` with a name that matches nothing prints `ok` and a warning about no tests.
|
|
|
|
// `command` is the generic verb: the command line as given, split as a shell would, nothing added
|
|
// (novox/hq ADR 0154, ADR 0175). It once carried an operator's `node account g14 jochen` too; a node's
|
|
// accounts are the controller's terminal's alone since ADR 0266 (TestNoVerbSetsANodesAccounts).
|
|
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
|
argv, err := argvFor("command", map[string]any{"command": "node show g14"})
|
|
if err != nil || strings.Join(argv, " ") != "node show g14" {
|
|
t.Fatalf("a plain line: %v %v", argv, err)
|
|
}
|
|
argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`})
|
|
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
|
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
|
}
|
|
argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`})
|
|
if err != nil || len(argv) != 3 || argv[1] != "the box" {
|
|
t.Fatalf("double quotes group: %q %v", argv, err)
|
|
}
|
|
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
|
t.Fatal("an empty line was accepted")
|
|
}
|
|
if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil {
|
|
t.Fatal("an unclosed quote was accepted")
|
|
}
|
|
}
|
|
|
|
// A verb in the row that this binary cannot run does not take the control plane off the bus: the
|
|
// rest are served, the unknown one answers the reason, and the start-up names it (novox/hq ADR
|
|
// 0185). One unknown word cost the mesh ten minutes of silence on 2026-10-02, recoverable only by
|
|
// a person running the binary by hand — the push that would have repaired it needs the control
|
|
// plane that was down.
|
|
|
|
// novox/hq ADR 0195: the console's discovery reads the machines and the modules; they answer as JSON,
|
|
// as status and seats do, so nothing parses a printed column.
|
|
func TestTheNodesAndModulesVerbsAnswerAsJSON(t *testing.T) {
|
|
for verb, want := range map[string]string{"nodes": "[node list --json]", "modules": "[module list --json]"} {
|
|
argv, err := argvFor(verb, map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(argv) != want {
|
|
t.Errorf("%s runs %v, want %s", verb, argv, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0197: the controller announces exactly the verbs it serves, each on the subject and
|
|
// queue it serves it on, with the seat's own description and schema, in NATS's services format.
|
|
|
|
// The generic verb only reads (novox/hq ADR 0266): an allow list of reading forms, and every line that
|
|
// writes, issues, sets a key or reveals a secret refused — the chain a review found ran through it: set the
|
|
// operator's key to one the caller holds, rotate secrets sealed to it, open them.
|
|
func TestTheCommandVerbOnlyReads(t *testing.T) {
|
|
for _, line := range []string{
|
|
"operator key set --replace k", "operator issue", "secret accept a b", "secret rotate a b c",
|
|
"secret recover a", "secret export a", "token issue --new x", "identity show", "broker users",
|
|
"api key", "licence show", "push anchor --why w", "assign novox m", "settings set m {}",
|
|
"settings clear m", "module add f", "module check /etc", "module forget m", "module issue m --node a",
|
|
"seat rename a b", "plans close p --why w", "plans go p", "plans retry p", "plans stop p",
|
|
"plans --json go p", "plans -n 3 close p", "plans --what-if r retry p", "doctor run", "conditions silence c --why w",
|
|
"retire approve x", "cleanup delete x", "delivery check", "delivery go x", "bus upgrade",
|
|
"mirrors --record x", "mirrors --confirm", "hand-act record x --why y --cause z", "serve", "migrate",
|
|
"prepare", "declare x", "overlay x", "facts", "merge-gate", "check-here", "build x", "rebuild x",
|
|
"cancel x", "kill x", "clear x", "replay x", "pause", "resume", "pin a b", "unpin a", "take x",
|
|
"converge", "adopt x", "rollout x", "upgrade x", "collect", "board", "builder", "ask x", "frobnicate",
|
|
} {
|
|
argv, err := argvFor("command", map[string]any{"command": line})
|
|
var policy *heldAtTheTerminal
|
|
if err == nil || !errors.As(err, &policy) {
|
|
t.Errorf("%q ran as %v (%v); the generic verb only reads", line, argv, err)
|
|
}
|
|
}
|
|
for _, line := range []string{
|
|
"status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection",
|
|
"images", "artifacts --collected", "data --machine a", "builds", "plan ace --diff",
|
|
"plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list",
|
|
"conditions show c", "conditions history", "node list", "node show ace", "module list",
|
|
"settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r",
|
|
"delivery walks", "bus", "mirrors --json",
|
|
} {
|
|
if _, err := argvFor("command", map[string]any{"command": line}); err != nil {
|
|
t.Errorf("%q, a read, was refused: %v", line, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// What hands a caller a key, a credential or a secret is refused whichever verb composed it.
|
|
|
|
// A value with a quote in it can be said on a `command` line, as in a shell, and a line whose quote
|
|
// is left open is refused naming where it opened and how a quote is written, quoting none of it (novox/hq issue 294: an
|
|
// apostrophe inside a single-quoted JSON value cut the line, and the refusal named no cause).
|
|
func TestAQuotedValueCanHoldAQuote(t *testing.T) {
|
|
for _, c := range []struct{ line, want string }{
|
|
{`settings set claude-code '{"role":"the operator'\''s laptop"}'`, `{"role":"the operator's laptop"}`},
|
|
{`settings set claude-code "{\"role\":\"the operator's laptop\"}"`, `{"role":"the operator's laptop"}`},
|
|
{"x \"a \\\\ b\nc\"", "a \\ b\nc"},
|
|
{`x 'a\b'`, `a\b`},
|
|
} {
|
|
argv, err := splitCommandLine(c.line)
|
|
if err != nil || argv[len(argv)-1] != c.want {
|
|
t.Errorf("%s: read back %q %v, want %q", c.line, argv, err, c.want)
|
|
}
|
|
}
|
|
|
|
_, err := splitCommandLine(`settings set claude-code '{"role":"the operator's laptop"}' --node g14`)
|
|
if err == nil {
|
|
t.Fatal("an apostrophe that leaves a quote open was accepted")
|
|
}
|
|
for _, want := range []string{"character 57", `'\''`, `\"`} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("the refusal does not say %q: %v", want, err)
|
|
}
|
|
}
|
|
if strings.Contains(err.Error(), "laptop") || strings.Contains(err.Error(), "g14") {
|
|
t.Errorf("the refusal quotes the line, which may carry a setting's value: %v", err)
|
|
}
|
|
}
|
|
|
|
// Every value reads back as it was when written the way the refusal says: single-quoted with '\”
|
|
// for each quote, or double-quoted with \ before each " and \ — newlines and backslashes included.
|
|
func TestAQuotedValueRoundTrips(t *testing.T) {
|
|
for _, v := range []string{`plain`, `it's`, `say "hi"`, `back\slash\`, "two\nlines", `'"\'\"`, `''`, ``} {
|
|
single := "x '" + strings.ReplaceAll(v, "'", `'\''`) + "'"
|
|
double := `x "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) + `"`
|
|
for _, line := range []string{single, double} {
|
|
argv, err := splitCommandLine(line)
|
|
if err != nil || len(argv) != 2 || argv[1] != v {
|
|
t.Errorf("%s: read back %q %v, want %q", line, argv, err, v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A named push waits as long as the call says, else as long as the controller's setting push-wait says, else
|
|
// not at all: no length is the code's own (novox/hq issue 629).
|
|
func TestAPushWaitsAsTheCallOrTheSettingSays(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "push.json")
|
|
if err := os.WriteFile(file, []byte(`{"push-wait": "2m"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(pushFileVar, file)
|
|
argv, err := argvFor("push", map[string]any{"node": "one", "why": "w"})
|
|
if err != nil || strings.Join(argv, " ") != "push one --wait 2m0s --why w" {
|
|
t.Fatalf("the setting was not the default: %v %v", argv, err)
|
|
}
|
|
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "30s"})
|
|
if err != nil || strings.Join(argv, " ") != "push one --wait 30s --why w" {
|
|
t.Fatalf("the call's wait did not win over the setting: %v %v", argv, err)
|
|
}
|
|
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "0"})
|
|
if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" {
|
|
t.Fatalf("a wait of 0 did not wait for nothing: %v %v", argv, err)
|
|
}
|
|
if _, err := argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "soon"}); err == nil ||
|
|
!strings.Contains(err.Error(), "no duration") {
|
|
t.Fatalf("a wait that is no duration was taken: %v", err)
|
|
}
|
|
if err := os.WriteFile(file, []byte(`{"push-wait": "later"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w"})
|
|
if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" {
|
|
t.Fatalf("an unreadable setting did not fall back to waiting for nothing: %v %v", argv, err)
|
|
}
|
|
argv, _ = argvFor("push", map[string]any{"why": "w"})
|
|
if strings.Join(argv, " ") != "push --behind --wait 0 --why w" {
|
|
t.Fatalf("a sweep waits: %v", argv)
|
|
}
|
|
}
|