From 50734095b8b77dbb43f64c847da63cf421205a10 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 19:02:35 +0200 Subject: [PATCH 01/18] A repeat assignment says nothing changed (ADR 0115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One assignment of a module per node is now the rule, not a limitation — the operator dropped the multi-assignment requirement, and the schema's (node, module) key has been the decision since migration 0005. What changed: Assign reports whether the assignment was new, and the command says 'already runs — one node runs one of each (ADR 0115); nothing changed' instead of printing 'is assigned' for a no-op, which read as an action that happened. Idempotence stays: a repeat is exit 0, because a script stating what is already true is not wrong. --- cmd/mesh-controller/acts.go | 9 ++++++++- cmd/mesh-controller/adoption.go | 2 +- cmd/mesh-controller/mesh_for_test.go | 2 +- internal/inventory/adoption_test.go | 4 ++-- internal/inventory/catalogue.go | 23 ++++++++++++++++------- internal/inventory/catalogue_test.go | 28 +++++++++++++++++++--------- internal/inventory/forget_test.go | 2 +- internal/inventory/ports_test.go | 4 ++-- internal/inventory/secrets_test.go | 2 +- 9 files changed, 51 insertions(+), 25 deletions(-) diff --git a/cmd/mesh-controller/acts.go b/cmd/mesh-controller/acts.go index c5bae72..9e7ca23 100644 --- a/cmd/mesh-controller/acts.go +++ b/cmd/mesh-controller/acts.go @@ -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 { diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index 9941728..6b9d95b 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -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 { diff --git a/cmd/mesh-controller/mesh_for_test.go b/cmd/mesh-controller/mesh_for_test.go index 35f3627..3008697 100644 --- a/cmd/mesh-controller/mesh_for_test.go +++ b/cmd/mesh-controller/mesh_for_test.go @@ -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) } } diff --git a/internal/inventory/adoption_test.go b/internal/inventory/adoption_test.go index a0443f5..c779d32 100644 --- a/internal/inventory/adoption_test.go +++ b/internal/inventory/adoption_test.go @@ -65,7 +65,7 @@ func TestTakingIsRefusedOnAConvergedNodeAndForAnUnassignedModule(t *testing.T) { if _, err := inv.AddNode(t.Context(), "converged"); err != nil { t.Fatal(err) } - if err := inv.Assign(t.Context(), "converged", "hello-web"); err != nil { + if _, err := inv.Assign(t.Context(), "converged", "hello-web"); err != nil { t.Fatal(err) } if err := inv.Take(t.Context(), "converged", "hello-web"); !errors.Is(err, ErrNotAdopted) { @@ -91,7 +91,7 @@ func TestATakenModuleOutlivesItsAssignmentAndReturningToAdopted(t *testing.T) { t.Fatal(err) } for _, m := range []string{"hello-web", "postgres"} { - if err := inv.Assign(t.Context(), "anchor", m); err != nil { + if _, err := inv.Assign(t.Context(), "anchor", m); err != nil { t.Fatal(err) } } diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index bcf3e55..51eaa02 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -430,27 +430,36 @@ func (i *Inventory) discard(ctx context.Context, name string) error { return nil } -// Assign puts a module on a node. +// Assign puts a module on a node, and says whether that is new. // // Records the intention and checks nothing. Whether the set of assignments can actually become a // declaration is resolution's question, asked over the whole set at once — and asking it here, // one module at a time, would let an assignment look accepted and then refuse when a second // arrives. -func (i *Inventory) Assign(ctx context.Context, nodeName, module string) error { +// +// **One assignment of a module per node is the rule, not a race lost** (novox/hq ADR 0115). The +// module's name is the assignment's identity — its database user, its broker account, its +// containers and its placed directory are all named by it — so the schema's (node, module) key +// is the decision, and a repeat is absorbed rather than refused. Absorbed audibly: the caller is +// told nothing changed, because "is assigned" printed for a no-op reads as an action. +func (i *Inventory) Assign(ctx context.Context, nodeName, module string) (bool, error) { node, err := i.NodeByName(ctx, nodeName) if err != nil { - return err + return false, err } if err := i.runsSomewhere(ctx, module); err != nil { - return err + return false, err } - _, err = i.store.Pool().Exec(ctx, + tag, err := i.store.Pool().Exec(ctx, `insert into assignment (node, module) values ($1, $2) on conflict do nothing`, node.ID, module) if err != nil && strings.Contains(err.Error(), "assignment_module_fkey") { - return fmt.Errorf("%w: %s", ErrNoSuchModule, module) + return false, fmt.Errorf("%w: %s", ErrNoSuchModule, module) } - return err + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil } // Unassign takes a module off a node. diff --git a/internal/inventory/catalogue_test.go b/internal/inventory/catalogue_test.go index 92c61a3..1fd4e9e 100644 --- a/internal/inventory/catalogue_test.go +++ b/internal/inventory/catalogue_test.go @@ -77,7 +77,7 @@ func TestAModuleAMachineIsRunningCannotBeForgotten(t *testing.T) { if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil { t.Fatal(err) } - if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil { + if _, err := inv.Assign(t.Context(), "laptop", "thing"); err != nil { t.Fatal(err) } @@ -104,7 +104,7 @@ func TestRemovingANodeTakesItsAssignments(t *testing.T) { if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil { t.Fatal(err) } - if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil { + if _, err := inv.Assign(t.Context(), "laptop", "thing"); err != nil { t.Fatal(err) } if _, err := inv.store.Pool().Exec(t.Context(), `delete from node where id = $1`, node.ID); err != nil { @@ -136,14 +136,16 @@ func TestAssigningAModuleTheMeshDoesNotKnowIsRefused(t *testing.T) { if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { t.Fatal(err) } - err := inv.Assign(t.Context(), "laptop", "not-a-module") + _, err := inv.Assign(t.Context(), "laptop", "not-a-module") if !errors.Is(err, ErrNoSuchModule) { t.Fatalf("assigning an unknown module gave %v", err) } } -func TestAssigningTwiceIsNotAnError(t *testing.T) { - // It is a statement of what should be true, and it already is. +func TestAssigningTwiceIsNotAnErrorAndSaysSo(t *testing.T) { + // It is a statement of what should be true, and it already is — one assignment of a module + // per node is the rule (novox/hq ADR 0115), so a repeat is absorbed, audibly: the caller is + // told nothing was new. inv := fresh(t) if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { t.Fatal(err) @@ -151,10 +153,18 @@ func TestAssigningTwiceIsNotAnError(t *testing.T) { if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil { t.Fatal(err) } - for i := 0; i < 3; i++ { - if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil { + first, err := inv.Assign(t.Context(), "laptop", "thing") + if err != nil || !first { + t.Fatalf("the first assignment is the new one; got fresh=%v err=%v", first, err) + } + for i := 0; i < 2; i++ { + again, err := inv.Assign(t.Context(), "laptop", "thing") + if err != nil { t.Fatalf("assigning again failed: %v", err) } + if again { + t.Fatal("a repeat must say nothing was new") + } } assigned, err := inv.Assigned(t.Context(), "laptop") if err != nil { @@ -277,7 +287,7 @@ func TestBeingBehindNamesTheMachinesRunningTheOldOne(t *testing.T) { t.Fatal(err) } for _, n := range []string{"laptop", "workstation"} { - if err := inv.Assign(t.Context(), n, "thing"); err != nil { + if _, err := inv.Assign(t.Context(), n, "thing"); err != nil { t.Fatal(err) } } @@ -451,7 +461,7 @@ func TestTheCatalogueSaysWhereEachModuleCameFromAndWhoRunsIt(t *testing.T) { t.Fatal(err) } for _, n := range []string{"workstation", "laptop"} { - if err := inv.Assign(ctx, n, "shell"); err != nil { + if _, err := inv.Assign(ctx, n, "shell"); err != nil { t.Fatal(err) } } diff --git a/internal/inventory/forget_test.go b/internal/inventory/forget_test.go index 5c675a0..75e5a36 100644 --- a/internal/inventory/forget_test.go +++ b/internal/inventory/forget_test.go @@ -212,7 +212,7 @@ func TestAModuleStillAssignedRefusesBeforeAnythingAboutWhatItHolds(t *testing.T) if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"a": 1}); err != nil { t.Fatal(err) } - if err := inv.Assign(ctx, "anchor", "step-ca"); err != nil { + if _, err := inv.Assign(ctx, "anchor", "step-ca"); err != nil { t.Fatal(err) } for _, forget := range []func() error{ diff --git a/internal/inventory/ports_test.go b/internal/inventory/ports_test.go index 2ac93c8..76f45ab 100644 --- a/internal/inventory/ports_test.go +++ b/internal/inventory/ports_test.go @@ -221,7 +221,7 @@ func TestWhatAMachineNoLongerHoldsIsAvailableAgain(t *testing.T) { func TestUnassigningReleasesTheModulesPorts(t *testing.T) { inv, node := aNodeWithModules(t, "mailu", "other-mail") ctx := t.Context() - if err := inv.Assign(ctx, node, "mailu"); err != nil { + if _, err := inv.Assign(ctx, node, "mailu"); err != nil { t.Fatal(err) } if _, err := inv.PortFor(ctx, node, "mailu", 25, true); err != nil { @@ -230,7 +230,7 @@ func TestUnassigningReleasesTheModulesPorts(t *testing.T) { if err := inv.Unassign(ctx, node, "mailu"); err != nil { t.Fatal(err) } - if err := inv.Assign(ctx, node, "other-mail"); err != nil { + if _, err := inv.Assign(ctx, node, "other-mail"); err != nil { t.Fatal(err) } if _, err := inv.PortFor(ctx, node, "other-mail", 25, true); err != nil { diff --git a/internal/inventory/secrets_test.go b/internal/inventory/secrets_test.go index 6cbcc69..e5b1610 100644 --- a/internal/inventory/secrets_test.go +++ b/internal/inventory/secrets_test.go @@ -350,7 +350,7 @@ func TestACredentialGoesWhenTheConsumerStopsAskingForIt(t *testing.T) { }, Source{}); err != nil { t.Fatal(err) } - if err := inv.Assign(ctx, "consumer", "meshboard"); err != nil { + if _, err := inv.Assign(ctx, "consumer", "meshboard"); err != nil { t.Fatal(err) } if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { From 952092ccb3449652b1ea00d6ab205318dc67155f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:09:42 +0200 Subject: [PATCH 02/18] A carried peer is nameable, and the mesh answers for it (hq 112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tunnel the hub took over routes to machines the predecessor knows by name and the mesh knew only by address — taking the resolver in that state silences three machines at once. Now the operator states which machine a carried address is (overlay name
), the statement rides tunnel_peer.named, and namesInTheMesh answers for named not-yet-enrolled peers — one reading, so the hosts fact, a container's hosts and the resolver cannot disagree. Enrolment verifies the word: a machine enrolling under a named peer's key with a different name is refused where the operator can read it, the stated name keeps the carried address, and an enrolled peer's name is the node's — naming it again refuses. The issue's rule holds: a name the predecessor answers for keeps resolving until the machine behind it is a node. --- cmd/mesh-controller/network.go | 39 +++++++- internal/inventory/addresses.go | 11 +++ .../0033-a-carried-peer-is-nameable.sql | 10 ++ internal/inventory/named_peer_test.go | 92 +++++++++++++++++++ internal/inventory/tunnel.go | 47 +++++++++- 5 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql create mode 100644 internal/inventory/named_peer_test.go diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index 724eaf7..5bdf704 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -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 [flags], or overlay show") + return errors.New("overlay place [flags], overlay 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 ") + } + 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. 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( @@ -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 } diff --git a/internal/inventory/addresses.go b/internal/inventory/addresses.go index 44214df..813f179 100644 --- a/internal/inventory/addresses.go +++ b/internal/inventory/addresses.go @@ -69,6 +69,17 @@ func (i *Inventory) AssignAddress(ctx context.Context, node, cidr string) (strin if key != nil && p.PublicKey == *key { // The tunnel already routes to this key: the node keeps that address, and the peer // notices nothing when its machine enrols. + // + // **Unless the operator named it something else** (novox/hq issue 112). The name is + // what the mesh has been answering for this address in the meantime; a machine + // enrolling under a different one would silently split the two — the name resolving + // here, the node known as that — so it is refused where the operator can read it. + if p.Named != "" && p.Named != name { + return "", fmt.Errorf( + "the carried peer at %s was named %q, and %q is enrolling under its key — "+ + "enrol it as %q, or rename the peer first (`overlay name`)", + p.Address, p.Named, name, p.Named) + } return i.place(ctx, node, p.Address) } taken[p.Address] = true diff --git a/internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql b/internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql new file mode 100644 index 0000000..0834ee8 --- /dev/null +++ b/internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql @@ -0,0 +1,10 @@ +-- A carried peer may be named before it enrols (novox/hq issue 112). +-- +-- The tunnel the hub took over routes to machines the predecessor knows by name and the mesh +-- knows only by address. A name the predecessor answers for must keep resolving until the +-- machine behind it is a node — so the operator may state which machine a carried address is, +-- and everything derived from "the machines the mesh knows" (a container's hosts, the hosts +-- fact, the resolver) answers for it in the meantime. The mesh records the statement as the +-- operator's, unverified: enrolment is what verifies it, and enrolling under a different name +-- than the one stated is refused rather than silently renamed. +alter table tunnel_peer add column named text; diff --git a/internal/inventory/named_peer_test.go b/internal/inventory/named_peer_test.go new file mode 100644 index 0000000..52c669e --- /dev/null +++ b/internal/inventory/named_peer_test.go @@ -0,0 +1,92 @@ +package inventory + +// A carried peer is nameable (novox/hq issue 112): the operator states which machine a carried +// address is, the mesh answers for the name until the machine enrols, and enrolment verifies the +// statement rather than silently renaming it. + +import ( + "strings" + "testing" +) + +func TestACarriedPeerIsNamedAndTheRegistrySaysSo(t *testing.T) { + inv := fresh(t) + anAdoptedHub(t, inv) + + if err := inv.NamePeer(t.Context(), "192.0.2.2", "home-server"); err != nil { + t.Fatal(err) + } + carried, err := inv.CarriedPeers(t.Context()) + if err != nil { + t.Fatal(err) + } + byKey := map[string]CarriedPeer{} + for _, c := range carried { + byKey[c.PublicKey] = c + } + if byKey[peerTwo].Named != "home-server" || byKey[peerThree].Named != "" { + t.Fatalf("the statement was not recorded where it was made: %+v", carried) + } +} + +func TestNamingRefusesWhatWouldCollide(t *testing.T) { + inv := fresh(t) + anAdoptedHub(t, inv) + + if err := inv.NamePeer(t.Context(), "192.0.2.9", "ghost"); err == nil || + !strings.Contains(err.Error(), "no carried peer") { + t.Fatalf("naming an address nothing carries must refuse; got %v", err) + } + if err := inv.NamePeer(t.Context(), "192.0.2.2", "anchor"); err == nil || + !strings.Contains(err.Error(), "node of this mesh") { + t.Fatalf("naming a peer after a node must refuse; got %v", err) + } + if err := inv.NamePeer(t.Context(), "192.0.2.2", "home-server"); err != nil { + t.Fatal(err) + } + if err := inv.NamePeer(t.Context(), "192.0.2.3", "home-server"); err == nil || + !strings.Contains(err.Error(), "already named") { + t.Fatalf("one machine per name; got %v", err) + } +} + +func TestEnrolmentUnderANamedKeyMustUseTheName(t *testing.T) { + inv := fresh(t) + anAdoptedHub(t, inv) + if err := inv.NamePeer(t.Context(), "192.0.2.3", "home-server"); err != nil { + t.Fatal(err) + } + + // The wrong name is refused where the operator can read it… + imposter, err := inv.AddNode(t.Context(), "some-other-name") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordOverlayKey(t.Context(), imposter.ID, peerThree); err != nil { + t.Fatal(err) + } + if _, err := inv.AssignAddress(t.Context(), imposter.ID, "192.0.2.0/24"); err == nil || + !strings.Contains(err.Error(), `named "home-server"`) { + t.Fatalf("enrolling a named peer under another name must refuse; got %v", err) + } + + // …and the stated name enrols cleanly, keeping the carried address. + named, err := inv.AddNode(t.Context(), "home-server") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordOverlayKey(t.Context(), named.ID, peerThree); err != nil { + t.Fatal(err) + } + address, err := inv.AssignAddress(t.Context(), named.ID, "192.0.2.0/24") + if err != nil { + t.Fatal(err) + } + if address != "192.0.2.3" { + t.Fatalf("the named peer keeps its carried address; got %s", address) + } + if err := inv.NamePeer(t.Context(), "192.0.2.3", "renamed"); err == nil || + !strings.Contains(err.Error(), "enrolled as") { + t.Fatalf("an enrolled peer's name is the node's; got %v", err) + } +} diff --git a/internal/inventory/tunnel.go b/internal/inventory/tunnel.go index 39ceeb7..18cb2f0 100644 --- a/internal/inventory/tunnel.go +++ b/internal/inventory/tunnel.go @@ -85,6 +85,9 @@ type CarriedPeer struct { Address string // EnrolledAs names the node that enrolled with this key, or is empty while none has. EnrolledAs string + // Named is what the operator said this peer is, before it enrolled (novox/hq issue 112) — + // a statement the mesh records and cannot verify, which is why enrolment checks it. + Named string } // ErrNoTunnel is asking about a tunnel on a node that presented none. @@ -247,7 +250,7 @@ func (i *Inventory) AdoptedTunnel(ctx context.Context) (Tunnel, string, bool, er // adopted no tunnel. func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) { rows, err := i.store.Pool().Query(ctx, - `select p.public_key, host(p.address), coalesce(n.name, '') + `select p.public_key, host(p.address), coalesce(n.name, ''), coalesce(p.named, '') from tunnel_peer p join node hub on hub.id = p.node and hub.is_hub and hub.tunnel is not null and hub.overlay_key = hub.tunnel->>'public_key' @@ -260,7 +263,7 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) { var out []CarriedPeer for rows.Next() { var p CarriedPeer - if err := rows.Scan(&p.PublicKey, &p.Address, &p.EnrolledAs); err != nil { + if err := rows.Scan(&p.PublicKey, &p.Address, &p.EnrolledAs, &p.Named); err != nil { return nil, err } out = append(out, p) @@ -268,6 +271,46 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) { return out, rows.Err() } +// NamePeer records the operator's statement that a carried address is a particular machine +// (novox/hq issue 112). Refused when nothing carried has that address, when a node of the mesh +// already has the name — the statement would collide with something verified — and when another +// peer was already named it. Naming an enrolled peer is refused too: its name is the node's now. +func (i *Inventory) NamePeer(ctx context.Context, address, name string) error { + peers, err := i.CarriedPeers(ctx) + if err != nil { + return err + } + var at *CarriedPeer + for idx := range peers { + if peers[idx].Address == address { + at = &peers[idx] + continue + } + if peers[idx].Named == name { + return fmt.Errorf("the carried peer at %s is already named %q — one machine per name", + peers[idx].Address, name) + } + } + if at == nil { + return fmt.Errorf("no carried peer has the address %s — `overlay show` lists them", address) + } + if at.EnrolledAs != "" { + return fmt.Errorf("the peer at %s enrolled as %q — its name is the node's now", address, at.EnrolledAs) + } + if _, err := i.NodeByName(ctx, name); err == nil { + return fmt.Errorf("%q is a node of this mesh — a carried peer cannot be named after one", name) + } + tag, err := i.store.Pool().Exec(ctx, + `update tunnel_peer set named = $2 where host(address) = $1`, address, name) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("no carried peer has the address %s", address) + } + return nil +} + // FoundTunnel is a node's found tunnel with the node's mode, for composing: the takeover is // declared to an adopted node only, since only there is a found unit kept to be stopped. type FoundTunnel struct { From 48d8c897497ad758872046ce17c88a54bb582756 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 22:20:10 +0200 Subject: [PATCH 03/18] The broker opening is only on the broker's host, not every node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrolling ace applied adoption.opening-tcp-5671-incoming to it, opening 5671 from anywhere (v4+v6) where nothing listens — the ace session caught it. foundation ports widen the broker's from:mesh port to from-anywhere so a machine that is not yet on the mesh can make its first dial; that belongs on the broker's host alone. foundationPortsFor keeps the port only when a module resolved onto this node listens on it, so novox opens 5671 and a node that merely dials out opens nothing. Two tests, both directions. --- cmd/mesh-controller/foundation_scope_test.go | 33 ++++++++++++++++++++ cmd/mesh-controller/plan.go | 27 +++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 cmd/mesh-controller/foundation_scope_test.go diff --git a/cmd/mesh-controller/foundation_scope_test.go b/cmd/mesh-controller/foundation_scope_test.go new file mode 100644 index 0000000..9ff8591 --- /dev/null +++ b/cmd/mesh-controller/foundation_scope_test.go @@ -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) + } +} diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index e744e2d..6927def 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -534,11 +534,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) } } } @@ -1135,3 +1143,20 @@ func portsOn( } return out, nil } + +// 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 +} From cc252472e26e24f265a00411b9a1a584a4b82dc9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 22:33:27 +0200 Subject: [PATCH 04/18] A taken tunnel brings its ListenPort, even on a node the hub cannot dial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A home node behind NAT (no Endpoint → not Reachable) that took over a tunnel must still listen on that tunnel's port: its LAN peers dial it there. ListenPort was gated on Reachable, which conflated 'a peer dials me here' with 'the hub can dial me' — so the takeover guard refused overlay-up, and the guard's suggested remedy (re-place with an endpoint) breaks a NAT'd node's path: it stops keepalive and hands the hub a private LAN address to dial. TakeOver now carries the found tunnel's port (already known to the controller), and the interface listens on it when the node is not otherwise reachable. Two tests; Endpoint-reachable nodes keep the old path unchanged. --- cmd/mesh-controller/network.go | 2 +- internal/overlay/declaration.go | 6 ++++++ internal/overlay/declaration_test.go | 26 ++++++++++++++++++++++++++ internal/overlay/graph.go | 5 +++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index 5bdf704..83243ee 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -256,7 +256,7 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool, "Re-place it — `overlay place %s --hub --endpoint :%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} } if p.Hub { for _, c := range carried { diff --git a/internal/overlay/declaration.go b/internal/overlay/declaration.go index 65b4e30..8950b69 100644 --- a/internal/overlay/declaration.go +++ b/internal/overlay/declaration.go @@ -123,6 +123,12 @@ func config(node Node, peers []Peer, keyPath string) string { if port := portOf(node.Endpoint); port != "" { fmt.Fprintf(&b, "ListenPort = %s\n", port) } + } else if node.TakesOver != nil && node.TakesOver.Port != 0 { + // Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over, + // so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings + // its port). Without this the takeover guard refuses overlay-up, and re-placing the node + // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path. + fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port) } // The private key is set from a file the node wrote, so it never appears here and never // travelled. Everything else in this file came from the mesh; this one line is the node's. diff --git a/internal/overlay/declaration_test.go b/internal/overlay/declaration_test.go index 8a941ff..d763816 100644 --- a/internal/overlay/declaration_test.go +++ b/internal/overlay/declaration_test.go @@ -260,3 +260,29 @@ func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) { } } } + +func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) { + // A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still + // listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel + // brings its port). Without this the takeover guard refuses overlay-up. + config, _ := declarationFor(t, Node{ + Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", + TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820}, + }, nil) + + if !strings.Contains(config, "ListenPort = 51820") { + t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config) + } + if strings.Contains(config, "Endpoint =") { + t.Error("a node that only listens for LAN peers must not advertise an endpoint") + } +} + +func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) { + // The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel + // writes no ListenPort — it purely dials out. + config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil) + if strings.Contains(config, "ListenPort") { + t.Fatalf("a dial-only node needs no ListenPort:\n%s", config) + } +} diff --git a/internal/overlay/graph.go b/internal/overlay/graph.go index c4f9c28..3a11bce 100644 --- a/internal/overlay/graph.go +++ b/internal/overlay/graph.go @@ -50,6 +50,11 @@ type TakeOver struct { Interface string Unit string Config string + // Port is the port the found tunnel listened on. The mesh's interface must listen on it too, + // even on a node that is not dialable from the hub: a home node's LAN peers dial it there + // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not + // "the hub can dial me", which is Reachable — the two were conflated. + Port int } // HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6. From 7fc5fd02fdad5106289c6c60dd52024ac3ae2672 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 22:40:24 +0200 Subject: [PATCH 05/18] The mesh's interface takes over the found tunnel's MTU Carries MTU from the reported tunnel (mesh-host#28) through inventory, the overlay graph's TakeOver, into the generated config's [Interface]. A tuned path keeps its MTU across the takeover instead of regressing to 1420 and hanging transfers no ping would reveal. Two emit tests; a tunnel with no MTU writes no line. --- cmd/mesh-controller/network.go | 2 +- internal/inventory/tunnel.go | 3 +++ internal/link/enrolment.go | 4 ++-- internal/link/protocol.go | 1 + internal/overlay/declaration.go | 6 ++++++ internal/overlay/declaration_test.go | 23 +++++++++++++++++++++++ internal/overlay/graph.go | 2 ++ 7 files changed, 38 insertions(+), 3 deletions(-) diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index 83243ee..5b52dff 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -256,7 +256,7 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool, "Re-place it — `overlay place %s --hub --endpoint :%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, Port: t.Port} + 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 { diff --git a/internal/inventory/tunnel.go b/internal/inventory/tunnel.go index 18cb2f0..ce6a3d7 100644 --- a/internal/inventory/tunnel.go +++ b/internal/inventory/tunnel.go @@ -33,6 +33,9 @@ type Tunnel struct { // Port is the port the found interface listened on — one the hosting provider already lets // through, which is why it is worth taking. Port int `json:"port"` + // MTU is the found interface's, when it set one; the mesh's interface takes it over so a + // tuned path does not silently regress to the default (novox/hq: a taken tunnel carries its MTU). + MTU int `json:"mtu,omitempty"` // Address is the interface's own address with its prefix length, 192.0.2.1/24; Range is the // network that prefix names, 192.0.2.0/24. Address string `json:"address"` diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index 54b8967..6789377 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -140,7 +140,7 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol } if err := e.Inventory.RecordTunnel(ctx, node.ID, inventory.Tunnel{ Interface: request.Tunnel.Interface, Unit: request.Tunnel.Unit, - Config: request.Tunnel.Config, Port: request.Tunnel.Port, + Config: request.Tunnel.Config, Port: request.Tunnel.Port, MTU: request.Tunnel.MTU, Address: request.Tunnel.Address, Range: request.Tunnel.Range, PublicKey: request.Tunnel.PublicKey, Peers: peers, }); err != nil { @@ -199,7 +199,7 @@ func (e Enrolment) rekey(ctx context.Context, node inventory.Node, r Rekey) erro peers = append(peers, inventory.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address}) } err := e.Inventory.Rekey(ctx, node.ID, r.Previous, r.OverlayKey, inventory.Tunnel{ - Interface: r.Tunnel.Interface, Unit: r.Tunnel.Unit, Config: r.Tunnel.Config, Port: r.Tunnel.Port, + Interface: r.Tunnel.Interface, Unit: r.Tunnel.Unit, Config: r.Tunnel.Config, Port: r.Tunnel.Port, MTU: r.Tunnel.MTU, Address: r.Tunnel.Address, Range: r.Tunnel.Range, PublicKey: r.Tunnel.PublicKey, Peers: peers, }) if err != nil { diff --git a/internal/link/protocol.go b/internal/link/protocol.go index d814d15..561f3b6 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -93,6 +93,7 @@ type Tunnel struct { Unit string `json:"unit"` Config string `json:"config"` Port int `json:"port"` + MTU int `json:"mtu,omitempty"` Address string `json:"address"` Range string `json:"range"` PublicKey string `json:"public_key"` diff --git a/internal/overlay/declaration.go b/internal/overlay/declaration.go index 8950b69..7c100af 100644 --- a/internal/overlay/declaration.go +++ b/internal/overlay/declaration.go @@ -130,6 +130,12 @@ func config(node Node, peers []Peer, keyPath string) string { // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path. fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port) } + // The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS + // and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows + // it (novox/hq: a taken tunnel carries its MTU). + if node.TakesOver != nil && node.TakesOver.MTU != 0 { + fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU) + } // The private key is set from a file the node wrote, so it never appears here and never // travelled. Everything else in this file came from the mesh; this one line is the node's. fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath) diff --git a/internal/overlay/declaration_test.go b/internal/overlay/declaration_test.go index d763816..64fabd9 100644 --- a/internal/overlay/declaration_test.go +++ b/internal/overlay/declaration_test.go @@ -286,3 +286,26 @@ func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) { t.Fatalf("a dial-only node needs no ListenPort:\n%s", config) } } + +func TestATakenTunnelCarriesItsMTU(t *testing.T) { + // A path tuned to a smaller MTU (1380 here) must survive the takeover — the mesh interface + // comes up with the same MTU, or transfers hang silently (novox/hq: a taken tunnel carries + // its MTU). + config, _ := declarationFor(t, Node{ + Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", + TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: 1380}, + }, nil) + if !strings.Contains(config, "MTU = 1380") { + t.Fatalf("the tuned MTU was dropped:\n%s", config) + } +} + +func TestNoMTULineWhenTheTunnelSetNone(t *testing.T) { + config, _ := declarationFor(t, Node{ + Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", + TakesOver: &TakeOver{Interface: "wg0", Port: 51820}, + }, nil) + if strings.Contains(config, "MTU") { + t.Fatalf("no MTU was found, so none should be written:\n%s", config) + } +} diff --git a/internal/overlay/graph.go b/internal/overlay/graph.go index 3a11bce..4be1a43 100644 --- a/internal/overlay/graph.go +++ b/internal/overlay/graph.go @@ -50,6 +50,8 @@ type TakeOver struct { Interface string Unit string Config string + // MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU. + MTU int // Port is the port the found tunnel listened on. The mesh's interface must listen on it too, // even on a node that is not dialable from the hub: a home node's LAN peers dial it there // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not From 0a8a592ef40b78144942a147255ef2a593cd7827 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:32:30 +0200 Subject: [PATCH 06/18] An empty declaration is sent, so a node drops what it last held (hq 127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit push skipped any node whose declaration composed to zero resources. A node that HELD something before — the broker opening a placement gave an adopted node, say — then kept it forever: the empty declaration that would drop it was never sent, and the node's own heartbeat re-applied the stale resource with no way for the mesh to say it is gone. Now the empty declaration is sent; the host drops what the mesh owned and keeps what it found. A node that never held anything applies it as a no-op. Surfaced on ace: the foundation-opening fix (#74) removed its only resource, and the correction could not reach it until this. --- cmd/mesh-controller/push.go | 10 ++++++++-- cmd/mesh-controller/push_test.go | 10 ++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 2319213..74ec025 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -502,8 +502,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}) } diff --git a/cmd/mesh-controller/push_test.go b/cmd/mesh-controller/push_test.go index 3e6dc9d..aeaabfa 100644 --- a/cmd/mesh-controller/push_test.go +++ b/cmd/mesh-controller/push_test.go @@ -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) } } From e14b02991e8052649486ec2997029d72a589c1e0 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:40:04 +0200 Subject: [PATCH 07/18] The controller marks a deliberately-empty declaration owns_nothing (hq 127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host refuses an empty body unless told the emptiness is meant (mesh-host#29). When a node's declaration composes to no resources — which #77 now sends rather than skips — Body() sets owns_nothing, so the node applies it and drops what it last held. A declaration with resources never carries the marker. One test. --- cmd/mesh-controller/sendable.go | 6 ++++++ cmd/mesh-controller/sendable_test.go | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 8c282ac..fdebf7e 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -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) } diff --git a/cmd/mesh-controller/sendable_test.go b/cmd/mesh-controller/sendable_test.go index f831e34..73ce86c 100644 --- a/cmd/mesh-controller/sendable_test.go +++ b/cmd/mesh-controller/sendable_test.go @@ -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) + } +} From 6557aca750486979d59b3826f4b7ba9b281b986c Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:45:47 +0200 Subject: [PATCH 08/18] A machine's uplink is a seat (hq ADR 0117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the-uplink joins the closed set as a node seat delivering nothing. Its holder is the module for the machine's own network manager, and keeps that manager from contradicting the mesh — the resolver file left to resolv-conf, mesh0 left alone — without ever declaring a link. Held per machine, so a machine running two managers is refused at assignment rather than found by its resolver being rewritten. The count test moves to fifteen; one test holds the seat's shape. --- internal/catalogue/seats.go | 7 +++++++ internal/catalogue/seats_test.go | 24 ++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 2475e06..dfcadc9 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -47,6 +47,13 @@ var seats = []Seat{ {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "the-resolver-configuration", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "the-showcase", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + // The program that manages the machine's own network. It delivers nothing: its holder only + // keeps the manager and the mesh from contradicting each other — the resolver file left to the + // mesh, the private network's interface left alone — and never declares a link, an address or + // a wireless network, because the link is the only channel a fix could arrive on. A seat + // rather than a condition in the resolver's module, so a machine running two managers is + // refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117). + {Name: "the-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"}, } // Seats is every seat the mesh defines, in reading order. diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 96cdfc2..f1c2087 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -44,12 +44,32 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - if len(Seats()) != 14 { - t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+ + if len(Seats()) != 15 { + t.Errorf("the mesh defines %d seats rather than 15; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } +// novox/hq ADR 0117: a machine's uplink is a seat, held per machine, and delivers nothing. +// +// **Nothing, because nothing may be required of it.** A holder only keeps its network manager from +// contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer +// for something, and the manager's link is the one thing the mesh must never be able to break. +func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { + seat, known := SeatNamed("the-uplink") + if !known { + t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames()) + } + if seat.Scope != ScopeNode || seat.Delivers != "" || seat.Decision != "novox/hq ADR 0117" { + t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat) + } + // And a manager's module can hold it without providing anything. + raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"the-uplink","scope":"node"}]}`) + if _, err := ParseManifest(raw); err != nil { + t.Fatalf("a network manager's module could not hold the uplink: %v", err) + } +} + func claimed(claims string) []byte { return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`) } From 51163b9f1479fcabc42e271dbe66ff0d7d2cd4b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:46:51 +0200 Subject: [PATCH 09/18] The mesh's names are written into the hosts file, not over it (hq 128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /etc/hosts is the machine's: the distribution's localhost lines, the operator's own entries, and marked blocks other tools maintain there. Writing node-names whole replaced all of it the moment the private network was taken, and every later write by those tools was lost at the next machine joining. The node-names fact is now emitted with into: "block", so the host owns only its marked region and keeps the rest byte for byte. The region holds only the mesh's names: no header claiming the file, no localhost, no 127.0.1.1 line — the floor was never the mesh's to write. How a fact is written is a property of the fact in the closed table; node-zones stays a whole file the mesh owns. Sequencing: a host older than the block mode refuses the whole declaration on an unknown into, so every host must be upgraded before this controller is rolled out. --- internal/catalogue/facts.go | 96 +++++++++++++++++++++++--------- internal/catalogue/facts_test.go | 57 ++++++++++++++++++- internal/overlay/names.go | 7 ++- 3 files changed, 128 insertions(+), 32 deletions(-) diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go index 2fdd510..99752c9 100644 --- a/internal/catalogue/facts.go +++ b/internal/catalogue/facts.go @@ -18,8 +18,10 @@ import ( // module at all (novox/hq ADR 0040). const ( - // FactNodeNames is every machine's name and address, as a hosts file. + // FactNodeNames is every machine's name and address, as lines of a hosts file. // + // Written *into* the machine's hosts file as a region of its own, never as the file: the rest + // of that file is the distribution's, the operator's and other tools' (novox/hq issue 128). // Exact names only: `homer` and `homer.internal` resolve to homer. Anything *under* a machine // is a wildcard, which a hosts file cannot express — that is FactNodeZones. FactNodeNames = "node-names" @@ -31,20 +33,44 @@ const ( FactNodeZones = "node-zones" ) -// facts is every fact the mesh computes, and what writes it. +// fact is one thing the mesh computes, and how it is written. +type fact struct { + // render is the fact's content. A fact is written from the names it is about. `every` is every + // name the mesh serves — machines and the names it was told to route; `machines` is only the + // machines. A fact takes the set it is true of, and the two must not be confused + // (novox/hq 04-ISSUES/111). + render func(r Resolution, every, machines map[string]string, suffix string) string + // shared is whether the file the fact goes to belongs to the machine rather than to the mesh. + // + // **A property of the fact, not of the path a module asked for.** A hosts file is the + // machine's wherever it lives: the distribution put `localhost` in it, the operator added + // their own lines, and a local development tool keeps marked blocks of its own there. The + // mesh writing it whole replaced all of that the moment the private network was taken, and + // every later write by the other tool was lost at the next machine joining — silently, with + // both sides believing they owned the file (novox/hq issue 128). That is ADR 0102's failure in + // a file 0102 did not name, because its merge is structured and a hosts file is not; the + // answer is the same idea for text — a marked region the host owns, everything outside it + // kept byte for byte. A resolver's zones file is the other way about: the mesh owns it, and + // nothing else writes there. + shared bool +} + +// facts is every fact the mesh computes, and how each is written. // // **A closed list.** A module asking for a fact the mesh does not have is asking for a file nobody // will write, and finding that out on a machine — as a daemon that starts, reads nothing, and // answers no queries — is worse than being told where the manifest is. -// A fact is written from the names it is about. `every` is every name the mesh serves — machines -// and the names it was told to route; `machines` is only the machines. A fact takes the set it is -// true of, and the two must not be confused (novox/hq 04-ISSUES/111). -var facts = map[string]func(r Resolution, every, machines map[string]string, suffix string) string{ - FactNodeNames: func(r Resolution, every, _ map[string]string, suffix string) string { - return nodeNames(r, every, suffix) +var facts = map[string]fact{ + FactNodeNames: { + render: func(r Resolution, every, _ map[string]string, suffix string) string { + return nodeNames(r, every, suffix) + }, + shared: true, }, - FactNodeZones: func(r Resolution, _, machines map[string]string, suffix string) string { - return nodeZones(r, machines, suffix) + FactNodeZones: { + render: func(r Resolution, _, machines map[string]string, suffix string) string { + return nodeZones(r, machines, suffix) + }, }, } @@ -64,7 +90,7 @@ func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, out := make([]map[string]any, 0, len(names)) for _, name := range names { - write, known := facts[name] + f, known := facts[name] if !known { return nil, fmt.Errorf( "%s asks the mesh for %q, which it does not compute. It has %s", @@ -75,10 +101,23 @@ func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, return nil, fmt.Errorf( "%s asks for %q at %q, which is not an absolute path", m.Module, name, path) } - out = append(out, map[string]any{ + file := map[string]any{ "id": "fact-" + name, "type": "file", "path": path, "mode": "0644", - "content": write(r, addresses, machines, suffix), - }) + "content": f.render(r, addresses, machines, suffix), + } + if f.shared { + // The host owns only the lines between `# BEGIN mesh ` and `# END mesh ` and + // keeps the rest of the file byte for byte; undeclared, the region goes and nothing + // else does. Replacing nothing, it is written on an adopted node without being held, + // so a machine is named on the private network before its module is taken. + // + // **Hosts first, then this.** A host older than the block mode refuses the whole + // declaration on an `into` it does not know — not just this file, everything — so + // every host is upgraded before a controller emitting it is rolled out, the same + // order ADR 0102 set for `into: json` (novox/hq issue 128). + file["into"] = "block" + } + out = append(out, file) } return out, nil } @@ -93,7 +132,18 @@ func spokenFacts() string { return strings.Join(names, ", ") } -// nodeNames is every machine's name and address, as a hosts file. +// nodeNames is every machine's name and address, as the mesh's region of a hosts file. +// +// **Only the mesh's names.** No header claiming the file, no `localhost`, no `127.0.1.1` line for +// the machine itself. Those used to be written here as the floor every Linux expects, because the +// mesh wrote the whole file and removing them would have broken things with nothing to do with +// the mesh. They were never the mesh's: the distribution wrote them before the mesh arrived and +// will want them after it leaves, and a machine's own name belongs to whoever named the machine. +// Now the host writes this into a marked region (novox/hq issue 128) and leaves the rest of the +// file as it found it, so the floor stays where it always was — the machine's — and the mesh +// writing a second `localhost` beside it would be one more line nobody could say the owner of. +// The one comment line is for a person reading the file: which lines are the mesh's, and that +// editing them is pointless. // // **A machine with no address is left out.** The mesh has a record for it — somebody added it — // and does not yet know where it is, which is the ordinary state between adding a machine and it @@ -101,21 +151,15 @@ func spokenFacts() string { // that hangs; leaving it out fails at once and says the name is unknown. func nodeNames(r Resolution, addresses map[string]string, suffix string) string { var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n") - b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n") - // The floor every Linux expects, and which removing would break things that have nothing to do - // with the mesh. - b.WriteString("127.0.0.1\tlocalhost\n") - b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n") - if r.Node != "" { - fmt.Fprintf(&b, "127.0.1.1\t%s\n", r.Node) - } - b.WriteString("\n") + b.WriteString("# The mesh's names. This region is replaced whenever a machine joins or leaves.\n") for _, name := range sortedNames(addresses) { at := addresses[name] internal, bare := meshName(name, suffix) // Its mesh name resolves to its address on the private network rather than to loopback, - // so a service binding the name it was given stays reachable from everywhere else. + // so a service binding the name it was given stays reachable from everywhere else. The + // bare name may be answered first by a line of the machine's own — `127.0.1.1 homer`, + // above the region — and that is the machine's choice to have made; the mesh name is the + // one nothing else in the file writes. fmt.Fprintf(&b, "%s\t%s\t%s", at, internal, bare) if bare == r.Node { b.WriteString("\t# this machine") diff --git a/internal/catalogue/facts_test.go b/internal/catalogue/facts_test.go index 1f30b69..577b309 100644 --- a/internal/catalogue/facts_test.go +++ b/internal/catalogue/facts_test.go @@ -54,9 +54,60 @@ func TestAMachinesOwnNameIsItsMeshAddress(t *testing.T) { if !strings.HasPrefix(line, "10.42.0.1") { t.Fatalf("a machine's own mesh name is not its mesh address: %q", line) } - // And the loopback floor is still there, or things with nothing to do with the mesh break. - if !strings.Contains(out, "127.0.0.1\tlocalhost") { - t.Fatalf("the loopback floor was removed:\n%s", out) +} + +// novox/hq issue 128: the names are the mesh's region of the machine's hosts file, and only that. +// +// **The loopback floor is the machine's now, not the mesh's.** It was written here while the mesh +// wrote the whole file. Written into a region, a `localhost` or a `127.0.1.1 homer` of the mesh's +// would stand beside the distribution's own — a second answer nobody could say the owner of, and +// one that goes when the mesh leaves. A header claiming the file would be a lie about the rest of +// it. And no line may look like the host's own markers, or the host would refuse the region. +func TestTheNamesAreOnlyTheMeshsRegionOfTheFile(t *testing.T) { + out := nodeNames(Resolution{Node: "homer"}, threeMachines, "") + want := "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" + + "10.42.0.1\thomer.internal\thomer\t# this machine\n" + + "10.42.0.2\tmarge.internal\tmarge\n" + if out != want { + t.Fatalf("the region is not exactly the mesh's names:\n%s\n--- want ---\n%s", out, want) + } + for _, floor := range []string{"localhost", "127.0.", "::1", "Generated by the mesh", "# BEGIN mesh ", "# END mesh "} { + if strings.Contains(out, floor) { + t.Fatalf("the region carries %q, which is not the mesh's to write:\n%s", floor, out) + } + } +} + +// The hosts file is written into; the resolver's zones are written whole. A property of each fact, +// so a module asking for node-names at another path still does not get a file of the mesh's. +func TestOnlyTheNamesAreWrittenIntoASharedFile(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]string{ + FactNodeZones: "/etc/mesh-resolver/nodes.conf", FactNodeNames: "/etc/hosts", + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") + if err != nil { + t.Fatal(err) + } + if len(given) != 2 { + t.Fatalf("expected two files, got %v", given) + } + for _, f := range given { + switch f["path"] { + case "/etc/hosts": + if f["into"] != "block" { + t.Errorf("the hosts file is written over rather than into: %v", f) + } + case "/etc/mesh-resolver/nodes.conf": + // The mesh owns the resolver's zones; nothing else writes there. + if into, set := f["into"]; set { + t.Errorf("the zones file is written into (%v), and it is the mesh's whole", into) + } + if !strings.HasPrefix(f["content"].(string), "# Generated by the mesh.") { + t.Errorf("the zones file lost its header: %v", f["content"]) + } + default: + t.Errorf("a file nobody asked for: %v", f) + } } } diff --git a/internal/overlay/names.go b/internal/overlay/names.go index a3b0b03..1c8f378 100644 --- a/internal/overlay/names.go +++ b/internal/overlay/names.go @@ -24,9 +24,10 @@ const DefaultSuffix = "internal" // // This is not the `/etc/hosts` floor the design removes. That floor existed because a node had to // reach the mesh's database before its own DNS worked — a fallback for a circularity, and the -// circularity is gone. This is the mechanism itself: the complete set of names in this mesh, -// generated whole and owned by the mesh (novox/hq ADR 0011), rather than a patch written -// underneath something else. +// circularity is gone. This is the mechanism itself: the complete set of names in this mesh +// (novox/hq ADR 0011). Written as a marked region *into* the file rather than as the file: the +// rest of it — `localhost`, the machine's own name, other tools' blocks — is the machine's, and +// writing it whole replaced all of that (novox/hq issue 128). // // A file rather than a resolver daemon, deliberately, for now: it works on every Linux, needs no // package, and has no failure mode of its own. A daemon becomes necessary when names are wanted From 63ba2d178f872d029965f3885bad60d97aaeb30e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:05:41 +0200 Subject: [PATCH 10/18] review: hold the hosts region through composition, and say so in plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declaration-level test composes the shipped networking module with a resolver and asserts /etc/hosts arrives as mesh-wireguard.fact-node-names with into: block and region-only content, that the resolver's restart-on still names it, and that a resource's at passes through untouched — a composition step dropping into would otherwise go unnoticed. plan --show marks files written into, so a region is not read as the whole file. The rollout order is spelled out: every node's host, the controller's own included, must be block-aware before this controller ships (hq 128). --- cmd/mesh-controller/plan.go | 16 +++- cmd/mesh-controller/plan_test.go | 14 ++++ internal/catalogue/facts.go | 12 ++- internal/catalogue/hosts_region_test.go | 106 ++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 internal/catalogue/hosts_region_test.go diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 6927def..6dcb96e 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -923,12 +923,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 diff --git a/cmd/mesh-controller/plan_test.go b/cmd/mesh-controller/plan_test.go index 35a86b8..0075d8f 100644 --- a/cmd/mesh-controller/plan_test.go +++ b/cmd/mesh-controller/plan_test.go @@ -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) + } +} diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go index 99752c9..c28ce17 100644 --- a/internal/catalogue/facts.go +++ b/internal/catalogue/facts.go @@ -111,10 +111,14 @@ func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, // else does. Replacing nothing, it is written on an adopted node without being held, // so a machine is named on the private network before its module is taken. // - // **Hosts first, then this.** A host older than the block mode refuses the whole - // declaration on an `into` it does not know — not just this file, everything — so - // every host is upgraded before a controller emitting it is rolled out, the same - // order ADR 0102 set for `into: json` (novox/hq issue 128). + // **Hosts first, then this — an order to roll out in, not a note.** A host older than + // the block mode refuses the whole declaration on an `into` it does not know: not just + // this file, everything the node was sent, so it stops converging on anything at all. + // Every node on the private network receives this fact, so every node's host — + // the controller's own machine included, which would otherwise stop taking the + // declaration that runs the controller — must run a block-aware host before a + // controller emitting it is rolled out. The same order ADR 0102 set for `into: json` + // (novox/hq issue 128). file["into"] = "block" } out = append(out, file) diff --git a/internal/catalogue/hosts_region_test.go b/internal/catalogue/hosts_region_test.go new file mode 100644 index 0000000..16e817d --- /dev/null +++ b/internal/catalogue/hosts_region_test.go @@ -0,0 +1,106 @@ +package catalogue_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/overlay" +) + +// novox/hq issue 128, held where the host will see it: the shipped networking module's names, +// through the whole composition, and not only through FactsInto. +// +// Composition prefixes a fact's id with the module that asked for it and passes everything else +// through; a step that dropped `into` on the way would send the region as a whole file, and the +// host would write the machine's hosts file over again with every unit test above still green. + +// onTheNetwork stands in for the overlay's generator: the node is part of the private network, +// and what the generator writes is not what is under test here. +type onTheNetwork struct{} + +func (onTheNetwork) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "overlay-config", "type": "file", + "path": "/etc/wireguard/mesh0.conf", "mode": "0600", "content": "[Interface]\n"}}, true, nil +} + +func TestTheHostsRegionArrivesAsTheHostWillReadIt(t *testing.T) { + shelf := provided(t) + // A resolver restarting on the names another module put on the machine, and one resource it + // only runs at start — neither of which composition has any business changing. + resolver, err := catalogue.ParseManifest([]byte(`{ + "module": "resolver", "version": "1", "requires": ["mesh-addressing"], + "resources": [ + {"id": "seed", "type": "file", "path": "/etc/resolver/seed", "mode": "0644", + "content": "seed\n", "at": "start"}, + {"id": "daemon", "type": "service", "unit": "resolver.service", "state": "running", + "restart-on": ["seed", "mesh-wireguard.fact-node-names"]} + ]}`)) + if err != nil { + t.Fatal(err) + } + shelf[resolver.Module] = resolver + + got, err := catalogue.Resolve(shelf, []string{overlay.Domain, "resolver"}, + catalogue.Node{Name: "homer", At: "homer.internal"}, catalogue.World{}) + if err != nil { + t.Fatal(err) + } + names := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} + out, err := got.Declaration(catalogue.Rendering{ + Names: names, Machines: names, Suffix: "internal", + Generators: map[string]catalogue.Generator{overlay.Name: onTheNetwork{}}, + }) + if err != nil { + t.Fatal(err) + } + ids := map[string]map[string]any{} + for _, r := range out { + ids[r["id"].(string)] = r + } + + hosts := ids[overlay.Name+".fact-node-names"] + if hosts == nil { + t.Fatalf("no names reached the machine; the declaration has %v", keys(ids)) + } + if hosts["path"] != "/etc/hosts" || hosts["into"] != "block" { + t.Fatalf("the hosts file is not written into as a region: %v", hosts) + } + content := hosts["content"].(string) + if !strings.Contains(content, "10.42.0.1\thomer.internal\thomer\t# this machine\n") { + t.Errorf("the region does not name the machine:\n%s", content) + } + for _, floor := range []string{"Generated by the mesh", "localhost", "127.0.1.1"} { + if strings.Contains(content, floor) { + t.Errorf("the region carries %q, which is the machine's:\n%s", floor, content) + } + } + + // The resolver's reference to it still names a resource the host will be sent. + daemon := ids["resolver.daemon"] + if daemon == nil { + t.Fatalf("the resolver's service was not composed: %v", keys(ids)) + } + for _, named := range daemon["restart-on"].([]any) { + if ids[named.(string)] == nil { + t.Errorf("the resolver restarts on %v, which is nothing the host is sent", named) + } + } + if !reflect.DeepEqual(daemon["restart-on"], []any{"resolver.seed", overlay.Name + ".fact-node-names"}) { + t.Errorf("restart-on is %v", daemon["restart-on"]) + } + + // And a resource's own `at` passes through as the manifest wrote it. + if seed := ids["resolver.seed"]; seed == nil || seed["at"] != "start" { + t.Errorf("a resource's at did not survive composition: %v", seed) + } +} + +func keys(m map[string]map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} From b36f822cb6741beed094f86a02b492b10e33bcd2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:33:20 +0200 Subject: [PATCH 11/18] Facts carry the format as a template, so the control plane holds none (ADR 0120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A roster fact used to be a name from a closed list, each formatted in Go here — node-names as a hosts file, node-zones as a resolver's zones. Every new consumer (ssh's known_hosts, an authorized_keys) meant another formatter in the control plane, in the consumer's own configuration language. Now a fact is a path and a Go template over the roster view (this node, the suffix, and every served name vs the machines). The mesh owns the data; the module owns the format. /etc/hosts is a template on the network module; dnsmasq's zones move to dnsmasq. The controller renders and reads neither. WireGuard stays a computed generator: the overlay is the substrate delivery rides on, and its config is topology, not a roster projection. Output is byte-for-byte unchanged, pinned by the hosts golden tests and the resolver tests that compose the real dnsmasq manifest. --- internal/catalogue/facts.go | 184 ----------------- internal/catalogue/facts_test.go | 188 ----------------- internal/catalogue/manifest.go | 27 ++- internal/catalogue/provided_test.go | 2 +- internal/catalogue/resolver_manifests_test.go | 2 +- internal/catalogue/roster.go | 165 +++++++++++++++ internal/catalogue/roster_test.go | 195 ++++++++++++++++++ internal/overlay/generator.go | 25 ++- internal/overlay/hosts_fact_test.go | 52 +++++ 9 files changed, 453 insertions(+), 387 deletions(-) delete mode 100644 internal/catalogue/facts.go delete mode 100644 internal/catalogue/facts_test.go create mode 100644 internal/catalogue/roster.go create mode 100644 internal/catalogue/roster_test.go create mode 100644 internal/overlay/hosts_fact_test.go diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go deleted file mode 100644 index 2fdd510..0000000 --- a/internal/catalogue/facts.go +++ /dev/null @@ -1,184 +0,0 @@ -package catalogue - -import ( - "fmt" - "sort" - "strings" -) - -// What only the mesh knows, written where a module asks for it. -// -// **The graph is the control plane's; using it is the module's.** The mesh knows which machines -// exist, what they are called, and where they are. Turning that into a name that resolves is -// somebody's software, and which software is a choice the mesh should not be making. -// -// This replaced three modules — names, a resolver's data, and the private network's own -// configuration — that existed only because computed output needed somewhere to live. They ran no -// software and could not be swapped for anything, which is the test of whether something is a -// module at all (novox/hq ADR 0040). - -const ( - // FactNodeNames is every machine's name and address, as a hosts file. - // - // Exact names only: `homer` and `homer.internal` resolve to homer. Anything *under* a machine - // is a wildcard, which a hosts file cannot express — that is FactNodeZones. - FactNodeNames = "node-names" - - // FactNodeZones is every machine as a wildcard: `*.homer.internal` is homer. - // - // Written in the form a resolver reads. A machine's own name and everything under it are one - // fact — if homer is at an address, so is anything homer serves. - FactNodeZones = "node-zones" -) - -// facts is every fact the mesh computes, and what writes it. -// -// **A closed list.** A module asking for a fact the mesh does not have is asking for a file nobody -// will write, and finding that out on a machine — as a daemon that starts, reads nothing, and -// answers no queries — is worse than being told where the manifest is. -// A fact is written from the names it is about. `every` is every name the mesh serves — machines -// and the names it was told to route; `machines` is only the machines. A fact takes the set it is -// true of, and the two must not be confused (novox/hq 04-ISSUES/111). -var facts = map[string]func(r Resolution, every, machines map[string]string, suffix string) string{ - FactNodeNames: func(r Resolution, every, _ map[string]string, suffix string) string { - return nodeNames(r, every, suffix) - }, - FactNodeZones: func(r Resolution, _, machines map[string]string, suffix string) string { - return nodeZones(r, machines, suffix) - }, -} - -// FactsInto renders the facts a module asked for, as files it will be given. -// -// The module owns everything after the file exists: loading it, restarting on it, what a resolver -// does with it. This only puts it there. -func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, suffix string) ([]map[string]any, error) { - if len(m.Facts) == 0 { - return nil, nil - } - names := make([]string, 0, len(m.Facts)) - for name := range m.Facts { - names = append(names, name) - } - sort.Strings(names) - - out := make([]map[string]any, 0, len(names)) - for _, name := range names { - write, known := facts[name] - if !known { - return nil, fmt.Errorf( - "%s asks the mesh for %q, which it does not compute. It has %s", - m.Module, name, spokenFacts()) - } - path := m.Facts[name] - if !strings.HasPrefix(path, "/") { - return nil, fmt.Errorf( - "%s asks for %q at %q, which is not an absolute path", m.Module, name, path) - } - out = append(out, map[string]any{ - "id": "fact-" + name, "type": "file", "path": path, "mode": "0644", - "content": write(r, addresses, machines, suffix), - }) - } - return out, nil -} - -// spokenFacts lists them, so a refusal says what would have worked. -func spokenFacts() string { - names := make([]string, 0, len(facts)) - for name := range facts { - names = append(names, name) - } - sort.Strings(names) - return strings.Join(names, ", ") -} - -// nodeNames is every machine's name and address, as a hosts file. -// -// **A machine with no address is left out.** The mesh has a record for it — somebody added it — -// and does not yet know where it is, which is the ordinary state between adding a machine and it -// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to -// that hangs; leaving it out fails at once and says the name is unknown. -func nodeNames(r Resolution, addresses map[string]string, suffix string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n") - b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n") - // The floor every Linux expects, and which removing would break things that have nothing to do - // with the mesh. - b.WriteString("127.0.0.1\tlocalhost\n") - b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n") - if r.Node != "" { - fmt.Fprintf(&b, "127.0.1.1\t%s\n", r.Node) - } - b.WriteString("\n") - for _, name := range sortedNames(addresses) { - at := addresses[name] - internal, bare := meshName(name, suffix) - // Its mesh name resolves to its address on the private network rather than to loopback, - // so a service binding the name it was given stays reachable from everywhere else. - fmt.Fprintf(&b, "%s\t%s\t%s", at, internal, bare) - if bare == r.Node { - b.WriteString("\t# this machine") - } - b.WriteString("\n") - } - return b.String() -} - -// nodeZones is every machine as a wildcard, in the form a resolver reads. -// -// `*.homer.internal` is homer, which is the whole rule: if homer is at an address, so is anything -// homer serves. A module wanting this runs the resolver; the mesh only says what is true. -// -// **And the suffix itself, as a local domain.** A resolver that forwards what it cannot answer -// would otherwise send a mesh name it does not know — a machine that left, a typo — to a public -// resolver, which is a leak of the mesh's names for no answer. `local=` keeps everything under the -// suffix here: answered from the lines below or refused. Written in this file rather than in the -// resolver's own configuration because the suffix is the mesh's choice (the operator may have -// picked another) and this file is the one place the mesh writes what it chose. -func nodeZones(_ Resolution, addresses map[string]string, suffix string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n") - b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n") - fmt.Fprintf(&b, "local=/%s/\n", strings.TrimPrefix(suffixOr(suffix), ".")) - for _, name := range sortedNames(addresses) { - internal, _ := meshName(name, suffix) - fmt.Fprintf(&b, "address=/%s/%s\n", internal, addresses[name]) - } - return b.String() -} - -// meshName is a machine's internal name and its bare one, from either. The control plane keys -// the names it hands a resolution by the internal name (`homer.internal`), the same map a -// container gets as its hosts; a caller that keys by the bare name gets the same answer. The -// suffix is the one the control plane composed those names with, handed down rather than written -// here a second time — the alternative was `homer.internal.internal` on every machine. -func meshName(name, suffix string) (internal, bare string) { - dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".") - if strings.HasSuffix(name, dotted) { - return name, strings.TrimSuffix(name, dotted) - } - return name + dotted, name -} - -// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down. -// The one place the default is written in this file, so a fact and a name cannot disagree about it. -func suffixOr(suffix string) string { - if suffix == "" { - return "internal" - } - return suffix -} - -func sortedNames(addresses map[string]string) []string { - out := make([]string, 0, len(addresses)) - for name, at := range addresses { - // See nodeNames: a machine the mesh cannot place is left out rather than named at nothing. - if at == "" { - continue - } - out = append(out, name) - } - sort.Strings(out) - return out -} diff --git a/internal/catalogue/facts_test.go b/internal/catalogue/facts_test.go deleted file mode 100644 index 1f30b69..0000000 --- a/internal/catalogue/facts_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Keyed by the internal name, as the control plane hands them (issue 079). -var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} - -// **`*.homer.internal` is homer. That is the whole rule.** And the suffix itself is local: a -// resolver that forwards what it cannot answer must not send a mesh name it does not know — a -// machine that left, a typo — to a public resolver (hal dnsmasq-app conversion, novox/hq -// 08-connectivity). -func TestEveryMachineIsAWildcardUnderItsOwnName(t *testing.T) { - out := nodeZones(Resolution{Node: "homer"}, threeMachines, "") - for _, want := range []string{ - "local=/internal/", - "address=/homer.internal/10.42.0.1", - "address=/marge.internal/10.42.0.2", - } { - if !strings.Contains(out, want) { - t.Fatalf("missing %q:\n%s", want, out) - } - } -} - -// A machine the mesh has a record for and cannot place is left out of both. -// -// **Not an oversight — the alternative is worse.** A name written with no address resolves to -// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is -// unknown, which is a thing somebody can act on. -func TestAMachineWithNoAddressIsNotNamed(t *testing.T) { - for _, out := range []string{ - nodeNames(Resolution{Node: "homer"}, threeMachines, ""), - nodeZones(Resolution{Node: "homer"}, threeMachines, ""), - } { - if strings.Contains(out, "bart") { - t.Fatalf("a machine with no address was named, so its name resolves to nothing:\n%s", out) - } - } -} - -// A machine's own mesh name points at its address on the private network, not at loopback — or a -// service binding the name it was given is unreachable from everywhere else. -func TestAMachinesOwnNameIsItsMeshAddress(t *testing.T) { - out := nodeNames(Resolution{Node: "homer"}, threeMachines, "") - var line string - for _, l := range strings.Split(out, "\n") { - if strings.Contains(l, "homer.internal") { - line = l - } - } - if !strings.HasPrefix(line, "10.42.0.1") { - t.Fatalf("a machine's own mesh name is not its mesh address: %q", line) - } - // And the loopback floor is still there, or things with nothing to do with the mesh break. - if !strings.Contains(out, "127.0.0.1\tlocalhost") { - t.Fatalf("the loopback floor was removed:\n%s", out) - } -} - -// A module says where it wants a fact, and is given a file. -func TestAModuleIsGivenTheFactsItAskedFor(t *testing.T) { - m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeZones: "/etc/mesh/zones.conf"}} - given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") - if err != nil { - t.Fatal(err) - } - if len(given) != 1 { - t.Fatalf("expected one file, got %d", len(given)) - } - if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" { - t.Fatalf("not written where it was asked for: %v", given[0]) - } - if !strings.Contains(given[0]["content"].(string), "homer.internal") { - t.Fatalf("the file does not hold the fact: %v", given[0]["content"]) - } -} - -// **Asking for a fact the mesh does not have is refused here, not on a machine.** A daemon that -// starts, reads a file nobody wrote, and answers no queries is a much worse way to find out. -func TestAskingForAFactTheMeshDoesNotHaveIsRefused(t *testing.T) { - m := Manifest{Module: "dnsmasq", Facts: map[string]string{"the-weather": "/etc/weather"}} - _, err := FactsInto(m, Resolution{}, nil, nil, "") - if err == nil { - t.Fatal("a module asked for something nobody computes and was given nothing, silently") - } - for _, known := range []string{FactNodeNames, FactNodeZones} { - if !strings.Contains(err.Error(), known) { - t.Fatalf("the refusal does not say what would have worked: %v", err) - } - } -} - -// And a relative path is refused, or a module decides where the mesh writes on a machine. -func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { - m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeNames: "etc/hosts"}} - if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { - t.Fatal("a relative path was accepted") - } -} - -// **The names the control plane hands a resolution are already internal names** — `homer.internal`, -// the same map every container gets as its hosts. Appending the suffix again wrote -// `homer.internal.internal` into every hosts file and every resolver's zones, and the large mesh -// bed's name test was the first to read it back. Either key gives the same files. -func TestNamesKeyedByInternalNameAreNotSuffixedTwice(t *testing.T) { - internal := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - bare := map[string]string{"homer": "10.42.0.1", "marge": "10.42.0.2"} - if a, b := nodeZones(Resolution{Node: "homer"}, internal, ""), nodeZones(Resolution{Node: "homer"}, bare, ""); a != b { - t.Fatalf("the zones differ by how the names were keyed:\n%s\n---\n%s", a, b) - } - if a, b := nodeNames(Resolution{Node: "homer"}, internal, ""), nodeNames(Resolution{Node: "homer"}, bare, ""); a != b { - t.Fatalf("the hosts differ by how the names were keyed:\n%s\n---\n%s", a, b) - } - zones := nodeZones(Resolution{Node: "homer"}, internal, "") - if strings.Contains(zones, "internal.internal") || !strings.Contains(zones, "address=/homer.internal/10.42.0.1") { - t.Fatalf("the zones carry a doubled suffix or miss the name:\n%s", zones) - } - hosts := nodeNames(Resolution{Node: "homer"}, internal, "") - if !strings.Contains(hosts, "10.42.0.1\thomer.internal\thomer\t# this machine") { - t.Fatalf("the hosts line for the machine itself is not name, bare name and the mark:\n%s", hosts) - } -} - -// The suffix the control plane composed the names with is the one the facts write — an operator -// who chose another does not get `.internal` appended to it. -func TestTheFactsWriteTheSuffixTheNamesWereComposedWith(t *testing.T) { - names := map[string]string{"homer.lan": "10.42.0.1"} - zones := nodeZones(Resolution{Node: "homer"}, names, "lan") - if !strings.Contains(zones, "address=/homer.lan/10.42.0.1") || strings.Contains(zones, "internal") { - t.Fatalf("the zones do not carry the operator's suffix as given:\n%s", zones) - } - if !strings.Contains(zones, "local=/lan/") { - t.Fatalf("the local domain is not the operator's suffix, so its names would leak upstream:\n%s", zones) - } - hosts := nodeNames(Resolution{Node: "homer"}, names, "lan") - if !strings.Contains(hosts, "10.42.0.1\thomer.lan\thomer\t# this machine") { - t.Fatalf("the hosts line does not carry the operator's suffix as given:\n%s", hosts) - } -} - -// novox/hq 04-ISSUES/111: the map the control plane hands a resolution holds every name the mesh -// serves — the machines, and the names it was told to route to whichever machine serves them. A -// container's hosts wants all of it. A resolver's zones want only the machines: told the mesh's -// suffix is its own, it answers authoritatively for everything under it and forwards nothing, so a -// routed name written there with the suffix appended is a name nobody will ever ask for, standing -// beside the machines and looking as real. -func TestTheResolverIsToldTheMachinesAndNotTheNamesTheMeshMerelyServes(t *testing.T) { - machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - every := map[string]string{ - "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", - "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", - } - m := Manifest{Module: "resolver", Facts: map[string]string{ - FactNodeZones: "/etc/zones.conf", FactNodeNames: "/etc/hosts", - }} - given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, "") - if err != nil { - t.Fatal(err) - } - by := map[string]string{} - for _, f := range given { - by[f["path"].(string)] = f["content"].(string) - } - - zones := by["/etc/zones.conf"] - for _, machine := range []string{"address=/homer.internal/10.42.0.1", "address=/marge.internal/10.42.0.2"} { - if !strings.Contains(zones, machine) { - t.Fatalf("the resolver was not told %q:\n%s", machine, zones) - } - } - for _, served := range []string{"drive.example.test", "git.example.test"} { - if strings.Contains(zones, served) { - t.Fatalf("the resolver was told %q, a name the mesh serves rather than a machine:\n%s", served, zones) - } - } - - // And the hosts file is the other way about: every name, so a container reaching a routed name - // finds the machine serving it. - hosts := by["/etc/hosts"] - for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { - if !strings.Contains(hosts, name) { - t.Fatalf("a container would not resolve %q from its hosts:\n%s", name, hosts) - } - } -} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index e73da8b..5702437 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -366,24 +366,29 @@ type Manifest struct { // firewall does. Ignored on a converged node, whose derived filter already closes them. Guards []int `json:"guards,omitempty"` - // Facts are things only the mesh knows, written where this module asks for them. + // Facts are things only the mesh knows, written where this module asks for them — in the + // module's own format. // - // **The graph is the control plane's; how a machine uses it is the module's.** The mesh knows - // which machines exist, what they are called and where they are. Making a name resolve, or a - // peer reachable, is somebody's software — dnsmasq, a resolver, a VPN — and the mesh has no - // business shipping one, choosing which, or knowing its configuration language. + // **The graph is the control plane's; the format is the module's.** The mesh knows which + // machines exist, what they are called and where they are. Turning that into a name that + // resolves, a peer that is reachable, a host a client trusts, is somebody's software — dnsmasq, + // a resolver, a VPN, ssh — in its own configuration language, and the mesh has no business + // knowing it. So a module gives a path and a template; the mesh renders the roster through it + // and owns nothing of what the file says. // - // So a module says *put the node names here* and owns everything after that. The same shape as - // `filtering`, generalised: a fact, and a path. + // This used to be a closed list of fact names, each formatted in Go in the control plane, so a + // new consumer meant a new formatter here in the consumer's language. Now the data is the mesh's + // and the format is the module's: the two built-in cases — the network module's `/etc/hosts` and + // dnsmasq's zones — render through the same template path any module uses, and no format lives + // in the control plane at all. See RosterFile for what a template sees. // // It replaces three modules that existed only because computed output needed somewhere to // live — they ran no software, could not be swapped for anything, and appeared in the graph as // modules while being a data channel wearing a costume. // - // Keyed by fact name; the names are a closed list, because a module asking for one the mesh - // does not compute is asking for something nobody will write, and finding that out on a machine - // is worse than being told here. - Facts map[string]string `json:"facts,omitempty"` + // Keyed by a name the module chooses, which is the rendered file's id (`fact-`) — what a + // `restart-on` names to restart when the roster changes. + Facts map[string]RosterFile `json:"facts,omitempty"` // Certificate is where this module wants a certificate for its machine's name inside the // mesh, and where the key that goes with it can be found. diff --git a/internal/catalogue/provided_test.go b/internal/catalogue/provided_test.go index 17719e6..029b19d 100644 --- a/internal/catalogue/provided_test.go +++ b/internal/catalogue/provided_test.go @@ -54,7 +54,7 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) { // network is what gives a machine a name, so the provider asks for the node-names fact and // there is nothing else to bring in. A module that ran nothing used to be here. for _, m := range got.Modules { - if m.Module == overlay.Name && m.Facts["node-names"] == "" { + if m.Module == overlay.Name && m.Facts["node-names"].Path == "" { t.Fatalf("the network's provider does not ask for the names: %+v", m.Facts) } } diff --git a/internal/catalogue/resolver_manifests_test.go b/internal/catalogue/resolver_manifests_test.go index 3336380..82d2bf0 100644 --- a/internal/catalogue/resolver_manifests_test.go +++ b/internal/catalogue/resolver_manifests_test.go @@ -50,7 +50,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T "\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n", "\nlisten-address=127.0.0.1\n", "\ninterface=mesh0\n", "\nbind-dynamic\n", "\ndomain-needed\n", "\nbogus-priv\n", - "\nconf-file=" + m.Facts[FactNodeZones] + "\n", + "\nconf-file=" + m.Facts["node-zones"].Path + "\n", } { if !strings.Contains(config, want) { t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config) diff --git a/internal/catalogue/roster.go b/internal/catalogue/roster.go new file mode 100644 index 0000000..849e813 --- /dev/null +++ b/internal/catalogue/roster.go @@ -0,0 +1,165 @@ +package catalogue + +import ( + "bytes" + "fmt" + "sort" + "strings" + "text/template" +) + +// What only the mesh knows, written where a module asks for it — in the module's own format. +// +// **The graph is the control plane's; the format is the module's.** The mesh knows which machines +// exist, what they are called and where they are. Turning that into a hosts file, a resolver's +// zones, an ssh known_hosts is somebody's configuration language, and the mesh has no business +// knowing it. So the mesh hands the roster to a template the module wrote and renders it; it never +// learns what the file means. +// +// This used to be a closed list of fact names, each with its format written in Go here — a hosts +// file, a resolver's zones. Every new consumer meant a new formatter in the control plane, in the +// consumer's configuration language. Now the data is the mesh's and the format is a template the +// module ships: the two built-in cases (the network module's `/etc/hosts`, dnsmasq's zones) render +// the same way any module's would, and the control plane holds no format at all. +// +// It replaced three modules that existed only because computed output needed somewhere to live — +// they ran no software, could not be swapped for anything, and appeared in the graph as modules +// while being a data channel wearing a costume (novox/hq ADR 0040). + +// A RosterFile is a file the mesh renders from the roster of machines, in the format the module +// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name), +// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names`, every +// name the mesh serves, and `.Machines`, only the nodes of the mesh. Which set a template ranges is +// how the hq issue 111 distinction is drawn: a container's hosts wants every name; a resolver told +// the suffix is its own wants only the machines. +type RosterFile struct { + // Path is where on the machine the rendered file goes. Absolute, or it is refused here rather + // than discovered as a daemon that reads nothing. + Path string `json:"path"` + // Template is the module's format, a Go text/template over the rosterView. It is the module's, + // not the mesh's: the mesh renders it and does not read it. + Template string `json:"template"` +} + +// rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field +// the mesh does not compute fails to render here, not on a machine. +type rosterView struct { + Node string + Suffix string + Names []rosterEntry + Machines []rosterEntry +} + +// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address. +type rosterEntry struct { + Name string + FQDN string + Address string +} + +// FactsInto renders the roster files a module asked for, as files it will be given. +// +// The module owns everything after the file exists: loading it, restarting on it, what a resolver +// or a client does with it. This only puts it there. `every` is every name the mesh serves; +// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both +// are given and the template chooses. +func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suffix string) ([]map[string]any, error) { + if len(m.Facts) == 0 { + return nil, nil + } + names := make([]string, 0, len(m.Facts)) + for name := range m.Facts { + names = append(names, name) + } + sort.Strings(names) + + view := rosterView{ + Node: r.Node, + Suffix: strings.TrimPrefix(suffixOr(suffix), "."), + Names: entriesFrom(every, suffix), + Machines: entriesFrom(machines, suffix), + } + + out := make([]map[string]any, 0, len(names)) + for _, name := range names { + fact := m.Facts[name] + if !strings.HasPrefix(fact.Path, "/") { + return nil, fmt.Errorf( + "%s asks for %q at %q, which is not an absolute path", m.Module, name, fact.Path) + } + content, err := renderRoster(fact.Template, view) + if err != nil { + return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err) + } + out = append(out, map[string]any{ + "id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644", + "content": content, + }) + } + return out, nil +} + +// renderRoster runs a module's template over the roster. A template that will not parse, or reads +// a field the mesh does not have, is an error here — where the manifest is — rather than an empty +// file on a machine. +func renderRoster(tmpl string, view rosterView) (string, error) { + t, err := template.New("roster").Option("missingkey=error").Parse(tmpl) + if err != nil { + return "", err + } + var b bytes.Buffer + if err := t.Execute(&b, view); err != nil { + return "", err + } + return b.String(), nil +} + +// entriesFrom is a name→address map as sorted roster entries. +// +// **A machine with no address is left out.** The mesh has a record for it — somebody added it — +// and does not yet know where it is, which is the ordinary state between adding a machine and it +// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to +// that hangs; leaving it out fails at once and says the name is unknown. +func entriesFrom(addresses map[string]string, suffix string) []rosterEntry { + out := make([]rosterEntry, 0, len(addresses)) + for _, name := range sortedNames(addresses) { + internal, bare := meshName(name, suffix) + out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name]}) + } + return out +} + +// meshName is a machine's internal name and its bare one, from either. The control plane keys +// the names it hands a resolution by the internal name (`homer.internal`), the same map a +// container gets as its hosts; a caller that keys by the bare name gets the same answer. The +// suffix is the one the control plane composed those names with, handed down rather than written +// here a second time — the alternative was `homer.internal.internal` on every machine. +func meshName(name, suffix string) (internal, bare string) { + dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".") + if strings.HasSuffix(name, dotted) { + return name, strings.TrimSuffix(name, dotted) + } + return name + dotted, name +} + +// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down. +// The one place the default is written, so a fact and a name cannot disagree about it. +func suffixOr(suffix string) string { + if suffix == "" { + return "internal" + } + return suffix +} + +func sortedNames(addresses map[string]string) []string { + out := make([]string, 0, len(addresses)) + for name, at := range addresses { + // A machine the mesh cannot place is left out rather than named at nothing. + if at == "" { + continue + } + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/internal/catalogue/roster_test.go b/internal/catalogue/roster_test.go new file mode 100644 index 0000000..0025955 --- /dev/null +++ b/internal/catalogue/roster_test.go @@ -0,0 +1,195 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Keyed by the internal name, as the control plane hands them (issue 079). bart has no address — +// the ordinary state between adding a machine and it joining. +var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} + +// A module says where it wants a roster file and in what format, and is given the rendered file. +func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/mesh/zones.conf", Template: "{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") + if err != nil { + t.Fatal(err) + } + if len(given) != 1 { + t.Fatalf("expected one file, got %d", len(given)) + } + if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" { + t.Fatalf("not written where it was asked for: %v", given[0]) + } + // The id is fact-, which is what a restart-on names when the roster changes. + if given[0]["id"] != "fact-zones" { + t.Fatalf("the file's id is not fact-, so a restart-on cannot find it: %v", given[0]["id"]) + } + if !strings.Contains(given[0]["content"].(string), "address=/homer.internal/10.42.0.1") { + t.Fatalf("the file does not hold the fact: %v", given[0]["content"]) + } +} + +// **The format is the module's — the mesh renders whatever template it gives.** The same roster +// through two templates is two entirely different files, and the control plane reads neither. +func TestTheFormatIsTheModulesOwn(t *testing.T) { + roster := map[string]string{"homer.internal": "10.42.0.1"} + hostsish := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Names}}{{.Address}}\t{{.Name}}\n{{end}}"}}} + sshish := Manifest{Module: "b", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n{{end}}"}}} + + h, err := FactsInto(hostsish, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + s, err := FactsInto(sshish, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + if h[0]["content"] != "10.42.0.1\thomer\n" { + t.Fatalf("the hosts-shaped template did not render its format: %q", h[0]["content"]) + } + if s[0]["content"] != "Host homer\n HostName homer.internal\n" { + t.Fatalf("the ssh-shaped template did not render its format: %q", s[0]["content"]) + } +} + +// **A template that will not parse is refused here, not on a machine.** A daemon that starts, reads +// a file the mesh could not render, and answers nothing is a much worse way to find out. +func TestABrokenTemplateIsRefusedHere(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{range .Machines}}oops"}}} + _, err := FactsInto(m, Resolution{}, nil, nil, "") + if err == nil { + t.Fatal("a template that does not parse was accepted, so the machine gets an empty file") + } + if !strings.Contains(err.Error(), "resolver") || !strings.Contains(err.Error(), "zones") { + t.Fatalf("the refusal does not say whose template, or which: %v", err) + } +} + +// A template reading something the mesh does not compute is refused, not rendered empty. The roster +// is a closed shape; asking it for the weather fails where the manifest is. +func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{.Weather}}"}}} + if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { + t.Fatal("a template read a field nobody computes and rendered anyway, silently") + } +} + +// A relative path is refused, or a module decides where the mesh writes on a machine. +func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "hosts": {Path: "etc/hosts", Template: "x"}}} + if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { + t.Fatal("a relative path was accepted") + } +} + +// A machine the mesh has a record for and cannot place is left out of the roster. +// +// **Not an oversight — the alternative is worse.** A name written with no address resolves to +// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is +// unknown, which is a thing somebody can act on. +func TestAMachineWithNoAddressIsNotInTheRoster(t *testing.T) { + m := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Machines}}{{.Name}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(given[0]["content"].(string), "bart") { + t.Fatalf("a machine with no address was in the roster, so its name resolves to nothing:\n%s", given[0]["content"]) + } +} + +// **The names the control plane hands a resolution are already internal names** — `homer.internal`, +// the same map every container gets as its hosts. A roster entry's FQDN is that name, not it with +// the suffix appended a second time; either key gives the same entries. +func TestNamesAreNotSuffixedTwice(t *testing.T) { + internal := map[string]string{"homer.internal": "10.42.0.1"} + bare := map[string]string{"homer": "10.42.0.1"} + tmpl := RosterFile{Path: "/f", Template: "{{range .Machines}}{{.FQDN}} {{.Name}}\n{{end}}"} + + fromInternal, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, internal, internal, "") + if err != nil { + t.Fatal(err) + } + fromBare, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, bare, bare, "") + if err != nil { + t.Fatal(err) + } + if fromInternal[0]["content"] != fromBare[0]["content"] { + t.Fatalf("the roster differs by how the names were keyed:\n%q\n%q", fromInternal[0]["content"], fromBare[0]["content"]) + } + got := fromInternal[0]["content"].(string) + if strings.Contains(got, "internal.internal") || !strings.Contains(got, "homer.internal homer") { + t.Fatalf("the entry carries a doubled suffix or the wrong bare name:\n%s", got) + } +} + +// The suffix the control plane composed the names with is the one a template sees — an operator who +// chose another does not get `.internal`. `.Suffix` is the bare form, and FQDNs carry it. +func TestTheSuffixIsCarriedAsComposed(t *testing.T) { + names := map[string]string{"homer.lan": "10.42.0.1"} + m := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "local=/{{.Suffix}}/\n{{range .Machines}}{{.FQDN}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, "lan") + if err != nil { + t.Fatal(err) + } + got := given[0]["content"].(string) + if !strings.Contains(got, "local=/lan/") || strings.Contains(got, "internal") { + t.Fatalf("the operator's suffix was not carried, so its names would be wrong:\n%s", got) + } + if !strings.Contains(got, "homer.lan") { + t.Fatalf("the FQDN does not carry the operator's suffix:\n%s", got) + } +} + +// novox/hq 04-ISSUES/111: a template is given both sets and chooses. `.Names` is every name the mesh +// serves — the machines and the names it was told to route; `.Machines` is only the machines. A +// container's hosts wants every name so a routed name resolves to the machine serving it; a resolver +// told the suffix is its own wants only the machines, or a routed name written there with the suffix +// is a name nobody will ever ask for, standing beside the machines and looking as real. +func TestATemplateChoosesMachinesOrEveryName(t *testing.T) { + machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} + every := map[string]string{ + "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", + "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", + } + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, + "hosts": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, "") + if err != nil { + t.Fatal(err) + } + by := map[string]string{} + for _, f := range given { + by[f["path"].(string)] = f["content"].(string) + } + + zones := by["/etc/zones"] + for _, served := range []string{"drive.example.test", "git.example.test"} { + if strings.Contains(zones, served) { + t.Fatalf("a template over .Machines saw %q, a name the mesh serves rather than a machine:\n%s", served, zones) + } + } + if !strings.Contains(zones, "homer.internal") { + t.Fatalf("a template over .Machines did not see the machines:\n%s", zones) + } + + hosts := by["/etc/hosts"] + for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { + if !strings.Contains(hosts, name) { + t.Fatalf("a template over .Names did not see %q, so a container would not resolve it:\n%s", name, hosts) + } + } +} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index eb0b9c9..d7b5ed5 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -161,6 +161,23 @@ func (g *Generator) Nodes() []Node { return g.nodes } // Graph is the peer list per node, for showing. func (g *Generator) Graph() Graph { return g.graph } +// hostsTemplate is the `/etc/hosts` the network module asks the mesh to write — every machine's +// mesh name at its private address, so a service binding the name it was given stays reachable from +// everywhere else. It is a roster template like any module's (catalogue.RosterFile): the mesh owns +// the data, this owns the format. +// +// - The loopback floor stays, or things with nothing to do with the mesh break. +// - `127.0.1.1 ` only when there is a node, the ordinary Debian self-name line. +// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback. +// - `.Names` is every name the mesh serves (issue 111), so a container reaching a routed name +// finds the machine serving it; machines with no address yet are already left out of the set. +const hostsTemplate = "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" + + "# joins or leaves, and an edit would survive until then and vanish.\n\n" + + "127.0.0.1\tlocalhost\n" + + "::1\t\tlocalhost ip6-localhost ip6-loopback\n" + + "{{if .Node}}127.0.1.1\t{{.Node}}\n{{end}}\n" + + "{{range .Names}}{{.Address}}\t{{.FQDN}}\t{{.Name}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}" + // Manifest is the module the mesh provides for itself. // // It ships with the control plane rather than coming from a repository, because the thing that @@ -175,8 +192,12 @@ func Manifest() map[string]any { // Being on the private network is what gives a machine a name, so the module that puts it // there is what writes them. Asked for rather than generated by a module of its own: the // mesh knows which machines exist and where; writing that into a hosts file is not a thing - // that needs a module to run nowhere. - "facts": map[string]string{"node-names": "/etc/hosts"}, + // that needs a module to run nowhere. The format is a template like any other roster fact — + // the mesh's own module owns the `/etc/hosts` layout the way dnsmasq owns its zones, and the + // control plane holds no formatter (see catalogue.RosterFile). + "facts": map[string]any{ + "node-names": map[string]any{"path": "/etc/hosts", "template": hostsTemplate}, + }, "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, } } diff --git a/internal/overlay/hosts_fact_test.go b/internal/overlay/hosts_fact_test.go new file mode 100644 index 0000000..485b379 --- /dev/null +++ b/internal/overlay/hosts_fact_test.go @@ -0,0 +1,52 @@ +package overlay + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The network module's `/etc/hosts` is a roster template like any module's (novox/hq: the graph is +// the control plane's, the format is the module's). These pin the format that used to be a Go +// formatter in the control plane, so the file a machine gets does not change with the mechanism: +// the loopback floor, the Debian self-name line, the machine's own line marked and at its mesh +// address, one line per machine, every served name (issue 111). +func hostsFor(t *testing.T, node string, every map[string]string) string { + t.Helper() + m := catalogue.Manifest{Module: "net", Facts: map[string]catalogue.RosterFile{ + "node-names": {Path: "/etc/hosts", Template: hostsTemplate}, + }} + given, err := catalogue.FactsInto(m, catalogue.Resolution{Node: node}, every, every, "") + if err != nil { + t.Fatal(err) + } + return given[0]["content"].(string) +} + +func TestTheHostsFileIsThisExactly(t *testing.T) { + got := hostsFor(t, "homer", map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"}) + want := "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" + + "# joins or leaves, and an edit would survive until then and vanish.\n\n" + + "127.0.0.1\tlocalhost\n" + + "::1\t\tlocalhost ip6-localhost ip6-loopback\n" + + "127.0.1.1\thomer\n\n" + + "10.42.0.1\thomer.internal\thomer\t# this machine\n" + + "10.42.0.2\tmarge.internal\tmarge\n" + if got != want { + t.Fatalf("the hosts file changed with the mechanism:\ngot:\n%q\nwant:\n%q", got, want) + } +} + +// With no node named there is no `127.0.1.1` self-line — but the blank line before the machines +// stays, exactly as the old formatter wrote it unconditionally. +func TestTheHostsFileWithoutASelfNameKeepsItsShape(t *testing.T) { + got := hostsFor(t, "", map[string]string{"homer.internal": "10.42.0.1"}) + want := "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" + + "# joins or leaves, and an edit would survive until then and vanish.\n\n" + + "127.0.0.1\tlocalhost\n" + + "::1\t\tlocalhost ip6-localhost ip6-loopback\n\n" + + "10.42.0.1\thomer.internal\thomer\n" + if got != want { + t.Fatalf("the hosts file without a self-name changed shape:\ngot:\n%q\nwant:\n%q", got, want) + } +} From a6d89e3f732ee7974c6f173d4a62bc0be8dad692 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:50:11 +0200 Subject: [PATCH 12/18] Reconcile with hq 128: hosts template is the region form, node-names is shared The merge commit took only the staged index; these reconciliation edits sat unstaged in the working tree. Integrate the template mechanism with #79's region write (hq 128): RosterFile gains Shared, FactsInto sets into:block for a shared fact, /etc/hosts becomes the region form (no floor) and node-names is marked shared. Without this the merge would have regressed /etc/hosts back to a whole-file write, replacing the operator's own lines. --- internal/catalogue/roster.go | 20 ++++++++++++++++++-- internal/catalogue/roster_test.go | 26 ++++++++++++++++++++++++++ internal/overlay/generator.go | 23 ++++++++++------------- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/internal/catalogue/roster.go b/internal/catalogue/roster.go index 849e813..48db115 100644 --- a/internal/catalogue/roster.go +++ b/internal/catalogue/roster.go @@ -39,6 +39,13 @@ type RosterFile struct { // Template is the module's format, a Go text/template over the rosterView. It is the module's, // not the mesh's: the mesh renders it and does not read it. Template string `json:"template"` + // Shared is whether the file the fact goes to belongs to the machine rather than the mesh. When + // it does, the mesh owns only a marked region of it and keeps the rest byte for byte (novox/hq + // issue 128) — a hosts file is shared, since the distribution's `localhost`, the operator's own + // lines and other tools' blocks live there too; a resolver's zones file is not, the mesh owns it + // whole. A property of the fact, not of the path: the format determines whether the file is + // wholly the mesh's, not where a module happened to ask for it. + Shared bool `json:"shared,omitempty"` } // rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field @@ -91,10 +98,19 @@ func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suff if err != nil { return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err) } - out = append(out, map[string]any{ + file := map[string]any{ "id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644", "content": content, - }) + } + if fact.Shared { + // The host owns only the lines between `# BEGIN mesh ` and `# END mesh ` and + // keeps the rest of the file byte for byte; undeclared, the region goes and nothing else + // does (novox/hq issue 128). Every node on the private network receives this, so every + // node's host — the controller's own machine included — must be block-aware before a + // controller emitting it is rolled out: the order ADR 0102 set for `into: json`. + file["into"] = "block" + } + out = append(out, file) } return out, nil } diff --git a/internal/catalogue/roster_test.go b/internal/catalogue/roster_test.go index 0025955..252f99d 100644 --- a/internal/catalogue/roster_test.go +++ b/internal/catalogue/roster_test.go @@ -33,6 +33,32 @@ func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) { } } +// **A shared fact is written into a region of the machine's file, not over it** (novox/hq issue +// 128). A hosts file is the machine's — its localhost, the operator's lines, other tools' blocks — +// so the mesh owns only a marked region (`into: block`); a resolver's zones file is the mesh's +// whole, and carries no `into`. +func TestASharedFactIsWrittenIntoARegion(t *testing.T) { + roster := map[string]string{"homer.internal": "10.42.0.1"} + m := Manifest{Module: "net", Facts: map[string]RosterFile{ + "node-names": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}", Shared: true}, + "node-zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + by := map[string]map[string]any{} + for _, f := range given { + by[f["path"].(string)] = f + } + if by["/etc/hosts"]["into"] != "block" { + t.Fatalf("a shared fact is not written into a region, so the mesh writes the file whole: %v", by["/etc/hosts"]) + } + if _, has := by["/etc/zones"]["into"]; has { + t.Fatalf("an unshared fact was written into a region, so the mesh does not own its own file whole: %v", by["/etc/zones"]) + } +} + // **The format is the module's — the mesh renders whatever template it gives.** The same roster // through two templates is two entirely different files, and the control plane reads neither. func TestTheFormatIsTheModulesOwn(t *testing.T) { diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index d7b5ed5..d41559a 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -161,21 +161,17 @@ func (g *Generator) Nodes() []Node { return g.nodes } // Graph is the peer list per node, for showing. func (g *Generator) Graph() Graph { return g.graph } -// hostsTemplate is the `/etc/hosts` the network module asks the mesh to write — every machine's -// mesh name at its private address, so a service binding the name it was given stays reachable from -// everywhere else. It is a roster template like any module's (catalogue.RosterFile): the mesh owns -// the data, this owns the format. +// hostsTemplate is the mesh's region of `/etc/hosts` — every machine's mesh name at its private +// address, written into a marked region and merged (RosterFile.Shared → `into: block`), so the rest +// of the file (localhost, the machine's own name, other tools' blocks) is kept byte for byte +// (novox/hq issue 128). It is a roster template like any module's: the mesh owns the data, this owns +// the format, and the control plane holds no formatter. // -// - The loopback floor stays, or things with nothing to do with the mesh break. -// - `127.0.1.1 ` only when there is a node, the ordinary Debian self-name line. +// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region. // - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback. // - `.Names` is every name the mesh serves (issue 111), so a container reaching a routed name // finds the machine serving it; machines with no address yet are already left out of the set. -const hostsTemplate = "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" + - "# joins or leaves, and an edit would survive until then and vanish.\n\n" + - "127.0.0.1\tlocalhost\n" + - "::1\t\tlocalhost ip6-localhost ip6-loopback\n" + - "{{if .Node}}127.0.1.1\t{{.Node}}\n{{end}}\n" + +const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" + "{{range .Names}}{{.Address}}\t{{.FQDN}}\t{{.Name}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}" // Manifest is the module the mesh provides for itself. @@ -194,9 +190,10 @@ func Manifest() map[string]any { // mesh knows which machines exist and where; writing that into a hosts file is not a thing // that needs a module to run nowhere. The format is a template like any other roster fact — // the mesh's own module owns the `/etc/hosts` layout the way dnsmasq owns its zones, and the - // control plane holds no formatter (see catalogue.RosterFile). + // control plane holds no formatter (see catalogue.RosterFile). `shared`: the mesh owns only + // its region of the file and keeps the rest (novox/hq issue 128). "facts": map[string]any{ - "node-names": map[string]any{"path": "/etc/hosts", "template": hostsTemplate}, + "node-names": map[string]any{"path": "/etc/hosts", "template": hostsTemplate, "shared": true}, }, "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, } From 1c562105304363723a5d7dca4ab312baeba1db59 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 14:30:56 +0200 Subject: [PATCH 13/18] Name system seats by scope; let a module define its own (ADR 0121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System seats are mesh-* (one, mesh-wide) or node-* (one per node). Renamed: the-build-machine -> mesh-build-machine (+scope mesh), the-catalogue -> mesh-catalog, the-dns-port -> node-dns-resolver, the-intrusion-prevention -> node-intrusion-prevention, the-packet-filter -> node-packet-filter, the-resolver-configuration -> node-resolver-config, the-uplink -> node-uplink. Removed the-showcase from the set — it becomes the first module-defined seat. A manifest may declare its own seats (DefinesSeats); a claim is a system seat, a reserved mesh-*/node-* name the mesh does not define (refused), or a module-defined seat valid only when the manifest declares it. Deferred: the delivering registry seats (git, npm-package-registry, the-artifact-store) and the-private-network (a scope + server/client model change), per ADR 0121. --- cmd/mesh-controller/seats_test.go | 8 +- internal/catalogue/manifest.go | 8 ++ internal/catalogue/resolver_manifests_test.go | 2 +- internal/catalogue/seats.go | 91 ++++++++++++++----- internal/catalogue/seats_test.go | 30 +++++- 5 files changed, 105 insertions(+), 34 deletions(-) diff --git a/cmd/mesh-controller/seats_test.go b/cmd/mesh-controller/seats_test.go index 469d3a3..9fc6135 100644 --- a/cmd/mesh-controller/seats_test.go +++ b/cmd/mesh-controller/seats_test.go @@ -35,13 +35,13 @@ func TestEverySeatIsListedIncludingTheOnesNobodyHolds(t *testing.T) { func TestANodeSeatListsEveryMachineHoldingIt(t *testing.T) { rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{ - {Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "node2", Module: "nftables"}, - {Claim: "the-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: "the-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 != "the-packet-filter" { + if r.Seat != "node-packet-filter" { continue } if len(r.Holders) != 2 || r.Holders[0].Node != "anchor" || r.Holders[1].Node != "node2" { diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 5702437..85800d9 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -192,6 +192,14 @@ type Manifest struct { // that every new module would force its predecessors to update. Claims []Claim `json:"claims,omitempty"` + // DefinesSeats are the seats this module defines for itself (novox/hq ADR 0121). The control + // plane defines the system seats — `mesh-*` and `node-*` — and a module may define its own, + // named outside that namespace, to coordinate its own instances: the mesh enforces + // one-holder-per-scope for it without knowing what it means. A module's declared seat is the + // only non-system name it may then claim; a claim to a name neither the mesh nor the module + // defines is refused. + DefinesSeats []Claim `json:"seats,omitempty"` + // Capabilities the machine must have. A different field from Requires because the remedy // differs: a missing module can be assigned, and a missing capability means the wrong // machine. diff --git a/internal/catalogue/resolver_manifests_test.go b/internal/catalogue/resolver_manifests_test.go index 82d2bf0..0cce6cd 100644 --- a/internal/catalogue/resolver_manifests_test.go +++ b/internal/catalogue/resolver_manifests_test.go @@ -170,7 +170,7 @@ func TestTwoThingsDecidingWhatAMachineAsksAreRefused(t *testing.T) { if err == nil { t.Fatal("resolv-conf and resolved-split-dns were both assigned to one machine") } - if !strings.Contains(err.Error(), "the-resolver-configuration") { + if !strings.Contains(err.Error(), "node-resolver-config") { t.Fatalf("the refusal does not say what was claimed: %v", err) } } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index dfcadc9..f1e548b 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -37,23 +37,36 @@ var seats = []Seat{ {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"}, {Name: "mesh-broker", Scope: ScopeMesh, Delivers: "amqp", Decision: "novox/hq ADR 0079"}, {Name: "the-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, - {Name: "the-catalogue", Scope: ScopeMesh, Decision: "novox/hq ADR 0110"}, + {Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"}, + // Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a + // delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight. + // They keep their names until that migration is done deliberately, apart from the node-* pass. {Name: "npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"}, {Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"}, - {Name: "the-build-machine", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, - {Name: "the-dns-port", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, - {Name: "the-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, - {Name: "the-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + {Name: "mesh-build-machine", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"}, + {Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, + {Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, + {Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, + // Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client + // model change, not a rename, so it stays until that is built. {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, - {Name: "the-resolver-configuration", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, - {Name: "the-showcase", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + {Name: "node-resolver-config", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, // The program that manages the machine's own network. It delivers nothing: its holder only // keeps the manager and the mesh from contradicting each other — the resolver file left to the // mesh, the private network's interface left alone — and never declares a link, an address or // a wireless network, because the link is the only channel a fix could arrive on. A seat // rather than a condition in the resolver's module, so a machine running two managers is // refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117). - {Name: "the-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"}, + {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"}, +} + +// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for +// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused; +// any other name is a module's own to define and claim. Some of the mesh's own seats predate this +// convention and are not yet renamed (git, npm-package-registry, the-artifact-store, +// the-private-network) — those are in the set, so they resolve by name, not by prefix. +func isSystemSeatName(name string) bool { + return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-") } // Seats is every seat the mesh defines, in reading order. @@ -84,30 +97,60 @@ func SeatDelivering(provision string) (Seat, bool) { return Seat{}, false } -// claimProblems is what is wrong with a manifest's claims against the set. +// claimProblems is what is wrong with a manifest's claims and the seats it defines. // -// Three refusals, each naming the seat: a seat the mesh does not define, a seat claimed at another -// scope, and a seat that delivers a provision claimed by a module that does not provide it — which -// would make the module the mesh's answer for something it cannot answer. +// A claim is one of three things (novox/hq ADR 0121): a **system seat** the control plane defines — +// checked for scope and, if it delivers a provision, that the claimant provides it; a **system name +// the mesh does not define** (`mesh-*`/`node-*`) — refused, because that namespace is the control +// plane's; or a **module-defined seat** — valid only when this manifest also declares it, since a +// module may coordinate its own instances through a seat of its own but may not invent one by +// claiming it. A module's own seat declaration may not sit in the system namespace or shadow a +// system seat. func claimProblems(m Manifest) []string { var problems []string - for _, c := range m.Claims { - seat, known := SeatNamed(c.Name) - if !known { + + defined := map[string]Claim{} + for _, d := range m.DefinesSeats { + if _, isSystem := SeatNamed(d.Name); isSystem || isSystemSeatName(d.Name) { problems = append(problems, fmt.Sprintf( - "%s claims %q, which is not a seat this mesh defines (novox/hq ADR 0110) — "+ - "the seats are: %s", m.Module, c.Name, seatNames())) + "%s defines a seat %q in the mesh's own namespace; a module's seat is named outside "+ + "mesh-*/node-* (novox/hq ADR 0121)", m.Module, d.Name)) continue } - if c.At() != seat.Scope { - problems = append(problems, fmt.Sprintf( - "%s claims %s at scope %q, and %s is a %s seat", - m.Module, c.Name, c.At(), c.Name, seat.Scope)) + defined[d.Name] = d + } + + for _, c := range m.Claims { + if seat, known := SeatNamed(c.Name); known { + if c.At() != seat.Scope { + problems = append(problems, fmt.Sprintf( + "%s claims %s at scope %q, and %s is a %s seat", + m.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", + m.Module, c.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)) + } + continue } - if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) { + if isSystemSeatName(c.Name) { problems = append(problems, fmt.Sprintf( - "%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope", - m.Module, c.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)) + "%s claims %q, which is a seat in the mesh's own namespace (mesh-*/node-*) that it "+ + "does not define (novox/hq ADR 0121) — the seats are: %s", m.Module, c.Name, seatNames())) + continue + } + d, ours := defined[c.Name] + if !ours { + problems = append(problems, fmt.Sprintf( + "%s claims %q, which is not a seat this mesh defines and not one %s declares itself "+ + "(novox/hq ADR 0121) — the seats are: %s", m.Module, c.Name, m.Module, seatNames())) + continue + } + if c.At() != d.At() { + problems = append(problems, fmt.Sprintf( + "%s claims its own seat %s at scope %q, having declared it at %q", + m.Module, c.Name, c.At(), d.At())) } } return problems diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index f1c2087..0c91ebf 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -44,8 +44,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - if len(Seats()) != 15 { - t.Errorf("the mesh defines %d seats rather than 15; the set is closed, so a change here is "+ + if len(Seats()) != 14 { + t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } @@ -56,7 +56,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer // for something, and the manager's link is the one thing the mesh must never be able to break. func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { - seat, known := SeatNamed("the-uplink") + seat, known := SeatNamed("node-uplink") if !known { t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames()) } @@ -64,7 +64,7 @@ func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat) } // And a manager's module can hold it without providing anything. - raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"the-uplink","scope":"node"}]}`) + raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"node-uplink","scope":"node"}]}`) if _, err := ParseManifest(raw); err != nil { t.Fatalf("a network manager's module could not hold the uplink: %v", err) } @@ -83,11 +83,31 @@ func TestAClaimOnASeatTheMeshDoesNotDefineIsRefused(t *testing.T) { t.Fatalf("the refusal does not say the seat is unknown: %v", err) } // And it says what the seats are, because "no" without the list sends somebody reading code. - if !strings.Contains(err.Error(), "the-packet-filter") { + if !strings.Contains(err.Error(), "node-packet-filter") { t.Fatalf("the refusal does not list the seats: %v", err) } } +// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing +// what it means (novox/hq ADR 0121). But it may not define one in the mesh's own namespace. +func TestAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { + ok := []byte(`{"module":"showcase","version":"1","seats":[{"name":"the-showcase","scope":"node"}],` + + `"claims":[{"name":"the-showcase","scope":"node"}]}`) + if _, err := ParseManifest(ok); err != nil { + t.Fatalf("a module could not define and claim its own seat: %v", err) + } + // Claiming a name it neither the mesh nor the module defines is still refused. + if _, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)); err == nil { + t.Fatal("a module claimed a seat nobody defines") + } + // A module may not carve its seat out of the mesh's own namespace. + bad := []byte(`{"module":"x","version":"1","seats":[{"name":"node-mine","scope":"node"}],` + + `"claims":[{"name":"node-mine","scope":"node"}]}`) + if _, err := ParseManifest(bad); err == nil || !strings.Contains(err.Error(), "own namespace") { + t.Fatalf("a module defined a seat in the mesh's namespace and was not refused: %v", err) + } +} + func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) { _, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`)) if err == nil { From 2ec0fd218b129656be05a7f81b4f32ea7e2a8b31 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 16:04:44 +0200 Subject: [PATCH 14/18] Seats are data the controller owns, loaded from its store (ADR 0122, phase 1) The seat set was a Go slice compiled into the controller and referenced by name everywhere, so changing it meant a rebuild and a freeze-prone deploy. It is now a table: catalogue keeps the shipped set as defaultSeats (the seed and the fallback) and a loadable working set; inventory adds the seat table (migration 0034), Seats to read it, and SeedSeats to fill it idempotently without overwriting an operator's edit; migrate seeds it; openInventory loads it, and an empty or unreadable table leaves the compiled defaults in force so it can never brick the control plane's boot. Behaviour-neutral: the seeded table equals the defaults. Phase 2 (reference by a stable id so a rename touches no manifest or code, and the builder reads the set from the mesh) follows. --- cmd/mesh-controller/stores.go | 17 ++++ internal/catalogue/seats.go | 29 ++++++- internal/catalogue/seats_test.go | 20 +++++ .../migrations/0034-the-seats-are-data.sql | 17 ++++ internal/inventory/seats.go | 56 +++++++++++++ internal/inventory/seats_test.go | 78 +++++++++++++++++++ 6 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 internal/inventory/migrations/0034-the-seats-are-data.sql create mode 100644 internal/inventory/seats.go create mode 100644 internal/inventory/seats_test.go diff --git a/cmd/mesh-controller/stores.go b/cmd/mesh-controller/stores.go index 8a46256..b77de10 100644 --- a/cmd/mesh-controller/stores.go +++ b/cmd/mesh-controller/stores.go @@ -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,14 @@ 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) + } return inv, nil } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index f1e548b..47cfb9a 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -31,8 +31,12 @@ type Seat struct { Decision string } -// seats is the whole set, in the order a person reads it: the mesh's own, then a node's. -var seats = []Seat{ +// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the +// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is +// written; the store's table is seeded from it and thereafter is the live, editable copy. +// +// In the order a person reads it: the mesh's own, then a node's. +var defaultSeats = []Seat{ {Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079"}, {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"}, {Name: "mesh-broker", Scope: ScopeMesh, Delivers: "amqp", Decision: "novox/hq ADR 0079"}, @@ -69,6 +73,27 @@ func isSystemSeatName(name string) bool { return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-") } +// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by +// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a +// change to data, not to this code. +var seats = defaultSeats + +// DefaultSeats is the set the mesh ships with, for seeding the store's seat table. +func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) } + +// UseSeats replaces the working set with the one the control plane read from its store. +// +// **Empty is ignored on purpose.** A store that has not been seeded yet — or one that could not be +// read — must leave the compiled defaults in force rather than emptying the set: an empty set would +// refuse every claim and could stop the control plane composing at all, which is a far worse failure +// than running on the set the binary shipped with. So the store can only ever *replace* the set with +// a non-empty one, never erase it. +func UseSeats(s []Seat) { + if len(s) > 0 { + seats = s + } +} + // Seats is every seat the mesh defines, in reading order. func Seats() []Seat { return append([]Seat(nil), seats...) diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 0c91ebf..cef41b1 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -246,3 +246,23 @@ func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) { t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) } } + +// The working set is loaded from the store, and an empty load never erases it (novox/hq ADR 0122). +func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { + before := Seats() + defer UseSeats(DefaultSeats()) // restore for other tests, whatever this leaves it as + + // An empty load (store not seeded, or unreadable) leaves the compiled defaults in force. + UseSeats(nil) + if len(Seats()) != len(before) { + t.Fatalf("an empty load changed the set from %d to %d seats", len(before), len(Seats())) + } + // A non-empty load replaces it — this is how a rename in the store reaches the lookups. + UseSeats([]Seat{{Name: "node-firewall", Scope: ScopeNode, Decision: "novox/hq ADR 0122"}}) + if _, known := SeatNamed("node-firewall"); !known { + t.Fatal("the loaded set did not replace the working set") + } + if len(Seats()) != 1 { + t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats())) + } +} diff --git a/internal/inventory/migrations/0034-the-seats-are-data.sql b/internal/inventory/migrations/0034-the-seats-are-data.sql new file mode 100644 index 0000000..d051889 --- /dev/null +++ b/internal/inventory/migrations/0034-the-seats-are-data.sql @@ -0,0 +1,17 @@ +-- The seats are data the control plane owns, not a slice compiled into it (novox/hq ADR 0122). +-- +-- Until this, the closed set 0110 defines lived only as a Go slice, referenced by name everywhere, +-- so renaming a seat or adding one meant a controller rebuild and a mesh-wide, freeze-prone deploy. +-- The set is now a table: one row per seat, seeded from the binary's defaults the first time the +-- control plane comes up, and thereafter the live copy the control plane reads and an operator can +-- change. A rename becomes an update here rather than a release. +-- +-- The name is the key for now, because claims and held records still reference a seat by name; the +-- move to a stable id that a rename does not touch is the next step (ADR 0122). `delivers` is empty +-- for a seat that answers for no provision, matching the compiled default. +create table seat ( + name text primary key, + scope text not null, + delivers text not null default '', + decided text not null +); diff --git a/internal/inventory/seats.go b/internal/inventory/seats.go new file mode 100644 index 0000000..564e578 --- /dev/null +++ b/internal/inventory/seats.go @@ -0,0 +1,56 @@ +package inventory + +import ( + "context" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The seats the mesh has, as data (novox/hq ADR 0122). +// +// The set the control plane reads is a table here, not a slice compiled into it. It is seeded from +// the binary's defaults the first time the mesh comes up (SeedSeats), and thereafter it is the live +// copy: a rename or an added seat is a write here, and the control plane loads it at startup rather +// than being rebuilt for it. + +// Seats is every seat the mesh defines, read from the store. +func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) { + rows, err := i.store.Pool().Query(ctx, + `select name, scope, delivers, decided from seat order by name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var seats []catalogue.Seat + for rows.Next() { + var s catalogue.Seat + if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision); err != nil { + return nil, err + } + seats = append(seats, s) + } + return seats, rows.Err() +} + +// SeedSeats writes the mesh's default set into the table where it is not already present. +// +// **Idempotent, and never overwriting.** Run every time the control plane migrates, it fills an +// empty table on first boot and adds a seat a new release ships — but it leaves a row already there +// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting +// the old name back. What a release removes from the defaults is not deleted here either; retiring a +// seat is its own decision, not a silent consequence of it dropping out of the binary. +func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) { + var added int + for _, s := range defaults { + tag, err := i.store.Pool().Exec(ctx, + `insert into seat (name, scope, delivers, decided) values ($1, $2, $3, $4) + on conflict (name) do nothing`, + s.Name, s.Scope, s.Delivers, s.Decision) + if err != nil { + return added, err + } + added += int(tag.RowsAffected()) + } + return added, nil +} diff --git a/internal/inventory/seats_test.go b/internal/inventory/seats_test.go new file mode 100644 index 0000000..94ccda5 --- /dev/null +++ b/internal/inventory/seats_test.go @@ -0,0 +1,78 @@ +package inventory + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The seat set is data the control plane owns (novox/hq ADR 0122): seeded from the binary's +// defaults, read back as the working set, and thereafter an operator's to change without a rebuild. + +func TestSeatsAreSeededFromTheDefaultsAndReadBack(t *testing.T) { + inv := ForTest(t) + defaults := catalogue.DefaultSeats() + + added, err := inv.SeedSeats(t.Context(), defaults) + if err != nil { + t.Fatal(err) + } + if added != len(defaults) { + t.Fatalf("seeded %d of %d seats", added, len(defaults)) + } + got, err := inv.Seats(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(got) != len(defaults) { + t.Fatalf("read back %d seats, seeded %d", len(got), len(defaults)) + } + // The set round-trips: name, scope and what it delivers survive the store. + by := map[string]catalogue.Seat{} + for _, s := range got { + by[s.Name] = s + } + for _, d := range defaults { + if by[d.Name].Scope != d.Scope || by[d.Name].Delivers != d.Delivers { + t.Errorf("%s came back as %+v, seeded %+v", d.Name, by[d.Name], d) + } + } +} + +// Re-seeding an already-seeded set adds nothing and changes nothing — every deploy runs SeedSeats, +// and a mesh already holding the set must be left exactly as it is (an operator's edit to a row +// included). A row's fields are not overwritten: on conflict the insert does nothing. +// +// (Phase 1 keys the table by name, so a seat *renamed* in the table would have its old name +// re-seeded — the move to a stable id a rename does not touch is the next step, ADR 0122. This test +// asserts only the property that holds now: an unchanged set re-seeds to a no-op.) +func TestReSeedingAnUnchangedSetIsANoOp(t *testing.T) { + inv := ForTest(t) + defaults := catalogue.DefaultSeats() + if _, err := inv.SeedSeats(t.Context(), defaults); err != nil { + t.Fatal(err) + } + + // An operator changes a row's scope in the table — the point of it being data. + if _, err := inv.store.Pool().Exec(t.Context(), + `update seat set scope = 'mesh' where name = 'node-uplink'`); err != nil { + t.Fatal(err) + } + + added, err := inv.SeedSeats(t.Context(), defaults) + if err != nil { + t.Fatal(err) + } + if added != 0 { + t.Fatalf("re-seeding an already-present set added %d rows", added) + } + got, err := inv.Seats(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, s := range got { + if s.Name == "node-uplink" && s.Scope != "mesh" { + t.Fatalf("re-seeding overwrote the operator's change: node-uplink scope is %q", s.Scope) + } + } +} From 6da9a5478bcd90a220b1fe454c93bd8782a3fba8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 16:32:22 +0200 Subject: [PATCH 15/18] Seats keep their former names, so a rename breaks nothing (ADR 0122, phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 made the set data; a rename still broke every reference to the old name. This adds the stable identity: a seat's canonical name changes and its old name becomes an alias that resolves to it forever. SeatNamed and the holder and display matching resolve a name (former or current) to its seat, so a manifest's claim, a held record, the git-seat lookup and the build machine's embedded set all go on working unchanged after a rename. seat_alias table (migration 0035), inventory Aliases/RenameSeat, openInventory loads them, and a 'seat rename ' command does the whole thing — one operation, no rebuild, no re-registration, no freeze. Behaviour-neutral until a seat is renamed. Validated against postgres. --- cmd/mesh-controller/main.go | 2 + cmd/mesh-controller/seats.go | 29 +++++++++-- cmd/mesh-controller/stores.go | 4 ++ internal/catalogue/seats.go | 25 ++++++++- internal/catalogue/seats_test.go | 20 +++++++ .../0035-a-seat-keeps-its-former-names.sql | 12 +++++ internal/inventory/seats.go | 52 +++++++++++++++++++ internal/inventory/seats_test.go | 37 +++++++++++++ 8 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 3bcf2c1..74c5a55 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -116,6 +116,8 @@ func run() error { return pushCommand(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": diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go index 1a21c6b..b041c10 100644 --- a/cmd/mesh-controller/seats.go +++ b/cmd/mesh-controller/seats.go @@ -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 ") +} + func seatsCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("seats", flag.ContinueOnError) asJSON := set.Bool("json", false, "the same, as JSON") diff --git a/cmd/mesh-controller/stores.go b/cmd/mesh-controller/stores.go index b77de10..1fa2045 100644 --- a/cmd/mesh-controller/stores.go +++ b/cmd/mesh-controller/stores.go @@ -102,6 +102,10 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) { 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 } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 47cfb9a..753bd30 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -94,18 +94,36 @@ func UseSeats(s []Seat) { } } +// aliases maps a seat's former names to its current canonical name (novox/hq ADR 0122). Loaded from +// the store alongside the set, so a reference to a name a seat used to have — a manifest's claim, a +// held record — still resolves to it after a rename, and nothing downstream has to change. +var aliases = map[string]string{} + +// UseAliases replaces the former-name map with the one the control plane read from its store. Empty +// is fine and ordinary: a mesh whose seats have never been renamed has no aliases. +func UseAliases(m map[string]string) { aliases = m } + // Seats is every seat the mesh defines, in reading order. func Seats() []Seat { return append([]Seat(nil), seats...) } -// SeatNamed is the seat a claim names, if the mesh defines one. +// SeatNamed is the seat a name refers to, whether that is its current name or one it used to have +// (novox/hq ADR 0122). A former name resolves to the seat's canonical row, so a rename breaks no +// reference to the old name. func SeatNamed(name string) (Seat, bool) { for _, s := range seats { if s.Name == name { return s, true } } + if canonical, aliased := aliases[name]; aliased { + for _, s := range seats { + if s.Name == canonical { + return s, true + } + } + } return Seat{}, false } @@ -211,7 +229,10 @@ func HolderAmong(provision string, providers []Provider, held []Held) (Provider, return Provider{}, false } for _, h := range held { - if h.Claim != seat.Name || h.Scope != seat.Scope { + // Resolve the held claim to a seat rather than comparing names, so a record naming a seat's + // former name still matches it after a rename (novox/hq ADR 0122). + hs, ok := SeatNamed(h.Claim) + if !ok || hs.Name != seat.Name || h.Scope != seat.Scope { continue } for _, p := range providers { diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index cef41b1..f479953 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -266,3 +266,23 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats())) } } + +// A former name resolves to the seat it was renamed from (novox/hq ADR 0122), so a manifest's claim +// and a held record naming the old name break nothing after a rename. +func TestAFormerNameResolvesAfterARename(t *testing.T) { + defer func() { UseSeats(DefaultSeats()); UseAliases(nil) }() + UseSeats([]Seat{{Name: "mesh-git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0121"}}) + UseAliases(map[string]string{"git": "mesh-git"}) + + // The old name resolves to the renamed seat. + if s, ok := SeatNamed("git"); !ok || s.Name != "mesh-git" { + t.Fatalf("the former name did not resolve to the renamed seat: %+v ok=%v", s, ok) + } + // And a holder recorded under the old name is still found for the provision the seat delivers. + providers := []Provider{{Node: "anchor", At: "anchor.internal", Module: "gitea"}} + held := []Held{{Claim: "git", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}} + holder, found := HolderAmong("git", providers, held) + if !found || holder.Module != "gitea" { + t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found) + } +} diff --git a/internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql b/internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql new file mode 100644 index 0000000..4c3091c --- /dev/null +++ b/internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql @@ -0,0 +1,12 @@ +-- A seat keeps its former names, so a rename breaks nothing (novox/hq ADR 0122). +-- +-- Phase 1 made the seat set data, but a rename still broke every reference to the old name — a +-- manifest's claim, a held record, the git-seat lookup — because they name the seat and the name +-- had changed. This is the stable identity ADR 0122 asked for, realised the simple way: a seat's +-- canonical name changes, and its old name becomes an alias that resolves to it forever. Nothing +-- downstream has to change — a manifest goes on claiming the old name, the build machine goes on +-- validating it — and a rename is one operation: set the new name, remember the old. +create table seat_alias ( + alias text primary key, -- a former name of a seat + seat text not null -- the seat's current canonical name it resolves to +); diff --git a/internal/inventory/seats.go b/internal/inventory/seats.go index 564e578..481e6bb 100644 --- a/internal/inventory/seats.go +++ b/internal/inventory/seats.go @@ -2,6 +2,7 @@ package inventory import ( "context" + "fmt" "github.com/novox/mesh-controller/internal/catalogue" ) @@ -54,3 +55,54 @@ func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (i } return added, nil } + +// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122). +func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) { + rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`) + if err != nil { + return nil, err + } + defer rows.Close() + + aliases := map[string]string{} + for rows.Next() { + var alias, seat string + if err := rows.Scan(&alias, &seat); err != nil { + return nil, err + } + aliases[alias] = seat + } + return aliases, rows.Err() +} + +// RenameSeat gives a seat a new name and keeps the old one as an alias (novox/hq ADR 0122). +// +// **This is the whole of a rename.** The seat's canonical name becomes `to`; `from` is remembered as +// an alias so every reference to it — a manifest's claim, a held record, the build machine's +// embedded set — goes on resolving to the same seat, unchanged. Nothing is rebuilt and nothing +// freezes. Any alias that pointed to `from` is repointed to `to`, so a chain of renames does not +// leave an older name resolving to a name that no longer exists. +func (i *Inventory) RenameSeat(ctx context.Context, from, to string) error { + if from == to { + return fmt.Errorf("a seat is renamed to a different name; %q is already its name", to) + } + tag, err := i.store.Pool().Exec(ctx, `update seat set name = $1 where name = $2`, to, from) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("no seat named %q to rename", from) + } + // The old name resolves to the new one; and any name that resolved to the old one now resolves + // to the new one, so no alias is left pointing at a name that is gone. + if _, err := i.store.Pool().Exec(ctx, + `insert into seat_alias (alias, seat) values ($1, $2) + on conflict (alias) do update set seat = excluded.seat`, from, to); err != nil { + return err + } + if _, err := i.store.Pool().Exec(ctx, + `update seat_alias set seat = $1 where seat = $2`, to, from); err != nil { + return err + } + return nil +} diff --git a/internal/inventory/seats_test.go b/internal/inventory/seats_test.go index 94ccda5..e8a7135 100644 --- a/internal/inventory/seats_test.go +++ b/internal/inventory/seats_test.go @@ -76,3 +76,40 @@ func TestReSeedingAnUnchangedSetIsANoOp(t *testing.T) { } } } + +// A rename is one operation: the seat gets the new name, the old name becomes an alias that still +// resolves to it (novox/hq ADR 0122). +func TestRenameSeatKeepsTheFormerNameAsAnAlias(t *testing.T) { + inv := ForTest(t) + if _, err := inv.SeedSeats(t.Context(), catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + if err := inv.RenameSeat(t.Context(), "node-packet-filter", "node-firewall"); err != nil { + t.Fatal(err) + } + seats, err := inv.Seats(t.Context()) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, s := range seats { + names[s.Name] = true + } + if !names["node-firewall"] || names["node-packet-filter"] { + t.Fatalf("the seat was not renamed in place: %v", names) + } + aliases, err := inv.Aliases(t.Context()) + if err != nil { + t.Fatal(err) + } + if aliases["node-packet-filter"] != "node-firewall" { + t.Fatalf("the former name is not an alias of the new one: %v", aliases) + } + // Renaming what has no seat is refused; renaming to the same name is refused. + if err := inv.RenameSeat(t.Context(), "no-such-seat", "x"); err == nil { + t.Fatal("renaming a seat that does not exist was accepted") + } + if err := inv.RenameSeat(t.Context(), "node-firewall", "node-firewall"); err == nil { + t.Fatal("renaming a seat to its own name was accepted") + } +} From 19d2725c13dc0a2188ecad2f6639674f62d83226 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 16:52:00 +0200 Subject: [PATCH 16/18] A module can name the mesh's range: ${machine:mesh-range} (novox/hq ADR 0112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module cannot know the private network's CIDR — it is a per-mesh value chosen at genesis — but sometimes must name it: an intrusion filter that must never ban a tunnel peer. Carry the overlay range on the Rendering and offer it as the machine fact mesh-range, the same way a machine's own address is offered, so the module names it rather than hardcoding a value (data is the mesh's). Absent when the mesh has no range. Enables the fail2ban ignoreip fix. --- cmd/mesh-controller/plan.go | 12 ++++++++-- internal/catalogue/declaration.go | 8 ++++++- internal/catalogue/machine_into_files.go | 8 ++++++- internal/catalogue/machine_into_files_test.go | 23 +++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 6dcb96e..781f330 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -488,6 +488,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 @@ -597,8 +604,9 @@ 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, - Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, + Suffix: overlay.Suffix(), MeshRange: meshRange, Foundation: foundation, Kept: kept, + Adopted: record.Adopted, + Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, }, record, nil } diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index ad33e90..cb8b070 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -97,6 +97,12 @@ type Rendering struct { // compose it a second time. Suffix string + // 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 + // never ban a tunnel peer, say. A per-mesh value the module cannot know, so it is carried here + // and offered as ${machine:mesh-range}, the same way one machine's address is. + MeshRange string + // Kept is every operator-sealed secret in the mesh, for a module that `keeps` them. Nil when // nothing on this node keeps them, or the mesh has no operator key. Kept *KeptExport @@ -545,7 +551,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. dirs := dirsFor(m, with) // And the machine underneath, which no binding of its own can tell it. - thisMachine := machineFacts(r, with.Names) + thisMachine := machineFacts(r, with.Names, with.MeshRange) // Which of this module's files carry a secret, for the rule that a container may not read // one of them as its environment without saying so (ADR 0086, issue 041). diff --git a/internal/catalogue/machine_into_files.go b/internal/catalogue/machine_into_files.go index cb642bc..d47dab6 100644 --- a/internal/catalogue/machine_into_files.go +++ b/internal/catalogue/machine_into_files.go @@ -57,7 +57,7 @@ func machineUsed(content string) []string { // the hosts file and the resolver's wildcards are written from, so a file naming the machine's // address and the file every other machine reaches it by cannot disagree. Absent, like `at`, when // the machine is off the network or the mesh has not placed it. -func machineFacts(r Resolution, names map[string]string) map[string]string { +func machineFacts(r Resolution, names map[string]string, meshRange string) map[string]string { out := map[string]string{"name": r.Node} if r.At != "" { out["at"] = r.At @@ -65,6 +65,12 @@ func machineFacts(r Resolution, names map[string]string) map[string]string { out["address"] = address } } + // The private network's whole range — a mesh-wide fact, not this machine's, but named here + // because a module cannot know it and sometimes must (an intrusion filter that must never ban a + // tunnel peer). Absent when the mesh has no range to give. + if meshRange != "" { + out["mesh-range"] = meshRange + } return out } diff --git a/internal/catalogue/machine_into_files_test.go b/internal/catalogue/machine_into_files_test.go index e59f3a2..74bc60b 100644 --- a/internal/catalogue/machine_into_files_test.go +++ b/internal/catalogue/machine_into_files_test.go @@ -103,3 +103,26 @@ func TestAModuleNamesTheAddressBehindItsMachinesName(t *testing.T) { t.Fatalf("a machine off the network was given an address, or refused for another reason: %v", err) } } + +// The mesh's private range is offered as ${machine:mesh-range}, so a module names it rather than +// hardcoding a value it cannot know (novox/hq ADR 0112) — the fail2ban ignoreip is the case. +func TestAModuleNamesTheMeshRange(t *testing.T) { + facts := machineFacts(Resolution{Node: "anchor", At: "anchor.internal"}, + map[string]string{"anchor.internal": "10.10.0.1"}, "10.10.0.0/24") + if facts["mesh-range"] != "10.10.0.0/24" { + t.Fatalf("the mesh range is not a machine fact: %v", facts) + } + res := map[string]any{"type": "file", "id": "jail", "content": "ignoreip = 127.0.0.1/8 ${machine:mesh-range}\n"} + if err := machineInto(res, facts, "fail2ban"); err != nil { + t.Fatal(err) + } + if got := res["content"].(string); !strings.Contains(got, "10.10.0.0/24") || strings.Contains(got, "${machine:") { + t.Fatalf("the mesh range was not written in: %q", got) + } + // A mesh with no range gives no such fact, and a file that names it is refused rather than + // left with a literal placeholder in it. + none := machineFacts(Resolution{Node: "anchor"}, nil, "") + if _, has := none["mesh-range"]; has { + t.Fatal("a mesh with no range still offered one") + } +} From 47d412e13fc646b677959dfb6dca710e7b534a2b Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 17:20:50 +0200 Subject: [PATCH 17/18] A module declares its fail2ban jail; the mesh composes them per node (to-be 31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism, mirroring Filtering: a module declares Jails (name, failregex, jail stanza) naming no node/path (ADR 0112); the intrusion-prevention holder declares Jailing (where composed jails go); the mesh gathers every assigned module's jails into one jail.d file (a fixed id the fail2ban service restarts on) plus a filter.d file per jail. A node not running a module has none of its jails. Tested. Behaviour-neutral until a service module declares a jail — the per-service content (postgres/mssql/mailu failregex+logpath) is authored next, against how each container actually logs. --- internal/catalogue/declaration.go | 6 +++ internal/catalogue/jails_into.go | 62 +++++++++++++++++++++++++++ internal/catalogue/jails_into_test.go | 46 ++++++++++++++++++++ internal/catalogue/manifest.go | 38 ++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 internal/catalogue/jails_into.go create mode 100644 internal/catalogue/jails_into_test.go diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index cb8b070..eb2e990 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -345,6 +345,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri "content": filtering, "mode": "0600", }) } + // The node's fail2ban jails, composed from every module it runs (novox/hq to-be 31), written + // where the intrusion-prevention holder owns them. Like the rule set above: gathered from all + // modules, written by the one that holds the role. + if j := m.Jailing; j != nil { + first = append(first, jailsInto(r.Modules, j)...) + } if c := m.Certificate; c != nil { if with.Certificate == "" { // Asked for and not issued. Refused rather than skipped: a module that serves TLS diff --git a/internal/catalogue/jails_into.go b/internal/catalogue/jails_into.go new file mode 100644 index 0000000..3b5bf36 --- /dev/null +++ b/internal/catalogue/jails_into.go @@ -0,0 +1,62 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// A node's fail2ban jails, composed from the modules it runs (novox/hq to-be 31). +// +// **The same shape as the firewall.** Every module's `listens` become the node's rule set; every +// module's `jails` become the node's fail2ban config. A module that runs an authenticating service +// declares what a break-in on it looks like and how to ban it, naming no node and no path (ADR +// 0112); the intrusion-prevention holder — the one module with `jailing` — gathers them and writes +// them where it owns. A node not running a module has none of its jails. + +// jailsInto composes every jail declared by the modules on a node into the files the holder writes: +// one jail file (all stanzas, so the fail2ban service restarts on a single resource) and one filter +// file per jail (its failregex, which fail2ban references by the jail's name). +// +// Owned by the holder, because the directory is: two modules writing into one fail2ban is the +// collision the holder model exists to prevent. Empty when nothing declares a jail — then the file +// is written empty rather than absent, so removing the last jail is an ordinary change the service +// restarts on rather than a file that vanishes. +func jailsInto(modules []Manifest, j *Jailing) []map[string]any { + type declared struct { + module string + jail Jail + } + var jails []declared + for _, m := range modules { + for _, jail := range m.Jails { + jails = append(jails, declared{m.Module, jail}) + } + } + // A stable order the host applies as given (ADR 0005), and so the same set composes byte for + // byte every time rather than differing by map iteration. + sort.Slice(jails, func(a, b int) bool { return jails[a].jail.Name < jails[b].jail.Name }) + + var composed strings.Builder + composed.WriteString("# The mesh's jails, composed from the modules this node runs. Do not edit —\n") + composed.WriteString("# replaced whenever the node's modules change (novox/hq to-be 31).\n") + + out := make([]map[string]any, 0, len(jails)+1) + for _, d := range jails { + fmt.Fprintf(&composed, "\n# from %s\n[%s]\nenabled = true\nfilter = %s\n%s\n", + d.module, d.jail.Name, d.jail.Name, strings.TrimRight(d.jail.Jail, "\n")) + // The filter is a file of its own, named as the jail's filter= references it. + out = append(out, map[string]any{ + "id": "filter-" + d.jail.Name, + "type": "file", "path": strings.TrimRight(j.FilterInto, "/") + "/" + d.jail.Name + ".conf", + "mode": "0644", + "content": "# Generated by the mesh (from module " + d.module + "). Do not edit.\n" + + "[Definition]\nfailregex = " + d.jail.Failregex + "\n", + }) + } + // The one jail file, first, with the fixed id the fail2ban service names in its restart-on. + return append([]map[string]any{{ + "id": ComposedJailsID(), "type": "file", "path": j.Into, "mode": "0644", + "content": composed.String(), + }}, out...) +} diff --git a/internal/catalogue/jails_into_test.go b/internal/catalogue/jails_into_test.go new file mode 100644 index 0000000..9ddb083 --- /dev/null +++ b/internal/catalogue/jails_into_test.go @@ -0,0 +1,46 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A node's fail2ban jails are composed from the modules it runs (novox/hq to-be 31): the holder +// (jailing) gathers every module's declared jail into one jail file and a filter file per jail. +func TestJailsAreComposedFromTheNodesModules(t *testing.T) { + modules := []Manifest{ + {Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh-composed.conf", FilterInto: "/etc/fail2ban/filter.d"}}, + {Module: "postgres", Jails: []Jail{{Name: "postgres-auth", Failregex: "auth failed from ", Jail: "port = 5432\nmaxretry = 5"}}}, + } + files := jailsInto(modules, modules[0].Jailing) + + by := map[string]map[string]any{} + for _, f := range files { + by[f["id"].(string)] = f + } + jail := by[ComposedJailsID()] + if jail == nil || jail["path"] != "/etc/fail2ban/jail.d/mesh-composed.conf" { + t.Fatalf("the composed jail file was not written: %v", jail) + } + body := jail["content"].(string) + if !strings.Contains(body, "[postgres-auth]") || !strings.Contains(body, "filter = postgres-auth") || + !strings.Contains(body, "port = 5432") { + t.Fatalf("the postgres jail stanza was not composed in:\n%s", body) + } + filter := by["filter-postgres-auth"] + if filter == nil || filter["path"] != "/etc/fail2ban/filter.d/postgres-auth.conf" { + t.Fatalf("the jail's filter file was not written: %v", filter) + } + if !strings.Contains(filter["content"].(string), "failregex = auth failed from ") { + t.Fatalf("the failregex was not written: %v", filter["content"]) + } +} + +// A holder whose node runs no jail-declaring module still gets the file, empty — so removing the +// last jail is a change the service restarts on, not a file that vanishes. +func TestTheComposedJailFileIsWrittenEvenWhenEmpty(t *testing.T) { + files := jailsInto([]Manifest{{Module: "fail2ban"}}, &Jailing{Into: "/x", FilterInto: "/f"}) + if len(files) != 1 || files[0]["id"] != ComposedJailsID() { + t.Fatalf("the empty composed jail file was not written alone: %v", files) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 85800d9..fca2fa8 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -366,6 +366,14 @@ type Manifest struct { // that could only see its own ports would write a rule set that closed everything else. Filtering *Filtering `json:"filtering,omitempty"` + // Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31). + // Written into whichever node runs the module, the same way `listens` become that node's rules. + Jails []Jail `json:"jails,omitempty"` + + // Jailing marks the module that composes the node's fail2ban jails — the intrusion-prevention + // holder. Like Filtering: one module per node gathers what every module declared and writes it. + Jailing *Jailing `json:"jailing,omitempty"` + // Guards are ports of this module's the mesh refuses on an adopted node except from the // private network and from the machine itself (novox/hq ADR 0100) — the store's port and the // broker's management port. The ports the software uses; the mesh guards where the machine @@ -615,6 +623,36 @@ func (l Listening) At() string { return l.Protocol } +// Jail is a fail2ban jail a module declares for its own service (novox/hq to-be 31). +// +// **The module names no node and no path** (ADR 0112): it says what a break-in on its service looks +// like — the failregex — and the jail's own keys (the port it watches, where it logs, how many +// tries, how long to ban). The mesh writes it into whichever node's fail2ban runs the module, the +// same way a module's `listens` become that node's firewall rules. A node not running the module +// has no such jail. +type Jail struct { + // Name is the jail and its filter, e.g. "postgres-auth". One holder of the name per node. + Name string `json:"name"` + // Failregex is what a failed authentication looks like in the service's log — the filter. + Failregex string `json:"failregex"` + // Jail is the body of the jail's stanza: the keys under [] the module knows and the mesh + // does not — the port it watches, its logpath and backend, maxretry, bantime. + Jail string `json:"jail"` +} + +// Jailing says a module composes the node's fail2ban jails — the intrusion-prevention holder. Like +// Filtering for the firewall: one module gathers what every other module declared and writes it +// where it owns. Into is the one jail file the stanzas are composed into (so the fail2ban service +// can restart on a single resource); FilterInto is the directory each jail's filter file goes in. +type Jailing struct { + Into string `json:"into"` + FilterInto string `json:"filter-into"` +} + +// ComposedJailsID is the single jail file the mesh composes every declared jail into, so the +// fail2ban service names one resource in its restart-on and a jail added or removed reaches it. +func ComposedJailsID() string { return "composed-jails" } + // Filtering says where a module wants the computed rule set. type Filtering struct { // Into is the path to write it to. Whatever loads it is this module's own business — an From 8ceec32692943e0f84ad1334d541e186c23636d5 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 17:50:56 +0200 Subject: [PATCH 18/18] The mesh owns the operator's ~/.ssh: account fact + home-scoped resources (to-be 29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node carries its operator account (name + home; migration 0036, Node.Account, SetAccount, 'node account' CLI). The account and its home are offered as machine facts ${machine:account} / ${machine:account-home}, and machineInto now resolves placeholders in a resource's path and owner (not just content), so a module writes into a person's home naming what it cannot know. A RosterFile gains Home: the file is placed under the account's home and chowned to it, its template sees each node's Account, and a machine with no account gets none — this is how the ssh Host blocks for every node reach a person's ~/.ssh. Roster carries per-node accounts (Rendering.Accounts). Tested, including ssh-client composed end-to-end. Not deployed. --- cmd/mesh-controller/nodes.go | 41 +++++++++++- cmd/mesh-controller/plan.go | 25 +++++++- internal/catalogue/declaration.go | 7 ++- internal/catalogue/machine_into_files.go | 53 +++++++++++----- internal/catalogue/resolve.go | 10 +++ internal/catalogue/roster.go | 51 +++++++++++---- internal/catalogue/roster_test.go | 63 +++++++++++++++---- internal/catalogue/ssh_client_test.go | 46 ++++++++++++++ .../0036-a-node-has-an-operator-account.sql | 13 ++++ internal/inventory/nodes.go | 43 ++++++++++++- 10 files changed, 307 insertions(+), 45 deletions(-) create mode 100644 internal/catalogue/ssh_client_test.go create mode 100644 internal/inventory/migrations/0036-a-node-has-an-operator-account.sql diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index d7ce9dd..d75ee40 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -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 ` uses. Reports with no argument; sets with one; + // an optional second argument is the home when it is not /home/. + 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 — what it is now; " + + "node account [home] — set it (home defaults to /home/)") + } + 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 ` 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 — what it is now; " + " to set it; --clear to take it away" diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 781f330..85f9a0f 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -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 } @@ -520,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 // `.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 @@ -604,7 +625,7 @@ 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(), MeshRange: meshRange, Foundation: foundation, Kept: kept, + Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation, Kept: kept, Adopted: record.Adopted, Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, }, record, nil diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index eb2e990..5cd726e 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -103,6 +103,11 @@ type Rendering struct { // and offered as ${machine:mesh-range}, the same way one machine's address is. MeshRange string + // Accounts is each machine's operator account, by the same internal name Names uses (novox/hq + // to-be 29). What an ssh Host block's `User` line is composed from; empty for a machine no + // operator account is known on. + Accounts map[string]string + // Kept is every operator-sealed secret in the mesh, for a module that `keeps` them. Nil when // nothing on this node keeps them, or the mesh has no operator key. Kept *KeptExport @@ -656,7 +661,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri // plane's; making a name resolve is the module's software. Emitted as ordinary files under // this module's name, so they are applied, reported and removed exactly as anything else // it declares. - given, err := FactsInto(m, r, with.Names, with.Machines, with.Suffix) + given, err := FactsInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix) if err != nil { return nil, err } diff --git a/internal/catalogue/machine_into_files.go b/internal/catalogue/machine_into_files.go index d47dab6..bc2071a 100644 --- a/internal/catalogue/machine_into_files.go +++ b/internal/catalogue/machine_into_files.go @@ -71,32 +71,53 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s if meshRange != "" { out["mesh-range"] = meshRange } + // The operator's login on this machine and where its home is (novox/hq to-be 29), so a module + // that writes operator config names the account and its home rather than a value it cannot know. + // Absent when no operator account is known — a headless box a person never logs into. + if r.Account != "" { + out["account"] = r.Account + out["account-home"] = accountHomeOf(r.Account, r.AccountHome) + } return out } +// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for +// root, /home/ otherwise. The one place the default is written, so a fact and the store +// cannot disagree about it. +func accountHomeOf(account, home string) string { + if home != "" { + return home + } + if account == "root" { + return "/root" + } + return "/home/" + account +} + // machineInto replaces a file's ${machine:…} placeholders with what the mesh knows about the // machine the module was assigned to. // // A key the mesh does not hold is refused, for the same reason a binding's is: left alone, the // literal would be written into a configuration file and read as a value. func machineInto(resource map[string]any, facts map[string]string, module string) error { - if fmt.Sprint(resource["type"]) != "file" { - return nil - } - content, ok := resource["content"].(string) - if !ok { - return nil - } - for _, key := range machineUsed(content) { - value, has := facts[key] - if !has { - return fmt.Errorf( - "%s has a file that says ${machine:%s}, and this machine says %s", - module, key, orNothing(namesOfFacts(facts))) + // Content, and now the path and owner too: a module that writes into a person's home names it + // with ${machine:account-home} and ${machine:account}, which it cannot know until assigned + // (novox/hq to-be 29), the same reason its content names ${machine:address}. + for _, field := range []string{"path", "owner", "content"} { + s, ok := resource[field].(string) + if !ok { + continue + } + for _, key := range machineUsed(s) { + value, has := facts[key] + if !has { + return fmt.Errorf( + "%s has a %s that says ${machine:%s}, and this machine says %s", + module, field, key, orNothing(namesOfFacts(facts))) + } + s = strings.ReplaceAll(s, fmt.Sprintf("${machine:%s}", key), value) + resource[field] = s } - resource["content"] = strings.ReplaceAll( - content, fmt.Sprintf("${machine:%s}", key), value) - content = resource["content"].(string) } return nil } diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index d825359..213490e 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -28,6 +28,10 @@ type Node struct { // (novox/hq ADR 0066). A route contribution carries only a label — the subdomain — and the mesh // joins