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). It snapshots one dataset only, never its children (no -r in this version). // // **A snapshot keeps space.** From the moment it is taken it holds every block of the dataset that is // deleted or overwritten afterwards, so that space is not freed until the snapshot is destroyed, which // no tool of the mesh does. Every answer says so, with the dataset's free space and the pool's capacity. // 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) []string { return []string{"snapshot", "--", 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"` // Outcome is made, not made, or unknown (the command ran out of time and the list does not show it). Outcome string `json:"outcome"` Dataset string `json:"dataset"` Name string `json:"snapshot"` Why string `json:"why"` Ran []string `json:"ran"` Made []Made `json:"made"` // Available is the dataset's free space and PoolCapacity its pool's capacity, as the list says them // after the act. Pool string `json:"pool"` Available uint64 `json:"available_bytes"` PoolCapacity int `json:"pool_capacity_pct"` PoolFree uint64 `json:"pool_free_bytes"` Holds string `json:"holds"` // 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. It answers what `zfs list` shows // afterwards. func (m *Machine) TakeSnapshot(ctx context.Context, dataset, name, why string) (SnapshotAnswer, error) { a := SnapshotAnswer{Dataset: dataset, Name: name, Why: why, Made: []Made{}, Others: []string{}, Pool: poolOf(dataset)} 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} 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)...) a.Ran = append([]string{program}, argv...) _, runErr := m.AsRoot(ctx, "zfs", SnapshotArgv(dataset, name)...) _, timedOut := runErr.(errTimedOut) // 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 command that ran out of time may still have made it: the list is the answer. a.Done = (runErr == nil || timedOut) && listErr == nil && len(a.Made) == len(covered) a.Holds = fmt.Sprintf("%s@%s holds every block of %s deleted or overwritten from now on, so that space "+ "is not freed until it is destroyed by hand; no tool of the mesh destroys a snapshot", dataset, name, dataset) space := m.spaceAfter(ctx, &a) switch { case timedOut && !a.Done: a.Outcome = "unknown" a.Said = fmt.Sprintf("zfs snapshot did not answer within its time and was ended, and the list does not show %s@%s: "+ "outcome unknown; check zfs_snapshots.", dataset, name) case runErr != nil && !timedOut && len(a.Made) == 0: a.Outcome = "not made" a.Said = fmt.Sprintf("NOT DONE: %v. No snapshot %s@%s is on the machine.", runErr, dataset, name) case runErr != nil && !timedOut: a.Outcome = "unknown" a.Said = fmt.Sprintf("zfs snapshot failed (%v), and yet the list shows %s: outcome unknown; check zfs_snapshots.", runErr, madeNames(a.Made)) case listErr != nil: a.Outcome = "unknown" a.Said = fmt.Sprintf("zfs snapshot said it succeeded, and the snapshots could not be listed again to see it (%v): "+ "outcome unknown; check zfs_snapshots.", listErr) case len(a.Made) != len(covered): a.Outcome = "not made" 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.Outcome = "made" 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.Said += " " + space + " " + a.Holds + "." } 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, "why": a.Why, "done": a.Done, "outcome": a.Outcome, "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 } // spaceAfter reads the dataset's free space and its pool's capacity after the act, and says them. func (m *Machine) spaceAfter(ctx context.Context, a *SnapshotAnswer) string { var parts []string if ds, err := m.Datasets(ctx); err == nil { if d := find(ds, a.Dataset); d != nil { a.Available = d.Available parts = append(parts, fmt.Sprintf("%s has %s free", a.Dataset, bytesSaid(d.Available))) } } if ps, err := m.Pools(ctx); err == nil { for _, p := range ps { if p.Name == a.Pool { a.PoolCapacity, a.PoolFree = p.Capacity, p.Free parts = append(parts, fmt.Sprintf("pool %s %d %% full (%s free)", p.Name, p.Capacity, bytesSaid(p.Free))) } } } if len(parts) == 0 { return "The free space could not be read after the act." } return "Now " + strings.Join(parts, ", ") + "." } 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, ", ") }