From 59fdffb9794a6109da45a9464ffb122cde6617ab Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 18:24:38 +0200 Subject: [PATCH 1/3] Add the node-nfs-server and node-mounts seats, so a share and a mount have a role the mesh defines (hq ADR 0263) A machine sharing folders and a machine mounting them each need one holder per machine, with verbs an agent calls instead of exportfs, fstab edits or zfs set. Both seats deliver nothing: nfs-share is provided at the mesh's scope. The two adopt verbs are dry runs unless confirmed. --- internal/catalogue/seats.go | 5 ++ internal/catalogue/seats_test.go | 6 +- internal/catalogue/shares.go | 94 ++++++++++++++++++++++ internal/catalogue/shares_test.go | 128 ++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 internal/catalogue/shares.go create mode 100644 internal/catalogue/shares_test.go diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 32d80f5e..8d42af16 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -304,6 +304,11 @@ var defaultSeats = append([]Seat{ // Where it is held, its holder writes the resolver file and the uplink's holder steps back from it // (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link. {Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs()}, + // A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server + // exports a machine's folders to the private network and provides each as `nfs-share`; the holder of + // node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go). + {Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()}, + {Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()}, }, // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index f0d71198..984be4e3 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -46,7 +46,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired + // Forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); 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); @@ -57,8 +57,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired // mesh-build-machine row goes, when no registered manifest claims it. - if len(Seats()) != 38 { - t.Errorf("the mesh defines %d seats rather than 38; the set is closed, so a change here is "+ + if len(Seats()) != 40 { + t.Errorf("the mesh defines %d seats rather than 40; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } diff --git a/internal/catalogue/shares.go b/internal/catalogue/shares.go new file mode 100644 index 00000000..d6413474 --- /dev/null +++ b/internal/catalogue/shares.go @@ -0,0 +1,94 @@ +package catalogue + +// A machine's shares, and the shares a machine mounts (novox/hq ADR 0263). +// +// **Two roles, one per side of the wire.** A machine that shares a directory with the mesh does it +// through the holder of `node-nfs-server`: it writes the machine's export file, runs the NFS service, +// opens its port to the private network only, and provides each share as the provision `nfs-share`. A +// machine that wants the files gets them through the holder of `node-mounts`: it writes a mount unit and +// an automount unit per share, so nothing mounts at boot and nothing can fail a boot, and it says a +// device that comes and goes is absent rather than failed. +// +// **One holder per machine on each side.** Two modules writing one machine's export file, or two writing +// mount units for one mount point, is the conflict a seat exists to refuse. Both seats deliver nothing: +// `nfs-share` is provided at the mesh's scope, by the module holding `node-nfs-server` on the machine that +// shares, and a seat at a node's scope cannot be the answer for a provision at the mesh's. +// +// **The data is the operator's** (ADR 0051). Neither holder creates, chowns or removes anything under a +// shared path; the export maps every client to the path's owner, so no client acts as another account on +// the server. Their verbs read, and the ones that act take over what a person wrote by hand only on a +// person's word: a dataset's export property, an fstab line. + +// NFSServerSeat is the role of the machine that shares directories over NFS (novox/hq ADR 0263). +const NFSServerSeat = "node-nfs-server" + +// MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263). +const MountsSeat = "node-mounts" + +// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263). +func nfsServerVerbs() []Verb { + return []Verb{ + {Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " + + "read-only, the owner every client is mapped to (uid and gid), the clients it is exported to (the " + + "private network's range), and whether the kernel holds it now. Also the exports found that are " + + "not the mesh's: a dataset's sharenfs property, a line in /etc/exports.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}}, + {Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " + + "knows its clients.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}}, + {Name: "test", Description: "Whether this machine exports one share to one node's address now, and " + + "whether its NFS service is up: what a machine mounting the share asks before it mounts.", + Input: schema(map[string]string{ + "share": "the share, by its name", + "node": "the node that would mount it, by name (the asking node when unsaid)", + }, []string{"share"}), + Replaces: []string{"showmount -e"}}, + {Name: "reload", Description: "Write this machine's export file again from the module's settings and " + + "have the kernel take it. Changes no shared path.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"exportfs -ra"}}, + {Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " + + "property for a path the mesh's own export now serves — only when that export is live. Without " + + "confirm, says what it would do and changes nothing.", + Input: schema(map[string]string{ + "path": "the shared path whose hand-made export is taken over", + "confirm": "true to change it; anything else is a dry run", + "why": "why, for the record", + }, []string{"path"}, "confirm"), + Replaces: []string{"zfs set sharenfs=off"}}, + } +} + +// mountsVerbs is the contract every holder of node-mounts serves (novox/hq ADR 0263). +func mountsVerbs() []Verb { + return []Verb{ + {Name: "list", Description: "Every mount point on this machine: its fstab line, the mesh's mount and " + + "automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, " + + "and which settings asked for it. A password in a mount's options is never shown.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"cat /etc/fstab", "findmnt", "systemctl list-units --type=mount"}}, + {Name: "test", Description: "Whether one share's or occasional source's server answers from this " + + "machine now, without touching its mount point.", + Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), + Replaces: []string{"showmount -e", "ping"}}, + {Name: "mount", Description: "Mount one share or occasional source now, rather than at its first " + + "access.", + Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), + Replaces: []string{"mount"}}, + {Name: "unmount", Description: "Release one share or occasional source now; its automount stays " + + "armed, so the next access mounts it again.", + Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), + Replaces: []string{"umount"}}, + {Name: "adopt", Description: "Take over a mount a person wrote by hand: comment out the /etc/fstab " + + "line for a mount point the mesh's own units now serve, keeping a copy of the file — only when " + + "the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", + Input: schema(map[string]string{ + "mountpoint": "the mount point whose fstab line is taken over", + "confirm": "true to change it; anything else is a dry run", + "why": "why, for the record", + }, []string{"mountpoint"}, "confirm"), + Replaces: []string{"sed -i /etc/fstab"}}, + } +} diff --git a/internal/catalogue/shares_test.go b/internal/catalogue/shares_test.go new file mode 100644 index 00000000..51ef5ee9 --- /dev/null +++ b/internal/catalogue/shares_test.go @@ -0,0 +1,128 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0263: a machine's shares and the shares a machine mounts are two node seats, each +// with its verbs required of every holder, each delivering nothing — `nfs-share` is provided at the mesh's +// scope by the holder of node-nfs-server, and a node seat cannot answer for a provision at the mesh's. + +func TestTheSharesAndMountsAreNodeSeatsWithTheirVerbs(t *testing.T) { + for seat, want := range map[string]string{ + NFSServerSeat: "exports clients test reload adopt", + MountsSeat: "list test mount unmount adopt", + } { + s, ok := SeatNamed(seat) + if !ok { + t.Fatalf("%s is not in the mesh's set", seat) + } + if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0263" || s.Delivers != "" || s.Replicated { + t.Errorf("%s is %+v; a node seat under ADR 0263 that delivers nothing", seat, s) + } + var got []string + for _, v := range s.Serves { + got = append(got, v.Name) + if v.Optional { + t.Errorf("%s.%s is optional; its first holder serves it", seat, v.Name) + } + if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 { + t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", seat, v.Name) + } + } + if strings.Join(got, " ") != want { + t.Errorf("%s serves %v, not %s", seat, got, want) + } + } +} + +// Both acts that take over what a person wrote by hand are dry runs unless confirmed, and name the path. +func TestTheAdoptVerbsAreDryRunsUnlessConfirmed(t *testing.T) { + for seat, key := range map[string]string{NFSServerSeat: "path", MountsSeat: "mountpoint"} { + s, _ := SeatNamed(seat) + for _, v := range s.Serves { + if v.Name != "adopt" { + continue + } + props, _ := v.Input["properties"].(map[string]any) + confirm, _ := props["confirm"].(map[string]any) + if confirm == nil || confirm["enum"] == nil { + t.Errorf("%s.adopt has no confirm switch: %v", seat, v.Input) + } + if req, _ := v.Input["required"].([]string); len(req) != 1 || req[0] != key { + t.Errorf("%s.adopt requires %v, not %q alone", seat, v.Input["required"], key) + } + if !strings.Contains(v.Description, "Without confirm, says what it would do and changes nothing") { + t.Errorf("%s.adopt does not say a call without confirm changes nothing: %s", seat, v.Description) + } + } + } +} + +// A holder claiming the seat at a node with every verb holds it; one naming a verb the seat does not +// promise, or leaving one out, is refused — as the catalogue's nfs-server and mounts modules claim them. +func TestAHolderOfTheShareSeatsServesEveryVerbAndNothingElse(t *testing.T) { + cases := []struct { + seat, module string + verbs []string + }{ + {NFSServerSeat, "nfs-server", []string{"exports", "clients", "test", "reload", "adopt"}}, + {MountsSeat, "mounts", []string{"list", "test", "mount", "unmount", "adopt"}}, + } + for _, c := range cases { + seat, _ := SeatNamed(c.seat) + holder := Manifest{Module: c.module, Version: "1", + Claims: []Claim{{Name: c.seat, Scope: ScopeNode, Serves: c.verbs}}} + if c.seat == NFSServerSeat { + holder.Provides = []Offer{{Name: "nfs-share", Scope: ScopeMesh}} + } + if err := CanHold(holder, seat); err != nil { + t.Errorf("%s serving every verb is refused: %v", c.module, err) + } + typo := holder + typo.Claims = []Claim{{Name: c.seat, Scope: ScopeNode, Serves: append(append([]string{}, c.verbs...), "export")}} + if err := CanHold(typo, seat); err == nil || !strings.Contains(err.Error(), "does not promise") { + t.Errorf("%s naming a verb the seat does not promise was accepted: %v", c.module, err) + } + short := holder + short.Claims = []Claim{{Name: c.seat, Scope: ScopeNode, Serves: c.verbs[:len(c.verbs)-1]}} + if err := CanHold(short, seat); err == nil || !strings.Contains(err.Error(), "adopt") { + t.Errorf("%s leaving adopt out was accepted: %v", c.module, err) + } + mesh := holder + mesh.Claims = []Claim{{Name: c.seat, Scope: ScopeMesh, Serves: c.verbs}} + if err := CanHold(mesh, seat); err == nil { + t.Errorf("%s claiming a node seat at the mesh's scope was accepted", c.module) + } + } +} + +// What each verb replaces is what an agent would type over ssh to read or change a share by hand. +func TestTheShareVerbsSayWhatTheyReplace(t *testing.T) { + want := map[string]string{ + NFSServerSeat + ".exports": "exportfs -v", + NFSServerSeat + ".adopt": "zfs set sharenfs=off", + MountsSeat + ".list": "cat /etc/fstab", + MountsSeat + ".adopt": "sed -i /etc/fstab", + } + for _, s := range DefaultSeats() { + for _, v := range s.Serves { + cmd, ok := want[s.Name+"."+v.Name] + if !ok { + continue + } + delete(want, s.Name+"."+v.Name) + found := false + for _, r := range v.Replaces { + found = found || r == cmd + } + if !found { + t.Errorf("%s.%s does not say it replaces %q: %v", s.Name, v.Name, cmd, v.Replaces) + } + } + } + for verb := range want { + t.Errorf("%s is not a verb of the compiled seats", verb) + } +} From 2b01f8786e93ba2ba148fe86ce863202f3257587 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 18:34:38 +0200 Subject: [PATCH 2/3] Let node-nfs-server.test take a client's address: the server knows the range, not the mesh's node names --- internal/catalogue/shares.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/catalogue/shares.go b/internal/catalogue/shares.go index d6413474..f23a5d17 100644 --- a/internal/catalogue/shares.go +++ b/internal/catalogue/shares.go @@ -38,11 +38,12 @@ func nfsServerVerbs() []Verb { "knows its clients.", Input: schema(map[string]string{}, nil), Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}}, - {Name: "test", Description: "Whether this machine exports one share to one node's address now, and " + - "whether its NFS service is up: what a machine mounting the share asks before it mounts.", + {Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " + + "NFS service is up; with an address, also whether that address is inside the private network's " + + "range the share is exported to. What a machine mounting the share asks before it mounts.", Input: schema(map[string]string{ - "share": "the share, by its name", - "node": "the node that would mount it, by name (the asking node when unsaid)", + "share": "the share, by its name", + "address": "a client's address on the private network (optional)", }, []string{"share"}), Replaces: []string{"showmount -e"}}, {Name: "reload", Description: "Write this machine's export file again from the module's settings and " + From d7fab82a89801df49d5bdb663338823868628561 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 20:11:33 +0200 Subject: [PATCH 3/3] Say the adopt switch is the string "true" and that reload only has the kernel reread its exports, as the holders do --- internal/catalogue/shares.go | 9 +++++---- internal/catalogue/shares_test.go | 33 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/internal/catalogue/shares.go b/internal/catalogue/shares.go index f23a5d17..87b56f3a 100644 --- a/internal/catalogue/shares.go +++ b/internal/catalogue/shares.go @@ -46,8 +46,9 @@ func nfsServerVerbs() []Verb { "address": "a client's address on the private network (optional)", }, []string{"share"}), Replaces: []string{"showmount -e"}}, - {Name: "reload", Description: "Write this machine's export file again from the module's settings and " + - "have the kernel take it. Changes no shared path.", + {Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " + + "-ra) and answer the shares as it then holds them. The module's own process writes its export " + + "file; this is for after a change made outside it. Changes no shared path.", Input: schema(map[string]string{}, nil), Replaces: []string{"exportfs -ra"}}, {Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " + @@ -55,7 +56,7 @@ func nfsServerVerbs() []Verb { "confirm, says what it would do and changes nothing.", Input: schema(map[string]string{ "path": "the shared path whose hand-made export is taken over", - "confirm": "true to change it; anything else is a dry run", + "confirm": "\"true\": change it (needs why); anything else is a dry run", "why": "why, for the record", }, []string{"path"}, "confirm"), Replaces: []string{"zfs set sharenfs=off"}}, @@ -87,7 +88,7 @@ func mountsVerbs() []Verb { "the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", Input: schema(map[string]string{ "mountpoint": "the mount point whose fstab line is taken over", - "confirm": "true to change it; anything else is a dry run", + "confirm": "\"true\": change it (needs why); anything else is a dry run", "why": "why, for the record", }, []string{"mountpoint"}, "confirm"), Replaces: []string{"sed -i /etc/fstab"}}, diff --git a/internal/catalogue/shares_test.go b/internal/catalogue/shares_test.go index 51ef5ee9..5fbe0f10 100644 --- a/internal/catalogue/shares_test.go +++ b/internal/catalogue/shares_test.go @@ -126,3 +126,36 @@ func TestTheShareVerbsSayWhatTheyReplace(t *testing.T) { t.Errorf("%s is not a verb of the compiled seats", verb) } } + +// The confirm switch is the seat's string "true", as every verb's switch is, and its description says so: +// a holder handed the boolean reading of "true to change it" would treat the string as a dry run. +func TestTheConfirmSwitchIsTheStringTrue(t *testing.T) { + for _, seat := range []string{NFSServerSeat, MountsSeat} { + s, _ := SeatNamed(seat) + for _, v := range s.Serves { + props, _ := v.Input["properties"].(map[string]any) + confirm, _ := props["confirm"].(map[string]any) + if confirm == nil { + continue + } + if confirm["type"] != "string" { + t.Errorf("%s.%s confirm is %v, not the string switch", seat, v.Name, confirm["type"]) + } + if d, _ := confirm["description"].(string); !strings.Contains(d, `"true"`) { + t.Errorf("%s.%s confirm does not name the string \"true\": %q", seat, v.Name, d) + } + } + } +} + +// reload has the kernel read the export files; the module's process writes its file. The description +// must not promise a write it does not do. +func TestReloadSaysWhatItDoes(t *testing.T) { + s, _ := SeatNamed(NFSServerSeat) + for _, v := range s.Serves { + if v.Name == "reload" && (strings.Contains(v.Description, "Write this machine's export file") || + !strings.Contains(v.Description, "exportfs")) { + t.Errorf("reload's description promises a write or does not name exportfs: %s", v.Description) + } + } +}