From e2b6f371684936f7d3296dd96d6126362626f9c6 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 16:16:32 +0200 Subject: [PATCH] Apply one module at a time: a scope of the module and what the others hold, and an owner record, so a removal is the module's own (R6-A A3, A4) --- internal/apply/apply.go | 98 +++++++++++++++++++++--- internal/apply/module_scope_test.go | 113 ++++++++++++++++++++++++++++ internal/store/store.go | 31 ++++++++ 3 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 internal/apply/module_scope_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index a22e345..83faeeb 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -300,6 +300,60 @@ func ApplyMindingWindows( unseal Unseal, keep Keep, windows *Windows, +) (Report, store.State, error) { + return applyScoped(ctx, sys, d, nil, known, origin, run, log, unseal, keep, windows) +} + +// Scope is what makes an apply one module's (novox/hq ADR 0429 §4, ADR 0539 §9): the module whose want changed, and +// the resources the node's other modules hold, so a path, a unit or a group another module still asks for is neither +// removed nor taken from it. Only that module's own records are orphans; a removed module is a Scope with an empty +// declaration. +type Scope struct { + // Module is the module applied. NodeModule (store.NodeModule) is the node's own composition. + Module string + // Others are the resources of every other module the node has a composition for. + Others []declaration.Resource +} + +// owns reports whether a record belongs to the scope's module. +func (sc *Scope) owns(a store.Applied) bool { + return store.OwnerOf(a, declaration.ModuleGuess) == sc.Module +} + +// isNode is whether the scope is the node's own composition, the one that carries identities, groups, libraries and +// the adoption. +func (sc *Scope) isNode() bool { return sc == nil || sc.Module == store.NodeModule } + +// ApplyModule makes the machine match one module's declaration, and only that module's: everything else the node +// runs is left as it is. d holds the module's resources; scope names the module and what the others hold. +func ApplyModule( + ctx context.Context, + sys system.System, + d *declaration.Declaration, + scope Scope, + known store.State, + origin string, + run Runner, + log func(string), + unseal Unseal, + keep Keep, + windows *Windows, +) (Report, store.State, error) { + return applyScoped(ctx, sys, d, &scope, known, origin, run, log, unseal, keep, windows) +} + +func applyScoped( + ctx context.Context, + sys system.System, + d *declaration.Declaration, + scope *Scope, + known store.State, + origin string, + run Runner, + log func(string), + unseal Unseal, + keep Keep, + windows *Windows, ) (report Report, _ store.State, _ error) { // Said whichever way the apply ended, failure included: a window being open is a fact about the // machine, not about this apply. @@ -326,7 +380,9 @@ func ApplyMindingWindows( } // What an unfinished apply found a unit as is kept only while its service is declared: one // declared again later is read afresh, as any resource with no record is (novox/hq ADR 0118). - known.DropFoundUndeclared(declared, origin) + if scope == nil { + known.DropFoundUndeclared(declared, origin) + } // Which units the mesh made, read before any removal can take a unit file's record away — so // whichever order a module declared a unit and its file in, the unit is known for the mesh's @@ -351,7 +407,11 @@ func ApplyMindingWindows( // it back would stop or disable a unit the declaration says is running, every container on it // with it, only for the declared one to start it again in the same apply. The record is // forgotten; the unit's found state is the remaining record's to give back. - heldUnits := unitsHeld(d.Resources) + everything := d.Resources + if scope != nil { + everything = append(append([]declaration.Resource{}, d.Resources...), scope.Others...) + } + heldUnits := unitsHeld(everything) // **A whole file still declared at its path by another resource is handed to it, never removed // first** (novox/hq ADR 0223). A file can change owners between modules — the machine's resolver @@ -361,13 +421,13 @@ func ApplyMindingWindows( // and stayed so until the new resource's turn came, with every resource in between applied on a // machine without it. The record going is kept until the one declaring the path now is recorded // in its place: then it is forgotten, and what it kept of the original goes with the file. - heldFiles := filesHeld(d.Resources) + heldFiles := filesHeld(everything) handedFiles := map[string]store.Applied{} // **A group still asked for by another resource is not given back when one record of it goes** // (novox/hq ADR 0252), on the unit's rule above: two modules may each want the account in one group, // and the one going takes nothing the other still needs. - wanted := groupsWanted(d.Resources) + wanted := groupsWanted(everything) // And what a record going hands to a resource that still asks for its group (novox/hq ADR 0252 §3). handed := Handed{} @@ -375,6 +435,18 @@ func ApplyMindingWindows( var action, detail string var err error 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 + // neither removed nor taken from it (ADR 0539 §9). + known.Forget(orphan.ID) + detail := "no longer declared; " + by + " of another module still holds the path, so it was left as it is" + report.Outcomes = append(report.Outcomes, Outcome{ + ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, + Action: "forgotten", Detail: detail, + }) + log(fmt.Sprintf(" forgotten %s (%s): %s", orphan.ID, orphan.Target, detail)) + return nil + } handedFiles[by] = orphan return nil } @@ -482,6 +554,9 @@ func ApplyMindingWindows( } handover := map[string][]store.Applied{} for _, orphan := range known.Orphans(declared, origin) { + if scope != nil && !scope.owns(orphan) { + continue + } if d.Adoption == nil && strings.HasPrefix(orphan.ID, declaration.AdoptionPrefix) { protecting = append(protecting, orphan) continue @@ -538,7 +613,7 @@ func ApplyMindingWindows( // what is assigned: a carried bundle's silence is not an unassignment. if origin == store.OriginDeclared { for _, h := range append([]store.Held{}, known.Held...) { - if declared[h.ID] { + if declared[h.ID] || (scope != nil && h.Module != scope.Module) { continue } if slices.Contains(d.LeftOut, h.Module) { @@ -745,7 +820,7 @@ func ApplyMindingWindows( in.held = heldRoots(verdicts, d.Libraries) // A tool runner no longer put in a group leaves it before anything is applied, so its unit, which names the // change under restart-on, is restarted in this same apply to lose it (ADR 0328 §11, ADR 0334 §2, ADR 0252 §3). - if origin == store.OriginDeclared { + if origin == store.OriginDeclared && scope.isNode() { for _, o := range leaveRunners(ctx, sys, d.Libraries, d.Identities, &known, run, wanted) { if o.Action == "restored" { changed[o.changes] = true @@ -1037,6 +1112,11 @@ func ApplyMindingWindows( } // Only now. The record follows the fact, never leads it. recordApplied(&known, origin, resource, outcome, kept, keptMode, keptOwner) + if scope != nil { + known.SetModule(resource.Identity(), scope.Module) + } else if module, ours := d.ModuleOfID(resource.Identity()); ours { + known.SetModule(resource.Identity(), module) + } appliedOK[resource.Identity()] = true // Its module has been taken, and what was held for it is now the mesh's. A file written // into, or a service whose lifecycle is the machine's, replaced nothing that was found, so @@ -1139,7 +1219,7 @@ func ApplyMindingWindows( } // Only the mesh says what is assigned: a carried bundle's silence undeclares no identity and no folder. - if origin == store.OriginDeclared { + if origin == store.OriginDeclared && scope.isNode() { // And an identity, then a library group, no longer declared goes, once what ran as it went above (ADR 0328 // §3, §4). if tool, err := toolFor(sys); err == nil { @@ -1158,7 +1238,7 @@ func ApplyMindingWindows( // the mesh's derived filter among it — applied cleanly (novox/hq ADR 0100), and on every // converged apply, not once (ADR 0168). Skipped, it is said: a step that does nothing is never // silent (issue 143). - if len(failures) == 0 { + if len(failures) == 0 && scope.isNode() { did, err := retireFirewall(ctx, d, origin, &known, run, log) if err != nil { return report, known, &Error{Resource: "the firewall found on this machine", Err: err, Done: report} @@ -1169,7 +1249,7 @@ func ApplyMindingWindows( return report, known, err } } - } else if rec := known.Firewall; origin == store.OriginDeclared && d.Adoption == nil && rec != nil && + } else if rec := known.Firewall; len(failures) > 0 && scope.isNode() && origin == store.OriginDeclared && d.Adoption == nil && rec != nil && rec.Kind == string(firewall.UFW) && rec.WasActive && firewall.Active(ctx, run) { report.Firewall = fmt.Sprintf("left in force: %d resource(s) failed, and the found firewall is retired only after a clean apply", len(failures)) log(" kept ufw in force: " + report.Firewall) diff --git a/internal/apply/module_scope_test.go b/internal/apply/module_scope_test.go new file mode 100644 index 0000000..2ad9d20 --- /dev/null +++ b/internal/apply/module_scope_test.go @@ -0,0 +1,113 @@ +package apply + +import ( + "context" + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// R6-A AC3: given two modules declaring one path, when one module's want changes, the shared path is neither removed +// nor taken from the other module (novox/hq ADR 0539 §9). +func TestASharedPathIsNeitherRemovedNorTakenFromTheOtherModule(t *testing.T) { + dir := t.TempDir() + shared := filepath.Join(dir, "shared.conf") + own := filepath.Join(dir, "a-own.conf") + a := declare(t, `{"id":"a.shared","type":"file","path":"`+shared+`","content":"x\n","module":"a"}, + {"id":"a.own","type":"file","path":"`+own+`","content":"a\n","module":"a"}`) + b := declare(t, `{"id":"b.shared","type":"file","path":"`+shared+`","content":"x\n","module":"b"}`) + + state := store.State{} + var err error + _, state, err = ApplyModule(context.Background(), archHost(t), a, Scope{Module: "a", Others: b.Resources}, + state, store.OriginDeclared, noServices, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + _, state, err = ApplyModule(context.Background(), archHost(t), b, Scope{Module: "b", Others: a.Resources}, + state, store.OriginDeclared, noServices, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + + // A's want changes: it no longer declares the shared path, and b still does. + a2 := declare(t, `{"id":"a.own","type":"file","path":"`+own+`","content":"a\n","module":"a"}`) + report, state, err := ApplyModule(context.Background(), archHost(t), a2, Scope{Module: "a", Others: b.Resources}, + state, store.OriginDeclared, noServices, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if !exists(shared) { + t.Fatal("the path another module still declares was removed") + } + if _, still := state.Find("b.shared"); !still { + t.Fatal("the other module's record was taken away") + } + if _, still := state.Find("a.shared"); still { + t.Fatal("the module's own record of the shared path was kept") + } + if o := outcomeOf(report, "a.shared"); o.Action != "forgotten" { + t.Fatalf("the shared path: %+v", o) + } +} + +// R6-A AC4: given modules A and B on a node, when A's want is removed, only A's resources are removed and B is +// untouched. +func TestARemovedModuleTakesOnlyItsOwnResources(t *testing.T) { + dir := t.TempDir() + fa, fb := filepath.Join(dir, "a.conf"), filepath.Join(dir, "b.conf") + d := declare(t, `{"id":"a.conf","type":"file","path":"`+fa+`","content":"a\n","module":"a"}, + {"id":"b.conf","type":"file","path":"`+fb+`","content":"b\n","module":"b"}`) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if rec, _ := state.Find("a.conf"); rec.Module != "a" { + t.Fatalf("the owner was not recorded: %+v", rec) + } + + gone := &declaration.Declaration{} + b := declare(t, `{"id":"b.conf","type":"file","path":"`+fb+`","content":"b\n","module":"b"}`) + report, state, err := ApplyModule(context.Background(), archHost(t), gone, Scope{Module: "a", Others: b.Resources}, + state, store.OriginDeclared, noServices, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if exists(fa) || !exists(fb) { + t.Fatalf("a's file exists %v, b's file exists %v", exists(fa), exists(fb)) + } + if o := outcomeOf(report, "a.conf"); o.Action != "removed" { + t.Fatalf("a's file: %+v", o) + } + if _, still := state.Find("b.conf"); !still { + t.Fatal("b's record was removed") + } + if _, still := state.Find("a.conf"); still { + t.Fatal("a's record is still there") + } +} + +// A record from before the owner was kept is read by its id, so the first removal after the switch is the module's too. +func TestARecordWithoutAnOwnerBelongsToTheModuleItsIdNames(t *testing.T) { + dir := t.TempDir() + fa, fb := filepath.Join(dir, "a.conf"), filepath.Join(dir, "b.conf") + d := declare(t, `{"id":"a.conf","type":"file","path":"`+fa+`","content":"a\n"}, + {"id":"b.conf","type":"file","path":"`+fb+`","content":"b\n"}`) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + for i := range state.Resources { + state.Resources[i].Module = "" + } + _, state, err = ApplyModule(context.Background(), archHost(t), &declaration.Declaration{}, Scope{Module: "a"}, + state, store.OriginDeclared, noServices, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if exists(fa) || !exists(fb) { + t.Fatalf("a's file exists %v, b's file exists %v", exists(fa), exists(fb)) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 3726606..5755b81 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -34,6 +34,10 @@ const DefaultPath = "/var/lib/mesh-host/state.json" type Applied struct { ID string `json:"id"` Type string `json:"type"` + // Module is the module whose want this resource was applied for (novox/hq ADR 0429 §4, ADR 0539 §9): the owner + // record that limits a removal to the module whose want changed. Absent on a record from before; then the + // module is read from the id (OwnerOf). + Module string `json:"module,omitempty"` // Origin is who asked for this: the bundle this host carries, or the mesh. // // Recorded because the two must not remove each other. A node raises its own foundation from @@ -680,6 +684,33 @@ func (s State) Orphans(declared map[string]bool, origin string) []Applied { return out } +// NodeModule is the owner of what belongs to no module: the node's own resources (adoption, firewall openings, +// accounts, the resolver, identities and groups; novox/hq ADR 0539 §2). +const NodeModule = "node" + +// OwnerOf is the module a record belongs to: the one recorded with it, else the one its id names, else the node. +// guess reads a module from an id (declaration.ModuleGuess), kept as a parameter so the store imports nothing. +func OwnerOf(a Applied, guess func(id string) (string, bool)) string { + if a.Module != "" { + return a.Module + } + if guess != nil { + if m, ok := guess(a.ID); ok { + return m + } + } + return NodeModule +} + +// SetModule records which module a resource was applied for. +func (s *State) SetModule(id, module string) { + for i := range s.Resources { + if s.Resources[i].ID == id { + s.Resources[i].Module = module + } + } +} + // Origins a resource can have. const ( // Carried is the bundle this host was built with.