diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index e47b9ed7..2c21504b 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -327,6 +327,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri } result.Against = built.Against result.SourceFingerprint = built.Source + result.Predefined = built.Predefined result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches for _, r := range built.Read { result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref}) diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 586b375f..61df585a 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -98,6 +98,11 @@ type Result struct { // recipe and the package it compiles; for any other, nothing is said and the whole of what the build // sees stays its source, as before. Sources []BuildSource + + // Predefined is the repository's list of predefined keys (catalogue.PredefinedFile at its root) as + // read at the commit built, when it has one (novox/hq issue 630): what the controller keeps as the + // list mesh and node config hold values for. Empty for a repository without one. + Predefined json.RawMessage } // BuildSource is the build source a build read in one repository (novox/hq ADR 0267). @@ -233,6 +238,25 @@ func build(ctx context.Context, run Runner, publish Publisher, say("manifest", "INVALID: %v", err) return Result{}, err } + // The repository's list of predefined keys, at its root (novox/hq issue 630): read and judged here, + // where a changed list is first read, and handed on for the controller to keep. A preference that + // falls back to a key the list does not hold, or with another type, is refused here as at registration. + var predefined json.RawMessage + if raw, err := os.ReadFile(filepath.Join(tree, catalogue.PredefinedFile)); err == nil { + list, err := catalogue.ParsePredefined(raw) + if err != nil { + say("manifest", "INVALID: %v", err) + return Result{}, err + } + if problems := catalogue.PredefinedProblems(manifest, list); len(problems) > 0 { + err := fmt.Errorf("%s", strings.Join(problems, "; ")) + say("manifest", "INVALID: %v", err) + return Result{}, err + } + predefined, _ = json.Marshal(list) + } else if !errors.Is(err, os.ErrNotExist) { + return Result{}, fmt.Errorf("%s's %s cannot be read: %w", repository, catalogue.PredefinedFile, err) + } say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest)) // What it is made from, for its source fingerprint: the module's own tree first. @@ -365,7 +389,7 @@ func build(ctx context.Context, run Runner, publish Publisher, } return Result{Manifest: resolved, Commit: commit, Built: built, Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint, - Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources}, nil + Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources, Predefined: predefined}, nil } // branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`. diff --git a/internal/catalogue/preferences_test.go b/internal/catalogue/preferences_test.go new file mode 100644 index 00000000..160b991a --- /dev/null +++ b/internal/catalogue/preferences_test.go @@ -0,0 +1,117 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The list of predefined keys is read strictly: a type and a why for each key (novox/hq issue 630). +func TestThePredefinedListIsReadStrictly(t *testing.T) { + list, err := ParsePredefined([]byte(`{"font-size": {"type": "number", "why": "the size text is drawn at"}, + "theme": {"type": "string", "why": "light or dark"}}`)) + if err != nil || len(list) != 2 || list["font-size"].Type != TypeNumber { + t.Fatalf("a good list: %v %v", list, err) + } + for raw, want := range map[string]string{ + `{"font-size": {"type": "integer", "why": "x"}}`: `the type "integer"`, + `{"font-size": {"type": "number"}}`: "no why", + `{"Font Size": {"type": "number", "why": "x"}}`: "not a usable key", + `{"font-size": {"type": "number", "why": "x", "a": 1}}`: "unknown field", + `{"ports": {"type": "number", "why": "x"}}`: "the mesh reads itself", + } { + if _, err := ParsePredefined([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", raw, err, want) + } + } +} + +// A manifest declares preferences with a type, a default of that type, a why and maybe a predefined key; +// a `settings` entry of kind preference is read as one, its type from its default. +func TestAManifestDeclaresPreferences(t *testing.T) { + file := `"resources": [{"id": "rc", "type": "file", "path": "/etc/rc", "mode": "0644", "trusted": false, + "content": "size = ${setting:font-size} width = ${setting:width}"}]` + m, err := ParseManifest([]byte(`{"module": "dunst", "version": "1", + "preferences": {"font-size": {"type": "number", "default": 10, "predefined": "font-size", "why": "100 DPI"}}, + "settings": {"width": {"kind": "preference", "default": 250, "why": "forty characters"}}, ` + file + `}`)) + if err != nil { + t.Fatal(err) + } + prefs := PreferencesOf(m) + if prefs["font-size"].Predefined != "font-size" || prefs["width"].Type != TypeNumber || prefs["width"].Default != float64(250) { + t.Fatalf("the preferences: %+v", prefs) + } + if got := PredefinedNamed(m); len(got) != 1 || got[0] != "font-size" { + t.Fatalf("the predefined keys named: %v", got) + } + for body, want := range map[string]string{ + `"preferences": {"font-size": {"type": "number", "default": "10", "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: `its default "10" is a string`, + `"preferences": {"font-size": {"type": "float", "default": 10, "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: `the type is "float"`, + `"preferences": {"font-size": {"type": "number", "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: "no default", + `"preferences": {"font-size": {"type": "number", "default": 10}, "width": {"type": "number", "default": 1, "why": "x"}}`: "no why", + } { + if _, err := ParseManifest([]byte(`{"module": "dunst", "version": "1", ` + body + `, ` + file + `}`)); err == nil || + !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", body, err, want) + } + } +} + +// A preference falls back only to a key on the list, with the list's type. +func TestAPreferencesPredefinedKeyIsOnTheListWithItsType(t *testing.T) { + m := Manifest{Module: "dunst", Preferences: map[string]Preference{ + "font-size": {Type: TypeNumber, Default: float64(10), Predefined: "font-size", Why: "x"}}} + if p := PredefinedProblems(m, Predefined{"font-size": {Type: TypeNumber, Why: "x"}}); len(p) != 0 { + t.Fatalf("on the list: %v", p) + } + if p := PredefinedProblems(m, Predefined{"theme": {Type: TypeString, Why: "x"}}); len(p) != 1 || + !strings.Contains(p[0], "not on the catalogue's list") || !strings.Contains(p[0], "theme") { + t.Fatalf("not on the list: %v", p) + } + if p := PredefinedProblems(m, Predefined{"font-size": {Type: TypeString, Why: "x"}}); len(p) != 1 || + !strings.Contains(p[0], "is a string") { + t.Fatalf("another type: %v", p) + } +} + +// Mesh and node config fill `${setting:}` between the default and the module's own layer, and — like +// the default — never reach a mergeable file. +func TestConfigLayersFillAPlaceholderAndNoMergeableFile(t *testing.T) { + m := Manifest{Module: "dunst", Preferences: map[string]Preference{ + "font-size": {Type: TypeNumber, Default: float64(10), Predefined: "font-size", Why: "x"}, + "width": {Type: TypeNumber, Default: float64(250), Why: "x"}}} + fill := func(layers []Layer) string { + t.Helper() + r := map[string]any{"id": "rc", "type": "file", "content": "font ${setting:font-size} width ${setting:width}"} + if err := settingInto(r, WithDefaults(m, layers), m.Module); err != nil { + t.Fatal(err) + } + return r["content"].(string) + } + mesh := map[string]any{"font-size": float64(11), "width": float64(999)} + node := map[string]any{"font-size": float64(13)} + if got := fill(nil); got != "font 10 width 250" { + t.Fatalf("the defaults: %s", got) + } + if got := fill(ConfigLayers(m, mesh, nil, "g14")); got != "font 11 width 250" { + t.Fatalf("mesh config, which a preference without predefined does not read: %s", got) + } + both := ConfigLayers(m, mesh, node, "g14") + if got := fill(both); got != "font 13 width 250" { + t.Fatalf("node config over mesh config: %s", got) + } + own := append(both, Layer{From: "g14", Values: map[string]any{"font-size": float64(15)}}) + if got := fill(own); got != "font 15 width 250" { + t.Fatalf("the module's own value over both: %s", got) + } + sources := map[string]string{} + for _, s := range Effective(m, both) { + sources[s.Key] = s.From + } + if sources["font-size"] != NodeConfigLayer("g14") || sources["width"] != DefaultLayer { + t.Fatalf("where each comes from: %v", sources) + } + merged, err := ApplySettings(map[string]any{"id": "conf", "type": "file", "merge": MergeJSON, "content": "{}"}, both) + if err != nil || merged["content"] != "{}\n" { + t.Fatalf("config reached a mergeable file: %v %v", merged["content"], err) + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 8b13beae..62a9bc42 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -299,10 +299,33 @@ var ControllerVerbs = []Verb{ "adopted": "\"true\" when the machine is in use and joins adopted", }, nil, "adopted"), Replaces: []string{"mesh-controller token issue", "wg set"}}, - {Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " + - "mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " + + {Name: "config", Description: "Read or set config values (novox/hq issue 630). A module's preference resolves from " + + "four sources, the last set winning: its default; mesh config, a mesh-wide value for a predefined key; node " + + "config, a value for a predefined key on one node; and the module's own value on that node. Mesh and node " + + "config hold values only for the predefined keys, the catalogue's predefined.json, which a preference names " + + "as its predefined; there is no module layer for the whole mesh. Without key: the predefined keys with their " + + "mesh and node config, then each module's preferences with the value on each node and its source (default, " + + "mesh config, node config (), on ); node and module narrow it. With key and value (JSON; " + + "a bare word is a string): sets mesh config, or node config with node, or with module and node the module's own " + + "value on that node — refused for a key not on the list, a value of the wrong type, and a module without " + + "node. With clear instead of value: removes it, and the next source down applies. A set or clear says why, " + + "is recorded in the hand-act log with the caller, keeps the value it replaced in the history, and takes effect " + + "at the next push of the machines it reaches, which the answer names. With history: a key's past mesh or node " + + "config values.", + Input: schema(map[string]string{ + "key": "a predefined key (mesh or node config), or with module one of that module's preferences", + "value": "the value as JSON (12, \"dark\", true); a bare word is a string", + "node": "one node: its node config, or with module that module's value there", + "module": "a module: its own value on node; needs node", + "clear": "\"true\" to remove the value instead of setting it", + "history": "\"true\": the key's past mesh config values, or with node its node config's", + "why": "why it is set or cleared, kept with the value and in the hand-act log; required to set or clear", + }, nil, "clear", "history")}, + {Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings on one " + + "machine; there is no module layer for the whole mesh (novox/hq issue 630) — a set or proposal without node is " + + "refused, and a mesh-wide value of a predefined key is the config verb's. Without values or clear, answers the layer as it stands — read it before setting it — " + "then every value the module gives a default or a layer sets, with where it comes from: the module's default, " + - "the mesh, or the machine (novox/hq ADR 0262); with list \"preferences\", or with no module, every " + + "mesh config, node config, or the module on the machine (novox/hq ADR 0262, issue 630); with list \"preferences\", or with no module, every " + "module's preferences and each machine's value; " + "with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " + "changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " + @@ -317,7 +340,7 @@ var ControllerVerbs = []Verb{ Input: schema(map[string]string{ "module": "the module's name; with list, only that module's", "values": "the settings as a JSON object, for set or propose", - "node": "one machine; the whole mesh when absent", + "node": "one machine; needed to set or propose values (no module layer for the whole mesh); without it, clear removes a whole-mesh layer left over", "clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values", "replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name", "history": "\"true\": without values or clear, the layers this one replaced, the latest first", diff --git a/internal/facts/facts.go b/internal/facts/facts.go index be55f445..5f61e744 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -59,6 +59,10 @@ type Facts struct { Modules []Module `json:"modules"` // Settings are the mesh-wide layer of every module's settings, scrubbed. Settings []Settings `json:"settings,omitempty"` + // Predefined is the list of predefined keys the mesh holds, and Config the mesh config's value for + // each it sets, scrubbed (novox/hq issue 630). A machine's node config is on the machine. + Predefined map[string]PredefinedKey `json:"predefined,omitempty"` + Config map[string]any `json:"config,omitempty"` // Edges are the build dependencies between modules, as the mesh recorded them — what a merge's // rebuild width is computed from. Edges []Edge `json:"edges,omitempty"` @@ -143,6 +147,9 @@ type Machine struct { Pins []Pin `json:"pins,omitempty"` // Settings are its own layer of each module's settings, scrubbed. Settings []Settings `json:"settings,omitempty"` + // Config is the node config of the machine: its value for each predefined key it sets, scrubbed + // (novox/hq issue 630). + Config map[string]any `json:"config,omitempty"` // Accepted are the secrets a person gave the mesh for modules here, by name only: the mesh cannot // make them, so a check composing this machine gives each a stand-in instead of refusing it. Accepted []Accepted `json:"accepted,omitempty"` @@ -173,6 +180,12 @@ type Accepted struct { Local string `json:"local,omitempty"` } +// PredefinedKey is one key of the list of predefined keys. +type PredefinedKey struct { + Type string `json:"type"` + Why string `json:"why"` +} + // Settings is one layer of one module's settings. type Settings struct { Module string `json:"module"` diff --git a/internal/link/build.go b/internal/link/build.go index 8262b8b9..7a26489c 100644 --- a/internal/link/build.go +++ b/internal/link/build.go @@ -250,6 +250,11 @@ type BuildResult struct { // the repository's default is the branch it follows. Branches []string `json:"branches,omitempty"` + // Predefined is the built repository's list of predefined keys, its root predefined.json at the commit + // built, when it has one (novox/hq issue 630). The controller keeps it, when it registers the build, + // as the list mesh and node config hold values for. Empty from a builder that predates it. + Predefined json.RawMessage `json:"predefined,omitempty"` + // SourceFingerprint is what the build was made from, hashed (novox/hq issue 280): the module's // tree at the commit, the trees of the contexts it read, its bases and toolchains by digest. Two // builds with one fingerprint are one build, however their digests differ — an image is not diff --git a/module.json b/module.json index 4ceb785d..ad7e591f 100644 --- a/module.json +++ b/module.json @@ -69,6 +69,7 @@ "issue", "token", "settings", + "config", "command", "queue", "cancel",