rollout check: whether this mesh could move its bus, and what is missing
The rollout moves every node at once, so there is nothing to inspect afterwards and no half to roll back — either the mesh was ready or it was not. That makes the readiness question the valuable half: it costs nothing, it can be asked of a mesh that is serving as many times as you like, and every answer is a thing somebody can go and fix. It reads from records and dials once. Is a bus answering, does a machine hold the seat, has that machine been sent the composed user list, does every machine have a credential for the new bus, does every module that speaks. Each missing thing names its own next step, because "not ready" that cannot be acted on is not an answer — and this is read at the point where the next step is irreversible. **A machine with no credential is the one that must stop it.** It keeps running and cannot come back, and afterwards there is no bus to tell it anything over, so the remedy has to happen first. The message says so. A module that never reaches the bus is not counted as missing a credential. A third of the catalogue never speaks, and listing those would bury the ones that matter. `rollout --confirm` refuses and says why: the move is not being written before its check has been run against a real mesh. And the plan it prints says the old broker stays — it remains an ordinary provider of `amqp` for whatever else uses it, which on this installation is a whole automation layer that has nothing to do with the mesh. This move is not its retirement, and that is why it is survivable: what breaks if it goes wrong is the mesh's ability to change things, not the services its modules serve.
This commit is contained in:
@@ -55,6 +55,19 @@ func CredentialIn(address string) (user, password, bare string) {
|
||||
return user, password, scheme + address[at+1:]
|
||||
}
|
||||
|
||||
// BareAddress is a bus address with any credential stripped, for something that only needs to know
|
||||
// whether a server is answering there.
|
||||
func BareAddress(address string) string {
|
||||
_, _, bare := CredentialIn(address)
|
||||
if bare == "" {
|
||||
return address
|
||||
}
|
||||
if strings.Contains(bare, "://") {
|
||||
return bare
|
||||
}
|
||||
return "nats://" + bare
|
||||
}
|
||||
|
||||
// MustBeOneBus refuses a configuration that names both buses for the mesh's own traffic.
|
||||
//
|
||||
// **Both clients ship and that is the point; both being live is not.** The rollout moves every node
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Whether a mesh could move its bus, and what is missing if not.
|
||||
//
|
||||
// **Asked before anything moves, and answerable from records alone.** The rollout moves every node at
|
||||
// once (novox/hq ADR 0116 step 5), so there is no partial state to inspect afterwards and no half to
|
||||
// roll back: either the mesh was ready or it was not. That makes a readiness question the most
|
||||
// valuable thing here — it costs nothing, it can be asked of a running mesh any number of times, and
|
||||
// every answer is a thing somebody can go and fix.
|
||||
//
|
||||
// Deliberately pure. It is handed what the mesh knows and returns sentences; nothing here connects to
|
||||
// anything, so it can be asked on a workstation about a mesh it has never reached.
|
||||
|
||||
// Readiness is what the mesh knows about its own ability to move.
|
||||
type Readiness struct {
|
||||
// TheBus is the address the mesh's own traffic would move to, empty when nothing names one.
|
||||
TheBus string
|
||||
// ServerStanding is whether a bus is reachable at that address, as somebody checked.
|
||||
ServerStanding bool
|
||||
// Holder is the node running the module that holds the bus seat, empty when nothing does.
|
||||
Holder string
|
||||
// AccountsComposed is whether that node has been sent the composed user list.
|
||||
AccountsComposed bool
|
||||
// Nodes is every machine the mesh knows.
|
||||
Nodes []string
|
||||
// Credentialled is which of them has a credential for the new bus.
|
||||
Credentialled map[string]bool
|
||||
// Modules is every assigned module, as `<node>/<module>`.
|
||||
Modules []string
|
||||
// ModuleCredentialled is which of those has one.
|
||||
ModuleCredentialled map[string]bool
|
||||
// StillOnTheOldBus is whether anything of the mesh's own still needs the bus it is leaving —
|
||||
// which is not a reason to stop, because that broker stays as an ordinary provider of `amqp`
|
||||
// (ADR 0119). Recorded so nobody reads the move as a retirement.
|
||||
OldBusHasOtherClients bool
|
||||
}
|
||||
|
||||
// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them.
|
||||
//
|
||||
// Empty means ready. **Each entry names one thing and what to do about it**, because a readiness check
|
||||
// that says "not ready" is a check nobody can act on — and this is read at the point where the next
|
||||
// step is irreversible.
|
||||
func NotReady(r Readiness) []string {
|
||||
var why []string
|
||||
|
||||
if strings.TrimSpace(r.TheBus) == "" {
|
||||
why = append(why, "nothing names the bus to move to: set "+NATSVar+" on the control node "+
|
||||
"to the address the new server answers on")
|
||||
}
|
||||
if !r.ServerStanding {
|
||||
why = append(why, "no bus is answering at that address. Step 2 of the change raises it beside "+
|
||||
"the one the mesh is on, carrying nothing — assign the module that holds "+
|
||||
"mesh-broker and push the machine that runs it")
|
||||
}
|
||||
if r.Holder == "" {
|
||||
why = append(why, "no machine holds mesh-broker, so nothing would compose the bus's user "+
|
||||
"list. Assign the module that claims it")
|
||||
} else if !r.AccountsComposed {
|
||||
why = append(why, fmt.Sprintf(
|
||||
"%s holds mesh-broker and has not been sent the composed user list, so the bus would "+
|
||||
"refuse every connection. `push %s`", r.Holder, r.Holder))
|
||||
}
|
||||
|
||||
// A node with no credential cannot come back after the move, and a node that cannot come back is
|
||||
// a machine the mesh has lost until somebody goes to it.
|
||||
var missing []string
|
||||
for _, n := range r.Nodes {
|
||||
if !r.Credentialled[n] {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
why = append(why, fmt.Sprintf(
|
||||
"%d machine(s) have no credential for the new bus and would not come back: %s. Each needs "+
|
||||
"one minted before the move, not after — after, there is no bus to ask over",
|
||||
len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
|
||||
// A module without one keeps running and stops being reachable, which is a smaller fault and still
|
||||
// one somebody should choose rather than discover.
|
||||
var quiet []string
|
||||
for _, m := range r.Modules {
|
||||
if !r.ModuleCredentialled[m] {
|
||||
quiet = append(quiet, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(quiet)
|
||||
if len(quiet) > 0 {
|
||||
why = append(why, fmt.Sprintf(
|
||||
"%d module(s) have no credential for the new bus: %s. Each keeps serving and stops "+
|
||||
"answering tools and hearing events until it is issued one",
|
||||
len(quiet), strings.Join(quiet, ", ")))
|
||||
}
|
||||
|
||||
return why
|
||||
}
|
||||
|
||||
// WhatMoves is what the rollout would do, in order, for somebody reading before they commit.
|
||||
//
|
||||
// **Written out rather than summarised.** This is the one step with nothing to inspect afterwards, so
|
||||
// the last useful moment to disagree with it is while reading this.
|
||||
func WhatMoves(r Readiness) []string {
|
||||
out := []string{
|
||||
fmt.Sprintf("compose the bus's user list and send it to %s", holderOr(r.Holder)),
|
||||
fmt.Sprintf("move this control plane to %s, and confirm it is heard", busOr(r.TheBus)),
|
||||
}
|
||||
nodes := append([]string(nil), r.Nodes...)
|
||||
sort.Strings(nodes)
|
||||
for _, n := range nodes {
|
||||
out = append(out, fmt.Sprintf("move %s, and confirm it reports", n))
|
||||
}
|
||||
if len(r.Modules) > 0 {
|
||||
out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers",
|
||||
len(r.Modules)))
|
||||
}
|
||||
if r.OldBusHasOtherClients {
|
||||
out = append(out, "leave the old broker running: it stays an ordinary provider of `amqp` for "+
|
||||
"whatever else uses it (ADR 0119), and this move is not its retirement")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func holderOr(node string) string {
|
||||
if node == "" {
|
||||
return "whichever machine holds mesh-broker"
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func busOr(address string) string {
|
||||
if address == "" {
|
||||
return "the new bus"
|
||||
}
|
||||
return address
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Whether a mesh could move its bus.
|
||||
//
|
||||
// Every case here is a way of moving that leaves something behind, and the one that matters most is a
|
||||
// machine with no credential: after the move there is no bus to ask it over, so it is lost until
|
||||
// somebody walks to it.
|
||||
|
||||
func aMeshReadyToMove() Readiness {
|
||||
return Readiness{
|
||||
TheBus: "nats://127.0.0.1:5671", ServerStanding: true,
|
||||
Holder: "anchor", AccountsComposed: true,
|
||||
Nodes: []string{"anchor", "laptop"},
|
||||
Credentialled: map[string]bool{"anchor": true, "laptop": true},
|
||||
Modules: []string{"anchor/gitea"},
|
||||
ModuleCredentialled: map[string]bool{"anchor/gitea": true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshWithEverythingInPlaceIsReady(t *testing.T) {
|
||||
if why := NotReady(aMeshReadyToMove()); len(why) != 0 {
|
||||
t.Fatalf("a mesh with everything in place was refused: %v", why)
|
||||
}
|
||||
}
|
||||
|
||||
// **A machine with no credential is the one that must stop this.** It keeps running and cannot come
|
||||
// back, and there is no bus left to tell it anything over — so the remedy has to happen before, and
|
||||
// the message says so.
|
||||
func TestAMachineWithNoCredentialStopsTheMove(t *testing.T) {
|
||||
r := aMeshReadyToMove()
|
||||
r.Credentialled = map[string]bool{"anchor": true}
|
||||
|
||||
why := NotReady(r)
|
||||
if len(why) == 0 {
|
||||
t.Fatal("a machine that could not come back did not stop the move")
|
||||
}
|
||||
said := strings.Join(why, "\n")
|
||||
if !strings.Contains(said, "laptop") {
|
||||
t.Errorf("the refusal does not name the machine: %s", said)
|
||||
}
|
||||
if !strings.Contains(said, "before the move") {
|
||||
t.Errorf("the refusal does not say the remedy comes first: %s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// A bus nobody has raised, a seat nobody holds, and a user list nobody has been sent: each stops it,
|
||||
// and each names its own next step, because "not ready" that cannot be acted on is not an answer.
|
||||
func TestEachThingMissingNamesItsOwnRemedy(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
break_ func(*Readiness)
|
||||
says string
|
||||
}{
|
||||
{"no address", func(r *Readiness) { r.TheBus = "" }, NATSVar},
|
||||
{"no server", func(r *Readiness) { r.ServerStanding = false }, "carrying nothing"},
|
||||
{"no holder", func(r *Readiness) { r.Holder = "" }, "mesh-broker"},
|
||||
{"no user list", func(r *Readiness) { r.AccountsComposed = false }, "push anchor"},
|
||||
{"a module with none", func(r *Readiness) {
|
||||
r.ModuleCredentialled = map[string]bool{}
|
||||
}, "anchor/gitea"},
|
||||
} {
|
||||
r := aMeshReadyToMove()
|
||||
c.break_(&r)
|
||||
why := NotReady(r)
|
||||
if len(why) == 0 {
|
||||
t.Errorf("%s did not stop the move", c.what)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(strings.Join(why, "\n"), c.says) {
|
||||
t.Errorf("%s: the refusal does not mention %q: %v", c.what, c.says, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the move would do is written out rather than summarised, because this is the one step with
|
||||
// nothing to inspect afterwards — so reading it is the last chance to disagree.
|
||||
func TestWhatMovesNamesEveryMachineAndSaysTheOldBrokerStays(t *testing.T) {
|
||||
r := aMeshReadyToMove()
|
||||
r.OldBusHasOtherClients = true
|
||||
steps := strings.Join(WhatMoves(r), "\n")
|
||||
|
||||
for _, want := range []string{"anchor", "laptop", "user list", "module runtime"} {
|
||||
if !strings.Contains(steps, want) {
|
||||
t.Errorf("the plan does not mention %q:\n%s", want, steps)
|
||||
}
|
||||
}
|
||||
// Said explicitly, so nobody reads the move as switching the old broker off — it stays serving
|
||||
// whatever else uses it, and that is a decision already taken.
|
||||
if !strings.Contains(steps, "not its retirement") {
|
||||
t.Errorf("the plan does not say the old broker stays:\n%s", steps)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user