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.
352 lines
9.9 KiB
Go
352 lines
9.9 KiB
Go
package delivery
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// Comparison is what CompareComposed finds, module by module: the same, differing, added by what was composed (not run
|
|
// now) and removed by it (run now, not composed). Each list is sorted by module.
|
|
type Comparison struct {
|
|
Same []string
|
|
Differs []ModuleDiffers
|
|
Added []ModuleAdded
|
|
Removed []string
|
|
}
|
|
|
|
// CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones
|
|
// it runs, as its agent would (ADR 0559 §4: a node's answer to a placement candidate). A module is the same when its
|
|
// composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing
|
|
// of it; it differs when the digests differ (both named); it is added when composed and not run, removed when run and
|
|
// not composed. The node's own composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out
|
|
// on both sides: the bus node follows it alone and never applies it from a composition it made (ADR 0547 §2, §8). A
|
|
// difference names the first resource, by its id, whose content differs or that one side alone holds; when every
|
|
// 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
|
|
}
|
|
return c.Module
|
|
}
|
|
ours := map[string]Composed{}
|
|
for _, c := range composed {
|
|
ours[name(c)] = c
|
|
}
|
|
theirs := map[string]Composed{}
|
|
for _, c := range running {
|
|
theirs[name(c)] = c
|
|
}
|
|
modules := map[string]bool{}
|
|
for m := range ours {
|
|
modules[m] = true
|
|
}
|
|
for m := range theirs {
|
|
modules[m] = true
|
|
}
|
|
var out Comparison
|
|
for _, m := range sortedKeys(modules) {
|
|
a, composedHere := ours[m]
|
|
b, runs := theirs[m]
|
|
switch {
|
|
case !runs:
|
|
dig, _ := ComposedDigestOf(withoutBusUsers(a))
|
|
out.Added = append(out.Added, ModuleAdded{Module: m, Composed: dig})
|
|
case !composedHere:
|
|
out.Removed = append(out.Removed, m)
|
|
default:
|
|
if d, ok := compareOne(m, a, b, sameValue); ok {
|
|
out.Same = append(out.Same, m)
|
|
} else {
|
|
out.Differs = append(out.Differs, d)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// compareOne is one module's comparison: true when the digests agree, else what differs first.
|
|
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)
|
|
if errC != nil {
|
|
return ModuleDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false
|
|
}
|
|
if errR == nil && dc == dr {
|
|
return ModuleDiffers{}, true
|
|
}
|
|
differs := func(resource, why string) (ModuleDiffers, bool) {
|
|
return ModuleDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false
|
|
}
|
|
// Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole
|
|
// and the same file carried by digest read as one.
|
|
a, _, errA := composed.ByDigest()
|
|
b, _, errB := running.ByDigest()
|
|
if errA != nil || errB != nil {
|
|
return differs("", "the compositions differ and cannot be read resource by resource")
|
|
}
|
|
ra, rb := resourcesByID(a.Resources), resourcesByID(b.Resources)
|
|
ids := map[string]bool{}
|
|
for id := range ra {
|
|
ids[id] = true
|
|
}
|
|
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:
|
|
why = "composed, and not in what runs"
|
|
case !inA:
|
|
why = "in what runs, and not composed"
|
|
case !bytes.Equal(x, y):
|
|
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):
|
|
return differs("", "the files the provisioner fills differ")
|
|
case a.Data != b.Data:
|
|
return differs("", "whether it declares data differs")
|
|
case !jsonEqual(a.Own, b.Own):
|
|
return differs("", "the node's own parts differ")
|
|
case a.FP != b.FP:
|
|
return differs("", "the fingerprint differs")
|
|
}
|
|
if dA, err := ComposedDigestOf(a); err == nil {
|
|
if dB, err := ComposedDigestOf(b); err == nil && dA == dB {
|
|
return differs("", "the same content, a large file carried whole on one side and by digest on the other")
|
|
}
|
|
}
|
|
return differs("", "the same resources in another order")
|
|
}
|
|
|
|
// withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `<module>.bus-users`).
|
|
func withoutBusUsers(c Composed) Composed {
|
|
out := c
|
|
out.Resources = nil
|
|
for _, raw := range c.Resources {
|
|
var head struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if json.Unmarshal(raw, &head) == nil && (head.ID == catalogue.BusUsersID() || strings.HasSuffix(head.ID, "."+catalogue.BusUsersID())) {
|
|
continue
|
|
}
|
|
out.Resources = append(out.Resources, raw)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resourcesByID is each resource canonical, by its id (a resource without one by its place).
|
|
func resourcesByID(resources []json.RawMessage) map[string][]byte {
|
|
out := make(map[string][]byte, len(resources))
|
|
for i, raw := range resources {
|
|
var head struct {
|
|
ID string `json:"id"`
|
|
}
|
|
_ = json.Unmarshal(raw, &head)
|
|
id := head.ID
|
|
if id == "" {
|
|
id = "#" + strconv.Itoa(i)
|
|
}
|
|
canon, err := canonicalJSON(raw)
|
|
if err != nil {
|
|
canon = raw
|
|
}
|
|
out[id] = canon
|
|
}
|
|
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: "<masked: the same on both sides>", "<masked: another seal of the same
|
|
// value>" or "<masked: another value>" when sameValue says so, "<masked: differs>" otherwise, "<masked>" 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 "<not JSON: " + strconv.Itoa(len(raw)) + " bytes>"
|
|
}
|
|
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 "<masked>"
|
|
}
|
|
x, _ := json.Marshal(a)
|
|
y, _ := json.Marshal(b)
|
|
if bytes.Equal(x, y) {
|
|
return "<masked: the same on both sides>"
|
|
}
|
|
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 "<masked: another seal of the same value>"
|
|
}
|
|
return "<masked: another value>"
|
|
}
|
|
}
|
|
return "<masked: differs>"
|
|
}
|