A module can be told which port it was given
The mesh assigns the machine-side port and a module does not choose one (ADR
0038). For a container that is invisible: the mesh rewrites ports into
assigned:wanted, the software binds the number it always bound, and the machine
publishes another.
A process has no such layer. It runs on the machine, there is nothing to rewrite,
and it binds whatever its configuration says. So every process bound the number
written in its own config, two modules declaring the same one would collide, and
the mesh's whole reason for assigning ports was defeated by the resource kind
that most needs it — introduced, by me, three commits ago.
So a module asks. ${port:8080} is "the machine-side port you gave me for the 8080
I said I listen on", written into its own configuration exactly as an address it
was bound to is.
Asking about a port it never declared is refused, and the refusal says what it
did declare: the module is asking about something the mesh has no opinion on, and
answering would put a guess into a configuration file as a port number. With
nothing assigned yet it is told what it asked for, so a mesh that has made no
assignment still composes something coherent rather than writing a zero.
Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -456,6 +456,12 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
// assigned to. Beside the bound values because it is the same kind of fact — the
|
// assigned to. Beside the bound values because it is the same kind of fact — the
|
||||||
// mesh's own, held in the clear — and because a module that must name itself to
|
// mesh's own, held in the clear — and because a module that must name itself to
|
||||||
// something else has no binding to learn it from (novox/hq ADR 0066).
|
// something else has no binding to learn it from (novox/hq ADR 0066).
|
||||||
|
// Which port this machine gave it, for a module that binds directly rather than
|
||||||
|
// through a runtime that can remap (ADR 0038). Applied before the machine's facts so
|
||||||
|
// a refusal names the port rather than whatever came after it.
|
||||||
|
if err := portInto(copied, m.Module, m.Listens, with); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := machineInto(copied, thisMachine, m.Module); err != nil {
|
if err := machineInto(copied, thisMachine, m.Module); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"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)). For a container 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.
|
||||||
|
//
|
||||||
|
// **A process has no such layer.** It 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.
|
||||||
|
//
|
||||||
|
// 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 into its own configuration exactly as it writes an
|
||||||
|
// address it was bound to.
|
||||||
|
|
||||||
|
// 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 file's content 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 file's ${port:…} placeholders with what this machine assigned.
|
||||||
|
//
|
||||||
|
// 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 and read as a port number.
|
||||||
|
func portInto(resource map[string]any, module string, listens []Listening, with Rendering) error {
|
||||||
|
if fmt.Sprint(resource["type"]) != "file" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content, ok := resource["content"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, wanted := range portsUsed(content) {
|
||||||
|
var declared bool
|
||||||
|
for _, l := range listens {
|
||||||
|
if l.Port == wanted {
|
||||||
|
declared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !declared {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s has a file that 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",
|
||||||
|
module, wanted, module, wanted, orNoListens(listens))
|
||||||
|
}
|
||||||
|
content = strings.ReplaceAll(content, fmt.Sprintf("${port:%d}", wanted),
|
||||||
|
strconv.Itoa(with.machinePort(module, wanted)))
|
||||||
|
resource["content"] = content
|
||||||
|
}
|
||||||
|
return 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, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A process binds the port the mesh gave it, not the one it wrote down.**
|
||||||
|
//
|
||||||
|
// A container never needed this: the mesh rewrites its `ports` into assigned:wanted, so the
|
||||||
|
// software binds the number it always bound and the machine publishes another. A process runs on
|
||||||
|
// the machine with nothing to rewrite, so without a way to ask, every process binds the number in
|
||||||
|
// its own configuration and two modules declaring the same one collide — which is the whole
|
||||||
|
// problem ADR 0038 exists to prevent, reintroduced by the resource kind that most needs it.
|
||||||
|
func TestAModuleIsToldWhichPortItWasGiven(t *testing.T) {
|
||||||
|
file := map[string]any{
|
||||||
|
"type": "file", "id": "settings",
|
||||||
|
"content": "LISTEN=${port:8080}\n",
|
||||||
|
}
|
||||||
|
listens := []Listening{{Port: 8080, From: FromMesh}}
|
||||||
|
with := Rendering{Ports: map[string]map[int]int{"showcase": {8080: 21000}}}
|
||||||
|
|
||||||
|
if err := portInto(file, "showcase", listens, with); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := file["content"].(string); got != "LISTEN=21000\n" {
|
||||||
|
t.Fatalf("the module was not told its assigned port: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With nothing assigned yet, it is told the port it asked about — so a mesh that has not made an
|
||||||
|
// assignment still composes something coherent rather than writing a zero.
|
||||||
|
func TestWithNoAssignmentAModuleIsToldWhatItAskedFor(t *testing.T) {
|
||||||
|
file := map[string]any{"type": "file", "content": "LISTEN=${port:8080}\n"}
|
||||||
|
if err := portInto(file, "showcase", []Listening{{Port: 8080}}, Rendering{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := file["content"].(string); got != "LISTEN=8080\n" {
|
||||||
|
t.Fatalf("an unassigned port did not fall back to what was declared: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Asking about a port it never declared is refused**, and the refusal says what it did declare.
|
||||||
|
// The module is asking about something the mesh has no opinion on, and answering would be a guess
|
||||||
|
// written into a configuration file as a port number.
|
||||||
|
func TestAskingAboutAnUndeclaredPortIsRefused(t *testing.T) {
|
||||||
|
file := map[string]any{"type": "file", "content": "LISTEN=${port:9999}\n"}
|
||||||
|
err := portInto(file, "showcase", []Listening{{Port: 8080}}, Rendering{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a module was told a port it never said it listens on")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "8080") {
|
||||||
|
t.Fatalf("the refusal does not say what would have worked: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a file mentioning no port is left exactly as it was.
|
||||||
|
func TestAFileWithNoPortIsUntouched(t *testing.T) {
|
||||||
|
file := map[string]any{"type": "file", "content": "GREETING=hello\n"}
|
||||||
|
if err := portInto(file, "showcase", nil, Rendering{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := file["content"].(string); got != "GREETING=hello\n" {
|
||||||
|
t.Fatalf("a file with no port was changed: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **The showcase module is parsed by the real parser, in the real test suite.**
|
||||||
|
//
|
||||||
|
// A module that exercises every capability is only worth having if something checks it still does.
|
||||||
|
// Written as a test rather than a script so it runs whenever anything about manifests changes —
|
||||||
|
// which is exactly when a module using all of it would quietly stop being valid.
|
||||||
|
func TestTheShowcaseModuleIsAValidManifest(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../../mesh-catalog/modules/showcase/module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("the catalogue is not beside this checkout: %v", err)
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the module that exercises everything does not parse:\n%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every resource kind a MODULE may use, actually in it.
|
||||||
|
//
|
||||||
|
// Two of the host's eleven are deliberately absent, and the reasons are worth keeping:
|
||||||
|
//
|
||||||
|
// - `action` is refused to modules outright. The link may not carry a command to run (ADR
|
||||||
|
// 0005), so a module that needs something done ships a program that reads what the mesh
|
||||||
|
// delivered and reconciles — which is what a run-once `process` is.
|
||||||
|
// - `service` puts an EXISTING unit into a state and deliberately installs none, which is
|
||||||
|
// right for software that ships its own unit. A module whose code the mesh built has no
|
||||||
|
// such unit until the mesh writes one, and that is a `process`.
|
||||||
|
kinds := map[string]bool{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
kind, _ := r["type"].(string)
|
||||||
|
kinds[kind] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"access", "archive", "container", "directory", "file", "network", "package",
|
||||||
|
"process", "user",
|
||||||
|
} {
|
||||||
|
if !kinds[want] {
|
||||||
|
t.Errorf("showcase no longer exercises %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And all three ways a module's own code can run, which is the thing most easily lost.
|
||||||
|
var stays, once, scheduled bool
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if kind, _ := r["type"].(string); kind != "process" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r["run-once"] == true:
|
||||||
|
once = true
|
||||||
|
case r["schedule"] != nil:
|
||||||
|
scheduled = true
|
||||||
|
default:
|
||||||
|
stays = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stays || !once || !scheduled {
|
||||||
|
t.Errorf("showcase does not exercise all three process modes: stays=%v once=%v scheduled=%v",
|
||||||
|
stays, once, scheduled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the artifact kinds, including the one that compiles.
|
||||||
|
var bundle, archive, upstream bool
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
switch a.Kind {
|
||||||
|
case ArtifactBundle:
|
||||||
|
bundle = true
|
||||||
|
case ArtifactArchive:
|
||||||
|
archive = true
|
||||||
|
case ArtifactUpstream:
|
||||||
|
upstream = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bundle || !archive || !upstream {
|
||||||
|
t.Errorf("showcase does not exercise every artifact kind: bundle=%v archive=%v upstream=%v",
|
||||||
|
bundle, archive, upstream)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user