ADR 0593 §7, §9: the checker asks compose-candidate of each placed node in parallel within its own setting compose-answer-bound (60 s); a refusal fails the check naming node and module, a silent, unready or unreachable node passes with "not composed on that node: no answer". A group with a node-engine member is composed by the composer at that head on the Go build seat over each node's input. The wait on the assigner's compositions goes, and the checker invokes the one engine verb it needs. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
169 lines
7.2 KiB
Go
169 lines
7.2 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
|
|
}
|
|
|
|
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()
|
|
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 { return nodes }, 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 { return []string{"anchor"} }, 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)
|
|
}
|
|
})
|
|
}
|