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>
477 lines
15 KiB
Go
477 lines
15 KiB
Go
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: `<dataset>@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 "<prefix>-" 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)
|
||
}
|
||
|
||
// 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
|
||
}
|