Settings: changing a module's config without editing its file
Managed files are generated and never edited, so somebody's intention about one has to live where the generator can see it. It does now: the module ships defaults, settings go over the top by key, and the file is produced from both. Upstream can rewrite its half freely and the keys somebody chose survive. Two layers, both from the start. The mesh's settings for a module, then one machine's over those. A node that differs is expressed by differing, rather than by restating everything the rest already say -- which would pin all of it against future changes for no reason. An override beats a default and there is nothing to resolve. A setting is a statement about that key made deliberately; the default was only ever what to do in the absence of one. So when upstream changes a key somebody has set, there is no conflict, no merge markers, and nothing to ask. Nested blocks merge and lists are replaced whole. Setting one field of a block must not delete its siblings, or every setting would restate the whole block and pin all of it. 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". A module can keep specific keys for itself -- a socket path its own code depends on -- and setting one is REFUSED rather than ignored. A setting quietly dropped is somebody believing they changed something. Settings that reach nothing are named at the moment they would be used, not discovered later by the machine not behaving differently. `plan --files` prints what a machine would be given before it is sent, because "1 resource" does not tell you whether the merge landed. One test kept with a note that it does not defend this code: output stability comes from Go's encoder sorting map keys, so it passes with the merging removed. Worth having as the thing that would catch a change of encoder, but it is not evidence about anything written here, and it was checked.
This commit is contained in:
+141
-13
@@ -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 <node> <module> put a module on a node
|
||||
unassign <node> <module> take it off
|
||||
settings set <module> <file> what a module's config should say, for the whole mesh
|
||||
settings set <module> <file> --node <n> ...or for one machine
|
||||
settings clear <module> [--node <n>] take a layer away
|
||||
plan <node> what that node would run, and why
|
||||
push [<node>] 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 <node>")
|
||||
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 <node> [--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 <module> <file> [--node <node>], or settings clear <module> [--node <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 <module> <settings.json> [--node <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 <module> [--node <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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user