Files
mesh-catalog/modules/restic/cmd/restic-backups/retention_test.go
T
jschoubben cbe9fa7a2c
mesh/merge-gate pass: builds restic → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
restic: raise a night that never happens, and have the mesh write the retention readable (novox/hq issue 454)
A hung night, a lock held for hours or a lock that cannot be taken left no
failed night behind, so it stayed healthy. The night's process now counts
how long the machine was up since its newest good night, by the monotonic
clock so a sleeping laptop is not blamed, and past 26 hours it is raised.

The retention file is written 0644 and root's instead of read through
sudo: the account reads it and still cannot change it. An unreadable file
names its cause, and the healthy answer says only what was seen.
2026-10-11 12:14:54 +02:00

203 lines
7.6 KiB
Go

package main
// The retention is a preference (novox/hq issue 416, ADR 0262): the mesh writes the keep-daily,
// keep-weekly and keep-monthly settings into a file in this module's state, and a night thins the
// restore points by what that file says. A value that is not a count thins nothing.
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
// thinning runs one night over a fake restic and answers the forget line it ran ("" when none) and
// what the module said.
func thinning(t *testing.T, retention *string) (string, []string) {
t.Helper()
where := placed(t, "# pg\npath /\n")
path := filepath.Join(where.State, RetentionFile)
if retention != nil {
must(t, os.WriteFile(path, []byte(*retention), 0o600))
} else {
must(t, os.Remove(path))
}
forget := ""
run := func(_ context.Context, name string, args ...string) (string, error) {
if name == "restic" && args[5] == "forget" {
forget = "restic " + strings.Join(args[5:], " ")
}
if name == "restic" && args[5] == "backup" {
return "{\"message_type\":\"summary\",\"snapshot_id\":\"abcdef0123456789\"}\n", nil
}
return "", nil
}
var said []string
say := func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }
b := &Backups{Where: where, Run: run, Now: func() time.Time { return time.Date(2026, 10, 11, 3, 0, 0, 0, time.UTC) }, Say: say}
outcome, err := b.BackUp(context.Background(), "")
must(t, err)
if !outcome["pg"].OK {
t.Fatalf("the night itself failed: %+v", outcome["pg"])
}
return forget, said
}
func TestANightThinsByTheRetentionTheMeshWrote(t *testing.T) {
conf := "# Written by the mesh.\ndaily=7\nweekly=4\nmonthly=24\n"
got, _ := thinning(t, &conf)
if want := "restic forget --prune --group-by host,tags --keep-daily 7 --keep-weekly 4 --keep-monthly 24"; got != want {
t.Errorf("ran %q, want %q", got, want)
}
}
// Every machine has had the file since mesh-catalog #200 rolled out (novox/hq issue 454), so a missing one is
// a fault, never a reason to thin by 14/8/6: a lost file would otherwise thin by a number nobody set.
func TestWithoutTheFileANightThinsNothingAndSaysTheFileIsMissing(t *testing.T) {
got, said := thinning(t, nil)
if got != "" {
t.Errorf("ran %q without a retention file", got)
}
all := strings.Join(said, "\n")
if !strings.Contains(all, "retention.conf is missing") || !strings.Contains(all, "every restore point is kept") {
t.Errorf("said %q, want it to say the file is missing and every restore point is kept", all)
}
}
// A file the account the night runs as may not read — the mode an older build of this module had the mesh
// write it in (0600, root's) — thins nothing, and says why (novox/hq issue 454).
func TestARetentionTheAccountCannotReadThinsNothingAndSaysWhy(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root reads a file of mode 0 itself")
}
where := placed(t, "# pg\npath /\n")
must(t, os.Chmod(filepath.Join(where.State, RetentionFile), 0))
forget := ""
var said []string
run := func(_ context.Context, name string, args ...string) (string, error) {
switch {
case name == "restic" && args[5] == "forget":
forget = "restic " + strings.Join(args[5:], " ")
case name == "restic" && args[5] == "backup":
return "{\"message_type\":\"summary\",\"snapshot_id\":\"abcdef0123456789\"}\n", nil
}
return "", nil
}
b := &Backups{Where: where, Run: run, Now: time.Now, Say: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }}
_, err := b.BackUp(context.Background(), "")
must(t, err)
if forget != "" {
t.Errorf("thinned by a file it could not read: %q", forget)
}
if all := strings.Join(said, "\n"); !strings.Contains(all, Unreadable) || !strings.Contains(all, "every restore point is kept") {
t.Errorf("said %q", all)
}
h := b.Health(context.Background())
if h.State != "unhealthy" || !strings.Contains(h.Why, Unreadable) || !strings.Contains(h.Why, "0644") {
t.Errorf("%s: %q, want it unhealthy naming the cause", h.State, h.Why)
}
}
// The mesh writes the file readable, and root's: the account reads it and cannot change it.
func TestTheManifestWritesTheRetentionReadable(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
must(t, err)
var m struct {
Resources []map[string]any `json:"resources"`
}
must(t, json.Unmarshal(raw, &m))
file, night := -1, -1
for i, r := range m.Resources {
switch r["id"] {
case "retention":
file = i
if r["mode"] != "0644" || r["owner"] != nil {
t.Errorf("the retention file is mode %v owner %v, want 0644 and root's", r["mode"], r["owner"])
}
case "night":
night = i
}
}
// Applied in declared order: the rollout that brings the night's process makes the file readable first.
if file < 0 || night < 0 || file > night {
t.Errorf("the retention file is declared at %d and the night at %d", file, night)
}
}
// A value that is not a count from 1 to 9999 is never handed to restic: nothing is thinned, every
// restore point is kept, and the module says which setting is wrong (issue 346: a raw setting is an
// injection point).
func TestARetentionThatIsNotACountThinsNothingAndSaysWhich(t *testing.T) {
for _, c := range []struct{ conf, names string }{
{"daily=0\nweekly=8\nmonthly=6\n", "keep-daily"},
{"daily=14\nweekly=-1\nmonthly=6\n", "keep-weekly"},
{"daily=14\nweekly=8\nmonthly=6 --keep-last 1\n", "keep-monthly"},
{"daily=14\nweekly=8\nmonthly=10000\n", "keep-monthly"},
{"daily=fourteen\nweekly=8\nmonthly=6\n", "keep-daily"},
{"daily=\nweekly=8\nmonthly=6\n", "keep-daily"},
{"daily=014\nweekly=8\nmonthly=6\n", "keep-daily"},
{"weekly=8\nmonthly=6\n", "keep-daily"},
{"daily=14\nweekly=8\nmonthly=6\nlast=1\n", "last"},
{"daily=14\ndaily=3\nweekly=8\nmonthly=6\n", "daily"},
{"daily 14\nweekly=8\nmonthly=6\n", "key=value"},
} {
got, said := thinning(t, &c.conf)
if got != "" {
t.Errorf("%q: ran %q", c.conf, got)
}
all := strings.Join(said, "\n")
if !strings.Contains(all, c.names) || !strings.Contains(all, "every restore point is kept") {
t.Errorf("%q: said %q, want it to name %s and say every restore point is kept", c.conf, all, c.names)
}
}
}
// The manifest declares the three preferences with today's values as their defaults, and its file,
// filled with those defaults, reads as today's retention: nothing changes on a machine that sets none.
func TestTheManifestsDefaultsAreTodaysRetention(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
must(t, err)
var m struct {
Settings map[string]struct {
Kind string `json:"kind"`
Default any `json:"default"`
Why string `json:"why"`
} `json:"settings"`
Resources []map[string]any `json:"resources"`
}
must(t, json.Unmarshal(raw, &m))
want := map[string]float64{"keep-daily": 14, "keep-weekly": 8, "keep-monthly": 6}
for k, v := range want {
s, ok := m.Settings[k]
if !ok || s.Kind != "preference" || s.Default != v || s.Why == "" {
t.Errorf("setting %s: %+v, want a preference defaulting to %v with why", k, s, v)
}
}
content := ""
for _, r := range m.Resources {
if r["id"] == "retention" {
if r["path"] != "${dir:state}/"+RetentionFile {
t.Errorf("the retention file is placed at %v, where this program does not read it", r["path"])
}
content, _ = r["content"].(string)
}
}
if content == "" {
t.Fatal("no retention resource in the manifest")
}
filled := regexp.MustCompile(`\$\{setting:([a-z-]+)\}`).ReplaceAllStringFunc(content, func(p string) string {
k := p[len("${setting:") : len(p)-1]
return fmt.Sprint(m.Settings[k].Default)
})
got, err := ParseRetention(filled)
must(t, err)
if got != TodaysRetention {
t.Errorf("the defaults read as %+v, today is %+v", got, TodaysRetention)
}
}