Files
mesh-controller/internal/catalogue/settings_test.go
jschoubben 5a3a87e8c3 A module can tell its provider what it needs
`requires` said a thing must be there. It never said what to do with it,
so a web application requiring a reverse proxy had nowhere to put "this
name, this port". The two modules that needed it most went round the
outside and opened a connection to the control plane's database, which is
why every node holds a credential to it permanently.

Two fields close it:

  contributes: {reverse-proxy: {host: board, port: 8080}}
  receives:    {reverse-proxy: /etc/traefik/dynamic/mesh.json}

The control plane collects every contribution on a node and writes them
to the path the provider named, ordered by module so the file does not
churn. Contributing to something is requiring it — asking to be published
means a publisher must exist, and a module that had to say both would
eventually say one.

The control plane does not know what a reverse proxy is and does not
write one's configuration. It delivers facts; the module turns them into
whatever it runs. That is why swapping the proxy touches nothing that
publishes through it, and why the host needs no new vocabulary — a
received file is a file.

Settings reach a contribution the same way they reach a file, because a
hostname is exactly what differs between one mesh and the next.

Two things found by running it:

- the file had a `//` header, so it said "do not edit" to a person and
  failed to parse for the program meant to read it. The note is inside
  the document now.
- a provider with no consumers gets an empty file rather than none. It
  cannot otherwise tell "nothing asked for me" from "the mesh never
  wrote it", and those want different responses.

Also `plan <node> --json`, which is how the declaration gets handed to
the host's own parser.
2026-08-29 23:35:43 +02:00

182 lines
6.6 KiB
Go

