From d43fbc202c8f00c964bfdf644e5ad1e3412b34e4 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 23:56:43 +0000 Subject: [PATCH] Let a file resource name a template file in its module's repository A file's text may live in files/ beside the manifest instead of inline, so modules can move their files out of module.json (ADR 0349 clause 2, issue 654). The build packs files/ into the module's declared archive and reads each template back from the packed bytes, proven by the digest, into content: the manifest the mesh holds, composition and the node-engine stay as they are. module check, the merge gate and module add read templates from the tree beside the manifest, so a change to a template alone is judged. A file naming both template and content, a template outside files/, a missing one, and a held manifest that still names one are refused by name. Co-Authored-By: Claude Opus 5.5 --- cmd/mesh-controller/check.go | 5 +++++ cmd/mesh-controller/merge_gate.go | 5 +++++ cmd/mesh-controller/modules.go | 5 +++++ internal/builder/builder.go | 14 +++++++++++++- internal/builder/reuse_test.go | 16 ++++++++++++++-- internal/catalogue/build.go | 24 ++++++++++++++++++++++++ internal/catalogue/manifest.go | 3 +++ internal/inventory/catalogue.go | 5 +++++ 8 files changed, 74 insertions(+), 3 deletions(-) diff --git a/cmd/mesh-controller/check.go b/cmd/mesh-controller/check.go index 16e16ba6..64b04429 100644 --- a/cmd/mesh-controller/check.go +++ b/cmd/mesh-controller/check.go @@ -55,6 +55,11 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error { continue } m, err := catalogue.ParseManifest(raw) + if err == nil { + // Each template's text read from the tree beside the manifest, and judged as inline content is + // (novox/hq ADR 0349 §2): a missing template is refused by name. + m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(path)) + } if err != nil { fmt.Fprintf(out, "%s: %v\n", path, err) failed++ diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go index ddcfd609..78b5332a 100644 --- a/cmd/mesh-controller/merge_gate.go +++ b/cmd/mesh-controller/merge_gate.go @@ -576,6 +576,11 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve return err } m, err := catalogue.ParseManifest(raw) + if err == nil { + // Each template's text from the tree beside the manifest, as the build reads it back from the archive it + // packs (novox/hq ADR 0349 §2): so a change to a template alone is a change to its module. + m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(path)) + } if err != nil { if inScope(dir) { failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s", diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 66804655..2a6a0a48 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "os" + "path/filepath" "sort" "strings" "time" @@ -127,6 +128,10 @@ func moduleCommand(ctx context.Context, args []string) error { if err != nil { return err } + // A manifest handed over by hand takes each template's text from the tree beside it (novox/hq ADR 0349 §2). + if m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(positionals[0])); err != nil { + return err + } from, err := whereItComesFrom(*repo, *ref, *commit, *path, *self) if err != nil { return err diff --git a/internal/builder/builder.go b/internal/builder/builder.go index e819ea41..c2c1cbf9 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -268,7 +268,14 @@ func build(ctx context.Context, run Runner, publish Publisher, } // What is judged only where a definition is written (novox/hq ADR 0326 §3, issue 574): a build is // where a changed definition is first read, so it is refused here before anything is made of it. - if problems := catalogue.WrittenSettingsProblems(manifest); len(problems) > 0 { + // Judged with each template's text in its file, as written in the tree (novox/hq ADR 0349 §2): a template the + // module's directory lacks is refused here, before anything is built. + judged, err := catalogue.WithTemplatesFrom(manifest, within) + if err != nil { + say("manifest", "INVALID: %v", err) + return Result{}, err + } + if problems := catalogue.WrittenSettingsProblems(judged); len(problems) > 0 { err := fmt.Errorf("%s", strings.Join(problems, "; ")) say("manifest", "INVALID: %v", err) return Result{}, err @@ -410,6 +417,11 @@ func build(ctx context.Context, run Runner, publish Publisher, } } + // Each template's text, read back from the archive the build made or took (novox/hq ADR 0349 §2). + if built, err = withTemplateTexts(manifest, within, built); err != nil { + say("templates", "FAILED: %v", err) + return Result{}, err + } resolved, err := manifest.Resolve(built) if err != nil { say("resolve", "FAILED: %v", err) diff --git a/internal/builder/reuse_test.go b/internal/builder/reuse_test.go index e3e4ec24..3b3d86e4 100644 --- a/internal/builder/reuse_test.go +++ b/internal/builder/reuse_test.go @@ -73,8 +73,14 @@ const reusedModule = `{"module":"meshboard","version":"1", // aModuleAt is a repository holding the module under mod/, whose directory git names as tree, on its trunk. func aModuleAt(t *testing.T, tree string) (*recorded, Runner, string) { t.Helper() - r := &recorded{tree: tree, contents: map[string]string{"mod/" + ManifestName: reusedModule, - "mod/Dockerfile": "FROM scratch", "mod/files/theme.conf": "dark"}} + return aModuleWith(t, tree, map[string]string{"mod/" + ManifestName: reusedModule, + "mod/Dockerfile": "FROM scratch", "mod/files/theme.conf": "dark"}) +} + +// aModuleWith is aModuleAt with the repository's files given. +func aModuleWith(t *testing.T, tree string, contents map[string]string) (*recorded, Runner, string) { + t.Helper() + r := &recorded{tree: tree, contents: contents} run := func(ctx context.Context, dir, name string, args ...string) (string, error) { if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" { r.ran = append(r.ran, name+" "+strings.Join(args, " ")) @@ -100,6 +106,12 @@ var aPull = PullRef{Owner: "novox", Repo: "meshboard", Number: 7} func aCheckBuilt(t *testing.T, scratch *aScratch, tree string) CheckBuilt { t.Helper() r, run, workspace := aModuleAt(t, tree) + return aCheckBuiltBy(t, scratch, r, run, workspace) +} + +// aCheckBuiltBy is aCheckBuilt of the repository given. +func aCheckBuiltBy(t *testing.T, scratch *aScratch, r *recorded, run Runner, workspace string) CheckBuilt { + t.Helper() spec := CheckSpec{ID: "build-1", Owner: aPull.Owner, Repo: aPull.Repo, Number: aPull.Number, Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod", Ref: "main"}}} diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index 8341916b..6fa59eb0 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -35,6 +35,10 @@ type Built struct { // Launchers are, for a bundle in an interpreted language, the executable the build wrote beside // each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it. Launchers map[string]string + // Texts are, for the archive a module packs its templates into (TemplatesArtifact), each file's text as the + // build read it back from the packed archive, by its path inside it (novox/hq ADR 0349 §2). Never carried on + // the wire or kept: Resolve reads them into the files' content, which is what the mesh holds. + Texts map[string]string `json:"-"` } // Resolve fills a manifest's resources in from what was built. @@ -71,6 +75,26 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { m.Module, strings.Join(missing, " and "), oneOrOther(len(missing))) } + // **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into + // the file's content, so what the mesh holds and composes is what the same text inline would be. + if m.Templated() { + name := TemplatesArtifact(m) + texts := by[name].Texts + if name == "" || texts == nil { + return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+ + "archive it packs %s/ into (%q): the builder that built it does not read templates", + m.Module, TemplatesDir, name) + } + filled, err := fillTemplates(m.Module, m.Resources, func(template string) (string, bool) { + t, ok := texts[strings.TrimPrefix(template, TemplatesDir+"/")] + return t, ok + }, fmt.Sprintf("the archive %q", name)) + if err != nil { + return Manifest{}, err + } + m.Resources = filled + } + out := m out.Build = nil out.Resources = nil diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 4dae3034..39dc1ba2 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1853,6 +1853,9 @@ func ParseManifest(raw []byte) (Manifest, error) { } } problems = append(problems, m.Build.problems(m.Module)...) + // A file's text in a template file of the module's repository (novox/hq ADR 0349 §2): only a file, never beside + // content, only under files/, and packed by the module's build. + problems = append(problems, templateProblems(m)...) problems = append(problems, undeliveredBundles(m)...) // **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029). // diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 6ce3740c..6cd27eb2 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -117,6 +117,11 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr } } } + // **The manifest the mesh holds carries each file's text** (novox/hq ADR 0349 §2): one still naming a template + // would compose a file with no text, so it is refused by name — a build by a builder that predates templates. + if why := catalogue.HeldTemplateProblem(m); why != "" { + return fmt.Errorf("%s is not registered: %s", m.Module, why) + } raw, err := json.Marshal(m) if err != nil { return err