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).
164 lines
6.2 KiB
Go
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, ", ")
|
|
}
|