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:
@@ -131,20 +131,85 @@ func TestAThirdModuleNeedsNoChangeToTheOthers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingCapabilityIsSaidToBeTheWrongMachine(t *testing.T) {
|
||||
// The remedy differs from a missing module and the message has to say which. Nothing can be
|
||||
// installed to give a server a seat.
|
||||
func TestADirectlyAssignedModuleTheMachineCannotHostIsReportedNotRefused(t *testing.T) {
|
||||
// A person put a display server on a seatless machine. The remedy differs from a missing module
|
||||
// and the message has to say which — nothing can be installed to give a server a seat. But it is
|
||||
// one module on the wrong machine, not a reason the whole node fails to resolve: it is kept out
|
||||
// of what the node runs and reported as un-applied, so the healthy modules beside it still
|
||||
// converge.
|
||||
server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}}
|
||||
_, err := Resolve(shelf(mod("xorg", nil, nil, []string{"seat"}, Claim{Name: "the-seat"})), []string{"xorg"}, server, World{})
|
||||
got, err := Resolve(shelf(mod("xorg", nil, nil, []string{"seat"}, Claim{Name: "the-seat"})), []string{"xorg"}, server, World{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("one un-hostable assignment refused the whole node: %v", err)
|
||||
}
|
||||
if len(got.Modules) != 0 {
|
||||
t.Errorf("xorg was declared on a machine that cannot run it: %v", names(got))
|
||||
}
|
||||
if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "xorg" {
|
||||
t.Fatalf("xorg was not reported as un-applied: %+v", got.Unhostable)
|
||||
}
|
||||
if len(got.Unhostable[0].Missing) != 1 || got.Unhostable[0].Missing[0] != "seat" {
|
||||
t.Errorf("the missing capability was not named: %+v", got.Unhostable[0])
|
||||
}
|
||||
if !strings.Contains(WrongMachine("xorg", "seat", "server"), "wrong machine") {
|
||||
t.Errorf("the report reads like a missing module")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnhostableModuleSomethingRequiresRefusesTheNode(t *testing.T) {
|
||||
// The other side of the distinction. A module the machine cannot host that is *required* by
|
||||
// something running here makes the set incoherent: the requiring module cannot have its
|
||||
// requirement met on this machine, so it is refused rather than quietly declared without it.
|
||||
server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}}
|
||||
_, err := Resolve(shelf(
|
||||
mod("desktop", nil, []string{"display-server"}, nil),
|
||||
mod("xorg", []string{"display-server"}, nil, []string{"seat"}),
|
||||
), []string{"desktop"}, server, World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("a display server was assigned to a machine with no seat")
|
||||
t.Fatal("a node requiring a module the machine cannot host was resolved")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wrong machine") {
|
||||
t.Errorf("the refusal reads like a missing module: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneUnhostableAssignmentDoesNotTakeDownTheHealthyModules(t *testing.T) {
|
||||
// The bug the whole-mesh dry-run found: fail2ban declares a capability the host lacks, and its
|
||||
// one un-hostable assignment refused the entire node's resolution — so a push refused to send
|
||||
// the healthy modules beside it too. The healthy modules must still resolve and converge; the
|
||||
// un-hostable one is reported as un-applied, neither silently dropped nor fatal to the rest.
|
||||
server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}}
|
||||
got, err := Resolve(shelf(
|
||||
mod("web", nil, nil, nil),
|
||||
mod("cache", nil, nil, nil),
|
||||
mod("cron", nil, nil, nil),
|
||||
// The one on the wrong machine: it needs a firewall the machine's profile does not report.
|
||||
mod("fail2ban", nil, nil, []string{"firewall"}),
|
||||
), []string{"web", "cache", "cron", "fail2ban"}, server, World{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("one un-hostable assignment refused the whole node: %v", err)
|
||||
}
|
||||
// The healthy three resolved.
|
||||
if got := names(got); len(got) != 3 {
|
||||
t.Fatalf("the healthy modules did not all resolve: %v", got)
|
||||
}
|
||||
for _, m := range got.Modules {
|
||||
if m.Module == "fail2ban" {
|
||||
t.Error("fail2ban was declared on a machine that cannot run it")
|
||||
}
|
||||
}
|
||||
// The un-hostable one is reported, not silently dropped.
|
||||
if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "fail2ban" {
|
||||
t.Fatalf("fail2ban was not reported as un-applied: %+v", got.Unhostable)
|
||||
}
|
||||
if len(got.Unhostable[0].Missing) != 1 || got.Unhostable[0].Missing[0] != "firewall" {
|
||||
t.Errorf("the missing capability was not named: %+v", got.Unhostable[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshWideClaimIsHeldByOneNode(t *testing.T) {
|
||||
// The hub, said as a claim rather than hard-coded. Another node already holds it, so this one
|
||||
// cannot.
|
||||
@@ -242,12 +307,17 @@ func TestWhatAServiceReflectsIsQualifiedToo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEveryReasonIsGivenAtOnce(t *testing.T) {
|
||||
// Somebody resolving these fixes them in one pass or in four.
|
||||
server := Node{Name: "server", Capabilities: map[string]bool{}}
|
||||
// Somebody resolving these fixes them in one pass or in three. Every reason that genuinely
|
||||
// refuses the set is collected, rather than only the first: a claim two modules both take, a
|
||||
// requirement nothing answers, and a requirement several answer without anybody choosing.
|
||||
_, err := Resolve(shelf(
|
||||
mod("xorg", nil, nil, []string{"seat"}, Claim{Name: "the-seat"}),
|
||||
mod("wayland", nil, nil, []string{"seat"}, Claim{Name: "the-seat"}),
|
||||
), []string{"xorg", "wayland"}, server, World{})
|
||||
mod("xorg", nil, nil, nil, Claim{Name: "the-seat"}),
|
||||
mod("wayland", nil, nil, nil, Claim{Name: "the-seat"}),
|
||||
mod("i3", nil, []string{"compositor"}, nil),
|
||||
mod("editor", nil, []string{"shell"}, nil),
|
||||
mod("bash", []string{"shell"}, nil, nil),
|
||||
mod("zsh", []string{"shell"}, nil, nil),
|
||||
), []string{"xorg", "wayland", "i3", "editor"}, workstation(), World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected refusals")
|
||||
|
||||
Reference in New Issue
Block a user