Compare commits

...
Author SHA1 Message Date
jschoubben 4d2003d77b Run the real catalogue through the real manifest gate
A test that reads every manifest in a catalogue checkout and parses it with the
control plane's own parser, rather than asserting against a fixture: whether the
manifests as written are accepted is the question, and a copy of one proves nothing
about the other seventy-one.

Skipped unless MESH_CATALOGUE names a checkout, so it costs nothing in ordinary
runs and is there when the catalogue changes shape. It also refuses to pass if no
endpoint is named, because a run that validated nothing would otherwise read as
success.
2026-09-29 11:49:22 +02:00
jschoubben c68d3a7432 An assignment configures an endpoint as one thing
novox/hq ADR 0138, completing it. One block per endpoint instead of three keys
joined by a port number:

  {"endpoints": {"web":    {"port": 20009, "label": "cinema", "reach": "both"},
                 "stream": {"reach": "internal"}}}

Which machine port it lands on, the subdomain a proxy serves it under, and how far
it reaches are the three things an operator says when a module is assigned, and they
were said in ports, in the route's label and in reach — each keyed by the port. A
module with two endpoints of different shapes could only be configured by a reader
who knew which number was which.

Every field is optional; a block that says only a reach leaves the port to the mesh
and the label to the module, which is the ordinary case. A name the module does not
declare is refused, and the refusal lists what it does declare. A port or a reach
said both here and through the older key is refused rather than merged — two places
saying one thing is what this key exists to end, and merging would follow whichever
was read last.

