Files
mesh-delivery/modules/mesh-delivery-checker/internal/check/engines_test.go
T
jschoubben 2fb1dc3214 mesh-delivery-checker: a placement that cannot be read leaves the composition not made, said
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
2026-11-02 19:37:22 +01:00

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)
}
})
}