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>
This commit is contained in:
Mitchell-Johnson
2026-09-24 13:09:43 -07:00
co-authored by Claude Opus 5
parent 4dc25fdd13
commit 09b7558d44
8 changed files with 395 additions and 159 deletions
+37 -58
View File
@@ -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 <id>` 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