Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c2eb51878 | ||
|
|
aa2d0b51ea | ||
|
|
64d154d9d7 | ||
|
|
ffa390f916 | ||
|
|
4d62e6caf1 | ||
|
|
386ae676ca | ||
|
|
f8a9c3d6bc | ||
|
|
83671fae5f | ||
|
|
9b715524a2 | ||
|
|
e06fc1ed16 | ||
|
|
13d7c5c5dd | ||
|
|
7b02feaebb | ||
|
|
bd10e2c695 | ||
|
|
4b209d944d | ||
|
|
84024cbdb6 | ||
|
|
ad2eed2f71 | ||
|
|
e8aa7ed9e7 | ||
|
|
337aaea123 | ||
|
|
4c41628b20 | ||
|
|
ae7fb520d7 | ||
|
|
f325073982 | ||
|
|
33c4e4be34 | ||
|
|
585a6abbdd | ||
|
|
8d52a2cfb0 | ||
|
|
1cfe6be9c4 | ||
|
|
4e4481b6f2 |
@@ -614,6 +614,15 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueWith is the delivery half: the minted password sealed to the machine as the module's broker
|
||||||
|
// secret, and the module's consumer created where the bus can be reached. Split from the minting
|
||||||
|
// so the move can issue every module against a bus whose address it worked out itself
|
||||||
|
// (`rollout mint`, design 28 task 5.2) rather than the one in this process's environment.
|
||||||
|
func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest,
|
||||||
|
node, busAddress string, known broker.Broker, reachable, user, password string) error {
|
||||||
held, err := json.Marshal(struct {
|
held, err := json.Marshal(struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
@@ -639,14 +648,19 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
|||||||
Kind: broker.KindModule, Node: node, Module: m.Module,
|
Kind: broker.KindModule, Node: node, Module: m.Module,
|
||||||
Emits: m.Emits, Consumes: m.Consumes, Serves: m.Tools,
|
Emits: m.Emits, Consumes: m.Consumes, Serves: m.Tools,
|
||||||
}); needed {
|
}); needed {
|
||||||
js, err := broker.Dial(busAddress)
|
if busAddress == "" {
|
||||||
if err != nil {
|
fmt.Printf(" %s consumes; its consumer is created when the bus is reachable (`push`, then "+
|
||||||
return fmt.Errorf("the credential is minted and the mesh cannot reach the bus to create "+
|
"`rollout mint` again is harmless)\n", m.Module)
|
||||||
"how %s hears what it consumes: %w", m.Module, err)
|
} else {
|
||||||
}
|
js, err := broker.Dial(busAddress)
|
||||||
defer js.Close()
|
if err != nil {
|
||||||
if err := js.EnsureConsumer(consumer); err != nil {
|
return fmt.Errorf("the credential is minted and the mesh cannot reach the bus to create "+
|
||||||
return err
|
"how %s hears what it consumes: %w", m.Module, err)
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
if err := js.EnsureConsumer(consumer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -537,6 +537,15 @@ func onTheNetwork(ctx context.Context, inv *inventory.Inventory,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// **With the seat holders on record**, or a machine running the next holder of a seat beside
|
||||||
|
// the current one resolves as two holders, is refused, and drops out of the map — taking the
|
||||||
|
// address every other machine composes for what it offers (novox/hq ADR 0131). Found live:
|
||||||
|
// the control node vanished from the private network the moment the new bus was assigned
|
||||||
|
// beside the old one.
|
||||||
|
holdings, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
var out []inventory.Overlay
|
var out []inventory.Overlay
|
||||||
for _, p := range places {
|
for _, p := range places {
|
||||||
if p.Address == "" {
|
if p.Address == "" {
|
||||||
@@ -549,7 +558,7 @@ func onTheNetwork(ctx context.Context, inv *inventory.Inventory,
|
|||||||
caps, _ := inv.ProfileOf(ctx, p.Name)
|
caps, _ := inv.ProfileOf(ctx, p.Name)
|
||||||
got, err := catalogue.Resolve(shelf, assigned,
|
got, err := catalogue.Resolve(shelf, assigned,
|
||||||
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps},
|
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps},
|
||||||
catalogue.World{Unchecked: true})
|
catalogue.World{Unchecked: true, Holdings: holdings})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
|
|
||||||
// Every node, not only the placed ones. A machine that was never put on the private network
|
// Every node, not only the placed ones. A machine that was never put on the private network
|
||||||
// still runs modules, still holds claims, and still offers whatever it offers.
|
// still runs modules, still holds claims, and still offers whatever it offers.
|
||||||
|
// **Who holds each seat on record, before anything is resolved** (novox/hq ADR 0131). Both
|
||||||
|
// passes below need it: without it, the assignment standing beside a seat's holder — the next
|
||||||
|
// holder, waiting for the handover — is refused as a second holder, and its node's whole set
|
||||||
|
// with it.
|
||||||
|
holdings, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.World{}, err
|
||||||
|
}
|
||||||
|
|
||||||
nodes, err := inv.Nodes(ctx)
|
nodes, err := inv.Nodes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.World{}, err
|
return catalogue.World{}, err
|
||||||
@@ -227,7 +236,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
offered := map[string][]catalogue.Provider{}
|
offered := map[string][]catalogue.Provider{}
|
||||||
var firstHeld []catalogue.Held
|
var firstHeld []catalogue.Held
|
||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true})
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Their set does not resolve for some other reason. Not this node's problem to
|
// Their set does not resolve for some other reason. Not this node's problem to
|
||||||
// report, and nothing of theirs is running, so it offers nothing.
|
// report, and nothing of theirs is running, so it offers nothing.
|
||||||
@@ -258,7 +267,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
// with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the
|
// with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the
|
||||||
// holder is known. Without them its set is refused here, and a refused node's own claims drop
|
// holder is known. Without them its set is refused here, and a refused node's own claims drop
|
||||||
// out of what the mesh holds — so a second holder of one of its seats would pass unrefused.
|
// out of what the mesh holds — so a second holder of one of its seats would pass unrefused.
|
||||||
world := catalogue.World{Offered: offered, Held: firstHeld}
|
world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
|
||||||
var held []catalogue.Held
|
var held []catalogue.Held
|
||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, world)
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, world)
|
||||||
@@ -627,8 +636,13 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
|
memberships, err := inv.BusMemberships(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
return catalogue.Rendering{
|
return catalogue.Rendering{
|
||||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
BusMembership: memberships[node],
|
||||||
|
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
||||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||||
Machines: machines,
|
Machines: machines,
|
||||||
Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation,
|
Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Moving the mesh's own traffic to the bus being built (novox/hq ADR 0116 step 5).
|
// Moving the mesh's own traffic to the bus being built (novox/hq ADR 0116 step 5).
|
||||||
@@ -25,18 +27,25 @@ import (
|
|||||||
// against a mesh that is serving. It answers from records: what is missing, and what would happen.
|
// 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.
|
// `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
|
// **The old broker goes with the move, and goes last** (novox/hq ADR 0131): AMQP is not a provision,
|
||||||
// else uses it — on this installation, a whole automation layer that has nothing to do with the mesh
|
// so once every machine reports on the new bus its module is unassigned. Only the mesh's own traffic
|
||||||
// ([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)). Only the mesh's own
|
// is what moves, which is why this is survivable at all: what breaks if it goes wrong is the mesh's
|
||||||
// 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 — measured on 2026-09-27, when
|
||||||
// ability to change things, not the services its modules are serving.
|
// a seat emptied mid-change and the control plane looped for two hours while every service stayed up.
|
||||||
|
|
||||||
const rolloutUsage = "rollout check | rollout --confirm"
|
const rolloutUsage = "rollout check | rollout mint [--again] | rollout --confirm"
|
||||||
|
|
||||||
func rolloutCommand(ctx context.Context, args []string) error {
|
func rolloutCommand(ctx context.Context, args []string) error {
|
||||||
switch {
|
switch {
|
||||||
case len(args) == 1 && args[0] == "check":
|
case len(args) == 1 && args[0] == "check":
|
||||||
return rolloutCheck(ctx)
|
return rolloutCheck(ctx)
|
||||||
|
case len(args) == 1 && args[0] == "mint":
|
||||||
|
return rolloutMint(ctx, false)
|
||||||
|
case len(args) == 2 && args[0] == "mint" && args[1] == "--again":
|
||||||
|
// Every credential minted afresh, whether or not one exists — for a mint that was wrong
|
||||||
|
// before anything was pushed. Afterwards nothing that received the old one still works,
|
||||||
|
// which is fine exactly when nothing received it.
|
||||||
|
return rolloutMint(ctx, true)
|
||||||
case len(args) == 1 && args[0] == "--confirm":
|
case len(args) == 1 && args[0] == "--confirm":
|
||||||
return errors.New(
|
return errors.New(
|
||||||
"the rollout itself is not built yet: `rollout check` answers whether it could run, and " +
|
"the rollout itself is not built yet: `rollout check` answers whether it could run, and " +
|
||||||
@@ -105,7 +114,6 @@ func readinessOf(ctx context.Context, inv *inventory.Inventory) (broker.Readines
|
|||||||
ModuleCredentialled: map[string]bool{},
|
ModuleCredentialled: map[string]bool{},
|
||||||
// The old broker keeps its other clients on this installation, and saying so is how the plan
|
// The old broker keeps its other clients on this installation, and saying so is how the plan
|
||||||
// stops reading as a retirement.
|
// stops reading as a retirement.
|
||||||
OldBusHasOtherClients: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
address, _, err := broker.OnNATS()
|
address, _, err := broker.OnNATS()
|
||||||
@@ -191,3 +199,191 @@ func wasSentTheUserList(ctx context.Context, inv *inventory.Inventory, node stri
|
|||||||
// notReadyOf is the readiness reasoning, named here so a test can reach it without the command's
|
// 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.
|
// printing. The reasoning itself is the broker package's, where it is pure.
|
||||||
func notReadyOf(state broker.Readiness) []string { return broker.NotReady(state) }
|
func notReadyOf(state broker.Readiness) []string { return broker.NotReady(state) }
|
||||||
|
|
||||||
|
// rolloutMint gives every principal the new bus will have a credential it does not yet have, and
|
||||||
|
// puts each where its owner reads it (novox/hq design 28, task 5.2): a machine's as a membership
|
||||||
|
// sealed into its declaration, a module's as its broker secret, the control plane's own as its
|
||||||
|
// `bus` secret. Idempotent: what already has a hash is left alone, so running it again is harmless.
|
||||||
|
//
|
||||||
|
// **Before anything moves, and it is what makes moving possible.** A machine moved without a
|
||||||
|
// credential cannot come back, and afterwards there is no bus to tell it anything over — which is
|
||||||
|
// why `rollout check` refuses until this has run. The bus's address is worked out here, from where
|
||||||
|
// the module that provides it is assigned, rather than read from this process's environment: this
|
||||||
|
// process is still on the old bus when this runs, and must be.
|
||||||
|
func rolloutMint(ctx context.Context, again bool) error {
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
inv := open.inventory
|
||||||
|
|
||||||
|
known, err := broker.FromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the bus's certificate is not known to this process, and every membership "+
|
||||||
|
"must carry its fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var busNode, controllerNode string
|
||||||
|
for _, e := range entries {
|
||||||
|
switch {
|
||||||
|
case e.Manifest.ClaimsSeat("mesh-broker") && providesBus(e.Manifest) && len(e.On) > 0:
|
||||||
|
busNode = e.On[0]
|
||||||
|
case e.Manifest.Module == "mesh-controller" && len(e.On) > 0:
|
||||||
|
controllerNode = e.On[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if busNode == "" {
|
||||||
|
return errors.New("no assigned module provides mesh-bus and claims mesh-broker, so there is no " +
|
||||||
|
"bus to mint credentials for — register and assign it first")
|
||||||
|
}
|
||||||
|
onNetwork, err := whereEveryoneIs(ctx, inv, shelf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
busHost := onNetwork[busNode]
|
||||||
|
if busHost == "" {
|
||||||
|
// **The hub is not in that map.** The machine that took over the tunnel is where the current
|
||||||
|
// bus already answers, and every machine dials it at the address the mesh handed them — so
|
||||||
|
// when the new bus runs on the same machine, that address is the one to tell them, with the
|
||||||
|
// new port. Found live: the control node is the hub, and the map lists the machines placed
|
||||||
|
// around it.
|
||||||
|
// The host alone: no scheme (BareAddress adds one where none was, which is the wrong
|
||||||
|
// direction here — every URL built below adds its own) and no port.
|
||||||
|
_, _, host := broker.CredentialIn(known.Address)
|
||||||
|
if host == "" {
|
||||||
|
host = known.Address
|
||||||
|
}
|
||||||
|
if _, after, hasScheme := strings.Cut(host, "://"); hasScheme {
|
||||||
|
host = after
|
||||||
|
}
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if i := strings.LastIndex(host, ":"); i > 0 && !strings.Contains(host[i:], "]") {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("%s runs the new bus and has no address on the private network, and the "+
|
||||||
|
"current bus's address is unknown too, so no machine could be told where it is", busNode)
|
||||||
|
}
|
||||||
|
busHost = host
|
||||||
|
}
|
||||||
|
busAddress := busHost + ":4222"
|
||||||
|
|
||||||
|
records, err := inv.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept, err := inv.BusUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hashes := make(map[string]string, len(kept))
|
||||||
|
for name, u := range kept {
|
||||||
|
hashes[name] = u.PasswordHash
|
||||||
|
}
|
||||||
|
_, missing := broker.WithPasswords(users, hashes)
|
||||||
|
wanted := map[string]bool{}
|
||||||
|
for _, m := range missing {
|
||||||
|
wanted[m] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var machines, modules, skipped int
|
||||||
|
for _, p := range users {
|
||||||
|
if !again && !wanted[p.Username()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch p.Kind {
|
||||||
|
case broker.KindController:
|
||||||
|
if controllerNode == "" {
|
||||||
|
return errors.New("the control plane is not assigned anywhere, so its credential has nowhere to go")
|
||||||
|
}
|
||||||
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusController})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := "nats://" + p.Username() + ":" + password + "@" + busAddress
|
||||||
|
if err := inv.AcceptSecretForModule(ctx, controllerNode, "mesh-controller", "bus", url); err != nil {
|
||||||
|
return fmt.Errorf("the control plane's credential is minted and could not be sealed to %s: %w", controllerNode, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("control plane: credential minted, sealed to %s as its `bus` secret\n", controllerNode)
|
||||||
|
|
||||||
|
case broker.KindNode:
|
||||||
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusNode, Node: p.Node})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
membership, _ := json.Marshal(map[string]string{
|
||||||
|
"broker": busAddress, "fingerprint": known.Fingerprint, "password": password, "transport": "nats",
|
||||||
|
})
|
||||||
|
key, err := inv.SealingKeyOf(ctx, p.Node)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s has no sealing key, so its membership cannot be sealed to it: %w", p.Node, err)
|
||||||
|
}
|
||||||
|
sealed, err := secrets.Seal(key, membership)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := inv.PutBusMembership(ctx, p.Node, sealed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
machines++
|
||||||
|
|
||||||
|
case broker.KindModule:
|
||||||
|
if p.Module == "mesh-controller" {
|
||||||
|
// The control plane is a module too, and its `broker` secret is the old bus's
|
||||||
|
// credential it is still using while this runs. Writing the new bus's blob there
|
||||||
|
// cut the mesh off from its own old bus mid-move (2026-09-28). Its new-bus credential
|
||||||
|
// is the controller principal's `bus` secret above; nothing else is needed here.
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, inShelf := shelf[p.Module]
|
||||||
|
if !inShelf {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, reads := m.OwnSecrets["broker"]; !reads {
|
||||||
|
fmt.Printf(" %s on %s speaks on the bus but declares no `broker` secret to receive a credential in; skipped\n", p.Module, p.Node)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusModule, Node: p.Node, Module: p.Module})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := issueWith(ctx, inv, m, p.Node, "", known, busAddress, p.Username(), password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
modules++
|
||||||
|
|
||||||
|
default:
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("minted for %d machine(s) and %d module runtime(s); %d skipped; the bus is at %s\n",
|
||||||
|
machines, modules, skipped, busAddress)
|
||||||
|
fmt.Println(" each machine's membership and each module's credential arrive with the next push of its machine;")
|
||||||
|
fmt.Println(" push the machine running the bus first, so the bus stands with its user list before anything dials it")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// providesBus is whether a manifest provides the mesh's bus.
|
||||||
|
func providesBus(m catalogue.Manifest) bool {
|
||||||
|
for _, o := range m.Provides {
|
||||||
|
if o.Name == "mesh-bus" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
@@ -85,7 +86,8 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata
|
|||||||
return rows, outside
|
return rows, outside
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122).
|
// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122) — and, since
|
||||||
|
// ADR 0131, changes who holds a seat.
|
||||||
func seatCommand(ctx context.Context, args []string) error {
|
func seatCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 3 && args[0] == "rename" {
|
if len(args) == 3 && args[0] == "rename" {
|
||||||
from, to := args[1], args[2]
|
from, to := args[1], args[2]
|
||||||
@@ -101,7 +103,101 @@ func seatCommand(ctx context.Context, args []string) error {
|
|||||||
"re-registered or frozen (novox/hq ADR 0122)\n", from, to)
|
"re-registered or frozen (novox/hq ADR 0122)\n", from, to)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("seat rename <from> <to>")
|
if len(args) == 3 && args[1] == "--to" {
|
||||||
|
return handOver(ctx, args[0], args[2])
|
||||||
|
}
|
||||||
|
return fmt.Errorf("seat rename <from> <to> | seat <name> --to <node>/<module>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handOver makes one assignment the holder of a seat, as one act, so the seat is never without a
|
||||||
|
// holder in between (novox/hq ADR 0131, design 28 task 5.3). The control plane finds its own bus
|
||||||
|
// through one of these seats; the day it was left empty mid-change is why this exists.
|
||||||
|
//
|
||||||
|
// Everything that could make the new holder wrong is refused here, before the row is written: the
|
||||||
|
// seat must exist, the assignment must exist, and the module must be able to hold the seat —
|
||||||
|
// claim it at its scope and provide what it delivers, judged against the store's row. What is
|
||||||
|
// **not** checked is whether the module is running yet: that is what `push` confirms afterwards,
|
||||||
|
// and refusing to record a handover to a module the node has not started would make the handover
|
||||||
|
// impossible to do before the switch instead of as the switch.
|
||||||
|
func handOver(ctx context.Context, seatName, to string) error {
|
||||||
|
nodeName, module, ok := strings.Cut(to, "/")
|
||||||
|
if !ok || nodeName == "" || module == "" {
|
||||||
|
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
||||||
|
}
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
inv := open.inventory
|
||||||
|
|
||||||
|
seat, known := catalogue.SeatNamed(seatName)
|
||||||
|
if !known {
|
||||||
|
return fmt.Errorf("%q is not a seat this mesh defines — `seats` lists them", seatName)
|
||||||
|
}
|
||||||
|
assigned, err := inv.Assigned(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !slices.Contains(assigned, module) {
|
||||||
|
return fmt.Errorf("%s is not assigned to %s, so it cannot hold anything there — "+
|
||||||
|
"`assign %s %s` first", module, nodeName, nodeName, module)
|
||||||
|
}
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var m *catalogue.Manifest
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Manifest.Module == module {
|
||||||
|
m = &entries[i].Manifest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("%s is assigned but not in the catalogue, which should not happen", module)
|
||||||
|
}
|
||||||
|
var was string
|
||||||
|
holdings, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, h := range holdings {
|
||||||
|
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name {
|
||||||
|
was = h.Node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Recording who already holds the seat is not making a new holder, and is not judged like
|
||||||
|
// one.** On a mesh that predates the record, the first handover has to begin by writing down
|
||||||
|
// the standing holder — otherwise the next holder cannot be assigned beside it, because two
|
||||||
|
// eligible claimants with nothing on record are refused. That standing holder may no longer
|
||||||
|
// satisfy what the seat delivers (the row moved under it, on purpose, as ADR 0131's first step),
|
||||||
|
// and it holds regardless: derivation never read that column. So when nothing is on record and
|
||||||
|
// the named assignment is the one holding by derivation, only the claim itself is checked here.
|
||||||
|
// Every *change* of holder is judged in full.
|
||||||
|
claimsIt := false
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if cs, ok := catalogue.SeatNamed(c.Name); ok && cs.Name == seat.Name && c.At() == seat.Scope {
|
||||||
|
claimsIt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if was == "" && claimsIt {
|
||||||
|
fmt.Printf("nothing was on record for %s; recording %s on %s as its standing holder\n",
|
||||||
|
seat.Name, module, nodeName)
|
||||||
|
} else if err := catalogue.CanHold(*m, seat); err != nil {
|
||||||
|
return fmt.Errorf("%s cannot hold %s: %w", module, seat.Name, err)
|
||||||
|
}
|
||||||
|
if err := inv.HoldSeat(ctx, seat.Name, seat.Scope, nodeName, module); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s is held by %s on %s\n", seat.Name, module, nodeName)
|
||||||
|
if was != "" && was != nodeName {
|
||||||
|
fmt.Printf(" `push %s` and `push %s` send both machines what changed\n", was, nodeName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" `push %s` sends the machine what changed; every other machine that reads the "+
|
||||||
|
"seat is re-declared by `push --behind`\n", nodeName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func seatsCommand(ctx context.Context, args []string) error {
|
func seatsCommand(ctx context.Context, args []string) error {
|
||||||
|
|||||||
@@ -244,7 +244,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
case KindNode:
|
case KindNode:
|
||||||
// A host publishes its own node's control traffic and subscribes its own declaration —
|
// A host publishes its own node's control traffic and subscribes its own declaration —
|
||||||
// and nothing of any other node's.
|
// and nothing of any other node's.
|
||||||
pub = []string{"mesh.control." + p.Node + ".>"}
|
// And binding to its consumer, which asks the server about it (CONSUMER.INFO) — the one
|
||||||
|
// thing the host does that nothing granted. Found the first time a machine dialled a
|
||||||
|
// permissioned server: "this node cannot read its declarations" (2026-09-28). The ack and
|
||||||
|
// the inbox are granted below with every principal's.
|
||||||
|
pub = []string{
|
||||||
|
"mesh.control." + p.Node + ".>",
|
||||||
|
"$JS.API.CONSUMER.INFO.NODES." + p.Node,
|
||||||
|
}
|
||||||
sub = []string{"mesh.node." + p.Node + ".declare"}
|
sub = []string{"mesh.node." + p.Node + ".declare"}
|
||||||
|
|
||||||
case KindModule:
|
case KindModule:
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ type Readiness struct {
|
|||||||
Modules []string
|
Modules []string
|
||||||
// ModuleCredentialled is which of those has one.
|
// ModuleCredentialled is which of those has one.
|
||||||
ModuleCredentialled map[string]bool
|
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.
|
// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them.
|
||||||
@@ -120,10 +116,11 @@ func WhatMoves(r Readiness) []string {
|
|||||||
out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers",
|
out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers",
|
||||||
len(r.Modules)))
|
len(r.Modules)))
|
||||||
}
|
}
|
||||||
if r.OldBusHasOtherClients {
|
// **The old broker goes, and it goes last** (novox/hq ADR 0131). AMQP is not a provision, so once
|
||||||
out = append(out, "leave the old broker running: it stays an ordinary provider of `amqp` for "+
|
// every machine reports on the new bus nothing of the mesh is left speaking to it, and its module
|
||||||
"whatever else uses it (ADR 0119), and this move is not its retirement")
|
// is unassigned. Said as a step so nobody reads the move as leaving a second bus behind.
|
||||||
}
|
out = append(out, "then unassign the old broker's module: AMQP is not a provision (ADR 0131), and "+
|
||||||
|
"once every machine reports on the new bus nothing of the mesh speaks to it")
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,8 @@ func TestEachThingMissingNamesItsOwnRemedy(t *testing.T) {
|
|||||||
|
|
||||||
// What the move would do is written out rather than summarised, because this is the one step with
|
// 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.
|
// nothing to inspect afterwards — so reading it is the last chance to disagree.
|
||||||
func TestWhatMovesNamesEveryMachineAndSaysTheOldBrokerStays(t *testing.T) {
|
func TestWhatMovesNamesEveryMachineAndEndsWithTheOldBrokerGoing(t *testing.T) {
|
||||||
r := aMeshReadyToMove()
|
r := aMeshReadyToMove()
|
||||||
r.OldBusHasOtherClients = true
|
|
||||||
steps := strings.Join(WhatMoves(r), "\n")
|
steps := strings.Join(WhatMoves(r), "\n")
|
||||||
|
|
||||||
for _, want := range []string{"anchor", "laptop", "user list", "module runtime"} {
|
for _, want := range []string{"anchor", "laptop", "user list", "module runtime"} {
|
||||||
@@ -89,9 +88,11 @@ func TestWhatMovesNamesEveryMachineAndSaysTheOldBrokerStays(t *testing.T) {
|
|||||||
t.Errorf("the plan does not mention %q:\n%s", want, steps)
|
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
|
// Said explicitly, and last: AMQP is not a provision (novox/hq ADR 0131), so the move ends with
|
||||||
// whatever else uses it, and that is a decision already taken.
|
// the old broker's module unassigned, not left behind as a second bus. An earlier version of this
|
||||||
if !strings.Contains(steps, "not its retirement") {
|
// test pinned the opposite, under a record 0131 superseded.
|
||||||
t.Errorf("the plan does not say the old broker stays:\n%s", steps)
|
lines := WhatMoves(r)
|
||||||
|
if last := lines[len(lines)-1]; !strings.Contains(last, "unassign the old broker") {
|
||||||
|
t.Errorf("the plan does not end with the old broker going:\n%s", steps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ accounts {
|
|||||||
subscribe: { allow: ["_INBOX.enrol.one.>"] }
|
subscribe: { allow: ["_INBOX.enrol.one.>"] }
|
||||||
} }
|
} }
|
||||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.NODES.one.>", "mesh.control.one.>"] }
|
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "mesh.control.one.>"] }
|
||||||
subscribe: { allow: ["_INBOX.node.one.>", "mesh.node.one.declare"] }
|
subscribe: { allow: ["_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||||
} }
|
} }
|
||||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **The word does not come back through a manifest** (novox/hq ADR 0131). A module that wants
|
||||||
|
// messaging wants the mesh's bus, reached through the sdk and named by the `mesh-broker` seat. Naming
|
||||||
|
// the old wire protocol asks for the one server being retired, so both directions are refused at the
|
||||||
|
// parser — this is judged from the manifest alone, no store needed.
|
||||||
|
|
||||||
|
func TestAManifestProvidingAmqpIsRefused(t *testing.T) {
|
||||||
|
raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`)
|
||||||
|
_, err := ParseManifest(raw)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) {
|
||||||
|
t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAManifestRequiringAmqpIsRefused(t *testing.T) {
|
||||||
|
raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`)
|
||||||
|
_, err := ParseManifest(raw)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) {
|
||||||
|
t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the catalogue as checked out beside this repository names it nowhere — the three modules that
|
||||||
|
// did are removed under design 28 task 5.4, not converted.
|
||||||
|
func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
|
||||||
|
modules, err := filepath.Glob("../../../mesh-catalog/modules/*/module.json")
|
||||||
|
if err != nil || len(modules) == 0 {
|
||||||
|
t.Skip("the catalogue is not checked out beside this repository")
|
||||||
|
}
|
||||||
|
for _, path := range modules {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), `"amqp"`) {
|
||||||
|
t.Errorf("%s names amqp, which is not a provision (novox/hq ADR 0131)",
|
||||||
|
filepath.Base(filepath.Dir(path)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,23 +46,9 @@ func TestTheSeatRefusesADifferentBusToo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The old broker no longer claims the seat: it is an ordinary provider of `amqp`
|
// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no
|
||||||
// (novox/hq ADR 0119), so it can sit on the same mesh as the bus without contending for it.
|
// longer a fixture here. That two eligible holders stand beside each other with one on record is
|
||||||
func TestTheAmqpBrokerDoesNotContendForTheSeat(t *testing.T) {
|
// pinned in holdings_test.go against manifests this package owns.
|
||||||
lavinmq := catalogueManifest(t, "lavinmq")
|
|
||||||
for _, c := range lavinmq.Claims {
|
|
||||||
if c.Name == "mesh-broker" {
|
|
||||||
t.Fatal("the amqp broker still claims mesh-broker; it is a provider, not foundation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
busHeld := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh,
|
|
||||||
Node: "anchor", Module: "nats"}}}
|
|
||||||
other := workstation()
|
|
||||||
other.Name = "laptop"
|
|
||||||
if _, err := Resolve(shelf(lavinmq), []string{"lavinmq"}, other, busHeld); err != nil {
|
|
||||||
t.Fatalf("the amqp broker was refused beside the mesh bus: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A seat and the interface it delivers are different names, and renaming one must not rename
|
// **A seat and the interface it delivers are different names, and renaming one must not rename
|
||||||
// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*`
|
// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*`
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ type Rendering struct {
|
|||||||
// **Only the users, never the server's own settings**: those are the module's, in its image and
|
// **Only the users, never the server's own settings**: those are the module's, in its image and
|
||||||
// its mounts (Manifest.BusUsers).
|
// its mounts (Manifest.BusUsers).
|
||||||
BusUsers string
|
BusUsers string
|
||||||
|
// BusMembership is this machine's membership for the bus the mesh is moving to, sealed to it
|
||||||
|
// (design 28, task 5.2). Empty for a machine not being moved. Written as a file the host reads
|
||||||
|
// after the declaration has applied, so the bus it names is standing before the machine leaves
|
||||||
|
// the one it is on.
|
||||||
|
BusMembership string
|
||||||
|
|
||||||
// MeshRange is the private network's CIDR (the range node addresses are allocated from), for a
|
// MeshRange is the private network's CIDR (the range node addresses are allocated from), for a
|
||||||
// module that must name the whole mesh rather than one machine — an intrusion filter that must
|
// module that must name the whole mesh rather than one machine — an intrusion filter that must
|
||||||
@@ -238,9 +243,23 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Composed{}, err
|
return Composed{}, err
|
||||||
}
|
}
|
||||||
|
if with.BusMembership != "" {
|
||||||
|
// The machine's own, not any module's: how it reaches the mesh from now on. Sealed like a
|
||||||
|
// secret and placed where the host looks for exactly this (design 28, task 5.2).
|
||||||
|
resources = append(resources, map[string]any{
|
||||||
|
"id": BusMembershipID(), "type": "file", "path": BusMembershipPath,
|
||||||
|
"sealed": with.BusMembership, "mode": "0600",
|
||||||
|
})
|
||||||
|
}
|
||||||
return Composed{Resources: resources, Owner: owner}, nil
|
return Composed{Resources: resources, Owner: owner}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||||
|
// BusMembershipPath is where the host reads it — the same constant on both sides.
|
||||||
|
func BusMembershipID() string { return "bus-membership" }
|
||||||
|
|
||||||
|
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||||
|
|
||||||
func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[string]any, error) {
|
func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[string]any, error) {
|
||||||
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
||||||
// credentials and contributions land are resolved against this node's directories, so every
|
// credentials and contributions land are resolved against this node's directories, so every
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ func TestTheStoreAndTheBrokerSayWhatTheMeshGuards(t *testing.T) {
|
|||||||
if got := catalogueManifest(t, "postgres").Guards; !reflect.DeepEqual(got, []int{5432}) {
|
if got := catalogueManifest(t, "postgres").Guards; !reflect.DeepEqual(got, []int{5432}) {
|
||||||
t.Errorf("postgres guards %v; the store's port must be refused from outside", got)
|
t.Errorf("postgres guards %v; the store's port must be refused from outside", got)
|
||||||
}
|
}
|
||||||
if got := catalogueManifest(t, "lavinmq").Guards; !reflect.DeepEqual(got, []int{15672}) {
|
// The bus's monitoring port, not its client port: a node reaches the bus, nobody outside
|
||||||
t.Errorf("lavinmq guards %v; the management port must be refused from outside", got)
|
// reads its state (novox/hq ADR 0131 — the broker that guarded 15672 has left the catalogue).
|
||||||
|
if got := catalogueManifest(t, "nats").Guards; !reflect.DeepEqual(got, []int{8222}) {
|
||||||
|
t.Errorf("nats guards %v; the monitoring port must be refused from outside", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A seat's holder on record settles who holds it, and lets the next holder stand beside the
|
||||||
|
// current one** (novox/hq ADR 0131, design 28 task 5.3). Until the record existed, two assignments
|
||||||
|
// whose modules both claimed a seat were refused outright — which left no way to hand a seat over
|
||||||
|
// without a moment where nobody held it, and the control plane finds its own bus through one of
|
||||||
|
// these seats. That moment was the outage of 2026-09-27.
|
||||||
|
|
||||||
|
func busSeatDelivering(t *testing.T, delivers string) {
|
||||||
|
t.Helper()
|
||||||
|
was := Seats()
|
||||||
|
t.Cleanup(func() { UseSeats(was) })
|
||||||
|
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func oldBroker() Manifest {
|
||||||
|
return Manifest{Module: "old-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||||
|
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBroker() Manifest {
|
||||||
|
return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||||
|
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing on record: exactly the old rule. One claimant holds; two are refused.
|
||||||
|
func TestWithNoHolderOnRecordTheSoleClaimantHoldsAndTwoAreRefused(t *testing.T) {
|
||||||
|
busSeatDelivering(t, "mesh-bus")
|
||||||
|
node := Node{Name: "anchor"}
|
||||||
|
|
||||||
|
held, problems := checkClaims([]Manifest{oldBroker()}, node, nil, nil)
|
||||||
|
if len(problems) != 0 || len(held) != 1 || held[0].Module != "old-broker" {
|
||||||
|
t.Fatalf("a sole claimant did not hold the seat: held=%v problems=%v", held, problems)
|
||||||
|
}
|
||||||
|
_, problems = checkClaims([]Manifest{oldBroker(), newBroker()}, node, nil, nil)
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], "both claim") {
|
||||||
|
t.Fatalf("two claimants with nothing on record were not refused: %v", problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a holder on record, the other eligible assignment is silent: not refused, and not holding.
|
||||||
|
func TestTheHolderOnRecordHoldsAndTheOtherClaimantStandsBesideIt(t *testing.T) {
|
||||||
|
busSeatDelivering(t, "mesh-bus")
|
||||||
|
node := Node{Name: "anchor"}
|
||||||
|
record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||||
|
|
||||||
|
held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, node, nil, record)
|
||||||
|
if len(problems) != 0 {
|
||||||
|
t.Fatalf("the assignment beside the holder was refused: %v", problems)
|
||||||
|
}
|
||||||
|
if len(held) != 1 || held[0].Module != "new-broker" {
|
||||||
|
t.Fatalf("the holder on record is not the one holding: %v", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The record names a node too: an eligible module on another machine holds nothing, and its
|
||||||
|
// machine's set still resolves.
|
||||||
|
func TestAHolderOnRecordElsewhereLeavesThisMachinesClaimantSilent(t *testing.T) {
|
||||||
|
busSeatDelivering(t, "mesh-bus")
|
||||||
|
record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||||
|
|
||||||
|
held, problems := checkClaims([]Manifest{oldBroker()}, Node{Name: "laptop"}, nil, record)
|
||||||
|
if len(problems) != 0 || len(held) != 0 {
|
||||||
|
t.Fatalf("a claimant elsewhere than the recorded holder was not simply silent: held=%v problems=%v",
|
||||||
|
held, problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A record naming a seat's former name still applies to it after a rename (ADR 0122).
|
||||||
|
func TestAHolderRecordedUnderAFormerNameStillHolds(t *testing.T) {
|
||||||
|
busSeatDelivering(t, "mesh-bus")
|
||||||
|
wasAliases := aliases
|
||||||
|
t.Cleanup(func() { UseAliases(wasAliases) })
|
||||||
|
UseAliases(map[string]string{"the-broker": "mesh-broker"})
|
||||||
|
record := []Held{{Claim: "the-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||||
|
|
||||||
|
held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, Node{Name: "anchor"}, nil, record)
|
||||||
|
if len(problems) != 0 || len(held) != 1 || held[0].Module != "new-broker" {
|
||||||
|
t.Fatalf("a record under the former name did not settle the seat: held=%v problems=%v", held, problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanHold is the one judgement registration and the handover share, against the store's row.
|
||||||
|
func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
||||||
|
busSeatDelivering(t, "mesh-bus")
|
||||||
|
seat, _ := SeatNamed("mesh-broker")
|
||||||
|
|
||||||
|
if err := CanHold(newBroker(), seat); err != nil {
|
||||||
|
t.Fatalf("a module that claims the seat and provides what it delivers was refused: %v", err)
|
||||||
|
}
|
||||||
|
noClaim := Manifest{Module: "quiet", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}}
|
||||||
|
if err := CanHold(noClaim, seat); err == nil || !strings.Contains(err.Error(), "does not claim") {
|
||||||
|
t.Fatalf("a module that never claimed the seat was allowed to hold it: %v", err)
|
||||||
|
}
|
||||||
|
wrongScope := newBroker()
|
||||||
|
wrongScope.Claims[0].Scope = ScopeNode
|
||||||
|
if err := CanHold(wrongScope, seat); err == nil || !strings.Contains(err.Error(), "scope") {
|
||||||
|
t.Fatalf("a claim at the wrong scope was allowed: %v", err)
|
||||||
|
}
|
||||||
|
cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}},
|
||||||
|
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||||
|
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
||||||
|
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
||||||
|
}
|
||||||
|
// And the judgement follows the store's row, not a compiled copy.
|
||||||
|
busSeatDelivering(t, "amqp")
|
||||||
|
seat, _ = SeatNamed("mesh-broker")
|
||||||
|
if err := CanHold(cannotAnswer, seat); err != nil {
|
||||||
|
t.Fatalf("with the row saying amqp, an amqp provider was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine being moved is handed its membership for the new bus as a sealed file in its own
|
||||||
|
// declaration — the machine's, not any module's (design 28, task 5.2).
|
||||||
|
func TestAMembershipForTheNewBusIsComposedAsASealedFile(t *testing.T) {
|
||||||
|
r := Resolution{Node: "anchor"}
|
||||||
|
got, err := r.Compose(Rendering{BusMembership: "sealed-blob"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var found map[string]any
|
||||||
|
for _, res := range got.Resources {
|
||||||
|
if res["id"] == BusMembershipID() {
|
||||||
|
found = res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("no membership resource in %v", got.Resources)
|
||||||
|
}
|
||||||
|
if found["path"] != BusMembershipPath || found["sealed"] != "sealed-blob" || found["mode"] != "0600" {
|
||||||
|
t.Fatalf("the membership is not a sealed 0600 file where the host reads it: %v", found)
|
||||||
|
}
|
||||||
|
// And a machine not being moved is handed nothing.
|
||||||
|
got, _ = r.Compose(Rendering{})
|
||||||
|
for _, res := range got.Resources {
|
||||||
|
if res["id"] == BusMembershipID() {
|
||||||
|
t.Fatal("a machine with no membership on record was handed one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1027,6 +1027,28 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug))
|
"%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug))
|
||||||
}
|
}
|
||||||
|
// **`amqp` is not a provision, and not a requirement** (novox/hq ADR 0131). A module that wants
|
||||||
|
// messaging wants the mesh's bus — it emits and consumes through the sdk, which the mesh hands the
|
||||||
|
// bus with the module's own credential — and the bus is whatever holds `mesh-broker`, spoken in
|
||||||
|
// whatever that holder speaks. Naming the old wire protocol asks for a specific server, and the
|
||||||
|
// only one that could answer is the one being retired. Refused here so the word cannot come back
|
||||||
|
// through a manifest.
|
||||||
|
for _, offer := range m.Provides {
|
||||||
|
if offer.Name == "amqp" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s provides %q, which is not a provision: the mesh's bus is whatever holds "+
|
||||||
|
"mesh-broker, and a module provides mesh-bus to be it (novox/hq ADR 0131)",
|
||||||
|
m.Module, offer.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range m.Requires {
|
||||||
|
if r == "amqp" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s requires %q, which is not a provision: a module reaches the mesh's bus through "+
|
||||||
|
"the sdk, and depends on the mesh-broker seat, not on a protocol (novox/hq ADR 0131)",
|
||||||
|
m.Module, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, offer := range m.Provides {
|
for _, offer := range m.Provides {
|
||||||
p := offer.Name
|
p := offer.Name
|
||||||
if !name.MatchString(p) {
|
if !name.MatchString(p) {
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ type Node struct {
|
|||||||
type World struct {
|
type World struct {
|
||||||
// Held is the claims already taken, for the scopes wider than one node.
|
// Held is the claims already taken, for the scopes wider than one node.
|
||||||
Held []Held
|
Held []Held
|
||||||
|
// Holdings is every seat whose holder is **on record** (novox/hq ADR 0131): the one assignment
|
||||||
|
// that holds it, chosen by a handover. A seat absent here is held by derivation — the sole
|
||||||
|
// eligible assignment — as it always was. Present, it decides, and any other assignment whose
|
||||||
|
// module could hold the seat is eligible and silent rather than refused.
|
||||||
|
Holdings []Held
|
||||||
// Offered is what other nodes provide at mesh scope, and everything needed to use it.
|
// Offered is what other nodes provide at mesh scope, and everything needed to use it.
|
||||||
Offered map[string][]Provider
|
Offered map[string][]Provider
|
||||||
// Pinned is which node this machine was told to get a provision from, by name. Only consulted
|
// Pinned is which node this machine was told to get a provision from, by name. Only consulted
|
||||||
@@ -557,7 +562,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
}
|
}
|
||||||
|
|
||||||
problems = append(problems, checkCapabilities(resolution.Modules, node)...)
|
problems = append(problems, checkCapabilities(resolution.Modules, node)...)
|
||||||
claims, claimProblems := checkClaims(resolution.Modules, node, elsewhere)
|
claims, claimProblems := checkClaims(resolution.Modules, node, elsewhere, world.Holdings)
|
||||||
problems = append(problems, claimProblems...)
|
problems = append(problems, claimProblems...)
|
||||||
problems = append(problems, checkResources(resolution.Modules)...)
|
problems = append(problems, checkResources(resolution.Modules)...)
|
||||||
resolution.Claims = claims
|
resolution.Claims = claims
|
||||||
@@ -650,14 +655,35 @@ func checkCapabilities(modules []Manifest, node Node) []string {
|
|||||||
//
|
//
|
||||||
// Within this node's own set, and against what is already held elsewhere for the wider scopes. A
|
// Within this node's own set, and against what is already held elsewhere for the wider scopes. A
|
||||||
// claim at mesh scope is the same idea as the mesh's one hub, said once instead of hard-coded.
|
// claim at mesh scope is the same idea as the mesh's one hub, said once instead of hard-coded.
|
||||||
func checkClaims(modules []Manifest, node Node, elsewhere []Held) ([]Held, []string) {
|
func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Held) ([]Held, []string) {
|
||||||
var problems []string
|
var problems []string
|
||||||
var held []Held
|
var held []Held
|
||||||
|
|
||||||
|
// onRecord is the recorded holder of a seat, if a handover ever named one.
|
||||||
|
onRecord := func(claim, scope string) (Held, bool) {
|
||||||
|
for _, h := range holdings {
|
||||||
|
hs, ok := SeatNamed(h.Claim)
|
||||||
|
cs, cok := SeatNamed(claim)
|
||||||
|
if ok && cok && hs.Name == cs.Name && h.Scope == scope {
|
||||||
|
return h, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Held{}, false
|
||||||
|
}
|
||||||
|
|
||||||
byScope := map[string]map[string]string{} // scope → claim → module
|
byScope := map[string]map[string]string{} // scope → claim → module
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
scope := c.At()
|
scope := c.At()
|
||||||
|
// **A recorded holder settles it before any counting.** An assignment that could hold
|
||||||
|
// the seat but is not the one on record is eligible, and that is all: it is not a second
|
||||||
|
// holder, so it is not refused, and it does not hold (novox/hq ADR 0131). This is what
|
||||||
|
// lets the next holder stand beside the current one until the seat is handed over.
|
||||||
|
if rec, recorded := onRecord(c.Name, scope); recorded {
|
||||||
|
if rec.Node != node.Name || rec.Module != m.Module {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if byScope[scope] == nil {
|
if byScope[scope] == nil {
|
||||||
byScope[scope] = map[string]string{}
|
byScope[scope] = map[string]string{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,31 @@ func claimProblems(m Manifest) []string {
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanHold is why a module could not hold a seat, or nothing: its definition must claim the seat at
|
||||||
|
// the seat's scope, and provide what the seat delivers, if it delivers anything. The seat is the
|
||||||
|
// store's row, so this is judged only where the store's set is loaded — at registration and in the
|
||||||
|
// handover command (novox/hq ADR 0131), never in the parser.
|
||||||
|
func CanHold(m Manifest, seat Seat) error {
|
||||||
|
var claimed *Claim
|
||||||
|
for i := range m.Claims {
|
||||||
|
if hs, ok := SeatNamed(m.Claims[i].Name); ok && hs.Name == seat.Name {
|
||||||
|
claimed = &m.Claims[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claimed == nil {
|
||||||
|
return fmt.Errorf("%s does not claim %s", m.Module, seat.Name)
|
||||||
|
}
|
||||||
|
if claimed.At() != seat.Scope {
|
||||||
|
return fmt.Errorf("%s claims %s at scope %q, and %s is a %s seat",
|
||||||
|
m.Module, seat.Name, claimed.At(), seat.Name, seat.Scope)
|
||||||
|
}
|
||||||
|
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) {
|
||||||
|
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
||||||
|
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func providesAt(m Manifest, provision, scope string) bool {
|
func providesAt(m Manifest, provision, scope string) bool {
|
||||||
for _, o := range m.Provides {
|
for _, o := range m.Provides {
|
||||||
if o.Name == provision && o.At() == scope {
|
if o.Name == provision && o.At() == scope {
|
||||||
|
|||||||
@@ -195,15 +195,8 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
// The parser cannot do it — it also runs on the build machine, against whatever
|
// The parser cannot do it — it also runs on the build machine, against whatever
|
||||||
// set that binary was compiled with.
|
// set that binary was compiled with.
|
||||||
seat, _ := SeatNamed(c.Name)
|
seat, _ := SeatNamed(c.Name)
|
||||||
if c.At() != seat.Scope {
|
if err := CanHold(m, seat); err != nil {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, err.Error())
|
||||||
"%s claims %s at scope %q, and %s is a %s seat",
|
|
||||||
module, c.Name, c.At(), c.Name, seat.Scope))
|
|
||||||
}
|
|
||||||
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
|
||||||
module, c.Name, seat.Delivers, module, seat.Delivers, seat.Scope))
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) {
|
|||||||
t.Cleanup(func() { UseSeats(was) })
|
t.Cleanup(func() { UseSeats(was) })
|
||||||
|
|
||||||
// A store whose bus seat delivers something this module does provide.
|
// A store whose bus seat delivers something this module does provide.
|
||||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "amqp", Decision: "test"}})
|
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
|
||||||
raw := []byte(`{"module":"lavinmq","version":"1",` +
|
raw := []byte(`{"module":"a-bus","version":"1",` +
|
||||||
`"provides":[{"name":"amqp","scope":"mesh"}],` +
|
`"provides":[{"name":"mesh-bus","scope":"mesh"}],` +
|
||||||
`"claims":[{"name":"mesh-broker","scope":"mesh"}]}`)
|
`"claims":[{"name":"mesh-broker","scope":"mesh"}]}`)
|
||||||
|
|
||||||
m, err := ParseManifest(raw)
|
m, err := ParseManifest(raw)
|
||||||
@@ -135,12 +135,12 @@ func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// And with the store saying the seat delivers something else, registration is what refuses it.
|
// And with the store saying the seat delivers something else, registration is what refuses it.
|
||||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
|
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "other-bus", Decision: "test"}})
|
||||||
if _, err := ParseManifest(raw); err != nil {
|
if _, err := ParseManifest(raw); err != nil {
|
||||||
t.Fatalf("the parser judged it the second time: %v", err)
|
t.Fatalf("the parser judged it the second time: %v", err)
|
||||||
}
|
}
|
||||||
got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ")
|
got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ")
|
||||||
if !strings.Contains(got, `does not provide "mesh-bus"`) {
|
if !strings.Contains(got, `does not provide "other-bus"`) {
|
||||||
t.Fatalf("registration did not refuse a holder that cannot answer for the seat: %q", got)
|
t.Fatalf("registration did not refuse a holder that cannot answer for the seat: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,3 +230,35 @@ func (i *Inventory) ForgetPerson(ctx context.Context, name string) error {
|
|||||||
}
|
}
|
||||||
return i.ForgetBusUser(ctx, "person."+name)
|
return i.ForgetBusUser(ctx, "person."+name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PutBusMembership records a machine's membership for the new bus, sealed to it (design 28, 5.2).
|
||||||
|
// Replaces any earlier one: a machine has one membership per bus, and re-minting is re-telling.
|
||||||
|
func (i *Inventory) PutBusMembership(ctx context.Context, nodeName, sealed string) error {
|
||||||
|
node, err := i.NodeByName(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
|
`insert into bus_membership (node, sealed) values ($1, $2)
|
||||||
|
on conflict (node) do update set sealed = excluded.sealed, since = now()`, node.ID, sealed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BusMemberships is every machine's sealed membership for the new bus, by node name.
|
||||||
|
func (i *Inventory) BusMemberships(ctx context.Context) (map[string]string, error) {
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
`select n.name, b.sealed from bus_membership b join node n on n.id = b.node`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var name, sealed string
|
||||||
|
if err := rows.Scan(&name, &sealed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[name] = sealed
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A seat's holder is a row, changed as one act (novox/hq ADR 0131). What these pin is the shape of
|
||||||
|
// that row's life: it needs an assignment to point at, it is replaced rather than added to, and it
|
||||||
|
// goes when the assignment does — so a seat never points at something that is not running anywhere.
|
||||||
|
|
||||||
|
func twoBrokersOnTwoNodes(t *testing.T) (*Inventory, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
old := catalogue.Manifest{Module: "old-broker", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
|
||||||
|
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||||
|
new := catalogue.Manifest{Module: "new-broker", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
|
||||||
|
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||||
|
inv, ctx := aMeshWith(t, old, new)
|
||||||
|
// The holding references the seat's row, which `migrate` seeds on a real mesh.
|
||||||
|
if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, n := range []string{"anchor", "laptop"} {
|
||||||
|
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := inv.Assign(ctx, "anchor", "old-broker"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := inv.Assign(ctx, "laptop", "new-broker"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return inv, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHandoverIsOneRowReplacedNotOneAdded(t *testing.T) {
|
||||||
|
inv, ctx := twoBrokersOnTwoNodes(t)
|
||||||
|
|
||||||
|
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "anchor", "old-broker"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "laptop", "new-broker"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(held) != 1 || held[0].Node != "laptop" || held[0].Module != "new-broker" || held[0].Scope != catalogue.ScopeMesh {
|
||||||
|
t.Fatalf("after a handover the seat is not held by exactly the new holder: %+v", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASeatCannotBeHandedToSomethingNotAssigned(t *testing.T) {
|
||||||
|
inv, ctx := twoBrokersOnTwoNodes(t)
|
||||||
|
// new-broker is assigned to laptop, not anchor.
|
||||||
|
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "anchor", "new-broker"); err == nil {
|
||||||
|
t.Fatal("a seat was handed to a module not assigned where it was named")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassigningTheHolderTakesTheHoldingWithIt(t *testing.T) {
|
||||||
|
inv, ctx := twoBrokersOnTwoNodes(t)
|
||||||
|
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "laptop", "new-broker"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.Unassign(ctx, "laptop", "new-broker"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(held) != 0 {
|
||||||
|
t.Fatalf("the holding outlived the assignment it pointed at: %+v", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMachinesMembershipIsOneRowReplacedAndGoesWithTheMachine(t *testing.T) {
|
||||||
|
inv, ctx := twoBrokersOnTwoNodes(t)
|
||||||
|
if err := inv.PutBusMembership(ctx, "anchor", "first"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.PutBusMembership(ctx, "anchor", "second"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := inv.BusMemberships(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["anchor"] != "second" || len(got) != 1 {
|
||||||
|
t.Fatalf("a re-told membership did not replace the first: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- A seat's holder is a recorded fact, not a derivation (novox/hq ADR 0131, design 26).
|
||||||
|
--
|
||||||
|
-- Until this, "which assignment holds the seat" was derived: the module that is assigned and
|
||||||
|
-- claims the seat holds it, and a second eligible assignment was refused at resolution. That has no
|
||||||
|
-- way to hand a seat from one holder to the next without a moment where nothing holds it — and the
|
||||||
|
-- control plane finds its own bus through one of these seats, so that moment was an outage
|
||||||
|
-- (2026-09-27). Now the holder is one row here, changed by `seat <name> --to <node>/<module>` as one
|
||||||
|
-- act, and other assignments whose module could hold the seat are simply eligible and silent.
|
||||||
|
--
|
||||||
|
-- No row means what it always meant: the sole eligible assignment holds the seat, and two eligible
|
||||||
|
-- ones are refused. So a mesh that has never handed a seat over behaves exactly as before, and the
|
||||||
|
-- row appears the first time somebody does.
|
||||||
|
--
|
||||||
|
-- The seat is referenced by name because claims still are (0034); the rename cascades here so a
|
||||||
|
-- handed-over seat survives being renamed. The holder is the assignment itself, so unassigning it
|
||||||
|
-- takes the holding with it and the seat falls back to derivation rather than pointing at nothing.
|
||||||
|
create table seat_holding (
|
||||||
|
seat text primary key references seat(name) on update cascade on delete cascade,
|
||||||
|
scope text not null,
|
||||||
|
node uuid not null,
|
||||||
|
module text not null,
|
||||||
|
since timestamptz not null default now(),
|
||||||
|
foreign key (node, module) references assignment(node, module) on delete cascade
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- The bus seat's holder answers for the mesh's bus, not for a wire protocol (novox/hq ADR 0131).
|
||||||
|
--
|
||||||
|
-- The row said `amqp`, which is the protocol the old broker spoke, and so only that broker could hold
|
||||||
|
-- the seat that names the mesh's bus — while the module that will carry the bus could not. The seat
|
||||||
|
-- delivers `mesh-bus`; whichever module provides that may hold it, and today that is one module.
|
||||||
|
--
|
||||||
|
-- Safe under the current holder: the control plane composes its own bus address through the seat by
|
||||||
|
-- name, and the overview derives holders by name. Only registration and provision-to-seat resolution
|
||||||
|
-- read this column. So the row changes, the current holder keeps holding by derivation, the next one
|
||||||
|
-- can register its claim, and the handover (0039) moves the seat when both are running. What must
|
||||||
|
-- not happen in between is re-registering the current holder — registration would now refuse it.
|
||||||
|
update seat set delivers = 'mesh-bus' where name = 'mesh-broker' and delivers = 'amqp';
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
-- A machine already enrolled is moved to the new bus by being told its membership for it
|
||||||
|
-- (novox/hq design 28, task 5.2). Until this, a membership — bus address, fingerprint, password,
|
||||||
|
-- transport — existed only in the enrolment reply, and nothing could hand one to a machine that
|
||||||
|
-- had already joined. The row is the membership sealed to that machine, composed into its
|
||||||
|
-- declaration as a file it reads after applying; the plaintext exists once, at minting, and then
|
||||||
|
-- only on the machine. One per node: the mesh moves to one bus.
|
||||||
|
create table bus_membership (
|
||||||
|
node uuid primary key references node(id) on delete cascade,
|
||||||
|
sealed text not null,
|
||||||
|
since timestamptz not null default now()
|
||||||
|
);
|
||||||
@@ -106,3 +106,44 @@ func (i *Inventory) RenameSeat(ctx context.Context, from, to string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HoldSeat records that one assignment holds a seat, replacing whoever held it — as one write, so
|
||||||
|
// the seat is never without a holder in between (novox/hq ADR 0131, design 28 task 5.3). The
|
||||||
|
// assignment must exist; the store refuses otherwise, and that refusal is the right one: a seat
|
||||||
|
// cannot be handed to something that is not running anywhere.
|
||||||
|
func (i *Inventory) HoldSeat(ctx context.Context, seat, scope, nodeName, module string) error {
|
||||||
|
node, err := i.NodeByName(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
|
`insert into seat_holding (seat, scope, node, module) values ($1, $2, $3, $4)
|
||||||
|
on conflict (seat) do update set scope = excluded.scope, node = excluded.node,
|
||||||
|
module = excluded.module, since = now()`,
|
||||||
|
seat, scope, node.ID, module)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recording %s on %s as the holder of %s: %w", module, nodeName, seat, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holdings is every seat whose holder is on record, as the resolver reads it. A seat with no row here
|
||||||
|
// is held by derivation, exactly as before the table existed.
|
||||||
|
func (i *Inventory) Holdings(ctx context.Context) ([]catalogue.Held, error) {
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
`select h.seat, h.scope, n.name, h.module, coalesce(n.site, '')
|
||||||
|
from seat_holding h join node n on n.id = h.node order by h.seat`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []catalogue.Held
|
||||||
|
for rows.Next() {
|
||||||
|
var h catalogue.Held
|
||||||
|
if err := rows.Scan(&h.Claim, &h.Scope, &h.Node, &h.Module, &h.Site); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
+5
-4
@@ -23,7 +23,8 @@
|
|||||||
"licences": "/var/lib/mesh/mesh-controller/licences",
|
"licences": "/var/lib/mesh/mesh-controller/licences",
|
||||||
"broker": "/var/lib/mesh/mesh-controller/broker",
|
"broker": "/var/lib/mesh/mesh-controller/broker",
|
||||||
"broker-management": "/var/lib/mesh/mesh-controller/broker-management",
|
"broker-management": "/var/lib/mesh/mesh-controller/broker-management",
|
||||||
"broker-address": "/var/lib/mesh/mesh-controller/broker-address"
|
"broker-address": "/var/lib/mesh/mesh-controller/broker-address",
|
||||||
|
"bus": "/var/lib/mesh/mesh-controller/bus"
|
||||||
},
|
},
|
||||||
"secrets-owner": "65534:65534",
|
"secrets-owner": "65534:65534",
|
||||||
"resources": [
|
"resources": [
|
||||||
@@ -46,15 +47,14 @@
|
|||||||
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory",
|
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory",
|
||||||
"MESH_STORE_IDENTITY_FILE": "/run/secrets/identity",
|
"MESH_STORE_IDENTITY_FILE": "/run/secrets/identity",
|
||||||
"MESH_STORE_LICENCES_FILE": "/run/secrets/licences",
|
"MESH_STORE_LICENCES_FILE": "/run/secrets/licences",
|
||||||
"MESH_BROKER_AMQP_FILE": "/run/secrets/broker",
|
|
||||||
"MESH_BROKER_MANAGEMENT_FILE": "/run/secrets/broker-management",
|
"MESH_BROKER_MANAGEMENT_FILE": "/run/secrets/broker-management",
|
||||||
"MESH_BROKER_ADDRESS_FILE": "/run/secrets/broker-address",
|
"MESH_BROKER_ADDRESS_FILE": "/run/secrets/broker-address",
|
||||||
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||||
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
|
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
|
||||||
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
||||||
"MESH_BROKER_AMQP_PORT": "${seat:mesh-broker:5672}",
|
|
||||||
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
||||||
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}"
|
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||||
|
"MESH_BUS_NATS_FILE": "/run/secrets/bus"
|
||||||
},
|
},
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh-broker-tls:/broker-tls:ro",
|
"/var/lib/mesh-broker-tls:/broker-tls:ro",
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
"/var/lib/mesh/mesh-controller/identity:/run/secrets/identity:ro",
|
"/var/lib/mesh/mesh-controller/identity:/run/secrets/identity:ro",
|
||||||
"/var/lib/mesh/mesh-controller/licences:/run/secrets/licences:ro",
|
"/var/lib/mesh/mesh-controller/licences:/run/secrets/licences:ro",
|
||||||
"/var/lib/mesh/mesh-controller/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/mesh-controller/broker:/run/secrets/broker:ro",
|
||||||
|
"/var/lib/mesh/mesh-controller/bus:/run/secrets/bus:ro",
|
||||||
"/var/lib/mesh/mesh-controller/broker-management:/run/secrets/broker-management:ro",
|
"/var/lib/mesh/mesh-controller/broker-management:/run/secrets/broker-management:ro",
|
||||||
"/var/lib/mesh/mesh-controller/broker-address:/run/secrets/broker-address:ro"
|
"/var/lib/mesh/mesh-controller/broker-address:/run/secrets/broker-address:ro"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user