Merge pull request 'Add the node-nfs-server and node-mounts seats (hq ADR 0263)' (#163) from feat/mounts-module into main
This commit was merged in pull request #163.
This commit is contained in:
@@ -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()...)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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 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",
|
||||
"address": "a client's address on the private network (optional)",
|
||||
}, []string{"share"}),
|
||||
Replaces: []string{"showmount -e"}},
|
||||
{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 " +
|
||||
"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\": change it (needs why); 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\": change it (needs why); anything else is a dry run",
|
||||
"why": "why, for the record",
|
||||
}, []string{"mountpoint"}, "confirm"),
|
||||
Replaces: []string{"sed -i /etc/fstab"}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user