Merge main: the trunk renamed the seats and made them data
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.
This commit is contained in:
@@ -46,9 +46,16 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
if err := open.inventory.Assign(ctx, node, module); err != nil {
|
||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !fresh {
|
||||
// Nothing changed, and saying "is assigned" would read as an action. One node runs one
|
||||
// of each — the module's name is the assignment's identity (novox/hq ADR 0115).
|
||||
return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed",
|
||||
node, module), nil
|
||||
}
|
||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
|
||||
@@ -355,7 +355,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
||||
strings.Join(assigned, ", "))
|
||||
}
|
||||
if !slices.Contains(assigned, filter) {
|
||||
if err := inv.Assign(ctx, node, filter); err != nil {
|
||||
if _, err := inv.Assign(ctx, node, filter); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, _, err := planFor(ctx, open, node); err != nil {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
// The broker opening belongs only on the node that listens on it (novox/hq: it leaked onto
|
||||
// every enrolled node's declaration, opening a from-anywhere hole for a port nothing there
|
||||
// serves). foundationPortsFor is the scope.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
func TestTheBrokerHostGetsTheFoundationOpening(t *testing.T) {
|
||||
broker := catalogue.Manifest{Module: "lavinmq", Listens: []catalogue.Listening{
|
||||
{Port: 5671, Protocol: "tcp", From: "mesh"},
|
||||
{Port: 5672, Protocol: "tcp", From: "mesh"},
|
||||
}}
|
||||
got := foundationPortsFor(5671, []catalogue.Manifest{broker})
|
||||
if len(got) != 1 || got[0] != 5671 {
|
||||
t.Fatalf("the node that listens on the broker port keeps it; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeThatOnlyDialsTheBrokerGetsNoOpening(t *testing.T) {
|
||||
// ace's set: things that reach the broker as a client, none listening on 5671.
|
||||
ace := []catalogue.Manifest{
|
||||
{Module: "plex", Listens: []catalogue.Listening{{Port: 32400, Protocol: "tcp", From: "anywhere"}}},
|
||||
{Module: "postgres", Listens: []catalogue.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}}},
|
||||
}
|
||||
if got := foundationPortsFor(5671, ace); got != nil {
|
||||
t.Fatalf("a node that only dials out opens nothing for the broker; got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,8 @@ func run() error {
|
||||
return rolloutCommand(ctx, args[1:])
|
||||
case "seats":
|
||||
return seatsCommand(ctx, args[1:])
|
||||
case "seat":
|
||||
return seatCommand(ctx, args[1:])
|
||||
case "status":
|
||||
return statusCommand(ctx, args[1:])
|
||||
case "version":
|
||||
|
||||
@@ -73,7 +73,7 @@ func aMesh(t *testing.T) *stores {
|
||||
if err := open.inventory.RecordOverlayKey(t.Context(), record.ID, aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.Assign(t.Context(), name, overlay.Name); err != nil {
|
||||
if _, err := open.inventory.Assign(t.Context(), name, overlay.Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func overlayRange(ctx context.Context, inv *inventory.Inventory) (string, error)
|
||||
|
||||
func overlayCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("overlay place <node> [flags], or overlay show")
|
||||
return errors.New("overlay place <node> [flags], overlay name <address> <name>, or overlay show")
|
||||
}
|
||||
// Answered before anything is opened. A message about which command to use should not need a
|
||||
// database to say so, and needing one turns a redirect into a connection error.
|
||||
@@ -69,12 +69,29 @@ func overlayCommand(ctx context.Context, args []string) error {
|
||||
return overlayPlace(ctx, inv, args[1:])
|
||||
case "show":
|
||||
return overlayShow(ctx, open)
|
||||
case "name":
|
||||
return overlayName(ctx, inv, args[1:])
|
||||
|
||||
default:
|
||||
return fmt.Errorf("overlay has no %q; it has place and show", args[0])
|
||||
return fmt.Errorf("overlay has no %q; it has place, name and show", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// overlayName is the operator saying which machine a carried address is (novox/hq issue 112),
|
||||
// so the mesh answers for its name until the machine enrols and verifies it.
|
||||
func overlayName(ctx context.Context, inv *inventory.Inventory, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("overlay name <carried-address> <node-name>")
|
||||
}
|
||||
address, name := args[0], args[1]
|
||||
if err := inv.NamePeer(ctx, address, name); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("the peer at %s is %s until it enrols — the mesh answers for %s.<suffix> from the "+
|
||||
"operator's word, and enrolment under this key must use this name\n", address, name, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func overlayPlace(ctx context.Context, inv *inventory.Inventory, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New(
|
||||
@@ -239,7 +256,7 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool,
|
||||
"Re-place it — `overlay place %s --hub --endpoint <host>:%d …` — and push again; "+
|
||||
"nothing was composed", p.Name, t.Interface, wrong, p.Name, t.Port)
|
||||
}
|
||||
n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config}
|
||||
n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU}
|
||||
}
|
||||
if p.Hub {
|
||||
for _, c := range carried {
|
||||
@@ -588,6 +605,24 @@ func namesInTheMesh(ctx context.Context, inv *inventory.Inventory,
|
||||
for _, p := range places {
|
||||
out[overlay.InternalName(p.Name)] = p.Address
|
||||
}
|
||||
// And the carried peers the operator has named (novox/hq issue 112): machines the
|
||||
// predecessor's resolver answers for and the mesh routes to, known by name on the operator's
|
||||
// word until they enrol — at which point enrolment verifies the name and the node's own
|
||||
// entry takes over above. A name the predecessor answers for must keep resolving until the
|
||||
// machine behind it is a node; without these, taking the resolver silences three machines.
|
||||
carried, err := inv.CarriedPeers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range carried {
|
||||
if p.Named == "" || p.EnrolledAs != "" {
|
||||
continue
|
||||
}
|
||||
if _, taken := out[overlay.InternalName(p.Named)]; taken {
|
||||
continue // a node of the mesh owns the name; the stale statement loses
|
||||
}
|
||||
out[overlay.InternalName(p.Named)] = p.Address
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,14 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
// because the damage is already done by the time it prints.
|
||||
return publicDomain(ctx, inv, args[1:])
|
||||
|
||||
case "account":
|
||||
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
||||
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
||||
// an optional second argument is the home when it is not /home/<account>.
|
||||
return nodeAccount(ctx, inv, args[1:])
|
||||
|
||||
default:
|
||||
return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0])
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +112,39 @@ func modeOf(n inventory.Node) string {
|
||||
}
|
||||
|
||||
// publicDomainUsage is the one description of the three forms, so a refusal and the help agree.
|
||||
// nodeAccount reports or sets a node's operator account (novox/hq to-be 29). Read-shaped with no
|
||||
// argument, like public-domain: `node account novox` answers, it does not change anything.
|
||||
func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []string) error {
|
||||
if len(positionals) == 0 || len(positionals) > 3 {
|
||||
return errors.New("node account <name> — what it is now; " +
|
||||
"node account <name> <account> [home] — set it (home defaults to /home/<account>)")
|
||||
}
|
||||
node := positionals[0]
|
||||
if len(positionals) == 1 {
|
||||
who, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if who.Account == "" {
|
||||
fmt.Printf("%s has no operator account known\n", node)
|
||||
fmt.Printf(" `node account %s <account>` sets it\n", node)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s logs a person in as %s (home %s)\n", node, who.Account, who.Home())
|
||||
return nil
|
||||
}
|
||||
home := ""
|
||||
if len(positionals) == 3 {
|
||||
home = positionals[2]
|
||||
}
|
||||
if err := inv.SetAccount(ctx, node, positionals[1], home); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s logs a person in as %s\n", node, positionals[1])
|
||||
fmt.Printf(" run `push %s` once ssh-client is assigned, to send its operator config\n", node)
|
||||
return nil
|
||||
}
|
||||
|
||||
const publicDomainUsage = "node public-domain <name> — what it is now; " +
|
||||
"<name> <domain> to set it; <name> --clear to take it away"
|
||||
|
||||
|
||||
@@ -83,9 +83,17 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
|
||||
// The operator account this node logs a person in as, and where its home is (novox/hq to-be
|
||||
// 29) — carried so a home-scoped file's owner and path resolve for this machine.
|
||||
who, err := inv.NodeByName(ctx, nodeName)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
|
||||
resolved, err := catalogue.Resolve(shelf, assigned,
|
||||
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
|
||||
At: onNetwork[nodeName], PublicDomain: publicDomain}, world)
|
||||
At: onNetwork[nodeName], PublicDomain: publicDomain,
|
||||
Account: who.Account, AccountHome: who.AccountHome}, world)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
@@ -488,6 +496,13 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
// The private network's range, offered to a module as ${machine:mesh-range} — a module that must
|
||||
// name the whole mesh (an intrusion filter that must never ban a tunnel peer) names it here
|
||||
// rather than hardcoding a value it cannot know.
|
||||
meshRange, err := overlayRange(ctx, inv)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
|
||||
// The artifact store as this node reaches it now — the address every image and archive the
|
||||
// mesh built is fetched through, composed here and recorded nowhere — with what the mesh has
|
||||
@@ -513,6 +528,19 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
|
||||
// Each machine's operator account, so an ssh Host block can name the login for every node
|
||||
// (novox/hq to-be 29). Keyed by the bare node name, which entriesFrom falls back to.
|
||||
allNodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
accounts := map[string]string{}
|
||||
for _, n := range allNodes {
|
||||
if n.Account != "" {
|
||||
accounts[n.Name] = n.Account
|
||||
}
|
||||
}
|
||||
|
||||
// And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the
|
||||
// `<node>.internal` names above, so a container — or an internal ACME validator — resolves a
|
||||
// routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told
|
||||
@@ -534,11 +562,19 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
// The ports the mesh itself needs open, which no module declares. Read from the broker this
|
||||
// control plane was told about rather than written down twice: the address a node is handed in
|
||||
// its token and the port its machine must accept on are the same fact.
|
||||
//
|
||||
// **Only on the node that listens on it** (novox/hq issue: the broker opening leaked onto
|
||||
// every node). The opening exists to WIDEN the broker's port to from-anywhere — a machine
|
||||
// enrolling is not on the mesh yet, so the broker's own `from: mesh` listen would refuse its
|
||||
// first dial. That widening belongs on the broker's host and nowhere else: a node that only
|
||||
// dials out needs no incoming rule, and an opening for a port nothing here listens on is a
|
||||
// from-anywhere hole for a dead port. So the foundation port is kept only when a module
|
||||
// resolved onto THIS node actually listens on it.
|
||||
var foundation []int
|
||||
if b, err := broker.FromEnvironment(); err == nil {
|
||||
if _, port, err := net.SplitHostPort(b.Address); err == nil {
|
||||
if n, err := strconv.Atoi(port); err == nil {
|
||||
foundation = append(foundation, n)
|
||||
foundation = foundationPortsFor(n, plan.Modules)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -595,7 +631,8 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
Machines: machines,
|
||||
Suffix: overlay.Suffix(), Foundation: foundation, Kept: kept, Adopted: record.Adopted,
|
||||
Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation,
|
||||
Kept: kept, Adopted: record.Adopted,
|
||||
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
|
||||
BusUsers: busUsers,
|
||||
}, record, nil
|
||||
@@ -922,12 +959,26 @@ func planCommand(ctx context.Context, args []string) error {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\n--- %v %v ---\n%s", r["id"], r["path"], content)
|
||||
fmt.Printf("\n--- %s ---\n%s", shownAs(r), content)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shownAs is the heading `plan --show` puts over a resource's content.
|
||||
//
|
||||
// **A file written into says so.** Its content is the mesh's part of a file that is otherwise the
|
||||
// machine's — the keys of a JSON document (novox/hq ADR 0102), the region of a hosts file (issue
|
||||
// 128). Shown under a bare path it reads as the whole file, and a person checking what a take
|
||||
// replaces would see a hosts file of a dozen lines where the machine keeps thirty.
|
||||
func shownAs(r map[string]any) string {
|
||||
heading := fmt.Sprintf("%v %v", r["id"], r["path"])
|
||||
if into, ok := r["into"].(string); ok && into != "" {
|
||||
heading += fmt.Sprintf(" (written into, %s)", into)
|
||||
}
|
||||
return heading
|
||||
}
|
||||
|
||||
// licencesFor is what this node can be answered with by record, and what it was put on.
|
||||
//
|
||||
// A mesh with no licences at all is the ordinary case and must not be an error: every existing
|
||||
@@ -1212,3 +1263,20 @@ func composeBusUsers(ctx context.Context, inv *inventory.Inventory,
|
||||
}
|
||||
return broker.ComposeAccounts(filled)
|
||||
}
|
||||
|
||||
// foundationPortsFor is the broker port, kept only when a module resolved onto this node listens
|
||||
// on it (novox/hq issue: the broker opening leaked onto every node). The foundation opening
|
||||
// exists to WIDEN the broker's `from: mesh` port to from-anywhere, because a machine enrolling is
|
||||
// not on the mesh yet and its first dial would be refused. That widening belongs on the broker's
|
||||
// host alone: a node that only dials out needs no incoming rule, and an opening for a port
|
||||
// nothing here listens on is a from-anywhere hole for a dead port.
|
||||
func foundationPortsFor(brokerPort int, modules []catalogue.Manifest) []int {
|
||||
for _, m := range modules {
|
||||
for _, l := range m.Listens {
|
||||
if l.Port == brokerPort {
|
||||
return []int{brokerPort}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,3 +35,17 @@ func TestListensLinesAreEmptyForAModuleWithNothingToListenOn(t *testing.T) {
|
||||
t.Errorf("a module with no listens should print nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// `plan --show` says when a file is written into rather than over (novox/hq issue 128), or the
|
||||
// mesh's region of a hosts file reads as the whole file.
|
||||
func TestAFileWrittenIntoIsShownAsSuch(t *testing.T) {
|
||||
region := shownAs(map[string]any{
|
||||
"id": "mesh-wireguard.fact-node-names", "path": "/etc/hosts", "into": "block"})
|
||||
if region != "mesh-wireguard.fact-node-names /etc/hosts (written into, block)" {
|
||||
t.Errorf("the region is shown as %q", region)
|
||||
}
|
||||
whole := shownAs(map[string]any{"id": "dnsmasq.fact-node-zones", "path": "/etc/mesh-resolver/nodes.conf"})
|
||||
if strings.Contains(whole, "written into") {
|
||||
t.Errorf("a whole file is shown as written into: %q", whole)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,8 +524,14 @@ func composeEach(names []string,
|
||||
continue
|
||||
}
|
||||
if len(declared.Resources) == 0 {
|
||||
fmt.Printf("%s is assigned nothing — skipped\n", name)
|
||||
continue
|
||||
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to
|
||||
// nothing may have HELD something before — the broker opening a placement gave it,
|
||||
// say — and skipping the empty declaration leaves that last resource in force
|
||||
// forever, re-applied by the node's own heartbeat, with no way for the mesh to say
|
||||
// it is gone. An empty declaration is the correction: the host drops what the mesh
|
||||
// owned and keeps what it found (the adoption envelope still rides along). A node
|
||||
// that never held anything applies it as the no-op it is.
|
||||
fmt.Printf("%s owns nothing now — sent so it drops what it last held\n", name)
|
||||
}
|
||||
sending = append(sending, readyNode{name, declared})
|
||||
}
|
||||
|
||||
@@ -39,12 +39,14 @@ func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// And a machine assigned nothing is neither sent nor a refusal — it is nothing to say.
|
||||
func TestAMachineAssignedNothingIsNotARefusal(t *testing.T) {
|
||||
// A machine whose declaration composes to nothing is SENT the empty declaration, not skipped
|
||||
// (novox/hq issue 127): it may have held something before, and only sending the empty
|
||||
// declaration tells it to drop what the mesh owned. It is never a refusal.
|
||||
func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) {
|
||||
sending, refusals := composeEach([]string{"spare"},
|
||||
func(string) (sendable, error) { return sendable{}, nil })
|
||||
if len(sending) != 0 || len(refusals) != 0 {
|
||||
t.Errorf("a machine assigned nothing was treated as something: %v / %v", sending, refusals)
|
||||
if len(sending) != 1 || len(refusals) != 0 {
|
||||
t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ func readinessOf(ctx context.Context, inv *inventory.Inventory) (broker.Readines
|
||||
// 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.Seats) > 0 || len(m.Uses) > 0 || len(m.Claims) > 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
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestReadinessIsGatheredFromWhatTheMeshHolds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for _, a := range [][2]string{{"anchor", "nats"}, {"anchor", "gitea"}, {"laptop", "wallpaper"}} {
|
||||
if err := inv.Assign(ctx, a[0], a[1]); err != nil {
|
||||
if _, err := inv.Assign(ctx, a[0], a[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,15 +43,16 @@ type seatRow struct {
|
||||
// overview would make the one thing the overview is for — what does this mesh have — quietly
|
||||
// incomplete.
|
||||
func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []catalogue.Held) {
|
||||
defined := map[string]bool{}
|
||||
rows := make([]seatRow, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
defined[s.Name] = true
|
||||
row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision,
|
||||
Holders: []seatHolder{}}
|
||||
seen := map[seatHolder]bool{}
|
||||
for _, h := range held {
|
||||
if h.Claim != s.Name || h.Scope != s.Scope {
|
||||
// Resolve the held claim to a seat rather than comparing names, so a record naming a
|
||||
// seat's former name groups under it after a rename (novox/hq ADR 0122).
|
||||
hs, ok := catalogue.SeatNamed(h.Claim)
|
||||
if !ok || hs.Name != s.Name || h.Scope != s.Scope {
|
||||
continue
|
||||
}
|
||||
holder := seatHolder{Node: h.Node, Module: h.Module}
|
||||
@@ -70,7 +71,8 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata
|
||||
}
|
||||
var outside []catalogue.Held
|
||||
for _, h := range held {
|
||||
if !defined[h.Claim] {
|
||||
// Outside the set only if it resolves to no seat at all — a former name still resolves.
|
||||
if _, ok := catalogue.SeatNamed(h.Claim); !ok {
|
||||
outside = append(outside, h)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +85,25 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata
|
||||
return rows, outside
|
||||
}
|
||||
|
||||
// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122).
|
||||
func seatCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 3 && args[0] == "rename" {
|
||||
from, to := args[1], args[2]
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
if err := open.inventory.RenameSeat(ctx, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s is now %s — its former name still resolves, so nothing is rebuilt, "+
|
||||
"re-registered or frozen (novox/hq ADR 0122)\n", from, to)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("seat rename <from> <to>")
|
||||
}
|
||||
|
||||
func seatsCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("seats", flag.ContinueOnError)
|
||||
asJSON := set.Bool("json", false, "the same, as JSON")
|
||||
|
||||
@@ -35,13 +35,13 @@ func TestEverySeatIsListedIncludingTheOnesNobodyHolds(t *testing.T) {
|
||||
|
||||
func TestANodeSeatListsEveryMachineHoldingIt(t *testing.T) {
|
||||
rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{
|
||||
{Claim: "mesh-packet-filter", Scope: catalogue.ScopeNode, Node: "node2", Module: "nftables"},
|
||||
{Claim: "mesh-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"},
|
||||
{Claim: "node-packet-filter", Scope: catalogue.ScopeNode, Node: "node2", Module: "nftables"},
|
||||
{Claim: "node-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"},
|
||||
// Resolved twice, reported once: a machine is one holder however many passes saw it.
|
||||
{Claim: "mesh-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"},
|
||||
{Claim: "node-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"},
|
||||
})
|
||||
for _, r := range rows {
|
||||
if r.Seat != "mesh-packet-filter" {
|
||||
if r.Seat != "node-packet-filter" {
|
||||
continue
|
||||
}
|
||||
if len(r.Holders) != 2 || r.Holders[0].Node != "anchor" || r.Holders[1].Node != "node2" {
|
||||
|
||||
@@ -38,6 +38,12 @@ func (s sendable) Body() ([]byte, error) {
|
||||
if s.Adoption != nil {
|
||||
envelope["adoption"] = s.Adoption
|
||||
}
|
||||
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
||||
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
||||
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
||||
if len(s.Resources) == 0 {
|
||||
envelope["owns_nothing"] = true
|
||||
}
|
||||
return json.Marshal(envelope)
|
||||
}
|
||||
|
||||
|
||||
@@ -355,3 +355,25 @@ func TestTheMachineSideOfAMappingIsMovedEverywhereTheNumberIsUsed(t *testing.T)
|
||||
t.Fatalf("the consumer is told the forge answers on %v", told)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyDeclarationSaysOwnsNothing(t *testing.T) {
|
||||
// The host refuses an empty body unless told the emptiness is meant (novox/hq issue 127).
|
||||
body, err := sendable{}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env["owns_nothing"] != true {
|
||||
t.Fatalf("an empty declaration must mark owns_nothing; got %v", env)
|
||||
}
|
||||
// A declaration with resources does not carry the marker.
|
||||
body, _ = sendable{Resources: []map[string]any{{"id": "x"}}}.Body()
|
||||
var env2 map[string]any
|
||||
_ = json.Unmarshal(body, &env2)
|
||||
if _, present := env2["owns_nothing"]; present {
|
||||
t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// seat's holder runs.
|
||||
|
||||
// gitSeat is the seat a self-hosted repository lives on.
|
||||
const gitSeat = "mesh-git"
|
||||
const gitSeat = "git"
|
||||
|
||||
// buildSource is where a build's repository is: a URL, or a path on a seat's holder.
|
||||
type buildSource struct {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func forgeHolding(port any) catalogue.World {
|
||||
return catalogue.World{
|
||||
Held: []catalogue.Held{{Claim: "mesh-git", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "gitea"}},
|
||||
Held: []catalogue.Held{{Claim: "git", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "gitea"}},
|
||||
Offered: map[string][]catalogue.Provider{"git": {
|
||||
// A second forge that does not hold the seat, so taking the first one found would be wrong.
|
||||
{Node: "archive", At: "archive.internal", Module: "gitea-mirror",
|
||||
@@ -23,7 +23,7 @@ func forgeHolding(port any) catalogue.World {
|
||||
}
|
||||
|
||||
func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) {
|
||||
got, err := clonedFromSeat(forgeHolding(float64(3000)), "mesh-git", "novox/mesh-catalog")
|
||||
got, err := clonedFromSeat(forgeHolding(float64(3000)), "git", "novox/mesh-catalog")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) {
|
||||
func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) {
|
||||
// The whole point: the node gave the forge another port, and the same recorded path clones
|
||||
// from the new one. Nothing recorded contained the old one to be wrong.
|
||||
got, err := clonedFromSeat(forgeHolding(float64(3100)), "mesh-git", "novox/mesh-catalog")
|
||||
got, err := clonedFromSeat(forgeHolding(float64(3100)), "git", "novox/mesh-catalog")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -45,11 +45,11 @@ func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWithNobodyHoldingTheSeatASelfHostedBuildIsRefusedAndSaysWhy(t *testing.T) {
|
||||
_, err := clonedFromSeat(catalogue.World{}, "mesh-git", "novox/mesh-catalog")
|
||||
_, err := clonedFromSeat(catalogue.World{}, "git", "novox/mesh-catalog")
|
||||
if err == nil {
|
||||
t.Fatal("a repository was cloned from a forge the mesh does not have")
|
||||
}
|
||||
for _, want := range []string{"nobody holds the mesh-git seat", "without --self"} {
|
||||
for _, want := range []string{"nobody holds the git seat", "without --self"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func TestAnExternalRepositoryIsClonedExactlyAsGiven(t *testing.T) {
|
||||
func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) {
|
||||
world := forgeHolding(float64(3000))
|
||||
world.Offered["git"][1].At = ""
|
||||
if _, err := clonedFromSeat(world, "mesh-git", "novox/mesh-catalog"); err == nil ||
|
||||
if _, err := clonedFromSeat(world, "git", "novox/mesh-catalog"); err == nil ||
|
||||
!strings.Contains(err.Error(), "private network") {
|
||||
t.Fatalf("a forge nothing can reach was cloned from: %v", err)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) {
|
||||
|
||||
func TestAHolderServingNoPortIsRefusedRatherThanGuessed(t *testing.T) {
|
||||
// A default port would be the forge's address guessed, which is what this exists to stop.
|
||||
if _, err := clonedFromSeat(forgeHolding(nil), "mesh-git", "novox/mesh-catalog"); err == nil {
|
||||
if _, err := clonedFromSeat(forgeHolding(nil), "git", "novox/mesh-catalog"); err == nil {
|
||||
t.Fatal("a port was guessed for a forge that serves none")
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,8 @@ func TestAnAddressGivenAsAPathOnTheForgeIsRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestASourceOnTheSeatReadsAsAPathNotAnAddress(t *testing.T) {
|
||||
s := buildSource{Repository: "novox/mesh-catalog", Seat: "mesh-git"}
|
||||
if got := s.String(); got != "novox/mesh-catalog on the mesh-git seat" {
|
||||
s := buildSource{Repository: "novox/mesh-catalog", Seat: "git"}
|
||||
if got := s.String(); got != "novox/mesh-catalog on the git seat" {
|
||||
t.Fatalf("read as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/licences"
|
||||
@@ -72,6 +73,14 @@ func migrate(ctx context.Context) error {
|
||||
}
|
||||
fmt.Printf("provided %s\n", m.Module)
|
||||
}
|
||||
// The seats the mesh ships with, into the table that now holds the set (novox/hq ADR 0122).
|
||||
// Idempotent: fills an empty table on first boot, adds a seat a release ships, and leaves an
|
||||
// operator's changes in the table as they are.
|
||||
added, err := inv.SeedSeats(ctx, catalogue.DefaultSeats())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("seeded %d seat(s)\n", added)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -85,6 +94,18 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) {
|
||||
inv.Close()
|
||||
return nil, err
|
||||
}
|
||||
// Load the seat set from the store, so the control plane reads the set as data rather than as
|
||||
// the slice it was compiled with (novox/hq ADR 0122). A store not yet seeded — or one whose
|
||||
// seat table a migration has not reached — returns nothing, and UseSeats leaves the compiled
|
||||
// defaults in force: the set is never emptied by a read that found nothing, which would refuse
|
||||
// every claim. So this can only ever replace the defaults with what the mesh actually holds.
|
||||
if seats, err := inv.Seats(ctx); err == nil {
|
||||
catalogue.UseSeats(seats)
|
||||
}
|
||||
// And the former names, so a reference to a seat's old name resolves after a rename (ADR 0122).
|
||||
if aliases, err := inv.Aliases(ctx); err == nil {
|
||||
catalogue.UseAliases(aliases)
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user