57/T1: A file resource may name a template file in its module's repository (issue 654) #313
@@ -0,0 +1,274 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 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).
|
||||
//
|
||||
// **Two forms, one composition.** A file resource says its text inline (`content`) or names a template file
|
||||
// beside its manifest (`"template": "files/<name>"`), never both. The module's build packs `files/` into an
|
||||
// archive artifact of its own — an `archive` artifact built `from: "files"` — and resolving the build reads each
|
||||
// template's text back from that packed archive into `content` and drops `template`. So the manifest the mesh
|
||||
// holds, composition and the node-engine are exactly as they were: a module with its text in `files/` composes
|
||||
// byte-identically to the same module with it inline. Where there is no build — `module check`, the merge gate,
|
||||
// `module add` — the text is read from the repository tree beside the manifest (WithTemplatesFrom).
|
||||
//
|
||||
// **Read back by the builder, carried in the resolved manifest.** The builder reads each template from the very
|
||||
// bytes it hashed into the archive's digest, so the text the mesh holds is the archive's. The controller
|
||||
// registers the resolved manifest as the build result carries it and fetches nothing from the artifact store; a
|
||||
// manifest that still names a template when it would be held — built by a builder that predates templates — is
|
||||
// refused at registration (HeldTemplateProblem).
|
||||
|
||||
// TemplateField is the key a file resource names its template file by.
|
||||
const TemplateField = "template"
|
||||
|
||||
// TemplatesDir is the module's directory its template files are kept in, and the directory its templates archive
|
||||
// is built from.
|
||||
const TemplatesDir = "files"
|
||||
|
||||
// Templated is whether any of the manifest's resources names a template.
|
||||
func (m Manifest) Templated() bool {
|
||||
for _, r := range m.Resources {
|
||||
if _, has := r[TemplateField]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TemplatesArtifact is the name of the archive artifact the module packs its templates into — the one built from
|
||||
// files/ — 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) == 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).
|
||||
func templateOf(module 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, TemplatesDir+"/<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, TemplatesDir+"/") ||
|
||||
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, TemplatesDir, TemplatesDir+"/<name>", TemplatesDir)
|
||||
}
|
||||
return name, true, ""
|
||||
}
|
||||
|
||||
// templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file,
|
||||
// beside content, outside files/, or a module naming templates whose build packs no archive from files/. Whether
|
||||
// each template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve).
|
||||
func templateProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
templated := false
|
||||
for _, r := range m.Resources {
|
||||
_, has, wrong := templateOf(m.Module, r)
|
||||
templated = templated || has
|
||||
if wrong != "" {
|
||||
problems = append(problems, wrong)
|
||||
}
|
||||
}
|
||||
if !templated {
|
||||
return problems
|
||||
}
|
||||
switch found := templatesArtifacts(m); len(found) {
|
||||
case 0:
|
||||
problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+
|
||||
"declare an artifact {\"name\": \"files\", \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)",
|
||||
m.Module, TemplatesDir, TemplatesDir))
|
||||
case 1:
|
||||
default:
|
||||
sort.Strings(found)
|
||||
problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive",
|
||||
m.Module, TemplatesDir, strings.Join(found, " and ")))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// 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 ("files/<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 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, 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
|
||||
}
|
||||
|
||||
// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository
|
||||
// tree, by template path. A missing template, or one that is not a plain file (a symlink is refused by the build's
|
||||
// packing too), is refused by name.
|
||||
func ReadTemplates(m Manifest, dir string) (map[string]string, error) {
|
||||
texts := map[string]string{}
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
name, has, wrong := templateOf(m.Module, r)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if wrong != "" {
|
||||
problems = append(problems, wrong)
|
||||
continue
|
||||
}
|
||||
if _, read := texts[name]; read {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, filepath.FromSlash(name))
|
||||
info, err := os.Lstat(full)
|
||||
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 := os.ReadFile(full)
|
||||
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
|
||||
}
|
||||
|
||||
// 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.Resources, 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
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return ParseManifest(raw)
|
||||
}
|
||||
|
||||
// HeldTemplateProblem is why a manifest cannot be held by the mesh because it still names a template, or "": the
|
||||
// manifest the mesh holds carries every file's text, which a build reads back from its templates archive. One
|
||||
// naming a template was resolved by a builder that does not read templates, and would compose files with no text.
|
||||
func HeldTemplateProblem(m Manifest) string {
|
||||
var named []string
|
||||
for _, r := range m.Resources {
|
||||
if t, has := r[TemplateField]; has {
|
||||
named = append(named, fmt.Sprintf("%v (%v)", r["id"], t))
|
||||
}
|
||||
}
|
||||
if len(named) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s still names the template(s) of %s: the manifest the mesh holds carries each file's "+
|
||||
"text, read back from the build's templates archive — this one was resolved by a builder that does not "+
|
||||
"read templates, or not built at all (novox/hq ADR 0349 §2)", m.Module, strings.Join(named, ", "))
|
||||
}
|
||||
Reference in New Issue
Block a user