The gap that has been named at the end of every report for a week. Until now a
declaration came from a person handing over a file; now it comes from what was
assigned, resolved against the catalogue, and the control plane is deciding
rather than relaying.
Everything from the module conversation, built and run on real machines:
assign laptop i3 -> accepted, brings xorg, because nothing else provides
it and there was no choice to make
assign laptop sway -> refused: xorg and wayland both claim the-seat
assign laptop editor -> refused: three modules provide a shell -- bash,
fish, zsh -- choose one
assign laptop zsh -> accepted, and the editor's requirement is answered
bash, fish beside it -> fine, nothing is claimed
Claims rather than pairwise exclusion, so a third display server would say what
it claims and need no edit to xorg or wayland. Scoped to node, site or mesh:
two DHCP servers at one site collide and at two sites do not, and the mesh-wide
one is the hub said as a claim instead of hard-coded.
Some conflicts cost no manifest field at all. The refusal above names the seat
AND the two files, because the mesh already holds every resource of every
module -- neither i3 nor sway knows the other exists.
Resource identities carry their module, so two modules may both call something
"config" without the second silently replacing the first. What a service
reflects is qualified the same way, or it would name a resource that no longer
exists and stop being restarted when its own configuration changes.
Nothing is sent until every node resolves. A push that configured three and
refused on the fourth would leave the mesh in a state nobody asked for, and the
fourth is exactly where a claim collision appears.
One real flaw found by using it rather than by testing it: assigning zsh did
not satisfy a requirement for a shell. Requirements were counted against the
catalogue without first asking what the set already offers, so "choose one and
assign it" named three modules and then ignored the one you chose. The remedy
was useless and every test passed.
303 lines
10 KiB
Go
303 lines
10 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
|
|
}
|
|
|
|
// 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 {
|
|
// 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
|
|
}
|
|
|
|
// 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, elsewhere []Held) (Resolution, error) {
|
|
var problems []string
|
|
|
|
// 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
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
for len(queue) > 0 {
|
|
want := queue[0]
|
|
queue = queue[1:]
|
|
if chosen[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
|
|
}
|
|
|
|
candidates := offers[want]
|
|
switch len(candidates) {
|
|
case 0:
|
|
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:
|
|
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.Requires {
|
|
if _, ok := because[r]; !ok {
|
|
because[r] = m.Module
|
|
}
|
|
queue = append(queue, r)
|
|
}
|
|
}
|
|
|
|
resolution := Resolution{Because: because}
|
|
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
|
|
}
|
|
|
|
// Declaration is everything the resolved modules put on the node, as the host reads it.
|
|
//
|
|
// Resource identities are prefixed with the module they came from. Two modules may reasonably
|
|
// both call something "config", and without this the second would silently replace the first —
|
|
// the node applying one of them and reporting success.
|
|
func (r Resolution) Declaration() []map[string]any {
|
|
var out []map[string]any
|
|
for _, m := range r.Modules {
|
|
for _, resource := range m.Resources {
|
|
copied := map[string]any{}
|
|
for k, v := range resource {
|
|
copied[k] = v
|
|
}
|
|
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
|
|
// A service saying what it reflects names resources within its own module, so those
|
|
// are prefixed too or they would point at nothing.
|
|
if reflects, ok := resource["restart-on"].([]any); ok {
|
|
var renamed []any
|
|
for _, id := range reflects {
|
|
renamed = append(renamed, m.Module+"."+fmt.Sprint(id))
|
|
}
|
|
copied["restart-on"] = renamed
|
|
}
|
|
out = append(out, copied)
|
|
}
|
|
}
|
|
return out
|
|
}
|