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 that merges it") { t.Errorf("settings that reached nothing were not named: %v", unused) } } func TestASettingLandsOnlyWhereSomethingDeclaresIt(t *testing.T) { // novox/hq 04-ISSUES/173. A module that contributes a route and serves a provision takes a // setting for a key either declares, and a setting for a key neither declares is stray — it // would not reach the route or the served fact, so it must be said rather than dropped. m := Manifest{Module: "mail", Contributes: map[string]map[string]any{"reverse-proxy": {"host": "mail", "port": 8080}}, Serves: map[string]map[string]any{"smtp": {"host": "mail", "port": 25}}, Resources: []map[string]any{ {"id": "env", "type": "file", "path": "/etc/mail.env", "content": "SITE=${setting:sitename}\n"}, }} layers := []Layer{{From: "the mesh", Values: map[string]any{ "host": "post", "sitename": "Mail", "website": "https://www.example.tld"}}} unused := UnusedSettings(m, layers) if len(unused) != 1 || !strings.Contains(unused[0], `"website"`) { t.Errorf("only website reaches nothing; named: %v", unused) } } func TestASettingOverridesAServedKeyAndAddsNone(t *testing.T) { // What a consumer is told is the provider's contract. A setting made for one of the // provider's files — its site name, its public address — is not part of it. served, err := Settle(map[string]any{"host": "mail", "port": 25}, []Layer{{From: "the mesh", Values: map[string]any{"host": "post", "sitename": "Mail"}}}) if err != nil { t.Fatal(err) } if served["host"] != "post" { t.Errorf("the setting did not override the served host: %v", served) } if _, leaked := served["sitename"]; leaked { t.Errorf("a setting for a file reached the consumers: %v", served) } } 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") } } func TestAServedValueMayBeTheOperators(t *testing.T) { // A mail provider serves its domain and an identity provider its issuer; neither is the // definition's to state (ADR 0155). Filled from the layers, refused by name when unset. served, err := Settle(map[string]any{"port": 587, "domain": "${setting:domain}"}, []Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld"}}}) if err != nil { t.Fatal(err) } if served["domain"] != "example.tld" { t.Errorf("the operator's value did not fill the served key: %v", served) } _, err = Settle(map[string]any{"domain": "${setting:domain}"}, nil) if err == nil || !strings.Contains(err.Error(), `"domain"`) { t.Errorf("a served value nothing sets must be refused by name; got %v", err) } m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"domain": "${setting:domain}"}}} if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"domain": "x"}}}); len(unused) != 0 { t.Errorf("a setting a served fact asks for is not stray: %v", unused) } }