A machine moves to the bus its declaration tells it to

Until now a membership — bus address, fingerprint, password — was written once,
at enrolment, and nothing ever rewrote it, so a machine already enrolled could not
be moved to another bus at all (novox/hq design 28, task 5.2). The mesh now
delivers a membership for the new bus as a sealed file in the declaration, like
any secret; the host reads it after the declaration has applied, saves it as its
identity, and exits cleanly so the service manager restarts it dialling the bus it
names. The same path a machine takes after a reboot — so nothing new has to be
right for it to work. A membership carries its transport; empty means the bus the
mesh ran on before, so nothing written earlier reads as unset.
This commit is contained in:
2026-09-28 00:08:44 +02:00
parent 587b8aa220
commit 9fd3762e93
2 changed files with 65 additions and 1 deletions
+60 -1
View File
@@ -869,6 +869,7 @@ func overlayCommand(ctx context.Context, opts options) error {
if err := link.Publish(ctx, link.Membership{
Node: mine.Node, Broker: mine.Membership.Broker, Fingerprint: mine.Membership.Fingerprint,
Password: mine.Membership.Password, Signer: mine.Membership.Signer,
Transport: mine.Membership.Transport,
}, link.Report{Node: mine.Node, Rekey: &rekey}, opts.timeout); err != nil {
return fmt.Errorf("the mesh could not be told; nothing was written here: %w", err)
}
@@ -973,7 +974,12 @@ func runLink(ctx context.Context, opts options) error {
go sched.Run(ctx)
applier := func(ctx context.Context, raw, signature []byte) link.Report {
return applyAndKeep(ctx, opts, raw, &store.Declared{Declaration: raw, Signature: signature}, sched, say)
report := applyAndKeep(ctx, opts, raw, &store.Declared{Declaration: raw, Signature: signature}, sched, say)
// **A declaration may carry this machine's membership for another bus.** It arrives as a
// sealed file like any secret, and is read after the rest has applied so the bus it names is
// standing before this machine leaves the one it is on (novox/hq design 28, task 5.2).
adoptDeliveredMembership(identity.Path(opts.state), &mine, say)
return report
}
// Two things at once, and the second is what makes disconnection ordinary. The link brings
@@ -1008,6 +1014,7 @@ func runLink(ctx context.Context, opts options) error {
Broker: mine.Membership.Broker,
Fingerprint: mine.Membership.Fingerprint,
Password: mine.Membership.Password,
Transport: mine.Membership.Transport,
Signer: mine.Membership.Signer,
}, applier, say, opts.timeout, rousedBySignal(ctx), outbox)
}
@@ -1376,3 +1383,55 @@ func carriedPorts(state store.State) []int {
sort.Ints(out)
return out
}
// MembershipNextPath is where the mesh delivers this machine's membership for the bus it is moving
// to: a sealed file in a declaration, written by the host like any secret, read here after the
// declaration has applied.
const MembershipNextPath = "/var/lib/mesh/membership-next.json"
// adoptDeliveredMembership moves this machine to the bus a delivered membership names.
//
// **Saved, then restarted — not swapped in place.** The link holds one membership for the life of
// the process, and every reconnect path assumes the bus did not change under it; a process that
// found itself half on one bus and half on another would be a new kind of state nothing was written
// for. Exiting cleanly hands the machine to the service manager's restart, and the process that
// comes back reads the identity file the way it always has and dials the bus it names. That is the
// same path a machine takes after a reboot, which is why nothing new has to be right for it to work.
//
// A membership identical to the one held is nothing: the file stays and is read again next time.
func adoptDeliveredMembership(identityPath string, mine *identity.Identity, say func(string)) {
raw, err := os.ReadFile(MembershipNextPath)
if err != nil {
return
}
var next identity.Membership
if err := json.Unmarshal(raw, &next); err != nil {
say(fmt.Sprintf("a membership was delivered at %s and could not be read: %v", MembershipNextPath, err))
return
}
if next.Broker == "" || next.Password == "" || next.Fingerprint == "" {
say(fmt.Sprintf("a membership was delivered at %s with no broker, fingerprint or password; ignored", MembershipNextPath))
return
}
same := next.Broker == mine.Membership.Broker && next.Fingerprint == mine.Membership.Fingerprint &&
next.Password == mine.Membership.Password && next.Transport == mine.Membership.Transport
if same {
return
}
// The signer is the mesh's, not the bus's: a delivered membership that names none keeps the one
// this machine already trusts, because a change of bus is not a change of who signs declarations.
if len(next.Signer) == 0 {
next.Signer = mine.Membership.Signer
}
mine.Membership = next
if err := identity.Save(identityPath, *mine); err != nil {
say(fmt.Sprintf("a membership for another bus was delivered and could not be saved: %v", err))
return
}
transport := next.Transport
if transport == "" {
transport = "the current"
}
say(fmt.Sprintf("moving to %s bus at %s — restarting to dial it", transport, next.Broker))
os.Exit(0)
}
+5
View File
@@ -76,6 +76,11 @@ type Membership struct {
// Not the token's secret: that is spent, and a credential that lives for ever should not be
// the same string as one that was meant to be used once.
Password string `json:"password"`
// Transport is which bus this membership is for. Empty is the bus the mesh ran on before
// the move — so every membership written before this field existed reads as correct, not as
// unset — and "nats" is the one being moved to (novox/hq design 28, task 5.2).
Transport string `json:"transport,omitempty"`
}
// Queue is where this node listens. Its account may read this and nothing else.