Merge branch 'feat/47-G-build' into feat/47-G-build-watchman
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
mcc "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/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// halves follows the catalog half of each watched branch through the seat mesh-catalog (design 47 G §14, §184, §195): one
|
||||
// meshcatalogclient.Catalog per branch, a first view of the verb `revision` and then each `catalog-revision` of that branch,
|
||||
// each entry taken into the world as the value and the graph node the rules read. Of the view the assigner reads only the catalog
|
||||
// half, never its own output back (§195).
|
||||
type halves struct {
|
||||
w *world
|
||||
call mcc.Call
|
||||
wake func()
|
||||
logf func(string, ...any)
|
||||
ctx context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
cats map[string]*mcc.Catalog
|
||||
// started is called with each catalog made, for the part that reads their status.
|
||||
started func(*mcc.Catalog)
|
||||
// settled is told once when a branch's first view is answered, for the first pass to wait on (the trunk's alone).
|
||||
settled chan<- struct{}
|
||||
}
|
||||
|
||||
// follow makes the catalog of branch and asks its first view, once; true when it was made now.
|
||||
func (h *halves) follow(branch string) bool {
|
||||
h.mu.Lock()
|
||||
if h.cats[branch] != nil {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
c, err := mcc.NewCatalog(h.call, branch)
|
||||
if err != nil {
|
||||
h.mu.Unlock()
|
||||
h.logf("the catalog half of branch %q is not followed: %v", branch, err)
|
||||
return false
|
||||
}
|
||||
c.Follower.Logf = h.logf
|
||||
c.Follower.Changed = func(module string, entry json.RawMessage, deleted bool, _ time.Time) {
|
||||
h.changed(branch, module, entry, deleted)
|
||||
}
|
||||
if h.cats == nil {
|
||||
h.cats = map[string]*mcc.Catalog{}
|
||||
}
|
||||
h.cats[branch] = c
|
||||
h.mu.Unlock()
|
||||
if h.started != nil {
|
||||
h.started(c)
|
||||
}
|
||||
go func() {
|
||||
if err := c.Start(h.ctx); err != nil {
|
||||
if h.ctx.Err() == nil {
|
||||
h.logf("the first view of the catalog half of branch %q is not answered: %v", branch, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if branch == trunkBranch && h.settled != nil {
|
||||
h.settled <- struct{}{}
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// hear gives a catalog-revision to every catalog: each takes its own branch's and passes over another's.
|
||||
func (h *halves) hear(m jetstream.Msg) {
|
||||
h.mu.Lock()
|
||||
cats := make([]*mcc.Catalog, 0, len(h.cats))
|
||||
for _, c := range h.cats {
|
||||
cats = append(cats, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, c := range cats {
|
||||
if err := c.Hear(h.ctx, m.Data()); err != nil {
|
||||
h.logf("%s on branch %q: %v", m.Subject(), c.Branch, err)
|
||||
}
|
||||
}
|
||||
_ = m.Ack()
|
||||
}
|
||||
|
||||
// followers are the first views the catalogs hold.
|
||||
func (h *halves) followers() []*mcc.Catalog {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := make([]*mcc.Catalog, 0, len(h.cats))
|
||||
for _, c := range h.cats {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changed takes one entry of branch's half into the world, or a module taken out of it.
|
||||
func (h *halves) changed(branch, module string, raw json.RawMessage, deleted bool) {
|
||||
defer h.wake()
|
||||
if deleted {
|
||||
h.w.dropModule(branch, module)
|
||||
return
|
||||
}
|
||||
var e meshcatalog.CatalogEntry
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
h.logf("the entry of %s on branch %q does not read: %v", module, branch, err)
|
||||
return
|
||||
}
|
||||
v, n, m := fromEntry(e)
|
||||
h.w.putModule(branch, module, v, n, m)
|
||||
}
|
||||
|
||||
// fromEntry is a catalog entry as the value and the graph node the rules read, and its manifest parsed once under the digest of
|
||||
// its words.
|
||||
func fromEntry(e meshcatalog.CatalogEntry) (meshcatalog.ModulesByModuleValue, graph.Node, *parsed) {
|
||||
v := meshcatalog.ModulesByModuleValue{Commit: e.Commit, Fp2: e.Fp, Needs: e.Needs, None2: e.None, Packages: e.Packages,
|
||||
Path: e.Path, Repository: e.Repository, Verdict: e.Verdict}
|
||||
n := graph.Node{FP: e.Fp, None: e.None, Repository: e.Repository, Path: e.Path, Bases: e.Bases, Provides: e.Provides, Requires: e.Requires,
|
||||
Awaits: e.Awaits, Invokes: e.Invokes, Serves: e.Serves, LeftOut: e.LeftOut}
|
||||
for p := range e.Packages {
|
||||
n.Packages = append(n.Packages, p)
|
||||
}
|
||||
slices.Sort(n.Packages)
|
||||
if len(e.Manifest) == 0 {
|
||||
return v, n, nil
|
||||
}
|
||||
raw, err := json.Marshal(e.Manifest)
|
||||
if err != nil {
|
||||
return v, n, nil
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
v.Manifest = "sha256:" + hex.EncodeToString(sum[:])
|
||||
man, err := catalogue.Decode(raw)
|
||||
if err != nil {
|
||||
return v, n, &parsed{module: e.Module, err: "it does not decode: " + err.Error()}
|
||||
}
|
||||
n.Serves = graph.ServesOf(man)
|
||||
return v, n, &parsed{module: e.Module, manifest: man}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"io"
|
||||
"maps"
|
||||
"mesh-delivery-assigner/internal/assignment"
|
||||
@@ -52,7 +53,6 @@ import (
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
@@ -71,6 +71,17 @@ type bed struct {
|
||||
placed map[string][]string
|
||||
// journal is what the programs the bed starts write in their journal.
|
||||
journal *journal
|
||||
// cat is the catalog half the seat mesh-catalog serves the assigner (serveCatalog).
|
||||
cat *catalogBed
|
||||
}
|
||||
|
||||
// catalogBed is the catalog half of each watched branch as the seat mesh-catalog keeps it: entries by branch and module, a
|
||||
// revision per branch, and the manifest bodies by digest.
|
||||
type catalogBed struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]map[string]meshcatalog.CatalogEntry
|
||||
rev map[string]int64
|
||||
bodies map[string]string
|
||||
}
|
||||
|
||||
// journal is a program's journal as the bed hears it: a test awaits a line in it (await) where the line is the only sign
|
||||
@@ -144,7 +155,8 @@ func startBed(t *testing.T, leaveOut string) *bed {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
b := &bed{srv: srv, js: js, ctx: ctx, dir: dir, buckets: map[string]jetstream.KeyValue{}, journal: &journal{grew: make(chan struct{})}}
|
||||
b := &bed{srv: srv, js: js, ctx: ctx, dir: dir, buckets: map[string]jetstream.KeyValue{}, journal: &journal{grew: make(chan struct{})},
|
||||
cat: &catalogBed{entries: map[string]map[string]meshcatalog.CatalogEntry{}, rev: map[string]int64{}, bodies: map[string]string{}}}
|
||||
for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) {
|
||||
if name == leaveOut {
|
||||
continue
|
||||
@@ -171,7 +183,7 @@ func startBed(t *testing.T, leaveOut string) *bed {
|
||||
// event names the engine's machine last, so the filter is the event with every machine (mesh-grants buscompose
|
||||
// machineEvent; the grants part's own test holds the derivation).
|
||||
if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner",
|
||||
FilterSubjects: []string{enginereads.SubjectPrefix + "*", "mesh.mod." + buckets.IndexerModule + ".event.views.>"}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
|
||||
FilterSubjects: []string{enginereads.SubjectPrefix + "*", "mesh.mod." + buckets.IndexerModule + ".event.views.>", meshcatalog.SubjectCatalogRevisionAll}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The operator-channel seat's stream of conditions, which the assigner says its conditions on.
|
||||
@@ -180,6 +192,8 @@ func startBed(t *testing.T, leaveOut string) *bed {
|
||||
}
|
||||
// The indexer as the owner of its manifests and modules (DLV-F2r), with the real owner's side.
|
||||
serveIndexer(t, b, nc)
|
||||
// The seat mesh-catalog's catalog half (design 47 G §194), which the assigner follows per branch.
|
||||
serveCatalog(t, b, nc)
|
||||
// The assigner's views family is its ordinary events on EVENTS (ADR 0635 §5); its marks are its state.
|
||||
if kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: marksBucket, History: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -422,6 +436,7 @@ func (b *bed) manifest(t *testing.T, module, body string) string {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.putJSON(t, buckets.Manifests, key, value)
|
||||
b.cat.bodies[digest] = body
|
||||
return digest
|
||||
}
|
||||
|
||||
@@ -447,11 +462,11 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
// DLV-F5r (ADR 0621 §3): the indexer's runtime starts together with the assigner and answers its first views only
|
||||
// after the assigner asked; within the bound the assigner writes the wants from what they answer.
|
||||
up := make(chan struct{})
|
||||
indexerLate = up
|
||||
catalogLate = up
|
||||
// ADR 0631 §2: while the indexer does not answer, past the first-view bound the assigner raises the condition naming
|
||||
// the indexer and graph, and reads nothing of the indexer's state; the answer clears it.
|
||||
firstViewBound, firstViewWait = 300*time.Millisecond, 50*time.Millisecond
|
||||
t.Cleanup(func() { indexerLate, firstViewBound, firstViewWait = nil, 0, 0 })
|
||||
t.Cleanup(func() { catalogLate, firstViewBound, firstViewWait = nil, 0, 0 })
|
||||
for _, name := range append(append([]string{}, readBuckets...), watched...) {
|
||||
if strings.HasPrefix(name, buckets.IndexerModule+"_") {
|
||||
t.Errorf("the assigner reads the indexer's state %s itself", name)
|
||||
@@ -461,7 +476,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
fNew, fOld := meshcatalogclient.SourcePrefix2+strings.Repeat("b", 64), meshcatalogclient.SourcePrefix2+strings.Repeat("a", 64)
|
||||
digest := b.manifest(t, "web", `{"module":"web","version":"1"}`)
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
b.putJSON(t, buckets.Modules, "web", mod)
|
||||
b.putModule(t, "web", mod)
|
||||
built, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -493,20 +508,20 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
"seat-holders": {"n1.node-tools"},
|
||||
"wants": {"n1.mesh-grants"},
|
||||
})
|
||||
graphKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, assignment.Module+"."+buckets.IndexerModule+"."+graph.State, views.UnansweredKind)
|
||||
graphKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, assignment.Module+"."+meshcatalog.Seat+"."+views.BranchState(meshcatalog.StateCatalogRevisions, trunkBranch), views.UnansweredKind)
|
||||
graphRaised := func() bool {
|
||||
e, err := b.buckets[assign.FindingsBucket].Get(b.ctx, graphKey)
|
||||
return err == nil && strings.Contains(string(e.Value()), buckets.IndexerModule+".graph")
|
||||
return err == nil && strings.Contains(string(e.Value()), "mesh-catalog.revision")
|
||||
}
|
||||
for deadline := time.Now().Add(10 * time.Second); !graphRaised(); time.Sleep(50 * time.Millisecond) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("with the indexer down, no condition names its graph (finding %s)", graphKey)
|
||||
t.Fatalf("with the seat mesh-catalog down, no condition names its revision verb (finding %s)", graphKey)
|
||||
}
|
||||
}
|
||||
close(up)
|
||||
for deadline := time.Now().Add(10 * time.Second); graphRaised(); time.Sleep(50 * time.Millisecond) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the indexer answered, and the condition naming its graph stands (finding %s)", graphKey)
|
||||
t.Fatalf("the seat answered, and the condition naming its revision verb stands (finding %s)", graphKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +623,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
fp, digest string
|
||||
}{"db": {fpOf('2'), dbDigest}, "app": {fpOf('3'), appDigest}, "tool": {fpOf('4'), toolDigest}} {
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: meshcatalogclient.RootPath})
|
||||
b.putJSON(t, buckets.Modules, module, mod)
|
||||
b.putModule(t, module, mod)
|
||||
fps := []string{row.fp}
|
||||
if module == "tool" {
|
||||
fps = append(fps, toolOld)
|
||||
@@ -680,7 +695,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
formsDigest := b.manifest(t, "forms", `{"module":"forms","version":"1"}`)
|
||||
formsAt := func(fp string) {
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: formsDigest, Repository: "novox/forms", Commit: strings.Repeat("3", 40), Path: meshcatalogclient.RootPath})
|
||||
b.putJSON(t, buckets.Modules, "forms", mod)
|
||||
b.putModule(t, "forms", mod)
|
||||
}
|
||||
for _, fp := range []string{formsOld, formsNew} {
|
||||
build := builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: formsDigest}
|
||||
@@ -802,7 +817,7 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) {
|
||||
fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64)
|
||||
digest := b.manifest(t, "web", `{"module":"web","version":"1"}`)
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
b.putJSON(t, buckets.Modules, "web", mod)
|
||||
b.putModule(t, "web", mod)
|
||||
on, _ := presenceValue(true)
|
||||
b.putJSON(t, buckets.Presence, "ace", on)
|
||||
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"})
|
||||
@@ -860,7 +875,7 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
|
||||
fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("d", 64)
|
||||
digest := b.manifest(t, "mail", `{"module":"mail","version":"1","resources":[{"id":"data","type":"directory","path":"/var/lib/mail"}],"data":{"own":[{"id":"mail","path":"${dir:data}","class":"irreplaceable","backup":"copy"}]}}`)
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/mail", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
b.putJSON(t, buckets.Modules, "mail", mod)
|
||||
b.putModule(t, "mail", mod)
|
||||
on, _ := presenceValue(true)
|
||||
b.putJSON(t, buckets.Presence, "ace", on)
|
||||
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"})
|
||||
@@ -910,7 +925,7 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) {
|
||||
fp := meshcatalogclient.SourcePrefix2 + strings.Repeat(string(rune('a'+i%6)), 63) + fmt.Sprintf("%x", i%16)
|
||||
digest := b.manifest(t, name, fmt.Sprintf(`{"module":%q,"version":"1"}`, name))
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/" + name, Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
b.putJSON(t, buckets.Modules, name, mod)
|
||||
b.putModule(t, name, mod)
|
||||
built, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -956,7 +971,7 @@ func (b *bed) fixture(t *testing.T, name, fp, manifest string) {
|
||||
t.Helper()
|
||||
digest := b.manifest(t, name, manifest)
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/" + name, Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
b.putJSON(t, buckets.Modules, name, mod)
|
||||
b.putModule(t, name, mod)
|
||||
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest})
|
||||
bkey, _ := builds.BuilderBuildKey(name, fp)
|
||||
b.putJSON(t, buckets.Builds, bkey, built)
|
||||
@@ -1305,9 +1320,9 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
|
||||
rec, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: node}})
|
||||
b.putJSON(t, buckets.Have, node+".node", rec)
|
||||
}
|
||||
b.putJSON(t, buckets.Modules, "web", []byte(`{"v":1}`))
|
||||
b.putJSON(t, buckets.Modules, "shell", []byte(`{"v":1}`))
|
||||
b.putJSON(t, buckets.Modules, "mesh-enrolment", []byte(`{"v":1}`))
|
||||
b.putModule(t, "web", []byte(`{"v":1}`))
|
||||
b.putModule(t, "shell", []byte(`{"v":1}`))
|
||||
b.putModule(t, "mesh-enrolment", []byte(`{"v":1}`))
|
||||
// place writes a node's assignments as another writer would, in its node and module keys (research 072 row 37), and
|
||||
// answers its node key's revision.
|
||||
place := func(node string, modules ...string) uint64 {
|
||||
@@ -1611,9 +1626,9 @@ func assignmentsSubjects() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// indexerLate, when set before the bed is made, fails every ask of the indexer's first views until it is closed: the
|
||||
// catalogLate, when set before the bed is made, fails every ask of the indexer's first views until it is closed: the
|
||||
// indexer down, its runtime coming up after the assigner asked (DLV-F5r, ADR 0631 §2).
|
||||
var indexerLate chan struct{}
|
||||
var catalogLate chan struct{}
|
||||
|
||||
// serveIndexer is the indexer as the owner of its manifests, modules and module graph (ADR 0615, ADR 0621, ADR 0631): its
|
||||
// buckets, which a test fills as the indexer writes them; its views family said by the views emitter as ordinary events on EVENTS,
|
||||
@@ -1635,7 +1650,7 @@ func serveIndexer(t *testing.T, b *bed, nc *nats.Conn) {
|
||||
if err := em.Start(b.ctx, state, kv, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
late := indexerLate
|
||||
late := catalogLate
|
||||
sub, err := nc.Subscribe("mesh.mod.mesh-delivery-indexer.call."+state+".>", func(m *nats.Msg) {
|
||||
if late != nil { // its runtime comes up after the assigner started (DLV-F5r): until then every ask fails, as on a down indexer
|
||||
select {
|
||||
@@ -1797,3 +1812,82 @@ func changeIn(body map[string]any) plc.Change {
|
||||
_ = json.Unmarshal(raw, &c)
|
||||
return c
|
||||
}
|
||||
|
||||
// indexerFirstViews are the indexer's states the bed still serves (the verbs' check of a proposal asks the indexer's modules and
|
||||
// manifests): the bucket each is kept in.
|
||||
var indexerFirstViews = map[string]string{"manifests": buckets.Manifests, "modules": buckets.Modules}
|
||||
|
||||
// putModule keeps a module's value as the indexer does for the verbs, and as an entry of the trunk's catalog half; a value that
|
||||
// names no manifest the bed holds is kept for the verbs alone.
|
||||
func (b *bed) putModule(t *testing.T, module string, value []byte) {
|
||||
t.Helper()
|
||||
b.putJSON(t, buckets.Modules, module, value)
|
||||
v, err := meshcatalogclient.DecodeModule(value)
|
||||
if err != nil { // a value the bed writes only for the module to exist
|
||||
v = meshcatalog.ModulesByModuleValue{Repository: "novox/" + module}
|
||||
}
|
||||
b.publish(t, trunkBranch, module, v)
|
||||
}
|
||||
|
||||
// publish makes the trunk's catalog half of branch name module at v, a new revision of it, and says catalog-revision.
|
||||
func (b *bed) publish(t *testing.T, branch, module string, v meshcatalog.ModulesByModuleValue) {
|
||||
t.Helper()
|
||||
e := meshcatalog.CatalogEntry{Module: module, Fp: v.Fp2, Commit: v.Commit, Repository: v.Repository, Path: v.Path, Needs: v.Needs, Verdict: "accepted"}
|
||||
if body, ok := b.cat.bodies[v.Manifest]; ok {
|
||||
_ = json.Unmarshal([]byte(body), &e.Manifest)
|
||||
}
|
||||
b.cat.mu.Lock()
|
||||
if b.cat.entries[branch] == nil {
|
||||
b.cat.entries[branch] = map[string]meshcatalog.CatalogEntry{}
|
||||
}
|
||||
b.cat.entries[branch][module] = e
|
||||
b.cat.rev[branch]++
|
||||
rev, n := b.cat.rev[branch], int64(len(b.cat.entries[branch]))
|
||||
b.cat.mu.Unlock()
|
||||
raw, _ := json.Marshal(meshcatalog.CatalogRevisionEvent{Kind: "trunk", Branch: keys.EscapeName(branch), Revision: rev, Entries: n, Changed: []string{module}})
|
||||
if _, err := b.js.Publish(b.ctx, meshcatalog.SubjectCatalogRevision(branch), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// serveCatalog answers the seat mesh-catalog's verb revision from the bed's catalog half, all in one page; until catalogLate is
|
||||
// closed every ask fails, as on a seat whose holder is down.
|
||||
func serveCatalog(t *testing.T, b *bed, nc *nats.Conn) {
|
||||
t.Helper()
|
||||
late := catalogLate
|
||||
answer := func(m *nats.Msg) {
|
||||
if late != nil {
|
||||
select {
|
||||
case <-late:
|
||||
default:
|
||||
_ = m.Respond([]byte(`{"error":"the seat mesh-catalog's holder is not up"}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
var in meshcatalog.RevisionInput
|
||||
_ = json.Unmarshal(m.Data, &in)
|
||||
branch, _ := keys.UnescapeName(in.Branch)
|
||||
b.cat.mu.Lock()
|
||||
defer b.cat.mu.Unlock()
|
||||
if b.cat.rev[branch] == 0 {
|
||||
_ = m.Respond([]byte(`{"error":"` + views.NoneYet + `"}`))
|
||||
return
|
||||
}
|
||||
rev := meshcatalog.CatalogRevision{Kind: "trunk", Branch: in.Branch, Revision: b.cat.rev[branch]}
|
||||
var entries []meshcatalog.CatalogEntry
|
||||
for _, name := range slices.Sorted(maps.Keys(b.cat.entries[branch])) {
|
||||
entries = append(entries, b.cat.entries[branch][name])
|
||||
rev.Entries = append(rev.Entries, meshcatalog.EntryRef{Module: name, Digest: "sha256:" + strings.Repeat("0", 64)})
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"result": meshcatalog.RevisionAnswer{Revision: rev, Entries: entries}, "node": "n0"})
|
||||
_ = m.Respond(raw)
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.mesh-catalog.tool.revision", "mesh.seat.mesh-catalog.call.revision.*", "mesh.seat.mesh-catalog.tool.revision.*"} {
|
||||
sub, err := nc.Subscribe(subject, answer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
subscribed(t, nc)
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
iv := &indexerViews{js: js, account: account, call: said.Call, findings: findings, logf: logger.Printf,
|
||||
iv := &catalogViews{js: js, account: account, call: said.Call, findings: findings, logf: logger.Printf,
|
||||
status: process.AwaitBucket(ctx, js, conn, firstViewsBucket, "assigner_first_views reads nothing and assigner_first_view_again is not heard", logger.Printf)}
|
||||
return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, iv, logger)
|
||||
}
|
||||
@@ -306,7 +306,7 @@ var watched = []string{
|
||||
|
||||
// follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after
|
||||
// each change; changes that come during a pass make one more. It returns when ctx ends or a watch ends.
|
||||
func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, kvs kvSet, say func(oc.Condition) error, findings assign.Findings, bus caller, iv *indexerViews, logger *log.Logger) error {
|
||||
func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, kvs kvSet, say func(oc.Condition) error, findings assign.Findings, bus caller, iv *catalogViews, logger *log.Logger) error {
|
||||
w := newWorld()
|
||||
dirty := make(chan struct{}, 1)
|
||||
wake := func() {
|
||||
@@ -494,6 +494,13 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
// The runner's pipeline-ended and group-ended (design 47 G §29): kept in the state written, which the wants follow
|
||||
// once the first registrations are written (§95, §353).
|
||||
p.ends = newPipelineEnds(kvEnds{ctx, kv}, wake, logger.Printf)
|
||||
if iv != nil && iv.h != nil {
|
||||
p.ends.onBranches = func(branches []string) {
|
||||
for _, b := range branches {
|
||||
iv.h.follow(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
err := hearEnds(hub, p.ends)
|
||||
if err == nil && bus != nil {
|
||||
err = followRegistrations(ctx, hub, bus.Call, p.ends)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"maps"
|
||||
"mesh-delivery-assigner/internal/buckets"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads"
|
||||
@@ -219,19 +221,21 @@ func (p *passer) nodeWants(ctx context.Context) {
|
||||
// the hold of a fingerprint whose health check failed. The assigner compares its wants with nothing the controller
|
||||
// sends: the differences bucket is kept read-only with its history (ADR 0539 §8).
|
||||
func (p *passer) wants(ctx context.Context) {
|
||||
get := p.w.getter(buckets.Manifests) // the indexer's manifests, as its first view and views family say them
|
||||
assigned := p.w.assigned()
|
||||
assignedBranches := p.w.placedBranches()
|
||||
// Every module of every branch's catalog half, for the parts that ask which exist; a node's entries come from the half of the
|
||||
// branch its placement names (design 47 G §14, §184).
|
||||
trunk := p.w.trunk()
|
||||
manifests := map[string]catalogue.Manifest{}
|
||||
for name, mod := range trunk {
|
||||
if mod.Manifest == "" {
|
||||
continue
|
||||
}
|
||||
if got := p.w.manifest(ctx, get, mod.Manifest); got.err == "" {
|
||||
manifests[name] = got.manifest
|
||||
views := map[string]*branchView{}
|
||||
viewOf := func(branch string) *branchView {
|
||||
if v := views[branch]; v != nil {
|
||||
return v
|
||||
}
|
||||
v := p.branchView(branch, assigned)
|
||||
views[branch] = v
|
||||
return v
|
||||
}
|
||||
nodes := p.w.nodes(manifests, assigned)
|
||||
nodes := viewOf(trunkBranch).nodes
|
||||
p.engineReads(nodes)
|
||||
nodesOf := map[string][]string{}
|
||||
for node, modules := range assigned {
|
||||
@@ -268,111 +272,118 @@ func (p *passer) wants(ctx context.Context) {
|
||||
defer func() { p.sayNeither(neither, stale, left) }()
|
||||
// Where grants are read from, once for the pass: the grants part's (issue 774, ADR 0571 clause 9).
|
||||
grants := p.w.grantsBucket()
|
||||
// The verb edges, once for the pass (ADR 0614 §2, §3).
|
||||
moduleGraph := p.moduleGraph()
|
||||
served := p.served(ctx, get, moduleGraph, nodes)
|
||||
offering := p.offering(ctx, get, manifests, nodes) // bus state (ADR 0614 §5)
|
||||
assignedBranches := p.w.placedBranches()
|
||||
for name := range names {
|
||||
fp := trunk[name].Fp2
|
||||
name := name
|
||||
// A want follows a passed pipeline: a module whose fingerprint no pipeline passed at on every branch its placements
|
||||
// chose, and whose wants do not name it already, gets none (design 47 G §29, §35).
|
||||
branches := p.ends.branchesOf(name, assignedBranches)
|
||||
if fp != "" && !p.ends.mayWant(name, fp, branches, wanted[name]) {
|
||||
continue
|
||||
}
|
||||
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], FormNeeds: p.w.buildNeeds(name, fp),
|
||||
Self: func(node string) bool { return placed[node] },
|
||||
Composed: func(node, fp string) (assign.ComposedDigest, bool) {
|
||||
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
|
||||
},
|
||||
GrantedAt: func(fp string) bool {
|
||||
key, err := grantsstate.PartGrantKey(name, fp)
|
||||
return err == nil && p.w.has(grants, key)
|
||||
},
|
||||
Restored: func(node string) bool {
|
||||
key, err := asks.RestoreAskKey(node, name, fp)
|
||||
return err == nil && p.answeredWith(key, assign.OptionRestore)
|
||||
},
|
||||
Allowed: func(node, digest string) bool {
|
||||
key, err := asks.RecordAskKey(node, name, digest)
|
||||
return err == nil && p.answeredWith(key, assign.OptionYes)
|
||||
},
|
||||
Release: func(fp string) (string, bool) { return p.w.release(name, fp) }}
|
||||
if fp != "" {
|
||||
held, _ := mwc.HeldKey(name, fp)
|
||||
grant, _ := grantsstate.PartGrantKey(name, fp)
|
||||
hold, _ := asks.HoldAskKey(name, fp)
|
||||
m.Released = p.answeredWith(hold, "release")
|
||||
m.Kept = p.answeredWith(hold, "keep")
|
||||
m.Built, m.Held, m.Granted = p.w.built(name, fp), p.w.has(buckets.Wants, held) && !m.Released, p.w.has(grants, grant)
|
||||
if raw := p.w.value(grants, grant); raw != nil {
|
||||
if g, err := grantsstate.DecodeGrant(raw); err == nil {
|
||||
m.GrantedOn = func(node string) bool { return assign.GrantNamesNode(g, node, name) }
|
||||
groups := p.groups(name, nodesOf[name], assignedBranches[name])
|
||||
for gi, g := range groups {
|
||||
func() {
|
||||
bv := viewOf(g.branch)
|
||||
trunk, manifests, nodes, moduleGraph, served, offering := bv.trunk, bv.manifests, bv.nodes, bv.graph, bv.served, bv.offering
|
||||
fp := trunk[name].Fp2
|
||||
name := name
|
||||
nodesOf := map[string][]string{name: g.nodes}
|
||||
wantedOf := map[string]map[string]mwc.Want{name: wantedHere(wanted[name], g.nodes, groups, gi == 0)}
|
||||
// A want follows a passed pipeline: a module whose fingerprint no pipeline passed at on the branch its placements
|
||||
// chose, and whose wants do not name it already, gets none (design 47 G §29, §35).
|
||||
var branches []string
|
||||
if p.ends.following() {
|
||||
branches = []string{g.branch}
|
||||
}
|
||||
}
|
||||
if m.Released {
|
||||
p.clearHeld(name, fp)
|
||||
}
|
||||
}
|
||||
if served != nil {
|
||||
m.Invokes, m.Served = keptInvokes(moduleGraph, name), served
|
||||
}
|
||||
m.Offering = offering
|
||||
last := map[string]string{}
|
||||
for _, node := range m.Nodes {
|
||||
last[node] = p.w.lastHealthy(node, name, fp)
|
||||
}
|
||||
out := assign.Wants(m, nodes, wanted[name], last)
|
||||
for _, node := range m.Nodes {
|
||||
for _, prov := range m.MissingProvisions(node) {
|
||||
missing[node+"."+name+"."+prov] = true
|
||||
}
|
||||
}
|
||||
p.yeses(ctx, name, m, wanted[name], &out)
|
||||
gatherNeither(neither, name, out)
|
||||
leftNeither(left, m, wanted[name], out)
|
||||
p.sayNoWant(name, m.Nodes, wanted[name], out)
|
||||
p.sayUnreached(name, m.Nodes, out)
|
||||
p.sayNodeFailed(name, fp, m.Nodes, nodes, out, first)
|
||||
if first {
|
||||
startClears(stale, m, out)
|
||||
if fp != "" && !m.Held && out.Held == nil && !out.Lifted {
|
||||
p.clearHeld(name, fp)
|
||||
}
|
||||
}
|
||||
if out.Held != nil {
|
||||
key, err := mwc.HeldKey(name, fp)
|
||||
if err == nil {
|
||||
held := *out.Held
|
||||
p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeHeld(held) })
|
||||
}
|
||||
}
|
||||
if out.Lifted {
|
||||
p.lift(ctx, name, fp)
|
||||
}
|
||||
if out.Held != nil || (m.Held && !out.Lifted) {
|
||||
p.holdAsk(ctx, name, fp, out.Held)
|
||||
p.heldCondition(ctx, name, fp, out.Held, len(out.Restore) > 0)
|
||||
}
|
||||
for _, node := range out.Restore {
|
||||
p.restoreAsk(ctx, name, node, fp)
|
||||
}
|
||||
for _, need := range out.Record {
|
||||
p.recordAsk(ctx, name, fp, need)
|
||||
}
|
||||
for node, want := range out.Wants {
|
||||
key, err := mwc.WantKey(node, name)
|
||||
if err != nil {
|
||||
p.logf("no want of %s on %s is written: %v", name, node, err)
|
||||
continue
|
||||
}
|
||||
want := want
|
||||
p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeWant(want) })
|
||||
}
|
||||
if len(out.Wants) > 0 {
|
||||
p.ends.wrote(name, fp, branches)
|
||||
if fp != "" && !p.ends.mayWant(name, fp, branches, wantedOf[name]) {
|
||||
return
|
||||
}
|
||||
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], FormNeeds: p.w.buildNeeds(name, fp),
|
||||
Self: func(node string) bool { return placed[node] },
|
||||
Composed: func(node, fp string) (assign.ComposedDigest, bool) {
|
||||
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
|
||||
},
|
||||
GrantedAt: func(fp string) bool {
|
||||
key, err := grantsstate.PartGrantKey(name, fp)
|
||||
return err == nil && p.w.has(grants, key)
|
||||
},
|
||||
Restored: func(node string) bool {
|
||||
key, err := asks.RestoreAskKey(node, name, fp)
|
||||
return err == nil && p.answeredWith(key, assign.OptionRestore)
|
||||
},
|
||||
Allowed: func(node, digest string) bool {
|
||||
key, err := asks.RecordAskKey(node, name, digest)
|
||||
return err == nil && p.answeredWith(key, assign.OptionYes)
|
||||
},
|
||||
Release: func(fp string) (string, bool) { return p.w.release(name, fp) }}
|
||||
if fp != "" {
|
||||
held, _ := mwc.HeldKey(name, fp)
|
||||
grant, _ := grantsstate.PartGrantKey(name, fp)
|
||||
hold, _ := asks.HoldAskKey(name, fp)
|
||||
m.Released = p.answeredWith(hold, "release")
|
||||
m.Kept = p.answeredWith(hold, "keep")
|
||||
m.Built, m.Held, m.Granted = p.w.built(name, fp), p.w.has(buckets.Wants, held) && !m.Released, p.w.has(grants, grant)
|
||||
if raw := p.w.value(grants, grant); raw != nil {
|
||||
if g, err := grantsstate.DecodeGrant(raw); err == nil {
|
||||
m.GrantedOn = func(node string) bool { return assign.GrantNamesNode(g, node, name) }
|
||||
}
|
||||
}
|
||||
if m.Released {
|
||||
p.clearHeld(name, fp)
|
||||
}
|
||||
}
|
||||
if served != nil {
|
||||
m.Invokes, m.Served = keptInvokes(moduleGraph, name), served
|
||||
}
|
||||
m.Offering = offering
|
||||
last := map[string]string{}
|
||||
for _, node := range m.Nodes {
|
||||
last[node] = p.w.lastHealthy(node, name, fp)
|
||||
}
|
||||
out := assign.Wants(m, nodes, wantedOf[name], last)
|
||||
for _, node := range m.Nodes {
|
||||
for _, prov := range m.MissingProvisions(node) {
|
||||
missing[node+"."+name+"."+prov] = true
|
||||
}
|
||||
}
|
||||
p.yeses(ctx, name, m, wantedOf[name], &out)
|
||||
gatherNeither(neither, name, out)
|
||||
leftNeither(left, m, wantedOf[name], out)
|
||||
p.sayNoWant(name, m.Nodes, wantedOf[name], out)
|
||||
p.sayUnreached(name, m.Nodes, out)
|
||||
p.sayNodeFailed(name, fp, m.Nodes, nodes, out, first)
|
||||
if first {
|
||||
startClears(stale, m, out)
|
||||
if fp != "" && !m.Held && out.Held == nil && !out.Lifted {
|
||||
p.clearHeld(name, fp)
|
||||
}
|
||||
}
|
||||
if out.Held != nil {
|
||||
key, err := mwc.HeldKey(name, fp)
|
||||
if err == nil {
|
||||
held := *out.Held
|
||||
p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeHeld(held) })
|
||||
}
|
||||
}
|
||||
if out.Lifted {
|
||||
p.lift(ctx, name, fp)
|
||||
}
|
||||
if out.Held != nil || (m.Held && !out.Lifted) {
|
||||
p.holdAsk(ctx, name, fp, out.Held)
|
||||
p.heldCondition(ctx, name, fp, out.Held, len(out.Restore) > 0)
|
||||
}
|
||||
for _, node := range out.Restore {
|
||||
p.restoreAsk(ctx, name, node, fp)
|
||||
}
|
||||
for _, need := range out.Record {
|
||||
p.recordAsk(ctx, name, fp, need)
|
||||
}
|
||||
for node, want := range out.Wants {
|
||||
key, err := mwc.WantKey(node, name)
|
||||
if err != nil {
|
||||
p.logf("no want of %s on %s is written: %v", name, node, err)
|
||||
continue
|
||||
}
|
||||
want := want
|
||||
p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeWant(want) })
|
||||
}
|
||||
if len(out.Wants) > 0 {
|
||||
p.ends.wrote(name, fp, branches)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1313,3 +1324,82 @@ func answering(manifests map[string]catalogue.Manifest, n assign.Nodes) func(str
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// branchView is what one pass reads of the catalog half of one watched branch (design 47 G §184, §195): its modules, their
|
||||
// manifests, the nodes the rules read through them, the module graph and the two readings of what runs.
|
||||
type branchView struct {
|
||||
trunk map[string]meshcatalog.ModulesByModuleValue
|
||||
manifests map[string]catalogue.Manifest
|
||||
nodes assign.Nodes
|
||||
graph graph.Graph
|
||||
served func(string) bool
|
||||
offering func(string) bool
|
||||
}
|
||||
|
||||
// branchView reads the catalog half of branch for this pass.
|
||||
func (p *passer) branchView(branch string, assigned map[string][]string) *branchView {
|
||||
v := &branchView{trunk: p.w.trunkOf(branch), manifests: map[string]catalogue.Manifest{}}
|
||||
for name, mod := range v.trunk {
|
||||
if mod.Manifest == "" {
|
||||
continue
|
||||
}
|
||||
if got := p.w.manifest(mod.Manifest); got.err == "" {
|
||||
v.manifests[name] = got.manifest
|
||||
}
|
||||
}
|
||||
v.nodes = p.w.nodes(v.manifests, assigned)
|
||||
v.graph = p.moduleGraph(branch)
|
||||
v.served = p.served(v.graph, v.nodes)
|
||||
v.offering = p.offering(v.manifests, v.nodes) // bus state (ADR 0614 §5)
|
||||
return v
|
||||
}
|
||||
|
||||
// group is the nodes of one module whose placements name one watched branch.
|
||||
type group struct {
|
||||
branch string
|
||||
nodes []string
|
||||
}
|
||||
|
||||
// groups splits the nodes of module by the branch their placements name, the branches in name order (the trunk's first); a
|
||||
// module placed nowhere is one group on its default branch.
|
||||
func (p *passer) groups(module string, nodes []string, placed map[string]string) []group {
|
||||
by := map[string][]string{}
|
||||
for _, node := range nodes {
|
||||
b := trunkBranch
|
||||
if p.ends.following() {
|
||||
b = p.ends.branchOf(module, placed[node])
|
||||
}
|
||||
by[b] = append(by[b], node)
|
||||
}
|
||||
if len(by) == 0 {
|
||||
b := trunkBranch
|
||||
if p.ends.following() {
|
||||
b = p.ends.branchOf(module, "")
|
||||
}
|
||||
by[b] = nil
|
||||
}
|
||||
var out []group
|
||||
for b, ns := range by {
|
||||
out = append(out, group{b, ns})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].branch < out[j].branch })
|
||||
return out
|
||||
}
|
||||
|
||||
// wantedHere is the wants of a module a group answers for: those of its own nodes, and (the first group only) those of nodes no
|
||||
// group holds, which the rules take away.
|
||||
func wantedHere(wanted map[string]mwc.Want, own []string, groups []group, first bool) map[string]mwc.Want {
|
||||
held := map[string]bool{}
|
||||
for _, g := range groups {
|
||||
for _, n := range g.nodes {
|
||||
held[n] = true
|
||||
}
|
||||
}
|
||||
out := map[string]mwc.Want{}
|
||||
for node, w := range wanted {
|
||||
if slices.Contains(own, node) || (first && !held[node]) {
|
||||
out[node] = w
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ func putOfTheBus(t *testing.T) {
|
||||
placeOn(t, p.w, "novox", "nats")
|
||||
mdigest := strings.Repeat("e", 64)
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: mdigest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("c", 40)})
|
||||
world(buckets.Modules, "nats", mod)
|
||||
putModuleRaw(p.w, trunkBranch, "nats", mod)
|
||||
p.w.manifests[mdigest] = parsed{module: "nats", manifest: catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}}}}
|
||||
bkey, _ := builds.BuilderBuildKey("nats", fp)
|
||||
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
|
||||
@@ -901,7 +901,7 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
|
||||
var said []oc.Condition
|
||||
p.say = func(c oc.Condition) error { said = append(said, c); return nil }
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
p.w.put(buckets.Modules, "web", mod)
|
||||
putModuleRaw(p.w, trunkBranch, "web", mod)
|
||||
placeOn(t, p.w, "g14", "web")
|
||||
on, _ := presenceValue(true)
|
||||
p.w.put(buckets.Presence, "g14", on)
|
||||
@@ -939,7 +939,7 @@ func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) {
|
||||
said := &[]oc.Condition{}
|
||||
p.say = func(c oc.Condition) error { *said = append(*said, c); return nil }
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/resolved", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
p.w.put(buckets.Modules, "systemd-resolved", mod)
|
||||
putModuleRaw(p.w, trunkBranch, "systemd-resolved", mod)
|
||||
placeOn(t, p.w, "shanks", "systemd-resolved")
|
||||
on, _ := presenceValue(true)
|
||||
p.w.put(buckets.Presence, "shanks", on)
|
||||
@@ -1198,7 +1198,7 @@ func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) {
|
||||
p := newPasser(kvSet{buckets.Asks: asksKV}, time.Now())
|
||||
p.logf = t.Logf
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FpSrc: c.fpSrc, Path: meshcatalogclient.RootPath})
|
||||
p.w.put(buckets.Modules, "web", mod)
|
||||
putModuleRaw(p.w, trunkBranch, "web", mod)
|
||||
src, _ := mdc.EncodeSource(mdseat.SourceByBranchValue{Group: "feat/web", Heads: map[string]string{"novox/web": commit}})
|
||||
p.w.putEntry(buckets.Source, mdc.SourceKey(trunkBranch), src, 7, time.Now())
|
||||
later, _ := mdc.EncodeSource(mdseat.SourceByBranchValue{Group: "feat/other", Heads: map[string]string{"novox/web": commit, "novox/x": commit}})
|
||||
@@ -1261,7 +1261,7 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
|
||||
p.logf = t.Logf
|
||||
put := func(bucket, key string, v []byte) { p.w.put(bucket, key, v) }
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
put(buckets.Modules, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld
|
||||
putModuleRaw(p.w, trunkBranch, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld
|
||||
placeOn(t, p.w, "g14", "web")
|
||||
pl, _ := mmac.EncodeAssignments(mma.Assignments{Node: "g14", Modules: []mma.PlacedModule{{Module: "web"}}})
|
||||
put(plc.AssignmentsBucket, "assignments.g14", pl)
|
||||
@@ -1380,7 +1380,7 @@ func TestAnApprovedAssignmentsCallsNoControllerVerb(t *testing.T) {
|
||||
func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
|
||||
p := newPasser(kvSet{}, time.Now())
|
||||
fp := func(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
|
||||
if p.served(context.Background(), nil, graph.Graph{}, assign.Nodes{}) != nil {
|
||||
if p.served(graph.Graph{}, assign.Nodes{}) != nil {
|
||||
t.Error("with no graph read, a want waits on a verb")
|
||||
}
|
||||
for module, n := range map[string]graph.Node{
|
||||
@@ -1394,26 +1394,25 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.w.put(buckets.Graph, module, raw)
|
||||
_ = raw
|
||||
p.w.putModule(trunkBranch, module, meshcatalog.ModulesByModuleValue{}, n, nil)
|
||||
}
|
||||
g := p.moduleGraph()
|
||||
g := p.moduleGraph(trunkBranch)
|
||||
if got := keptInvokes(g, "caller"); !slices.Equal(got, []string{"seat:x.v"}) || keptInvokes(g, "unknown") != nil {
|
||||
t.Errorf("the kept verb edges of caller = %v", got)
|
||||
}
|
||||
digest := strings.Repeat("d", 64)
|
||||
man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "server", Manifest: `{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`})
|
||||
get := func(_ context.Context, key string) ([]byte, error) {
|
||||
if key == digest {
|
||||
return man, nil
|
||||
}
|
||||
return nil, errNotThere
|
||||
parsedServer, err := catalogue.Decode([]byte(`{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.w.manifests[digest] = parsed{module: "server", manifest: parsedServer}
|
||||
build, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: digest})
|
||||
key, _ := builds.BuilderBuildKey("server", fp("f"))
|
||||
p.w.put(buckets.Builds, key, build)
|
||||
running := nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
|
||||
at := func(f string) nodeengine.HaveByNodeModuleValue { h := running; h.Fp = f; return h }
|
||||
served := p.served(context.Background(), get, g, assign.Nodes{Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
|
||||
served := p.served(g, assign.Nodes{Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
|
||||
"g14": {"server": at(fp("f")), "late": at(fp("2"))}}})
|
||||
for v, want := range map[string]bool{"seat:x.w": true, "seat:x.v": false, "late.t": true} {
|
||||
if served(v) != want {
|
||||
@@ -1424,7 +1423,7 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
|
||||
// offline node offers nothing, and one whose manifest is not read counts its trunk manifest's offer.
|
||||
bus := map[string]catalogue.Manifest{"bus": {Module: "bus", Provides: []catalogue.Offer{{Name: graph.BusState, Scope: catalogue.ScopeMesh}}}}
|
||||
for _, online := range []bool{false, true} {
|
||||
offering := p.offering(context.Background(), get, bus, assign.Nodes{Online: map[string]bool{"n2": online},
|
||||
offering := p.offering(bus, assign.Nodes{Online: map[string]bool{"n2": online},
|
||||
Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{"n2": {"bus": at(fp("2"))}}})
|
||||
if offering(graph.BusState) != online {
|
||||
t.Errorf("with the bus module's node online %v, bus-state offered %v", online, offering(graph.BusState))
|
||||
|
||||
@@ -151,6 +151,8 @@ type pipelineEnds struct {
|
||||
// firsts is, by module, the first watched branch of its registration, the branch of a placement that names none (design
|
||||
// 47 G §14).
|
||||
firsts map[string]string
|
||||
// onBranches is told the branches a registration names, for the catalog half of each to be followed (design 47 G §14, §195).
|
||||
onBranches func([]string)
|
||||
}
|
||||
|
||||
func newPipelineEnds(st endStore, wake func(), logf func(string, ...any)) *pipelineEnds {
|
||||
@@ -379,6 +381,9 @@ func (e *pipelineEnds) registered(reg mpcat.Registration, gone bool) {
|
||||
e.logf("the end %s is not deleted: %v", k, err)
|
||||
}
|
||||
}
|
||||
if e.onBranches != nil && !gone {
|
||||
e.onBranches(reg.Branches)
|
||||
}
|
||||
if !e.on.Swap(true) {
|
||||
e.logf("the first registration is heard (%s): the wants follow the pipelines' ends from now", reg.Module)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
plc "mesh-delivery-assigner/internal/assignment"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -147,7 +152,7 @@ func TestAPassWritesNoWantWithoutAPassedPipeline(t *testing.T) {
|
||||
p.say = func(oc.Condition) error { return nil }
|
||||
mdigest := strings.Repeat("e", 64)
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: mdigest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
p.w.put(buckets.Modules, "web", mod)
|
||||
putModuleRaw(p.w, trunkBranch, "web", mod)
|
||||
p.w.manifests[mdigest] = parsed{module: "web", manifest: catalogue.Manifest{Module: "web"}}
|
||||
bkey, _ := builds.BuilderBuildKey("web", fp)
|
||||
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
|
||||
@@ -211,3 +216,101 @@ func TestTheWantsSwitchOnTheFirstRegistration(t *testing.T) {
|
||||
t.Errorf("a registration gone left %d ends", len(st.vals))
|
||||
}
|
||||
}
|
||||
|
||||
// Design 47 G §14, §184 (test 68): a node's entry is computed from the catalog half of the branch its placement names, so a
|
||||
// change on `beta` moves only the want of the node whose placement chose `beta`.
|
||||
func TestACommitOnBetaMovesOnlyTheNodeThatChoseBeta(t *testing.T) {
|
||||
fp := func(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
|
||||
wantsKV := &memKV{values: map[string][]byte{}}
|
||||
p := newPasser(kvSet{buckets.Wants: wantsKV, buckets.Asks: &memKV{values: map[string][]byte{}}}, time.Now())
|
||||
p.say = func(oc.Condition) error { return nil }
|
||||
p.ends = newPipelineEnds(newMemEnds(), func() {}, t.Logf)
|
||||
p.ends.registered(mpcat.Registration{Module: "web", Branches: []string{"main", "beta"}}, false)
|
||||
digest := "sha256:" + strings.Repeat("c", 64)
|
||||
on, _ := presenceValue(true)
|
||||
for _, node := range []string{"g14", "ace"} {
|
||||
composes(t, p.w, node, digest)
|
||||
p.w.put(buckets.Presence, node, on)
|
||||
}
|
||||
for node, branch := range map[string]string{"g14": "", "ace": "beta"} {
|
||||
pl := mma.Assignments{Node: node, Modules: []mma.PlacedModule{{Module: "web", Branch: branch}}}
|
||||
values, err := mmac.AssignmentsValues(pl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, raw := range values {
|
||||
p.w.put(plc.AssignmentsBucket, key, raw)
|
||||
}
|
||||
}
|
||||
// publish makes the half of branch name web at fingerprint f: built, granted and passed by its pipeline.
|
||||
publish := func(branch, f string, source int64) {
|
||||
mdigest := strings.Repeat("e", 62) + fmt.Sprintf("%02d", source)
|
||||
if branch == "beta" {
|
||||
mdigest = strings.Repeat("f", 62) + fmt.Sprintf("%02d", source)
|
||||
}
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: f, Manifest: mdigest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
putModuleRaw(p.w, branch, "web", mod)
|
||||
p.w.manifests[mdigest] = parsed{module: "web", manifest: catalogue.Manifest{Module: "web"}}
|
||||
bkey, _ := builds.BuilderBuildKey("web", f)
|
||||
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
|
||||
p.w.put(buckets.Builds, bkey, built)
|
||||
gkey, _ := grantsstate.PartGrantKey("web", f)
|
||||
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
|
||||
p.w.put(buckets.Grants, gkey, grant)
|
||||
_ = p.ends.take(pipe("web", branch, source, 1, f, mpr.PipelineEndedOutcomePassed), true)
|
||||
}
|
||||
run := func(node, f string) {
|
||||
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: f, Composed: digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
|
||||
p.w.put(buckets.Have, node+".web", have)
|
||||
}
|
||||
fpWant := func(node string) string {
|
||||
key, _ := mwc.WantKey(node, "web")
|
||||
w, err := mwc.DecodeWant(wantsKV.values[key])
|
||||
if err != nil {
|
||||
t.Fatalf("the want of web on %s: %v", node, err)
|
||||
}
|
||||
return w.Fp
|
||||
}
|
||||
publish("main", fp("a"), 5)
|
||||
publish("beta", fp("b"), 5)
|
||||
run("g14", fp("a"))
|
||||
run("ace", fp("b"))
|
||||
p.wants(context.Background())
|
||||
if fpWant("g14") != fp("a") || fpWant("ace") != fp("b") {
|
||||
t.Fatalf("each node is wanted at its branch's build: g14 %s, ace %s", fpWant("g14"), fpWant("ace"))
|
||||
}
|
||||
publish("beta", fp("d"), 6)
|
||||
p.wants(context.Background())
|
||||
if fpWant("g14") != fp("a") || fpWant("ace") != fp("d") {
|
||||
t.Errorf("a commit on beta moves only ace: g14 %s (want main's), ace %s (want beta's new)", fpWant("g14"), fpWant("ace"))
|
||||
}
|
||||
}
|
||||
|
||||
// Design 47 G §14, §195: the catalog half of each branch is taken into the world per branch, an entry as the value and graph node
|
||||
// the rules read, and a registration's branches are each followed.
|
||||
func TestTheCatalogHalfIsTakenInPerBranch(t *testing.T) {
|
||||
w := newWorld()
|
||||
woken := 0
|
||||
h := &halves{w: w, wake: func() { woken++ }, logf: t.Logf}
|
||||
entry := func(fp string) json.RawMessage {
|
||||
raw, _ := json.Marshal(meshcatalog.CatalogEntry{Module: "web", Fp: fp, Repository: "novox/web", Commit: "c1", Manifest: map[string]any{"module": "web", "version": "1"},
|
||||
Invokes: []string{"seat:x.v"}})
|
||||
return raw
|
||||
}
|
||||
h.changed("main", "web", entry("src2:1"), false)
|
||||
h.changed("beta", "web", entry("src2:2"), false)
|
||||
if w.trunkOf("main")["web"].Fp2 != "src2:1" || w.trunkOf("beta")["web"].Fp2 != "src2:2" || woken != 2 {
|
||||
t.Errorf("main %+v, beta %+v, woken %d: each branch keeps its own entry", w.trunkOf("main")["web"], w.trunkOf("beta")["web"], woken)
|
||||
}
|
||||
v := w.trunkOf("beta")["web"]
|
||||
if got := w.manifest(v.Manifest); got.err != "" || got.manifest.Module != "web" {
|
||||
t.Errorf("the entry's manifest is not read by its digest: %+v", got)
|
||||
}
|
||||
if n := w.graphOf("main")["web"]; n.FP != "src2:1" || !slices.Equal(n.Invokes, []string{"seat:x.v"}) {
|
||||
t.Errorf("graph node %+v", n)
|
||||
}
|
||||
h.changed("beta", "web", nil, true)
|
||||
if _, ok := w.trunkOf("beta")["web"]; ok || w.trunkOf("main")["web"].Fp2 == "" {
|
||||
t.Error("a module taken out of beta's half leaves main's")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ func (p *passer) puts(ctx context.Context) {
|
||||
}
|
||||
assigned := p.w.assigned()
|
||||
trunk := p.w.trunk()
|
||||
get := p.w.getter(buckets.Manifests) // the indexer's manifests, as its first view and views family say them
|
||||
wanted := p.w.wantsOf()
|
||||
modules := make([]string, 0, len(all))
|
||||
for m := range all {
|
||||
@@ -122,7 +121,7 @@ func (p *passer) puts(ctx context.Context) {
|
||||
for _, module := range modules {
|
||||
var manifest catalogue.Manifest
|
||||
if mod, ok := trunk[module]; ok && mod.Manifest != "" {
|
||||
if got := p.w.manifest(ctx, get, mod.Manifest); got.err == "" {
|
||||
if got := p.w.manifest(mod.Manifest); got.err == "" {
|
||||
manifest = got.manifest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
mdcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
|
||||
@@ -465,10 +464,8 @@ func (p *passer) layAgain(ctx context.Context, prop mma.Proposal, rev uint64, cu
|
||||
// nodes with a record.
|
||||
func (p *passer) lays(ctx context.Context) (func(string) bool, func(string) bool) {
|
||||
held := map[string]bool{}
|
||||
for key := range p.w.read(buckets.Modules) {
|
||||
if name, err := meshcatalogclient.ParseModulesKey(key); err == nil {
|
||||
held[name] = true
|
||||
}
|
||||
for name := range p.w.trunk() {
|
||||
held[name] = true
|
||||
}
|
||||
records := p.w.records()
|
||||
return func(m string) bool { return held[m] }, func(n string) bool { _, ok := records[n]; return ok }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mesh-delivery-assigner/internal/buckets"
|
||||
"slices"
|
||||
|
||||
@@ -20,21 +19,8 @@ import (
|
||||
// each kept edge until a build serving its verb runs healthy, read from the haves, the builder's builds and the indexer's
|
||||
// manifests, as a provider is counted (ADR 0603 §5).
|
||||
|
||||
// moduleGraph is the module graph as the indexer's first view and views family last said it (ADR 0631); a key that
|
||||
// does not decode is left out, so its module waits on no verb.
|
||||
func (p *passer) moduleGraph() graph.Graph {
|
||||
g := graph.Graph{}
|
||||
for key, raw := range p.w.read(buckets.Graph) {
|
||||
module, err := graph.ParseKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if n, err := graph.Decode(raw); err == nil {
|
||||
g[module] = n
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
// moduleGraph is the module graph of the catalog half of branch, from the entries' invokes and serves (design 47 G §184, §195).
|
||||
func (p *passer) moduleGraph(branch string) graph.Graph { return p.w.graphOf(branch) }
|
||||
|
||||
// keptInvokes are the verbs of module the graph keeps as edges: each it invokes and does not leave out, that a module
|
||||
// other than itself serves. A verb no module of the graph serves is no edge, so it holds nothing; nil when the graph holds
|
||||
@@ -61,11 +47,11 @@ func keptInvokes(g graph.Graph, module string) []string {
|
||||
// serves are served. A healthy build whose manifest cannot be read counts what the graph says its module serves, said in
|
||||
// the log, so a build read late never holds its callers for good; nil when the graph holds nothing, and no want waits on
|
||||
// a verb.
|
||||
func (p *passer) served(ctx context.Context, get getter, g graph.Graph, n assign.Nodes) func(string) bool {
|
||||
func (p *passer) served(g graph.Graph, n assign.Nodes) func(string) bool {
|
||||
if len(g) == 0 {
|
||||
return nil
|
||||
}
|
||||
served, unread := graph.Served(n.Haves, func(module, fp string) ([]string, bool) { return p.servesOf(ctx, get, module, fp) },
|
||||
served, unread := graph.Served(n.Haves, func(module, fp string) ([]string, bool) { return p.servesOf(module, fp) },
|
||||
func(module string) []string { return g[module].Serves })
|
||||
if len(unread) > 0 {
|
||||
p.logf("the manifest of the running build of %v cannot be read, so what the module graph says they serve is counted as served", unread)
|
||||
@@ -77,7 +63,7 @@ func (p *passer) served(ctx context.Context, get getter, g graph.Graph, n assign
|
||||
// §5, ADR 0614 §5): every running healthy have on an online node, its build's manifest read as servesOf reads it. A
|
||||
// healthy build whose manifest cannot be read counts what its module's trunk manifest offers, so a build read late never
|
||||
// holds a consumer for good.
|
||||
func (p *passer) offering(ctx context.Context, get getter, trunk map[string]catalogue.Manifest, n assign.Nodes) func(string) bool {
|
||||
func (p *passer) offering(trunk map[string]catalogue.Manifest, n assign.Nodes) func(string) bool {
|
||||
offered := map[string]bool{}
|
||||
for node, haves := range n.Haves {
|
||||
if !n.Online[node] {
|
||||
@@ -87,7 +73,7 @@ func (p *passer) offering(ctx context.Context, get getter, trunk map[string]cata
|
||||
if h.Phase != nodeengineclient.PhaseRunning || h.Health != nodeengineclient.HealthOK {
|
||||
continue
|
||||
}
|
||||
m, ok := p.manifestOf(ctx, get, module, h.Fp)
|
||||
m, ok := p.manifestOf(module, h.Fp)
|
||||
if !ok {
|
||||
m = trunk[module]
|
||||
}
|
||||
@@ -100,8 +86,8 @@ func (p *passer) offering(ctx context.Context, get getter, trunk map[string]cata
|
||||
}
|
||||
|
||||
// servesOf are the verbs the manifest of module's build at fp serves (graph.ServesOf), and whether it could be read.
|
||||
func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([]string, bool) {
|
||||
m, ok := p.manifestOf(ctx, get, module, fp)
|
||||
func (p *passer) servesOf(module, fp string) ([]string, bool) {
|
||||
m, ok := p.manifestOf(module, fp)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
@@ -110,7 +96,7 @@ func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([
|
||||
|
||||
// manifestOf is the manifest of module's build at fp (the builder's build names its digest in the indexer's manifests),
|
||||
// and whether it could be read.
|
||||
func (p *passer) manifestOf(ctx context.Context, get getter, module, fp string) (catalogue.Manifest, bool) {
|
||||
func (p *passer) manifestOf(module, fp string) (catalogue.Manifest, bool) {
|
||||
key, err := builds.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false
|
||||
@@ -123,7 +109,7 @@ func (p *passer) manifestOf(ctx context.Context, get getter, module, fp string)
|
||||
if err != nil || b.Manifest == "" {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
got := p.w.manifest(ctx, get, b.Manifest)
|
||||
got := p.w.manifest(b.Manifest)
|
||||
if got.err != "" {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"mesh-delivery-assigner/internal/assignment"
|
||||
"mesh-delivery-assigner/internal/buckets"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -20,7 +21,8 @@ import (
|
||||
|
||||
sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
mcat "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
plc "mesh-delivery-assigner/internal/assignment"
|
||||
@@ -126,10 +128,11 @@ func viewFindings(f assign.Findings) views.FindingWriter {
|
||||
// firstViewsBucket is the assigner's own state of its first views (views.StatusState).
|
||||
var firstViewsBucket = assignment.Module + "_" + views.StatusState
|
||||
|
||||
// indexerViews are the assigner's first views of the indexer's manifests, modules and module graph (ADR 0615 §1,
|
||||
// ADR 0621 §2, ADR 0631; DLV-F2r): asked at the start through the indexer's tools, then followed on indexer-views, asking once more on a gap;
|
||||
// past the bound its finding names the indexer and the tool, cleared by the answer.
|
||||
type indexerViews struct {
|
||||
// catalogViews is the assigner's first view of the catalog half of each watched branch (design 47 G §14, §184, §195): asked
|
||||
// at the start through the seat mesh-catalog's verb `revision` (the trunk's, then each branch a registration names), then
|
||||
// followed on its event `catalog-revision`, asking once more on a gap; past the bound its finding names the seat and the verb,
|
||||
// cleared by the answer. Its status is read by assigner_first_views and asked again by assigner_first_view_again.
|
||||
type catalogViews struct {
|
||||
js jetstream.JetStream
|
||||
account process.Account
|
||||
call func(string, any) (json.RawMessage, error)
|
||||
@@ -137,21 +140,17 @@ type indexerViews struct {
|
||||
status *process.AwaitedBucket
|
||||
logf func(string, ...any)
|
||||
hub *process.EventHub
|
||||
}
|
||||
|
||||
// indexerFirstViews are the indexer's states the assigner takes by first view and the views family, each by the name of
|
||||
// the indexer's bucket the world keeps it under: manifests and modules (ADR 0621 §2), and the module graph, whose verb
|
||||
// edges a want waits on (ADR 0614 §2, §3; ADR 0631 §1).
|
||||
var indexerFirstViews = map[string]string{"manifests": buckets.Manifests, "modules": buckets.Modules, graph.State: buckets.Graph}
|
||||
h *halves
|
||||
}
|
||||
|
||||
// firstViewBound and firstViewWait are each first view's bound before its condition is raised, and its first wait
|
||||
// between two failed asks; zero is the views package's (views.Bound, two minutes, ADR 0615 §6; and a first wait of five
|
||||
// seconds). A test shortens them.
|
||||
var firstViewBound, firstViewWait time.Duration
|
||||
|
||||
// start asks the three first views, each answer taken into w under the indexer's bucket names and settled once (a pass waits
|
||||
// for it), and hears the views family; it answers how many settles to wait for.
|
||||
func (iv *indexerViews) start(ctx context.Context, w *world, wake func(), settled chan<- struct{}) int {
|
||||
// start follows the trunk's catalog half and hears the seat's catalog-revision; it answers how many settles to wait for.
|
||||
func (iv *catalogViews) start(ctx context.Context, w *world, wake func(), settled chan<- struct{}) int {
|
||||
finding := func(s views.Status, raised bool) {
|
||||
if iv.findings.Put == nil {
|
||||
return
|
||||
@@ -167,33 +166,28 @@ func (iv *indexerViews) start(ctx context.Context, w *world, wake func(), settle
|
||||
iv.logf("views: the finding of the first view %s is not written: %v", s.Tool, err)
|
||||
}
|
||||
}
|
||||
followers := map[string]*views.Follower{}
|
||||
var all []*views.Follower
|
||||
for state, bucket := range indexerFirstViews {
|
||||
bucket := bucket
|
||||
tool := buckets.IndexerModule + "." + state
|
||||
f := &views.Follower{Owner: buckets.IndexerModule, Tool: tool, State: state, Ask: views.AskVia(iv.call, tool), Logf: iv.logf,
|
||||
Bound: firstViewBound, Wait: firstViewWait,
|
||||
Raise: func(s views.Status) { finding(s, true) }, Clear: func(s views.Status) { finding(s, false) },
|
||||
Changed: func(key string, value json.RawMessage, deleted bool, at time.Time) {
|
||||
if deleted {
|
||||
value = nil
|
||||
}
|
||||
w.putEntry(bucket, key, value, 0, at)
|
||||
wake()
|
||||
}}
|
||||
followers[state] = f
|
||||
all = append(all, f)
|
||||
go func() {
|
||||
if f.Start(ctx) == nil {
|
||||
settled <- struct{}{}
|
||||
}
|
||||
}()
|
||||
var statusKV jetstream.KeyValue
|
||||
var mu sync.Mutex
|
||||
iv.h = &halves{w: w, call: iv.call, wake: wake, logf: iv.logf, ctx: ctx, settled: settled}
|
||||
iv.h.started = func(c *mcat.Catalog) {
|
||||
f := c.Follower
|
||||
f.Bound, f.Wait = firstViewBound, firstViewWait
|
||||
f.Raise, f.Clear = func(s views.Status) { finding(s, true) }, func(s views.Status) { finding(s, false) }
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if statusKV != nil {
|
||||
f.Said = views.KeepStatus(ctx, statusKV, iv.logf)
|
||||
}
|
||||
}
|
||||
if iv.status != nil {
|
||||
iv.status.Ready(func(kv jetstream.KeyValue) {
|
||||
for _, f := range all {
|
||||
f.Said = views.KeepStatus(ctx, kv, iv.logf)
|
||||
mu.Lock()
|
||||
statusKV = kv
|
||||
mu.Unlock()
|
||||
var all []*views.Follower
|
||||
for _, c := range iv.h.followers() {
|
||||
c.Follower.Said = views.KeepStatus(ctx, kv, iv.logf)
|
||||
all = append(all, c.Follower)
|
||||
}
|
||||
if err := views.HearAgain(ctx, kv, all, iv.logf); err != nil {
|
||||
iv.logf("views: assigner_first_view_again is not heard (%v) until the assigner starts again", err)
|
||||
@@ -202,16 +196,17 @@ func (iv *indexerViews) start(ctx context.Context, w *world, wake func(), settle
|
||||
}
|
||||
// The hub says when the bus made its consumer: a picture asked before then is asked again (views.Follower.ConsumerMade).
|
||||
iv.hub.OnBound(func(created time.Time) {
|
||||
for _, f := range all {
|
||||
f.ConsumerMade(ctx, created)
|
||||
for _, c := range iv.h.followers() {
|
||||
c.Follower.ConsumerMade(ctx, created)
|
||||
}
|
||||
})
|
||||
// Not heard, the indexer's views family is the finding family-not-heard until it binds (ADR 0615 §5): the handler is
|
||||
// registered on the module's hub, which the assigner starts once every handler is.
|
||||
_ = notHeardWriter(iv.findings, iv.logf).Bind(process.EventsStream, "the indexer's manifests, modules and graph are the first views' alone until the assigner starts again", func() error {
|
||||
return iv.hub.Handle("mesh.mod."+buckets.IndexerModule+".event."+views.EventPrefix+"*", views.Hear(ctx, followers, iv.logf))
|
||||
// Not heard, the catalog-revision is the finding family-not-heard until it binds (ADR 0615 §5): the handler is registered
|
||||
// on the module's hub, which the assigner starts once every handler is.
|
||||
_ = notHeardWriter(iv.findings, iv.logf).Bind(process.EventsStream, "the catalog half is the first views' alone until the assigner starts again", func() error {
|
||||
return iv.hub.Handle(meshcatalog.SubjectCatalogRevisionAll, iv.h.hear)
|
||||
})
|
||||
return len(all)
|
||||
iv.h.follow(trunkBranch)
|
||||
return 1
|
||||
}
|
||||
|
||||
// notHeardWriter writes the assigner's finding of a family it does not hear into its findings (heard.Writer); with no
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
@@ -14,16 +13,14 @@ import (
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
wdc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/trunk"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
@@ -56,6 +53,10 @@ type world struct {
|
||||
// durable consumer (design 47 G §199, §309); runningHeard says that consumer is bound.
|
||||
running map[string]nodeengine.Running
|
||||
runningHeard bool
|
||||
// modules and graphs are, by watched branch and module, the catalog half the assigner follows per branch (design 47 G §14,
|
||||
// §184, §195), as the value and the graph node the rules read: written by putModule from the entries of meshcatalogclient.Catalog.
|
||||
modules map[string]map[string]meshcatalog.ModulesByModuleValue
|
||||
graphs map[string]map[string]graph.Node
|
||||
}
|
||||
|
||||
// entryMeta is a value's revision in its bucket and when it was written.
|
||||
@@ -74,7 +75,8 @@ type parsed struct {
|
||||
func newWorld() *world {
|
||||
return &world{values: map[string]map[string][]byte{}, manifests: map[string]parsed{}, healthy: map[string][]string{}, changes: map[string]uint64{},
|
||||
meta: map[string]entryMeta{}, trunkBuilt: map[string]builds.BuildLogEntry{}, assignmentKeys: mmac.NewKeys(),
|
||||
sourceGroups: map[uint64]string{}, running: map[string]nodeengine.Running{}}
|
||||
sourceGroups: map[uint64]string{}, running: map[string]nodeengine.Running{},
|
||||
modules: map[string]map[string]meshcatalog.ModulesByModuleValue{}, graphs: map[string]map[string]graph.Node{}}
|
||||
}
|
||||
|
||||
// putEntry is put, keeping the value's revision and the time it was written.
|
||||
@@ -194,71 +196,16 @@ func (w *world) read(bucket string) map[string][]byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// getter reads one key's value; errNotThere when the key does not exist.
|
||||
type getter func(ctx context.Context, key string) ([]byte, error)
|
||||
|
||||
var errNotThere = errors.New("no such key")
|
||||
|
||||
// kvGetter reads keys of a bucket.
|
||||
func kvGetter(kv jetstream.KeyValue) getter {
|
||||
return func(ctx context.Context, key string) ([]byte, error) {
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, errNotThere
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// getter reads a key of a bucket the world holds: errNotThere where it holds none.
|
||||
func (w *world) getter(bucket string) getter {
|
||||
return func(_ context.Context, key string) ([]byte, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
v, ok := w.values[bucket][key]
|
||||
if !ok {
|
||||
return nil, errNotThere
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
// manifest reads the manifest of a digest from the indexer's bucket once and keeps it: a digest's manifest never changes.
|
||||
func (w *world) manifest(ctx context.Context, get getter, digest string) parsed {
|
||||
// manifest is the manifest of a digest the catalog half said (putModule keeps each once); one not said yet is not readable.
|
||||
func (w *world) manifest(digest string) parsed {
|
||||
w.mu.Lock()
|
||||
p, ok := w.manifests[digest]
|
||||
w.mu.Unlock()
|
||||
if ok {
|
||||
defer w.mu.Unlock()
|
||||
if p, ok := w.manifests[digest]; ok {
|
||||
return p
|
||||
}
|
||||
key, err := meshcatalogclient.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return parsed{err: err.Error()}
|
||||
}
|
||||
value, err := get(ctx, key)
|
||||
if errors.Is(err, errNotThere) {
|
||||
return parsed{err: "the indexer holds no manifest of digest " + digest + " yet"}
|
||||
}
|
||||
if err != nil {
|
||||
return parsed{err: err.Error()}
|
||||
}
|
||||
m, err := meshcatalogclient.DecodeManifest(value)
|
||||
if err != nil {
|
||||
p = parsed{err: err.Error()}
|
||||
} else if man, err := catalogue.Decode([]byte(m.Manifest)); err != nil {
|
||||
// Decoded, never judged (novox/hq ADR 0578 §3): the indexer's judge judged it, and its verdict is taken from the
|
||||
// module's value where it is read (world.mesh).
|
||||
p = parsed{module: m.Module, err: "it does not decode: " + err.Error()}
|
||||
} else {
|
||||
p = parsed{module: m.Module, manifest: man}
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.manifests[digest] = p
|
||||
w.mu.Unlock()
|
||||
return p
|
||||
return parsed{err: "the catalog half holds no manifest of digest " + digest + " yet"}
|
||||
}
|
||||
|
||||
// assigned is each node's modules the seat mesh-module-assigner's assignments names, in name order (ADR 0549 §5, ADR 0567 §8): the
|
||||
@@ -299,21 +246,74 @@ func (w *world) placedNodes() map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// trunk is every module the indexer holds at the trunk, with its value.
|
||||
// trunkOf is every module the catalog half of branch holds, with its value (design 47 G §184).
|
||||
func (w *world) trunkOf(branch string) map[string]meshcatalog.ModulesByModuleValue {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return maps.Clone(w.modules[branch])
|
||||
}
|
||||
|
||||
// graphOf is the module graph of the catalog half of branch.
|
||||
func (w *world) graphOf(branch string) graph.Graph {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return graph.Graph(maps.Clone(w.graphs[branch]))
|
||||
}
|
||||
|
||||
// branches are the watched branches the catalog half is held of, the trunk's first.
|
||||
func (w *world) branches() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
out := slices.Sorted(maps.Keys(w.modules))
|
||||
if i := slices.Index(out, trunkBranch); i > 0 {
|
||||
out = slices.Insert(slices.Delete(out, i, i+1), 0, trunkBranch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trunk is every module any branch's catalog half holds, each with its value on the first branch that holds it (the trunk's
|
||||
// first): the modules that exist, for the parts that ask which, not at which fingerprint a node runs it.
|
||||
func (w *world) trunk() map[string]meshcatalog.ModulesByModuleValue {
|
||||
out := map[string]meshcatalog.ModulesByModuleValue{}
|
||||
for key, value := range w.read(buckets.Modules) {
|
||||
name, err := meshcatalogclient.ParseModulesKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if m, err := meshcatalogclient.DecodeModule(value); err == nil {
|
||||
out[name] = m
|
||||
for _, b := range w.branches() {
|
||||
for name, v := range w.trunkOf(b) {
|
||||
if _, ok := out[name]; !ok {
|
||||
out[name] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// putModule takes one module of branch's catalog half in: its value, its graph node and its parsed manifest by the digest the
|
||||
// value names.
|
||||
func (w *world) putModule(branch, module string, v meshcatalog.ModulesByModuleValue, n graph.Node, m *parsed) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.modules[branch] == nil {
|
||||
w.modules[branch], w.graphs[branch] = map[string]meshcatalog.ModulesByModuleValue{}, map[string]graph.Node{}
|
||||
}
|
||||
w.modules[branch][module], w.graphs[branch][module] = v, n
|
||||
if m != nil && v.Manifest != "" {
|
||||
w.manifests[v.Manifest] = *m
|
||||
}
|
||||
}
|
||||
|
||||
// dropModule takes a module out of branch's catalog half.
|
||||
func (w *world) dropModule(branch, module string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
delete(w.modules[branch], module)
|
||||
delete(w.graphs[branch], module)
|
||||
}
|
||||
|
||||
// holdsBranch says whether the catalog half of branch has been taken in (even empty).
|
||||
func (w *world) holdsBranch(branch string) bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.modules[branch] != nil
|
||||
}
|
||||
|
||||
// records is each node's record in its own have key (`<node>.node`): the nodes an assignments proposal is answered by,
|
||||
// with those assignments names (ADR 0593 §8).
|
||||
func (w *world) records() map[string]nodeengine.NodeRecord {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"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/modules/mesh-grants/grantsstate"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// fakeManifests is the indexer's manifests bucket in memory.
|
||||
@@ -71,3 +72,12 @@ func theWorldCountsTheValuesAKeyTakes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// putModuleRaw takes a module value encoded as the catalog's modules say it into the world as branch's catalog half holds it.
|
||||
func putModuleRaw(w *world, branch, module string, raw []byte) {
|
||||
v, err := meshcatalogclient.DecodeModule(raw)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
w.putModule(branch, module, v, graph.Node{FP: v.Fp2, Repository: v.Repository}, nil)
|
||||
}
|
||||
|
||||
@@ -123,11 +123,11 @@ func TestManifest(t *testing.T) {
|
||||
}
|
||||
// ADR 0618 §4: and it hears each node's engine say its reads changed; ADR 0636 §4 (issue 1966): and the seat list's two events;
|
||||
// issue 1966 note 151: and the teller's ask-ended, its subject the one the assigner hears.
|
||||
if !slices.Equal(m.Consumes, []string{"mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.graph", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running"}) {
|
||||
t.Errorf("consumes = %v, want the indexer's views of graph, manifests and modules, the teller's ask-ended, the engines' reads changed and what they run", m.Consumes)
|
||||
if !slices.Equal(m.Consumes, []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running"}) {
|
||||
t.Errorf("consumes = %v, want the catalog half's revisions (design 47 G §195), the teller's ask-ended, the engines' reads changed and what they run", m.Consumes)
|
||||
}
|
||||
if want := []string{"mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
|
||||
"seat:mesh-catalog.registrations", "seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list
|
||||
if want := []string{"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
|
||||
"seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list
|
||||
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone
|
||||
"seat:node-backup.backed-up", "seat:node-backup.now",
|
||||
"seat:node-engine.manifest-reads", // ADR 0618 §3
|
||||
|
||||
@@ -39,12 +39,10 @@
|
||||
"operator-channel"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-catalog.catalog-revision",
|
||||
"mesh-catalog.registration-changed",
|
||||
"mesh-catalog.seat-definition-changed",
|
||||
"mesh-catalog.seat-definition-removed",
|
||||
"mesh-delivery-indexer.views.graph",
|
||||
"mesh-delivery-indexer.views.manifests",
|
||||
"mesh-delivery-indexer.views.modules",
|
||||
"mesh-delivery-teller.ask-ended",
|
||||
"mesh-pipeline-runner.pipeline-ended",
|
||||
"mesh-pipeline-runner.group-ended",
|
||||
@@ -52,10 +50,10 @@
|
||||
"node-engine.running"
|
||||
],
|
||||
"invokes": [
|
||||
"mesh-delivery-indexer.graph",
|
||||
"mesh-delivery-indexer.manifests",
|
||||
"mesh-delivery-indexer.modules",
|
||||
"seat:mesh-catalog.registrations",
|
||||
"seat:mesh-catalog.revision",
|
||||
"seat:mesh-catalog.seat-definitions",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-hand-acts.hand-acts",
|
||||
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -82,9 +86,22 @@ func Tools(r pipeline.Reads) []stdio.Tool {
|
||||
return out
|
||||
}
|
||||
|
||||
// Module and Slug are the runner's module and its tools' prefix, as its manifest names them.
|
||||
const (
|
||||
Module = "mesh-delivery-pipeline"
|
||||
Slug = "pipeline"
|
||||
)
|
||||
|
||||
// FirstViewTools are the runner's `<module>_first_views` and `<module>_first_view_again` over its state first-views
|
||||
// (design 47 G §77, §200): each view it holds or waits for, since when, and asked again by hand, a hand act.
|
||||
func FirstViewTools(st stdio.KeptState, acts views.HandActs, now func() time.Time) []stdio.Tool {
|
||||
return views.StatusTools(Module, Slug+"_first_views", Slug+"_first_view_again", st, acts, now)
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := pipeline.Reads{Pipelines: kept{stdio.State(seat.StatePipelines)}, Kept: kept{stdio.State(seat.StateKept)}}
|
||||
if err := stdio.Serve("", Tools(r)); err != nil {
|
||||
tools := append(Tools(r), FirstViewTools(stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...)
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-pipeline-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"mesh-delivery-pipeline/internal/pipeline"
|
||||
)
|
||||
|
||||
@@ -13,13 +16,17 @@ type none struct{}
|
||||
func (none) Keys() ([]string, error) { return nil, nil }
|
||||
func (none) Value(string) (json.RawMessage, error) { return nil, nil }
|
||||
|
||||
// TestTools: the bundle serves the five read verbs as <seat>.<verb> (§83, §306; RN15/AC1).
|
||||
// TestTools: the bundle serves the five read verbs as <seat>.<verb> (§83, §306; RN15/AC1), and the first-view tools
|
||||
// (§77, §200; RN2/AC4).
|
||||
func TestTools(t *testing.T) {
|
||||
var names []string
|
||||
for _, tl := range Tools(pipeline.Reads{Pipelines: none{}, Kept: none{}}) {
|
||||
names = append(names, tl.Name)
|
||||
}
|
||||
want := "mesh-pipeline-runner.show mesh-pipeline-runner.list mesh-pipeline-runner.stands mesh-pipeline-runner.why-carried mesh-pipeline-runner.kept"
|
||||
for _, tl := range FirstViewTools(stdio.State(views.StatusState), nil, nil) {
|
||||
names = append(names, tl.Name)
|
||||
}
|
||||
want := "mesh-pipeline-runner.show mesh-pipeline-runner.list mesh-pipeline-runner.stands mesh-pipeline-runner.why-carried mesh-pipeline-runner.kept pipeline_first_views pipeline_first_view_again"
|
||||
if got := strings.Join(names, " "); got != want {
|
||||
t.Errorf("tools %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -12,8 +13,12 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-pipeline/internal/pipeline"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -39,9 +44,39 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
defer conn.Close()
|
||||
// The state pipelines, `<module>_pipelines`, holds every pipeline by compare-and-set (§27): without it the
|
||||
// runner keeps nothing, so it stops.
|
||||
if _, err := process.EssentialBucket(ctx, js, "pipeline runner", ownBucket(cred.Module, seat.StatePipelines), "keep a pipeline"); err != nil {
|
||||
pipelines, err := process.EssentialBucket(ctx, js, "pipeline runner", ownBucket(cred.Module, seat.StatePipelines), "keep a pipeline")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The registrations and each watched branch's catalog half, first views then events (§21, §195, §200): until a
|
||||
// branch's view is held the runner acts on nothing new of it, and says so.
|
||||
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer calls.Close()
|
||||
hub := process.NewEventHub(logger.Printf)
|
||||
rv, err := startViews(ctx, js, conn, cred, hub, calls.Call, logger.Printf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A pipeline starts by compare-and-set on its key's absence and says pipeline-started (§54, §103); a publish starts
|
||||
// one per dependent behind (§53, §334).
|
||||
starter := &pipeline.Starter{Pipelines: createdKV{ctx: ctx, kv: pipelines}, Logf: logger.Printf,
|
||||
Say: func(subject string, body any) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err == nil {
|
||||
_, err = js.Publish(ctx, subject, data)
|
||||
}
|
||||
return err
|
||||
}}
|
||||
if err := hearPublished(ctx, hub, starter, calls, rv.Catalog, logger.Printf); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hub.StartUntilBound(ctx, js, cred, nil, nil); err != nil {
|
||||
logger.Printf("the consumer %s is not bound yet (%v): bound once the bus makes it", cred.EventConsumerName(), err)
|
||||
}
|
||||
go func() { <-ctx.Done(); hub.Stop() }()
|
||||
logger.Printf("holding %s", seat.Seat)
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
builds "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-pipeline/internal/pipeline"
|
||||
)
|
||||
|
||||
// outdatedVerb is the catalog's verb a publication's dependents behind are asked by, as the manifest's invokes names it.
|
||||
const outdatedVerb = "seat:" + catalog.Seat + "." + catalog.VerbOutdated
|
||||
|
||||
// createdKV is the state pipelines written by compare-and-set on a key's absence (§54).
|
||||
type createdKV struct {
|
||||
ctx context.Context
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (c createdKV) Create(key string, value []byte) error {
|
||||
_, err := c.kv.Create(c.ctx, key, value)
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return pipeline.ErrExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// hearPublished starts the pipelines of each package-published heard (§53, §334): the dependents the catalog's outdated
|
||||
// names behind, each asked as a kept call. The message is acknowledged once its starts are written, on a goroutine of its
|
||||
// own, so a slow catalog holds no other event.
|
||||
func hearPublished(ctx context.Context, hub *process.EventHub, starter *pipeline.Starter, calls *process.Conn, v *pipeline.Views, logf func(string, ...any)) error {
|
||||
outdated := func(in catalog.OutdatedInput) (catalog.OutdatedAnswer, error) {
|
||||
raw, err := calls.CallKept(outdatedVerb, in)
|
||||
if err != nil {
|
||||
return catalog.OutdatedAnswer{}, err
|
||||
}
|
||||
answer, said, ok := buscalls.Unwrap(raw)
|
||||
if !ok {
|
||||
return catalog.OutdatedAnswer{}, fmt.Errorf("%s answered: %s", outdatedVerb, said)
|
||||
}
|
||||
var a catalog.OutdatedAnswer
|
||||
err = json.Unmarshal(answer, &a)
|
||||
return a, err
|
||||
}
|
||||
return hub.Handle(builds.SubjectPackagePublished, func(m jetstream.Msg) {
|
||||
var ev builds.PackagePublished
|
||||
if err := json.Unmarshal(m.Data(), &ev); err != nil {
|
||||
logf("%s is no package-published: %v", m.Subject(), err)
|
||||
_ = m.Ack()
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
n, err := starter.Published(ev, outdated, v.View)
|
||||
if err != nil {
|
||||
logf("the publish of %s %s on %s: %d pipelines started, then %v; heard again", ev.Artifact, ev.Version, ev.Branch, n, err)
|
||||
_ = m.Nak()
|
||||
return
|
||||
}
|
||||
logf("the publish of %s %s on %s started %d pipelines", ev.Artifact, ev.Version, ev.Branch, n)
|
||||
_ = m.Ack()
|
||||
}()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"mesh-delivery-pipeline/internal/pipeline"
|
||||
)
|
||||
|
||||
// catalogRevisions is every watched branch's catalog-revision, `mesh.seat.mesh-catalog.event.catalog-revision.<branch>`.
|
||||
const catalogRevisions = "mesh.seat." + catalog.Seat + ".event." + catalog.EventCatalogRevision + ".>"
|
||||
|
||||
// runnerViews are the runner's first views (design 47 G §21, §195, §200): the registrations, then the catalog half of
|
||||
// each watched branch they name, each said in `<module>_first_views` (its state first-views) and, past five minutes
|
||||
// without a view, as view-waits in its findings.
|
||||
type runnerViews struct {
|
||||
Registrations *meshcatalogclient.Registrations
|
||||
Catalog *pipeline.Views
|
||||
}
|
||||
|
||||
// startViews wires the runner's views on hub and asks each first view; register every other handler on hub before or
|
||||
// after, then start hub once.
|
||||
func startViews(ctx context.Context, js jetstream.JetStream, conn process.Subscriber, cred process.Account, hub *process.EventHub,
|
||||
call meshcatalogclient.Call, logf func(string, ...any)) (*runnerViews, error) {
|
||||
findings := process.AwaitBucket(ctx, js, conn, mcc.FindingsBucket(cred.Module), "a view waited for past five minutes is said in the log alone", logf)
|
||||
status := process.AwaitBucket(ctx, js, conn, cred.Module+"_"+views.StatusState, "the runner's first views are not said in its first-view tool, nor asked again by hand", logf)
|
||||
writer := func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) }
|
||||
reader := &views.ProcessReader{Module: cred.Module, Call: call, Logf: logf, Hub: hub, Findings: writer}
|
||||
|
||||
// A watched branch first named by a registration gets its view of the catalog half (CT10), its conditions and its
|
||||
// status kept as every first view's, asked at once.
|
||||
wire := func(f *views.Follower) {
|
||||
f.Logf = logf
|
||||
f.Raise = func(s views.Status) {
|
||||
putFinding(writer(), views.UnansweredFinding(cred.Module, s, time.Now()), true, logf)
|
||||
}
|
||||
f.Clear = func(s views.Status) {
|
||||
putFinding(writer(), views.UnansweredFinding(cred.Module, s, time.Now()), false, logf)
|
||||
}
|
||||
f.RaiseWaits = func(s views.Status) {
|
||||
logf("waits for %s's view %s since %s", s.Owner, s.WaitsFor, s.WaitingSince.Format(time.RFC3339))
|
||||
putFinding(writer(), views.ViewWaitsFinding(cred.Module, s, time.Now()), true, logf)
|
||||
}
|
||||
f.ClearWaits = func(s views.Status) {
|
||||
putFinding(writer(), views.ViewWaitsFinding(cred.Module, s, time.Now()), false, logf)
|
||||
}
|
||||
f.Said = func(s views.Status) {
|
||||
if kv := status.KV(); kv != nil {
|
||||
views.KeepStatus(ctx, kv, logf)(s)
|
||||
}
|
||||
}
|
||||
status.Ready(func(kv jetstream.KeyValue) {
|
||||
if err := views.HearAgain(ctx, kv, []*views.Follower{f}, logf); err != nil {
|
||||
logf("the hand asks of the first view %s are not heard (%v) until the runner starts again", f.Tool, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
made []*views.Follower
|
||||
)
|
||||
cv := &pipeline.Views{Logf: logf, Open: func(branch string) (pipeline.BranchView, error) {
|
||||
c, err := meshcatalogclient.NewCatalog(call, branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wire(c.Follower)
|
||||
mu.Lock()
|
||||
made = append(made, c.Follower)
|
||||
mu.Unlock()
|
||||
go func() {
|
||||
if err := c.Start(ctx); err != nil {
|
||||
logf("the first view of the catalog half of %s was not answered: %v", branch, err)
|
||||
}
|
||||
}()
|
||||
return catalogView{c}, nil
|
||||
}}
|
||||
regs, _, err := meshcatalogclient.WireRegistrationsProcess(reader, call, func(reg catalog.Registration, gone bool) {
|
||||
if !gone {
|
||||
cv.Registered(reg)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := hub.Handle(catalogRevisions, func(m jetstream.Msg) {
|
||||
if err := cv.HearRevision(ctx, m.Data()); err != nil {
|
||||
logf("%s is not taken: %v", m.Subject(), err)
|
||||
}
|
||||
_ = m.Ack()
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A consumer made after a view was asked does not deliver what was said before it: each branch's view asks again.
|
||||
hub.OnBound(func(created time.Time) {
|
||||
mu.Lock()
|
||||
fs := append([]*views.Follower(nil), made...)
|
||||
mu.Unlock()
|
||||
for _, f := range fs {
|
||||
f.ConsumerMade(ctx, created)
|
||||
}
|
||||
})
|
||||
reader.Start(ctx, js, cred, status)
|
||||
return &runnerViews{Registrations: regs, Catalog: cv}, nil
|
||||
}
|
||||
|
||||
func putFinding(w views.FindingWriter, f views.Finding, raised bool, logf func(string, ...any)) {
|
||||
var err error
|
||||
if raised {
|
||||
err = w.Put(f)
|
||||
} else {
|
||||
err = w.Delete(f)
|
||||
}
|
||||
if err != nil {
|
||||
logf("the finding %s is not written: %v", f.Kind, err)
|
||||
}
|
||||
}
|
||||
|
||||
// catalogView is a branch's meshcatalogclient.Catalog as the runner reads it.
|
||||
type catalogView struct{ c *meshcatalogclient.Catalog }
|
||||
|
||||
func (v catalogView) Hear(ctx context.Context, data []byte) error { return v.c.Hear(ctx, data) }
|
||||
|
||||
func (v catalogView) View() (pipeline.Half, bool) {
|
||||
h, ok := v.c.View()
|
||||
if !ok {
|
||||
return pipeline.Half{}, false
|
||||
}
|
||||
return pipeline.Half{Revision: h.Revision.Revision, Source: h.Revision.Source, Entries: h.Entries}, true
|
||||
}
|
||||
@@ -9,6 +9,12 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshpipelinerunnerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner"
|
||||
)
|
||||
|
||||
// Status is a step's status for one artifact of a module, read from the module's registration and never from a
|
||||
// manifest (design 47 G §21, §23): the artifact's own status where its policy names one, else the module's. A step
|
||||
// neither names is "" (design question Q-RN8, open).
|
||||
func Status(reg catalog.Registration, artifact, step string) string {
|
||||
if a, ok := reg.Policy.Artifacts[artifact]; ok {
|
||||
if s := statusOf(a, step); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return statusOf(reg.Policy.Steps, step)
|
||||
}
|
||||
|
||||
func statusOf(s catalog.StepStatuses, step string) string {
|
||||
switch step {
|
||||
case seat.StepResultStepLint:
|
||||
return s.Lint
|
||||
case seat.StepResultStepUnit:
|
||||
return s.Unit
|
||||
case seat.StepResultStepIntegration:
|
||||
return s.Integration
|
||||
case seat.StepResultStepComposition:
|
||||
return s.Composition
|
||||
case seat.StepResultStepReview:
|
||||
return s.Review
|
||||
case seat.StepResultStepApproval:
|
||||
return s.Approval
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildStep is the acting rule's step a build seat names for the languages it builds (design 47 G §323).
|
||||
const buildStep = "build"
|
||||
|
||||
// BuildSeat is the build seat that runs lint and the test steps of an artifact of language, read from the catalog half
|
||||
// of the pipeline's revision view (design 47 G §323): the seat whose acting rule `build` has `on` naming that language,
|
||||
// as an entry of the half that claims it carries it in `acts`. Two seats naming one language are an error naming both.
|
||||
func BuildSeat(entries map[string]catalog.CatalogEntry, language string) (string, error) {
|
||||
var seats []string
|
||||
for _, e := range entries {
|
||||
for _, r := range e.Acts {
|
||||
if r.Step == buildStep && r.On == language && r.Seat != "" && !contains(seats, r.Seat) {
|
||||
seats = append(seats, r.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(seats)
|
||||
switch len(seats) {
|
||||
case 1:
|
||||
return seats[0], nil
|
||||
case 0:
|
||||
return "", fmt.Errorf("no build seat's acting rule %s names the language %q in the catalog half", buildStep, language)
|
||||
}
|
||||
return "", fmt.Errorf("the build seats %v each name the language %q in their acting rule %s", seats, language, buildStep)
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BranchView is the runner's view of one watched branch's catalog half (design 47 G §195, §200, §479): a first view, then
|
||||
// that branch's catalog-revision events. meshcatalogclient.Catalog is one.
|
||||
type BranchView interface {
|
||||
Hear(ctx context.Context, data []byte) error
|
||||
View() (Half, bool)
|
||||
}
|
||||
|
||||
// Half is the catalog half a branch view holds, as the runner reads it: its revision and its entries by module.
|
||||
type Half struct {
|
||||
Revision int64
|
||||
Source int64
|
||||
Entries map[string]catalog.CatalogEntry
|
||||
}
|
||||
|
||||
// Views is the runner's views of the catalog half, one per watched branch the registrations name (design 47 G §21,
|
||||
// §195): a branch first named by a registration gets its view made and started by Open, and every catalog-revision
|
||||
// heard goes to every branch's view, each taking its own branch's alone.
|
||||
type Views struct {
|
||||
// Open makes and starts the view of a branch; its error is said in the journal and the branch asked again at the
|
||||
// next registration heard.
|
||||
Open func(branch string) (BranchView, error)
|
||||
Logf func(string, ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
branches map[string]BranchView
|
||||
}
|
||||
|
||||
// Registered takes the watched branches of a module's registration (design 47 G §21): each branch the views do not hold
|
||||
// yet is opened.
|
||||
func (v *Views) Registered(reg catalog.Registration) {
|
||||
for _, b := range reg.Branches {
|
||||
v.ensure(b)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Views) ensure(branch string) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if v.branches == nil {
|
||||
v.branches = map[string]BranchView{}
|
||||
}
|
||||
if _, ok := v.branches[branch]; ok || branch == "" {
|
||||
return
|
||||
}
|
||||
bv, err := v.Open(branch)
|
||||
if err != nil {
|
||||
if v.Logf != nil {
|
||||
v.Logf("the catalog half of the watched branch %s is not followed (asked again at the next registration heard): %v", branch, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
v.branches[branch] = bv
|
||||
}
|
||||
|
||||
// Branches are the watched branches the views hold, sorted.
|
||||
func (v *Views) Branches() []string {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
out := make([]string, 0, len(v.branches))
|
||||
for b := range v.branches {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// View is the catalog half the view of branch holds; false while it has none, when the runner acts on nothing new of
|
||||
// that branch (design 47 G §200).
|
||||
func (v *Views) View(branch string) (Half, bool) {
|
||||
v.mu.Lock()
|
||||
bv := v.branches[branch]
|
||||
v.mu.Unlock()
|
||||
if bv == nil {
|
||||
return Half{}, false
|
||||
}
|
||||
return bv.View()
|
||||
}
|
||||
|
||||
// HearRevision hands one catalog-revision event to every branch's view.
|
||||
func (v *Views) HearRevision(ctx context.Context, data []byte) error {
|
||||
v.mu.Lock()
|
||||
all := make([]BranchView, 0, len(v.branches))
|
||||
for _, bv := range v.branches {
|
||||
all = append(all, bv)
|
||||
}
|
||||
v.mu.Unlock()
|
||||
var first error
|
||||
for _, bv := range all {
|
||||
if err := bv.Hear(ctx, data); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
)
|
||||
|
||||
// The policy, the build seat by language and the views per watched branch (design 47 G §21, §23, §323, §195).
|
||||
func TestPolicyAndBuildSeat(t *testing.T) {
|
||||
reg := catalog.Registration{Module: "m", Branches: []string{"main"}, Policy: catalog.Policy{
|
||||
Steps: catalog.StepStatuses{Lint: "required", Unit: "required", Integration: "optional", Approval: "required"},
|
||||
Artifacts: map[string]catalog.StepStatuses{"tools": {Unit: "bypass"}},
|
||||
}}
|
||||
for _, c := range []struct{ artifact, step, want string }{
|
||||
{"program", "unit", "required"},
|
||||
{"tools", "unit", "bypass"},
|
||||
{"tools", "integration", "optional"},
|
||||
{"tools", "approval", "required"},
|
||||
{"program", "composition", ""},
|
||||
} {
|
||||
if got := Status(reg, c.artifact, c.step); got != c.want {
|
||||
t.Errorf("Status(%s, %s) = %q, want %q", c.artifact, c.step, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
entries := map[string]catalog.CatalogEntry{
|
||||
"mesh-build-go": {Module: "mesh-build-go", Acts: []catalog.ActingRule{{Seat: "node-build-go", Step: "build", On: "go"}}},
|
||||
"mesh-build-node": {Module: "mesh-build-node", Acts: []catalog.ActingRule{{Seat: "node-build-node", Step: "build", On: "typescript"}, {Seat: "node-build-node", Step: "check", On: "go"}}},
|
||||
"app": {Module: "app", Artifacts: []catalog.Artifact{{Ref: "app:program", Language: "go"}}},
|
||||
}
|
||||
for _, c := range []struct{ language, want, err string }{
|
||||
{"go", "node-build-go", ""},
|
||||
{"typescript", "node-build-node", ""},
|
||||
{"rust", "", `"rust"`},
|
||||
} {
|
||||
got, err := BuildSeat(entries, c.language)
|
||||
if got != c.want || (c.err == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.err)) {
|
||||
t.Errorf("BuildSeat(%s) = %q, %v; want %q, an error naming %s", c.language, got, err, c.want, c.err)
|
||||
}
|
||||
}
|
||||
entries["other"] = catalog.CatalogEntry{Acts: []catalog.ActingRule{{Seat: "node-build-other", Step: "build", On: "go"}}}
|
||||
if _, err := BuildSeat(entries, "go"); err == nil || !strings.Contains(err.Error(), "node-build-other") {
|
||||
t.Errorf("two seats naming go: %v, want an error naming both", err)
|
||||
}
|
||||
|
||||
heard := map[string]int{}
|
||||
opened := 0
|
||||
v := &Views{Open: func(b string) (BranchView, error) {
|
||||
opened++
|
||||
if b == "broken" {
|
||||
return nil, errors.New("refused")
|
||||
}
|
||||
return fakeView{branch: b, heard: heard}, nil
|
||||
}}
|
||||
v.Registered(reg)
|
||||
v.Registered(catalog.Registration{Module: "n", Branches: []string{"main", "feature/x", "broken"}})
|
||||
if got := v.Branches(); strings.Join(got, ",") != "feature/x,main" || opened != 3 {
|
||||
t.Fatalf("branches %v opened %d, want feature/x and main, three opens", got, opened)
|
||||
}
|
||||
v.Registered(catalog.Registration{Module: "n", Branches: []string{"broken"}})
|
||||
if opened != 4 {
|
||||
t.Errorf("a branch whose view was refused is opened again at the next registration: %d opens", opened)
|
||||
}
|
||||
if err := v.HearRevision(context.Background(), []byte(`{}`)); err != nil || heard["main"] != 1 || heard["feature/x"] != 1 {
|
||||
t.Errorf("a revision heard reaches every branch's view: %v %v", err, heard)
|
||||
}
|
||||
if h, ok := v.View("main"); !ok || h.Revision != 7 {
|
||||
t.Errorf("View(main) = %v %v", h, ok)
|
||||
}
|
||||
if _, ok := v.View("gone"); ok {
|
||||
t.Error("a branch not watched has no view")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeView struct {
|
||||
branch string
|
||||
heard map[string]int
|
||||
}
|
||||
|
||||
func (f fakeView) Hear(context.Context, []byte) error { f.heard[f.branch]++; return nil }
|
||||
func (f fakeView) View() (Half, bool) { return Half{Revision: 7}, true }
|
||||
@@ -0,0 +1,136 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
runnerclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshpipelinerunnerclient"
|
||||
builds "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// ErrExists is a Create of a pipeline key that is written already: the second start, which writes nothing (§54).
|
||||
var ErrExists = errors.New("the pipeline's key is written already")
|
||||
|
||||
// Created is the runner's state pipelines as a start writes it: a key written only when it is absent (compare-and-set on
|
||||
// its absence), ErrExists when it is not.
|
||||
type Created interface {
|
||||
Create(key string, value []byte) error
|
||||
}
|
||||
|
||||
// Begin is what a start names: the pipeline's key (watched branch, source revision, module; design 47 G §27, §54) and
|
||||
// what the catalog half of that branch's view says of the module.
|
||||
type Begin struct {
|
||||
Branch string
|
||||
Source int64
|
||||
Module string
|
||||
Commit string
|
||||
Fp string
|
||||
Group string
|
||||
Repository string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
// Key is a pipeline's key in the state pipelines, `<branch>.<source revision>.<module>`, the branch escaped as a key's
|
||||
// name is (§27).
|
||||
func Key(branch string, source int64, module string) string {
|
||||
return seat.PipelinesByBranchRevisionModuleKey(keys.EscapeName(branch), strconv.FormatInt(source, 10), module)
|
||||
}
|
||||
|
||||
// Starter starts pipelines (design 47 G §54, §103): it writes a pipeline's key by compare-and-set on its absence, so one
|
||||
// source revision and module has one pipeline whichever event starts it first, and says pipeline-started for the one it
|
||||
// wrote.
|
||||
type Starter struct {
|
||||
Pipelines Created
|
||||
// Say says one of the seat's events on its subject.
|
||||
Say func(subject string, body any) error
|
||||
Now func() time.Time
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Start writes b's pipeline, running, and says pipeline-started; false, nil when its key was written already (the second
|
||||
// start writes nothing, §54).
|
||||
func (s *Starter) Start(b Begin) (bool, error) {
|
||||
if b.Branch == "" || b.Module == "" || b.Source <= 0 {
|
||||
return false, fmt.Errorf("a pipeline names its watched branch, source revision and module: %+v", b)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
key := Key(b.Branch, b.Source, b.Module)
|
||||
p := seat.Pipeline{V: 1, Pipeline: key, Branch: b.Branch, Source: b.Source, Module: b.Module, Commit: b.Commit, Fp: b.Fp,
|
||||
Group: b.Group, Repository: b.Repository, Revision: b.Revision, Outcome: seat.PipelineOutcomeRunning, Started: &now,
|
||||
Steps: []seat.StepResult{}}
|
||||
raw, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.Pipelines.Create(key, raw); errors.Is(err, ErrExists) {
|
||||
s.logf("%s is started already: this start writes nothing", key)
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, fmt.Errorf("the pipeline %s is not written: %w", key, err)
|
||||
}
|
||||
ev := seat.PipelineStarted{V: 1, At: &now, Pipeline: key, Branch: b.Branch, Source: b.Source, Module: b.Module,
|
||||
Commit: b.Commit, Fp: b.Fp, Group: b.Group, Revision: b.Revision}
|
||||
commitOrGroup := b.Commit
|
||||
if b.Group != "" {
|
||||
commitOrGroup = b.Group
|
||||
}
|
||||
if err := s.Say(runnerclient.Subject(seat.EventPipelineStarted, b.Module, b.Branch, commitOrGroup), ev); err != nil {
|
||||
s.logf("pipeline-started of %s is not said: %v", key, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Starter) logf(format string, args ...any) {
|
||||
if s.Logf != nil {
|
||||
s.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// outdatedKind is the edge `outdated` names a dependent behind by for a rebuild on a publish: B published before A builds,
|
||||
// a package A imports (§53).
|
||||
const outdatedKind = 1
|
||||
|
||||
// Published starts, for a package-published, a pipeline for each dependent the catalog's `outdated` names behind for that
|
||||
// package on that branch by kind 1, keyed by the publication's watched branch and source revision and the dependent
|
||||
// (design 47 G §53, §334). outdated is the kept call of the verb; view the catalog half of the publication's branch. A
|
||||
// dependent the half does not hold is said and started with what the event names.
|
||||
func (s *Starter) Published(ev builds.PackagePublished, outdated func(catalog.OutdatedInput) (catalog.OutdatedAnswer, error), view func(branch string) (Half, bool)) (int, error) {
|
||||
if ev.Artifact == "" || ev.Branch == "" || ev.Source <= 0 {
|
||||
return 0, fmt.Errorf("a package-published names its artifact, watched branch and source revision: %+v", ev)
|
||||
}
|
||||
a, err := outdated(catalog.OutdatedInput{Branch: ev.Branch, Dependency: ev.Artifact})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("outdated of %s on %s: %w", ev.Artifact, ev.Branch, err)
|
||||
}
|
||||
half, _ := view(ev.Branch)
|
||||
started := 0
|
||||
var first error
|
||||
for _, o := range a.Behind {
|
||||
if o.Kind != outdatedKind || o.Module == "" {
|
||||
continue
|
||||
}
|
||||
b := Begin{Branch: ev.Branch, Source: ev.Source, Module: o.Module, Revision: half.Revision}
|
||||
if e, ok := half.Entries[o.Module]; ok {
|
||||
b.Commit, b.Fp, b.Repository = e.Commit, e.Fp, e.Repository
|
||||
} else {
|
||||
s.logf("the catalog half of %s holds no entry of %s, behind %s: its pipeline starts without its commit", ev.Branch, o.Module, ev.Artifact)
|
||||
}
|
||||
ok, err := s.Start(b)
|
||||
if err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
if ok {
|
||||
started++
|
||||
}
|
||||
}
|
||||
return started, first
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
builds "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner"
|
||||
)
|
||||
|
||||
type created map[string][]byte
|
||||
|
||||
func (c created) Create(key string, value []byte) error {
|
||||
if _, ok := c[key]; ok {
|
||||
return ErrExists
|
||||
}
|
||||
c[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// The starts (design 47 G §54, §103, §334): a pipeline's key written by compare-and-set on its absence, so a second start
|
||||
// of the same source revision and module writes nothing; a publish starts one pipeline per dependent outdated names
|
||||
// behind by kind 1, keyed by the publishing branch and revision.
|
||||
func TestStarts(t *testing.T) {
|
||||
store := created{}
|
||||
var said []string
|
||||
s := &Starter{Pipelines: store, Say: func(subject string, body any) error {
|
||||
ev := body.(seat.PipelineStarted)
|
||||
said = append(said, subject+" "+ev.Pipeline)
|
||||
return nil
|
||||
}}
|
||||
view := func(branch string) (Half, bool) {
|
||||
return Half{Revision: 9, Entries: map[string]catalog.CatalogEntry{
|
||||
"app": {Module: "app", Commit: "c1", Fp: "src2:aa", Repository: "o/app"}}}, true
|
||||
}
|
||||
var asked catalog.OutdatedInput
|
||||
outdated := func(in catalog.OutdatedInput) (catalog.OutdatedAnswer, error) {
|
||||
asked = in
|
||||
return catalog.OutdatedAnswer{Behind: []catalog.Outdated{
|
||||
{Module: "app", Kind: 1}, {Module: "tool", Kind: 1}, {Module: "ordered", Kind: 4}}}, nil
|
||||
}
|
||||
n, err := s.Published(builds.PackagePublished{Artifact: "mesh-sdk:go", Branch: "main", Source: 41, Module: "mesh-sdk", Version: "0.1.16"}, outdated, view)
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("a publish started %d (%v), want the two dependents behind by kind 1", n, err)
|
||||
}
|
||||
if asked.Dependency != "mesh-sdk:go" || asked.Branch != "main" {
|
||||
t.Errorf("outdated asked %+v, want the package on its branch", asked)
|
||||
}
|
||||
var p seat.Pipeline
|
||||
if err := json.Unmarshal(store["main.41.app"], &p); err != nil || p.Commit != "c1" || p.Fp != "src2:aa" || p.Revision != 9 ||
|
||||
p.Outcome != seat.PipelineOutcomeRunning || p.Source != 41 {
|
||||
t.Errorf("the pipeline main.41.app = %+v (%v)", p, err)
|
||||
}
|
||||
if _, ok := store["main.41.ordered"]; ok {
|
||||
t.Error("an ordering edge starts no pipeline")
|
||||
}
|
||||
// The take-in of the same source revision and module is the same pipeline: the second start writes nothing.
|
||||
before := string(store["main.41.app"])
|
||||
ok, err := s.Start(Begin{Branch: "main", Source: 41, Module: "app", Commit: "c2"})
|
||||
if ok || err != nil || string(store["main.41.app"]) != before || len(said) != 2 {
|
||||
t.Errorf("a second start wrote %v (%v); said %v", ok, err, said)
|
||||
}
|
||||
if !strings.HasPrefix(said[0], "mesh.seat.mesh-pipeline-runner.event.pipeline-started.app.main.c1 main.41.app") {
|
||||
t.Errorf("pipeline-started said on %q", said[0])
|
||||
}
|
||||
// A branch holding a dot is escaped in the key, so the key reads back; a start naming no source revision is refused.
|
||||
if _, err := s.Start(Begin{Branch: "release.1", Source: 3, Module: "app"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, r, m, ok := seat.ParsePipelinesByBranchRevisionModuleKey(Key("release.1", 3, "app")); !ok || r != "3" || m != "app" || b == "release" {
|
||||
t.Errorf("the key of release.1 is %q", Key("release.1", 3, "app"))
|
||||
}
|
||||
if _, err := s.Start(Begin{Branch: "main", Module: "app"}); err == nil {
|
||||
t.Error("a start with no source revision is refused")
|
||||
}
|
||||
s.Pipelines = failing{}
|
||||
if _, err := s.Start(Begin{Branch: "main", Source: 5, Module: "app"}); err == nil {
|
||||
t.Error("a write the bus refuses is an error")
|
||||
}
|
||||
}
|
||||
|
||||
type failing struct{}
|
||||
|
||||
func (failing) Create(string, []byte) error { return errors.New("bus away") }
|
||||
@@ -49,8 +49,13 @@ func TestManifest(t *testing.T) {
|
||||
// §27, §85, §330, §355, §77: the old state sections.
|
||||
{"old state sections", equalSets(states, []string{seat.StatePipelines, seat.StateKept, "aged", "findings", "first-views"})},
|
||||
// §27, §85: the step log on an old stream, its outputs in an old objects section.
|
||||
{"old streams section", equalSets(streams, []string{seat.StateStepLog})},
|
||||
{"step-log 128 MiB", len(m.Streams) == 1 && m.Streams[0].MaxMiB == 128},
|
||||
// RN24 (§383, §387): beside it, the finding stream its findings are said on.
|
||||
{"old streams section", equalSets(streams, []string{seat.StateStepLog, "mesh-delivery-pipeline-findings"})},
|
||||
{"step-log 128 MiB", len(m.Streams) == 2 && m.Streams[0].MaxMiB == 128},
|
||||
{"finding stream 7 days 16 MiB", len(m.Streams) == 2 && m.Streams[1].MaxAgeDays == 7 && m.Streams[1].MaxMiB == 16 &&
|
||||
equalSets(m.Streams[1].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"})},
|
||||
// RN24 (§368): the version as MAJOR.MINOR, 0.1 with none published.
|
||||
{"version 0.1", m.Version == "0.1"},
|
||||
{"old objects section", equalSets(objects, []string{seat.StateStepOutputs})},
|
||||
{"step-outputs 1 GiB", len(m.Objects) == 1 && m.Objects[0].MaxMiB == 1024},
|
||||
// §333, §54, §44, §53, §376, §24, §37, §4, §77: what the runner hears.
|
||||
@@ -58,7 +63,13 @@ func TestManifest(t *testing.T) {
|
||||
"mesh-delivery-indexer.take-in.taken", "mesh-builds.package-published", "mesh-builds.toolchain-agreed",
|
||||
"operator-channel.decided", "node-build-go.step-ended", "node-build-node.step-ended",
|
||||
"node-build-image.step-ended", "mesh-wants.step-ended", "mesh-catalog.registration-changed",
|
||||
"mesh-catalog.catalog-revision"})},
|
||||
"mesh-catalog.catalog-revision", "mesh-conditions.findings-asked"})},
|
||||
// RN2 (§21, §195, §200, §77): the registrations and the catalog half asked by their verbs, the first views read and
|
||||
// asked again by hand (a hand act).
|
||||
{"invokes", equalSets(m.Invokes, []string{"seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-hand-acts.hand-act",
|
||||
// RN3 (§53, §334): a publication's dependents behind.
|
||||
"seat:mesh-catalog.outdated"})},
|
||||
{"first-view tools", equalSets(m.Tools, []string{"pipeline_first_views", "pipeline_first_view_again"})},
|
||||
// RN1/AC5 (§95, §130): the new assigner is delivered before the runner.
|
||||
{"program needs the assigner's program", slices.Equal(program.Needs, []string{"mesh-delivery-assigner:program"})},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "mesh-delivery-pipeline",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "pipeline",
|
||||
"claims": [
|
||||
{
|
||||
@@ -22,6 +22,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"pipeline_first_views",
|
||||
"pipeline_first_view_again"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-indexer.take-in.taken",
|
||||
"mesh-builds.package-published",
|
||||
@@ -32,7 +36,14 @@
|
||||
"node-build-image.step-ended",
|
||||
"mesh-wants.step-ended",
|
||||
"mesh-catalog.registration-changed",
|
||||
"mesh-catalog.catalog-revision"
|
||||
"mesh-catalog.catalog-revision",
|
||||
"mesh-conditions.findings-asked"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-catalog.registrations",
|
||||
"seat:mesh-catalog.revision",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-catalog.outdated"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
@@ -63,6 +74,16 @@
|
||||
"step-log.**"
|
||||
],
|
||||
"max-mib": 128
|
||||
},
|
||||
{
|
||||
"name": "mesh-delivery-pipeline-findings",
|
||||
"events": [
|
||||
"finding-written.*",
|
||||
"finding-cleared.*",
|
||||
"findings-stated.*"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
],
|
||||
"objects": [
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mesh-delivery-view/internal/view"
|
||||
)
|
||||
|
||||
// catalogWithin is how long a call waits for the seat mesh-catalog's first view before it says there is none.
|
||||
var catalogWithin = 30 * time.Second
|
||||
|
||||
// catalogRead is the modules the view's tools answer, read from the catalog half through the seat mesh-catalog's verb
|
||||
// `revision` (design 47, Two graphs): the trunk's newest revision, asked once per call and in pages, each entry in the
|
||||
// form of the value the tools always answered. With no view the read says so; it never answers no modules.
|
||||
func catalogRead(ask func(key string, body any) (json.RawMessage, error)) view.StateRead {
|
||||
var (
|
||||
once sync.Once
|
||||
values map[string]json.RawMessage
|
||||
rev uint64
|
||||
failed error
|
||||
)
|
||||
load := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), catalogWithin)
|
||||
defer cancel()
|
||||
c, err := meshcatalogclient.NewCatalog(ask, "main")
|
||||
if err != nil {
|
||||
failed = err
|
||||
return
|
||||
}
|
||||
if err := c.Start(ctx); err != nil {
|
||||
failed = fmt.Errorf("the catalog half has no first view yet (%s): %w", meshcatalogclient.RevisionVerb, err)
|
||||
return
|
||||
}
|
||||
half, ok := c.View()
|
||||
if !ok {
|
||||
failed = fmt.Errorf("the catalog half has no first view yet (%s)", meshcatalogclient.RevisionVerb)
|
||||
return
|
||||
}
|
||||
rev, values = uint64(half.Revision.Revision), map[string]json.RawMessage{}
|
||||
for module, e := range half.Entries {
|
||||
raw, _ := json.Marshal(e)
|
||||
v, err := view.ModuleValue(raw)
|
||||
if err != nil {
|
||||
failed = fmt.Errorf("the catalog entry of %s: %w", module, err)
|
||||
return
|
||||
}
|
||||
values[module] = v
|
||||
}
|
||||
}
|
||||
read := func() (map[string]json.RawMessage, uint64, error) {
|
||||
once.Do(load)
|
||||
return values, rev, failed
|
||||
}
|
||||
return view.StateRead{
|
||||
Keys: func() ([]string, error) {
|
||||
v, _, err := read()
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys, err
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, error) {
|
||||
v, _, err := read()
|
||||
return v[key], err
|
||||
},
|
||||
GetEntry: func(key string) (json.RawMessage, uint64, error) {
|
||||
v, r, err := read()
|
||||
return v[key], r, err
|
||||
},
|
||||
All: func() (map[string]json.RawMessage, error) {
|
||||
v, _, err := read()
|
||||
return v, err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -396,7 +396,7 @@ func ownersRead(ask func(key string, body any) (json.RawMessage, error)) view.Re
|
||||
// Each owner's read tool as the view's invokes names it.
|
||||
indexer := func(tool string) view.StateRead { return view.OwnerRead(ask, view.IndexerModule+"."+tool) }
|
||||
assigner := func(tool string) view.StateRead { return view.OwnerRead(ask, view.AssignerModule+"."+tool) }
|
||||
return view.Reader{State: indexer("indexer_read_modules"), CandidatesState: indexer("indexer_read_candidates"),
|
||||
return view.Reader{State: catalogRead(ask), CandidatesState: indexer("indexer_read_candidates"),
|
||||
WantsState: assigner("assigner_read_wants"), DifferencesState: assigner("assigner_read_differences"),
|
||||
HeardState: indexer("indexer_read_heard"),
|
||||
PredefinedState: indexer("indexer_read_predefined"),
|
||||
|
||||
@@ -3,12 +3,14 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"mesh-delivery-view/internal/view"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
@@ -51,7 +53,6 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
states := map[string]source{
|
||||
view.IndexerModule + ".modules": {"a": withFP2, "b": withNone},
|
||||
view.IndexerModule + ".candidates": {"feat/x": cand, "fix": `{"v":1,"group":"fix","base":3,"heads":"h","pulls":[{"modules":["netcheck"]}]}`},
|
||||
view.AssignerModule + ".wants": {"want.ace.zsh": `{"fp":"` + fp2A + `"}`, "want.novox.zsh": `{"fp":"` + fp2B + `"}`,
|
||||
"held." + keyB: `{"node":"ace","words":"failed"}`},
|
||||
@@ -67,14 +68,12 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
|
||||
const heard1 = `{"v":1,"subject":"s","sequence":41,"repository":"novox/mesh-catalog","commit":"9cdac09a"}`
|
||||
states[view.IndexerModule+".heard"] = source{"forge": heard1}
|
||||
histories := map[string]map[string][]views.Revision{
|
||||
view.IndexerModule + ".modules": {"a": {{Revision: 7, At: at, Op: "put", Value: json.RawMessage(withNone)},
|
||||
{Revision: 9, At: at.Add(time.Minute), Op: "put", Value: json.RawMessage(withFP2)}}},
|
||||
view.IndexerModule + ".heard": {"forge": {{Revision: 3, At: at, Op: "put", Value: json.RawMessage(heard1)}}},
|
||||
view.AssignerModule + ".wants": {"want.ace.zsh": {{Revision: 12, At: at, Op: "put", Value: json.RawMessage(`{"fp":"` + fp2A + `"}`)}}},
|
||||
}
|
||||
// The direct reading, as the tools read the states before (the reads the manifest no longer has).
|
||||
st := func(name string) view.StateRead { return fake(states[name]).reader().State }
|
||||
direct := view.Reader{State: st(view.IndexerModule + ".modules"), CandidatesState: st(view.IndexerModule + ".candidates"),
|
||||
direct := view.Reader{CandidatesState: st(view.IndexerModule + ".candidates"),
|
||||
WantsState: st(view.AssignerModule + ".wants"), DifferencesState: st(view.AssignerModule + ".differences"),
|
||||
PredefinedState: st(view.IndexerModule + "." + view.IndexerPredefinedState),
|
||||
ConfigCandidatesState: st(view.IndexerModule + "." + view.IndexerConfigCandidatesState),
|
||||
@@ -89,6 +88,10 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
|
||||
}
|
||||
asked := map[string]int{}
|
||||
ask := func(key string, body any) (json.RawMessage, error) {
|
||||
if key == meshcatalogclient.RevisionVerb {
|
||||
asked[key]++
|
||||
return json.Marshal(catalogAnswer)
|
||||
}
|
||||
name, ok := tools[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no tool %s", key)
|
||||
@@ -118,10 +121,6 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
|
||||
tool string
|
||||
args map[string]any
|
||||
}{
|
||||
{view.ModuleReadTool, map[string]any{"module": "a"}},
|
||||
{view.ModuleReadTool, map[string]any{"module": "gone"}},
|
||||
{view.ModulesReadTool, map[string]any{}},
|
||||
{view.ModulesReadTool, map[string]any{"none2": true}},
|
||||
{view.CandidateReadTool, map[string]any{"group": "feat/x"}},
|
||||
{view.CandidatesReadTool, map[string]any{}},
|
||||
{view.CandidatesReadTool, map[string]any{"module": "zsh"}},
|
||||
@@ -143,12 +142,24 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
|
||||
t.Errorf("%s %v through the owner's read tool = %s, %v; reading the state it was %s, %v", row.tool, row.args, gj, gerr, wj, werr)
|
||||
}
|
||||
}
|
||||
// A key's history is asked of the owner's read tool and answered from its kept values (A2j finding 1, J4).
|
||||
out, err := run(t, owned, view.ModuleHistoryTool, map[string]any{"module": "a"})
|
||||
if h, _ := out.(view.ModuleHistory); err != nil || len(h.Values) != 2 || h.Values[0].Revision != 9 || h.Values[1].FP2 != "" || !h.Values[1].At.Equal(at) {
|
||||
t.Errorf("indexer_module_history a through the indexer's read tool = %+v, %v", out, err)
|
||||
// The modules are the catalog half's, asked of the seat mesh-catalog's verb revision (W16.1): a module is its entry in
|
||||
// the form of the value the tool always answered, the modules without a fingerprint say why, and the module's history
|
||||
// is not a thing the catalog half keeps.
|
||||
a, err := run(t, owned, view.ModuleReadTool, map[string]any{"module": "a"})
|
||||
if raw, _ := json.Marshal(a); err != nil || !strings.Contains(string(raw), `"fp2":"`+fp2A+`"`) || !strings.Contains(string(raw), `"commit":"c1"`) || !strings.Contains(string(raw), `"src":7`) {
|
||||
t.Errorf("indexer_module a = %s, %v", raw, err)
|
||||
}
|
||||
out, err = run(t, owned, view.HeardTool, map[string]any{})
|
||||
if _, err := run(t, owned, view.ModuleReadTool, map[string]any{"module": "gone"}); err == nil {
|
||||
t.Errorf("indexer_module gone answered a module the catalog half does not hold")
|
||||
}
|
||||
all, err := run(t, owned, view.ModulesReadTool, map[string]any{"none2": true})
|
||||
if raw, _ := json.Marshal(all); err != nil || !strings.Contains(string(raw), `"b"`) || strings.Contains(string(raw), `"a"`) || !strings.Contains(string(raw), "builder's fingerprint not yet known") {
|
||||
t.Errorf("indexer_modules none2 = %s, %v", raw, err)
|
||||
}
|
||||
if _, err := run(t, owned, view.ModuleHistoryTool, map[string]any{"module": "a"}); err == nil {
|
||||
t.Errorf("indexer_module_history answered a history the catalog half does not keep")
|
||||
}
|
||||
out, err := run(t, owned, view.HeardTool, map[string]any{})
|
||||
if rows, _ := out.(view.HeardAnswer); err != nil || len(rows.Heard) != 1 || rows.Heard[0].Revision != 3 || string(rows.Heard[0].Heard) != heard1 {
|
||||
t.Errorf("indexer_heard through the indexer's read tool = %+v, %v", out, err)
|
||||
}
|
||||
@@ -156,10 +167,29 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
|
||||
if revs, err := owned.WantsState.History("want.ace.zsh"); err != nil || len(revs) != 1 || !revs[0].At.Equal(at) || revs[0].Revision != 12 {
|
||||
t.Errorf("the want's history through the assigner's read tool = %+v, %v", revs, err)
|
||||
}
|
||||
// With no view of the catalog half the modules are not read: the tools say so, never that no module is held.
|
||||
catalogWithin = 20 * time.Millisecond
|
||||
none := ownersRead(func(key string, body any) (json.RawMessage, error) { return nil, fmt.Errorf("the holder is away") })
|
||||
if out, err := run(t, none, view.ModulesReadTool, map[string]any{}); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
t.Errorf("indexer_modules with no view = %v, %v, want an error naming it", out, err)
|
||||
}
|
||||
// Each owner state the tools read was asked of its owner's read tool.
|
||||
for tool, name := range tools {
|
||||
if name != view.IndexerModule+"."+graph.State && asked[tool] == 0 {
|
||||
t.Errorf("%s was never asked", tool)
|
||||
}
|
||||
}
|
||||
if asked[meshcatalogclient.RevisionVerb] == 0 {
|
||||
t.Errorf("%s was never asked", meshcatalogclient.RevisionVerb)
|
||||
}
|
||||
}
|
||||
|
||||
// catalogAnswer is the seat mesh-catalog's newest trunk revision: a with a fingerprint and the source revision it moved at,
|
||||
// b with none and the reason.
|
||||
var catalogAnswer = meshcatalog.RevisionAnswer{
|
||||
Revision: meshcatalog.CatalogRevision{Revision: 4, Kind: meshcatalogclient.KindTrunk, Source: 7},
|
||||
Entries: []meshcatalog.CatalogEntry{
|
||||
{Module: "a", Repository: "novox/r", Commit: "c1", Path: ".", Manifest: map[string]any{"module": "a"}, Fp: fp2A, Inputs: &meshcatalog.EntryInputs{Src: 7}},
|
||||
{Module: "b", Repository: "novox/r", Commit: "c2", Path: "modules/b", Manifest: map[string]any{"module": "b"}, None: "builder's fingerprint not yet known"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +48,67 @@ func serveOwners(t *testing.T, ctx context.Context, js jetstream.JetStream, nc *
|
||||
}
|
||||
}
|
||||
|
||||
// catalogHolder is the seat mesh-catalog's holder as the view's process needs it (W16.1): its verb revision answering the
|
||||
// trunk's newest revision whole, and its event catalog-revision said on the shared events stream at each change, which the
|
||||
// view's durable consumer hears.
|
||||
type catalogHolder struct {
|
||||
t testing.TB
|
||||
js jetstream.JetStream
|
||||
mu sync.Mutex
|
||||
// rev is the revision's number; entries are its entries by module.
|
||||
rev int64
|
||||
entries map[string]meshcatalog.CatalogEntry
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func serveCatalog(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn) *catalogHolder {
|
||||
t.Helper()
|
||||
h := &catalogHolder{t: t, js: js, ctx: ctx, rev: 1, entries: map[string]meshcatalog.CatalogEntry{}}
|
||||
events, err := js.Stream(ctx, process.EventsStream)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := events.CachedInfo().Config
|
||||
if !slices.Contains(info.Subjects, "mesh.seat.*.event.>") {
|
||||
info.Subjects = append(info.Subjects, "mesh.seat.*.event.>")
|
||||
if _, err := js.UpdateStream(ctx, info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
answer := func(m *nats.Msg) {
|
||||
h.mu.Lock()
|
||||
a := meshcatalog.RevisionAnswer{Revision: meshcatalog.CatalogRevision{Revision: h.rev, Kind: meshcatalogclient.KindTrunk}}
|
||||
for _, e := range h.entries {
|
||||
a.Entries = append(a.Entries, e)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
raw, _ := json.Marshal(map[string]any{"result": a, "node": "n0"})
|
||||
_ = m.Respond(raw)
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.mesh-catalog.call.revision.>", "mesh.seat.mesh-catalog.call.revision"} {
|
||||
sub, err := nc.Subscribe(subject, answer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// put is the holder's next trunk revision with the entry of one module changed, said as catalog-revision.
|
||||
func (h *catalogHolder) put(e meshcatalog.CatalogEntry) {
|
||||
h.t.Helper()
|
||||
h.mu.Lock()
|
||||
h.rev++
|
||||
h.entries[e.Module] = e
|
||||
rev := h.rev
|
||||
h.mu.Unlock()
|
||||
body, _ := json.Marshal(meshcatalog.CatalogRevisionEvent{Kind: meshcatalogclient.KindTrunk, Branch: "main", Revision: rev, Changed: []string{e.Module}})
|
||||
if _, err := h.js.Publish(h.ctx, "mesh.seat.mesh-catalog.event."+meshcatalog.EventCatalogRevision, body); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessWholeSeesTheBuckets starts the program the way the node does (its entry, run, with its environment and a
|
||||
// broker file) against an embedded bus holding the three buckets, and asks its socket.
|
||||
func TestProcessWholeSeesTheBuckets(t *testing.T) { // 1134/AC8
|
||||
@@ -126,6 +188,7 @@ func TestProcessWholeSeesTheBuckets(t *testing.T) { // 1134/AC8
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
@@ -226,6 +289,7 @@ func TestProcessWholeShowsWhatTheWatchmanSays(t *testing.T) { // 1168/AC5
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
@@ -293,6 +357,7 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
catalog := serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
@@ -306,26 +371,16 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mods, err := js.KeyValue(ctx, view.ModulesBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const head = "0123456789abcdef0123456789abcdef01234567"
|
||||
hash, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: head}})
|
||||
cand, err := json.Marshal(meshcatalog.CandidatesByGroupValue{Group: "feat/x", Heads: hash, Pulls: []meshcatalog.CandidatesByGroupValuePullsItem{{Repository: "novox/mesh-delivery", Number: 12, Head: head, Modules: []string{"a"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mod, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{None: "no tree", Repository: "novox/mesh-delivery", Commit: head})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cands.Put(ctx, "feat/x", cand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := mods.Put(ctx, "a", mod); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog.put(meshcatalog.CatalogEntry{Module: "a", None: "no tree", Repository: "novox/mesh-delivery", Commit: head, Manifest: map[string]any{"module": "a"}})
|
||||
var last view.Snapshot
|
||||
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||
if s, err := view.Ask(socket); err == nil {
|
||||
@@ -380,6 +435,7 @@ func TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes(t *testing.T) { //
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
@@ -471,6 +527,7 @@ func TestProcessWholeListsEveryDifference(t *testing.T) { // 1297/B8 AC
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
@@ -569,6 +626,7 @@ func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 129
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
@@ -721,6 +779,7 @@ func TestProcessWholeShowsTheBacklogOfBuildAsks(t *testing.T) { // 1887/A8a AC
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
@@ -768,7 +827,7 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kvs := map[string]jetstream.KeyValue{}
|
||||
for _, name := range []string{view.SourceBucket, view.ModulesBucket, view.AssignerWantsBucket, view.HaveBucket} {
|
||||
for _, name := range []string{view.SourceBucket, view.AssignerWantsBucket, view.HaveBucket} {
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -789,20 +848,16 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
catalog := serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
fp := "src2:" + strings.Repeat("2", 64)
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: strings.Repeat("a", 64), Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
want, _ := meshwantsclient.EncodeWant(meshwantsclient.Want{Fp: fp, Composed: "sha256:" + strings.Repeat("c", 64)})
|
||||
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed})
|
||||
good, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
|
||||
src, _ := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: "feat/x", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
|
||||
for bucket, kv := range map[string][]byte{view.ModulesBucket: mod} {
|
||||
if _, err := kvs[bucket].Put(ctx, "web", kv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
catalog.put(meshcatalog.CatalogEntry{Module: "web", Fp: fp, Manifest: map[string]any{"module": "a"}, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
|
||||
if _, err := kvs[view.AssignerWantsBucket].Put(ctx, "want.ace.web", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -886,6 +941,7 @@ func TestProcessWholeSaysModulesInMotion(t *testing.T) {
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
serveOwners(t, ctx, js, nc)
|
||||
serveCatalog(t, ctx, js, nc)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
time.Sleep(2 * time.Second) // the view has read what stood: nothing
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"io"
|
||||
"log"
|
||||
"mesh-delivery-view/internal/view"
|
||||
@@ -126,15 +127,21 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
}
|
||||
}
|
||||
open := buckets{js: js, reader: reader}
|
||||
for _, bucket := range []string{view.HaveBucket, view.AssignmentsBucket, view.PresenceBucket, view.StuckBucket, view.CandidatesBucket, view.ModulesBucket, view.AsksBucket, view.AnswersBucket, builds.BuilderBuildsBucket, view.AssignerWantsBucket, view.SourceBucket, view.AssignerAsksBucket, viewstate.ViewAnswersBucket, view.TakenBucket} {
|
||||
for _, bucket := range []string{view.HaveBucket, view.AssignmentsBucket, view.PresenceBucket, view.StuckBucket, view.CandidatesBucket, view.AsksBucket, view.AnswersBucket, builds.BuilderBuildsBucket, view.AssignerWantsBucket, view.SourceBucket, view.AssignerAsksBucket, viewstate.ViewAnswersBucket, view.TakenBucket} {
|
||||
go follow(ctx, open, bucket, false, model, logger)
|
||||
}
|
||||
// The modules' and the source's history give each fingerprint's first commit and each head's source revision (ADR 0556).
|
||||
// The modules' picture holds no history: from its first view on, each change it takes is the history (finding of DLV-M4).
|
||||
for _, bucket := range []string{view.ModulesBucket, view.SourceBucket} {
|
||||
go follow(ctx, open, bucket, true, model, logger)
|
||||
// The source's history gives each head's source revision (ADR 0556); the modules' history is each revision of the
|
||||
// catalog half the view takes, in its order (below).
|
||||
go follow(ctx, open, view.SourceBucket, true, model, logger)
|
||||
statusBucket := process.AwaitBucket(ctx, js, conn, firstViewsBucket, "view_first_views reads nothing and view_first_view_again is not heard", logger.Printf)
|
||||
// The modules are the catalog half's, adopted whole by the seat's client: its first view, its status, its hand asks and
|
||||
// its event catalog-revision (on the module's durable consumer) are the reader's.
|
||||
catalog, _, err := meshcatalogclient.WireCatalogProcess(reader, calls.Call, "main", catalogChanged(model, logger))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "view_first_views reads nothing and view_first_view_again is not heard", logger.Printf))
|
||||
reader.Start(ctx, js, cred, statusBucket)
|
||||
catalogHeld(ctx, catalog, model)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-webUp:
|
||||
@@ -189,7 +196,6 @@ var ownersFollowed = []struct{ owner, state, tool string }{
|
||||
{view.AssignerModule, "wants", view.AssignerModule + ".assigner_read_wants"},
|
||||
{view.AssignerModule, "asks", view.AssignerModule + ".assigner_read_asks"},
|
||||
{view.IndexerModule, "candidates", view.IndexerModule + ".indexer_read_candidates"},
|
||||
{view.IndexerModule, "modules", view.IndexerModule + ".indexer_read_modules"},
|
||||
{view.IndexerModule, "taken", view.IndexerModule + ".indexer_read_taken"},
|
||||
}
|
||||
|
||||
@@ -277,3 +283,35 @@ func emitEvent(ctx context.Context, js jetstream.JetStream, cred process.Account
|
||||
_, err = js.PublishMsg(pub, msg, jetstream.WithMsgID(id))
|
||||
return err
|
||||
}
|
||||
|
||||
// catalogChanged hands one module of a revision the view took to the model, in the form of its value; the modules'
|
||||
// history is these changes in the order of the revisions.
|
||||
func catalogChanged(model *view.Model, logger *log.Logger) func(string, json.RawMessage, bool, time.Time) {
|
||||
return func(module string, entry json.RawMessage, gone bool, at time.Time) {
|
||||
e := view.Entry{Bucket: view.ModulesBucket, Key: module, At: at, Deleted: gone}
|
||||
if !gone {
|
||||
v, err := view.ModuleValue(entry)
|
||||
if err != nil {
|
||||
logger.Printf("the catalog entry of %s is not taken: %v", module, err)
|
||||
return
|
||||
}
|
||||
e.Value = v
|
||||
}
|
||||
model.Apply(e)
|
||||
e.History = true
|
||||
model.Apply(e)
|
||||
}
|
||||
}
|
||||
|
||||
// catalogHeld makes the model's pictures of the modules initial once the catalog half's first view is held: the process
|
||||
// starts at once and waits for the seat's holder, saying so.
|
||||
func catalogHeld(ctx context.Context, cat *meshcatalogclient.Catalog, model *view.Model) {
|
||||
go func() {
|
||||
select {
|
||||
case <-cat.Follower.Ready():
|
||||
model.Initial(view.ModulesBucket)
|
||||
model.Initial(view.ReachHistory(view.ModulesBucket))
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package view
|
||||
|
||||
// The view reads the catalog half of the delivery through the seat mesh-catalog (design 47, Two graphs; ADR 0656 §6):
|
||||
// a first view of its verb `revision`, then each trunk revision its event `catalog-revision` says, never the indexer's
|
||||
// state `modules`. The model keeps the picture it always kept (one value per module, its history for the reach), so each
|
||||
// entry of a revision is handed to it in the form of that value.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
)
|
||||
|
||||
// CatalogSeat is the seat whose verb `revision` and event `catalog-revision` the view reads the modules from, as its
|
||||
// manifest's `invokes` and `consumes` name them.
|
||||
const CatalogSeat = meshcatalog.Seat
|
||||
|
||||
// ModulesBucket names the model's picture of the catalog half's modules (and its history): the model's label, no
|
||||
// bucket of anybody's.
|
||||
const ModulesBucket = CatalogSeat + "_modules"
|
||||
|
||||
// ModuleValue is the value of one module of the model, as the model decodes it, made from the catalog entry (its JSON):
|
||||
// the fingerprint is the entry's `fp` (the only form there is), the source revisions are the entry's inputs'. A manifest
|
||||
// held for unknown fields has no verdict here: the model reads the fingerprint and the commit.
|
||||
func ModuleValue(entry json.RawMessage) (json.RawMessage, error) {
|
||||
var e meshcatalog.CatalogEntry
|
||||
if err := json.Unmarshal(entry, &e); err != nil || e.Module == "" {
|
||||
return nil, fmt.Errorf("a catalog entry does not read: %v", err)
|
||||
}
|
||||
v := meshcatalog.ModulesByModuleValue{Commit: e.Commit, Fp2: e.Fp, None2: e.None, Path: e.Path, Repository: e.Repository, Packages: e.Packages}
|
||||
if len(e.Manifest) > 0 {
|
||||
// The entry holds its manifest whole; the model keeps the digest of its bytes, as the value it always kept does.
|
||||
raw, _ := json.Marshal(e.Manifest)
|
||||
v.Manifest = meshcatalogclient.ManifestDigest(raw)
|
||||
}
|
||||
if e.Verdict == meshcatalogclient.VerdictAccepted || e.Verdict == meshcatalogclient.VerdictRefused {
|
||||
v.Verdict = e.Verdict
|
||||
}
|
||||
if in := e.Inputs; in != nil {
|
||||
v.Src, v.FpSrc, v.Toolchains, v.Paths, v.Warnings = in.Src, in.FpSrc, in.Toolchains, in.Paths, in.Warnings
|
||||
if f := in.From; f != nil && v.Manifest != "" {
|
||||
v.Inputs2 = &meshcatalog.ModulesByModuleValueInputs2{Bases: f.Bases, Builder: f.Builder, Contexts: f.Contexts,
|
||||
ImportForm: f.ImportForm, Imports: f.Imports, Locks: f.Locks, Packages: f.Packages, Paths: f.Paths, Tree: f.Tree}
|
||||
}
|
||||
if v.Verdict == meshcatalogclient.VerdictRefused {
|
||||
v.Problems = in.Problems
|
||||
}
|
||||
}
|
||||
if v.Verdict == meshcatalogclient.VerdictRefused && len(v.Problems) == 0 {
|
||||
v.Verdict = ""
|
||||
}
|
||||
if v.Verdict != meshcatalogclient.VerdictRefused {
|
||||
v.Warnings = nil
|
||||
}
|
||||
return meshcatalogclient.EncodeModule(v)
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const (
|
||||
EngineModule = "node-engine"
|
||||
|
||||
// The indexer's states the view reads.
|
||||
ModulesBucket = IndexerModule + "_modules"
|
||||
CandidatesBucket = IndexerModule + "_candidates"
|
||||
TakenBucket = IndexerModule + "_taken"
|
||||
PackagesState = "packages"
|
||||
|
||||
@@ -3,6 +3,7 @@ package manifest
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"io/fs"
|
||||
"mesh-delivery-view/internal/view"
|
||||
"os"
|
||||
@@ -119,8 +120,9 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
// follows then heard on their views families (ADR 0615 §1, §5; DLV-M4).
|
||||
if want := []string{"mesh-delivery-assigner.assigner_read_asks", "mesh-delivery-assigner.assigner_read_differences", "mesh-delivery-assigner.assigner_read_assignments",
|
||||
"mesh-delivery-assigner.assigner_read_wants", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_config_candidates",
|
||||
"mesh-delivery-indexer.indexer_read_graph", "mesh-delivery-indexer.indexer_read_heard", "mesh-delivery-indexer.indexer_pending", "mesh-delivery-indexer.indexer_read_modules",
|
||||
"mesh-delivery-indexer.indexer_read_graph", "mesh-delivery-indexer.indexer_read_heard", "mesh-delivery-indexer.indexer_pending",
|
||||
"mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken",
|
||||
"seat:mesh-catalog.revision", // ADR 0663: the modules are the catalog half's, read through the seat's verb revision, never the indexer's
|
||||
"seat:mesh-hand-acts.hand-act", // the join: its first view asked again by hand is a hand act (views.StatusTools)
|
||||
"seat:node-engine.manifest-reads"}; !reflect.DeepEqual(m.Invokes, want) { // ADR 0618 §3: node_manifest_reads asks the node-engine seat's verb of every node; 1585/T24: view_tiers asks indexer_pending, a verb edge (ADR 0614 §2)
|
||||
t.Errorf("invokes = %v, want %v", m.Invokes, want)
|
||||
@@ -133,7 +135,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
t.Errorf("version = %q, want 0.1", m.Version)
|
||||
}
|
||||
if want := []string{"mesh-conditions.findings-asked", "mesh-delivery-assigner.views.asks", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants",
|
||||
"mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) {
|
||||
"mesh-catalog.catalog-revision", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) {
|
||||
t.Errorf("consumes = %v, want %v", m.Consumes, want)
|
||||
}
|
||||
// The one event the view says: a source revision is delivered (1906/D9, ADR 0539 §17), derived by it from the wants and the haves.
|
||||
@@ -180,7 +182,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
if !slices.Contains(m.Reads, view.SignersRead) {
|
||||
t.Errorf("the view reads %s, its manifest's reads are %v", view.SignersRead, m.Reads)
|
||||
}
|
||||
followed := map[string]string{view.ModulesBucket: view.IndexerModule + ".views.modules", view.CandidatesBucket: view.IndexerModule + ".views.candidates",
|
||||
followed := map[string]string{view.CandidatesBucket: view.IndexerModule + ".views.candidates",
|
||||
view.TakenBucket: view.IndexerModule + ".views.taken", view.AssignmentsBucket: view.AssignerModule + ".views.assignments",
|
||||
view.AssignerWantsBucket: view.AssignerModule + ".views.wants", view.AssignerAsksBucket: view.AssignerModule + ".views.asks"}
|
||||
for bucket, family := range followed {
|
||||
@@ -191,11 +193,20 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
t.Errorf("the view follows %s, its manifest consumes %v", family, m.Consumes)
|
||||
}
|
||||
}
|
||||
for _, tool := range []string{view.IndexerModule + ".indexer_read_modules", view.IndexerModule + ".indexer_read_candidates", view.IndexerModule + ".indexer_pending", view.AssignerModule + ".assigner_read_wants"} {
|
||||
for _, tool := range []string{meshcatalogclient.RevisionVerb, view.IndexerModule + ".indexer_read_candidates", view.IndexerModule + ".indexer_pending", view.AssignerModule + ".assigner_read_wants"} {
|
||||
if !slices.Contains(m.Invokes, tool) {
|
||||
t.Errorf("the view asks %s, its manifest invokes %v", tool, m.Invokes)
|
||||
}
|
||||
}
|
||||
// The modules are the catalog half's: the seat's verb is invoked, its event consumed, and the indexer's state modules is neither.
|
||||
if !slices.Contains(m.Consumes, view.CatalogSeat+"."+meshcatalog.EventCatalogRevision) {
|
||||
t.Errorf("the view hears the seat's %s, its manifest consumes %v", meshcatalog.EventCatalogRevision, m.Consumes)
|
||||
}
|
||||
for _, name := range append(slices.Clone(m.Invokes), m.Consumes...) {
|
||||
if strings.Contains(name, "indexer_read_modules") || strings.Contains(name, "views.modules") {
|
||||
t.Errorf("the manifest names %s: the modules are the catalog half's (ADR 0663)", name)
|
||||
}
|
||||
}
|
||||
if viewstate.ViewModule != m.Module || viewstate.ViewAnswersBucket != m.Module+"_answers" || viewstate.ReachBucket != m.Module+"_reach" {
|
||||
t.Errorf("the view names itself %s (%s, %s), its manifest %s", viewstate.ViewModule, viewstate.ViewAnswersBucket, viewstate.ReachBucket, m.Module)
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
"mesh-delivery-assigner.views.asks",
|
||||
"mesh-delivery-assigner.views.assignments",
|
||||
"mesh-delivery-assigner.views.wants",
|
||||
"mesh-catalog.catalog-revision",
|
||||
"mesh-delivery-indexer.views.candidates",
|
||||
"mesh-delivery-indexer.views.modules",
|
||||
"mesh-delivery-indexer.views.taken"
|
||||
],
|
||||
"streams": [
|
||||
@@ -68,10 +68,10 @@
|
||||
"mesh-delivery-indexer.indexer_read_graph",
|
||||
"mesh-delivery-indexer.indexer_read_heard",
|
||||
"mesh-delivery-indexer.indexer_pending",
|
||||
"mesh-delivery-indexer.indexer_read_modules",
|
||||
"mesh-delivery-indexer.indexer_read_packages",
|
||||
"mesh-delivery-indexer.indexer_read_predefined",
|
||||
"mesh-delivery-indexer.indexer_read_taken",
|
||||
"seat:mesh-catalog.revision",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:node-engine.manifest-reads"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user