Pools, datasets and snapshots move out of Disks — Disks is the hardware, this is the filesystem on top of it. New: snapshot schedules, hourly to monthly, with a retention period and a floor that keeps the newest few whatever their age. The naming is zfSnap's, deliberately. grid has run zfSnap from root cron since before this repo existed and it works, so rather than invent a second convention this writes and reads exactly what zfSnap does: 2026-09-24_02.30.01--6w, with the retention in the name. Ham Lab can therefore show and expire the snapshots zfSnap already took, the two cannot disagree about what a snapshot is, and changing a schedule never re-dates old ones — each carries the rule it was taken under. An existing cron job is detected and shown on the page, with a warning if a Ham Lab schedule is running as well. Nothing is destroyed unseen. Every schedule answers what it would delete, with the list, before Run now touches anything, and only names that parse as a stamp and a TTL are ever candidates. A snapshot taken by hand is invisible to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
3.7 KiB
Go
108 lines
3.7 KiB
Go
package storage
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
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 TestScheduleIsDueAndNext(t *testing.T) {
|
|
now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local)
|
|
daily := &Policy{Every: "daily", At: "02:30", Enabled: true}
|
|
if d := daily.lastDue(now); d.Hour() != 2 || d.Day() != 24 {
|
|
t.Fatalf("daily lastDue = %v", d)
|
|
}
|
|
if !daily.Due(now) {
|
|
t.Fatal("never run: must be due")
|
|
}
|
|
daily.LastRun = now.Add(-time.Hour) // ran at 08:15 today, after 02:30
|
|
if daily.Due(now) {
|
|
t.Fatal("already ran since the window: not due")
|
|
}
|
|
if n := daily.NextRun(now); n.Day() != 25 || n.Hour() != 2 {
|
|
t.Fatalf("daily NextRun = %v", n)
|
|
}
|
|
// a machine that was off through its window runs once when it comes back, not never
|
|
daily.LastRun = now.AddDate(0, 0, -2)
|
|
if !daily.Due(now) {
|
|
t.Fatal("a missed window must still be due")
|
|
}
|
|
// disabled never runs
|
|
daily.Enabled = false
|
|
if daily.Due(now) {
|
|
t.Fatal("disabled must never be due")
|
|
}
|
|
weekly := &Policy{Every: "weekly", At: "04:00", Weekday: int(time.Sunday), Enabled: true}
|
|
if d := weekly.lastDue(now); d.Weekday() != time.Sunday || d.After(now) {
|
|
t.Fatalf("weekly lastDue = %v (%v)", d, d.Weekday())
|
|
}
|
|
hourly := &Policy{Every: "hourly", At: "00:30", Enabled: true}
|
|
if d := hourly.lastDue(now); d.Minute() != 30 || d.Hour() != 8 {
|
|
t.Fatalf("hourly lastDue at 09:15 with minute 30 = %v, want 08:30", d)
|
|
}
|
|
}
|
|
|
|
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: "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)
|
|
}
|
|
}
|
|
}
|