diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 5f3cc0e..f642c36 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -4,6 +4,7 @@ package main import ( "bufio" "context" + "encoding/json" "errors" "flag" "fmt" @@ -32,6 +33,7 @@ import ( "hamlab/internal/metrics" "hamlab/internal/nodes" "hamlab/internal/services" + "hamlab/internal/priv" "hamlab/internal/setup" "hamlab/internal/space" "hamlab/internal/storage" @@ -59,6 +61,7 @@ func main() { serve run the console in the foreground (what the service runs) passwd [user] set a user's password (makes the account if it is new; reads stdin when piped) users list the accounts + zfs-snapshot run one ZFS snapshot schedule (what its cron line calls) user-rm remove an account and sign it out everywhere check verify the config and state load, then exit version @@ -129,6 +132,10 @@ func main() { if err := passwd(cfg, flag.Arg(1)); err != nil { log.Fatal(err) } + case "zfs-snapshot": + if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil { + log.Fatal(err) + } case "users": migrateUsers(cfg) if len(cfg.Users) == 0 { @@ -178,6 +185,58 @@ func main() { // migrateUsers gives a pre-users config its first account, named after this OS user, so // the CLI sees the same accounts the daemon will. +// zfsSnapshot runs one snapshot schedule: take it, then expire what has aged out. This is +// what the cron line Ham Lab writes actually calls, so a schedule keeps running whether or +// not the console is up — and keeps running if Ham Lab is never opened again. +// +// It deliberately does not write state.json. The daemon owns that file and would clobber +// anything written from out here; the outcome goes beside it instead, in zfs-runs/. +func zfsSnapshot(cfg *config.Config, id string) error { + if strings.TrimSpace(id) == "" { + return errors.New("usage: hamlab zfs-snapshot ") + } + st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")) + if err != nil { + return err + } + var raw json.RawMessage + st.View(func(s *store.State) { raw = s.ZFS }) + var z struct { + Policies []storage.Policy `json:"policies"` + } + if len(raw) > 0 { + if err := json.Unmarshal(raw, &z); err != nil { + return fmt.Errorf("reading the schedules: %w", err) + } + } + var p *storage.Policy + for i := range z.Policies { + if z.Policies[i].ID == id { + p = &z.Policies[i] + } + } + if p == nil { + return fmt.Errorf("no snapshot schedule %q — it may have been removed; this cron line is stale", id) + } + res, runErr := storage.New(priv.Available()).Run(context.Background(), p) + rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned} + if runErr != nil { + rec.Error = runErr.Error() + } + if err := storage.WriteRun(cfg.DataDir(), id, rec); err != nil { + fmt.Fprintln(os.Stderr, "recording the run:", err) + } + if runErr != nil { + return runErr + } + fmt.Printf("%s: took %s", p.Name, res.Taken) + if len(res.Pruned) > 0 { + fmt.Printf(", expired %d", len(res.Pruned)) + } + fmt.Println() + return nil +} + func migrateUsers(cfg *config.Config) { name := os.Getenv("USER") if u, err := user.Current(); err == nil && u.Username != "" { diff --git a/internal/api/server.go b/internal/api/server.go index b8dce55..5482678 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -137,7 +137,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me go s.keepFleetInStep() } go s.watchForUpdates(context.Background()) - go s.runSchedules(context.Background()) m := http.NewServeMux() m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) diff --git a/internal/api/zfs.go b/internal/api/zfs.go index e644c00..e4fb70f 100644 --- a/internal/api/zfs.go +++ b/internal/api/zfs.go @@ -16,8 +16,9 @@ import ( "io" "log" "net/http" + "os" + "path/filepath" "strings" - "sync" "time" "hamlab/internal/storage" @@ -28,11 +29,6 @@ type zfsState struct { Policies []storage.Policy `json:"policies"` } -type zfsEngine struct { - s *Server - mu sync.Mutex -} - func (s *Server) zfsLoad() zfsState { var raw json.RawMessage s.store.View(func(st *store.State) { raw = st.ZFS }) @@ -99,45 +95,22 @@ func randomID() (string, error) { return hex.EncodeToString(b), nil } -// ---- the schedule runs itself ---- +// ---- the cron file ---- +// +// A schedule is a cron line, written to /etc/cron.d/hamlab-zfs, which Ham Lab owns and +// rewrites whole whenever the schedules change. Nothing in the daemon runs them: snapshots +// are backup-shaped and should not stop because a web console is down, being upgraded, or +// uninstalled. The line calls `hamlab zfs-snapshot ` as the account Ham Lab runs as, +// which reaches zfs through sudo exactly as the daemon does. -// runSchedules takes the snapshots that are due. Every 30 s, which is fine for a schedule -// whose finest grain is hourly, and cheap when nothing is due. -func (s *Server) runSchedules(ctx context.Context) { - t := time.NewTicker(30 * time.Second) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - } - z := s.zfsLoad() - now := time.Now() - changed := false - for i := range z.Policies { - p := &z.Policies[i] - if !p.Due(now) { - continue - } - res, err := s.storage.Run(ctx, p) - p.LastRun = now - p.LastTaken, p.Pruned = res.Taken, len(res.Pruned) - if err != nil { - p.LastError = err.Error() - log.Printf("zfs schedule %q: %v", p.Name, err) - } else { - p.LastError = "" - log.Printf("zfs schedule %q: took %s, expired %d", p.Name, res.Taken, len(res.Pruned)) - } - changed = true - } - if changed { - if err := s.zfsSave(z); err != nil { - log.Printf("zfs schedule: saving: %v", err) - } - } +// syncCron puts the current schedules on the machine. +func (s *Server) syncCron(ctx context.Context, z zfsState) error { + exe, err := os.Executable() + if err != nil { + return err } + exe, _ = filepath.EvalSymlinks(exe) + return s.storage.WriteCronFile(ctx, z.Policies, s.user, exe, s.cfg.Path()) } // ---- endpoints ---- @@ -145,12 +118,16 @@ func (s *Server) runSchedules(ctx context.Context) { func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) { z := s.zfsLoad() now := time.Now() + dir := s.cfg.DataDir() out := make([]map[string]any, 0, len(z.Policies)) for i := range z.Policies { p := z.Policies[i] - m := map[string]any{"policy": p} + expr, _ := p.CronExpr() + m := map[string]any{"policy": p, "cron": expr, "last": storage.ReadRun(dir, p.ID)} if p.Enabled { - m["next"] = p.NextRun(now) + if n := p.NextRun(now); !n.IsZero() { + m["next"] = n + } } out = append(out, m) } @@ -173,7 +150,6 @@ func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) { found := false for i := range z.Policies { if z.Policies[i].ID == p.ID && p.ID != "" { - p.LastRun, p.LastError, p.LastTaken, p.Pruned = z.Policies[i].LastRun, z.Policies[i].LastError, z.Policies[i].LastTaken, z.Policies[i].Pruned z.Policies[i] = p found = true } @@ -193,7 +169,12 @@ func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) { jsonErr(w, 500, err.Error()) return } - log.Printf("zfs schedule %q saved by %s (%s, %s, keep %s)", p.Name, clientIP(r), p.Dataset, p.Every, p.KeepFor) + if err := s.syncCron(r.Context(), z); err != nil { + jsonErr(w, 500, "saved, but writing "+storage.CronFile+" failed: "+err.Error()) + return + } + expr, _ := p.CronExpr() + log.Printf("zfs schedule %q saved by %s (%s, %q, keep %s)", p.Name, clientIP(r), p.Dataset, expr, p.KeepFor) jsonOK(w, 200, p) } @@ -218,6 +199,11 @@ func (s *Server) zfsPolicyDelete(w http.ResponseWriter, r *http.Request) { jsonErr(w, 500, err.Error()) return } + if err := s.syncCron(r.Context(), z); err != nil { + jsonErr(w, 500, "removed, but writing "+storage.CronFile+" failed: "+err.Error()) + return + } + storage.ForgetRun(s.cfg.DataDir(), id) log.Printf("zfs schedule %s removed by %s (its snapshots are left alone)", id, clientIP(r)) w.WriteHeader(204) } @@ -257,18 +243,11 @@ func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) { return } res, err := s.storage.Run(r.Context(), &p) - now := time.Now() - z := s.zfsLoad() - for i := range z.Policies { - if z.Policies[i].ID == id { - z.Policies[i].LastRun, z.Policies[i].LastTaken, z.Policies[i].Pruned = now, res.Taken, len(res.Pruned) - z.Policies[i].LastError = "" - if err != nil { - z.Policies[i].LastError = err.Error() - } - } + rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned} + if err != nil { + rec.Error = err.Error() } - _ = s.zfsSave(z) + _ = storage.WriteRun(s.cfg.DataDir(), id, rec) if err != nil { jsonErr(w, 500, err.Error()) return diff --git a/internal/cron/schedule.go b/internal/cron/schedule.go index 4c52d5b..3e1cf8b 100644 --- a/internal/cron/schedule.go +++ b/internal/cron/schedule.go @@ -132,6 +132,17 @@ func (s *schedule) dayMatches(t time.Time) bool { } } +// NextRun is the first time strictly after `from` that a cron expression fires. Exported so +// anything that writes a cron line — the ZFS snapshot schedules — can say when it runs next +// using the same evaluator that reads everyone else's. +func NextRun(expr string, from time.Time) (time.Time, bool) { return nextRun(expr, from) } + +// ValidSchedule reports whether a cron expression parses. +func ValidSchedule(expr string) error { + _, err := parseSchedule(expr) + return err +} + // nextRun finds the first time strictly after `from` that the schedule fires. func nextRun(expr string, from time.Time) (time.Time, bool) { s, err := parseSchedule(expr) diff --git a/internal/storage/snapshots.go b/internal/storage/snapshots.go index a3d40ff..66cce28 100644 --- a/internal/storage/snapshots.go +++ b/internal/storage/snapshots.go @@ -18,13 +18,21 @@ package storage // to it. And every policy can be asked what it would delete, before it deletes anything. import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "os" + "os/exec" + "path/filepath" "sort" "strconv" "strings" "time" + + "hamlab/internal/cron" + "hamlab/internal/priv" ) // stampLayout is zfSnap's timestamp: 2026-09-24_02.30.01. @@ -41,12 +49,15 @@ type Policy struct { Dataset string `json:"dataset"` // Recursive takes one atomic snapshot of the dataset and everything under it (zfs -r). Recursive bool `json:"recursive"` - // Every is hourly | daily | weekly | monthly. At is "HH:MM" (the minute alone is used - // for hourly). Weekday is 0=Sunday, Day is 1–28. + // Every is hourly | daily | weekly | monthly | custom. At is "HH:MM" (the minute alone + // is used for hourly). Weekday is 0=Sunday, Day is 1–28. With "custom", Cron is a plain + // cron expression and the rest is ignored — the escape hatch for anything the four + // common shapes cannot say. Every string `json:"every"` At string `json:"at"` Weekday int `json:"weekday"` Day int `json:"day"` + Cron string `json:"cron,omitempty"` // KeepFor is how long a snapshot lives: "6w", "30d", "12h". It is written into the // snapshot's own name, zfSnap-style, so each one carries the rule it was taken under. // KeepMin is a floor — that many newest snapshots survive however old they are, so a @@ -56,10 +67,6 @@ type Policy struct { Prefix string `json:"prefix"` Enabled bool `json:"enabled"` - LastRun time.Time `json:"last_run,omitempty"` - LastError string `json:"last_error,omitempty"` - LastTaken string `json:"last_taken,omitempty"` // the snapshot the last run made - Pruned int `json:"pruned,omitempty"` // how many the last run destroyed } func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) } @@ -74,8 +81,15 @@ func (p *Policy) Validate() error { } switch p.Every { case "hourly", "daily", "weekly", "monthly": + case "custom": + if strings.TrimSpace(p.Cron) == "" { + return errors.New("a custom schedule needs a cron expression") + } default: - return errors.New("every: hourly, daily, weekly or monthly") + return errors.New("every: hourly, daily, weekly, monthly or custom") + } + if _, err := p.CronExpr(); err != nil { + return err } if _, _, err := p.clock(); err != nil { return err @@ -144,67 +158,50 @@ func ParseAge(s string) (time.Duration, error) { return 0, fmt.Errorf("%q: the unit is m, h, d or w", s) } -// lastDue is the most recent moment this policy was supposed to run, at or before now. -// A policy whose LastRun is older than that is due — so a machine that was off through -// its window takes one snapshot when it comes back, rather than skipping silently. -func (p *Policy) lastDue(now time.Time) time.Time { - hour, min, _ := p.clock() - y, mo, d := now.Date() - loc := now.Location() +// CronExpr renders the schedule as a cron expression — which is what actually runs it. The +// four common shapes are sugar over this; a custom policy states it outright. One expression +// is the only description of when a schedule fires, so the page, the cron file and the next +// run can never disagree. +func (p *Policy) CronExpr() (string, error) { + if p.Every == "custom" { + expr := strings.Join(strings.Fields(p.Cron), " ") + if err := cron.ValidSchedule(expr); err != nil { + return "", fmt.Errorf("cron expression: %w", err) + } + if strings.HasPrefix(expr, "@reboot") { + return "", errors.New("@reboot is not a schedule for snapshots") + } + return expr, nil + } + hour, min, err := p.clock() + if err != nil { + return "", err + } switch p.Every { case "hourly": - t := time.Date(y, mo, d, now.Hour(), min, 0, 0, loc) - if t.After(now) { - t = t.Add(-time.Hour) - } - return t + return fmt.Sprintf("%d * * * *", min), nil case "daily": - t := time.Date(y, mo, d, hour, min, 0, 0, loc) - if t.After(now) { - t = t.AddDate(0, 0, -1) - } - return t + return fmt.Sprintf("%d %d * * *", min, hour), nil case "weekly": - t := time.Date(y, mo, d, hour, min, 0, 0, loc) - back := (int(t.Weekday()) - p.Weekday + 7) % 7 - t = t.AddDate(0, 0, -back) - if t.After(now) { - t = t.AddDate(0, 0, -7) - } - return t + return fmt.Sprintf("%d %d * * %d", min, hour, p.Weekday), nil case "monthly": - t := time.Date(y, mo, p.Day, hour, min, 0, 0, loc) - if t.After(now) { - t = t.AddDate(0, -1, 0) - } - return t + return fmt.Sprintf("%d %d %d * *", min, hour, p.Day), nil } - return time.Time{} + return "", errors.New("unknown schedule") } -// NextRun is when this policy runs next, for the UI to show. +// NextRun is when cron will next fire this schedule, read with the same evaluator the Jobs +// tab uses on everyone else's lines. func (p *Policy) NextRun(now time.Time) time.Time { - last := p.lastDue(now) - switch p.Every { - case "hourly": - return last.Add(time.Hour) - case "daily": - return last.AddDate(0, 0, 1) - case "weekly": - return last.AddDate(0, 0, 7) - case "monthly": - return last.AddDate(0, 1, 0) + expr, err := p.CronExpr() + if err != nil { + return time.Time{} } - return time.Time{} -} - -// Due reports whether the policy should run now. -func (p *Policy) Due(now time.Time) bool { - if !p.Enabled { - return false + t, ok := cron.NextRun(expr, now) + if !ok { + return time.Time{} } - d := p.lastDue(now) - return !d.IsZero() && p.LastRun.Before(d) + return t } // SnapTime reads a snapshot's name in zfSnap's convention and returns when it was taken, @@ -349,3 +346,131 @@ func (m *Manager) Run(ctx context.Context, p *Policy) (RunResult, error) { } return res, nil } + +// ---- what happened when it ran ---- +// +// The policies are a definition the daemon owns; what a run did is written by whatever ran +// it, which is a cron job in its own process. Keeping the two apart means the CLI never has +// to write state.json behind the daemon's back — the last writer would win and one of them +// would lose its work. + +// RunRecord is the outcome of one run of one schedule. +type RunRecord struct { + At time.Time `json:"at"` + Taken string `json:"taken,omitempty"` + Pruned []string `json:"pruned,omitempty"` + Error string `json:"error,omitempty"` +} + +func runPath(dir, id string) string { return filepath.Join(dir, "zfs-runs", id+".json") } + +// WriteRun records what a run did, beside the config. +func WriteRun(dir, id string, r RunRecord) error { + if err := os.MkdirAll(filepath.Join(dir, "zfs-runs"), 0o700); err != nil { + return err + } + b, err := json.Marshal(r) + if err != nil { + return err + } + p := runPath(dir, id) + tmp := p + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, p) +} + +// ReadRun is the last thing a schedule did, or the zero value if it has never run. +func ReadRun(dir, id string) RunRecord { + var r RunRecord + b, err := os.ReadFile(runPath(dir, id)) + if err == nil { + _ = json.Unmarshal(b, &r) + } + return r +} + +// ForgetRun drops a schedule's record, for when the schedule itself goes. +func ForgetRun(dir, id string) { _ = os.Remove(runPath(dir, id)) } + +// ---- the cron file ---- + +// CronFile is where Ham Lab writes the snapshot schedules. One file it owns entirely and +// rewrites whole, so there is never a half-updated set of lines, and so a schedule removed +// in the UI is removed from the machine. +const CronFile = "/etc/cron.d/hamlab-zfs" + +// CronFileText renders every schedule as cron lines. user is who they run as — the account +// Ham Lab itself runs as, which reaches zfs through sudo exactly as the daemon does, so the +// files it writes stay owned by that account rather than by root. +func CronFileText(policies []Policy, user, exe, config string) (string, error) { + var b strings.Builder + b.WriteString("# Ham Lab snapshot schedules — written by the ZFS tab, rewritten whole on every change.\n") + b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n") + b.WriteString("SHELL=/bin/sh\n") + b.WriteString("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n") + for i := range policies { + p := &policies[i] + if !p.Enabled { + b.WriteString("# " + p.Name + " — paused\n") + continue + } + expr, err := p.CronExpr() + if err != nil { + return "", fmt.Errorf("%s: %w", p.Name, err) + } + name := strings.ReplaceAll(p.Name, "\n", " ") + fmt.Fprintf(&b, "# %s — %s, keep %s\n", name, p.Dataset, p.KeepFor) + fmt.Fprintf(&b, "%s %s %s -config %s zfs-snapshot %s\n\n", expr, user, exe, config, p.ID) + } + return b.String(), nil +} + +// WriteCronFile puts the schedules on the machine, or removes the file when there are none. +func (m *Manager) WriteCronFile(ctx context.Context, policies []Policy, user, exe, config string) error { + live := false + for i := range policies { + if policies[i].Enabled { + live = true + } + } + if !live { + out, code, err := m.run(ctx, "rm", "-f", CronFile) + if err != nil { + return err + } + if code != 0 { + return errors.New(strings.TrimSpace(string(out))) + } + return nil + } + text, err := CronFileText(policies, user, exe, config) + if err != nil { + return err + } + return m.writeRoot(ctx, CronFile, text) +} + +// writeRoot puts text at a root-owned path, through the same privilege escalation every +// other write here uses. +func (m *Manager) writeRoot(ctx context.Context, path, text string) error { + args := []string{"tee", path} + if m.sudo { + args = priv.Wrap(args...) + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Stdin = strings.NewReader(text) + var errb bytes.Buffer + cmd.Stdout, cmd.Stderr = nil, &errb + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(errb.String()) + if msg == "" { + msg = err.Error() + } + return errors.New(msg) + } + return nil +} diff --git a/internal/storage/snapshots_test.go b/internal/storage/snapshots_test.go index ae5c12d..9c39d26 100644 --- a/internal/storage/snapshots_test.go +++ b/internal/storage/snapshots_test.go @@ -1,8 +1,11 @@ package storage import ( + "strings" "testing" "time" + + "hamlab/internal/cron" ) func TestSnapTimeReadsZfSnapNames(t *testing.T) { @@ -33,39 +36,62 @@ func TestSnapTimeReadsZfSnapNames(t *testing.T) { } } -func TestScheduleIsDueAndNext(t *testing.T) { +func TestCronExprAndNextRun(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) + 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) + } } - if !daily.Due(now) { - t.Fatal("never run: must be due") + // 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) } - 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") + // 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 n := daily.NextRun(now); n.Day() != 25 || n.Hour() != 2 { - t.Fatalf("daily NextRun = %v", n) + if _, err := (&Policy{Every: "custom", Cron: "@reboot"}).CronExpr(); err == nil { + t.Fatal("@reboot is not a snapshot schedule") } - // 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") +} + +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"}, } - // disabled never runs - daily.Enabled = false - if daily.Due(now) { - t.Fatal("disabled must never be due") + text, err := CronFileText(ps, "core", "/usr/local/bin/hamlab", "/home/core/apps/hamlab/config.json") + if err != nil { + t.Fatal(err) } - 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()) + 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) } - 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) + 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) + } } } @@ -77,6 +103,8 @@ func TestValidateRefusesNonsense(t *testing.T) { 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 diff --git a/web/src/ZFS.tsx b/web/src/ZFS.tsx index 8d7b171..8b6084b 100644 --- a/web/src/ZFS.tsx +++ b/web/src/ZFS.tsx @@ -9,7 +9,7 @@ // disagree over. An existing cron job is detected and shown, so the page tells the truth // about the machine whether Ham Lab or something else is doing the work. import { FormEvent, useCallback, useEffect, useState } from 'react' -import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSSnapshot } from './api' +import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api' import { fmtBytes } from './fmt' import { useRefreshMs } from './prefs' import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui' @@ -26,7 +26,8 @@ const soon = (t?: string) => { } const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] const describe = (p: ZFSPolicy) => { - const when = p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}` + const when = p.every === 'custom' ? `on ${p.cron}` + : p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}` : p.every === 'daily' ? `every day at ${p.at}` : p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}` : `on day ${p.day} of the month at ${p.at}` @@ -107,7 +108,7 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange // ---- schedules ---- -function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) { +function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; onAdopt: () => void }) { return (
0 ? ' attn' : '')} style={{ marginBottom: 8 }}> {x.tool} is already taking snapshots on this machine, from cron as {x.user}: @@ -115,13 +116,16 @@ function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) { in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''} {mine > 0 ?
A Ham Lab schedule is running as well. Both use the same names and both only remove snapshots that have passed the expiry written into them, so they will not fight — but they will take twice as many. Keep one.
- :
Ham Lab reads the snapshots it makes and shows when each expires. Add a schedule here only if you mean to replace it; edit this one on the Jobs tab.
} + :
+ Adopt as a schedule + Copies it into a Ham Lab schedule you can edit here. Turn the old cron job off on the Jobs tab once you are happy with it. +
}
) } type ExternalJob = ZFSExternal -function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: string; onChanged: () => void; onEdit: () => void }) { +function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) { const [expired, setExpired] = useState(null) const [busy, setBusy] = useState('') const preview = async () => { @@ -146,7 +150,7 @@ function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: str return (
- + {p.name || p.dataset} {!p.enabled && paused} @@ -155,11 +159,12 @@ function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: str
Dataset{p.dataset}
When{describe(p)}
-
Last run{ago(p.last_run)}{p.pruned ? ` · ${p.pruned} expired removed` : ''}
- {p.last_taken &&
Last snapshot{p.last_taken.split('@')[1]}
} +
Cron{cron}
+
Last run{ago(last.at)}{last.pruned?.length ? ` · ${last.pruned.length} expired removed` : ''}
+ {last.taken &&
Last snapshot{last.taken.split('@')[1]}
} {p.keep_min > 0 &&
Flooralways keep the newest {p.keep_min}
}
- {p.last_error &&
{p.last_error}
} + {last.error &&
{last.error}
} {expired && (
{expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : ( @@ -210,14 +215,20 @@ function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial - + {f.every === 'custom' ? ( + + ) : ( + + )} {f.every === 'weekly' && ( @@ -255,6 +269,26 @@ function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial is the retention, -r is recursive. +function adopt(x: ExternalJob, fallbackDataset: string): Partial { + const cmd = x.command + const keep = /-a\s+(\d+[mhdwMy])/.exec(cmd)?.[1] ?? /--\s*(\d+[mhdw])\b/.exec(cmd)?.[1] ?? '6w' + const f = x.schedule.trim().split(/\s+/) + const min = Number(f[0]), hour = Number(f[1]) + const simple = f.length === 5 && Number.isInteger(min) && Number.isInteger(hour) && f[2] === '*' && f[3] === '*' && f[4] === '*' + const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset + return { + ...BLANK, name: `${x.tool} (adopted)`, dataset: ds, + recursive: /\s-\w*r/.test(cmd) || true, + every: simple ? 'daily' : 'custom', + at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30', + cron: simple ? '' : x.schedule.trim(), + keep_for: keep, keep_min: 1, enabled: true, + } +} + // ---- page ---- export default function ZFS() { @@ -286,10 +320,10 @@ export default function ZFS() {

Snapshot schedules

setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule
- {ov.external && } + {ov.external && setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />} {ov.policies.length === 0 && !ov.external &&
Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.
} -
{ov.policies.map(({ policy, next }) => ( - setEditing(policy)} /> +
{ov.policies.map(({ policy, cron, next, last }) => ( + setEditing(policy)} /> ))}

Datasets

used · referenced · in snapshots · count · compression · mount
diff --git a/web/src/api.ts b/web/src/api.ts index 9742fc4..8ff68c0 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -133,9 +133,10 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent: export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number } export interface SpaceFile { path: string; size: number; apparent: number; mtime: number } -export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly'; at: string; weekday: number; day: number; keep_for: string; keep_min: number; prefix?: string; enabled: boolean; last_run?: string; last_error?: string; last_taken?: string; pruned?: number } +export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean } +export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string } export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number } -export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; next?: string }[]; external?: ZFSExternal | null } +export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; cron: string; next?: string; last: ZFSRun }[]; external?: ZFSExternal | null } export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean } export interface FileInfo extends FileEntry { path: string; text: boolean }