// Package zfsadm performs the few mutating ZFS operations the UI offers // (create / destroy datasets). 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) 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 := 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.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 } // 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 ValidateOwner(s string) error { if s == "" || reOwner.MatchString(s) { return nil } return errors.New("owner must be numeric uid:gid, e.g. 1000:1000") }