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 }