Files
mesh-sdk/go/compose/network.go
T
jschoubben c154bdedd3 Keep a machine that does not resolve on the private network at its place, so one machine's modules refuse only its own declaration (issue 1833)
A machine whose own set could not be resolved was left off the private network; when it was the hub, the network
could not be composed and every machine's declaration failed. Its placement (key and address) is its last good place
and does not change when its modules fail, so it stays on the network and only its own declaration is refused. A
refused machine with no place is left off, and a network that cannot be composed from what remains still fails.
2026-10-22 15:03:17 +02:00

272 lines
9.4 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
}
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 is not counted, and said.
func (c *composer) onTheNetwork() []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 {
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
}