Files
hamlab/internal/storage/snapshots_test.go
T
Mitchell-JohnsonandClaude Opus 5 09b7558d44 Snapshot schedules are real cron jobs, written and owned by Ham Lab
A schedule is now a line in /etc/cron.d/hamlab-zfs, which Ham Lab owns and rewrites whole
whenever the schedules change; the line calls `hamlab zfs-snapshot <id>` as the account Ham
Lab runs as, reaching zfs through sudo exactly as the daemon does. The daemon's own ticker
is gone. Snapshots are backup-shaped and should not stop because a web console is down,
being upgraded, or uninstalled — and a cron line is greppable by anyone looking at the
machine the normal way, which a schedule hidden inside a daemon is not.

A cron expression is now the single description of when a schedule fires: the four common
shapes render to one, a custom schedule states it outright, and the next run is read back
with the same evaluator the Jobs tab uses on everyone else's lines. So the page, the file
and the prediction cannot disagree.

Schedules gain a custom cron expression and an optional snapshot name prefix. What a run
did moves out of the policy into zfs-runs/<id>.json beside the config, because the cron job
is a separate process and must never write state.json behind the daemon's back.

The detected external job can be adopted: its schedule, retention and dataset are read out
of the cron line into a new Ham Lab schedule, ready to check rather than retype.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:09:43 -07:00

136 lines
4.8 KiB
Go

package storage
import (
"strings"
"testing"
"time"
"hamlab/internal/cron"
)
func TestSnapTimeReadsZfSnapNames(t *testing.T) {
// the real thing off grid
taken, exp, ok := SnapTime("tank@2026-09-24_02.30.01--6w")
if !ok {
t.Fatal("zfSnap's own name must parse")
}
if taken.Format("2006-01-02 15:04:05") != "2026-09-24 02:30:01" {
t.Fatalf("taken = %v", taken)
}
if got := exp.Sub(taken); got != 6*7*24*time.Hour {
t.Fatalf("ttl = %v, want 6w", got)
}
// a labelled one from a policy with a prefix
if _, e, ok := SnapTime("tank/apps@nightly-2026-09-24_02.30.01--30d"); !ok || e.IsZero() {
t.Fatal("a prefixed name must parse and expire")
}
// no TTL: never expires
if _, e, ok := SnapTime("tank@2026-09-24_02.30.01"); !ok || !e.IsZero() {
t.Fatal("a name without a TTL must parse and never expire")
}
// things that are not ours
for _, n := range []string{"tank@before-upgrade", "tank@manual", "tank", "tank@2026-13-45_99.99.99--6w"} {
if _, _, ok := SnapTime(n); ok {
t.Errorf("%q must not parse as a scheduled snapshot", n)
}
}
}
func TestCronExprAndNextRun(t *testing.T) {
now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local)
for _, c := range []struct {
p Policy
want string
}{
{Policy{Every: "daily", At: "02:30"}, "30 2 * * *"},
{Policy{Every: "hourly", At: "00:30"}, "30 * * * *"},
{Policy{Every: "weekly", At: "04:00", Weekday: 0}, "0 4 * * 0"},
{Policy{Every: "monthly", At: "05:15", Day: 12}, "15 5 12 * *"},
{Policy{Every: "custom", Cron: "*/15 9-17 * * 1-5"}, "*/15 9-17 * * 1-5"},
} {
got, err := c.p.CronExpr()
if err != nil || got != c.want {
t.Errorf("CronExpr(%+v) = %q, %v; want %q", c.p.Every, got, err, c.want)
}
}
// the next run is read with the same evaluator the Jobs tab uses
daily := Policy{Every: "daily", At: "02:30"}
if n := daily.NextRun(now); n.Hour() != 2 || n.Minute() != 30 || n.Day() != 25 {
t.Fatalf("NextRun = %v, want 25th 02:30", n)
}
// a custom expression that does not parse is refused, not silently ignored
if _, err := (&Policy{Every: "custom", Cron: "not a schedule"}).CronExpr(); err == nil {
t.Fatal("a bad cron expression must be refused")
}
if _, err := (&Policy{Every: "custom", Cron: "@reboot"}).CronExpr(); err == nil {
t.Fatal("@reboot is not a snapshot schedule")
}
}
func TestCronFileText(t *testing.T) {
ps := []Policy{
{ID: "abc", Name: "tank nightly", Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Enabled: true},
{ID: "def", Name: "paused one", Dataset: "tank/apps", Every: "hourly", At: "00:05", KeepFor: "2d"},
}
text, err := CronFileText(ps, "core", "/usr/local/bin/hamlab", "/home/core/apps/hamlab/config.json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(text, "30 2 * * * core /usr/local/bin/hamlab -config /home/core/apps/hamlab/config.json zfs-snapshot abc") {
t.Fatalf("the enabled schedule is missing or malformed:\n%s", text)
}
if strings.Contains(text, "zfs-snapshot def") {
t.Fatalf("a paused schedule must not be scheduled:\n%s", text)
}
// every line cron will read must parse as a schedule
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.Contains(line, "=") {
continue
}
f := strings.Fields(line)
if err := cron.ValidSchedule(strings.Join(f[:5], " ")); err != nil {
t.Errorf("line %q: %v", line, err)
}
}
}
func TestValidateRefusesNonsense(t *testing.T) {
ok := Policy{Dataset: "tank/apps", Every: "daily", At: "02:30", KeepFor: "6w"}
if err := ok.Validate(); err != nil {
t.Fatalf("a sane policy: %v", err)
}
bad := []Policy{
{Dataset: "", Every: "daily", At: "02:30", KeepFor: "6w"},
{Dataset: "tank", Every: "yearly", At: "02:30", KeepFor: "6w"},
{Dataset: "tank", Every: "custom", KeepFor: "6w"},
{Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"},
{Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"},
{Dataset: "tank", Every: "daily", At: "02:30"}, // no retention
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
{Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"},
}
for i, p := range bad {
if err := p.Validate(); err == nil {
t.Errorf("bad policy %d was accepted: %+v", i, p)
}
}
}
func TestParseAge(t *testing.T) {
for s, want := range map[string]time.Duration{
"6w": 6 * 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour,
"12h": 12 * time.Hour, "90m": 90 * time.Minute,
} {
if got, err := ParseAge(s); err != nil || got != want {
t.Errorf("ParseAge(%q) = %v, %v; want %v", s, got, err, want)
}
}
for _, s := range []string{"", "6", "w", "-1d", "0w", "6y", "six weeks"} {
if _, err := ParseAge(s); err == nil {
t.Errorf("ParseAge(%q) should fail", s)
}
}
}