novox/hq 08-connectivity §3, built. The mirror of a database grant: there the consumer supplies a name and receives credentials; here it supplies a target and receives a name. Nothing new in the vocabulary — a route is a provision like any other. One field was missing and it is the one that matters for anything reaching back: a contribution now carries where the mesh says that machine is. A reverse proxy is told to send traffic to a consumer and has to open a connection, so without it every provider implementing a provision would have to know how the mesh names machines — a convention leaking into every module. The proxy itself is an example, not part of the control plane: the contract is the file, not this program. It replaces its table whole rather than merging, because the file is the whole truth about who has a route and merging would keep serving a name whose module was unassigned — the stale-route fault 08-connectivity lists as open, reintroduced one level down. A name it does not serve is refused by saying which it does: a route withdrawn and a name that never existed are different things.
144 lines
5.0 KiB
Go
144 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"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"] != "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"] != "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"] == "" {
|
|
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]string{"app.example": "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]string{
|
|
"going.example": "http://a.internal:80",
|
|
"staying.example": "http://b.internal:80",
|
|
})
|
|
held.set(map[string]string{"staying.example": "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]string{"app.example": "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")
|
|
}
|
|
}
|