Files
mesh-controller/internal/catalogue/seat_dependencies.go
T
jochen 35314175f2 Refuse an unmet seat dependency the catalogue could meet (hq ADR 0207 §4)
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.
2026-10-04 13:07:45 +02:00

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}
}