package catalogue
import (
"encoding/json"
"strings"
"testing"
)
func file(content string, protected ...string) map[string]any {
r := map[string]any{"id": "conf", "type": "file", "path": "/etc/thing.json",
"merge": MergeJSON, "content": content}
if len(protected) > 0 {
var as []any
for _, p := range protected {
as = append(as, p)
}
r["protected"] = as
}
return r
}
func merged(t *testing.T, resource map[string]any, layers ...Layer) map[string]any {
t.Helper()
out, err := ApplySettings(resource, layers)
if err != nil {
t.Fatal(err)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(out["content"].(string)), &parsed); err != nil {
t.Fatalf("the merged file is not JSON: %v", err)
}
return parsed
}
func TestASettingBeatsTheModulesDefault(t *testing.T) {
// The whole rule. A setting is a statement about that key made deliberately; the default was
// only ever what to do in the absence of one. So there is nothing to resolve.
got := merged(t, file(`{"port":8080,"log":"info"}`),
Layer{From: "node", Values: map[string]any{"port": 9090.0}})
if got["port"] != 9090.0 {
t.Errorf("port is %v; the setting should have won", got["port"])
}
if got["log"] != "info" {
t.Errorf("log is %v; a key nobody set should keep the module's value", got["log"])
}
}
func TestUpstreamKeepsTheKeysNobodySet(t *testing.T) {
// The point of merging rather than replacing: the module can change its half freely and only
// the keys somebody actually cares about are pinned.
got := merged(t, file(`{"port":8080,"log":"info","workers":4}`),
Layer{From: "node", Values: map[string]any{"log": "debug"}})
if got["port"] != 8080.0 || got["workers"] != 4.0 {
t.Errorf("the module's other keys did not survive: %v", got)
}
}
func TestTheNodeBeatsTheMesh(t *testing.T) {
// Two layers, in order. A node that differs is expressed by differing, rather than by
// repeating everything the rest of the mesh already says.
got := merged(t, file(`{"log":"info"}`),
Layer{From: "mesh", Values: map[string]any{"log": "warn", "workers": 8.0}},
Layer{From: "node", Values: map[string]any{"log": "debug"}})
if got["log"] != "debug" {
t.Errorf("log is %v; the node's setting should have won", got["log"])
}
if got["workers"] != 8.0 {
t.Errorf("workers is %v; a mesh-wide setting the node did not touch should stand", got["workers"])
}
}
func TestNestedBlocksMergeRatherThanReplace(t *testing.T) {
// Setting one field of a block must not delete its siblings, or every setting would have to
// restate the whole block and would then pin all of it against upstream.
got := merged(t, file(`{"http":{"gzip":"off","timeout":30,"port":80}}`),
Layer{From: "node", Values: map[string]any{
"http": map[string]any{"gzip": "on"}}})
block := got["http"].(map[string]any)
if block["gzip"] != "on" {
t.Errorf("gzip is %v", block["gzip"])
}
if block["timeout"] != 30.0 || block["port"] != 80.0 {
t.Errorf("the block's other fields were lost: %v", block)
}
}
func TestAListIsReplacedWholeNotMerged(t *testing.T) {
// A list that merged element-wise could neither be shortened nor reordered, and there is no
// correct guess about which element is "the same one".
got := merged(t, file(`{"hosts":["a","b","c"]}`),
Layer{From: "node", Values: map[string]any{"hosts": []any{"x"}}})
hosts := got["hosts"].([]any)
if len(hosts) != 1 || hosts[0] != "x" {
t.Errorf("hosts is %v; a list is replaced whole", hosts)
}
}
func TestAProtectedKeyIsRefusedNotIgnored(t *testing.T) {
// A setting quietly dropped is somebody believing they changed something. Refusing says so
// while they are looking at it.
_, err := ApplySettings(file(`{"socket":"/run/thing.sock","log":"info"}`, "socket"),
[]Layer{{From: "node", Values: map[string]any{"socket": "/tmp/mine.sock"}}})
if err == nil {
t.Fatal("a protected key was overridden")
}
if !strings.Contains(err.Error(), "socket") || !strings.Contains(err.Error(), "not settable") {
t.Errorf("the refusal does not say which key or why: %v", err)
}
}
func TestSettingsAroundAProtectedKeyStillApply(t *testing.T) {
got := merged(t, file(`{"socket":"/run/thing.sock","log":"info"}`, "socket"),
Layer{From: "node", Values: map[string]any{"log": "debug"}})
if got["log"] != "debug" {
t.Errorf("log is %v", got["log"])
}
}
func TestTheSameSettingsAlwaysProduceTheSameBytes(t *testing.T) {
// A file whose lines move for no reason makes every reconcile look like a change, and a
// service reflecting it would restart for ever.
//
// Note what this defends: Go's JSON encoder sorts map keys, so the stability comes from the
// standard library and this passes with the merging removed. It is worth keeping as the thing
// that would catch a move to an encoder that does not sort — but it is not evidence about the
// code below it, and it was checked.
resource := file(`{"b":2,"a":1,"c":3}`)
layer := Layer{From: "node", Values: map[string]any{"z": 26.0, "a": 100.0}}
first, err := ApplySettings(resource, []Layer{layer})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
again, err := ApplySettings(resource, []Layer{layer})
if err != nil {
t.Fatal(err)
}
if again["content"] != first["content"] {
t.Fatal("the same settings produced different bytes")
}
}
}
func TestAFileThatDoesNotMergeIsLeftAlone(t *testing.T) {
plain := map[string]any{"id": "conf", "type": "file", "path": "/etc/thing",
"content": "not structured at all\n"}
out, err := ApplySettings(plain, []Layer{{From: "node", Values: map[string]any{"x": 1}}})
if err != nil {
t.Fatal(err)
}
if out["content"] != "not structured at all\n" {
t.Errorf("a file with no merge rule was changed: %v", out["content"])
}
}
func TestSettingsThatReachNothingAreNamed(t *testing.T) {
// Somebody who misspells a module, or sets a key on one with nothing mergeable, has changed
// nothing — and would otherwise find out by the machine not behaving differently, which is
// the slowest way there is.
m := Manifest{Module: "thing", Resources: []map[string]any{
{"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"},
}}
unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}})
if len(unused) != 1 || !strings.Contains(unused[0], "no file or contribution to merge it into") {
t.Errorf("settings that reached nothing were not named: %v", unused)
}
}
func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
// Rather than on the machine, at apply time, as a file the program cannot read.
_, err := ApplySettings(file(`this is not json`), nil)
if err == nil {
t.Fatal("a module claiming to merge as JSON shipped something else and was accepted")
}
}