303 lines
14 KiB
Go
303 lines
14 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"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))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
// Issue 774, ADR 0594 §2: the bus module's first own apply takes over the server the node's own composition made
|
|
// while the bus was folded. Kept (a want without the warrant's mark), the server's container and its program archive
|
|
// are left as they run, whatever the build changed in them, and become nats's; its files and the tools' account file
|
|
// are written. Not kept, the same apply replaces the container.
|
|
for _, keep := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("the bus server kept %v", keep), func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
conf, account, program := filepath.Join(dir, "nats.conf"), filepath.Join(dir, "tools", "account"), filepath.Join(dir, "program")
|
|
bus := func(env, confBody, programBody, module string) *declaration.Declaration {
|
|
archive, _ := archiveDecl(t, "nats.program-snapshot", program, map[string]string{"snapshot": programBody}, "")
|
|
if module != "" {
|
|
archive = strings.Replace(archive, `"id":"nats.program-snapshot"`, `"id":"nats.program-snapshot","module":"nats"`, 1)
|
|
module = `,"module":"nats"`
|
|
}
|
|
return declare(t, archive+`,
|
|
{"id":"nats.server-conf","type":"file","path":"`+conf+`","content":"`+confBody+`"`+module+`},
|
|
{"id":"nats.server","type":"container","name":"mesh-broker-nats","image":"`+pinned+`","env":{"V":"`+env+
|
|
`"},"restart-on":["nats.program-snapshot","nats.server-conf"]`+module+`}`)
|
|
}
|
|
m := &machine{containers: map[string]*fakeContainer{}}
|
|
_, state, err := ApplyModule(context.Background(), archHost(t), bus("1", "a", "one", ""), Scope{Module: store.NodeModule},
|
|
state0(), store.OriginDeclared, m.run, nil, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
made := m.containers["mesh-broker-nats"]
|
|
if made == nil {
|
|
t.Fatalf("the node's own made no server: %v", m.asked)
|
|
}
|
|
m.asked = nil
|
|
next := bus("2", "b", "two", "nats")
|
|
next.Resources = append(next.Resources, declare(t, `{"id":"nats.tools-account","type":"file","path":"`+account+
|
|
`","content":"tools","module":"nats"}`).Resources...)
|
|
scope := Scope{Module: "nats", Placed: map[string]bool{"nats": true}}
|
|
if keep {
|
|
scope.Keep = map[string]bool{"nats.server": true, "nats.program-snapshot": true}
|
|
}
|
|
report, after, err := ApplyModule(context.Background(), archHost(t), next, scope, state,
|
|
store.OriginDeclared, m.run, nil, nil, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var moved []string
|
|
for _, a := range m.asked {
|
|
for _, verb := range []string{"docker rm", "docker run", "docker create", "docker stop", "docker restart"} {
|
|
if strings.HasPrefix(a, verb+" ") {
|
|
moved = append(moved, a)
|
|
}
|
|
}
|
|
}
|
|
if keep == (len(moved) > 0) {
|
|
t.Fatalf("kept %v, and the server was moved by %v", keep, moved)
|
|
}
|
|
if got, _ := os.ReadFile(conf); string(got) != "b" {
|
|
t.Errorf("the server's file holds %q", got)
|
|
}
|
|
if got, _ := os.ReadFile(account); string(got) != "tools" {
|
|
t.Errorf("the tools' account file holds %q", got)
|
|
}
|
|
if got, _ := os.ReadFile(filepath.Join(program, "snapshot")); keep != (string(got) == "one") {
|
|
t.Errorf("kept %v, and the server's program is %q", keep, got)
|
|
}
|
|
for _, id := range []string{"nats.server", "nats.program-snapshot"} {
|
|
rec, ok := after.Find(id)
|
|
if !ok || rec.Module != "nats" {
|
|
t.Errorf("%s is recorded %+v, not as nats's", id, rec)
|
|
}
|
|
if o := outcomeOf(report, id); keep != (o.Action == keptAsItRuns) {
|
|
t.Errorf("kept %v, and %s was %s: %s", keep, id, o.Action, o.Detail)
|
|
}
|
|
}
|
|
if keep && report.Changed() == false {
|
|
t.Error("an apply that wrote the server's files says nothing changed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func state0() store.State { return store.State{} }
|