Files
mesh-host/internal/compose/resolution/roster.go
T
jschoubben d68e241724 Take the catalogue's composition rules into the node-engine
ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk
carries no composition rule. The catalogue functions only the
node-engine's composition reaches (Resolve, Resolution's Compose,
Declaration, DataItems, LeftOut, Rules and the rest, the settings,
placeholder and template refusals, the rendering helpers) are copied
with their tests into internal/compose/resolution; a method of a
mesh-sdk shape becomes a function taking it first. The composition
calls its own copies, and the captured catalogue its tests read lives
here now, shared by mesh-compose's tests.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 19:15:55 +01:00

227 lines
8.9 KiB
Go

package resolution
import (
"bytes"
"fmt"
"sort"
"strings"
"text/template"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// 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
}
// 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 mcat.Manifest, r mcat.Resolution, with mcat.Rendering) ([]map[string]any, error) {
if len(m.Facts) == 0 {
return nil, nil
}
names := sortedFacts(m.Facts)
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 []mcat.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
}
// sortedFacts is a module's fact names in order, so what is said about them is said the same way twice.
func sortedFacts(facts map[string]mcat.RosterFile) []string {
out := make([]string, 0, len(facts))
for name := range facts {
out = append(out, name)
}
sort.Strings(out)
return out
}