A preference may fall back to one reviewed mesh-wide or node-wide value instead of a module layer for the whole mesh; like a default, config fills ${setting:} only.
271 lines
8.9 KiB
Go
271 lines
8.9 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// A config value resolves from four sources, the last set winning (novox/hq issue 630):
|
|
//
|
|
// 1. the default in the module's manifest;
|
|
// 2. mesh config: a mesh-wide value for a predefined key, such as `font-size`;
|
|
// 3. node config: a value for a predefined key for one node;
|
|
// 4. module-assignment config: that module's own value on that node, the most specific.
|
|
//
|
|
// An assignment is one module on one node: there is no module layer for the whole mesh. Mesh and node
|
|
// config hold values only for the keys on a fixed, reviewable list — the catalogue's `predefined.json` —
|
|
// that any module may fall back to, by naming one as its preference's `predefined`. A preference without
|
|
// `predefined` reads only its default and its module-assignment config. A preference is never trusted.
|
|
|
|
// Preference is one value a module reads that a machine may tune, as the manifest declares it under
|
|
// `preferences`.
|
|
type Preference struct {
|
|
// Type is "number", "string" or "boolean".
|
|
Type string `json:"type"`
|
|
// Default is the value when no source sets it, of Type.
|
|
Default any `json:"default"`
|
|
// Predefined names the key on the catalogue's list this preference falls back to: mesh config,
|
|
// then node config, for that key. Empty reads only the default and the module-assignment config.
|
|
Predefined string `json:"predefined,omitempty"`
|
|
// Why this default: one sentence for whoever wonders whether to change it.
|
|
Why string `json:"why"`
|
|
}
|
|
|
|
// The types a preference and a predefined key may have.
|
|
const (
|
|
TypeNumber = "number"
|
|
TypeString = "string"
|
|
TypeBoolean = "boolean"
|
|
)
|
|
|
|
// PredefinedFile is the catalogue repository's root file holding the predefined keys.
|
|
const PredefinedFile = "predefined.json"
|
|
|
|
// PredefinedKey is one key mesh and node config may hold a value for.
|
|
type PredefinedKey struct {
|
|
Type string `json:"type"`
|
|
Why string `json:"why"`
|
|
}
|
|
|
|
// Predefined is the list of predefined keys, by key.
|
|
type Predefined map[string]PredefinedKey
|
|
|
|
// Keys is the list's keys, sorted.
|
|
func (p Predefined) Keys() []string {
|
|
keys := make([]string, 0, len(p))
|
|
for k := range p {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// predefinedName is what a predefined key looks like: the shape of a setting key.
|
|
var predefinedName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
|
|
|
// ParsePredefined reads `predefined.json` strictly, and refuses a key whose name, type or why is wrong.
|
|
func ParsePredefined(raw []byte) (Predefined, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.DisallowUnknownFields()
|
|
var list Predefined
|
|
if err := decoder.Decode(&list); err != nil {
|
|
return nil, fmt.Errorf("%s is not a list of predefined keys ({\"<key>\": {\"type\": …, \"why\": …}}): %w",
|
|
PredefinedFile, err)
|
|
}
|
|
var problems []string
|
|
for _, key := range list.Keys() {
|
|
k := list[key]
|
|
if !predefinedName.MatchString(key) {
|
|
problems = append(problems, fmt.Sprintf("%q is not a usable key: lower-case letters, digits, dots, dashes and underscores", key))
|
|
}
|
|
if meshWords[key] {
|
|
problems = append(problems, fmt.Sprintf("%q is a key the mesh reads itself", key))
|
|
}
|
|
if !knownType(k.Type) {
|
|
problems = append(problems, fmt.Sprintf("%q has the type %q; a type is %q, %q or %q", key, k.Type,
|
|
TypeNumber, TypeString, TypeBoolean))
|
|
}
|
|
if strings.TrimSpace(k.Why) == "" {
|
|
problems = append(problems, fmt.Sprintf("%q has no why: say in one sentence what the key is for", key))
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
return nil, fmt.Errorf("%s: %s", PredefinedFile, strings.Join(problems, "; "))
|
|
}
|
|
if list == nil {
|
|
list = Predefined{}
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func knownType(t string) bool {
|
|
return t == TypeNumber || t == TypeString || t == TypeBoolean
|
|
}
|
|
|
|
// TypeOf is the type a JSON value has, as a preference names it, or "" for one that is none of them.
|
|
func TypeOf(v any) string {
|
|
switch v.(type) {
|
|
case float64, int, int64, json.Number:
|
|
return TypeNumber
|
|
case string:
|
|
return TypeString
|
|
case bool:
|
|
return TypeBoolean
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// PreferencesOf is every preference a module declares: those under `preferences`, and — while the
|
|
// catalogue moves to them — every `settings` entry of kind preference, its type read from its default.
|
|
func PreferencesOf(m Manifest) map[string]Preference {
|
|
if len(m.Preferences) == 0 && len(m.Settings) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]Preference, len(m.Preferences)+len(m.Settings))
|
|
for key, d := range m.Settings {
|
|
out[key] = Preference{Type: TypeOf(d.Default), Default: d.Default, Why: d.Why}
|
|
}
|
|
for key, p := range m.Preferences {
|
|
out[key] = p
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DeclaresPreference is whether a module declares a preference by this key.
|
|
func DeclaresPreference(m Manifest, key string) bool {
|
|
_, has := m.Preferences[key]
|
|
if !has {
|
|
_, has = m.Settings[key]
|
|
}
|
|
return has
|
|
}
|
|
|
|
// PredefinedProblems is every way a module's preferences disagree with the list of predefined keys: a
|
|
// `predefined` not on it, or a type other than the list's.
|
|
func PredefinedProblems(m Manifest, list Predefined) []string {
|
|
var problems []string
|
|
keys := make([]string, 0, len(m.Preferences))
|
|
for k := range m.Preferences {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
p := m.Preferences[key]
|
|
if p.Predefined == "" {
|
|
continue
|
|
}
|
|
k, on := list[p.Predefined]
|
|
if !on {
|
|
listed := "the list holds none"
|
|
if len(list) > 0 {
|
|
listed = "the list holds " + strings.Join(list.Keys(), ", ")
|
|
}
|
|
problems = append(problems, fmt.Sprintf("%s's preference %q falls back to the predefined key %q, which "+
|
|
"is not on the catalogue's list of predefined keys (%s; %s)", m.Module, key, p.Predefined, PredefinedFile,
|
|
listed))
|
|
continue
|
|
}
|
|
if k.Type != p.Type {
|
|
problems = append(problems, fmt.Sprintf("%s's preference %q is a %s, and the predefined key %q it falls "+
|
|
"back to is a %s", m.Module, key, p.Type, p.Predefined, k.Type))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// Where a value from mesh or node config is said to come from.
|
|
const MeshConfigLayer = "mesh config"
|
|
|
|
// NodeConfigLayer is what the node config of one node is called where a value's source is said.
|
|
func NodeConfigLayer(node string) string { return "node config (" + node + ")" }
|
|
|
|
// ConfigLayers are the layers mesh config and node config make for one module: each of its preferences
|
|
// that names a predefined key, given that key's value. Laid between the module's defaults and its
|
|
// module-assignment config; like the defaults they fill `${setting:<key>}` only.
|
|
func ConfigLayers(m Manifest, mesh, node map[string]any, nodeName string) []Layer {
|
|
var out []Layer
|
|
for _, source := range []struct {
|
|
from string
|
|
values map[string]any
|
|
}{{MeshConfigLayer, mesh}, {NodeConfigLayer(nodeName), node}} {
|
|
values := map[string]any{}
|
|
for key, p := range m.Preferences {
|
|
if p.Predefined == "" {
|
|
continue
|
|
}
|
|
if v, set := source.values[p.Predefined]; set {
|
|
values[key] = v
|
|
}
|
|
}
|
|
if len(values) > 0 {
|
|
out = append(out, Layer{From: source.from, Values: values, Config: true})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ConfigValueProblem refuses a value of the wrong type for a predefined key or a preference.
|
|
func ConfigValueProblem(what string, want string, v any) error {
|
|
if got := TypeOf(v); got != want {
|
|
shown, _ := json.Marshal(v)
|
|
return fmt.Errorf("%s is a %s, and %s is %s", what, want, shown, aOrNothing(got))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func aOrNothing(t string) string {
|
|
if t == "" {
|
|
return "none of a number, a string or a boolean"
|
|
}
|
|
return "a " + t
|
|
}
|
|
|
|
// preferenceProblems is every way a module's `preferences` are wrong in themselves.
|
|
func preferenceProblems(m Manifest) []string {
|
|
var problems []string
|
|
keys := make([]string, 0, len(m.Preferences))
|
|
for k := range m.Preferences {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
p := m.Preferences[key]
|
|
say := func(format string, args ...any) {
|
|
problems = append(problems, fmt.Sprintf("%s's preference %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
|
}
|
|
if _, twice := m.Settings[key]; twice {
|
|
say("declared under settings too; declare it once, under preferences")
|
|
}
|
|
if !knownType(p.Type) {
|
|
say("the type is %q; a type is %q, %q or %q", p.Type, TypeNumber, TypeString, TypeBoolean)
|
|
} else if p.Default == nil {
|
|
say("no default: a preference has one")
|
|
} else if got := TypeOf(p.Default); got != p.Type {
|
|
shown, _ := json.Marshal(p.Default)
|
|
say("it is a %s, and its default %s is %s", p.Type, shown, aOrNothing(got))
|
|
}
|
|
if p.Predefined != "" && !predefinedName.MatchString(p.Predefined) {
|
|
say("%q is not a predefined key's name", p.Predefined)
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// PredefinedNamed is every predefined key a module's preferences fall back to, sorted.
|
|
func PredefinedNamed(m Manifest) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, p := range m.Preferences {
|
|
if p.Predefined != "" && !seen[p.Predefined] {
|
|
seen[p.Predefined] = true
|
|
out = append(out, p.Predefined)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|