`requires` said a thing must be there. It never said what to do with it,
so a web application requiring a reverse proxy had nowhere to put "this
name, this port". The two modules that needed it most went round the
outside and opened a connection to the control plane's database, which is
why every node holds a credential to it permanently.
Two fields close it:
contributes: {reverse-proxy: {host: board, port: 8080}}
receives: {reverse-proxy: /etc/traefik/dynamic/mesh.json}
The control plane collects every contribution on a node and writes them
to the path the provider named, ordered by module so the file does not
churn. Contributing to something is requiring it — asking to be published
means a publisher must exist, and a module that had to say both would
eventually say one.
The control plane does not know what a reverse proxy is and does not
write one's configuration. It delivers facts; the module turns them into
whatever it runs. That is why swapping the proxy touches nothing that
publishes through it, and why the host needs no new vocabulary — a
received file is a file.
Settings reach a contribution the same way they reach a file, because a
hostname is exactly what differs between one mesh and the next.
Two things found by running it:
- the file had a `//` header, so it said "do not edit" to a person and
failed to parse for the program meant to read it. The note is inside
the document now.
- a provider with no consumers gets an empty file rather than none. It
cannot otherwise tell "nothing asked for me" from "the mesh never
wrote it", and those want different responses.
Also `plan <node> --json`, which is how the declaration gets handed to
the host's own parser.
440 lines
15 KiB
Go
440 lines
15 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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 {
|
|
// Node is which machine this was resolved for, so a generator can be asked about it.
|
|
Node 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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, 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
|
|
}
|
|
|
|
// SettingsBy is the layers that apply to each module, keyed by module name.
|
|
type SettingsBy map[string][]Layer
|
|
|
|
// Generator works out a module's resources for one node, where they cannot be written in advance.
|
|
type Generator interface {
|
|
// Resources for this node. Absent means the node is not part of whatever this generates,
|
|
// which is an ordinary answer rather than a failure — a machine assigned the module before it
|
|
// has an address on the network is in exactly that state.
|
|
Resources(node string) ([]map[string]any, bool, error)
|
|
}
|
|
|
|
// Rendering is everything needed to turn a resolution into the declaration a node is sent.
|
|
type Rendering struct {
|
|
Settings SettingsBy
|
|
Generators map[string]Generator
|
|
}
|
|
|
|
// Declaration is everything the resolved modules put on the node, with settings applied.
|
|
//
|
|
// 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(with Rendering) ([]map[string]any, error) {
|
|
given, err := r.contributions(with.Settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var out []map[string]any
|
|
for _, m := range r.Modules {
|
|
resources := m.Resources
|
|
for _, to := range sortedKeys(m.Receives) {
|
|
file, err := receivedFile(to, m.Receives[to], given[to])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resources = append(append([]map[string]any{}, resources...), file)
|
|
}
|
|
if m.Computed != "" {
|
|
generator, known := with.Generators[m.Computed]
|
|
if !known {
|
|
return nil, fmt.Errorf(
|
|
"%s says its resources are computed by %q, and this control plane has no %q",
|
|
m.Module, m.Computed, m.Computed)
|
|
}
|
|
generated, part, err := generator.Resources(r.Node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !part {
|
|
// Assigned, and not yet part of what this generates. Nothing to put on the
|
|
// machine, which is different from an error: a node given the network module
|
|
// before it has an address is in exactly that state, briefly.
|
|
continue
|
|
}
|
|
resources = generated
|
|
}
|
|
for _, unsettled := range resources {
|
|
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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, nil
|
|
}
|
|
|
|
// Contribution is one module telling the answer to a requirement what it needs from it.
|
|
type Contribution struct {
|
|
// From is the module that said it, so the provider and a person reading the file can tell
|
|
// which route belongs to what.
|
|
From string `json:"from"`
|
|
// Values are the module's own, with settings applied. What the keys mean is agreed by the
|
|
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
|
// is what makes swapping one for another cost nothing.
|
|
Values map[string]any `json:"values"`
|
|
}
|
|
|
|
// contributions collects what every module in this set contributes, by requirement.
|
|
//
|
|
// Ordered by contributing module, because the result becomes a file on a machine and a file whose
|
|
// lines move about is a file that looks changed when nothing changed.
|
|
func (r Resolution) contributions(settings SettingsBy) (map[string][]Contribution, error) {
|
|
out := map[string][]Contribution{}
|
|
modules := append([]Manifest{}, r.Modules...)
|
|
sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module })
|
|
|
|
for _, m := range modules {
|
|
for _, to := range sortedKeys(m.Contributes) {
|
|
// Settings reach a contribution the same way they reach a file. A route's hostname is
|
|
// exactly the kind of thing that differs between one mesh and the next, and a module
|
|
// that could not have it set would have to be edited to be reused.
|
|
values, err := settle(m.Contributes[to], settings[m.Module], nil,
|
|
m.Module+" contributing to "+to)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
|
}
|
|
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// receivedFile is the file a provider is given its consumers' contributions in.
|
|
func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) {
|
|
if given == nil {
|
|
// Nobody contributed. The file is still written, empty, rather than left absent: a
|
|
// provider that finds no file cannot tell "nothing asked for me" from "the mesh never
|
|
// wrote it", and the two want completely different responses.
|
|
given = []Contribution{}
|
|
}
|
|
// The note goes *inside* the document, not above it. The first version wrote a `//` header
|
|
// and produced a file that says "do not edit" to a person and fails to parse for the program
|
|
// meant to read it — which is the whole audience.
|
|
body, err := json.MarshalIndent(map[string]any{
|
|
"contributions": 1,
|
|
"requirement": requirement,
|
|
"generated": "by the mesh — do not edit; replaced whenever a module contributing to " +
|
|
requirement + " arrives or leaves",
|
|
"given": given,
|
|
}, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"id": ReceivedID(requirement), "type": "file", "path": path, "mode": "0644",
|
|
"content": string(body) + "\n",
|
|
}, nil
|
|
}
|
|
|
|
// sortedKeys is map iteration made repeatable, which everything written to a machine needs.
|
|
func sortedKeys[V any](m map[string]V) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|