diff --git a/internal/broker/seattools_test.go b/internal/broker/seattools_test.go index ed24e1f..2f83c5a 100644 --- a/internal/broker/seattools_test.go +++ b/internal/broker/seattools_test.go @@ -5,16 +5,16 @@ import "testing" // A node-scoped seat's tool carries the node (novox/hq ADR 0132, design 33 §4): two nodes holding one // node-scoped seat derive two addresses, and a user of the seat may publish any node's. func TestTwoNodesHoldingOneNodeSeatDeriveTwoToolAddresses(t *testing.T) { - seat := Seat{Name: "node-hosts-file", Scope: "node", Serves: []string{"entries"}} - one, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "hosts", Holds: []Seat{seat}, PasswordHash: "x"}) - two, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "hosts", Holds: []Seat{seat}, PasswordHash: "x"}) - has(t, one.Subscribe, "mesh.seat.node-hosts-file.tool.entries.one") - has(t, two.Subscribe, "mesh.seat.node-hosts-file.tool.entries.two") - hasNot(t, one.Subscribe, "mesh.seat.node-hosts-file.tool.entries") - hasNot(t, one.Subscribe, "mesh.seat.node-hosts-file.tool.entries.two") + seat := Seat{Name: "node-hostname", Scope: "node", Serves: []string{"entries"}} + one, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "hostname", Holds: []Seat{seat}, PasswordHash: "x"}) + two, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "hostname", Holds: []Seat{seat}, PasswordHash: "x"}) + has(t, one.Subscribe, "mesh.seat.node-hostname.tool.entries.one") + has(t, two.Subscribe, "mesh.seat.node-hostname.tool.entries.two") + hasNot(t, one.Subscribe, "mesh.seat.node-hostname.tool.entries") + hasNot(t, one.Subscribe, "mesh.seat.node-hostname.tool.entries.two") user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "three", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"}) - has(t, user.Publish, "mesh.seat.node-hosts-file.tool.entries.*") + has(t, user.Publish, "mesh.seat.node-hostname.tool.entries.*") } // A mesh-scoped seat's tool stays flat: nothing about it changes. diff --git a/internal/catalogue/hostname_seat_test.go b/internal/catalogue/hostname_seat_test.go new file mode 100644 index 0000000..c151efa --- /dev/null +++ b/internal/catalogue/hostname_seat_test.go @@ -0,0 +1,98 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0223 part 3: a machine's names are one seat's. The `hosts` module holding +// `node-hosts-file` became `hostname` holding `node-hostname`, which writes /etc/hostname beside the +// machine's own lines in /etc/hosts. The seat was renamed (ADR 0122), so what claims the old name — a +// manifest registered before the rename — still holds the one seat. + +func withHostnameAlias(t *testing.T) { + t.Helper() + was := aliases + t.Cleanup(func() { aliases = was }) + UseAliases(map[string]string{"node-hosts-file": "node-hostname"}) +} + +func TestTheHostsFilesFormerNameResolvesToTheHostnameSeat(t *testing.T) { + if _, known := SeatNamed("node-hostname"); !known { + t.Fatal("node-hostname is not in the mesh's set") + } + withHostnameAlias(t) + seat, known := SeatNamed("node-hosts-file") + if !known || seat.Name != "node-hostname" || seat.Scope != ScopeNode { + t.Fatalf("the former name did not resolve: %+v %v", seat, known) + } + var verbs []string + for _, v := range seat.Serves { + verbs = append(verbs, v.Name) + } + if strings.Join(verbs, " ") != "entries add remove" { + t.Errorf("the renamed seat serves %v; its verbs are unchanged", verbs) + } +} + +// One seat under either name: the module registered before the rename and the one after cannot both +// hold it on one machine. +func TestTheOldAndTheNewClaimantAreOneSeatOnAMachine(t *testing.T) { + withHostnameAlias(t) + cat := shelf( + mod("hosts", nil, nil, nil, Claim{Name: "node-hosts-file"}), + mod("hostname", nil, nil, nil, Claim{Name: "node-hostname"}), + ) + _, err := Resolve(cat, []string{"hosts", "hostname"}, workstation(), World{}) + if err == nil || !strings.Contains(err.Error(), "node-hostname") { + t.Errorf("hosts and hostname both held the machine's names on one machine: %v", err) + } + if _, err := Resolve(cat, []string{"hosts"}, workstation(), World{}); err != nil { + t.Errorf("a machine still assigned hosts under the old name does not resolve: %v", err) + } +} + +// The catalogue's module: /etc/hostname is the operator's `hostname` setting. Without it the module is +// left out, naming the key — the mesh never renames a machine on its own — and a mesh-wide setting +// naming ${machine:name} gives every machine its mesh name. +func TestTheMachinesNameIsItsSetting(t *testing.T) { + cat := map[string]Manifest{"hostname": catalogueManifest(t, "hostname")} + got, err := Resolve(cat, []string{"hostname"}, Node{Name: "ace", At: "ace.internal"}, World{}) + if err != nil { + t.Fatal(err) + } + machines := map[string]string{"ace.internal": "10.42.0.2"} + nameOf := func(settings SettingsBy) (string, string) { + t.Helper() + composed, err := got.Compose(Rendering{Names: machines, Machines: machines, Suffix: "internal", + Settings: settings}) + if err != nil { + t.Fatal(err) + } + if why := composed.LeftOut["hostname"]; why != "" { + return "", why + } + for _, r := range composed.Resources { + if r["path"] == "/etc/hostname" { + return r["content"].(string), "" + } + } + t.Fatal("no /etc/hostname composed") + return "", "" + } + + if _, why := nameOf(nil); !strings.Contains(why, "hostname") { + t.Errorf("with no setting the machine's name was written, or left out for another reason: %q", why) + } + if name, why := nameOf(SettingsBy{"hostname": {{From: "ace", Values: map[string]any{"hostname": "Ace"}}}}); name != "Ace\n" { + t.Errorf("the operator's name for the machine gave %q (%s)", name, why) + } + mesh := Layer{From: "the mesh", Values: map[string]any{"hostname": "${machine:name}"}} + if name, why := nameOf(SettingsBy{"hostname": {mesh}}); name != "ace\n" { + t.Errorf("a mesh-wide ${machine:name} gave %q (%s)", name, why) + } + node := Layer{From: "ace", Values: map[string]any{"hostname": "Ace"}} + if name, why := nameOf(SettingsBy{"hostname": {mesh, node}}); name != "Ace\n" { + t.Errorf("a machine's own name over the mesh-wide one gave %q (%s)", name, why) + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 2970872..b0972a7 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -816,13 +816,17 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel if byScope[scope] == nil { byScope[scope] = map[string]string{} } - if other, taken := byScope[scope][c.Name]; taken { + // **One seat under either of its names** (novox/hq ADR 0122): a manifest registered before + // a rename claims the former name, and one written after it the current — two claimants of + // one seat, compared by the seat they resolve to and not by how each spelled it. + seat := canonicalSeat(c.Name) + if other, taken := byScope[scope][seat]; taken { problems = append(problems, fmt.Sprintf( "%s and %s both claim %q, and only one thing may hold it per %s", - other, m.Module, c.Name, scope)) + other, m.Module, seat, scope)) continue } - byScope[scope][c.Name] = m.Module + byScope[scope][seat] = m.Module held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name, Module: m.Module, Site: node.Site}) } @@ -831,7 +835,7 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel // And against the rest of the mesh, for the scopes that reach past this machine. for _, h := range held { for _, e := range elsewhere { - if e.Node == node.Name || e.Claim != h.Claim || e.Scope != h.Scope { + if e.Node == node.Name || canonicalSeat(e.Claim) != canonicalSeat(h.Claim) || e.Scope != h.Scope { continue } switch h.Scope { @@ -862,6 +866,15 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel return held, problems } +// canonicalSeat is the seat a claimed name refers to, by its current name: itself for a name the mesh +// does not know (a module's own seat), its seat's name for a former one. +func canonicalSeat(name string) string { + if s, known := SeatNamed(name); known { + return s.Name + } + return name +} + // checkResources refuses two modules writing the same thing. // // This costs no manifest field: the mesh already holds every resource of every module, so two diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index aa1949a..8177582 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -149,10 +149,12 @@ var defaultSeats = append([]Seat{ // requirement resolves to a holder wherever they are placed. {Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Replicated: true, Decision: "novox/hq ADR 0194, ADR 0223"}, - // **A machine's /etc/hosts is one module's** (novox/hq ADR 0199): its holder writes the machine's - // own lines and keeps every other line as the operator's, changed through these three verbs on that - // machine alone. The controller holds none of it. - {Name: "node-hosts-file", Scope: ScopeNode, Decision: "novox/hq ADR 0199", + // **A machine's names are one module's** (novox/hq ADR 0199, ADR 0223): its holder writes + // /etc/hostname and the machine's own lines in /etc/hosts, and keeps every other line of the hosts + // file as the operator's, changed through these three verbs on that machine alone. The controller + // holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an + // alias on a mesh that knew it. + {Name: "node-hostname", Scope: ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223", Serves: []Verb{ {Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " + "the operator's, or the block of the module or tool that writes it.", diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index f36cf1b..506fd5a 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -49,7 +49,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // Thirty-six since node-resolver-config retired into node-uplink (novox/hq ADR 0223); thirty-seven // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with // node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215); - // thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199); thirty-four + // thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199, now + // node-hostname); thirty-four // with node-hotkeys (ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the // graphical session's eleven (ADR 0208); twenty-one with node-package-manager and // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, diff --git a/internal/inventory/migrations/0064-a-machines-names-are-one-seats.sql b/internal/inventory/migrations/0064-a-machines-names-are-one-seats.sql new file mode 100644 index 0000000..120695b --- /dev/null +++ b/internal/inventory/migrations/0064-a-machines-names-are-one-seats.sql @@ -0,0 +1,23 @@ +-- A machine's names are one seat's (novox/hq ADR 0223 part 3): `node-hosts-file` is renamed +-- `node-hostname`, whose holder writes /etc/hostname beside the machine's own lines in /etc/hosts. +-- +-- A rename is a database update (ADR 0122): the row keeps its verbs, the former name becomes an alias +-- that resolves to it, so a manifest registered under the old name — the `hosts` module still assigned +-- while machines move to `hostname` — goes on holding the one seat, and two claimants of it on one +-- machine are still refused. +-- +-- **Both rows may exist when this runs**, as 0048 found for the artifact store: a controller whose +-- compiled defaults carry the new name may seed it before this migration. Then the old row's holding +-- moves to it and the old row goes; otherwise the old row is renamed. Either way the old name becomes +-- an alias. +update seat_holding set seat = 'node-hostname' + where seat = 'node-hosts-file' + and exists (select 1 from seat where name = 'node-hostname'); +delete from seat + where name = 'node-hosts-file' + and exists (select 1 from seat where name = 'node-hostname'); +update seat set name = 'node-hostname', decided = 'novox/hq ADR 0199, ADR 0223' + where name = 'node-hosts-file'; +insert into seat_alias (alias, seat) values ('node-hosts-file', 'node-hostname') + on conflict (alias) do update set seat = excluded.seat; +update seat_alias set seat = 'node-hostname' where seat = 'node-hosts-file'; diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index 9f1a475..062de5b 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -32,8 +32,8 @@ const Requirement = "private-network" // Name is the module that answers it with WireGuard. // // **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq -// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hosts-file` (ADR 0199): the -// controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it +// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199, +// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it // asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts // until 2026-10-05; the host gives that region back at the first push without it. const Name = "mesh-wireguard"