mesh/delivery merged: source.main moved to novox/mesh-sdk@be77f6656326
mesh/delivery-group group fix/1126-shadow-bodies merged: every member is merged
mesh/check pass 362a3f1378aa on 03da552cb331 | unit 2860 run, 1141 kept, 36 skipped | integration 2887 run, 1241 kept, 29 skipped | over bound: unit (…
ace and g14 answered 'nothing in this mesh provides artifact-store … assign distribution to a node' while novox runs it: a module another node cannot run in this composition (catalogue Resolution.Unhostable) offers nothing, silently. The refusal now names each such module and what it lacks, and each node left out. And the culprit search composed the whole mesh once per placed module for a refusal no stub changes (ace's answer took 7m46s): only the modules the refusal names are tried, at most 8.
300 lines
12 KiB
Go
300 lines
12 KiB
Go
package frombus
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/compose"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// Answer is a node's answer to its group's placement candidates (novox/hq ADR 0559 §4): s.Node composed in shadow with
|
|
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
|
|
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
|
|
// nothing; head and revision are the candidate's, said back in the answer.
|
|
//
|
|
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
|
|
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
|
|
// composition stands in for is judged: a value of a module the group adds (on this node or another: its credential, own
|
|
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
|
|
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
|
|
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, head string, revision uint64) delivery.CandidateAnswer {
|
|
a := delivery.CandidateAnswer{Head: head, Revision: revision}
|
|
refuse := func(err error) delivery.CandidateAnswer {
|
|
a.Refused = err.Error()
|
|
a.Verdict = delivery.CandidateRefused
|
|
return a
|
|
}
|
|
trunk, err := r.Placements(ctx)
|
|
if err != nil {
|
|
return refuse(fmt.Errorf("frombus: placement: %w", err))
|
|
}
|
|
if s.Running == nil {
|
|
s.Running = running
|
|
}
|
|
// Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
|
|
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
|
|
failed := map[string]string{}
|
|
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() })
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
|
|
if err != nil {
|
|
return refuse(err)
|
|
}
|
|
// What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the
|
|
// whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is
|
|
// assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet
|
|
// issued under Added (review of L2b, round 1, finding 1).
|
|
runs := map[string]bool{}
|
|
for _, c := range running {
|
|
if c.Module != "" {
|
|
runs[c.Module] = true
|
|
}
|
|
}
|
|
var consumed []compose.CredentialKey
|
|
for _, k := range asConsumer {
|
|
if runs[k.ConsumerModule] {
|
|
consumed = append(consumed, k)
|
|
}
|
|
}
|
|
asConsumer = consumed
|
|
if len(asConsumer)+len(asProvider) > 0 {
|
|
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
|
|
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
|
|
}
|
|
}
|
|
composed, skipped, stood, err := compose.CompositionsShadow(in, s.Node, fps)
|
|
for err != nil {
|
|
module := culprit(in, s.Node, fps, failed, err)
|
|
if module == "" {
|
|
// Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's.
|
|
if len(failed) == 0 {
|
|
return refuse(err)
|
|
}
|
|
failed[delivery.HaveNodeWord] = err.Error()
|
|
composed, skipped, stood = nil, nil, nil
|
|
break
|
|
}
|
|
failed[module] = err.Error()
|
|
in = stubbed(in, s.Node, module)
|
|
composed, skipped, stood, err = compose.CompositionsShadow(in, s.Node, fps)
|
|
}
|
|
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
|
|
// names it (the node agent's own composer hands it on that want alone), so a candidate does not add it.
|
|
runsOwn := false
|
|
for _, c := range running {
|
|
runsOwn = runsOwn || c.Module == ""
|
|
}
|
|
if !runsOwn {
|
|
for key, c := range composed {
|
|
if c.Module == "" {
|
|
delete(composed, key)
|
|
}
|
|
}
|
|
}
|
|
if _, whole := failed[delivery.HaveNodeWord]; whole {
|
|
// Nothing composed to compare: every refusal is said, and nothing else.
|
|
for _, m := range sortedKeys(failed) {
|
|
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
|
|
}
|
|
a.Verdict = delivery.VerdictOf(a)
|
|
return a
|
|
}
|
|
// 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)
|
|
// The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
|
|
budget := MaxShown
|
|
for i := range cmp.Differs {
|
|
kept := cmp.Differs[i].Bodies[:0]
|
|
for _, b := range cmp.Differs[i].Bodies {
|
|
if n := len(b.Composed) + len(b.Running); n <= budget {
|
|
budget -= n
|
|
kept = append(kept, b)
|
|
} else {
|
|
kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why})
|
|
a.Note = "some differing resources are named without their bodies: the answer holds at most " +
|
|
fmt.Sprint(MaxShown) + " bytes of them"
|
|
}
|
|
}
|
|
cmp.Differs[i].Bodies = kept
|
|
}
|
|
// 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) })
|
|
a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
|
|
a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) })
|
|
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
|
|
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
|
|
|
|
// What the group adds, by node and module: placed by a candidate and not by the trunk on that node.
|
|
added := map[string]bool{}
|
|
for node, p := range s.Placements {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
was := map[string]bool{}
|
|
for _, m := range trunk[node].Modules {
|
|
was[m.Module] = true
|
|
}
|
|
for _, m := range p.Modules {
|
|
if !was[m.Module] {
|
|
added[node+"/"+m.Module] = true
|
|
}
|
|
}
|
|
}
|
|
unissued := map[string][]string{}
|
|
refusals := map[string][]string{}
|
|
for _, x := range stood {
|
|
node, module := standsInFor(x, s.Node)
|
|
switch {
|
|
case node == s.Node && runs[module]:
|
|
// A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is
|
|
// empty, and every module counts as the group's).
|
|
refusals[module] = append(refusals[module], x)
|
|
case module != "" && added[node+"/"+module]:
|
|
if node == s.Node {
|
|
unissued[module] = append(unissued[module], x)
|
|
} // another node's addition: the module here that carries it differs, which the assigner judges
|
|
case module == "" || node != s.Node:
|
|
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
|
|
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
|
|
default:
|
|
refusals[module] = append(refusals[module], x)
|
|
}
|
|
}
|
|
addedHere := map[string]delivery.ModuleAdded{}
|
|
for _, m := range cmp.Added {
|
|
addedHere[m.Module] = m
|
|
}
|
|
for _, m := range skipped { // placed, and no build given to compose it at
|
|
addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
|
|
}
|
|
for m, xs := range unissued {
|
|
got := addedHere[m]
|
|
got.Module = m
|
|
got.Unissued = append(got.Unissued, xs...)
|
|
addedHere[m] = got
|
|
}
|
|
for _, m := range sortedKeys(addedHere) {
|
|
a.Added = append(a.Added, addedHere[m])
|
|
}
|
|
for _, m := range sortedKeys(refusals) {
|
|
if _, isAdded := addedHere[m]; isAdded {
|
|
continue // a module the candidate adds here: what it lacks is said under Added
|
|
}
|
|
if _, isFailed := failed[m]; isFailed {
|
|
continue // said below, as why it could not be composed
|
|
}
|
|
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
|
|
}
|
|
for _, m := range sortedKeys(failed) {
|
|
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 })
|
|
a.Verdict = delivery.VerdictOf(a)
|
|
return a
|
|
}
|
|
|
|
// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
|
|
const maxCulprits = 8
|
|
|
|
// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies).
|
|
const MaxShown = 256 << 10
|
|
|
|
var (
|
|
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
|
|
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
|
|
)
|
|
|
|
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
|
|
// `port module:port`, a grant `the grant of <provision> for <module> on <node>`, a need `<module> on <node> needs ...`;
|
|
// the module empty when the words name none (the certificate, the authority).
|
|
func standsInFor(x, node string) (string, string) {
|
|
if m := grantStandIn.FindStringSubmatch(x); m != nil {
|
|
return m[2], m[1]
|
|
}
|
|
if m := needsStandIn.FindStringSubmatch(x); m != nil {
|
|
return m[2], m[1]
|
|
}
|
|
if rest, ok := strings.CutPrefix(x, "port "); ok {
|
|
if module, _, ok := strings.Cut(rest, ":"); ok {
|
|
return node, module
|
|
}
|
|
}
|
|
if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") {
|
|
return node, module
|
|
}
|
|
return node, ""
|
|
}
|
|
|
|
// culprit is the module placed on node whose composition the error is of: the first, in placement order, whose stub
|
|
// (compose.Stub) makes the composition succeed or fail otherwise; empty when none does (the error is not one module's).
|
|
// Modules already refused are passed over.
|
|
func culprit(in compose.Input, node string, fps map[string]string, failed map[string]string, err error) string {
|
|
var assigned []string
|
|
for _, n := range in.Nodes {
|
|
if n.Name == node {
|
|
assigned = n.Assigned
|
|
}
|
|
}
|
|
// Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
|
|
// refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
|
|
// answer took 7m46s).
|
|
var order []string
|
|
for _, m := range assigned {
|
|
if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
|
|
order = append(order, m)
|
|
}
|
|
}
|
|
for _, m := range order {
|
|
if _, done := failed[m]; done {
|
|
continue
|
|
}
|
|
if _, _, _, again := compose.CompositionsShadow(stubbed(in, node, m), node, fps); again == nil || again.Error() != err.Error() {
|
|
return m
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// stubbed is in with module composed as a stub on node alone (compose.Stub, as node's Kept).
|
|
func stubbed(in compose.Input, node, module string) compose.Input {
|
|
out := in
|
|
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
|
for i := range out.Nodes {
|
|
if out.Nodes[i].Name != node {
|
|
continue
|
|
}
|
|
man, ok := out.Nodes[i].Kept[module]
|
|
if !ok {
|
|
if man, ok = in.Catalogue[module]; !ok {
|
|
return in
|
|
}
|
|
}
|
|
kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
|
|
for k, v := range out.Nodes[i].Kept {
|
|
kept[k] = v
|
|
}
|
|
kept[module] = compose.Stub(man)
|
|
out.Nodes[i].Kept = kept
|
|
}
|
|
return out
|
|
}
|