From 09b7558d4402e42ebe5706e4d3d37cb1847b4b0c Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 24 Sep 2026 13:09:43 -0700 Subject: [PATCH] Snapshot schedules are real cron jobs, written and owned by Ham Lab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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/.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 --- cmd/hamlab/main.go | 59 +++++++ internal/api/server.go | 1 - internal/api/zfs.go | 95 +++++------- internal/cron/schedule.go | 11 ++ internal/storage/snapshots.go | 239 ++++++++++++++++++++++------- internal/storage/snapshots_test.go | 78 +++++++--- web/src/ZFS.tsx | 66 ++++++-- web/src/api.ts | 5 +- 8 files changed, 395 insertions(+), 159 deletions(-) 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 }