status reported no unmet dependency on any node once systemd, pacman and docker were assigned to all four (to-be 42), which is the condition ADR 0207 set for the switch. A dependency no catalogue module could meet stays a report before and after the switch, as assign already said it: there is no remedy to name.
287 lines
11 KiB
Go
287 lines
11 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// A module depends on the node seats that apply its resources (novox/hq ADR 0207).
|
|
//
|
|
// Some of what a module declares is applied through software on the machine that is itself a
|
|
// module: a service through the service manager, a package through the package manager, a container
|
|
// through the container runtime. A *capability* only says that software is installed; it does not
|
|
// say that a module of the mesh holds the role and answers for it. So the dependency is derived from
|
|
// the resources — never stated in a manifest, because a module that adds a service and forgets a
|
|
// field would pass — and is met when some module assigned to the same node holds the seat.
|
|
|
|
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
|
|
// the seed and the messages all turn on these strings.
|
|
const (
|
|
ServiceManagerSeat = "node-service-manager"
|
|
PackageManagerSeat = "node-package-manager"
|
|
ContainerRuntimeSeat = "node-container-runtime"
|
|
)
|
|
|
|
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
|
|
// names them and no more. A process is supervised by the host itself, a file, a directory, an
|
|
// archive, a user or an action is the host's own act, and a module's other kinds reach the machine
|
|
// without a role in between — adding one here is a decision, not a refinement.
|
|
var appliedThrough = map[string]string{
|
|
"service": ServiceManagerSeat,
|
|
"package": PackageManagerSeat,
|
|
"container": ContainerRuntimeSeat,
|
|
}
|
|
|
|
// enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at
|
|
// composition is *reported* — in the resolution, in `status`, once in the log — and the node still
|
|
// resolves; on, it is refused like any unresolved requirement. Off until `status` reports none,
|
|
// which is when the three holders are assigned to every node: switching it before then would stop
|
|
// every machine lacking one from being sent anything at all.
|
|
//
|
|
// Switched on 2026-10-04, when `status` first reported no unmet dependency on any node: systemd,
|
|
// pacman and docker were assigned to all four machines that afternoon (novox/hq to-be 42).
|
|
//
|
|
// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it.
|
|
var enforceSeatDependencies = true
|
|
|
|
// EnforcingSeatDependencies sets the switch and returns what puts it back. For tests in other
|
|
// packages that hold the behaviour from before the switch; nothing else calls it.
|
|
func EnforcingSeatDependencies(on bool) (restore func()) {
|
|
was := enforceSeatDependencies
|
|
enforceSeatDependencies = on
|
|
return func() { enforceSeatDependencies = was }
|
|
}
|
|
|
|
// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5):
|
|
// the host and the private network. Registered as modules so they can be assigned, but what they
|
|
// declare is the installation's, not a module's, so it is never judged. The third piece, the
|
|
// bootstrap container runtime, is not a module at all: its package and service are in the genesis
|
|
// bundle the host applies itself, and never pass through a resolution here.
|
|
//
|
|
// The private network's module is overlay.Name, written out because the overlay package composes
|
|
// on top of this one; its resources are computed, which exempts it by the rule below as well.
|
|
var foundationModules = map[string]bool{
|
|
"mesh-host": true,
|
|
"mesh-wireguard": true,
|
|
}
|
|
|
|
// isFoundation is whether a module's declarations are the foundation's rather than its own. A
|
|
// module whose resources are computed is the mesh's by construction — the private network's peer
|
|
// list and its tools are the controller's, written per node — so it counts whatever its name.
|
|
func isFoundation(m Manifest) bool {
|
|
return foundationModules[m.Module] || m.Computed != ""
|
|
}
|
|
|
|
// DependsOn is every seat a module needs held on its node, derived from the resource types it
|
|
// declares itself (novox/hq ADR 0207 §2), sorted.
|
|
//
|
|
// **The module's own `resources` only.** What the controller composes around a module — its
|
|
// filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put there because the
|
|
// module is assigned, and depending on it would make the module answer for the mesh's choices.
|
|
func DependsOn(m Manifest) []string {
|
|
if isFoundation(m) {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, r := range m.Resources {
|
|
if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied {
|
|
seen[seat] = true
|
|
}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for s := range seen {
|
|
out = append(out, s)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// claimsSeat is whether a module claims a node seat, by its current name or one it used to have
|
|
// (ADR 0122), so a rename leaves the dependency met.
|
|
func claimsSeat(m Manifest, seat string) bool {
|
|
for _, c := range m.Claims {
|
|
if c.At() != ScopeNode {
|
|
continue
|
|
}
|
|
name := c.Name
|
|
if s, known := SeatNamed(name); known {
|
|
name = s.Name
|
|
}
|
|
if name == seat {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PossibleHolders is every module in the catalogue that claims a seat at node scope — what a
|
|
// refusal names as the remedy.
|
|
func PossibleHolders(catalogue map[string]Manifest, seat string) []string {
|
|
var out []string
|
|
for name, m := range catalogue {
|
|
if claimsSeat(m, seat) {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Unheld is one dependency of one module on a node that nothing on that node holds.
|
|
type Unheld struct {
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
Seat string `json:"seat"`
|
|
// Holders are the modules in the catalogue that could hold the seat: assigning one meets it.
|
|
Holders []string `json:"holders"`
|
|
}
|
|
|
|
// String is the line a refusal and a report both say, so the two never drift.
|
|
func (u Unheld) String() string {
|
|
remedy := "and no module in the catalogue claims it yet"
|
|
if len(u.Holders) > 0 {
|
|
remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ")
|
|
}
|
|
return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s",
|
|
u.Module, u.Node, u.Seat, u.Node, remedy)
|
|
}
|
|
|
|
// UnheldDependencies is every dependency of the modules in `judged` that the node's whole set
|
|
// leaves unmet (novox/hq ADR 0207 §3).
|
|
//
|
|
// **Judged over the whole set, never one module at a time.** The holders depend on each other:
|
|
// the service manager's own package needs the package manager, and the package manager's timer
|
|
// needs the service manager. Asked one by one, neither could ever be first; asked of the set, the
|
|
// two assigned together meet each other. A module holding a seat it depends on meets its own
|
|
// dependency. `judged` nil judges every module of the set.
|
|
func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld {
|
|
held := map[string]bool{}
|
|
for _, m := range set {
|
|
for seat := range seatsApplying() {
|
|
if claimsSeat(m, seat) {
|
|
held[seat] = true
|
|
}
|
|
}
|
|
}
|
|
var out []Unheld
|
|
for _, m := range set {
|
|
if judged != nil && !judged[m.Module] {
|
|
continue
|
|
}
|
|
for _, seat := range DependsOn(m) {
|
|
if held[seat] {
|
|
continue
|
|
}
|
|
out = append(out, Unheld{Node: node, Module: m.Module, Seat: seat,
|
|
Holders: PossibleHolders(catalogue, seat)})
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Module != out[j].Module {
|
|
return out[i].Module < out[j].Module
|
|
}
|
|
return out[i].Seat < out[j].Seat
|
|
})
|
|
return out
|
|
}
|
|
|
|
func seatsApplying() map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, s := range appliedThrough {
|
|
out[s] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not
|
|
// know contributes nothing, as it does to a resolution.
|
|
func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest {
|
|
var out []Manifest
|
|
seen := map[string]bool{}
|
|
for _, n := range names {
|
|
if m, known := catalogue[n]; known && !seen[n] {
|
|
seen[n] = true
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or
|
|
// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones
|
|
// included, leave unmet.
|
|
//
|
|
// **Only the new modules are judged.** A node already short of a holder is reported by `status`;
|
|
// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too.
|
|
//
|
|
// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the
|
|
// module that would meet it; with none registered there is no remedy to name, and refusing would
|
|
// stop every assignment of that kind until a module that does not exist yet is written. The answer
|
|
// still says it, and `status` reports it — before the switch and after it alike: there is never a
|
|
// remedy to name for it. The first return is those lines.
|
|
func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) {
|
|
set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))
|
|
judged := map[string]bool{}
|
|
for _, a := range adding {
|
|
judged[a] = true
|
|
}
|
|
var refused, said []string
|
|
for _, u := range UnheldDependencies(catalogue, node, set, judged) {
|
|
if len(u.Holders) == 0 {
|
|
said = append(said, u.String())
|
|
continue
|
|
}
|
|
refused = append(refused, u.String())
|
|
}
|
|
if len(refused) > 0 {
|
|
return said, &Refusal{Problems: append(refused,
|
|
fmt.Sprintf("holders that depend on each other are assigned together: `assign %s <module> <module>…`", node))}
|
|
}
|
|
return said, nil
|
|
}
|
|
|
|
// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing
|
|
// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while
|
|
// a module left there depends on it. Names the dependents, because they are what must go first —
|
|
// or the holder's replacement come.
|
|
func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error {
|
|
gone := map[string]bool{}
|
|
for _, r := range removing {
|
|
gone[r] = true
|
|
}
|
|
var left []string
|
|
for _, a := range assigned {
|
|
if !gone[a] {
|
|
left = append(left, a)
|
|
}
|
|
}
|
|
before := map[string]bool{}
|
|
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) {
|
|
before[u.Module+"\x00"+u.Seat] = true
|
|
}
|
|
dependents := map[string][]string{}
|
|
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) {
|
|
if before[u.Module+"\x00"+u.Seat] {
|
|
continue // unmet already; not this removal's doing
|
|
}
|
|
dependents[u.Seat] = append(dependents[u.Seat], u.Module)
|
|
}
|
|
if len(dependents) == 0 {
|
|
return nil
|
|
}
|
|
seats := make([]string, 0, len(dependents))
|
|
for s := range dependents {
|
|
seats = append(seats, s)
|
|
}
|
|
sort.Strings(seats)
|
|
var problems []string
|
|
for _, s := range seats {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+
|
|
"or assign another holder first", strings.Join(removing, ", "), s, node,
|
|
strings.Join(dependents[s], ", ")))
|
|
}
|
|
return &Refusal{Problems: problems}
|
|
}
|