Pools are shown by where their bytes land (plain files on this disk, a ZFS pool inside one file, a dataset…) rather than by driver name, which read as the NAS pool. A location can be added as a plain-files pool at any folder on the machine, and removed while nothing uses it. The driver order at Install is as it was; the labels say what each one really is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
493 lines
16 KiB
Go
493 lines
16 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)
|
||
}
|
||
|
||
// 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
|
||
}
|