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()) } // A symlink under files/ that no template names: the build's packing refuses it, so the check does too. linked := t.TempDir() aTemplatedModule(t, linked, templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"}) if err := os.Symlink("/etc/hostname", filepath.Join(linked, "files", "elsewhere")); err != nil { t.Fatal(err) } out.Reset() if err := moduleCheck([]string{filepath.Join(linked, "module.json")}, &out); err == nil || !strings.Contains(out.String(), "files/elsewhere is a symlink") { t.Fatalf("a symlink under files/ passed the check:\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()) } // A symlinked files/ fails: nothing is read from outside the tree. tree := aTree(t, withEdit(manifests, "notifier", "")) elsewhere := t.TempDir() if err := os.WriteFile(filepath.Join(elsewhere, "notifier.conf"), []byte("volume = 3\n"), 0o644); err != nil { t.Fatal(err) } aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, nil) if err := os.Symlink(elsewhere, filepath.Join(tree, "modules", "notifier", "files")); err != nil { t.Fatal(err) } v = gateJudged(t, f, tree, notifierTemplate) if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/ is a symlink") { t.Fatalf("a symlinked files/ 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) } texts, err := catalogue.ReadTemplates(m, dir) if err != nil { t.Fatal(err) } resolved, err := standInBuild(m, texts) 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) } } // A module whose files/ is unpacked onto the machine names its own templates directory (issue 731): `module check` // reads its templates from there and says of it exactly what it says of the same module with its text inline. func TestModuleCheckReadsTemplatesFromTheTemplatesDirectoryTheManifestNames(t *testing.T) { const inline = `{"module":"notifier","version":"1", "build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}]}, "resources":[{"id":"scripts","type":"archive","path":"/usr/local/lib/notifier","artifact":"scripts"}, {"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644","content":"volume = 3\n"}]}` const templated = `{"module":"notifier","version":"1","templates":"templates", "build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}, {"name":"templates","kind":"archive","from":"templates"}]}, "resources":[{"id":"scripts","type":"archive","path":"/usr/local/lib/notifier","artifact":"scripts"}, {"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644","template":"templates/notifier.conf"}]}` scripts := map[string]string{"files/bin/notify": "#!/bin/sh\n"} var want bytes.Buffer if err := moduleCheck([]string{aTemplatedModule(t, t.TempDir(), inline, scripts)}, &want); err != nil { t.Fatalf("the inline module was refused: %v\n%s", err, want.String()) } with := map[string]string{"files/bin/notify": "#!/bin/sh\n", "templates/notifier.conf": "volume = 3\n"} var got bytes.Buffer if err := moduleCheck([]string{aTemplatedModule(t, t.TempDir(), templated, with)}, &got); err != nil { t.Fatalf("a module with its text in templates/ was refused: %v\n%s", err, got.String()) } if got.String() != want.String() { t.Fatalf("module check says otherwise of the templated module:\ninline: %s\ntemplated: %s", want.String(), got.String()) } // Its text in files/ instead, where the machine unpacks it, is not where its manifest says its templates are. misplaced := strings.Replace(templated, `"templates/notifier.conf"`, `"files/notifier.conf"`, 1) got.Reset() if err := moduleCheck([]string{aTemplatedModule(t, t.TempDir(), misplaced, map[string]string{"files/notifier.conf": "volume = 3\n"})}, &got); err == nil || !strings.Contains(got.String(), "files/notifier.conf") { t.Fatalf("a template outside the templates directory the manifest names passed:\n%s", got.String()) } } // Issue 876: the gate judges a moved module that names its templates as registration reads it after a real build, // handed its templates; it refused every such module as "marked moved and names no template". This calls the gate's // own shelf (shelfWithChange), which needs no database. func TestReplay876(t *testing.T) { moved := strings.Replace(templatedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1) judge := func(manifest string, files map[string]string) (map[string]catalogue.Manifest, []string) { tree := t.TempDir() aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), manifest, files) out, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, nil, snapshot.Facts{}, "novox/mesh-catalog", tree, map[string]string{}, nil) if err != nil { t.Fatal(err) } return out, failures } out, failures := judge(moved, map[string]string{"files/notifier.conf": "volume = 3\n"}) if len(failures) > 0 { t.Fatalf("a moved module naming its templates was refused: %v", failures) } if h := out["notifier"].Handed; h == nil || h.Templates["conf"] != "notifier.conf" { t.Fatalf("the gate did not hand the moved module its templates: %+v", h) } // A moved module naming no template is still refused. none := strings.Replace(strings.Replace(moved, `"template":"files/notifier.conf"`, `"content":"volume = 3\n"`, 1), `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) if _, failures := judge(none, nil); len(failures) != 1 || !strings.Contains(failures[0], "names no template") { t.Fatalf("a moved module naming no template was not refused: %v", failures) } }