650 lines
24 KiB
Go
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" })
|
|
}
|