Resolve: one un-hostable assignment no longer refuses the whole node
A module a person assigns to a machine that cannot host it — its declared capability has no detector there, as fail2ban does on a host with no firewall — made Resolve refuse the entire node, so a whole-node push refused to send the healthy modules beside it too. One module on the wrong machine took down every other module on that node. Assign already keeps such an assignment on purpose (it is what a person meant, and acts.go says so), so the fix is on the resolve/push side: a directly-assigned module the machine cannot host is left out of the closure and reported as un-applied on the Resolution, rather than refusing the set. The healthy modules still resolve, declare, and converge. A module that is *required* by something running here and cannot be hosted still refuses — that set is genuinely incoherent — so the distinction is who wanted it. assign, plan and push now name the un-applied module and the missing capability, via a shared WrongMachine message, so it is neither silently dropped nor fatal. Reconciled two tests that encoded the old whole-node refusal for directly-assigned un-hostable modules; added coverage for the healthy-modules-still-converge case and the required-un-hostable-still-refuses distinction. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -113,6 +113,21 @@ type Resolution struct {
|
||||
// because it is where a credential will have to be handed back once there is a mechanism for
|
||||
// that, and because "what does this machine depend on that is not on it" has no other answer.
|
||||
Needs []Needed
|
||||
// Unhostable is what a person assigned to this machine that this machine cannot run — a module
|
||||
// whose declared capability has no detector here. Kept out of Modules rather than refusing the
|
||||
// whole set: a module put on the wrong machine is that one module's problem, and the healthy
|
||||
// modules beside it still resolve, declare, and converge. Reported so it is neither silently
|
||||
// dropped nor fatal to the rest. A module that is *required* by something running here is a
|
||||
// different case — that set is incoherent and is refused (see checkCapabilities).
|
||||
Unhostable []Unhostable
|
||||
}
|
||||
|
||||
// Unhostable is one directly-assigned module the machine cannot run.
|
||||
type Unhostable struct {
|
||||
// Module is the assigned module the machine cannot host.
|
||||
Module string
|
||||
// Missing is the capabilities it declares that this machine does not have.
|
||||
Missing []string
|
||||
}
|
||||
|
||||
// Needed is one thing this node's set takes from elsewhere in the mesh.
|
||||
@@ -212,16 +227,32 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
// 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...)
|
||||
// Everything a person assigned goes in first, except what this machine cannot run. Those are
|
||||
// choices already made, and a requirement one of them answers is not a choice to put back to
|
||||
// anybody.
|
||||
//
|
||||
// **A module the machine cannot host is left out here rather than refusing the whole set.**
|
||||
// Assigning fail2ban to a machine whose profile reports no firewall is one module on the wrong
|
||||
// machine (novox/hq ADR 0009's sibling case): the person meant something, the remedy is theirs,
|
||||
// and it is a fact about this one module — not a reason the healthy modules beside it should
|
||||
// fail to resolve and converge. It is reported as un-applied on the resolution, so it is neither
|
||||
// silently dropped nor fatal to the rest. The distinction is who wanted it: a module *required*
|
||||
// by something running here that cannot be hosted makes the set itself incoherent, and that is
|
||||
// still refused, by checkCapabilities, where it stays in the closure.
|
||||
var unhostable []Unhostable
|
||||
var queue []string
|
||||
for _, a := range assigned {
|
||||
because[a] = "assigned"
|
||||
if m, known := catalogue[a]; known {
|
||||
if missing := missingCapabilities(m, node); len(missing) > 0 {
|
||||
unhostable = append(unhostable, Unhostable{Module: a, Missing: missing})
|
||||
continue
|
||||
}
|
||||
for _, o := range m.Offers() {
|
||||
satisfied[o] = true
|
||||
}
|
||||
}
|
||||
because[a] = "assigned"
|
||||
queue = append(queue, a)
|
||||
}
|
||||
|
||||
// What has already been complained about. A requirement can be wanted by several modules at
|
||||
@@ -473,7 +504,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
}
|
||||
}
|
||||
|
||||
resolution := Resolution{Node: node.Name, At: node.At, Because: because, Needs: needs}
|
||||
resolution := Resolution{Node: node.Name, At: node.At, Because: because, Needs: needs,
|
||||
Unhostable: unhostable}
|
||||
for _, n := range providersFirst(order, catalogue) {
|
||||
resolution.Modules = append(resolution.Modules, catalogue[n])
|
||||
}
|
||||
@@ -519,19 +551,42 @@ func isModule(catalogue map[string]Manifest, want string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// checkCapabilities refuses a module the machine cannot run.
|
||||
// missingCapabilities is the capabilities a module declares that this machine does not have.
|
||||
//
|
||||
// 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.
|
||||
// Only the absent ones: a capability the machine reports having is nothing to say. Empty means the
|
||||
// machine can host the module as far as its capabilities go.
|
||||
func missingCapabilities(m Manifest, node Node) []string {
|
||||
var missing []string
|
||||
for _, c := range m.Capabilities {
|
||||
if !node.Capabilities[c] {
|
||||
missing = append(missing, c)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// WrongMachine is why a module cannot run here, said as a fact about the machine.
|
||||
//
|
||||
// Said the same whether a module was refused because something running here requires it or reported
|
||||
// as un-applied because a person assigned it directly: the reason is identical, and nothing can be
|
||||
// installed to change it.
|
||||
func WrongMachine(module, capability, node string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s needs the capability %q and %s does not have it — this is the wrong machine, "+
|
||||
"not a missing module", module, capability, node)
|
||||
}
|
||||
|
||||
// checkCapabilities refuses a module the machine cannot run that something running here requires.
|
||||
//
|
||||
// A directly-assigned module the machine cannot host is left out of the closure before this runs
|
||||
// and reported as un-applied instead (see Resolve); what reaches here is a module still in the
|
||||
// closure because something requires it, and that set is genuinely incoherent — the requiring
|
||||
// module cannot have its requirement met on this machine.
|
||||
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))
|
||||
}
|
||||
for _, c := range missingCapabilities(m, node) {
|
||||
problems = append(problems, WrongMachine(m.Module, c, node.Name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
|
||||
Reference in New Issue
Block a user