Serve node-engine.compose-candidate from the node's shadow (H4)
ADR 0593 §7: a check's composition is the checker's, made from each placed node's answer, since only a node can compose with its own sealed values. The node composes the indexer's candidate in shadow on a copy of its shadow's inputs, the candidate's modules laid over the indexer's with their manifest verdicts, and answers per module composed (changed or not), refused or held, and its own composition's verdict, within the answer bound; it applies, writes and hands nothing. With input it also answers the composition input, for the sandbox path. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package selfcompose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0593 §7: the verb compose-candidate composes a check's candidate on this node in shadow. A candidate that
|
||||
// changes a placed module composes it at the candidate's build, changed; one whose manifest the indexer refused is
|
||||
// answered refused, naming the module and the judge's words, and the verdict is refused; a candidate the indexer does not
|
||||
// hold is refused in words. Nothing is applied, nothing is written, no placement answer is handed, and the answer comes
|
||||
// within the bound (an ended bound answers at once, refused).
|
||||
func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
|
||||
b := newFakeBus()
|
||||
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
|
||||
fpNew := delivery.SourcePrefix2 + strings.Repeat("b", 64)
|
||||
digest := delivery.ManifestDigest([]byte(lemurs))
|
||||
lemurs2 := strings.Replace(lemurs, `"version":"1"`, `"version":"2"`, 1)
|
||||
digest2 := delivery.ManifestDigest([]byte(lemurs2))
|
||||
for d, body := range map[string]string{digest: lemurs, digest2: lemurs2} {
|
||||
man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: body})
|
||||
key, _ := delivery.ManifestsKey(d)
|
||||
b.kv[delivery.ManifestsBucket+"/"+key] = man
|
||||
}
|
||||
candidateOf := func(manifest string) string {
|
||||
head := delivery.Head{Repository: "novox/x", Number: 7, Head: "c0ffee" + manifest[len(manifest)-6:]}
|
||||
heads, _ := delivery.HeadsHash([]delivery.Head{head})
|
||||
pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"lemurs"},
|
||||
Fps: map[string]delivery.CandidateModule{"lemurs": {FP2: fpNew, Manifest: manifest}}}}
|
||||
raw, err := delivery.EncodeCandidate(delivery.Candidate{Group: "novox/x#7", HeadsHash: heads, Pulls: pulls,
|
||||
FPSet: delivery.FingerprintSet(pulls)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.CompositionKey("novox/x#7", heads)
|
||||
b.kv[delivery.CandidatesBucket+"/"+key] = raw
|
||||
return key
|
||||
}
|
||||
accepted, refusedKey := candidateOf(digest2), ""
|
||||
{
|
||||
// The same change, its manifest refused by the indexer's judge.
|
||||
refusedMan := strings.Replace(lemurs2, `"version":"2"`, `"version":"3"`, 1)
|
||||
d3 := delivery.ManifestDigest([]byte(refusedMan))
|
||||
man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: refusedMan})
|
||||
key, _ := delivery.ManifestsKey(d3)
|
||||
b.kv[delivery.ManifestsBucket+"/"+key] = man
|
||||
vkey, _ := delivery.VerdictsKey(d3)
|
||||
v, err := delivery.EncodeManifestVerdict(delivery.ManifestVerdict{Module: "lemurs", State: delivery.VerdictRefused,
|
||||
Problems: []string{"a field the judge refuses"}, Judge: "src2:j", At: time.Now()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.kv[delivery.VerdictsBucket+"/"+vkey] = v
|
||||
refusedKey = candidateOf(d3)
|
||||
}
|
||||
|
||||
env := b.env()
|
||||
state := &fakeComposed{kv: map[string]ComposedEntry{}}
|
||||
env.ComposedState = state
|
||||
var applied atomic.Int64
|
||||
env.Refused = func(string, map[string]string) { applied.Add(1) }
|
||||
var handed atomic.Int64
|
||||
s := NewShadow(env, func(context.Context, map[string]delivery.CandidateAnswer) { handed.Add(1) })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = s.Run(ctx) }()
|
||||
if a := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted}); !strings.Contains(a.Refused, "not read every input") {
|
||||
t.Errorf("a shadow not ready answered %+v", a)
|
||||
}
|
||||
place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
|
||||
Modules: []delivery.PlacedModule{{Module: "lemurs"}}})
|
||||
have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
watching := func() int { b.mu.Lock(); defer b.mu.Unlock(); return len(b.watches) }
|
||||
for watching() < 6 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
b.put(t, delivery.PlacementBucket, "placement.n1", place)
|
||||
b.put(t, delivery.HaveBucket, "n1.node", have)
|
||||
b.put(t, delivery.ModulesBucket, "lemurs", mod)
|
||||
for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket} {
|
||||
b.send(bucket, agent.Update{Caught: true})
|
||||
}
|
||||
for !s.ready.Load() && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
a := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted})
|
||||
if _, err := delivery.EncodeComposeCandidateAnswer(a); err != nil {
|
||||
t.Fatalf("the answer does not fit the seat's shape: %v (%+v)", err, a)
|
||||
}
|
||||
if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" ||
|
||||
a.Modules[0].State != delivery.ModuleComposedState || a.Modules[0].FP != fpNew || !a.Modules[0].Changed ||
|
||||
a.Own.Module != delivery.HaveNodeWord || a.Took > delivery.ComposeAnswerBound.Milliseconds() {
|
||||
t.Errorf("an accepted candidate answered %+v", a)
|
||||
}
|
||||
r := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: refusedKey})
|
||||
if r.Verdict != delivery.ComposeCandidateRefused || len(r.Modules) != 1 || r.Modules[0].State != delivery.ModuleRefusedState ||
|
||||
!strings.Contains(r.Modules[0].Words, "a field the judge refuses") {
|
||||
t.Errorf("a candidate whose manifest the indexer refused answered %+v", r)
|
||||
}
|
||||
if _, err := delivery.EncodeComposeCandidateAnswer(r); err != nil {
|
||||
t.Errorf("the refused answer does not fit the seat's shape: %v", err)
|
||||
}
|
||||
missing, _ := delivery.CompositionKey("novox/x#8", "0123456789ab")
|
||||
if m := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: missing}); !strings.Contains(m.Refused, "holds no candidate") {
|
||||
t.Errorf("a candidate the indexer does not hold answered %+v", m)
|
||||
}
|
||||
ended, end := context.WithCancel(ctx)
|
||||
end()
|
||||
if e := s.ComposeCandidate(ended, delivery.ComposeCandidateAsk{Candidate: accepted}); e.Verdict != delivery.ComposeCandidateRefused {
|
||||
t.Errorf("an ended bound answered %+v", e)
|
||||
}
|
||||
if len(state.written()) != 0 || applied.Load() != 0 || handed.Load() != 0 {
|
||||
t.Errorf("composing candidates wrote %v, marked %d have(s), handed %d answer(s); want nothing", state.written(), applied.Load(), handed.Load())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user