mesh/merge-gate head ff3f8ca192a4 merged into trunk dae760224526 — pass: the change touches no module of the mesh's graph
mesh/repo-check head ff3f8ca192a4 merged into trunk dae760224526 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): feat/57-config-and-provisioning'…
mesh/delivery delivered
mesh/delivery-group group pkg/57-k2-predefined-keys delivered: every member is delivered
Part 2 of 2: the check merge-check.sh runs, and its tests with a planted removal (hq ADR 0348 clause 7, issue 661). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
4.8 KiB
Go
154 lines
4.8 KiB
Go
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
|
|
// 0348 clause 7, design 57 package K2, issue 661).
|
|
//
|
|
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
|
|
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
|
|
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
|
|
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
|
|
// it, naming each module that names the key.
|
|
//
|
|
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
|
|
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
|
|
// itself, removing a key nobody names is let through here, and said.
|
|
//
|
|
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
|
|
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
|
|
// module check; this check reads only its keys.
|
|
//
|
|
// go run . [-base <the trunk's predefined.json>] <catalogue root>
|
|
//
|
|
// Exits 1 on a finding.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// File is the list's file at the root of the catalogue.
|
|
const File = "predefined.json"
|
|
|
|
// Keys is the keys of a list, read as a JSON object; an absent file has none.
|
|
func Keys(path string) (map[string]bool, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return map[string]bool{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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", path, err)
|
|
}
|
|
out := map[string]bool{}
|
|
for k := range keys {
|
|
out[k] = true
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
|
|
func Named(root string) (map[string][]string, error) {
|
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(manifests)
|
|
named := map[string][]string{}
|
|
for _, path := range manifests {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m struct {
|
|
Module string `json:"module"`
|
|
Config map[string]json.RawMessage `json:"config"`
|
|
Settings map[string]json.RawMessage `json:"settings"`
|
|
}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
|
|
for _, d := range declared {
|
|
var value struct {
|
|
Predefined string `json:"predefined"`
|
|
}
|
|
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
|
|
named[value.Predefined] = append(named[value.Predefined], m.Module)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return named, nil
|
|
}
|
|
|
|
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
|
|
// key nobody names is answered with none.
|
|
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
|
|
out := map[string][]string{}
|
|
for k := range base {
|
|
if !tree[k] {
|
|
out[k] = named[k]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func main() {
|
|
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
|
|
flag.Parse()
|
|
if flag.NArg() != 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
|
|
os.Exit(2)
|
|
}
|
|
root := flag.Arg(0)
|
|
tree, err := Keys(filepath.Join(root, File))
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
if *basePath == "" {
|
|
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
|
|
return
|
|
}
|
|
base, err := Keys(*basePath)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
named, err := Named(root)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
removed := Removed(base, tree, named)
|
|
keys := make([]string, 0, len(removed))
|
|
for k := range removed {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
failed := false
|
|
for _, k := range keys {
|
|
if modules := removed[k]; len(modules) > 0 {
|
|
failed = true
|
|
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
|
|
"clause 7)\n", File, k, strings.Join(modules, ", "))
|
|
} else {
|
|
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
|
|
"value for it is mesh-config's to refuse, not this check's\n", File, k)
|
|
}
|
|
}
|
|
if failed {
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
|
|
}
|