A module's data section says what it keeps and how precious it is; the backup holder's lines, binding stickiness, retirement on unassign and D13's conditions follow from it, so issue 273's empty replacement is said and an unassigned module's data is remembered, not forgotten.
358 lines
14 KiB
Go
358 lines
14 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// A module depends on the node seats that apply its resources (novox/hq ADR 0207) and on the
|
|
// seats it contributes to (novox/hq ADR 0210). A third source — what a seat it holds needs beside it
|
|
// (novox/hq ADR 0220) — had one user, node-resolver-config needing node-uplink, and went with that
|
|
// seat when the resolver file became the uplink's own (novox/hq ADR 0223).
|
|
//
|
|
// 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
|
|
}
|
|
}
|
|
for _, seat := range contributedTo(m) {
|
|
seen[seat] = true
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for s := range seen {
|
|
out = append(out, s)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// contributedTo is every seat a module contributes to (novox/hq ADR 0210 §3): a contribution is
|
|
// configuration only the seat's holder applies, so it is a dependency on that seat exactly as a
|
|
// resource is on the seat that applies it. The environment goes to node-environment's holder
|
|
// (ADR 0203); shell code to the holder that places it for its target — the login shell's for a
|
|
// shell, the display server's for the session's start and resources (ADR 0204, ADR 0208 §4).
|
|
func contributedTo(m Manifest) []string {
|
|
var out []string
|
|
if e := m.Environment; e != nil && (len(e.Variables) > 0 || len(e.Path) > 0) {
|
|
out = append(out, EnvironmentSeat)
|
|
}
|
|
for _, c := range m.Shell {
|
|
out = append(out, placerOf(c.For))
|
|
}
|
|
// Any other seat's contribution (novox/hq ADR 0212 §4), by the seat's canonical name; one the
|
|
// mesh does not define is refused at registration and depends on nothing here.
|
|
for _, c := range m.Contributions {
|
|
if s, known := SeatNamed(c.Seat); known {
|
|
out = append(out, s.Name)
|
|
}
|
|
}
|
|
// And the backup holder for what is irreplaceable (novox/hq ADR 0233): it backs it up or watches the
|
|
// array it is on. What is valuable is backed up where a holder is — the standard plan — and makes
|
|
// no machine refuse it for want of one.
|
|
if m.NeedsBackupHolder() {
|
|
out = append(out, BackupSeat)
|
|
}
|
|
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 nodeSeatsClaimed(m) {
|
|
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
|
|
}
|
|
|
|
// nodeSeatsClaimed is every node seat a module claims, each by its current name (ADR 0122), so
|
|
// a dependency on any of them — a resource's or a contribution's — is met by the claim.
|
|
func nodeSeatsClaimed(m Manifest) []string {
|
|
var out []string
|
|
for _, c := range m.Claims {
|
|
if c.At() != ScopeNode {
|
|
continue
|
|
}
|
|
name := c.Name
|
|
if s, known := SeatNamed(name); known {
|
|
name = s.Name
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
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}
|
|
}
|
|
|
|
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
|
|
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235).
|
|
//
|
|
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
|
|
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
|
|
// A collision is not an order anything can complete — no further assignment makes two owners of one
|
|
// package one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
|
|
// it from the mesh. Only collisions involving a module being added are refused; one already on the
|
|
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
|
|
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
|
|
before := map[string]bool{}
|
|
for _, p := range checkResources(manifestsOf(catalogue, assigned)) {
|
|
before[p] = true
|
|
}
|
|
var problems []string
|
|
for _, p := range checkResources(manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))) {
|
|
if before[p] {
|
|
continue // on the node already; not this assignment's doing
|
|
}
|
|
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
|
|
"depends on the owner's seat instead)")
|
|
}
|
|
if len(problems) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(problems)
|
|
return &Refusal{Problems: append(problems, fmt.Sprintf("nothing was assigned to %s", node))}
|
|
}
|