Initial import: nasmon health monitor, split out of home_nas
Go service (nasmon/) plus nfpm packaging (packaging/), carried over from the home_nas monorepo. Adds rpm packaging alongside the existing deb build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea Actions workflow that builds both formats on every push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user