The merge commit took only the staged index; these reconciliation edits sat unstaged in the working tree. Integrate the template mechanism with #79's region write (hq 128): RosterFile gains Shared, FactsInto sets into:block for a shared fact, /etc/hosts becomes the region form (no floor) and node-names is marked shared. Without this the merge would have regressed /etc/hosts back to a whole-file write, replacing the operator's own lines.
182 lines
7.7 KiB
Go
182 lines
7.7 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"`
|
|
// Shared is whether the file the fact goes to belongs to the machine rather than the mesh. When
|
|
// it does, the mesh owns only a marked region of it and keeps the rest byte for byte (novox/hq
|
|
// issue 128) — a hosts file is shared, since the distribution's `localhost`, the operator's own
|
|
// lines and other tools' blocks live there too; a resolver's zones file is not, the mesh owns it
|
|
// whole. A property of the fact, not of the path: the format determines whether the file is
|
|
// wholly the mesh's, not where a module happened to ask for it.
|
|
Shared bool `json:"shared,omitempty"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
file := map[string]any{
|
|
"id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644",
|
|
"content": content,
|
|
}
|
|
if fact.Shared {
|
|
// The host owns only the lines between `# BEGIN mesh <id>` and `# END mesh <id>` and
|
|
// keeps the rest of the file byte for byte; undeclared, the region goes and nothing else
|
|
// does (novox/hq issue 128). Every node on the private network receives this, so every
|
|
// node's host — the controller's own machine included — must be block-aware before a
|
|
// controller emitting it is rolled out: the order ADR 0102 set for `into: json`.
|
|
file["into"] = "block"
|
|
}
|
|
out = append(out, file)
|
|
}
|
|
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
|
|
}
|