Files
nasmon/internal/zfsadm/zfsadm.go
T
a.gritsenkoandClaude Opus 5.5 0cdee29359
build / build (push) Successful in 1m4s
ZFS: dedup toggle per pool and dedup option for new datasets
Pool cards show the dedup ratio and can set dedup=on|off on the pool root
dataset (inherited by children); the new-dataset form gets a dedup option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:30:38 +03:00

249 lines
7.0 KiB
Go

// Package zfsadm performs the few mutating ZFS operations the UI offers
// (create / destroy datasets, toggle pool dedup). Commands go through a Runner so they can be
// executed on the host via systemd-run: nasmon runs in a sandboxed mount
// namespace (ProtectSystem=strict), where a plain "zfs create" would mount the
// new dataset only inside the service and leave the host without it.
package zfsadm
import (
"bufio"
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"nasmon/internal/hostrun"
)
// Runner executes a command and returns its combined output; err is non-nil on
// a non-zero exit or when the command could not be started.
type Runner func(ctx context.Context, name string, args ...string) (out string, err error)
// NoInput adapts a hostrun.Runner (which takes stdin) to the Runner used here.
func NoInput(r hostrun.Runner) Runner {
return func(ctx context.Context, name string, args ...string) (string, error) {
return r(ctx, "", name, args...)
}
}
// DirectRunner runs the command in-process (development, tests, non-systemd hosts).
var DirectRunner = NoInput(hostrun.Direct)
// HostRunner runs the command as a transient systemd unit, i.e. in the host's
// mount namespace and outside this service's sandbox.
var HostRunner = NoInput(hostrun.Host)
// AutoRunner picks HostRunner when systemd is managing the system and
// systemd-run is available, DirectRunner otherwise.
func AutoRunner() Runner { return NoInput(hostrun.Auto()) }
// Admin runs dataset operations through Run.
type Admin struct {
Run Runner
Timeout time.Duration
}
func New(run Runner) *Admin { return &Admin{Run: run, Timeout: 60 * time.Second} }
func (a *Admin) ctx() (context.Context, context.CancelFunc) {
t := a.Timeout
if t <= 0 {
t = 60 * time.Second
}
return context.WithTimeout(context.Background(), t)
}
// CreateOpts are the user-visible knobs of "zfs create".
type CreateOpts struct {
Compression string // "", lz4, zstd, off ("" = inherit)
Dedup string // "", on, off ("" = inherit)
Quota string // "", or a size like 100G / 1.5T
Owner string // uid:gid applied to the mountpoint; "" = leave root
}
// Create creates dataset full (pool/name[/sub]) and chowns its mountpoint.
func (a *Admin) Create(full string, o CreateOpts) error {
if err := ValidateFullName(full); err != nil {
return err
}
if err := ValidateCompression(o.Compression); err != nil {
return err
}
if err := ValidateDedup(o.Dedup); err != nil {
return err
}
if err := ValidateSize(o.Quota); err != nil {
return err
}
if err := ValidateOwner(o.Owner); err != nil {
return err
}
args := []string{"create", "-p"}
if o.Compression != "" {
args = append(args, "-o", "compression="+o.Compression)
}
if o.Dedup != "" {
args = append(args, "-o", "dedup="+o.Dedup)
}
if o.Quota != "" {
args = append(args, "-o", "quota="+o.Quota)
}
args = append(args, full)
ctx, cancel := a.ctx()
defer cancel()
if _, err := a.Run(ctx, "zfs", args...); err != nil {
return err
}
if o.Owner == "" {
return nil
}
mp, err := a.Run(ctx, "zfs", "get", "-H", "-o", "value", "mountpoint", full)
if err != nil {
return fmt.Errorf("created, but mountpoint lookup failed: %w", err)
}
if !strings.HasPrefix(mp, "/") {
return fmt.Errorf("created, but dataset is not mounted (mountpoint=%s); chown skipped", mp)
}
if _, err := a.Run(ctx, "chown", o.Owner, mp); err != nil {
return fmt.Errorf("created, but chown failed: %w", err)
}
return nil
}
// SetDedup sets the dedup property (on/off) on the root dataset of pool, so it
// applies to every dataset that does not override it. Only newly written
// blocks are affected either way.
func (a *Admin) SetDedup(pool, value string) error {
if err := ValidateFullName(pool); err != nil {
return err
}
if strings.Contains(pool, "/") {
return errors.New("dedup is set on the pool root dataset only")
}
if value != "on" && value != "off" {
return errors.New("dedup must be on or off")
}
ctx, cancel := a.ctx()
defer cancel()
_, err := a.Run(ctx, "zfs", "set", "dedup="+value, pool)
return err
}
// EmptyRefer is the upper bound of "referenced" bytes for a dataset we still
// consider empty (a fresh dataset refers ~96-128 KiB of metadata).
const EmptyRefer = 1 << 20
// DestroyEmpty destroys full only if, on the live system, it has no children,
// no snapshots and refers less than EmptyRefer bytes.
func (a *Admin) DestroyEmpty(full string) error {
if err := ValidateFullName(full); err != nil {
return err
}
if !strings.Contains(full, "/") {
return errors.New("refusing to destroy a pool root dataset")
}
ctx, cancel := a.ctx()
defer cancel()
out, err := a.Run(ctx, "zfs", "list", "-Hp", "-r", "-t", "filesystem", "-o", "name,refer", full)
if err != nil {
return err
}
sc := bufio.NewScanner(strings.NewReader(out))
rows := 0
var refer uint64
for sc.Scan() {
f := strings.Split(sc.Text(), "\t")
if len(f) < 2 {
continue
}
rows++
if f[0] == full {
refer, _ = strconv.ParseUint(f[1], 10, 64)
}
}
if rows != 1 {
return fmt.Errorf("%s has child datasets", full)
}
if refer >= EmptyRefer {
return fmt.Errorf("%s is not empty (%d bytes referenced)", full, refer)
}
snaps, err := a.Run(ctx, "zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", full)
if err != nil {
return err
}
if strings.TrimSpace(snaps) != "" {
return fmt.Errorf("%s has snapshots", full)
}
_, err = a.Run(ctx, "zfs", "destroy", full)
return err
}
// ---- validation (pure)
var (
reComponent = regexp.MustCompile(`^[A-Za-z0-9_.:-]+$`)
reSize = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?[KMGTPkmgtp]?$`)
reOwner = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
)
// ValidateRelName checks a dataset name relative to its pool, e.g. "media/video".
func ValidateRelName(s string) error {
if s == "" {
return errors.New("name is required")
}
if len(s) > 200 {
return errors.New("name is too long")
}
for _, c := range strings.Split(s, "/") {
switch {
case c == "":
return errors.New("name must not start or end with / or contain //")
case c == "." || c == "..":
return errors.New("name must not contain . or .. components")
case strings.HasPrefix(c, "-"):
return errors.New("name components must not start with -")
case !reComponent.MatchString(c):
return errors.New("name may only contain letters, digits, _ . : - and /")
}
}
return nil
}
// ValidateFullName checks "pool/name..." (or a bare pool).
func ValidateFullName(s string) error {
return ValidateRelName(s)
}
func ValidateSize(s string) error {
if s == "" || reSize.MatchString(s) {
return nil
}
return errors.New("size must look like 100G, 1.5T or 512M")
}
func ValidateCompression(s string) error {
switch s {
case "", "lz4", "zstd", "off":
return nil
}
return errors.New("compression must be inherit, lz4, zstd or off")
}
func ValidateDedup(s string) error {
switch s {
case "", "on", "off":
return nil
}
return errors.New("dedup must be inherit, on or off")
}
func ValidateOwner(s string) error {
if s == "" || reOwner.MatchString(s) {
return nil
}
return errors.New("owner must be numeric uid:gid, e.g. 1000:1000")
}