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 }