From 74b0dab34c7d4fdbf2b1d62af6517636d8d57b5a Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:25:27 +0200 Subject: [PATCH] A container publishes only a port its module declares (hq issue 227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The short form is a question the mesh answers: "80" means publish what the software calls 80, and the mesh fills in the machine's half from the port it assigned. It can only assign one for a port the module declared, so a number appearing nowhere in listens gets no assignment and reaches the machine as written — which is how the photo module asked for port 80 on the node whose reverse proxy holds it. Four modules publish 80 quite safely, because they declare 80. The difference is the declaration, not the number. A catalogue-wide test now says so; it names all three offenders against the catalogue as it was. --- internal/catalogue/published_ports_test.go | 75 ++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 internal/catalogue/published_ports_test.go diff --git a/internal/catalogue/published_ports_test.go b/internal/catalogue/published_ports_test.go new file mode 100644 index 0000000..48036d2 --- /dev/null +++ b/internal/catalogue/published_ports_test.go @@ -0,0 +1,75 @@ +package catalogue + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// A container publishes only a port its module declares (novox/hq issue 227). +// +// **The short form is a question the mesh answers.** `"80"` means *publish what the software +// calls 80*, and the mesh fills in the machine's half from the port it assigned +// ([ADR 0038](0038)). It can only assign one for a port the module declared in `listens` — so a +// container publishing a number that appears nowhere in `listens` gets no assignment, and +// `publishedOn` falls back to the number as written. It escapes to the machine. +// +// That is how the photo module asked for port 80 on the control node, where the reverse proxy +// holds it: it declared its web endpoint at 4001, published a bare 80, and the container never +// started. Four other modules publish 80 quite safely — because they declare 80, so the mesh +// gives them a machine port for it. The difference is the declaration, not the number. +// +// A mapping written the long way is a module pinning both halves on purpose and is left alone. +func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) { + root := catalogueRoot(t) + entries, err := os.ReadDir(filepath.Join(root, "modules")) + if err != nil { + t.Fatal(err) + } + var escaped []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(root, "modules", entry.Name(), "module.json")) + if err != nil { + continue + } + m, err := ParseManifest(raw) + if err != nil { + // Whether every manifest parses is TestEveryCatalogueManifestParses's question. + continue + } + declared := map[int]bool{} + for _, l := range m.Listens { + declared[l.Port] = true + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + listed, _ := r["ports"].([]any) + for _, p := range listed { + written := strings.Split(fmt.Sprint(p), "/")[0] + if strings.Contains(written, ":") { + continue // pinned by hand, both halves, on purpose + } + port, err := strconv.Atoi(strings.TrimSpace(written)) + if err != nil || declared[port] { + continue + } + escaped = append(escaped, fmt.Sprintf( + "%s's %v publishes %d, and %s declares no such port — the mesh has nothing "+ + "to assign, so %d reaches the machine as written", + m.Module, r["id"], port, m.Module, port)) + } + } + } + if len(escaped) > 0 { + t.Fatalf("a container may publish only a port its module declares:\n - %s", + strings.Join(escaped, "\n - ")) + } +}