The registry's public name is served by the predecessor with a twenty-gigabyte buffering middleware, because a registry takes image layers in single requests of gigabytes and a proxy's default turns every push into a 413 the registry never sees. A route contribution had no way to say so, so the mesh could not take the name over without losing what made it usable. The contribution now carries `max-request-body`, a whole positive number of bytes, and the catalogue holds every route to an agreed vocabulary — label or name, port, and the limit — refusing a key no proxy reads (a field that parses cleanly and does nothing is a promise nobody keeps) and a route with no port (unreachable by the proxy it just asked for, found at parse time rather than in a proxy's log). The mesh's own proxy reads the limit as written, refuses a body past it as 413 rather than the 502 the transport would have reported, and skips a route whose limit it cannot read rather than carrying what the module said not to. The registry's hand-over itself is read from the catalogue beside this checkout: the store still resolves with no proxy, the gate beside it pulls the store in, contributes the predecessor's name on the port the node gave it, and locks only the door that faces the world. hq ADR 0082/0104, the registry hand-over.
163 lines
6.1 KiB
Go
163 lines
6.1 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// What a module may say when it contributes a route.
|
|
//
|
|
// **The contract is the file, and this is its vocabulary** (novox/hq ADR 0007, 08-connectivity §3).
|
|
// A module reachable by name requires `route` and contributes what the proxy has to know; the
|
|
// mesh's own proxy (`examples/route-proxy`) and the adapter to a predecessor's (the catalogue's
|
|
// `route-adapter`, ADR 0104) both read the same contribution, which is what lets one stand in for
|
|
// the other without a module that publishes through them noticing. So the keys are agreed here,
|
|
// once, and a manifest is refused for a key no proxy reads: a field that parses cleanly and does
|
|
// nothing is a promise nobody keeps, and the module goes on believing its route is limited when it
|
|
// is not.
|
|
//
|
|
// The control plane still does not know what a reverse proxy *is* — it validates the shape and
|
|
// carries the values, and turning them into a router, a middleware or a body limit is the
|
|
// provider's. What is checked is exactly what every provider needs to be true: a name to serve, a
|
|
// port to send to, and a limit that is a number of bytes.
|
|
|
|
// RouteProvision is the provision a module reachable by name requires.
|
|
const RouteProvision = "route"
|
|
|
|
// RouteLabel is the subdomain a module asks to be published under; the node's public domain is
|
|
// joined to it (ADR 0066, composeName).
|
|
const RouteLabel = "label"
|
|
|
|
// RouteName is the legacy full name, left untouched when a module still writes one.
|
|
const RouteName = "name"
|
|
|
|
// RoutePort is the port the contributing workload's software uses. The mesh redirects it to
|
|
// wherever the machine published it (ADR 0038, atMachinePort) before the proxy sees it.
|
|
const RoutePort = "port"
|
|
|
|
// RouteMaxRequestBody is the largest request body, in bytes, the proxy may accept for this route.
|
|
//
|
|
// **The registry's hand-over is why it exists** (novox/hq ADR 0082, the registry hand-over). A
|
|
// registry takes image layers in single requests of gigabytes, and a proxy's default limit — a
|
|
// megabyte, in some — turns every push into a 413 that the registry never sees. The predecessor
|
|
// served the registry's public name with exactly this limit as a middleware; a route that could not
|
|
// say it would have a public name it could not be pushed to. Absent, the proxy applies whatever it
|
|
// does by default, which for the mesh's own is no limit at all.
|
|
const RouteMaxRequestBody = "max-request-body"
|
|
|
|
// routeKeys is every key a route contribution may carry, in the order a refusal names them.
|
|
var routeKeys = []string{RouteLabel, RouteName, RoutePort, RouteMaxRequestBody}
|
|
|
|
// routeProblems is everything wrong with one module's route contribution, empty when nothing is.
|
|
//
|
|
// Read from the manifest as written: a per-node setting laid over it (settle) is a fact about one
|
|
// machine and is checked where settings are; this is the module's own promise.
|
|
func routeProblems(module string, values map[string]any) []string {
|
|
var problems []string
|
|
known := map[string]bool{}
|
|
for _, k := range routeKeys {
|
|
known[k] = true
|
|
}
|
|
var unknown []string
|
|
for k := range values {
|
|
if !known[k] {
|
|
unknown = append(unknown, k)
|
|
}
|
|
}
|
|
sort.Strings(unknown)
|
|
if len(unknown) > 0 {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s contributes %s to %q, which no proxy reads — a route carries %s",
|
|
module, quoted(unknown), RouteProvision, strings.Join(routeKeys, ", ")))
|
|
}
|
|
|
|
label, hasLabel := values[RouteLabel]
|
|
name, hasName := values[RouteName]
|
|
if !hasLabel && !hasName {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s contributes a route and names nothing — a %q is the subdomain the node's public "+
|
|
"domain is joined to", module, RouteLabel))
|
|
}
|
|
if hasLabel && !aText(label) {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s contributes a route whose %q is %v, and a label is a non-empty string",
|
|
module, RouteLabel, label))
|
|
}
|
|
if hasName && !aText(name) {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s contributes a route whose %q is %v, and a name is a non-empty string",
|
|
module, RouteName, name))
|
|
}
|
|
|
|
port, hasPort := values[RoutePort]
|
|
if !hasPort {
|
|
// Refused here rather than skipped by the proxy: a module that asked for a route and not
|
|
// for the port is unreachable by the proxy it just asked for (08-connectivity §3), and the
|
|
// proxy saying so in a log is the fault found at the wrong end.
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s contributes a route and no %q — the proxy has nowhere to send it", module, RoutePort))
|
|
} else if n, ok := asPort(port); !ok || float64(n) != asNumber(port) || n < 1 || n > 65535 {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s contributes a route on port %v, which is not a port", module, port))
|
|
}
|
|
|
|
if limit, said := values[RouteMaxRequestBody]; said {
|
|
if _, ok := RouteBodyLimit(limit); !ok {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s contributes a route whose %q is %v, and a limit is a whole number of bytes, "+
|
|
"at least one", module, RouteMaxRequestBody, limit))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// RouteBodyLimit reads a contribution's request-body limit: a whole, positive number of bytes.
|
|
//
|
|
// Shared with nothing that runs on a machine — the proxies read the file with their own parsers —
|
|
// but the rule they apply is this one, and a test holds each of them to it.
|
|
func RouteBodyLimit(v any) (int64, bool) {
|
|
var n float64
|
|
switch x := v.(type) {
|
|
case float64:
|
|
n = x
|
|
case int:
|
|
n = float64(x)
|
|
case int64:
|
|
n = float64(x)
|
|
default:
|
|
return 0, false
|
|
}
|
|
if n < 1 || n != math.Trunc(n) || n > math.MaxInt64 {
|
|
return 0, false
|
|
}
|
|
return int64(n), true
|
|
}
|
|
|
|
// aText is a non-empty string.
|
|
func aText(v any) bool {
|
|
s, ok := v.(string)
|
|
return ok && strings.TrimSpace(s) != ""
|
|
}
|
|
|
|
// asNumber is a number's value whatever JSON or a test made of it, NaN otherwise.
|
|
func asNumber(v any) float64 {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n
|
|
case int:
|
|
return float64(n)
|
|
}
|
|
return math.NaN()
|
|
}
|
|
|
|
// quoted is a list as a refusal names it.
|
|
func quoted(keys []string) string {
|
|
out := make([]string, len(keys))
|
|
for i, k := range keys {
|
|
out[i] = fmt.Sprintf("%q", k)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|