57/K2: The controller reads, judges against and keeps the catalogue's list of predefined keys (issue 661) #323

Merged
mesh-admin merged 6 commits from pkg/57-k2-predefined-keys into feat/57-config-and-provisioning 2026-10-14 06:52:25 +00:00
2 changed files with 342 additions and 0 deletions
Showing only changes of commit 2941ef3695 - Show all commits
+106
View File
@@ -0,0 +1,106 @@
package builder
import (
"context"
"slices"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A build reads the list of predefined keys at the root of the repository at the commit it builds (novox/hq ADR
// 0348 §7, design 57 package K2, issue 661): it refuses a list that is wrong, judges the manifest against it, and
// carries it with the commit's generation for the controller to publish.
const theFirstKeys = `{
"font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72},
"agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true}
}`
// fallingBack is a module of the catalogue whose font size falls back to the predefined key named.
func fallingBack(key, kind string) string {
def := "10"
if kind == "string" {
def = `"10"`
}
return `{"module":"dunst","version":"1",
"build":{"artifacts":[{"name":"look","kind":"archive","from":"files"}]},
"config":{"font-size":{"type":"` + kind + `","description":"the notification's font size, in points",
"default":` + def + `,"predefined":"` + key + `"}},
"resources":[
{"id":"theme","type":"archive","path":"/opt/dunst","artifact":"look"},
{"id":"rc","type":"file","path":"/etc/xdg/dunst/dunstrc","content":"font = Inter ${config:font-size}\n"}]}`
}
func buildInTheCatalogue(t *testing.T, manifest string, list *string) (Result, error) {
t.Helper()
r := &recorded{contents: map[string]string{"modules/dunst/" + ManifestName: manifest,
"modules/dunst/files/theme.conf": "dark", "README.md": "the catalogue"}}
if list != nil {
r.contents[catalogue.PredefinedFile] = *list
}
return Build(context.Background(), onTrunk{recorded: r}.run, r, "https://forge.invalid/mesh-catalog.git",
"modules/dunst", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
}
func TestABuildNamingAPredefinedKeyIsJudgedAgainstItsTreesListAndCarriesIt(t *testing.T) {
list := theFirstKeys
got, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), &list)
if err != nil {
t.Fatalf("a module falling back to font-size did not build: %v", err)
}
if got.Predefined == nil || got.Predefined.Generation != 42 {
t.Fatalf("the list was not carried with its generation: %+v", got.Predefined)
}
carried, err := catalogue.ParsePredefined(got.Predefined.Keys)
if err != nil || len(carried) != 2 || carried["font-size"].Type != catalogue.ConfigNumber {
t.Fatalf("the list carried is %s (%v)", got.Predefined.Keys, err)
}
// A change to the list is a change to the module that was judged against it.
if len(got.Sources) == 0 || !slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
t.Fatalf("its build source leaves the list out: %+v", got.Sources)
}
for _, c := range []struct{ manifest, says string }{
{fallingBack("font-scale", "number"), `"font-scale", which is not on the list`},
{fallingBack("font-size", "string"), "which is a number, and this value is a string"},
} {
if _, err := buildInTheCatalogue(t, c.manifest, &list); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("wanted the build refused saying %q, got %v", c.says, err)
}
}
}
func TestABuildRefusesAWrongList(t *testing.T) {
wrong := `{"font-size": {"type": "number", "description": "points", "unit": "pt"}}`
if _, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), &wrong); err == nil ||
!strings.Contains(err.Error(), `unknown field "unit"`) {
t.Fatalf("a wrong list was built with: %v", err)
}
}
// A repository without the list builds as before, carrying none; a value naming a key there is refused.
func TestABuildWithoutAListCarriesNone(t *testing.T) {
plain := strings.Replace(fallingBack("font-size", "number"), `,"predefined":"font-size"`, "", 1)
got, err := buildInTheCatalogue(t, plain, nil)
if err != nil {
t.Fatal(err)
}
if got.Predefined != nil {
t.Fatalf("a list was carried from a tree holding none: %+v", got.Predefined)
}
if slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
t.Fatalf("the build source names a list: %+v", got.Sources)
}
if _, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), nil); err == nil ||
!strings.Contains(err.Error(), "is not present") {
t.Fatalf("a key named where no list is: %v", err)
}
// A list present and a module naming no key: carried, and not the module's build source.
list := theFirstKeys
got, err = buildInTheCatalogue(t, plain, &list)
if err != nil || got.Predefined == nil || slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
t.Fatalf("a module naming no key: %+v %+v %v", got.Predefined, got.Sources, err)
}
}
+236
View File
@@ -0,0 +1,236 @@
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
}