Files
mesh-catalog/modules/zfs/cmd/zfs-tools/snapshot.go
T
jochen e69506a26f
mesh/merge-gate pass: builds zfs, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Make the zfs module safe to put on the home server, and give it the pool's scrub schedule
The safety review of the first version found it safe for the pool's data and asked for these fixes before
it goes on the home server:

- zfs_snapshot: no recursive. Every answer gives the dataset's free space and the pool's capacity, and
  says that the snapshot holds every block deleted or overwritten until it is destroyed by hand.
- A command that runs out of time is ended with its whole process group. An act that timed out is
  answered from the list, or as "outcome unknown", never as "NOT DONE".
- The watch runs as the operator account.
- The event follower reads ZFS's queue from its start, with a cursor per boot, so the pool's import and
  any downtime reach the journal once. It writes "dropped N events", kills zpool before waiting for it,
  and exits non-zero after five failed follows in a row.
- Past error counts are a warning, and counts that rise are unhealthy.
- The guard test catches .Run(, exec.Command(, exec.CommandContext( and process starts.
- The fixtures and the parser follow OpenZFS 2.4.4's own printers: human scan sizes under -p, no iostat
  section lines, error scrubs, per-vdev rebuild resilvers, paused scrubs.

The operator also asked the module to own the scrub. A timer and a service of the module's own run
zpool scrub -w -- <pool> from the scrub-pool, scrub-cadence, scrub-day and scrub-time settings. In the
same declaration, the distribution's weekly and monthly timers for that pool are held stopped and
disabled, so exactly one schedule scrubs it. zfs_scrub shows the schedule and the next run, with any
resilver's progress. zfs_scrub_start, zfs_scrub_pause and zfs_scrub_stop act with why. The health check
warns of a late or failed scrub and of a running resilver, and is unhealthy for a resilver that finished
with errors.
2026-10-09 01:17:34 +02:00

249 lines
9.2 KiB
Go

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, ", ")
}