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 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") } }