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:}. 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, ", ") }