mesh/merge-gate pass: builds new: modules/zfs, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestASnapshotIsAnsweredFromTheListAfterwardsAndRecorded (0.00s)
mesh/delivery superseded: a newer head of the same pull request
The home server's pool holds the operator's media library, and no mesh tool could say whether it is healthy, how full it is, when it was last scrubbed or what snapshots exist; the only way was a login. This module reads all of it with fixed zfs and zpool commands (list, get, status, iostat, events, history) and has one act, zfs_snapshot, which only adds a snapshot: refused for an unknown dataset, a name outside [A-Za-z0-9._-], an existing snapshot or no why; run as sudo -n zfs snapshot -- ds@name; answered from zfs list afterwards, with any other snapshot that came or went meanwhile; recorded as the event snapshot.taken. No destroy, rollback, set or send exists, and a test holds every command to its verb. Health (ADR 0240): zfs_health on the watch process, unhealthy for a pool not ONLINE, device or data errors, a pool past 90 % full or no ZFS at all; past 80 % and a scrub older than 35 days are warnings. Beside it, the event follower (zpool events -f, as root) keeps ZFS's errors in its journal past a reboot and is judged by its unit, the check the module does not run itself. No seat (ADR 0121), no package: ZFS is the machine's.
208 lines
7.1 KiB
Go
208 lines
7.1 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). 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, ", ")
|
|
}
|