Go is the default for new module code; the holder is one binary like the licence manager, serving the seat's verbs over the SDK and running the nights beside them. Same behaviour and tests.
229 lines
8.6 KiB
Go
229 lines
8.6 KiB
Go
package main
|
|
|
|
// The machine's backups over a fake restic and fake stores (novox/hq ADR 0214, to-be 43), and — where
|
|
// restic is installed — over the real one, on throwaway directories.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const composed = "# What the modules on this machine back up, composed by the mesh. Do not edit.\n" +
|
|
"# postgres\nrun docker exec -u postgres postgres sh -c 'pg-dump-all'\npath /var/lib/mesh-store/dumps\n" +
|
|
"# mailu\npath /var/lib/mailu/data-mail\npath /var/lib/mailu/data-dkim\n"
|
|
|
|
func placed(t *testing.T, declared string) Where {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
must(t, os.WriteFile(filepath.Join(dir, "backups.conf"), []byte(declared), 0o600))
|
|
must(t, os.WriteFile(filepath.Join(dir, "pw"), []byte("secret\n"), 0o600))
|
|
return Where{Declared: filepath.Join(dir, "backups.conf"), Repository: filepath.Join(dir, "repo"),
|
|
PasswordFile: filepath.Join(dir, "pw"), State: dir}
|
|
}
|
|
|
|
func must(t *testing.T, err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func quiet(string, ...any) {}
|
|
|
|
func TestTheComposedFileIsReadIntoEachModulesRunsAndPaths(t *testing.T) {
|
|
got, err := parseDeclared(composed)
|
|
must(t, err)
|
|
want := []Declared{
|
|
{Module: "postgres", Runs: []string{"docker exec -u postgres postgres sh -c 'pg-dump-all'"}, Paths: []string{"/var/lib/mesh-store/dumps"}},
|
|
{Module: "mailu", Paths: []string{"/var/lib/mailu/data-mail", "/var/lib/mailu/data-dkim"}},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("read %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestALineThisHolderDoesNotReadIsRefusedNamingTheModule(t *testing.T) {
|
|
for _, bad := range []string{"# pg\ncopy /x\n", "# pg\npath relative/dir\n"} {
|
|
if _, err := parseDeclared(bad); err == nil || !strings.Contains(err.Error(), "pg contributes a backup line") {
|
|
t.Errorf("%q: %v", bad, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResticAndTheDumpsRunThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
|
|
if p, a := escalated(1000, "restic", []string{"snapshots"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "restic", "snapshots"}) {
|
|
t.Errorf("as an account: %s %v", p, a)
|
|
}
|
|
if p, a := escalated(0, "restic", []string{"snapshots"}); p != "restic" || !reflect.DeepEqual(a, []string{"snapshots"}) {
|
|
t.Errorf("as root: %s %v", p, a)
|
|
}
|
|
}
|
|
|
|
func TestANightDumpsBeforeEachSnapshotAndOneFailingModuleFailsOnlyItself(t *testing.T) {
|
|
where := placed(t, "# pg\nrun dump-it\npath /\n# broken\nrun fail-it\npath /\n# mail\npath /\n")
|
|
var calls []string
|
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
|
line := name + " " + strings.Join(args, " ")
|
|
if name == "restic" {
|
|
line = "restic " + strings.Join(args[5:], " ")
|
|
}
|
|
calls = append(calls, line)
|
|
switch {
|
|
case line == "sh -c fail-it":
|
|
return "", errors.New("the dump failed")
|
|
case strings.HasPrefix(line, "restic backup"):
|
|
return "{\"message_type\":\"status\"}\n{\"message_type\":\"summary\",\"snapshot_id\":\"abcdef0123456789\"}\n", nil
|
|
}
|
|
return "", nil
|
|
}
|
|
b := &Backups{Where: where, Run: run, Now: func() time.Time { return time.Date(2026, 10, 6, 3, 0, 0, 0, time.UTC) }, Say: quiet}
|
|
outcome, err := b.BackUp(context.Background(), "")
|
|
must(t, err)
|
|
if !outcome["pg"].OK || outcome["pg"].Snapshot != "abcdef01" {
|
|
t.Errorf("pg: %+v", outcome["pg"])
|
|
}
|
|
if outcome["broken"].OK || !strings.Contains(outcome["broken"].Error, "the dump failed") {
|
|
t.Errorf("broken: %+v", outcome["broken"])
|
|
}
|
|
if !outcome["mail"].OK {
|
|
t.Errorf("mail failed with broken: %+v", outcome["mail"])
|
|
}
|
|
want := []string{
|
|
"restic cat config",
|
|
"sh -c dump-it",
|
|
"restic backup --json --tag module=pg /",
|
|
"sh -c fail-it",
|
|
"restic backup --json --tag module=mail /",
|
|
"restic forget --prune --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 6",
|
|
}
|
|
if !reflect.DeepEqual(calls, want) {
|
|
t.Errorf("ran\n%s\nwant\n%s", strings.Join(calls, "\n"), strings.Join(want, "\n"))
|
|
}
|
|
// Recorded, so `backed-up` and the missed-night check read it.
|
|
var nights map[string]Night
|
|
raw, err := os.ReadFile(filepath.Join(where.State, "nights.json"))
|
|
must(t, err)
|
|
must(t, json.Unmarshal(raw, &nights))
|
|
if nights["broken"].OK || !nights["mail"].OK {
|
|
t.Errorf("recorded %+v", nights)
|
|
}
|
|
}
|
|
|
|
func TestARepositoryThatWillNotOpenIsNeverReplaced(t *testing.T) {
|
|
var calls []string
|
|
run := func(_ context.Context, _ string, args ...string) (string, error) {
|
|
calls = append(calls, strings.Join(args[5:], " "))
|
|
if args[5] == "cat" {
|
|
return "", errors.New("Fatal: wrong password or no key found")
|
|
}
|
|
return "", nil
|
|
}
|
|
b := &Backups{Where: placed(t, "# pg\npath /\n"), Run: run, Now: time.Now, Say: quiet}
|
|
if _, err := b.BackUp(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "cannot be opened, and is left as it is") {
|
|
t.Fatalf("got %v", err)
|
|
}
|
|
for _, c := range calls {
|
|
if c == "init" {
|
|
t.Fatal("it made a new repository over one it could not open")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestADeclaredDirectoryThatDoesNotExistFailsThatModulesNight(t *testing.T) {
|
|
b := &Backups{Where: placed(t, "# pg\npath /nowhere/at/all\n"), Run: func(context.Context, string, ...string) (string, error) { return "", nil },
|
|
Now: time.Now, Say: quiet}
|
|
outcome, err := b.BackUp(context.Background(), "")
|
|
must(t, err)
|
|
if outcome["pg"].OK || !strings.Contains(outcome["pg"].Error, "/nowhere/at/all does not exist") {
|
|
t.Fatalf("pg: %+v", outcome["pg"])
|
|
}
|
|
}
|
|
|
|
func TestANightIsDueAtTheHourAndAMissedOneIsNoticed(t *testing.T) {
|
|
morning := time.Date(2026, 10, 6, 1, 30, 0, 0, time.Local)
|
|
if got := nextNight(morning, 3); !got.Equal(time.Date(2026, 10, 6, 3, 0, 0, 0, time.Local)) {
|
|
t.Errorf("from the morning: %v", got)
|
|
}
|
|
afternoon := time.Date(2026, 10, 6, 15, 0, 0, 0, time.Local)
|
|
if got := nextNight(afternoon, 3); !got.Equal(time.Date(2026, 10, 7, 3, 0, 0, 0, time.Local)) {
|
|
t.Errorf("from the afternoon: %v", got)
|
|
}
|
|
at := func(day int, ok bool) map[string]Night {
|
|
return map[string]Night{"pg": {OK: ok, At: time.Date(2026, 10, day, 3, 5, 0, 0, time.Local)}}
|
|
}
|
|
for _, c := range []struct {
|
|
nights map[string]Night
|
|
missed bool
|
|
}{{map[string]Night{}, true}, {at(6, true), false}, {at(4, true), true}, {at(6, false), true}} {
|
|
if got := missedANight(c.nights, afternoon); got != c.missed {
|
|
t.Errorf("%+v: missed %v", c.nights, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The real thing, where restic is installed: a dump, a snapshot, a mistake, and a restore beside.
|
|
func TestWithTheRealResticAMistakeIsUndoneBesideTheLiveData(t *testing.T) {
|
|
if _, err := exec.LookPath("restic"); err != nil {
|
|
t.Skip("restic is not installed")
|
|
}
|
|
root := t.TempDir()
|
|
store, dumps := filepath.Join(root, "store"), filepath.Join(root, "dumps")
|
|
must(t, os.Mkdir(store, 0o700))
|
|
must(t, os.Mkdir(dumps, 0o700))
|
|
must(t, os.WriteFile(filepath.Join(store, "mailbox"), []byte("the only copy of a letter\n"), 0o600))
|
|
where := placed(t, "# mail\npath "+store+"\n# pg\nrun echo 'every row' > "+dumps+"/all.dump\npath "+dumps+"\n")
|
|
// As whoever runs the test, against its own repository: no sudo.
|
|
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
out, err := exec.CommandContext(ctx, name, args...).Output()
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
return string(out), errors.New(string(ee.Stderr))
|
|
}
|
|
return string(out), err
|
|
}
|
|
b := &Backups{Where: where, Run: run, Now: func() time.Time { return time.Date(2026, 10, 6, 3, 0, 0, 0, time.UTC) }, Say: quiet}
|
|
ctx := context.Background()
|
|
|
|
night, err := b.BackUp(ctx, "")
|
|
must(t, err)
|
|
if !night["mail"].OK || !night["pg"].OK {
|
|
t.Fatalf("the night: %+v", night)
|
|
}
|
|
if raw, _ := os.ReadFile(filepath.Join(dumps, "all.dump")); string(raw) != "every row\n" {
|
|
t.Fatalf("the dump: %q", raw)
|
|
}
|
|
|
|
// The mistake.
|
|
must(t, os.Remove(filepath.Join(store, "mailbox")))
|
|
|
|
listed, err := b.BackedUp(ctx, "")
|
|
must(t, err)
|
|
if len(listed) != 2 || listed[0].RestorePoints != 1 || listed[1].RestorePoints != 1 {
|
|
t.Fatalf("listed %+v", listed)
|
|
}
|
|
|
|
restored, err := b.Restore(ctx, "mail", "", "")
|
|
must(t, err)
|
|
if len(restored.Restored) != 1 || !strings.HasSuffix(restored.Restored[0], "store.restored-20261006-030000") {
|
|
t.Fatalf("restored %+v", restored)
|
|
}
|
|
if raw, _ := os.ReadFile(filepath.Join(restored.Restored[0], "mailbox")); string(raw) != "the only copy of a letter\n" {
|
|
t.Fatalf("the restored letter: %q", raw)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(store, "mailbox")); err == nil {
|
|
t.Fatal("the restore wrote into the live directory")
|
|
}
|
|
|
|
// Never over anything: the same restore again finds its target taken.
|
|
if _, err := b.Restore(ctx, "mail", "", ""); err == nil || !strings.Contains(err.Error(), "nothing is restored over anything") {
|
|
t.Fatalf("a second restore: %v", err)
|
|
}
|
|
}
|