Files
mesh-catalog/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go
T

650 lines
24 KiB
Go

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":"<node>.<module>.<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":"<node>.<collection>.<contributor>"}`)
}
for _, kind := range placementKinds {
b.put(t, RegistryBucket, kind, `{"kind":"`+kind+`","domain":"mesh-controller","from":"bucket",`+
`"bucket":"mesh-controller_assignments","key":"<node>.<module>"}`)
}
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" })
}