From 7283209d1abc7d00c568f437c6c6b669ee2a50d5 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 06:58:39 +0200 Subject: [PATCH] Place the bus's users after every push until the bus is adopted (hq issue 608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bus reads its user list from a file that, until the broker step adopts it as the nats module, only the installer writes, and the installer wrote it only at enrolment. The build agent's account, minted with step 13's push, never reached the bus; the agent was refused (Authorization Violation) and the sdk step's build waited until it was killed. Every push genesis makes now places the composed list and has the bus re-read it, until the nats module is registered (ADR 0331 §5, ADR 0333 §4). --- internal/bootstrap/module.go | 9 ++ internal/bootstrap/phase_packages.go | 9 +- internal/bootstrap/ports_test.go | 6 ++ internal/bootstrap/push_places_users_test.go | 97 ++++++++++++++++++++ internal/bootstrap/registry_test.go | 4 + 5 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 internal/bootstrap/push_places_users_test.go diff --git a/internal/bootstrap/module.go b/internal/bootstrap/module.go index 6352d4d..3140aa9 100644 --- a/internal/bootstrap/module.go +++ b/internal/bootstrap/module.go @@ -146,6 +146,15 @@ func pushNode(ctx context.Context, o Options, control controlPlane, say func(str "already registered and try the push again", err) } say(" pushed " + o.Node) + // **Every push may carry a new bus user, and until the adoption the bus hears of it only from here** + // (novox/hq issue 608, ADR 0331 §5, ADR 0333 §4). A module's account is minted into the mesh's + // records and sealed to this node with the push; the bus reads its user list from a file, and until + // the broker step adopts the bus as the nats module nothing but this installer writes that file. The + // list was placed only when the node enrolled, so the build agent's account (step 13) never reached + // the bus and the agent was refused ("Authorization Violation") until the sdk step gave up. + if err := placeUntilAdopted(ctx, control, say); err != nil { + return "", err + } return strings.TrimSpace(said), nil } diff --git a/internal/bootstrap/phase_packages.go b/internal/bootstrap/phase_packages.go index d9d4156..02bc574 100644 --- a/internal/bootstrap/phase_packages.go +++ b/internal/bootstrap/phase_packages.go @@ -413,11 +413,10 @@ func deliverGenesisRegistry(ctx context.Context, o Options, control controlPlane if _, err := control.tell(ctx, "secret", "accept", o.Node, BuilderModule, GenesisRegistrySecret, "--from", at); err != nil { return err } - // Push so the sealed document reaches the build agent, which reads it at its next build. - if _, err := control.tell(ctx, "push", o.Node); err != nil { - return err - } - return nil + // Push so the sealed document reaches the build agent, which reads it at its next build — and, + // like every push before the adoption, the bus hears the users it carries (issue 608). + _, err = pushNode(ctx, o, control, say) + return err } // PublishTheSDK dispatches a build of the SDK to the build agent. The agent has the genesis document by diff --git a/internal/bootstrap/ports_test.go b/internal/bootstrap/ports_test.go index 41b33d1..0e2c008 100644 --- a/internal/bootstrap/ports_test.go +++ b/internal/bootstrap/ports_test.go @@ -239,6 +239,12 @@ func (c *controlRecorder) run(_ context.Context, name string, args ...string) (s } if name == "docker" && args[0] == "exec" { c.told = append(c.told, strings.Join(args[3:], " ")) + if strings.Join(args[3:], " ") == "broker accounts" { + return "accounts {}\n", nil // the composed user list, placed after every push (issue 608) + } + } + if name == "sh" { + c.told = append(c.told, "placed the bus's users") } return "", nil } diff --git a/internal/bootstrap/push_places_users_test.go b/internal/bootstrap/push_places_users_test.go new file mode 100644 index 0000000..77d5f44 --- /dev/null +++ b/internal/bootstrap/push_places_users_test.go @@ -0,0 +1,97 @@ +package bootstrap + +import ( + "context" + "strings" + "testing" + "time" +) + +// Defends novox/hq issue 608 (ADR 0331 §5, ADR 0333 §4): until the bus is adopted as the nats module, +// every push genesis makes is followed by the installer placing the composed user list where the bus +// reads it, and making the bus re-read it. The build agent's account was minted at step 13 and sealed +// with that push, and the list was placed only at enrolment, so the bus refused the agent +// ("Authorization Violation") and genesis stopped at step 15. + +// placing answers the controller as a mesh whose bus is or is not the nats module yet, and records +// what was run, in order. +func placing(adopted bool) (*asked, controlPlane) { + listed := "build-agent 1\n" + if adopted { + listed += "nats 1\n" + } + runtime := &asked{} + runtime.answer = func(name string, args []string) (string, error) { + joined := strings.Join(args, " ") + switch { + case name == "sh": + return "", nil + case strings.HasSuffix(joined, "module list"): + return listed, nil + case strings.HasSuffix(joined, "broker accounts"): + return "accounts {\n MESH { users = [ { user: \"anchor.build-agent\" } ] }\n}\n", nil + } + return "", nil + } + return runtime, controlPlane{container: "mesh-controller", run: runtime.run, timeout: time.Second} +} + +// placedAfterThePush is whether the bus's users were placed — written at the module's bus-users path, +// the bus told to re-read them — after the push, and how often. +func placedAfterThePush(t *testing.T, commands []string) int { + t.Helper() + pushed, placed := -1, 0 + for i, c := range commands { + if strings.HasSuffix(c, "push anchor") && pushed < 0 { + pushed = i + } + if strings.HasPrefix(c, "sh -c") && strings.Contains(c, busAccounts) && + strings.Contains(c, "kill -s HUP "+busContainer) { + if pushed < 0 || i < pushed { + t.Fatalf("the bus's users were placed before the push that carries the account: %v", commands) + } + placed++ + } + } + if pushed < 0 { + t.Fatalf("nothing was pushed: %v", commands) + } + return placed +} + +func TestTheBusHearsTheBuildAgentsAccountAfterItsPush(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + runtime, control := placing(false) + o := Options{Node: "anchor", Wait: time.Second} + if _, err := installModule(context.Background(), o, control, BuilderModule, []byte(`{}`), quietly); err != nil { + t.Fatal(err) + } + if n := placedAfterThePush(t, runtime.commands); n != 1 { + t.Fatalf("the bus's users were placed %d time(s) after the build agent's push; once: %v", n, runtime.commands) + } +} + +func TestTheGenesisDocumentsPushIsHeardByTheBusToo(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + runtime, control := placing(false) + o := Options{Node: "anchor", Wait: time.Second} + if err := deliverGenesisRegistry(context.Background(), o, control, genesisRegistry(DefaultPorts(), "pw"), quietly); err != nil { + t.Fatal(err) + } + if n := placedAfterThePush(t, runtime.commands); n != 1 { + t.Fatalf("the bus's users were placed %d time(s) after the genesis document's push; once: %v", n, runtime.commands) + } +} + +// From the adoption on the nats module writes the list, and the installer does not (ADR 0333 §4). +func TestOnceTheBusIsTheNatsModulesThePushPlacesNothing(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + runtime, control := placing(true) + o := Options{Node: "anchor", Wait: time.Second} + if _, err := installModule(context.Background(), o, control, BuilderModule, []byte(`{}`), quietly); err != nil { + t.Fatal(err) + } + if n := placedAfterThePush(t, runtime.commands); n != 0 { + t.Fatalf("the installer placed the bus's users over the nats module's: %v", runtime.commands) + } +} diff --git a/internal/bootstrap/registry_test.go b/internal/bootstrap/registry_test.go index d05bb83..f9a0280 100644 --- a/internal/bootstrap/registry_test.go +++ b/internal/bootstrap/registry_test.go @@ -58,8 +58,12 @@ func aMeshThatAgrees(answers map[string]string) func(string, []string) (string, } } switch { + case name == "sh" && len(args) == 2 && strings.Contains(args[1], "accounts.conf"): + return "", nil // the bus's users placed after a push (issue 608) case name != "docker": return "", fmt.Errorf("unexpected program %q", name) + case strings.HasSuffix(joined, "broker accounts"): + return "accounts {}\n", nil case args[0] == "cp": return "", nil case args[0] == "container":