306 lines
15 KiB
Go
306 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"errors"
|
|
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
|
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
|
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
engcomp "github.com/novox/mesh-host/internal/compositions"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
|
"git.novox.be/novox/mesh-sdk/go/declaration"
|
|
|
|
"github.com/novox/mesh-host/internal/agent"
|
|
"github.com/novox/mesh-host/internal/filling"
|
|
"github.com/novox/mesh-host/internal/liveness"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
func aFileOf(module, id string) json.RawMessage {
|
|
raw, _ := json.Marshal(map[string]any{"id": module + "." + id, "type": "file", "path": "/tmp/r6/" + module + "/" + id,
|
|
"content": "x", "module": module})
|
|
return raw
|
|
}
|
|
|
|
// What runs is the agent's compositions and the kept declaration's resources for each module the agent does not hold;
|
|
// the node's own parts are the kept declaration's until the agent holds the node's composition (ADR 0539 §9). One module
|
|
// under the agent must not make the rest invisible to the liveness, units, accounts, libraries, filling and the take act.
|
|
func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
|
|
kept := &declaration.Declaration{Identities: []declaration.Identity{{Module: "db", Name: "mesh-db"}},
|
|
Libraries: []declaration.Library{{Library: "media"}}}
|
|
everything := []engcomp.Composed{
|
|
{Node: "n1", Module: "web", Resources: []json.RawMessage{aFileOf("web", "a")}},
|
|
{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}}, // still the kept declaration's
|
|
}
|
|
d, err := whatRuns(everything, false, func() (*declaration.Declaration, error) { return kept, nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(d.Resources) != 2 || len(d.Identities) != 1 || len(d.Libraries) != 1 {
|
|
t.Fatalf("%d resources, %d identities, %d libraries: the kept modules and the node's kept parts are missing",
|
|
len(d.Resources), len(d.Identities), len(d.Libraries))
|
|
}
|
|
|
|
// Once the agent holds the node's composition, its parts are the node's.
|
|
node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}}
|
|
d, err = whatRuns(append(everything, node), true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(d.Identities) != 1 || d.Identities[0].Name != "mesh-web" || len(d.Libraries) != 0 {
|
|
t.Fatalf("the node's parts: %+v %+v", d.Identities, d.Libraries)
|
|
}
|
|
}
|
|
|
|
// What the take act, the judgement of directories and the filling read is what runs, not the frozen kept declaration.
|
|
func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
|
t.Run("a fill while the agent restarts", aFillWhileTheAgentRestartsIsWrittenAfterwards)
|
|
setRunsHere(func(string, ed25519.PublicKey) (*declaration.Declaration, error) {
|
|
return &declaration.Declaration{Version: 1, Resources: nil, Provisioner: "from-agent"}, nil
|
|
})
|
|
defer setRunsHere(nil)
|
|
d, err := declaredNow("/nowhere", nil)
|
|
if err != nil || d.Provisioner != "from-agent" {
|
|
t.Fatalf("declaredNow read %+v (%v), not what runs", d, err)
|
|
}
|
|
|
|
// And the health loop's judges are handed what runs once a module settles (g14 and shanks, 2026-10-30 16:14):
|
|
// restic's composition dropped its process restic.measure, which stayed watched and said down on every look.
|
|
j, err := liveness.Open(filepath.Join(t.TempDir(), "liveness.json"), fakeRuntime{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
judging = j
|
|
defer func() { judging = nil }()
|
|
process := func(name string) json.RawMessage {
|
|
raw, _ := json.Marshal(map[string]any{"id": "restic." + name, "type": "process", "name": "restic-" + name,
|
|
"source": "https://store.invalid/restic", "digest": "sha256:" + strings.Repeat("a", 64),
|
|
"run": []string{"./restic"}, "module": "restic"})
|
|
return raw
|
|
}
|
|
for _, row := range []struct {
|
|
runs []string
|
|
watch string
|
|
}{{[]string{"night", "measure"}, "restic.measure restic.night"}, {[]string{"night"}, "restic.night"}} {
|
|
var raws []json.RawMessage
|
|
for _, name := range row.runs {
|
|
raws = append(raws, process(name))
|
|
}
|
|
runs, err := whatRuns([]engcomp.Composed{{Node: "n1", Module: "restic", Resources: raws}}, false,
|
|
func() (*declaration.Declaration, error) { return nil, errors.New("none kept") })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
setRunsHere(func(string, ed25519.PublicKey) (*declaration.Declaration, error) { return runs, nil })
|
|
judgeWhatRunsNow(filepath.Join(t.TempDir(), "state.json"), nil)
|
|
st, _ := j.Look(context.Background())
|
|
var watched []string
|
|
for _, r := range st.Resources {
|
|
watched = append(watched, r.ID)
|
|
}
|
|
sort.Strings(watched)
|
|
if got := strings.Join(watched, " "); got != row.watch {
|
|
t.Errorf("running %v, the judge watches %q, want %q", row.runs, got, row.watch)
|
|
}
|
|
}
|
|
|
|
// L3 s9 (ADR 0550 §2): a node whose placed modules all have a have reads the kept declaration no more, though the
|
|
// file is there and readable and the controller's key is dropped; until then it reads it.
|
|
state := filepath.Join(t.TempDir(), "state", "state.json")
|
|
if err := store.SaveDeclared(store.DeclaredPath(state), store.Declared{Declaration: []byte(`{"declaration":1,` +
|
|
`"resources":[{"id":"zsh.rc","type":"file","module":"zsh","path":"/etc/zshrc","content":"x"}]}`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d, err := keptDeclaration(state, nil); err != nil || len(d.Resources) != 1 {
|
|
t.Fatalf("before the read is retired: %+v, %v", d, err)
|
|
}
|
|
placed := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}}
|
|
// Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the
|
|
// node's own) keeps the kept declaration read, since it is the one record of what runs of it.
|
|
newForm := nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning}
|
|
oldForm := nodeengine.HaveByNodeModuleValue{Build: "build-1791404191702719760", Phase: nodeengineclient.PhaseRunning}
|
|
for _, row := range []struct {
|
|
name string
|
|
has map[string]nodeengine.HaveByNodeModuleValue
|
|
want bool
|
|
}{
|
|
{"every placed module has a have of the new form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": newForm}, true},
|
|
{"one has none yet", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm}, false},
|
|
{"one runs at a build of the old form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": oldForm}, false},
|
|
} {
|
|
if got, why := allHave(placed, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want {
|
|
t.Errorf("%s: %v (%s)", row.name, got, why)
|
|
}
|
|
}
|
|
// The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774).
|
|
withWireguard := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}}
|
|
if got, why := allHave(withWireguard, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) {
|
|
if m == overlay.Name {
|
|
return oldForm, true, nil
|
|
}
|
|
return newForm, true, nil
|
|
}); !got {
|
|
t.Errorf("%s without a fingerprint beside zsh at the new form keeps the read: %s", overlay.Name, why)
|
|
}
|
|
if got, _ := allHave(meshmoduleassigner.Assignments{Node: "n1"}, nil); got {
|
|
t.Error("a node with nothing placed retires the read")
|
|
}
|
|
keptRetired.Store(true)
|
|
defer keptRetired.Store(false)
|
|
if _, err := keptDeclaration(state, nil); !errors.Is(err, errKeptRetired) {
|
|
t.Errorf("after the read is retired: %v", err)
|
|
}
|
|
if by, filled := keptOnce(options{state: state}, nil)(); by != nil || filled != nil {
|
|
t.Errorf("after the read is retired the kept declaration was split: %v %v", by, filled)
|
|
}
|
|
}
|
|
|
|
// 1945/AC17 (unit): a resource the node's own composition shares with a running module's composition is declared once,
|
|
// the module's: the declaration joined of both names each file once, so the filling writes it; the node's own resources
|
|
// no module declares stay. (Live from 2026-10-25 12:43: "2 resources declare X" on every node.)
|
|
func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) {
|
|
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")},
|
|
Filled: []string{"/tmp/r6/zsh/rc"}}
|
|
zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")},
|
|
Filled: []string{"/tmp/r6/zsh/rc"}}
|
|
d, err := declarationOf(own, zsh)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
at := map[string]int{}
|
|
for _, r := range d.Resources {
|
|
at[r.Target()]++
|
|
}
|
|
if at["/tmp/r6/zsh/rc"] != 1 || at["/tmp/r6/mesh-wireguard/conf"] != 1 || len(d.Resources) != 2 {
|
|
t.Errorf("the joined declaration declares %v, want each path once", at)
|
|
}
|
|
if !d.Fills("/tmp/r6/zsh/rc") {
|
|
t.Errorf("the filled path is no longer named")
|
|
}
|
|
|
|
// The same at the node's own apply: what the others declare is theirs.
|
|
trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh})
|
|
if len(trimmed.Resources) != 1 || len(dropped) != 1 || dropped[0] != "zsh.rc (zsh)" {
|
|
t.Errorf("the node's own without zsh's: %d resources, dropped %v", len(trimmed.Resources), dropped)
|
|
}
|
|
if len(own.Resources) != 2 {
|
|
t.Errorf("ownWithout changed its argument")
|
|
}
|
|
// A node's own composition alone is all of it.
|
|
if d, err := declarationOf(own); err != nil || len(d.Resources) != 2 {
|
|
t.Errorf("the node's own alone: %v (%v)", d, err)
|
|
}
|
|
}
|
|
|
|
// 1945/AC18 (unit): the kept declaration's filled paths go to the module whose file is at each path, the node's own to
|
|
// the node's; and what runs from it, joined with the node's composition, names them filled.
|
|
func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) {
|
|
raw := []byte(`{"resources":[
|
|
{"id":"claude-code.settings","type":"file","module":"claude-code","path":"/etc/cc.json"},
|
|
{"id":"screen-lock.conf","type":"file","module":"screen-lock","path":"/etc/lock.conf"},
|
|
{"id":"host.motd","type":"file","path":"/etc/motd"},
|
|
{"id":"zsh.rc","type":"file","module":"zsh","path":"/etc/zshrc"}],
|
|
"filled_files":["/etc/cc.json","/etc/lock.conf","/etc/motd"]}`)
|
|
by, filled := splitKept(raw)
|
|
if len(by["claude-code"]) != 1 || len(by[agent.NodeModule]) != 1 {
|
|
t.Fatalf("resources by module: %v", by)
|
|
}
|
|
if strings.Join(filled["claude-code"], ",") != "/etc/cc.json" || strings.Join(filled["screen-lock"], ",") != "/etc/lock.conf" ||
|
|
strings.Join(filled[agent.NodeModule], ",") != "/etc/motd" || len(filled["zsh"]) != 0 {
|
|
t.Fatalf("filled by module: %v", filled)
|
|
}
|
|
cc := engcomp.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]}
|
|
node := engcomp.Composed{Node: "n1"}
|
|
d, err := whatRuns([]engcomp.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
|
|
if err != nil || !d.Fills("/etc/cc.json") {
|
|
t.Fatalf("what runs does not name claude-code's file filled: %v (%v)", d, err)
|
|
}
|
|
if splitBy, _ := splitKept([]byte("not json")); splitBy != nil {
|
|
t.Errorf("a kept declaration that does not read split into %v", splitBy)
|
|
}
|
|
}
|
|
|
|
// 1945/AC17 (unit, review): a resource of the node's own that shares only its target with a module's (another id: a
|
|
// unit or a container of the same name) is not the module's, and is neither dropped nor left to be removed.
|
|
func TestASharedTargetWithAnotherIDIsNotDropped(t *testing.T) {
|
|
ownFile, _ := json.Marshal(map[string]any{"id": "host.zshrc", "type": "file", "path": "/tmp/r6/zsh/rc", "content": "x"})
|
|
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}}
|
|
zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}}
|
|
trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh})
|
|
if len(dropped) != 0 || len(trimmed.Resources) != 1 {
|
|
t.Errorf("a resource sharing only its target was dropped: %v", dropped)
|
|
}
|
|
}
|
|
|
|
// Issue 924: the bus link drops, the agent restarts, and the filling is asked while it holds nothing yet. What runs is
|
|
// then the last view the node held, never the retired kept declaration; and a fill refused for want of any is owed and
|
|
// told once the agent holds the node again, so every file is written afterwards.
|
|
func aFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) {
|
|
keptRetired.Store(true)
|
|
defer keptRetired.Store(false)
|
|
node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Provisioner: "mesh-node-provisioner"}}
|
|
files := []engcomp.Composed{{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}, Filled: []string{"/tmp/r6/db/a"}}}
|
|
view := &runsView{everything: append(files, node), holdsNode: true}
|
|
|
|
var placed []string
|
|
// The agent holds the node, then the link drops and it stops: set(nil) keeps its view.
|
|
holding := true
|
|
current := &agentNow{view: func(*agent.Agent) *runsView {
|
|
if holding {
|
|
return view
|
|
}
|
|
return &runsView{}
|
|
}}
|
|
current.set(agent.New(agent.Env{}))
|
|
e := &filling.Engine{
|
|
Declared: func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) },
|
|
UIDOf: func(string) (uint32, error) { return 4242, nil },
|
|
Lock: func() (func(), error) { return func() {}, nil },
|
|
Place: func(_ context.Context, _ *declaration.Declaration, path string, _ []byte) (string, string, error) {
|
|
placed = append(placed, path)
|
|
return "updated", "d", nil
|
|
},
|
|
}
|
|
fill := func() filling.Answer {
|
|
return e.Write(context.Background(), 4242, filling.Request{Path: "/tmp/r6/db/a", Content: []byte("x")})
|
|
}
|
|
socket := filepath.Join(t.TempDir(), "sock")
|
|
if err := os.WriteFile(socket, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The next agent has read no haves and the previous one's view is gone (a cold start): refused, and owed.
|
|
cold := &agentNow{}
|
|
cold.set(agent.New(agent.Env{}))
|
|
cold.set(nil)
|
|
e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(cold)("/nowhere", nil) }
|
|
if a := fill(); a.Failed == "" && a.Refused == "" {
|
|
t.Fatalf("a fill with nothing read was accepted: %+v", a)
|
|
}
|
|
// The link drops: set(nil) keeps the view the agent held, and the fill during the restart is written.
|
|
e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) }
|
|
current.set(nil)
|
|
if told, err := e.Knock(socket); err != nil || told == "" {
|
|
t.Fatalf("the refused fill was not owed and told: %q, %v", told, err)
|
|
}
|
|
if a := fill(); a.Failed != "" || a.Refused != "" || len(placed) != 1 {
|
|
t.Fatalf("the fill during the restart was not written: %+v, placed %v", a, placed)
|
|
}
|
|
// The new agent holds the node: its own view answers and the kept one is dropped.
|
|
next := agent.New(agent.Env{})
|
|
current.set(next)
|
|
holding = true
|
|
if _, err := runsFromAgent(current)("/nowhere", nil); err != nil || current.lastView() != nil {
|
|
t.Fatalf("the kept view stays after the new agent holds the node: %v, %v", err, current.lastView())
|
|
}
|
|
}
|