Files
mesh-catalog/modules/restic/cmd/restic-backups/data_test.go
T

198 lines
7.3 KiB
Go

package main
// The data the modules declare, measured, and a retired item deleted only after a last restore point
// (novox/hq ADR 0233) — over a fake restic and a fake machine.
import (
"context"
"errors"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
const composedData = "# The data the modules on this machine declare, composed by the mesh. Do not edit.\n" +
"# postgres\nitem store irreplaceable /var/lib/mesh-store /var/lib/mesh-store/dumps\nitem dumps rebuildable /var/lib/mesh-store/dumps -\n" +
"# searxng\nitem valkey cache /var/lib/searxng/valkey-data -\n"
func withData(t *testing.T, declared, data string) Where {
t.Helper()
w := placed(t, declared)
w.Data = filepath.Join(w.State, "data.conf")
must(t, os.WriteFile(w.Data, []byte(data), 0o600))
return w
}
func TestTheComposedDataFileIsReadIntoEachModulesItems(t *testing.T) {
b := &Backups{Where: withData(t, composed, composedData), Now: time.Now, Say: quiet}
got, err := b.Items()
must(t, err)
if len(got["postgres"]) != 2 || got["postgres"][0].CoveredBy != "/var/lib/mesh-store/dumps" || got["postgres"][1].CoveredBy != "" ||
got["searxng"][0].Class != "cache" {
t.Fatalf("%+v", got)
}
// An older controller composes no data file: nothing is declared, nothing fails.
b = &Backups{Where: placed(t, composed), Now: time.Now, Say: quiet}
if items, err := b.Items(); err != nil || len(items) != 0 {
t.Fatalf("%v, %v", items, err)
}
}
// `backed-up` says every item with disk-load's newest reading and its peak, and the newest good night of the module that
// keeps the path covering it; an item with no reading yet is said with none.
func TestEveryItemIsSaidWithItsReadingAndItsLastGoodBackup(t *testing.T) {
where := withData(t, composed, composedData)
size, peak := int64(1<<30), int64(2<<30)
at := time.Date(2026, 10, 6, 4, 0, 0, 0, time.UTC)
write := time.Unix(1759744800, 0).UTC()
night := time.Date(2026, 10, 6, 3, 0, 0, 0, time.UTC)
b := &Backups{Where: where, Now: func() time.Time { return night.Add(time.Hour) }, Say: quiet,
Run: func(context.Context, string, ...string) (string, error) { return "[]", nil },
Readings: func() ([]health.Reading, error) {
return []health.Reading{{V: 1, Node: "ace", PlacedDataItem: catalogue.PlacedDataItem{Module: "postgres", ID: "store"},
Measured: health.Measured{SizeBytes: &size, LastWrite: &write, MeasuredAt: &at, Precision: "exact"}, Peak: &peak, PeakAt: "2026-10-05"}}, nil
}}
b.recordGood("postgres", night)
got, err := b.BackedUp(context.Background(), "")
must(t, err)
var store, dumps *ItemReport
for _, m := range got {
for i := range m.Data {
switch m.Data[i].ID {
case "store":
store = &m.Data[i]
case "dumps":
dumps = &m.Data[i]
}
}
}
if store == nil || store.SizeBytes == nil || *store.SizeBytes != 1<<30 || store.Peak == nil || *store.Peak != 2<<30 ||
store.LastWrite == nil || store.LastWrite.Unix() != 1759744800 || store.LastBackup == nil || !store.LastBackup.Equal(night) {
t.Fatalf("the store is said as %+v", store)
}
if dumps == nil || dumps.LastBackup != nil || dumps.SizeBytes != nil {
t.Fatalf("an item with no reading or no backup is said with one: %+v", dumps)
}
// The same last good night is kept on the bus beside good.json, one row per item a backup path covers.
b.Node = "ace"
wrote := map[string]health.GoodRow{}
b.PutGood = func(k string, r health.GoodRow) error { wrote[k] = r; return nil }
b.recordGood("postgres", night) // a good night writes the rows, once; the same night again writes nothing
n := len(wrote)
b.recordGood("postgres", night)
row, ok := wrote[health.GoodKey("ace", "postgres", "store")]
if len(wrote) != 1 || n != 1 || !ok || !row.LastGood.Equal(night) || row.CoveredBy != "/var/lib/mesh-store/dumps" || row.V != health.GoodVersion {
t.Fatalf("the good rows are %+v", wrote)
}
}
// A retired item is deleted only after a last restore point of it, tagged as retired; a restore point
// that fails deletes nothing; and nothing declared now — itself, inside it, or holding it — is deleted.
func TestARetiredItemIsDeletedOnlyAfterALastRestorePoint(t *testing.T) {
where := withData(t, composed, composedData)
var mu sync.Mutex
var calls []string
failBackup := false
run := func(_ context.Context, name string, args ...string) (string, error) {
mu.Lock()
defer mu.Unlock()
line := name + " " + strings.Join(args, " ")
if name == "restic" {
line = "restic " + strings.Join(args[5:], " ")
}
calls = append(calls, line)
switch {
case name == "test":
return "", nil
case strings.HasPrefix(line, "restic backup"):
if failBackup {
return "", errors.New("the repository is locked")
}
return "{\"message_type\":\"summary\",\"snapshot_id\":\"0123456789abcdef\"}\n", nil
}
return "", nil
}
b := &Backups{Where: where, Run: run, Now: time.Now, Say: quiet}
ctx := context.Background()
for _, refused := range []struct{ path, want string }{
{"/var/lib/mesh-store", "declared now"},
{"/var/lib/mesh-store/dumps/old", "declared now"},
{"/var/lib", "declared now"},
{"/var/lib/mailu/data-mail", "backed up now"},
{"/srv", "too near the root"},
{"relative/x", "not a path"},
} {
if _, err := b.DeleteRetired(ctx, "m", "i", refused.path, "i", "op", "tidy"); err == nil || !strings.Contains(err.Error(), refused.want) {
t.Errorf("%s: %v, want %q", refused.path, err, refused.want)
}
}
if _, err := b.DeleteRetired(ctx, "house", "config", "/var/lib/house/config", "nope", "op", "tidy"); err == nil {
t.Error("a deletion without the item's name again was started")
}
if _, err := b.DeleteRetired(ctx, "house", "config", "/var/lib/house/config", "config", "op", ""); err == nil {
t.Error("a deletion without why was started")
}
wait := func(path string) Deletion {
t.Helper()
for i := 0; i < 200; i++ {
if d, err := b.DeletedOutcome(path); err == nil && d.Done {
return d
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("the deletion of %s never finished", path)
return Deletion{}
}
mu.Lock()
failBackup = true
mu.Unlock()
_, err := b.DeleteRetired(ctx, "house", "config", "/var/lib/house/config", "config", "op", "moved to the anchor")
must(t, err)
if d := wait("/var/lib/house/config"); d.OK || !strings.Contains(d.Error, "nothing was deleted") {
t.Fatalf("a deletion with no restore point: %+v", d)
}
mu.Lock()
for _, c := range calls {
if strings.HasPrefix(c, "rm ") {
t.Fatal("it deleted without a last restore point")
}
}
mu.Unlock()
mu.Lock()
failBackup, calls = false, nil
mu.Unlock()
_, err = b.DeleteRetired(ctx, "house", "config", "/var/lib/house/config", "config", "op", "moved to the anchor")
must(t, err)
d := wait("/var/lib/house/config")
if !d.OK || d.Snapshot != "01234567" {
t.Fatalf("%+v", d)
}
var backup, rm = -1, -1
mu.Lock()
defer mu.Unlock()
for i, c := range calls {
switch {
case c == "restic backup --json --tag module=house --tag retired=config /var/lib/house/config":
backup = i
case c == "rm -rf --one-file-system -- /var/lib/house/config":
rm = i
}
}
if backup < 0 || rm < 0 || rm < backup {
t.Fatalf("ran %v", calls)
}
// Asked again, it answers how it went rather than starting over.
again, err := b.DeleteRetired(ctx, "house", "config", "/var/lib/house/config", "config", "op", "moved")
if err != nil || !again.Done || !again.OK {
t.Fatalf("%+v, %v", again, err)
}
}