Files
mesh-controller/internal/catalogue/settings.go
T
jschoubben c4782ae2fd An app is told where its database is
Knowing that a machine needs the anchor's database is useless to the
program that needs it unless the program is told. It knew; nothing was
written anywhere it could read.

Two fields, mirroring contributes/receives in the other direction:

  serves: {database: {port: 5432, driver: postgres}}   on the provider
  binds:  {database: /etc/app/database.json}           on the consumer

The provider says what a consumer needs to know; the mesh adds the half
only it has — which machine, and what that machine is called on the
private network. The file says, in itself, that it carries no credential
and why. A missing field looks like a bug; a stated absence looks like a
boundary.

Binding something answered on this machine writes nothing. A file saying
"it is on this node" is a fact nobody needs and one more thing to keep
true.

And two machines that share no private network are refused rather than
wired together. An app here and a database there with no path between
them is a mesh that reports itself configured and does not work — the
failure surfaces as a connection timing out, which is the slowest place
to find it. This is checkable now only because the network became
something a machine is given rather than something it has by having an
address.

One fault, found by running it: working out who is on the private network
resolved the mesh, and resolving the mesh asks who is on the private
network. It hung for two minutes. The comment above the function said not
to do that and the function did it anyway; it now resolves each node
locally, which is the right answer to the question regardless — whether a
machine is on the network depends on what it was assigned, not on what it
takes from others.
2026-08-30 00:02:18 +02:00

196 lines
6.6 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 {
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
}