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:
@@ -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;
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user