Files
mesh-host/internal/selfcompose/entries_test.go
T
jschoubben cf378f9a25 G §421, §683: the node-engine reads the entry its want names by fingerprint and keeps its entries and wants
The node's own composer reads the catalog half through meshcatalogclient's Entries cache over its own calls of
mesh-catalog's verb entry, and no longer watches or reads the indexer's modules, manifests and verdicts or the
builder's builds: a module's manifest is its entry's, each artifact's reference is made from the digest its want's
part names (artifact-store://<module>/<artifact>/blobs/<digest>, A-50). A want whose entry is asked or not found is
held, its have the phase held with a detail opening entry-asked: or entry-missing: naming the fingerprint. The
entries and wants it composes from are kept in composed-<node> (entry.<digits> and want.<module>), written before
the composition that uses them, read at start before anything is asked, a kept want standing until the wants'
live read answers, and dropped when nothing names them. The shadow keeps its reads (G §628).

The node grant gains the verb entry and the running stream's consumer calls; OverNATS hears the running stream
as the link's session does.

Builds: design 47 G §421, §683, §614, §628, A-47, A-50
2026-11-11 23:55:50 +01:00

146 lines
6.0 KiB
Go

package selfcompose
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/buckets/bucketstest"
"github.com/novox/mesh-host/internal/compositions"
)
// Design 47 G §421, §683, §698 (the lead's answers A-47, A-50): the node-engine reads the entry its want names by the
// fingerprint, through its own calls of the seat mesh-catalog's verb entry, and reads neither the indexer's states nor the
// builder's; a want whose entry the catalog half holds none of is held, its have saying entry-missing and naming the
// fingerprint; the entries and wants it composes from are kept in its composition state, written before the composition that
// uses them and read at its start before anything is asked; a want removed takes its kept want and entry away.
func composeFromEntries(t *testing.T) {
b := newFakeBus()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64)
ghost := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64)
var manifest map[string]any
if err := json.Unmarshal([]byte(lemurs), &manifest); err != nil {
t.Fatal(err)
}
entry := meshcatalog.CatalogEntry{Module: "lemurs", Fp: fp, Manifest: manifest, Repository: "novox/x", Commit: "c",
ManifestDigest: meshcatalogclient.ManifestDigest([]byte(lemurs))}
var mu sync.Mutex
var asked []string
ask := func(_ context.Context, seat, verb string, args any) (json.RawMessage, error) {
in := args.(meshcatalog.EntryInput)
mu.Lock()
asked = append(asked, in.Fingerprint)
mu.Unlock()
if seat != "mesh-catalog" || verb != "entry" {
t.Errorf("asked %s.%s", seat, verb)
}
if in.Fingerprint != fp {
return nil, errors.New("no entry of fingerprint " + in.Fingerprint + " is kept")
}
return json.Marshal(meshcatalog.EntryAnswer{Entry: entry})
}
state := &fakeComposed{kv: map[string]ComposedEntry{}}
var held map[string]string
env := b.env()
env.Entry, env.ComposedState = EntryCall(ask), state
env.Refused = func(_ string, h map[string]string) { mu.Lock(); held = h; mu.Unlock() }
c := New(env)
out, err := c.Watch(ctx, "n1.>")
if err != nil {
t.Fatal(err)
}
place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "ghost"}}})
have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
nodeWant, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true})
b.putAssigned(t, place)
b.put(t, buckets.Have, "n1.node", have)
b.put(t, buckets.Wants, "want.n1.node", nodeWant)
for module, w := range map[string]meshwants.WantsByWantNodeModuleValue{"lemurs": {Fp: fp, Self: true}, "ghost": {Fp: ghost, Self: true}} {
raw, _ := bucketstest.Want(w)
b.put(t, buckets.Wants, "want.n1."+module, raw)
}
for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, ResolvedBucket} {
b.send(bucket, agent.Update{Caught: true})
}
lemursKey, _ := compositions.Key("n1", "lemurs", fp)
handed := map[string]bool{}
for !handed[lemursKey] {
handed[next(t, out).Key] = true
}
// Neither the indexer's states nor the builder's are watched or read.
b.mu.Lock()
for _, r := range b.reads {
if r[0] == buckets.Modules || r[0] == buckets.Manifests || r[0] == buckets.Verdicts || r[0] == builds.BuilderBuildsBucket {
t.Errorf("the node-engine read %s of %s", r[1], r[0])
}
}
b.mu.Unlock()
// A want whose entry the half holds none of is held, saying entry-missing and the fingerprint; the rest is composed.
mu.Lock()
why := held["ghost"]
mu.Unlock()
if !strings.HasPrefix(why, agent.EntryMissing) || !strings.Contains(why, ghost) || held["lemurs"] != "" {
t.Errorf("held %q, want ghost held with %s naming %s", held, agent.EntryMissing, ghost)
}
// Each entry and want it composes from is kept in the composition state before the composition that uses them.
state.mu.Lock()
_, hasEntry := state.kv[entryKey(fp)]
_, hasWant := state.kv["want.lemurs"]
state.mu.Unlock()
if !hasEntry || !hasWant {
t.Errorf("the composition state holds entry %v and want %v of lemurs: %v", hasEntry, hasWant, state.written())
}
// The seat's first-views lists them.
_, entries, wants := c.FirstViews()
if len(entries) < 2 || len(wants) != 2 {
t.Errorf("first-views lists %v and %v", entries, wants)
}
// A restart reads what is kept before it asks anything: lemurs's entry is not asked again.
mu.Lock()
asked = nil
mu.Unlock()
env2 := b.env()
env2.Entry, env2.ComposedState = EntryCall(ask), state
c2 := New(env2)
if _, err := c2.Watch(ctx, "n1.>"); err != nil {
t.Fatal(err)
}
c2.mu.Lock()
kept, keptWant := c2.keptEnt[fp], c2.wants["lemurs"].Fp
c2.mu.Unlock()
if kept.entry.Module != "lemurs" || keptWant != fp {
t.Errorf("a restart read entry %q and want %q from the state", kept.entry.Module, keptWant)
}
// A want removed takes its kept want and entry away.
raw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Removed: true})
b.put(t, buckets.Wants, "want.n1.lemurs", raw)
for range 20 {
state.mu.Lock()
_, hasWant = state.kv["want.lemurs"]
_, hasEntry = state.kv[entryKey(fp)]
state.mu.Unlock()
if !hasWant && !hasEntry {
break
}
next(t, out)
}
if hasWant || hasEntry {
t.Errorf("a removed want left its kept want %v and entry %v", hasWant, hasEntry)
}
}