Z7 zfs-apply-properties: pool and dataset properties converged, read back and said

Builds: design 42 zfs-owns-zfs §10, §16
This commit is contained in:
2026-11-13 00:27:31 +01:00
parent 0fa4b5d8e7
commit a10bf45ed9
3 changed files with 287 additions and 1 deletions
@@ -0,0 +1,176 @@
package main
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
)
// The apply's pool and dataset properties (design 42 zfs-owns-zfs §16, §10).
//
// For each set pool property the apply runs `zpool set <property>=<value> <pool>`, and for `default` `zpool
// set <property>=<ZFS's default> <pool>`. For each set dataset property it runs `zfs set <property>=<value>
// <dataset>`, and for `default` `zfs inherit <property> <dataset>`, so its source stays default. A property
// already at its value runs nothing. Each change is read back with `zpool get -Hp` or `zfs get -Hp` and said.
// sharenfs and sharesmb are never here: the parse refuses them (config.go).
// poolDefaults are ZFS's defaults of the pool properties the module sets.
var poolDefaults = map[string]string{"autotrim": "off", "autoexpand": "off", "autoreplace": "off", "failmode": "wait"}
// PoolGetArgv reads one pool property's value.
func PoolGetArgv(pool, property string) []string {
return []string{"get", "-Hp", "-o", "value", property, "--", pool}
}
// PoolSetArgv sets one pool property.
func PoolSetArgv(pool, property, value string) []string {
return []string{"set", property + "=" + value, "--", pool}
}
// DatasetGetArgv reads one dataset property's value and source.
func DatasetGetArgv(dataset, property string) []string {
return []string{"get", "-Hp", "-o", "value,source", property, "--", dataset}
}
// DatasetSetArgv sets one dataset property.
func DatasetSetArgv(dataset, property, value string) []string {
return []string{"set", property + "=" + value, "--", dataset}
}
// DatasetInheritArgv gives a dataset property back to ZFS's inheritance.
func DatasetInheritArgv(dataset, property string) []string {
return []string{"inherit", property, "--", dataset}
}
// sizeBytes reads `1M`, `128K`, `5T` or plain digits as bytes; false for another form.
func sizeBytes(v string) (uint64, bool) {
mult := uint64(1)
if n := len(v); n > 0 {
if i := strings.IndexByte("KMGT", v[n-1]); i >= 0 {
mult = uint64(1) << (10 * (i + 1))
v = v[:n-1]
}
}
n, err := strconv.ParseUint(v, 10, 64)
return n * mult, err == nil
}
// sameValue says whether ZFS's `-p` value is the wanted one in the form the config states it.
func sameValue(property, got, want string) bool {
switch property {
case "recordsize", "quota", "reservation":
if want == "none" {
return got == "0" || got == "none"
}
g, ok1 := sizeBytes(got)
w, ok2 := sizeBytes(want)
return ok1 && ok2 && g == w
}
return got == want
}
// ApplyProperties converges the set pool and dataset properties. It answers every command that failed, as
// one error; a property whose read-back differs from what was set is a failure too.
func (m *Machine) ApplyProperties(ctx context.Context, c *Config) error {
var failed []error
fail := func(key string, err error) {
failed = append(failed, fmt.Errorf("%s: %v", key, err))
_ = m.CommandFailed(key, err)
}
for _, p := range sortedKeys(c.PoolProperties) {
key := "pool-properties." + p
want := c.PoolProperties[p]
if want == "default" {
want = poolDefaults[p]
}
got, err := m.ask(ctx, "zpool", PoolGetArgv(c.Pool, p)...)
if err != nil {
fail(key, err)
continue
}
before := strings.TrimSpace(got)
if before == want {
_ = m.keepAsSet(key, before)
continue
}
if _, err := m.AsRoot(ctx, "zpool", PoolSetArgv(c.Pool, p, want)...); err != nil {
fail(key, err)
continue
}
back, err := m.ask(ctx, "zpool", PoolGetArgv(c.Pool, p)...)
if err != nil || strings.TrimSpace(back) != want {
fail(key, fmt.Errorf("read back %q after setting %q (%v)", strings.TrimSpace(back), want, err))
continue
}
if err := m.Changed("pool", key, before, want, strings.TrimSpace(back)); err != nil {
fail(key, err)
}
}
for _, ds := range sortedKeys(c.DatasetProperties) {
for _, p := range sortedKeys(c.DatasetProperties[ds]) {
key := "dataset-properties." + ds + "." + p
want := c.DatasetProperties[ds][p]
value, source, err := m.datasetProperty(ctx, ds, p)
if err != nil {
fail(key, err)
continue
}
before := value
if want == "default" {
if source != "local" && source != "received" {
_ = m.keepAsSet(key, before)
continue
}
if _, err := m.AsRoot(ctx, "zfs", DatasetInheritArgv(ds, p)...); err != nil {
fail(key, err)
continue
}
back, bsource, err := m.datasetProperty(ctx, ds, p)
if err != nil || bsource == "local" || bsource == "received" {
fail(key, fmt.Errorf("read back source %q after inheriting (%v)", bsource, err))
continue
}
if err := m.Changed("dataset", key, before, "default", back); err != nil {
fail(key, err)
}
continue
}
if sameValue(p, value, want) {
_ = m.keepAsSet(key, before)
continue
}
if _, err := m.AsRoot(ctx, "zfs", DatasetSetArgv(ds, p, want)...); err != nil {
fail(key, err)
continue
}
back, _, err := m.datasetProperty(ctx, ds, p)
if err != nil || !sameValue(p, back, want) {
fail(key, fmt.Errorf("read back %q after setting %q (%v)", back, want, err))
continue
}
if err := m.Changed("dataset", key, before, want, back); err != nil {
fail(key, err)
}
}
}
return errors.Join(failed...)
}
// keepAsSet keeps a value found already at what is set: no command, no event, the state says it is as set.
func (m *Machine) keepAsSet(key, value string) error {
return m.keep(key, AppliedEntry{Before: value, After: value, Outcome: OutcomeApplied})
}
func (m *Machine) datasetProperty(ctx context.Context, ds, p string) (value, source string, err error) {
out, err := m.ask(ctx, "zfs", DatasetGetArgv(ds, p)...)
if err != nil {
return "", "", err
}
f := strings.Split(strings.TrimSpace(out), "\t")
if len(f) != 2 {
return "", "", fmt.Errorf("zfs get answered %q, not a value and a source", strings.TrimSpace(out))
}
return f[0], f[1], nil
}
@@ -0,0 +1,100 @@
package main
import (
"context"
"strings"
"testing"
)
// §16, §31 convergence table: a property at its value runs nothing; a set one is set and read back and
// said; `default` is ZFS's default for a pool property and an inherit for a dataset's; a read-back that
// differs is a failure.
func TestPropertiesConvergeFromWhatZFSSays(t *testing.T) {
cfg := func(j string) *Config {
c, ref := ParseConfig([]byte(j), 16000)
if len(ref) != 0 {
t.Fatal(ref)
}
return c
}
type step struct{ cmd, out string }
cases := []struct {
name string
conf string
reads []step // read answers
writes []string
after map[string]string // a read that changes once the write ran
events int
fails bool
}{
{"pool property at its value", `{"pool":"tank","pool-properties":{"autotrim":"on"}}`,
[]step{{"zpool get -Hp -o value autotrim -- tank", "on\n"}}, nil, nil, 0, false},
{"pool property set", `{"pool":"tank","pool-properties":{"autotrim":"on"}}`,
[]step{{"zpool get -Hp -o value autotrim -- tank", "off\n"}}, []string{"zpool set autotrim=on -- tank"},
map[string]string{"zpool get -Hp -o value autotrim -- tank": "on\n"}, 1, false},
{"pool property to ZFS's default", `{"pool":"tank","pool-properties":{"failmode":"default"}}`,
[]step{{"zpool get -Hp -o value failmode -- tank", "continue\n"}}, []string{"zpool set failmode=wait -- tank"},
map[string]string{"zpool get -Hp -o value failmode -- tank": "wait\n"}, 1, false},
{"pool property read back differs", `{"pool":"tank","pool-properties":{"autotrim":"on"}}`,
[]step{{"zpool get -Hp -o value autotrim -- tank", "off\n"}}, []string{"zpool set autotrim=on -- tank"}, nil, 0, true},
{"dataset size at its value in bytes", `{"pool":"tank","dataset-properties":{"tank/a":{"recordsize":"1M"}}}`,
[]step{{"zfs get -Hp -o value,source recordsize -- tank/a", "1048576\tlocal\n"}}, nil, nil, 0, false},
{"dataset compression set", `{"pool":"tank","dataset-properties":{"tank":{"compression":"zstd-3"}}}`,
[]step{{"zfs get -Hp -o value,source compression -- tank", "lz4\tlocal\n"}}, []string{"zfs set compression=zstd-3 -- tank"},
map[string]string{"zfs get -Hp -o value,source compression -- tank": "zstd-3\tlocal\n"}, 1, false},
{"dataset default of an inherited one", `{"pool":"tank","dataset-properties":{"tank/a":{"atime":"default"}}}`,
[]step{{"zfs get -Hp -o value,source atime -- tank/a", "on\tinherited from tank\n"}}, nil, nil, 0, false},
{"dataset default of a local one", `{"pool":"tank","dataset-properties":{"tank/a":{"atime":"default"}}}`,
[]step{{"zfs get -Hp -o value,source atime -- tank/a", "off\tlocal\n"}}, []string{"zfs inherit atime -- tank/a"},
map[string]string{"zfs get -Hp -o value,source atime -- tank/a": "on\tdefault\n"}, 1, false},
{"quota none at zero", `{"pool":"tank","dataset-properties":{"tank/a":{"quota":"none"}}}`,
[]step{{"zfs get -Hp -o value,source quota -- tank/a", "0\tdefault\n"}}, nil, nil, 0, false},
}
for _, c := range cases {
f := newFake(t)
f.uid = 0
for _, r := range c.reads {
f.on(r.cmd, r.out)
}
wrote := map[string]bool{}
for _, w := range c.writes {
w := w
f.on(w, "")
f.after[w] = func(f *fake) {
wrote[w] = true
for k, v := range c.after {
f.on(k, v)
}
}
}
m := f.machine()
m.States = memStates{}
err := m.ApplyProperties(context.Background(), cfg(c.conf))
if (err != nil) != c.fails {
t.Errorf("%s: error %v", c.name, err)
}
for _, w := range c.writes {
if !wrote[w] {
t.Errorf("%s: %q not run; ran %v", c.name, w, f.calls)
}
}
for _, call := range f.calls {
if (strings.Contains(call, " set ") || strings.Contains(call, " inherit ")) && !contains(c.writes, call) {
t.Errorf("%s: ran %q", c.name, call)
}
}
if len(f.emitted) != c.events {
t.Errorf("%s: %d events, want %d: %v", c.name, len(f.emitted), c.events, f.emitted)
}
}
}
// §10: a dataset's sharenfs and sharesmb are never converged, never run: the parse refuses them, so the
// apply has no Config to run.
func TestSharenfsAndSharesmbAreNeverASetProperty(t *testing.T) {
for _, p := range []string{"sharenfs", "sharesmb"} {
if c, ref := ParseConfig([]byte(`{"pool":"tank","dataset-properties":{"tank":{"`+p+`":"on"}}}`), 16000); c != nil || len(ref) == 0 {
t.Errorf("%s accepted", p)
}
}
}
+11 -1
View File
@@ -218,12 +218,22 @@ func TestTheBundleRunsOnlyReadingVerbsSnapshotAndScrub(t *testing.T) {
"EventsArgv": {"zpool", "events"}, "HistoryArgv": {"zpool", "history"}, "FollowArgv": {"zpool", "events"},
"ScrubArgv": {"zpool", "scrub"}, "WaitArgv": {"zpool", "wait"},
"TimerShowArgv": {"systemctl", "show"}, "ServiceShowArgv": {"systemctl", "show"},
"PoolGetArgv": {"zpool", "get"}, "PoolSetArgv": {"zpool", "set"}, "DatasetGetArgv": {"zfs", "get"},
"DatasetSetArgv": {"zfs", "set"}, "DatasetInheritArgv": {"zfs", "inherit"},
}
verbs := map[string][]string{"DatasetsArgv": DatasetsArgv(), "SnapshotsArgv": SnapshotsArgv(), "PoolsArgv": PoolsArgv(),
"StatusArgv": StatusArgv(), "IOArgv": IOArgv(3), "EventsArgv": EventsArgv(), "HistoryArgv": HistoryArgv("tank"),
"PropertiesArgv": PropertiesArgv("tank"), "SnapshotArgv": SnapshotArgv("tank", "x"), "FollowArgv": FollowArgv(),
"TimerShowArgv": TimerShowArgv("x.timer"), "ServiceShowArgv": ServiceShowArgv("x.service"), "WaitArgv": WaitArgv("tank")}
"TimerShowArgv": TimerShowArgv("x.timer"), "ServiceShowArgv": ServiceShowArgv("x.service"), "WaitArgv": WaitArgv("tank"),
"PoolGetArgv": PoolGetArgv("tank", "autotrim"), "PoolSetArgv": PoolSetArgv("tank", "autotrim", "on"),
"DatasetGetArgv": DatasetGetArgv("tank", "atime"), "DatasetSetArgv": DatasetSetArgv("tank", "atime", "on"),
"DatasetInheritArgv": DatasetInheritArgv("tank", "atime")}
for fn, argv := range verbs {
for _, a := range argv {
if fn != "FollowArgv" && (a == "-f" || a == "-r" || a == "-R" || a == "-d") {
t.Errorf("%s runs %q", fn, a)
}
}
if argv[0] != allowed[fn][1] {
t.Errorf("%s runs %q, not %q", fn, argv[0], allowed[fn][1])
}