The installer lets the first node onto the bus it raised

At genesis the bus's users reach it in no declaration, because the
machine running it has not enrolled. The installer, which raised the
bus from its bundle, places the control plane's composed list beside it
and makes it re-read it: before the machine enrols, for the token's
account, and after, for the node's own (novox/hq issue 146).
This commit is contained in:
2026-10-02 16:31:00 +02:00
parent a31cce0fd0
commit feb74e2069
2 changed files with 96 additions and 0 deletions
+43
View File
@@ -119,6 +119,11 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
// its ports and range on and not another that came up since.
args = append(args, "--tunnel", o.Tunnel)
}
// The enrolment account the token's secret is the password of exists in the mesh's records
// and nowhere on the bus yet (novox/hq 04-ISSUES/146): placed before the machine presents it.
if err := placeTheBusUsers(ctx, control, say); err != nil {
return out, err
}
joined, err := control.run(joining, o.Host, args...)
cancel()
if err != nil {
@@ -137,6 +142,10 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
}
out.Joined = true
say(" enrolled as " + o.Node)
// And the node's own account, minted as it enrolled, before its agent connects as it.
if err := placeTheBusUsers(ctx, control, say); err != nil {
return out, err
}
}
// 4. The agent.
@@ -148,6 +157,40 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
return out, nil
}
// busAccounts and busContainer are where the installer's bundle raises the bus: the file its
// configuration includes, and the container that reads it. The installer raised them, so it is the
// one that knows them (examples/foundation-first-node-nats.lock).
const (
busAccounts = "/var/lib/mesh-bus-conf/accounts.conf"
busContainer = "mesh-broker"
)
// placeTheBusUsers writes the mesh's composed user list beside the bus the installer raised, and makes
// the bus re-read it.
//
// **Genesis's own step** (novox/hq 04-ISSUES/146). Every account on the bus reaches it in the
// declaration of the machine that runs it — which needs that machine to be an enrolled node, and at
// genesis it is not. The control plane composes the list and says it; whoever raised the bus places
// it. That is this installer: it carried the bus in its bundle, so it knows where the bus reads it,
// and the control plane never has to.
func placeTheBusUsers(ctx context.Context, control controlPlane, say func(string)) error {
users, err := control.tell(ctx, "broker", "accounts")
if err != nil {
return fmt.Errorf("the control plane would not say the bus's users, so no machine could "+
"join it: %w", err)
}
if !strings.Contains(users, "accounts") {
return fmt.Errorf("the control plane's account of the bus's users is not one:\n%s", indent(users))
}
script := "umask 077 && cat > " + busAccounts + ".next <<'MESHBUSUSERS'\n" + users + "\nMESHBUSUSERS\n" +
"mv " + busAccounts + ".next " + busAccounts + " && docker kill -s HUP " + busContainer + " >/dev/null"
if out, err := control.run(ctx, "sh", "-c", script); err != nil {
return fmt.Errorf("the bus's users could not be placed at %s: %w\n%s", busAccounts, err, indent(out))
}
say(" bus users placed")
return nil
}
// runTheHost makes sure something on this machine is listening to the mesh, and proves it.
//
// **The installer does not install the service, and says so.** A unit file is a packaging decision
+53
View File
@@ -261,3 +261,56 @@ func TestAListingIsMatchedByNameAndNotBySubstring(t *testing.T) {
t.Error("registry-mirror was not found")
}
}
// **Genesis places the bus's users, before the machine enrols and again after** (novox/hq
// 04-ISSUES/146). The enrolment account exists only in the mesh's records until somebody writes it
// beside the bus; so does the node's own, minted as it enrols. Without the first, the machine is
// refused by the bus it just raised; without the second, its agent is.
func TestAFirstNodeIsLetOntoTheBusItRaised(t *testing.T) {
enrolled := false
runtime := &asked{answer: func(name string, args []string) (string, error) {
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, "node list"):
if enrolled {
return "anchor here 01J0\n", nil
}
return "", nil
case strings.Contains(joined, "node add"):
return "added anchor\n", nil
case strings.Contains(joined, "token issue"):
return "a token for anchor, good once:\n\n " + strings.Repeat("t", 240) + "\n\n", nil
case strings.Contains(joined, "broker accounts"):
return "accounts {\n MESH { users = [] }\n}\n", nil
case name == "/usr/local/bin/mesh-host":
enrolled = true
return "enrolled as anchor\n", nil
case name == "pgrep", name == "sh":
return "", nil
}
return "", fmt.Errorf("unexpected: %s %v", name, args)
}}
if _, err := Enrol(context.Background(), Options{
Node: "anchor", State: filepath.Join(t.TempDir(), "state.json"), Timeout: time.Second,
Host: "/usr/local/bin/mesh-host", HostInBackground: true,
}, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second},
func(string) {}); err != nil {
t.Fatal(err)
}
placed, enrol := []int{}, -1
for i, c := range runtime.commands {
if strings.HasPrefix(c, "sh -c") && strings.Contains(c, "kill -s HUP mesh-broker") &&
strings.Contains(c, "/var/lib/mesh-bus-conf/accounts.conf") {
placed = append(placed, i)
}
if strings.HasPrefix(c, "/usr/local/bin/mesh-host enrol") {
enrol = i
}
}
if enrol < 0 || len(placed) != 2 || placed[0] > enrol || placed[1] < enrol {
t.Fatalf("the bus's users were not placed before the machine enrolled and again after "+
"(placed at %v, enrolled at %d):\n%s", placed, enrol, strings.Join(runtime.commands, "\n"))
}
}