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) } }