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)) }