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
+80
View File
@@ -83,8 +83,56 @@ type Manifest struct {
// doing the same job, and every machine with an address was on the network whether or not
// anybody wanted it there.
Computed string `json:"computed,omitempty"`
// Contributes is what this module tells whatever answers a requirement.
//
// The other half of an edge. `requires` says a thing must be there; this says what to do with
// it — a web application requiring a reverse proxy has to say *which name, which port*, and
// until now there was nowhere to put that. Every module that needed it was reduced to
// reaching into the control plane's database directly, which is how two of them came to hold
// a credential to it permanently.
//
// Keyed by the requirement, because that is what the contribution is *about*. 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.
Contributes map[string]map[string]any `json:"contributes,omitempty"`
// Receives is where this module wants its consumers' contributions written, per requirement
// it provides.
//
// A file, in the mesh's own shape, replaced whenever the set changes. **The control plane
// does not know what a reverse proxy is** and does not write one's configuration — it
// delivers the facts, and the module turns them into whatever it runs. That boundary is why
// swapping the proxy does not touch a single module that publishes through it.
Receives map[string]string `json:"receives,omitempty"`
}
// Wants is everything that must be provided on the same node: what this module requires, and what
// it contributes to.
func (m Manifest) Wants() []string {
out := append([]string{}, m.Requires...)
for to := range m.Contributes {
var already bool
for _, r := range m.Requires {
if r == to {
already = true
}
}
if !already {
out = append(out, to)
}
}
sort.Strings(out)
return out
}
// ReceivedID is the resource identity of the file a provider is given its contributions in.
//
// Named rather than positional so a module can point `restart-on` at it: a proxy that got a new
// route and did not reload is a route that silently does not work, which is the same fault the
// overlay had when a peer list changed under a running interface.
func ReceivedID(requirement string) string { return "received-" + requirement }
// ParseManifest reads a module manifest, refusing anything it cannot act on.
//
// Every problem is reported rather than the first, because somebody writing a manifest fixes
@@ -138,6 +186,38 @@ func ParseManifest(raw []byte) (Manifest, error) {
"%s has resources of its own and says they are computed by %q; it is one or the other",
m.Module, m.Computed))
}
for to, values := range m.Contributes {
if !name.MatchString(to) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to))
}
if len(values) == 0 {
// An empty contribution is either a mistake or a requirement written the long way
// round, and both are better said plainly.
problems = append(problems, fmt.Sprintf(
"%s contributes nothing to %q; if it only needs one, require it", m.Module, to))
}
}
for to, where := range m.Receives {
if !name.MatchString(to) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to))
}
if !strings.HasPrefix(where, "/") {
problems = append(problems, fmt.Sprintf(
"%s receives %q at %q, which is not an absolute path", m.Module, to, where))
}
var offered bool
for _, o := range m.Offers() {
if o == to {
offered = true
}
}
if !offered {
// Receiving contributions to something you do not provide would create a file nobody
// ever writes to, on a machine where nothing asked for it.
problems = append(problems, fmt.Sprintf(
"%s receives contributions to %q and does not provide it", m.Module, to))
}
}
for i, r := range m.Resources {
id, _ := r["id"].(string)
if id == "" {