listens.from was a manifest constant — one value for every node a module runs
on. Now a per-node setting overrides it: {"expose": {"5432": "anywhere"}} makes
postgres public on the machine it is set for while it stays from:mesh elsewhere,
and the firewall (ADR 0050) is computed from the effective source. Exposure()
validates it — a port the module does not listen on, or a source that is not
mesh/anywhere/machine, is refused rather than reaching nothing; UnusedSettings
knows 'expose' is a real destination. Tested: default mesh, setting opens it to
anywhere, bad settings refused.
201 lines
6.9 KiB
Go
201 lines
6.9 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, err := settle(base, layers, protected, fmt.Sprint(resource["id"]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Settle lays settings over a module's own values. Exported for what a provider serves, which is
|
|
// settled where the mesh is walked rather than where a node is declared.
|
|
func Settle(base map[string]any, layers []Layer) (map[string]any, error) {
|
|
return settle(base, layers, nil, "what is served")
|
|
}
|
|
|
|
// settle lays the layers over a module's own values, in order.
|
|
//
|
|
// Shared by a file's content and a module's contributions, because they are the same act: the
|
|
// module says what it means by default, and somebody says what it means here. A contribution that
|
|
// could not be settled would have to be edited to be reused anywhere else.
|
|
func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) (
|
|
map[string]any, error) {
|
|
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, what, key)
|
|
}
|
|
merged[key] = mergeValue(merged[key], value)
|
|
}
|
|
}
|
|
return merged, 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 {
|
|
for _, r := range m.Resources {
|
|
if how, _ := r["merge"].(string); how != "" {
|
|
return nil
|
|
}
|
|
}
|
|
// A contribution is a destination too. A route's hostname is exactly the kind of thing that
|
|
// differs between one mesh and the next, and calling it stray would refuse the one setting
|
|
// most modules that publish anything will have.
|
|
if len(m.Contributes) > 0 {
|
|
return nil
|
|
}
|
|
// A computed module has no resources here to look at — they are worked out per node, and
|
|
// whether a setting lands is not knowable until then. Silence rather than a wrong answer:
|
|
// claiming every setting on the private network is stray would be worse than saying nothing.
|
|
if m.Computed != "" {
|
|
return nil
|
|
}
|
|
|
|
var unused []string
|
|
for _, layer := range layers {
|
|
for key := range layer.Values {
|
|
// `expose` is a real destination for a module that listens: it overrides a port's
|
|
// source (novox/hq ADR 0051), validated in Exposure, so it is not stray here.
|
|
if key == ExposeSetting && len(m.Listens) > 0 {
|
|
continue
|
|
}
|
|
unused = append(unused, fmt.Sprintf(
|
|
"%s sets %q, and %s has no file or contribution 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
|
|
}
|