Files
mesh-catalog/modules/nfs-server/cmd/nfs-server/backup_test.go
T
jschoubben 35ff67bda0
mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
Design 47's last milestones: the keeper, self-check, healers, hand-acts, provisioner, vault, artifact sweep, disk readings, credential forms (issues 774, 1952, 1953, 1954, 1955)
One commit with exactly the tree of the reviewed head 35b4509c0a, on main 12c2574c9d, so two compiled programs added and removed on the branch leave no trace in history (issue 1577).
2026-10-30 02:21:51 +01:00

192 lines
8.2 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
"strings"
"testing"
"time"
)
// Follow-up to the review (point 4): the one-hour look below a share must not hide a backup repository
// that appears later. Where this machine's backup seat keeps its repository is read from the bus and every
// share is judged against it on every pass.
func (f *fake) backup(holder string, paths ...string) {
raw, _ := json.Marshal(BackupFromBus{Holder: holder, Paths: paths})
f.file(BackupFile, string(raw))
}
func TestAShareHoldingTheBackupSeatsRepositoryIsRefusedOnTheNextPass(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/pool/media")
f.dir("/srv/pool/restic")
f.file(ConfigFile, grantsConfig("pool=/srv/pool media=/srv/pool/media", "pool=ws:rw media=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "pool", Access: "rw"}, {Share: "media", Access: "rw"}}})
m := f.machine()
now := time.Date(2026, 10, 8, 20, 0, 0, 0, time.UTC)
m.Now = func() time.Time { return now }
// The repository is not there yet: the look below finds nothing and is kept for an hour.
if s := m.Pass(context.Background(), nil); refusedWhy(s, "pool") != "" {
t.Fatalf("the pool is refused before any repository exists: %+v", s.Refused)
}
// The backup seat starts keeping its repository there; a minute later is well inside LookKept.
f.backup("restic", "/srv/pool/restic", "/var/lib/restic")
now = now.Add(time.Minute)
s := m.Pass(context.Background(), nil)
if why := refusedWhy(s, "pool"); !strings.Contains(why, "/srv/pool/restic") || !strings.Contains(why, "restic") {
t.Errorf("a share holding the backup seat's repository is exported inside the kept look: %q", why)
}
if refusedWhy(s, "media") != "" {
t.Errorf("a share beside the repository is refused: %q", refusedWhy(s, "media"))
}
if strings.Contains(f.read(ExportFile), "/srv/pool ") {
t.Errorf("the export file still names the pool:\n%s", f.read(ExportFile))
}
}
func TestAShareInsideTheBackupSeatsRepositoryIsRefused(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/backups/data")
f.file(ConfigFile, grantsConfig("inside=/srv/backups/data", "inside=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "inside", Access: "rw"}}})
f.backup("restic", "/srv/backups")
s := f.machine().Pass(context.Background(), nil)
if !strings.Contains(refusedWhy(s, "inside"), "/srv/backups") {
t.Errorf("a share inside the backup seat's repository is not refused: %+v", s.Refused)
}
}
// Until a list has been read, what is exported stays exported — the bus being slow is no reason to take
// the library away — and the status says the list is not known.
func TestWithoutTheBackupListTheExportsStayAndItIsSaid(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
s := f.machine().Pass(context.Background(), nil)
if refusedWhy(s, "library") != "" || len(s.Shares) != 1 {
t.Fatalf("the library is not exported while the backup list is unknown: %+v", s)
}
if !strings.Contains(strings.Join(s.Warnings, "\n"), "backup seat") {
t.Errorf("the status does not warn that the backup seat's repository is not known: %v", s.Warnings)
}
}
func TestBackupFromTheSeatsAndTheData(t *testing.T) {
seats := backupHolders("ace", "restic")
data := readingsOfPaths("ace", map[string]string{"restic/repository": "/storage/restic", "restic/state": "/var/lib/restic", "plex/data": "/mnt/plex/data"})
holder, paths, err := BackupFrom("ace", seats, data)
if err != nil || holder != "restic" || strings.Join(paths, " ") != "/storage/restic /var/lib/restic" {
t.Errorf("got %q %v %v", holder, paths, err)
}
if _, _, err := BackupFrom("g14", seats, data); err == nil {
t.Errorf("a machine whose backup seat nobody holds is not said")
}
if _, _, err := BackupFrom("ace", seats, nil); err == nil {
t.Errorf("no readings read as an empty list")
}
}
// A failed ask keeps the last paths: the file is not emptied, only the error is added.
func TestAFailedAskKeepsTheLastBackupPaths(t *testing.T) {
f := newFake(t)
m := f.machine()
m.TakeBackupWith("ace", func(string) ([]health.Reading, error) {
return nil, errors.New("no responders")
}, noHolders)
f.backup("restic", "/storage/restic")
m.TakeBackupWith("ace", func(string) ([]health.Reading, error) {
return nil, errors.New("timeout")
}, func() (map[string][]byte, error) { return backupHolders("ace", "restic"), nil })
var b BackupFromBus
if err := json.Unmarshal([]byte(f.read(BackupFile)), &b); err != nil {
t.Fatal(err)
}
if strings.Join(b.Paths, " ") != "/storage/restic" || !strings.Contains(b.Error, "timeout") {
t.Errorf("a failed ask lost the last paths or did not say why: %+v", b)
}
}
// The review of #142: the backup seat's path is compared as the kernel resolves it. A repository the data
// list names through a symbolic link is still found under the share that really holds it.
func TestTheBackupPathIsComparedAfterItsSymlinks(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/pool/restic")
f.symlink("pool/restic", "/srv/backups") // /srv/backups -> /srv/pool/restic
f.file(ConfigFile, grantsConfig("pool=/srv/pool", "pool=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "pool", Access: "rw"}}})
f.backup("restic", "/srv/backups")
s := f.machine().Pass(context.Background(), nil)
if why := refusedWhy(s, "pool"); !strings.Contains(why, "/srv/pool/restic") {
t.Errorf("a share holding the backup seat's repository, named through a symlink, is not refused: %q", why)
}
}
// The review of #142: a list the tools could not refresh is said in the status and the health with its
// age, and a list not known yet is a warning in the health — neither is a plain "healthy".
func TestAStaleOrUnknownBackupListIsSaidWithItsAge(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
f.kernelReads()
m := f.machine()
now := time.Date(2026, 10, 8, 20, 0, 0, 0, time.UTC)
m.Now = func() time.Time { return now }
m.Pass(context.Background(), nil)
h := m.Health()
if !strings.Contains(h.Why, "Warning") || !strings.Contains(h.Why, "backup seat") {
t.Errorf("an unknown backup list is not a warning in the health: %+v", h)
}
ok := func(string) ([]health.Reading, error) {
return readingsOfPaths("hs", map[string]string{"restic/repository": "/srv/restic"}), nil
}
m.TakeBackupWith("hs", ok, func() (map[string][]byte, error) { return backupHolders("hs", "restic"), nil })
now = now.Add(2 * time.Hour)
m.TakeBackupWith("hs", func(string) ([]health.Reading, error) { return nil, errors.New("the bus did not answer") },
func() (map[string][]byte, error) { return backupHolders("hs", "restic"), nil })
s := m.Pass(context.Background(), nil)
said := strings.Join(append(append([]string{}, s.Said...), s.Warnings...), "\n")
if !strings.Contains(said, "2h0m0s") || !strings.Contains(said, "the bus did not answer") {
t.Errorf("the status does not say the backup list is 2 h old and why: %q", said)
}
h = m.Health()
if !strings.Contains(h.Why, "2h0m0s") {
t.Errorf("the health does not say the backup list's age: %+v", h)
}
}
// backupHolders is the placement seat's state with one holder of node-backup on one node.
func backupHolders(node, module string) map[string][]byte {
return map[string][]byte{"node-backup." + node: []byte(`{"v":1,"seat":"node-backup","scope":"node","node":"` + node +
`","holders":[{"module":"` + module + `","node":"` + node + `"}]}`)}
}
func noHolders() (map[string][]byte, error) { return nil, errors.New("the state is not read yet") }
// readingsOfPaths is disk-load's readings of one node's items, by "<module>/<item>", each at its path.
func readingsOfPaths(node string, items map[string]string) []health.Reading {
var out []health.Reading
for k, p := range items {
module, item, _ := strings.Cut(k, "/")
out = append(out, health.Reading{V: 1, Node: node, PlacedDataItem: catalogue.PlacedDataItem{Module: module, ID: item, Path: p}})
}
return out
}