Seals are reused and compared by slot (resource, field), never across resources; one rule says which resolved config values are laid; a seat path that steps back is owned by the seat it steps back for, whatever the seat order; a folded module of an old-form build is composed from what runs of it; the operator export's file is carried as it runs; a differing resource names its differing JSON paths, found before its bodies are cut, and an answer is fitted to the node's key.
358 lines
10 KiB
Go
358 lines
10 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)
|
|
// A sealed value is judged by the value it opens to, slot by slot (issue 1126, C1): a value sealed again at the same
|
|
// field of the same resource is the box that runs, never a difference; and a box is never matched across resources.
|
|
composed = KeepSeals(composed, SealsIn(running), sameValue)
|
|
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 {
|
|
// The paths are found on the whole bodies, before either is cut (issue 1126, T1: three resources over
|
|
// MaxBody could not be told apart).
|
|
c, r := MaskedPair(x, y, sameValue)
|
|
bodies = append(bodies, ResourceBodies{ID: id, Why: why, Paths: DifferingPaths(x, y),
|
|
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>"
|
|
}
|