Files
mesh-controller/internal/catalogue/routing_test.go
jschoubben f996a6707e a route composes its internal-network alias too, not only its public name
Every cutover done on novox tonight (drive, files, files-api, git,
keycloak, umami) dropped the <label>.<node>.internal alias HAL always
paired with the public hostname — found only when the operator tested it
by hand. Not a security boundary (a predecessor proxy served both as a
convenience, reaching a service over the VPN without a public TLS round
trip, not as access control), so restoring it is composing the same
convenience the same way the public name already is: <label> joined to
the node's own private address (r.At), independently of whether a public
domain exists to join the other half to.

composeName's signature changes (publicDomain, internalDomain) but its
shape does not — additive, label-gated, apex-aware, exactly mirroring the
public half it already did. A contribution the mesh writes both names
into is the entire fix; route-adapter and route-proxy pick up internal-
name whenever they're updated to serve it, not before, so this alone
changes nothing about what is live on any node yet.
2026-09-25 16:51:38 +02:00

235 lines
9.1 KiB
Go

package catalogue
import (
"strings"
"testing"
)
// novox/hq ADR 0066 — public routing is name-agnostic.
//
// A route contribution carries a label (the subdomain the operator chose); the node carries its
// public domain; the mesh composes <label>.<public-domain> and interprets neither half. The
// checks here are the ADR's own: the same catalogue resolves against two domains by changing one
// node setting and nothing else, and a legacy full-name contribution passes through untouched so
// the catalogue can migrate module by module.
// labelled is a module that asks to be published under a subdomain rather than a full hostname.
func labelled(module, label string, port int) Manifest {
return Manifest{Module: module, Version: "1",
Contributes: map[string]map[string]any{
"reverse-proxy": {"label": label, "port": port},
}}
}
// withDomain is a workstation that composes its routed names under one public domain.
func withDomain(domain string) Node {
n := workstation()
n.PublicDomain = domain
return n
}
func TestALabelComposesWithTheNodesPublicDomain(t *testing.T) {
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, withDomain("example.tld"), World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if len(given) != 1 {
t.Fatalf("the proxy was told about %d of 1: %v", len(given), given)
}
if given[0].Values["name"] != "git.example.tld" {
t.Fatalf("the label did not compose with the domain: %v", given[0].Values)
}
// The port the module gave is still there — composing the name must not drop what the proxy
// needs to reach the workload.
if given[0].Values["port"] != float64(8080) {
t.Fatalf("composing the name dropped the port: %v", given[0].Values)
}
}
func TestTheApexLabelComposesToTheBareDomain(t *testing.T) {
got, err := Resolve(shelf(proxy(), labelled("board", "@", 4000)),
[]string{"board"}, withDomain("example.tld"), World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if len(given) != 1 {
t.Fatalf("the proxy was told about %d of 1: %v", len(given), given)
}
// `@` is the zone-file apex: served at the bare public domain, no subdomain, no leading dot.
if given[0].Values["name"] != "example.tld" {
t.Fatalf("the apex label did not compose to the bare domain: %v", given[0].Values)
}
}
func TestOneNodeSettingMovesTheCatalogueBetweenDomains(t *testing.T) {
// The ADR's name-agnostic check: the same catalogue resolves against two different public
// domains by changing one node setting and nothing else.
one, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, withDomain("example.tld"), World{})
if err != nil {
t.Fatal(err)
}
two, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, withDomain("other.example"), World{})
if err != nil {
t.Fatal(err)
}
first := received(t, mustDeclare(t, one))[0].Values
second := received(t, mustDeclare(t, two))[0].Values
if first["name"] != "git.example.tld" || second["name"] != "git.other.example" {
t.Fatalf("the name did not track the domain: %v then %v", first["name"], second["name"])
}
// And nothing else moved: same label, same port. The domain is the one fact that changed.
if first["label"] != second["label"] || first["label"] != "git" {
t.Fatalf("the label changed with the domain: %v then %v", first["label"], second["label"])
}
if first["port"] != second["port"] {
t.Fatalf("the port changed with the domain: %v then %v", first["port"], second["port"])
}
}
func TestALegacyFullNameContributionPassesThroughUnchanged(t *testing.T) {
// Backward compatibility: a contribution that still carries a full `name` and no `label` is
// granted that name as-is, even on a node that has a public domain. This is what lets the
// catalogue migrate one module at a time while the running mesh keeps working.
legacy := Manifest{Module: "board", Version: "1",
Contributes: map[string]map[string]any{
"reverse-proxy": {"name": "git.pinned.example", "port": 8080},
}}
got, err := Resolve(shelf(proxy(), legacy), []string{"board"}, withDomain("example.tld"), World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if given[0].Values["name"] != "git.pinned.example" {
t.Fatalf("a full name was rewritten: %v", given[0].Values)
}
if _, grewLabel := given[0].Values["label"]; grewLabel {
t.Fatalf("a legacy contribution grew a label: %v", given[0].Values)
}
}
func TestALabelWithNoPublicDomainComposesNothing(t *testing.T) {
// A routed module on a node with no public domain has nothing to join its label to. It composes
// no name — which reads downstream exactly as a route that named no host, the same as before
// this existed — rather than an fabricated `git.` with a dangling dot.
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if _, named := given[0].Values["name"]; named {
t.Fatalf("a name was composed with no domain to compose it from: %v", given[0].Values)
}
if given[0].Values["label"] != "git" {
t.Fatalf("the label was lost: %v", given[0].Values)
}
}
// withPrivateAddress is a workstation on the private network, at the given internal name — the
// same fact a route's own consumers already receive as `${bound:...:at}`.
func withPrivateAddress(at string) Node {
n := workstation()
n.At = at
return n
}
func TestALabelComposesWithTheNodesPrivateAddressToo(t *testing.T) {
// A predecessor proxy answered a route on both a public and a private-network hostname for the
// same convenience the mesh restores here: reaching a service over the VPN without a public TLS
// round trip. Composed independently of the public name, from the node's own `At`.
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, withPrivateAddress("anchor.internal"), World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if given[0].Values["internal-name"] != "git.anchor.internal" {
t.Fatalf("the label did not compose with the private address: %v", given[0].Values)
}
}
func TestThePublicAndInternalNamesComposeIndependently(t *testing.T) {
// A node with both a public domain and a private address gets both names from one label; a
// node with only one of the two gets only the matching one — neither composition depends on
// the other being possible.
both := withPrivateAddress("anchor.internal")
both.PublicDomain = "example.tld"
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, both, World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if given[0].Values["name"] != "git.example.tld" {
t.Fatalf("the public name did not compose alongside the internal one: %v", given[0].Values)
}
if given[0].Values["internal-name"] != "git.anchor.internal" {
t.Fatalf("the internal name did not compose alongside the public one: %v", given[0].Values)
}
publicOnly, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
[]string{"board"}, withDomain("example.tld"), World{})
if err != nil {
t.Fatal(err)
}
givenPublicOnly := received(t, mustDeclare(t, publicOnly))
if _, has := givenPublicOnly[0].Values["internal-name"]; has {
t.Fatalf("an internal name was composed with no private address to compose it from: %v",
givenPublicOnly[0].Values)
}
}
func TestTheApexLabelComposesToTheBarePrivateAddress(t *testing.T) {
got, err := Resolve(shelf(proxy(), labelled("board", "@", 4000)),
[]string{"board"}, withPrivateAddress("anchor.internal"), World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if given[0].Values["internal-name"] != "anchor.internal" {
t.Fatalf("the apex label did not compose to the bare private address: %v", given[0].Values)
}
}
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
// novox/hq ADR 0066 propagate: a granted route name is published into internal resolution,
// mapped to the node that serves it, alongside the `<node>.internal` names — so every
// container, and an in-mesh ACME validator, resolves a routed name to the proxy that serves it.
// The names map is what withMeshNames writes into every container as `--add-host`; a route name
// mapped to the serving node's address rides the same mechanism.
got := containersOf(t, Resolution{Node: "anchor", Modules: []Manifest{{
Module: "app",
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
}}}, Rendering{Names: map[string]string{
"anchor.internal": "10.42.0.1",
"git.example.tld": "10.42.0.1",
}})
if len(got) != 1 {
t.Fatalf("expected one container, got %d", len(got))
}
given := namesOf(got[0])
var sawNode, sawRoute bool
for _, h := range given {
if h == "anchor.internal:10.42.0.1" {
sawNode = true
}
if h == "git.example.tld:10.42.0.1" {
sawRoute = true
}
}
if !sawNode {
t.Fatalf("the container lost the mesh's node names: %v", given)
}
if !sawRoute {
t.Fatalf("the routed name was not published to the serving node: %v", given)
}
}