563 lines
24 KiB
Go
563 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The secret every test that has one uses; it must never appear in anything the module says.
|
|
const testPassword = "s3cr3t-Pa55"
|
|
|
|
// fakeSystem is a machine under a temporary root with a recorded service manager.
|
|
type fakeSystem struct {
|
|
t *testing.T
|
|
root string
|
|
calls []string
|
|
// units are the service manager's answers: ActiveState and UnitFileState by unit.
|
|
units map[string]map[string]string
|
|
// reachable are the host:port pairs that answer.
|
|
reachable map[string]bool
|
|
}
|
|
|
|
func newSystem(t *testing.T) *fakeSystem {
|
|
f := &fakeSystem{t: t, root: t.TempDir(), units: map[string]map[string]string{}, reachable: map[string]bool{}}
|
|
for _, d := range []string{UnitDir, RunDir, BusDir, "/etc/mesh-mounts", "/proc/self", "/var/lib/mesh/mounts"} {
|
|
if err := os.MkdirAll(filepath.Join(f.root, d), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
f.write("/proc/self/mountinfo", "")
|
|
// The mesh keeps no data on this machine, as the controller said through the bundle.
|
|
f.write(DataFile, `{"machine":"workstation","paths":[]}`)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeSystem) write(p, content string) {
|
|
f.t.Helper()
|
|
full := filepath.Join(f.root, p)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (f *fakeSystem) read(p string) string {
|
|
b, _ := os.ReadFile(filepath.Join(f.root, p))
|
|
return string(b)
|
|
}
|
|
|
|
func (f *fakeSystem) run(_ context.Context, name string, args ...string) (string, error) {
|
|
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
|
|
if name != "systemctl" || len(args) == 0 {
|
|
return "", nil
|
|
}
|
|
switch args[0] {
|
|
case "show":
|
|
if len(args) >= 5 && args[3] == "--value" {
|
|
return f.units[args[4]]["ActiveState"] + "\n", nil
|
|
}
|
|
var blocks []string
|
|
for _, u := range args {
|
|
if !strings.HasSuffix(u, ".mount") && !strings.HasSuffix(u, ".automount") {
|
|
continue
|
|
}
|
|
s := f.units[u]
|
|
blocks = append(blocks, "Id="+u+"\nActiveState="+orDefault(s["ActiveState"], "inactive")+"\nSubState=dead\nResult="+
|
|
orDefault(s["Result"], "success")+"\nUnitFileState="+orDefault(s["UnitFileState"], "disabled"))
|
|
}
|
|
return strings.Join(blocks, "\n\n") + "\n", nil
|
|
case "enable":
|
|
u := args[len(args)-1]
|
|
f.units[u] = map[string]string{"ActiveState": "active", "UnitFileState": "enabled"}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (f *fakeSystem) machine() *Machine {
|
|
return &Machine{Root: f.root, Run: f.run, Escalate: f.run, LookPath: func(string) bool { return true }}
|
|
}
|
|
|
|
func (f *fakeSystem) applier(now time.Time) *Applier {
|
|
return &Applier{M: f.machine(), Act: true, Judge: true,
|
|
Dial: func(addr string, _ time.Duration) error {
|
|
if f.reachable[addr] {
|
|
return nil
|
|
}
|
|
return errors.New("dial tcp " + addr + ": connect: no route to host")
|
|
},
|
|
Now: func() time.Time { return now },
|
|
Lookup: func(string) (int, int, error) { return 1000, 1000, nil },
|
|
}
|
|
}
|
|
|
|
func (f *fakeSystem) called(prefix string) bool {
|
|
for _, c := range f.calls {
|
|
if strings.HasPrefix(c, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// everything is every file under the root, for a search that a secret appears nowhere it should not.
|
|
func (f *fakeSystem) everything(except ...string) string {
|
|
var b strings.Builder
|
|
filepath.Walk(f.root, func(p string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
for _, e := range except {
|
|
if strings.HasSuffix(p, e) {
|
|
return nil
|
|
}
|
|
}
|
|
raw, _ := os.ReadFile(p)
|
|
b.Write(raw)
|
|
return nil
|
|
})
|
|
return b.String()
|
|
}
|
|
|
|
const config = "shares=library=/srv/library downloads=/srv/downloads:ro\n" +
|
|
"sources=games=smb://192.0.2.76/Share@/mnt/games usb=disk:1234-ABCD@/mnt/usb\n" +
|
|
"server=homeserver\nat=192.0.2.10\nnode=workstation\naccount=operator\n" +
|
|
"secret=/var/lib/mesh/mounts/smb-credentials.secret\nstate=/var/lib/mesh/mounts\n"
|
|
|
|
func exportsAnswer(t *testing.T, shares ...ServerShare) string {
|
|
raw, err := json.Marshal(ExportsAnswer{Server: "homeserver", At: "192.0.2.10", Shares: shares})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
var (
|
|
library = ServerShare{Name: "library", Path: "/tank/media", Access: "rw", UID: 1001, GID: 2000, Live: true, To: []ShareClient{{Node: "workstation", Address: "10.0.0.3", Access: "rw"}}}
|
|
downloads = ServerShare{Name: "downloads", Path: "/tank/downloads", Access: "rw", UID: 1001, GID: 2000, Live: true, To: []ShareClient{{Node: "workstation", Address: "10.0.0.3", Access: "rw"}}}
|
|
)
|
|
|
|
func TestTheSettingsAreReadAndWhatCannotBeIsSaidPlainly(t *testing.T) {
|
|
shares, problems := ParseShares("library=/srv/library downloads=/srv/downloads:ro,bad Upper=/x other=relative library=/srv/again root=/")
|
|
if len(shares) != 2 || shares[0] != (Wanted{"library", "/srv/library", false}) || shares[1] != (Wanted{"downloads", "/srv/downloads", true}) {
|
|
t.Fatalf("shares %+v", shares)
|
|
}
|
|
want := []string{`"bad" is not name=mountpoint`, `"Upper" is not lower-case`, `"relative" is not an absolute path`, "named twice", `"/" is the system's own directory`}
|
|
for i, w := range want {
|
|
if i >= len(problems) || !strings.Contains(problems[i], w) {
|
|
t.Errorf("problem %d: want %q in %q", i, w, problems)
|
|
}
|
|
}
|
|
if s, p := ParseShares("none"); len(s) != 0 || len(p) != 0 {
|
|
t.Errorf("none is nothing: %v %v", s, p)
|
|
}
|
|
sources, problems := ParseSources("games=smb://192.0.2.76/Share@/mnt/games:ro usb=disk:1234-ABCD@/mnt/usb ftp=ftp://x/y@/mnt/x nomount=smb://h/s")
|
|
if len(sources) != 2 || sources[0].Kind != "smb" || sources[0].Host != "192.0.2.76" || sources[0].Share != "Share" || !sources[0].ReadOnly ||
|
|
sources[1].Kind != "disk" || sources[1].UUID != "1234-ABCD" {
|
|
t.Fatalf("sources %+v", sources)
|
|
}
|
|
if len(problems) != 2 || !strings.Contains(problems[0], "neither smb://") || !strings.Contains(problems[1], "names no mount point") {
|
|
t.Errorf("problems %q", problems)
|
|
}
|
|
if err := CheckMountpoint("/etc/x"); err == nil {
|
|
t.Error("a mount point under /etc is the system's")
|
|
}
|
|
}
|
|
|
|
func TestAMountPointIsNamedAsSystemdEscapeNamesIt(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"/srv/library": "srv-library",
|
|
"/storage/media": "storage-media",
|
|
"/mnt/my-disk": `mnt-my\x2ddisk`,
|
|
"/mnt/.hidden": `mnt-.hidden`,
|
|
"/.x": `\x2ex`,
|
|
"/srv/a b": `srv-a\x20b`,
|
|
"/dev/disk/by-uuid/1234-ABCD": `dev-disk-by\x2duuid-1234\x2dABCD`,
|
|
} {
|
|
if got := EscapePath(in); got != want {
|
|
t.Errorf("EscapePath(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestANodesShareIsMountedHardWhenWritableAndSoftWhenNot(t *testing.T) {
|
|
rw := NFSPair(Wanted{Name: "library", Mountpoint: "/srv/library"}, "192.0.2.10", library)
|
|
for _, want := range []string{"What=192.0.2.10:/tank/media", "Where=/srv/library", "Type=nfs4",
|
|
"Options=vers=4.2,hard,timeo=150,retrans=3,_netdev", "TimeoutSec=30", "After=network-online.target"} {
|
|
if !strings.Contains(rw.Mount, want) {
|
|
t.Errorf("the read-write mount lacks %q:\n%s", want, rw.Mount)
|
|
}
|
|
}
|
|
if !strings.HasPrefix(rw.Mount, Marker) || !strings.HasPrefix(rw.Automount, Marker) {
|
|
t.Error("both units open with the module's marker")
|
|
}
|
|
if strings.Contains(rw.Mount, "[Install]") {
|
|
t.Error("the mount is never enabled: only the automount is")
|
|
}
|
|
for _, want := range []string{"[Automount]\nWhere=/srv/library\nTimeoutIdleSec=600", "WantedBy=remote-fs.target"} {
|
|
if !strings.Contains(rw.Automount, want) {
|
|
t.Errorf("the automount lacks %q:\n%s", want, rw.Automount)
|
|
}
|
|
}
|
|
if rw.MountUnit != "srv-library.mount" || rw.AutoUnit != "srv-library.automount" || rw.Probe != "192.0.2.10:2049" {
|
|
t.Errorf("names %+v", rw)
|
|
}
|
|
ro := NFSPair(Wanted{Name: "downloads", Mountpoint: "/srv/downloads", ReadOnly: true}, "192.0.2.10", downloads)
|
|
if !strings.Contains(ro.Mount, "Options=ro,vers=4.2,soft,") {
|
|
t.Errorf("a read-only share is mounted ro and soft:\n%s", ro.Mount)
|
|
}
|
|
served := library
|
|
served.Access = "ro"
|
|
if p := NFSPair(Wanted{Name: "library", Mountpoint: "/srv/library"}, "192.0.2.10", served); !strings.Contains(p.Mount, "Options=ro,") {
|
|
t.Error("a share the server exports read-only is mounted read-only")
|
|
}
|
|
}
|
|
|
|
func TestAnOccasionalSourceWaitsTenSecondsAndNeverFailsABoot(t *testing.T) {
|
|
smb := SMBPair(Source{Name: "games", Kind: "smb", Host: "192.0.2.76", Share: "Share", Mountpoint: "/mnt/games"}, "/var/lib/mesh/mounts/smb-games.credentials", 1000, 1000)
|
|
for _, want := range []string{"What=//192.0.2.76/Share", "Type=cifs", "credentials=/var/lib/mesh/mounts/smb-games.credentials", "nofail", "uid=1000,gid=1000", "TimeoutSec=10"} {
|
|
if !strings.Contains(smb.Mount, want) {
|
|
t.Errorf("the SMB mount lacks %q:\n%s", want, smb.Mount)
|
|
}
|
|
}
|
|
if !smb.Occasional || smb.Probe != "192.0.2.76:445" || strings.Contains(smb.Mount, "password") {
|
|
t.Errorf("smb %+v", smb)
|
|
}
|
|
disk := DiskPair(Source{Name: "usb", Kind: "disk", UUID: "1234-ABCD", Mountpoint: "/mnt/usb"})
|
|
dev := `dev-disk-by\x2duuid-1234\x2dABCD.device`
|
|
if !strings.Contains(disk.Mount, "BindsTo="+dev) || !strings.Contains(disk.Mount, "What=/dev/disk/by-uuid/1234-ABCD") {
|
|
t.Errorf("the disk's mount goes with the disk:\n%s", disk.Mount)
|
|
}
|
|
if strings.Contains(disk.Automount, "BindsTo") || !strings.Contains(disk.Automount, "WantedBy=local-fs.target") {
|
|
t.Errorf("the disk's automount stays armed when the disk is away:\n%s", disk.Automount)
|
|
}
|
|
}
|
|
|
|
func TestALookWritesArmsAndRemovesOnlyTheModulesOwnUnits(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, config)
|
|
f.write(ExportsFile, exportsAnswer(t, library, downloads))
|
|
f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[games]\nusername=root\npassword="+testPassword+"\n")
|
|
// The operator's own unit for the downloads' mount point: never written over.
|
|
f.write(filepath.Join(UnitDir, "srv-downloads.mount"), "[Mount]\nWhat=elsewhere\n")
|
|
// A pair of the module's whose mount point nothing names any more.
|
|
f.write(filepath.Join(UnitDir, "srv-old.mount"), Marker+"\n[Mount]\n")
|
|
f.write(filepath.Join(UnitDir, "srv-old.automount"), Marker+"\n[Automount]\n")
|
|
f.reachable["192.0.2.10:2049"] = true
|
|
now := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC)
|
|
|
|
st := f.applier(now).Look(context.Background())
|
|
|
|
if got := f.read(filepath.Join(UnitDir, "srv-library.mount")); !strings.Contains(got, "What=192.0.2.10:/tank/media") {
|
|
t.Errorf("the library's mount: %q", got)
|
|
}
|
|
if got := f.read(filepath.Join(UnitDir, "srv-downloads.mount")); got != "[Mount]\nWhat=elsewhere\n" {
|
|
t.Errorf("a unit file that is not the module's is left byte for byte: %q", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-old.mount")); !os.IsNotExist(err) {
|
|
t.Error("the module's pair nothing names any more is removed")
|
|
}
|
|
for _, c := range []string{"systemctl disable --now srv-old.automount", "systemctl stop srv-old.mount", "systemctl daemon-reload",
|
|
"systemctl enable --now srv-library.automount", "systemctl enable --now mnt-games.automount", "systemctl enable --now mnt-usb.automount"} {
|
|
if !f.called(c) {
|
|
t.Errorf("not called: %s (calls %q)", c, f.calls)
|
|
}
|
|
}
|
|
if f.called("systemctl enable --now srv-downloads.automount") {
|
|
t.Error("the refused share is not armed")
|
|
}
|
|
if info, err := os.Stat(filepath.Join(f.root, "/srv/library")); err != nil || !info.IsDir() {
|
|
t.Error("a missing mount point is made")
|
|
}
|
|
creds := filepath.Join(f.root, "/var/lib/mesh/mounts/smb-games.credentials")
|
|
if info, err := os.Stat(creds); err != nil || info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("the source's login is a file only root reads: %v", err)
|
|
}
|
|
if b, _ := os.ReadFile(creds); string(b) != "username=root\npassword="+testPassword+"\n" {
|
|
t.Errorf("credentials file %q", b)
|
|
}
|
|
|
|
states := map[string]ShareStatus{}
|
|
for _, s := range st.Shares {
|
|
states[s.Name] = s
|
|
}
|
|
if states["library"].State != StateArmed || states["games"].State != StateAbsent || states["usb"].State != StateAbsent {
|
|
t.Errorf("states %+v", states)
|
|
}
|
|
if d := states["downloads"]; d.State != StateFailed || !strings.Contains(d.Why, "not the module's") {
|
|
t.Errorf("downloads %+v", d)
|
|
}
|
|
if !strings.Contains(states["games"].Why, "not present") || states["games"].Since != now.Format(time.RFC3339) {
|
|
t.Errorf("games %+v", states["games"])
|
|
}
|
|
// The password is in the secret and in the credentials file, and nowhere else.
|
|
if strings.Contains(f.everything("smb-credentials.secret", "smb-games.credentials"), testPassword) {
|
|
t.Error("the password leaked into a file the module wrote")
|
|
}
|
|
raw, _ := json.Marshal(f.machine().Health(now))
|
|
if strings.Contains(string(raw), testPassword) {
|
|
t.Error("the password leaked into the health")
|
|
}
|
|
|
|
// A second look a minute later changes nothing and keeps since.
|
|
f.calls = nil
|
|
later := now.Add(time.Minute)
|
|
st = f.applier(later).Look(context.Background())
|
|
if f.called("systemctl daemon-reload") || f.called("systemctl enable") {
|
|
t.Errorf("a look with nothing to change changes nothing: %q", f.calls)
|
|
}
|
|
for _, s := range st.Shares {
|
|
if s.Since != now.Format(time.RFC3339) {
|
|
t.Errorf("%s since %s, want the first look's", s.Name, s.Since)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestASilentServerRemovesNoMount(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n")
|
|
f.write(filepath.Join(UnitDir, "srv-library.mount"), Marker+"\nkept\n")
|
|
f.write(filepath.Join(UnitDir, "srv-library.automount"), Marker+"\nkept\n")
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
if f.read(filepath.Join(UnitDir, "srv-library.mount")) != Marker+"\nkept\n" {
|
|
t.Error("with no answer from the server the pair stays as it is")
|
|
}
|
|
if len(st.Shares) != 1 || st.Shares[0].State != StateUnreachable || !strings.Contains(st.Shares[0].Why, "has not been read from the bus yet") {
|
|
t.Errorf("shares %+v", st.Shares)
|
|
}
|
|
// And a missing config file writes and removes nothing.
|
|
g := newSystem(t)
|
|
g.write(filepath.Join(UnitDir, "srv-x.mount"), Marker+"\n")
|
|
st = g.applier(time.Now()).Look(context.Background())
|
|
if _, err := os.Stat(filepath.Join(g.root, UnitDir, "srv-x.mount")); err != nil || len(st.Problems) == 0 {
|
|
t.Errorf("without its config the process removes nothing and says why: %v %v", err, st.Problems)
|
|
}
|
|
}
|
|
|
|
func TestAMissingLoginIsSaidWithItsRemedyAndTheSourceIsNotMounted(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, "sources=games=smb://192.0.2.76/Share@/mnt/games\nsecret=/var/lib/mesh/mounts/smb-credentials.secret\nstate=/var/lib/mesh/mounts\n")
|
|
f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[other]\nusername=x\npassword="+testPassword+"\n")
|
|
now := time.Now()
|
|
st := f.applier(now).Look(context.Background())
|
|
if len(st.Shares) != 1 || st.Shares[0].State != StateFailed ||
|
|
!strings.Contains(st.Shares[0].Why, "secret accept <node> mounts smb-credentials --from <file>") {
|
|
t.Fatalf("shares %+v", st.Shares)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "mnt-games.mount")); !os.IsNotExist(err) {
|
|
t.Error("a source without its login gets no units")
|
|
}
|
|
h := f.machine().Health(now)
|
|
if h.Healthy || !strings.Contains(h.Why, "login is not given") || strings.Contains(h.Why, testPassword) {
|
|
t.Errorf("health %+v", h)
|
|
}
|
|
creds, problems := ParseSMBCredentials("[a]\nusername=u\npassword=" + testPassword + "\nbogus=1\n[b]\npassword=p\n")
|
|
if creds["a"].Password != testPassword || len(problems) != 2 || strings.Contains(strings.Join(problems, " "), testPassword) {
|
|
t.Errorf("creds %v problems %q", len(creds), problems)
|
|
}
|
|
}
|
|
|
|
func TestTheHealthGivesAShareFiveMinutesAndAnAbsentSourceNone(t *testing.T) {
|
|
now := time.Date(2026, 10, 8, 19, 30, 0, 0, time.Local)
|
|
at := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) }
|
|
st := Status{Looked: at(10 * time.Second), Server: "homeserver", Shares: []ShareStatus{
|
|
{Name: "library", Kind: "nfs", Mountpoint: "/srv/library", State: StateUnreachable, Since: at(4 * time.Minute), Why: "192.0.2.10:2049 does not answer"},
|
|
{Name: "games", Kind: "smb", Occasional: true, Mountpoint: "/mnt/games", State: StateAbsent, Since: at(3 * time.Hour)},
|
|
}}
|
|
if h := Judge(st, nil, now); !h.Healthy || !strings.Contains(strings.Join(h.Said, " "), "games at /mnt/games: not present since 16:30") {
|
|
t.Errorf("under five minutes, and an absent source, are healthy: %+v", h)
|
|
}
|
|
st.Shares[0].Since = at(9 * time.Minute)
|
|
h := Judge(st, nil, now)
|
|
if h.Healthy || !strings.Contains(h.Why, "The share library of homeserver is not reachable from this machine since 19:21") {
|
|
t.Errorf("five minutes away raises: %+v", h)
|
|
}
|
|
st.Shares = []ShareStatus{{Name: "library", Kind: "nfs", Mountpoint: "/srv/library", State: StateFailed, Since: at(time.Minute),
|
|
Why: "its automount srv-library.automount is not armed (inactive)"}}
|
|
if h := Judge(st, nil, now); h.Healthy {
|
|
t.Error("an automount not armed is raised at once")
|
|
}
|
|
st.Looked = at(3 * time.Minute)
|
|
st.Shares = nil
|
|
if h := Judge(st, nil, now); h.Healthy || !strings.Contains(h.Why, "last looked") {
|
|
t.Errorf("a stale look is raised: %+v", h)
|
|
}
|
|
if h := Judge(Status{}, errors.New("not present"), now); h.Healthy {
|
|
t.Error("no status is unhealthy")
|
|
}
|
|
if h := Judge(Status{Looked: at(time.Second)}, nil, now); !h.Healthy || h.Why != "This machine is asked to mount nothing." {
|
|
t.Errorf("nothing asked: %+v", h)
|
|
}
|
|
}
|
|
|
|
// The workstation's two lines as they were found (research 038), in documentation addresses.
|
|
const operatorsFstab = "UUID=ad8b / ext4 rw,relatime 0 1\n" +
|
|
"192.0.2.10:/tank/media /srv/library nfs defaults,_netdev 0 0\n" +
|
|
"//192.0.2.76/Share /mnt/games cifs username=root,password=" + testPassword + ",iocharset=utf8,sec=ntlmssp 0 0\n"
|
|
|
|
func TestAnFstabLineIsTakenOverOnlyOnceItsReplacementIsArmed(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.write("/etc/fstab", operatorsFstab)
|
|
m := f.machine()
|
|
now := time.Date(2026, 10, 8, 20, 0, 0, 0, time.UTC)
|
|
ctx := context.Background()
|
|
|
|
a, err := m.Adopt(ctx, "/mnt/games", false, "", now)
|
|
if err != nil || a.Done || !strings.HasPrefix(a.Said, "Would be refused now: the module has no unit pair") {
|
|
t.Fatalf("no pair yet: %+v %v", a, err)
|
|
}
|
|
if strings.Contains(a.Was, testPassword) || !strings.Contains(a.Was, "password=(redacted)") {
|
|
t.Errorf("the dry run says the line without its password: %q", a.Was)
|
|
}
|
|
if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil {
|
|
t.Fatal("the root half refuses too")
|
|
}
|
|
if f.read("/etc/fstab") != operatorsFstab {
|
|
t.Fatal("a refusal changes nothing")
|
|
}
|
|
|
|
f.write(filepath.Join(UnitDir, "mnt-games.mount"), Marker+"\n")
|
|
f.write(filepath.Join(UnitDir, "mnt-games.automount"), Marker+"\n")
|
|
f.units["mnt-games.automount"] = map[string]string{"ActiveState": "inactive"}
|
|
if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil || !strings.Contains(err.Error(), "not armed") {
|
|
t.Fatalf("an automount not armed refuses: %v", err)
|
|
}
|
|
f.units["mnt-games.automount"] = map[string]string{"ActiveState": "active"}
|
|
if a, err := m.Adopt(ctx, "/mnt/games", false, "", now); err != nil || !strings.HasPrefix(a.Said, "A dry run") || a.Line != 3 {
|
|
t.Fatalf("dry run: %+v %v", a, err)
|
|
}
|
|
if _, err := m.Adopt(ctx, "/mnt/games", true, "", now); err == nil {
|
|
t.Error("confirm without why is refused")
|
|
}
|
|
|
|
a, err = m.AdoptAsRoot(ctx, "/mnt/games", now)
|
|
if err != nil || !a.Done || a.Line != 3 || a.Copy != "/etc/fstab.mesh-adopted-20261008-200000" {
|
|
t.Fatalf("adopt: %+v %v", a, err)
|
|
}
|
|
got := f.read("/etc/fstab")
|
|
lines := strings.Split(got, "\n")
|
|
if lines[0] != "UUID=ad8b / ext4 rw,relatime 0 1" || lines[1] != "192.0.2.10:/tank/media /srv/library nfs defaults,_netdev 0 0" {
|
|
t.Errorf("every other line is kept byte for byte:\n%s", got)
|
|
}
|
|
if !strings.HasPrefix(lines[2], AdoptedPrefix+"2026-10-08: //192.0.2.76/Share") || strings.Contains(got, testPassword) {
|
|
t.Errorf("the line is commented out with its password redacted:\n%s", got)
|
|
}
|
|
info, err := os.Stat(filepath.Join(f.root, a.Copy))
|
|
if err != nil || info.Mode().Perm() != 0o600 || f.read(a.Copy) != operatorsFstab {
|
|
t.Errorf("the file as it was is kept, root only: %v", err)
|
|
}
|
|
if !f.called("systemctl daemon-reload") {
|
|
t.Error("the service manager reloads")
|
|
}
|
|
raw, _ := json.Marshal(a)
|
|
if strings.Contains(string(raw), testPassword) {
|
|
t.Error("the answer holds the password")
|
|
}
|
|
if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil || !strings.Contains(err.Error(), "no line for /mnt/games") {
|
|
t.Errorf("a line taken over is not taken twice: %v", err)
|
|
}
|
|
if _, _, err := PlanAdoption("a /x nfs d 0 0\nb /x nfs d 0 0\n", "/x", now); err == nil || !strings.Contains(err.Error(), "more than one line") {
|
|
t.Errorf("two lines for one mount point are left to a person: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAFailedAskKeepsTheLastAnswer(t *testing.T) {
|
|
cfg := ParseConfig("shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n")
|
|
now := time.Now()
|
|
var asked string
|
|
ok := func(key string) (json.RawMessage, error) {
|
|
asked = key
|
|
return json.RawMessage(`{"shares":[{"name":"library","path":"/tank/media","access":"rw","uid":1001,"gid":2000,"live":true}]}`), nil
|
|
}
|
|
first := AskExports(cfg, ExportsAnswer{}, ok, now)
|
|
if asked != "homeserver" || len(first.Shares) != 1 || first.Error != "" {
|
|
t.Fatalf("first %+v asked %q", first, asked)
|
|
}
|
|
fail := func(string) (json.RawMessage, error) { return nil, errors.New("no responders") }
|
|
second := AskExports(cfg, first, fail, now.Add(time.Minute))
|
|
empty := AskExports(cfg, first, func(string) (json.RawMessage, error) { return nil, nil }, now)
|
|
if !strings.Contains(empty.Error, "keeps nothing") || len(empty.Shares) != 1 {
|
|
t.Errorf("a machine with nothing kept yet: %+v", empty)
|
|
}
|
|
if second.Error != "no responders" || len(second.Shares) != 1 || second.Answered != first.Answered {
|
|
t.Errorf("second %+v", second)
|
|
}
|
|
if got := ParseConfig("at=homeserver.mesh.example\n").ServerNode(); got != "homeserver" {
|
|
t.Errorf("without from, the first label of at: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTheBundlePutsWhatTheProcessFoundOnTheBus(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n")
|
|
f.write(StatusFile, `{"looked":"2026-10-08T19:21:00Z","shares":[{"name":"library","state":"armed"}]}`)
|
|
var put []string
|
|
last := ""
|
|
ask := func(string) (json.RawMessage, error) { return json.RawMessage(`{"shares":[]}`), nil }
|
|
said := f.machine().Share("workstation", ask, func(key string, _ any) error { put = append(put, key); return nil }, &last, time.Now())
|
|
if len(said) != 0 || len(put) != 1 || put[0] != "workstation" || !strings.Contains(f.read(ExportsFile), `"server": "homeserver"`) {
|
|
t.Errorf("said %q put %q exports %q", said, put, f.read(ExportsFile))
|
|
}
|
|
f.machine().Share("workstation", ask, func(key string, _ any) error { put = append(put, key); return nil }, &last, time.Now())
|
|
if len(put) != 1 {
|
|
t.Error("an unchanged status is not put again")
|
|
}
|
|
}
|
|
|
|
// In production the acting process and the judging one are two: apply writes and arms and says nothing
|
|
// of how the shares are; watch writes and arms nothing and says how they are.
|
|
func TestApplyActsAndWatchJudgesAndNeitherDoesTheOthersWork(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, config)
|
|
f.write(ExportsFile, exportsAnswer(t, library, downloads))
|
|
f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[other]\nusername=x\npassword="+testPassword+"\n")
|
|
f.write(filepath.Join(UnitDir, "srv-downloads.mount"), "[Mount]\nWhat=elsewhere\n")
|
|
f.reachable["192.0.2.10:2049"] = true
|
|
now := time.Now()
|
|
|
|
apply := f.applier(now)
|
|
apply.Judge = false
|
|
apply.Look(context.Background())
|
|
if _, err := os.Stat(filepath.Join(f.root, StatusFile)); !os.IsNotExist(err) {
|
|
t.Error("apply leaves no status: that is watch's")
|
|
}
|
|
if !f.called("systemctl enable --now srv-library.automount") || f.read(filepath.Join(UnitDir, "srv-library.mount")) == "" {
|
|
t.Fatal("apply writes and arms")
|
|
}
|
|
applied, err := readJSON[Applied](f.machine(), AppliedFile)
|
|
if err != nil || len(applied.NotSet) != 2 {
|
|
t.Fatalf("apply says what it could not set up: %+v %v", applied, err)
|
|
}
|
|
|
|
f.calls = nil
|
|
watch := f.applier(now)
|
|
watch.Act = false
|
|
f.write(filepath.Join(UnitDir, "srv-gone.mount"), Marker+"\n")
|
|
st := watch.Look(context.Background())
|
|
for _, c := range f.calls {
|
|
if !strings.HasPrefix(c, "systemctl show") {
|
|
t.Errorf("watch only reads the service manager: %s", c)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-gone.mount")); err != nil {
|
|
t.Error("watch removes nothing")
|
|
}
|
|
states := map[string]string{}
|
|
for _, s := range st.Shares {
|
|
states[s.Name] = s.State
|
|
}
|
|
if states["library"] != StateArmed || states["downloads"] != StateFailed || states["games"] != StateFailed || states["usb"] != StateAbsent {
|
|
t.Errorf("watch says how each is: %v", states)
|
|
}
|
|
if strings.Contains(f.everything("smb-credentials.secret"), testPassword) {
|
|
t.Error("the password leaked")
|
|
}
|
|
}
|