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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user