diff --git a/cmd/mesh-controller/template_test.go b/cmd/mesh-controller/template_test.go new file mode 100644 index 00000000..60ca0e74 --- /dev/null +++ b/cmd/mesh-controller/template_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + snapshot "github.com/novox/mesh-controller/internal/facts" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A file's text kept as a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package T1, +// issue 654): `module check` and the merge gate read it from the tree beside the manifest, so a change to a +// template alone is judged as a change to its module. + +const templatedNotifier = `{"module":"notifier","version":"1", + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644","template":"files/notifier.conf"}]}` + +// inlineNotifier is the same module as the mesh holds it: built, its text inline. +const inlineNotifier = `{"module":"notifier","version":"1", + "resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644","content":"volume = 3\n"}]}` + +// aTemplatedModule writes a module's directory: its manifest and its files/. +func aTemplatedModule(t *testing.T, dir, manifest string, files map[string]string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "module.json") + if err := os.WriteFile(path, []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + for name, text := range files { + full := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + return path +} + +func TestModuleCheckReadsTemplatesFromTheTreeBesideTheManifest(t *testing.T) { + good := aTemplatedModule(t, t.TempDir(), templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"}) + var out bytes.Buffer + if err := moduleCheck([]string{good}, &out); err != nil || !strings.Contains(out.String(), "notifier: ok") { + t.Fatalf("a module with its text in files/ was refused: %v\n%s", err, out.String()) + } + + // A change to the template alone, to text the controller refuses inline, is refused the same. + bad := aTemplatedModule(t, t.TempDir(), templatedNotifier, + map[string]string{"files/notifier.conf": "volume = 3\nstore = ${dir:nowhere}\n"}) + out.Reset() + if err := moduleCheck([]string{bad}, &out); err == nil || !strings.Contains(out.String(), "nowhere") { + t.Fatalf("a template whose text is refused inline passed:\n%s", out.String()) + } + + // And a template the tree lacks, by name. + missing := aTemplatedModule(t, t.TempDir(), templatedNotifier, nil) + out.Reset() + if err := moduleCheck([]string{missing}, &out); err == nil || !strings.Contains(out.String(), "files/notifier.conf") { + t.Fatalf("a missing template was not refused by name:\n%s", out.String()) + } +} + +// templatedMesh is catalogueMesh with the notifier on the laptop, registered as built: its text inline. +func templatedMesh(t *testing.T) (snapshot.Facts, map[string]string) { + t.Helper() + open := aMesh(t) + ctx := t.Context() + manifests := map[string]string{ + "lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"], + "resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`, + "notifier": inlineNotifier, + } + for name, raw := range manifests { + m, err := catalogue.ParseManifest([]byte(raw)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog", + Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil { + t.Fatal(err) + } + if _, err := assign(ctx, open, "laptop", name); err != nil { + t.Fatalf("assign %s: %v", name, err) + } + } + f, err := gatherFacts(ctx, open, "2.11.17") + if err != nil { + t.Fatal(err) + } + return f, manifests +} + +// aTemplatedTree is the catalogue's checkout with the notifier's text in files/. +func aTemplatedTree(t *testing.T, manifests map[string]string, text string, has bool) string { + t.Helper() + tree := aTree(t, withEdit(manifests, "notifier", "")) + files := map[string]string{} + if has { + files["files/notifier.conf"] = text + } + aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, files) + return tree +} + +const notifierTemplate = "modules/notifier/files/notifier.conf" + +func TestTheMergeGateJudgesAChangeToATemplateAlone(t *testing.T) { + f, manifests := templatedMesh(t) + + // The module moved to its template, the same text: nothing changes on any machine. + v := gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 3\n", true), "modules/notifier/module.json", + notifierTemplate) + if v.Verdict == "fail" || v.Modules["notifier"] != "" { + t.Fatalf("the module moved to its template with the same text reads as a change (%v):\n%s", v.Modules, v.Report()) + } + + // The template alone changed: its module changed, and composes. + v = gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 4\n", true), notifierTemplate) + if v.Verdict == "fail" || v.Modules["notifier"] != "changed" { + t.Fatalf("a change to the template alone is not a change to its module (%v):\n%s", v.Modules, v.Report()) + } + if v.Width == nil || strings.Join(v.Width.Modules, ",") != "notifier" { + t.Fatalf("a change to the template rebuilds %+v, not its module", v.Width) + } + + // The template alone changed to text the controller refuses: the pull request fails, naming the module. + v = gateJudged(t, f, aTemplatedTree(t, manifests, "store = ${dir:nowhere}\n", true), notifierTemplate) + if v.Verdict != "fail" || !strings.Contains(v.Report(), "notifier") { + t.Fatalf("a template the controller refuses passed the gate:\n%s", v.Report()) + } + + // And the template removed from the tree fails, naming it. + v = gateJudged(t, f, aTemplatedTree(t, manifests, "", false), notifierTemplate) + if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/notifier.conf") { + t.Fatalf("a missing template passed the gate:\n%s", v.Report()) + } +} + +// What the gate composes for the module in its template form is what the mesh composes for it inline. +func TestTheMergeGateResolvesATemplatedModuleToItsInlineTwin(t *testing.T) { + dir := t.TempDir() + path := aTemplatedModule(t, dir, templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"}) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + m, err := catalogue.ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + m, err = catalogue.WithTemplatesFrom(m, dir) + if err != nil { + t.Fatal(err) + } + resolved, err := standInBuild(m) + if err != nil { + t.Fatal(err) + } + inline, err := catalogue.ParseManifest([]byte(inlineNotifier)) + if err != nil { + t.Fatal(err) + } + if builtAlike(resolved) != builtAlike(inline) { + got, _ := json.Marshal(resolved) + t.Fatalf("the templated module resolves to %s, not its inline twin", got) + } +} diff --git a/internal/catalogue/template_test.go b/internal/catalogue/template_test.go new file mode 100644 index 00000000..85c81896 --- /dev/null +++ b/internal/catalogue/template_test.go @@ -0,0 +1,258 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// A file resource may name a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package +// T1, issue 654): the build packs files/ into an archive, and resolving the build reads each template's text back +// into content, so the mesh holds, composes and sends exactly what it would for the same text inline. + +// The two forms of one module: its text inline, and its text in files/. +const ( + inlineTwin = `{"module":"tidy","version":"1", + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"conf","type":"directory","path":"${dir:conf}"}, + {"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","content":"` + mainText + `"}, + {"id":"extra","type":"file","path":"${dir:conf}/extra.conf","content":"` + extraText + `"}]}` + templatedTwin = `{"module":"tidy","version":"1", + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[ + {"id":"conf","type":"directory","path":"${dir:conf}"}, + {"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","template":"files/tidy.conf"}, + {"id":"extra","type":"file","path":"${dir:conf}/extra.conf","template":"files/deeper/extra.conf"}]}` + mainText = `# tidy\nroot = ${dir:conf}\nquoted = \"yes\" é\n` + extraText = `one\ntwo\n` +) + +// templateTexts is what a build reads back from the packed files/ archive: by path inside it. +func templateTexts(t *testing.T) map[string]string { + t.Helper() + var m struct{ Main, Extra string } + if err := json.Unmarshal([]byte(`{"Main":"`+mainText+`","Extra":"`+extraText+`"}`), &m); err != nil { + t.Fatal(err) + } + return map[string]string{"tidy.conf": m.Main, "deeper/extra.conf": m.Extra, "unused.conf": "not named"} +} + +func builtFiles(texts map[string]string) []Built { + return []Built{{Name: "files", Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + "tidy/files/blobs/sha256:" + strings.Repeat("c", 64), + Digest: "sha256:" + strings.Repeat("c", 64), Texts: texts}} +} + +// held is a manifest as registration holds it: resolved with what was built, then read again strictly. +func held(t *testing.T, raw string, built []Built) Manifest { + t.Helper() + m, err := ParseManifest([]byte(raw)) + if err != nil { + t.Fatal(err) + } + resolved, err := m.Resolve(built) + if err != nil { + t.Fatal(err) + } + again, err := json.Marshal(resolved) + if err != nil { + t.Fatal(err) + } + out, err := ParseManifest(again) + if err != nil { + t.Fatal(err) + } + if problem := HeldTemplateProblem(out); problem != "" { + t.Fatal(problem) + } + return out +} + +func composed(t *testing.T, m Manifest) string { + t.Helper() + got, err := Resolve(shelf(m), []string{m.Module}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := json.Marshal(mustDeclare(t, got)) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +func TestATemplatedModuleComposesByteIdenticallyToItsInlineTwin(t *testing.T) { + inline := composed(t, held(t, inlineTwin, builtFiles(nil))) + templated := composed(t, held(t, templatedTwin, builtFiles(templateTexts(t)))) + if inline != templated { + t.Fatalf("the two forms compose differently:\ninline: %s\ntemplated: %s", inline, templated) + } + if !strings.Contains(templated, "quoted = \\\"yes\\\"") || strings.Contains(templated, `"template"`) { + t.Fatalf("the composition does not carry the template's text as content: %s", templated) + } +} + +// What `module check`, the merge gate and `module add` judge: the text read from the tree beside the manifest. +func TestATemplatedModuleReadFromItsTreeComposesByteIdenticallyToItsInlineTwin(t *testing.T) { + dir := t.TempDir() + for name, text := range templateTexts(t) { + full := filepath.Join(dir, "files", filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + filled, err := WithTemplatesFrom(m, dir) + if err != nil { + t.Fatal(err) + } + if filled.Templated() { + t.Fatal("a manifest read from its tree still names a template") + } + resolved, err := filled.Resolve(builtFiles(nil)) + if err != nil { + t.Fatal(err) + } + if got, want := composed(t, resolved), composed(t, held(t, inlineTwin, builtFiles(nil))); got != want { + t.Fatalf("read from the tree, the module composes differently:\ninline: %s\ntemplated: %s", want, got) + } +} + +func TestAFileNamingBothATemplateAndContentIsRefused(t *testing.T) { + raw := strings.Replace(templatedTwin, `"template":"files/tidy.conf"`, + `"template":"files/tidy.conf","content":"x"`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "never both") { + t.Fatalf("a file with both a template and content was not refused by name: %v", err) + } +} + +func TestATemplateOutsideFilesIsRefusedByName(t *testing.T) { + for _, bad := range []string{"../tidy.conf", "/etc/tidy.conf", "files", "files/", "files/../tidy.conf", + "files/./tidy.conf", "other/tidy.conf", "tidy.conf", "files//tidy.conf", `files\\tidy.conf`} { + raw := strings.Replace(templatedTwin, `"files/tidy.conf"`, `"`+bad+`"`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "not a file under files/") { + t.Errorf("the template %q was not refused by name: %v", bad, err) + } + } +} + +func TestATemplateOnAResourceThatIsNotAFileIsRefused(t *testing.T) { + raw := strings.Replace(templatedTwin, `{"id":"conf","type":"directory","path":"${dir:conf}"}`, + `{"id":"conf","type":"directory","path":"${dir:conf}","template":"files/tidy.conf"}`, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "conf is a directory and names a template") { + t.Fatalf("a directory naming a template was not refused: %v", err) + } +} + +func TestATemplatedModuleWhoseBuildPacksNoFilesIsRefused(t *testing.T) { + for _, build := range []string{ + ``, + `"build":{"artifacts":[{"name":"look","kind":"archive","from":"theme"}]},`, + `"build":{"artifacts":[{"name":"a","kind":"archive","from":"files"},{"name":"b","kind":"archive","from":"./files/"}]},`, + } { + raw := strings.Replace(templatedTwin, + `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, build, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "files/") { + t.Errorf("a templated module with the build %q was not refused: %v", build, err) + } + } +} + +func TestAMissingTemplateIsRefusedByName(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + // In the tree beside the manifest. + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "tidy.conf"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + _, err = WithTemplatesFrom(m, dir) + if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "files/deeper/extra.conf") { + t.Fatalf("a template missing from the tree was not refused by name: %v", err) + } + // And in what the build read back from its archive. + texts := templateTexts(t) + delete(texts, "deeper/extra.conf") + _, err = m.Resolve(builtFiles(texts)) + if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "files/deeper/extra.conf") { + t.Fatalf("a template missing from the archive was not refused by name: %v", err) + } +} + +func TestATemplateThatIsASymlinkIsRefused(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files", "deeper"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "elsewhere"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "tidy.conf"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, "files", "deeper", "extra.conf")); err != nil { + t.Fatal(err) + } + if _, err := WithTemplatesFrom(m, dir); err == nil || !strings.Contains(err.Error(), "not a plain file") { + t.Fatalf("a template that is a symlink was not refused: %v", err) + } +} + +func TestAResolveWithoutTheArchivesTextIsRefused(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + if _, err := m.Resolve(builtFiles(nil)); err == nil || !strings.Contains(err.Error(), "files") { + t.Fatalf("a templated module resolved with no text read back from its archive: %v", err) + } +} + +func TestATemplateThatIsNotUTF8IsRefused(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + texts := templateTexts(t) + texts["tidy.conf"] = "\xff\xfe" + if _, err := m.Resolve(builtFiles(texts)); err == nil || !strings.Contains(err.Error(), "UTF-8") { + t.Fatalf("a template that is not UTF-8 was resolved: %v", err) + } +} + +// What registration refuses: a manifest to be held that still names a template — one resolved by a builder that +// predates templates would otherwise compose files with no text. +func TestAManifestStillNamingATemplateIsNotHeld(t *testing.T) { + m, err := ParseManifest([]byte(templatedTwin)) + if err != nil { + t.Fatal(err) + } + if problem := HeldTemplateProblem(m); !strings.Contains(problem, "main") || !strings.Contains(problem, "files/tidy.conf") { + t.Fatalf("a manifest naming a template would be held: %q", problem) + } + if problem := HeldTemplateProblem(held(t, inlineTwin, builtFiles(nil))); problem != "" { + t.Fatalf("an inline manifest is refused: %s", problem) + } +}