Files
mesh-delivery/modules/mesh-delivery-checker/internal/check/engines_test.go
T

214 lines
9.6 KiB
Go

package check
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/composer"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// fakeEngines answers compose-candidate by node from memory; a node it holds no answer for never answers.
type fakeEngines struct {
mu sync.Mutex
answers map[string]delivery.ComposeCandidateAnswer
errs map[string]error
asked map[string]delivery.ComposeCandidateAsk
}
func (f *fakeEngines) ComposeCandidate(ctx context.Context, node string, ask delivery.ComposeCandidateAsk) (delivery.ComposeCandidateAnswer, error) {
f.mu.Lock()
if f.asked == nil {
f.asked = map[string]delivery.ComposeCandidateAsk{}
}
f.asked[node] = ask
a, ok := f.answers[node]
err := f.errs[node]
f.mu.Unlock()
if err != nil {
return delivery.ComposeCandidateAnswer{}, err
}
if !ok {
<-ctx.Done()
return delivery.ComposeCandidateAnswer{}, ctx.Err()
}
return a, nil
}
// fakeSandbox answers a compose job with a verdict, recording the job.
type fakeSandbox struct {
mu sync.Mutex
jobs []delivery.ComposeJob
v func(node string) composer.Verdict
err error
}
func (f *fakeSandbox) Compose(_ context.Context, job delivery.ComposeJob) (composer.Verdict, error) {
var req composer.Request
if err := json.Unmarshal(job.Request, &req); err != nil {
return composer.Verdict{}, err
}
f.mu.Lock()
f.jobs = append(f.jobs, job)
f.mu.Unlock()
if f.err != nil {
return composer.Verdict{}, f.err
}
return f.v(req.Machines[0]), nil
}
type digests map[string]string
func (d digests) ManifestDigests() map[string]string { return d }
// ADR 0593 §7, §9 (D12, G1, G2): the checker asks every placed node's engine in parallel and makes the composition from
// their answers: a refusal fails it naming the node and each module; 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, decided by the verdict and never by its words; a group with a node-engine member is composed by the composer
// built at that member's head, over the input each node's engine composed from.
func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
composed := func(node string) delivery.ComposeCandidateAnswer {
return delivery.ComposeCandidateAnswer{Node: node, Verdict: delivery.ComposeCandidateComposed,
Modules: []delivery.ModuleComposed{{Module: "web", State: delivery.ModuleComposedState, Digest: "sha256:" + strings.Repeat("a", 64)}},
Own: delivery.ModuleComposed{Module: "node", State: delivery.ModuleComposedState, Digest: "sha256:" + strings.Repeat("b", 64)}}
}
cs := caseOf(t, pullOf(repoA, 7, head1))
key, _ := delivery.CompositionKey(cs.Group, cs.HeadsHash)
compose := func(e *fakeEngines, nodes ...string) delivery.CompositionVerdict {
t.Helper()
ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return nodes, nil }, Bound: 50 * time.Millisecond,
Composer: func() string { return strings.Repeat("7", 40) }}
v, err := ec.Compose(context.Background(), cs)
if err != nil {
t.Fatal(err)
}
return v
}
t.Run("one node refusing fails the composition, naming the node and the module", func(t *testing.T) {
refusing := composed("laptop")
refusing.Verdict = delivery.ComposeCandidateRefused
refusing.Modules = []delivery.ModuleComposed{{Module: "web", State: delivery.ModuleRefusedState, Words: "a required value is unset"},
{Module: "mail", State: delivery.ModuleHeldState, Words: "held as today"}}
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": composed("anchor"), "laptop": refusing}}
v := compose(e, "anchor", "laptop")
if v.State != delivery.CompositionFail || v.Machine != "laptop" || !strings.Contains(v.Words, "not composed on laptop: web: a required value is unset") ||
strings.Contains(v.Words, "mail") || len(v.Machines) != 2 {
t.Errorf("verdict = %+v", v)
}
if e.asked["anchor"].Candidate != key || e.asked["laptop"].Input {
t.Errorf("asked %+v, want the check's candidate %s and no input", e.asked, key)
}
var ver delivery.Verdict
withComposition(&ver, v)
if ver.State != delivery.VerdictFail || ver.Failed.Machine != "laptop" {
t.Errorf("the check's verdict = %+v", ver)
}
})
t.Run("a silent, unready or unreachable node is no answer, and the check passes naming it", func(t *testing.T) {
unready := delivery.ComposeCandidateAnswer{Node: "g14", Verdict: delivery.ComposeCandidateUnready, Refused: "a module refused it"}
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": composed("anchor"), "g14": unready},
errs: map[string]error{"ace": errors.New("no responders")}}
began := time.Now()
v := compose(e, "anchor", "laptop", "g14", "ace")
if took := time.Since(began); took > 2*time.Second {
t.Errorf("the nodes were not asked in parallel within the bound: %s", took)
}
if v.State != delivery.CompositionPass || v.SDK != strings.Repeat("7", 40) || len(v.Machines) != 1 {
t.Fatalf("verdict = %+v", v)
}
for _, node := range []string{"ace", "g14", "laptop"} {
if !strings.Contains(strings.Join(v.Warnings, "; "), node+": "+delivery.ComposeNoAnswer) {
t.Errorf("%s is not named: %v", node, v.Warnings)
}
}
var ver delivery.Verdict
ver.State = delivery.VerdictPass
withComposition(&ver, v)
if ver.State != delivery.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+delivery.ComposeNoAnswer) {
t.Errorf("the line = %q", composition(ver.Composition, 0, false))
}
})
t.Run("a node-engine member is composed by the composer at its head over each node's input", func(t *testing.T) {
c, err := delivery.DecodeCandidate(candidateOf(t, "feat/x", pullOf(nodeEngineRepo, 3, head2), pullOf(repoA, 7, head1)))
if err != nil {
t.Fatal(err)
}
host := CaseOf(c, map[string]string{repoA: trunkA, nodeEngineRepo: trunkA})
withInput := composed("anchor")
withInput.Input = json.RawMessage(`{"nodes":[{"name":"anchor"}]}`)
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": withInput}}
sb := &fakeSandbox{v: func(node string) composer.Verdict {
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change",
Machines: map[string]composer.Machine{node: {Composes: false, Reason: "the new composer refuses it"}}}}}
}}
ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return []string{"anchor"}, nil }, Bound: time.Second,
Sandbox: sb, Manifests: digests{"web": "sha256:" + strings.Repeat("c", 64)}}
v, err := ec.Compose(context.Background(), host)
if err != nil {
t.Fatal(err)
}
if !e.asked["anchor"].Input || len(sb.jobs) != 1 {
t.Fatalf("asked %+v, jobs %+v: want the input asked and one job", e.asked, sb.jobs)
}
job := sb.jobs[0]
var req composer.Request
_ = json.Unmarshal(job.Request, &req)
if job.Fetch["repository"] != nodeEngineRepo || job.Fetch["commit"] != head2 || job.Package != composer.Package ||
string(req.Input) != string(withInput.Input) || req.Cases[0].Manifests["web"].Digest == "" {
t.Errorf("job = %+v, request %+v", job, req)
}
if v.State != delivery.CompositionFail || v.SDK != head2 || !strings.Contains(v.Words, "the new composer refuses it") {
t.Errorf("verdict = %+v", v)
}
// ADR 0593 §9 (ADR 0601 §4's kind): a node the composer did not judge is named as no answer with its words, by the
// contract's field, and the check passes; words in Reason alone are no such mark.
sb.err = nil
for _, row := range []struct {
m composer.Machine
pass bool
}{
{composer.Machine{NotJudged: "its node-engine sends no running state for web"}, true},
{composer.Machine{Reason: "not judged: its node-engine sends no running state for web"}, false},
} {
sb.v = func(node string) composer.Verdict {
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change", Machines: map[string]composer.Machine{node: row.m}}}}
}
v, err = ec.Compose(context.Background(), host)
named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+delivery.ComposeNoAnswer+" (its node-engine sends no running state for web)")
if err != nil || (v.State == delivery.CompositionPass) != row.pass || named != row.pass {
t.Errorf("%+v: verdict %+v, %v", row.m, v, err)
}
}
// Review finding 3 (ADR 0593 §9 lets only a node that does not answer pass): a composer at the head that cannot
// run fails the composition, naming the head, never passing as a silent node.
sb.err = errors.New("the build of mesh-compose failed")
v, err = ec.Compose(context.Background(), host)
if err != nil || v.State != delivery.CompositionFail || !strings.Contains(v.Words, head2) ||
!strings.Contains(v.Words, "the build of mesh-compose failed") || len(v.Warnings) != 0 {
t.Errorf("a composer that cannot run: %+v, %v", v, err)
}
})
// Review finding 4: a placement that cannot be read is no mesh with no node: the composition is not made, said.
t.Run("an unreadable placement makes no composition", func(t *testing.T) {
ec := EngineCompositions{Asker: &fakeEngines{}, Placed: func() ([]string, error) { return nil, errors.New("no such bucket") }}
if _, err := ec.Compose(context.Background(), cs); err == nil || !strings.Contains(err.Error(), "no such bucket") {
t.Errorf("an unreadable placement: %v", err)
}
b := newBench(t)
b.ex.Compositions = ec
v := b.judge(t, cs).Verdict
if v.Composition == nil || v.Composition.State != delivery.CompositionNotRun || !strings.Contains(v.Composition.Words, "no such bucket") {
t.Errorf("the verdict's composition = %+v", v.Composition)
}
})
}