Add the node-nfs-server and node-mounts seats, so a share and a mount have a role the mesh defines (hq ADR 0263)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/mounts-module ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request

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.
This commit is contained in:
jochen
2026-10-08 18:24:38 +02:00
parent e3ec15f707
commit 59fdffb979
4 changed files with 230 additions and 3 deletions
+5
View File
@@ -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()...)
+3 -3
View File
@@ -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())
}
}
+94
View File
@@ -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"}},
}
}
+128
View File
@@ -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)
}
}