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/compose/frombus/answer.go b/go/compose/frombus/answer.go index f7d6e93..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 @@ -322,7 +324,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" } } @@ -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 { @@ -404,9 +416,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 +506,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 +537,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 +550,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..87959d1 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,26 +167,37 @@ 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 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")) + 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"`}) @@ -208,6 +221,25 @@ 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"]) + // 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) + } + } 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..a060fc7 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,114 @@ 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. +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..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" ) @@ -28,6 +29,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,29 +46,48 @@ 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) } } + // 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) }