package main import ( "archive/tar" "bytes" "compress/gzip" "context" "encoding/json" "errors" "fmt" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) // The client's test list of novox/hq ADR 0349 ("How it is checked"), against a real bus (design 57 P5, issue 671): // a template fills from each owner's keys; a missing value keeps the old file and reports what it waits for; a change // refills, writes once and triggers the declared reaction; an owned file changed on disk is written back, a seed // file is left, a managed file keeps the program's other keys; a module taken off the node loses its files and // needs; a node coming back fills from current values. // // The bus is a real server, its accounts the client's as mesh-controller composes them (testdata/accounts.conf), so // every read and write the client makes is one its grants allow. The node-engine is a stand-in on a real unix // socket speaking its format (mesh-host internal/filling), writing each file as its ownership says — the // node-engine's own tests prove its writes; these prove the client asks for the right ones — and counting each // write that moved a file, which is what the node-engine reloads or restarts the program for. const node = "g14" // aBus is a server of this test alone with the client's buckets, the registry written as mesh-provisioner writes it. type aBus struct { url string domain jetstream.JetStream conn *nats.Conn kv map[string]jetstream.KeyValue } func startBus(t *testing.T) *aBus { t.Helper() accounts, err := os.ReadFile("testdata/accounts.conf") if err != nil { t.Fatal(err) } conf := filepath.Join(t.TempDir(), "bus.conf") if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+t.TempDir()+"\" }\n"+ string(accounts)), 0o600); err != nil { t.Fatal(err) } opts, err := server.ProcessConfigFile(conf) if err != nil { t.Fatal(err) } opts.NoLog, opts.NoSigs = true, true s, err := server.NewServer(opts) if err != nil { t.Fatal(err) } go s.Start() if !s.ReadyForConnections(10 * time.Second) { t.Fatal("the bus did not come up") } t.Cleanup(s.Shutdown) nc, err := nats.Connect(s.ClientURL(), nats.UserInfo("domain", "x")) if err != nil { t.Fatal(err) } t.Cleanup(nc.Close) js, err := jetstream.New(nc) if err != nil { t.Fatal(err) } b := &aBus{url: s.ClientURL(), domain: js, conn: nc, kv: map[string]jetstream.KeyValue{}} for _, name := range []string{AssignmentsBucket, ConfigBucket, RegistryBucket, NeedsBucket, AppliedBucket, CollectBucket} { kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: name}) if err != nil { t.Fatal(err) } b.kv[name] = kv } b.put(t, RegistryBucket, "config", `{"kind":"config","domain":"mesh-config","from":"bucket",`+ `"bucket":"mesh-config_resolved","key":".."}`) b.put(t, RegistryBucket, "secret", `{"kind":"secret","domain":"mesh-vault","from":"ask","ask":"mesh-vault.get"}`) for _, kind := range append([]string{CollectedKind}, oldCollectionKinds...) { b.put(t, RegistryBucket, kind, `{"kind":"`+kind+`","domain":"mesh-provisioner","from":"bucket",`+ `"bucket":"mesh-provisioner_collect","key":".."}`) } for _, kind := range placementKinds { b.put(t, RegistryBucket, kind, `{"kind":"`+kind+`","domain":"mesh-controller","from":"bucket",`+ `"bucket":"mesh-controller_assignments","key":"."}`) } return b } func (b *aBus) put(t *testing.T, bucket, key, value string) { t.Helper() if _, err := b.kv[bucket].PutString(context.Background(), key, value); err != nil { t.Fatalf("writing %s in %s: %v", key, bucket, err) } } func (b *aBus) del(t *testing.T, bucket, key string) { t.Helper() if err := b.kv[bucket].Delete(context.Background(), key); err != nil { t.Fatal(err) } } // config writes a resolved value as mesh-config does. func (b *aBus) config(t *testing.T, module, key, value string) { t.Helper() b.put(t, ConfigBucket, node+"."+module+"."+key, fmt.Sprintf(`{"node":%q,"module":%q,"key":%q,"value":%s,`+ `"source":"node"}`, node, module, key, value)) } // assign writes a module's assignment with its plan, as the controller does. func (b *aBus) assign(t *testing.T, module string, plan Plan) { t.Helper() p, err := json.Marshal(plan) if err != nil { t.Fatal(err) } b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+ `"provisioning":%s}`, node, module, p)) } // keys is the bucket's keys under prefix. func (b *aBus) keys(t *testing.T, bucket, prefix string) []string { t.Helper() all, err := b.kv[bucket].Keys(context.Background()) if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) { t.Fatal(err) } var out []string for _, k := range all { if strings.HasPrefix(k, prefix) { out = append(out, k) } } return out } // reports collects what is said on prov.applied. type reports struct { mu sync.Mutex all []Report } func (b *aBus) listen(t *testing.T) *reports { t.Helper() r := &reports{} sub, err := b.conn.Subscribe("prov.applied."+node+".>", func(m *nats.Msg) { var rep Report if err := json.Unmarshal(m.Data, &rep); err == nil { r.mu.Lock() r.all = append(r.all, rep) r.mu.Unlock() } }) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = sub.Unsubscribe() }) _ = b.conn.Flush() return r } func (r *reports) last(module string) (Report, int) { r.mu.Lock() defer r.mu.Unlock() n := 0 var out Report for _, rep := range r.all { if rep.Module == module { out, n = rep, n+1 } } return out, n } // engine stands in for the node-engine's write socket: each path written under root, as its ownership says. type engine struct { root string socket string mu sync.Mutex owner map[string]string // path → owned, seed or managed moved map[string]int // writes that changed the file: what the program is reloaded for asked map[string]int wrote map[string]string // what it last wrote } func startEngine(t *testing.T, owners map[string]string) *engine { t.Helper() // A unix socket's path is short: under /tmp, not the test's own directory. dir, err := os.MkdirTemp("/tmp", "p5-") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) e := &engine{root: t.TempDir(), socket: filepath.Join(dir, "fill.sock"), owner: owners, moved: map[string]int{}, asked: map[string]int{}, wrote: map[string]string{}} l, err := net.Listen("unix", e.socket) if err != nil { t.Fatal(err) } t.Cleanup(func() { l.Close() }) go func() { for { conn, err := l.Accept() if err != nil { return } go func() { defer conn.Close() var req FillRequest dec := json.NewDecoder(conn) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { _ = json.NewEncoder(conn).Encode(FillAnswer{Refused: "unreadable: " + err.Error()}) return } _ = json.NewEncoder(conn).Encode(e.write(req)) }() } }() return e } func (e *engine) at(path string) string { return filepath.Join(e.root, path) } func (e *engine) write(req FillRequest) FillAnswer { e.mu.Lock() defer e.mu.Unlock() e.asked[req.Path]++ owner, declared := e.owner[req.Path] if !declared { return FillAnswer{Path: req.Path, Refused: req.Path + " is not a file this node's declaration names as filled"} } at := e.at(req.Path) have, err := os.ReadFile(at) exists := err == nil want := string(req.Content) switch owner { case "seed": if exists { return FillAnswer{Path: req.Path, Action: "kept"} } case "managed": // The mesh's block between its marks; the program's lines around it kept. const begin, end = "# >>> mesh\n", "# <<< mesh\n" block := begin + want + end if exists { text := string(have) if i, j := strings.Index(text, begin), strings.Index(text, end); i >= 0 && j > i { block = text[:i] + block + text[j+len(end):] } else { block = text + block } } want = block } action := "created" switch { case exists && string(have) == want: return FillAnswer{Path: req.Path, Action: "unchanged", Digest: digestOf(have)} case exists && string(have) != e.wrote[req.Path]: action = "corrected" case exists: action = "updated" } _ = os.MkdirAll(filepath.Dir(at), 0o755) if err := os.WriteFile(at, []byte(want), 0o644); err != nil { return FillAnswer{Path: req.Path, Failed: err.Error()} } e.wrote[req.Path] = want e.moved[req.Path]++ return FillAnswer{Path: req.Path, Action: action, Digest: digestOf([]byte(want))} } func (e *engine) counts(path string) (moved, asked int) { e.mu.Lock() defer e.mu.Unlock() return e.moved[path], e.asked[path] } func (e *engine) read(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(e.at(path)) if err != nil { return "" } return string(b) } // templates is an artifact store holding one archive per name, and the plan's Templates for it. func templates(t *testing.T, files map[string]string) (Templates, *httptest.Server) { t.Helper() var buf bytes.Buffer z := gzip.NewWriter(&buf) w := tar.NewWriter(z) for _, name := range sortedKeys(files) { if err := w.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(files[name])), Typeflag: tar.TypeReg}); err != nil { t.Fatal(err) } if _, err := w.Write([]byte(files[name])); err != nil { t.Fatal(err) } } if err := w.Close(); err != nil { t.Fatal(err) } if err := z.Close(); err != nil { t.Fatal(err) } body := buf.Bytes() srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write(body) })) t.Cleanup(srv.Close) return Templates{Source: srv.URL + "/files.tar.gz", Digest: digestOf(body)}, srv } // aClient runs a client on g14's account until the returned stop is called. func aClient(t *testing.T, b *aBus, e *engine, state string, drift time.Duration) (*Client, func()) { t.Helper() nc, err := nats.Connect(b.url, nats.UserInfo("provisioner."+node, "x"), nats.CustomInboxPrefix("_INBOX.provisioner."+node), nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { t.Errorf("the bus refused the client: %v", err) })) if err != nil { t.Fatal(err) } js, err := jetstream.New(nc) if err != nil { t.Fatal(err) } secrets, err := NewVaultSecrets(nc, "provisioner."+node) if err != nil { t.Fatal(err) } secrets.Within = 2 * time.Second c := &Client{Node: node, JS: js, Conn: nc, Drift: drift, Secrets: secrets, Fill: SocketFiller{Socket: e.socket, Busy: 10 * time.Millisecond, Tries: 3}, Templates: &templateStore{dir: state, fetch: HTTPFetch}} ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- c.Run(ctx) }() stopped := false stop := func() { if stopped { return } stopped = true cancel() if err := <-done; err != nil && !errors.Is(err, context.Canceled) { t.Errorf("the client stopped with %v", err) } nc.Close() } t.Cleanup(stop) return c, stop } func eventually(t *testing.T, what string, ok func() bool) { t.Helper() deadline := time.Now().Add(10 * time.Second) for !ok() { if time.Now().After(deadline) { t.Fatalf("not within 10s: %s", what) } time.Sleep(20 * time.Millisecond) } } const dunstrc = "/home/nox/.config/dunst/dunstrc" // dunst is a module with one owned file naming a config value and a placement value. func dunst(t *testing.T) Plan { tpl, _ := templates(t, map[string]string{ "dunstrc": "[global]\nfont = Monospace ${config:font-size}\nicons = ${machine:account-home}/.icons\n"}) return Plan{Templates: tpl, Files: []PlannedFile{{ID: "dunstrc", Path: dunstrc, Template: "dunstrc", OwnerAtRuntime: "owned", Takes: "reload-on"}}, Placement: map[string]string{"machine:account-home": "/home/nox"}} } func TestNatsATemplateFillsFromEachOwnersKeys(t *testing.T) { b := startBus(t) heard := b.listen(t) e := startEngine(t, map[string]string{dunstrc: "owned"}) b.config(t, "dunst", "font-size", "13") b.assign(t, "dunst", dunst(t)) aClient(t, b, e, t.TempDir(), time.Hour) want := "[global]\nfont = Monospace 13\nicons = /home/nox/.icons\n" eventually(t, "dunstrc is filled", func() bool { return e.read(t, dunstrc) == want }) eventually(t, "prov.applied says applied", func() bool { r, _ := heard.last("dunst") return r.Status == StatusApplied }) r, _ := heard.last("dunst") if r.Templates == "" || len(r.Files) != 1 || r.Files[0].Action != "created" || r.Files[0].Takes != "reload-on" { t.Errorf("prov.applied said %+v", r) } // The needs, one per placeholder, under the node's and the module's name. eventually(t, "the needs are recorded", func() bool { got := b.keys(t, NeedsBucket, node+".dunst.") return len(got) == 2 && got[0] == "g14.dunst.config.font-size" && got[1] == "g14.dunst.machine.account-home" }) kept, err := b.kv[AppliedBucket].Get(context.Background(), node+".dunst") if err != nil || !strings.Contains(string(kept.Value()), `"status":"applied"`) { t.Errorf("mesh-provisioner_applied keeps %v (%v)", kept, err) } } func TestNatsAMissingValueKeepsTheOldFileAndSaysWhatItWaitsFor(t *testing.T) { b := startBus(t) heard := b.listen(t) e := startEngine(t, map[string]string{dunstrc: "owned"}) b.assign(t, "dunst", dunst(t)) aClient(t, b, e, t.TempDir(), time.Hour) // No value yet: nothing written, and the wait said. eventually(t, "prov.applied says it waits", func() bool { r, _ := heard.last("dunst") return r.Status == StatusWaiting }) r, _ := heard.last("dunst") if len(r.WaitsFor) != 1 || !strings.HasPrefix(r.WaitsFor[0], "config:font-size") { t.Errorf("it waits for %v", r.WaitsFor) } if _, asked := e.counts(dunstrc); asked != 0 { t.Errorf("a file waiting for a value was written %d times", asked) } // Filled once it is there; and a value gone keeps the file there is. b.config(t, "dunst", "font-size", "13") eventually(t, "dunstrc is filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") }) b.del(t, ConfigBucket, node+".dunst.font-size") eventually(t, "prov.applied says it waits again", func() bool { r, _ := heard.last("dunst") return r.Status == StatusWaiting }) if !strings.Contains(e.read(t, dunstrc), "Monospace 13") { t.Errorf("a value gone changed the file there was: %q", e.read(t, dunstrc)) } if _, asked := e.counts(dunstrc); asked != 1 { t.Errorf("dunstrc was asked for %d times, want 1", asked) } } func TestNatsAChangeRefillsWritesOnceAndTheProgramTakesIt(t *testing.T) { b := startBus(t) heard := b.listen(t) e := startEngine(t, map[string]string{dunstrc: "owned"}) b.config(t, "dunst", "font-size", "13") b.assign(t, "dunst", dunst(t)) aClient(t, b, e, t.TempDir(), time.Hour) eventually(t, "dunstrc is filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") }) b.config(t, "dunst", "font-size", "15") eventually(t, "dunstrc takes the change", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 15") }) eventually(t, "prov.applied says it", func() bool { r, _ := heard.last("dunst") return r.Status == StatusApplied && len(r.Files) == 1 && r.Files[0].Action == "updated" }) // The same value again, and a key the template does not name: nothing written. b.config(t, "dunst", "font-size", "15") b.config(t, "dunst", "geometry", `"300x5"`) time.Sleep(300 * time.Millisecond) if moved, asked := e.counts(dunstrc); moved != 2 || asked != 2 { t.Errorf("two values wrote dunstrc %d times (asked %d); want 2 and 2: the program is reloaded once a change", moved, asked) } } func TestNatsAnOwnedFileIsWrittenBackASeedLeftAndAManagedFileKeepsTheProgramsLines(t *testing.T) { const seed, managed = "/home/nox/.config/app/first.conf", "/home/nox/.config/app/settings.ini" b := startBus(t) heard := b.listen(t) e := startEngine(t, map[string]string{dunstrc: "owned", seed: "seed", managed: "managed"}) tpl, _ := templates(t, map[string]string{"dunstrc": "font = ${config:font-size}\n", "first.conf": "start = ${config:font-size}\n", "settings.ini": "size = ${config:font-size}\n"}) b.config(t, "app", "font-size", "13") b.assign(t, "app", Plan{Templates: tpl, Files: []PlannedFile{ {ID: "rc", Path: dunstrc, Template: "dunstrc", OwnerAtRuntime: "owned"}, {ID: "first", Path: seed, Template: "first.conf", OwnerAtRuntime: "seed"}, {ID: "settings", Path: managed, Template: "settings.ini", OwnerAtRuntime: "managed", Takes: "read-on-use"}}}) aClient(t, b, e, t.TempDir(), 200*time.Millisecond) eventually(t, "all three are placed", func() bool { r, _ := heard.last("app") return r.Status == StatusApplied }) // The program changes each on disk. for path, text := range map[string]string{dunstrc: "font = 99\n", seed: "start = mine\n", managed: "theme = dark\n" + e.read(t, managed) + "recent = a,b\n"} { if err := os.WriteFile(e.at(path), []byte(text), 0o644); err != nil { t.Fatal(err) } } eventually(t, "the owned file is written back", func() bool { return e.read(t, dunstrc) == "font = 13\n" }) eventually(t, "prov.applied says it was put back", func() bool { r, _ := heard.last("app") return r.Status == StatusApplied && len(r.Files) == 3 && r.Files[0].Action == "corrected" }) time.Sleep(500 * time.Millisecond) if got := e.read(t, seed); got != "start = mine\n" { t.Errorf("the seed file is %q: written once, then the program's", got) } if got := e.read(t, managed); !strings.Contains(got, "theme = dark\n") || !strings.Contains(got, "recent = a,b\n") || !strings.Contains(got, "size = 13\n") { t.Errorf("the managed file is %q: the mesh's block and the program's own lines", got) } // The drift pass that finds every file as it should be says nothing. _, before := heard.last("app") time.Sleep(600 * time.Millisecond) if _, after := heard.last("app"); after != before { t.Errorf("drift passes that moved nothing said %d reports", after-before) } } func TestNatsAModuleTakenOffTheNodeLosesItsNeedsAndItsReport(t *testing.T) { b := startBus(t) heard := b.listen(t) e := startEngine(t, map[string]string{dunstrc: "owned"}) b.config(t, "dunst", "font-size", "13") b.assign(t, "dunst", dunst(t)) aClient(t, b, e, t.TempDir(), time.Hour) eventually(t, "the needs are recorded", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 2 }) b.del(t, AssignmentsBucket, node+".dunst") eventually(t, "its needs go", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 0 }) eventually(t, "its report goes", func() bool { return len(b.keys(t, AppliedBucket, node+".dunst")) == 0 }) eventually(t, "prov.applied says it is removed", func() bool { r, _ := heard.last("dunst") return r.Status == StatusRemoved }) } func TestNatsANodeComingBackFillsFromTheCurrentValues(t *testing.T) { b := startBus(t) const zshrc = "/home/nox/.zshrc" e := startEngine(t, map[string]string{dunstrc: "owned", zshrc: "owned"}) b.config(t, "dunst", "font-size", "13") b.assign(t, "dunst", dunst(t)) b.config(t, "zsh", "prompt", `"%"`) tpl, _ := templates(t, map[string]string{"zshrc": "PS1=${config:prompt}\n"}) b.assign(t, "zsh", Plan{Templates: tpl, Files: []PlannedFile{{ID: "zshrc", Path: zshrc, Template: "zshrc", OwnerAtRuntime: "owned"}}}) state := t.TempDir() _, stop := aClient(t, b, e, state, time.Hour) eventually(t, "both are filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") && e.read(t, zshrc) == "PS1=%\n" }) // Away: a value changes and a module leaves, and the artifact store is gone. stop() b.config(t, "dunst", "font-size", "17") b.del(t, AssignmentsBucket, node+".zsh") heard := b.listen(t) aClient(t, b, e, state, time.Hour) eventually(t, "dunstrc has the value set while it was away", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 17") }) eventually(t, "the module that left lost its needs", func() bool { return len(b.keys(t, NeedsBucket, node+".zsh.")) == 0 }) eventually(t, "and its report", func() bool { r, _ := heard.last("zsh") return r.Status == StatusRemoved && len(b.keys(t, AppliedBucket, node+".zsh")) == 0 }) } // A plan that cannot be acted on, templates that are not the ones named, and a secret the plan does not name each // fail, with the reason; nothing is written. func TestNatsWhatCannotBeFilledFailsWithTheReason(t *testing.T) { b := startBus(t) heard := b.listen(t) e := startEngine(t, map[string]string{dunstrc: "owned", "/etc/app.conf": "owned"}) aClient(t, b, e, t.TempDir(), time.Hour) b.put(t, AssignmentsBucket, node+".broken", `{"node":"g14","module":"broken","provisioning":{"templates":`+ `{"source":"x","digest":"sha256:00"},"files":[],"surprise":1}}`) p := dunst(t) p.Templates.Digest = "sha256:" + strings.Repeat("0", 64) b.assign(t, "forged", p) tpl, _ := templates(t, map[string]string{"app.conf": "token = ${secret:app-token}\n"}) b.assign(t, "secretive", Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: "/etc/app.conf", Template: "app.conf", OwnerAtRuntime: "owned"}}}) for module, why := range map[string]string{"broken": "surprise", "forged": "and the plan names", "secretive": "the plan names no secret app-token"} { eventually(t, module+" fails", func() bool { r, _ := heard.last(module) return r.Status == StatusFailed && strings.Contains(r.Reason, why) }) } if _, asked := e.counts("/etc/app.conf"); asked != 0 { t.Errorf("a file that cannot be filled was written") } } func TestANeedsKeyHoldsAnyName(t *testing.T) { for n, want := range map[Need]string{ {Kind: "config", Name: "font-size"}: "g14.dunst.config.font-size", {Kind: "seat", Name: "mesh-broker:4222"}: "g14.dunst.seat.mesh-broker=3A4222", {Kind: "config", Name: "a=b c"}: "g14.dunst.config.a=3Db=20c", {Kind: "machine", Name: "account-home"}: "g14.dunst.machine.account-home", } { if got := NeedKey(node, "dunst", n); got != want { t.Errorf("%v: %s, want %s", n, got, want) } } } // TestNatsResticsFilesFillFromTheBackupPlacement is the backup lines as placement (issue 794): restic's two files // are the plan's `backup:lines` and `backup:data`, each exactly the text the controller composed, trailing newline // and all, and a changed plan refills them. func TestNatsResticsFilesFillFromTheBackupPlacement(t *testing.T) { const backups, data = "/var/lib/restic/backups.conf", "/var/lib/restic/data.conf" b := startBus(t) e := startEngine(t, map[string]string{backups: "owned", data: "owned"}) tpl, _ := templates(t, map[string]string{ "backups.conf": "# What the modules on this machine back up, composed by the mesh. Do not edit.\n${backup:lines}", "data.conf": "# The data the modules on this machine declare, composed by the mesh. Do not edit.\n${backup:data}"}) plan := func(lines, declared string) Plan { return Plan{Templates: tpl, Files: []PlannedFile{ {ID: "declared", Path: backups, Template: "backups.conf", OwnerAtRuntime: "owned"}, {ID: "data-declared", Path: data, Template: "data.conf", OwnerAtRuntime: "owned"}}, Placement: map[string]string{"backup:lines": lines, "backup:data": declared}} } b.assign(t, "restic", plan("# a\n/srv/a\n# b\n/srv/b\n", "# a\nstate\n")) aClient(t, b, e, t.TempDir(), time.Hour) head := "# What the modules on this machine back up, composed by the mesh. Do not edit.\n" eventually(t, "backups.conf is filled", func() bool { return e.read(t, backups) == head+"# a\n/srv/a\n# b\n/srv/b\n" }) eventually(t, "data.conf is filled", func() bool { return e.read(t, data) == "# The data the modules on this machine declare, composed by the mesh. Do not edit.\n# a\nstate\n" }) b.assign(t, "restic", plan("# a\n/srv/a\n", "# a\nstate\n")) eventually(t, "a module taken away refills backups.conf", func() bool { return e.read(t, backups) == head+"# a\n/srv/a\n" }) }