Files
mesh-host/internal/apply/module_scope_test.go
T
jschoubben 1d8d726758
mesh/delivery merged: source.main moved to novox/mesh-host@62b211bb6040
mesh/check checking 1d8d7267584e on 62b211bb6040
Leave a record the node's own carried for a module that composes its own, and let that module's apply remove it, so the node's own is not refused for ever (issue 774)
On the bus node restic's manifest replaced its process measure by awake (mesh-catalog 35ff67bd). The record of restic.measure was the node's, made while restic was folded, so restic's own apply never saw it, and the guard against removing a placed module's resources refused the node's own apply whole every two minutes from 2026-10-30 19:42: nothing of the node's own applied.

Quick fix considered: forget the one record by hand (a hand act on the node; refused). Thorough fix (this): the node's own apply keeps such a record and says so, never refusing or removing it; a module's own apply owns a node record whose id names it, so it alone removes it once neither composition declares it.
2026-10-31 01:13:11 +01:00

225 lines
10 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)
// 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
}{
{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"}},
// 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()
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.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}
}
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)
}
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))
}
}
})
}
}
func state0() store.State { return store.State{} }