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.
167 lines
5.2 KiB
Go
167 lines
5.2 KiB
Go
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
|
|
}
|