mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestModuleCheckPassesTheCatalogue (0.89s); and TestEveryCatalogueManifestParses, TestNoCatalogue…
mesh/delivery superseded: a newer head of the same pull request
Every key of a module's layers merged into every mergeable file it owns:
the mesh's own words, a key meant for a ${setting:} in another file, a
key for a second configuration file. A module could not own two
configurable files. A file now names its keys in takes-settings and
takes only those; a key no file names is stray where it is set; a
module that declares a setting names the keys of every mergeable file,
and one that declares none keeps today's form (ADR 0164 §15).
218 lines
9.1 KiB
Go
218 lines
9.1 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// twoFiles is a module that owns two configurable files, which issue 168 said no module could:
|
|
// every key of every layer reached both.
|
|
func twoFiles(app, sidecar []any) Manifest {
|
|
appFile := map[string]any{"id": "app", "type": "file", "path": "/etc/app.json", "merge": MergeJSON,
|
|
"content": `{"server":{"port":8080},"search":{"safe":1}}`}
|
|
sideFile := map[string]any{"id": "sidecar", "type": "file", "path": "/etc/sidecar.json", "merge": MergeJSON,
|
|
"content": `{"url":"http://127.0.0.1:8080"}`}
|
|
if app != nil {
|
|
appFile[TakesSettingsField] = app
|
|
}
|
|
if sidecar != nil {
|
|
sideFile[TakesSettingsField] = sidecar
|
|
}
|
|
return Manifest{Module: "search", Resources: []map[string]any{
|
|
appFile, sideFile,
|
|
{"id": "env", "type": "file", "path": "/etc/search.env", "content": "SITE=${setting:site-name}\n"},
|
|
}}
|
|
}
|
|
|
|
func settledContent(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)
|
|
}
|
|
if _, kept := out[TakesSettingsField]; kept {
|
|
t.Errorf("%v still carries %s; the host knows no such field", out["id"], TakesSettingsField)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(out["content"].(string)), &parsed); err != nil {
|
|
t.Fatalf("%v is not JSON after settling: %v", out["id"], err)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func TestEachMergeableFileTakesOnlyTheKeysItNames(t *testing.T) {
|
|
// novox/hq ADR 0326 §1, issue 168: two configurable files, each taking its own keys. A key for
|
|
// the other file, for a ${setting:…} elsewhere or for the mesh itself lands in neither.
|
|
m := twoFiles([]any{"server", "search"}, []any{"url"})
|
|
layers := []Layer{
|
|
{From: "the mesh", Values: map[string]any{
|
|
"server": map[string]any{"base_url": "https://search.example.tld/"},
|
|
"url": "http://127.0.0.1:9090", "site-name": "Search",
|
|
"endpoints": map[string]any{"web": map[string]any{"reach": "public"}}}},
|
|
{From: "node-a", Values: map[string]any{"search": map[string]any{"safe": 0.0}}},
|
|
}
|
|
|
|
app := settledContent(t, m.Resources[0], layers)
|
|
for _, stray := range []string{"url", "site-name", "endpoints"} {
|
|
if _, in := app[stray]; in {
|
|
t.Errorf("app took %q, which it does not name: %v", stray, app)
|
|
}
|
|
}
|
|
server, _ := app["server"].(map[string]any)
|
|
if server["base_url"] != "https://search.example.tld/" || server["port"] != 8080.0 {
|
|
t.Errorf("app's server block is %v; the setting should merge into the file's own", server)
|
|
}
|
|
if search, _ := app["search"].(map[string]any); search["safe"] != 0.0 {
|
|
t.Errorf("app's search block is %v; the node's layer should win", search)
|
|
}
|
|
|
|
side := settledContent(t, m.Resources[1], layers)
|
|
if side["url"] != "http://127.0.0.1:9090" {
|
|
t.Errorf("sidecar's url is %v; it names url", side["url"])
|
|
}
|
|
if len(side) != 1 {
|
|
t.Errorf("sidecar took keys it does not name: %v", side)
|
|
}
|
|
}
|
|
|
|
func TestAnEmptyListTakesNoSetting(t *testing.T) {
|
|
// ADR 0164 §12: a file that names no setting takes none.
|
|
m := twoFiles([]any{"server"}, []any{})
|
|
side := settledContent(t, m.Resources[1], []Layer{{From: "the mesh", Values: map[string]any{
|
|
"server": map[string]any{"port": 1.0}, "url": "x"}}})
|
|
if len(side) != 1 || side["url"] != "http://127.0.0.1:8080" {
|
|
t.Errorf("a file taking none was changed: %v", side)
|
|
}
|
|
}
|
|
|
|
func TestAFileThatNamesNothingKeepsTodaysForm(t *testing.T) {
|
|
// ADR 0326 §3, ADR 0164 §15: a module none of whose files names keys is left as it was.
|
|
m := twoFiles(nil, nil)
|
|
app := settledContent(t, m.Resources[0], []Layer{{From: "the mesh", Values: map[string]any{"url": "x"}}})
|
|
if app["url"] != "x" {
|
|
t.Errorf("a silent file should still take every key until its module names them: %v", app)
|
|
}
|
|
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"anything": 1}}}); len(unused) != 0 {
|
|
t.Errorf("today's form has nothing stray, and said: %v", unused)
|
|
}
|
|
}
|
|
|
|
func TestAKeyNoFileNamesIsStray(t *testing.T) {
|
|
// ADR 0326 §5: once the files name their keys, a key none of them names — and nothing else
|
|
// takes — is stray, and is said with what each file takes.
|
|
m := twoFiles([]any{"server", "search"}, []any{"url"})
|
|
unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{
|
|
"server": 1, "url": "x", "site-name": "Search", "ui": map[string]any{"theme": "dark"}}}})
|
|
if len(unused) != 1 || !strings.Contains(unused[0], `"ui"`) {
|
|
t.Fatalf("only ui reaches nothing; named: %v", unused)
|
|
}
|
|
if !strings.Contains(unused[0], "app takes server, search") || !strings.Contains(unused[0], "sidecar takes url") {
|
|
t.Errorf("the stray key's message does not say what each file takes: %s", unused[0])
|
|
}
|
|
}
|
|
|
|
func TestOneNamingFileMakesEveryMergeableFileName(t *testing.T) {
|
|
// ADR 0326 §2: a module with one naming and one silent mergeable file is refused, naming the
|
|
// silent one — the silent one would otherwise still take every key.
|
|
m := twoFiles([]any{"server"}, nil)
|
|
problems := TakenSettingsProblems(m)
|
|
if len(problems) != 1 || !strings.Contains(problems[0], "sidecar merges and names no takes-settings") {
|
|
t.Fatalf("problems: %v", problems)
|
|
}
|
|
if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "sidecar") {
|
|
t.Errorf("composing it should refuse it too, and said: %v", err)
|
|
}
|
|
raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ParseManifest(raw); err == nil || !strings.Contains(err.Error(), "sidecar merges and names no") {
|
|
t.Errorf("the module check does not say it: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTakesSettingsIsRefusedWhereItCannotMeanAnything(t *testing.T) {
|
|
// ADR 0326 §4.
|
|
cases := map[string]struct {
|
|
resource map[string]any
|
|
says string
|
|
}{
|
|
"on a file that does not merge": {
|
|
map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x",
|
|
TakesSettingsField: []any{"a"}},
|
|
"does not merge"},
|
|
"not a list": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
|
TakesSettingsField: "server"},
|
|
"is a list of setting keys"},
|
|
"a mesh word": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
|
TakesSettingsField: []any{"endpoints"}},
|
|
"the mesh reads"},
|
|
"a protected key": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
|
"protected": []any{"secret"}, TakesSettingsField: []any{"secret"}},
|
|
"keeps it as protected"},
|
|
"named twice": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
|
TakesSettingsField: []any{"a", "a"}},
|
|
"twice"},
|
|
"not a key": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
|
TakesSettingsField: []any{"two words"}},
|
|
"not a usable setting key"},
|
|
}
|
|
for name, c := range cases {
|
|
problems := TakenSettingsProblems(Manifest{Module: "m", Resources: []map[string]any{c.resource}})
|
|
if !containsText(problems, c.says) {
|
|
t.Errorf("%s: want a problem saying %q, got %v", name, c.says, problems)
|
|
}
|
|
}
|
|
// And a file that does not merge is refused where it is settled, not handed to the host.
|
|
plain := map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x",
|
|
TakesSettingsField: []any{"a"}}
|
|
if _, err := ApplySettings(plain, nil); err == nil {
|
|
t.Error("a file that does not merge and names keys was settled")
|
|
}
|
|
}
|
|
|
|
func TestASoftwareSpelledKeyIsUsable(t *testing.T) {
|
|
// A file's keys are its software's: nodered's timeZone is not lower case and is still a key.
|
|
m := Manifest{Module: "flows", Resources: []map[string]any{{"id": "settings", "type": "file",
|
|
"path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"timeZone"}}}}
|
|
if problems := TakenSettingsProblems(m); len(problems) != 0 {
|
|
t.Errorf("timeZone was refused: %v", problems)
|
|
}
|
|
}
|
|
|
|
func containsText(list []string, text string) bool {
|
|
for _, s := range list {
|
|
if strings.Contains(s, text) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestAModuleThatDeclaresASettingNamesItsFilesKeys(t *testing.T) {
|
|
// ADR 0326 §3, ADR 0164 §15: only a module that declares no settings keeps today's form. One that
|
|
// declares a preference and owns a silent mergeable file is refused where it is checked — and not
|
|
// where a stored definition is composed, so a machine does not lose it before its next change.
|
|
m := twoFiles(nil, nil)
|
|
m.Settings = map[string]SettingDeclaration{"site-name": {Kind: KindPreference, Default: "Search", Why: "a name"}}
|
|
problems := TakenSettingsProblems(m)
|
|
if len(problems) != 2 || !containsText(problems, "app names no takes-settings") ||
|
|
!containsText(problems, "sidecar names no takes-settings") {
|
|
t.Errorf("problems: %v", problems)
|
|
}
|
|
if err := JudgeSettings(m, nil, false); err != nil {
|
|
t.Errorf("a stored definition is composed as before, and was refused: %v", err)
|
|
}
|
|
m = twoFiles([]any{"server"}, []any{})
|
|
m.Settings = map[string]SettingDeclaration{"site-name": {Kind: KindPreference, Default: "Search", Why: "a name"}}
|
|
if problems := TakenSettingsProblems(m); len(problems) != 0 {
|
|
t.Errorf("every mergeable file names its keys, and was refused: %v", problems)
|
|
}
|
|
}
|