A module can tell its provider what it needs

`requires` said a thing must be there. It never said what to do with it,
so a web application requiring a reverse proxy had nowhere to put "this
name, this port". The two modules that needed it most went round the
outside and opened a connection to the control plane's database, which is
why every node holds a credential to it permanently.

Two fields close it:

  contributes: {reverse-proxy: {host: board, port: 8080}}
  receives:    {reverse-proxy: /etc/traefik/dynamic/mesh.json}

The control plane collects every contribution on a node and writes them
to the path the provider named, ordered by module so the file does not
churn. Contributing to something is requiring it — asking to be published
means a publisher must exist, and a module that had to say both would
eventually say one.

The control plane does not know what a reverse proxy is and does not
write one's configuration. It delivers facts; the module turns them into
whatever it runs. That is why swapping the proxy touches nothing that
publishes through it, and why the host needs no new vocabulary — a
received file is a file.

Settings reach a contribution the same way they reach a file, because a
hostname is exactly what differs between one mesh and the next.

Two things found by running it:

- the file had a `//` header, so it said "do not edit" to a person and
  failed to parse for the program meant to read it. The note is inside
  the document now.
- a provider with no consumers gets an empty file rather than none. It
  cannot otherwise tell "nothing asked for me" from "the mesh never
  wrote it", and those want different responses.

Also `plan <node> --json`, which is how the declaration gets handed to
the host's own parser.
This commit is contained in:
2026-08-29 23:35:43 +02:00
parent 44d134ba25
commit 5a3a87e8c3
6 changed files with 453 additions and 20 deletions
+39 -16
View File
@@ -63,18 +63,9 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
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)
}
merged, err := settle(base, layers, protected, fmt.Sprint(resource["id"]))
if err != nil {
return nil, err
}
out := map[string]any{}
@@ -94,6 +85,29 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
return out, nil
}
// 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
@@ -130,20 +144,29 @@ func deepCopy(in map[string]any) map[string]any {
// 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
return nil
}
}
if mergeable {
// 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 to merge it into",
unused = append(unused, fmt.Sprintf(
"%s sets %q, and %s has no file or contribution to merge it into",
layer.From, key, m.Module))
}
}