A module's own code moving out of its container (novox/hq to-be 38 WP4c)
becomes a process on the machine, and still has to be told what its
container was: the port this machine gave the module and where the
foundation's seats are. ${port:…} and ${seat:…} were filled only in a
file's content and a container's env, so in a process's env they reached
the machine as literals, and the modules that moved first (mesh-catalog
#245) wrote their run-once steps a 0600 env file instead. A process's env
now takes the same resolution and the same refusals; ${dir:…} and
${access:…} already did, and a bundle's env (ADR 0192) already resolves
${dir:…} and ${port:…}.
169 lines
6.7 KiB
Go
169 lines
6.7 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 or a process'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 or a process's environment.
|
|
//
|
|
// **A process's environment is a container's** (novox/hq to-be 38 WP4c). A module's code moving out
|
|
// of its container becomes a process on the machine and still has to be told what the container
|
|
// was told; filled for one kind and not the other, the literal reached the process and was read as
|
|
// a port, and the modules that moved first wrote their run-once steps a 0600 env file instead.
|
|
//
|
|
// 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", "process":
|
|
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 %s %s sets %s to something that",
|
|
module, resource["type"], 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, ", ")
|
|
}
|