From b03bf4c118d95c9e48b4af39533e3b8389373902 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 22:13:37 +0100 Subject: [PATCH 1/5] A node's answer holds as today what it holds today, so a seed that reproduces today is the same (issue 1126) --- go/compose/frombus/answer.go | 22 ++++++++++++++++++++++ go/compose/frombus/answer_rows_test.go | 7 +++++++ go/compose/frombus/frombus.go | 4 ++++ go/delivery/placement_candidate.go | 6 +++++- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index 1e05a20..031231f 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -99,6 +99,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st for _, m := range sortedKeys(failed) { a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) } + heldAsToday(&a, s.HeldToday) a.Verdict = delivery.VerdictOf(a) return a } @@ -203,10 +204,31 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) } sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module }) + heldAsToday(&a, s.HeldToday) a.Verdict = delivery.VerdictOf(a) return a } +// heldAsToday moves each refusal of a module the node holds today (Scope.HeldToday) into Held: the candidate holds it as +// the node does, which is no change (issue 1126: a seed reproduces today, and today holds it too). +func heldAsToday(a *delivery.CandidateAnswer, today []string) { + if len(today) == 0 { + return + } + kept := a.Refusals[:0] + for _, r := range a.Refusals { + if slices.Contains(today, r.Module) { + a.Held = append(a.Held, r) + } else { + kept = append(kept, r) + } + } + a.Refusals = kept + if len(a.Refusals) == 0 { + a.Refusals = nil + } +} + // maxCulprits bounds the modules one refusal is tried on, each a whole composition. const maxCulprits = 8 diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index 15da9b4..e62289e 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -91,6 +91,13 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK { t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a) } + // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every + // refused module held today the verdict is no refusal, and each is named under Held. + today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: []string{"api", "spiegel", "web"}}, + map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) + if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { + t.Errorf("held as today: %+v", today) + } // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 979502c..b287751 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -124,6 +124,10 @@ type Scope struct { Placements map[string]*delivery.Placement InOwn []string Running []delivery.Composed + // HeldToday is, in an answer, the modules the node holds today ("node" for its own composition): their have's phase + // is held, or they are held inside its own composition. A candidate that holds one too holds it as today, which is + // said under CandidateAnswer.Held and is no refusal (issue 1126). + HeldToday []string // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them // as differing. diff --git a/go/delivery/placement_candidate.go b/go/delivery/placement_candidate.go index ad86720..241bbce 100644 --- a/go/delivery/placement_candidate.go +++ b/go/delivery/placement_candidate.go @@ -3,6 +3,7 @@ package delivery import ( "encoding/json" "fmt" + "slices" "strings" ) @@ -121,6 +122,9 @@ type CandidateAnswer struct { Added []ModuleAdded `json:"added,omitempty"` Removed []string `json:"removed,omitempty"` Refusals []ModuleRefused `json:"refusals,omitempty"` + // Held is each module the candidate cannot compose that the node holds today too, with why: held as today, which + // changes nothing and counts for no verdict (issue 1126: a seed reproduces today). + Held []ModuleRefused `json:"held,omitempty"` // Refused is why the node could not compose with the candidate at all; Refusals is then empty. Refused string `json:"refused,omitempty"` // Returns is, for a proposal that takes the node's placement key away, what the node does then: it returns to the @@ -233,7 +237,7 @@ func (a CandidateAnswer) check() error { return fmt.Errorf("delivery: a module the candidate adds is not named") } } - for _, d := range a.Refusals { + for _, d := range append(slices.Clone(a.Refusals), a.Held...) { if d.Module == "" || d.Why == "" { return fmt.Errorf("delivery: a candidate refusal names no module or no why") } -- 2.54.0 From 0830fcb25ec4c8d528acc5d343ad9fb395898591 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 22:17:40 +0100 Subject: [PATCH 2/5] Hold that a refused module not held today keeps the answer refused (issue 1126) --- go/compose/frombus/answer_rows_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index e62289e..46461d1 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -98,6 +98,12 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { t.Errorf("held as today: %+v", today) } + // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. + part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: []string{"api"}}, + map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) + if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { + t.Errorf("held as today in part: %+v", part) + } // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. -- 2.54.0 From 06b13ec1cc073cf86cbf0492a269370ff9945978 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 22:17:57 +0100 Subject: [PATCH 3/5] Show in a proposal's brief what stays held as each node holds it today (issue 1126) --- go/compose/judge/judge.go | 14 +++++++++++++- go/compose/judge/judge_test.go | 5 +++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/go/compose/judge/judge.go b/go/compose/judge/judge.go index a6780db..1b3cab5 100644 --- a/go/compose/judge/judge.go +++ b/go/compose/judge/judge.go @@ -209,7 +209,7 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b } // Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered -// beside it, marked, with the proposal's why. +// beside it, marked, and what stays held as the node holds it today, with the proposal's why. func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { var b strings.Builder fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By) @@ -223,6 +223,11 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { for _, n := range p.Nodes() { nodes[n] = true } + for n, a := range v.Answers { + if len(a.Held) > 0 { + nodes[n] = true + } + } names := make([]string, 0, len(nodes)) for n := range nodes { names = append(names, n) @@ -238,6 +243,13 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { if m := v.Unintended[n]; len(m) > 0 { fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", ")) } + if held := v.Answers[n].Held; len(held) > 0 { + var names []string + for _, h := range held { + names = append(names, h.Module) + } + fmt.Fprintf(&b, "; stays held as today: %s", strings.Join(names, ", ")) + } } return b.String() } diff --git a/go/compose/judge/judge_test.go b/go/compose/judge/judge_test.go index caf5111..077d644 100644 --- a/go/compose/judge/judge_test.go +++ b/go/compose/judge/judge_test.go @@ -52,6 +52,8 @@ func TestJudge(t *testing.T) { Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""}, {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""}, {"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictFail, "anchor did not answer", ""}, + {"a module held as today passes, shown in the brief", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, + Verdict: delivery.CandidateSame, Held: []delivery.ModuleRefused{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, delivery.VerdictPass, "", ""}, {"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, } { q := p @@ -61,6 +63,9 @@ func TestJudge(t *testing.T) { if v.State != row.state || !strings.Contains(all, row.words) { t.Errorf("%s: %s, %q", row.name, v.State, all) } + if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays held as today: spiegel") { + t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v)) + } if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) { t.Errorf("%s: the difference is not marked: %+v, %s", row.name, v.Unintended, Brief(q, v)) } -- 2.54.0 From e84a121b8a9a4518b5f2ce1f4862a60ed1fcc271 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 22:24:57 +0100 Subject: [PATCH 4/5] Let the seed carry the differences its nodes found, each named in its brief for the operator to approve knowingly; a refusal or silence still fails (issue 1126) --- go/compose/judge/judge.go | 34 ++++++++++++++++++++++++++++--- go/compose/judge/judge_test.go | 7 ++++++- go/delivery/placement_proposal.go | 22 ++++++++++++-------- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/go/compose/judge/judge.go b/go/compose/judge/judge.go index 1b3cab5..698a3f6 100644 --- a/go/compose/judge/judge.go +++ b/go/compose/judge/judge.go @@ -121,7 +121,7 @@ func Intended(current, laidInput *compose.Input, currentPlaced map[string]delive // shown in the ask's brief. The seed alone fails on any difference. func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer, revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict { - v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, + v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{}, Answers: map[string]delivery.CandidateAnswer{}} for node, set := range intended { for m := range set { @@ -150,7 +150,8 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b want := intended[node] differs := func(module, words string) { if p.Seed { - v.Fails = append(v.Fails, fmt.Sprintf("%s %s with the seed, which must be the same as what runs", node, words)) + // The seed carries what its nodes found, each named in the brief for the operator who approves it. + v.Carried[node] = append(v.Carried[node], words) return } if !want[module] { @@ -158,7 +159,7 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b } } for _, d := range a.Differs { - differs(d.Module, fmt.Sprintf("composes %s differently (%s)", d.Module, d.Why)) + differs(d.Module, differenceWords(d)) } for _, d := range a.Added { differs(d.Module, "would run "+d.Module) @@ -174,6 +175,9 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b if len(v.Unintended) == 0 { v.Unintended = nil } + if len(v.Carried) == 0 { + v.Carried = nil + } if len(v.Answers) == 0 { v.Answers = nil } @@ -228,6 +232,9 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { nodes[n] = true } } + for n := range v.Carried { + nodes[n] = true + } names := make([]string, 0, len(nodes)) for n := range nodes { names = append(names, n) @@ -243,6 +250,9 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { if m := v.Unintended[n]; len(m) > 0 { fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", ")) } + if m := v.Carried[n]; len(m) > 0 { + fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; ")) + } if held := v.Answers[n].Held; len(held) > 0 { var names []string for _, h := range held { @@ -253,3 +263,21 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { } return b.String() } + +// differenceWords names one module's difference: the module, the resource and the first path that differs in it, and how +// many more, or why when no body is shown. +func differenceWords(d delivery.ModuleDiffers) string { + for _, b := range d.Bodies { + if len(b.Paths) > 0 { + words := fmt.Sprintf("%s: %s at %s", d.Module, b.ID, b.Paths[0]) + if n := len(b.Paths) - 1; n > 0 { + words += fmt.Sprintf(" (and %d more)", n) + } + return words + } + } + if d.Resource != "" { + return fmt.Sprintf("%s: %s (%s)", d.Module, d.Resource, d.Why) + } + return fmt.Sprintf("%s: %s", d.Module, d.Why) +} diff --git a/go/compose/judge/judge_test.go b/go/compose/judge/judge_test.go index 077d644..2cc5454 100644 --- a/go/compose/judge/judge_test.go +++ b/go/compose/judge/judge_test.go @@ -47,7 +47,9 @@ func TestJudge(t *testing.T) { {"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers, Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, {"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"}, - {"the seed with a difference", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": differs(8, "web")}, nil, delivery.VerdictFail, "laptop composes web differently", ""}, + {"the seed carries a difference, named", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, + Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs", + Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"}}}}}}}, nil, delivery.VerdictPass, "", ""}, {"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused, Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""}, {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""}, @@ -63,6 +65,9 @@ func TestJudge(t *testing.T) { if v.State != row.state || !strings.Contains(all, row.words) { t.Errorf("%s: %s, %q", row.name, v.State, all) } + if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: node-tools.runtime at env.MESH_OWNED_PATHS./etc/resolv.conf (and 1 more)") { + t.Errorf("%s: the brief does not name it: %s", row.name, Brief(q, v)) + } if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays held as today: spiegel") { t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v)) } diff --git a/go/delivery/placement_proposal.go b/go/delivery/placement_proposal.go index 1a99de2..6548705 100644 --- a/go/delivery/placement_proposal.go +++ b/go/delivery/placement_proposal.go @@ -356,15 +356,19 @@ func NewProposalID(at time.Time) string { // is shown by node: Intended is what the holder computed the proposal changes, Unintended what a node answered beside // it, marked as such in the brief. type PlacementVerdict struct { - State string `json:"state"` - At time.Time `json:"at"` - Fails []string `json:"fails,omitempty"` - Silent []string `json:"silent,omitempty"` - Until time.Time `json:"until,omitempty"` - Intended map[string][]string `json:"intended,omitempty"` - Unintended map[string][]string `json:"unintended,omitempty"` - Answers map[string]CandidateAnswer `json:"answers,omitempty"` - Notes []string `json:"notes,omitempty"` + State string `json:"state"` + At time.Time `json:"at"` + Fails []string `json:"fails,omitempty"` + Silent []string `json:"silent,omitempty"` + Until time.Time `json:"until,omitempty"` + Intended map[string][]string `json:"intended,omitempty"` + Unintended map[string][]string `json:"unintended,omitempty"` + // Carried is, for the seed, by node each difference a node found that the seed carries, named for the operator who + // approves it knowingly (": at "): the seed fails on no difference, only on a refusal or + // silence (issue 1126). + Carried map[string][]string `json:"carried,omitempty"` + Answers map[string]CandidateAnswer `json:"answers,omitempty"` + Notes []string `json:"notes,omitempty"` } // PlacementProposal is an open proposal, the value of its key in PlacementProposalsBucket. -- 2.54.0 From 77b48a4f1affb6315cdfc801d8cb6dc75686253e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 22:26:05 +0100 Subject: [PATCH 5/5] Name in the seed's brief the first differing field's value as it runs and as composed, so the operator sees what changes (issue 1126) --- go/compose/judge/judge.go | 101 ++++++++++++++++++++++++++++++--- go/compose/judge/judge_test.go | 15 ++++- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/go/compose/judge/judge.go b/go/compose/judge/judge.go index 698a3f6..43c3a91 100644 --- a/go/compose/judge/judge.go +++ b/go/compose/judge/judge.go @@ -5,9 +5,11 @@ package judge import ( + "encoding/json" "fmt" "reflect" "sort" + "strconv" "strings" "time" @@ -264,20 +266,103 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { return b.String() } -// differenceWords names one module's difference: the module, the resource and the first path that differs in it, and how -// many more, or why when no body is shown. +// differenceWords names one module's difference: the module, the first field that differs in it with its value as it +// runs and as the seed composes it, each cut to about 60 characters ("(value cut)" where a body shown is cut before the +// field), and how many fields more; or the resource and why when no body is shown. func differenceWords(d delivery.ModuleDiffers) string { for _, b := range d.Bodies { - if len(b.Paths) > 0 { - words := fmt.Sprintf("%s: %s at %s", d.Module, b.ID, b.Paths[0]) - if n := len(b.Paths) - 1; n > 0 { - words += fmt.Sprintf(" (and %d more)", n) - } - return words + if len(b.Paths) == 0 { + continue } + path := b.Paths[0] + words := fmt.Sprintf("%s: %s %s → %s", d.Module, fieldName(b.Running, b.Composed, path), + valueAt(b.Running, path), valueAt(b.Composed, path)) + if n := len(b.Paths) - 1; n > 0 { + words += fmt.Sprintf(" (and %d more fields)", n) + } + return words } if d.Resource != "" { return fmt.Sprintf("%s: %s (%s)", d.Module, d.Resource, d.Why) } return fmt.Sprintf("%s: %s", d.Module, d.Why) } + +// shownValue bounds a value named in a brief. +const shownValue = 60 + +// walk follows a differing path (delivery.DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value +// written in a string is read as JSON) through a body, answering the keys it passed and the value at its end. +func walk(body, path string) (keys []string, value any, ok bool) { + var v any + if json.Unmarshal([]byte(body), &v) != nil { + return nil, nil, false + } + rest := path + for rest != "" { + if s, isString := v.(string); isString { + var inner any + if json.Unmarshal([]byte(s), &inner) != nil { + return nil, nil, false + } + v = inner + } + switch t := v.(type) { + case map[string]any: + best := "" + for k := range t { + if (rest == k || strings.HasPrefix(rest, k+".")) && len(k) > len(best) { + best = k + } + } + if best == "" { + return nil, nil, false + } + keys, v, rest = append(keys, best), t[best], strings.TrimPrefix(strings.TrimPrefix(rest, best), ".") + case []any: + seg, after, _ := strings.Cut(rest, ".") + n, err := strconv.Atoi(seg) + if err != nil || n < 0 || n >= len(t) { + return nil, nil, false + } + keys, v, rest = append(keys, seg), t[n], after + default: + return nil, nil, false + } + } + return keys, v, true +} + +// valueAt is the value at path in a shown body, cut to shownValue, or why it is not shown. +func valueAt(body, path string) string { + if body == "" { + return "(none)" + } + _, v, ok := walk(body, path) + if !ok { + return "(value cut)" + } + s, isString := v.(string) + if !isString { + raw, _ := json.Marshal(v) + s = string(raw) + } + if r := []rune(s); len(r) > shownValue { + s = string(r[:shownValue-1]) + "…" + } + return s +} + +// fieldName is the differing field as a person reads it: its parent's name and its own, `PARENT[key]`, from whichever +// body reaches it; the path's end when neither does. +func fieldName(running, composed, path string) string { + for _, body := range []string{running, composed} { + if keys, _, ok := walk(body, path); ok && len(keys) > 0 { + if len(keys) == 1 { + return keys[0] + } + return keys[len(keys)-2] + "[" + keys[len(keys)-1] + "]" + } + } + return path +} diff --git a/go/compose/judge/judge_test.go b/go/compose/judge/judge_test.go index 2cc5454..510830a 100644 --- a/go/compose/judge/judge_test.go +++ b/go/compose/judge/judge_test.go @@ -49,7 +49,9 @@ func TestJudge(t *testing.T) { {"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"}, {"the seed carries a difference, named", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs", - Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"}}}}}}}, nil, delivery.VerdictPass, "", ""}, + Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"}, + Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, + Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, delivery.VerdictPass, "", ""}, {"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused, Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""}, {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""}, @@ -65,9 +67,18 @@ func TestJudge(t *testing.T) { if v.State != row.state || !strings.Contains(all, row.words) { t.Errorf("%s: %s, %q", row.name, v.State, all) } - if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: node-tools.runtime at env.MESH_OWNED_PATHS./etc/resolv.conf (and 1 more)") { + if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → (value cut) (and 1 more fields)") { t.Errorf("%s: the brief does not name it: %s", row.name, Brief(q, v)) } + if row.seed && row.state == delivery.VerdictPass { + whole := row.answers["laptop"].Differs[0] + whole.Bodies = []delivery.ResourceBodies{{ID: "r", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf"}, + Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, + Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}"}}`}} + if got := differenceWords(whole); got != "node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → systemd-resolved (node-resolver)" { + t.Errorf("the difference is named %q", got) + } + } if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays held as today: spiegel") { t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v)) } -- 2.54.0