Files
mesh-catalog/modules/zfs/cmd/zfs-tools/datasets.go
T
jochen 7c8497225d
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
Add the zfs module, so the mesh can see the home server's pools and snapshot a dataset
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.
2026-10-09 00:11:31 +02:00

424 lines
15 KiB
Go

package main
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// DatasetColumns are the properties `zfs list` is asked for, in order: every one in bytes under -p but
// the name, the type, the mountpoint, sharenfs, compression and the compression ratio.
var DatasetColumns = []string{
"name", "type", "mountpoint", "used", "available", "referenced", "compressratio", "sharenfs",
"usedbysnapshots", "usedbydataset", "usedbychildren", "usedbyrefreservation",
"quota", "refquota", "reservation", "refreservation", "logicalused", "logicalreferenced", "compression",
}
// SnapshotColumns are the properties of a snapshot `zfs list -t snapshot` is asked for.
var SnapshotColumns = []string{"name", "creation", "used", "referenced"}
// DatasetsArgv is the one command that lists every filesystem and volume.
func DatasetsArgv() []string {
return []string{"list", "-Hp", "-t", "filesystem,volume", "-o", strings.Join(DatasetColumns, ",")}
}
// SnapshotsArgv is the one command that lists every snapshot, oldest first.
func SnapshotsArgv() []string {
return []string{"list", "-Hp", "-t", "snapshot", "-s", "creation", "-o", strings.Join(SnapshotColumns, ",")}
}
// Dataset is one filesystem or volume, its sizes in bytes.
type Dataset struct {
Name string `json:"name"`
Type string `json:"type"`
Pool string `json:"pool"`
Mountpoint string `json:"mountpoint,omitempty"`
Used uint64 `json:"used"`
Available uint64 `json:"available"`
Referenced uint64 `json:"referenced"`
// CompressRatio is the ratio of the logical to the physical size of what it references, 1.00 for none.
CompressRatio float64 `json:"compressratio"`
Compression string `json:"compression,omitempty"`
ShareNFS string `json:"sharenfs,omitempty"`
UsedBySnapshots uint64 `json:"usedbysnapshots"`
UsedByDataset uint64 `json:"usedbydataset"`
UsedByChildren uint64 `json:"usedbychildren"`
UsedByRefreservation uint64 `json:"usedbyrefreservation"`
// Quota, RefQuota, Reservation and RefReservation are nil when none is set.
Quota *uint64 `json:"quota,omitempty"`
RefQuota *uint64 `json:"refquota,omitempty"`
Reservation *uint64 `json:"reservation,omitempty"`
RefReservation *uint64 `json:"refreservation,omitempty"`
LogicalUsed uint64 `json:"logicalused"`
LogicalReferenced uint64 `json:"logicalreferenced"`
}
// Snapshot is one snapshot.
type Snapshot struct {
Name string `json:"name"`
Dataset string `json:"dataset"`
Snap string `json:"snapshot"`
Created time.Time `json:"created"`
// Used is the space only this snapshot holds: what destroying it alone would free.
Used uint64 `json:"used"`
Referenced uint64 `json:"referenced"`
}
// ParseDatasets reads `zfs list -Hp -o <DatasetColumns>`. A line that does not have every column is
// refused by name, never skipped silently.
func ParseDatasets(out string) ([]Dataset, error) {
var ds []Dataset
for _, line := range lines(out) {
f := strings.Split(line, "\t")
if len(f) != len(DatasetColumns) {
return nil, fmt.Errorf("zfs list answered a line with %d columns, not %d: %q", len(f), len(DatasetColumns), line)
}
col := func(name string) string { return f[index(DatasetColumns, name)] }
d := Dataset{Name: col("name"), Type: col("type"), Pool: poolOf(col("name")), Compression: dash(col("compression")),
Mountpoint: dash(col("mountpoint")), ShareNFS: dash(col("sharenfs"))}
var err error
num := func(name string) uint64 {
v, e := number(col(name))
if e != nil && err == nil {
err = fmt.Errorf("zfs list: %s of %s: %v", name, d.Name, e)
}
return v
}
d.Used, d.Available, d.Referenced = num("used"), num("available"), num("referenced")
d.UsedBySnapshots, d.UsedByDataset = num("usedbysnapshots"), num("usedbydataset")
d.UsedByChildren, d.UsedByRefreservation = num("usedbychildren"), num("usedbyrefreservation")
d.LogicalUsed, d.LogicalReferenced = num("logicalused"), num("logicalreferenced")
d.Quota, d.RefQuota = optional(col("quota")), optional(col("refquota"))
d.Reservation, d.RefReservation = optional(col("reservation")), optional(col("refreservation"))
if err != nil {
return nil, err
}
cr, rerr := ratio(col("compressratio"))
if rerr != nil {
return nil, fmt.Errorf("zfs list: compressratio of %s: %v", d.Name, rerr)
}
d.CompressRatio = cr
ds = append(ds, d)
}
return ds, nil
}
// ParseSnapshots reads `zfs list -Hp -t snapshot -o <SnapshotColumns>`.
func ParseSnapshots(out string) ([]Snapshot, error) {
var ss []Snapshot
for _, line := range lines(out) {
f := strings.Split(line, "\t")
if len(f) != len(SnapshotColumns) {
return nil, fmt.Errorf("zfs list -t snapshot answered a line with %d columns, not %d: %q", len(f), len(SnapshotColumns), line)
}
at := strings.IndexByte(f[0], '@')
if at < 1 {
return nil, fmt.Errorf("zfs list -t snapshot answered %q, which is not a snapshot's name", f[0])
}
s := Snapshot{Name: f[0], Dataset: f[0][:at], Snap: f[0][at+1:]}
created, err := strconv.ParseInt(f[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("zfs list: creation of %s is %q, not seconds", s.Name, f[1])
}
s.Created = time.Unix(created, 0)
if s.Used, err = number(f[2]); err != nil {
return nil, fmt.Errorf("zfs list: used of %s: %v", s.Name, err)
}
if s.Referenced, err = number(f[3]); err != nil {
return nil, fmt.Errorf("zfs list: referenced of %s: %v", s.Name, err)
}
ss = append(ss, s)
}
return ss, nil
}
// Datasets are every filesystem and volume on the machine.
func (m *Machine) Datasets(ctx context.Context) ([]Dataset, error) {
out, err := m.ask(ctx, "zfs", DatasetsArgv()...)
if err != nil {
return nil, err
}
return ParseDatasets(out)
}
// Snapshots are every snapshot on the machine, oldest first.
func (m *Machine) Snapshots(ctx context.Context) ([]Snapshot, error) {
out, err := m.ask(ctx, "zfs", SnapshotsArgv()...)
if err != nil {
return nil, err
}
return ParseSnapshots(out)
}
// DatasetRow is one dataset as zfs_datasets answers it.
type DatasetRow struct {
Name string `json:"name"`
Type string `json:"type"`
Mountpoint string `json:"mountpoint,omitempty"`
Used uint64 `json:"used_bytes"`
Available uint64 `json:"available_bytes"`
Referenced uint64 `json:"referenced_bytes"`
CompressRatio float64 `json:"compressratio"`
ShareNFS string `json:"sharenfs,omitempty"`
Snapshots int `json:"snapshots"`
// UsedBySnapshots is the space the snapshots hold together: what destroying all of them would free.
UsedBySnapshots uint64 `json:"usedbysnapshots_bytes"`
Said string `json:"said"`
}
// DatasetsAnswer is what zfs_datasets answers.
type DatasetsAnswer struct {
Applicable bool `json:"applicable"`
Datasets []DatasetRow `json:"datasets"`
Count int `json:"count"`
Said []string `json:"said"`
}
// DatasetsNow lists every dataset with its space and how many snapshots it has.
func (m *Machine) DatasetsNow(ctx context.Context) (any, error) {
ds, err := m.Datasets(ctx)
if err != nil {
return notHere(err)
}
ss, err := m.Snapshots(ctx)
if err != nil {
return nil, err
}
count := map[string]int{}
for _, s := range ss {
count[s.Dataset]++
}
a := DatasetsAnswer{Applicable: true, Datasets: []DatasetRow{}, Said: []string{}}
for _, d := range ds {
r := DatasetRow{Name: d.Name, Type: d.Type, Mountpoint: d.Mountpoint, Used: d.Used, Available: d.Available,
Referenced: d.Referenced, CompressRatio: d.CompressRatio, ShareNFS: d.ShareNFS, Snapshots: count[d.Name],
UsedBySnapshots: d.UsedBySnapshots}
where := d.Mountpoint
if where == "" {
where = d.Type
}
r.Said = fmt.Sprintf("%s (%s): %s used, %s free, %d snapshots holding %s, compression %.2fx",
d.Name, where, bytesSaid(d.Used), bytesSaid(d.Available), r.Snapshots, bytesSaid(d.UsedBySnapshots), d.CompressRatio)
if d.ShareNFS != "" && d.ShareNFS != "off" {
r.Said += ", sharenfs " + d.ShareNFS
}
a.Datasets = append(a.Datasets, r)
a.Said = append(a.Said, r.Said)
}
a.Count = len(a.Datasets)
return a, nil
}
// SnapshotRow is one snapshot as zfs_snapshots answers it.
type SnapshotRow struct {
Name string `json:"name"`
Dataset string `json:"dataset"`
Created string `json:"created"`
Used uint64 `json:"used_bytes"`
Referenced uint64 `json:"referenced_bytes"`
}
// SnapshotsAnswer is what zfs_snapshots answers.
type SnapshotsAnswer struct {
Applicable bool `json:"applicable"`
Dataset string `json:"dataset,omitempty"`
Snapshots []SnapshotRow `json:"snapshots"`
Count int `json:"count"`
// Unique is the sum of the snapshots' own space: less than they hold together, which a dataset's
// usedbysnapshots says.
Unique uint64 `json:"used_bytes_sum"`
Said string `json:"said"`
}
// SnapshotsOf lists the snapshots of one dataset (exactly that one, not its children), or of all.
func (m *Machine) SnapshotsOf(ctx context.Context, dataset string) (any, error) {
if dataset != "" {
ds, err := m.Datasets(ctx)
if err != nil {
return notHere(err)
}
if find(ds, dataset) == nil {
return nil, fmt.Errorf("there is no dataset %q on this machine; zfs_datasets lists them", dataset)
}
}
ss, err := m.Snapshots(ctx)
if err != nil {
return notHere(err)
}
a := SnapshotsAnswer{Applicable: true, Dataset: dataset, Snapshots: []SnapshotRow{}}
for _, s := range ss {
if dataset != "" && s.Dataset != dataset {
continue
}
a.Snapshots = append(a.Snapshots, SnapshotRow{Name: s.Name, Dataset: s.Dataset, Created: s.Created.Local().Format(time.RFC3339),
Used: s.Used, Referenced: s.Referenced})
a.Unique += s.Used
}
a.Count = len(a.Snapshots)
of := "on this machine"
if dataset != "" {
of = "of " + dataset
}
a.Said = fmt.Sprintf("%d snapshots %s, oldest first; each one's own space adds up to %s.", a.Count, of, bytesSaid(a.Unique))
return a, nil
}
// SpaceRow is one dataset's space as zfs_space answers it.
type SpaceRow struct {
Name string `json:"name"`
Used uint64 `json:"used_bytes"`
Available uint64 `json:"available_bytes"`
UsedByDataset uint64 `json:"usedbydataset_bytes"`
UsedBySnapshots uint64 `json:"usedbysnapshots_bytes"`
UsedByChildren uint64 `json:"usedbychildren_bytes"`
UsedByRefreservation uint64 `json:"usedbyrefreservation_bytes"`
Quota *uint64 `json:"quota_bytes,omitempty"`
RefQuota *uint64 `json:"refquota_bytes,omitempty"`
Reservation *uint64 `json:"reservation_bytes,omitempty"`
RefReservation *uint64 `json:"refreservation_bytes,omitempty"`
CompressRatio float64 `json:"compressratio"`
Compression string `json:"compression,omitempty"`
LogicalUsed uint64 `json:"logicalused_bytes"`
// Saved is what compression saves: the logical size less the physical, never below zero.
Saved uint64 `json:"saved_bytes"`
Said string `json:"said"`
}
// SpaceAnswer is what zfs_space answers.
type SpaceAnswer struct {
Applicable bool `json:"applicable"`
Pools []PoolRow `json:"pools"`
Datasets []SpaceRow `json:"datasets"`
Said []string `json:"said"`
}
// Space is every pool's space and every dataset's, biggest first, with what uses it.
func (m *Machine) Space(ctx context.Context) (any, error) {
pools, err := m.Pools(ctx)
if err != nil {
return notHere(err)
}
ds, err := m.Datasets(ctx)
if err != nil {
return nil, err
}
a := SpaceAnswer{Applicable: true, Pools: []PoolRow{}, Datasets: []SpaceRow{}, Said: []string{}}
for _, p := range pools {
r := p.Row()
a.Pools = append(a.Pools, r)
a.Said = append(a.Said, r.Said)
}
sort.SliceStable(ds, func(i, j int) bool { return ds[i].Used > ds[j].Used })
for _, d := range ds {
r := SpaceRow{Name: d.Name, Used: d.Used, Available: d.Available, UsedByDataset: d.UsedByDataset,
UsedBySnapshots: d.UsedBySnapshots, UsedByChildren: d.UsedByChildren, UsedByRefreservation: d.UsedByRefreservation,
Quota: d.Quota, RefQuota: d.RefQuota, Reservation: d.Reservation, RefReservation: d.RefReservation,
CompressRatio: d.CompressRatio, Compression: d.Compression, LogicalUsed: d.LogicalUsed}
if d.LogicalUsed > d.Used {
r.Saved = d.LogicalUsed - d.Used
}
r.Said = fmt.Sprintf("%s: %s used (itself %s, snapshots %s, children %s, refreservation %s), %s free; "+
"%s logical, %.2fx, %s saved", d.Name, bytesSaid(d.Used), bytesSaid(d.UsedByDataset), bytesSaid(d.UsedBySnapshots),
bytesSaid(d.UsedByChildren), bytesSaid(d.UsedByRefreservation), bytesSaid(d.Available), bytesSaid(d.LogicalUsed),
d.CompressRatio, bytesSaid(r.Saved))
for _, l := range []struct {
name string
v *uint64
}{{"quota", d.Quota}, {"refquota", d.RefQuota}, {"reservation", d.Reservation}, {"refreservation", d.RefReservation}} {
if l.v != nil {
r.Said += fmt.Sprintf(", %s %s", l.name, bytesSaid(*l.v))
}
}
a.Datasets = append(a.Datasets, r)
a.Said = append(a.Said, r.Said)
}
return a, nil
}
// notHere answers a machine without ZFS as not applicable, and any other failure as itself.
func notHere(err error) (any, error) {
if a, ok := err.(errAbsent); ok {
return NotApplicable{Applicable: false, Said: a.why}, nil
}
return nil, err
}
func find(ds []Dataset, name string) *Dataset {
for i := range ds {
if ds[i].Name == name {
return &ds[i]
}
}
return nil
}
func poolOf(name string) string {
if i := strings.IndexAny(name, "/@"); i >= 0 {
return name[:i]
}
return name
}
func lines(out string) []string {
var ls []string
for _, l := range strings.Split(out, "\n") {
if strings.TrimSpace(l) != "" {
ls = append(ls, strings.TrimRight(l, "\r"))
}
}
return ls
}
func index(cols []string, name string) int {
for i, c := range cols {
if c == name {
return i
}
}
panic("no column " + name)
}
// dash is a property's value, "" for ZFS's "-" (not applicable).
func dash(s string) string {
if s == "-" {
return ""
}
return s
}
// number is a size under -p: whole bytes. "-" (not applicable to this type) is 0.
func number(s string) (uint64, error) {
if s == "-" || s == "" {
return 0, nil
}
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("%q is not a whole number of bytes", s)
}
return v, nil
}
// optional is a quota or a reservation: nil when none is set (0 under -p, or "-" or "none").
func optional(s string) *uint64 {
if s == "-" || s == "none" || s == "" {
return nil
}
v, err := strconv.ParseUint(s, 10, 64)
if err != nil || v == 0 {
return nil
}
return &v
}
// ratio is a compression or dedup ratio, "1.52" under -p or "1.52x" without.
func ratio(s string) (float64, error) {
if s == "-" || s == "" {
return 1, nil
}
return strconv.ParseFloat(strings.TrimSuffix(s, "x"), 64)
}