From 9d8cbe7b81ba7c78b3fb68b49e5fee87b96045c0 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 03:01:19 +0200 Subject: [PATCH] Grant the controller its work queues' cancelled sets (hq issue 269) A cancel writes the ask's id into the seat's cancelled set before deleting the ask, and a write is a publish to the bucket's subject, which the controller was not granted: against a server holding exactly the controller's composed list, every cancel timed out. --- internal/broker/controller_buckets_test.go | 31 ++++++++++++++++++++++ internal/broker/nats.go | 6 +++++ internal/broker/testdata/composed.conf | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 internal/broker/controller_buckets_test.go diff --git a/internal/broker/controller_buckets_test.go b/internal/broker/controller_buckets_test.go new file mode 100644 index 0000000..bb9008d --- /dev/null +++ b/internal/broker/controller_buckets_test.go @@ -0,0 +1,31 @@ +package broker + +import ( + "slices" + "testing" +) + +// **The controller may write every bucket it writes** (novox/hq to-be 45 §1, issue 269). Writing a +// key is a publish to the bucket's own subject, which the management interface's grant does not +// cover: the cancelled sets' writes timed out for want of this, and the controller's own buckets +// would have. +func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) { + p, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>"} + for _, seat := range seatsTheControllerAsks { + if hasCancelledSet(seat) { + want = append(want, "$KV."+CancelledSetName(seat)+".>") + } + } + for _, subject := range want { + if !slices.Contains(p.Publish, subject) { + t.Errorf("the controller may not publish %s, so it cannot write that bucket", subject) + } + } + if slices.Contains(p.Publish, "$KV.>") { + t.Error("the controller may write any bucket, a module's state included") + } +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 560bdd6..112f8eb 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -237,6 +237,12 @@ func PermissionsFor(p Principal) (Permissions, error) { // building, kill it, pause it, resume it. The queue is the controller's to show and to // change, and what one machine is doing with an ask it took only that machine can say. pub = append(pub, "mesh.seat."+seat+".tool.>") + // **And its cancelled set** (novox/hq ADR 0219, issue 269): a cancel writes the ask's id + // there before it deletes the ask, and a write is a publish to the bucket's subject, which + // `$JS.API.>` does not cover — so every cancel timed out, refused by this list. + if hasCancelledSet(seat) { + pub = append(pub, "$KV."+CancelledSetName(seat)+".>") + } } // **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own // facts under the seat it holds, because a role's events belong to the role and keep their diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index db5831c..93395ba 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -24,7 +24,7 @@ accounts { jetstream: enabled users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_hand-acts.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_hand-acts.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>"] } subscribe: { allow: ["$JS.API.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] } allow_responses: { max: 1, ttl: "1m" } } }