Part 3: the list's own reading (catalogue/predefined.go) and the tests of the build, module check, merge gate and registration (hq ADR 0348 clause 7, issue 661). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.7 KiB
Go
237 lines
8.7 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661).
|
|
//
|
|
// **A fixed, reviewable list, owned by the build.** Mesh config and node config hold values only for the keys
|
|
// in the catalogue's `predefined.json`, at the root of its repository: each key with its type, a description
|
|
// including its unit, its validation (enum, minimum and maximum, pattern) and whether it is trusted. A module's
|
|
// value may fall back to one of them, of the same type (ADR 0348 §2, §4).
|
|
//
|
|
// **Read where a manifest is judged, from the tree it is judged in.** A build reads the file at the root of the
|
|
// repository at the commit it builds, `module check` at the root of the repository above each manifest, the
|
|
// merge gate at the root of the tree under check; each refuses a file that is not exactly a list of keys, and
|
|
// judges every manifest it reads against it (ParseManifestWith). Everywhere else — registration, a stored build
|
|
// read again — a manifest is judged against the list the mesh published (PublishedPredefined): the newest trunk
|
|
// build's of the catalogue, which the controller keeps and loads with its store.
|
|
|
|
// PredefinedFile is the list's file at the root of the catalogue's repository.
|
|
const PredefinedFile = "predefined.json"
|
|
|
|
// CatalogueRepository is the name of the repository whose list the mesh publishes: the catalogue's, by the name
|
|
// a check finds it beside under (internal/builder's check, ADR 0121).
|
|
const CatalogueRepository = "mesh-catalog"
|
|
|
|
// PredefinedKey is one key of the list: its type, its description, its validation and whether it is trusted
|
|
// (ADR 0348 §7). It has no default: a module's value falling back to it has its own.
|
|
type PredefinedKey struct {
|
|
Type string `json:"type"`
|
|
Description string `json:"description"`
|
|
Enum []any `json:"enum,omitempty"`
|
|
Minimum *float64 `json:"minimum,omitempty"`
|
|
Maximum *float64 `json:"maximum,omitempty"`
|
|
Pattern string `json:"pattern,omitempty"`
|
|
Trusted bool `json:"trusted,omitempty"`
|
|
}
|
|
|
|
// PredefinedList is the list by key. Nil is no list at all, which is not the same as an empty one: a value
|
|
// naming a key is refused then saying the list is not present.
|
|
type PredefinedList map[string]PredefinedKey
|
|
|
|
// ParsePredefined reads predefined.json strictly: one JSON object of keys, each with exactly the fields of a
|
|
// PredefinedKey, and every key judged as a module's config value is (PredefinedProblems). Anything else is
|
|
// refused whole, naming each problem — a list half read would let a module fall back to a key nobody reviewed.
|
|
func ParsePredefined(raw []byte) (PredefinedList, error) {
|
|
var keys map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &keys); err != nil {
|
|
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", PredefinedFile, err)
|
|
}
|
|
if keys == nil {
|
|
return nil, fmt.Errorf("%s is not a JSON object of keys: it says null", PredefinedFile)
|
|
}
|
|
list := PredefinedList{}
|
|
var problems []string
|
|
for _, key := range sortedKeys(keys) {
|
|
decoder := json.NewDecoder(bytes.NewReader(keys[key]))
|
|
decoder.DisallowUnknownFields()
|
|
var k PredefinedKey
|
|
if err := decoder.Decode(&k); err != nil {
|
|
problems = append(problems, fmt.Sprintf("%q is not a predefined key's declaration (type, description, "+
|
|
"enum, minimum, maximum, pattern, trusted): %v", key, err))
|
|
continue
|
|
}
|
|
list[key] = k
|
|
}
|
|
problems = append(problems, PredefinedProblems(list)...)
|
|
if len(problems) > 0 {
|
|
return nil, fmt.Errorf("%s is refused: %s", PredefinedFile, strings.Join(problems, "; "))
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
// PredefinedProblems is every way a key of the list is declared wrongly, by the rules a module's config value
|
|
// is held to (ADR 0348 §4, §7, §11): a usable name that is not placement, a type, a description, validation
|
|
// that fits the type, and an object or a list only when trusted.
|
|
func PredefinedProblems(list PredefinedList) []string {
|
|
var problems []string
|
|
for _, key := range sortedKeys(list) {
|
|
k := list[key]
|
|
say := func(format string, args ...any) {
|
|
problems = append(problems, fmt.Sprintf("the predefined key %q: ", key)+fmt.Sprintf(format, args...))
|
|
}
|
|
if !settingRef.MatchString("${setting:" + key + "}") {
|
|
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
|
|
continue
|
|
}
|
|
if meshWords[key] || accountFacts[key] {
|
|
say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key)
|
|
continue
|
|
}
|
|
switch k.Type {
|
|
case ConfigNumber, ConfigString, ConfigBoolean:
|
|
case ConfigObject, ConfigList:
|
|
if !k.Trusted {
|
|
say("is %s and not trusted: an object or a list is trusted, \"trusted\": true (novox/hq ADR 0348 §4)", k.Type)
|
|
}
|
|
case "":
|
|
say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)")
|
|
default:
|
|
say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", k.Type)
|
|
}
|
|
if strings.TrimSpace(k.Description) == "" {
|
|
say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §7)")
|
|
}
|
|
for _, wrong := range validationProblems(SettingDeclaration{Type: k.Type, Enum: k.Enum, Minimum: k.Minimum,
|
|
Maximum: k.Maximum, Pattern: k.Pattern}) {
|
|
say("%s", wrong)
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// ReadPredefined is the list in predefined.json at a repository's root, and nil with no error when the root
|
|
// holds none: a repository other than the catalogue, or the catalogue before the file.
|
|
func ReadPredefined(root string) (PredefinedList, error) {
|
|
path := filepath.Join(root, PredefinedFile)
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("%s is %s, not a plain file", path, kindOf(info.Mode()))
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParsePredefined(raw)
|
|
}
|
|
|
|
// RepositoryRoot is the nearest directory at or above dir holding `.git`: the root of the repository a manifest
|
|
// is in, where `module check` reads the list. Empty when none is: a manifest outside a repository is judged
|
|
// against no list.
|
|
func RepositoryRoot(dir string) string {
|
|
at, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for {
|
|
if _, err := os.Lstat(filepath.Join(at, ".git")); err == nil {
|
|
return at
|
|
}
|
|
up := filepath.Dir(at)
|
|
if up == at {
|
|
return ""
|
|
}
|
|
at = up
|
|
}
|
|
}
|
|
|
|
// PredefinedAbove is the list at the root of the repository above dir (RepositoryRoot), nil when there is no
|
|
// repository or it holds no list.
|
|
func PredefinedAbove(dir string) (PredefinedList, error) {
|
|
root := RepositoryRoot(dir)
|
|
if root == "" {
|
|
return nil, nil
|
|
}
|
|
return ReadPredefined(root)
|
|
}
|
|
|
|
// Canonical is the list as JSON with its keys sorted: what a build carries and the controller keeps, so one list
|
|
// is one text.
|
|
func (l PredefinedList) Canonical() ([]byte, error) {
|
|
if l == nil {
|
|
return nil, nil
|
|
}
|
|
return json.Marshal(map[string]PredefinedKey(l))
|
|
}
|
|
|
|
// Keys is the list's keys, sorted.
|
|
func (l PredefinedList) Keys() []string {
|
|
out := make([]string, 0, len(l))
|
|
for k := range l {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// NamesPredefined is whether any value of the manifest falls back to a predefined key.
|
|
func (m Manifest) NamesPredefined() bool {
|
|
for _, d := range m.Settings {
|
|
if d.Predefined != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// publishedPredefined is the list the mesh published, which a manifest is judged against where no tree is read.
|
|
var publishedPredefined struct {
|
|
sync.RWMutex
|
|
list PredefinedList
|
|
}
|
|
|
|
// UsePredefined makes list the one a manifest is judged against where no tree is read: what the controller
|
|
// loads from its store, and what it keeps when a newer trunk build of the catalogue publishes one. Nil leaves
|
|
// the list it has: a store that holds none yet never empties one already loaded.
|
|
func UsePredefined(list PredefinedList) {
|
|
if list == nil {
|
|
return
|
|
}
|
|
publishedPredefined.Lock()
|
|
defer publishedPredefined.Unlock()
|
|
publishedPredefined.list = list
|
|
}
|
|
|
|
// PublishedPredefined is the list a manifest is judged against where no tree is read; nil when none is loaded.
|
|
func PublishedPredefined() PredefinedList {
|
|
publishedPredefined.RLock()
|
|
defer publishedPredefined.RUnlock()
|
|
return publishedPredefined.list
|
|
}
|
|
|
|
// ReplacePublished sets the published list, nil included, and answers the one it replaced: for a test that
|
|
// judges against a list of its own and puts the mesh's back.
|
|
func ReplacePublished(list PredefinedList) (was PredefinedList) {
|
|
publishedPredefined.Lock()
|
|
defer publishedPredefined.Unlock()
|
|
was, publishedPredefined.list = publishedPredefined.list, list
|
|
return was
|
|
}
|