diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 3b4a1c5..707a086 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -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 // 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). + // 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 { return nil, err } diff --git a/internal/catalogue/port_into_files.go b/internal/catalogue/port_into_files.go new file mode 100644 index 0000000..f694cd9 --- /dev/null +++ b/internal/catalogue/port_into_files.go @@ -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:}. +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, ", ") +} diff --git a/internal/catalogue/port_into_files_test.go b/internal/catalogue/port_into_files_test.go new file mode 100644 index 0000000..e8bcba7 --- /dev/null +++ b/internal/catalogue/port_into_files_test.go @@ -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) + } +} diff --git a/internal/catalogue/showcase_manifest_test.go b/internal/catalogue/showcase_manifest_test.go new file mode 100644 index 0000000..3dc2d49 --- /dev/null +++ b/internal/catalogue/showcase_manifest_test.go @@ -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) + } +}