Files
mesh-catalog/modules/nfs-server/cmd/nfs-server/nfs_test.go
T
jochen b8e04acfaf
mesh/merge-gate pass: builds new: modules/mounts, modules/nfs-server, sent nowhere; no bus step; every machine composes with the change as it did without (…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Let nfs-server's tests see their folders as the library owner's, so the root refusal is tested and not tripped when the build seat runs them as root
2026-10-08 21:05:52 +02:00

450 lines
16 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fake is a machine under a temporary root, with recorded command answers.
type fake struct {
t *testing.T
root string
answers map[string]Ran
ran []string
now time.Time
uid int
owners map[string]Owner
// onExportfs, when set, writes the kernel's table as exportfs -ra would.
onExportfs func()
}
func newFake(t *testing.T) *fake {
f := &fake{t: t, root: t.TempDir(), answers: map[string]Ran{}, now: time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC), uid: 0}
f.answers["systemctl is-active nfs-server.service"] = Ran{Out: "active\n"}
f.answers["zfs list -H -o name,mountpoint,sharenfs -t filesystem"] = Ran{Code: -1, Err: `exec: "zfs": executable file not found in $PATH`}
return f
}
func (f *fake) machine() *Machine {
return &Machine{Root: f.root, UID: f.uid, owners: f.owners, ownerOf: asTheLibrarysOwner, Now: func() time.Time { return f.now }, Run: func(_ context.Context, name string, args ...string) Ran {
line := strings.Join(append([]string{name}, args...), " ")
f.ran = append(f.ran, line)
if (line == "exportfs -ra" || line == "sudo -n exportfs -ra") && f.onExportfs != nil {
f.onExportfs()
}
if r, ok := f.answers[line]; ok {
return r
}
return Ran{}
}}
}
func (f *fake) dir(p string) {
if err := os.MkdirAll(filepath.Join(f.root, p), 0o755); err != nil {
f.t.Fatal(err)
}
}
func (f *fake) file(p, content string) {
f.dir(filepath.Dir(p))
if err := os.WriteFile(filepath.Join(f.root, p), []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
func (f *fake) read(p string) string {
b, _ := os.ReadFile(filepath.Join(f.root, p))
return string(b)
}
func (f *fake) restic(p string) {
f.file(p+"/config", "x")
for _, d := range []string{"keys", "snapshots", "data"} {
f.dir(p + "/" + d)
}
}
// kernelReads makes exportfs -ra copy the mesh's export file into the kernel's table, as it would.
func (f *fake) kernelReads() {
f.onExportfs = func() {
var b strings.Builder
for _, e := range ParseExports(f.read(ExportFile), ExportFile) {
fmt.Fprintf(&b, "%s\t%s(%s)\n", e.Path, e.Client, e.Options)
}
f.file(Etab, b.String())
}
}
const meshRange = "10.0.0.0/24"
// ws is the node every older test serves: granted every share and wanting it.
const wsAddress = "10.0.0.3"
// config is a config granting every share to ws; serve writes the roster and ws's wants for those shares.
func config(shares string) string {
parsed, _ := ParseShares(shares)
var grants []string
for _, s := range parsed {
grants = append(grants, s.Name+"=ws:rw")
}
g := strings.Join(grants, " ")
if g == "" {
g = "none"
}
return "# test\nshares=" + shares + "\ngrants=" + g + "\nrange=" + meshRange + "\n"
}
// serve has ws want these shares of this machine, at the mesh's address in the roster.
func (f *fake) serve(names ...string) {
f.roster("ws " + wsAddress)
var w []Want
for _, n := range names {
w = append(w, Want{Share: n, Access: "rw"})
}
f.wants(map[string][]Want{"ws": w})
}
func TestAnExportLineSquashesEveryClientToTheFoldersOwnerToTheMeshRangeOnly(t *testing.T) {
f := newFake(t)
f.dir("/srv/library")
f.dir("/srv/downloads")
f.file(ConfigFile, config("library=/srv/library downloads=/srv/downloads:ro"))
f.serve("library", "downloads")
f.kernelReads()
s := f.machine().Pass(context.Background(), nil)
if len(s.Shares) != 2 || len(s.Refused) != 0 {
t.Fatalf("status: %+v", s)
}
uid, gid := 1001, 2000 // the owner the tests' folders stand for (asTheLibrarysOwner)
text := f.read(ExportFile)
for _, want := range []string{
fmt.Sprintf("/srv/library %s(rw,sync,secure,no_subtree_check,all_squash,anonuid=%d,anongid=%d)", wsAddress, uid, gid),
fmt.Sprintf("/srv/downloads %s(ro,sync,secure,no_subtree_check,all_squash,anonuid=%d,anongid=%d)", wsAddress, uid, gid),
} {
if !strings.Contains(text, want+"\n") {
t.Errorf("the export file lacks %q:\n%s", want, text)
}
}
for _, e := range ParseExports(text, ExportFile) {
if e.Client != wsAddress {
t.Errorf("a client other than the granted and wanting node: %+v", e)
}
if !strings.Contains(e.Options, "all_squash") || strings.Contains(e.Options, "no_root_squash") {
t.Errorf("every client is squashed: %+v", e)
}
}
if !s.Shares[0].Live || !s.Shares[1].Live {
t.Errorf("both shares are live after exportfs -ra: %+v", s.Shares)
}
if s.Reloaded == nil || s.ReloadWhy != "the export file changed" {
t.Errorf("the kernel read the file again because it changed: %+v", s)
}
// Rendering is stable: a second pass changes nothing and asks the kernel nothing.
f.ran = nil
f.machine().Pass(context.Background(), &s)
for _, r := range f.ran {
if strings.Contains(r, "exportfs") {
t.Errorf("a pass with nothing changed reloaded: %v", f.ran)
}
}
}
func TestTheRootABackupRepositoryAndAMissingFolderAreRefusedAndNothingIsCreated(t *testing.T) {
f := newFake(t)
f.restic("/srv/pool/restic")
f.dir("/srv/pool/media")
f.restic("/srv/backups")
f.file("/srv/a-file", "x")
f.file(ConfigFile, config("root=/ pool=/srv/pool backups=/srv/backups missing=/srv/nowhere file=/srv/a-file rel=srv/x media=/srv/pool/media"))
f.serve("root", "pool", "backups", "missing", "file", "rel", "media")
f.kernelReads()
s := f.machine().Pass(context.Background(), nil)
refused := map[string]string{}
for _, r := range s.Refused {
refused[r.Name] = r.Why
}
for name, word := range map[string]string{
"root": "root", "pool": "holds a backup repository", "backups": "is a backup repository",
"missing": "not there", "file": "not a folder", "rel": "not an absolute path",
} {
if !strings.Contains(refused[name], word) {
t.Errorf("%s refused with %q, want %q", name, refused[name], word)
}
}
if len(s.Shares) != 1 || s.Shares[0].Name != "media" {
t.Errorf("only the folder beside the backups is exported: %+v", s.Shares)
}
if strings.Contains(f.read(ExportFile), "/srv/pool ") || strings.Contains(f.read(ExportFile), "/srv/backups") {
t.Errorf("a refused folder is in the export file:\n%s", f.read(ExportFile))
}
if _, err := os.Stat(filepath.Join(f.root, "/srv/nowhere")); err == nil {
t.Errorf("the module created a missing folder")
}
h := f.machine().Health()
if h.Healthy || !strings.Contains(h.Why, "backups are never exported") {
t.Errorf("a refused share makes the module unhealthy, said: %+v", h)
}
}
func TestTheModuleWritesOnlyItsTwoFilesAndHasNoWayToChangeAnOwnerOrMode(t *testing.T) {
m := newFake(t).machine()
for _, p := range []string{"/srv/library/x", "/etc/exports", "/etc/exports.d/zfs.exports", ConfigFile} {
if err := m.Write(p, "x"); err == nil {
t.Errorf("Write accepted %s", p)
}
}
// No code path changes an owner or a mode, or removes anything: the words are not in the bundle.
files, _ := filepath.Glob("*.go")
for _, file := range files {
if strings.HasSuffix(file, "_test.go") {
continue
}
raw, _ := os.ReadFile(file)
text := string(raw)
// The one removal: unexport takes the module's own export file away on unassignment.
if file == "machine.go" {
start := strings.Index(text, "func (m *Machine) unexport() error {")
end := strings.Index(text[start:], "\n}\n")
body := text[start : start+end]
if strings.Count(body, "os.Remove(") != 1 || !strings.Contains(body, "os.Remove(m.at(ExportFile))") {
t.Errorf("unexport removes something other than the export file:\n%s", body)
}
text = text[:start] + text[start+end:]
}
for _, word := range []string{"Chown", "Lchown", "Chmod", "os.Remove", "RemoveAll", `"chown"`, `"chmod"`, `"rm"`, "Mkdir(", "MkdirAll(filepath.Join(m.Root"} {
if strings.Contains(text, word) {
t.Errorf("%s says %s", file, word)
}
}
}
}
func TestParseSharesAndConfig(t *testing.T) {
shares, problems := ParseShares("library=/srv/library, downloads=/srv/downloads:ro Bad=/x noequals library=/again")
if len(shares) != 2 || shares[0].Name != "downloads" || !shares[0].ReadOnly || shares[1].Access != "rw" {
t.Errorf("shares: %+v", shares)
}
if len(problems) != 3 {
t.Errorf("problems: %v", problems)
}
c, problems := ParseConfig("shares=\nrange=not-a-range\n")
if c.Range != "" || len(problems) != 1 {
t.Errorf("a range that is not one is said: %+v %v", c, problems)
}
_, problems = ParseConfig("shares=a=/x\n")
if len(problems) != 1 || !strings.Contains(problems[0], "no private network range") {
t.Errorf("no range is said: %v", problems)
}
}
func TestParseExportsReadsTheKernelsTableAndZFSFile(t *testing.T) {
etab := "/srv/library\t192.0.2.0/24(rw,sync,wdelay,root_squash,sec=sys)\n/srv/x\n# comment\n/srv/y a(ro) b(rw)\n"
got := ParseExports(etab, Etab)
if len(got) != 4 || got[0].Client != "192.0.2.0/24" || got[1].Client != "*" || got[3].Client != "b" || got[3].Options != "rw" {
t.Errorf("%+v", got)
}
}
func zfsWith(f *fake, sharenfs string) {
f.answers["zfs list -H -o name,mountpoint,sharenfs -t filesystem"] = Ran{Out: "pool\t/srv/pool\toff\npool/library\t/srv/library\t" + sharenfs + "\n"}
}
func TestAdoptIsADryRunWithoutConfirmAndRefusesWhileTheMeshExportIsNotLive(t *testing.T) {
f := newFake(t)
f.uid = 1000
f.dir("/srv/library")
zfsWith(f, "rw=@192.0.2.0/24")
f.file(ConfigFile, config("library=/srv/library"))
f.serve("library")
// The kernel has not read the mesh's file: no exportfs effect.
f.machine().Pass(context.Background(), nil)
if _, err := f.machine().Adopt("/srv/library", true, "take it over"); err == nil || !strings.Contains(err.Error(), "not live") {
t.Fatalf("adopt while not live: %v", err)
}
f.kernelReads()
f.machine().Pass(context.Background(), nil)
f.ran = nil
dry, err := f.machine().Adopt("/srv/library", false, "")
if err != nil || dry.Done || !dry.DryRun || !strings.Contains(dry.Said, "would set sharenfs=off on the dataset pool/library") {
t.Fatalf("dry run: %+v %v", dry, err)
}
for _, r := range f.ran {
if strings.Contains(r, "zfs set") {
t.Fatalf("a dry run changed something: %v", f.ran)
}
}
if _, err := f.machine().Adopt("/srv/library", true, ""); err == nil {
t.Errorf("adopt with confirm and no why was done")
}
done, err := f.machine().Adopt("/srv/library", true, "the mesh exports it now")
if err != nil || !done.Done {
t.Fatalf("adopt: %+v %v", done, err)
}
var set []string
for _, r := range f.ran {
if strings.Contains(r, "zfs set") {
set = append(set, r)
}
}
if len(set) != 1 || set[0] != "sudo -n zfs set sharenfs=off pool/library" {
t.Errorf("exactly one property of exactly one dataset, through sudo: %v", set)
}
zfsWith(f, "off")
if _, err := f.machine().Adopt("/srv/library", true, "again"); err == nil || !strings.Contains(err.Error(), "already has sharenfs off") {
t.Errorf("adopt of a dataset already off: %v", err)
}
if _, err := f.machine().Adopt("/srv/elsewhere", false, ""); err == nil {
t.Errorf("adopt of a folder no dataset is mounted at")
}
}
func TestAdoptRefusesAFolderTheMeshDoesNotExport(t *testing.T) {
f := newFake(t)
f.dir("/srv/library")
zfsWith(f, "on")
f.file(ConfigFile, config(""))
f.kernelReads()
f.machine().Pass(context.Background(), nil)
if _, err := f.machine().Adopt("/srv/library", false, ""); err == nil || !strings.Contains(err.Error(), "does not export") {
t.Errorf("%v", err)
}
}
func TestHealthSaysAStoppedServerAStaleProcessAndAShareNotInTheTable(t *testing.T) {
f := newFake(t)
f.dir("/srv/library")
f.file(ConfigFile, config("library=/srv/library"))
f.serve("library")
f.kernelReads()
f.machine().Pass(context.Background(), nil)
if h := f.machine().Health(); !h.Healthy || !strings.Contains(h.Why, "exports library to ws (10.0.0.3, rw)") {
t.Errorf("healthy: %+v", h)
}
f.file(Etab, "")
f.answers["systemctl is-active nfs-server.service"] = Ran{Out: "failed\n", Code: 3}
f.now = f.now.Add(10 * time.Minute)
h := f.machine().Health()
for _, want := range []string{"is failed", "last looked 10m0s ago", "The share library (/srv/library) is not in the kernel's export table."} {
if h.Healthy || !strings.Contains(h.Why, want) {
t.Errorf("health lacks %q: %+v", want, h)
}
}
if h := newFake(t).machine().Health(); h.Healthy || !strings.Contains(h.Why, "written no status") {
t.Errorf("no status is unhealthy: %+v", h)
}
}
func TestExportsAnswersTheSharesAndWhatIsNotTheMeshs(t *testing.T) {
f := newFake(t)
f.dir("/srv/library")
f.file(ConfigFile, config("library=/srv/library"))
f.file("/etc/exports.d/zfs.exports", "# !!! DO NOT EDIT\n/srv/library 192.0.2.0/24(sec=sys,rw,no_subtree_check)\n")
f.file("/etc/exports", "# only comments\n")
f.serve("library")
zfsWith(f, "rw=@192.0.2.0/24")
f.kernelReads()
f.machine().Pass(context.Background(), nil)
a := f.machine().Exports()
raw, _ := json.Marshal(a)
var shape struct {
Shares []struct {
Name, Path, Access, Clients string
UID, GID int
Live bool
}
Foreign []map[string]any
Service string
}
if err := json.Unmarshal(raw, &shape); err != nil {
t.Fatal(err)
}
if len(shape.Shares) != 1 || shape.Shares[0].Name != "library" || !shape.Shares[0].Live || shape.Shares[0].Clients != wsAddress || shape.Service != "active" {
t.Errorf("%s", raw)
}
if len(shape.Foreign) != 2 {
t.Errorf("the ZFS file's line and the dataset's property are not the mesh's: %s", raw)
}
for _, key := range []string{`"shares"`, `"foreign"`, `"service"`, `"uid"`, `"gid"`, `"live"`, `"clients"`, `"access"`} {
if !strings.Contains(string(raw), key) {
t.Errorf("the answer lacks %s, which the mounts module reads", key)
}
}
}
func TestTestSaysWhetherAShareIsServedAndWhetherAnAddressIsInRange(t *testing.T) {
f := newFake(t)
f.dir("/srv/library")
f.file(ConfigFile, config("library=/srv/library"))
f.serve("library")
f.kernelReads()
f.machine().Pass(context.Background(), nil)
ok, _ := f.machine().Test("library", "10.0.0.3")
if !ok.OK || ok.InRange == nil || !*ok.InRange {
t.Errorf("%+v", ok)
}
out, _ := f.machine().Test("library", "192.0.2.7")
if out.OK || !strings.Contains(out.Why, "not inside the private network's range") {
t.Errorf("%+v", out)
}
other, _ := f.machine().Test("library", "10.0.0.9")
if other.OK || !strings.Contains(other.Why, "is not exported to 10.0.0.9") {
t.Errorf("an address in the range the share is not exported to: %+v", other)
}
none, _ := f.machine().Test("photos", "")
if none.OK || !strings.Contains(none.Why, "no share named photos") {
t.Errorf("%+v", none)
}
}
func TestClientsReadTheKernelsListOrFallBackToConnections(t *testing.T) {
f := newFake(t)
f.file("/proc/fs/nfsd/clients/7/info", "clientid: 0xabc\naddress: \"10.0.0.3:785\"\nname: \"Linux NFSv4.2 workstation\"\nminor version: 2\n")
a := f.machine().Clients()
if len(a.Clients) != 1 || a.Clients[0].Address != "10.0.0.3:785" || a.Clients[0].Minor != "2" {
t.Errorf("%+v", a)
}
g := newFake(t)
g.answers["ss -tnH state established ( sport = :2049 )"] = Ran{Out: "0 0 10.0.0.2:2049 10.0.0.3:785\n"}
b := g.machine().Clients()
if len(b.Clients) != 1 || b.Clients[0].Address != "10.0.0.3:785" || b.From != "connections to port 2049" {
t.Errorf("%+v", b)
}
}
func TestAnActNeedingRootGoesThroughSudoOnlyWhenNotRoot(t *testing.T) {
f := newFake(t)
f.uid = 1000
f.machine().AsRoot("exportfs", "-ra")
f.uid = 0
f.machine().AsRoot("exportfs", "-ra")
if len(f.ran) != 2 || f.ran[0] != "sudo -n exportfs -ra" || f.ran[1] != "exportfs -ra" {
t.Errorf("%v", f.ran)
}
f.answers["sudo -n zfs set sharenfs=off x"] = Ran{Code: 1, Err: "sudo: a password is required"}
f.uid = 1000
if _, err := f.machine().AsRoot("zfs", "set", "sharenfs=off", "x"); err == nil || !strings.Contains(err.Error(), "sudo refused") {
t.Errorf("%v", err)
}
}
// asTheLibrarysOwner is how the tests see the folders they make: owned by the library's owner (1001:2000),
// not by whoever runs them. The build seat runs the suite as root, where every temporary folder is root's
// and the refusal of a root-owned share would fire first; on a person's machine they are that person's.
// A folder the test runner does not own keeps its real owner, so a real root-owned folder is still root's.
func asTheLibrarysOwner(info os.FileInfo) (uid, gid int) {
uid, gid = inodeOwner(info)
if uid == os.Getuid() {
uid = 1001
}
if gid == os.Getgid() {
gid = 2000
}
return uid, gid
}