A node's plans took 2.6-13.6 s of CPU on the provisioner: one composition resolved every machine of the mesh some two hundred times (who is on the private network, and the rest of the mesh, worked out again on every look), three compositions did so again each, and the holder composed twice. A composer now works each out once over its unchanging Input; Composing gives Resolve, Render and Generators one composer; PlansHeldTwice composes the wanted and the newest builds over one read of the bus, says where the time went by phase, and the states reader keeps a build's take-in by its content. WatchedStates takes a process's own writes into the copy at once, so a holder reads its own states from it. Measured on a captured novox mesh: 11.7 s to 0.49 s per composition of an unchanged node, plans and provisions identical on all four nodes. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
308 lines
11 KiB
Go
308 lines
11 KiB
Go
package compose
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/overlay"
|
|
)
|
|
|
|
// The private network: who is on it, where, and what they are called. Ported from mesh-controller
|
|
// cmd/mesh-controller/network.go at 6c2a8d62.
|
|
|
|
// DefaultOverlayCIDR is the range the mesh allocates from when nothing says another.
|
|
const DefaultOverlayCIDR = "10.42.0.0/16"
|
|
|
|
// composer is one composition over an Input: it reads Input, changes nothing in it, and keeps what the controller
|
|
// said on its standard error as lines to return.
|
|
type composer struct {
|
|
in Input
|
|
said []string
|
|
seen map[string]bool
|
|
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
|
|
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
|
|
unhostable []string
|
|
|
|
// What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
|
|
// 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
|
|
// on the private network, and the rest of the mesh as each node sees it.
|
|
onNetwork []Node
|
|
onNetworkSet bool
|
|
rest map[string]restOf
|
|
plans map[string]planOf
|
|
}
|
|
|
|
// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
|
|
type restOf struct {
|
|
world catalogue.World
|
|
passes Passes
|
|
left []LeftOut
|
|
unhostable []string
|
|
err error
|
|
}
|
|
|
|
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
|
|
|
|
// say keeps a line the controller would have written to its log, once.
|
|
func (c *composer) say(format string, args ...any) {
|
|
line := fmt.Sprintf(format, args...)
|
|
if c.seen[line] {
|
|
return
|
|
}
|
|
c.seen[line] = true
|
|
c.said = append(c.said, line)
|
|
}
|
|
|
|
// suffix is what the mesh's internal names end in: the Input's, and overlay.DefaultSuffix when it gives none. The package never calls
|
|
// overlay.Suffix or overlay.InternalName, which read the environment, so the assigner and the controller compose the same names
|
|
// from the same Input.
|
|
func (c *composer) suffix() string {
|
|
if v := strings.TrimPrefix(strings.TrimSpace(c.in.InternalSuffix), "."); v != "" {
|
|
return v
|
|
}
|
|
return overlay.DefaultSuffix
|
|
}
|
|
|
|
// internalName is a node's name inside the mesh.
|
|
func (c *composer) internalName(node string) string { return node + "." + c.suffix() }
|
|
|
|
// saidBeside is the lines already said and then every line the composer kept, once each, in the order they were found.
|
|
func (c *composer) saidBeside(lines []string) []string {
|
|
for _, l := range c.said {
|
|
if !slices.Contains(lines, l) {
|
|
lines = append(lines, l)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// overlayRange is the range the mesh allocates node addresses from: the adopted tunnel's first (novox/hq ADR 0105),
|
|
// then the range genesis was told, then the default.
|
|
func (c *composer) overlayRange() string {
|
|
if a := c.in.Adopted; a != nil && a.Adopted {
|
|
return a.Tunnel.Range
|
|
}
|
|
if v := strings.TrimSpace(c.in.OverlayCIDR); v != "" {
|
|
return v
|
|
}
|
|
return DefaultOverlayCIDR
|
|
}
|
|
|
|
// network builds the private network over the machines that resolved the module for it. A machine is on it because it
|
|
// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set
|
|
// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and
|
|
// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and
|
|
// only its own declaration is refused (issue 1833). A machine refused with no place is left off.
|
|
func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) {
|
|
tunnels := c.in.Tunnels
|
|
carried := c.in.CarriedPeers
|
|
isJoining := map[string]bool{}
|
|
for _, name := range c.in.Joining {
|
|
isJoining[name] = true
|
|
}
|
|
var nodes []overlay.Node
|
|
for _, node := range c.in.sorted() {
|
|
p := node.Place
|
|
placed := p.Key != "" && p.Address != ""
|
|
_, wasRefused := refused[node.Name]
|
|
if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) {
|
|
continue
|
|
}
|
|
if !on[node.Name] && wasRefused && !isJoining[node.Name] {
|
|
c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
|
|
node.Name, p.Address)
|
|
}
|
|
n := overlay.Node{
|
|
Name: node.Name, Key: p.Key, Endpoint: p.Endpoint,
|
|
Site: p.Site, Hub: p.Hub, Address: p.Address,
|
|
}
|
|
if t, takes := tunnels[node.Name]; takes && t.NodeAdopted {
|
|
// Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is
|
|
// refused, not composed.
|
|
if wrong := disagrees(p, t.Tunnel); wrong != "" {
|
|
return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+
|
|
"Re-place it — `overlay place %s --hub --endpoint <host>:%d …` — and push again; "+
|
|
"nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port)
|
|
}
|
|
n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU}
|
|
}
|
|
if p.Hub {
|
|
for _, cp := range carried {
|
|
n.Carried = append(n.Carried, overlay.Carried{Key: cp.PublicKey, Address: cp.Address})
|
|
}
|
|
}
|
|
nodes = append(nodes, n)
|
|
}
|
|
if len(nodes) == 0 {
|
|
// Nobody was given it: an empty network is a legitimate mesh, not a broken one.
|
|
return overlay.Empty(), nil
|
|
}
|
|
g, err := overlay.From(nodes, c.overlayRange(), "")
|
|
if err != nil && len(refused) > 0 {
|
|
// The network is missing something, and some machines could not be resolved at all: almost always the same fact.
|
|
var who []string
|
|
for name, why := range refused {
|
|
who = append(who, fmt.Sprintf(" %s: %s", name, why))
|
|
}
|
|
sort.Strings(who)
|
|
return nil, fmt.Errorf("%w\n\nand %d node(s) could not be resolved at all, which is "+
|
|
"probably why:\n%s", err, len(refused), strings.Join(who, "\n"))
|
|
}
|
|
return g, err
|
|
}
|
|
|
|
// Graph is the whole mesh's network, for showing it: the nodes on it and each one's peers.
|
|
func Graph(in Input) ([]overlay.Node, overlay.Graph, error) {
|
|
c := newComposer(in)
|
|
on, refused, err := c.whoResolves(overlay.Requirement)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
g, err := c.network(on, refused)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return g.Nodes(), g.Graph(), nil
|
|
}
|
|
|
|
// whoResolves is the machines whose resolution answers a requirement, and why the others did not: by what a module
|
|
// provides, not by its name, resolved rather than read from the assignments.
|
|
func (c *composer) whoResolves(requirement string) (map[string]bool, map[string]string, error) {
|
|
on := map[string]bool{}
|
|
// Why a node could not be resolved, kept rather than raised: one broken node must not stop the rest being described.
|
|
refused := map[string]string{}
|
|
for _, n := range c.in.sorted() {
|
|
resolved, err := c.planFor(n.Name)
|
|
switch {
|
|
case unresolvable(err):
|
|
refused[n.Name] = err.Error()
|
|
continue
|
|
case err != nil:
|
|
// Not a node that does not resolve: a question that went unanswered.
|
|
return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err)
|
|
}
|
|
for _, m := range resolved.Plan.Modules {
|
|
for _, offered := range m.Offers() {
|
|
if offered == requirement {
|
|
on[n.Name] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return on, refused, nil
|
|
}
|
|
|
|
// Generators is what every node's declaration is composed with, over the whole mesh: the private network's generator
|
|
// (one generator now; the names and a resolver's zone file are facts a module asks for in its manifest).
|
|
func Generators(in Input) (map[string]catalogue.Generator, error) {
|
|
c := newComposer(in)
|
|
return c.generators()
|
|
}
|
|
|
|
func (c *composer) generators() (map[string]catalogue.Generator, error) {
|
|
on, refused, err := c.whoResolves(overlay.Addressing)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
net, err := c.network(on, refused)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]catalogue.Generator{overlay.Name: net}, nil
|
|
}
|
|
|
|
// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its
|
|
// endpoint not on the tunnel's port — or nothing when both agree.
|
|
func disagrees(p Place, t Tunnel) string {
|
|
var wrong []string
|
|
want := t.Address
|
|
if i := strings.Index(want, "/"); i >= 0 {
|
|
want = want[:i]
|
|
}
|
|
if p.Address != want {
|
|
wrong = append(wrong, fmt.Sprintf("its address is %s and the tunnel's is %s", orNothing(p.Address), want))
|
|
}
|
|
if p.Reachable() && portOfEndpoint(p.Endpoint) != strconv.Itoa(t.Port) {
|
|
wrong = append(wrong, fmt.Sprintf("its endpoint %s is not on the tunnel's port %d", p.Endpoint, t.Port))
|
|
}
|
|
return strings.Join(wrong, "; ")
|
|
}
|
|
|
|
func orNothing(s string) string {
|
|
if s == "" {
|
|
return "unset"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// portOfEndpoint is the port in host:port, or empty.
|
|
func portOfEndpoint(endpoint string) string {
|
|
if i := strings.LastIndex(endpoint, ":"); i >= 0 {
|
|
return endpoint[i+1:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// whereEveryoneIs is each machine's name on the private network, for the ones on it. Resolved without consulting the
|
|
// rest of the mesh (it is called from that path), and refused without the catalogue.
|
|
func (c *composer) whereEveryoneIs() (map[string]string, error) {
|
|
if c.in.Catalogue == nil {
|
|
return nil, errors.New("asked where everyone is without the catalogue, which cannot be answered")
|
|
}
|
|
out := map[string]string{}
|
|
for _, p := range c.onTheNetwork() {
|
|
out[p.Name] = c.internalName(p.Name)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// onTheNetwork is every placed machine that resolves the private network: has an address AND runs what puts it there
|
|
// (novox/hq issue 079). A machine that does not resolve keeps its place when it holds one (a key and an address), by the
|
|
// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
|
|
// counted, and said.
|
|
func (c *composer) onTheNetwork() []Node {
|
|
if !c.onNetworkSet {
|
|
c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true
|
|
}
|
|
return slices.Clone(c.onNetwork)
|
|
}
|
|
|
|
// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved.
|
|
func (c *composer) onTheNetworkNow() []Node {
|
|
var out []Node
|
|
for _, n := range c.in.sorted() {
|
|
if n.Place.Address == "" {
|
|
continue
|
|
}
|
|
if len(n.Assigned) == 0 {
|
|
continue
|
|
}
|
|
got, err := catalogue.Resolve(c.in.Catalogue, n.Assigned,
|
|
catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities},
|
|
catalogue.World{Unchecked: true, Holdings: c.in.Holdings})
|
|
if err != nil {
|
|
if n.Place.Key != "" {
|
|
c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
|
|
n.Name, n.Place.Address)
|
|
out = append(out, n)
|
|
continue
|
|
}
|
|
c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err)
|
|
continue
|
|
}
|
|
for _, m := range got.Modules {
|
|
for _, offered := range m.Offers() {
|
|
if offered == overlay.Requirement {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|