Pools are shown by where their bytes land (plain files on this disk, a ZFS pool inside one file, a dataset…) rather than by driver name, which read as the NAS pool. A location can be added as a plain-files pool at any folder on the machine, and removed while nothing uses it. The driver order at Install is as it was; the labels say what each one really is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
136 lines
4.9 KiB
Go
136 lines
4.9 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)
|
|
}
|
|
}
|
|
}
|