251 lines
11 KiB
Go
251 lines
11 KiB
Go
package check
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/composer"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// **A check's composition is the checker's, made from what each node's engine answers** (novox/hq ADR 0593 §7, §9).
|
|
// Only a node knows whether a change composes there: the checker asks every placed node's engine
|
|
// `node-engine.compose-candidate` in parallel, with the check's candidate, and waits at most its answer bound
|
|
// (compose-answer-bound, 60 s by default). A node that refuses fails the check, naming the node and each module with the
|
|
// engine's words; a node that does not answer within the bound, answers unready or cannot be asked is named, "not composed
|
|
// on that node: no answer", and the check passes with that warning: its own composition judges again when it applies,
|
|
// and the canary rule holds. The checker decides by the answer's verdict, never by its words.
|
|
//
|
|
// **A group that changes the node-engine's composition code** (a member of the node-engine's repository) is not judged by
|
|
// the running engines, which cannot judge their own next code: each node's engine answers the composition input it
|
|
// composed from (ComposeCandidateAsk.Input), and the composer program built from that member's head composes it in a
|
|
// build module's sandbox (Sandbox), with every manifest of the catalogue as the case leaves it and the indexer's verdicts.
|
|
|
|
// EngineAsker asks one node's engine to compose a check's candidate (the verb node-engine.compose-candidate on that node).
|
|
type EngineAsker interface {
|
|
ComposeCandidate(ctx context.Context, node string, ask delivery.ComposeCandidateAsk) (delivery.ComposeCandidateAnswer, error)
|
|
}
|
|
|
|
// Sandbox runs a compose job on the Go build seat and answers the composer's verdict.
|
|
type Sandbox interface {
|
|
Compose(ctx context.Context, job delivery.ComposeJob) (composer.Verdict, error)
|
|
}
|
|
|
|
// EngineCompositions is the checker's composition of a check (Examiner.Compositions).
|
|
type EngineCompositions struct {
|
|
Asker EngineAsker
|
|
// Placed names every node in placement now (the seat mesh-placement's state), or why it cannot be read: then no node
|
|
// can be asked, and the composition is not made (an error), never passed with nobody asked.
|
|
Placed func() ([]string, error)
|
|
// Bound is the answer bound (the setting compose-answer-bound); zero is delivery.ComposeAnswerBound.
|
|
Bound time.Duration
|
|
// Composer is the commit of the node-engine the mesh runs, named as what composed (ADR 0548 §6's running commit).
|
|
Composer func() string
|
|
// Sandbox runs the composer of a node-engine member's head; nil leaves such a group judged by the running engines,
|
|
// said in a warning.
|
|
Sandbox Sandbox
|
|
// Manifests are the indexer's trunk manifest digests by module, and Verdicts its refusals of them: the catalogue the
|
|
// sandbox composes with, before the case's own manifests are laid over.
|
|
Manifests TrunkManifests
|
|
Verdicts func() map[string]delivery.ManifestVerdict
|
|
}
|
|
|
|
// composerHead is the node-engine member's head of a case, or empty.
|
|
func composerHead(c *Case) string {
|
|
if m := c.member(nodeEngineRepo); m != nil {
|
|
return m.Head
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Compose asks every placed node and judges their answers (JudgeEngines). An error is only a case it cannot ask at all.
|
|
func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.CompositionVerdict, error) {
|
|
began := time.Now()
|
|
key, err := delivery.CompositionKey(c.Group, c.HeadsHash)
|
|
if err != nil {
|
|
return delivery.CompositionVerdict{}, err
|
|
}
|
|
var nodes []string
|
|
if e.Placed != nil {
|
|
if nodes, err = e.Placed(); err != nil {
|
|
return delivery.CompositionVerdict{}, fmt.Errorf("the placement cannot be read, so no node can be asked: %w", err)
|
|
}
|
|
}
|
|
bound := e.Bound
|
|
if bound <= 0 {
|
|
bound = delivery.ComposeAnswerBound
|
|
}
|
|
head := composerHead(c)
|
|
sandboxed := head != "" && e.Sandbox != nil
|
|
answers := map[string]delivery.ComposeCandidateAnswer{}
|
|
silent := map[string]string{}
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
for _, node := range nodes {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
at, cancel := context.WithTimeout(ctx, bound)
|
|
defer cancel()
|
|
a, err := e.Asker.ComposeCandidate(at, node, delivery.ComposeCandidateAsk{Candidate: key, Input: sandboxed})
|
|
if err == nil && sandboxed && a.Verdict != delivery.ComposeCandidateUnready {
|
|
// The composer's build and run are the check's to bound, not the engine's answer bound. A composer that
|
|
// cannot run is no silent node (ADR 0593 §9 lets only that pass): the node's composition fails, naming
|
|
// the head.
|
|
if composed, serr := e.inSandbox(ctx, c, head, node, a); serr != nil {
|
|
a = delivery.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: delivery.ComposeCandidateRefused,
|
|
Refused: "the composer at the node-engine's head " + head + " could not compose it: " + serr.Error()}
|
|
} else {
|
|
a = composed
|
|
}
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
switch {
|
|
case err != nil:
|
|
silent[node] = err.Error()
|
|
case a.Verdict == delivery.ComposeCandidateUnready:
|
|
silent[node] = "unready"
|
|
default:
|
|
answers[node] = a
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
// What composed, named by the node-engine's commit: the member's head in the sandbox, else the one the mesh runs.
|
|
composerName := "node-engine"
|
|
if sandboxed {
|
|
composerName = head
|
|
} else if e.Composer != nil && e.Composer() != "" {
|
|
composerName = e.Composer()
|
|
}
|
|
v := JudgeEngines(c.Group, c.HeadsHash, composerName, nodes, answers, silent)
|
|
if head != "" && e.Sandbox == nil {
|
|
v.Warnings = append(v.Warnings, "the group changes the node-engine's composition code and was composed by the running engines")
|
|
}
|
|
v.Took = time.Since(began).Milliseconds()
|
|
return v, nil
|
|
}
|
|
|
|
// JudgeEngines is the check's composition verdict from the engines' answers (ADR 0593 §7, §9): failed when any node's
|
|
// answer is refused, naming each node and each module refused with its words (the first refusing node is the machine);
|
|
// passed otherwise. Every node in nodes without an answer (silent, by node with why, for the journal only) is named
|
|
// with delivery.ComposeNoAnswer as a warning. composer names what composed, for the line. Pure.
|
|
func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]delivery.ComposeCandidateAnswer, silent map[string]string) delivery.CompositionVerdict {
|
|
v := delivery.CompositionVerdict{Group: group, Heads: heads, State: delivery.CompositionPass, SDK: composerName}
|
|
sorted := append([]string(nil), nodes...)
|
|
sort.Strings(sorted)
|
|
var fails []string
|
|
for _, node := range sorted {
|
|
a, answered := answers[node]
|
|
if !answered {
|
|
v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer)
|
|
continue
|
|
}
|
|
if a.Verdict == delivery.ComposeCandidateNotJudged {
|
|
// Composed in the sandbox and not judged (ADR 0593 §9): named as no answer, with the composer's words; decided by the
|
|
// verdict, never by the words (ADR 0601 §4's kind).
|
|
v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer+" ("+a.Refused+")")
|
|
continue
|
|
}
|
|
v.Machines = append(v.Machines, node)
|
|
if a.Verdict != delivery.ComposeCandidateRefused {
|
|
continue
|
|
}
|
|
if v.Machine == "" {
|
|
v.Machine = node
|
|
}
|
|
var words []string
|
|
if a.Refused != "" {
|
|
words = append(words, a.Refused)
|
|
}
|
|
for _, m := range append(append([]delivery.ModuleComposed(nil), a.Modules...), a.Own) {
|
|
if m.State == delivery.ModuleRefusedState {
|
|
words = append(words, m.Module+": "+m.Words)
|
|
}
|
|
}
|
|
if len(words) == 0 {
|
|
words = append(words, "refused, naming no module")
|
|
}
|
|
fails = append(fails, "not composed on "+node+": "+strings.Join(words, "; "))
|
|
}
|
|
if len(fails) > 0 {
|
|
v.State, v.Words = delivery.CompositionFail, strings.Join(fails, "; ")
|
|
}
|
|
if len(sorted) == 0 {
|
|
v.Warnings = append(v.Warnings, "no node is placed, so the change was composed on none")
|
|
}
|
|
return v
|
|
}
|
|
|
|
// inSandbox composes a node's answer again with the composer built from the node-engine member's head, over the input the
|
|
// node's engine composed from: the node's answer becomes the sandbox's, refused with the composer's words where the
|
|
// composer refuses the node.
|
|
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node string, a delivery.ComposeCandidateAnswer) (delivery.ComposeCandidateAnswer, error) {
|
|
if len(a.Input) == 0 {
|
|
return a, fmt.Errorf("the engine of %s answered no composition input, so the node-engine's head cannot compose it", node)
|
|
}
|
|
manifests := map[string]composer.Manifest{}
|
|
if e.Manifests != nil {
|
|
for name, digest := range e.Manifests.ManifestDigests() {
|
|
manifests[name] = composer.Manifest{Digest: digest}
|
|
}
|
|
}
|
|
for _, p := range c.Pulls {
|
|
for name, m := range p.Fps {
|
|
if m.Manifest != "" {
|
|
manifests[name] = composer.Manifest{Digest: m.Manifest}
|
|
}
|
|
}
|
|
}
|
|
verdicts := map[string]delivery.ManifestVerdict{}
|
|
if e.Verdicts != nil {
|
|
for name, v := range e.Verdicts() {
|
|
verdicts[name] = v
|
|
}
|
|
}
|
|
for _, p := range c.Pulls {
|
|
for name := range p.Fps {
|
|
delete(verdicts, name) // a manifest the change brings is the checker's own judge's (ADR 0578 §2)
|
|
}
|
|
}
|
|
req := composer.Request{V: composer.Version, Input: a.Input, Depth: composer.DepthCompose, Machines: []string{node},
|
|
Cases: []composer.Case{{Name: "change", Manifests: manifests, Verdicts: verdicts}}}
|
|
raw, err := json.Marshal(req)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
job := delivery.ComposeJob{Fetch: map[string]any{"repository": nodeEngineRepo, "commit": head}, Dir: delivery.RootPath,
|
|
Package: composer.Package, Request: raw, Reader: "the node-engine head of " + c.Group}
|
|
verdict, err := e.Sandbox.Compose(ctx, job)
|
|
if err != nil {
|
|
return a, fmt.Errorf("the composer at the node-engine's head %s did not answer for %s: %w", head, node, err)
|
|
}
|
|
out := delivery.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: delivery.ComposeCandidateComposed}
|
|
notJudged := ""
|
|
for _, cv := range verdict.Cases {
|
|
if m, ok := cv.Machines[node]; ok && m.NotJudged != "" {
|
|
notJudged = m.NotJudged // the composer did not judge the node: neither composed nor refused (ADR 0593 §9)
|
|
} else if ok && !m.Composes {
|
|
out.Verdict = delivery.ComposeCandidateRefused
|
|
out.Refused = strings.Join(append(append([]string{m.Reason}, m.Problems...), m.Refusals...), "; ")
|
|
}
|
|
for name, read := range cv.Manifests {
|
|
if !read.Accepted {
|
|
out.Verdict = delivery.ComposeCandidateRefused
|
|
out.Modules = append(out.Modules, delivery.ModuleComposed{Module: name, State: delivery.ModuleRefusedState, Words: read.Refused})
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(out.Modules, func(i, j int) bool { return out.Modules[i].Module < out.Modules[j].Module })
|
|
if notJudged != "" && out.Verdict == delivery.ComposeCandidateComposed {
|
|
out.Verdict, out.Refused = delivery.ComposeCandidateNotJudged, notJudged
|
|
}
|
|
return out, nil
|
|
}
|