Files
mesh-sdk/go/catalogue/template.go
T
jschoubben 2e4eb0be90 Catalogue: a module's needs include its templates' text (ADR 0632 §2; issue 1966)
docker's daemon file and artifact-store-tools' store address move to the image
seat's reach in their templates while their manifests stay the same, so needs
read from the manifest alone let an engine before the reading lose the file.
ModuleNeeds reads the templates TemplatePaths names; the merge message carries
the result per directory for the indexer.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 21:37:43 +01:00

379 lines
13 KiB
Go

package catalogue
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
"unicode/utf8"
)
// TemplateField is the key a file resource names its template file by.
const TemplateField = "template"
// DefaultTemplatesDir is the directory a module's template files are kept in, and its templates archive is built
// from, when its manifest names none (Manifest.Templates, issue 731).
const DefaultTemplatesDir = "files"
// TemplatesDir is the module's directory its template files are kept in, and the directory its templates archive is
// built from: the one its manifest names, else files/.
func (m Manifest) TemplatesDir() string {
if m.Templates != "" {
return m.Templates
}
return DefaultTemplatesDir
}
// Templated is whether any of the manifest's resources, or any item it contributes (collections.go), names a
// template.
func (m Manifest) Templated() bool {
for _, r := range m.Resources {
if _, has := r[TemplateField]; has {
return true
}
}
for _, it := range m.Items {
if it.Template != "" {
return true
}
}
return false
}
// TemplatesArtifact is the name of the archive artifact the module packs its templates into — the one built from its
// templates directory (TemplatesDir) — or "" when it declares none, or more than one (both refused by templateProblems
// for a module naming a template).
func TemplatesArtifact(m Manifest) string {
found := templatesArtifacts(m)
if len(found) != 1 {
return ""
}
return found[0]
}
func templatesArtifacts(m Manifest) []string {
if m.Build == nil {
return nil
}
var found []string
for _, a := range m.Build.Artifacts {
if a.Kind == ArtifactArchive && a.URL == "" && cleanFrom(a.From) == m.TemplatesDir() {
found = append(found, a.Name)
}
}
return found
}
// cleanFrom is an artifact's directory as one relative path: "./files/" is "files".
func cleanFrom(from string) string {
c := path.Clean(strings.TrimSpace(from))
return strings.TrimPrefix(c, "./")
}
// templateOf is the template a resource names, whether it names one, and what is wrong with it ("" when nothing):
// dir is the module's templates directory.
func templateOf(module, dir string, r map[string]any) (string, bool, string) {
raw, has := r[TemplateField]
if !has {
return "", false, ""
}
id := fmt.Sprint(r["id"])
if t := fmt.Sprint(r["type"]); t != "file" {
return "", true, fmt.Sprintf("%s: %s is a %s and names a template; only a file takes its text from one "+
"(novox/hq ADR 0349 §2)", module, id, t)
}
name, ok := raw.(string)
if !ok || strings.TrimSpace(name) == "" {
return "", true, fmt.Sprintf("%s: %s names a template that is not a path: %v; a template is %q",
module, id, raw, dir+"/<name>")
}
if _, both := r["content"]; both {
return name, true, fmt.Sprintf("%s: %s names the template %q and says its content too; a file's text is "+
"in one place, the template or the content, never both (novox/hq ADR 0349 §2)", module, id, name)
}
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
strings.Contains(name, "\\") {
return name, true, fmt.Sprintf("%s: %s names the template %q, which is not a file under %s/ of its own "+
"repository: a template is %q, a plain relative path, and the build packs only %s/",
module, id, name, dir, dir+"/<name>", dir)
}
return name, true, ""
}
// fillTemplates is resources with each template's text as its content and the template key dropped: text says the text
// of a template by its path ("<templates directory>/<name>"), and whether it has one. A template it has no text for, or
// text that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were.
func fillTemplates(module, dir string, resources []map[string]any, text func(string) (string, bool),
where string) ([]map[string]any, error) {
out := make([]map[string]any, 0, len(resources))
var problems []string
for _, r := range resources {
name, has, wrong := templateOf(module, dir, r)
if !has {
out = append(out, r)
continue
}
if wrong != "" {
problems = append(problems, wrong)
continue
}
body, ok := text(name)
if !ok {
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and %s holds no such file",
module, r["id"], name, where))
continue
}
if !utf8.ValidString(body) {
// A manifest is JSON: text that is not UTF-8 would be changed on the way, silently.
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not UTF-8 text; a "+
"file's text is carried as JSON and would not reach the machine as written", module, r["id"], name))
continue
}
filled := make(map[string]any, len(r))
for k, v := range r {
filled[k] = v
}
delete(filled, TemplateField)
filled["content"] = body
out = append(out, filled)
}
if len(problems) > 0 {
return nil, errors.New(strings.Join(problems, "; "))
}
return out, nil
}
// fillItemTemplates is items with each template's text as its content and the template dropped, as fillTemplates
// does for files (collections.go).
func fillItemTemplates(m Manifest, items []Item, text func(string) (string, bool), where string) ([]Item, error) {
if len(items) == 0 {
return items, nil
}
out := make([]Item, 0, len(items))
var problems []string
for _, it := range items {
if it.Template == "" {
out = append(out, it)
continue
}
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
problems = append(problems, fmt.Sprintf("%s's item of %s: %s", m.Module, it.Collection, wrong))
continue
}
body, ok := text(it.Template)
switch {
case !ok:
problems = append(problems, fmt.Sprintf("%s: its item of %s names the template %q, and %s holds no "+
"such file", m.Module, it.Collection, it.Template, where))
continue
case !utf8.ValidString(body):
problems = append(problems, fmt.Sprintf("%s: its item of %s names the template %q, which is not UTF-8 "+
"text", m.Module, it.Collection, it.Template))
continue
}
it.Template, it.Content = "", body
out = append(out, it)
}
if len(problems) > 0 {
return nil, errors.New(strings.Join(problems, "; "))
}
return out, nil
}
// TemplatePaths is every template the manifest's files and items name, by its path in the module's directory, sorted
// and once each: what a reader of the module's files reads for ModuleNeeds. A template whose name is wrong is left out;
// the judge refuses it by name.
func TemplatePaths(m Manifest) []string {
var out []string
for _, r := range m.Resources {
if name, has, wrong := templateOf(m.Module, m.TemplatesDir(), r); has && wrong == "" {
out = append(out, name)
}
}
for _, it := range m.Items {
if it.Template != "" && itemTemplateProblem(m, it.Template) == "" {
out = append(out, it.Template)
}
}
slices.Sort(out)
return slices.Compact(out)
}
// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository tree,
// by template path. Refused by name: a template the directory lacks, and anything under its templates directory
// (TemplatesDir) the build's packing refuses — that directory itself, or any directory or file under it, that is a
// symlink or not a plain file — so nothing judged here can be read from outside the tree, and no tree passes here that
// the build would refuse. Each template is read through an os.Root of the module's directory besides, which no path
// element can leave.
func ReadTemplates(m Manifest, dir string) (map[string]string, error) {
problems := templatesTreeProblems(m.Module, dir, m.TemplatesDir())
root, err := os.OpenRoot(dir)
if err != nil {
return nil, fmt.Errorf("%s: the module's directory cannot be opened to read its templates: %w", m.Module, err)
}
defer root.Close()
texts := map[string]string{}
type named struct {
id any
template string
wrong string
}
var templates []named
for _, r := range m.Resources {
name, has, wrong := templateOf(m.Module, m.TemplatesDir(), r)
if has {
templates = append(templates, named{r["id"], name, wrong})
}
}
for _, it := range m.Items {
if it.Template != "" {
templates = append(templates, named{"its item of " + it.key(), it.Template,
itemTemplateProblem(m, it.Template)})
}
}
for _, t := range templates {
r := map[string]any{"id": t.id}
name, wrong := t.template, t.wrong
if wrong != "" {
problems = append(problems, wrong)
continue
}
if _, read := texts[name]; read {
continue
}
info, err := root.Lstat(name)
switch {
case errors.Is(err, fs.ErrNotExist):
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and the module's directory "+
"has no such file", m.Module, r["id"], name))
continue
case err != nil:
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v",
m.Module, r["id"], name, err))
continue
case !info.Mode().IsRegular():
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not a plain file; "+
"the build packs only files", m.Module, r["id"], name))
continue
}
body, err := root.ReadFile(name)
if err != nil {
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v",
m.Module, r["id"], name, err))
continue
}
texts[name] = string(body)
}
if len(problems) > 0 {
return nil, errors.New(strings.Join(problems, "; "))
}
return texts, nil
}
// templatesTreeProblems is everything under the module's templates directory (templates, inside dir) that the
// build's packing refuses: that directory itself not a plain directory, and any entry under it that is neither a
// directory nor a plain file — a symlink, to a file or a directory, a device, a pipe. Each said by its path. A
// templates directory that does not exist is no problem here: each template it would have held is refused as missing.
func templatesTreeProblems(module, dir, templates string) []string {
top := filepath.Join(dir, filepath.FromSlash(templates))
info, err := os.Lstat(top)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return []string{fmt.Sprintf("%s: %s/ cannot be read: %v", module, templates, err)}
}
if !info.IsDir() {
return []string{fmt.Sprintf("%s: %s/ is %s, not a directory of the module's own; its templates are read "+
"only from inside the repository, and the build packs only directories and files", module, templates,
kindOf(info.Mode()))}
}
var problems []string
err = filepath.WalkDir(top, func(at string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || d.Type().IsRegular() {
return nil
}
rel, _ := filepath.Rel(dir, at)
problems = append(problems, fmt.Sprintf("%s: %s is %s; the build packs only directories and files, so "+
"%s/ holds nothing else", module, filepath.ToSlash(rel), kindOf(d.Type()), templates))
return nil
})
if err != nil {
problems = append(problems, fmt.Sprintf("%s: %s/ cannot be read: %v", module, templates, err))
}
return problems
}
// kindOf says what a file that is not a plain file is.
func kindOf(mode fs.FileMode) string {
switch {
case mode&fs.ModeSymlink != 0:
return "a symlink"
case mode&fs.ModeDevice != 0:
return "a device"
case mode&fs.ModeNamedPipe != 0:
return "a named pipe"
case mode&fs.ModeSocket != 0:
return "a socket"
case mode.IsDir():
return "a directory"
}
return "not a plain file"
}
// WithTemplatesFrom is the manifest with each template's text read from the module's directory in a repository
// tree as its content: what `module check`, the merge gate and `module add` judge, where nothing was built. Read
// again strictly afterwards, so every rule about a file's text judges the template's text exactly as it judges
// inline content. A manifest naming no template is returned as it is.
func WithTemplatesFrom(m Manifest, dir string) (Manifest, error) {
if !m.Templated() {
return m, nil
}
texts, err := ReadTemplates(m, dir)
if err != nil {
return Manifest{}, err
}
filled, err := fillTemplates(m.Module, m.TemplatesDir(), m.Resources, func(name string) (string, bool) {
t, ok := texts[name]
return t, ok
}, "the module's directory")
if err != nil {
return Manifest{}, err
}
items, err := fillItemTemplates(m, m.Items, func(name string) (string, bool) {
t, ok := texts[name]
return t, ok
}, "the module's directory")
if err != nil {
return Manifest{}, err
}
out := m
out.Resources = filled
out.Items = items
// Nothing names a template now, so nothing names its directory.
out.Templates = ""
// A moved module's mark was judged above, while it still named its templates; read again with its text inline,
// it is judged as it composes without its build's templates archive — as before, nothing handed (handed.go).
out.Moved = false
raw, err := json.Marshal(out)
if err != nil {
return Manifest{}, err
}
// Decoded again, never judged (novox/hq ADR 0578 §3): the judge judges what this answers where it judges.
again, err := Decode(raw)
if err != nil {
return Manifest{}, err
}
again.predefined = m.predefined
again.Moved = m.Moved
return again, nil
}