Both branches changed the seat set from the same starting point, so every number collided and every `mesh-*` name existed twice. The trunk's numbers and names win: this branch's records became 0129/0130 and its migrations 0037/0038, and the hardcoded rename map gave way to the trunk's `seat_alias` table — a rename is a row now (ADR 0122), not a recompile. Three of my checks were wrong and the merge is what showed it: A seat with an empty protocol is a marker, not an incomplete declaration. Most node-scoped seats are markers — which module is this machine's packet filter — and refusing one refused most of the set, the showcase module included. A mistyped field name is already refused by the parser, so an empty protocol was written as one deliberately. A claim on a seat this manifest does not declare is not the parser's to judge. A module may hold a seat another module declared; that is the whole reason ADR 0126 has callers name the seat and not its provider. Whether the seat exists is a fact about the catalogue, so the refusal is at registration, where every declaration is in view. And a seat may share a name with the provision it delivers. `git`, the npm registry and the artifact store still do, because renaming a delivering seat cascades to every consumer requiring it, with a window where a holder stops resolving mid-flight. The trunk deferred exactly those three on purpose. Full suite green against a real NATS and store.
194 lines
6.7 KiB
Go
194 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// Moving the mesh's own traffic to the bus being built (novox/hq ADR 0116 step 5).
|
|
//
|
|
// **The whole mesh moves at once, so there is nothing to inspect afterwards.** Every seam ships both
|
|
// transports and every one of them chooses by a single fact; this is the step that flips it. That
|
|
// shape is deliberate — steps 1 to 4 leave every node where it is, so the cost of being wrong stays
|
|
// bounded until here — and it means the useful work is almost all in the checking.
|
|
//
|
|
// So `rollout check` is the command that matters and the one that can be run any number of times
|
|
// against a mesh that is serving. It answers from records: what is missing, and what would happen.
|
|
// `rollout` itself refuses unless the check is clean.
|
|
//
|
|
// **The old broker is not switched off by this.** It stays an ordinary provider of `amqp` for whatever
|
|
// else uses it — on this installation, a whole automation layer that has nothing to do with the mesh
|
|
// ([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)). Only the mesh's own
|
|
// traffic moves, which is why this is survivable at all: what breaks if it goes wrong is the mesh's
|
|
// ability to change things, not the services its modules are serving.
|
|
|
|
const rolloutUsage = "rollout check | rollout --confirm"
|
|
|
|
func rolloutCommand(ctx context.Context, args []string) error {
|
|
switch {
|
|
case len(args) == 1 && args[0] == "check":
|
|
return rolloutCheck(ctx)
|
|
case len(args) == 1 && args[0] == "--confirm":
|
|
return errors.New(
|
|
"the rollout itself is not built yet: `rollout check` answers whether it could run, and " +
|
|
"what is missing. Moving every node at once is the one step with nothing to inspect " +
|
|
"afterwards, so it is not being written before the check it depends on has been run " +
|
|
"against a real mesh")
|
|
default:
|
|
return errors.New(rolloutUsage)
|
|
}
|
|
}
|
|
|
|
// rolloutCheck says whether the mesh could move, and what would happen if it did.
|
|
func rolloutCheck(ctx context.Context) error {
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
state, err := readinessOf(ctx, inv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("the bus this mesh would move to")
|
|
if state.TheBus == "" {
|
|
fmt.Printf(" nothing names one (%s is unset)\n", broker.NATSVar)
|
|
} else {
|
|
standing := "not answering"
|
|
if state.ServerStanding {
|
|
standing = "answering"
|
|
}
|
|
fmt.Printf(" %s — %s\n", state.TheBus, standing)
|
|
}
|
|
fmt.Println()
|
|
|
|
fmt.Println("what would move")
|
|
for _, step := range broker.WhatMoves(state) {
|
|
fmt.Printf(" %s\n", step)
|
|
}
|
|
fmt.Println()
|
|
|
|
why := notReadyOf(state)
|
|
if len(why) == 0 {
|
|
fmt.Println("nothing is missing: this mesh could move its bus.")
|
|
fmt.Println()
|
|
fmt.Println("Read `what would move` above once more before running it. Every node moves at the")
|
|
fmt.Println("same moment and there is no half-moved state to look at afterwards.")
|
|
return nil
|
|
}
|
|
fmt.Printf("not ready — %d thing(s) to do first:\n", len(why))
|
|
for i, w := range why {
|
|
fmt.Printf(" %d. %s\n", i+1, w)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readinessOf gathers what the mesh knows about its own ability to move.
|
|
//
|
|
// Reads and one dial, and nothing is written. Safe to run on a mesh that is serving, which is the
|
|
// point: the answer is only useful if it can be had without committing to anything.
|
|
func readinessOf(ctx context.Context, inv *inventory.Inventory) (broker.Readiness, error) {
|
|
state := broker.Readiness{
|
|
Credentialled: map[string]bool{},
|
|
ModuleCredentialled: map[string]bool{},
|
|
// The old broker keeps its other clients on this installation, and saying so is how the plan
|
|
// stops reading as a retirement.
|
|
OldBusHasOtherClients: true,
|
|
}
|
|
|
|
address, _, err := broker.OnNATS()
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
state.TheBus = address
|
|
if address != "" {
|
|
// One dial, briefly. "Is it answering" is the one fact records cannot hold, and a mesh about
|
|
// to move onto a server that is not there should hear it here rather than afterwards.
|
|
if conn, err := nats.Connect(broker.BareAddress(address), nats.Timeout(5*time.Second)); err == nil {
|
|
state.ServerStanding = true
|
|
conn.Close()
|
|
}
|
|
}
|
|
|
|
nodes, err := inv.Nodes(ctx)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
kept, err := inv.BusUsers(ctx)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
shelf, err := inv.Catalogue(ctx)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
|
|
for _, n := range nodes {
|
|
state.Nodes = append(state.Nodes, n.Name)
|
|
_, has := kept[broker.Principal{Kind: broker.KindNode, Node: n.Name}.Username()]
|
|
state.Credentialled[n.Name] = has
|
|
|
|
assigned, err := inv.Assigned(ctx, n.Name)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
for _, module := range assigned {
|
|
m, known := shelf[module]
|
|
if !known {
|
|
continue
|
|
}
|
|
// The machine that holds the bus seat is the one that would be sent the user list.
|
|
if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") {
|
|
state.Holder = n.Name
|
|
state.AccountsComposed = wasSentTheUserList(ctx, inv, n.Name)
|
|
}
|
|
// A module that never speaks needs no credential, so it is not counted as missing one.
|
|
if !speaksOnTheBus(m) {
|
|
continue
|
|
}
|
|
named := n.Name + "/" + module
|
|
state.Modules = append(state.Modules, named)
|
|
_, hasOne := kept[broker.Principal{
|
|
Kind: broker.KindModule, Node: n.Name, Module: module,
|
|
}.Username()]
|
|
state.ModuleCredentialled[named] = hasOne
|
|
}
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
// speaksOnTheBus says whether a module reaches the bus at all.
|
|
//
|
|
// A third of the catalogue never does (novox/hq ADR 0120), and counting those as missing a credential
|
|
// would bury the ones that matter under a list nobody can act on.
|
|
func speaksOnTheBus(m catalogue.Manifest) bool {
|
|
return len(m.Emits) > 0 || len(m.Consumes) > 0 || len(m.Tools) > 0 ||
|
|
len(m.DefinesSeats) > 0 || len(m.Uses) > 0 || len(m.Claims) > 0
|
|
}
|
|
|
|
// wasSentTheUserList says whether the machine holding the bus has had a declaration since the user
|
|
// list became part of one.
|
|
//
|
|
// Read from what the mesh recorded sending rather than asked of the machine: a machine that is away
|
|
// has still been sent it, and this question is about whether the mesh did its part.
|
|
func wasSentTheUserList(ctx context.Context, inv *inventory.Inventory, node string) bool {
|
|
digest, err := inv.Outstanding(ctx, node)
|
|
return err == nil && strings.TrimSpace(digest) != ""
|
|
}
|
|
|
|
// notReadyOf is the readiness reasoning, named here so a test can reach it without the command's
|
|
// printing. The reasoning itself is the broker package's, where it is pure.
|
|
func notReadyOf(state broker.Readiness) []string { return broker.NotReady(state) }
|