A trim schedule of weekly or monthly with no trim-day or trim-time is one refused value naming it; a keep count of 0 is that kind's none: the existing scheduled ZFS snapshots are kept and a warning names them. Builds: design 42 zfs-owns-zfs §20, §21, §22, A-33, A-35
480 lines
15 KiB
Go
480 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// The module's config file and the forms of its values (design 42 zfs-owns-zfs §9, §11).
|
|
//
|
|
// The node-engine writes ConfigFile as one JSON object of the values that are set (`${config-json}`, A-15);
|
|
// an unset value is absent. **A value that is absent, an empty string, an empty list or an empty object is
|
|
// not managed**: the module writes nothing, converges nothing and reports no difference for it, so a blank
|
|
// value never removes anything. A value of another form is refused here, before any file is written or
|
|
// command run; the caller says each refusal as `config.refused` and acts on none of the file (§25).
|
|
|
|
// ConfigFile is the module's config file; the apply and the scheduled run read it at each start.
|
|
const ConfigFile = "/etc/zfs-tools/config.json"
|
|
|
|
// Refusal is one value the parse refused: `config.refused` {key, value, reason}.
|
|
type Refusal struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// ModuleOption is one `name=digits` entry of module-options.
|
|
type ModuleOption struct{ Name, Value string }
|
|
|
|
// Keeps are the keep counts of `snapshots`; None is the value `none`.
|
|
type Keeps struct {
|
|
None bool
|
|
Hourly, Daily, Weekly, Monthly int
|
|
}
|
|
|
|
// Config is the module's values as set. A pointer, an empty string, a nil list or map is not managed.
|
|
type Config struct {
|
|
Pool string
|
|
ScrubCadence, ScrubDay, ScrubTime string
|
|
ArcMax, ArcMin *uint64 // MiB
|
|
ModuleOptions []ModuleOption
|
|
HostID string // eight lower-case hexadecimal digits
|
|
Zed string
|
|
ZedHandlers []string
|
|
TrimSchedule, TrimDay, TrimTime string
|
|
CacheDevices, LogDevices []string
|
|
LogMirror *bool
|
|
PoolProperties map[string]string
|
|
DatasetProperties map[string]map[string]string
|
|
Snapshots *Keeps
|
|
}
|
|
|
|
var (
|
|
moduleOptionNames = map[string]bool{"l2arc_write_max": true, "l2arc_write_boost": true, "l2arc_headroom": true,
|
|
"l2arc_noprefetch": true, "l2arc_rebuild_enabled": true, "zfs_txg_timeout": true, "zfs_dirty_data_max": true,
|
|
"zfs_scan_vdev_limit": true, "zfs_scrub_min_time_ms": true, "zfs_resilver_min_time_ms": true, "zfs_arc_sys_free": true}
|
|
hostIDForm = regexp.MustCompile(`^[0-9a-fA-F]{8}$`)
|
|
digitsForm = regexp.MustCompile(`^[0-9]+$`)
|
|
deviceForm = regexp.MustCompile(`^[A-Za-z0-9_.:-]+$`)
|
|
datasetForm = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.:-]*(/[A-Za-z0-9_.:-]+)*$`)
|
|
recordsizeRe = regexp.MustCompile(`^(512|1K|2K|4K|8K|16K|32K|64K|128K|256K|512K|1M|2M|4M|8M|16M)$`)
|
|
sizeForm = regexp.MustCompile(`^[0-9]+[KMGT]$`)
|
|
zedHandlerOK = map[string]bool{"all-syslog": true, "resilver_finish-start-scrub": true}
|
|
)
|
|
|
|
// keepKinds are the kinds of `snapshots`, in the order they are said.
|
|
var keepKinds = []string{"hourly", "daily", "weekly", "monthly"}
|
|
|
|
// MaxKeep is the bound of a keep count: a week of hours (§11), so the ledger's cap stays bounded.
|
|
const MaxKeep = 168
|
|
|
|
// poolPropertyForm and datasetPropertyForm hold each listed property's form (§11, §16); "" is the form's
|
|
// failure, and every property also takes `default`.
|
|
func poolPropertyForm(p, v string) string {
|
|
switch p {
|
|
case "autotrim", "autoexpand", "autoreplace":
|
|
return oneOf(v, "on", "off", "default")
|
|
case "failmode":
|
|
return oneOf(v, "wait", "continue", "panic", "default")
|
|
}
|
|
return "is not a pool property the module sets (autotrim, autoexpand, autoreplace, failmode)"
|
|
}
|
|
|
|
func oneOf(v string, ok ...string) string {
|
|
for _, o := range ok {
|
|
if v == o {
|
|
return ""
|
|
}
|
|
}
|
|
return "is not one of " + strings.Join(ok, ", ")
|
|
}
|
|
|
|
func datasetPropertyForm(p, v string) string {
|
|
if v == "default" {
|
|
switch p {
|
|
case "compression", "atime", "relatime", "recordsize", "primarycache", "secondarycache", "sync", "logbias", "quota", "reservation":
|
|
return ""
|
|
}
|
|
}
|
|
switch p {
|
|
case "compression":
|
|
if v == "off" || v == "lz4" || v == "zle" || v == "zstd" {
|
|
return ""
|
|
}
|
|
if n, ok := strings.CutPrefix(v, "zstd-"); ok {
|
|
if i, err := strconv.Atoi(n); err == nil && i >= 1 && i <= 19 && strconv.Itoa(i) == n {
|
|
return ""
|
|
}
|
|
}
|
|
if n, ok := strings.CutPrefix(v, "gzip-"); ok {
|
|
if i, err := strconv.Atoi(n); err == nil && i >= 1 && i <= 9 && strconv.Itoa(i) == n {
|
|
return ""
|
|
}
|
|
}
|
|
return "is not a compression of the list (off, lz4, zstd, zstd-1 to zstd-19, gzip-1 to gzip-9, zle)"
|
|
case "atime", "relatime":
|
|
return oneOf(v, "on", "off")
|
|
case "recordsize":
|
|
if !recordsizeRe.MatchString(v) {
|
|
return "is not a power of two from 512 to 16M, with K or M"
|
|
}
|
|
return ""
|
|
case "primarycache", "secondarycache":
|
|
return oneOf(v, "all", "metadata", "none")
|
|
case "sync":
|
|
return oneOf(v, "standard", "always", "disabled")
|
|
case "logbias":
|
|
return oneOf(v, "latency", "throughput")
|
|
case "quota", "reservation":
|
|
if v == "none" || sizeForm.MatchString(v) {
|
|
return ""
|
|
}
|
|
return "is not none, or digits with K, M, G or T"
|
|
}
|
|
return "is not a dataset property of the list (compression, atime, relatime, recordsize, primarycache, secondarycache, sync, logbias, quota, reservation)"
|
|
}
|
|
|
|
type parser struct {
|
|
raw map[string]json.RawMessage
|
|
out []Refusal
|
|
}
|
|
|
|
func (p *parser) refuse(key string, v json.RawMessage, why string) {
|
|
val := strings.TrimSpace(string(v))
|
|
if len(val) > 200 {
|
|
val = val[:200] + "..."
|
|
}
|
|
p.out = append(p.out, Refusal{Key: key, Value: val, Reason: why})
|
|
}
|
|
|
|
// str reads a string value; ok is false when it is not managed (absent or empty) or refused.
|
|
func (p *parser) str(key string, form func(string) string) (string, bool) {
|
|
v, has := p.raw[key]
|
|
if !has || string(bytes.TrimSpace(v)) == "null" {
|
|
return "", false
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(v, &s); err != nil {
|
|
p.refuse(key, v, "is not a string")
|
|
return "", false
|
|
}
|
|
if s == "" {
|
|
return "", false
|
|
}
|
|
if why := form(s); why != "" {
|
|
p.refuse(key, v, why)
|
|
return "", false
|
|
}
|
|
return s, true
|
|
}
|
|
|
|
func (p *parser) list(key string) ([]string, json.RawMessage, bool) {
|
|
v, has := p.raw[key]
|
|
if !has || string(bytes.TrimSpace(v)) == "null" {
|
|
return nil, nil, false
|
|
}
|
|
var l []string
|
|
if err := json.Unmarshal(v, &l); err != nil {
|
|
p.refuse(key, v, "is not a list of strings")
|
|
return nil, nil, false
|
|
}
|
|
if len(l) == 0 {
|
|
return nil, nil, false
|
|
}
|
|
return l, v, true
|
|
}
|
|
|
|
func (p *parser) number(key string) (uint64, json.RawMessage, bool) {
|
|
v, has := p.raw[key]
|
|
if !has || string(bytes.TrimSpace(v)) == "null" {
|
|
return 0, nil, false
|
|
}
|
|
if t := bytes.TrimSpace(v); len(t) == 0 || t[0] == '"' {
|
|
p.refuse(key, v, "is not a whole number of MiB")
|
|
return 0, nil, false
|
|
}
|
|
var n json.Number
|
|
dec := json.NewDecoder(bytes.NewReader(v))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&n); err != nil {
|
|
p.refuse(key, v, "is not a whole number of MiB")
|
|
return 0, nil, false
|
|
}
|
|
u, err := strconv.ParseUint(n.String(), 10, 64)
|
|
if err != nil {
|
|
p.refuse(key, v, "is not a whole number of MiB")
|
|
return 0, nil, false
|
|
}
|
|
return u, v, true
|
|
}
|
|
|
|
func (p *parser) object(key string) (map[string]json.RawMessage, json.RawMessage, bool) {
|
|
v, has := p.raw[key]
|
|
if !has || string(bytes.TrimSpace(v)) == "null" {
|
|
return nil, nil, false
|
|
}
|
|
var o map[string]json.RawMessage
|
|
if err := json.Unmarshal(v, &o); err != nil {
|
|
p.refuse(key, v, "is not an object")
|
|
return nil, nil, false
|
|
}
|
|
if len(o) == 0 {
|
|
return nil, nil, false
|
|
}
|
|
return o, v, true
|
|
}
|
|
|
|
func sortedKeys[V any](m map[string]V) []string {
|
|
k := make([]string, 0, len(m))
|
|
for x := range m {
|
|
k = append(k, x)
|
|
}
|
|
sort.Strings(k)
|
|
return k
|
|
}
|
|
|
|
// ParseConfig reads the config file's text. memMiB is the node's memory from /proc/meminfo, in MiB (0 when
|
|
// it could not be read, which refuses arc-max). It answers every refusal; a Config with any refusal is
|
|
// never acted on.
|
|
func ParseConfig(text []byte, memMiB uint64) (*Config, []Refusal) {
|
|
p := &parser{}
|
|
if err := json.Unmarshal(text, &p.raw); err != nil {
|
|
return nil, []Refusal{{Key: ConfigFile, Value: "", Reason: "is not a JSON object: " + err.Error()}}
|
|
}
|
|
c := &Config{}
|
|
c.Pool, _ = p.str("pool", func(s string) string {
|
|
if !poolName.MatchString(s) {
|
|
return "is not a pool's name"
|
|
}
|
|
return ""
|
|
})
|
|
c.ScrubCadence, _ = p.str("scrub-cadence", func(s string) string { return oneOf(s, "weekly", "monthly") })
|
|
c.ScrubDay, _ = p.str("scrub-day", func(s string) string {
|
|
return oneOf(s, "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
})
|
|
c.ScrubTime, _ = p.str("scrub-time", clockForm)
|
|
c.TrimSchedule, _ = p.str("trim-schedule", func(s string) string { return oneOf(s, "none", "weekly", "monthly") })
|
|
c.TrimDay, _ = p.str("trim-day", func(s string) string {
|
|
return oneOf(s, "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
})
|
|
c.TrimTime, _ = p.str("trim-time", clockForm)
|
|
// A-33: a trim schedule of weekly or monthly with no day or no time runs no trim and is said, as one refused value.
|
|
if c.TrimSchedule == "weekly" || c.TrimSchedule == "monthly" {
|
|
var missing []string
|
|
for _, need := range []struct{ key, have string }{{"trim-day", c.TrimDay}, {"trim-time", c.TrimTime}} {
|
|
if need.have == "" {
|
|
missing = append(missing, need.key)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
p.refuse("trim-schedule", p.raw["trim-schedule"], "needs "+strings.Join(missing, " and ")+" set: without it no trim runs")
|
|
}
|
|
}
|
|
c.Zed, _ = p.str("zed", func(s string) string { return oneOf(s, "on", "off") })
|
|
if h, ok := p.str("hostid", func(s string) string {
|
|
switch {
|
|
case !hostIDForm.MatchString(s):
|
|
return "is not eight hexadecimal digits"
|
|
case strings.Trim(s, "0") == "":
|
|
return "is all zero"
|
|
}
|
|
return ""
|
|
}); ok {
|
|
c.HostID = strings.ToLower(h)
|
|
}
|
|
|
|
var arcMin json.RawMessage
|
|
if n, raw, ok := p.number("arc-max"); ok {
|
|
switch {
|
|
case n < 64:
|
|
p.refuse("arc-max", raw, "is below 64 MiB")
|
|
case memMiB == 0:
|
|
p.refuse("arc-max", raw, "cannot be checked against the node's memory, which /proc/meminfo did not say")
|
|
case n >= memMiB:
|
|
p.refuse("arc-max", raw, fmt.Sprintf("is not below the node's memory of %d MiB", memMiB))
|
|
default:
|
|
c.ArcMax = &n
|
|
}
|
|
}
|
|
if n, raw, ok := p.number("arc-min"); ok {
|
|
arcMin = raw
|
|
c.ArcMin = &n
|
|
}
|
|
if c.ArcMin != nil && c.ArcMax != nil && *c.ArcMin > *c.ArcMax {
|
|
p.refuse("arc-min", arcMin, "is above arc-max")
|
|
c.ArcMin = nil
|
|
}
|
|
|
|
if l, raw, ok := p.list("module-options"); ok {
|
|
seen := map[string]bool{}
|
|
for _, e := range l {
|
|
name, val, has := strings.Cut(e, "=")
|
|
switch {
|
|
case !has || !digitsForm.MatchString(val) || len(val) > 20:
|
|
p.refuse("module-options", raw, fmt.Sprintf("%q is not name=digits", e))
|
|
case name == "zfs_arc_max" || name == "zfs_arc_min":
|
|
p.refuse("module-options", raw, name+" is held by arc-max and arc-min")
|
|
case !moduleOptionNames[name]:
|
|
p.refuse("module-options", raw, fmt.Sprintf("%q is not a parameter of the module's list", name))
|
|
case seen[name]:
|
|
p.refuse("module-options", raw, name+" is given twice")
|
|
default:
|
|
seen[name] = true
|
|
c.ModuleOptions = append(c.ModuleOptions, ModuleOption{name, val})
|
|
}
|
|
}
|
|
}
|
|
if l, raw, ok := p.list("zed-handlers"); ok {
|
|
seen := map[string]bool{}
|
|
for _, h := range l {
|
|
switch {
|
|
case !zedHandlerOK[h]:
|
|
p.refuse("zed-handlers", raw, fmt.Sprintf("%q is not all-syslog or resilver_finish-start-scrub", h))
|
|
case seen[h]:
|
|
p.refuse("zed-handlers", raw, h+" is given twice")
|
|
default:
|
|
seen[h] = true
|
|
c.ZedHandlers = append(c.ZedHandlers, h)
|
|
}
|
|
}
|
|
}
|
|
devices := func(key string) []string {
|
|
l, raw, ok := p.list(key)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var good []string
|
|
for _, d := range l {
|
|
if !deviceForm.MatchString(d) || d == "." || d == ".." {
|
|
p.refuse(key, raw, fmt.Sprintf("%q is not a name under /dev/disk/by-id (letters, digits, - _ . : only)", d))
|
|
continue
|
|
}
|
|
good = append(good, d)
|
|
}
|
|
return good
|
|
}
|
|
c.CacheDevices, c.LogDevices = devices("cache-devices"), devices("log-devices")
|
|
if v, has := p.raw["log-mirror"]; has && string(bytes.TrimSpace(v)) != "null" {
|
|
var b bool
|
|
if err := json.Unmarshal(v, &b); err != nil {
|
|
p.refuse("log-mirror", v, "is not true or false")
|
|
} else {
|
|
c.LogMirror = &b
|
|
if b && len(c.LogDevices) != 2 {
|
|
p.refuse("log-mirror", v, "is true, and log-devices does not hold exactly two names")
|
|
}
|
|
}
|
|
}
|
|
|
|
if o, raw, ok := p.object("pool-properties"); ok {
|
|
c.PoolProperties = map[string]string{}
|
|
for _, k := range sortedKeys(o) {
|
|
var s string
|
|
if json.Unmarshal(o[k], &s) != nil {
|
|
p.refuse("pool-properties", raw, fmt.Sprintf("%s is not a string", k))
|
|
} else if why := poolPropertyForm(k, s); why != "" {
|
|
p.refuse("pool-properties", raw, fmt.Sprintf("%s=%s %s", k, s, why))
|
|
} else {
|
|
c.PoolProperties[k] = s
|
|
}
|
|
}
|
|
}
|
|
if o, raw, ok := p.object("dataset-properties"); ok {
|
|
c.DatasetProperties = map[string]map[string]string{}
|
|
for _, ds := range sortedKeys(o) {
|
|
var props map[string]string
|
|
switch {
|
|
case c.Pool == "":
|
|
p.refuse("dataset-properties", raw, "needs the pool set")
|
|
case ds != c.Pool && !strings.HasPrefix(ds, c.Pool+"/"):
|
|
p.refuse("dataset-properties", raw, fmt.Sprintf("%q is not the pool's name or one beginning %s/", ds, c.Pool))
|
|
case !datasetForm.MatchString(ds):
|
|
p.refuse("dataset-properties", raw, fmt.Sprintf("%q is not a dataset's name", ds))
|
|
case json.Unmarshal(o[ds], &props) != nil:
|
|
p.refuse("dataset-properties", raw, fmt.Sprintf("%s is not an object of strings", ds))
|
|
default:
|
|
c.DatasetProperties[ds] = map[string]string{}
|
|
for _, k := range sortedKeys(props) {
|
|
if why := datasetPropertyForm(k, props[k]); why != "" {
|
|
p.refuse("dataset-properties", raw, fmt.Sprintf("%s %s=%s %s", ds, k, props[k], why))
|
|
} else {
|
|
c.DatasetProperties[ds][k] = props[k]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if o, raw, ok := p.object("snapshots"); ok {
|
|
k := &Keeps{}
|
|
if _, none := o["none"]; none {
|
|
if len(o) != 1 {
|
|
p.refuse("snapshots", raw, "none is given with keep counts")
|
|
}
|
|
k.None = true
|
|
} else {
|
|
for _, name := range sortedKeys(o) {
|
|
var n int
|
|
if !contains(keepKinds, name) {
|
|
p.refuse("snapshots", raw, fmt.Sprintf("%q is not none, hourly, daily, weekly or monthly", name))
|
|
} else if json.Unmarshal(o[name], &n) != nil || n < 0 || n > MaxKeep {
|
|
p.refuse("snapshots", raw, fmt.Sprintf("%s is not a whole number from 0 to %d", name, MaxKeep))
|
|
} else {
|
|
k.set(name, n)
|
|
}
|
|
}
|
|
}
|
|
c.Snapshots = k
|
|
}
|
|
if len(p.out) > 0 {
|
|
return nil, p.out
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func clockForm(s string) string {
|
|
if !clock.MatchString(s) {
|
|
return "is not HH:MM, 00:00 to 23:59"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func contains(l []string, s string) bool {
|
|
for _, x := range l {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (k *Keeps) set(kind string, n int) {
|
|
switch kind {
|
|
case "hourly":
|
|
k.Hourly = n
|
|
case "daily":
|
|
k.Daily = n
|
|
case "weekly":
|
|
k.Weekly = n
|
|
case "monthly":
|
|
k.Monthly = n
|
|
}
|
|
}
|
|
|
|
// Of is the keep count of a kind.
|
|
func (k *Keeps) Of(kind string) int {
|
|
switch kind {
|
|
case "hourly":
|
|
return k.Hourly
|
|
case "daily":
|
|
return k.Daily
|
|
case "weekly":
|
|
return k.Weekly
|
|
}
|
|
return k.Monthly
|
|
}
|