diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index f33b5af..a8b59a9 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -158,8 +158,31 @@ func awaitBackup(ctx context.Context, events <-chan []byte, node, module string, // declarationOf is the declaration the compositions make together: their resources, and the node's own parts when the // node's composition is among them. It is what the node judges and says its health by, and what one module's apply is // made of (with a single composition). +// +// **A resource the node's own composition shares with a module's is the module's** (issue 1945): from 2026-10-25 12:43 +// the controller put modules it knew no fingerprint of the new form for into the node's own composition while their +// wants named their own, and the declaration joined of both declared each such file twice, which the filling refuses +// ("2 resources declare X") on every node. The node's own copy is left out, and which were left out is said once. func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) { d := &declaration.Declaration{Version: declaration.Version} + var modules []delivery.Composed + for _, c := range cs { + if c.Module != "" { + modules = append(modules, c) + } + } + if len(modules) > 0 { + joined := make([]delivery.Composed, 0, len(cs)) + for _, c := range cs { + if c.Module == "" { + var dropped []string + c, dropped = ownWithout(c, modules) + sayDroppedOnce(dropped) + } + joined = append(joined, c) + } + cs = joined + } for _, c := range cs { for _, raw := range c.Resources { r, err := declaration.ParseResource(raw) @@ -180,6 +203,70 @@ func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) { return d, nil } +// ownWithout is the node's own composition without the resources a module's composition among modules also declares: +// the same kind and id, or the same kind and target (a file at one path). It answers what it left out, as " ()". +func ownWithout(own delivery.Composed, modules []delivery.Composed) (delivery.Composed, []string) { + byID, byTarget := map[string]string{}, map[string]string{} + for _, c := range modules { + if c.Module == "" { + continue + } + for _, raw := range c.Resources { + r, err := declaration.ParseResource(raw) + if err != nil { + continue + } + byID[string(r.Kind())+"\x00"+r.Identity()] = c.Module + if t := r.Target(); t != "" { + byTarget[string(r.Kind())+"\x00"+t] = c.Module + } + } + } + if len(byID) == 0 { + return own, nil + } + var dropped []string + kept := make([]json.RawMessage, 0, len(own.Resources)) + for _, raw := range own.Resources { + r, err := declaration.ParseResource(raw) + if err != nil { + kept = append(kept, raw) // refused where it is parsed, with its words + continue + } + module, dup := byID[string(r.Kind())+"\x00"+r.Identity()] + if !dup && r.Target() != "" { + module, dup = byTarget[string(r.Kind())+"\x00"+r.Target()] + } + if dup { + dropped = append(dropped, fmt.Sprintf("%s (%s)", r.Identity(), module)) + continue + } + kept = append(kept, raw) + } + if len(dropped) == 0 { + return own, nil + } + own.Resources = kept + return own, dropped +} + +// droppedSaid is every set of the node's own resources left out that was said, so each is said once. +var droppedSaid sync.Map + +// sayDroppedOnce says once which of the node's own resources were left out because a module's composition declares them. +func sayDroppedOnce(dropped []string) { + if len(dropped) == 0 { + return + } + sort.Strings(dropped) + key := strings.Join(dropped, ", ") + if _, said := droppedSaid.LoadOrStore(key, true); said { + return + } + fmt.Fprintf(os.Stderr, "mesh-host: the node's own composition also declares %d resource(s) a module's composition "+ + "declares, and they are left out of it: %s (issue 1945)\n", len(dropped), key) +} + func scopeName(c delivery.Composed) string { if c.Module == "" { return agent.NodeModule @@ -233,40 +320,60 @@ func trustedKeys(opts options, controller ed25519.PublicKey) func() []ed25519.Pu } } -// keptByModule is the resources of the declaration the node kept before the switch, by the module they say; the node's -// own are those that say none (ADR 0539 §9). -func keptByModule(opts options, signer ed25519.PublicKey) func() map[string][]json.RawMessage { +// keptOnce reads the kept declaration once and splits it by module: its resources (the node's own are those that say +// none, ADR 0539 §9) and its filled paths. +func keptOnce(opts options, signer ed25519.PublicKey) func() (map[string][]json.RawMessage, map[string][]string) { var once sync.Once var by map[string][]json.RawMessage - return func() map[string][]json.RawMessage { + var filled map[string][]string + return func() (map[string][]json.RawMessage, map[string][]string) { once.Do(func() { raw, err := store.LoadDeclared(store.DeclaredPath(opts.state), signer) if err != nil { return } - var kept struct { - Resources []json.RawMessage `json:"resources"` - } - if json.Unmarshal(raw, &kept) != nil { - return - } - by = map[string][]json.RawMessage{} - for _, r := range kept.Resources { - var head struct { - Module string `json:"module"` - } - _ = json.Unmarshal(r, &head) - name := head.Module - if name == "" { - name = agent.NodeModule - } - by[name] = append(by[name], r) - } + by, filled = splitKept(raw) }) - return by + return by, filled } } +// splitKept is a kept declaration's resources by the module they say (the node's own under agent.NodeModule) and its +// filled paths by the module whose file is at each path; nil for a declaration that does not read. +func splitKept(raw []byte) (map[string][]json.RawMessage, map[string][]string) { + var kept struct { + Resources []json.RawMessage `json:"resources"` + Filled []string `json:"filled_files"` + } + if json.Unmarshal(raw, &kept) != nil { + return nil, nil + } + isFilled := make(map[string]bool, len(kept.Filled)) + for _, p := range kept.Filled { + isFilled[p] = true + } + by, filled := map[string][]json.RawMessage{}, map[string][]string{} + named := map[string]bool{} + for _, r := range kept.Resources { + var head struct { + Module string `json:"module"` + Type string `json:"type"` + Path string `json:"path"` + } + _ = json.Unmarshal(r, &head) + name := head.Module + if name == "" { + name = agent.NodeModule + } + by[name] = append(by[name], r) + if head.Type == string(declaration.TypeFile) && isFilled[head.Path] && !named[name+"\x00"+head.Path] { + named[name+"\x00"+head.Path] = true + filled[name] = append(filled[name], head.Path) + } + } + return by, filled +} + // nodeAgent runs the node agent under the session's links for as long as ctx lasts: it waits for a link, follows the // node's wants and compositions on it, and begins again with the next link. A watch it cannot make (a bucket this node's // account may not read) is said, and tried again with a growing wait, as the link is. @@ -287,13 +394,16 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess return } var ag *agent.Agent + kept := keptOnce(opts, signer) env := agent.Env{ Node: node, Wants: bucketWatch{session, delivery.AssignerWantsBucket}, Composed: bucketWatch{session, composedBucket}, Haves: sessionHaves{session}, - Kept: keptByModule(opts, signer), - Recorded: recordedIn(opts.state, say), + Kept: func() map[string][]json.RawMessage { by, _ := kept(); return by }, + // The same kept declaration's filled paths, so a module still running from it keeps its files filled. + KeptFilled: func() map[string][]string { _, filled := kept(); return filled }, + Recorded: recordedIn(opts.state, say), Revisions: func(ctx context.Context, key string) ([]agent.Update, error) { got, err := session.KVHistory(ctx, composedBucket, key) if err != nil { @@ -372,6 +482,12 @@ func (c *agentNow) get() *agent.Agent { func applyComposition(ctx context.Context, opts options, c delivery.Composed, others []delivery.Composed, sched *apply.Scheduler, say link.Announce) agent.Result { name := scopeName(c) + if c.Module == "" { + // What another module's composition declares is that module's, never the node's own apply's (issue 1945). + var dropped []string + c, dropped = ownWithout(c, others) + sayDroppedOnce(dropped) + } d, err := declarationOf(c) if err != nil { return agent.Result{Failed: true, Detail: shorten(err.Error())} diff --git a/cmd/mesh-host/whatruns_test.go b/cmd/mesh-host/whatruns_test.go index 2b49c0e..d443cda 100644 --- a/cmd/mesh-host/whatruns_test.go +++ b/cmd/mesh-host/whatruns_test.go @@ -4,10 +4,13 @@ import ( "crypto/ed25519" "encoding/json" "errors" + "strings" "testing" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" + + "github.com/novox/mesh-host/internal/agent" ) func aFileOf(module, id string) json.RawMessage { @@ -57,3 +60,68 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) { t.Fatalf("declaredNow read %+v (%v), not what runs", d, err) } } + +// 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 := delivery.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")}, + Filled: []string{"/tmp/r6/zsh/rc"}} + zsh := delivery.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, []delivery.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 := delivery.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]} + node := delivery.Composed{Node: "n1"} + d, err := whatRuns([]delivery.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) + } +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c71c99b..cefbd72 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -90,6 +90,10 @@ type Env struct { // §9), as JSON; the node's own are under NodeModule. Nil when the node kept none. A module the agent does not hold yet // still runs from it. Kept func() map[string][]json.RawMessage + // KeptFilled is the paths the kept declaration names as filled by the provisioner, by the module whose file is at + // each (the node's own under NodeModule). A module that still runs from the kept declaration keeps its filled files + // filled: without them the filling refused each one as "not named as filled" (issue 1945). Nil names none. + KeptFilled func() map[string][]string // Trusted is the public keys a composition may be signed by: the controller's and the operator's (ADR 0539 §3). A // composition unsigned, signed by another key, or changed after it was signed is refused and nothing of it is // applied. Nil trusts no key, so nothing is applied. @@ -441,6 +445,9 @@ func (a *Agent) others(except string) []delivery.Composed { sort.Strings(names) for _, m := range names { c := delivery.Composed{Node: a.env.Node, Module: m, Resources: kept[m]} + if a.env.KeptFilled != nil { + c.Filled = a.env.KeptFilled()[m] + } if m == NodeModule { c.Module = "" } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index ef014b0..7319982 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -835,3 +835,18 @@ func TestTheRevisionAWantNamesIsAppliedFromHistory(t *testing.T) { t.Fatalf("the have names %s, want %s", h.Composed, oldDig) } } + +// 1945/AC18 (unit): a module that still runs from the kept declaration carries the kept declaration's filled paths of +// its files, so the filling writes them ("not named as filled" for claude-code and screen-lock, 2026-10-25). +func TestAModuleRunningFromTheKeptDeclarationKeepsItsFilledPaths(t *testing.T) { + b := newBed(t) + b.kept = map[string][]json.RawMessage{"claude-code": {file("claude-code", "settings", "")}} + b.env.KeptFilled = func() map[string][]string { + return map[string][]string{"claude-code": {"/tmp/r6/claude-code/settings"}} + } + a := New(b.env) + got := a.Everything() + if len(got) != 1 || got[0].Module != "claude-code" || strings.Join(got[0].Filled, ",") != "/tmp/r6/claude-code/settings" { + t.Fatalf("what runs from the kept declaration: %+v", got) + } +}