catalogue: compose a route's name from a label and its node's domain, and resolve it in-mesh

A public route used to carry its whole hostname as a literal in the module
manifest, so running the same catalogue against a different domain meant
overriding that literal on every routed module, per node. The mesh was, in
effect, holding a map of names to services: the one thing it should never hold,
because the subdomain is the operator's choice and the domain is the node's.

Compose instead. A route contribution carries a `label` (the subdomain); a node
carries its `public_domain` as node-level configuration; the mesh joins
`<label>.<public-domain>` and grants exactly that, interpreting neither half.
Held as a node property beside the node's other node-level facts (endpoint,
site, overlay address), not in a module's settings — the ADR calls it
node-level, and the settings table is keyed per module.

Additive, so an unmigrated catalogue keeps working: a contribution that still
carries a full `name` and no `label` passes through unchanged, and the catalogue
can migrate module by module. A labelled contribution on a node with no public
domain composes nothing, reading downstream as a route that named no host.

And propagate: each granted route name is published into internal resolution
mesh-wide, mapped to the node that serves it, alongside the `<node>.internal`
names every container already gets. So a container — and an internal ACME
validator, which cannot complete a challenge for a name it cannot reach —
resolves a routed name to the proxy that serves it. Name-agnostic throughout:
the mesh propagates whatever names it was told to serve and knows nothing about
what they mean.

