A roster fact used to be a name from a closed list, each formatted in Go here — node-names as a hosts file, node-zones as a resolver's zones. Every new consumer (ssh's known_hosts, an authorized_keys) meant another formatter in the control plane, in the consumer's own configuration language. Now a fact is a path and a Go template over the roster view (this node, the suffix, and every served name vs the machines). The mesh owns the data; the module owns the format. /etc/hosts is a template on the network module; dnsmasq's zones move to dnsmasq. The controller renders and reads neither. WireGuard stays a computed generator: the overlay is the substrate delivery rides on, and its config is topology, not a roster projection. Output is byte-for-byte unchanged, pinned by the hosts golden tests and the resolver tests that compose the real dnsmasq manifest.
166 lines
6.6 KiB
Go
166 lines
6.6 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"text/template"
|
|
)
|
|
|
|
// What only the mesh knows, written where a module asks for it — in the module's own format.
|
|
//
|
|
// **The graph is the control plane's; the format is the module's.** The mesh knows which machines
|
|
// exist, what they are called and where they are. Turning that into a hosts file, a resolver's
|
|
// zones, an ssh known_hosts is somebody's configuration language, and the mesh has no business
|
|
// knowing it. So the mesh hands the roster to a template the module wrote and renders it; it never
|
|
// learns what the file means.
|
|
//
|
|
// This used to be a closed list of fact names, each with its format written in Go here — a hosts
|
|
// file, a resolver's zones. Every new consumer meant a new formatter in the control plane, in the
|
|
// consumer's configuration language. Now the data is the mesh's and the format is a template the
|
|
// module ships: the two built-in cases (the network module's `/etc/hosts`, dnsmasq's zones) render
|
|
// the same way any module's would, and the control plane holds no format at all.
|
|
//
|
|
// It replaced three modules that existed only because computed output needed somewhere to live —
|
|
// they ran no software, could not be swapped for anything, and appeared in the graph as modules
|
|
// while being a data channel wearing a costume (novox/hq ADR 0040).
|
|
|
|
// A RosterFile is a file the mesh renders from the roster of machines, in the format the module
|
|
// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name),
|
|
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names`, every
|
|
// name the mesh serves, and `.Machines`, only the nodes of the mesh. Which set a template ranges is
|
|
// how the hq issue 111 distinction is drawn: a container's hosts wants every name; a resolver told
|
|
// the suffix is its own wants only the machines.
|
|
type RosterFile struct {
|
|
// Path is where on the machine the rendered file goes. Absolute, or it is refused here rather
|
|
// than discovered as a daemon that reads nothing.
|
|
Path string `json:"path"`
|
|
// Template is the module's format, a Go text/template over the rosterView. It is the module's,
|
|
// not the mesh's: the mesh renders it and does not read it.
|
|
Template string `json:"template"`
|
|
}
|
|
|
|
// rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field
|
|
// the mesh does not compute fails to render here, not on a machine.
|
|
type rosterView struct {
|
|
Node string
|
|
Suffix string
|
|
Names []rosterEntry
|
|
Machines []rosterEntry
|
|
}
|
|
|
|
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address.
|
|
type rosterEntry struct {
|
|
Name string
|
|
FQDN string
|
|
Address string
|
|
}
|
|
|
|
// FactsInto renders the roster files a module asked for, as files it will be given.
|
|
//
|
|
// The module owns everything after the file exists: loading it, restarting on it, what a resolver
|
|
// or a client does with it. This only puts it there. `every` is every name the mesh serves;
|
|
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
|
|
// are given and the template chooses.
|
|
func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suffix string) ([]map[string]any, error) {
|
|
if len(m.Facts) == 0 {
|
|
return nil, nil
|
|
}
|
|
names := make([]string, 0, len(m.Facts))
|
|
for name := range m.Facts {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
view := rosterView{
|
|
Node: r.Node,
|
|
Suffix: strings.TrimPrefix(suffixOr(suffix), "."),
|
|
Names: entriesFrom(every, suffix),
|
|
Machines: entriesFrom(machines, suffix),
|
|
}
|
|
|
|
out := make([]map[string]any, 0, len(names))
|
|
for _, name := range names {
|
|
fact := m.Facts[name]
|
|
if !strings.HasPrefix(fact.Path, "/") {
|
|
return nil, fmt.Errorf(
|
|
"%s asks for %q at %q, which is not an absolute path", m.Module, name, fact.Path)
|
|
}
|
|
content, err := renderRoster(fact.Template, view)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err)
|
|
}
|
|
out = append(out, map[string]any{
|
|
"id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644",
|
|
"content": content,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// renderRoster runs a module's template over the roster. A template that will not parse, or reads
|
|
// a field the mesh does not have, is an error here — where the manifest is — rather than an empty
|
|
// file on a machine.
|
|
func renderRoster(tmpl string, view rosterView) (string, error) {
|
|
t, err := template.New("roster").Option("missingkey=error").Parse(tmpl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var b bytes.Buffer
|
|
if err := t.Execute(&b, view); err != nil {
|
|
return "", err
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// entriesFrom is a name→address map as sorted roster entries.
|
|
//
|
|
// **A machine with no address is left out.** The mesh has a record for it — somebody added it —
|
|
// and does not yet know where it is, which is the ordinary state between adding a machine and it
|
|
// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to
|
|
// that hangs; leaving it out fails at once and says the name is unknown.
|
|
func entriesFrom(addresses map[string]string, suffix string) []rosterEntry {
|
|
out := make([]rosterEntry, 0, len(addresses))
|
|
for _, name := range sortedNames(addresses) {
|
|
internal, bare := meshName(name, suffix)
|
|
out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// meshName is a machine's internal name and its bare one, from either. The control plane keys
|
|
// the names it hands a resolution by the internal name (`homer.internal`), the same map a
|
|
// container gets as its hosts; a caller that keys by the bare name gets the same answer. The
|
|
// suffix is the one the control plane composed those names with, handed down rather than written
|
|
// here a second time — the alternative was `homer.internal.internal` on every machine.
|
|
func meshName(name, suffix string) (internal, bare string) {
|
|
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
|
|
if strings.HasSuffix(name, dotted) {
|
|
return name, strings.TrimSuffix(name, dotted)
|
|
}
|
|
return name + dotted, name
|
|
}
|
|
|
|
// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down.
|
|
// The one place the default is written, so a fact and a name cannot disagree about it.
|
|
func suffixOr(suffix string) string {
|
|
if suffix == "" {
|
|
return "internal"
|
|
}
|
|
return suffix
|
|
}
|
|
|
|
func sortedNames(addresses map[string]string) []string {
|
|
out := make([]string, 0, len(addresses))
|
|
for name, at := range addresses {
|
|
// A machine the mesh cannot place is left out rather than named at nothing.
|
|
if at == "" {
|
|
continue
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|