diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 8eb83d8..fd1432e 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -347,8 +347,19 @@ func (sc *Scope) placedOwner(a store.Applied) string { } // owns reports whether a record belongs to the scope's module. +// +// In a module's own apply, a record the node's own composition made for the module while it was folded (recorded as the +// node's, its id naming the module) is the module's too: it is the only apply that may remove it once neither +// composition declares it (issue 774). One the node's own still declares stays, as another's held resource does. func (sc *Scope) owns(a store.Applied) bool { - return store.OwnerOf(a, declaration.ModuleGuess) == sc.Module + if store.OwnerOf(a, declaration.ModuleGuess) == sc.Module { + return true + } + if sc.Module == store.NodeModule || a.Module != store.NodeModule { + return false + } + m, ok := declaration.ModuleGuess(a.ID) + return ok && m == sc.Module } // isNode is whether the scope is the node's own composition, the one that carries identities, groups, libraries and @@ -473,6 +484,29 @@ func applyScoped( } } + // **A resource the node's own carried for a module that now has a composition of its own is that module's to remove** + // (issue 774, 2026-10-30 on the bus node): recorded as the node's while the module was folded, it is no longer in the + // node's own composition and no longer in the module's own either (restic's measure, replaced by awake). The node's own + // apply leaves it, record and all, and says so; the module's own apply sees it as its own (Scope.owns) and removes it. + // Refused instead, the node's own applied nothing at all, every two minutes, for as long as the record stayed. + takenByItsModule := map[string]string{} + if scope != nil && scope.Module == store.NodeModule { + composes := map[string]bool{} + for _, r := range scope.Others { + if m := r.DeclaredModule(); m != "" && m != store.NodeModule { + composes[m] = true + } + } + for _, orphan := range known.Orphans(declared, origin) { + if orphan.Module != store.NodeModule { + continue + } + if m, ok := declaration.ModuleGuess(orphan.ID); ok && composes[m] { + takenByItsModule[orphan.ID] = m + } + } + } + // **A module a want still places here is never removed by another's apply** (issue 774): its resources go only with // its own apply or its want's removal. One that would is refused whole, before anything is changed, naming them: a // composition that leaves them out lost what runs, and removing what runs is the worst a node can do with that. @@ -485,6 +519,9 @@ func applyScoped( if _, kept := heldByOthers[orphan.ID]; kept { continue } + if _, kept := takenByItsModule[orphan.ID]; kept { + continue + } if m := scope.placedOwner(orphan); m != "" { refused = append(refused, orphan.ID+" of "+m) } @@ -513,6 +550,16 @@ func applyScoped( log(fmt.Sprintf(" kept %s (%s): %s", orphan.ID, orphan.Target, detail)) return nil } + if module, taken := takenByItsModule[orphan.ID]; taken { + detail := "no longer declared here; " + module + " has a composition of its own here, whose apply removes it, " + + "so it was left as it is" + report.Outcomes = append(report.Outcomes, Outcome{ + ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, + Action: "kept", Detail: detail, + }) + log(fmt.Sprintf(" kept %s (%s): %s", orphan.ID, orphan.Target, detail)) + return nil + } if by, held := heldFiles[fileKey(orphan)]; held && by != orphan.ID { if scope != nil && !declared[by] { // Another module declares the path: it is that module's, so this record is let go and the file is diff --git a/internal/apply/module_scope_test.go b/internal/apply/module_scope_test.go index 51e42a5..1bdb768 100644 --- a/internal/apply/module_scope_test.go +++ b/internal/apply/module_scope_test.go @@ -125,12 +125,15 @@ func TestARecordWithoutAnOwnerBelongsToTheModuleItsIdNames(t *testing.T) { // want's removal lets it go. func TestAResourceThatMovedToItsModulesCompositionIsLeftByTheNodesOwnApply(t *testing.T) { for _, tc := range []struct { - name string - id string // the resource the node's own composition carried, and no longer does - moved bool // its module's own composition now declares it (Others) - placed bool // a want still places its module here - refused bool // the node's own apply is refused whole - stays bool // the resource is still on the machine and recorded + name string + id string // the resource the node's own composition carried, and no longer does + moved bool // its module's own composition now declares it (Others) + // dropped: its module has a composition of its own here that no longer declares it (Others holds another of its + // resources); the node's own apply leaves it, and the module's own apply removes it. + dropped bool + placed bool // a want still places its module here + refused bool // the node's own apply is refused whole + stays bool // the resource is still on the machine and recorded // wants names the modules whose want says removed (Scope.RemovedByWant); nil leaves the guard to Placed alone. wants []string }{ @@ -141,6 +144,11 @@ func TestAResourceThatMovedToItsModulesCompositionIsLeftByTheNodesOwnApply(t *te // it; in the node's own apply only a module a want removed loses its resources. {name: "a folded module with no want", id: "nats.server-conf", wants: []string{}, refused: true, stays: true}, {name: "a folded module a want removed", id: "nats.server-conf", wants: []string{"nats", "old"}}, + // 2026-10-30 19:42 on the bus node: restic's manifest replaced its process measure by awake. Its record was the + // node's, carried when restic was folded, so restic's own apply never saw it and the node's own was refused + // whole every two minutes, applying nothing of the node's own. + {name: "its module's own composition dropped it", id: "restic.measure", dropped: true, placed: true, + wants: []string{"old"}, stays: true}, } { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() @@ -159,6 +167,11 @@ func TestAResourceThatMovedToItsModulesCompositionIsLeftByTheNodesOwnApply(t *te moved := declare(t, `{"id":"`+tc.id+`","type":"file","path":"`+fact+`","content":"mesh\n","module":"`+module+`"}`) scope.Others = moved.Resources } + if tc.dropped { + later := declare(t, `{"id":"`+module+`.awake","type":"file","path":"`+filepath.Join(dir, "awake")+ + `","content":"a\n","module":"`+module+`"}`) + scope.Others = later.Resources + } if tc.placed { scope.Placed = map[string]bool{module: true} } @@ -188,6 +201,22 @@ func TestAResourceThatMovedToItsModulesCompositionIsLeftByTheNodesOwnApply(t *te if exists(gone) == !tc.refused { t.Fatalf("a resource no composition holds any more exists %v, with the apply refused %v", exists(gone), tc.refused) } + if tc.dropped { + if o := outcomeOf(report, tc.id); o.Action != "kept" { + t.Fatalf("the node's own apply did not say it left it: %+v", o) + } + later := declare(t, `{"id":"`+module+`.awake","type":"file","path":"`+filepath.Join(dir, "awake")+ + `","content":"a\n","module":"`+module+`"}`) + _, last, err := ApplyModule(context.Background(), archHost(t), later, Scope{Module: module}, after, + store.OriginDeclared, noServices, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, still := last.Find(tc.id); still || exists(fact) { + t.Fatalf("the module's own apply left what it no longer declares: recorded %v, on the machine %v", + still, exists(fact)) + } + } }) } }