Eight tests. The reach assertion deliberately narrows what the manifest says, because
a reach that agrees with the manifest proves nothing about whether the block was read
— which I found by writing the weaker version first and watching a revert not fail.
2026-09-29 11:25:40 +02:00
mesh-admin a5209bd849 Merge pull request 'A module names its endpoints, and a route names the one it serves' (#138) from feat/a-module-names-its-endpoints into main 2026-09-29 07:28:41 +00:00
6 changed files with 378 additions and 3 deletions
@@ -0,0 +1,47 @@
package catalogue
import (
"os"
"path/filepath"
"testing"
)
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
//
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
func TestEveryCatalogueManifestParses(t *testing.T) {
root := os.Getenv("MESH_CATALOGUE")
if root == "" {
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
}
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil || len(found) == 0 {
t.Fatalf("no manifests under %s: %v", root, err)
}
named, routed := 0, 0
for _, p := range found {
raw, err := os.ReadFile(p)
if err != nil {
t.Fatalf("%s: %v", p, err)
}
m, err := ParseManifest(raw)
if err != nil {
t.Errorf("%s: %v", filepath.Base(filepath.Dir(p)), err)
continue
}
for _, l := range m.Listens {
if l.Name != "" {
named++
}
}
for port := range RoutedPorts(m) {
_ = port
routed++
}
}
t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
if named == 0 {
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
}
}
+20 -3
View File
@@ -1098,7 +1098,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
if err != nil { if err != nil {
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
} }
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m)) blocks, err := Endpoints(m, settings[m.Module])
if err != nil {
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
}
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
out[to] = append(out[to], Contribution{From: m.Module, Values: values}) out[to] = append(out[to], Contribution{From: m.Module, Values: values})
} }
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
@@ -1116,7 +1120,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
if err != nil { if err != nil {
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err) return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
} }
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m)) blocks, err := Endpoints(m, settings[m.Module])
if err != nil {
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
}
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
out[to] = append(out[to], Contribution{From: m.Module, Values: values}) out[to] = append(out[to], Contribution{From: m.Module, Values: values})
} }
} }
@@ -1148,10 +1156,19 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
// no public domain composes nothing — there is nothing to join it to — which reads downstream as a // no public domain composes nothing — there is nothing to join it to — which reads downstream as a
// route that named no host, the same as it would have before this existed. // route that named no host, the same as it would have before this existed.
func composeName(values map[string]any, publicDomain, internalDomain string, reaches map[int]string, func composeName(values map[string]any, publicDomain, internalDomain string, reaches map[int]string,
ports map[string]int) { ports map[string]int, blocks map[string]Endpoint) {
if values == nil { if values == nil {
return return
} }
// **The subdomain an assignment gave this endpoint**, before the name is joined (novox/hq ADR
// 0138). The module contributes a label because it names its own parts; an assignment may say a
// different one, because where a thing lives under a domain is the operator's to choose and used
// to require editing the module to change.
if name, ok := values[RouteEndpoint].(string); ok {
if ep, said := blocks[strings.TrimSpace(name)]; said && ep.Label != "" {
values["label"] = ep.Label
}
}
// **How far the endpoint this route serves reaches decides which names exist** (novox/hq ADR // **How far the endpoint this route serves reaches decides which names exist** (novox/hq ADR
// 0138). Both were composed whenever the node had both domains, so every routed module got a // 0138). Both were composed whenever the node had both domains, so every routed module got a
// public name and an internal one whether anybody wanted them or not — and a certificate for // public name and an internal one whether anybody wanted them or not — and a certificate for
+6
View File
@@ -19,6 +19,12 @@ func aMediaServer() Manifest {
Contributes: map[string]map[string]any{ Contributes: map[string]map[string]any{
"route": {"label": "media", RouteEndpoint: "web"}, "route": {"label": "media", RouteEndpoint: "web"},
}, },
// Both endpoints are published by its container, which is what lets a machine port be given
// for either: the mesh moves a port the module publishes, never one it merely listens on.
Resources: []map[string]any{
{"id": "server", "type": "container", "name": "media",
"ports": []any{"80", "32400"}},
},
} }
} }
@@ -0,0 +1,140 @@
package catalogue
import (
"strings"
"testing"
)
func configured(block map[string]any) SettingsBy {
return SettingsBy{"media": {{From: "node anchor",
Values: map[string]any{EndpointsSetting: block}}}}
}
// **One block per endpoint, saying all three things.** The machine port, the subdomain and the reach
// were `ports`, the route's label and `reach`, each keyed by a port number, so configuring a module
// with two endpoints of different shapes meant knowing which number was which.
func TestAnEndpointsBlockSaysPortLabelAndReach(t *testing.T) {
m := aMediaServer()
// stream's reach NARROWS what the manifest says — the manifest has it from anywhere, the
// assignment says internal. Chosen deliberately: a reach that agrees with the manifest proves
// nothing about whether the block was read at all.
settings := configured(map[string]any{
"web": map[string]any{"port": 20009, "label": "cinema", "reach": ReachBoth},
"stream": map[string]any{"reach": ReachInternal},
})
// The machine port, where the mapping is read.
given, err := GivenPorts(m, settings["media"])
if err != nil {
t.Fatal(err)
}
if given[80] != 20009 {
t.Fatalf("the web endpoint is on machine port %d, want 20009: %v", given[80], given)
}
// The reach, where the filter reads it.
r := Resolution{Node: "anchor", Modules: []Manifest{m},
PublicDomain: "example.test", At: "anchor.internal"}
rules, err := r.Rules(Rendering{Settings: settings})
if err != nil {
t.Fatal(err)
}
for _, rule := range rules {
if rule.Port == 32400 && rule.From != FromMesh {
t.Fatalf("the directly-dialled endpoint is %q; the assignment narrowed it to the private "+
"network and the manifest's 'anywhere' should not win", rule.From)
}
if rule.Port == 80 && rule.From != FromMesh {
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached", rule.From)
}
}
// And the subdomain, where the name is composed — the assignment's, not the module's.
nodes, err := r.contributions(settings, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, c := range nodes["route"] {
if got, _ := c.Values["name"].(string); got != "cinema.example.test" {
t.Fatalf("the public name is %q, want the label the assignment gave", got)
}
if got, _ := c.Values["internal-name"].(string); got != "cinema.anchor.internal" {
t.Fatalf("the internal name is %q, want the label the assignment gave", got)
}
}
}
// A block that says only a reach leaves the port to the mesh and the label to the module, which is the
// ordinary case and must not require writing the other two.
func TestABlockMaySayOnlyAReach(t *testing.T) {
m := aMediaServer()
settings := configured(map[string]any{"web": map[string]any{"reach": ReachInternal}})
r := Resolution{Node: "anchor", Modules: []Manifest{m},
PublicDomain: "example.test", At: "anchor.internal"}
nodes, err := r.contributions(settings, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, c := range nodes["route"] {
if got, _ := c.Values["name"].(string); got != "" {
t.Fatalf("an internal endpoint composed the public name %q", got)
}
// The module's own label, untouched.
if got, _ := c.Values["internal-name"].(string); got != "media.anchor.internal" {
t.Fatalf("the internal name is %q, want the module's own label", got)
}
}
}
// An endpoint the module does not declare reaches nothing, and the refusal says what it does declare.
func TestConfiguringAnEndpointTheModuleLacksIsRefused(t *testing.T) {
_, err := Endpoints(aMediaServer(), configured(map[string]any{
"admin": map[string]any{"reach": ReachInternal}})["media"])
if err == nil || !strings.Contains(err.Error(), "does not declare") {
t.Fatalf("configuring an absent endpoint was accepted: %v", err)
}
if err != nil && !strings.Contains(err.Error(), "stream") {
t.Fatalf("the refusal does not name what the module declares: %v", err)
}
}
func TestAReachInABlockIsHeldToTheFourValues(t *testing.T) {
_, err := Endpoints(aMediaServer(), configured(map[string]any{
"web": map[string]any{"reach": "mesh"}})["media"])
if err == nil || !strings.Contains(err.Error(), "a reach is") {
t.Fatalf("a filter word was accepted as a reach: %v", err)
}
}
// **Two places giving one endpoint a machine port is the confusion this key exists to end.**
func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
m := aMediaServer()
_, err := GivenPorts(m, []Layer{{From: "node anchor", Values: map[string]any{
EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}},
PortsSetting: map[string]any{"80": 30000},
}}})
if err == nil || !strings.Contains(err.Error(), "published once") {
t.Fatalf("an endpoint given two machine ports was accepted: %v", err)
}
}
// And the same for its reach, said once here and once through the older key.
func TestAnEndpointWhoseReachIsAlsoExposedIsRefused(t *testing.T) {
_, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Values: map[string]any{
EndpointsSetting: map[string]any{"web": map[string]any{"reach": ReachInternal}},
ExposeSetting: map[string]any{"80": FromEverywhere},
}}})
if err == nil || !strings.Contains(err.Error(), "same thing in different words") {
t.Fatalf("a reach said two ways was accepted: %v", err)
}
}
// A module whose endpoints are unnamed cannot be configured this way, and is told so rather than
// having a block silently reach nothing — which is every module in the catalogue today.
func TestAModuleWithNoNamedEndpointsIsToldSo(t *testing.T) {
m := Manifest{Module: "media", Listens: []Listening{{Port: 80, From: FromMesh}}}
_, err := Endpoints(m, configured(map[string]any{"web": map[string]any{"reach": ReachBoth}})["media"])
if err == nil || !strings.Contains(err.Error(), "no endpoints by name") {
t.Fatalf("a module with no named endpoints accepted a block: %v", err)
}
}
+160
View File
@@ -536,6 +536,21 @@ const MeshWideLayer = "the mesh"
// two mappings share a number, it is an entry one of them writes over the other's, and the reader // two mappings share a number, it is an entry one of them writes over the other's, and the reader
// that finds the survivor disagrees with the reader that recomputes it. // that finds the survivor disagrees with the reader that recomputes it.
func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) { func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
// An endpoint's own block may put it on a machine port, which is the same thing `ports` says about
// the number rather than about the endpoint (novox/hq ADR 0138). Collected first and then let the
// older key be read, which refuses a port said twice.
byName, err := Endpoints(m, layers)
if err != nil {
return nil, err
}
named := map[int]int{}
for name, ep := range byName {
if ep.Port == 0 {
continue
}
named[endpointPorts(m)[name]] = ep.Port
}
// Every name a setting may use, and the mapping it names. // Every name a setting may use, and the mapping it names.
names := map[int][]publishing{} names := map[int][]publishing{}
for _, p := range publishedPorts(m) { for _, p := range publishedPorts(m) {
@@ -634,6 +649,17 @@ func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
out[key], by[key] = at, port out[key], by[key] = at, port
} }
} }
// And what the endpoints' own blocks put them on. Refused rather than merged where both keys name
// one endpoint: two places giving a port is the confusion this key exists to end.
for wanted, at := range named {
if was, twice := out[wanted]; twice && was != at {
return nil, fmt.Errorf(
"%s puts its port %d on %d through %s and on %d through %s — one endpoint, two "+
"machine ports, and it is published once. Keep the endpoint's own block",
m.Module, wanted, at, EndpointsSetting, was, PortsSetting)
}
out[wanted] = at
}
if len(out) == 0 { if len(out) == 0 {
return nil, nil return nil, nil
} }
@@ -821,6 +847,20 @@ func Reaches(m Manifest, layers []Layer) (map[int]string, error) {
} }
out := map[int]string{} out := map[int]string{}
// What an endpoint's own block says, which is the same statement in the shape that names the
// endpoint rather than its port (novox/hq ADR 0138). Read first so the older key, which says less,
// cannot quietly win over the newer one that says more.
blocks, err := Endpoints(m, layers)
if err != nil {
return nil, err
}
declared := endpointPorts(m)
for name, ep := range blocks {
if ep.Reach == "" {
continue
}
out[declared[name]] = ep.Reach
}
for _, layer := range layers { for _, layer := range layers {
raw, ok := layer.Values[ReachSetting] raw, ok := layer.Values[ReachSetting]
if !ok { if !ok {
@@ -896,3 +936,123 @@ func RouteProblems(m Manifest) []string {
} }
return problems return problems
} }
// EndpointsSetting is the settings key that configures a module's endpoints by name, per node
// (novox/hq ADR 0138):
//
// {"endpoints": {"web": {"port": 20009, "label": "media", "reach": "both"},
// "stream": {"reach": "public"}}}
//
// **One block per endpoint, instead of three keys joined by a number.** Which machine port it lands
// on, the subdomain a proxy serves it under, and how far it reaches are the three things an operator
// says when a module is assigned, and they were said in `ports`, in the route's label and in `reach`,
// each keyed by the port. A module with two endpoints of different shapes — a web surface behind the
// proxy and a protocol port clients dial directly — could only be configured by a reader who knew
// which number was which.
//
// Every field is optional. A block that says only a reach leaves the port to the mesh and the label to
// the module, which is the ordinary case.
const EndpointsSetting = "endpoints"
// Endpoint is what an assignment says about one of a module's endpoints.
type Endpoint struct {
// Port is the machine-side port it is published on. Zero means the mesh assigns one, which it
// does anyway — a fixed port is the module's claim and is honoured without being said here.
Port int
// Label is the subdomain a proxy serves it under, overriding the one the module contributes.
Label string
// Reach is how far it reaches: machine, internal, public or both.
Reach string
}
// Endpoints reads a module's per-node endpoint configuration, by endpoint name.
//
// It refuses a name the module does not declare — the setting would reach nothing — and a reach that
// is not one of the four. It also refuses an endpoint whose port or reach is said twice, once here and
// once through the older key: two places saying the same thing is what this key exists to end, and
// letting both stand would mean the mesh followed whichever it read last.
func Endpoints(m Manifest, layers []Layer) (map[string]Endpoint, error) {
declared := endpointPorts(m)
exposed, err := Exposure(m, layers)
if err != nil {
return nil, err
}
out := map[string]Endpoint{}
for _, layer := range layers {
raw, ok := layer.Values[EndpointsSetting]
if !ok {
continue
}
blocks, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { endpoint: { … } } map, and %q set it to something else",
m.Module, EndpointsSetting, layer.From)
}
for name, body := range blocks {
port, known := declared[name]
if !known {
return nil, fmt.Errorf(
"%s configures the endpoint %q, which it does not declare — the setting reaches "+
"nothing. It declares %s", m.Module, name, spokenEndpoints(m))
}
values, ok := body.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: the endpoint %q is configured with something that is not a "+
"block of settings", m.Module, name)
}
ep := out[name]
if reach, said := values["reach"]; said {
text, ok := reach.(string)
if !ok || !slices.Contains(reaches, text) {
return nil, fmt.Errorf("%s says the endpoint %q reaches %v; a reach is %s",
m.Module, name, reach, strings.Join(reaches, ", "))
}
if _, also := exposed[port]; also {
return nil, fmt.Errorf(
"%s says how far %q reaches and also exposes port %d. They say the same thing "+
"in different words; keep the endpoint's own block",
m.Module, name, port)
}
ep.Reach = text
}
if at, said := values["port"]; said {
machine, ok := asPort(at)
if !ok {
return nil, fmt.Errorf("%s puts the endpoint %q on %v, which is not a port",
m.Module, name, at)
}
ep.Port = machine
}
if label, said := values["label"]; said {
text, ok := label.(string)
if !ok || strings.TrimSpace(text) == "" {
return nil, fmt.Errorf("%s gives the endpoint %q a label that is not a name: %v",
m.Module, name, label)
}
ep.Label = strings.TrimSpace(text)
}
out[name] = ep
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// spokenEndpoints is what a module's endpoints are called, as a refusal lists them — so a reader who
// named one wrongly is one edit from right, and a module that has named none is told so.
func spokenEndpoints(m Manifest) string {
names := make([]string, 0, len(m.Listens))
for _, l := range m.Listens {
if name := strings.TrimSpace(l.Name); name != "" {
names = append(names, name)
}
}
if len(names) == 0 {
return "no endpoints by name"
}
sort.Strings(names)
return strings.Join(names, ", ")
}
+5
View File
@@ -192,6 +192,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
if key == ReachSetting && len(m.Listens) > 0 { if key == ReachSetting && len(m.Listens) > 0 {
continue continue
} }
// `endpoints` configures a module's endpoints by name — the machine port, the subdomain and
// the reach as one block each (novox/hq ADR 0138). Validated in Endpoints, so not stray.
if key == EndpointsSetting && len(m.Listens) > 0 {
continue
}
unused = append(unused, fmt.Sprintf( unused = append(unused, fmt.Sprintf(
"%s sets %q, and %s has no file or contribution to merge it into", "%s sets %q, and %s has no file or contribution to merge it into",
layer.From, key, m.Module)) layer.From, key, m.Module))