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 `. 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 `. 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) }