package storage // Scheduled snapshots with a retention period: take a snapshot of a dataset on a schedule, // and drop the ones that have aged out. // // **The naming is zfSnap's, deliberately.** grid has run zfSnap v1.11.1 from root cron since // before this repo existed and it works well, so rather than invent a second convention and // have two tools disagree about what a snapshot is, this writes and reads exactly what // zfSnap does: `@2026-09-24_02.30.01--6w`, with the retention encoded in the name. // That has three consequences worth knowing. Ham Lab can show and manage the snapshots // zfSnap already took. The two can run side by side without fighting, because both only // ever destroy snapshots that have passed the expiry written into their own name. And // changing a policy's retention does not retroactively re-date old snapshots — each one // carries the rule it was taken under, which is the behaviour you want from a backup. // // Two rules make this safe to run unattended. Only a snapshot whose name parses as a stamp // and a TTL is ever a candidate — anything taken by hand or named anything else is invisible // 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. const stampLayout = "2006-01-02_15.04.05" // ttlSep separates the stamp from the retention in a snapshot name, as zfSnap writes it. const ttlSep = "--" // Policy is a standing instruction: snapshot this dataset on this schedule, keep the // snapshots this long. type Policy struct { ID string `json:"id"` Name string `json:"name"` 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 | 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 // dataset is never left with none. KeepFor string `json:"keep_for"` KeepMin int `json:"keep_min"` Prefix string `json:"prefix"` Enabled bool `json:"enabled"` } func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) } // Validate reports what is wrong with a policy, or nil. func (p *Policy) Validate() error { if !datasetRe.MatchString(p.Dataset) { return errors.New("dataset: a pool or pool/dataset name") } if pre := p.prefix(); pre != "" && (!snapRe.MatchString(pre) || strings.Contains(pre, ttlSep)) { return errors.New("prefix: letters, digits, . _ : and - only, and no --") } 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, monthly or custom") } if _, err := p.CronExpr(); err != nil { return err } if _, _, err := p.clock(); err != nil { return err } if p.Every == "weekly" && (p.Weekday < 0 || p.Weekday > 6) { return errors.New("weekday: 0 (Sunday) to 6") } if p.Every == "monthly" && (p.Day < 1 || p.Day > 28) { return errors.New("day: 1 to 28, so every month has one") } if p.KeepFor != "" { if _, err := ParseAge(p.KeepFor); err != nil { return err } } if p.KeepMin < 0 { return errors.New("keep at least: 0 or more") } if p.KeepFor == "" { return errors.New("a retention period is required — it is written into each snapshot's name, and is what lets it be cleaned up later") } return nil } func (p *Policy) clock() (hour, min int, err error) { at := strings.TrimSpace(p.At) if at == "" { return 0, 0, nil } h, m, ok := strings.Cut(at, ":") if !ok { return 0, 0, errors.New("at: HH:MM") } hour, err = strconv.Atoi(h) if err == nil { min, err = strconv.Atoi(m) } if err != nil || hour < 0 || hour > 23 || min < 0 || min > 59 { return 0, 0, errors.New("at: HH:MM, 00:00 to 23:59") } return hour, min, nil } // ParseAge reads "6w", "30d", "12h", "90m" as a duration. Weeks and days are what a // retention period is actually written in, and time.ParseDuration knows neither. func ParseAge(s string) (time.Duration, error) { s = strings.TrimSpace(strings.ToLower(s)) if s == "" { return 0, errors.New("empty period") } unit := s[len(s)-1] n, err := strconv.Atoi(s[:len(s)-1]) if err != nil || n <= 0 { return 0, fmt.Errorf("%q: a number and a unit, like 6w, 30d or 12h", s) } switch unit { case 'm': return time.Duration(n) * time.Minute, nil case 'h': return time.Duration(n) * time.Hour, nil case 'd': return time.Duration(n) * 24 * time.Hour, nil case 'w': return time.Duration(n) * 7 * 24 * time.Hour, nil } return 0, fmt.Errorf("%q: the unit is m, h, d or w", s) } // 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": return fmt.Sprintf("%d * * * *", min), nil case "daily": return fmt.Sprintf("%d %d * * *", min, hour), nil case "weekly": return fmt.Sprintf("%d %d * * %d", min, hour, p.Weekday), nil case "monthly": return fmt.Sprintf("%d %d %d * *", min, hour, p.Day), nil } return "", errors.New("unknown schedule") } // 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 { expr, err := p.CronExpr() if err != nil { return time.Time{} } t, ok := cron.NextRun(expr, now) if !ok { return time.Time{} } return t } // SnapTime reads a snapshot's name in zfSnap's convention and returns when it was taken, // when it expires, and whether the name parsed at all. A name without a TTL never expires. // An optional prefix in front of the stamp is allowed, so a policy can label its own. func SnapTime(name string) (taken time.Time, expires time.Time, ok bool) { _, snap, cut := strings.Cut(name, "@") if !cut { return } body, ttl, hasTTL := strings.Cut(snap, ttlSep) // an optional "-" before the stamp stamp := body if len(body) > len(stampLayout) { stamp = body[len(body)-len(stampLayout):] } t, err := time.ParseInLocation(stampLayout, stamp, time.Local) if err != nil { return time.Time{}, time.Time{}, false } taken, ok = t, true if hasTTL { if d, err := ParseAge(ttl); err == nil { expires = t.Add(d) } } return } // Doomed is which of this dataset's snapshots have passed the expiry written into their own // names, oldest first — exactly what zfSnap -d would remove. This is what the UI shows // before anything is deleted, and what Run then acts on. KeepMin holds back that many of the // newest whatever their names say, so a dataset is never left with none. func (m *Manager) Doomed(ctx context.Context, p *Policy) ([]Snapshot, error) { if err := p.Validate(); err != nil { return nil, err } all, err := m.Snapshots(ctx, p.Dataset) if err != nil { return nil, err } var dated []Snapshot expiry := map[string]time.Time{} for _, s := range all { taken, exp, ok := SnapTime(s.Name) if !ok || exp.IsZero() { continue // hand-made, or kept forever: not ours to remove } _ = taken dated = append(dated, s) expiry[s.Name] = exp } sort.Slice(dated, func(i, j int) bool { return dated[i].Created > dated[j].Created }) // newest first now := time.Now() var doomed []Snapshot for i, s := range dated { if i < p.KeepMin { continue // the floor: these survive however old they are } if expiry[s.Name].Before(now) { doomed = append(doomed, s) } } sort.Slice(doomed, func(i, j int) bool { return doomed[i].Created < doomed[j].Created }) // oldest first return doomed, nil } // DestroySnapshot removes one snapshot. recursive also removes the same-named snapshot on // every dataset beneath it, which is what a recursive policy created. func (m *Manager) DestroySnapshot(ctx context.Context, full string, recursive bool) error { dataset, snap, ok := strings.Cut(full, "@") if !ok || !datasetRe.MatchString(dataset) || !snapRe.MatchString(snap) { return errors.New("bad snapshot name") } args := []string{"zfs", "destroy"} if recursive { args = append(args, "-r") } out, code, err := m.run(ctx, append(args, full)...) if err != nil { return err } if code != 0 { return errors.New(strings.TrimSpace(string(out))) } return nil } // SnapshotRecursive takes dataset@name, optionally of everything beneath it too. func (m *Manager) SnapshotRecursive(ctx context.Context, dataset, name string, recursive bool) error { if !datasetRe.MatchString(dataset) || !snapRe.MatchString(name) { return errors.New("bad snapshot name") } args := []string{"zfs", "snapshot"} if recursive { args = append(args, "-r") } out, code, err := m.run(ctx, append(args, dataset+"@"+name)...) if err != nil { return err } if code != 0 { return errors.New(strings.TrimSpace(string(out))) } return nil } // RunResult is what one pass of a policy did. type RunResult struct { Taken string `json:"taken"` Pruned []string `json:"pruned"` Skipped int `json:"skipped"` // snapshots that are not this policy's, left alone } // Run takes the snapshot and prunes what has aged out. It is the same code the scheduler // calls, so "Run now" and the timer cannot drift apart. func (m *Manager) Run(ctx context.Context, p *Policy) (RunResult, error) { var res RunResult if err := p.Validate(); err != nil { return res, err } name := time.Now().Format(stampLayout) if pre := strings.TrimSpace(p.Prefix); pre != "" { name = pre + "-" + name } if p.KeepFor != "" { name += ttlSep + strings.TrimSpace(strings.ToLower(p.KeepFor)) // zfSnap's convention: the retention lives in the name } if err := m.SnapshotRecursive(ctx, p.Dataset, name, p.Recursive); err != nil { return res, err } res.Taken = p.Dataset + "@" + name doomed, err := m.Doomed(ctx, p) if err != nil { return res, err // the snapshot was taken; the prune is what failed } for _, s := range doomed { if err := m.DestroySnapshot(ctx, s.Name, p.Recursive); err != nil { return res, fmt.Errorf("destroying %s: %w", s.Name, err) } res.Pruned = append(res.Pruned, s.Name) } 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) } // WriteRootFile puts text at a root-owned path, through the same privilege escalation every // other write here uses. Shared by the snapshot schedules and the image-update schedules, // which both own a file in /etc/cron.d. func (m *Manager) WriteRootFile(ctx context.Context, path, text string) error { return m.writeRoot(ctx, path, text) } // RemoveRootFile deletes one, for when the last schedule of a kind goes away. func (m *Manager) RemoveRootFile(ctx context.Context, path string) error { out, code, err := m.run(ctx, "rm", "-f", path) if err != nil { return err } if code != 0 { return errors.New(strings.TrimSpace(string(out))) } return nil } 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 }