330 lines
14 KiB
Go
330 lines
14 KiB
Go
package main
|
|
|
|
// The review of 2026-10-08 (mesh-catalog #136): each finding reproduced by a test before it was fixed.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// dataAnswer is mesh-controller.data's answer for this machine, as the bundle leaves it for the process.
|
|
func (f *fakeSystem) dataPaths(paths ...string) {
|
|
f.t.Helper()
|
|
if paths == nil {
|
|
paths = []string{}
|
|
}
|
|
raw, _ := json.Marshal(DataPaths{Machine: "workstation", Asked: time.Now().Format(time.RFC3339), Paths: paths})
|
|
f.write(DataFile, string(raw))
|
|
}
|
|
|
|
func shareOf(st Status, name string) ShareStatus {
|
|
for _, s := range st.Shares {
|
|
if s.Name == name {
|
|
return s
|
|
}
|
|
}
|
|
return ShareStatus{}
|
|
}
|
|
|
|
const libraryOnly = "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\nnode=workstation\n"
|
|
|
|
// Finding 8: a setting entry that cannot be read removed the module's pair for a mount point it had named
|
|
// before, because only what parsed counted as named.
|
|
func TestAnUnreadableEntryRemovesNoMount(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.dataPaths()
|
|
f.write(ExportsFile, exportsAnswer(t, library))
|
|
f.write(filepath.Join(UnitDir, "srv-library.mount"), Marker+"\n[Mount]\nWhat=192.0.2.10:/tank/media\nWhere=/srv/library\n")
|
|
f.write(filepath.Join(UnitDir, "srv-library.automount"), Marker+"\n[Automount]\nWhere=/srv/library\n")
|
|
// The operator meant library=/srv/library and wrote it without its slash.
|
|
f.write(ConfigFile, "shares=library=srv/library\nserver=homeserver\nat=192.0.2.10\nnode=workstation\n")
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-library.mount")); err != nil {
|
|
t.Fatal("an entry that cannot be read removes no pair")
|
|
}
|
|
if f.called("systemctl stop srv-library.mount") || f.called("systemctl disable --now srv-library.automount") {
|
|
t.Errorf("nothing is stopped: %q", f.calls)
|
|
}
|
|
said := strings.Join(st.Problems, " ")
|
|
if !strings.Contains(said, "srv/library") || !strings.Contains(said, "nothing is removed") {
|
|
t.Errorf("the bad entry and what is kept are said: %q", st.Problems)
|
|
}
|
|
}
|
|
|
|
// Finding 4a: a mount point already mounted by something else, or a folder with files in it, was taken.
|
|
func TestAMountPointInUseOrNotEmptyIsRefused(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.dataPaths()
|
|
f.write(ConfigFile, "shares=library=/srv/library downloads=/srv/downloads\nserver=homeserver\nat=192.0.2.10\nnode=workstation\n")
|
|
f.write(ExportsFile, exportsAnswer(t, library, downloads))
|
|
// Something else is mounted at the library's mount point; the downloads' folder holds a file.
|
|
f.write("/proc/self/mountinfo", "36 1 8:2 / /srv/library rw,relatime shared:1 - ext4 /dev/sdb1 rw\n")
|
|
f.write("/srv/downloads/notes.txt", "the operator's")
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
for _, name := range []string{"library", "downloads"} {
|
|
s := shareOf(st, name)
|
|
if s.State != StateFailed {
|
|
t.Errorf("%s: %+v", name, s)
|
|
}
|
|
if f.called("systemctl enable --now srv-" + name + ".automount") {
|
|
t.Errorf("%s is not armed", name)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-"+name+".mount")); !os.IsNotExist(err) {
|
|
t.Errorf("%s: no unit is written", name)
|
|
}
|
|
}
|
|
if s := shareOf(st, "library"); !strings.Contains(s.Why, "/dev/sdb1") {
|
|
t.Errorf("the library says what is mounted there: %q", s.Why)
|
|
}
|
|
if s := shareOf(st, "downloads"); !strings.Contains(s.Why, "not empty") {
|
|
t.Errorf("the downloads say the folder is not empty: %q", s.Why)
|
|
}
|
|
if f.read("/srv/downloads/notes.txt") != "the operator's" {
|
|
t.Error("the operator's file is untouched")
|
|
}
|
|
}
|
|
|
|
// Its own share, mounted by its own unit, is not "in use by something else".
|
|
func TestTheModulesOwnMountIsNotRefused(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.dataPaths()
|
|
f.write(ConfigFile, libraryOnly)
|
|
f.write(ExportsFile, exportsAnswer(t, library))
|
|
f.write("/proc/self/mountinfo", "40 1 0:50 / /srv/library rw - autofs systemd-1 rw\n"+
|
|
"41 40 0:51 / /srv/library rw - nfs4 192.0.2.10:/tank/media rw\n")
|
|
f.write(filepath.Join(UnitDir, "srv-library.mount"), Marker+"\n")
|
|
f.write(filepath.Join(UnitDir, "srv-library.automount"), Marker+"\n")
|
|
f.reachable["192.0.2.10:2049"] = true
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
if s := shareOf(st, "library"); s.State != StateMounted {
|
|
t.Errorf("library %+v", s)
|
|
}
|
|
}
|
|
|
|
// Finding 4b: a share the machine serves itself was mounted onto itself.
|
|
func TestAShareThisMachineServesIsRefused(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.dataPaths()
|
|
f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\nnode=homeserver\n")
|
|
f.write(ExportsFile, exportsAnswer(t, library))
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
s := shareOf(st, "library")
|
|
if s.State != StateFailed || !strings.Contains(s.Why, "this machine shares it") {
|
|
t.Errorf("library %+v", s)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-library.mount")); !os.IsNotExist(err) {
|
|
t.Error("no unit is written")
|
|
}
|
|
}
|
|
|
|
// Finding 4c: removing a pair stopped its .mount even when what was mounted there was not its source.
|
|
func TestAMountThatIsNotItsOwnSourceIsNeverStopped(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.dataPaths()
|
|
f.write(ConfigFile, "shares=none\nnode=workstation\n")
|
|
f.write(filepath.Join(UnitDir, "srv-old.mount"), Marker+"\n[Mount]\nWhat=192.0.2.10:/tank/old\nWhere=/srv/old\nType=nfs4\n")
|
|
f.write(filepath.Join(UnitDir, "srv-old.automount"), Marker+"\n[Automount]\nWhere=/srv/old\n")
|
|
f.write("/proc/self/mountinfo", "36 1 8:2 / /srv/old rw - ext4 /dev/sdb1 rw\n")
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
if f.called("systemctl stop srv-old.mount") || f.called("systemctl disable --now srv-old.automount") {
|
|
t.Errorf("a mount that is not its own source is not stopped: %q", f.calls)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-old.mount")); err != nil {
|
|
t.Error("and its pair is kept")
|
|
}
|
|
if !strings.Contains(strings.Join(st.Problems, " "), "/dev/sdb1") {
|
|
t.Errorf("said: %q", st.Problems)
|
|
}
|
|
// The verb unmount refuses the same.
|
|
f.write(StatusFile, `{"shares":[{"name":"old","kind":"nfs","mountpoint":"/srv/old","what":"192.0.2.10:/tank/old","state":"mounted","mount_unit":"srv-old.mount","automount_unit":"srv-old.automount"}]}`)
|
|
f.calls = nil
|
|
_, err := callVerb(t, f.machine(), "unmount", map[string]any{"name": "old"})
|
|
if err == nil || !strings.Contains(err.Error(), "/dev/sdb1") || f.called("systemctl stop") {
|
|
t.Errorf("unmount refuses: %v %q", err, f.calls)
|
|
}
|
|
}
|
|
|
|
// Finding 4d: a mount point at, under or above data the mesh keeps on this machine was taken.
|
|
func TestAMountPointNearTheMeshsDataIsRefused(t *testing.T) {
|
|
cases := map[string]string{
|
|
"/mnt/plex": "above",
|
|
"/mnt/plex/data": "at",
|
|
"/mnt/plex/data/x": "under",
|
|
}
|
|
for point, how := range cases {
|
|
f := newSystem(t)
|
|
f.dataPaths("/mnt/plex/data", "/mnt/plex/config")
|
|
f.write(ConfigFile, "shares=library="+point+"\nserver=homeserver\nat=192.0.2.10\nnode=workstation\n")
|
|
f.write(ExportsFile, exportsAnswer(t, library))
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
s := shareOf(st, "library")
|
|
if s.State != StateFailed || !strings.Contains(s.Why, "/mnt/plex/data") || !strings.Contains(s.Why, how) {
|
|
t.Errorf("%s: %+v", point, s)
|
|
}
|
|
if f.called("systemctl enable") {
|
|
t.Errorf("%s is not armed", point)
|
|
}
|
|
}
|
|
}
|
|
|
|
// When what the mesh keeps here cannot be read, nothing new is set up; what exists stays.
|
|
func TestWithoutTheMeshsDataNothingNewIsSetUp(t *testing.T) {
|
|
f := newSystem(t)
|
|
os.Remove(filepath.Join(f.root, DataFile))
|
|
f.write(ConfigFile, libraryOnly)
|
|
f.write(ExportsFile, exportsAnswer(t, library))
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-library.mount")); !os.IsNotExist(err) {
|
|
t.Error("nothing new is written without the data list")
|
|
}
|
|
if s := shareOf(st, "library"); s.State != StateFailed || !strings.Contains(s.Why, "data") {
|
|
t.Errorf("library %+v", s)
|
|
}
|
|
// A pair that is already the module's stays and stays armed.
|
|
f.write(filepath.Join(UnitDir, "srv-library.mount"), Marker+"\n")
|
|
f.write(filepath.Join(UnitDir, "srv-library.automount"), Marker+"\n")
|
|
f.applier(time.Now()).Look(context.Background())
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-library.mount")); err != nil {
|
|
t.Error("an existing pair stays")
|
|
}
|
|
}
|
|
|
|
// The bundle asks the controller for this machine's data and leaves the paths for the process.
|
|
func TestTheBundleLeavesTheMeshsDataForTheProcess(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, libraryOnly)
|
|
answer := json.RawMessage(`{"answer":{"data":[{"machine":"workstation","module":"plex","item":"data","path":"/mnt/plex/data"}]},"ok":true}`)
|
|
paths, err := DataFrom(answer)
|
|
if err != nil || len(paths) != 1 || paths[0] != "/mnt/plex/data" {
|
|
t.Fatalf("paths %q %v", paths, err)
|
|
}
|
|
// The controller's verbs also answer as text in output.
|
|
paths, err = DataFrom(json.RawMessage(`{"ok":true,"output":"{\"data\":[{\"path\":\"/srv/x\"}]}"}`))
|
|
if err != nil || len(paths) != 1 || paths[0] != "/srv/x" {
|
|
t.Fatalf("paths from output %q %v", paths, err)
|
|
}
|
|
if _, err := DataFrom(json.RawMessage(`{"ok":false}`)); err == nil {
|
|
t.Error("an answer with no data list is an error, not an empty list")
|
|
}
|
|
}
|
|
|
|
// Finding 5: the server exports a share only to the nodes that want it, so the bundle says what this node
|
|
// wants of which server, also before the process has looked.
|
|
func TestTheBundleSaysWhatThisNodeWants(t *testing.T) {
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, "shares=library=/srv/library downloads=/srv/downloads:ro\nserver=homeserver\nat=192.0.2.10\nnode=workstation\n")
|
|
var put []any
|
|
last := ""
|
|
ask := func(string) (json.RawMessage, error) { return json.RawMessage(`{"shares":[]}`), nil }
|
|
f.machine().Share("workstation", ask, func(_ string, v any) error { put = append(put, v); return nil }, &last, time.Now())
|
|
if len(put) != 1 {
|
|
t.Fatalf("the wants are put before the process has looked: %d", len(put))
|
|
}
|
|
raw, _ := json.Marshal(put[0])
|
|
var got struct {
|
|
Wants []Want `json:"wants"`
|
|
}
|
|
json.Unmarshal(raw, &got)
|
|
want := []Want{{Server: "homeserver", Share: "downloads", Access: "ro"}, {Server: "homeserver", Share: "library", Access: "rw"}}
|
|
if len(got.Wants) != 2 || got.Wants[0] != want[0] || got.Wants[1] != want[1] {
|
|
t.Errorf("wants %s", raw)
|
|
}
|
|
}
|
|
|
|
// Finding 9: the seat passes confirm as the string "true".
|
|
func TestConfirmIsTrueAsABooleanOrAString(t *testing.T) {
|
|
for _, v := range []any{true, "true"} {
|
|
if !flag(map[string]any{"confirm": v}, "confirm") {
|
|
t.Errorf("%#v is a confirmation", v)
|
|
}
|
|
}
|
|
for _, v := range []any{false, "false", "yes", "1", nil} {
|
|
if flag(map[string]any{"confirm": v}, "confirm") {
|
|
t.Errorf("%#v is not a confirmation", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Low: a password hidden in user=name%pass, and one holding a comma written as two (",,").
|
|
func TestEveryWayAPasswordIsWrittenIsRedacted(t *testing.T) {
|
|
for _, opts := range []string{
|
|
"user=bob%" + testPassword + ",vers=3.0",
|
|
"username=bob%" + testPassword,
|
|
"password=" + testPassword + ",,tail,vers=3.0",
|
|
"password=,,," + testPassword,
|
|
"pass=" + testPassword,
|
|
} {
|
|
if got := RedactOptions(opts); strings.Contains(got, testPassword) || strings.Contains(got, "tail") {
|
|
t.Errorf("RedactOptions(%q) = %q", opts, got)
|
|
}
|
|
line := "//192.0.2.76/Share /mnt/games cifs " + opts + " 0 0"
|
|
if got := RedactLine(line); strings.Contains(got, testPassword) || strings.Contains(got, "tail") {
|
|
t.Errorf("RedactLine(%q) = %q", line, got)
|
|
}
|
|
}
|
|
if got := RedactOptions("user=bob,vers=3.0"); got != "user=bob,vers=3.0" {
|
|
t.Errorf("a user name alone is kept: %q", got)
|
|
}
|
|
}
|
|
|
|
func callVerb(t *testing.T, m *Machine, name string, args map[string]any) (any, error) {
|
|
t.Helper()
|
|
for _, tool := range Verbs(m) {
|
|
if tool.Name == Seat+"."+name {
|
|
return tool.Run(args)
|
|
}
|
|
}
|
|
t.Fatalf("no verb %s", name)
|
|
return nil, nil
|
|
}
|
|
|
|
// The bundle asks the controller, leaves the paths, and keeps the last ones when an ask fails.
|
|
func TestTheBundleKeepsTheLastDataWhenAnAskFails(t *testing.T) {
|
|
f := newSystem(t)
|
|
m := f.machine()
|
|
var asked []string
|
|
ok := func(key string, body any) (json.RawMessage, error) {
|
|
raw, _ := json.Marshal(body)
|
|
asked = append(asked, key+" "+string(raw))
|
|
return json.RawMessage(`{"answer":{"data":[{"path":"/mnt/plex/data"}]}}`), nil
|
|
}
|
|
if said := m.LeaveData("workstation", ok, time.Now()); len(said) != 0 {
|
|
t.Fatalf("said %q", said)
|
|
}
|
|
if len(asked) != 1 || asked[0] != `seat:mesh-controller.data {"machine":"workstation"}` {
|
|
t.Errorf("asked %q", asked)
|
|
}
|
|
failing := func(string, any) (json.RawMessage, error) { return nil, os.ErrDeadlineExceeded }
|
|
said := m.LeaveData("workstation", failing, time.Now())
|
|
d, err := readJSON[DataPaths](m, DataFile)
|
|
if err != nil || len(d.Paths) != 1 || d.Paths[0] != "/mnt/plex/data" || d.Error == "" || len(said) == 0 {
|
|
t.Errorf("a failed ask keeps the last paths and says why: %+v %v %q", d, err, said)
|
|
}
|
|
}
|
|
|
|
// The server decides each node's access (finding 5): a share it exports read-write may reach this node
|
|
// read-only, and a share it exports to other nodes only does not reach this one at all. The mount takes
|
|
// the access the server gave this node, never the share's own.
|
|
func TestTheServersAccessForThisNodeIsTheMountsAccess(t *testing.T) {
|
|
library := ServerShare{Name: "library", Path: "/srv/library", Access: "rw", Live: true,
|
|
To: []ShareClient{{Node: "ws", Address: "10.0.0.3", Access: "rw"}, {Node: "laptop", Address: "10.0.0.4", Access: "ro"}}}
|
|
got, ok := library.ForNode("laptop")
|
|
if !ok || got.Access != "ro" {
|
|
t.Errorf("the laptop, granted read-only, gets %q (exported to it: %v)", got.Access, ok)
|
|
}
|
|
if p := NFSPair(Wanted{Name: "library", Mountpoint: "/srv/library"}, "192.0.2.10", got); !strings.Contains(p.Mount, "Options=ro,") {
|
|
t.Errorf("a read-only grant mounted read-write:\n%s", p.Mount)
|
|
}
|
|
if got, ok := library.ForNode("ws"); !ok || got.Access != "rw" {
|
|
t.Errorf("the workstation, granted read-write, gets %q (%v)", got.Access, ok)
|
|
}
|
|
if _, ok := library.ForNode("control"); ok {
|
|
t.Errorf("a node the server does not export to is treated as exported to")
|
|
}
|
|
}
|