Review finding 4: an unreadable placement gave no nodes, so nobody was asked and the check passed. The placement read now answers its error, and the verdict says the composition was not made and why. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
195 lines
8.5 KiB
Go
195 lines
8.5 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)
|
|
}
|
|
// 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)
|
|
}
|
|
})
|
|
}
|