450 lines
16 KiB
Go
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
|
|
}
|