diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index d071434..dd66ac9 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -1098,7 +1098,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, 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, 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}) } // 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 { 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}) } } @@ -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 // 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, - ports map[string]int) { + ports map[string]int, blocks map[string]Endpoint) { if values == nil { 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 // 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 diff --git a/internal/catalogue/endpoint_test.go b/internal/catalogue/endpoint_test.go index de37ee0..38352a1 100644 --- a/internal/catalogue/endpoint_test.go +++ b/internal/catalogue/endpoint_test.go @@ -19,6 +19,12 @@ func aMediaServer() Manifest { Contributes: map[string]map[string]any{ "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"}}, + }, } } diff --git a/internal/catalogue/endpoints_setting_test.go b/internal/catalogue/endpoints_setting_test.go new file mode 100644 index 0000000..47d48d5 --- /dev/null +++ b/internal/catalogue/endpoints_setting_test.go @@ -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) + } +} diff --git a/internal/catalogue/filtering.go b/internal/catalogue/filtering.go index 94b77e7..2792a39 100644 --- a/internal/catalogue/filtering.go +++ b/internal/catalogue/filtering.go @@ -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 // that finds the survivor disagrees with the reader that recomputes it. 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. names := map[int][]publishing{} 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 } } + // 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 { return nil, nil } @@ -821,6 +847,20 @@ func Reaches(m Manifest, layers []Layer) (map[int]string, error) { } 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 { raw, ok := layer.Values[ReachSetting] if !ok { @@ -896,3 +936,123 @@ func RouteProblems(m Manifest) []string { } 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, ", ") +} diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 0a12ee0..3dc8d68 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -192,6 +192,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string { if key == ReachSetting && len(m.Listens) > 0 { 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( "%s sets %q, and %s has no file or contribution to merge it into", layer.From, key, m.Module))