diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 465ee94..09568b6 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -389,6 +389,48 @@ func (a *Agent) others(except string) []delivery.Composed { return out } +// heldOut is what an apply or a removal holds out: others, and every module whose own composition the node's bucket holds +// and the agent does not run yet. A module that left the node's own composition for one of its own keeps its resources +// until that one is applied, so the node's own apply never removes them in between (issue 1945, live on 2026-10-25: +// shanks lost systemd-resolved's guard while its composition waited for a credential). The composition its want names, +// else its newest. Not part of Everything: what has not been applied is not what runs. +func (a *Agent) heldOut(except string) []delivery.Composed { + out := a.others(except) + held := map[string]bool{} + for _, c := range out { + held[c.Module] = true + } + waiting := map[string]compNow{} + for key, c := range a.comps { + _, m, fp, isNode, err := delivery.ParseComposedKey(key) + if err != nil || isNode || c.Err != nil || m == except || held[m] { + continue + } + if h, ok := a.running[m]; ok && h.Phase != delivery.PhaseRemoved { + continue + } + if w := a.wants[m]; w.Present && !w.Want.Removed && w.Want.FP == fp { + waiting[m] = c + continue + } + if w := a.wants[m]; w.Present && !w.Want.Removed && waiting[m].Rev != 0 && waiting[m].C.FP == w.Want.FP { + continue + } + if prev, ok := waiting[m]; !ok || c.Rev > prev.Rev { + waiting[m] = c + } + } + names := make([]string, 0, len(waiting)) + for m := range waiting { + names = append(names, m) + } + sort.Strings(names) + for _, m := range names { + out = append(out, waiting[m].C) + } + return out +} + // Everything is what the node runs: the compositions the agent runs, and the kept declaration's resources for each module // the agent does not hold yet. What the node judges its health by, and what the filling, the take act and the judgement // of directories read, is built from it. @@ -457,7 +499,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) { a.mu.Unlock() return // held for a failed backup, and no answer since } - others := a.others(module) + others := a.heldOut(module) a.mu.Unlock() a.apply(ctx, module, w, comp, others) } @@ -559,7 +601,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) { a.mu.Unlock() return } - others := a.others(module) + others := a.heldOut(module) a.mu.Unlock() res := a.env.Remove(ctx, module, others) a.mu.Lock() diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 3b7608a..f5f0596 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -640,3 +640,39 @@ func TestAHaveWithNoCompositionDefersToTheKeptDeclaration(t *testing.T) { t.Fatalf("applied %v", got) } } + +// A module that leaves the node's own composition for a composition of its own keeps its resources until that one is +// applied: the node's own apply holds out every module whose composition the node's bucket holds and the agent does not +// run yet (issue 1945, live on 2026-10-25 at 02:32: shanks' node-engine applied a node's own composition that no longer +// carried systemd-resolved, whose own composition waited for its credential, and removed its guard, so the machine lost +// its resolver). +func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing.T) { + b := newBed(t) + fp := fpOf("b") + // systemd-resolved's own composition is written; no want names it yet. + rk, rraw, _ := composition(t, "systemd-resolved", fp, false, file("systemd-resolved", "guard", "g")) + b.comps.before(rk, rraw) + nk, nraw, ndig := composition(t, NodeModule, "", false) + nwk, err := delivery.WantNodeKey(node) + if err != nil { + t.Fatal(err) + } + nwraw, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig}) + if err != nil { + t.Fatal(err) + } + b.comps.before(nk, nraw) + b.wants.before(nwk, nwraw) + stop := b.run() + defer stop() + eventually(t, "the node's own not applied", func() bool { return len(b.appliedList()) == 1 }) + b.mu.Lock() + defer b.mu.Unlock() + var names []string + for _, c := range b.others[0] { + names = append(names, c.Module) + } + if !strings.Contains(strings.Join(names, ","), "systemd-resolved") { + t.Fatalf("the others of the node's own apply were %v: systemd-resolved's resources would be removed", names) + } +}