diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 30ec1c8..71e7f58 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -83,6 +83,8 @@ func run() error { return moduleCommand(ctx, args[1:]) case "assign", "unassign": return assignCommand(ctx, args[0], args[1:]) + case "settings": + return settingsCommand(ctx, args[1:]) case "plan": return planCommand(ctx, args[1:]) case "push": @@ -122,6 +124,9 @@ func usage() { status what the mesh is behind on, and which nodes assign put a module on a node unassign take it off + settings set what a module's config should say, for the whole mesh + settings set --node ...or for one machine + settings clear [--node ] take a layer away plan what that node would run, and why push [] send a node everything it should be version what this binary is @@ -799,7 +804,7 @@ func assignCommand(ctx context.Context, verb string, args []string) error { // Resolved immediately, because an assignment that cannot be applied should be said now // rather than at the next push. The assignment is kept either way: it is what a person meant, // and the refusal is about the set rather than about this one. - if _, err := planFor(ctx, inv, args[0]); err != nil { + if _, _, err := planFor(ctx, inv, args[0]); err != nil { fmt.Println() return err } @@ -808,23 +813,23 @@ func assignCommand(ctx context.Context, verb string, args []string) error { } // planFor works out everything a node should run, from what was assigned to it. -func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (catalogue.Resolution, error) { +func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (catalogue.Resolution, catalogue.SettingsBy, error) { shelf, err := inv.Catalogue(ctx) if err != nil { - return catalogue.Resolution{}, err + return catalogue.Resolution{}, nil, err } assigned, err := inv.Assigned(ctx, nodeName) if err != nil { - return catalogue.Resolution{}, err + return catalogue.Resolution{}, nil, err } capabilities, err := inv.ProfileOf(ctx, nodeName) if err != nil { - return catalogue.Resolution{}, err + return catalogue.Resolution{}, nil, err } places, err := inv.Overlays(ctx) if err != nil { - return catalogue.Resolution{}, err + return catalogue.Resolution{}, nil, err } var site string for _, p := range places { @@ -856,21 +861,57 @@ func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (ca elsewhere = append(elsewhere, got.Claims...) } - return catalogue.Resolve(shelf, assigned, + resolved, err := catalogue.Resolve(shelf, assigned, catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities}, elsewhere) + if err != nil { + return catalogue.Resolution{}, nil, err + } + + // Settings for everything that resolved, including modules nobody assigned directly: a + // requirement pulled in by something else is still configurable, and finding out that it is + // not only when you try would be an arbitrary line nobody could predict. + settings := catalogue.SettingsBy{} + var stray []string + for _, m := range resolved.Modules { + layers, err := inv.SettingsFor(ctx, nodeName, m.Module) + if err != nil { + return catalogue.Resolution{}, nil, err + } + if len(layers) == 0 { + continue + } + settings[m.Module] = layers + stray = append(stray, catalogue.UnusedSettings(m, layers)...) + } + if len(stray) > 0 { + // Somebody set something that reaches no file. Said here rather than discovered by the + // machine not behaving differently, which is the slowest way there is. + return catalogue.Resolution{}, nil, fmt.Errorf( + "these settings reach nothing:\n - %s", strings.Join(stray, "\n - ")) + } + return resolved, settings, nil } func planCommand(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("plan ") + set := flag.NewFlagSet("plan", flag.ContinueOnError) + // Because "one resource" does not tell you whether the settings landed. Being able to read + // the file before it is sent is the difference between believing a merge worked and knowing. + show := set.Bool("files", false, "print the files this node would be given") + positionals, err := parseAround(set, args) + if err != nil { + return err } + if len(positionals) != 1 { + return errors.New("plan [--files]") + } + args = positionals inv, err := openInventory(ctx) if err != nil { return err } defer inv.Close() - plan, err := planFor(ctx, inv, args[0]) + plan, settings, err := planFor(ctx, inv, args[0]) if err != nil { return err } @@ -885,7 +926,26 @@ func planCommand(ctx context.Context, args []string) error { for _, c := range plan.Claims { fmt.Printf(" holds %s, one per %s\n", c.Claim, c.Scope) } - fmt.Printf("\n%d resource(s)\n", len(plan.Declaration())) + resources, err := plan.Declaration(settings) + if err != nil { + return err + } + for module, layers := range settings { + for _, layer := range layers { + fmt.Printf(" %-20s settings from %s\n", module, layer.From) + } + } + fmt.Printf("\n%d resource(s)\n", len(resources)) + + if *show { + for _, r := range resources { + content, ok := r["content"].(string) + if !ok { + continue + } + fmt.Printf("\n--- %v %v ---\n%s", r["id"], r["path"], content) + } + } return nil } @@ -953,12 +1013,17 @@ func pushCommand(ctx context.Context, args []string) error { return err } - plan, err := planFor(ctx, inv, n.Name) + plan, settings, err := planFor(ctx, inv, n.Name) if err != nil { refusals = append(refusals, fmt.Sprintf("%s:\n%v", n.Name, err)) continue } - sending = append(sending, ready{n, append(resources.Resources, plan.Declaration()...)}) + fromModules, err := plan.Declaration(settings) + if err != nil { + refusals = append(refusals, fmt.Sprintf("%s:\n%v", n.Name, err)) + continue + } + sending = append(sending, ready{n, append(resources.Resources, fromModules...)}) } if len(refusals) > 0 { @@ -1057,3 +1122,66 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) { rest = rest[1:] } } + +func settingsCommand(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("settings set [--node ], or settings clear [--node ]") + } + inv, err := openInventory(ctx) + if err != nil { + return err + } + defer inv.Close() + + set := flag.NewFlagSet("settings", flag.ContinueOnError) + node := set.String("node", "", "one machine, rather than the whole mesh") + positionals, err := parseAround(set, args[1:]) + if err != nil { + return err + } + + where := "the whole mesh" + if *node != "" { + where = *node + } + + switch args[0] { + case "set": + if len(positionals) != 2 { + return errors.New("settings set [--node ]") + } + raw, err := os.ReadFile(positionals[1]) + if err != nil { + return err + } + var values map[string]any + if err := json.Unmarshal(raw, &values); err != nil { + return fmt.Errorf("%s is not a settings file: %w", positionals[1], err) + } + if err := inv.SetSettings(ctx, *node, positionals[0], values); err != nil { + return err + } + + var keys []string + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + fmt.Printf("%s on %s: %s\n", positionals[0], where, strings.Join(keys, ", ")) + fmt.Println(" run `push` to send it") + return nil + + case "clear": + if len(positionals) != 1 { + return errors.New("settings clear [--node ]") + } + if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil { + return err + } + fmt.Printf("%s on %s is back to what the module says\n", positionals[0], where) + return nil + + default: + return fmt.Errorf("settings has no %q; it has set and clear", args[0]) + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index e83077c..7e2f111 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -272,15 +272,22 @@ func checkResources(modules []Manifest) []string { return problems } -// Declaration is everything the resolved modules put on the node, as the host reads it. +// SettingsBy is the layers that apply to each module, keyed by module name. +type SettingsBy map[string][]Layer + +// Declaration is everything the resolved modules put on the node, with settings applied. // // Resource identities are prefixed with the module they came from. Two modules may reasonably // both call something "config", and without this the second would silently replace the first — // the node applying one of them and reporting success. -func (r Resolution) Declaration() []map[string]any { +func (r Resolution) Declaration(settings SettingsBy) ([]map[string]any, error) { var out []map[string]any for _, m := range r.Modules { - for _, resource := range m.Resources { + for _, unsettled := range m.Resources { + resource, err := ApplySettings(unsettled, settings[m.Module]) + if err != nil { + return nil, err + } copied := map[string]any{} for k, v := range resource { copied[k] = v @@ -298,5 +305,5 @@ func (r Resolution) Declaration() []map[string]any { out = append(out, copied) } } - return out + return out, nil } diff --git a/internal/catalogue/resolve_test.go b/internal/catalogue/resolve_test.go index 80c3f6a..b5ea2ec 100644 --- a/internal/catalogue/resolve_test.go +++ b/internal/catalogue/resolve_test.go @@ -201,7 +201,7 @@ func TestResourceIdentitiesCarryTheirModule(t *testing.T) { t.Fatal(err) } seen := map[string]bool{} - for _, r := range got.Declaration() { + for _, r := range mustDeclare(t, got) { id := r["id"].(string) if seen[id] { t.Errorf("two resources share the identity %q", id) @@ -226,7 +226,7 @@ func TestWhatAServiceReflectsIsQualifiedToo(t *testing.T) { if err != nil { t.Fatal(err) } - for _, r := range got.Declaration() { + for _, r := range mustDeclare(t, got) { if r["id"] == "thing.svc" { if got := fmt.Sprint(r["restart-on"]); got != "[thing.conf]" { t.Errorf("a service reflects %s, which is not a resource in the declaration", got) @@ -317,3 +317,12 @@ func TestARequirementNamingAModuleMeansThatModule(t *testing.T) { t.Errorf("unexpected refusal: %v", err) } } + +func mustDeclare(t *testing.T, r Resolution) []map[string]any { + t.Helper() + out, err := r.Declaration(nil) + if err != nil { + t.Fatal(err) + } + return out +} diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go new file mode 100644 index 0000000..a2c4c6d --- /dev/null +++ b/internal/catalogue/settings.go @@ -0,0 +1,166 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Settings are how somebody changes a config file a module owns, without editing it. +// +// Managed files are generated and never edited (novox/hq ADR 0011), so a person's intention has +// to be expressed somewhere the generator can see it. Then the file is produced from the module's +// defaults and that intention together, and upstream can rewrite its half freely. +// +// **An override beats a default, so there is nothing to resolve.** When the module changes a key +// somebody has set, the setting wins — it was a statement about that key, made deliberately, and +// the default was only ever what to do in the absence of one. This is the same shape as the rest +// of the mesh refusing to guess: there is no guess to make. + +// Merge is how a file's content is combined with settings. A module names one per resource. +const ( + // MergeJSON treats the content as a JSON document and merges settings into it by key. + MergeJSON = "json" +) + +// Layer is one source of settings, in the order they are applied. +type Layer struct { + // Where these came from, for saying which layer set a value. + From string + Values map[string]any +} + +// ApplySettings produces a resource's final content from the module's own and the layers over it. +// +// Layers are applied in order, so a later one beats an earlier one — the mesh's settings for a +// module first, then this node's. A node that differs from the rest is expressed by differing, +// rather than by repeating everything the rest already say. +func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, error) { + how, _ := resource["merge"].(string) + if how == "" { + // Not a mergeable file. Settings for it are ignored rather than silently doing nothing + // somewhere else — see UnusedSettings, which is what says so. + return resource, nil + } + if how != MergeJSON { + return nil, fmt.Errorf( + "%v says it merges as %q, and this control plane knows how to merge %q", + resource["id"], how, MergeJSON) + } + + content, _ := resource["content"].(string) + var base map[string]any + if strings.TrimSpace(content) == "" { + base = map[string]any{} + } else if err := json.Unmarshal([]byte(content), &base); err != nil { + return nil, fmt.Errorf("%v says it merges as JSON and its content is not JSON: %w", + resource["id"], err) + } + + protected := map[string]bool{} + for _, k := range stringsOf(resource["protected"]) { + protected[k] = true + } + + merged := deepCopy(base) + for _, layer := range layers { + for key, value := range layer.Values { + if protected[key] { + // The module said it must own this one. Refused rather than ignored: a setting + // that is quietly dropped is somebody believing they changed something. + return nil, fmt.Errorf( + "%s sets %q on %v, and that module keeps %q for itself — it is not settable", + layer.From, key, resource["id"], key) + } + merged[key] = mergeValue(merged[key], value) + } + } + + out := map[string]any{} + for k, v := range resource { + out[k] = v + } + // Keys sorted by the encoder, so the same settings always produce the same bytes. A file + // whose lines move for no reason makes every reconcile look like a change, and anything + // reflecting it would restart for ever. + rendered, err := json.MarshalIndent(merged, "", " ") + if err != nil { + return nil, err + } + out["content"] = string(rendered) + "\n" + delete(out, "merge") + delete(out, "protected") + return out, nil +} + +// mergeValue combines one value with the one over it. +// +// Two objects merge key by key, so setting one field of a nested block does not delete its +// siblings. Anything else is replaced whole: a list that merged element-wise could neither be +// shortened nor reordered, and there is no correct guess about which element is "the same one". +func mergeValue(under, over any) any { + underMap, isUnderMap := under.(map[string]any) + overMap, isOverMap := over.(map[string]any) + if !isUnderMap || !isOverMap { + return over + } + merged := deepCopy(underMap) + for k, v := range overMap { + merged[k] = mergeValue(merged[k], v) + } + return merged +} + +func deepCopy(in map[string]any) map[string]any { + out := map[string]any{} + for k, v := range in { + if nested, ok := v.(map[string]any); ok { + out[k] = deepCopy(nested) + continue + } + out[k] = v + } + return out +} + +// UnusedSettings names settings that reached no file. +// +// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed +// nothing — and would find out by the machine not behaving differently, which is the slowest +// way there is. This is what makes that visible at the moment they set it. +func UnusedSettings(m Manifest, layers []Layer) []string { + mergeable := false + for _, r := range m.Resources { + if how, _ := r["merge"].(string); how != "" { + mergeable = true + } + } + if mergeable { + return nil + } + + var unused []string + for _, layer := range layers { + for key := range layer.Values { + unused = append(unused, fmt.Sprintf("%s sets %q, and %s has no file to merge it into", + layer.From, key, m.Module)) + } + } + sort.Strings(unused) + return unused +} + +func stringsOf(v any) []string { + raw, ok := v.([]any) + if !ok { + return nil + } + var out []string + for _, item := range raw { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} diff --git a/internal/catalogue/settings_test.go b/internal/catalogue/settings_test.go new file mode 100644 index 0000000..b6977b4 --- /dev/null +++ b/internal/catalogue/settings_test.go @@ -0,0 +1,181 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +func file(content string, protected ...string) map[string]any { + r := map[string]any{"id": "conf", "type": "file", "path": "/etc/thing.json", + "merge": MergeJSON, "content": content} + if len(protected) > 0 { + var as []any + for _, p := range protected { + as = append(as, p) + } + r["protected"] = as + } + return r +} + +func merged(t *testing.T, resource map[string]any, layers ...Layer) map[string]any { + t.Helper() + out, err := ApplySettings(resource, layers) + if err != nil { + t.Fatal(err) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(out["content"].(string)), &parsed); err != nil { + t.Fatalf("the merged file is not JSON: %v", err) + } + return parsed +} + +func TestASettingBeatsTheModulesDefault(t *testing.T) { + // The whole rule. A setting is a statement about that key made deliberately; the default was + // only ever what to do in the absence of one. So there is nothing to resolve. + got := merged(t, file(`{"port":8080,"log":"info"}`), + Layer{From: "node", Values: map[string]any{"port": 9090.0}}) + + if got["port"] != 9090.0 { + t.Errorf("port is %v; the setting should have won", got["port"]) + } + if got["log"] != "info" { + t.Errorf("log is %v; a key nobody set should keep the module's value", got["log"]) + } +} + +func TestUpstreamKeepsTheKeysNobodySet(t *testing.T) { + // The point of merging rather than replacing: the module can change its half freely and only + // the keys somebody actually cares about are pinned. + got := merged(t, file(`{"port":8080,"log":"info","workers":4}`), + Layer{From: "node", Values: map[string]any{"log": "debug"}}) + + if got["port"] != 8080.0 || got["workers"] != 4.0 { + t.Errorf("the module's other keys did not survive: %v", got) + } +} + +func TestTheNodeBeatsTheMesh(t *testing.T) { + // Two layers, in order. A node that differs is expressed by differing, rather than by + // repeating everything the rest of the mesh already says. + got := merged(t, file(`{"log":"info"}`), + Layer{From: "mesh", Values: map[string]any{"log": "warn", "workers": 8.0}}, + Layer{From: "node", Values: map[string]any{"log": "debug"}}) + + if got["log"] != "debug" { + t.Errorf("log is %v; the node's setting should have won", got["log"]) + } + if got["workers"] != 8.0 { + t.Errorf("workers is %v; a mesh-wide setting the node did not touch should stand", got["workers"]) + } +} + +func TestNestedBlocksMergeRatherThanReplace(t *testing.T) { + // Setting one field of a block must not delete its siblings, or every setting would have to + // restate the whole block and would then pin all of it against upstream. + got := merged(t, file(`{"http":{"gzip":"off","timeout":30,"port":80}}`), + Layer{From: "node", Values: map[string]any{ + "http": map[string]any{"gzip": "on"}}}) + + block := got["http"].(map[string]any) + if block["gzip"] != "on" { + t.Errorf("gzip is %v", block["gzip"]) + } + if block["timeout"] != 30.0 || block["port"] != 80.0 { + t.Errorf("the block's other fields were lost: %v", block) + } +} + +func TestAListIsReplacedWholeNotMerged(t *testing.T) { + // A list that merged element-wise could neither be shortened nor reordered, and there is no + // correct guess about which element is "the same one". + got := merged(t, file(`{"hosts":["a","b","c"]}`), + Layer{From: "node", Values: map[string]any{"hosts": []any{"x"}}}) + + hosts := got["hosts"].([]any) + if len(hosts) != 1 || hosts[0] != "x" { + t.Errorf("hosts is %v; a list is replaced whole", hosts) + } +} + +func TestAProtectedKeyIsRefusedNotIgnored(t *testing.T) { + // A setting quietly dropped is somebody believing they changed something. Refusing says so + // while they are looking at it. + _, err := ApplySettings(file(`{"socket":"/run/thing.sock","log":"info"}`, "socket"), + []Layer{{From: "node", Values: map[string]any{"socket": "/tmp/mine.sock"}}}) + if err == nil { + t.Fatal("a protected key was overridden") + } + if !strings.Contains(err.Error(), "socket") || !strings.Contains(err.Error(), "not settable") { + t.Errorf("the refusal does not say which key or why: %v", err) + } +} + +func TestSettingsAroundAProtectedKeyStillApply(t *testing.T) { + got := merged(t, file(`{"socket":"/run/thing.sock","log":"info"}`, "socket"), + Layer{From: "node", Values: map[string]any{"log": "debug"}}) + if got["log"] != "debug" { + t.Errorf("log is %v", got["log"]) + } +} + +func TestTheSameSettingsAlwaysProduceTheSameBytes(t *testing.T) { + // A file whose lines move for no reason makes every reconcile look like a change, and a + // service reflecting it would restart for ever. + // + // Note what this defends: Go's JSON encoder sorts map keys, so the stability comes from the + // standard library and this passes with the merging removed. It is worth keeping as the thing + // that would catch a move to an encoder that does not sort — but it is not evidence about the + // code below it, and it was checked. + resource := file(`{"b":2,"a":1,"c":3}`) + layer := Layer{From: "node", Values: map[string]any{"z": 26.0, "a": 100.0}} + + first, err := ApplySettings(resource, []Layer{layer}) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 5; i++ { + again, err := ApplySettings(resource, []Layer{layer}) + if err != nil { + t.Fatal(err) + } + if again["content"] != first["content"] { + t.Fatal("the same settings produced different bytes") + } + } +} + +func TestAFileThatDoesNotMergeIsLeftAlone(t *testing.T) { + plain := map[string]any{"id": "conf", "type": "file", "path": "/etc/thing", + "content": "not structured at all\n"} + out, err := ApplySettings(plain, []Layer{{From: "node", Values: map[string]any{"x": 1}}}) + if err != nil { + t.Fatal(err) + } + if out["content"] != "not structured at all\n" { + t.Errorf("a file with no merge rule was changed: %v", out["content"]) + } +} + +func TestSettingsThatReachNothingAreNamed(t *testing.T) { + // Somebody who misspells a module, or sets a key on one with nothing mergeable, has changed + // nothing — and would otherwise find out by the machine not behaving differently, which is + // the slowest way there is. + m := Manifest{Module: "thing", Resources: []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"}, + }} + unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}}) + if len(unused) != 1 || !strings.Contains(unused[0], "no file to merge it into") { + t.Errorf("settings that reached nothing were not named: %v", unused) + } +} + +func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) { + // Rather than on the machine, at apply time, as a file the program cannot read. + _, err := ApplySettings(file(`this is not json`), nil) + if err == nil { + t.Fatal("a module claiming to merge as JSON shipped something else and was accepted") + } +} diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index c0ee428..4968fc9 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -303,3 +303,92 @@ func (i *Inventory) ProfileOf(ctx context.Context, nodeName string) (map[string] } return out, nil } + +// SetSettings records what somebody wants a module's configuration to say. +// +// An empty node name means the whole mesh. Replacing rather than merging what is already there: +// this is a statement of the whole layer, so removing a key is done by leaving it out, which is +// the only way removing one could work at all. +func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, values map[string]any) error { + raw, err := json.Marshal(values) + if err != nil { + return err + } + if nodeName == "" { + _, err = i.store.Pool().Exec(ctx, + `insert into settings (node, module, values) values (null, $1, $2) + on conflict (module) where node is null + do update set values = excluded.values, set_at = now()`, module, raw) + return wrapModule(err, module) + } + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return err + } + _, err = i.store.Pool().Exec(ctx, + `insert into settings (node, module, values) values ($1, $2, $3) + on conflict (node, module) where node is not null + do update set values = excluded.values, set_at = now()`, node.ID, module, raw) + return wrapModule(err, module) +} + +func wrapModule(err error, module string) error { + if err != nil && strings.Contains(err.Error(), "settings_module_fkey") { + return fmt.Errorf("%w: %s", ErrNoSuchModule, module) + } + return err +} + +// ClearSettings removes a layer. +func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string) error { + if nodeName == "" { + _, err := i.store.Pool().Exec(ctx, + `delete from settings where module = $1 and node is null`, module) + return err + } + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return err + } + _, err = i.store.Pool().Exec(ctx, + `delete from settings where module = $1 and node = $2`, module, node.ID) + return err +} + +// SettingsFor is the layers that apply to one module on one node, in the order they are applied. +// +// The mesh's first, then the node's, so a node that differs is expressed by differing rather +// than by restating everything the rest of the mesh already says. +func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([]catalogue.Layer, error) { + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return nil, err + } + rows, err := i.store.Pool().Query(ctx, + `select node is null, values from settings + where module = $1 and (node is null or node = $2) + order by node is null desc`, module, node.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + var layers []catalogue.Layer + for rows.Next() { + var meshWide bool + var raw []byte + if err := rows.Scan(&meshWide, &raw); err != nil { + return nil, err + } + values := map[string]any{} + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + from := nodeName + if meshWide { + from = "the mesh" + } + layers = append(layers, catalogue.Layer{From: from, Values: values}) + } + return layers, rows.Err() +} diff --git a/internal/inventory/migrations/0007-settings.sql b/internal/inventory/migrations/0007-settings.sql new file mode 100644 index 0000000..053c419 --- /dev/null +++ b/internal/inventory/migrations/0007-settings.sql @@ -0,0 +1,25 @@ +-- What somebody wants a module's configuration to say on a node. +-- +-- Managed files are generated and never edited (novox/hq ADR 0011), so an intention about one has +-- to live somewhere the generator can see it. This is that place: the file is produced from the +-- module's defaults and these together, and the module can rewrite its half freely. + +create table settings ( + -- Null means the whole mesh. Two layers, and both wanted from the start: "every machine + -- running this gets that" and "this one differs" are different statements, and expressing the + -- second by repeating the first would pin everything it restated against future changes. + node uuid references node(id) on delete cascade, + module text not null references module(name) on delete cascade, + + values jsonb not null, + set_at timestamptz not null default now() +); + +-- One row per layer per module. A partial index for each half, because null is not equal to null +-- and a plain unique constraint would let the mesh-wide layer be written twice. +create unique index settings_for_the_mesh on settings (module) where node is null; +create unique index settings_for_a_node on settings (node, module) where node is not null; + +-- Settings go when their module does, unlike assignments, which hold a module in place. A setting +-- for a module nobody has is not something a machine is running -- it is a note about a thing that +-- no longer exists, and keeping it would mean the mesh reporting settings that can never apply.