build / build (push) Successful in 1m4s
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>
249 lines
7.0 KiB
Go
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")
|
|
}
|