251 lines
9.5 KiB
Go
251 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MeshPrefix begins the name of every ZFS snapshot the scheduled run takes (§21): a person's snapshot never
|
|
// does, so the run's snapshots and the person's stay apart (§23).
|
|
const MeshPrefix = "mesh-"
|
|
|
|
// 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, by 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 strings.HasPrefix(name, MeshPrefix):
|
|
return a, fmt.Errorf("the snapshot's name may not begin %q: those are the scheduled run's, and a person's snapshot stays apart from them", MeshPrefix)
|
|
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, by)
|
|
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, by string) (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(EventTakenPerson, map[string]any{"dataset": a.Dataset, "snapshot": a.Dataset + "@" + a.Name, "by": by,
|
|
"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 " + EventTakenPerson
|
|
}
|
|
|
|
// 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, ", ")
|
|
}
|