musl takes the first reply from any listed nameserver, so a public fallback beside the mesh's resolver answered NXDOMAIN for mesh names in every Alpine container (hq ADR 0223). The fix is two mesh resolvers and no public one, which needs mesh-dns-resolver held on two machines: a seat can now be replicated, each holder recorded by 'seat <name> --add', checkClaims accepts every holder on record and still refuses a second holder of any other mesh seat, a holder answers its own requirement, and a roster fact gives each replicated seat's holders, this machine first, so resolv-conf can list them. Migration 0062 keys a holding by seat and assignment.
282 lines
13 KiB
Go
282 lines
13 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` and
|
|
// `.Machines`. Both are the nodes of the mesh: routed names were once in `.Names` too, and are not
|
|
// since every route became a name under its node's internal domain (novox/hq ADR 0191) — the hq
|
|
// issue 111 distinction is kept as two fields so the templates that range either keep rendering.
|
|
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"`
|
|
// Home places the file under this node's operator-account home and chowns it to that account,
|
|
// rather than at an absolute system path (novox/hq to-be 29). Then Path is home-relative
|
|
// (`.ssh/config.d/mesh`), resolved against the account's home on the node it is composed for; a
|
|
// node with no operator account gets no such file. This is how the ssh-client config — every
|
|
// other node's Host block — is written into a person's home rather than into /etc.
|
|
Home bool `json:"home,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
|
|
// Zones is every zone a module in the mesh answers itself, with where its answerer is (novox/hq
|
|
// ADR 0199) — what the mesh's resolver forwards. Ordered by zone.
|
|
Zones []rosterZone
|
|
// Holders is the machines holding each replicated mesh seat, by seat (novox/hq ADR 0223) — what
|
|
// a machine's resolver file lists for `mesh-dns-resolver`. **This machine first when it is one
|
|
// of them**, then the rest by name: the nearest holder is asked first, and two renderings of
|
|
// one mesh on one machine are one file. A template reads one seat's with
|
|
// `index .Holders "<seat>"`; a seat nobody holds ranges over nothing.
|
|
Holders map[string][]rosterEntry
|
|
}
|
|
|
|
// rosterZone is one zone as a template sees it: the zone, and the address and port answering it.
|
|
type rosterZone struct {
|
|
Zone string
|
|
Address string
|
|
Port int
|
|
}
|
|
|
|
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address,
|
|
// and the operator account to log into it as (novox/hq to-be 29) — empty when none is known, so an
|
|
// ssh Host block template can omit the User line for a machine nobody has an account on.
|
|
type rosterEntry struct {
|
|
Name string
|
|
FQDN string
|
|
Address string
|
|
Account 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, accounts map[string]string, suffix string) ([]map[string]any, error) {
|
|
return FactsWithZonesInto(m, r, every, machines, accounts, suffix, nil)
|
|
}
|
|
|
|
// FactsWithZonesInto is FactsInto with the mesh's zones in the view, for a template that ranges them.
|
|
func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string,
|
|
zones []ZoneAt) ([]map[string]any, error) {
|
|
return FactsFrom(m, r, Rendering{Names: every, Machines: machines, Accounts: accounts, Suffix: suffix,
|
|
Zones: zones})
|
|
}
|
|
|
|
// FactsFrom renders the roster files a module asked for from everything the mesh composed for this
|
|
// machine: its machines, zones and the holders of each replicated seat.
|
|
func FactsFrom(m Manifest, r Resolution, with Rendering) ([]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(with.Suffix), "."),
|
|
Names: entriesFrom(with.Names, with.Accounts, with.Suffix),
|
|
Machines: entriesFrom(with.Machines, with.Accounts, with.Suffix),
|
|
Zones: zonesFrom(with.Zones),
|
|
Holders: holdersFrom(with.Holders, with.Accounts, with.Suffix, r.Node),
|
|
}
|
|
|
|
out := make([]map[string]any, 0, len(names))
|
|
for _, name := range names {
|
|
fact := m.Facts[name]
|
|
content, err := renderRoster(fact.Template, view)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err)
|
|
}
|
|
// Where the file goes: under the operator's home and chowned to it (a home fact), or at the
|
|
// absolute system path it names. A home fact on a machine with no operator account cannot be
|
|
// placed, and is left out rather than written to nowhere (novox/hq to-be 29).
|
|
path := fact.Path
|
|
var owner string
|
|
if fact.Home {
|
|
if r.Account == "" {
|
|
continue
|
|
}
|
|
path = accountHomeOf(r.Account, r.AccountHome) + "/" + strings.TrimLeft(fact.Path, "/")
|
|
owner = r.Account
|
|
} else 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)
|
|
}
|
|
file := map[string]any{
|
|
"id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
|
|
"content": content,
|
|
}
|
|
if owner != "" {
|
|
file["owner"] = owner
|
|
}
|
|
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, accounts map[string]string, suffix string) []rosterEntry {
|
|
out := make([]rosterEntry, 0, len(addresses))
|
|
for _, name := range sortedNames(addresses) {
|
|
if routed(name, suffix) {
|
|
// A routed name is already a full name under a public domain, and it has no mesh
|
|
// form: appending the suffix made `<name>.<suffix>`, which every machine's hosts file
|
|
// carried and nothing served (novox/hq issue 157). It is published as itself, once.
|
|
out = append(out, rosterEntry{Name: name, FQDN: name, Address: addresses[name], Account: accounts[name]})
|
|
continue
|
|
}
|
|
internal, bare := meshName(name, suffix)
|
|
// The account is looked up by whichever key the caller keys accounts on — the internal name
|
|
// or the bare one — so a template gets the right login however the maps were built.
|
|
account := accounts[name]
|
|
if account == "" {
|
|
account = accounts[bare]
|
|
}
|
|
out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name], Account: account})
|
|
}
|
|
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
|
|
}
|
|
|
|
// routed says whether a name the mesh serves is a routed public name rather than a machine's: it
|
|
// carries a domain of its own and not the mesh's suffix. A machine's name is bare (`homer`) or
|
|
// internal (`homer.internal`); anything else with a dot in it was composed under a public domain.
|
|
func routed(name, suffix string) bool {
|
|
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
|
|
return strings.Contains(name, ".") && !strings.HasSuffix(name, dotted)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// zonesFrom is the zones a template ranges, ordered by zone so two renderings of one mesh are one file.
|
|
func zonesFrom(zones []ZoneAt) []rosterZone {
|
|
out := make([]rosterZone, 0, len(zones))
|
|
for _, z := range zones {
|
|
out = append(out, rosterZone{Zone: z.Zone, Address: z.Address, Port: z.Port})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Zone < out[j].Zone })
|
|
return out
|
|
}
|
|
|
|
// holdersFrom is each replicated seat's holders as a template ranges them: this machine first when
|
|
// it holds the seat, then the others by name (novox/hq ADR 0223). A machine with no address is left
|
|
// out, as everywhere in the roster — a resolver named at nothing is a lookup that hangs.
|
|
func holdersFrom(holders map[string]map[string]string, accounts map[string]string, suffix, node string) map[string][]rosterEntry {
|
|
out := make(map[string][]rosterEntry, len(holders))
|
|
for seat, at := range holders {
|
|
entries := entriesFrom(at, accounts, suffix)
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
|
return entries[i].Name == node && entries[j].Name != node
|
|
})
|
|
out[seat] = entries
|
|
}
|
|
return out
|
|
}
|