novox/hq 02-DECISIONS/0056

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-09 23:27:35 +02:00
parent c147a26138
commit 232862315c
8 changed files with 448 additions and 5 deletions
+35 -2
View File
@@ -22,7 +22,7 @@ import (
func nodeCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("node add <name>, node list, or node show <name>")
return errors.New("node add <name>, node list, node show <name>, or node public-domain <name> [domain]")
}
open, err := openStores(ctx)
if err != nil {
@@ -65,8 +65,30 @@ func nodeCommand(ctx context.Context, args []string) error {
}
return nil
case "public-domain":
// The domain this node composes its routed names under (novox/hq ADR 0056). Given a domain,
// it is set; given nothing, it is cleared — a node that stops facing the outside composes no
// names. Lab-versus-production is this one setting and nothing else (see the ADR).
if len(args) < 2 || len(args) > 3 {
return errors.New(
"node public-domain <name> [domain] — a domain sets it, nothing clears it")
}
domain := ""
if len(args) == 3 {
domain = args[2]
}
if err := inv.SetPublicDomain(ctx, args[1], domain); err != nil {
return err
}
if domain == "" {
fmt.Printf("%s has no public domain, so it composes no routed names\n", args[1])
} else {
fmt.Printf("%s composes its routed names under %s\n", args[1], domain)
}
return nil
default:
return fmt.Errorf("node has no %q; it has add and list", args[0])
return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0])
}
}
@@ -261,6 +283,17 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
fmt.Printf("%s\n", node.Name)
fmt.Printf(" last heard from %s\n", heardFrom(node))
// The domain its routed names are composed under, when it has one (novox/hq ADR 0056). Shown
// only when set: a machine that serves nothing to the outside has no domain, and saying so of
// every internal node would be noise.
domain, err := inv.PublicDomainOf(ctx, name)
if err != nil {
return err
}
if domain != "" {
fmt.Printf(" public domain %s\n", domain)
}
held, err := inv.Profile(ctx, name)
if err != nil {
return err
+96 -1
View File
@@ -69,9 +69,18 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
return catalogue.Resolution{}, nil, err
}
// The domain this node composes its routed names under (novox/hq ADR 0056). A route
// contribution carries only a label; the resolver joins <label>.<public-domain> for this node,
// so the fact travels on the node it belongs to rather than being looked up where the name is
// composed.
publicDomain, err := inv.PublicDomainOf(ctx, nodeName)
if err != nil {
return catalogue.Resolution{}, nil, err
}
resolved, err := catalogue.Resolve(shelf, assigned,
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
At: onNetwork[nodeName]}, world)
At: onNetwork[nodeName], PublicDomain: publicDomain}, world)
if err != nil {
return catalogue.Resolution{}, nil, err
}
@@ -426,11 +435,97 @@ func declarationWith(ctx context.Context, open *stores, node string,
return nil, err
}
// And every routed name → the node that serves it (novox/hq ADR 0056). Alongside the
// `<node>.internal` names above, so a container — or an internal ACME validator — resolves a
// routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told
// to serve and knows nothing about what they mean.
routes, err := routeNamesInTheMesh(ctx, open)
if err != nil {
return nil, err
}
for name, at := range routes {
names[name] = at
}
return plan.Declaration(catalogue.Rendering{
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
Certificate: certificate, Authority: authority, Mesh: private, Names: names})
}
// routeNamesInTheMesh is every routed name and the address of the node that serves it (novox/hq
// ADR 0056).
//
// **Mesh-wide, so any container resolves any routed name to its proxy** — including an internal
// ACME validator, which cannot complete a challenge for a name it cannot reach. A routed name is
// composed on the consumer's node (from its label and that node's public domain) and served by the
// node answering the consumer's route requirement; this gathers both.
//
// It reads route names off resolutions rather than a table because there is no table: a route is a
// contribution, computed from what each node runs. Name-agnostic — a contribution counts as a
// routed name only because it carried a label the mesh composed, never because the mesh knows what
// "route" means. A node that does not resolve is skipped, so one machine's broken set does not cost
// the rest their names.
func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string, error) {
inv := open.inventory
places, err := inv.Overlays(ctx)
if err != nil {
return nil, err
}
address := map[string]string{}
for _, p := range places {
if strings.TrimSpace(p.Address) != "" {
address[p.Name] = p.Address
}
}
nodes, err := inv.Nodes(ctx)
if err != nil {
return nil, err
}
out := map[string]string{}
for _, n := range nodes {
plan, settings, err := planFor(ctx, open, n.Name)
if err != nil {
continue
}
for _, m := range plan.Modules {
for to := range m.Contributes {
values, asks, err := plan.ContributionsFrom(to, m.Module, settings)
if err != nil {
return nil, err
}
if !asks {
continue
}
// A routed name, and only that: a contribution the mesh composed a name for from a
// label it was given. A grant that happens to carry a `name` of its own — a database
// name — carries no label and is left alone.
if _, labelled := values["label"]; !labelled {
continue
}
name, _ := values["name"].(string)
if name == "" {
continue
}
// The node that serves it: whoever answers this consumer's route requirement, or
// this same node when the proxy is beside the consumer.
serving := n.Name
for _, need := range plan.Needs {
if need.Name == to && need.For == m.Module {
serving = need.From
break
}
}
if at := address[serving]; at != "" {
out[strings.ToLower(name)] = at
}
}
}
}
return out, nil
}
// certificateFor is what the mesh certifies about one machine's internal name.
//
// It reaches across two contexts and reads neither one's store from the other: `inventory` knows
+33
View File
@@ -560,12 +560,45 @@ 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)
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
}
}
return out, nil
}
// composeName joins a contribution's label with a node's public domain, in place (novox/hq ADR
// 0056).
//
// **The whole of what the mesh does with a route's name: join two given strings.** A contribution
// carries a `label` — the subdomain its operator chose — and the node carries its public domain;
// the granted name is `<label>.<public-domain>` and the mesh interprets neither half. It runs on
// any contribution carrying a label, not only a route's, because the mesh does not know what a
// provision means — a name it can compose from parts it was given is the point, whatever the
// provision is called.
//
// **Additive, so an unmigrated catalogue still works.** A contribution that already carries a full
// `name` and no `label` is left exactly as it is: the catalogue can migrate module by module while
// the running mesh keeps serving the full names it has. And a labelled contribution on a node with
// 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 string) {
if values == nil || publicDomain == "" {
return
}
if _, already := values["name"]; already {
// A full name was given rather than a label. Left as-is: this is the legacy shape, and the
// point of the label is to not have to write the full name — a contribution that wrote both
// has said what it wants and the mesh does not second-guess it.
return
}
label, ok := values["label"].(string)
if !ok || strings.TrimSpace(label) == "" {
return
}
values["name"] = strings.TrimSpace(label) + "." + publicDomain
}
// receivedFile is the file a provider is given its consumers' contributions in.
func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) {
if given == nil {
+10 -2
View File
@@ -24,6 +24,10 @@ type Node struct {
// At is this machine's own name on the private network, empty if it is not on one. Needed to
// tell whether it can reach the node answering its requirements at all.
At string
// PublicDomain is the domain this node composes its routed names under, empty if it has none
// (novox/hq ADR 0056). A route contribution carries only a label — the subdomain — and the mesh
// joins <label>.<public-domain> to make the name it grants, interpreting neither half.
PublicDomain string
}
// World is what the rest of the mesh already has.
@@ -102,6 +106,10 @@ type Resolution struct {
// because a consumer bound to something answered on this same machine still has to be told
// where it is — the answer being local does not make the port guessable.
At string
// PublicDomain is the domain this node composes its routed names under, empty if it has none
// (novox/hq ADR 0056). Carried from the node so that composing <label>.<public-domain> for a
// route contribution needs no store lookup here — the join is a fact about this one machine.
PublicDomain string
// Modules in the order they were resolved: assigned first, then what they pulled in.
Modules []Manifest
@@ -504,8 +512,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
}
}
resolution := Resolution{Node: node.Name, At: node.At, Because: because, Needs: needs,
Unhostable: unhostable}
resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain,
Because: because, Needs: needs, Unhostable: unhostable}
for _, n := range providersFirst(order, catalogue) {
resolution.Modules = append(resolution.Modules, catalogue[n])
}
+152
View File
@@ -0,0 +1,152 @@
package catalogue
import (
"strings"
"testing"
)
// novox/hq ADR 0056 — 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 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)
}
}
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
// novox/hq ADR 0056 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)
}
}
@@ -0,0 +1,16 @@
-- The public domain a node's routed names are composed under.
--
-- novox/hq ADR 0056. A public route used to carry its whole hostname in the module manifest, so
-- running the same catalogue against a different domain — a lab standing in for production, a
-- second operator's mesh — meant overriding that literal on every routed module. That made the
-- mesh hold a map of names to services: the one thing it must not, because the subdomain is the
-- operator's choice and the domain is the node's.
--
-- So the domain becomes a fact about the node, held here beside the node's other node-level
-- configuration (its endpoint, its site, its overlay address). A module contributes only the label
-- (the subdomain); the mesh composes <label>.<public-domain> and never interprets what it means.
--
-- Null for a node with no public domain, which is the ordinary case: most machines serve nothing
-- to the outside. A routed module on such a node composes no name and the proxy simply has nothing
-- to serve for it — additive, so an unmigrated catalogue carrying full hostnames is untouched.
alter table node add column public_domain text;
+34
View File
@@ -350,6 +350,40 @@ func (i *Inventory) SealingKeyOf(ctx context.Context, name string) (string, erro
return *key, nil
}
// SetPublicDomain records the domain a node's routed names are composed under.
//
// A node-level fact (novox/hq ADR 0056), kept beside the node's other node-level configuration
// rather than in a module's settings: the subdomain is a module's to choose and the domain is the
// node's, and the mesh joins the two without interpreting either. An empty domain clears it — a
// node that stops facing the outside composes no names — which is why this writes null rather than
// refusing.
func (i *Inventory) SetPublicDomain(ctx context.Context, name, domain string) error {
node, err := i.NodeByName(ctx, name)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`update node set public_domain = nullif($2, '') where id = $1`, node.ID, strings.TrimSpace(domain))
return err
}
// PublicDomainOf is the domain a node composes its routed names under, empty if it has none.
func (i *Inventory) PublicDomainOf(ctx context.Context, name string) (string, error) {
var domain *string
err := i.store.Pool().QueryRow(ctx,
`select public_domain from node where name = $1`, name).Scan(&domain)
if errors.Is(err, pgx.ErrNoRows) {
return "", fmt.Errorf("%w: %s", ErrNoSuchNode, name)
}
if err != nil {
return "", err
}
if domain == nil {
return "", nil
}
return *domain, nil
}
// RecordOverlayKey keeps the public half a node generated.
func (i *Inventory) RecordOverlayKey(ctx context.Context, node, key string) error {
if strings.TrimSpace(key) == "" {
+72
View File
@@ -0,0 +1,72 @@
package inventory
import (
"testing"
)
// novox/hq ADR 0056 — a node's public domain is a node-level fact, held here beside its other
// node-level configuration rather than in a module's settings. These check it round-trips, that a
// node with none reports empty rather than failing, and that clearing it works — the setting the
// ADR names as the whole of moving a catalogue between a lab and production.
func TestAPublicDomainRoundTrips(t *testing.T) {
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "anchor"); err != nil {
t.Fatal(err)
}
if err := inv.SetPublicDomain(t.Context(), "anchor", "example.tld"); err != nil {
t.Fatal(err)
}
domain, err := inv.PublicDomainOf(t.Context(), "anchor")
if err != nil {
t.Fatal(err)
}
if domain != "example.tld" {
t.Fatalf("set example.tld and read %q", domain)
}
}
func TestANodeWithNoPublicDomainReportsEmpty(t *testing.T) {
// Empty, not an error: most machines serve nothing to the outside, and a routed module on such
// a node simply composes no name.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "workstation"); err != nil {
t.Fatal(err)
}
domain, err := inv.PublicDomainOf(t.Context(), "workstation")
if err != nil {
t.Fatal(err)
}
if domain != "" {
t.Fatalf("a node that was never given a domain reported %q", domain)
}
}
func TestAPublicDomainCanBeCleared(t *testing.T) {
// A node that stops facing the outside composes no names. Clearing with an empty value is how
// that is said, and it must actually clear rather than store the empty string.
inv := fresh(t)
if _, err := inv.AddNode(t.Context(), "anchor"); err != nil {
t.Fatal(err)
}
if err := inv.SetPublicDomain(t.Context(), "anchor", "example.tld"); err != nil {
t.Fatal(err)
}
if err := inv.SetPublicDomain(t.Context(), "anchor", ""); err != nil {
t.Fatal(err)
}
domain, err := inv.PublicDomainOf(t.Context(), "anchor")
if err != nil {
t.Fatal(err)
}
if domain != "" {
t.Fatalf("cleared the domain and read %q", domain)
}
}
func TestPublicDomainOfAnUnknownNodeFails(t *testing.T) {
inv := fresh(t)
if _, err := inv.PublicDomainOf(t.Context(), "never-seen"); err == nil {
t.Fatal("reading the domain of a node that does not exist was not refused")
}
}