Files
mesh-catalog/modules/nfs-server/cmd/nfs-server/backup_test.go
T

192 lines
8.3 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"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) ([]diskreadings.Reading, error) {
return nil, errors.New("no responders")
}, noHolders)
f.backup("restic", "/storage/restic")
m.TakeBackupWith("ace", func(string) ([]diskreadings.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) ([]diskreadings.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) ([]diskreadings.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) []diskreadings.Reading {
var out []diskreadings.Reading
for k, p := range items {
module, item, _ := strings.Cut(k, "/")
out = append(out, diskreadings.Reading{V: 1, Node: node, PlacedDataItem: catalogue.PlacedDataItem{Module: module, ID: item, Path: p}})
}
return out
}