Files
mesh-controller/examples/route-proxy/routes_test.go
T
jschoubben 01d57b629f A route says the largest body its proxy may carry, and both proxies honour it
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.
2026-09-23 23:19:12 +02:00

287 lines
11 KiB
Go

package main
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func write(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "routes.json")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// A route is a grant: the consumer supplies a target, and where that machine is comes from the
// mesh rather than from a naming convention the proxy has to know.
func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
routes, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[
{"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}}
]}`))
if err != nil {
t.Fatal(err)
}
// Lower-cased, because a Host header is not case-sensitive and a route that only answers the
// spelling in the manifest answers half the requests made to it.
if routes["app.example"].Target != "http://laptop.internal:8080" {
t.Fatalf("the route does not point at the consumer: %v", routes)
}
}
// A workload beside the proxy is ordinary, and reaching it over loopback is both correct and the
// only thing that works when there is no private network.
func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) {
routes, err := routesFrom(write(t, `{"given":[
{"from":"app","node":"anchor","values":{"name":"app.example","port":9000}}
]}`))
if err != nil {
t.Fatal(err)
}
if routes["app.example"].Target != "http://127.0.0.1:9000" {
t.Fatalf("a workload on this machine was not reachable: %v", routes)
}
}
// Skipped rather than served wrongly. A route with no port would proxy to :0.
func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) {
routes, err := routesFrom(write(t, `{"given":[
{"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}},
{"from":"b","node":"n","at":"n.internal","values":{"port":8080}},
{"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}}
]}`))
if err != nil {
t.Fatal(err)
}
if len(routes) != 1 || routes["fine.example"].Target == "" {
t.Fatalf("an unusable contribution was served: %v", routes)
}
}
// End to end through the proxy itself: a request for the name reaches the workload, and a name
// nobody asked for is refused in a way that says what IS served.
func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("the workload"))
}))
defer workload.Close()
target := strings.TrimPrefix(workload.URL, "http://")
host, port, _ := strings.Cut(target, ":")
held := newTable()
held.set(map[string]route{"app.example": {Target: "http://" + host + ":" + port}})
proxy := httptest.NewServer(handler(held))
defer proxy.Close()
asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil)
if err != nil {
t.Fatal(err)
}
asked.Host = "app.example"
answer, err := http.DefaultClient.Do(asked)
if err != nil {
t.Fatal(err)
}
defer answer.Body.Close()
if answer.StatusCode != http.StatusOK {
t.Fatalf("a request for a served name got %d", answer.StatusCode)
}
// And a name that is not served says which are — a route withdrawn and a name that never
// existed are different things, and a bare 404 makes an operator go and read the mesh.
other, _ := http.NewRequest(http.MethodGet, proxy.URL, nil)
other.Host = "nobody.example"
refused, err := http.DefaultClient.Do(other)
if err != nil {
t.Fatal(err)
}
defer refused.Body.Close()
if refused.StatusCode != http.StatusNotFound {
t.Fatalf("a name nobody asked for got %d", refused.StatusCode)
}
body := make([]byte, 256)
n, _ := refused.Body.Read(body)
if !strings.Contains(string(body[:n]), "app.example") {
t.Fatalf("the refusal does not say what is served: %s", body[:n])
}
}
// The file is the whole truth about who has a route, so the table replaces rather than merges.
//
// Merging would keep serving a name whose module was unassigned — the stale-route fault
// 08-connectivity lists as open, reintroduced one level down. A stale public name pointing at
// nothing fails more visibly than a stale grant, which is exactly why it must not survive.
func TestWithdrawingARouteStopsServingIt(t *testing.T) {
held := newTable()
held.set(map[string]route{
"going.example": {Target: "http://a.internal:80"},
"staying.example": {Target: "http://b.internal:80"},
})
held.set(map[string]route{"staying.example": {Target: "http://b.internal:80"}})
if _, still := held.find("going.example"); still {
t.Fatal("a route whose module was unassigned is still served")
}
if _, kept := held.find("staying.example"); !kept {
t.Fatal("withdrawing one route took another with it")
}
}
// A Host header carries a port and the name does not.
func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) {
held := newTable()
held.set(map[string]route{"app.example": {Target: "http://a.internal:8080"}})
if _, found := held.find("app.example:8080"); !found {
t.Fatal("a request to app.example:8080 did not find the route for app.example")
}
}
// Defends novox/hq 04-ISSUES/004: issuance targets staging unless something says otherwise.
//
// The failure this guards is not a broken proxy. It is a working one that quietly spends a
// production quota which does not replenish for a week, on exactly the work most likely to
// iterate.
func TestTheIssuerIsStagingUnlessNamed(t *testing.T) {
t.Setenv("ACME_DIRECTORY", "")
if got := issuer(); !strings.Contains(got, "staging") {
t.Fatalf("with nothing set the issuer is %q, and a default that spends production quota "+
"is a default nobody chose", got)
}
t.Setenv("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory")
if got := issuer(); strings.Contains(got, "staging") {
t.Fatalf("an issuer was named explicitly and %q was used instead", got)
}
}
// A certificate is only ever asked for on a name the mesh routes here.
//
// **Without this, anything that can reach the port spends the quota.** A scan sending arbitrary
// names, or one misconfigured client, becomes a stream of failed orders against the account's
// rate limit — and the proxy would look healthy throughout.
func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
held := newTable()
held.set(map[string]route{"photos.example": {Target: "http://127.0.0.1:8080"}})
policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "photos.example"); err != nil {
t.Errorf("a name the mesh routes here was refused a certificate: %v", err)
}
for _, name := range []string{"unknown.example", "", "photos.example.evil"} {
if err := policy(context.Background(), name); err == nil {
t.Errorf("a certificate would be ordered for %q, which the mesh never mentioned", name)
}
}
}
// A route withdrawn stops being certifiable, without the proxy restarting.
func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
held := newTable()
held.set(map[string]route{"photos.example": {Target: "http://127.0.0.1:8080"}})
policy := onlyWhatTheMeshSaid(held)
if err := policy(context.Background(), "photos.example"); err != nil {
t.Fatal(err)
}
held.set(nil)
if err := policy(context.Background(), "photos.example"); err == nil {
t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " +
"once rather than what is served now")
}
}
// The registry's hand-over (novox/hq ADR 0082, ADR 0104): a registry takes image layers in single
// requests of gigabytes, and its public name was served by the predecessor with a twenty-gigabyte
// body limit. The contribution now says so, and this proxy reads it as written — and a limit it
// cannot read is a route it does not serve, like a port that is not one.
func TestABodyLimitIsReadFromTheContributionOrTheRouteIsSkipped(t *testing.T) {
routes, err := routesFrom(write(t, `{"given":[
{"from":"gate","node":"anchor","values":{"name":"registry-api.example","port":5001,"max-request-body":21474836480}},
{"from":"app","node":"anchor","values":{"name":"app.example","port":8080}},
{"from":"odd","node":"anchor","values":{"name":"odd.example","port":8081,"max-request-body":"20g"}},
{"from":"none","node":"anchor","values":{"name":"none.example","port":8082,"max-request-body":0}}
]}`))
if err != nil {
t.Fatal(err)
}
if got := routes["registry-api.example"].MaxRequestBody; got != 21474836480 {
t.Errorf("the limit did not arrive as written: %d", got)
}
if got := routes["app.example"].MaxRequestBody; got != 0 {
t.Errorf("a route that asked for no limit was given one: %d", got)
}
for _, skipped := range []string{"odd.example", "none.example"} {
if _, served := routes[skipped]; served {
t.Errorf("%s asked for a limit that is not a number of bytes and was served anyway", skipped)
}
}
}
// A body past the route's limit is refused as too large — whether its length is declared up front
// or only discovered while it is read — and a body within it reaches the workload whole. Refused
// as 413, not 502: a push that is too large must be told so, not told the registry is down.
func TestABodyPastTheRoutesLimitIsRefusedAsTooLarge(t *testing.T) {
var received int64
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n, _ := io.Copy(io.Discard, r.Body)
received = n
w.WriteHeader(http.StatusCreated)
}))
defer workload.Close()
held := newTable()
held.set(map[string]route{
"limited.example": {Target: workload.URL, MaxRequestBody: 1024},
"unlimited.example": {Target: workload.URL},
})
proxy := httptest.NewServer(handler(held))
defer proxy.Close()
push := func(host string, body []byte, declared bool) int {
t.Helper()
var reader io.Reader = bytes.NewReader(body)
if !declared {
// A reader that is not a bytes.Reader carries no length: the request goes out chunked
// and the proxy learns the size only by reading it.
reader = io.MultiReader(bytes.NewReader(body))
}
asked, err := http.NewRequest(http.MethodPut, proxy.URL+"/v2/blob", reader)
if err != nil {
t.Fatal(err)
}
asked.Host = host
answer, err := http.DefaultClient.Do(asked)
if err != nil {
t.Fatal(err)
}
defer answer.Body.Close()
_, _ = io.Copy(io.Discard, answer.Body)
return answer.StatusCode
}
small, large := bytes.Repeat([]byte("x"), 1000), bytes.Repeat([]byte("y"), 4096)
if got := push("limited.example", small, true); got != http.StatusCreated || received != 1000 {
t.Fatalf("a body within the limit got %d and %d bytes arrived", got, received)
}
if got := push("limited.example", large, true); got != http.StatusRequestEntityTooLarge {
t.Fatalf("a declared body past the limit got %d, not 413", got)
}
if got := push("limited.example", large, false); got != http.StatusRequestEntityTooLarge {
t.Fatalf("an undeclared body past the limit got %d, not 413", got)
}
// And a route that asked for no limit carries whatever it is given.
if got := push("unlimited.example", large, true); got != http.StatusCreated || received != 4096 {
t.Fatalf("a route with no limit refused or truncated a body: %d, %d bytes", got, received)
}
}