ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk carries no composition rule. The catalogue functions only the node-engine's composition reaches (Resolve, Resolution's Compose, Declaration, DataItems, LeftOut, Rules and the rest, the settings, placeholder and template refusals, the rendering helpers) are copied with their tests into internal/compose/resolution; a method of a mesh-sdk shape becomes a function taking it first. The composition calls its own copies, and the captured catalogue its tests read lives here now, shared by mesh-compose's tests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
220 lines
8.5 KiB
Go
220 lines
8.5 KiB
Go
package resolution
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
|
|
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// 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": mcat.ServiceManagerSeat,
|
|
"package": mcat.PackageManagerSeat,
|
|
"container": mcat.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
|
|
|
|
// 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 mcat.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), from what it contributes to (ADR 0210), and from what a
|
|
// node seat it claims needs beside it — read from whichever manifest in `catalogue` declares that
|
|
// seat (novox/hq ADR 0220, ADR 0378 §8). Sorted.
|
|
//
|
|
// **The module's own `resources` only**, for the first source. 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(catalogue map[string]mcat.Manifest, m mcat.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
|
|
}
|
|
for _, seat := range neededBesideClaims(catalogue, 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 mcat.Manifest) []string {
|
|
var out []string
|
|
if e := m.Environment; e != nil && (len(e.Variables) > 0 || len(e.Path) > 0) {
|
|
out = append(out, mcat.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.
|
|
// A kind the seat only offers to place makes no dependency (novox/hq ADR 0255).
|
|
for _, c := range m.Contributions {
|
|
if s, r, ok := receivable(c.Seat, c.Kind); ok && r.Offered {
|
|
continue
|
|
} else if s.Name != "" {
|
|
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, mcat.BackupSeat)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// neededBesideClaims is every seat a node seat the module claims needs held on the same node
|
|
// (novox/hq ADR 0220, ADR 0378 §8): a module-declared seat's needs are read from whichever manifest
|
|
// in the catalogue declares that seat, never written again in the claimant's own manifest. Derived
|
|
// from the claim, as a resource's seat is derived from its type, so a module that claims the seat
|
|
// cannot leave the dependency out. A compiled seat carries no needs (novox/hq ADR 0378 §8).
|
|
func neededBesideClaims(catalogue map[string]mcat.Manifest, m mcat.Manifest) []string {
|
|
var out []string
|
|
for _, seat := range nodeSeatsClaimed(m) {
|
|
for _, candidate := range catalogue {
|
|
for _, s := range candidate.DefinesSeats {
|
|
if s.Name == seat {
|
|
out = append(out, s.Needs...)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 mcat.Manifest, seat string) bool {
|
|
for _, c := range m.Claims {
|
|
if c.At() != mcat.ScopeNode {
|
|
continue
|
|
}
|
|
name := c.Name
|
|
if s, known := mcat.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]mcat.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
|
|
}
|
|
|
|
// 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]mcat.Manifest, node string, set []mcat.Manifest, judged map[string]bool) []mcat.Unheld {
|
|
held := map[string]bool{}
|
|
for _, m := range set {
|
|
for _, seat := range nodeSeatsClaimed(m) {
|
|
held[seat] = true
|
|
}
|
|
}
|
|
var out []mcat.Unheld
|
|
for _, m := range set {
|
|
if judged != nil && !judged[m.Module] {
|
|
continue
|
|
}
|
|
for _, seat := range DependsOn(catalogue, m) {
|
|
if held[seat] {
|
|
continue
|
|
}
|
|
out = append(out, mcat.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 mcat.Manifest) []string {
|
|
var out []string
|
|
for _, c := range m.Claims {
|
|
if c.At() != mcat.ScopeNode {
|
|
continue
|
|
}
|
|
name := c.Name
|
|
if s, known := mcat.SeatNamed(name); known {
|
|
name = s.Name
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
return out
|
|
}
|