From 409c56685d4796bf6be27ce276d27611abf5f895 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 00:20:01 +0100 Subject: [PATCH 1/3] Keep a held module's contributions when only its own secret is missing, and say a provider's move as consumers joining or leaving (issue 774) A module held for an own secret never made (nextcloud's admin on novox, 2026-10-30) was composed as a bare stub, so its contributions left postgres's grants and keycloak's clients, and their helpers would have retired its login. The culprit now tries leaving out its own secrets alone before its whole offer. JudgeMove said a changed received-, bound- or secret- file as "where its data is kept"; it now names the consumers that join or leave. The judgement stays a move: its yes is what kept nextcloud's login. Quick fix: leave out own secrets alone before the whole offer. Thorough fix (not here): carry a held module's last contributions from what runs into its providers' received files whatever part of it fails to compose. --- go/compose/frombus/answer.go | 27 ++++++-- go/compose/frombus/answer_rows_test.go | 2 +- go/compose/frombus/held_rows_test.go | 19 +++++- go/delivery/judge.go | 92 +++++++++++++++++++++++++- go/delivery/judge_test.go | 38 +++++++---- 5 files changed, 153 insertions(+), 25 deletions(-) diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index f7d6e93..71e6f22 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -322,7 +322,7 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b left[m] = map[string]bool{} } left[m][leave] = true - if leave != leaveAll { + if leave != leaveAll && leave != leaveOwn { failed[m] += "; composed without what it contributes to " + leave + ", which does not compose" } } @@ -404,9 +404,16 @@ func blames(m string, again, err error) bool { return again == nil || again.Error() != err.Error() } -// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); any other word leaves out -// what it contributes to that provision alone. -const leaveAll = "*" +// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets +// alone, its offer kept; any other word leaves out what it contributes to that provision alone. +// +// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774, +// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its +// login while it is held. +const ( + leaveAll = "*" + leaveOwn = "own-secrets" +) // assignedOn is the modules placed on node, in placement order. func assignedOn(in compose.Input, node string) []string { @@ -487,6 +494,9 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose. if isBare(man) { continue } + if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) { + return m, leaveOwn + } if changes(m, leaveAll) { return m, leaveAll } @@ -515,9 +525,9 @@ func stubbed(in compose.Input, node, module, leave string) compose.Input { // heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of // runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of -// its offer: a provision's contributions, or with leaveAll everything it contributes and its own secrets, none of a -// held module's own composition being applied, so what it has not been issued (restic's repository on a node it never -// ran on) is no refusal of the rest. +// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes +// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's +// repository on a node it never ran on) is no refusal of the rest. func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input { out := in out.Nodes = append([]compose.Node(nil), in.Nodes...) @@ -528,6 +538,9 @@ func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left if left[leaveAll] { man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil } else if len(left) > 0 { + if left[leaveOwn] { + man.OwnSecrets = nil + } contributes := map[string]map[string]any{} for p, v := range man.Contributes { if !left[p] { diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index 6eb01a2..45fbe64 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -516,7 +516,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { for _, row := range []struct { left map[string]bool route, smtp, own bool - }{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveAll: true}, false, false, false}} { + }{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} { k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"] if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own { t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets) diff --git a/go/compose/frombus/held_rows_test.go b/go/compose/frombus/held_rows_test.go index f38dac6..5566683 100644 --- a/go/compose/frombus/held_rows_test.go +++ b/go/compose/frombus/held_rows_test.go @@ -76,7 +76,8 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` - store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}}}` + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64) running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{ json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`), @@ -94,6 +95,7 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { {name: "the trunk's build done", want: next, done: true, digest: dg("2")}, } { b, fps := aNode(t, store, tools, lamp) + b.held = true // every grant held, so a contribution's credential stands in for nothing b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} mod := b.modules["node-tools"] mod.FP2 = next @@ -165,12 +167,13 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` own := func(name string) string { return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` + - `"own-secrets":{"broker":"/var/lib/` + name + `/broker"},` + + `"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` + `"resources":[{"id":"account","type":"user","name":"` + name + `"}],` + `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}` } lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` - store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}}}` + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` for _, row := range []struct { name string issued []string // the own-account modules whose broker is issued @@ -185,6 +188,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}}, } { b, fps := aNode(t, store, tools, own("check"), lamp, own("heal")) + b.held = true // every grant held, so a contribution's credential stands in for nothing b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} for _, m := range row.issued { b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) @@ -208,6 +212,15 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { t.Errorf("%s: the node is refused: %v", row.name, err) continue } + // A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out + // of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left + // out of its stand-in, never its contributions, so the provider's received file names every consumer. + storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"]) + for _, m := range []string{"check", "heal"} { + if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) { + t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got) + } + } for _, m := range []string{"node-tools", "check", "lamp", "heal"} { key, _ := delivery.ComposedKey("n1", m, fps[m]) _, composed := out[key] diff --git a/go/delivery/judge.go b/go/delivery/judge.go index 1aef5da..7da4d6c 100644 --- a/go/delivery/judge.go +++ b/go/delivery/judge.go @@ -107,7 +107,26 @@ func judged(from, to Composed) (Judgement, string) { // moving its data). It is said loudly in the words, and never hides an item that moved. if len(moved) > 0 { sort.Strings(moved) - words := "it changes where its data is kept: " + strings.Join(moved, ", ") + // A file the mesh makes for the module's provisions (what its consumers contribute, what it is bound to, the + // secret it is given) is said as the consumers that join or leave, not as its data moving (issue 774, + // 2026-10-31: postgres's yes said "where its data is kept" when nextcloud left its grants). Still a move: the + // yes it asks is what keeps a consumer's login while that consumer is held. + var kept, made []string + for _, id := range moved { + if who := consumersOf(id, before[id], after[id]); who != nil { + made = append(made, who...) + } else { + kept = append(kept, id) + } + } + var parts []string + if len(kept) > 0 { + parts = append(parts, "it changes where its data is kept: "+strings.Join(kept, ", ")) + } + if len(made) > 0 { + parts = append(parts, "consumers join or leave: "+strings.Join(made, ", ")) + } + words := strings.Join(parts, "; ") if from.Data != to.Data { words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data) } @@ -120,6 +139,77 @@ func judged(from, to Composed) (Judgement, string) { return JudgedNeither, "it recreates no container and moves no data" } +// consumersOf is, for a changed file the mesh makes for a provision (received-, bound- or secret-, catalogue.ReceivedID, +// BoundID, SecretID), what changes of it in words: each consumer that joins or leaves a received file, by the given +// list's from and node; else that what it is given for the provision changes. Nil for any other resource. +func consumersOf(id string, before, after *judgedResource) []string { + _, local, _ := strings.Cut(id, ".") + r := after + if r == nil { + r = before + } + if r == nil || r.kind != string(declaration.TypeFile) { + return nil + } + if provision, ok := strings.CutPrefix(local, "received-"); ok { + was, now := givenIn(before), givenIn(after) + var out []string + for _, c := range sortedGiven(was) { + if !now[c] { + out = append(out, c+" leaves "+provision) + } + } + for _, c := range sortedGiven(now) { + if !was[c] { + out = append(out, c+" joins "+provision) + } + } + if len(out) == 0 { + out = append(out, "what "+provision+"'s consumers are given changes") + } + return out + } + for _, prefix := range []string{"bound-", "secret-"} { + if provision, ok := strings.CutPrefix(local, prefix); ok { + return []string{"what it is given for " + provision + " changes"} + } + } + return nil +} + +// givenIn is the consumers a received file's content names, each as " on ". +func givenIn(r *judgedResource) map[string]bool { + out := map[string]bool{} + if r == nil { + return out + } + var file struct { + Content string `json:"content"` + } + var received struct { + Given []struct { + From string `json:"from"` + Node string `json:"node"` + } `json:"given"` + } + if json.Unmarshal(r.raw, &file) != nil || json.Unmarshal([]byte(file.Content), &received) != nil { + return out + } + for _, g := range received.Given { + out[g.From+" on "+g.Node] = true + } + return out +} + +func sortedGiven(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + // judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount, // and its canonical bytes. type judgedResource struct { diff --git a/go/delivery/judge_test.go b/go/delivery/judge_test.go index 2ffa7c1..39545ec 100644 --- a/go/delivery/judge_test.go +++ b/go/delivery/judge_test.go @@ -28,6 +28,8 @@ func TestJudgeMove(t *testing.T) { config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}` process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}` process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}` + received = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"cloud\",\"node\":\"novox\"},{\"from\":\"photos\",\"node\":\"novox\"}]}"}` + received2 = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"photos\",\"node\":\"novox\"},{\"from\":\"wiki\",\"node\":\"ace\"}]}"}` users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}` users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}` natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}` @@ -43,25 +45,35 @@ func TestJudgeMove(t *testing.T) { from, to *Composed want Judgement userList bool + words string // what the words must say, when given }{ - {"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false}, - {"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false}, - {"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false}, - {"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false}, - {"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false}, - {"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false}, - {"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false}, - {"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false}, - {"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false}, - {"never ran", nil, mail('b', true, container), JudgedNeither, false}, - {"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false}, - {"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true}, - {"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false}, + {"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false, ""}, + {"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false, ""}, + {"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""}, + {"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false, "it changes where its data is kept: mesh-mail.conf"}, + // A received file the mesh makes, in its data's place, names its consumers joining or leaving, not its data moving + // (issue 774, 2026-10-31: postgres asked a yes for "where its data is kept" when nextcloud left its grants). + {"a consumer leaves", mail('a', true, container, spool, received), mail('b', true, container, spool, received2), JudgedMovesData, false, + "consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"}, + {"a consumer leaves and a file moves", mail('a', true, container, spool, received, config), mail('b', true, container, spool, received2, config2), JudgedMovesData, false, + "it changes where its data is kept: mesh-mail.conf; consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"}, + {"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false, ""}, + {"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""}, + {"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false, ""}, + {"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false, ""}, + {"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false, ""}, + {"never ran", nil, mail('b', true, container), JudgedNeither, false, ""}, + {"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false, ""}, + {"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true, ""}, + {"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false, ""}, } { got := JudgeMove(c.from, *c.to) if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" { t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList) } + if c.words != "" && got.Words != c.words { + t.Errorf("%s: says %q, want %q", c.name, got.Words, c.words) + } if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") { t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words) } From 6bce1fb0d2a12795e1e4201fede314a632e6a170 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 00:23:42 +0100 Subject: [PATCH 2/3] Add the node-engine verb composition to the seat's contract, and the resources two compositions differ in (issue 774) A provider waiting for a yes said why only in its have's words; nobody could read the composition that waits beside what runs. The engine's read verb composition answers that, and ChangedResources lists what a move changes, a received file naming the consumers that join or leave. --- go/catalogue/engine_seat.go | 12 ++++++++++++ go/catalogue/seats_test.go | 2 +- go/delivery/judge.go | 37 +++++++++++++++++++++++++++++++++++++ go/delivery/judge_test.go | 10 ++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index b924506..a10e9ec 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -49,6 +49,18 @@ func EngineVerbs() []Verb { "hands its lease away, beside the controller's mirror for this machine and their difference. With write, the " + "key written again from what the engine composed.", Input: schema(map[string]string{"write": "\"true\": write this machine's key again from what the engine composed"}, nil, "write")}, + // Read-only (issue 774): why a module waits for a yes, or what its next apply would change. Optional until every + // engine serves it. + {Name: "composition", Optional: true, Description: "One module's composition on this machine beside what runs of " + + "it, read-only: the composition its want's build has in the bucket composed- (the latest revision, or the " + + "one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs " + + "(applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, " + + "and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for " + + "a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers " + + "that join or leave.", + Input: schema(map[string]string{"module": "the module, as placed on this machine; node for the node's own", + "digest": "a composition's digest (sha256:…), to read that revision rather than the latest", + "diff": "\"true\": list the resources that differ from what runs"}, []string{"module"})}, } } diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index e6b206b..d18d35f 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -98,7 +98,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil { t.Errorf("acts takes no id: %v", engine.Serves[5].Input) } - if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages" || + if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition" || strings.Join(engine.Emits, ",") != "asked,done,refused" { t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits) } diff --git a/go/delivery/judge.go b/go/delivery/judge.go index 7da4d6c..a060fc7 100644 --- a/go/delivery/judge.go +++ b/go/delivery/judge.go @@ -139,6 +139,43 @@ func judged(from, to Composed) (Judgement, string) { return JudgedNeither, "it recreates no container and moves no data" } +// ResourceChange is one resource that differs between two compositions of a module: its id, its kind, whether it is +// added, removed or changed, and for a file the mesh makes for a provision (received-, bound-, secret-) the consumers +// that join or leave, in words. +type ResourceChange struct { + ID string `json:"id"` + Kind string `json:"kind"` + Change string `json:"change"` + Consumers []string `json:"consumers,omitempty"` +} + +// ChangedResources is every resource added, removed or changed from running to composed, by id, each compared +// canonical (issue 774: the engine's verb composition shows what a move waiting for a yes would change). Running nil +// is every resource added. +func ChangedResources(running *Composed, composed Composed) []ResourceChange { + var from Composed + if running != nil { + from = *running + } + before, after := judgedResourcesByID(from), judgedResourcesByID(composed) + var out []ResourceChange + for _, id := range changedOf(before, after, func(string) bool { return true }) { + was, now := before[id], after[id] + c := ResourceChange{ID: id, Change: "changed"} + switch { + case was == nil: + c.Change, c.Kind = "added", now.kind + case now == nil: + c.Change, c.Kind = "removed", was.kind + default: + c.Kind = now.kind + } + c.Consumers = consumersOf(id, was, now) + out = append(out, c) + } + return out +} + // consumersOf is, for a changed file the mesh makes for a provision (received-, bound- or secret-, catalogue.ReceivedID, // BoundID, SecretID), what changes of it in words: each consumer that joins or leaves a received file, by the given // list's from and node; else that what it is given for the provision changes. Nil for any other resource. diff --git a/go/delivery/judge_test.go b/go/delivery/judge_test.go index 39545ec..ad17be4 100644 --- a/go/delivery/judge_test.go +++ b/go/delivery/judge_test.go @@ -2,6 +2,7 @@ package delivery import ( "encoding/json" + "fmt" "strings" "testing" ) @@ -78,6 +79,15 @@ func TestJudgeMove(t *testing.T) { t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words) } } + // The resources that differ, as the engine's verb composition lists them (issue 774): a provider's received file + // names its consumers, a file changed and one added are said as such, and nothing equal is listed. + changes := ChangedResources(mail('a', true, container, spool, received, config), *mail('b', true, container, spool, received2, config2, tools)) + if want := `[{mesh-mail.conf file changed []} {mesh-mail.received-smtp file changed [cloud on novox leaves smtp wiki on ace joins smtp]} {mesh-mail.tools file added []}]`; fmt.Sprint(changes) != want { + t.Errorf("changed resources: %v, want %s", changes, want) + } + if got := ChangedResources(nil, *mail('b', true, spool)); len(got) != 1 || got[0].Change != "added" { + t.Errorf("never ran: %v, want its one resource added", got) + } if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither { t.Errorf("the node's own composition: %+v", got) } From 8ed3b872e0ba0cd0a1517c797f0e059a547e01e3 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 31 Oct 2026 00:34:39 +0100 Subject: [PATCH 3/3] Leave out a held module's contribution whose grant is not issued, so a provider never applies a stand-in (issue 774, review round 1) A module held for its own secret keeps its contributions (leaveOwn). When its grant to the provider was not issued yet, the provider's grants file carried "foreseen: made by the next send" for it, and the applying path passed over stand-ins of a module already held. Such a stand-in now leaves that provision's contribution out of the held module. --- go/compose/frombus/answer.go | 14 +++++++++++++- go/compose/frombus/held_rows_test.go | 23 +++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index 71e6f22..64eb6d2 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -240,7 +240,9 @@ const MaxShown = 256 << 10 var ( grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`) - needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `) + // grantOfStandIn is the provision a grant's stand-in is of. + grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`) + needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `) ) // standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port @@ -360,6 +362,16 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b if _, done := failed[m]; !done { hold(m, "stands in for "+strings.Join(of[m], ", "), "") again = true + continue + } + // Already held, and still a stand-in in another module's composition: a grant not yet issued for what + // it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution + // to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1). + for _, x := range of[m] { + if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] { + hold(m, failed[m], g[1]) + again = true + } } } if !again && len(nodes) > 0 { diff --git a/go/compose/frombus/held_rows_test.go b/go/compose/frombus/held_rows_test.go index 5566683..87959d1 100644 --- a/go/compose/frombus/held_rows_test.go +++ b/go/compose/frombus/held_rows_test.go @@ -179,16 +179,25 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { issued []string // the own-account modules whose broker is issued building bool // node-tools' build is not done, and nothing of it runs held map[string]string // the modules held, each with words of the reason it was first held for + // ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's + // usual state): a held module's contribution then stands in for its grant, and is left out (review round 1). + ungranted bool }{ {name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}}, + {name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}}, {name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}}, {name: "both issued", issued: []string{"check", "heal"}}, // The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node. {name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true, held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}}, } { - b, fps := aNode(t, store, tools, own("check"), lamp, own("heal")) - b.held = true // every grant held, so a contribution's credential stands in for nothing + provider := store + if row.ungranted { + provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1) + } + b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal")) + // Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none. + b.held = !row.ungranted b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} for _, m := range row.issued { b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) @@ -216,7 +225,17 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { // of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left // out of its stand-in, never its contributions, so the provider's received file names every consumer. storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"]) + // And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is + // not issued is composed without that contribution, never with "foreseen: made by the next send". + for key, c := range out { + if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") { + t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got) + } + } for _, m := range []string{"check", "heal"} { + if row.ungranted { + break + } if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) { t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got) }