From 3cb2b83a15a06dab0d4215dbdb9a70e8660ac11a Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 00:25:48 +0100 Subject: [PATCH] Z5 zfs-config-parse: the config file's values each read in their fixed form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A value of another form is refused before anything is written or run; absent and empty are not managed. Builds: design 42 zfs-owns-zfs §9, §11, A-15, A-23 --- modules/zfs/cmd/zfs-tools/config.go | 467 +++++++++++++++++++++++ modules/zfs/cmd/zfs-tools/config_test.go | 96 +++++ 2 files changed, 563 insertions(+) create mode 100644 modules/zfs/cmd/zfs-tools/config.go create mode 100644 modules/zfs/cmd/zfs-tools/config_test.go diff --git a/modules/zfs/cmd/zfs-tools/config.go b/modules/zfs/cmd/zfs-tools/config.go new file mode 100644 index 000000000..b608a09cd --- /dev/null +++ b/modules/zfs/cmd/zfs-tools/config.go @@ -0,0 +1,467 @@ +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) + 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 +} diff --git a/modules/zfs/cmd/zfs-tools/config_test.go b/modules/zfs/cmd/zfs-tools/config_test.go new file mode 100644 index 000000000..af1298096 --- /dev/null +++ b/modules/zfs/cmd/zfs-tools/config_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "strings" + "testing" +) + +// Design 42 zfs-owns-zfs §11: each value of a fixed form, not managed until set and not empty; a value of +// another form is refused before anything is written or run (§31, one table per value). + +func TestEachValueHasItsFormAndABlankOneIsNotManaged(t *testing.T) { + bad := func(key, val string) string { return `{"pool":"tank","` + key + `":` + val + `}` } + refused := []struct{ name, conf, why string }{ + {"pool with a space", `{"pool":"ta nk"}`, "pool's name"}, + {"pool with a newline", `{"pool":"tank\nx"}`, "pool's name"}, + {"pool not a string", `{"pool":3}`, "not a string"}, + {"arc-max below 64", bad("arc-max", "63"), "below 64"}, + {"arc-max at memory", bad("arc-max", "16000"), "not below the node's memory"}, + {"arc-max fraction", bad("arc-max", "100.5"), "whole number"}, + {"arc-max string", bad("arc-max", `"4096"`), "whole number"}, + {"arc-min above arc-max", `{"arc-max":1000,"arc-min":2000}`, "above arc-max"}, + {"module option of arc", bad("module-options", `["zfs_arc_max=1"]`), "arc-max and arc-min"}, + {"module option unknown", bad("module-options", `["zfs_foo=1"]`), "not a parameter"}, + {"module option not digits", bad("module-options", `["zfs_txg_timeout=5;x"]`), "not name=digits"}, + {"module option twice", bad("module-options", `["zfs_txg_timeout=5","zfs_txg_timeout=6"]`), "twice"}, + {"hostid short", bad("hostid", `"abc"`), "eight hexadecimal"}, + {"hostid zero", bad("hostid", `"00000000"`), "all zero"}, + {"hostid backtick", bad("hostid", "\"0123456`\""), "eight hexadecimal"}, + {"zed value", bad("zed", `"yes"`), "on, off"}, + {"zed handler", bad("zed-handlers", `["notify"]`), "not all-syslog"}, + {"trim schedule", bad("trim-schedule", `"daily"`), "none, weekly, monthly"}, + {"trim day", bad("trim-day", `"Monday"`), "Mon"}, + {"trim time", bad("trim-time", `"24:00"`), "HH:MM"}, + {"cache device with a slash", bad("cache-devices", `["a/b"]`), "under /dev/disk/by-id"}, + {"cache device with a dollar", bad("cache-devices", `["a$b"]`), "under /dev/disk/by-id"}, + {"cache device with a quote", bad("cache-devices", `["a\"b"]`), "under /dev/disk/by-id"}, + {"log device with a semicolon", bad("log-devices", `["a;b"]`), "under /dev/disk/by-id"}, + {"log mirror of one", `{"log-mirror":true,"log-devices":["a"]}`, "exactly two"}, + {"log mirror not a boolean", bad("log-mirror", `"yes"`), "true or false"}, + {"pool property", bad("pool-properties", `{"ashift":"12"}`), "not a pool property"}, + {"pool property value", bad("pool-properties", `{"failmode":"loud"}`), "failmode=loud"}, + {"dataset of another pool", bad("dataset-properties", `{"other/x":{"atime":"off"}}`), "not the pool's name"}, + {"dataset property sharenfs", bad("dataset-properties", `{"tank":{"sharenfs":"on"}}`), "not a dataset property"}, + {"dataset property sharesmb", bad("dataset-properties", `{"tank":{"sharesmb":"on"}}`), "not a dataset property"}, + {"dataset recordsize", bad("dataset-properties", `{"tank/a":{"recordsize":"100K"}}`), "power of two"}, + {"dataset compression", bad("dataset-properties", `{"tank/a":{"compression":"zstd-20"}}`), "compression"}, + {"dataset quota", bad("dataset-properties", `{"tank/a":{"quota":"5X"}}`), "quota"}, + {"dataset name with a snapshot mark", bad("dataset-properties", `{"tank/a@x":{"atime":"on"}}`), "dataset's name"}, + {"snapshots above the bound", bad("snapshots", `{"hourly":169}`), "0 to 168"}, + {"snapshots unknown kind", bad("snapshots", `{"yearly":1}`), "not none, hourly"}, + {"snapshots none with counts", bad("snapshots", `{"none":true,"daily":1}`), "none is given"}, + {"scrub cadence", bad("scrub-cadence", `"daily"`), "weekly, monthly"}, + {"not an object", `[1]`, "not a JSON object"}, + } + for _, c := range refused { + cfg, ref := ParseConfig([]byte(c.conf), 16000) + if cfg != nil || len(ref) == 0 || !strings.Contains(ref[0].Reason, c.why) { + t.Errorf("%s: %+v %+v, want a refusal saying %q", c.name, cfg, ref, c.why) + } + } + // Without the node's memory arc-max cannot be checked. + if _, ref := ParseConfig([]byte(`{"arc-max":1000}`), 0); len(ref) != 1 || ref[0].Key != "arc-max" { + t.Errorf("arc-max with no memory read: %v", ref) + } + // Absent, empty and null are not managed: no refusal, nothing set. + for _, conf := range []string{`{}`, `{"pool":"tank","cache-devices":[],"log-devices":[],"module-options":[],"zed-handlers":[], + "pool-properties":{},"dataset-properties":{},"snapshots":{},"hostid":"","zed":"","trim-schedule":"","arc-max":null}`} { + c, ref := ParseConfig([]byte(conf), 16000) + if len(ref) != 0 || c.ArcMax != nil || c.HostID != "" || c.Zed != "" || c.TrimSchedule != "" || c.Snapshots != nil || + len(c.CacheDevices)+len(c.LogDevices)+len(c.ModuleOptions)+len(c.ZedHandlers)+len(c.PoolProperties)+len(c.DatasetProperties) != 0 { + t.Errorf("%s: %+v %v", conf, c, ref) + } + } +} + +func TestAWholeConfigIsReadValueByValue(t *testing.T) { + c, ref := ParseConfig([]byte(`{"pool":"tank","scrub-cadence":"weekly","scrub-day":"Mon","scrub-time":"00:00", + "arc-max":8192,"arc-min":1024,"module-options":["zfs_txg_timeout=10"],"hostid":"0A1B2C3D","zed":"on", + "zed-handlers":["all-syslog"],"trim-schedule":"weekly","trim-day":"Sun","trim-time":"03:00", + "cache-devices":["nvme-a","nvme-b"],"log-devices":["nvme-c","nvme-d"],"log-mirror":true, + "pool-properties":{"autotrim":"on","failmode":"default"}, + "dataset-properties":{"tank":{"compression":"zstd-3"},"tank/media":{"recordsize":"1M","quota":"5T"}}, + "snapshots":{"hourly":24,"daily":7}}`), 16000) + if len(ref) != 0 { + t.Fatal(ref) + } + if c.Pool != "tank" || *c.ArcMax != 8192 || *c.ArcMin != 1024 || c.HostID != "0a1b2c3d" || c.Zed != "on" || + c.ModuleOptions[0] != (ModuleOption{"zfs_txg_timeout", "10"}) || len(c.CacheDevices) != 2 || !*c.LogMirror || + c.PoolProperties["autotrim"] != "on" || c.DatasetProperties["tank/media"]["quota"] != "5T" || + c.Snapshots.Of("hourly") != 24 || c.Snapshots.Of("monthly") != 0 || c.Snapshots.None { + t.Errorf("%+v", c) + } + if c, _ := ParseConfig([]byte(`{"snapshots":{"none":true}}`), 0); !c.Snapshots.None { + t.Errorf("none: %+v", c.Snapshots) + } +}