Carry the catalogue's predefined.json with a build, and declare the config verb (hq issue 630)
The controller only learns manifests through builds, so the list of predefined keys travels the same way: the builder reads it at the catalogue's root, judges the manifest against it and hands it on.
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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:<key>}` 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)
|
||||
}
|
||||
}
|
||||
@@ -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 (<node>), <module> on <node>); 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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"issue",
|
||||
"token",
|
||||
"settings",
|
||||
"config",
|
||||
"command",
|
||||
"queue",
|
||||
"cancel",
|
||||
|
||||
Reference in New Issue
Block a user