From b31c4580aa93cea337b2e322b60a637e756f6710 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 01:08:49 +0100 Subject: [PATCH] A candidate answer shows every differing resource, masked; a module folded in the node's own is judged there; the node's own carries its bus membership as it runs it (issue 1126, seed #531) novox's answer to seed #531 named 15 resources by id alone, so why each differed was guessed. ModuleDiffers.Bodies keeps every differing resource with both bodies, each value under a key a secret could be in masked (the same, another seal of the same value, another value). The five old-form modules read 'removed' because the agent also lists them by module from the declaration kept before the switch; they are judged inside the node's own. And the node's own lacked bus-membership, which only the controller composes from its store: the membership the node runs is carried, until the grants part hands it sealed. --- go/compose/frombus/answer.go | 13 +- go/compose/frombus/answer_rows_test.go | 19 +++ go/compose/frombus/frombus.go | 29 ++++ go/delivery/composed_compare.go | 194 ++++++++++++++++++++++++- go/delivery/placement_candidate.go | 21 +++ go/delivery/placement_test.go | 33 +++++ 6 files changed, 303 insertions(+), 6 deletions(-) diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go index baa549e..52f3bbb 100644 --- a/go/compose/frombus/answer.go +++ b/go/compose/frombus/answer.go @@ -107,7 +107,18 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st a.Verdict = delivery.VerdictOf(a) return a } - cmp := delivery.CompareComposed(composed, running) + // A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from + // the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531). + if len(s.InOwn) > 0 { + var judged []delivery.Composed + for _, c := range running { + if c.Module == "" || !slices.Contains(s.InOwn, c.Module) { + judged = append(judged, c) + } + } + running = judged + } + cmp := delivery.CompareComposedWith(composed, running, s.SameValue) // A refused module was composed as a stub: what it composed is no difference; its refusal is said. notFailed := func(m string) bool { _, ok := failed[m]; return !ok } a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) }) diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go index 9c191d0..2a361af 100644 --- a/go/compose/frombus/answer_rows_test.go +++ b/go/compose/frombus/answer_rows_test.go @@ -135,6 +135,25 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { if a.Verdict != delivery.CandidateSame { t.Errorf("the fold's answer: %+v, want the same", a) } + // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a + // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer). + keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 { + t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a) + } + // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed + // the same, not 'in what runs, not composed' (seed #531, novox). + membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`) + withMembership := make([]delivery.Composed, 0, len(running)) + for _, c := range running { + if c.Module == "" { + c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership) + } + withMembership = append(withMembership, c) + } + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "h1", 3); a.Verdict != delivery.CandidateSame { + t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) + } // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. if _, _, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err == nil || !strings.Contains(err.Error(), "spiegel has not been built for this mesh") { diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 7aef315..735a831 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 + // 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. + SameValue func(a, b string) (same, known bool) } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -296,6 +300,16 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module if s.Running[i].Module == "" { own := s.Running[i] n.RunsOwn = &own + // Its bus membership as it runs it: the controller composes it from its store, which the node does + // not read; until the grants part hands memberships sealed to the node, the node's own composition + // carries the one it runs (issue 1126, seed #531 on novox: "bus-membership in what runs, not + // composed", which applied would have taken the node's bus credential away). + if sealed := membershipOf(own); sealed != "" { + if in.BusMemberships == nil { + in.BusMemberships = map[string]string{} + } + in.BusMemberships[s.Node] = sealed + } } } } @@ -693,3 +707,18 @@ func storeSought(in compose.Input) string { return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") + ", none of it on the private network with a port" } + +// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own +// composition, or "". +func membershipOf(own delivery.Composed) string { + for _, raw := range own.Resources { + var r struct { + ID string `json:"id"` + Sealed string `json:"sealed"` + } + if json.Unmarshal(raw, &r) == nil && r.ID == catalogue.BusMembershipID() { + return r.Sealed + } + } + return "" +} diff --git a/go/delivery/composed_compare.go b/go/delivery/composed_compare.go index ecb4ca0..4faee55 100644 --- a/go/delivery/composed_compare.go +++ b/go/delivery/composed_compare.go @@ -28,6 +28,13 @@ type Comparison struct { // resource is equal it says what else differs (their order, the files filled, the data, the node's own parts, a file // carried by digest). func CompareComposed(composed map[string]Composed, running []Composed) Comparison { + return CompareComposedWith(composed, running, nil) +} + +// CompareComposedWith is CompareComposed showing, for each module that differs, every differing resource with both +// bodies masked (MaskedPair); sameValue, when given, says whether two sealed values open to the same value (the node +// opens them with its own key), so a value sealed again reads as such. +func CompareComposedWith(composed map[string]Composed, running []Composed, sameValue func(a, b string) (same, known bool)) Comparison { name := func(c Composed) string { if c.Module == "" { return HaveNodeWord @@ -60,7 +67,7 @@ func CompareComposed(composed map[string]Composed, running []Composed) Compariso case !composedHere: out.Removed = append(out.Removed, m) default: - if d, ok := compareOne(m, a, b); ok { + if d, ok := compareOne(m, a, b, sameValue); ok { out.Same = append(out.Same, m) } else { out.Differs = append(out.Differs, d) @@ -71,7 +78,7 @@ func CompareComposed(composed map[string]Composed, running []Composed) Compariso } // compareOne is one module's comparison: true when the digests agree, else what differs first. -func compareOne(module string, composed, running Composed) (ModuleDiffers, bool) { +func compareOne(module string, composed, running Composed, sameValue func(a, b string) (bool, bool)) (ModuleDiffers, bool) { composed, running = withoutBusUsers(composed), withoutBusUsers(running) dc, errC := ComposedDigestOf(composed) dr, errR := ComposedDigestOf(running) @@ -99,17 +106,35 @@ func compareOne(module string, composed, running Composed) (ModuleDiffers, bool) for id := range rb { ids[id] = true } + var first ModuleDiffers + found := false + var bodies []ResourceBodies for _, id := range sortedKeys(ids) { x, inA := ra[id] y, inB := rb[id] + why := "" switch { case !inB: - return differs(id, "composed, and not in what runs") + why = "composed, and not in what runs" case !inA: - return differs(id, "in what runs, and not composed") + why = "in what runs, and not composed" case !bytes.Equal(x, y): - return differs(id, "the resource differs") + why = "the resource differs" + default: + continue } + if !found { + first, _ = differs(id, why) + found = true + } + if len(bodies) < MaxBodies { + c, r := MaskedPair(x, y, sameValue) + bodies = append(bodies, ResourceBodies{ID: id, Why: why, Composed: cut(c), Running: cut(r)}) + } + } + if found { + first.Bodies = bodies + return first, false } switch { case !jsonEqual(a.Filled, b.Filled): @@ -165,3 +190,162 @@ func resourcesByID(resources []json.RawMessage) map[string][]byte { } return out } + +// cut is s cut at MaxBody bytes, saying so. +func cut(s string) string { + if len(s) <= MaxBody { + return s + } + return s[:MaxBody] + "… (cut at " + strconv.Itoa(MaxBody) + " bytes)" +} + +// secretWords are the words of a key whose value may hold a secret or a sealed one: masked in a shown body. +var secretWords = []string{"sealed", "secret", "password", "passwd", "token", "private", "key", "credential", "jwt", "salt", "hash"} + +func secretKey(k string) bool { + k = strings.ToLower(k) + for _, w := range secretWords { + if strings.Contains(k, w) { + return true + } + } + return false +} + +// MaskedPair is two bodies of one resource (either may be nil) as JSON text with every value under a key a secret could +// be in (secretWords, at any depth) masked: "", "" or "" when sameValue says so, "" otherwise, "" on one side +// alone. A body that is not JSON is shown as its length only. +func MaskedPair(composed, running json.RawMessage, sameValue func(a, b string) (same, known bool)) (string, string) { + var a, b any + okA := composed != nil && json.Unmarshal(composed, &a) == nil + okB := running != nil && json.Unmarshal(running, &b) == nil + if okA && okB { + a, b = maskBoth(a, b, sameValue) + } else if okA { + a, _ = maskBoth(a, nil, nil) + } else if okB { + _, b = maskBoth(nil, b, nil) + } + show := func(v any, raw json.RawMessage, ok bool) string { + if raw == nil { + return "" + } + if !ok { + return "" + } + var out bytes.Buffer + enc := json.NewEncoder(&out) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) + return strings.TrimSuffix(out.String(), "\n") + } + return show(a, composed, okA), show(b, running, okB) +} + +func maskBoth(a, b any, sameValue func(a, b string) (bool, bool)) (any, any) { + ma, isA := a.(map[string]any) + mb, isB := b.(map[string]any) + if isA || isB { + outA, outB := map[string]any{}, map[string]any{} + keys := map[string]bool{} + for k := range ma { + keys[k] = true + } + for k := range mb { + keys[k] = true + } + for k := range keys { + va, inA := ma[k] + vb, inB := mb[k] + if secretKey(k) { + word := maskWord(va, vb, inA && inB, sameValue) + if inA { + outA[k] = word + } + if inB { + outB[k] = word + } + continue + } + x, y := maskBoth(va, vb, sameValue) + if inA { + outA[k] = x + } + if inB { + outB[k] = y + } + } + var ra, rb any + if isA { + ra = outA + } else { + ra = a + } + if isB { + rb = outB + } else { + rb = b + } + return ra, rb + } + la, isLA := a.([]any) + lb, isLB := b.([]any) + if isLA || isLB { + outA := make([]any, len(la)) + outB := make([]any, len(lb)) + for i := range max(len(la), len(lb)) { + var x, y any + if i < len(la) { + x = la[i] + } + if i < len(lb) { + y = lb[i] + } + x, y = maskBoth(x, y, sameValue) + if i < len(la) { + outA[i] = x + } + if i < len(lb) { + outB[i] = y + } + } + var ra, rb any = a, b + if isLA { + ra = outA + } + if isLB { + rb = outB + } + return ra, rb + } + return a, b +} + +func maskWord(a, b any, both bool, sameValue func(a, b string) (bool, bool)) string { + if !both { + return "" + } + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + if bytes.Equal(x, y) { + return "" + } + if sameValue != nil { + sa, okA := a.(string) + sb, okB := b.(string) + if !okA { + sa = string(x) + } + if !okB { + sb = string(y) + } + if same, known := sameValue(sa, sb); known { + if same { + return "" + } + return "" + } + } + return "" +} diff --git a/go/delivery/placement_candidate.go b/go/delivery/placement_candidate.go index 6b28569..64a8d47 100644 --- a/go/delivery/placement_candidate.go +++ b/go/delivery/placement_candidate.go @@ -138,6 +138,9 @@ type CandidateAnswer struct { // the node's wants with what it runs, and the verdict is theirs; a want the controller holds no composition for is // refused (Refused, without Returns). Returns string `json:"returns,omitempty"` + // Note is what the node says of how it answered, in words, beside the verdict: a pass that ran over a minute, inputs + // it waited for (issue 1126: the shadow shows its work). It changes no verdict. + Note string `json:"note,omitempty"` } // ReturnsToController is what a node answers a head that deletes its placement file. @@ -152,8 +155,26 @@ type ModuleDiffers struct { Running string `json:"running,omitempty"` Resource string `json:"resource,omitempty"` Why string `json:"why"` + // Bodies is every resource that differs, both sides, each value a secret could be in masked (issue 1126: the shadow + // shows its work, so why a module differs is read, not guessed). At most MaxBodies, each side cut at MaxBody bytes. + Bodies []ResourceBodies `json:"bodies,omitempty"` } +// ResourceBodies is one differing resource of a module: its id, why, and its body composed and as it runs, masked +// (MaskedPair); a side that does not hold it is empty. +type ResourceBodies struct { + ID string `json:"id"` + Why string `json:"why"` + Composed string `json:"composed,omitempty"` + Running string `json:"running,omitempty"` +} + +// The bounds of a module's shown bodies. +const ( + MaxBodies = 20 + MaxBody = 8192 +) + // ModuleAdded is one module the candidate places on the node and the node does not run: its composition's digest when // it composed, and the values not yet issued for it (its own secrets, its grants, a host port), each in the words the // composition stood in for it with. diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index e0b7810..dd906b2 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -283,6 +283,39 @@ func TestPlacement(t *testing.T) { t.Errorf("%s: differs %+v, want %s: %s with both digests", name, c.Differs, tc.resource, tc.why) } } + // Issue 1126, the shadow shows its work: every differing resource with both bodies, each value a secret could be + // in masked (never a secret in an answer), saying whether the two sides hold the same value. + sealedFile := func(id, sealed, mode string) json.RawMessage { + return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"` + mode + `","sealed":"` + sealed + `"}`) + } + shown := CompareComposedWith(map[string]Composed{"k": comp(sealedFile("a", "box-1", "0600"), sealedFile("b", "box-2", "0600"), file("c", "x"))}, + []Composed{comp(sealedFile("a", "box-1", "0644"), sealedFile("b", "box-3", "0600"), file("c", "y"))}, + func(a, b string) (same, known bool) { return a == "box-2" && b == "box-3", true }) + if len(shown.Differs) != 1 || len(shown.Differs[0].Bodies) != 3 { + t.Fatalf("every differing resource is not shown: %+v", shown.Differs) + } + for _, row := range []struct{ id, has, hasNot string }{ + {"a", `"mode":"0600"`, "box-1"}, + {"b", "another seal of the same value", "box-2"}, + {"c", `"content":"x"`, ""}, + } { + var body ResourceBodies + for _, b := range shown.Differs[0].Bodies { + if b.ID == row.id { + body = b + } + } + both := body.Composed + body.Running + if !strings.Contains(both, row.has) || (row.hasNot != "" && strings.Contains(both, row.hasNot)) { + t.Errorf("%s shown as %+v", row.id, body) + } + } + if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`), + json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") || + strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "") || + !strings.Contains(c, "") { + t.Errorf("masked as %s and %s", c, r) + } if c := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" { t.Errorf("the node's own composition run and not composed is removed: %+v", c) }