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 }