package main import ( "context" "fmt" "regexp" "sort" "strings" "time" ) // SnapshotEvent is the event each attempted snapshot is published as (module.json's emits): the record // of the act, beside the pool's own history, which ZFS writes for every snapshot. const SnapshotEvent = "snapshot.taken" // The one act of this module: a snapshot of one dataset, by a person, with why. It adds a snapshot and // changes nothing else: no destroy, rollback, set or send exists in this module (novox/hq ADR 0051, the // operator's data). ZFS takes a snapshot atomically, and with -r every child's at the same instant. // snapName is what a snapshot's own name may hold: letters, digits, dot, underscore and hyphen. var snapName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // MaxName is ZFS's limit on a whole name, dataset@snapshot (ZFS_MAX_DATASET_NAME_LEN less its end). const MaxName = 255 // MaxWhy bounds the reason kept with the act. const MaxWhy = 500 // SnapshotArgv is the command for one snapshot: fixed words, the dataset@name after `--`. func SnapshotArgv(dataset, name string, recursive bool) []string { argv := []string{"snapshot"} if recursive { argv = append(argv, "-r") } return append(argv, "--", dataset+"@"+name) } // Made is one snapshot as `zfs list` shows it after the act. type Made struct { Name string `json:"name"` Created string `json:"created"` Used uint64 `json:"used_bytes"` Referenced uint64 `json:"referenced_bytes"` } // SnapshotAnswer is what zfs_snapshot answers: what the list shows after the act, not what was run. type SnapshotAnswer struct { Done bool `json:"done"` Dataset string `json:"dataset"` Name string `json:"snapshot"` Recursive bool `json:"recursive"` Why string `json:"why"` Ran []string `json:"ran"` Made []Made `json:"made"` // Others are the snapshots that came or went on the machine during the act and were not this act's — // another schedule's, say. Empty when nothing else changed. Others []string `json:"others_changed"` Recorded bool `json:"recorded"` Record string `json:"record"` Said string `json:"said"` } // TakeSnapshot takes dataset@name. It refuses an unknown dataset, a name with anything but // [A-Za-z0-9._-], an empty why, and a snapshot that already exists (for -r, on any child too). It // answers what `zfs list` shows afterwards. func (m *Machine) TakeSnapshot(ctx context.Context, dataset, name, why string, recursive bool) (SnapshotAnswer, error) { a := SnapshotAnswer{Dataset: dataset, Name: name, Recursive: recursive, Why: why, Made: []Made{}, Others: []string{}} switch { case dataset == "": return a, fmt.Errorf("name the dataset; zfs_datasets lists them") case strings.ContainsAny(dataset, "@#"): return a, fmt.Errorf("%q is not a dataset: name the filesystem or volume, and the snapshot's name in name", dataset) case name == "": return a, fmt.Errorf("name the snapshot") case !snapName.MatchString(name): return a, fmt.Errorf("the snapshot's name %q may hold only letters, digits, '.', '_' and '-'", name) case len(dataset)+1+len(name) > MaxName: return a, fmt.Errorf("%s@%s is longer than ZFS's %d characters", dataset, name, MaxName) case why == "": return a, fmt.Errorf("say why: a snapshot is a person's act, and the why is kept with it") case strings.ContainsAny(why, "\n\r"): return a, fmt.Errorf("say why in one line") case len(why) > MaxWhy: return a, fmt.Errorf("say why in at most %d characters", MaxWhy) } ds, err := m.Datasets(ctx) if err != nil { return a, err } if find(ds, dataset) == nil { return a, fmt.Errorf("there is no dataset %q on this machine; zfs_datasets lists them", dataset) } before, err := m.Snapshots(ctx) if err != nil { return a, err } covered := []string{dataset} if recursive { for _, d := range ds { if strings.HasPrefix(d.Name, dataset+"/") { covered = append(covered, d.Name) } } } had := names(before) for _, d := range covered { if had[d+"@"+name] { return a, fmt.Errorf("the snapshot %s@%s already exists; choose another name", d, name) } } program, argv := m.Escalated("zfs", SnapshotArgv(dataset, name, recursive)...) a.Ran = append([]string{program}, argv...) _, runErr := m.AsRoot(ctx, "zfs", SnapshotArgv(dataset, name, recursive)...) // What is there now, whatever the command said. after, listErr := m.Snapshots(ctx) now := names(after) expected := map[string]bool{} for _, d := range covered { expected[d+"@"+name] = true } for _, s := range after { if expected[s.Name] { a.Made = append(a.Made, Made{Name: s.Name, Created: s.Created.Local().Format(time.RFC3339), Used: s.Used, Referenced: s.Referenced}) } } if listErr == nil { for n := range now { if !had[n] && !expected[n] { a.Others = append(a.Others, "appeared: "+n) } } for n := range had { if !now[n] { a.Others = append(a.Others, "went: "+n) } } sort.Strings(a.Others) } a.Done = runErr == nil && listErr == nil && len(a.Made) == len(covered) switch { case runErr != nil && len(a.Made) == 0: a.Said = fmt.Sprintf("NOT DONE: %v. No snapshot %s@%s is on the machine.", runErr, dataset, name) case runErr != nil: a.Said = fmt.Sprintf("zfs snapshot failed (%v), and yet the list shows %s.", runErr, madeNames(a.Made)) case listErr != nil: a.Said = fmt.Sprintf("zfs snapshot said it succeeded, and the snapshots could not be listed again to see it: %v.", listErr) case len(a.Made) != len(covered): a.Said = fmt.Sprintf("zfs snapshot said it succeeded, and the list shows %d of the %d snapshots it should have made: %s.", len(a.Made), len(covered), madeNames(a.Made)) default: a.Said = fmt.Sprintf("Made %s, created %s. Why: %s.", madeNames(a.Made), a.Made[0].Created, strings.TrimSuffix(why, ".")) if len(a.Others) == 0 { a.Said += " No other snapshot came or went meanwhile." } else { a.Said += fmt.Sprintf(" Meanwhile, not by this act: %s.", strings.Join(a.Others, ", ")) } } a.Recorded, a.Record = m.record(a) if !a.Recorded { a.Said += " NOT RECORDED on the bus: " + a.Record + "; the pool's own history (zpool history) has the command." } if !a.Done { // Failing is loud: a snapshot that is not there is never answered as made. return a, fmt.Errorf("%s", a.Said) } return a, nil } // record publishes the act as this module's event: the names, the reason and the outcome, nothing else. func (m *Machine) record(a SnapshotAnswer) (bool, string) { if m.Emit == nil { return false, "this process publishes no events" } created := "" if len(a.Made) > 0 { created = a.Made[0].Created } err := m.Emit(SnapshotEvent, map[string]any{"dataset": a.Dataset, "snapshot": a.Name, "recursive": a.Recursive, "why": a.Why, "done": a.Done, "made": madeNames(a.Made), "created": created, "at": m.Now().UTC().Format(time.RFC3339)}) if err != nil { return false, err.Error() } return true, "published as the event " + SnapshotEvent } func names(ss []Snapshot) map[string]bool { out := map[string]bool{} for _, s := range ss { out[s.Name] = true } return out } func madeNames(ms []Made) string { if len(ms) == 0 { return "nothing" } var ns []string for _, x := range ms { ns = append(ns, x.Name) } return strings.Join(ns, ", ") }