Files
jschoubben 7352c846dd A module is told its port in a container's environment too (hq issue 088)
${port:…} answered only inside a file's content, and the one place a module
routinely writes its own address is a container's `env` — where the literal is
wrong on every node whose assignment differs from the manifest's number, and
wrong again on a node given that port as a setting (ADR 0100). Nothing checked
it: the value is a string like any other, and it fails at runtime, on one node.

Filled by the control plane, like a bound value: a port is not secret, so there
is nothing for the host to be the only witness of and it learns no new field.
That is the line ADR 0086 draws — its objection is to a secret being in an
environment at all, not to who fills one in — so a port crosses it and a
credential still does not. Same guard as before: a port the module never said it
listens on is refused, now naming the container and the variable.

The env map is the catalogue's, shared by every node running the module, and the
resource around it is a shallow copy, so a filled value goes into a fresh map —
otherwise the first node composed writes its own port into the manifest and
every node after it is told that one.

Inert on the catalogue as it stands: ${port:…} is written in one other place in
it, a file. Renamed off _files, which this no longer is.
2026-09-22 22:36:28 +02:00

165 lines
6.3 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 — the trap
// withMeshNames is written to avoid, one field along.
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, ", ")
}