Files
mesh-controller/internal/catalogue/port_into.go
T
jschoubben c978aa7d64 No container is given the mesh's names; it resolves them
novox/hq ADR 0148, step 3. Every container got the whole roster as
--add-host entries at creation and nothing re-read them (issues 109,
135); once the roster was in the digest so that could be caught, one
name moving anywhere replaced every container in the mesh (issue 151).
A container resolves through its machine's resolver, which the resolver
module tells the runtime about once per machine. A module's own hosts
entries stay exactly as declared.

Also brings the resolver tests up to the catalogue as it now is: the
runtime is reloaded (never restarted) and given live-restore, and the
resolver answers by address, not by interface (issue 110).
2026-09-30 14:38:07 +02:00

164 lines
6.2 KiB
Go

package catalogue
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// Telling a module which port it was given.
//
// **The mesh assigns the machine-side port and a module does not choose one**
// ([ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md)); on a node given one for a
// module it is the operator's number rather than the mesh's
// ([ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)). For a
// container's own listening socket that is invisible: the mesh rewrites `ports` into
// `assigned:wanted`, the software inside binds the number it has always bound, and the machine
// publishes a different one.
//
// **Two kinds of resource have no such layer.**
//
// - **A process** runs on the machine, there is nothing to rewrite, and it binds whatever its
// configuration says — so without this, every process binds the number written in its own
// config, two modules declaring the same one collide, and the mesh's whole reason for
// assigning ports is defeated by the resource kind that most needs it.
// - **A container that DIALS the machine** — a module's own sidecar reaching the service beside
// it over the machine's loopback — is told that address in its environment, and the mapping
// that saves the listener does nothing for the caller: what it must dial is the machine-side
// number, which is exactly the one the module cannot know (novox/hq 04-ISSUES/088).
//
// So a module asks. `${port:8080}` is "the machine-side port you gave me for the 8080 I said I
// listen on", and the module writes that where it would otherwise have written a literal — in a
// file's content, or in a value of a container's `env`.
//
// **The environment is filled by the control plane, exactly as a bound value is.** A port is not
// secret — the mesh holds it in the clear — so there is nothing for the host to be the only
// witness of, and the host learns no new field. That is what separates this from
// [ADR 0086](../../02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md), which refuses a
// `${secret:…}` in an `env` outright: the objection there is to the value being in an environment
// at all, not to who fills it in.
//
// **It answers with the machine's number, wherever it is written.** A container reaching a sibling
// over the runtime's own network reaches it on the port inside that container and goes on writing
// that number literally — it is a number the module does control. This is for the machine side,
// which is the side nobody but the mesh can know.
// ofPort is where a module asks which port it was given: ${port:<the port its software uses>}.
var ofPort = regexp.MustCompile(`\$\{port:([0-9]+)\}`)
// portsUsed are the ports a written value asks about, first appearance first.
func portsUsed(content string) []int {
var used []int
seen := map[int]bool{}
for _, m := range ofPort.FindAllStringSubmatch(content, -1) {
n, err := strconv.Atoi(m[1])
if err != nil || seen[n] {
continue
}
seen[n] = true
used = append(used, n)
}
return used
}
// portInto replaces a resource's ${port:…} placeholders with what this machine assigned — in a
// file's content, and in a value of a container's environment.
//
// A port the module did not say it listens on is refused, for the same reason a binding's unknown
// key is: the module is asking about something it never declared, and the answer would be a guess.
// Left alone, the literal would be written into a configuration file, or handed to a process as
// its environment, and read as a port number.
func portInto(resource map[string]any, module string, listens []Listening, with Rendering) error {
switch fmt.Sprint(resource["type"]) {
case "file":
content, ok := resource["content"].(string)
if !ok {
return nil
}
filled, err := portsFilledInto(content,
fmt.Sprintf("%s has a file that", module), module, listens, with)
if err != nil {
return err
}
resource["content"] = filled
case "container":
env, ok := resource["env"].(map[string]any)
if !ok {
return nil
}
// In a stated order, so a container with two bad values always refuses on the same one.
named := make([]string, 0, len(env))
for key := range env {
named = append(named, key)
}
sort.Strings(named)
// **A fresh map, and only when something changes.** This map came out of the module's
// manifest and the resource around it is a shallow copy, so filling a value in place would
// change what the catalogue holds for every other machine running the module.
var filled map[string]any
for _, key := range named {
written, ok := env[key].(string)
if !ok || len(portsUsed(written)) == 0 {
continue
}
value, err := portsFilledInto(written,
fmt.Sprintf("%s's container %s sets %s to something that",
module, resource["name"], key), module, listens, with)
if err != nil {
return err
}
if filled == nil {
filled = map[string]any{}
for k, v := range env {
filled[k] = v
}
}
filled[key] = value
}
if filled != nil {
resource["env"] = filled
}
}
return nil
}
// portsFilledInto answers every ${port:…} in one written value, or refuses. `where` names the
// place it was written, so a refusal is one edit from right whichever kind of resource it came
// out of.
func portsFilledInto(written, where, module string, listens []Listening, with Rendering) (
string, error) {
for _, wanted := range portsUsed(written) {
var declared bool
for _, l := range listens {
if l.Port == wanted {
declared = true
}
}
if !declared {
return "", fmt.Errorf(
"%s says ${port:%d}, and %s does not say it listens on %d. A module is told the "+
"port it was given for something it declared, and %s",
where, wanted, module, wanted, orNoListens(listens))
}
written = strings.ReplaceAll(written, fmt.Sprintf("${port:%d}", wanted),
strconv.Itoa(with.machinePort(module, wanted)))
}
return written, nil
}
// orNoListens says what would have worked, so a refusal is one edit from right.
func orNoListens(listens []Listening) string {
if len(listens) == 0 {
return "it declares no ports at all"
}
said := make([]string, 0, len(listens))
for _, l := range listens {
said = append(said, strconv.Itoa(l.Port))
}
return "it declares " + strings.Join(said, ", ")
}