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 }