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 ( "context" "errors" "fmt" "sort" "strconv" "strings" "time" ) // 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. At is "HH:MM" (the minute alone is used // for hourly). Weekday is 0=Sunday, Day is 1–28. Every string `json:"every"` At string `json:"at"` Weekday int `json:"weekday"` Day int `json:"day"` // 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"` 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) } // 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": default: return errors.New("every: hourly, daily, weekly or monthly") } 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) } // 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() 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 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 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 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 time.Time{} } // NextRun is when this policy runs next, for the UI to show. 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) } return time.Time{} } // Due reports whether the policy should run now. func (p *Policy) Due(now time.Time) bool { if !p.Enabled { return false } d := p.lastDue(now) return !d.IsZero() && p.LastRun.Before(d) } // 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 }