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
205 lines
7.8 KiB
Go
205 lines
7.8 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\nhand=10\n"
|
|
got, _ := thinning(t, &conf)
|
|
if want := "restic forget --prune --group-by host,tags --keep-daily 7 --keep-weekly 4 --keep-monthly 24 --keep-tag hand"; 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"},
|
|
{"daily=14\nweekly=8\nmonthly=6\nhand=0\n", "keep-hand-taken-days"},
|
|
{"daily=14\nweekly=8\nmonthly=6\n", "hand= line is missing"},
|
|
} {
|
|
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, "keep-hand-taken-days": 7}
|
|
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)
|
|
}
|
|
}
|