Files
mesh-host/internal/apply/module_scope_test.go
T
jschoubben 81672b183d Keep the node's own as it runs from an engine's first start, and refuse the node's own apply removing a module no want removed (issue 774, review round 1)
The reviewer showed how 76e214a still lets the 07:54 outage happen at the re-landed engine's first start on novox.
applied/ is empty there, so loadApplied loads nothing. The kept declaration retires, because every placed module has a
have. Nothing then carries the node's own composition. The guard misses nats, which has no want there, so its
nats.conf is guessed as nats's and removed.

- At the start, when the store has no node's own composition, the node's own runs as the kept declaration has it, and
  the store keeps it now. The kept declaration retires only once the store holds `node`.
- What this engine applied runs until loadHad reads the composition a differing have names. The bucket no longer keeps
  the one novox's have names, so the stored one stays.
- In the node's own apply, a resource of another module goes only when that module's want says removed
  (Scope.RemovedByWant). A module the node's own composition carried has no want, so Placed never named it.
  Cost: the node's own apply cannot drop one of its own dotted resources without such a want. It is refused whole and
  said, never removed silently.

Tests, with no new test functions:
- A first-start case in the agent's Everything test (empty store, then a start with the declaration retired). It fails
  on 76e214a.
- Two rows in the guard's table: no want refuses; a want saying removed removes.
- A case for the store in the authority test.
2026-10-29 21:25:51 +01:00

196 lines
8.6 KiB
Go

package apply
import (
"context"
"path/filepath"
"strings"
"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))
}
}
// A resource that leaves the node's own composition for its module's own is that module's now: the node's own apply
// leaves it as it is, whatever its type, until the module's own apply takes it (issue 1945, live on 2026-10-25 at 02:32:
// shanks and g14 lost systemd-resolved's guard, its resolver files and sudo's operator file, ace qbittorrent's server,
// because the node's own apply removed every record no longer in it, the others holding the very same resources or not).
//
// And a resource the node's own composition carried for a module a want still places here (a folded module of the old
// form, held) is never removed by the node's own apply when a composition leaves it out: the apply is refused whole and
// says why (issue 774, live on 2026-10-29 at 07:54 UTC on the bus node: the node's own composition, composed without what
// it ran once the kept declaration was read no more, removed the bus server, the proxy and three held modules). Only its
// 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
// wants names the modules whose want says removed (Scope.RemovedByWant); nil leaves the guard to Placed alone.
wants []string
}{
{name: "moved to its module's own composition", id: "systemd-resolved.fact-suffix", moved: true, placed: true, stays: true},
{name: "a held folded module, in no composition", id: "nats.server-conf", placed: true, refused: true, stays: true},
{name: "its module's want removed", id: "nats.server-conf"},
// Issue 774, review of the re-land, round 1: a module folded in the node's own has no want, so Placed does not name
// 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"}},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
fact := filepath.Join(dir, "carried")
gone := filepath.Join(dir, "gone.conf")
own := declare(t, `{"id":"`+tc.id+`","type":"file","path":"`+fact+`","content":"mesh\n"},
{"id":"old.gone","type":"file","path":"`+gone+`","content":"g\n"}`)
_, state, err := ApplyModule(context.Background(), archHost(t), own, Scope{Module: store.NodeModule},
state0(), store.OriginDeclared, noServices, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
module, _ := declaration.ModuleGuess(tc.id)
scope := Scope{Module: store.NodeModule}
if tc.moved {
moved := declare(t, `{"id":"`+tc.id+`","type":"file","path":"`+fact+`","content":"mesh\n","module":"`+module+`"}`)
scope.Others = moved.Resources
}
if tc.placed {
scope.Placed = map[string]bool{module: true}
}
if tc.wants != nil {
scope.RemovedByWant = map[string]bool{}
for _, m := range tc.wants {
scope.RemovedByWant[m] = true
}
}
report, after, err := ApplyModule(context.Background(), archHost(t), &declaration.Declaration{}, scope, state,
store.OriginDeclared, noServices, nil, nil, nil, nil)
if tc.refused != (err != nil) {
t.Fatalf("refused %v, want %v: %v", err != nil, tc.refused, err)
}
if tc.refused {
if !strings.Contains(err.Error(), tc.id+" of "+module) {
t.Fatalf("the refusal does not name what it kept: %v", err)
}
after = state
}
if exists(fact) != tc.stays {
t.Fatalf("the carried resource exists %v, want %v: %+v", exists(fact), tc.stays, outcomeOf(report, tc.id))
}
if _, still := after.Find(tc.id); still != tc.stays {
t.Fatalf("the carried resource's record kept %v, want %v", still, tc.stays)
}
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)
}
})
}
}
func state0() store.State { return store.State{} }