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
227 lines
8.9 KiB
Go
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
|
|
}
|