Files
mesh-controller/internal/catalogue/resolve.go
T
jschoubben 8b2d1bce9d Something answered on this machine is still bound
A binding was skipped when the provider turned out to be on the same node,
reasoning that a file saying "it is on this node" is a fact nobody needs. That
is right about the location and wrong about everything beside it: a binding
also carries what the provider said a consumer must know, which is the port,
and a consumer cannot invent that.

A build machine sharing a node with the registry it pushes to sat in a loop
saying it could not read its own binding. Nothing was wrong with the machine,
the module, the credential or the provision — the file was never written, and
the absence looked exactly like a mistake in the module.

The original intent is kept where it was right: a provision whose provider said
nothing a consumer must know is still not written. A shell is answered here and
there is nothing to say about it. A registry is answered here and the port is
still unguessable.

The address is this machine's name on the private network, or loopback when it
has none — a machine off the network still reaches itself, and a name nothing
resolves is worse than an address that always works.
2026-08-31 01:35:23 +02:00

475 lines
17 KiB
Go

package catalogue
import (
"errors"
"fmt"
"sort"
"strings"
)
// Resolving is turning "these modules are assigned here" into "this is what the node runs".
//
// It refuses rather than guesses, everywhere. novox/hq ADR 0009: a requirement with several
// answers is refused and named, because counting candidates has no surprising behaviour and a
// solver that picks has to be understood before its answer can be trusted.
// Node is what resolution needs to know about the machine.
type Node struct {
Name string
Site string
// Capabilities the machine actually has, as its profile reported them. Only the present ones
// — a capability that was looked for and not found is the same as one nobody looked for, as
// far as deciding what may run here goes.
Capabilities map[string]bool
// At is this machine's own name on the private network, empty if it is not on one. Needed to
// tell whether it can reach the node answering its requirements at all.
At string
}
// World is what the rest of the mesh already has.
//
// Some requirements cannot be answered on the machine that has them — a database runs somewhere
// and is reached over the network — so resolving one node needs to know what the others offer.
type World struct {
// Held is the claims already taken, for the scopes wider than one node.
Held []Held
// Offered is what other nodes provide at mesh scope, and everything needed to use it.
Offered map[string][]Provider
// Pinned is which node this machine was told to get a provision from, by name. Only consulted
// when more than one node could answer -- a choice recorded before it was needed should not
// start meaning something the day a second provider appears, and one recorded and then made
// unnecessary should not quietly stop applying either.
Pinned map[string]string
// Unchecked takes brokered requirements on trust instead of refusing when nothing answers
// them.
//
// For the first of two passes. Working out what a node offers the mesh needs that node
// resolved, and resolving it may need what the mesh offers — so the first pass answers only
// *what does each node offer*, and the second pass answers everything with that in hand. A
// declaration is never built from an unchecked resolution.
Unchecked bool
}
// Provider is one node answering a mesh-scoped requirement.
type Provider struct {
// Node is the machine.
Node string
// At is its name on the private network, or empty if it is not on one. The mesh's half of
// the answer: a module can say it serves on port 5432, and only the mesh knows where.
At string
// Serves is what the providing module said a consumer needs to know, settled.
Serves map[string]any
}
// Held is a claim somebody already has, used for the scopes wider than one node.
type Held struct {
Claim string
Scope string
Node string
Module string
Site string
}
// Resolution is what a node should run, and why.
type Resolution struct {
// Node is which machine this was resolved for, so a generator can be asked about it.
Node string
// At is this machine's own name on the private network, empty if it is not on one. Kept
// because a consumer bound to something answered on this same machine still has to be told
// where it is — the answer being local does not make the port guessable.
At string
// Modules in the order they were resolved: assigned first, then what they pulled in.
Modules []Manifest
// Because says why each module is here — assigned, or required by something.
Because map[string]string
// Claims is what this node's set holds, so wider scopes can be checked against it.
Claims []Held
// Needs is what this node's set gets from other nodes. Recorded rather than resolved away,
// because it is where a credential will have to be handed back once there is a mechanism for
// that, and because "what does this machine depend on that is not on it" has no other answer.
Needs []Needed
}
// Needed is one thing this node's set takes from elsewhere in the mesh.
type Needed struct {
// Name is the provision, as required.
Name string
// From is the node providing it.
From string
// At is where that node is on the private network.
At string
// Serves is what the providing module said a consumer needs to know.
Serves map[string]any
// Sealed is the credential, closed to this node. Filled in after resolving, because whose
// credential it is only becomes answerable once which node answers has been settled.
Sealed string
// For is the module that wanted it.
For string
}
// Refusal is why a set of assignments cannot become a declaration.
//
// Every reason at once rather than the first, and each says what to do about it. A person
// resolving these fixes them in one pass or in four.
type Refusal struct{ Problems []string }
func (r *Refusal) Error() string {
return "these assignments cannot be applied:\n - " + strings.Join(r.Problems, "\n - ")
}
// ErrAmbiguous is returned inside a Refusal when a requirement has more than one answer.
var ErrAmbiguous = errors.New("more than one module provides that")
// Resolve works out everything a node runs, from what was assigned to it.
//
// The catalogue is every module the mesh knows about; assigned is what a person put on this node.
// What comes back is the closure — assigned modules plus everything they require — or a refusal
// naming every reason it could not be closed.
func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world World) (Resolution, error) {
elsewhere := world.Held
var problems []string
// Which names are answered from elsewhere in the mesh rather than from this machine. A
// property of the name, not of each provider: two modules disagreeing about whether a
// database is local would make the same requirement mean different things depending on which
// one happened to answer it.
brokered := map[string]bool{}
local := map[string]bool{}
for _, m := range catalogue {
for _, o := range m.Provides {
if o.At() == ScopeMesh {
brokered[o.Name] = true
continue
}
local[o.Name] = true
}
}
for want := range brokered {
if local[want] {
problems = append(problems, fmt.Sprintf(
"the catalogue disagrees about %q: some modules provide it here and others from "+
"anywhere in the mesh, so the same requirement would mean two things", want))
}
}
// What each name can be satisfied by. Built once from the whole catalogue, because "how many
// modules provide this" is the question the whole rule turns on.
offers := map[string][]string{}
for _, m := range catalogue {
for _, o := range m.Offers() {
offers[o] = append(offers[o], m.Module)
}
}
for k := range offers {
sort.Strings(offers[k])
}
chosen := map[string]bool{}
because := map[string]string{}
var order []string
var needs []Needed
// What the set already offers, which is the first thing a requirement is checked against.
//
// Without this, assigning zsh does not satisfy something that requires a shell: the
// requirement is counted against the catalogue, three modules provide it, and the answer is
// still "choose one" after somebody has chosen one. That makes the remedy useless, and it is
// how this read when first used.
satisfied := map[string]bool{}
// Everything a person assigned goes in first. Those are choices already made, and a
// requirement one of them answers is not a choice to put back to anybody.
queue := append([]string{}, assigned...)
for _, a := range assigned {
because[a] = "assigned"
if m, known := catalogue[a]; known {
for _, o := range m.Offers() {
satisfied[o] = true
}
}
}
// What has already been complained about. A requirement can be wanted by several modules at
// once, and saying the same thing twice makes a person hunt for the difference between two
// identical lines before realising there is none.
reported := map[string]bool{}
for len(queue) > 0 {
want := queue[0]
queue = queue[1:]
if chosen[want] || reported[want] {
continue
}
// Already answered by something in the set. This is the case that makes assigning zsh do
// what a person meant by it.
if satisfied[want] && !isModule(catalogue, want) {
continue
}
// Answered from elsewhere in the mesh, if it is that kind of name. **Never installed
// here.** Choosing a machine to put a database on is a decision with consequences
// nobody would want made silently by something resolving a web application.
if brokered[want] {
reported[want] = true
where := world.Offered[want]
names := make([]string, 0, len(where))
for _, p := range where {
names = append(names, p.Node)
}
sort.Strings(names)
take := func(p Provider) {
if node.At != "" && p.At == "" || node.At == "" && p.At != "" || node.At == "" && p.At == "" {
// One of them is not on the private network, so there is no path between
// them. Said here rather than discovered as a connection timing out on a
// machine that the mesh reported as configured.
problems = append(problems, fmt.Sprintf(
"%s needs %q from %s, and they are not both on the private network — "+
"assign %s to whichever is missing it",
node.Name, want, p.Node, meshNetwork))
return
}
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At,
Serves: p.Serves, For: because[want]})
}
switch {
case world.Unchecked:
// First pass. Whether anything answers this is exactly the question this pass
// exists to make answerable, so it is not asked here.
case len(where) == 0:
remedy := "and nothing in the catalogue could"
if answers := offers[want]; len(answers) == 1 {
remedy = "— assign " + answers[0] + " to a node"
} else if len(answers) > 1 {
remedy = "— assign one of these to a node: " + strings.Join(answers, ", ")
}
problems = append(problems, fmt.Sprintf(
"nothing in this mesh provides %q, wanted by %s %s",
want, because[want], remedy))
case len(where) == 1:
if chosenNode, pinned := world.Pinned[want]; pinned && chosenNode != where[0].Node {
// One provider, and it is not the one this machine was told to use. Silently
// using the other would be the mesh overruling a choice somebody made.
problems = append(problems, fmt.Sprintf(
"%s was told to get %q from %s, and only %s provides it",
node.Name, want, chosenNode, where[0].Node))
break
}
take(where[0])
default:
chosenNode, pinned := world.Pinned[want]
if !pinned {
problems = append(problems, fmt.Sprintf(
"%d nodes provide %q, wanted by %s — say which with `pin %s %s <node>`: %s",
len(where), want, because[want], node.Name, want,
strings.Join(names, ", ")))
break
}
var chosen *Provider
for i, w := range where {
if w.Node == chosenNode {
chosen = &where[i]
}
}
if chosen == nil {
// Pointed at a machine that does not answer this. Refused rather than
// falling back to another: a fallback would quietly move somebody's data to
// a machine they did not choose, which is the whole reason this is asked.
problems = append(problems, fmt.Sprintf(
"%s was told to get %q from %s, and %s does not provide it — these do: %s",
node.Name, want, chosenNode, chosenNode, strings.Join(names, ", ")))
break
}
take(*chosen)
}
continue
}
candidates := offers[want]
switch len(candidates) {
case 0:
reported[want] = true
problems = append(problems, fmt.Sprintf(
"nothing provides %q, wanted by %s", want, because[want]))
continue
case 1:
// No choice to make, so none is made. This is the case that lets `install i3` bring
// in xorg without anybody being asked anything.
default:
reported[want] = true
problems = append(problems, fmt.Sprintf(
"%q is wanted by %s and %d modules provide it — choose one and assign it: %s",
want, because[want], len(candidates), strings.Join(candidates, ", ")))
continue
}
m := catalogue[candidates[0]]
if chosen[m.Module] {
continue
}
chosen[m.Module] = true
order = append(order, m.Module)
for _, o := range m.Offers() {
satisfied[o] = true
}
if _, ok := because[m.Module]; !ok {
because[m.Module] = fmt.Sprintf("required by %s", because[want])
}
for _, r := range m.Wants() {
if _, ok := because[r]; !ok {
because[r] = m.Module
}
queue = append(queue, r)
}
}
resolution := Resolution{Node: node.Name, At: node.At, Because: because, Needs: needs}
for _, n := range order {
resolution.Modules = append(resolution.Modules, catalogue[n])
}
problems = append(problems, checkCapabilities(resolution.Modules, node)...)
claims, claimProblems := checkClaims(resolution.Modules, node, elsewhere)
problems = append(problems, claimProblems...)
problems = append(problems, checkResources(resolution.Modules)...)
resolution.Claims = claims
if len(problems) > 0 {
sort.Strings(problems)
return Resolution{}, &Refusal{Problems: problems}
}
return resolution, nil
}
// isModule reports whether a name is a module in its own right rather than only something
// modules provide.
//
// A requirement naming a module is not satisfied by something else providing that name: `i3`
// requires `xorg` and means xorg, not "anything calling itself a display server".
func isModule(catalogue map[string]Manifest, want string) bool {
_, ok := catalogue[want]
return ok
}
// checkCapabilities refuses a module the machine cannot run.
//
// Said as a fact about the machine rather than about the module, because that is what it is and
// because nothing can be installed to change it.
func checkCapabilities(modules []Manifest, node Node) []string {
var problems []string
for _, m := range modules {
for _, c := range m.Capabilities {
if !node.Capabilities[c] {
problems = append(problems, fmt.Sprintf(
"%s needs the capability %q and %s does not have it — this is the wrong "+
"machine, not a missing module", m.Module, c, node.Name))
}
}
}
return problems
}
// checkClaims refuses two modules holding one singular thing.
//
// Within this node's own set, and against what is already held elsewhere for the wider scopes. A
// claim at mesh scope is the same idea as the mesh's one hub, said once instead of hard-coded.
func checkClaims(modules []Manifest, node Node, elsewhere []Held) ([]Held, []string) {
var problems []string
var held []Held
byScope := map[string]map[string]string{} // scope → claim → module
for _, m := range modules {
for _, c := range m.Claims {
scope := c.At()
if byScope[scope] == nil {
byScope[scope] = map[string]string{}
}
if other, taken := byScope[scope][c.Name]; taken {
problems = append(problems, fmt.Sprintf(
"%s and %s both claim %q, and only one thing may hold it per %s",
other, m.Module, c.Name, scope))
continue
}
byScope[scope][c.Name] = m.Module
held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name,
Module: m.Module, Site: node.Site})
}
}
// And against the rest of the mesh, for the scopes that reach past this machine.
for _, h := range held {
for _, e := range elsewhere {
if e.Node == node.Name || e.Claim != h.Claim || e.Scope != h.Scope {
continue
}
switch h.Scope {
case ScopeMesh:
problems = append(problems, fmt.Sprintf(
"%s on %s claims %q, which %s on %s already holds — one per mesh",
h.Module, node.Name, h.Claim, e.Module, e.Node))
case ScopeSite:
if node.Site != "" && node.Site == e.Site {
problems = append(problems, fmt.Sprintf(
"%s on %s claims %q, which %s on %s already holds at %s — one per site",
h.Module, node.Name, h.Claim, e.Module, e.Node, node.Site))
}
}
}
}
return held, problems
}
// checkResources refuses two modules writing the same thing.
//
// This costs no manifest field: the mesh already holds every resource of every module, so two
// declaring one path or one unit are visible without either having to know about the other. A
// declared claim is only for the abstract conflicts nothing in the resources reveals.
func checkResources(modules []Manifest) []string {
var problems []string
owner := map[string]string{}
for _, m := range modules {
for _, r := range m.Resources {
for _, field := range []string{"path", "unit", "name", "package"} {
value, ok := r[field].(string)
if !ok || value == "" {
continue
}
key := field + " " + value
if other, taken := owner[key]; taken && other != m.Module {
problems = append(problems, fmt.Sprintf(
"%s and %s both declare the %s %q", other, m.Module, field, value))
}
owner[key] = m.Module
}
}
}
return problems
}
// Offers is a list of node-scoped provisions, which is what nearly everything is.
func Offers(names ...string) []Offer {
out := make([]Offer, 0, len(names))
for _, n := range names {
out = append(out, Offer{Name: n})
}
return out
}
// FromAnywhere is a provision answered by whichever node in the mesh runs it.
func FromAnywhere(names ...string) []Offer {
out := make([]Offer, 0, len(names))
for _, n := range names {
out = append(out, Offer{Name: n, Scope: ScopeMesh})
}
return out
}
// meshNetwork is what to assign to a machine that needs to reach another one.
//
// A string here rather than an import, because the private network is a module the control plane
// ships and this package must not depend on the thing it resolves. The name being wrong would
// show up as a refusal naming a module nobody can assign, which a test checks.
const meshNetwork = "networking"