Merge pull request 'An assignment configures an endpoint as one thing' (#139) from feat/an-assignment-configures-an-endpoint into main
This commit was merged in pull request #139.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user