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") if retention != nil { must(t, os.WriteFile(filepath.Join(where.State, "retention.conf"), []byte(*retention), 0o600)) } 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) } } // A machine placed by a definition older than the file keeps today's rotation. func TestWithoutTheFileANightKeepsTodaysRetention(t *testing.T) { got, _ := thinning(t, nil) if want := "restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6"; got != want { t.Errorf("ran %q, want %q", got, want) } } // 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) } }