From c9eba5f3b848aca735bff60580b51845361acc1a Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 2 Oct 2026 01:55:06 +0200 Subject: [PATCH] The controller's tools can issue a module its bus account A module's account was mintable only from the controller's command line, so a rollout that gave a module one could not be finished through the mesh's own tools (novox/hq issue 191). The issue verb runs module issue for a module on a machine; the caller pushes the machine after. --- cmd/mesh-controller/seatverbs.go | 8 ++++++++ cmd/mesh-controller/seatverbs_test.go | 16 ++++++++++++++++ internal/catalogue/verbs.go | 7 +++++++ 3 files changed, 31 insertions(+) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index bb9db6d..cca6f99 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -129,6 +129,14 @@ func argvFor(verb string, args map[string]any) ([]string, error) { // Half of either shape: the command says its usage, which names both shapes, and that is // the answer the caller needs. return []string{"rotate"}, nil + case "issue": + // The same act as `module issue` at a shell (novox/hq design 25 §4): the account is minted + // into the mesh's records and delivered at the machine's next push, which is the caller's to + // ask for — so the mesh is never pushed as a side effect of a credential. + if err := need("node", "module"); err != nil { + return nil, err + } + return []string{"module", "issue", str("module"), "--node", str("node")}, nil case "build": if err := need("repository"); err != nil { return nil, err diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 44365e3..e8b11fd 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -73,6 +73,22 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) { } } +// `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"`) { diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index aa36a0a..3a6e00e 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -129,6 +129,13 @@ var ControllerVerbs = []Verb{ "module": "an own secret: the module", "secret": "an own secret: its name in the module's definition", }, nil)}, + {Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " + + "machine as the module's own secret named broker, read at the next push of that machine. For a module " + + "whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.", + Input: schema(map[string]string{ + "node": "the machine that runs the module", + "module": "the module's name", + }, []string{"node", "module"})}, {Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " + "`builds` with that id follows it line by line, and the module is registered when the outcome comes.", Input: schema(map[string]string{ -- 2.54.0