Pools, datasets and snapshots move out of Disks — Disks is the hardware, this is the filesystem on top of it. New: snapshot schedules, hourly to monthly, with a retention period and a floor that keeps the newest few whatever their age. The naming is zfSnap's, deliberately. grid has run zfSnap from root cron since before this repo existed and it works, so rather than invent a second convention this writes and reads exactly what zfSnap does: 2026-09-24_02.30.01--6w, with the retention in the name. Ham Lab can therefore show and expire the snapshots zfSnap already took, the two cannot disagree about what a snapshot is, and changing a schedule never re-dates old ones — each carries the rule it was taken under. An existing cron job is detected and shown on the page, with a warning if a Ham Lab schedule is running as well. Nothing is destroyed unseen. Every schedule answers what it would delete, with the list, before Run now touches anything, and only names that parse as a stamp and a TTL are ever candidates. A snapshot taken by hand is invisible to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
302 lines
7.6 KiB
Go
302 lines
7.6 KiB
Go
package api
|
|
|
|
// The ZFS tab: pools, datasets, snapshots, and a schedule for taking and expiring them.
|
|
//
|
|
// grid already runs zfSnap from root cron and has done for longer than this repo has
|
|
// existed, so this does not replace it. It reads the same snapshot names, expires by the
|
|
// same rule, and surfaces the cron job that drives it — so what is on screen is the truth
|
|
// about the machine, whether Ham Lab or zfSnap is doing the work. A Ham Lab schedule is for
|
|
// a machine that has no such tool, and the page says plainly when both are running.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"hamlab/internal/storage"
|
|
"hamlab/internal/store"
|
|
)
|
|
|
|
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 })
|
|
var z zfsState
|
|
if len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &z)
|
|
}
|
|
if z.Policies == nil {
|
|
z.Policies = []storage.Policy{}
|
|
}
|
|
return z
|
|
}
|
|
|
|
func (s *Server) zfsSave(z zfsState) error {
|
|
b, err := json.Marshal(z)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.store.Update(func(st *store.State) { st.ZFS = b })
|
|
}
|
|
|
|
// externalSnapshotJob finds a cron job that is already taking snapshots on this machine, so
|
|
// the page can say so rather than let two schedules quietly double up.
|
|
func (s *Server) externalSnapshotJob(ctx context.Context) map[string]any {
|
|
if s.cron == nil {
|
|
return nil
|
|
}
|
|
ov := s.cron.Overview(ctx)
|
|
for _, j := range ov.Jobs {
|
|
if !j.Enabled {
|
|
continue
|
|
}
|
|
c := strings.ToLower(j.Command)
|
|
if !strings.Contains(c, "zfsnap") && !strings.Contains(c, "zfs-auto-snapshot") && !strings.Contains(c, "sanoid") &&
|
|
!(strings.Contains(c, "zfs ") && strings.Contains(c, "snapshot")) {
|
|
continue
|
|
}
|
|
return map[string]any{
|
|
"tool": toolName(c), "schedule": j.Schedule, "command": j.Command,
|
|
"user": j.User, "source": j.Source, "id": j.ID, "next": j.Next, "last": j.Last,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func toolName(cmd string) string {
|
|
switch {
|
|
case strings.Contains(cmd, "zfsnap"):
|
|
return "zfSnap"
|
|
case strings.Contains(cmd, "zfs-auto-snapshot"):
|
|
return "zfs-auto-snapshot"
|
|
case strings.Contains(cmd, "sanoid"):
|
|
return "sanoid"
|
|
}
|
|
return "a cron job"
|
|
}
|
|
|
|
// randomID is a short opaque id for a schedule.
|
|
func randomID() (string, error) {
|
|
b := make([]byte, 6)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// ---- the schedule runs itself ----
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- endpoints ----
|
|
|
|
func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) {
|
|
z := s.zfsLoad()
|
|
now := time.Now()
|
|
out := make([]map[string]any, 0, len(z.Policies))
|
|
for i := range z.Policies {
|
|
p := z.Policies[i]
|
|
m := map[string]any{"policy": p}
|
|
if p.Enabled {
|
|
m["next"] = p.NextRun(now)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
jsonOK(w, 200, map[string]any{
|
|
"zfs": s.storage.ZFS(r.Context()), "policies": out, "external": s.externalSnapshotJob(r.Context()),
|
|
})
|
|
}
|
|
|
|
func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
|
|
var p storage.Policy
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
|
|
jsonErr(w, 400, "bad request")
|
|
return
|
|
}
|
|
if err := p.Validate(); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
z := s.zfsLoad()
|
|
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
|
|
}
|
|
}
|
|
if !found {
|
|
if p.ID == "" {
|
|
id, err := randomID()
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
p.ID = id
|
|
}
|
|
z.Policies = append(z.Policies, p)
|
|
}
|
|
if err := s.zfsSave(z); err != nil {
|
|
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)
|
|
jsonOK(w, 200, p)
|
|
}
|
|
|
|
func (s *Server) zfsPolicyDelete(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
z := s.zfsLoad()
|
|
kept := z.Policies[:0]
|
|
found := false
|
|
for _, p := range z.Policies {
|
|
if p.ID == id {
|
|
found = true
|
|
continue
|
|
}
|
|
kept = append(kept, p)
|
|
}
|
|
if !found {
|
|
jsonErr(w, 404, "no such schedule")
|
|
return
|
|
}
|
|
z.Policies = kept
|
|
if err := s.zfsSave(z); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
log.Printf("zfs schedule %s removed by %s (its snapshots are left alone)", id, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) zfsPolicy(id string) (storage.Policy, bool) {
|
|
for _, p := range s.zfsLoad().Policies {
|
|
if p.ID == id {
|
|
return p, true
|
|
}
|
|
}
|
|
return storage.Policy{}, false
|
|
}
|
|
|
|
// zfsPreview is what this schedule would destroy if it ran now. Nothing is deleted.
|
|
func (s *Server) zfsPreview(w http.ResponseWriter, r *http.Request) {
|
|
p, ok := s.zfsPolicy(r.PathValue("id"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no such schedule")
|
|
return
|
|
}
|
|
doomed, err := s.storage.Doomed(r.Context(), &p)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if doomed == nil {
|
|
doomed = []storage.Snapshot{}
|
|
}
|
|
jsonOK(w, 200, map[string]any{"expired": doomed})
|
|
}
|
|
|
|
func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
p, ok := s.zfsPolicy(id)
|
|
if !ok {
|
|
jsonErr(w, 404, "no such schedule")
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
_ = s.zfsSave(z)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
log.Printf("zfs schedule %q run by %s: took %s, expired %d", p.Name, clientIP(r), res.Taken, len(res.Pruned))
|
|
jsonOK(w, 200, res)
|
|
}
|
|
|
|
// zfsSnapshotDelete removes one snapshot, named in full and typed back by the caller.
|
|
func (s *Server) zfsSnapshotDelete(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Confirm string `json:"confirm"`
|
|
Recursive bool `json:"recursive"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
|
jsonErr(w, 400, "bad request")
|
|
return
|
|
}
|
|
if body.Confirm != body.Name {
|
|
jsonErr(w, 400, "type the snapshot's full name to confirm")
|
|
return
|
|
}
|
|
if err := s.storage.DestroySnapshot(r.Context(), body.Name, body.Recursive); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
log.Printf("zfs snapshot %s destroyed by %s%s", body.Name, clientIP(r), map[bool]string{true: " (recursive)", false: ""}[body.Recursive])
|
|
w.WriteHeader(204)
|
|
}
|