Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83f7a7612d | ||
|
|
1fe1beee58 | ||
|
|
40da5e924e |
@@ -5,7 +5,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,23 +19,16 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"mesh-delivery-assigner/internal/judge"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
|
||||
@@ -100,31 +91,13 @@ func startBed(t *testing.T, leaveOut string) *bed {
|
||||
Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"}, AllowDirect: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The assigner's durable consumer on EVENTS, as the grants part makes it from the manifest's consumes: a node-engine seat's
|
||||
// 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 + "*"}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The operator-channel seat's stream of conditions, which the assigner says its conditions on.
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "CONDITIONS", Subjects: []string{"mesh.seat.operator-channel.accept.condition.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The indexer as the owner of its manifests and modules (DLV-F2r), with the real owner's side.
|
||||
serveIndexer(t, b, nc)
|
||||
// The assigner's views family and its marks (ADR 0615 §5, ADR 0619 §2), on its old sections.
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ASSIGNER_VIEWS", Subjects: []string{"mesh.stream.mesh-delivery-assigner.event.views.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: marksBucket, History: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
b.buckets[marksBucket] = kv
|
||||
}
|
||||
// The Go build seat's work queue, as the broker derives it from the seat's accepted kinds (delivery.AskStream).
|
||||
seat, _ := delivery.AskStream(delivery.AskGo)
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: seat, Subjects: []string{"mesh.seat.node-build-go.accept.>"},
|
||||
@@ -366,19 +339,6 @@ func (b *bed) get(t *testing.T, bucket, key string) []byte {
|
||||
// raises the condition naming g14, web and the canary rule; once ace runs F healthy, g14's first want names F and the condition is
|
||||
// cleared.
|
||||
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
|
||||
// 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 })
|
||||
for _, name := range append(append([]string{}, readBuckets...), watched...) {
|
||||
if strings.HasPrefix(name, delivery.IndexerModule+"_") {
|
||||
t.Errorf("the assigner reads the indexer's state %s itself", name)
|
||||
}
|
||||
}
|
||||
b := startBed(t, "")
|
||||
fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64)
|
||||
digest := b.manifest(t, "web", `{"module":"web","version":"1"}`)
|
||||
@@ -406,31 +366,6 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
}
|
||||
b.compose(t, "shanks", 'a')
|
||||
b.start(t)
|
||||
// DLV-F5r (ADR 0621 §1-§3, design 25 § 12 on a fresh mesh): the assigner asks nothing of the bus module (its manifest
|
||||
// has no bus-state entry, the bed runs no bus module and holds no binding of one), and while it still waits on the
|
||||
// indexer its runtime of its own answers each first-view tool to the readers its manifest's callers lists name, at
|
||||
// its mark, and refuses every other account, the assigner's own included.
|
||||
firstViewsBeforeAnyNewFormState(t, b, map[string][]string{
|
||||
"placement": {"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer"},
|
||||
"seat-holders": {"n1.node-tools"},
|
||||
"wants": {"n1.mesh-grants"},
|
||||
})
|
||||
graphKey, _, _ := views.UnansweredFinding(delivery.AssignerModule, views.Status{Owner: delivery.IndexerModule, State: graph.State}, time.Now())
|
||||
graphRaised := func() bool {
|
||||
e, err := b.buckets[assign.FindingsBucket].Get(b.ctx, graphKey)
|
||||
return err == nil && strings.Contains(string(e.Value()), delivery.IndexerModule+".graph")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
wkey, _ := delivery.WantKey("ace", "web")
|
||||
want, err := delivery.DecodeWant(b.get(t, delivery.AssignerWantsBucket, wkey))
|
||||
@@ -440,18 +375,15 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
if w := b.wantNow(t, "shanks", "web", "shanks' first want at what it runs", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fOld || w.Composed != "" || !w.Self || w.Newest != fNew {
|
||||
t.Errorf("shanks' want = %+v, want the fingerprint it runs, the node composing it, held back from F", w)
|
||||
}
|
||||
// DLV-O5 (ADR 0615 §5, ADR 0619 §2): each want is said on the assigner's views family, its value whole at its revision,
|
||||
// and the first view at the mark followed by those events holds it as the bucket does; a waiter on put never wakes.
|
||||
b.viewsHold(t, "wants", wkey)
|
||||
var neither oc.Condition
|
||||
var neither asks.Condition
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && neither.Key == ""; time.Sleep(100 * time.Millisecond) {
|
||||
for k, c := range b.conditionsSaid(t) {
|
||||
if strings.HasSuffix(k, ".neither") && c.State == occ.StateRaised {
|
||||
if strings.HasSuffix(k, ".neither") && c.State == asks.StateRaised {
|
||||
neither = c
|
||||
}
|
||||
}
|
||||
}
|
||||
if neither.Subject.Machine != "g14" || !strings.HasPrefix(neither.Subject.ID, "g14.web") || neither.Severity != occ.SeverityUrgent || !strings.Contains(neither.Explanation, "canary") {
|
||||
if neither.Subject.Machine != "g14" || !strings.HasPrefix(neither.Subject.ID, "g14.web") || neither.Severity != asks.SeverityUrgent || !strings.Contains(neither.Explanation, "canary") {
|
||||
t.Errorf("the condition of web on neither list on g14 = %+v", neither)
|
||||
}
|
||||
if keys, err := b.buckets[delivery.AssignerDifferencesBucket].Keys(b.ctx); err == nil && len(keys) != 0 {
|
||||
@@ -471,7 +403,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
cleared := false
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && !cleared; time.Sleep(100 * time.Millisecond) {
|
||||
c, ok := b.conditionsSaid(t)[neither.Key]
|
||||
cleared = ok && c.State == occ.StateCleared
|
||||
cleared = ok && c.State == asks.StateCleared
|
||||
}
|
||||
if !cleared {
|
||||
t.Errorf("the condition of web on neither list on g14 was not cleared once g14 got its want")
|
||||
@@ -502,173 +434,6 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
if !self {
|
||||
t.Error("ace's own want does not say it composes its own once placed")
|
||||
}
|
||||
|
||||
// ADR 0603 (L3, D15): one source revision changes a provider (db), its new consumer (app) and a build module (tool, whose
|
||||
// recipe moved: ace runs its first build and the second, under the agreed toolchain, is the one the source revision names).
|
||||
// The keys are written as their writers write them: the indexer's modules and manifests, the builder's builds, the
|
||||
// controller's grants, the placement seat's placement and the provisioner's provisions. The build module's want moves at
|
||||
// once and the provider's with it; the consumer's waits for its provision, with the words naming it, and moves once the
|
||||
// provisioner provides it (rule 8, ADR 0570 §4): nothing waits for a turn or an order, only for a key.
|
||||
offer := `"provides":[{"name":"database","scope":"mesh","answered-by":"helper"}]`
|
||||
dbDigest := b.manifest(t, "db", `{"module":"db","version":"1",`+offer+`}`)
|
||||
appDigest := b.manifest(t, "app", `{"module":"app","version":"1","requires":["database"]}`)
|
||||
toolDigest := b.manifest(t, "tool", `{"module":"tool","version":"1"}`)
|
||||
asked := func(state, why string) {
|
||||
raw, err := provisions.Encode(provisions.Provision{V: provisions.Version, Provision: "database", Consumer: provisions.End{Module: "app", Node: "ace"},
|
||||
Provider: provisions.End{Module: "db", Node: "ace"}, State: state, Why: why, At: time.Now()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.putJSON(t, provisions.ProvisionsBucket, "ace.app.database", raw)
|
||||
}
|
||||
// The provisioner keys it from the manifests, as soon as they are known and whether the provider is placed yet or not.
|
||||
asked(provisions.Asked, "the provider's build is not done")
|
||||
fpOf := func(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
toolOld := fpOf('1')
|
||||
for module, row := range map[string]struct {
|
||||
fp, digest string
|
||||
}{"db": {fpOf('2'), dbDigest}, "app": {fpOf('3'), appDigest}, "tool": {fpOf('4'), toolDigest}} {
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: delivery.RootPath})
|
||||
b.putJSON(t, delivery.ModulesBucket, module, mod)
|
||||
fps := []string{row.fp}
|
||||
if module == "tool" {
|
||||
fps = append(fps, toolOld)
|
||||
}
|
||||
for _, fp := range fps {
|
||||
built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: row.digest})
|
||||
bkey, _ := delivery.BuilderBuildKey(module, fp)
|
||||
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
|
||||
gkey, _ := delivery.PartGrantKey(module, fp)
|
||||
grant, _ := delivery.EncodeGrant(delivery.Grant{})
|
||||
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
|
||||
}
|
||||
b.place(t, "ace", module)
|
||||
}
|
||||
runsOld, _ := delivery.EncodeHave(delivery.Have{FP: toolOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.tool", runsOld)
|
||||
if w := b.wantNow(t, "ace", "tool", "the build module's want at the second build", func(w delivery.Want) bool { return w.FP == fpOf('4') }); w.FP != fpOf('4') {
|
||||
t.Errorf("tool's want = %+v", w)
|
||||
}
|
||||
b.wantNow(t, "ace", "db", "the provider's want at its fingerprint", func(w delivery.Want) bool { return w.FP == fpOf('2') })
|
||||
if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, "want.ace.app"); err == nil {
|
||||
t.Errorf("app was wanted while its provision was asked: %s", e.Value())
|
||||
}
|
||||
asked(provisions.Provided, "")
|
||||
if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w delivery.Want) bool { return w.FP == fpOf('3') }); w.FP != fpOf('3') {
|
||||
t.Errorf("app's want = %+v", w)
|
||||
}
|
||||
|
||||
// ADR 0618 §1, §3, §4, ADR 0624 §2: ace's engine answers the node-engine seat's manifest-reads; a build written only in a
|
||||
// form it does not state is held on ace, naming the form, and moves once the engine says manifest-reads-changed on its
|
||||
// subject and states the form; no timer moves it. g14's engine answers nothing and has no record: read as the baseline.
|
||||
stated := catalogue.ManifestReads()
|
||||
var statedMu sync.Mutex
|
||||
nc, err := nats.Connect(b.srv.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer nc.Close()
|
||||
if _, err := nc.Subscribe("mesh.seat.node-engine.tool.manifest-reads.ace", func(m *nats.Msg) {
|
||||
statedMu.Lock()
|
||||
answer, _ := json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: "ace", ManifestReads: stated, StatedAt: time.Now().Format(time.RFC3339Nano)})
|
||||
statedMu.Unlock()
|
||||
reply, _ := json.Marshal(map[string]any{"result": json.RawMessage(answer), "node": "ace"})
|
||||
_ = m.Respond(reply)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
say := func(why string) {
|
||||
body, _ := json.Marshal(nodeengine.ManifestReadsChangedEvent{V: 1, Node: "ace", ManifestReads: stated, At: time.Now().Format(time.RFC3339Nano), Why: why})
|
||||
if _, err := b.js.Publish(b.ctx, enginereads.SubjectPrefix+"ace", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
say(enginereads.ReadsStarted)
|
||||
formsOld, formsNew := fpOf('5'), fpOf('6')
|
||||
formsDigest := b.manifest(t, "forms", `{"module":"forms","version":"1"}`)
|
||||
formsAt := func(fp string) {
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: formsDigest, Repository: "novox/forms", Commit: strings.Repeat("3", 40), Path: delivery.RootPath})
|
||||
b.putJSON(t, delivery.ModulesBucket, "forms", mod)
|
||||
}
|
||||
for _, fp := range []string{formsOld, formsNew} {
|
||||
build := delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: formsDigest}
|
||||
if fp == formsNew {
|
||||
build.Needs = []string{"form:new"}
|
||||
}
|
||||
built, err := delivery.EncodeBuilderBuild(build)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bkey, _ := delivery.BuilderBuildKey("forms", fp)
|
||||
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
|
||||
gkey, _ := delivery.PartGrantKey("forms", fp)
|
||||
grant, _ := delivery.EncodeGrant(delivery.Grant{})
|
||||
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
|
||||
}
|
||||
runsFormsOld, _ := delivery.EncodeHave(delivery.Have{FP: formsOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.forms", runsFormsOld)
|
||||
b.place(t, "ace", "forms")
|
||||
formsAt(formsOld)
|
||||
b.wantNow(t, "ace", "forms", "the want at what ace runs, a build in the first form", func(w delivery.Want) bool { return w.FP == formsOld })
|
||||
formsAt(formsNew)
|
||||
statement := func(node string, ok func(enginereads.Statement) bool) enginereads.Statement {
|
||||
var last enginereads.Statement
|
||||
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if e, err := b.buckets[engineReadsBucket].Get(b.ctx, node); err == nil {
|
||||
if last, err = enginereads.Decode(e.Value()); err == nil && ok(last) {
|
||||
return last
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s's engine reads are %+v", node, last)
|
||||
return last
|
||||
}
|
||||
statement("ace", func(s enginereads.Statement) bool { return s.From == enginereads.FromVerb && s.Answered })
|
||||
if s := statement("g14", func(s enginereads.Statement) bool { return !s.Answered }); s.From != enginereads.FromNone {
|
||||
t.Errorf("g14, whose engine answers nothing and which has no record, reads %+v", s)
|
||||
}
|
||||
if w := b.wantNow(t, "ace", "forms", "the want held on ace, naming the form", func(w delivery.Want) bool {
|
||||
return w.Newest == formsNew && strings.Contains(w.Words, "form:new")
|
||||
}); w.FP != formsOld || w.Rule != delivery.RuleComposed {
|
||||
t.Errorf("forms' want = %+v, want it held at what ace runs under rule 7", w)
|
||||
}
|
||||
statedMu.Lock()
|
||||
stated = append(slices.Clone(stated), "form:new")
|
||||
statedMu.Unlock()
|
||||
say(enginereads.ReadsStarted)
|
||||
b.wantNow(t, "ace", "forms", "the want moved once ace's engine said it reads the form", func(w delivery.Want) bool { return w.FP == formsNew })
|
||||
// assigner_engine_reads asks ace's engine again through the state: the assigner answers there with what it states now.
|
||||
again, err := enginereads.AskAgainOf(bedState{b, engineReadsBucket}, "ace", "n1.ada", "a person asks", time.Now(), 15*time.Second)
|
||||
if err != nil || again.From != enginereads.FromVerb || !slices.Contains(again.ManifestReads, "form:new") || again.AskAgain != nil {
|
||||
t.Errorf("the ask again of ace answered %+v, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
// bedState is one of the bed's buckets as the engine reads' tool reads it.
|
||||
type bedState struct {
|
||||
b *bed
|
||||
bucket string
|
||||
}
|
||||
|
||||
func (s bedState) Keys() ([]string, error) { return s.b.buckets[s.bucket].Keys(s.b.ctx) }
|
||||
func (s bedState) Get(key string) ([]byte, uint64, bool, error) {
|
||||
e, err := s.b.buckets[s.bucket].Get(s.b.ctx, key)
|
||||
if err != nil {
|
||||
return nil, 0, false, nil
|
||||
}
|
||||
return e.Value(), e.Revision(), true, nil
|
||||
}
|
||||
func (s bedState) Put(key string, value []byte) (uint64, error) {
|
||||
return s.b.buckets[s.bucket].Put(s.b.ctx, key, value)
|
||||
}
|
||||
func (s bedState) Delete(key string) error { return s.b.buckets[s.bucket].Delete(s.b.ctx, key) }
|
||||
func (s bedState) Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error) {
|
||||
for deadline := time.Now().Add(bound); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if v, rev, found, _ := s.Get(key); found && rev > after && done(v) {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no answer at %s within %s", key, bound)
|
||||
}
|
||||
|
||||
// 1297/B9 (the assigner's half, ADR 0528 clause 6): started whole, the assigner holds a fingerprint whose health check
|
||||
@@ -693,7 +458,7 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) {
|
||||
t.Fatalf("held = %+v, %v", h, err)
|
||||
}
|
||||
akey, _ := delivery.HoldAskKey("web", fNew)
|
||||
a, err := decodeAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
a, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
if err != nil || a.Asking <= 0 || a.Ask.ID != delivery.AskingID(akey, a.Asking) || len(a.Ask.Options) != 2 {
|
||||
t.Fatalf("the ask = %+v, %v", a, err)
|
||||
}
|
||||
@@ -702,27 +467,27 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) {
|
||||
// conditionsSaid reads what the assigner said of its conditions, by finding key: each a finding in its own bucket, which
|
||||
// the condition keeper carries (novox/hq ADR 0590 §3, issue 1126), raised while it stands and cleared once deleted, each
|
||||
// with the words its last finding said and the subject the finding names (`<module>[.<fingerprint>]`).
|
||||
func (b *bed) conditionsSaid(t *testing.T) map[string]oc.Condition {
|
||||
func (b *bed) conditionsSaid(t *testing.T) map[string]asks.Condition {
|
||||
t.Helper()
|
||||
w, err := b.buckets[assign.FindingsBucket].WatchAll(b.ctx, jetstream.IncludeHistory())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Stop()
|
||||
out := map[string]oc.Condition{}
|
||||
out := map[string]asks.Condition{}
|
||||
for e := range w.Updates() {
|
||||
if e == nil {
|
||||
return out
|
||||
}
|
||||
c := out[e.Key()]
|
||||
c.Key, c.State = e.Key(), occ.StateCleared
|
||||
c.Key, c.State = e.Key(), asks.StateCleared
|
||||
if e.Operation() == jetstream.KeyValuePut {
|
||||
f, err := health.DecodeFinding(e.Value())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c = oc.Condition{Key: e.Key(), State: occ.StateRaised, Severity: string(f.Severity), Explanation: f.Explanation,
|
||||
Resolved: f.Resolved, Subject: oc.ConditionSubject{Scope: string(f.Subject.Scope), ID: f.Subject.ID, Machine: f.Subject.Machine}}
|
||||
c = asks.Condition{Key: e.Key(), State: asks.StateRaised, Severity: string(f.Severity), Explanation: f.Explanation,
|
||||
Resolved: f.Resolved, Subject: asks.Scope{Scope: string(f.Subject.Scope), ID: f.Subject.ID, Machine: f.Subject.Machine}}
|
||||
}
|
||||
out[e.Key()] = c
|
||||
}
|
||||
@@ -749,7 +514,7 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
|
||||
b.start(t)
|
||||
|
||||
akey, _ := delivery.RestoreAskKey("ace", "mail", fNew)
|
||||
a, err := decodeAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
a, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
if err != nil || len(a.Ask.Options) != 2 || a.Ask.Options[0].ID != "restore" || a.Ask.Options[1].ID != "leave-stopped" {
|
||||
t.Fatalf("the restore ask = %+v, %v", a, err)
|
||||
}
|
||||
@@ -759,7 +524,7 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
|
||||
key := ""
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && key == ""; time.Sleep(100 * time.Millisecond) {
|
||||
for k, c := range b.conditionsSaid(t) {
|
||||
if c.State == occ.StateRaised && strings.HasPrefix(c.Subject.ID, "mail.") && strings.HasSuffix(k, ".held") {
|
||||
if c.State == asks.StateRaised && strings.HasPrefix(c.Subject.ID, "mail.") && strings.HasSuffix(k, ".held") {
|
||||
if c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") {
|
||||
t.Fatalf("the condition = %+v, want it to name ace and the check's words", c)
|
||||
}
|
||||
@@ -877,7 +642,7 @@ func (b *bed) wantNow(t *testing.T, node, module, what string, ok func(delivery.
|
||||
// answer says the teller wrote the operator's answer to the ask that stands at key (its warrant names that asking's id).
|
||||
func (b *bed) answer(t *testing.T, key, option string) {
|
||||
t.Helper()
|
||||
a, err := decodeAsk(key, b.get(t, delivery.AssignerAsksBucket, key))
|
||||
a, err := delivery.DecodeAssignerAsk(key, b.get(t, delivery.AssignerAsksBucket, key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -927,7 +692,7 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) {
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||
cleared := false
|
||||
for k, c := range b.conditionsSaid(t) {
|
||||
cleared = cleared || (strings.HasPrefix(k, "module.web.") && c.State == occ.StateCleared && strings.HasSuffix(k, ".held"))
|
||||
cleared = cleared || (strings.HasPrefix(k, "module.web.") && c.State == asks.StateCleared && strings.HasSuffix(k, ".held"))
|
||||
}
|
||||
if cleared {
|
||||
return
|
||||
@@ -937,17 +702,17 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) {
|
||||
}
|
||||
|
||||
// askNow waits for the backup ask at key to be the one numbered asking and answers it.
|
||||
func (b *bed) askNow(t *testing.T, key string, asking int) decodedAsk {
|
||||
func (b *bed) askNow(t *testing.T, key string, asking int) delivery.AssignerAsk {
|
||||
t.Helper()
|
||||
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if e, err := b.buckets[delivery.AssignerAsksBucket].Get(b.ctx, key); err == nil {
|
||||
if a, err := decodeAsk(key, e.Value()); err == nil && a.Asking == asking {
|
||||
if a, err := delivery.DecodeAssignerAsk(key, e.Value()); err == nil && a.Asking == asking {
|
||||
return a
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("the ask at %s never became asking %d", key, asking)
|
||||
return decodedAsk{}
|
||||
return delivery.AssignerAsk{}
|
||||
}
|
||||
|
||||
// 1906/D4 (ADR 0539 §16), whole on the bus: a module held on a node because its backup failed is asked about, and the answer
|
||||
@@ -1017,7 +782,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
for k, c := range b.conditionsSaid(t) {
|
||||
if strings.HasPrefix(k, "module.web.") && c.State == occ.StateCleared && strings.HasSuffix(k, ".held") {
|
||||
if strings.HasPrefix(k, "module.web.") && c.State == asks.StateCleared && strings.HasSuffix(k, ".held") {
|
||||
if w := b.wantNow(t, "ace", "web", "the want at the lifted fingerprint", func(w delivery.Want) bool { return w.FP == fNew }); w.Rule != 0 {
|
||||
t.Errorf("the want = %+v", w)
|
||||
}
|
||||
@@ -1042,7 +807,7 @@ func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) {
|
||||
_, stop := b.startStoppable(t)
|
||||
hkey, _ := delivery.HeldKey("web", fNew)
|
||||
akey, _ := delivery.HoldAskKey("web", fNew)
|
||||
first, err := decodeAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
first, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1057,7 +822,7 @@ func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) {
|
||||
b.answerTo(t, akey, "keep", first.Ask.ID) // an answer to the first hold, which the later hold must not take
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.web", failed)
|
||||
b.start(t)
|
||||
again, err := decodeAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
again, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1116,7 +881,7 @@ func (s bedStore) history(string, string) ([]stdio.StateRevision, error) { retur
|
||||
func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
|
||||
b := startBed(t, "")
|
||||
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "MOD_" + delivery.AssignerModule + "_placement",
|
||||
Subjects: placementSubjects()}); err != nil { // the seat's four events: the views family has a stream of its own
|
||||
Subjects: []string{process.ModuleStreamSubject(delivery.AssignerModule, ">")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heard := make(chan delivery.PlacementEvent, 16)
|
||||
@@ -1346,270 +1111,3 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
|
||||
t.Error("the removed node's key stays")
|
||||
}
|
||||
}
|
||||
|
||||
// viewsHold waits until a follower of state, from a first view at its mark and the views family's events, holds key as
|
||||
// the bucket does, and checks that no views event landed where a waiter on the assigner's put waits.
|
||||
func (b *bed) viewsHold(t *testing.T, state, key string) {
|
||||
t.Helper()
|
||||
kv, marks := b.buckets[viewedStates[state]], b.buckets[marksBucket]
|
||||
src := func(kv jetstream.KeyValue) views.Source { return bedSource{b.ctx, kv} }
|
||||
st, err := b.js.Stream(b.ctx, "ASSIGNER_VIEWS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) {
|
||||
f := &views.Follower{Owner: delivery.AssignerModule, Tool: state, State: state, Ask: func(context.Context, string) (views.Answer, error) {
|
||||
return views.FirstView(delivery.AssignerModule, state, src(kv), src(marks), "", time.Now())
|
||||
}}
|
||||
if err := f.Start(b.ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, _ := st.Info(b.ctx)
|
||||
gap := false
|
||||
for seq := info.State.FirstSeq; seq > 0 && seq <= info.State.LastSeq; seq++ {
|
||||
m, err := st.GetMsg(b.ctx, seq)
|
||||
if err != nil || views.StateOfEvent(m.Subject) != state {
|
||||
continue
|
||||
}
|
||||
e, err := views.DecodeEvent(m.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g, _ := f.Apply(e); g {
|
||||
gap = true
|
||||
}
|
||||
}
|
||||
e, err := kv.Get(b.ctx, key)
|
||||
v, ok := f.Get(key)
|
||||
if err == nil && ok && !gap && string(v) == string(e.Value()) && f.Status().Revision >= e.Revision() {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the views family holds %s.%s as %s at %d (gap %v), the bucket %v", state, key, v, f.Status().Revision, gap, err)
|
||||
}
|
||||
}
|
||||
ev, err := b.js.Stream(b.ctx, "EVENTS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ev.GetLastMsgForSubject(b.ctx, "mesh.mod.mesh-delivery-assigner.event.put"); err == nil {
|
||||
t.Errorf("a waiter on put woke: the bus holds a put")
|
||||
}
|
||||
}
|
||||
|
||||
// bedSource is a bucket as a views.Source.
|
||||
type bedSource struct {
|
||||
ctx context.Context
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (s bedSource) Keys() ([]string, error) {
|
||||
k, err := s.kv.Keys(s.ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return k, err
|
||||
}
|
||||
|
||||
func (s bedSource) Get(key string) (*views.Entry, bool, error) {
|
||||
e, err := s.kv.Get(s.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &views.Entry{Value: e.Value(), Revision: e.Revision()}, true, nil
|
||||
}
|
||||
|
||||
// placementSubjects are the subjects of the seat's four events on the assigner's stream placement.
|
||||
func placementSubjects() []string {
|
||||
var out []string
|
||||
for _, e := range delivery.PlacementEvents {
|
||||
out = append(out, process.ModuleStreamSubject(delivery.AssignerModule, e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// indexerLate, 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{}
|
||||
|
||||
// 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 onto INDEXER_VIEWS,
|
||||
// read by the assigner's durable consumer; and its first-view tools of the three answered from the buckets at their marks.
|
||||
func serveIndexer(t *testing.T, b *bed, nc *nats.Conn) {
|
||||
t.Helper()
|
||||
marks, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: delivery.IndexerModule + "_" + views.MarksState, History: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "INDEXER_VIEWS", Subjects: []string{"mesh.stream.mesh-delivery-indexer.event.views.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.js.CreateOrUpdateConsumer(b.ctx, "INDEXER_VIEWS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner", AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
em := views.Emitter{Owner: delivery.IndexerModule, Subject: func(e string) string { return process.ModuleStreamSubject(delivery.IndexerModule, e) },
|
||||
Publish: views.JetStreamPublish(b.js), Marks: marks}
|
||||
for state, bucket := range indexerFirstViews {
|
||||
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.buckets[bucket] = kv
|
||||
if err := em.Start(b.ctx, state, kv, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
late := indexerLate
|
||||
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 {
|
||||
case <-late:
|
||||
default:
|
||||
_ = m.Respond([]byte(`{"error":"the indexer's runtime is not up"}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
var args struct {
|
||||
After string `json:"after"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Data, &args)
|
||||
a, err := views.FirstView(delivery.IndexerModule, state, bedSource{b.ctx, kv}, bedSource{b.ctx, marks}, args.After, time.Now())
|
||||
out := map[string]any{"result": a, "node": "n0"}
|
||||
if err != nil {
|
||||
out = map[string]any{"error": err.Error()}
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
_ = m.Respond(raw)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
}
|
||||
|
||||
// firstViewsBeforeAnyNewFormState serves the assigner's first-view tools as its runtime of its own does (the tools bundle's
|
||||
// viewTools over the bed's buckets, the env its manifest's bundle writes, the caller the runtime proves from the subject's
|
||||
// last token), before any new-form state exists, and asks each from every reader granted and from the others.
|
||||
func firstViewsBeforeAnyNewFormState(t *testing.T, b *bed, granted map[string][]string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := catalogue.Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.BusState) != 0 {
|
||||
t.Fatalf("the assigner asks bus state of the bus module: %+v", m.BusState)
|
||||
}
|
||||
var manifest struct {
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Kind string `json:"kind"`
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{}
|
||||
for _, a := range manifest.Build.Artifacts {
|
||||
if a.Kind == "bundle" {
|
||||
env = a.Env
|
||||
}
|
||||
}
|
||||
state := func(name string) views.Source {
|
||||
if name == views.MarksState {
|
||||
return bedSource{b.ctx, b.buckets[marksBucket]}
|
||||
}
|
||||
return bedSource{b.ctx, b.buckets[viewedStates[name]]}
|
||||
}
|
||||
// The runtime: one bundle on a pair of pipes, a call on mesh.mod.<module>.call.<tool>.<caller token> handed to it with
|
||||
// the caller in _meta, its answer replied as the tool runner replies ({result, error, node}).
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
t.Cleanup(func() { inW.Close(); outW.Close() })
|
||||
go func() {
|
||||
_ = stdio.ServeOn(delivery.AssignerModule, viewTools(func(k string) string { return env[k] }, state, time.Now), inR, outW)
|
||||
}()
|
||||
answers := make(chan map[string]any, 16)
|
||||
go func() {
|
||||
dec := json.NewDecoder(outR)
|
||||
for {
|
||||
var line map[string]any
|
||||
if dec.Decode(&line) != nil {
|
||||
return
|
||||
}
|
||||
if _, asked := line["method"]; !asked {
|
||||
answers <- line
|
||||
}
|
||||
}
|
||||
}()
|
||||
one := make(chan struct{}, 1) // one call at a time on the pipe, so each answer is its call's
|
||||
nc, err := nats.Connect(b.srv.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
sub, err := nc.Subscribe("mesh.mod."+delivery.AssignerModule+".call.*.*", func(msg *nats.Msg) {
|
||||
one <- struct{}{}
|
||||
defer func() { <-one }()
|
||||
tokens := strings.Split(msg.Subject, ".")
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(msg.Data, &args)
|
||||
call, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": "c", "method": "tools/call", "params": map[string]any{"name": tokens[4],
|
||||
"arguments": args, "_meta": map[string]any{stdio.MetaCaller: strings.ReplaceAll(tokens[5], "~", ".")}}})
|
||||
if _, err := inW.Write(append(call, '\n')); err != nil {
|
||||
return
|
||||
}
|
||||
a := <-answers
|
||||
res, _ := a["result"].(map[string]any)
|
||||
content, _ := res["content"].([]any)
|
||||
text := ""
|
||||
if len(content) == 1 {
|
||||
text, _ = content[0].(map[string]any)["text"].(string)
|
||||
}
|
||||
out := map[string]any{"result": json.RawMessage(text), "node": "n1"}
|
||||
if isErr, _ := res["isError"].(bool); isErr || len(content) != 1 {
|
||||
out = map[string]any{"error": text}
|
||||
}
|
||||
reply, _ := json.Marshal(out)
|
||||
_ = msg.Respond(reply)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
ask := func(user, tool string) (views.Answer, error) {
|
||||
return views.AskVia(func(key string, body any) (json.RawMessage, error) {
|
||||
data, _ := json.Marshal(body)
|
||||
msg, err := nc.Request("mesh.mod."+delivery.AssignerModule+".call."+tool+"."+strings.ReplaceAll(user, ".", "~"), data, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg.Data, nil
|
||||
}, tool)(b.ctx, "")
|
||||
}
|
||||
readers := []string{"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer", "n1.mesh-delivery-assigner", "n1.mesh-delivery-builder"}
|
||||
for tool, allowed := range granted {
|
||||
for _, user := range readers {
|
||||
a, err := ask(user, tool)
|
||||
switch {
|
||||
case slices.Contains(allowed, user) && err != nil:
|
||||
t.Errorf("%s's first view of %s before any new-form state: %v", user, tool, err)
|
||||
case slices.Contains(allowed, user):
|
||||
keys, _ := b.buckets[viewedStates[tool]].Keys(b.ctx)
|
||||
if a.Owner != delivery.AssignerModule || a.State != tool || len(a.Values) != len(keys) {
|
||||
t.Errorf("%s's first view of %s = %+v, the state holds %v", user, tool, a, keys)
|
||||
}
|
||||
case err == nil || !strings.Contains(err.Error(), "refused"):
|
||||
t.Errorf("%s, granted no first view of %s, was answered %+v, %v", user, tool, a, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,13 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
|
||||
@@ -59,11 +55,11 @@ var readBuckets = []string{
|
||||
delivery.PartGrantsBucket,
|
||||
delivery.AnswersBucket,
|
||||
delivery.BuilderBuildsBucket,
|
||||
delivery.ManifestsBucket,
|
||||
delivery.ModulesBucket,
|
||||
delivery.PresenceBucket,
|
||||
delivery.HaveBucket,
|
||||
provisions.ProvisionsBucket,
|
||||
delivery.SourceBucket,
|
||||
delivery.MergesBucket,
|
||||
}
|
||||
|
||||
// readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every
|
||||
@@ -74,15 +70,7 @@ var readFamilies = []string{delivery.ComposedRead}
|
||||
// (ADR 0567 §1), named after the assigner as every module state is.
|
||||
var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerDifferencesBucket,
|
||||
delivery.AssignerPutsBucket, delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.PlacementProposalsBucket,
|
||||
delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, delivery.SeatHoldersBucket,
|
||||
engineReadsBucket}
|
||||
|
||||
// engineReadsState is the assigner's state of what each node's engine states it reads (ADR 0618 §3), one key `<node>`, as
|
||||
// the verb manifest-reads last answered it or the fallback read it: what assigner_engine_reads lists and asks again.
|
||||
const engineReadsState = "engine-reads"
|
||||
|
||||
// engineReadsBucket is that state's bucket.
|
||||
const engineReadsBucket = delivery.AssignerModule + "_" + engineReadsState
|
||||
delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, delivery.SeatHoldersBucket}
|
||||
|
||||
// seatBuckets are the seat's states, new with it: until the controller has made them from the manifest and the bus grants
|
||||
// them, the assigner starts without them and binds each once the bus says it was created (issue 1790's kind).
|
||||
@@ -106,9 +94,6 @@ var keptStates = []announce.State{
|
||||
{Name: health.FindingsState, Readable: true},
|
||||
{Name: delivery.AssignerPoliciesState, Readable: true},
|
||||
{Name: delivery.SeatHoldersState, Readable: true},
|
||||
{Name: views.MarksState, Readable: true}, // what its views family said each state up to (views.go)
|
||||
{Name: views.StatusState, Readable: true}, // its first views of the indexer's states (views.go)
|
||||
{Name: engineReadsState, Readable: true},
|
||||
}
|
||||
|
||||
// buckets is every bucket the assigner opened, by name.
|
||||
@@ -155,18 +140,6 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
|
||||
logf("the bucket %s cannot be opened (%v): the assigner's conditions are said straight to the router until it starts again", name, err)
|
||||
continue
|
||||
}
|
||||
if (name == delivery.SourceBucket || name == delivery.MergesBucket) && err != nil {
|
||||
// The gate's source and merges (ADR 0601 §1): without them no release is known to carry a build, so every move of
|
||||
// a record module is asked of a person, as before.
|
||||
logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err)
|
||||
continue
|
||||
}
|
||||
if name == engineReadsBucket && err != nil {
|
||||
// New with ADR 0618 §3: without it the statements the verb answers are kept in memory only, and
|
||||
// assigner_engine_reads lists nothing.
|
||||
logf("the bucket %s cannot be opened (%v): what each node's engine reads is kept in memory only until it starts again", name, err)
|
||||
continue
|
||||
}
|
||||
if name == delivery.PartGrantsBucket && err != nil {
|
||||
// The grants part's grants (ADR 0571 clause 9): without them no module counts as granted until it is bound.
|
||||
logf("the bucket %s cannot be opened (%v): no grant is read until it is bound once the bus says it was made", name, err)
|
||||
@@ -185,7 +158,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
|
||||
func startsWithout(name string) bool {
|
||||
switch name {
|
||||
case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket,
|
||||
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, engineReadsBucket:
|
||||
delivery.AssignerPutsBucket:
|
||||
return true
|
||||
}
|
||||
return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket
|
||||
@@ -220,21 +193,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
thisNode = account.Node
|
||||
logger.Printf("started on the account %s, reading %d buckets", account.User, len(kvs))
|
||||
findings := findingsOf(ctx, kvs, logger.Printf)
|
||||
// Each change of the states its readers read is said on its views family (ADR 0615 §5, ADR 0619 §2); a watch of it
|
||||
// that ends stops the program, as a watch of what a pass reads does.
|
||||
viewErrs := make(chan error, len(viewedStates))
|
||||
startViews(ctx, js, conn, account, kvs, findings, viewErrs, logger.Printf)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-viewErrs:
|
||||
logger.Printf("%v: the assigner stops, and is started again", err)
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
iv := &indexerViews{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)
|
||||
return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, logger)
|
||||
}
|
||||
|
||||
// findingsOf is where the assigner's said conditions go as findings (issue 1952 WM6): its findings bucket, which the
|
||||
@@ -269,6 +228,7 @@ var watched = []string{
|
||||
delivery.PartGrantsBucket,
|
||||
delivery.AnswersBucket,
|
||||
delivery.BuilderBuildsBucket,
|
||||
delivery.ModulesBucket,
|
||||
delivery.PlacementBucket,
|
||||
delivery.PlacementProposalsBucket,
|
||||
delivery.PlacementCandidatesBucket,
|
||||
@@ -281,14 +241,11 @@ var watched = []string{
|
||||
assign.PoliciesBucket,
|
||||
provisions.ProvisionsBucket,
|
||||
delivery.SeatHoldersBucket,
|
||||
delivery.SourceBucket,
|
||||
delivery.MergesBucket,
|
||||
engineReadsBucket,
|
||||
}
|
||||
|
||||
// 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 buckets, 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 buckets, say func(asks.Condition) error, findings assign.Findings, bus caller, logger *log.Logger) error {
|
||||
w := newWorld()
|
||||
dirty := make(chan struct{}, 1)
|
||||
wake := func() {
|
||||
@@ -299,16 +256,6 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
}
|
||||
errs := make(chan error, len(watched)+2)
|
||||
settled := make(chan struct{}, len(watched)+2)
|
||||
// What each node's engine reads (ADR 0618 §3, §4): asked through the seat's verb, kept in the state engine-reads, read
|
||||
// from the node's record where the engine does not answer (the fallback, said in the journal).
|
||||
reads := &enginereads.Keeper{Fallback: w.recordReads, Changed: wake, Logf: logger.Printf}
|
||||
if bus != nil {
|
||||
reads.Call = bus.CallOn
|
||||
}
|
||||
if kv := kvs[engineReadsBucket]; kv != nil {
|
||||
reads.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err }
|
||||
reads.Delete = func(node string) error { return kv.Delete(ctx, node) }
|
||||
}
|
||||
watch := func(name string, opts ...jetstream.WatchOpt) error {
|
||||
kw, err := kvs[name].WatchAll(ctx, opts...)
|
||||
if err != nil {
|
||||
@@ -342,10 +289,6 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
if e.Operation() == jetstream.KeyValuePut {
|
||||
value = e.Value()
|
||||
}
|
||||
if name == engineReadsBucket {
|
||||
reads.Heard(e.Key(), value, value == nil) // it wakes a pass when a statement changed
|
||||
continue
|
||||
}
|
||||
w.putEntry(name, e.Key(), value, e.Revision(), e.Created())
|
||||
wake()
|
||||
}
|
||||
@@ -393,18 +336,15 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
}
|
||||
started++
|
||||
var opts []jetstream.WatchOpt
|
||||
if name == delivery.HaveBucket || name == delivery.SourceBucket {
|
||||
// what each node ran healthy before, for a set-back; and the release behind each source revision (ADR 0601 §1)
|
||||
opts = append(opts, jetstream.IncludeHistory())
|
||||
if name == delivery.HaveBucket {
|
||||
opts = append(opts, jetstream.IncludeHistory()) // what each node ran healthy before, for a set-back
|
||||
}
|
||||
if err := watch(name, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The indexer's manifests, modules and module graph, through its first views and views family, never its buckets
|
||||
// (DLV-F2r, ADR 0631): the first pass waits for the three answers, as it waits for every watch to hand what stands.
|
||||
if iv != nil {
|
||||
started += iv.start(ctx, w, wake, settled)
|
||||
if err := watch(delivery.ManifestsBucket, jetstream.MetaOnly()); err != nil {
|
||||
return err
|
||||
}
|
||||
// The seat's states arrive with the same delivery as this assigner (ADR 0567 §1): one not on the bus at this start is
|
||||
// bound once the bus says it was created, and watched from then on, with no restart (issue 1790's kind). Until then no
|
||||
@@ -436,7 +376,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
logger.Printf("the bucket %s is open and watched", a.name)
|
||||
return watch(a.name)
|
||||
}
|
||||
for range started {
|
||||
for range started + 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
@@ -445,14 +385,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
case <-settled:
|
||||
}
|
||||
}
|
||||
p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{},
|
||||
reads: reads}
|
||||
// The node-engine seat's manifest-reads-changed, for one node (ADR 0618 §4): that node is asked again, with no timer.
|
||||
// Heard on the assigner's durable consumer on EVENTS, which the grants part makes from the manifest's consumes; where it
|
||||
// is not there yet, a node is asked again only when it comes back online.
|
||||
if js != nil {
|
||||
hearReadsChanged(ctx, js, reads, findings, logger.Printf)
|
||||
}
|
||||
p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{}}
|
||||
if bus != nil && kvs[delivery.AssignerPutsBucket] != nil {
|
||||
p.acts = newPutActs(bus, wake, logger.Printf)
|
||||
}
|
||||
@@ -503,13 +436,13 @@ var PutsReopenEvery = time.Minute
|
||||
|
||||
// sayVia publishes a condition on the operator-channel seat's one subject for conditions, under the assigner's own name
|
||||
// (ADR 0437 clause 9).
|
||||
func sayVia(ctx context.Context, js jetstream.JetStream) func(oc.Condition) error {
|
||||
return func(c oc.Condition) error {
|
||||
subject, err := occ.ConditionSubject(delivery.AssignerModule)
|
||||
func sayVia(ctx context.Context, js jetstream.JetStream) func(asks.Condition) error {
|
||||
return func(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject(delivery.AssignerModule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := occ.EncodeCondition(c)
|
||||
data, err := asks.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -517,39 +450,3 @@ func sayVia(ctx context.Context, js jetstream.JetStream) func(oc.Condition) erro
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// hearReadsChanged hears the node-engine seat's event manifest-reads-changed on the assigner's durable consumer on EVENTS,
|
||||
// and asks the node it names again. Any other event the consumer hands is acknowledged and left.
|
||||
func hearReadsChanged(ctx context.Context, js jetstream.JetStream, reads *enginereads.Keeper, findings assign.Findings, logf func(string, ...any)) {
|
||||
account := process.Account{Node: thisNode, Module: delivery.AssignerModule}
|
||||
w := notHeardWriter(findings, logf)
|
||||
cons, err := account.EventConsumer(ctx, js, process.EventsStream)
|
||||
if err != nil {
|
||||
w.NotHeard(process.EventsStream, enginereads.Event+" is not heard until the assigner starts again (a node is asked again when it comes back online)",
|
||||
fmt.Errorf("the consumer %s on %s does not exist yet: %w", account.EventConsumerName(), process.EventsStream, err))
|
||||
return
|
||||
}
|
||||
cc, err := process.HearEvents(cons, func(m jetstream.Msg) {
|
||||
if err := m.Ack(); err != nil {
|
||||
logf("acknowledging %s: %v", m.Subject(), err)
|
||||
}
|
||||
node, ours, err := enginereads.NodeOf(m.Subject(), m.Data())
|
||||
switch {
|
||||
case !ours:
|
||||
case err != nil:
|
||||
logf("%s holds what is not %s, and %s is asked again all the same: %v", m.Subject(), enginereads.Event, node, err)
|
||||
reads.AskNode(node, enginereads.Event+" that does not read")
|
||||
default:
|
||||
reads.AskNode(node, enginereads.Event)
|
||||
}
|
||||
}, logf)
|
||||
if err != nil {
|
||||
w.NotHeard(process.EventsStream, enginereads.Event+" is not heard until the assigner starts again", err)
|
||||
return
|
||||
}
|
||||
w.Heard(process.EventsStream)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
cc.Stop()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -18,20 +18,14 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
|
||||
// 1297/B2a: the buckets the program opens are exactly the manifest's reads and states, as the broker names them
|
||||
// (`<module>.<state>` read as the bucket `<module>_<state>`), so it is granted what it reads and reads nothing else.
|
||||
func TestOpensWhatItsManifestNames(t *testing.T) {
|
||||
// Review round 2: the assigner's views emitter writes its finding view-unsaid (Emitter.Findings set) when it keeps findings.
|
||||
findings := assign.Findings{Put: func(string, []byte) error { return nil }, Delete: func(string) error { return nil }}
|
||||
if viewsEmitter(nil, process.Account{}, nil, findings, nil, t.Logf).Findings == nil {
|
||||
t.Error("the assigner's views emitter writes no view-unsaid finding")
|
||||
}
|
||||
// 1297/B2a: without a credential the program does not start, and says why.
|
||||
if err := run(context.Background(), func(string) string { return "" }, io.Discard); err == nil || !strings.Contains(err.Error(), "MESH_BROKER_FILE") {
|
||||
t.Errorf("run without a credential = %v", err)
|
||||
@@ -63,7 +57,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
|
||||
for _, pair := range []struct {
|
||||
what string
|
||||
code, want []string
|
||||
}{{"reads", readBuckets, reads}, {"states", append(slices.Clone(ownBuckets), marksBucket, firstViewsBucket), states}} { // the marks are bound by the views family (views.go)
|
||||
}{{"reads", readBuckets, reads}, {"states", ownBuckets, states}} {
|
||||
got, want := slices.Sorted(slices.Values(pair.code)), slices.Sorted(slices.Values(pair.want))
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("the program's %s are %v, the manifest's %v", pair.what, got, want)
|
||||
@@ -73,7 +67,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
|
||||
for _, s := range keptStates {
|
||||
kept = append(kept, m.Module+"_"+s.Name)
|
||||
}
|
||||
if got, want := slices.Sorted(slices.Values(kept)), slices.Sorted(slices.Values(append(slices.Clone(ownBuckets), marksBucket, firstViewsBucket))); !reflect.DeepEqual(got, want) {
|
||||
if got, want := slices.Sorted(slices.Values(kept)), slices.Sorted(slices.Values(ownBuckets)); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("the program announces %v, keeps %v", got, want)
|
||||
}
|
||||
// ADR 0546: whatever keeps the puts' bucket from opening (not there, not granted, no answer in time), the assigner opens
|
||||
@@ -119,7 +113,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
|
||||
}
|
||||
kvs[delivery.AssignerPutsBucket].(*watchKV).closed = true
|
||||
kvs[delivery.AssignerWantsBucket].(*watchKV).wrote = wrote
|
||||
go follow(ctx, nil, nil, kvs, func(oc.Condition) error { return nil }, assign.Findings{}, nil, nil, log.New(io.Discard, "", 0))
|
||||
go follow(ctx, nil, nil, kvs, func(asks.Condition) error { return nil }, assign.Findings{}, nil, log.New(io.Discard, "", 0))
|
||||
select {
|
||||
case key := <-wrote:
|
||||
if key != "want.ace.node" {
|
||||
|
||||
@@ -10,13 +10,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
|
||||
@@ -29,7 +27,7 @@ type passer struct {
|
||||
sdk string
|
||||
// say publishes a condition to the operator; said is, by condition key and state, what this process has said, so a
|
||||
// pass says a condition once and a restart says it again (the router keeps one per key).
|
||||
say func(oc.Condition) error
|
||||
say func(asks.Condition) error
|
||||
said map[string]bool
|
||||
// findings is the assigner's findings bucket (its said conditions, ADR 0564 §2).
|
||||
findings assign.Findings
|
||||
@@ -70,40 +68,6 @@ type passer struct {
|
||||
// saidWaits is the last said of the seat's wait for its states (sayWaits).
|
||||
saidWaits string
|
||||
intended map[string]map[string]map[string]bool
|
||||
// reads keeps what each node's engine states it reads, asked through the node-engine seat's verb (ADR 0618 §3); nil reads
|
||||
// each node's record, as the rules did before the verb. online is, by node, whether the last pass saw it online.
|
||||
reads *enginereads.Keeper
|
||||
online map[string]bool
|
||||
}
|
||||
|
||||
// engineReads sets what the rules read of each node's engine (ADR 0618 §3, §4): the statement the verb manifest-reads
|
||||
// answered, or its record where the verb was not answered (the fallback) or not asked yet. A node is asked when the
|
||||
// assigner first hears of it, and again when it comes back online, since an engine built before the event says none.
|
||||
func (p *passer) engineReads(n assign.Nodes) {
|
||||
if p.reads == nil {
|
||||
return
|
||||
}
|
||||
known := p.w.knownNodes()
|
||||
first := p.online == nil
|
||||
for _, node := range known {
|
||||
switch {
|
||||
case first:
|
||||
p.reads.AskNode(node, "the assigner started")
|
||||
case !p.reads.Known(node):
|
||||
p.reads.AskNode(node, "first heard of")
|
||||
case !first && n.Online[node] && !p.online[node]:
|
||||
p.reads.AskNode(node, "back online")
|
||||
}
|
||||
}
|
||||
p.online = maps.Clone(n.Online)
|
||||
if p.online == nil {
|
||||
p.online = map[string]bool{}
|
||||
}
|
||||
for node, reads := range p.reads.Reads(known) {
|
||||
if p.reads.Known(node) {
|
||||
n.Reads[node] = reads
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *passer) clock() time.Time {
|
||||
@@ -143,7 +107,7 @@ 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(delivery.ManifestsBucket) // the indexer's manifests, as its first view and views family say them
|
||||
get := kvGetter(p.kvs[delivery.ManifestsBucket])
|
||||
assigned := p.w.assigned()
|
||||
trunk := p.w.trunk()
|
||||
manifests := map[string]catalogue.Manifest{}
|
||||
@@ -156,7 +120,6 @@ func (p *passer) wants(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
nodes := p.w.nodes(manifests, assigned)
|
||||
p.engineReads(nodes)
|
||||
nodesOf := map[string][]string{}
|
||||
for node, modules := range assigned {
|
||||
for _, m := range modules {
|
||||
@@ -192,14 +155,10 @@ 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)
|
||||
for name := range names {
|
||||
fp := trunk[name].FP2
|
||||
name := name
|
||||
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),
|
||||
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name],
|
||||
Self: func(node string) bool { return placed[node] },
|
||||
Composed: func(node, fp string) (delivery.ComposedDigest, bool) {
|
||||
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
|
||||
@@ -215,8 +174,7 @@ func (p *passer) wants(ctx context.Context) {
|
||||
Allowed: func(node, digest string) bool {
|
||||
key, err := delivery.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, _ := delivery.HeldKey(name, fp)
|
||||
grant, _ := delivery.PartGrantKey(name, fp)
|
||||
@@ -233,10 +191,6 @@ func (p *passer) wants(ctx context.Context) {
|
||||
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)
|
||||
@@ -554,7 +508,7 @@ func (p *passer) holdAsk(ctx context.Context, module, fp string, fresh *delivery
|
||||
asking := int(now.Unix())
|
||||
h := p.heldHold(module, fp, fresh)
|
||||
a := assign.HoldAsk(key, module, fp, h.Node, h.Words, asking, now)
|
||||
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
|
||||
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) })
|
||||
}
|
||||
|
||||
// lift takes away a hold the assigner lifted by itself, since every node that runs the module runs the held fingerprint healthy
|
||||
@@ -737,7 +691,7 @@ func (p *passer) restoreAsk(ctx context.Context, module, node, fp string) {
|
||||
}
|
||||
words := p.heldHold(module, fp, nil).Words
|
||||
a := assign.RestoreAsk(key, node, module, fp, words, time.Now())
|
||||
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
|
||||
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) })
|
||||
}
|
||||
|
||||
// answeredWith says whether the teller wrote an answer to the ask at key as it stands now and the option chosen is option. The
|
||||
@@ -749,12 +703,12 @@ func (p *passer) answeredWith(key, option string) bool {
|
||||
if !ok || a.Option != option {
|
||||
return false
|
||||
}
|
||||
_, ask, err := assign.DecodeAsk(key, p.w.value(delivery.AssignerAsksBucket, key))
|
||||
ask, err := delivery.DecodeAssignerAsk(key, p.w.value(delivery.AssignerAsksBucket, key))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
id, _, _ := strings.Cut(a.Warrant, "@")
|
||||
return id == ask.ID
|
||||
return id == ask.Ask.ID
|
||||
}
|
||||
|
||||
// clearHeld clears, once, the condition of a hold the operator released.
|
||||
@@ -779,7 +733,7 @@ func (p *passer) recordAsk(ctx context.Context, module, fp string, need assign.R
|
||||
return
|
||||
}
|
||||
a := assign.RecordAsk(key, need.Node, module, fp, need, time.Now())
|
||||
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
|
||||
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) })
|
||||
}
|
||||
|
||||
// backups sees to the modules held on a node because the backup before an apply failed (the have's phase held, ADR 0539 §16):
|
||||
@@ -810,7 +764,7 @@ func (p *passer) backups(ctx context.Context) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
current, currentAsk, there := assign.DecodeAsk(akey, p.w.read(delivery.AssignerAsksBucket)[akey])
|
||||
current, there := delivery.DecodeAssignerAsk(akey, p.w.read(delivery.AssignerAsksBucket)[akey])
|
||||
if there != nil || assign.BackupAskDigest(current) != want.Composed {
|
||||
after := -1
|
||||
if there == nil {
|
||||
@@ -820,7 +774,7 @@ func (p *passer) backups(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
answer, answered := p.w.answer(akey)
|
||||
if router, _, _ := strings.Cut(answer.Warrant, "@"); !answered || router != currentAsk.ID ||
|
||||
if router, _, _ := strings.Cut(answer.Warrant, "@"); !answered || router != current.Ask.ID ||
|
||||
(answer.Option != delivery.BackupAgain && answer.Option != delivery.BackupSkip) {
|
||||
continue // not answered yet: the ask stands
|
||||
}
|
||||
@@ -831,7 +785,7 @@ func (p *passer) backups(ctx context.Context) {
|
||||
if wkey, err := delivery.WantKey(node, module); err == nil {
|
||||
w := want
|
||||
p.put(ctx, delivery.AssignerWantsBucket, wkey, func() ([]byte, error) { return delivery.EncodeWant(w) })
|
||||
p.answeredAt[currentAsk.ID] = haveChanges
|
||||
p.answeredAt[current.Ask.ID] = haveChanges
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -839,8 +793,8 @@ func (p *passer) backups(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
// The answer is in the want. The node retried and the backup failed again when the have took another value since.
|
||||
if seen, known := p.answeredAt[currentAsk.ID]; !known {
|
||||
p.answeredAt[currentAsk.ID] = haveChanges // after a restart: wait for the next failure
|
||||
if seen, known := p.answeredAt[current.Ask.ID]; !known {
|
||||
p.answeredAt[current.Ask.ID] = haveChanges // after a restart: wait for the next failure
|
||||
} else if haveChanges > seen {
|
||||
p.backupAsk(ctx, akey, node, module, want, have.Detail, assign.NextBackupAsking(current.Asking))
|
||||
}
|
||||
@@ -850,7 +804,7 @@ func (p *passer) backups(ctx context.Context) {
|
||||
// backupAsk writes the backup ask at its asking and raises the condition of the failed backup, once.
|
||||
func (p *passer) backupAsk(ctx context.Context, key, node, module string, want delivery.Want, words string, asking int) {
|
||||
a := assign.BackupAsk(key, node, module, want.FP, want.Composed, words, asking, time.Now())
|
||||
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
|
||||
p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) })
|
||||
c := assign.BackupCondition(node, module, want.FP, words, true)
|
||||
if p.say == nil || p.said[c.Key] {
|
||||
return
|
||||
@@ -865,9 +819,7 @@ func (p *passer) backupAsk(ctx context.Context, key, node, module string, want d
|
||||
// yeses writes a placed node's wants naming the build alone, and sees to the moves a node that composes its own judged a
|
||||
// person must allow (ADR 0547 §3): for a have of phase
|
||||
// waiting-yes it raises the record ask once, naming the composition waiting and the node's judgement, and on a yes writes
|
||||
// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. A
|
||||
// move of the build alone (the have says so) to a build an approved release carries is given its yes by that Approve, the
|
||||
// want naming the release, with no ask (ADR 0601 §1).
|
||||
// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again.
|
||||
func (p *passer) yeses(ctx context.Context, module string, m assign.Module, before map[string]delivery.Want, out *assign.Outcome) {
|
||||
for _, node := range m.Nodes {
|
||||
if m.Self == nil || !m.Self(node) {
|
||||
@@ -884,37 +836,25 @@ func (p *passer) yeses(ctx context.Context, module string, m assign.Module, befo
|
||||
if w.Removed {
|
||||
continue
|
||||
}
|
||||
yes, release := "", ""
|
||||
yes := ""
|
||||
if had && was.FP == w.FP {
|
||||
yes, release = was.Yes, was.Release
|
||||
yes = was.Yes
|
||||
}
|
||||
key, err := delivery.HaveKey(node, module)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if h, err := delivery.DecodeHave(p.w.value(delivery.HaveBucket, key)); err == nil && h.Phase == delivery.PhaseWaitingYes && h.Waits != yes {
|
||||
switch {
|
||||
case m.Allowed != nil && m.Allowed(node, h.Waits):
|
||||
yes, release = h.Waits, ""
|
||||
case h.BuildAlone && h.FP != w.FP && m.Release != nil:
|
||||
// A move of the build alone, to the build an approved release carries: that Approve is the yes (ADR 0601 §1),
|
||||
// named in the want. A move that changes a setting or a secret too, or one the node could not tell, is asked.
|
||||
if r, ok := m.Release(w.FP); ok {
|
||||
yes, release = h.Waits, r
|
||||
break
|
||||
}
|
||||
p.recordAsk(ctx, module, w.FP, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail})
|
||||
default:
|
||||
if m.Allowed != nil && m.Allowed(node, h.Waits) {
|
||||
yes = h.Waits
|
||||
} else {
|
||||
p.recordAsk(ctx, module, w.FP, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail})
|
||||
}
|
||||
}
|
||||
// A want carried over from before the node composed its own may still name the controller's composition; a placed
|
||||
// node's want names the build alone (ADR 0547 §2).
|
||||
if yes == "" {
|
||||
release = ""
|
||||
}
|
||||
if yes != w.Yes || release != w.Release || w.Composed != "" || !w.Self {
|
||||
w.Yes, w.Release, w.Composed, w.Self = yes, release, "", true
|
||||
if yes != w.Yes || w.Composed != "" || !w.Self {
|
||||
w.Yes, w.Composed, w.Self = yes, "", true
|
||||
out.Wants[node] = w
|
||||
}
|
||||
}
|
||||
@@ -1131,26 +1071,6 @@ func (p *passer) seatHolders(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// offered says whether some manifest of the trunk, or a module the mesh provides itself, offers a provision under its name
|
||||
// (a module offers its own name too, catalogue.Manifest.Offers): what a requirement nobody offers is told from.
|
||||
func offered(manifests map[string]catalogue.Manifest) func(string) bool {
|
||||
names := map[string]bool{}
|
||||
add := func(m catalogue.Manifest) {
|
||||
for _, o := range m.Offers() {
|
||||
names[o] = true
|
||||
}
|
||||
}
|
||||
for _, m := range manifests {
|
||||
add(m)
|
||||
}
|
||||
for _, module := range delivery.ProvidedModules() {
|
||||
if m, _, err := delivery.ProvidedManifest(module); err == nil {
|
||||
add(m)
|
||||
}
|
||||
}
|
||||
return func(provision string) bool { return names[provision] }
|
||||
}
|
||||
|
||||
// answering is, by provision, the manifests of the modules placed on some node that provide it: what a want's
|
||||
// provisions are judged by (catalogue.Awaited, issue 774). A provider in the catalogue placed nowhere answers nobody.
|
||||
func answering(manifests map[string]catalogue.Manifest, n assign.Nodes) func(string) []catalogue.Manifest {
|
||||
|
||||
@@ -13,16 +13,10 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/busretry"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
|
||||
@@ -95,7 +89,7 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) {
|
||||
held := &delivery.Held{Node: "ace", Words: "health failed"}
|
||||
first := newPasser(kvs, time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC))
|
||||
first.holdAsk(context.Background(), "web", fpTest, held)
|
||||
a1, err := decodeAsk(key, asksKV.values[key])
|
||||
a1, err := delivery.DecodeAssignerAsk(key, asksKV.values[key])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,7 +99,7 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) {
|
||||
}
|
||||
again := newPasser(kvs, time.Date(2026, 10, 25, 2, 0, 0, 0, time.UTC)) // started again: it remembers nothing
|
||||
again.holdAsk(context.Background(), "web", fpTest, held)
|
||||
a2, err := decodeAsk(key, asksKV.values[key])
|
||||
a2, err := delivery.DecodeAssignerAsk(key, asksKV.values[key])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,24 +111,16 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) {
|
||||
// 1906/H4: an answer counts only for the asking it answered: an answer to an earlier hold of the same fingerprint, or to an ask
|
||||
// taken away, is not taken for the hold that stands now.
|
||||
func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
|
||||
// ADR 0603 §5: a provision is offered by a manifest's provides, by a module's own name, and by a module the mesh provides
|
||||
// itself (mesh-addressing, mesh-wireguard's); nothing else.
|
||||
off := offered(map[string]catalogue.Manifest{"node-tools": {Module: "node-tools"}, "db": {Module: "db", Provides: []catalogue.Offer{{Name: "database"}}}})
|
||||
for provision, want := range map[string]bool{"node-tools": true, "database": true, "mesh-addressing": true, "queue": false} {
|
||||
if off(provision) != want {
|
||||
t.Errorf("offered(%s) = %v, want %v", provision, !want, want)
|
||||
}
|
||||
}
|
||||
key, _ := delivery.HoldAskKey("web", fpTest)
|
||||
p := newPasser(buckets{}, time.Now())
|
||||
put := func(asking int) string {
|
||||
a := assign.HoldAsk(key, "web", fpTest, "ace", "health failed", asking, time.Now())
|
||||
v, err := assign.EncodeAsk(key, a)
|
||||
v, err := delivery.EncodeAssignerAsk(key, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.w.put(delivery.AssignerAsksBucket, key, v)
|
||||
return mustAskID(a)
|
||||
return a.Ask.ID
|
||||
}
|
||||
first := put(100)
|
||||
v, _ := delivery.EncodeAnswer(delivery.Answer{Option: "release", Warrant: first + "@2026-10-25T01:00:00Z", Words: "the operator"})
|
||||
@@ -154,89 +140,6 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
|
||||
t.Error("an answer to an ask taken away was taken")
|
||||
}
|
||||
t.Run("a put of the bus acts on the operator's warrant alone", putOfTheBus)
|
||||
// ADR 0614 §4: the rules read what each node's engine states it reads from its record in `<node>.node`; a node with no
|
||||
// record, or one that states nothing, is read as stating nothing (the rules then read the baseline).
|
||||
t.Run("the rules read what each node's engine reads", func(t *testing.T) {
|
||||
w := newWorld()
|
||||
for node, reads := range map[string][]string{"ace": {"word:module", "word:tool-runner"}, "g14": nil} {
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node, ManifestReads: reads}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.HaveNodeKey(node)
|
||||
w.put(delivery.HaveBucket, key, raw)
|
||||
}
|
||||
n := w.nodes(nil, nil)
|
||||
if !slices.Equal(n.Reads["ace"], []string{"word:module", "word:tool-runner"}) || len(n.Reads["g14"]) != 0 || len(n.Reads["novox"]) != 0 {
|
||||
t.Errorf("the nodes' reads are %v", n.Reads)
|
||||
}
|
||||
// ADR 0618 §3: with the verb asked, a node's reads are what its engine answered; where the engine answers it does not
|
||||
// serve the verb, its record is read once in its place (the fallback, issue 774); a node asked again when it comes
|
||||
// back online, since an engine built before the event says none.
|
||||
asked := map[string]int{}
|
||||
call := func(node, key string, _ any) (json.RawMessage, error) {
|
||||
asked[node]++
|
||||
if node == "g14" || key != enginereads.Invoke {
|
||||
return nil, &busretry.Answered{Words: "the seat node-engine serves no verb manifest-reads"}
|
||||
}
|
||||
return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)})
|
||||
}
|
||||
p := &passer{w: w, reads: &enginereads.Keeper{Call: call, Fallback: w.recordReads, Sync: true}}
|
||||
on, _ := delivery.EncodePresence(delivery.Presence{Online: false})
|
||||
w.put(delivery.PresenceBucket, "ace", on)
|
||||
n = w.nodes(nil, nil)
|
||||
p.engineReads(n)
|
||||
if !slices.Equal(n.Reads["ace"], []string{"form:new"}) || len(n.Reads["g14"]) != 0 || asked["ace"] != 1 {
|
||||
t.Errorf("with the verb the nodes' reads are %v (asked %v)", n.Reads, asked)
|
||||
}
|
||||
if s := p.reads.Statements(); s["ace"].From != enginereads.FromVerb || s["g14"].From != enginereads.FromRecord {
|
||||
t.Errorf("the statements are %+v", s)
|
||||
}
|
||||
p.engineReads(w.nodes(nil, nil))
|
||||
on, _ = delivery.EncodePresence(delivery.Presence{Online: true})
|
||||
w.put(delivery.PresenceBucket, "ace", on)
|
||||
p.engineReads(w.nodes(nil, nil))
|
||||
if asked["ace"] != 2 {
|
||||
t.Errorf("ace, back online, was asked %d times, want 2", asked["ace"])
|
||||
}
|
||||
// The contract carries no checks, so its reader judges what the engine says (mesh-contracts seats/nodeengine): an
|
||||
// answer or an event that does not hold is no statement, and an event's node is its subject's last token.
|
||||
now := time.Now().Format(time.RFC3339Nano)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
raw string
|
||||
stays bool
|
||||
}{
|
||||
{"a whole answer", `{"v":1,"node":"ace","manifest-reads":["form:new"],"stated-at":"` + now + `"}`, true},
|
||||
{"an engine stating nothing", `{"v":1,"node":"ace","stated-at":"` + now + `"}`, true},
|
||||
{"another layout", `{"v":2,"node":"ace","stated-at":"` + now + `"}`, false},
|
||||
{"no node", `{"v":1,"node":"","stated-at":"` + now + `"}`, false},
|
||||
{"a node that is no token", `{"v":1,"node":"a.ce","stated-at":"` + now + `"}`, false},
|
||||
{"another node's answer", `{"v":1,"node":"g14","stated-at":"` + now + `"}`, false},
|
||||
{"an empty capability", `{"v":1,"node":"ace","manifest-reads":[""],"stated-at":"` + now + `"}`, false},
|
||||
{"a time that is no time", `{"v":1,"node":"ace","stated-at":"yesterday"}`, false},
|
||||
} {
|
||||
s := enginereads.Ask(func(string, string, any) (json.RawMessage, error) { return json.RawMessage(row.raw), nil }, "ace", time.Now())
|
||||
if (s.From == enginereads.FromVerb) != row.stays || !s.Answered {
|
||||
t.Errorf("answer %s: %+v", row.name, s)
|
||||
}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name, subject, raw string
|
||||
ours, fails bool
|
||||
}{
|
||||
{"the event", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"returned"}`, true, false},
|
||||
{"another reason", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"bored"}`, true, true},
|
||||
{"another node in the body", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"g14","at":"` + now + `","why":"started"}`, true, true},
|
||||
{"a time that is no time", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"x","why":"started"}`, true, true},
|
||||
{"another event", "mesh.seat.node-engine.event.done.ace", `{}`, false, false},
|
||||
} {
|
||||
_, ours, err := enginereads.NodeOf(row.subject, []byte(row.raw))
|
||||
if ours != row.ours || (err != nil) != row.fails {
|
||||
t.Errorf("event %s: ours %v, %v", row.name, ours, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// memEntry is a value a memKV holds, as a bucket's Get answers it.
|
||||
@@ -398,8 +301,8 @@ func putOfTheBus(t *testing.T) {
|
||||
if got.Step != delivery.PutPerson || got.Ask != akey {
|
||||
t.Fatalf("composed, the put stands at %s (%s), ask %q", got.Step, got.Words, got.Ask)
|
||||
}
|
||||
a, err := decodeAsk(akey, asksKV.values[akey])
|
||||
if err != nil || a.Ask.Options[0].Level != occ.Approve {
|
||||
a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey])
|
||||
if err != nil || a.Ask.Options[0].Level != asks.Approve {
|
||||
t.Fatalf("the ask is %+v, %v; want a yes that approves", a, err)
|
||||
}
|
||||
if _, there := wantsKV.values["want.novox.nats"]; there {
|
||||
@@ -447,7 +350,7 @@ func putOfTheBus(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := step()
|
||||
a2, err := decodeAsk(akey, asksKV.values[akey])
|
||||
a2, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey])
|
||||
if again.Step != delivery.PutPerson || err != nil || a2.Ask.ID == a.Ask.ID {
|
||||
t.Fatalf("put again, it stands at %s (%s); its ask %q, %v; the first ask was %q", again.Step, again.Words, a2.Ask.ID, err, a.Ask.ID)
|
||||
}
|
||||
@@ -477,13 +380,11 @@ func putOfTheBus(t *testing.T) {
|
||||
func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
|
||||
// A pair only one source names is a condition of the same kind (issue 1126): folded here to hold the count.
|
||||
aPlacementAloneNamesWhereModulesRun(t)
|
||||
// The verb edges a want waits on come from the graph and what runs (ADR 0614 §2, §3): folded here to hold the count.
|
||||
aWantWaitsOnTheVerbEdgesTheGraphKeeps(t)
|
||||
t.Run("gathers", testThePassGathersTheModulesOnNeitherList)
|
||||
p := newPasser(buckets{}, time.Date(2026, 10, 25, 15, 0, 0, 0, time.UTC))
|
||||
var said []oc.Condition
|
||||
var said []asks.Condition
|
||||
failing := true
|
||||
p.say = func(c oc.Condition) error {
|
||||
p.say = func(c asks.Condition) error {
|
||||
if failing {
|
||||
failing = false
|
||||
return errors.New("the channel is away")
|
||||
@@ -498,7 +399,7 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
|
||||
p.sayNeither(one, nil, nil) // the say fails
|
||||
p.sayNeither(one, nil, nil)
|
||||
p.sayNeither(one, nil, nil)
|
||||
if len(said) != 1 || said[0].State != occ.StateRaised || said[0].Subject.Machine != "g14" || said[0].Subject.ID != "screen-lock" {
|
||||
if len(said) != 1 || said[0].State != asks.StateRaised || said[0].Subject.Machine != "g14" || said[0].Subject.ID != "screen-lock" {
|
||||
t.Fatalf("said = %+v, want one raised condition of screen-lock on g14", said)
|
||||
}
|
||||
if !strings.Contains(strings.Join(logged, "\n"), "screen-lock") {
|
||||
@@ -507,21 +408,20 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
|
||||
wants := map[string]string{"g14.screen-lock": ""}
|
||||
p.sayNeither(map[string]neitherOne{}, nil, wants)
|
||||
p.sayNeither(map[string]neitherOne{}, nil, wants)
|
||||
if len(said) != 2 || said[1].State != occ.StateCleared || said[1].Key != said[0].Key {
|
||||
if len(said) != 2 || said[1].State != asks.StateCleared || said[1].Key != said[0].Key {
|
||||
t.Errorf("said = %+v, want the condition cleared once under its key", said)
|
||||
}
|
||||
if last := logged[len(logged)-1]; last != "screen-lock on g14 has a want again" {
|
||||
t.Errorf("the clear said %q", last)
|
||||
}
|
||||
// 1945: a module that leaves neither list with no want is cleared in words saying so, never "has a want again". The bus is
|
||||
// on neither list by a rule as any module (ADR 0601 §1), nothing for the operator to do.
|
||||
bus := map[string]neitherOne{"novox.nats": {node: "novox", module: "nats", holding: assign.Holding{Rule: delivery.RuleGrant, Words: "no credential"}}}
|
||||
// 1945 (the bus bar): a module that leaves neither list with no want is cleared in words saying so, never "has a want again".
|
||||
bus := map[string]neitherOne{"novox.nats": {node: "novox", module: "nats", holding: assign.Holding{Bus: true, Words: "the bus moves only by the operator's put"}}}
|
||||
p.sayNeither(bus, nil, nil)
|
||||
if c := said[len(said)-1]; c.State != occ.StateRaised || !strings.HasPrefix(c.Explanation, occ.NothingToDo) || !strings.Contains(c.Explanation, "rule 6 holds its first want back: no credential") {
|
||||
if c := said[len(said)-1]; c.State != asks.StateRaised || !strings.HasPrefix(c.Explanation, asks.NeedsYou) || !strings.Contains(c.Explanation, "the bus moves only by the operator's put") {
|
||||
t.Errorf("the bus's neither condition = %+v", c)
|
||||
}
|
||||
p.sayNeither(map[string]neitherOne{}, nil, map[string]string{"novox.nats": "it is held, and its held condition says so"})
|
||||
if c := said[len(said)-1]; c.State != occ.StateCleared || strings.Contains(c.Resolved, "has a want") {
|
||||
if c := said[len(said)-1]; c.State != asks.StateCleared || strings.Contains(c.Resolved, "has a want") {
|
||||
t.Errorf("a module that left neither list with no want was cleared as %+v", c)
|
||||
}
|
||||
if last := logged[len(logged)-1]; last != "nats on novox is no longer on neither list and has no want: it is held, and its held condition says so" {
|
||||
@@ -574,6 +474,7 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
|
||||
{nil, assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: delivery.RuleGrant, Words: "no credential"}}}, "rofi on shanks gets no want: rule 6: no credential"},
|
||||
{nil, wanted, "rofi on shanks has a want"},
|
||||
{map[string]delivery.Want{"shanks": wanted.Wants["shanks"]}, assign.Outcome{}, ""},
|
||||
{nil, assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Bus: true, Words: "the bus moves only by the operator's put"}}}, "rofi on shanks gets no want: the bus moves only by the operator's put"},
|
||||
} {
|
||||
logged = nil
|
||||
p.sayNoWant("rofi", []string{"shanks"}, c.before, c.out)
|
||||
@@ -583,16 +484,16 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
|
||||
}
|
||||
// 1496, ADR 0557 §1: a failure that is the node's own (its fingerprint healthy elsewhere, not held) raises the node's condition
|
||||
// once, naming the node, the module, F and the have's words, and clears it once when it ends.
|
||||
var conds []oc.Condition
|
||||
p.say = func(c oc.Condition) error { conds = append(conds, c); return nil }
|
||||
var conds []asks.Condition
|
||||
p.say = func(c asks.Condition) error { conds = append(conds, c); return nil }
|
||||
failing := map[string]assign.NodeFailure{"shanks": {Others: []string{"ace"}, Words: "health failed, phase running: w"}}
|
||||
for i, c := range []struct {
|
||||
failed map[string]assign.NodeFailure
|
||||
state string // the condition said in this pass, "" for none
|
||||
}{
|
||||
{failing, occ.StateRaised},
|
||||
{failing, asks.StateRaised},
|
||||
{failing, ""},
|
||||
{nil, occ.StateCleared},
|
||||
{nil, asks.StateCleared},
|
||||
{nil, ""},
|
||||
} {
|
||||
conds = nil
|
||||
@@ -600,7 +501,7 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
|
||||
switch {
|
||||
case c.state == "" && len(conds) != 0, c.state != "" && (len(conds) != 1 || conds[0].State != c.state):
|
||||
t.Errorf("node failed, pass %d said %+v, want %q", i, conds, c.state)
|
||||
case c.state == occ.StateRaised && (conds[0].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" ||
|
||||
case c.state == asks.StateRaised && (conds[0].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" ||
|
||||
!strings.Contains(conds[0].Explanation, "failed on shanks (health failed, phase running: w), while it runs healthy on ace")):
|
||||
t.Errorf("node failed, raised %+v", conds[0])
|
||||
}
|
||||
@@ -611,7 +512,7 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
|
||||
p.sayNodeFailed("rofi", fpTest, []string{"shanks"}, assign.Nodes{}, assign.Outcome{NodeFailed: failing}, false)
|
||||
p.sayNodeFailed("rofi", fpTest, nil, assign.Nodes{}, assign.Outcome{}, false)
|
||||
p.sayNodeFailed("rofi", fpTest, nil, assign.Nodes{}, assign.Outcome{}, false)
|
||||
if len(conds) != 2 || conds[1].State != occ.StateCleared || conds[1].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" {
|
||||
if len(conds) != 2 || conds[1].State != asks.StateCleared || conds[1].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" {
|
||||
t.Errorf("a node leaving the module said %+v, want one raise and one clear", conds)
|
||||
}
|
||||
known := assign.Nodes{Online: map[string]bool{"ace": true, "g14": false},
|
||||
@@ -627,11 +528,11 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
|
||||
conds = nil
|
||||
fresh := newPasser(buckets{}, time.Date(2026, 10, 26, 22, 0, 0, 0, time.UTC))
|
||||
fresh.logf = func(string, ...any) {}
|
||||
fresh.say = func(c oc.Condition) error { conds = append(conds, c); return nil }
|
||||
fresh.say = func(c asks.Condition) error { conds = append(conds, c); return nil }
|
||||
fresh.sayNodeFailed("rofi", fpTest, nil, c.known, assign.Outcome{}, true)
|
||||
var keys []string
|
||||
for _, cond := range conds {
|
||||
if cond.State == occ.StateCleared {
|
||||
if cond.State == asks.StateCleared {
|
||||
keys = append(keys, cond.Key)
|
||||
}
|
||||
}
|
||||
@@ -648,8 +549,8 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
|
||||
t.Run("a held module", testAHeldModuleRaisesNoNeitherAndClearsAStandingOne)
|
||||
wantsKV := &memKV{values: map[string][]byte{}}
|
||||
p := newPasser(buckets{delivery.AssignerWantsBucket: wantsKV, delivery.AssignerAsksBucket: &memKV{values: map[string][]byte{}}}, time.Now())
|
||||
var said []oc.Condition
|
||||
p.say = func(c oc.Condition) error { said = append(said, c); return nil }
|
||||
var said []asks.Condition
|
||||
p.say = func(c asks.Condition) error { said = append(said, c); return nil }
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
|
||||
p.w.put(delivery.ModulesBucket, "web", mod)
|
||||
placeOn(t, p.w, "g14", "web")
|
||||
@@ -663,7 +564,7 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
|
||||
p.wants(context.Background())
|
||||
cleared := map[string]bool{}
|
||||
for _, c := range said {
|
||||
if c.State != occ.StateCleared {
|
||||
if c.State != asks.StateCleared {
|
||||
t.Errorf("the first pass raised %+v", c)
|
||||
}
|
||||
cleared[c.Key] = true
|
||||
@@ -684,10 +585,10 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
|
||||
// neither condition for it, a neither condition this process raised for it before is cleared, and so is one a previous process
|
||||
// raised (the first pass after a start), each once.
|
||||
func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) {
|
||||
setup := func() (*passer, *[]oc.Condition) {
|
||||
setup := func() (*passer, *[]asks.Condition) {
|
||||
p := newPasser(buckets{delivery.AssignerWantsBucket: &memKV{values: map[string][]byte{}}, delivery.AssignerAsksBucket: &memKV{values: map[string][]byte{}}}, time.Now())
|
||||
said := &[]oc.Condition{}
|
||||
p.say = func(c oc.Condition) error { *said = append(*said, c); return nil }
|
||||
said := &[]asks.Condition{}
|
||||
p.say = func(c asks.Condition) error { *said = append(*said, c); return nil }
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/resolved", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
|
||||
p.w.put(delivery.ModulesBucket, "systemd-resolved", mod)
|
||||
placeOn(t, p.w, "shanks", "systemd-resolved")
|
||||
@@ -701,12 +602,12 @@ func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) {
|
||||
return p, said
|
||||
}
|
||||
key := assign.NeitherCondition("shanks", "systemd-resolved", assign.Holding{}, false).Key
|
||||
neithers := func(said []oc.Condition) (raised, cleared int) {
|
||||
neithers := func(said []asks.Condition) (raised, cleared int) {
|
||||
for _, c := range said {
|
||||
if c.Key != key {
|
||||
continue
|
||||
}
|
||||
if c.State == occ.StateRaised {
|
||||
if c.State == asks.StateRaised {
|
||||
raised++
|
||||
} else {
|
||||
cleared++
|
||||
@@ -765,8 +666,8 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
|
||||
p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now())
|
||||
var said []oc.Condition
|
||||
p.say = func(c oc.Condition) error { said = append(said, c); return nil }
|
||||
var said []asks.Condition
|
||||
p.say = func(c asks.Condition) error { said = append(said, c); return nil }
|
||||
for _, key := range all {
|
||||
k, _ := delivery.ParseAnyAskKey(key)
|
||||
var a delivery.AssignerAsk
|
||||
@@ -780,7 +681,7 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
default:
|
||||
a = assign.BackupAsk(key, k.Node, k.Module, k.FP, digOld, "w", 2, time.Now())
|
||||
}
|
||||
v, err := assign.EncodeAsk(key, a)
|
||||
v, err := delivery.EncodeAssignerAsk(key, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -794,13 +695,13 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
heldOld, _ := delivery.HeldKey("web", fpOld)
|
||||
wantsKV.values[heldOld] = []byte("x")
|
||||
p.w.put(delivery.AssignerWantsBucket, heldOld, []byte("x"))
|
||||
firstAsk, _ := decodeAsk(holdOld, asksKV.values[holdOld])
|
||||
firstAsk, _ := delivery.DecodeAssignerAsk(holdOld, asksKV.values[holdOld])
|
||||
p.staleAsks(context.Background(), c.trunk)
|
||||
// the held record and condition of a fingerprint whose hold ask went are taken away with it
|
||||
heldOld, _ = delivery.HeldKey("web", fpOld)
|
||||
_, heldThere := wantsKV.values[heldOld]
|
||||
cleared := slices.ContainsFunc(said, func(c oc.Condition) bool {
|
||||
return c.Key == assign.HeldCondition("web", fpOld, "", "", false, false).Key && c.State == occ.StateCleared
|
||||
cleared := slices.ContainsFunc(said, func(c asks.Condition) bool {
|
||||
return c.Key == assign.HeldCondition("web", fpOld, "", "", false, false).Key && c.State == asks.StateCleared
|
||||
})
|
||||
if gone := slices.Contains(c.gone, holdOld); !heldThere || cleared != gone {
|
||||
t.Errorf("hold ask of the old fingerprint gone = %v: its held record stays = %v (must), its condition cleared = %v", gone, heldThere, cleared)
|
||||
@@ -817,8 +718,8 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
p.now = func() time.Time { return time.Now().Add(time.Hour) }
|
||||
p.holdAsk(context.Background(), "web", fpOld, nil)
|
||||
p.heldCondition(context.Background(), "web", fpOld, nil, false)
|
||||
again, err := decodeAsk(holdOld, asksKV.values[holdOld])
|
||||
raised := slices.ContainsFunc(said, func(c oc.Condition) bool { return c.State == occ.StateRaised })
|
||||
again, err := delivery.DecodeAssignerAsk(holdOld, asksKV.values[holdOld])
|
||||
raised := slices.ContainsFunc(said, func(c asks.Condition) bool { return c.State == asks.StateRaised })
|
||||
if err != nil || again.Ask.ID == firstAsk.Ask.ID || !raised {
|
||||
t.Errorf("the trunk returned to the held fingerprint: ask %q (first %q, %v), condition raised again = %v", again.Ask.ID, firstAsk.Ask.ID, err, raised)
|
||||
}
|
||||
@@ -829,9 +730,9 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
t.Run("a clear that failed to be said is said at the next pass, and the ask goes after it", func(t *testing.T) {
|
||||
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
|
||||
p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now())
|
||||
var said []oc.Condition
|
||||
var said []asks.Condition
|
||||
fail := true
|
||||
p.say = func(c oc.Condition) error {
|
||||
p.say = func(c asks.Condition) error {
|
||||
if fail {
|
||||
fail = false
|
||||
return errors.New("router unreachable")
|
||||
@@ -840,7 +741,7 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
a := assign.HoldAsk(holdOld, "web", fpOld, "novox", "w", 1, time.Now())
|
||||
v, err := assign.EncodeAsk(holdOld, a)
|
||||
v, err := delivery.EncodeAssignerAsk(holdOld, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -851,7 +752,7 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
t.Fatalf("after a failed clear: ask left = %v, conditions said = %d; want the ask kept and nothing said", there, len(said))
|
||||
}
|
||||
p.staleAsks(context.Background(), trunkOf(fpNew))
|
||||
if _, there := asksKV.values[holdOld]; there || len(said) != 1 || said[0].State != occ.StateCleared {
|
||||
if _, there := asksKV.values[holdOld]; there || len(said) != 1 || said[0].State != asks.StateCleared {
|
||||
t.Errorf("at the next pass: ask left = %v, conditions said = %v; want the ask gone and the clear said", there, said)
|
||||
}
|
||||
})
|
||||
@@ -887,74 +788,6 @@ func mustKey(key string, err error) string {
|
||||
func TestARecordAskOfAHeldBackNodeSurvivesPassesAndItsYesMovesTheWant(t *testing.T) {
|
||||
// A placed node's yes comes from its have (issue 1126): folded here to hold the count.
|
||||
aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t)
|
||||
aReleasesApproveAnswersTheMoveOfItsBuildAlone(t)
|
||||
}
|
||||
|
||||
// ADR 0601 §1: a placed node's have waiting for a yes to a move of the build alone, to a build an approved release carries, is
|
||||
// given its yes by that Approve, the want naming the release, and no record ask is raised; a move that changes a setting or a
|
||||
// secret too, one at the build it runs, and one no approved release carries are asked of a person as before.
|
||||
func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) {
|
||||
fpOld := delivery.SourcePrefix2 + strings.Repeat("a", 64)
|
||||
waiting, was := "sha256:"+strings.Repeat("e", 64), "sha256:"+strings.Repeat("d", 64)
|
||||
commit := strings.Repeat("1", 40)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
alone bool
|
||||
at string // the fingerprint the have runs
|
||||
mergedAt string // the commit the release merged web at; "" for no merge of the release
|
||||
fpSrc uint64 // the source revision at which web's fingerprint last changed
|
||||
yes, asked bool
|
||||
}{
|
||||
{"the build alone, its release approved", true, fpOld, commit, 7, true, false},
|
||||
{"a setting changed too", false, fpOld, commit, 7, false, true},
|
||||
{"a setting changed at the build it runs", true, fpTest, commit, 7, false, true},
|
||||
{"no approved release carries it", true, fpOld, "", 7, false, true},
|
||||
{"the release merged another commit", true, fpOld, strings.Repeat("2", 40), 7, false, true},
|
||||
// Its fingerprint last moved at another release's revision, which merged nothing of web: that Approve answers nothing.
|
||||
{"a fingerprint another release moved", true, fpOld, commit, 8, false, true},
|
||||
// Review rounds 1 and 2: moved by a recompute after the release (a toolchain, a package, a base) with no merge, the
|
||||
// indexer writes fp-src 0, not known, though Src is still the release's: asked of a person.
|
||||
{"a fingerprint moved by a recompute after the release", true, fpOld, commit, 0, false, true},
|
||||
} {
|
||||
asksKV := &memKV{values: map[string][]byte{}}
|
||||
p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV}, time.Now())
|
||||
p.logf = t.Logf
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FPSrc: c.fpSrc, Path: delivery.RootPath})
|
||||
p.w.put(delivery.ModulesBucket, "web", mod)
|
||||
src, _ := delivery.EncodeSource(delivery.Source{Group: "feat/web", Heads: map[string]string{"novox/web": commit}})
|
||||
p.w.putEntry(delivery.SourceBucket, delivery.SourceMainKey, src, 7, time.Now())
|
||||
later, _ := delivery.EncodeSource(delivery.Source{Group: "feat/other", Heads: map[string]string{"novox/web": commit, "novox/x": commit}})
|
||||
p.w.putEntry(delivery.SourceBucket, delivery.SourceMainKey, later, 8, time.Now()) // a later merge leaves revision 7's release
|
||||
if c.mergedAt != "" {
|
||||
merge, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/web", Ask: "merge.feat_web.0123456789ab", Pulls: []delivery.MergePull{
|
||||
{Head: delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}, State: delivery.MergeMerged, Commit: c.mergedAt}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.w.put(delivery.MergesBucket, "feat/web", merge)
|
||||
other, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/other", Ask: "merge.feat_other.0123456789ab", Pulls: []delivery.MergePull{
|
||||
{Head: delivery.Head{Repository: "novox/x", Number: 4, Head: strings.Repeat("4", 40)}, State: delivery.MergeMerged, Commit: commit}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.w.put(delivery.MergesBucket, "feat/other", other)
|
||||
}
|
||||
have, _ := delivery.EncodeHave(delivery.Have{FP: c.at, Composed: was, Phase: delivery.PhaseWaitingYes, Health: delivery.HealthOK,
|
||||
Judgement: delivery.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server", BuildAlone: c.alone})
|
||||
p.w.put(delivery.HaveBucket, "g14.web", have)
|
||||
m := assign.Module{Name: "web", FP: fpTest, Nodes: []string{"g14"}, Self: func(string) bool { return true },
|
||||
Release: func(fp string) (string, bool) { return p.w.release("web", fp) }}
|
||||
out := assign.Outcome{Wants: map[string]delivery.Want{"g14": {FP: fpTest, Self: true}}}
|
||||
p.yeses(context.Background(), "web", m, map[string]delivery.Want{"g14": {FP: fpOld, Self: true}}, &out)
|
||||
w := out.Wants["g14"]
|
||||
if got := w.Yes == waiting && w.Release == "feat/web"; got != c.yes || (!c.yes && (w.Yes != "" || w.Release != "")) {
|
||||
t.Errorf("%s: the want is %+v", c.name, w)
|
||||
}
|
||||
akey, _ := delivery.RecordAskKey("g14", "web", waiting)
|
||||
if _, there := asksKV.values[akey]; there != c.asked {
|
||||
t.Errorf("%s: a record ask raised %v, want %v", c.name, there, c.asked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S26/AC1 (ADR 0549 §5, ADR 0567 §8): the seat's placement alone names where modules run: no controller state is read, so a
|
||||
@@ -1040,7 +873,7 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
|
||||
if wantsKV.puts != wantPuts {
|
||||
t.Fatalf("a want put unchanged reached the bucket")
|
||||
}
|
||||
a, err := decodeAsk(akey, asksKV.values[akey])
|
||||
a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1077,8 +910,8 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) {
|
||||
t.Fatalf("calls = %d, said = %v", len(bus.calls), said)
|
||||
}
|
||||
|
||||
var conds []oc.Condition
|
||||
p.say = func(c oc.Condition) error { conds = append(conds, c); return nil }
|
||||
var conds []asks.Condition
|
||||
p.say = func(c asks.Condition) error { conds = append(conds, c); return nil }
|
||||
fails := 1
|
||||
p.findings.Keys = func() ([]string, error) {
|
||||
if fails > 0 {
|
||||
@@ -1090,85 +923,8 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) {
|
||||
p.clearStaleCalls()
|
||||
p.clearStaleCalls()
|
||||
p.clearStaleCalls()
|
||||
if len(conds) != 2 || conds[0].State != occ.StateCleared || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" ||
|
||||
if len(conds) != 2 || conds[0].State != asks.StateCleared || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" ||
|
||||
conds[1].Key != "mesh-delivery-assigner.controller-pin.laptop.db" {
|
||||
t.Fatalf("the old conditions cleared = %+v", conds)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0614 §2, §3: of what a module invokes, the pass keeps as edges the verbs the graph does not leave out and another
|
||||
// module serves; a verb is served once a running healthy build's manifest (the builder's build, the indexer's manifest)
|
||||
// serves it, and a build whose manifest cannot be read counts what the graph says its module serves. No graph read: no wait.
|
||||
func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
|
||||
p := newPasser(buckets{}, time.Now())
|
||||
fp := func(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
|
||||
if p.served(context.Background(), nil, 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{
|
||||
"caller": {FP: fp("c"), Repository: "novox/caller", Invokes: []string{"seat:x.v", "loop.l", "none.t", "caller.own"}, LeftOut: []string{"loop.l"},
|
||||
Serves: []string{"caller.own"}},
|
||||
"server": {FP: fp("e"), Repository: "novox/server", Serves: []string{"seat:x.v", "seat:x.w"}},
|
||||
"loop": {FP: fp("1"), Repository: "novox/loop", Serves: []string{"loop.l"}},
|
||||
"late": {FP: fp("2"), Repository: "novox/late", Serves: []string{"late.t"}},
|
||||
} {
|
||||
raw, err := graph.Encode(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.w.put(graph.Bucket, module, raw)
|
||||
}
|
||||
g := p.moduleGraph()
|
||||
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, _ := delivery.EncodeManifest(delivery.Manifest{Module: "server", JSON: `{"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
|
||||
}
|
||||
build, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest})
|
||||
key, _ := delivery.BuilderBuildKey("server", fp("f"))
|
||||
p.w.put(delivery.BuilderBuildsBucket, key, build)
|
||||
running := delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK}
|
||||
at := func(f string) delivery.Have { h := running; h.FP = f; return h }
|
||||
served := p.served(context.Background(), get, g, assign.Nodes{Haves: map[string]map[string]delivery.Have{
|
||||
"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 {
|
||||
t.Errorf("served(%s) = %v, want %v: the running server's manifest serves w alone, late's is not read", v, served(v), want)
|
||||
}
|
||||
}
|
||||
// ADR 0614 §5: bus-state counts as offered only from a healthy build on a node present; the bus module's build on an
|
||||
// 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},
|
||||
Haves: map[string]map[string]delivery.Have{"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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodedAsk is an asks-state value with the router's ask it carries decoded (mesh-sdk keeps it as raw JSON).
|
||||
type decodedAsk struct {
|
||||
Asking int
|
||||
Ask oc.Ask
|
||||
}
|
||||
|
||||
func decodeAsk(key string, data []byte) (decodedAsk, error) {
|
||||
a, ask, err := assign.DecodeAsk(key, data)
|
||||
return decodedAsk{Asking: a.Asking, Ask: ask}, err
|
||||
}
|
||||
|
||||
func mustAskID(a delivery.AssignerAsk) string {
|
||||
ask, err := assign.AskOf(a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ask.ID
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
@@ -99,7 +99,7 @@ func (p *passer) puts(ctx context.Context) {
|
||||
}
|
||||
assigned := p.w.assigned()
|
||||
trunk := p.w.trunk()
|
||||
get := p.w.getter(delivery.ManifestsBucket) // the indexer's manifests, as its first view and views family say them
|
||||
get := kvGetter(p.kvs[delivery.ManifestsBucket])
|
||||
wanted := p.w.wantsOf()
|
||||
modules := make([]string, 0, len(all))
|
||||
for m := range all {
|
||||
@@ -195,15 +195,14 @@ func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalo
|
||||
w.Recorded = policy == catalogue.PolicyRecord
|
||||
if akey, err := delivery.RecordAskKey(node, module, d.Digest); err == nil && put.Ask == akey {
|
||||
// Only this put's own ask counts: an ask (and its answer) of an earlier put under the same key is another asking.
|
||||
if a, ask, err := assign.DecodeAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey)); err == nil && a.Asking == assign.PutAsking(put) {
|
||||
if a, err := delivery.DecodeAssignerAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey)); err == nil && a.Asking == assign.PutAsking(put) {
|
||||
switch {
|
||||
case p.answeredWith(akey, assign.OptionYes):
|
||||
w.Answer = assign.OptionYes
|
||||
case p.answeredWith(akey, assign.OptionNo):
|
||||
w.Answer = assign.OptionNo
|
||||
default:
|
||||
expires := assign.ExpiresAt(ask)
|
||||
w.AskExpired = !expires.IsZero() && w.Now.After(expires)
|
||||
w.AskExpired = !a.Ask.Expires.IsZero() && w.Now.After(a.Ask.Expires)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,13 +242,13 @@ func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.Pu
|
||||
// The put's own ask, written again over one an earlier put left under the key (its asking differs), so the teller
|
||||
// raises it anew under its own id.
|
||||
akey := next.Put.Ask
|
||||
current, _, err := assign.DecodeAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey))
|
||||
current, err := delivery.DecodeAssignerAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey))
|
||||
if err != nil || current.Asking != assign.PutAsking(next.Put) {
|
||||
a, err := assign.PutAsk(akey, node, module, next.Put, *w.Composed, w.Bus, w.Now)
|
||||
if err != nil {
|
||||
p.logf("the ask about putting %s on %s is not written: %v", module, node, err)
|
||||
} else {
|
||||
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return assign.EncodeAsk(akey, a) })
|
||||
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return delivery.EncodeAssignerAsk(akey, a) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,15 +257,15 @@ func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.Pu
|
||||
}
|
||||
}
|
||||
|
||||
// performs checks that the act about to be performed is the one the yes of the ask at key bound (occ.Performs).
|
||||
func (p *passer) performs(key string, act occ.Act) error {
|
||||
_, ask, err := assign.DecodeAsk(key, p.w.value(delivery.AssignerAsksBucket, key))
|
||||
// performs checks that the act about to be performed is the one the yes of the ask at key bound (asks.Option.Performs).
|
||||
func (p *passer) performs(key string, act asks.Act) error {
|
||||
a, err := delivery.DecodeAssignerAsk(key, p.w.value(delivery.AssignerAsksBucket, key))
|
||||
if err != nil {
|
||||
return fmt.Errorf("the ask %s cannot be read: %w", key, err)
|
||||
}
|
||||
for _, o := range ask.Options {
|
||||
for _, o := range a.Ask.Options {
|
||||
if o.ID == assign.OptionYes {
|
||||
return occ.Performs(o, act)
|
||||
return o.Performs(act)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("the ask %s offers no yes", key)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
@@ -124,7 +124,7 @@ func (p *passer) sayWaits(unbound []string) {
|
||||
if p.say == nil || p.said[c.Key] && p.saidWaits == state {
|
||||
return
|
||||
}
|
||||
if c.State == occ.StateCleared && p.saidWaits == "" {
|
||||
if c.State == asks.StateCleared && p.saidWaits == "" {
|
||||
return // nothing was raised by this process
|
||||
}
|
||||
if err := p.say(c); err != nil {
|
||||
@@ -243,16 +243,11 @@ func (p *passer) ask(ctx context.Context, prop delivery.PlacementProposal, akey,
|
||||
p.logf("the ask about the proposal %s is not written: %v", prop.ID, err)
|
||||
return prop, false
|
||||
}
|
||||
placed, err := assign.AskOf(a)
|
||||
if err != nil {
|
||||
p.logf("the ask about the proposal %s is not written: %v", prop.ID, err)
|
||||
return prop, false
|
||||
}
|
||||
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return assign.EncodeAsk(akey, a) })
|
||||
p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return delivery.EncodeAssignerAsk(akey, a) })
|
||||
if pk, err := delivery.PulseAskKey(prop.ID); err == nil {
|
||||
p.pulse(ctx, pk, assign.ExpiresAt(placed).Sub(p.clock()))
|
||||
p.pulse(ctx, pk, a.Ask.Expires.Sub(p.clock()))
|
||||
}
|
||||
prop.State, prop.Ask = delivery.ProposalAsked, placed.ID
|
||||
prop.State, prop.Ask = delivery.ProposalAsked, a.Ask.ID
|
||||
return prop, true
|
||||
}
|
||||
|
||||
|
||||
@@ -127,8 +127,7 @@ func TestTheSeatsVerbs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for _, stream := range raw0.Streams {
|
||||
// The views family is no seat's stream: the readers of the assigner's states follow it (ADR 0615 §5).
|
||||
if stream.Name != viewsStream && !reads["stream "+stream.Name] {
|
||||
if !reads["stream "+stream.Name] {
|
||||
t.Errorf("no verb reads the seat's stream %s", stream.Name)
|
||||
}
|
||||
}
|
||||
@@ -386,7 +385,7 @@ func wantsVerbs(t *testing.T) {
|
||||
}
|
||||
manifest("web", "")
|
||||
manifest("photos", `,"upgrade":{"policy":"record","why":"a person checks the albums"}`)
|
||||
manifest("nats", `,"provides":[{"name":"mesh-bus","scope":"mesh"}],"upgrade":{"policy":"record","why":"its streams"}`)
|
||||
manifest("nats", `,"provides":[{"name":"mesh-bus","scope":"mesh"}]`)
|
||||
for node, fp := range map[string]string{"anchor": fpOld, "laptop": fpNew} {
|
||||
st[haveState].values[node+".nats"], _ = delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
}
|
||||
@@ -407,7 +406,7 @@ func wantsVerbs(t *testing.T) {
|
||||
if p := policyOf("photos"); p.Policy != catalogue.PolicyRecord || p.From != catalogue.FromModule {
|
||||
t.Errorf("photos = %+v", p)
|
||||
}
|
||||
if p := policyOf("nats"); p.Policy != catalogue.PolicyRecord || p.From != catalogue.FromModule { // the policy its manifest says (issue 774)
|
||||
if p := policyOf("nats"); p.Policy != catalogue.PolicyRecord || p.From != catalogue.FromBus {
|
||||
t.Errorf("nats = %+v", p)
|
||||
}
|
||||
// compare: each node's own composition is the digest its have names, and the assigner's findings; nothing splits, the
|
||||
@@ -472,12 +471,8 @@ func wantsVerbs(t *testing.T) {
|
||||
len(said) != 1 || !strings.HasPrefix(said[0], "policy-changed policy-changed.web.") {
|
||||
t.Errorf("chosen %+v, asked %v, said %v", c, asked, said)
|
||||
}
|
||||
// A person may choose the bus's policy as any module's (ADR 0601 §1: no part treats the module that provides the bus apart).
|
||||
if _, err := w.doUpgrade(map[string]any{"module": "nats", "policy": "roll", "why": "x"}, "op"); err != nil {
|
||||
t.Errorf("a person's policy for the bus was refused: %v", err)
|
||||
}
|
||||
if _, err := w.doUpgrade(map[string]any{"module": "nats", "policy": "default", "why": "back to the module"}, "op"); err != nil {
|
||||
t.Fatal(err)
|
||||
if _, err := w.doUpgrade(map[string]any{"module": "nats", "policy": "roll", "why": "x"}, "op"); err == nil {
|
||||
t.Error("the bus was given a policy")
|
||||
}
|
||||
if _, err := w.doUpgrade(map[string]any{"module": "web", "policy": "roll"}, "op"); err == nil {
|
||||
t.Error("a policy without why was set")
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
)
|
||||
|
||||
// The assigner's tools (novox/hq ADR 0546), served through the tool runner from its bundle: a process never serves (ADR
|
||||
@@ -23,9 +21,6 @@ import (
|
||||
// every step as the event put. They are the module's own tools; the seat mesh-placement's verbs are served beside them
|
||||
// (seat_tools.go, ADR 0567 §10).
|
||||
|
||||
// engineReadsTool lists what each node's engine reads, as the assigner uses it, and asks one node's engine again.
|
||||
const engineReadsTool = "assigner_engine_reads"
|
||||
|
||||
// putsState is the assigner's state of puts, as the runtime names a module's own state.
|
||||
const putsState = "puts"
|
||||
|
||||
@@ -363,9 +358,6 @@ func serveTools() error {
|
||||
})
|
||||
wants.watched = stdio.NewWatchedStates() // the large states read whole from one watched copy (ADR 0580 clause 3)
|
||||
tools = append(tools, wants.tools()...)
|
||||
tools = append(tools, viewTools(os.Getenv, keptSource, time.Now)...)
|
||||
// What each node's engine reads, as the assigner uses it (ADR 0618 §3): its own state engine-reads, asked again through it.
|
||||
tools = append(tools, enginereads.Tool(engineReadsTool, "the assigner", enginereads.KeptState(stdio.State(engineReadsState)), false, time.Now))
|
||||
return stdio.Serve("", tools)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
|
||||
// The verb edges a want waits on (novox/hq ADR 0614 §2, §3): the module graph the indexer writes names what each module
|
||||
// invokes and serves and which of its verb edges it leaves out on a cycle (one place decides the cycle); a want waits for
|
||||
// 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(graph.Bucket) {
|
||||
module, err := graph.ParseKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if n, err := graph.Decode(raw); err == nil {
|
||||
g[module] = n
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// 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
|
||||
// no node of module.
|
||||
func keptInvokes(g graph.Graph, module string) []string {
|
||||
n, ok := g[module]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, v := range n.Invokes {
|
||||
if slices.Contains(n.LeftOut, v) {
|
||||
continue
|
||||
}
|
||||
if slices.ContainsFunc(g.Servers(v), func(s string) bool { return s != module }) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// served is, for this pass, whether a build serving a verb runs healthy on some node (graph.Served): every running healthy
|
||||
// have's build (the builder's builds key of its module and fingerprint) names its manifest, and the verbs that manifest
|
||||
// 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 {
|
||||
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) },
|
||||
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)
|
||||
}
|
||||
return served
|
||||
}
|
||||
|
||||
// offering is, for this pass, whether a build whose manifest offers a provision runs healthy on a node present (ADR 0603
|
||||
// §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 {
|
||||
offered := map[string]bool{}
|
||||
for node, haves := range n.Haves {
|
||||
if !n.Online[node] {
|
||||
continue
|
||||
}
|
||||
for module, h := range haves {
|
||||
if h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthOK {
|
||||
continue
|
||||
}
|
||||
m, ok := p.manifestOf(ctx, get, module, h.FP)
|
||||
if !ok {
|
||||
m = trunk[module]
|
||||
}
|
||||
for _, o := range m.Offers() {
|
||||
offered[o] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return func(provision string) bool { return offered[provision] }
|
||||
}
|
||||
|
||||
// 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)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return graph.ServesOf(m), true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
raw := p.w.value(delivery.BuilderBuildsBucket, key)
|
||||
if raw == nil {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
b, err := delivery.DecodeBuilderBuild(raw)
|
||||
if err != nil || b.Manifest == "" {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
got := p.w.manifest(ctx, get, b.Manifest)
|
||||
if got.err != "" {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
return got.manifest, true
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/heard"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
|
||||
// viewsStream is the assigner's views family on its old streams section, beside placement (ADR 0619 §2, ADR 0615 §5),
|
||||
// as its manifest's `streams` names it; indexerViewsStream is the indexer's, as the indexer's manifest names it.
|
||||
const (
|
||||
viewsStream = "assigner-views"
|
||||
indexerViewsStream = "indexer-views"
|
||||
)
|
||||
|
||||
// marksBucket is the assigner's state of what its views family said each state up to.
|
||||
var marksBucket = delivery.AssignerModule + "_" + views.MarksState
|
||||
|
||||
// viewedTool is one tool over one state of the assigner's.
|
||||
type viewedTool struct{ tool, state, words string }
|
||||
|
||||
// firstViewTools are the assigner's first-view tools (ADR 0621 §1): each answers only its callers list
|
||||
// (views.CallersEnv), which the manifest writes.
|
||||
var firstViewTools = []viewedTool{
|
||||
{"placement", delivery.PlacementState, "where each module is placed: the seat mesh-placement's placement, by module."},
|
||||
{"seat-holders", delivery.SeatHoldersState, "who holds which seat on which node."},
|
||||
{"wants", "wants", "what each node should run: want.<node>.<module>, its fingerprint and the rule that gave it."},
|
||||
}
|
||||
|
||||
// readTools are the assigner's ordinary read tools, one per state its readers read (design 25 § 12.2).
|
||||
var readTools = []viewedTool{
|
||||
{"assigner_read_placement", delivery.PlacementState, "where each module is placed, by module."},
|
||||
{"assigner_read_seat_holders", delivery.SeatHoldersState, "who holds which seat on which node."},
|
||||
{"assigner_read_wants", "wants", "what each node should run: want.<node>.<module>."},
|
||||
{"assigner_read_findings", health.FindingsState, "the assigner's findings, which the condition keeper raises and clears."},
|
||||
{"assigner_read_asks", "asks", "the asks the assigner put to a person, and their answers."},
|
||||
{"assigner_read_differences", "differences", "every difference between a want and what a node runs."},
|
||||
}
|
||||
|
||||
// viewedStates are the states the views family says, each by its bucket: the six its readers read.
|
||||
var viewedStates = map[string]string{
|
||||
delivery.PlacementState: delivery.PlacementBucket,
|
||||
delivery.SeatHoldersState: delivery.SeatHoldersBucket,
|
||||
"wants": delivery.AssignerWantsBucket,
|
||||
health.FindingsState: assign.FindingsBucket,
|
||||
"asks": delivery.AssignerAsksBucket,
|
||||
"differences": delivery.AssignerDifferencesBucket,
|
||||
}
|
||||
|
||||
// viewTools are the assigner's first-view and read tools, reading only its own states, so they answer as soon as its
|
||||
// runtime of its own is connected (ADR 0621 §3).
|
||||
func viewTools(getenv func(string) string, state func(string) views.Source, now func() time.Time) []stdio.Tool {
|
||||
marks := state(views.MarksState)
|
||||
var out []stdio.Tool
|
||||
for _, t := range firstViewTools {
|
||||
out = append(out, views.FirstViewTool(delivery.AssignerModule, t.tool, t.state, t.words, views.ParseCallers(getenv(views.CallersEnv(t.tool))), state(t.state), marks, now))
|
||||
}
|
||||
for _, t := range readTools {
|
||||
out = append(out, views.ReadTool(delivery.AssignerModule, t.tool, t.state, t.words, state(t.state), marks, now))
|
||||
}
|
||||
// Its own first views, of the indexer's manifests, modules and graph (DLV-F3r, ADR 0631 §2): read, and asked again by hand.
|
||||
return append(out, views.StatusTools(delivery.AssignerModule, "assigner_first_views", "assigner_first_view_again", stdio.State(views.StatusState), handacts.Recorder{Ask: stdio.Ask}, now)...)
|
||||
}
|
||||
|
||||
func keptSource(name string) views.Source { return views.KeptSource{State: stdio.State(name)} }
|
||||
|
||||
// startViews says each change of the assigner's viewed states on its views family, once its marks state is bound,
|
||||
// each state as soon as its bucket is (kvs, or bound once it appears). A watch that ends is sent on errs.
|
||||
func startViews(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, account process.Account, kvs buckets, findings assign.Findings, errs chan<- error, logf func(string, ...any)) {
|
||||
tooLarge := func(state, key string, size int) {
|
||||
if findings.Put == nil {
|
||||
return
|
||||
}
|
||||
k, v, err := views.TooLargeFinding(delivery.AssignerModule, state, key, size, time.Now())
|
||||
if err == nil {
|
||||
err = findings.Put(k, v)
|
||||
}
|
||||
if err != nil {
|
||||
logf("views: the finding of %s.%s too large is not written: %v", state, key, err)
|
||||
}
|
||||
}
|
||||
process.AwaitBucket(ctx, js, nc, marksBucket, "no change is said on the views family, so every reader waits on its first view", logf).Ready(func(mk jetstream.KeyValue) {
|
||||
em := viewsEmitter(js, account, mk, findings, tooLarge, logf)
|
||||
for _, state := range []string{"asks", "differences", health.FindingsState, delivery.PlacementState, delivery.SeatHoldersState, "wants"} {
|
||||
state := state
|
||||
start := func(kv jetstream.KeyValue) {
|
||||
if err := em.Start(ctx, state, kv, errs); err != nil {
|
||||
logf("%v: its readers ask its first view at each change until the assigner starts again", err)
|
||||
}
|
||||
}
|
||||
if kv := kvs[viewedStates[state]]; kv != nil {
|
||||
start(kv)
|
||||
continue
|
||||
}
|
||||
process.AwaitBucket(ctx, js, nc, viewedStates[state], "its changes are not said on the views family", logf).Ready(start)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// viewsEmitter is the assigner's emitter of its views family: a change not said past views.Bound is its finding
|
||||
// view-unsaid, in its findings (review round 2); with no findings state it is said in the log only.
|
||||
func viewsEmitter(js jetstream.JetStream, account process.Account, mk jetstream.KeyValue, findings assign.Findings, tooLarge func(string, string, int), logf func(string, ...any)) views.Emitter {
|
||||
em := views.Emitter{Owner: delivery.AssignerModule, Subject: account.StreamSubject, Publish: views.JetStreamPublish(js), Marks: mk, TooLarge: tooLarge, Logf: logf}
|
||||
if findings.Put != nil && findings.Delete != nil {
|
||||
em.Findings = assignerFindings{findings}
|
||||
}
|
||||
return em
|
||||
}
|
||||
|
||||
// assignerFindings is the assigner's findings as views.Emitter's Findings.
|
||||
type assignerFindings struct{ f assign.Findings }
|
||||
|
||||
func (a assignerFindings) Put(key string, value []byte) error { return a.f.Put(key, value) }
|
||||
func (a assignerFindings) Delete(key string) error { return a.f.Delete(key) }
|
||||
|
||||
// firstViewsBucket is the assigner's own state of its first views (views.StatusState).
|
||||
var firstViewsBucket = delivery.AssignerModule + "_" + 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 {
|
||||
js jetstream.JetStream
|
||||
account process.Account
|
||||
call func(string, any) (json.RawMessage, error)
|
||||
findings assign.Findings
|
||||
status *process.AwaitedBucket
|
||||
logf func(string, ...any)
|
||||
}
|
||||
|
||||
// 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": delivery.ManifestsBucket, "modules": delivery.ModulesBucket, graph.State: graph.Bucket}
|
||||
|
||||
// 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 {
|
||||
finding := func(s views.Status, raised bool) {
|
||||
if iv.findings.Put == nil {
|
||||
return
|
||||
}
|
||||
key, value, err := views.UnansweredFinding(delivery.AssignerModule, s, time.Now())
|
||||
if err == nil && raised {
|
||||
err = iv.findings.Put(key, value)
|
||||
} else if err == nil {
|
||||
err = iv.findings.Delete(key)
|
||||
}
|
||||
if err != nil {
|
||||
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 := delivery.IndexerModule + "." + state
|
||||
f := &views.Follower{Owner: delivery.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{}{}
|
||||
}
|
||||
}()
|
||||
}
|
||||
if iv.status != nil {
|
||||
iv.status.Ready(func(kv jetstream.KeyValue) {
|
||||
for _, f := range all {
|
||||
f.Said = views.KeepStatus(ctx, kv, iv.logf)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Not heard, the indexer's views family is the finding family-not-heard until it binds (ADR 0615 §5).
|
||||
stream := catalogue.StreamBusName(indexerViewsStream)
|
||||
_ = notHeardWriter(iv.findings, iv.logf).Bind(stream, "the indexer's manifests, modules and graph are the first views' alone until the assigner starts again", func() error {
|
||||
cons, err := iv.account.EventConsumer(ctx, iv.js, stream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the consumer %s on %s does not exist yet: %w", iv.account.EventConsumerName(), stream, err)
|
||||
}
|
||||
_, err = process.HearEvents(cons, views.Hear(ctx, followers, iv.logf), iv.logf)
|
||||
return err
|
||||
})
|
||||
return len(all)
|
||||
}
|
||||
|
||||
// notHeardWriter writes the assigner's finding of a family it does not hear into its findings (heard.Writer); with no
|
||||
// findings state it is said in the log alone.
|
||||
func notHeardWriter(findings assign.Findings, logf func(string, ...any)) heard.Writer {
|
||||
w := heard.Writer{Observer: delivery.AssignerModule, Logf: logf, Put: findings.Put}
|
||||
if findings.Delete != nil {
|
||||
w.Delete = func(key string) error {
|
||||
if keys, err := findings.Keys(); err == nil && !slices.Contains(keys, key) {
|
||||
return nil // none stands
|
||||
}
|
||||
return findings.Delete(key)
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -197,14 +197,18 @@ func (s *wantsTools) doUpgrade(args map[string]any, by string) (any, error) {
|
||||
}
|
||||
|
||||
// setPolicy writes a person's choice of a module's policy, or deletes it for default, records the act in the hand-act log
|
||||
// and says policy-changed. The bus's choice is a person's as any module's (ADR 0601 §1).
|
||||
// and says policy-changed. The bus is never rolled: a choice for it is refused.
|
||||
func (s *wantsTools) setPolicy(module, policy, why, by string, manifests map[string]catalogue.Manifest) (any, error) {
|
||||
if module == "" {
|
||||
return nil, errors.New("a policy is set for one module: name it")
|
||||
}
|
||||
if _, ok := manifests[module]; !ok {
|
||||
m, ok := manifests[module]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the indexer holds no manifest of %s", module)
|
||||
}
|
||||
if catalogue.ProvidesBus(m) {
|
||||
return nil, fmt.Errorf("%s is the bus, which is never rolled whoever says so (novox/hq ADR 0236): its step is the put on the operator's warrant", module)
|
||||
}
|
||||
if why == "" {
|
||||
return nil, errors.New("a person's policy says why")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
@@ -38,9 +37,6 @@ type world struct {
|
||||
// placement is the seat's state placement assembled node by node from its keys in either form (mesh-sdk
|
||||
// delivery.PlacementKeys; research 072 row 37): the one reader of its keys in the assigner.
|
||||
placement *delivery.PlacementKeys
|
||||
// sourceGroups is, by revision of the gate's source.main, the release (group) that moved it, from every revision its
|
||||
// history keeps (watched with history): a build's source revision names the release that carries it (ADR 0601 §1).
|
||||
sourceGroups map[uint64]string
|
||||
}
|
||||
|
||||
// entryMeta is a value's revision in its bucket and when it was written.
|
||||
@@ -58,8 +54,7 @@ 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]delivery.BuildLogEntry{}, placement: delivery.NewPlacementKeys(),
|
||||
sourceGroups: map[uint64]string{}}
|
||||
meta: map[string]entryMeta{}, trunkBuilt: map[string]delivery.BuildLogEntry{}, placement: delivery.NewPlacementKeys()}
|
||||
}
|
||||
|
||||
// putEntry is put, keeping the value's revision and the time it was written.
|
||||
@@ -75,44 +70,6 @@ func (w *world) putEntry(bucket, key string, value []byte, revision uint64, at t
|
||||
if bucket == delivery.PlacementBucket {
|
||||
_, _ = w.placement.Put(key, value, revision, false) // put took it already; this is its revision
|
||||
}
|
||||
if bucket == delivery.SourceBucket && key == delivery.SourceMainKey {
|
||||
if s, err := delivery.DecodeSource(value); err == nil && s.Group != "" {
|
||||
w.sourceGroups[revision] = s.Group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// release is the release whose Approve carries module's build fp (ADR 0601 §1), and whether one does: the trunk's value of
|
||||
// the module names fp, the source revision at which that fingerprint last changed (FPSrc, never the Src of its last merge,
|
||||
// which a recompute with no merge keeps) names the release that moved it, and the gate's merge of that release, which it
|
||||
// writes on the operator's Approve, holds the module's own trunk merge among its merged pull requests. Any of them
|
||||
// unknown: none, and the move is asked of a person.
|
||||
func (w *world) release(module, fp string) (string, bool) {
|
||||
t, ok := w.trunk()[module]
|
||||
if !ok || fp == "" || t.FP2 != fp || t.FPSrc == 0 || t.Commit == "" {
|
||||
return "", false
|
||||
}
|
||||
w.mu.Lock()
|
||||
group := w.sourceGroups[t.FPSrc]
|
||||
w.mu.Unlock()
|
||||
key, err := delivery.MergesKey(group)
|
||||
if group == "" || err != nil {
|
||||
return "", false
|
||||
}
|
||||
raw := w.value(delivery.MergesBucket, key)
|
||||
if raw == nil {
|
||||
return "", false
|
||||
}
|
||||
merge, err := delivery.DecodeMerge(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, pull := range merge.Pulls {
|
||||
if pull.State == delivery.MergeMerged && pull.Commit == t.Commit && delivery.SameRepository(pull.Repository, t.Repository) {
|
||||
return group, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// metaOf is the revision and time of the value a bucket holds at key, as the watch said them.
|
||||
@@ -198,19 +155,6 @@ func kvGetter(kv jetstream.KeyValue) getter {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
w.mu.Lock()
|
||||
@@ -352,8 +296,7 @@ func (w *world) wantsOf() map[string]map[string]delivery.Want {
|
||||
|
||||
// nodes is what the six rules read of the nodes: presence, haves, and every provision's providers with their nodes.
|
||||
func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[string][]string) assign.Nodes {
|
||||
n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]delivery.Have{}, Providers: map[string]map[string][]string{},
|
||||
Reads: map[string][]string{}}
|
||||
n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]delivery.Have{}, Providers: map[string]map[string][]string{}}
|
||||
for key, value := range w.read(delivery.PresenceBucket) {
|
||||
node, _, _, err := delivery.ParsePresenceKey(key)
|
||||
if err != nil || node == "" {
|
||||
@@ -365,14 +308,7 @@ func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[stri
|
||||
}
|
||||
for key, value := range w.read(delivery.HaveBucket) {
|
||||
node, module, isNode, err := delivery.ParseHaveKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if isNode {
|
||||
// What the node's engine reads (ADR 0614 §4); a record that states none is read as the baseline.
|
||||
if h, err := delivery.DecodeHaveNode(value); err == nil && h.Record != nil {
|
||||
n.Reads[node] = h.Record.ManifestReads
|
||||
}
|
||||
if err != nil || isNode {
|
||||
continue
|
||||
}
|
||||
if h, err := delivery.DecodeHave(value); err == nil {
|
||||
@@ -434,42 +370,6 @@ func (w *world) built(module, fp string) bool {
|
||||
return err == nil && b.State == delivery.BuildDone
|
||||
}
|
||||
|
||||
// buildNeeds are the reference forms the builder's done build of module at fp is written in (the build key's needs, ADR
|
||||
// 0618 §1): nil when it is not done or names none.
|
||||
func (w *world) buildNeeds(module, fp string) []string {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
b, err := delivery.DecodeBuilderBuild(w.value(delivery.BuilderBuildsBucket, key))
|
||||
if err != nil || b.State != delivery.BuildDone {
|
||||
return nil
|
||||
}
|
||||
return b.Needs
|
||||
}
|
||||
|
||||
// recordReads is what node's record `<node>.node` states its engine reads (ADR 0614 §4), and whether the assigner holds
|
||||
// a record of it: the fallback where the engine does not answer the verb manifest-reads (enginereads.FromRecord).
|
||||
func (w *world) recordReads(node string) ([]string, bool) {
|
||||
r, ok := w.records()[node]
|
||||
return r.ManifestReads, ok
|
||||
}
|
||||
|
||||
// knownNodes is every node the assigner hears of: placed, with a record, or with a presence.
|
||||
func (w *world) knownNodes() []string {
|
||||
set := map[string]bool{}
|
||||
for node := range w.placedNodes() {
|
||||
set[node] = true
|
||||
}
|
||||
for node := range w.records() {
|
||||
set[node] = true
|
||||
}
|
||||
for node := range w.online() {
|
||||
set[node] = true
|
||||
}
|
||||
return slices.Sorted(maps.Keys(set))
|
||||
}
|
||||
|
||||
// composed is the composition of module for node: a placed node composes its own, and its composition is the digest its own
|
||||
// have names (ADR 0571 §6); an unplaced node has none, so it gets no want (and the pass says so, unplacedFindings). The
|
||||
// controller's copy (mesh-controller_composed) is read no more (issue 774).
|
||||
|
||||
@@ -3,32 +3,20 @@ module mesh-delivery-assigner
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 h1:3FTxvHrv3Hv+ZUbp/ezTrndqdgiS+Z6EUG2XGYJNJr8=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/V/1wfyQwjE9VbPLsFtkf6uX+SHKUXX34s5JscDuGAs=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 h1:19PhtrCOwia711Oqdyafvs5EKTVmmgk5Gdb4leCkq3w=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Dj6fbL8Evgy72EWO3Gl3L3Ra8N/nJSzgEVwSGmeRam0=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 h1:YQ6eJvc+RYUVIVf+oX+R4TC1dYL8TLLMZ0/JPDU1tp4=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 h1:K/1yIkqAWKGtLEtJ0PibijIdpo5YATL/9IUVXFoEglM=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2 h1:MOuutmbKBL1GtTZU9W1+ja/K7TL05EFU6hEt3Swf/oE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
@@ -33,5 +23,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package assign
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// assignerAsk is the asking-th ask a, as the asks state holds it: the router's ask as JSON (delivery.AssignerAsk.Ask).
|
||||
func assignerAsk(asking int, a oc.Ask) delivery.AssignerAsk {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil { // an oc.Ask holds only strings, bools and a slice of them: it cannot fail
|
||||
panic(fmt.Sprintf("assign: the ask %q does not marshal: %v", a.ID, err))
|
||||
}
|
||||
return delivery.AssignerAsk{Asking: asking, Ask: raw}
|
||||
}
|
||||
|
||||
// AskOf is the router's ask an asks-state value carries.
|
||||
func AskOf(a delivery.AssignerAsk) (oc.Ask, error) {
|
||||
var ask oc.Ask
|
||||
if err := json.Unmarshal(a.Ask, &ask); err != nil {
|
||||
return oc.Ask{}, fmt.Errorf("assign: the value carries no router's ask: %w", err)
|
||||
}
|
||||
return ask, nil
|
||||
}
|
||||
|
||||
// EncodeAsk is delivery.EncodeAssignerAsk at key, after the checks every keeper of an ask owes (occ.Carried, ADR 0627 §4):
|
||||
// the ask's id is delivery.AskingID(key, asking), and it offers at least one option.
|
||||
func EncodeAsk(key string, a delivery.AssignerAsk) ([]byte, error) {
|
||||
if _, err := checkedAsk(key, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return delivery.EncodeAssignerAsk(key, a)
|
||||
}
|
||||
|
||||
func checkedAsk(key string, a delivery.AssignerAsk) (oc.Ask, error) {
|
||||
ask, err := occ.Carried(a.Ask, delivery.AskingID(key, a.Asking))
|
||||
if err != nil {
|
||||
return oc.Ask{}, fmt.Errorf("assign: %w", err)
|
||||
}
|
||||
return ask, nil
|
||||
}
|
||||
|
||||
// DecodeAsk reads the ask at key (delivery.DecodeAssignerAsk) with the router's ask it carries.
|
||||
func DecodeAsk(key string, data []byte) (delivery.AssignerAsk, oc.Ask, error) {
|
||||
a, err := delivery.DecodeAssignerAsk(key, data)
|
||||
if err != nil {
|
||||
return delivery.AssignerAsk{}, oc.Ask{}, err
|
||||
}
|
||||
ask, err := checkedAsk(key, a)
|
||||
if err != nil {
|
||||
return delivery.AssignerAsk{}, oc.Ask{}, err
|
||||
}
|
||||
return a, ask, nil
|
||||
}
|
||||
|
||||
// ExpiresAt is when the ask ends unanswered; the zero time for an ask that names none.
|
||||
func ExpiresAt(a oc.Ask) time.Time {
|
||||
t, err := occ.Time(a.Expires)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -16,21 +15,21 @@ import (
|
||||
// record's: the machine whose health check failed and the hold's words (issue 1906). asking numbers the writing: 0 the first, a
|
||||
// larger number an ask written again after a hold lifted by itself, so the router takes it as new.
|
||||
func HoldAsk(key, module, fp, node, words string, asking int, now time.Time) delivery.AssignerAsk {
|
||||
return assignerAsk(asking, oc.Ask{
|
||||
return delivery.AssignerAsk{Asking: asking, Ask: asks.Ask{
|
||||
ID: delivery.AskingID(key, asking),
|
||||
Headline: fmt.Sprintf("%s is held at %s", module, short(fp)),
|
||||
Explanation: fmt.Sprintf("The health check of %s failed on %s at %s (%s), so the assigner holds that version and wants "+
|
||||
"no node to move to it. It lifts the hold by itself once every node that runs %s runs it healthy; releasing it "+
|
||||
"lets the wants move to it now.", module, node, short(fp), words, module),
|
||||
Options: []oc.Option{
|
||||
{ID: "keep", Label: "Keep it held", Does: "the version stays held", Level: occ.Acknowledge},
|
||||
{ID: "release", Label: "Release it", Does: "the version is released and the wants move to it by the rules again", Level: occ.Acknowledge},
|
||||
Options: []asks.Option{
|
||||
{ID: "keep", Label: "Keep it held", Does: "the version stays held", Level: asks.Acknowledge},
|
||||
{ID: "release", Label: "Release it", Does: "the version is released and the wants move to it by the rules again", Level: asks.Acknowledge},
|
||||
},
|
||||
Who: occ.Operator,
|
||||
Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)),
|
||||
Who: asks.Operator,
|
||||
Expires: now.Add(7 * 24 * time.Hour),
|
||||
OnExpiry: "the version stays held",
|
||||
About: key,
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
// The options of the restore ask (ADR 0537 §3, ADR 0539 §19), and what each does on the node.
|
||||
@@ -43,41 +42,41 @@ const (
|
||||
// not set back by itself, since a set-back would lose the writes made since. words is the check's words. Restore sets the
|
||||
// node back to the last healthy fingerprint and loses the newer writes; leaving it stopped keeps the data as it is.
|
||||
func RestoreAsk(key, node, module, fp, words string, now time.Time) delivery.AssignerAsk {
|
||||
return assignerAsk(0, oc.Ask{
|
||||
return delivery.AssignerAsk{Ask: asks.Ask{
|
||||
ID: delivery.AskingID(key, 0),
|
||||
Headline: fmt.Sprintf("%s failed its health check on %s", module, node),
|
||||
Explanation: fmt.Sprintf("%s holds data, and its health check failed on %s at %s: %s. Setting it back to the version that ran healthy "+
|
||||
"would lose the writes made since; leaving it stopped keeps the data as it is.", module, node, short(fp), words),
|
||||
Options: []oc.Option{
|
||||
{ID: OptionRestore, Label: "Restore it", Does: "the node is set back to the last healthy version and the newer writes are lost", Level: occ.Acknowledge},
|
||||
{ID: OptionLeaveStopped, Label: "Leave it stopped", Does: "the module stays as it is on the node and the data is kept", Level: occ.Acknowledge},
|
||||
Options: []asks.Option{
|
||||
{ID: OptionRestore, Label: "Restore it", Does: "the node is set back to the last healthy version and the newer writes are lost", Level: asks.Acknowledge},
|
||||
{ID: OptionLeaveStopped, Label: "Leave it stopped", Does: "the module stays as it is on the node and the data is kept", Level: asks.Acknowledge},
|
||||
},
|
||||
Who: occ.Operator,
|
||||
Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)),
|
||||
Who: asks.Operator,
|
||||
Expires: now.Add(7 * 24 * time.Hour),
|
||||
OnExpiry: "the module stays as it is on the node",
|
||||
About: key,
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
// HeldCondition is the condition the assigner raises when it holds a fingerprint of a module (ADR 0429 §6, ADR 0539 §19), or
|
||||
// clears (raised false) when the hold is released. It names the module, the node whose health check failed and the check's
|
||||
// words, under the assigner's own name (ADR 0437 clause 9), keyed by the held fingerprint. needsYou is true for a module holding
|
||||
// data, whose restore ask waits for the operator.
|
||||
func HeldCondition(module, fp, node, words string, raised, needsYou bool) oc.Condition {
|
||||
func HeldCondition(module, fp, node, words string, raised, needsYou bool) asks.Condition {
|
||||
key := delivery.AssignerModule + ".held." + module + "." + strings.TrimPrefix(fp, delivery.SourcePrefix2)
|
||||
subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node}
|
||||
subject := asks.Scope{Scope: "module", ID: module, Machine: node}
|
||||
if !raised {
|
||||
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared}
|
||||
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared}
|
||||
}
|
||||
c := oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised,
|
||||
c := asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised,
|
||||
Summary: fmt.Sprintf("%s is held at %s", module, short(fp)),
|
||||
Headline: fmt.Sprintf("%s is held at %s", module, short(fp))}
|
||||
what := fmt.Sprintf("The health check of %s failed on %s at %s: %s. The assigner holds that version and wants no node to move to it.", module, node, short(fp), words)
|
||||
if needsYou {
|
||||
c.Needs = "answer whether to restore " + module + " on " + node
|
||||
c.Explanation = occ.NeedsYou + c.Needs + ". " + what
|
||||
c.Explanation = asks.NeedsYou + c.Needs + ". " + what
|
||||
} else {
|
||||
c.Explanation = occ.NothingToDo + " " + what + " Nodes that wanted it are set back to the version that ran healthy."
|
||||
c.Explanation = asks.NothingToDo + " " + what + " Nodes that wanted it are set back to the version that ran healthy."
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -86,22 +85,22 @@ func HeldCondition(module, fp, node, words string, raised, needsYou bool) oc.Con
|
||||
// node while other nodes run it healthy (ADR 0557 §1, issue 1496): the failure is the node's, so nothing is held or set back and
|
||||
// the node keeps its want. It names the node, the module, fp and the have's words, keyed by node and module, and is cleared
|
||||
// (raised false) once the node no longer fails alone.
|
||||
func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) oc.Condition {
|
||||
func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) asks.Condition {
|
||||
key := delivery.AssignerModule + ".failed-alone." + node + "." + module
|
||||
subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node}
|
||||
subject := asks.Scope{Scope: "module", ID: module, Machine: node}
|
||||
if !raised {
|
||||
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared,
|
||||
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared,
|
||||
Resolved: fmt.Sprintf("%s on %s no longer fails its health check alone.", module, node)}
|
||||
}
|
||||
headline := fmt.Sprintf("%s fails on %s alone", module, node)
|
||||
if n := len([]rune(headline)); n > occ.HeadlineLength {
|
||||
headline = string([]rune(headline)[:occ.HeadlineLength])
|
||||
if n := len([]rune(headline)); n > asks.HeadlineLength {
|
||||
headline = string([]rune(headline)[:asks.HeadlineLength])
|
||||
}
|
||||
return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised,
|
||||
return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised,
|
||||
Summary: headline, Headline: headline,
|
||||
Explanation: fmt.Sprintf("%s The health check of %s at %s failed on %s (%s), while it runs healthy on %s. The failure is %s's, "+
|
||||
"so the assigner holds nothing and sets no node back, and %s keeps its want. This clears once %s runs it healthy or "+
|
||||
"another version is the newest.", occ.NothingToDo, module, short(fp), node, f.Words, strings.Join(f.Others, ", "), node,
|
||||
"another version is the newest.", asks.NothingToDo, module, short(fp), node, f.Words, strings.Join(f.Others, ", "), node,
|
||||
node, node)}
|
||||
}
|
||||
|
||||
@@ -109,28 +108,36 @@ func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) oc
|
||||
// gives up every module with a fingerprint of the new form, so nothing applies it there and its files are gone. It is raised (urgent)
|
||||
// naming the module, the node and the rule holding its first want back with the rule's words, under the assigner's own name keyed by
|
||||
// node and module, and cleared (raised false) when the node has a want for it again.
|
||||
func NeitherCondition(node, module string, h Holding, raised bool) oc.Condition {
|
||||
func NeitherCondition(node, module string, h Holding, raised bool) asks.Condition {
|
||||
key := delivery.AssignerModule + ".neither." + node + "." + module
|
||||
subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node}
|
||||
subject := asks.Scope{Scope: "module", ID: module, Machine: node}
|
||||
if !raised {
|
||||
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared,
|
||||
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared,
|
||||
Resolved: fmt.Sprintf("%s on %s has a want again, so its own composition is applied there.", module, node)}
|
||||
}
|
||||
headline := fmt.Sprintf("%s on %s is on no list", module, node)
|
||||
if n := len([]rune(headline)); n > occ.HeadlineLength {
|
||||
headline = string([]rune(headline)[:occ.HeadlineLength])
|
||||
if n := len([]rune(headline)); n > asks.HeadlineLength {
|
||||
headline = string([]rune(headline)[:asks.HeadlineLength])
|
||||
}
|
||||
return oc.Condition{Key: key, Severity: occ.SeverityUrgent, Subject: subject, State: occ.StateRaised,
|
||||
if h.Bus {
|
||||
needs := fmt.Sprintf("put %s on %s with assigner_put", module, node)
|
||||
return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised,
|
||||
Summary: headline, Headline: headline, Needs: needs,
|
||||
Explanation: fmt.Sprintf("%s%s. %s is assigned to %s, but the node holds no want for it and the node's own composition does not "+
|
||||
"hold it either, so nothing applies it there. The assigner never moves the bus: %s. This clears once the node has a want for it.",
|
||||
asks.NeedsYou, needs, module, node, h.Words)}
|
||||
}
|
||||
return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised,
|
||||
Summary: headline, Headline: headline,
|
||||
Explanation: fmt.Sprintf("%s %s is assigned to %s, but the node holds no want for it and the node's own composition does not "+
|
||||
"hold it either, so nothing applies it there. The assigner's rule %d holds its first want back: %s. The assigner writes the want once the "+
|
||||
"rule holds, and clears this.", occ.NothingToDo, module, node, h.Rule, h.Words)}
|
||||
"rule holds, and clears this.", asks.NothingToDo, module, node, h.Rule, h.Words)}
|
||||
}
|
||||
|
||||
// NeitherLeftCondition clears the neither condition of module on node when it is no longer on neither list: why is "" when the
|
||||
// node has a live want for it again, else why it has none (it is held, its move waits for an ask, the node no longer runs it), so
|
||||
// the clear never says a want the node does not hold (issue 1945: "nats on novox has a want again" while it had none).
|
||||
func NeitherLeftCondition(node, module, why string) oc.Condition {
|
||||
func NeitherLeftCondition(node, module, why string) asks.Condition {
|
||||
c := NeitherCondition(node, module, Holding{}, false)
|
||||
if why != "" {
|
||||
c.Resolved = fmt.Sprintf("%s on %s is no longer on neither list and has no want: %s.", module, node, why)
|
||||
@@ -139,7 +146,7 @@ func NeitherLeftCondition(node, module, why string) oc.Condition {
|
||||
}
|
||||
|
||||
// LiftedCondition clears the condition of a held fingerprint the assigner lifted by itself, saying why (issue 1906).
|
||||
func LiftedCondition(module, fp string) oc.Condition {
|
||||
func LiftedCondition(module, fp string) asks.Condition {
|
||||
c := HeldCondition(module, fp, "", "", false, false)
|
||||
c.Resolved = LiftedWords(module, fp) + "."
|
||||
return c
|
||||
@@ -162,20 +169,20 @@ func RecordAsk(key, node, module, fp string, need RecordNeed, now time.Time) del
|
||||
delivery.JudgedMovesData: "moves the module's data",
|
||||
delivery.JudgedCannot: "cannot be judged: " + need.Words,
|
||||
}[need.Judgement]
|
||||
return assignerAsk(0, oc.Ask{
|
||||
return delivery.AssignerAsk{Ask: asks.Ask{
|
||||
ID: delivery.AskingID(key, 0),
|
||||
Headline: fmt.Sprintf("%s waits for your yes on %s", module, node),
|
||||
Explanation: fmt.Sprintf("%s is recorded: a person takes each of its moves. The next one on %s (version %s) %s. "+
|
||||
"The node is told to apply it only after you say yes.", module, node, short(fp), what),
|
||||
Options: []oc.Option{
|
||||
{ID: OptionYes, Label: "Yes, move it", Does: "the node is told to apply the new composition of " + module, Level: occ.Acknowledge},
|
||||
{ID: OptionNo, Label: "No, not now", Does: "the node keeps what it runs", Level: occ.Acknowledge},
|
||||
Options: []asks.Option{
|
||||
{ID: OptionYes, Label: "Yes, move it", Does: "the node is told to apply the new composition of " + module, Level: asks.Acknowledge},
|
||||
{ID: OptionNo, Label: "No, not now", Does: "the node keeps what it runs", Level: asks.Acknowledge},
|
||||
},
|
||||
Who: occ.Operator,
|
||||
Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)),
|
||||
Who: asks.Operator,
|
||||
Expires: now.Add(7 * 24 * time.Hour),
|
||||
OnExpiry: "the node keeps what it runs",
|
||||
About: key,
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
// NextBackupAsking is the number of the asking that follows the one numbered after (a negative number: none yet). Numbers 0 and 1
|
||||
@@ -193,11 +200,7 @@ var backupDigest = regexp.MustCompile(`\(composition (sha256:[0-9a-f]{64})\)`)
|
||||
// none. An answer is taken for the composition of the ask it answers, and a new revision at the same fingerprint is asked again
|
||||
// under a new number (ADR 0539 §16).
|
||||
func BackupAskDigest(a delivery.AssignerAsk) string {
|
||||
ask, err := AskOf(a)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if m := backupDigest.FindStringSubmatch(ask.Explanation); m != nil {
|
||||
if m := backupDigest.FindStringSubmatch(a.Ask.Explanation); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
@@ -213,33 +216,33 @@ func BackupAnswerWords(option string, asking int) string {
|
||||
// without a backup. digest is the composition about to be applied and asking the number of the asking, 0 the first; words are the
|
||||
// backup's own words, from the node's have.
|
||||
func BackupAsk(key, node, module, fp, digest, words string, asking int, now time.Time) delivery.AssignerAsk {
|
||||
return assignerAsk(asking, oc.Ask{
|
||||
return delivery.AssignerAsk{Asking: asking, Ask: asks.Ask{
|
||||
ID: delivery.AskingID(key, asking),
|
||||
Headline: fmt.Sprintf("The backup of %s failed on %s", module, node),
|
||||
Explanation: fmt.Sprintf("The backup before applying %s at %s on %s failed: %s. %s keeps running what it has until you answer. (composition %s)",
|
||||
module, short(fp), node, words, module, digest),
|
||||
Options: []oc.Option{
|
||||
{ID: OptionBackupAgain, Label: "Back up again", Does: "the node tries the backup again before it applies", Level: occ.Acknowledge},
|
||||
{ID: OptionBackupSkip, Label: "Go on without a backup", Does: "the node applies the new version without a backup", Level: occ.Acknowledge},
|
||||
Options: []asks.Option{
|
||||
{ID: OptionBackupAgain, Label: "Back up again", Does: "the node tries the backup again before it applies", Level: asks.Acknowledge},
|
||||
{ID: OptionBackupSkip, Label: "Go on without a backup", Does: "the node applies the new version without a backup", Level: asks.Acknowledge},
|
||||
},
|
||||
Who: occ.Operator,
|
||||
Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)),
|
||||
Who: asks.Operator,
|
||||
Expires: now.Add(7 * 24 * time.Hour),
|
||||
OnExpiry: "the module keeps running what it has",
|
||||
About: key,
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
// BackupCondition is the condition raised when a backup before an apply fails and the module is held on the node (ADR 0539 §16),
|
||||
// naming the node, the module and the backup's words; cleared under the same key.
|
||||
func BackupCondition(node, module, fp, words string, raised bool) oc.Condition {
|
||||
func BackupCondition(node, module, fp, words string, raised bool) asks.Condition {
|
||||
key := delivery.AssignerModule + ".backup." + node + "." + module + "." + strings.TrimPrefix(fp, delivery.SourcePrefix2)
|
||||
subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node}
|
||||
subject := asks.Scope{Scope: "module", ID: module, Machine: node}
|
||||
if !raised {
|
||||
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared}
|
||||
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared}
|
||||
}
|
||||
headline := fmt.Sprintf("The backup of %s failed on %s", module, node)
|
||||
needs := "answer whether to back up again or go on without a backup"
|
||||
return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised,
|
||||
return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised,
|
||||
Summary: headline, Headline: headline, Needs: needs,
|
||||
Explanation: occ.NeedsYou + needs + ". The backup before applying " + module + " at " + short(fp) + " on " + node + " failed: " + words + "."}
|
||||
Explanation: asks.NeedsYou + needs + ". The backup before applying " + module + " at " + short(fp) + " on " + node + " failed: " + words + "."}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package assign
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
@@ -22,42 +20,27 @@ func TestTheHoldAskIsValid(t *testing.T) {
|
||||
}
|
||||
now := time.Now()
|
||||
a := HoldAsk(key, "mail", fNew, "ace", "health failed, phase running: connection refused", 0, now)
|
||||
if err := occ.Check(mustAsk(t, a), now); err != nil {
|
||||
if err := a.Ask.Check(now); err != nil {
|
||||
t.Errorf("the router refuses the ask: %v", err)
|
||||
}
|
||||
if _, err := EncodeAsk(key, a); err != nil {
|
||||
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
|
||||
t.Errorf("the ask cannot be written: %v", err)
|
||||
}
|
||||
// 1906/H3: the ask names the machine whose check failed and the hold's words.
|
||||
if !strings.Contains(mustAsk(t, a).Explanation, "on ace") || !strings.Contains(mustAsk(t, a).Explanation, "connection refused") {
|
||||
t.Errorf("the ask does not name the node and the hold's words: %q", mustAsk(t, a).Explanation)
|
||||
if !strings.Contains(a.Ask.Explanation, "on ace") || !strings.Contains(a.Ask.Explanation, "connection refused") {
|
||||
t.Errorf("the ask does not name the node and the hold's words: %q", a.Ask.Explanation)
|
||||
}
|
||||
// 1906/H2: an ask written again after a lift is another asking, under an id of its own.
|
||||
again := HoldAsk(key, "mail", fNew, "ace", "x", 7, now)
|
||||
if again.Asking != 7 || mustAsk(t, again).ID != delivery.AskingID(key, 7) || mustAsk(t, again).ID == mustAsk(t, a).ID {
|
||||
t.Errorf("the asking after a lift = %d %s", again.Asking, mustAsk(t, again).ID)
|
||||
if again.Asking != 7 || again.Ask.ID != delivery.AskingID(key, 7) || again.Ask.ID == a.Ask.ID {
|
||||
t.Errorf("the asking after a lift = %d %s", again.Asking, again.Ask.ID)
|
||||
}
|
||||
if _, err := EncodeAsk(key, again); err != nil {
|
||||
if _, err := delivery.EncodeAssignerAsk(key, again); err != nil {
|
||||
t.Errorf("the ask asked again cannot be written: %v", err)
|
||||
}
|
||||
if _, err := delivery.ViewAnswerKey(key); err != nil {
|
||||
t.Errorf("the view may not answer it: %v", err)
|
||||
}
|
||||
// The join (A1d ruling 1): mesh-sdk no longer refuses another id, no option or no ask in an assigner's ask; EncodeAsk does.
|
||||
t.Run("EncodeAsk refuses what mesh-sdk no longer does", func(t *testing.T) {
|
||||
good := HoldAsk(key, "mail", fNew, "ace", "x", 0, time.Unix(100, 0))
|
||||
other := good
|
||||
other.Asking = 3 // its id is the asking 0's
|
||||
empty := good
|
||||
ask := mustAsk(t, good)
|
||||
ask.Options = nil
|
||||
empty.Ask = assignerAsk(0, ask).Ask
|
||||
for name, a := range map[string]delivery.AssignerAsk{"another id": other, "no option": empty, "no ask": {}} {
|
||||
if _, err := EncodeAsk(key, a); err == nil {
|
||||
t.Errorf("%s: written", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1906/D3 (ADR 0539 §19): the ask about a data module held on a node is a valid ask of the router and of mesh-sdk's layout at
|
||||
@@ -69,24 +52,24 @@ func TestTheRestoreAskIsValid(t *testing.T) {
|
||||
}
|
||||
now := time.Now()
|
||||
a := RestoreAsk(key, "ace", "mail", fNew, "connection refused", now)
|
||||
if err := occ.Check(mustAsk(t, a), now); err != nil {
|
||||
if err := a.Ask.Check(now); err != nil {
|
||||
t.Errorf("the router refuses the ask: %v", err)
|
||||
}
|
||||
if _, err := EncodeAsk(key, a); err != nil {
|
||||
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
|
||||
t.Errorf("the ask cannot be written: %v", err)
|
||||
}
|
||||
if _, err := delivery.ViewAnswerKey(key); err != nil {
|
||||
t.Errorf("the view may not answer it: %v", err)
|
||||
}
|
||||
var ids []string
|
||||
for _, o := range mustAsk(t, a).Options {
|
||||
for _, o := range a.Ask.Options {
|
||||
ids = append(ids, o.ID)
|
||||
}
|
||||
if len(ids) != 2 || ids[0] != OptionRestore || ids[1] != OptionLeaveStopped {
|
||||
t.Errorf("options = %v", ids)
|
||||
}
|
||||
if !strings.Contains(mustAsk(t, a).Explanation, "connection refused") || !strings.Contains(mustAsk(t, a).Explanation, "ace") {
|
||||
t.Errorf("the ask does not name the node and the check's words: %q", mustAsk(t, a).Explanation)
|
||||
if !strings.Contains(a.Ask.Explanation, "connection refused") || !strings.Contains(a.Ask.Explanation, "ace") {
|
||||
t.Errorf("the ask does not name the node and the check's words: %q", a.Ask.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,13 +83,13 @@ func TestTheAssignersConditions(t *testing.T) {
|
||||
|
||||
func heldConditionNamesModuleNodeAndWords(t *testing.T) {
|
||||
c := HeldCondition("mail", fNew, "ace", "connection refused", true, true)
|
||||
if _, err := occ.EncodeCondition(c); err != nil {
|
||||
if _, err := asks.EncodeCondition(c); err != nil {
|
||||
t.Fatalf("the condition is refused: %v", err)
|
||||
}
|
||||
if c.State != occ.StateRaised || c.Subject.ID != "mail" || c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") || !strings.Contains(c.Key, "mail") {
|
||||
if c.State != asks.StateRaised || c.Subject.ID != "mail" || c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") || !strings.Contains(c.Key, "mail") {
|
||||
t.Errorf("condition = %+v", c)
|
||||
}
|
||||
if got := HeldCondition("mail", fNew, "", "", false, false); got.State != occ.StateCleared || got.Key != c.Key {
|
||||
if got := HeldCondition("mail", fNew, "", "", false, false); got.State != asks.StateCleared || got.Key != c.Key {
|
||||
t.Errorf("cleared = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -114,10 +97,10 @@ func heldConditionNamesModuleNodeAndWords(t *testing.T) {
|
||||
// 1906/H2: a hold lifted by itself clears the held condition under its key, saying why.
|
||||
func liftedHoldClearsItsConditionSayingWhy(t *testing.T) {
|
||||
c := LiftedCondition("mail", fNew)
|
||||
if _, err := occ.EncodeCondition(c); err != nil {
|
||||
if _, err := asks.EncodeCondition(c); err != nil {
|
||||
t.Fatalf("the condition is refused: %v", err)
|
||||
}
|
||||
if c.State != occ.StateCleared || c.Key != HeldCondition("mail", fNew, "ace", "x", true, false).Key || !strings.Contains(c.Resolved, "lifted its hold by itself") {
|
||||
if c.State != asks.StateCleared || c.Key != HeldCondition("mail", fNew, "ace", "x", true, false).Key || !strings.Contains(c.Resolved, "lifted its hold by itself") {
|
||||
t.Errorf("condition = %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +112,7 @@ func liftedHoldClearsItsConditionSayingWhy(t *testing.T) {
|
||||
func saidConditionsAreFindings(t *testing.T) {
|
||||
at := time.Date(2026, 10, 29, 21, 0, 0, 0, time.UTC)
|
||||
for _, c := range []struct {
|
||||
raised, cleared oc.Condition
|
||||
raised, cleared asks.Condition
|
||||
key string
|
||||
}{
|
||||
{HeldCondition("mail", fNew, "ace", "refused", true, true), LiftedCondition("mail", fNew), "module.mail." + strings.TrimPrefix(fNew, delivery.SourcePrefix2) + ".held"},
|
||||
@@ -144,7 +127,7 @@ func saidConditionsAreFindings(t *testing.T) {
|
||||
puts[k] = got
|
||||
return err
|
||||
}, Delete: func(k string) error { deletes = append(deletes, k); return nil }, Now: func() time.Time { return at }}
|
||||
say := f.Say(func(oc.Condition) error { said++; return nil })
|
||||
say := f.Say(func(asks.Condition) error { said++; return nil })
|
||||
if err := say(c.raised); err != nil {
|
||||
t.Fatalf("%s: %v", c.key, err)
|
||||
}
|
||||
@@ -156,11 +139,11 @@ func saidConditionsAreFindings(t *testing.T) {
|
||||
if err := say(c.cleared); err != nil || !slices.Equal(deletes, []string{c.key}) || said != 0 {
|
||||
t.Fatalf("%s: deleted %v, said %d (%v)", c.key, deletes, said, err)
|
||||
}
|
||||
if err := (Findings{}).Say(func(oc.Condition) error { said++; return nil })(c.raised); err != nil || said != 1 {
|
||||
if err := (Findings{}).Say(func(asks.Condition) error { said++; return nil })(c.raised); err != nil || said != 1 {
|
||||
t.Fatalf("%s: with no findings bucket said %d (%v), want straight once", c.key, said, err)
|
||||
}
|
||||
}
|
||||
if _, _, _, err := FindingOf(oc.Condition{Key: "another.x", State: occ.StateRaised}, at); err == nil {
|
||||
if _, _, _, err := FindingOf(asks.Condition{Key: "another.x", State: asks.StateRaised}, at); err == nil {
|
||||
t.Error("a condition not the assigner's is a finding")
|
||||
}
|
||||
}
|
||||
@@ -181,24 +164,24 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
|
||||
dgst := "sha256:" + strings.Repeat("b", 64)
|
||||
b := BackupAsk(bkey, "ace", "mail", fNew, dgst, "no space left", 0, now)
|
||||
for key, a := range map[string]delivery.AssignerAsk{rkey: r, bkey: b} {
|
||||
if err := occ.Check(mustAsk(t, a), now); err != nil {
|
||||
if err := a.Ask.Check(now); err != nil {
|
||||
t.Errorf("%s: the router refuses the ask: %v", key, err)
|
||||
}
|
||||
if _, err := EncodeAsk(key, a); err != nil {
|
||||
if _, err := delivery.EncodeAssignerAsk(key, a); err != nil {
|
||||
t.Errorf("%s: cannot be written: %v", key, err)
|
||||
}
|
||||
if _, err := delivery.ViewAnswerKey(key); err != nil {
|
||||
t.Errorf("%s: the view may not answer it: %v", key, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mustAsk(t, r).Explanation, "gone") || mustAsk(t, r).Options[0].ID != OptionYes {
|
||||
t.Errorf("the record ask = %+v", mustAsk(t, r))
|
||||
if !strings.Contains(r.Ask.Explanation, "gone") || r.Ask.Options[0].ID != OptionYes {
|
||||
t.Errorf("the record ask = %+v", r.Ask)
|
||||
}
|
||||
if !strings.Contains(mustAsk(t, b).Explanation, "no space left") || mustAsk(t, b).Options[0].ID != delivery.BackupAgain || mustAsk(t, b).Options[1].ID != delivery.BackupSkip {
|
||||
t.Errorf("the backup ask = %+v", mustAsk(t, b))
|
||||
if !strings.Contains(b.Ask.Explanation, "no space left") || b.Ask.Options[0].ID != delivery.BackupAgain || b.Ask.Options[1].ID != delivery.BackupSkip {
|
||||
t.Errorf("the backup ask = %+v", b.Ask)
|
||||
}
|
||||
// the id binds the ask to the composition and to its number (review round 1, items 3 and 4)
|
||||
if again := BackupAsk(bkey, "ace", "mail", fNew, dgst, "x", NextBackupAsking(0), now); mustAsk(t, again).ID == mustAsk(t, b).ID {
|
||||
if again := BackupAsk(bkey, "ace", "mail", fNew, dgst, "x", NextBackupAsking(0), now); again.Ask.ID == b.Ask.ID {
|
||||
t.Errorf("asked again under the same id")
|
||||
}
|
||||
if NextBackupAsking(-1) != 0 || NextBackupAsking(0) != 2 || NextBackupAsking(2) != 3 {
|
||||
@@ -211,7 +194,7 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
|
||||
t.Errorf("two answers of the same option are one want value")
|
||||
}
|
||||
c := BackupCondition("ace", "mail", fNew, "no space left", true)
|
||||
if _, err := occ.EncodeCondition(c); err != nil {
|
||||
if _, err := asks.EncodeCondition(c); err != nil {
|
||||
t.Errorf("the backup condition is refused: %v", err)
|
||||
}
|
||||
// ADR 0546 clauses 7 and 8: a put's ask is valid at the record ask's key; for the bus its yes approves and binds the act,
|
||||
@@ -223,39 +206,32 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("bus %v: %v", bus, err)
|
||||
}
|
||||
if err := occ.Check(mustAsk(t, a), now); err != nil {
|
||||
if err := a.Ask.Check(now); err != nil {
|
||||
t.Errorf("bus %v: the router refuses the put's ask: %v", bus, err)
|
||||
}
|
||||
if _, err := EncodeAsk(rkey, a); err != nil {
|
||||
if _, err := delivery.EncodeAssignerAsk(rkey, a); err != nil {
|
||||
t.Errorf("bus %v: the put's ask cannot be written: %v", bus, err)
|
||||
}
|
||||
viewed := delivery.ViewAnswerFits(delivery.ViewAnswer{Asking: mustAsk(t, a).ID, Option: OptionYes}, rkey, a)
|
||||
yes := mustAsk(t, a).Options[0]
|
||||
// mesh-sdk gives no refusal by level any more: the teller refuses an option a view cannot answer (its level is not
|
||||
// acknowledge), as it did through the sdk before.
|
||||
if viewed == nil {
|
||||
if o, ok := occ.OptionOf(mustAsk(t, a), OptionYes); ok && o.Level != "" && o.Level != occ.Acknowledge {
|
||||
viewed = fmt.Errorf("only a channel that proves who answered takes the option %s", o.ID)
|
||||
}
|
||||
}
|
||||
viewed := delivery.ViewAnswerFits(delivery.ViewAnswer{Asking: a.Ask.ID, Option: OptionYes}, a)
|
||||
yes := a.Ask.Options[0]
|
||||
switch {
|
||||
case !strings.Contains(mustAsk(t, a).Explanation, "the bus's user list"):
|
||||
t.Errorf("bus %v: the put's ask does not say why: %s", bus, mustAsk(t, a).Explanation)
|
||||
case bus && (yes.Level != occ.Approve || yes.Binds == "" || viewed == nil || expiresAt(t, a).After(now.Add(occ.ApproveLasts))):
|
||||
t.Errorf("bus: the yes is %+v, the view's answer %v, expiring %s", yes, viewed, expiresAt(t, a))
|
||||
case !bus && (yes.Level != occ.Acknowledge || viewed != nil):
|
||||
case !strings.Contains(a.Ask.Explanation, "the bus's user list"):
|
||||
t.Errorf("bus %v: the put's ask does not say why: %s", bus, a.Ask.Explanation)
|
||||
case bus && (yes.Level != asks.Approve || yes.Binds == "" || viewed == nil || a.Ask.Expires.After(now.Add(asks.ApproveLasts))):
|
||||
t.Errorf("bus: the yes is %+v, the view's answer %v, expiring %s", yes, viewed, a.Ask.Expires)
|
||||
case !bus && (yes.Level != asks.Acknowledge || viewed != nil):
|
||||
t.Errorf("a recorded module: the yes is %+v, the view's answer %v", yes, viewed)
|
||||
}
|
||||
later := put
|
||||
later.At = now.Add(time.Minute)
|
||||
b, _ := PutAsk(rkey, "ace", "mail", later, d, bus, now)
|
||||
if mustAsk(t, b).ID == mustAsk(t, a).ID || b.Asking != PutAsking(later) {
|
||||
t.Errorf("bus %v: a later put's ask is asking %d under id %q, the earlier's %q", bus, b.Asking, mustAsk(t, b).ID, mustAsk(t, a).ID)
|
||||
if b.Ask.ID == a.Ask.ID || b.Asking != PutAsking(later) {
|
||||
t.Errorf("bus %v: a later put's ask is asking %d under id %q, the earlier's %q", bus, b.Asking, b.Ask.ID, a.Ask.ID)
|
||||
}
|
||||
if err := occ.Performs(yes, PutAct("ace", "mail", fNew, dgst)); err != nil {
|
||||
if err := yes.Performs(PutAct("ace", "mail", fNew, dgst)); err != nil {
|
||||
t.Errorf("bus %v: the yes does not perform the act it bound: %v", bus, err)
|
||||
}
|
||||
if err := occ.Performs(yes, PutAct("ace", "mail", fNew, dig(fOld))); bus && err == nil {
|
||||
if err := yes.Performs(PutAct("ace", "mail", fNew, dig(fOld))); bus && err == nil {
|
||||
t.Errorf("bus: the yes performs another composition's act")
|
||||
}
|
||||
}
|
||||
@@ -265,29 +241,14 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) {
|
||||
// under the assigner's own name keyed by node and module, and is cleared under the same key.
|
||||
func TestTheNeitherConditionNamesModuleNodeAndRule(t *testing.T) {
|
||||
c := NeitherCondition("g14", "i3status-rust", Holding{Rule: delivery.RuleProviders, Words: "no provider of x11-display runs healthy on g14 or at its canary"}, true)
|
||||
if _, err := occ.EncodeCondition(c); err != nil {
|
||||
if _, err := asks.EncodeCondition(c); err != nil {
|
||||
t.Fatalf("the condition is refused: %v", err)
|
||||
}
|
||||
if c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent || c.Subject.ID != "i3status-rust" || c.Subject.Machine != "g14" ||
|
||||
if c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent || c.Subject.ID != "i3status-rust" || c.Subject.Machine != "g14" ||
|
||||
!strings.Contains(c.Explanation, "x11-display") || !strings.Contains(c.Explanation, "rule 5") || c.Key != "mesh-delivery-assigner.neither.g14.i3status-rust" {
|
||||
t.Errorf("condition = %+v", c)
|
||||
}
|
||||
if got := NeitherCondition("g14", "i3status-rust", Holding{}, false); got.State != occ.StateCleared || got.Key != c.Key {
|
||||
if got := NeitherCondition("g14", "i3status-rust", Holding{}, false); got.State != asks.StateCleared || got.Key != c.Key {
|
||||
t.Errorf("cleared = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// mustAsk is the router's ask an asks-state value carries.
|
||||
func mustAsk(t *testing.T, a delivery.AssignerAsk) oc.Ask {
|
||||
t.Helper()
|
||||
ask, err := AskOf(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ask
|
||||
}
|
||||
|
||||
func expiresAt(t *testing.T, a delivery.AssignerAsk) time.Time {
|
||||
t.Helper()
|
||||
return ExpiresAt(mustAsk(t, a))
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
@@ -25,7 +24,7 @@ var FindingsBucket = health.FindingsBucket(delivery.AssignerModule)
|
||||
// FindingOf is a said condition as a finding: its key `<scope>.<id>.<kind>`, from the condition's own key
|
||||
// `mesh-delivery-assigner.<kind>.<id…>` (the subject's id when the key names none), its words unchanged. raised is false for
|
||||
// a clear, whose key is to be deleted; value is nil then.
|
||||
func FindingOf(c oc.Condition, at time.Time) (key string, value []byte, raised bool, err error) {
|
||||
func FindingOf(c asks.Condition, at time.Time) (key string, value []byte, raised bool, err error) {
|
||||
rest, ok := strings.CutPrefix(c.Key, delivery.AssignerModule+".")
|
||||
if !ok || rest == "" {
|
||||
return "", nil, false, fmt.Errorf("the condition %q is not the assigner's", c.Key)
|
||||
@@ -39,10 +38,10 @@ func FindingOf(c oc.Condition, at time.Time) (key string, value []byte, raised b
|
||||
scope = health.ScopeModule
|
||||
}
|
||||
key = health.FindingKey(scope, id, kind)
|
||||
if c.State == occ.StateCleared {
|
||||
if c.State == asks.StateCleared {
|
||||
return key, nil, false, nil
|
||||
}
|
||||
if c.State != occ.StateRaised {
|
||||
if c.State != asks.StateRaised {
|
||||
return "", nil, false, fmt.Errorf("the condition %s is neither raised nor cleared", c.Key)
|
||||
}
|
||||
summary := c.Summary
|
||||
@@ -71,8 +70,8 @@ type Findings struct {
|
||||
// Say is say with each condition written as a finding (or its finding deleted), which the condition keeper carries, the
|
||||
// one source since the controller's end (novox/hq ADR 0590 §3); said straight to the router only where there is no
|
||||
// findings bucket at all. A finding that cannot be written is the error.
|
||||
func (f Findings) Say(say func(oc.Condition) error) func(oc.Condition) error {
|
||||
return func(c oc.Condition) error {
|
||||
func (f Findings) Say(say func(asks.Condition) error) func(asks.Condition) error {
|
||||
return func(c asks.Condition) error {
|
||||
var errs []error
|
||||
if f.Put != nil {
|
||||
now := time.Now
|
||||
|
||||
@@ -3,21 +3,20 @@ package assign
|
||||
import (
|
||||
"strings"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// SeatWaitsCondition is the assigner's word on the seat mesh-placement while a state of the seat is not bound (ADR 0567
|
||||
// §1): raised naming each, cleared when none is left.
|
||||
func SeatWaitsCondition(unbound []string) oc.Condition {
|
||||
func SeatWaitsCondition(unbound []string) asks.Condition {
|
||||
key := delivery.AssignerModule + ".seat-states"
|
||||
subject := oc.ConditionSubject{Scope: "module", ID: delivery.AssignerModule}
|
||||
subject := asks.Scope{Scope: "module", ID: delivery.AssignerModule}
|
||||
if len(unbound) == 0 {
|
||||
return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared, Resolved: "the seat mesh-placement holds all its states."}
|
||||
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, Resolved: "the seat mesh-placement holds all its states."}
|
||||
}
|
||||
why := "the seat waits for its states: " + strings.Join(unbound, ", ") + " are not on the bus or not granted yet"
|
||||
return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised,
|
||||
return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised,
|
||||
Summary: "the seat waits for its states", Headline: "the seat waits for its states",
|
||||
Explanation: occ.NothingToDo + " " + why + "; each is bound at the next pass once it is, with no restart."}
|
||||
Explanation: asks.NothingToDo + " " + why + "; each is bound at the next pass once it is, with no restart."}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
// A person's upgrade policy is the assigner's (novox/hq ADR 0565 §7, §8, issue 1953 D4, D5): kept in its state `policies`, one
|
||||
// key per module, set and taken back through `mesh-wants.upgrade`. Every reader takes one derivation, mesh-sdk's
|
||||
// catalogue.Upgrade: a person's choice over the module's word, its irreplaceable data and the default, the bus as any module (ADR 0601 §1).
|
||||
// catalogue.Upgrade: a person's choice over the module's word, its irreplaceable data and the default, the bus never rolled.
|
||||
|
||||
// PoliciesBucket is the assigner's state `policies`.
|
||||
var PoliciesBucket = delivery.AssignerModule + "_" + delivery.AssignerPoliciesState
|
||||
@@ -70,7 +70,7 @@ func ProvisionFailedFinding(key string, p provisions.Provision, at time.Time) ([
|
||||
return health.EncodeFinding(health.Finding{Key: ProvisionFailedKey(key), Kind: KindProvisionFailed,
|
||||
Subject: health.Subject{Scope: health.ScopeProvider, ID: key, Machine: p.Consumer.Node, Also: also(p.Provider.Node, p.Consumer.Node)},
|
||||
Severity: health.Warning, Summary: summary, Headline: clipHeadline(p.Consumer.Module + " waits for a provision that failed"),
|
||||
Explanation: occ.NothingToDo + " " + summary + ". It clears once the provider provides it.",
|
||||
Explanation: asks.NothingToDo + " " + summary + ". It clears once the provider provides it.",
|
||||
Resolved: p.Provision + " for " + p.Consumer.Module + " is provided", Evidence: []health.Evidence{{At: at.UTC(), Said: p.Why}},
|
||||
Source: "assigner." + KindProvisionFailed, Observer: delivery.AssignerModule, At: at.UTC()})
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func ProvisionMissingFinding(key string, bound time.Duration, at time.Time) ([]b
|
||||
return health.EncodeFinding(health.Finding{Key: ProvisionMissingKey(key), Kind: KindProvisionMissing,
|
||||
Subject: health.Subject{Scope: health.ScopeProvider, ID: key, Machine: node},
|
||||
Severity: health.Warning, Summary: summary, Headline: clipHeadline(module + " on " + node + " lacks " + provision),
|
||||
Explanation: occ.NothingToDo + " " + summary + ". It clears once the provision has its key.",
|
||||
Explanation: asks.NothingToDo + " " + summary + ". It clears once the provision has its key.",
|
||||
Resolved: provision + " for " + module + " on " + node + " has its key", Evidence: []health.Evidence{{At: at.UTC(), Said: summary}},
|
||||
Source: "assigner." + KindProvisionMissing, Observer: delivery.AssignerModule, At: at.UTC()})
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func UnplacedFinding(node string, at time.Time) ([]byte, error) {
|
||||
return health.EncodeFinding(health.Finding{Key: UnplacedKey(node), Kind: KindUnplaced,
|
||||
Subject: health.Subject{Scope: health.ScopeMachine, ID: node, Machine: node}, Severity: health.Warning, Summary: summary,
|
||||
Headline: clipHeadline(node + " has no placement"), Needs: "place it with mesh-placement.propose.",
|
||||
Explanation: occ.NeedsYou + "place it with mesh-placement.propose. " + summary + ".",
|
||||
Explanation: asks.NeedsYou + "place it with mesh-placement.propose. " + summary + ".",
|
||||
Resolved: node + " is placed", Evidence: []health.Evidence{{At: at.UTC(), Said: summary}},
|
||||
Source: "assigner." + KindUnplaced, Observer: delivery.AssignerModule, At: at.UTC()})
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -211,15 +210,15 @@ func PutAsking(p delivery.Put) int {
|
||||
}
|
||||
|
||||
// PutAct is the act a bus put's yes binds and that is performed when its want is written.
|
||||
func PutAct(node, module, fp, composed string) occ.Act {
|
||||
return occ.Act{"put": "want", "node": node, "module": module, "fp": fp, "composed": composed}
|
||||
func PutAct(node, module, fp, composed string) asks.Act {
|
||||
return asks.Act{"put": "want", "node": node, "module": module, "fp": fp, "composed": composed}
|
||||
}
|
||||
|
||||
// PutAsk is the ask about a put's move (ADR 0546 clauses 7 and 8), under the record ask's key of the composition so the
|
||||
// teller relays it and its answer as any record ask's. For the bus its yes is of level approve and binds the act, so only a
|
||||
// channel that proves who answered takes it (ADR 0259), never the view.
|
||||
func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest, bus bool, now time.Time) (delivery.AssignerAsk, error) {
|
||||
yes := oc.Option{ID: OptionYes, Label: "Yes, put it", Does: "the node is told to apply " + module + " at " + short(p.FP), Level: occ.Acknowledge}
|
||||
yes := asks.Option{ID: OptionYes, Label: "Yes, put it", Does: "the node is told to apply " + module + " at " + short(p.FP), Level: asks.Acknowledge}
|
||||
what := map[delivery.Judgement]string{
|
||||
delivery.JudgedRecreates: "recreates a container",
|
||||
delivery.JudgedMovesData: "moves the module's data",
|
||||
@@ -228,26 +227,26 @@ func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest,
|
||||
delivery.JudgedUnchanged: "changes nothing the node runs",
|
||||
}[d.Judgement]
|
||||
explanation := fmt.Sprintf("Asked to put %s at version %s on %s: %s. The move %s.", module, short(p.FP), node, p.Why, what)
|
||||
expires := occ.FormatTime(now.Add(7 * 24 * time.Hour))
|
||||
expires := now.Add(7 * 24 * time.Hour)
|
||||
if bus {
|
||||
binds, err := occ.ActDigest(PutAct(node, module, p.FP, d.Digest))
|
||||
binds, err := asks.ActDigest(PutAct(node, module, p.FP, d.Digest))
|
||||
if err != nil {
|
||||
return delivery.AssignerAsk{}, err
|
||||
}
|
||||
yes.Level, yes.Binds = occ.Approve, binds
|
||||
yes.Level, yes.Binds = asks.Approve, binds
|
||||
yes.Does = "the bus's streams are snapshotted, then " + node + " is told to apply the bus at " + short(p.FP)
|
||||
explanation += " It is the mesh's bus: every module reconnects while it restarts, and its streams are snapshotted first."
|
||||
expires = occ.FormatTime(now.Add(occ.ApproveLasts))
|
||||
expires = now.Add(asks.ApproveLasts)
|
||||
}
|
||||
return assignerAsk(PutAsking(p), oc.Ask{
|
||||
return delivery.AssignerAsk{Ask: asks.Ask{
|
||||
ID: delivery.AskingID(key, PutAsking(p)),
|
||||
Headline: fmt.Sprintf("Put %s on %s?", module, node),
|
||||
Explanation: explanation,
|
||||
Options: []oc.Option{yes,
|
||||
{ID: OptionNo, Label: "No", Does: "the node keeps what it runs, and the put is refused", Level: occ.Acknowledge}},
|
||||
Who: occ.Operator,
|
||||
Options: []asks.Option{yes,
|
||||
{ID: OptionNo, Label: "No", Does: "the node keeps what it runs, and the put is refused", Level: asks.Acknowledge}},
|
||||
Who: asks.Operator,
|
||||
Expires: expires,
|
||||
OnExpiry: "the node keeps what it runs",
|
||||
About: key,
|
||||
}), nil
|
||||
}, Asking: PutAsking(p)}, nil
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -171,15 +170,15 @@ const (
|
||||
|
||||
// CallClearedCondition clears the condition of a refused controller call a previous process raised, for its pair (node and
|
||||
// module, or node and provision for a pin).
|
||||
func CallClearedCondition(kind, node, module string) oc.Condition {
|
||||
return oc.Condition{Key: delivery.AssignerModule + "." + kind + "." + node + "." + module,
|
||||
Subject: oc.ConditionSubject{Scope: "module", ID: module, Machine: node}, State: occ.StateCleared,
|
||||
func CallClearedCondition(kind, node, module string) asks.Condition {
|
||||
return asks.Condition{Key: delivery.AssignerModule + "." + kind + "." + node + "." + module,
|
||||
Subject: asks.Scope{Scope: "module", ID: module, Machine: node}, State: asks.StateCleared,
|
||||
Resolved: "the assigner calls no verb of the controller any more: placement is the one source."}
|
||||
}
|
||||
|
||||
// PlacementAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions.
|
||||
func PlacementAct(p delivery.PlacementProposal) occ.Act {
|
||||
return occ.Act{"placement": "write", "proposal": p.ID, "changes": delivery.ChangesDigest(p)}
|
||||
func PlacementAct(p delivery.PlacementProposal) asks.Act {
|
||||
return asks.Act{"placement": "write", "proposal": p.ID, "changes": delivery.ChangesDigest(p)}
|
||||
}
|
||||
|
||||
// PlacementAsking is a proposal's asking number: each judging that raises its ask again (again, a key moved) is
|
||||
@@ -191,29 +190,29 @@ func PlacementAsking(p delivery.PlacementProposal, at time.Time) int {
|
||||
// PlacementAsk is the authorising ask about a proposal (§5): an Approve that binds its act, so only a channel that
|
||||
// proves who answered takes it, with the brief of the verdict by node (judge.Brief) as its explanation.
|
||||
func PlacementAsk(key string, p delivery.PlacementProposal, brief string, asking int, now time.Time) (delivery.AssignerAsk, error) {
|
||||
binds, err := occ.ActDigest(PlacementAct(p))
|
||||
binds, err := asks.ActDigest(PlacementAct(p))
|
||||
if err != nil {
|
||||
return delivery.AssignerAsk{}, err
|
||||
}
|
||||
nodes := p.Nodes()
|
||||
headline := fmt.Sprintf("Change placement on %s?", strings.Join(nodes, ", "))
|
||||
if n := len([]rune(headline)); n > occ.HeadlineLength {
|
||||
if n := len([]rune(headline)); n > asks.HeadlineLength {
|
||||
headline = fmt.Sprintf("Change placement on %d machines?", len(nodes))
|
||||
}
|
||||
return assignerAsk(asking, oc.Ask{
|
||||
return delivery.AssignerAsk{Ask: asks.Ask{
|
||||
ID: delivery.AskingID(key, asking),
|
||||
Headline: headline,
|
||||
Explanation: brief,
|
||||
Options: []oc.Option{
|
||||
Options: []asks.Option{
|
||||
{ID: OptionYes, Label: "Approve", Does: "the placement of " + strings.Join(nodes, ", ") + " is written as proposed",
|
||||
Level: occ.Approve, Binds: binds},
|
||||
{ID: OptionNo, Label: "Decline", Does: "nothing is written, and the proposal ends", Level: occ.Acknowledge},
|
||||
Level: asks.Approve, Binds: binds},
|
||||
{ID: OptionNo, Label: "Decline", Does: "nothing is written, and the proposal ends", Level: asks.Acknowledge},
|
||||
},
|
||||
Who: occ.Operator,
|
||||
Expires: occ.FormatTime(now.Add(occ.ApproveLasts)),
|
||||
Who: asks.Operator,
|
||||
Expires: now.Add(asks.ApproveLasts),
|
||||
OnExpiry: "nothing is written, and the proposal ends",
|
||||
About: key,
|
||||
}), nil
|
||||
}, Asking: asking}, nil
|
||||
}
|
||||
|
||||
// Rest is what a proposal leaves to write once the keys of written are: its changes of the other nodes, to be laid again
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestThePlacementSeatDecides(t *testing.T) {
|
||||
// The seat says it waits while one of its states is not bound, naming it, and clears once all are.
|
||||
for _, unbound := range [][]string{{delivery.PlacementPulsesBucket}, nil} {
|
||||
c := SeatWaitsCondition(unbound)
|
||||
if _, err := occ.EncodeCondition(c); err != nil || (len(unbound) > 0) != (c.State == occ.StateRaised) ||
|
||||
if _, err := asks.EncodeCondition(c); err != nil || (len(unbound) > 0) != (c.State == asks.StateRaised) ||
|
||||
(len(unbound) > 0 && !strings.Contains(c.Explanation, "the seat waits for its states: "+delivery.PlacementPulsesBucket)) {
|
||||
t.Errorf("waiting for %v: %+v, %v", unbound, c, err)
|
||||
}
|
||||
@@ -92,12 +92,12 @@ func TestThePlacementSeatDecides(t *testing.T) {
|
||||
t.Fatalf("writes = %+v, %v", writes, err)
|
||||
}
|
||||
a, err := PlacementAsk("placement.p-one", p, "move web (proposed by operator@laptop)", 7, time.Unix(100, 0))
|
||||
if err != nil || occ.Check(mustAsk(t, a), time.Unix(100, 0)) != nil || mustAsk(t, a).Options[0].Level != occ.Approve || occ.Performs(mustAsk(t, a).Options[0], PlacementAct(p)) != nil {
|
||||
if err != nil || a.Ask.Check(time.Unix(100, 0)) != nil || a.Ask.Options[0].Level != asks.Approve || a.Ask.Options[0].Performs(PlacementAct(p)) != nil {
|
||||
t.Fatalf("the ask = %+v, %v", a, err)
|
||||
}
|
||||
q := p
|
||||
q.Changes = append(slices.Clone(p.Changes), delivery.PlacementProposalChange{Op: delivery.OpUnpin, Node: "laptop", Provision: "x"})
|
||||
if occ.Performs(mustAsk(t, a).Options[0], PlacementAct(q)) == nil {
|
||||
if a.Ask.Options[0].Performs(PlacementAct(q)) == nil {
|
||||
t.Error("the ask's yes binds changes it was not asked about")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package assign
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// verbWaiting says why the module's want on node waits for a verb it invokes (novox/hq ADR 0614 §2): the first seat verb or
|
||||
// module tool of Invokes (the module graph's verb edges, those it leaves out on a cycle not among them, §3) that no
|
||||
// running healthy build serves. Nil Served (nothing read of what runs) waits for none, and a verb a running build serves
|
||||
// already adds no wait. It holds under the provisions' rule (RuleProvisions): no reader knows a rule number of its own
|
||||
// yet, so the words name the edge (graph.VerbWaitWords, which the delivery view reads).
|
||||
func (m Module) verbWaiting(node string) (string, bool) {
|
||||
if m.Served == nil {
|
||||
return "", false
|
||||
}
|
||||
for _, v := range slices.Sorted(slices.Values(m.Invokes)) {
|
||||
if !m.Served(v) {
|
||||
return graph.VerbWaitWords(v, m.Name, node), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// busStateWaiting says why the module's want on node waits for bus state (ADR 0614 §5): its manifest asks bus state (a
|
||||
// bus-state entry, as the module graph reads it) and no build whose manifest offers bus-state runs healthy on a node
|
||||
// present. The provision bus-state is answered with no credential of a consumer's own, so ADR 0603 §5 counts the
|
||||
// provider's own have and no provisioner key is waited for, handed or not. Nil Offering (nothing read of what runs) waits
|
||||
// for none, and neither does a module that offers bus-state itself. It holds under the provisions' rule, its words naming bus state (graph.BusStateWaitWords).
|
||||
func (m Module) busStateWaiting(node string) (string, bool) {
|
||||
if len(m.Manifest.BusState) == 0 || m.Offering == nil || m.Offering(graph.BusState) ||
|
||||
slices.Contains(m.Manifest.Offers(), graph.BusState) { // the bus module offering it waits on nobody for it
|
||||
|
||||
return "", false
|
||||
}
|
||||
return graph.BusStateWaitWords(m.Name, node), true
|
||||
}
|
||||
@@ -11,9 +11,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// Module is what the six rules read of one module (ADR 0429 §6, ADR 0528 clause 3): its newest fingerprint F at the
|
||||
@@ -39,21 +36,6 @@ type Module struct {
|
||||
// Answering is the manifests of the modules placed somewhere that provide a provision: what catalogue.Awaited
|
||||
// judges a want's provisions by (issue 774). Nil: none known, and no key is waited for that is not there.
|
||||
Answering func(provision string) []catalogue.Manifest
|
||||
// Offered says whether any module offers a provision by name or by its own name (catalogue.Manifest.Offers) among every
|
||||
// manifest the trunk holds and the modules the mesh provides itself, placed or not. Nil: not known, and no requirement
|
||||
// is waited on for want of an offer.
|
||||
Offered func(provision string) bool
|
||||
// Invokes are the seat verbs and module tools the module graph says the module invokes and keeps as edges to the
|
||||
// modules serving them, those it leaves out on a cycle not among them (ADR 0614 §2, §3): the want waits until each is
|
||||
// served (Served). Nil: no graph node is read, and no want waits on a verb.
|
||||
Invokes []string
|
||||
// Served says whether a build serving a seat verb or module tool runs healthy, counted from its holder's have (ADR 0614
|
||||
// §2, as ADR 0603 §5 counts a provider's). Nil: nothing is read of what runs, and no want waits on a verb.
|
||||
Served func(invoke string) bool
|
||||
// Offering says whether a build whose manifest offers a provision runs healthy on a node present, counted from the
|
||||
// provider's own have (ADR 0603 §5): what a want asking bus state waits for (ADR 0614 §5). Nil: nothing is read of what
|
||||
// runs, and no want waits on bus state.
|
||||
Offering func(provision string) bool
|
||||
// Chosen is a person's choice of the module's upgrade policy (the assigner's `policies`, ADR 0565 §7); nil: none.
|
||||
Chosen *catalogue.PersonPolicy
|
||||
Nodes []string
|
||||
@@ -63,10 +45,6 @@ type Module struct {
|
||||
// Allowed says whether the operator answered yes to the record ask about the move of this module on node to the composition
|
||||
// with this digest (ADR 0539 §14). Nil: none did.
|
||||
Allowed func(node, digest string) bool
|
||||
// Release names the release whose Approve carries the build fp, as mesh-delivery's gate holds it for the release's heads,
|
||||
// read by the build's source revision (ADR 0601 §1): that Approve is the yes to a move of the build alone. False when no
|
||||
// approved release is known to carry it. Nil: none is read.
|
||||
Release func(fp string) (string, bool)
|
||||
// Released is a hold on FP the operator released (ADR 0539 §19): FP is not held again by a health check that failed.
|
||||
Released bool
|
||||
// Restored says whether the operator answered "restore" to the restore ask of this data module on node: it is then set back
|
||||
@@ -78,13 +56,6 @@ type Module struct {
|
||||
Now time.Time
|
||||
// Puts are the puts of the module, by node (ADR 0546): while one owns a node's want (PutOwns), the rules pass over it.
|
||||
Puts map[string]delivery.Put
|
||||
// Needs are the reader capabilities the manifest at FP needs (the indexer's modules value, catalogue.ManifestNeeds, ADR
|
||||
// 0614 §4): a node's want moves only while that node's engine states each of them. Nil: none.
|
||||
Needs []string
|
||||
// FormNeeds are the reference forms the module's build at FP is written in (`form:<name>`, the builder's build key's
|
||||
// needs, ADR 0618 §1): a node's want is held only while that node's engine reads none of them (ADR 0624 §2). Nil: the
|
||||
// build names none, and holds nothing.
|
||||
FormNeeds []string
|
||||
// Self says whether node composes the module itself: the catalogue's placement names the node (ADR 0547 §2). It is the one
|
||||
// source of a want's Self: every want the rules write or keep is derived from it (selfDerived), so a want written Self while
|
||||
// the node was placed does not outlive the placement (issue 1126). Nil: no node does.
|
||||
@@ -106,9 +77,6 @@ type Nodes struct {
|
||||
Haves map[string]map[string]delivery.Have
|
||||
// Providers is, by provision, the modules that provide it, with the nodes each is assigned to.
|
||||
Providers map[string]map[string][]string
|
||||
// Reads is, by node, the reader capabilities its node-engine states in its node record (delivery.NodeRecord.ManifestReads,
|
||||
// ADR 0614 §4). A node with none, or none read, is read as stating the baseline (catalogue.StatedManifestReads).
|
||||
Reads map[string][]string
|
||||
}
|
||||
|
||||
// healthy says whether a have runs fp healthy.
|
||||
@@ -162,42 +130,15 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
|
||||
return false, delivery.RuleGrant, fmt.Sprintf("the controller has not issued the credential of %s on %s for %s", m.Name, node,
|
||||
short(m.FP))
|
||||
}
|
||||
if words, lacking := m.readerLacking(node, n); lacking {
|
||||
return false, delivery.RuleComposed, words
|
||||
}
|
||||
if _, ok := m.composition(node, m.FP); !ok {
|
||||
return false, delivery.RuleComposed, fmt.Sprintf("%s has named no composition of its own for %s at %s yet", node, m.Name, short(m.FP))
|
||||
}
|
||||
if words, waiting := m.provisionWaiting(node); waiting {
|
||||
return false, delivery.RuleProvisions, words
|
||||
}
|
||||
if words, waiting := m.verbWaiting(node); waiting {
|
||||
return false, delivery.RuleProvisions, words
|
||||
}
|
||||
if words, waiting := m.busStateWaiting(node); waiting {
|
||||
return false, delivery.RuleProvisions, words
|
||||
}
|
||||
return true, 0, ""
|
||||
}
|
||||
|
||||
// readerLacking says why the module's want on node is held for the node's engine (ADR 0614 §4): a reader capability its
|
||||
// manifest needs that the engine does not state, read as the baseline when it states none. It is held under rule 7 (the
|
||||
// composition), since no rule of its own is numbered in this release, with words naming the capability; it moves once the
|
||||
// engine states it. The node-engine's own new build is held only by a word of its own manifest no engine states.
|
||||
//
|
||||
// A build's reference forms hold the want only when the engine reads none of them (ADR 0624 §2): every engine reads a
|
||||
// first form, so a build written in one is never held by its forms; the words name the forms it reads none of.
|
||||
func (m Module) readerLacking(node string, n Nodes) (string, bool) {
|
||||
missing := catalogue.MissingManifestReads(m.Needs, n.Reads[node])
|
||||
if len(missing) == 0 && forms.Current.Held(m.FormNeeds, n.Reads[node]) {
|
||||
missing = slices.Clone(m.FormNeeds)
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return graph.ReaderWaitWords(node, missing, m.Name, short(m.FP)), true // the words the delivery view names the edge by
|
||||
}
|
||||
|
||||
// provisionWaiting says why the module's want on node waits for a provision (ADR 0570 §4): a provision it requires that has
|
||||
// no key yet, or the first of its keys that is not provided. Nil Provisions (the seat's state not on the bus) waits for none.
|
||||
func (m Module) provisionWaiting(node string) (string, bool) {
|
||||
@@ -208,36 +149,8 @@ func (m Module) provisionWaiting(node string) (string, bool) {
|
||||
return fmt.Sprintf("provision %s has no key yet for %s on %s", missing[0], m.Name, node), true
|
||||
}
|
||||
keyed := m.Provisions(node)
|
||||
// Only a provision the newest manifest still requires is waited on (ADR 0603 §9): a key of an older revision's edge,
|
||||
// asked or retiring for the operator's answer (ADR 0570 §5), holds nothing. A requirement catalogue.Awaited leaves out is
|
||||
// not waited on when something offers it (a module's own name, a module the mesh provides itself, a provision the
|
||||
// composition does not key: optional, a toolchain on the module's own machine, a login the controller makes). One that
|
||||
// nothing offers is waited on (ADR 0603 §5: an unknown provider has provided nothing), so a consumer never moves ahead of
|
||||
// a provider whose module the indexer has not yet taken in; it holds loudly, since the watchman raises the hold past its
|
||||
// bound (ADR 0603 §8).
|
||||
needed := m.Manifest.Requires
|
||||
if m.Answering != nil {
|
||||
needed = catalogue.Awaited(m.Manifest, m.Answering)
|
||||
have := map[string]bool{}
|
||||
for _, p := range keyed {
|
||||
have[p.Provision] = true
|
||||
}
|
||||
for _, r := range slices.Sorted(slices.Values(m.Manifest.Requires)) {
|
||||
if have[r] && !m.Manifest.OptionalRequires[r] && !slices.Contains(needed, r) {
|
||||
needed = append(needed, r) // the provisioner keys it from the manifests, whoever is placed yet
|
||||
continue
|
||||
}
|
||||
if m.Offered == nil || m.Manifest.OptionalRequires[r] || len(m.Answering(r)) > 0 || m.Offered(r) || slices.Contains(needed, r) {
|
||||
continue
|
||||
}
|
||||
needed = append(needed, r)
|
||||
if !have[r] {
|
||||
return fmt.Sprintf("no module offers %s yet for %s on %s: an unknown provider has provided nothing, so the want waits for one, and the watchman raises it past its bound", r, m.Name, node), true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(keyed)) {
|
||||
if p := keyed[key]; p.State != provisions.Provided && slices.Contains(needed, p.Provision) {
|
||||
if p := keyed[key]; p.State != provisions.Provided {
|
||||
words := fmt.Sprintf("the provision %s of %s on %s is %s, not provided", key, m.Name, node, p.State)
|
||||
if p.Why != "" {
|
||||
words += ": " + p.Why
|
||||
@@ -355,18 +268,29 @@ type RecordNeed struct {
|
||||
Words string
|
||||
}
|
||||
|
||||
// Holding is a rule holding a node back, and why. The bus module is held back by the rules alone, as every module (ADR 0601 §1,
|
||||
// §2): no bar of its own.
|
||||
// Holding is a rule holding a node back, and why. Bus marks the bar on the bus instead of one of the seven rules (Rule is then
|
||||
// 0): the bus moves only by the operator's put (ADR 0539 §15, ADR 0546 §8), never by the assigner (issue 1945: nats on novox,
|
||||
// running an old-form build, got no want and the pass said nothing).
|
||||
type Holding struct {
|
||||
Rule int
|
||||
Words string
|
||||
Bus bool
|
||||
}
|
||||
|
||||
// Reason is the holding in words: the rule and its words.
|
||||
// Reason is the holding in words: the rule and its words, or for the bus bar its words alone.
|
||||
func (h Holding) Reason() string {
|
||||
if h.Bus {
|
||||
return h.Words
|
||||
}
|
||||
return fmt.Sprintf("rule %d: %s", h.Rule, h.Words)
|
||||
}
|
||||
|
||||
// BusBarWords say why the assigner writes no want moving the bus on node: it runs prev, the trunk builds fp, and only the
|
||||
// operator's put moves it (ADR 0539 §15, ADR 0546 §8). The words reach the operator, so they name no record.
|
||||
func BusBarWords(node, prev, fp string) string {
|
||||
return fmt.Sprintf("only your put moves the bus: %s runs %s, the trunk builds %s; put it with assigner_put", node, short(prev), short(fp))
|
||||
}
|
||||
|
||||
// Wants is the pass of one module m: for each of its nodes, the want by the six rules, starting from the want written
|
||||
// before (wants, by node); lastHealthy is, by node, the newest fingerprint other than m.FP the node ran healthy. A failed
|
||||
// health check of m.FP on a node holds it: every node wanting m.FP is set back to its last healthy fingerprint, but a
|
||||
@@ -501,6 +425,14 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st
|
||||
if need, wait := at.mustAsk(node, asked, hadLive, ran, running.FP, running.Composed == "", d); wait {
|
||||
if need != nil {
|
||||
out.Record = append(out.Record, *need)
|
||||
} else if prev, barred := at.busBar(asked, hadLive, running.FP); barred {
|
||||
// The bus is barred: no want moves it but the operator's put. Said, never silent (issue 1945): its holding is named,
|
||||
// and a node that runs it with no live want has it on neither list, so its condition stays raised.
|
||||
h := Holding{Bus: true, Words: BusBarWords(node, prev, at.FP)}
|
||||
out.Rules[node] = h
|
||||
if !hadLive && running.Phase != delivery.PhaseRemoved && !held {
|
||||
out.Neither[node] = h
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -727,10 +659,10 @@ func short(fp string) string {
|
||||
return part[:12]
|
||||
}
|
||||
|
||||
// mustAsk says whether the move to the composition d on node waits for the operator's yes (need names the ask, ADR 0539 §14). A
|
||||
// module the node never ran (it has no want and no have), a move judged neither, a module that is not of policy record and a want
|
||||
// that stays are written as any want. The bus module is no exception: it moves by the rules (ADR 0601 §1), its node backing up its
|
||||
// streams before it applies (§2); the assigner asks no snapshot for it.
|
||||
// mustAsk says whether the move to the composition d on node waits: for the operator's yes (need names the ask) or, for the bus,
|
||||
// for the operator's hand (ADR 0539 §14, §15). A module the node never ran (it has no want and no have), a move judged neither, a module that is not of
|
||||
// policy record and a want that stays are written as any want; so is a revision of the bus module's composition at the
|
||||
// same fingerprint judged neither (a user list only, §7).
|
||||
func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, running string, haveNamesNoComposition bool, d delivery.ComposedDigest) (*RecordNeed, bool) {
|
||||
if (!had && !ran) || before.Removed || (before.FP == m.FP && before.Composed == d.Digest) {
|
||||
return nil, false
|
||||
@@ -742,6 +674,9 @@ func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, runnin
|
||||
if !had && ran && running == m.FP && haveNamesNoComposition && d.Judgement == delivery.JudgedUnchanged {
|
||||
return nil, false
|
||||
}
|
||||
if _, barred := m.busBar(before, had, running); barred {
|
||||
return nil, true // the bus is never moved by the assigner; the operator writes its want (§15)
|
||||
}
|
||||
if !m.recorded(d) || d.Judgement == delivery.JudgedNeither || d.Judgement == delivery.JudgedUnchanged {
|
||||
return nil, false
|
||||
}
|
||||
@@ -751,7 +686,17 @@ func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, runnin
|
||||
return &RecordNeed{Node: node, Digest: d.Digest, Judgement: d.Judgement, Words: d.Words}, true
|
||||
}
|
||||
|
||||
// recorded says whether the module is of policy record: a person's choice at the assigner over the manifest's word (ADR
|
||||
// busBar says whether m is the bus and the move would take it off the fingerprint it ran before, prev: its want's, or with no
|
||||
// readable want the one its have names (ADR 0539 §15 bars a new one).
|
||||
func (m Module) busBar(before delivery.Want, had bool, running string) (prev string, barred bool) {
|
||||
prev = before.FP
|
||||
if !had {
|
||||
prev = running
|
||||
}
|
||||
return prev, catalogue.ProvidesBus(m.Manifest) && prev != m.FP
|
||||
}
|
||||
|
||||
// recorded says whether the module is of policy record: a person's choice at the assigner over everything but the bus (ADR
|
||||
// 0565 §7, catalogue.Upgrade); for a module with none, as the controller holds it beside the composition's digest until the
|
||||
// lease write (ADR 0539 §14); on a composition made by hand, which carries none, as the manifest derives it.
|
||||
func (m Module) recorded(d delivery.ComposedDigest) bool {
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
func fpOf(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
@@ -106,123 +104,6 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
|
||||
t.Errorf("provided provisions held g14's want: %+v (%s)", out.Wants["g14"], out.Rules["g14"].Words)
|
||||
}
|
||||
}
|
||||
// ADR 0614 §4: a want is held on a node whose engine does not state a reader capability the manifest needs, under rule 7
|
||||
// with the capability as its words, and moves once the engine states it; an engine stating nothing reads the baseline,
|
||||
// so a manifest of the baseline's words, the node-engine's own new build among them, is not held.
|
||||
hostNeeds, err := catalogue.ManifestNeeds([]byte(`{"module":"mesh-host","version":"1","slug":"host","build":{"artifacts":[` +
|
||||
`{"name":"host-arch","kind":"bundle","language":"go","system":"arch","from":"cmd/mesh-host"}]},"consumes":["restic.backed-up"],` +
|
||||
`"state":[{"name":"health"}],"config":{"x":{"type":"number","minimum":1,"default":50,"runtime":true}},` +
|
||||
`"resources":[{"id":"k","type":"file","path":"/var/lib/k","content":"${config:x}\n"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
why string
|
||||
name string
|
||||
needs []string
|
||||
reads map[string][]string
|
||||
held bool
|
||||
// forms are the build's reference forms (ADR 0624 §2), and names what the hold's words name.
|
||||
forms []string
|
||||
names string
|
||||
}{
|
||||
{"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true, nil, "word:tool-runner"},
|
||||
{"the same, once both engines state it", "mail", []string{"word:module", "word:tool-runner"},
|
||||
map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false, nil, ""},
|
||||
{"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false, nil, ""},
|
||||
{"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false, nil, ""},
|
||||
// ADR 0624 §2, with form:new a form no engine reads unless it states it and artifact-store the first form.
|
||||
{"g14 back stating {old}, a build carrying {old,new}: moves", "mail", nil, map[string][]string{"g14": {"word:module"}}, false,
|
||||
[]string{"form:artifact-store", "form:new"}, ""},
|
||||
{"an engine stating nothing, a build carrying the baseline's form: moves", "mail", nil, nil, false, []string{"form:artifact-store"}, ""},
|
||||
{"a build carrying {new} only: held, naming the form", "mail", nil, map[string][]string{"g14": {"word:module"}}, true,
|
||||
[]string{"form:new"}, "form:new"},
|
||||
{"the same, once g14's engine states it", "mail", nil, map[string][]string{"g14": {"form:new"}}, false, []string{"form:new"}, ""},
|
||||
{"a manifest needing an unstated word, its build in the first form: held, naming the word", "mail", []string{"word:tool-runner"}, nil, true,
|
||||
[]string{"form:artifact-store"}, "word:tool-runner"},
|
||||
} {
|
||||
m, n := ready()
|
||||
m.Name, m.Needs, n.Reads, m.FormNeeds = c.name, c.needs, c.reads, c.forms
|
||||
n.Haves["ace"][c.name], n.Haves["g14"][c.name] = n.Haves["ace"]["mail"], n.Haves["g14"]["mail"]
|
||||
out := Wants(m, n, wantedOld(), nil)
|
||||
if moved := out.Wants["g14"].FP == fNew; moved == c.held {
|
||||
t.Errorf("%s: g14's want %+v, held by %+v", c.why, out.Wants["g14"], out.Rules["g14"])
|
||||
}
|
||||
if r := out.Rules["g14"]; c.held && (r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, c.names) ||
|
||||
!strings.Contains(r.Words, "node-engine of g14")) {
|
||||
t.Errorf("%s: held by %+v, want rule 7 naming the capability", c.why, r)
|
||||
}
|
||||
if w := out.Wants["g14"]; c.held && (w.Rule != delivery.RuleComposed || w.Newest != fNew) {
|
||||
t.Errorf("%s: g14's want %+v, want it kept at what it runs and naming rule 7", c.why, w)
|
||||
}
|
||||
}
|
||||
// ADR 0614 §2, §3: a want waits, under the provisions' rule, until each verb it invokes is served by a running build,
|
||||
// and moves once one is; a verb a running build serves already adds no wait, and an edge the graph leaves out on a cycle
|
||||
// is not among Invokes, so it holds nothing; nothing read of what runs waits for none.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
invokes []string
|
||||
served []string
|
||||
nilRead bool
|
||||
holds string
|
||||
}{
|
||||
{"a verb no running build serves", []string{"store.store_put", "seat:mesh-catalog.modules"}, []string{"store.store_put"}, false, "seat:mesh-catalog.modules"},
|
||||
{"once a build serving it runs", []string{"seat:mesh-catalog.modules"}, []string{"seat:mesh-catalog.modules"}, false, ""},
|
||||
{"an edge left out on a cycle", nil, nil, false, ""},
|
||||
{"nothing read of what runs", []string{"seat:mesh-catalog.modules"}, nil, true, ""},
|
||||
// ADR 0614 §5: a module asking bus state is held while no build offering bus-state runs, and moves once one does.
|
||||
{"bus state no running build offers", nil, nil, false, graph.BusState},
|
||||
{"bus state a running build offers", nil, []string{graph.BusState}, false, ""},
|
||||
{"bus state its own module offers", nil, nil, false, ""},
|
||||
} {
|
||||
m, n := ready()
|
||||
m.Invokes = c.invokes
|
||||
if !c.nilRead {
|
||||
m.Served = func(v string) bool { return slices.Contains(c.served, v) }
|
||||
}
|
||||
if strings.HasPrefix(c.name, "bus state") {
|
||||
m.Manifest.BusState = []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}}
|
||||
m.Offering = func(p string) bool { return slices.Contains(c.served, p) }
|
||||
if c.name == "bus state its own module offers" {
|
||||
m.Manifest.Provides = []catalogue.Offer{{Name: graph.BusState, Scope: catalogue.ScopeMesh}}
|
||||
}
|
||||
}
|
||||
words := graph.VerbWaitWords(c.holds, "mail", "g14")
|
||||
if c.holds == graph.BusState {
|
||||
words = graph.BusStateWaitWords("mail", "g14")
|
||||
}
|
||||
out := Wants(m, n, wantedOld(), nil)
|
||||
if c.holds == "" {
|
||||
if out.Wants["g14"].FP != fNew {
|
||||
t.Errorf("%s: held %+v", c.name, out.Rules["g14"])
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != words {
|
||||
t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"])
|
||||
}
|
||||
}
|
||||
// ADR 0603 §9: the newest source revision governs. The module now at fNew no longer requires the database an older revision
|
||||
// asked for and requires oidc instead; the seat's keys follow the newest manifest, so the older edge is no wait: a provided
|
||||
// oidc moves the want, an asked one holds it and names oidc and never database.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
state, older string
|
||||
moves bool
|
||||
}{{"the replaced edge provided, the older edge retiring", provisions.Provided, provisions.Retiring, true},
|
||||
{"the replaced edge provided, the older edge asked", provisions.Provided, provisions.Asked, true},
|
||||
{"the replaced edge asked, the older edge retiring", provisions.Asked, provisions.Retiring, false}} {
|
||||
m, n := ready()
|
||||
m.Manifest.Requires = []string{"oidc"}
|
||||
m.Provisions = func(node string) map[string]provisions.Provision {
|
||||
return map[string]provisions.Provision{node + ".mail.oidc": {Provision: "oidc", State: c.state},
|
||||
node + ".mail.database": {Provision: "database", State: c.older, Why: "the operator's answer"}}
|
||||
}
|
||||
out := Wants(m, n, wantedOld(), nil)
|
||||
if got := out.Wants["g14"].FP == fNew; got != c.moves || (!c.moves && (!strings.Contains(out.Rules["g14"].Words, "oidc") || strings.Contains(out.Rules["g14"].Words, "database"))) {
|
||||
t.Errorf("%s: want %+v, held by %q", c.name, out.Wants["g14"], out.Rules["g14"].Words)
|
||||
}
|
||||
}
|
||||
// 1954/D1 (the lead, 2026-10-29): a required provision with no key yet holds the want, named; and only one the
|
||||
// composition keys (catalogue.Awaited, issue 774), replaying 2026-10-30: grafana (not handed) waited on ace for
|
||||
// oidc-client, which the controller still makes, and mesh-build-go (handed) for go-toolchain on its own machine.
|
||||
@@ -274,24 +155,6 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ADR 0603 §5: a requirement nobody offers is an unknown provider and waits, loudly; one something offers (a module's own
|
||||
// name, a module the mesh provides itself) is left to catalogue.Awaited as before and does not.
|
||||
for _, c := range []struct {
|
||||
name, requires string
|
||||
offered bool
|
||||
}{{"a module's own name (node-tools)", "node-tools", true}, {"a module the mesh provides itself (mesh-addressing)", "mesh-addressing", true},
|
||||
{"a provision nobody offers", "database", false}} {
|
||||
m, n := ready()
|
||||
m.Manifest.Requires = []string{c.requires}
|
||||
m.Answering = func(string) []catalogue.Manifest { return nil }
|
||||
m.Offered = func(p string) bool { return c.offered && p == c.requires }
|
||||
m.Provisions = func(string) map[string]provisions.Provision { return map[string]provisions.Provision{} }
|
||||
out := Wants(m, n, wantedOld(), nil)
|
||||
if got := out.Wants["g14"].FP == fNew; got != c.offered || (!c.offered && (out.Rules["g14"].Rule != delivery.RuleProvisions ||
|
||||
!strings.Contains(out.Rules["g14"].Words, "no module offers database yet") || !strings.Contains(out.Rules["g14"].Words, "past its bound"))) {
|
||||
t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"])
|
||||
}
|
||||
}
|
||||
// 1960: rule 5 holds no want, and no module is left without a first want for a provider down or a node offline; the
|
||||
// provider down is said (Unreached). novox on 2026-10-29: route-proxy down held de-spiegel and link2pay, minio not
|
||||
// healthy held photos, and novox offline left a module with no canary.
|
||||
@@ -776,13 +639,11 @@ func TestAMoveJudgedNeitherOrNeverRunOrNotRecordedIsWrittenAtOnce(t *testing.T)
|
||||
|
||||
// 1906/D4 (ADR 0539 §7, §15): the bus module's new fingerprint is never written by the assigner, but a revision of its
|
||||
// composition at the same fingerprint that only changes the user list (judged neither) is written at once.
|
||||
func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T) {
|
||||
func TestTheBusIsNotMovedButItsUserListRevisionIsWrittenAtOnce(t *testing.T) {
|
||||
m, n, _ := movingTo(delivery.JudgedNeither)
|
||||
m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}}
|
||||
// ADR 0601 §1, §2: the bus module's want is written by the rules, as any module's, and no snapshot is asked for it: its node
|
||||
// backs its streams up before it applies.
|
||||
if out := Wants(m, n, wantedOld(), nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 || len(out.Neither) != 0 {
|
||||
t.Errorf("the bus did not move by the rules: %+v", out)
|
||||
if out := Wants(m, n, wantedOld(), nil); out.Wants["ace"].FP == fNew {
|
||||
t.Errorf("the bus moved to a new fingerprint: %+v", out.Wants["ace"])
|
||||
}
|
||||
running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fOld)}, "g14": {FP: fNew, Composed: dig(fOld)}}
|
||||
out := Wants(m, n, running, nil)
|
||||
@@ -835,6 +696,23 @@ func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T
|
||||
t.Errorf("a held bus did not take its user-list revision as a put: %+v", w)
|
||||
}
|
||||
m.Puts = nil
|
||||
// 1945 (the bus bar made loud): a node that runs the bus at an old-form build and holds no want for it gets none, since only
|
||||
// the operator's put moves the bus, and the pass says so: a holding naming the bar, and the node on neither list so its
|
||||
// condition stays raised. A node with a live want keeps it and is not on neither list.
|
||||
m.Composed = composedEverywhere
|
||||
n.Haves["ace"]["mail"] = delivery.Have{FP: "b-1700000000-oldform", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}
|
||||
out = Wants(m, n, map[string]delivery.Want{"g14": {FP: fOld, Composed: dig(fOld)}}, nil)
|
||||
if w, wrote := out.Wants["ace"]; wrote {
|
||||
t.Errorf("the bus bar wrote ace's want %+v", w)
|
||||
}
|
||||
h, on := out.Neither["ace"]
|
||||
if !on || !h.Bus || out.Rules["ace"] != h || !strings.Contains(h.Words, "only your put moves the bus") || !strings.Contains(h.Words, "ace runs b-1700000000-oldform") ||
|
||||
!strings.Contains(h.Words, "assigner_put") {
|
||||
t.Errorf("the barred bus on ace: neither %+v (%v), rules %+v", h, on, out.Rules["ace"])
|
||||
}
|
||||
if _, on := out.Neither["g14"]; on || out.Wants["g14"].FP == fNew {
|
||||
t.Errorf("g14 has a live want: neither %+v, its want %+v", out.Neither, out.Wants["g14"])
|
||||
}
|
||||
stepsOfAPut(t)
|
||||
}
|
||||
|
||||
@@ -1011,8 +889,8 @@ func TestTheRecordPolicyIsTheOneTheControllerHolds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A node that runs the bus with no readable want gets its want by the rules, at the fingerprint it runs or at a new one alike: the
|
||||
// bus has no bar of its own (ADR 0601 §1).
|
||||
// Review round 1, item 2 (ADR 0539 §15): a node that runs the bus with no readable want compares the new fingerprint with the one
|
||||
// it runs, from its have: the same fingerprint is not a new one (written by the rules), another is barred.
|
||||
func TestTheBusWithNoWantIsComparedWithWhatItRuns(t *testing.T) {
|
||||
m, n, _ := movingTo(delivery.JudgedNeither)
|
||||
m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}}
|
||||
@@ -1021,8 +899,8 @@ func TestTheBusWithNoWantIsComparedWithWhatItRuns(t *testing.T) {
|
||||
t.Errorf("the bus runs F with no want and was not given one: %+v %v", w, ok)
|
||||
}
|
||||
n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}
|
||||
if w, ok := Wants(m, n, nil, nil).Wants["ace"]; !ok || w.FP != fNew {
|
||||
t.Errorf("the bus running an older fingerprint with no want was not given its want by the rules: %+v %v", w, ok)
|
||||
if w, ok := Wants(m, n, nil, nil).Wants["ace"]; ok {
|
||||
t.Errorf("the bus was moved to a new fingerprint with no want: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,15 +14,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// AnswerBound is how long the holder waits for a node's answer after its candidate was written: the time to live of the
|
||||
// node's pulse key (delivery.PulseAnswerKey), whose expiry names it as giving no answer, never passed. The same answer
|
||||
// bound a check gives a node (ADR 0593 §8, §7: 60 seconds, nec.AnswerBound). A variable so a test on the bus
|
||||
// bound a check gives a node (ADR 0593 §8, §7: 60 seconds, delivery.ComposeAnswerBound). A variable so a test on the bus
|
||||
// can shorten it.
|
||||
var AnswerBound = nec.AnswerBound
|
||||
var AnswerBound = delivery.ComposeAnswerBound
|
||||
|
||||
// Intended is, by node, the modules whose placed entry the proposal changes there, and the nodes that answer it: every
|
||||
// node (ADR 0593 §8, changing ADR 0567 §4's list of the nodes that must answer), sorted. current is the placement as it
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
func load(t *testing.T) catalogue.Manifest {
|
||||
@@ -36,10 +34,8 @@ func TestManifest(t *testing.T) {
|
||||
reads := slices.Clone(m.Reads)
|
||||
slices.Sort(reads)
|
||||
// ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads
|
||||
// every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1).
|
||||
// DLV-F2r (ADR 0615 §1, ADR 0631 §1): the indexer's manifests, modules and module graph (whose verb edges a want waits
|
||||
// on, ADR 0614 §2, §3) are read through its first views and views family, none of the indexer's states by reads.
|
||||
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source",
|
||||
// every node's published compositions to compare them.
|
||||
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
|
||||
"mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed",
|
||||
"mesh-provisioner.provisions", "node-engine.have"}
|
||||
if !reflect.DeepEqual(reads, want) {
|
||||
@@ -50,8 +46,7 @@ func TestManifest(t *testing.T) {
|
||||
states[s.Name] = s.History
|
||||
}
|
||||
if want := map[string]int{"asks": 5, "differences": 64, "puts": 20, "wants": 20,
|
||||
"placement": 64, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5, views.MarksState: 1, views.StatusState: 1,
|
||||
"engine-reads": 1}; !reflect.DeepEqual(states, want) {
|
||||
"placement": 64, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5}; !reflect.DeepEqual(states, want) {
|
||||
t.Errorf("state = %v, want %v", states, want)
|
||||
}
|
||||
if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 {
|
||||
@@ -82,42 +77,16 @@ func TestManifest(t *testing.T) {
|
||||
if seat, _ := catalogue.SeatNamed(catalogue.PlacementSeat); catalogue.CanHold(m, seat) != nil {
|
||||
t.Errorf("the assigner cannot hold the seat: %v", catalogue.CanHold(m, seat))
|
||||
}
|
||||
// ADR 0621 §1: the first-view tools placement, seat-holders and wants and design 25 § 12.2's six read tools, of the
|
||||
// program's bundle, which runs in a runtime of its own on the assigner's account; ADR 0618 §3: and assigner_engine_reads,
|
||||
// which lists and asks again what each node's engine reads.
|
||||
if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts", "placement", "seat-holders", "wants",
|
||||
"assigner_read_placement", "assigner_read_seat_holders", "assigner_read_wants", "assigner_read_findings", "assigner_read_asks", "assigner_read_differences",
|
||||
"assigner_first_views", "assigner_first_view_again", "assigner_engine_reads"}) {
|
||||
t.Errorf("the assigner serves tools %v, want its three put tools, three first views, six read tools and assigner_engine_reads", m.Tools)
|
||||
if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts"}) {
|
||||
t.Errorf("the assigner serves tools %v, want its three put tools", m.Tools)
|
||||
}
|
||||
if m.RunsAs != m.Module || m.SecretsOwner != m.RunsAs {
|
||||
t.Errorf("runs-as = %q: the bundle runs on the assigner's own account (ADR 0621 §1)", m.RunsAs)
|
||||
}
|
||||
// ADR 0621 §2: each first-view tool answers exactly its readers, never the assigner.
|
||||
callers := map[string]string{"placement": "mesh-grants,node-tools,mesh-delivery-indexer", "seat-holders": "node-tools", "wants": "mesh-grants"}
|
||||
env := m.Build.Artifacts[0].Env
|
||||
for tool, list := range callers {
|
||||
if env[views.CallersEnv(tool)] != list || views.ParseCallers(env[views.CallersEnv(tool)]).Allows("n1."+m.Module) {
|
||||
t.Errorf("%s callers = %q, want %q", tool, env[views.CallersEnv(tool)], list)
|
||||
}
|
||||
}
|
||||
// ADR 0619 §2, ADR 0615 §5: its views family beside placement on its old streams section.
|
||||
if len(m.Streams) != 2 || m.Streams[1].Name != "assigner-views" || !slices.Equal(m.Streams[1].Events, []string{views.EventPattern}) ||
|
||||
m.Streams[1].MaxAgeDays != views.StreamDays || m.Streams[1].MaxMiB != views.StreamMiB || m.Streams[1].Retention != "" {
|
||||
t.Errorf("streams = %+v, want placement and assigner-views", m.Streams)
|
||||
}
|
||||
if m.Streams[0].Name != delivery.PlacementStream || m.Streams[0].MaxAgeDays != delivery.PlacementStreamDays ||
|
||||
if len(m.Streams) != 1 || m.Streams[0].Name != delivery.PlacementStream || m.Streams[0].MaxAgeDays != delivery.PlacementStreamDays ||
|
||||
!slices.Equal(slices.Sorted(slices.Values(m.Streams[0].Events)), slices.Sorted(slices.Values(delivery.PlacementEvents))) {
|
||||
t.Errorf("streams = %+v, want placement carrying the seat's four events for %d days", m.Streams, delivery.PlacementStreamDays)
|
||||
}
|
||||
// ADR 0618 §4: and it hears each node's engine say its reads changed.
|
||||
if !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.graph", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", "node-engine.manifest-reads-changed"}) {
|
||||
t.Errorf("consumes = %v, want the indexer's views of graph, manifests and modules and the engines' reads changed", m.Consumes)
|
||||
}
|
||||
if want := []string{"mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
|
||||
if want := []string{
|
||||
"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"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { // ADR 0618 §3
|
||||
"seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) {
|
||||
t.Errorf("invokes = %v, want %v", m.Invokes, want)
|
||||
}
|
||||
if !slices.Equal(m.Emits, []string{"put"}) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"module": "mesh-delivery-assigner",
|
||||
"version": "1",
|
||||
"slug": "assigner",
|
||||
"runs-as": "mesh-delivery-assigner",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-placement",
|
||||
@@ -26,8 +25,8 @@
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-builder.builds",
|
||||
"mesh-delivery-gate.merges",
|
||||
"mesh-delivery-gate.source",
|
||||
"mesh-delivery-indexer.manifests",
|
||||
"mesh-delivery-indexer.modules",
|
||||
"mesh-delivery-teller.answers",
|
||||
"mesh-delivery-watchman.presence",
|
||||
"mesh-grants.grants",
|
||||
@@ -38,22 +37,12 @@
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-indexer.views.graph",
|
||||
"mesh-delivery-indexer.views.manifests",
|
||||
"mesh-delivery-indexer.views.modules",
|
||||
"node-engine.manifest-reads-changed"
|
||||
],
|
||||
"invokes": [
|
||||
"mesh-delivery-indexer.graph",
|
||||
"mesh-delivery-indexer.manifests",
|
||||
"mesh-delivery-indexer.modules",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-hand-acts.hand-acts",
|
||||
"seat:mesh-hand-acts.warranted",
|
||||
"seat:node-backup.backed-up",
|
||||
"seat:node-backup.now",
|
||||
"seat:node-engine.manifest-reads"
|
||||
"seat:node-backup.now"
|
||||
],
|
||||
"emits": [
|
||||
"put"
|
||||
@@ -61,19 +50,7 @@
|
||||
"tools": [
|
||||
"assigner_put",
|
||||
"assigner_release",
|
||||
"assigner_puts",
|
||||
"placement",
|
||||
"seat-holders",
|
||||
"wants",
|
||||
"assigner_read_placement",
|
||||
"assigner_read_seat_holders",
|
||||
"assigner_read_wants",
|
||||
"assigner_read_findings",
|
||||
"assigner_read_asks",
|
||||
"assigner_read_differences",
|
||||
"assigner_first_views",
|
||||
"assigner_first_view_again",
|
||||
"assigner_engine_reads"
|
||||
"assigner_puts"
|
||||
],
|
||||
"state": [
|
||||
{ "name": "placement", "history": 64 },
|
||||
@@ -86,10 +63,7 @@
|
||||
{ "name": "wants", "history": 20 },
|
||||
{ "name": "findings", "history": 5 },
|
||||
{ "name": "policies", "history": 20 },
|
||||
{ "name": "seat-holders", "history": 5 },
|
||||
{ "name": "view-marks", "history": 1 },
|
||||
{ "name": "first-views", "history": 1 },
|
||||
{ "name": "engine-reads", "history": 1 }
|
||||
{ "name": "seat-holders", "history": 5 }
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
@@ -101,8 +75,7 @@
|
||||
"placement-withdrawn"
|
||||
],
|
||||
"max-age-days": 30
|
||||
},
|
||||
{ "name": "assigner-views", "events": ["views.**"], "max-age-days": 7, "max-mib": 256 }
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
@@ -152,12 +125,7 @@
|
||||
"binary": "mesh-delivery-assigner",
|
||||
"loads": [
|
||||
"mesh-delivery-assigner"
|
||||
],
|
||||
"env": {
|
||||
"MESH_VIEW_CALLERS_PLACEMENT": "mesh-grants,node-tools,mesh-delivery-indexer",
|
||||
"MESH_VIEW_CALLERS_SEAT_HOLDERS": "node-tools",
|
||||
"MESH_VIEW_CALLERS_WANTS": "mesh-grants"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,21 +3,20 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
@@ -25,5 +23,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -43,9 +42,6 @@ type ToolchainGate struct {
|
||||
known bool // the placement is settled
|
||||
raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it
|
||||
left map[string]string // <module>/<key> -> why it was said left out, so each reason is said once
|
||||
// OnPlaced is told, outside the gate's lock, once the placement's history is read and after each revision of it
|
||||
// FollowPlacement hears from then on (the counted nodes, Nodes, may have changed). Nil tells nobody.
|
||||
OnPlaced func()
|
||||
}
|
||||
|
||||
// NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what
|
||||
@@ -94,19 +90,6 @@ func (g *ToolchainGate) Placement(key string, value []byte, deleted bool, at tim
|
||||
return g.again(node, changed)
|
||||
}
|
||||
|
||||
// Nodes are the nodes the placement places any module on, sorted: the nodes the mesh counts (a node leaves the count
|
||||
// when the placement no longer names it; ADR 0624 §1). None until the placement is read.
|
||||
func (g *ToolchainGate) Nodes() []string {
|
||||
var out []string
|
||||
for node, modules := range g.placed.ByNode() {
|
||||
if len(modules) > 0 {
|
||||
out = append(out, node)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Settled says the placement's history is read: from now on only the reports of placed nodes pass, once a node's
|
||||
// placement is held. It answers whether what it passed on changed.
|
||||
func (g *ToolchainGate) Settled() bool {
|
||||
@@ -220,9 +203,6 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha
|
||||
if gate.Settled() {
|
||||
changed(ctx)
|
||||
}
|
||||
if gate.OnPlaced != nil {
|
||||
gate.OnPlaced()
|
||||
}
|
||||
go func() {
|
||||
defer func() { _ = w.Stop() }()
|
||||
for {
|
||||
@@ -238,9 +218,6 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha
|
||||
if e != nil && put(e) {
|
||||
changed(ctx)
|
||||
}
|
||||
if e != nil && gate.OnPlaced != nil {
|
||||
gate.OnPlaced()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -17,116 +17,18 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// EngineReadsTool lists, asks again and drops what each counted node's engine reads, as the builder keeps it.
|
||||
const EngineReadsTool = "builder_engine_reads"
|
||||
|
||||
// queueAsker writes a queues value and waits for the builder's answer to it.
|
||||
type queueAsker func(key string, value []byte) (json.RawMessage, error)
|
||||
|
||||
// Tools are the builder's tools: stuck reads, askAgain writes the reasks value, queue writes a queues value and waits for
|
||||
// the builder's read, buildAgain writes a reasks value of mode build and waits for the builder's answer.
|
||||
// tags writes a tag-asks value and waits for the builder's answer; ta reads tag-asks.
|
||||
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time, vs versions.Reader,
|
||||
tags queueAsker, ta, fw, of versions.Reader) []stdio.Tool {
|
||||
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time) []stdio.Tool {
|
||||
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
||||
// handAct writes a reasks value of a versions hand act and answers the builder's answer, or its refusal as an error.
|
||||
handAct := func(key string, value []byte, err error) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := buildAgain(key, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := build.DecodeReAsk(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Refused {
|
||||
return nil, errors.New(a.Answer)
|
||||
}
|
||||
return map[string]any{"key": key, "name": a.Name, "do": a.Do, "version": a.Version, "answer": a.Answer}, nil
|
||||
}
|
||||
tagAct := func(key string, value []byte, err error) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := tags(key, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return versions.DecodeTagAsk(raw)
|
||||
}
|
||||
nameFP := map[string]any{"type": "object", "required": []string{"name", "fp", "why"}, "properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "the numbered name, <module>/<artifact>, as builder_versions lists it"},
|
||||
"fp": map[string]any{"type": "string", "description": "the fingerprint, src2:<hex>"},
|
||||
"why": map[string]any{"type": "string", "description": "why, in plain words"}}}
|
||||
branch := func(head bool) map[string]any {
|
||||
req := []string{"repository", "branch", "why"}
|
||||
props := map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
|
||||
"branch": map[string]any{"type": "string", "description": "the branch; its cleaned name is the tag"},
|
||||
"why": map[string]any{"type": "string", "description": "why, in plain words"}}
|
||||
if head {
|
||||
req = append(req, "head")
|
||||
props["head"] = map[string]any{"type": "string", "description": "the commit the tagged version is built from"}
|
||||
}
|
||||
return map[string]any{"type": "object", "required": req, "properties": props}
|
||||
}
|
||||
return []stdio.Tool{
|
||||
{Name: versions.PublishAgainTool, Description: versions.PublishAgainDescription, Input: nameFP,
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return handAct(build.NewPublishAgain(str(args, "name"), str(args, "fp"), str(args, "why"), stdio.CallerOf(args), now()))
|
||||
}},
|
||||
{Name: versions.FloorAgainTool, Description: versions.FloorAgainDescription,
|
||||
Input: map[string]any{"type": "object", "required": []string{"name", "why"}, "properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "the numbered name, <module>/<artifact>"},
|
||||
"why": map[string]any{"type": "string", "description": "why, in plain words"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return handAct(build.NewFloorAgain(str(args, "name"), str(args, "why"), stdio.CallerOf(args), now()))
|
||||
}},
|
||||
{Name: versions.TagTool, Description: versions.TagDescription, Input: branch(true),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return tagAct(versions.NewTagAsk(str(args, "repository"), str(args, "branch"), str(args, "head"), str(args, "why"), stdio.CallerOf(args), now()))
|
||||
}},
|
||||
{Name: versions.UntagTool, Description: versions.UntagDescription, Input: branch(false),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return tagAct(versions.NewUntag(str(args, "repository"), str(args, "branch"), str(args, "why"), stdio.CallerOf(args), now()))
|
||||
}},
|
||||
{Name: versions.TagAsksTool, Description: versions.TagAsksDescription,
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "only this repository's asks, <owner>/<name>"}}},
|
||||
Run: func(args map[string]any) (any, error) { return versions.AnswerTagAsks(ta, str(args, "repository")) }},
|
||||
{Name: versions.OldFormTool, Description: versions.OldFormDescription,
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"drop": map[string]any{"type": "string", "description": "a retired node to drop from the count by hand"},
|
||||
"why": map[string]any{"type": "string", "description": "with drop: why, in plain words"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
if node := str(args, "drop"); node != "" {
|
||||
return handAct(build.NewOldFormDrop(node, str(args, "why"), stdio.CallerOf(args), now()))
|
||||
}
|
||||
return versions.AnswerOldForm(of)
|
||||
}},
|
||||
{Name: versions.FloorWaitsTool, Description: versions.FloorWaitsDescription,
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
Run: func(map[string]any) (any, error) { return versions.AnswerFloorWaits(fw) }},
|
||||
{Name: versions.VersionsTool, Description: versions.VersionsDescription,
|
||||
Input: map[string]any{"type": "object", "required": []string{"name"}, "properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "the package, image, bundle or archive name"},
|
||||
"version": map[string]any{"type": "string", "description": "one version, to ask whether it is recorded"},
|
||||
"digest": map[string]any{"type": "string", "description": "with version: the digest the holder received"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return versions.AnswerVersions(context.Background(), vs, str(args, "name"), str(args, "version"), str(args, "digest"))
|
||||
}},
|
||||
{Name: build.StuckTool,
|
||||
Description: "Every build key that waits (building or queued), oldest first: its module, fingerprint, step words and since when, and each ask it " +
|
||||
"waits on by language with its id, when it was put, its asking (1 for the first; the builder asks an ask again when it can no longer be " +
|
||||
@@ -196,7 +98,7 @@ func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, q
|
||||
Description: "Every build that ended since a time, oldest first, from the builder's log build-log (kept 30 days): per build its module, " +
|
||||
"fingerprint, source revision, repository, directory, commit and ref, the build node, when it ended, its outcome (done, failed or " +
|
||||
"queued), the manifest built, the bases it was built against and each artifact made with its digest and reference. more says the " +
|
||||
"log held more than one answer carries: ask again from the last build's time. What the operator's command nox builds reads. Read-only.",
|
||||
"log held more than one answer carries: ask again from the last build's time. What mesh-catalog registers a build from. Read-only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"since"}, "properties": map[string]any{
|
||||
"since": map[string]any{"type": "string", "description": "RFC 3339 time; the builds that ended at or after it"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
@@ -362,65 +264,7 @@ func main() {
|
||||
a, err := build.DecodeReAsk(v)
|
||||
return err == nil && !a.Answered.IsZero()
|
||||
})
|
||||
vs := stdio.State(versions.State)
|
||||
vr := versions.Reader{Keys: vs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
|
||||
e, err := vs.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}}
|
||||
ts := stdio.State(versions.TagAsksState)
|
||||
tags := askKept(ts, versions.TagAsksState, versions.TagAsksBucket, "the ask", 25*time.Second, func(v []byte) bool {
|
||||
a, err := versions.DecodeTagAsk(v)
|
||||
return err == nil && !a.Answered.IsZero()
|
||||
})
|
||||
tr := versions.Reader{Keys: ts.Keys, Get: func(key string) (json.RawMessage, bool, error) {
|
||||
e, err := ts.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}}
|
||||
fs := stdio.State(versions.FloorWaitsState)
|
||||
fr := versions.Reader{Keys: fs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
|
||||
e, err := fs.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}}
|
||||
ofs := stdio.State(versions.OldFormState)
|
||||
ofr := versions.Reader{Keys: ofs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
|
||||
e, err := ofs.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}}
|
||||
bs := stdio.State(versions.BoundsState)
|
||||
br := versions.Reader{Keys: bs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
|
||||
e, err := bs.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}}
|
||||
tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now, vr, tags, tr, fr, ofr)
|
||||
// The builder's expiring keys (state bounds): what each bounds and when it ends.
|
||||
tools = append(tools, stdio.Tool{Name: versions.BoundsTool, Description: versions.BoundsDescription,
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
Run: func(map[string]any) (any, error) {
|
||||
rows, err := versions.AnswerBounds(br, time.Now())
|
||||
return map[string]any{"bounds": rows}, err
|
||||
}})
|
||||
// What each counted node's engine reads, as the builder keeps it (ADR 0618 §2, ADR 0624 §1): listed, one node asked
|
||||
// again, or one node's key dropped.
|
||||
tools = append(tools, enginereads.Tool(EngineReadsTool, "the builder", enginereads.KeptState(stdio.State("engine-reads")), true, time.Now))
|
||||
// builder_first_views and builder_first_view_again: where the builder's first views of the indexer's and the assigner's
|
||||
// states stand, and asking one again by hand (ADR 0615, DLV-M1).
|
||||
tools = append(tools, views.StatusTools(delivery.BuilderModule, "builder_first_views", "builder_first_view_again", stdio.State(views.StatusState), handacts.Recorder{Ask: stdio.Ask}, time.Now)...)
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
if err := stdio.Serve("", Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// forgeStream is the forge bridge's stream `forge` as the bus names it, where the builder's durable consumer hears a
|
||||
// branch's end (design 51 *Tagged versions*).
|
||||
const forgeStream = "FORGE"
|
||||
|
||||
// forgeBranchDeleted is the forge bridge's kind for a branch's end (delivery.ForgeBranchDeleted in the group's mesh-sdk).
|
||||
const forgeBranchDeleted = "branch-deleted"
|
||||
|
||||
// checkerAsksStream is the checker's family `asks` as the bus names it, and tagWantedSubject its event tag-wanted
|
||||
// (design 51 *Tagged versions*, DLV-C3): on the checker's own stream, so its subject is mesh.stream.<module>.event.*.
|
||||
const (
|
||||
checkerAsksStream = "ASKS"
|
||||
tagWantedSubject = "mesh.stream." + delivery.CheckerModule + ".event.tag-wanted"
|
||||
)
|
||||
|
||||
// forgeEnd is the part of a forge message the builder reads.
|
||||
type forgeEnd struct {
|
||||
Kind string `json:"kind"`
|
||||
Repository string `json:"repository"`
|
||||
Branch string `json:"branch"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// ended says whether the message ends a branch: merged, a pull closed, or branch-deleted.
|
||||
func (f forgeEnd) ended() bool {
|
||||
switch f.Kind {
|
||||
case delivery.ForgeMerged, forgeBranchDeleted:
|
||||
return f.Branch != ""
|
||||
case delivery.ForgePull:
|
||||
return f.Action == delivery.PullClosed && f.Branch != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// branchEnds hears the forge bridge's stream: every message wakes a floor whose bridge_tags was silent, and a branch's
|
||||
// end removes its tagged versions, acknowledged only once all are removed.
|
||||
type branchEnds struct {
|
||||
acts versions.Acts
|
||||
findings *findings
|
||||
tagAsks func() jetstream.KeyValue
|
||||
wake func(ctx context.Context)
|
||||
logf func(string, ...any)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (h *branchEnds) heard(ctx context.Context, m jetstream.Msg) {
|
||||
if h.wake != nil {
|
||||
h.wake(ctx)
|
||||
}
|
||||
var f forgeEnd
|
||||
if err := json.Unmarshal(m.Data(), &f); err != nil || !f.ended() {
|
||||
h.ack(m)
|
||||
return
|
||||
}
|
||||
end, err := h.acts.BranchEnded(ctx, f.Repository, f.Branch)
|
||||
if err != nil {
|
||||
h.logf("the end of %s %s: the versions cannot be read, heard again: %v", f.Repository, f.Branch, err)
|
||||
h.again(m)
|
||||
return
|
||||
}
|
||||
id := f.Repository + "/" + f.Branch
|
||||
if end.Done {
|
||||
h.findings.clear(ctx, versions.RemovalNotDone, health.ScopeModule, id)
|
||||
if kv := h.tagAsks(); kv != nil {
|
||||
if err := kv.Delete(ctx, versions.TagAskKey(f.Repository, f.Branch)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
h.logf("the tag ask of %s %s stays in %s: %v", f.Repository, f.Branch, versions.TagAsksState, err)
|
||||
}
|
||||
}
|
||||
h.ack(m)
|
||||
return
|
||||
}
|
||||
since := h.now()
|
||||
if md, err := m.Metadata(); err == nil {
|
||||
since = md.Timestamp
|
||||
}
|
||||
if h.now().Sub(since) >= versions.RemovalFindingAfter {
|
||||
h.findings.raise(ctx, finding{kind: versions.RemovalNotDone, scope: health.ScopeModule, id: id,
|
||||
summary: end.Finding(f.Repository, f.Branch, since),
|
||||
headline: "A branch's tagged versions are not removed",
|
||||
explanation: "The branch ended, and the registry seats did not say removed for every version tagged for it within 15 minutes.",
|
||||
resolved: "Every seat said removed.",
|
||||
needs: "Look at the seats named: " + strings.Join(end.Seats(), ", ")})
|
||||
}
|
||||
h.logf("the end of %s %s: remove asked of %d tagged versions, heard again in a minute", f.Repository, f.Branch, len(end.Left))
|
||||
h.again(m)
|
||||
}
|
||||
|
||||
func (h *branchEnds) ack(m jetstream.Msg) {
|
||||
if err := m.Ack(); err != nil {
|
||||
h.logf("acknowledging %s: %v", m.Subject(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// again leaves the message for the bus to give again in a minute: the retry is the bus's redelivery, not a timer here.
|
||||
func (h *branchEnds) again(m jetstream.Msg) {
|
||||
if err := m.NakWithDelay(versions.RemoveAgainAfter); err != nil {
|
||||
h.logf("leaving %s for the bus to give again: %v", m.Subject(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// tagWanted takes the checker's tag-wanted in: written to tag-asks as builder_tag writes it, answered by its watch.
|
||||
func tagWanted(ctx context.Context, kv jetstream.KeyValue, m jetstream.Msg, by string, now time.Time, logf func(string, ...any)) {
|
||||
if m.Subject() != tagWantedSubject {
|
||||
_ = m.Ack() // the family's other events (the verdicts) are not the builder's
|
||||
return
|
||||
}
|
||||
var t versions.TagWanted
|
||||
err := json.Unmarshal(m.Data(), &t)
|
||||
var key string
|
||||
var value []byte
|
||||
if err == nil {
|
||||
key, value, err = versions.TagWantedAsk(t, by, now)
|
||||
}
|
||||
if err != nil {
|
||||
logf("%s holds what is no tag-wanted, left: %v", m.Subject(), err)
|
||||
_ = m.Ack()
|
||||
return
|
||||
}
|
||||
if kv == nil {
|
||||
logf("tag-wanted of %s %s is heard again: %s is not on the bus yet", t.Repository, t.Branch, versions.TagAsksState)
|
||||
_ = m.NakWithDelay(versions.RemoveAgainAfter)
|
||||
return
|
||||
}
|
||||
if _, err := kv.Put(ctx, key, value); err != nil {
|
||||
logf("tag-wanted of %s %s is heard again: %v", t.Repository, t.Branch, err)
|
||||
_ = m.Nak()
|
||||
return
|
||||
}
|
||||
_ = m.Ack()
|
||||
}
|
||||
|
||||
// tagsVia is build.Builder.Tags over the state tag-asks: the asks of the repository, read when a check's build is
|
||||
// looked at; none while the state is not bound.
|
||||
func tagsVia(kv func() jetstream.KeyValue, logf func(string, ...any)) build.TagOf {
|
||||
return func(ctx context.Context, repository, head string) (string, bool) {
|
||||
store := kv()
|
||||
if store == nil {
|
||||
return "", false
|
||||
}
|
||||
lister, err := store.ListKeysFiltered(ctx, versions.EscapeToken(repository)+".>")
|
||||
if err != nil {
|
||||
logf("the tag asks of %s cannot be read, so its check builds no tagged version: %v", repository, err)
|
||||
return "", false
|
||||
}
|
||||
var asks []versions.TagAsk
|
||||
for key := range lister.Keys() {
|
||||
e, err := store.Get(ctx, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if t, err := versions.DecodeTagAsk(e.Value()); err == nil {
|
||||
asks = append(asks, t)
|
||||
}
|
||||
}
|
||||
return versions.TagFor(asks, repository, head)
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/heard"
|
||||
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// findingsBucket is the builder's findings (novox/hq ADR 0564 §2): what it sees wrong now, written at its raise and
|
||||
// deleted at its clear, which the condition keeper raises and clears.
|
||||
var findingsBucket = health.FindingsBucket(delivery.BuilderModule)
|
||||
|
||||
// findings writes the builder's findings into its state, once bound; until then each is said in the log only.
|
||||
type findings struct {
|
||||
kv *process.AwaitedBucket
|
||||
logf func(string, ...any)
|
||||
now func() time.Time
|
||||
// store stands in for kv in a test.
|
||||
store findingStore
|
||||
}
|
||||
|
||||
// findingStore is the part of the findings bucket written.
|
||||
type findingStore interface {
|
||||
Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error)
|
||||
Put(ctx context.Context, key string, value []byte) (uint64, error)
|
||||
Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error
|
||||
}
|
||||
|
||||
// finding is one of the builder's findings: its kind, what it is about, and its words.
|
||||
type finding struct {
|
||||
kind, scope, id string
|
||||
summary, headline, explanation, resolved, needs string
|
||||
severity health.Severity
|
||||
}
|
||||
|
||||
func (f *findings) bucket() findingStore {
|
||||
switch {
|
||||
case f == nil:
|
||||
return nil
|
||||
case f.store != nil:
|
||||
return f.store
|
||||
case f.kv == nil:
|
||||
return nil
|
||||
}
|
||||
if kv := f.kv.KV(); kv != nil {
|
||||
return kv
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildFailed is the finding of a failed trunk build (DLV-B7, design 25), about its module.
|
||||
const BuildFailed = "build-failed"
|
||||
|
||||
// buildEnded raises the module's build-failed for a trunk build that failed and clears it at a later done one; a
|
||||
// candidate's build (its ref named) is the check's to say.
|
||||
func (f *findings) buildEnded(ctx context.Context, e delivery.BuildLogEntry) {
|
||||
if e.Event != delivery.BuildEndedEvent || e.Ref != "" {
|
||||
return
|
||||
}
|
||||
switch e.Outcome {
|
||||
case delivery.BuildFailed:
|
||||
f.raise(ctx, finding{kind: BuildFailed, scope: health.ScopeModule, id: e.Module,
|
||||
summary: "the trunk build of " + e.Module + " at " + e.FP + " (commit " + e.Commit + ") failed",
|
||||
headline: "A module's trunk build failed",
|
||||
explanation: "The builder built " + e.Module + " from the trunk and the build failed, so its new version is not delivered.",
|
||||
resolved: "A later trunk build of " + e.Module + " is done.",
|
||||
needs: "builder_builds_since shows the build; builder_build_again builds it again."})
|
||||
case delivery.BuildDone:
|
||||
f.clear(ctx, BuildFailed, health.ScopeModule, e.Module)
|
||||
}
|
||||
}
|
||||
|
||||
// raise writes fd; it is written again only when its words changed, so the keeper's condition moves only then.
|
||||
func (f *findings) raise(ctx context.Context, fd finding) {
|
||||
key := health.FindingKey(fd.scope, fd.id, fd.kind)
|
||||
kv := f.bucket()
|
||||
if kv == nil {
|
||||
f.logf("finding %s (not written, %s is not on the bus yet): %s", key, findingsBucket, fd.summary)
|
||||
return
|
||||
}
|
||||
if e, err := kv.Get(ctx, key); err == nil {
|
||||
if old, err := health.DecodeFinding(e.Value()); err == nil && old.Summary == fd.summary {
|
||||
return
|
||||
}
|
||||
}
|
||||
at := f.now().UTC()
|
||||
sev := fd.severity
|
||||
if sev == "" {
|
||||
sev = health.Warning
|
||||
}
|
||||
value, err := health.EncodeFinding(health.Finding{Key: key, Kind: fd.kind, Subject: health.Subject{Scope: fd.scope, ID: fd.id},
|
||||
Severity: sev, Summary: fd.summary, Headline: fd.headline, Explanation: fd.explanation, Resolved: fd.resolved, Needs: fd.needs,
|
||||
Evidence: []health.Evidence{{At: at, Said: fd.summary}}, Source: "builder." + fd.kind, Observer: delivery.BuilderModule, At: at})
|
||||
if err == nil {
|
||||
_, err = kv.Put(ctx, key, value)
|
||||
}
|
||||
if err != nil {
|
||||
f.logf("the finding %s cannot be written: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// clear deletes the finding of kind about scope and id; one not there is nothing to clear.
|
||||
func (f *findings) clear(ctx context.Context, kind, scope, id string) {
|
||||
kv := f.bucket()
|
||||
if kv == nil {
|
||||
return
|
||||
}
|
||||
key := health.FindingKey(scope, id, kind)
|
||||
if _, err := kv.Get(ctx, key); errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return
|
||||
}
|
||||
if err := kv.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
f.logf("the finding %s cannot be cleared: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// notHeard is the writer of the builder's finding of a family it does not hear (heard.Writer, ADR 0615 §5): written into
|
||||
// its findings once bound, said in the log until then.
|
||||
func (f *findings) notHeard(ctx context.Context) heard.Writer {
|
||||
on := func(do func(findingStore)) {
|
||||
switch {
|
||||
case f.store != nil:
|
||||
do(f.store)
|
||||
case f.kv != nil:
|
||||
f.kv.Ready(func(kv jetstream.KeyValue) { do(kv) })
|
||||
}
|
||||
}
|
||||
return heard.Writer{Observer: delivery.BuilderModule, Logf: f.logf, Now: f.now,
|
||||
Put: func(key string, value []byte) error {
|
||||
on(func(kv findingStore) {
|
||||
if _, err := kv.Put(ctx, key, value); err != nil {
|
||||
f.logf("the finding %s cannot be written: %v", key, err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
Delete: func(key string) error {
|
||||
on(func(kv findingStore) {
|
||||
if _, err := kv.Get(ctx, key); err != nil {
|
||||
return // none stands
|
||||
}
|
||||
if err := kv.Delete(ctx, key); err != nil {
|
||||
f.logf("the finding %s cannot be cleared: %v", key, err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// floorSaid is the floor finding (design 51 *Health and conditions*: a floor source silent): raised for a name waiting on
|
||||
// its floor, about that name, cleared once all three sources answered.
|
||||
func (f *findings) floorSaid(ctx context.Context) func(versions.FloorWait, bool) {
|
||||
return func(w versions.FloorWait, raised bool) {
|
||||
if !raised {
|
||||
f.clear(ctx, versions.FloorSilent, health.ScopeModule, w.Name)
|
||||
return
|
||||
}
|
||||
f.raise(ctx, finding{kind: versions.FloorSilent, scope: health.ScopeModule, id: w.Name, summary: w.Finding(),
|
||||
headline: "A version's floor is not known",
|
||||
explanation: "The builder numbers a name only once its registry seat, the forge bridge's tags and the indexer's requirements all answered; one of them did not.",
|
||||
resolved: "All three answered.",
|
||||
needs: "builder_floor_waits names the silent source; builder_floor_again asks again by hand."})
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -26,17 +25,12 @@ import (
|
||||
// The build runner the build modules pull and run asks with: mesh-build-go's own package (novox/hq ADR 0593 §1),
|
||||
// another repository's, by its published version, so the builder is tested against the real pulling code.
|
||||
runner "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views/viewstest"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
|
||||
@@ -78,9 +72,6 @@ type fakeBuildModules struct {
|
||||
// waiting in their streams).
|
||||
closed atomic.Bool
|
||||
pullers []*runner.Puller
|
||||
// hang, while it holds a channel, keeps every build step running until the channel is closed: a step that never ends,
|
||||
// so the builder's step bound runs out (design 51's round-2 case).
|
||||
hang atomic.Pointer[chan struct{}]
|
||||
}
|
||||
|
||||
// open lets the nodes pull again and wakes their pullers.
|
||||
@@ -119,12 +110,6 @@ func (f *fakeBuildModules) step(module, name string, args map[string]any) (any,
|
||||
f.builds = append(f.builds, args)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if h := f.hang.Load(); h != nil && name == "build" {
|
||||
select {
|
||||
case <-*h:
|
||||
case <-f.ctx.Done():
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case name == "fetch":
|
||||
merge := ""
|
||||
@@ -210,11 +195,7 @@ type bed struct {
|
||||
builds jetstream.KeyValue
|
||||
tools map[string]jetstream.KeyValue
|
||||
instances jetstream.KeyValue
|
||||
tagAsks jetstream.KeyValue
|
||||
tagBounds jetstream.KeyValue
|
||||
packages jetstream.KeyValue // the indexer's packages, served by its read tool and views family
|
||||
out *saidLog // the builder's process log, every instance's
|
||||
stop func() // stops the builder started last
|
||||
stop func() // stops the builder started last
|
||||
fake *fakeBuildModules
|
||||
srvURL string
|
||||
dir string
|
||||
@@ -289,23 +270,8 @@ func startBedWith(t *testing.T, closed bool) *bed {
|
||||
b.candidates = mk(delivery.CandidatesBucket, delivery.CandidatesHistory)
|
||||
b.source = mk(delivery.SourceBucket, delivery.SourceHistory)
|
||||
mk(delivery.ManifestsBucket, delivery.ManifestsHistory)
|
||||
// The indexer and the assigner as the owners of the states the builder follows: their read tools and views families
|
||||
// (ADR 0615, DLV-M1); n1 and n2 each have their durable consumer.
|
||||
readers := []string{"n1_" + delivery.BuilderModule, "n2_" + delivery.BuilderModule}
|
||||
for _, st := range []string{modulesState, manifestsState, candidatesState} {
|
||||
viewstest.Serve(t, ctx, js, ctl, delivery.IndexerModule, st, delivery.IndexerModule+".indexer_read_"+st, indexerViews, readers...)
|
||||
}
|
||||
b.packages = viewstest.Serve(t, ctx, js, ctl, delivery.IndexerModule, delivery.PackagesState, delivery.IndexerModule+".indexer_read_packages", indexerViews, readers...)
|
||||
viewstest.Serve(t, ctx, js, ctl, delivery.AssignerModule, delivery.PlacementState, delivery.AssignerModule+".assigner_read_placement", assignerViews, readers...)
|
||||
b.builds = mk(delivery.BuilderBuildsBucket, delivery.BuilderBuildsHistory)
|
||||
b.instances = mk(instancesBucket, 1)
|
||||
// The tag asks and their bounds (design 51 *Tagged versions*): the bounds declared key-ttl, so an ended bound leaves the
|
||||
// bus's marker a watch hears.
|
||||
b.tagAsks = mk(versions.TagAsksBucket, 1)
|
||||
if b.tagBounds, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: versions.TagAskBoundsBucket, History: 1, LimitMarkerTTL: time.Minute}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.out = &saidLog{}
|
||||
// The builder's module log, as the runtime raises it (ADR 0297): its stream on the log's subjects.
|
||||
logStream := buscompose.LogStreamName(delivery.BuilderModule, delivery.BuilderLog)
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: logStream, Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"},
|
||||
@@ -365,24 +331,15 @@ func (b *bed) report(t *testing.T, module, node, language, version, recipe strin
|
||||
|
||||
// module writes modules.<name> as the indexer does for a module with a new-form fingerprint, and its manifest by digest.
|
||||
func (b *bed) module(t *testing.T, name, fixture, path string) string {
|
||||
return b.moduleWith(t, name, fixture, path, bothToolchains)
|
||||
return b.moduleWith(t, name, fixture, path, map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}})
|
||||
}
|
||||
|
||||
// bothToolchains are the toolchains the bed's nodes report, as a module's fingerprint names them.
|
||||
var bothToolchains = map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}}
|
||||
|
||||
// moduleWith is module with the toolchains its fingerprint was computed with.
|
||||
func (b *bed) moduleWith(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed) string {
|
||||
return b.moduleCommit(t, name, fixture, path, toolchains, commit)
|
||||
}
|
||||
|
||||
func (b *bed) moduleCommit(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed, at string) string {
|
||||
t.Helper()
|
||||
return b.moduleFP(t, name, fixture, path, toolchains, at, fp, 0)
|
||||
}
|
||||
|
||||
// moduleFP is moduleCommit with the new-form fingerprint fp2 and the source revision src of its merge (0: none).
|
||||
func (b *bed) moduleFP(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed, at, fp2 string, src uint64) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", fixture))
|
||||
if err != nil {
|
||||
@@ -400,8 +357,8 @@ func (b *bed) moduleFP(t *testing.T, name, fixture, path string, toolchains map[
|
||||
if _, err := manifests.Put(b.ctx, digest, mv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: at, FP: "src1:" + strings.Repeat("f", 64), FP2: fp2,
|
||||
Manifest: digest, Path: path, Toolchains: toolchains, Src: src})
|
||||
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: at, FP: "src1:" + strings.Repeat("f", 64), FP2: fp,
|
||||
Manifest: digest, Path: path, Toolchains: toolchains})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -432,58 +389,16 @@ func (b *bed) start(t *testing.T, node string, env map[string]string) {
|
||||
}
|
||||
runCtx, stop := context.WithCancel(b.ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run(runCtx, func(k string) string { return e[k] }, b.out) }()
|
||||
go func() { done <- run(runCtx, func(k string) string { return e[k] }, io.Discard) }()
|
||||
var once sync.Once
|
||||
b.stop = func() { once.Do(func() { stop(); <-done }) }
|
||||
t.Cleanup(b.stop)
|
||||
}
|
||||
|
||||
// saidLog is the builder's process log as its instances write it.
|
||||
type saidLog struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func (l *saidLog) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buf.Write(p)
|
||||
}
|
||||
|
||||
// awaitSaid reads the process log until it holds want, within 20 s, and answers the line.
|
||||
func (b *bed) awaitSaid(t *testing.T, want string) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for {
|
||||
b.out.mu.Lock()
|
||||
all := b.out.buf.String()
|
||||
b.out.mu.Unlock()
|
||||
for _, line := range strings.Split(all, "\n") {
|
||||
if strings.Contains(line, want) {
|
||||
return line
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the builder never said %q; it said:\n%s", want, all)
|
||||
}
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
t.Fatal(b.ctx.Err())
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// await reads the build key until pred holds of it, within 20 s.
|
||||
func (b *bed) await(t *testing.T, module string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild {
|
||||
t.Helper()
|
||||
return b.awaitFP(t, module, fp, pred)
|
||||
}
|
||||
|
||||
// awaitFP is await of the build key of fingerprint at.
|
||||
func (b *bed) awaitFP(t *testing.T, module, at string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild {
|
||||
t.Helper()
|
||||
key, err := delivery.BuilderBuildKey(module, at)
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -510,31 +425,9 @@ func isState(state string) func(delivery.BuilderBuild) bool {
|
||||
// the digests of both bundles, both build modules' toolchains, after a fetch and a build on one node (ADR 0478 clause 4).
|
||||
func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
|
||||
b := startBed(t)
|
||||
// DLV-B10 (design 51, round 2): the builder's versions, findings and the floor's three sources are on the bus; the seat
|
||||
// answers 0.1.4, so netcheck's bundles are numbered from 0.1.5. Two merges close together, the first replaced before
|
||||
// its claim: the second is numbered at once, and the first never.
|
||||
reg := b.registry(t, "n1", "0.1.4")
|
||||
const netcheck = "catalogue-a67f0196-netcheck.module.json"
|
||||
first := "src2:" + strings.Repeat("1", 64)
|
||||
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, first, 1)
|
||||
digest := b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, fp, 2)
|
||||
// DLV-B6: a tag ask kept and never numbered (no check builds its head) is narrated once its bound ends on the bus, with
|
||||
// what it waits on, and its bound is written again; no timer runs in the builder.
|
||||
key, ask, err := versions.NewTagAsk("novox/mesh-sdk", "feat/x", "abc", "a group member's check", "me@shanks", time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.tagAsks.Put(b.ctx, key, ask); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.start(t, "n1", map[string]string{"MESH_BUILDER_TAG_ASK_BOUND": "1s", "MESH_BUILDER_STEP_BOUND": "1s"})
|
||||
digest := b.module(t, "netcheck", "catalogue-a67f0196-netcheck.module.json", "modules/netcheck")
|
||||
b.start(t, "n1", nil)
|
||||
v := b.await(t, "netcheck", isState(delivery.BuildDone))
|
||||
for _, a := range []string{"tools-go", "tools-typescript"} {
|
||||
es := reg.of(t, "netcheck/"+a)
|
||||
if len(es) != 1 || es[0].Version != "0.1.5" || es[0].Fingerprint != fp || es[0].State != versions.Numbered || es[0].Digest != digestOf(a) {
|
||||
t.Errorf("versions of netcheck/%s = %+v, want the second merge numbered 0.1.5 at once with its digest, the first never", a, es)
|
||||
}
|
||||
}
|
||||
if v.By != "n1.mesh-delivery-builder" || v.Manifest != digest || len(v.Artifacts) != 2 {
|
||||
t.Fatalf("build = %+v", v)
|
||||
}
|
||||
@@ -546,21 +439,9 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
|
||||
if v.Toolchains["go"].Version != "go1.27.1" || v.Toolchains["typescript"].Version != "tsc5" {
|
||||
t.Errorf("toolchains = %+v", v.Toolchains)
|
||||
}
|
||||
// ADR 0618 §1: the done key names the reference forms it is written in.
|
||||
if len(v.Needs) != 1 || v.Needs[0] != "form:artifact-store" {
|
||||
t.Errorf("needs = %v, want the first form", v.Needs)
|
||||
}
|
||||
// 1955/L7 (ADR 0571 §1): the build's start and end are in the module log build-log under the module's key, read as a
|
||||
// reader with the log's read reads them, the end naming the manifest and what was made.
|
||||
// DLV-B10: between them the build log holds the build's narrations (build.said), each version numbered, and why.
|
||||
var ended []delivery.BuildLogEntry
|
||||
for _, e := range b.logged(t, "netcheck") {
|
||||
if e.Event != delivery.BuildSaidEvent {
|
||||
ended = append(ended, e)
|
||||
} else if e.Why != versions.SaidNumbered || !strings.Contains(e.Line, "numbered 0.1.5") || e.FP != fp {
|
||||
t.Errorf("said = %+v, want netcheck's versions numbered", e)
|
||||
}
|
||||
}
|
||||
ended := b.logged(t, "netcheck")
|
||||
if len(ended) != 2 || ended[0].Event != delivery.BuildStartedEvent || ended[1].Event != delivery.BuildEndedEvent ||
|
||||
ended[1].Manifest != digest || len(ended[1].Made) != 2 || ended[1].Node != "n1" {
|
||||
t.Errorf("logged = %+v, want the start and the end of netcheck's build", ended)
|
||||
@@ -570,168 +451,38 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
|
||||
t.Errorf("%s called %d times, want %d (on one node, ADR 0478 clause 4)", tool, got, want)
|
||||
}
|
||||
}
|
||||
said := b.awaitSaid(t, "the tag ask of novox/mesh-sdk feat/x at abc (feat-x) is not answered after")
|
||||
if !strings.Contains(said, "the check's build of abc is not claimed yet") {
|
||||
t.Errorf("the narration = %q, want what the ask waits on", said)
|
||||
}
|
||||
|
||||
// TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey: two builders on two nodes start together on one module key of an
|
||||
// archive only (no toolchain named, as the indexer writes it); the second takes over the key the first holds building
|
||||
// (ADR 0478 clause 6), and the key ends done, written once.
|
||||
func TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey(t *testing.T) { // 1607/AC7
|
||||
b := startBed(t)
|
||||
b.archive(t, "ca-trust", "modules/ca-trust", commit)
|
||||
b.start(t, "n1", nil)
|
||||
b.start(t, "n2", nil)
|
||||
v := b.await(t, "ca-trust", isState(delivery.BuildDone))
|
||||
if len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || v.Artifacts[0].Digest != digestOf("files") {
|
||||
t.Fatalf("build = %+v, want the archive's digest", v)
|
||||
}
|
||||
// And in the build log, under the builder's own key (the ruling of 13:17: build.said).
|
||||
if !slices.ContainsFunc(b.logged(t, delivery.BuilderModule), func(e delivery.BuildLogEntry) bool {
|
||||
return e.Event == delivery.BuildSaidEvent && e.Why == versions.SaidUnanswered && e.FP == key && e.Commit == "abc" &&
|
||||
strings.Contains(e.Line, "the check's build of abc is not claimed yet")
|
||||
}) {
|
||||
t.Errorf("the build log of %s = %+v, want the unanswered tag ask said", delivery.BuilderModule, b.logged(t, delivery.BuilderModule))
|
||||
if n := b.fake.count("mesh-build-go.build_go_build"); n < 1 { // each instance that built, on one node
|
||||
t.Errorf("build_go_build was called %d times, want one for each instance that built", n)
|
||||
}
|
||||
if e, err := b.tagAsks.Get(b.ctx, key); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if a, _ := versions.DecodeTagAsk(e.Value()); a.Answered.IsZero() || !a.Unanswered() {
|
||||
t.Errorf("the tag ask = %+v, want kept and answered, no version yet", a)
|
||||
}
|
||||
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(50 * time.Millisecond) {
|
||||
if _, err := b.tagBounds.Get(b.ctx, key); err == nil {
|
||||
break
|
||||
} else if time.Now().After(deadline) {
|
||||
t.Fatalf("the bound is not written again after its narration: %v", err)
|
||||
}
|
||||
}
|
||||
// Design 32, ADR 0615 §1: a package's name the indexer writes later reaches the builder by its packages' views family
|
||||
// (never their bucket), and looks at the module again; the done bundles are kept, not numbered twice.
|
||||
if _, err := b.packages.Put(b.ctx, "netcheck.0.1", []byte(`{"v":1,"name":"netcheck","package":"netcheck/lib","series":"0.1"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.awaitSaid(t, "names a package of netcheck")
|
||||
if got := reg.numberedOf("netcheck/tools-go"); len(got) != 1 || got[0] != "0.1.5" {
|
||||
t.Errorf("version.numbered of netcheck/tools-go = %v, want 0.1.5 once", got)
|
||||
}
|
||||
// DLV-B10: builder_publish_again after a published takes a new number, and says it numbered.
|
||||
for _, a := range []string{"tools-go", "tools-typescript"} {
|
||||
reg.published(t, "netcheck/"+a, "0.1.5", digestOf(a))
|
||||
}
|
||||
reg.awaitOf(t, "netcheck/tools-go", func(es []versions.Entry) bool { return len(es) == 1 && es[0].State == versions.Published })
|
||||
key, again, err := build.NewPublishAgain("netcheck/tools-go", fp, "the holder lost it", "me@shanks", time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := reg.answered(t, key, again); a.Refused || a.Do != versions.AgainRenumber || a.Version != "0.1.6" {
|
||||
t.Errorf("builder_publish_again = %+v, want a new number, 0.1.6", a)
|
||||
}
|
||||
// Running-testing note (review round 1): two claims never get one number on the bus's own compare-and-set, as
|
||||
// the builder's state versions keeps them: eight claims of one name at once, eight versions.
|
||||
store := versions.Versions{Store: awaitedVersions{process.AwaitBucket(b.ctx, b.js, nil, versions.Bucket, "", t.Logf)}}
|
||||
numbers := make(chan string, 8)
|
||||
var claims sync.WaitGroup
|
||||
for i := range 8 {
|
||||
claims.Go(func() {
|
||||
d, _, err := store.Number(b.ctx, versions.Claim{Name: "netcheck/race", Kind: versions.KindBundle, Fingerprint: fmt.Sprintf("f%d", i)})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
numbers <- d.Version
|
||||
})
|
||||
}
|
||||
claims.Wait()
|
||||
close(numbers)
|
||||
got := map[string]bool{}
|
||||
for v := range numbers {
|
||||
got[v] = true
|
||||
}
|
||||
if len(got) != 8 {
|
||||
t.Errorf("eight claims of one name at once were numbered %v, want eight versions", slices.Sorted(maps.Keys(got)))
|
||||
}
|
||||
if got := reg.numberedOf("netcheck/tools-go"); len(got) != 2 || got[1] != "0.1.6" {
|
||||
t.Errorf("version.numbered of netcheck/tools-go = %v, want 0.1.6 said after 0.1.5", got)
|
||||
}
|
||||
// DLV-B10: a build module's bound running out fails the trunk build, which is the builder's finding build-failed; the
|
||||
// fingerprint built again keeps its number, said with why, and its done build clears the finding.
|
||||
release := make(chan struct{})
|
||||
b.fake.hang.Store(&release)
|
||||
third := "src2:" + strings.Repeat("4", 64)
|
||||
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3)
|
||||
if f := b.awaitFP(t, "netcheck", third, isState(delivery.BuildFailed)); !strings.Contains(f.Error, "did not end within") {
|
||||
t.Errorf("the failed build = %+v, want its bound run out", f)
|
||||
}
|
||||
failed := health.FindingKey(health.ScopeModule, "netcheck", BuildFailed)
|
||||
reg.awaitFinding(t, failed, true)
|
||||
b.fake.hang.Store(nil)
|
||||
close(release)
|
||||
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3) // the indexer writes it again
|
||||
b.awaitFP(t, "netcheck", third, isState(delivery.BuildDone))
|
||||
b.awaitSaid(t, "kept: fingerprint "+third+" is numbered under it")
|
||||
if !slices.ContainsFunc(b.logged(t, "netcheck"), func(e delivery.BuildLogEntry) bool {
|
||||
return e.Event == delivery.BuildSaidEvent && e.Why == versions.SaidKept && e.FP == third && strings.Contains(e.Line, "kept: fingerprint "+third)
|
||||
}) {
|
||||
t.Errorf("the build log of netcheck says no version kept at %s", third)
|
||||
}
|
||||
reg.awaitFinding(t, failed, false)
|
||||
// DLV-B4: the seats' refused and removed, heard on the builder's durable consumer, move a numbered version: a retention
|
||||
// removal turns the entry removed with its reason, a refusal refused with the holder's reason and the bytes it refused.
|
||||
reg.outcome(t, ociartifactregistry.SubjectRemoved, versions.Outcome{Name: "netcheck/tools-typescript", Version: "0.1.5", Kind: "bundle", Holder: "distribution", Reason: "retention"})
|
||||
reg.outcome(t, ociartifactregistry.SubjectRefused, versions.Outcome{Name: "netcheck/tools-go", Version: "0.1.6", Digest: digestOf("other"), Kind: "bundle", Holder: "distribution", Reason: "the cap"})
|
||||
reg.awaitOf(t, "netcheck/tools-typescript", func(es []versions.Entry) bool {
|
||||
return slices.ContainsFunc(es, func(e versions.Entry) bool {
|
||||
return e.Version == "0.1.5" && e.State == versions.Removed && e.Reason == "retention"
|
||||
})
|
||||
})
|
||||
reg.awaitOf(t, "netcheck/tools-go", func(es []versions.Entry) bool {
|
||||
return slices.ContainsFunc(es, func(e versions.Entry) bool {
|
||||
return e.Version == "0.1.6" && e.State == versions.Refused && e.Reason == "the cap" && e.RefusedDigest == digestOf("other")
|
||||
})
|
||||
})
|
||||
// DLV-B6: a branch's end (the forge bridge's merged) asks the seat to remove each version tagged for it, the reason
|
||||
// tag-gone, and the seat's removed turns it removed.
|
||||
tagged := versions.Entry{Name: "mesh-sdk/go", Kind: versions.KindPackage, Series: "0.1", Version: "0.1.15-feat-x.1", Tag: "feat-x", Module: "mesh-sdk",
|
||||
Fingerprint: "src2:" + strings.Repeat("5", 64), Repository: "novox/mesh-sdk", Seat: gopackageregistry.Seat, State: versions.Published, At: time.Now().UTC()}
|
||||
raw, _ := json.Marshal(tagged)
|
||||
if _, err := reg.versions.Put(b.ctx, versions.Key(tagged.Name, tagged.Version), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.js.Publish(b.ctx, process.ModuleStreamSubject(delivery.ForgeBridgeModule, "forge"),
|
||||
[]byte(`{"kind":"`+delivery.ForgeMerged+`","repository":"novox/mesh-sdk","branch":"feat/x"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) {
|
||||
reg.mu.Lock()
|
||||
asked := slices.ContainsFunc(reg.removes, func(a map[string]any) bool {
|
||||
return a["name"] == tagged.Name && a["version"] == tagged.Version && a["reason"] == "tag-gone"
|
||||
})
|
||||
reg.mu.Unlock()
|
||||
if asked {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the seat's remove of %s@%s was not asked at the branch's end: %v", tagged.Name, tagged.Version, reg.removes)
|
||||
}
|
||||
}
|
||||
reg.outcome(t, gopackageregistry.SubjectRemoved, versions.Outcome{Name: tagged.Name, Version: tagged.Version, Kind: versions.KindPackage, Holder: "gitea", Reason: "tag-gone"})
|
||||
reg.awaitOf(t, tagged.Name, func(es []versions.Entry) bool { return len(es) == 1 && es[0].State == versions.Removed })
|
||||
}
|
||||
|
||||
// TestAnArchiveOnlyModuleAndARootModuleAreBuilt: the indexer gives an archive no language, so modules.ca-trust has no
|
||||
// toolchains; the builder builds it on the build module's nodes all the same (review round 4). A module whose path is "."
|
||||
// is given to the build tools as it is (1615/AC4). Two builders on two nodes start together on the archive's key: the
|
||||
// second takes over the key the first holds building (ADR 0478 clause 6), and the key ends done, written once (1607/AC7).
|
||||
// One table: three tests were merged, to pay for the enrolment's and to bring mesh-delivery's count down (DLV-B8).
|
||||
// is given to the build tools as it is (1615/AC4). One table: the two tests were merged to pay for the enrolment's.
|
||||
func TestAnArchiveOnlyModuleAndARootModuleAreBuilt(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name, path string
|
||||
two bool
|
||||
}{{"no toolchain", "modules/ca-trust", false}, {"root", delivery.RootPath, false}, {"two instances together", "modules/ca-trust", true}} {
|
||||
for _, c := range []struct{ name, path string }{{"no toolchain", "modules/ca-trust"}, {"root", delivery.RootPath}} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
b := startBed(t)
|
||||
b.archive(t, "ca-trust", c.path, commit)
|
||||
b.start(t, "n1", nil)
|
||||
if c.two {
|
||||
b.start(t, "n2", nil)
|
||||
}
|
||||
v := b.await(t, "ca-trust", isState(delivery.BuildDone))
|
||||
if c.name == "no toolchain" && (len(v.Toolchains) != 0 || len(v.Artifacts) != 1 || strings.HasPrefix(v.Step, "one node (")) {
|
||||
t.Errorf("build = %+v, want done naming no toolchain", v)
|
||||
}
|
||||
if c.two && (len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || v.Artifacts[0].Digest != digestOf("files")) {
|
||||
t.Errorf("build = %+v, want the archive's digest", v)
|
||||
}
|
||||
if n := b.fake.count("mesh-build-go.build_go_build"); c.two && n < 1 { // each instance that built, on one node
|
||||
t.Errorf("build_go_build was called %d times, want one for each instance that built", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1040,12 +791,7 @@ func TestTheBuilderAsksTheBuildSeatsAndCallsNoNode(t *testing.T) {
|
||||
}
|
||||
defer sub.Close()
|
||||
var calls atomic.Int64
|
||||
// A build module's tool, never the owners' read tools the builder's first views ask (DLV-M1).
|
||||
s, err := sub.Subscribe("mesh.mod.*.call.>", func(m *nats.Msg) {
|
||||
if strings.HasPrefix(m.Subject, "mesh.mod.mesh-build-") {
|
||||
calls.Add(1)
|
||||
}
|
||||
})
|
||||
s, err := sub.Subscribe("mesh.mod.*.call.>", func(*nats.Msg) { calls.Add(1) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1164,184 +910,3 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) {
|
||||
t.Errorf("done = %+v", done)
|
||||
}
|
||||
}
|
||||
|
||||
// registry is the part of the bed design 51's builder cases need: the builder's states versions, findings, floor-waits and
|
||||
// reasks, EVENTS with the seat oci-artifact-registry's outcomes and the builder's durable consumer of it, the builder's
|
||||
// own events kept, and the floor's three sources served on the bus as their holders answer through the tool runner
|
||||
// ({result}): the seat's versions, the forge bridge's bridge_tags and the indexer's indexer_requirements.
|
||||
type registry struct {
|
||||
b *bed
|
||||
versions, findings, reasks jetstream.KeyValue
|
||||
mu sync.Mutex
|
||||
numbered []delivery.VersionNumbered
|
||||
removes []map[string]any
|
||||
}
|
||||
|
||||
func (b *bed) registry(t *testing.T, node string, seatVersions ...string) *registry {
|
||||
t.Helper()
|
||||
r := ®istry{b: b}
|
||||
mk := func(name string) jetstream.KeyValue {
|
||||
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: name, History: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kv
|
||||
}
|
||||
r.versions, r.findings, r.reasks = mk(versions.Bucket), mk(findingsBucket), mk(build.ReAsksBucket)
|
||||
mk(versions.FloorWaitsBucket)
|
||||
// EVENTS with two seats' outcomes, and the forge bridge's stream (a branch's end): each with the builder's durable consumer.
|
||||
for stream, subjects := range map[string][]string{
|
||||
eventsStream: {delivery.ArtifactsGoneSubject, ociartifactregistry.SubjectPublished, ociartifactregistry.SubjectRefused, ociartifactregistry.SubjectRemoved,
|
||||
gopackageregistry.SubjectPublished, gopackageregistry.SubjectRefused, gopackageregistry.SubjectRemoved},
|
||||
forgeStream: {process.ModuleStreamSubject(delivery.ForgeBridgeModule, ">")},
|
||||
} {
|
||||
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: stream, Subjects: subjects}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.js.CreateOrUpdateConsumer(b.ctx, stream, jetstream.ConsumerConfig{Durable: node + "_" + delivery.BuilderModule,
|
||||
AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The Go seat's remove, as its holder answers it; each call is kept.
|
||||
rm, err := b.js.Conn().Subscribe("mesh.seat."+gopackageregistry.Seat+".call.remove.>", func(m *nats.Msg) {
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(m.Data, &args)
|
||||
r.mu.Lock()
|
||||
r.removes = append(r.removes, args)
|
||||
r.mu.Unlock()
|
||||
_ = m.Respond([]byte(`{"result":{},"node":"ctl"}`))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rm.Unsubscribe() })
|
||||
own := "mesh.mod." + delivery.BuilderModule + ".event.>"
|
||||
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "BUILDER_EVENTS", Subjects: []string{own}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nc := b.js.Conn()
|
||||
// As a build module hears it: on the contract's subject, read by the contract's decoder (BG2's late publish).
|
||||
sub, err := nc.Subscribe(delivery.VersionNumberedSubject, func(m *nats.Msg) {
|
||||
if nb, err := delivery.DecodeVersionNumbered(m.Data); err == nil {
|
||||
r.mu.Lock()
|
||||
r.numbered = append(r.numbered, nb)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
var listed []map[string]string
|
||||
for _, v := range seatVersions {
|
||||
listed = append(listed, map[string]string{"version": v})
|
||||
}
|
||||
for subject, answer := range map[string]any{
|
||||
"mesh.seat." + ociartifactregistry.Seat + ".call.versions.>": map[string]any{"versions": listed},
|
||||
"mesh.mod.mesh-delivery-forge-bridge.call.bridge_tags.>": map[string]any{"tags": []string{}},
|
||||
"mesh.mod." + delivery.IndexerModule + ".call.indexer_requirements.>": map[string]any{"requirements": []string{}},
|
||||
} {
|
||||
reply, _ := json.Marshal(map[string]any{"result": answer, "node": "ctl"})
|
||||
s, err := nc.Subscribe(subject, func(m *nats.Msg) { _ = m.Respond(reply) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Unsubscribe() })
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// of is every entry versions holds of name.
|
||||
func (r *registry) of(t *testing.T, name string) []versions.Entry {
|
||||
t.Helper()
|
||||
l, err := r.versions.ListKeysFiltered(r.b.ctx, versions.NameFilter(name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = l.Stop() }()
|
||||
var out []versions.Entry
|
||||
for k := range l.Keys() {
|
||||
e, err := r.versions.Get(r.b.ctx, k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v versions.Entry
|
||||
if err := json.Unmarshal(e.Value(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// awaitOf reads name's entries until pred holds of them, within 20 s.
|
||||
func (r *registry) awaitOf(t *testing.T, name string, pred func([]versions.Entry) bool) []versions.Entry {
|
||||
t.Helper()
|
||||
var es []versions.Entry
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if es = r.of(t, name); pred(es) {
|
||||
return es
|
||||
}
|
||||
}
|
||||
t.Fatalf("versions of %s = %+v within 20 s", name, es)
|
||||
return es
|
||||
}
|
||||
|
||||
// published says the artifact seat's published for name@version, as its holder does.
|
||||
func (r *registry) published(t *testing.T, name, version, digest string) {
|
||||
t.Helper()
|
||||
r.outcome(t, ociartifactregistry.SubjectPublished, versions.Outcome{Name: name, Version: version, Digest: digest, Kind: "bundle", Holder: "distribution"})
|
||||
}
|
||||
|
||||
// outcome says a seat's outcome on subject, as its holder does.
|
||||
func (r *registry) outcome(t *testing.T, subject string, o versions.Outcome) {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(o)
|
||||
msg := nats.NewMsg(subject)
|
||||
msg.Header.Set("x-event-id", subject+"-"+o.Name+"-"+o.Version)
|
||||
msg.Data = data
|
||||
if _, err := r.b.js.PublishMsg(r.b.ctx, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// answered writes a reasks value and reads it until the builder answered it, within 20 s.
|
||||
func (r *registry) answered(t *testing.T, key string, value []byte) build.ReAsk {
|
||||
t.Helper()
|
||||
if _, err := r.reasks.Put(r.b.ctx, key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var a build.ReAsk
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if e, err := r.reasks.Get(r.b.ctx, key); err == nil && json.Unmarshal(e.Value(), &a) == nil && !a.Answered.IsZero() {
|
||||
return a
|
||||
}
|
||||
}
|
||||
t.Fatalf("reasks.%s = %+v, not answered within 20 s", key, a)
|
||||
return a
|
||||
}
|
||||
|
||||
// awaitFinding reads the builder's finding key until it is there (raised) or gone, within 20 s.
|
||||
func (r *registry) awaitFinding(t *testing.T, key string, raised bool) {
|
||||
t.Helper()
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if _, err := r.findings.Get(r.b.ctx, key); (err == nil) == raised {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("the finding %s raised = %v within 20 s, want %v", key, !raised, raised)
|
||||
}
|
||||
|
||||
// numberedOf are the version.numbered the builder said for name, in order.
|
||||
func (r *registry) numberedOf(name string) []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var out []string
|
||||
for _, nb := range r.numbered {
|
||||
if nb.Name == name {
|
||||
out = append(out, nb.Version)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -20,17 +19,14 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -65,53 +61,22 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The indexer's modules, manifests, candidates and packages and the assigner's placement are their owners' states, read by the
|
||||
// first view of their read tools and then their views families, never their buckets (ADR 0615 §1, DLV-M1). An owner that
|
||||
// is down never holds the start: each is read on a goroutine once its first view is answered, and until then the
|
||||
// unanswered first view is the builder's finding.
|
||||
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
|
||||
modules, err := process.EssentialBucket(ctx, js, "builder", delivery.ModulesBucket, "know any fingerprint to build")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer calls.Close()
|
||||
reader := &views.ProcessReader{Module: delivery.BuilderModule, Call: calls.Call, Logf: logger.Printf}
|
||||
findingsKV := process.AwaitBucket(ctx, js, conn, findingsBucket, "the builder's findings, a first view unanswered past its bound among them, are said in its log only", logger.Printf)
|
||||
reader.Findings = findingsKV.KV
|
||||
modules, err := reader.Follow(delivery.IndexerModule, modulesState, delivery.IndexerModule+".indexer_read_modules", indexerViews)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifests, err := reader.Follow(delivery.IndexerModule, manifestsState, delivery.IndexerModule+".indexer_read_manifests", indexerViews)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The candidates' fingerprints are built too (issue 1684).
|
||||
candidates, err := reader.Follow(delivery.IndexerModule, candidatesState, delivery.IndexerModule+".indexer_read_candidates", indexerViews)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The indexer's packages, whose names a package is numbered under (design 32).
|
||||
packages, err := reader.Follow(delivery.IndexerModule, delivery.PackagesState, delivery.IndexerModule+".indexer_read_packages", indexerViews)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
placement, err := reader.Follow(delivery.AssignerModule, delivery.PlacementState, delivery.AssignerModule+".assigner_read_placement", assignerViews)
|
||||
manifests, err := process.EssentialBucket(ctx, js, "builder", delivery.ManifestsBucket, "read what a module builds")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The candidates' fingerprints are built too (issue 1684): a builder that cannot read them builds the trunk's.
|
||||
candidates := process.OptionalBucket(ctx, js, delivery.CandidatesBucket, "no candidate's fingerprint is built until the builder starts again; the trunk's are", logger.Printf)
|
||||
bound := defaultStepBound
|
||||
if v := strings.TrimSpace(getenv("MESH_BUILDER_STEP_BOUND")); v != "" {
|
||||
if bound, err = time.ParseDuration(v); err != nil {
|
||||
return fmt.Errorf("MESH_BUILDER_STEP_BOUND: %w", err)
|
||||
}
|
||||
}
|
||||
// An unanswered tag ask is narrated every TagAskBound (design 51: five minutes); a test gives a shorter one.
|
||||
tagAskBound := versions.TagAskBound
|
||||
if v := strings.TrimSpace(getenv("MESH_BUILDER_TAG_ASK_BOUND")); v != "" {
|
||||
if tagAskBound, err = time.ParseDuration(v); err != nil {
|
||||
return fmt.Errorf("MESH_BUILDER_TAG_ASK_BOUND: %w", err)
|
||||
}
|
||||
}
|
||||
tools := &buildrun.ToolchainSet{}
|
||||
// Each build module's part of a build is an ask on its build seat's stream, pulled by a build node with a free slot
|
||||
// (novox/hq ADR 0525): the builder chooses no node, so it reads neither presence nor have and holds no capacity.
|
||||
@@ -138,31 +103,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
heard := buildrun.HearEnds(ctx, asks, asks, buildModules, logger.Printf)
|
||||
conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") })
|
||||
// The builder asks the controller, as its own account, whether the artifact store still holds a done key's blobs
|
||||
// (the seat verb held, novox/hq issue 1937) before it leaves the key done, on its calls connection.
|
||||
// The builder numbers each trunk build at its claim (novox/hq design 51 *The versions*): its state versions, bound once
|
||||
// the bus holds it; the floor's three sources asked as its own account; each new number said as version.numbered.
|
||||
versionsKV := process.AwaitBucket(ctx, js, conn, versions.Bucket, "no trunk build is numbered: each is built unnumbered and published by its digest", logger.Printf)
|
||||
found := &findings{kv: findingsKV, logf: logger.Printf, now: time.Now}
|
||||
floorWaits := &versions.FloorWaits{Logf: logger.Printf, Said: found.floorSaid(context.WithoutCancel(ctx))}
|
||||
// The names waiting on their floor are kept in the state floor-waits (DLV-B4): a restarted builder takes them in, so
|
||||
// their silent sources' events still wake them; until the state is bound they are kept here only.
|
||||
process.AwaitBucket(ctx, js, conn, versions.FloorWaitsBucket, "the names waiting on their floor are kept in memory only, and builder_floor_waits shows none", logger.Printf).
|
||||
Ready(func(kv jetstream.KeyValue) {
|
||||
floorWaits.Keep(kvKeeper{ctx: ctx, kv: kv}, heldFloorWaits(ctx, kv, logger.Printf))
|
||||
})
|
||||
// A package is numbered under its name in its ecosystem, read from the indexer's packages state (design 32).
|
||||
numberer := &versions.Numberer{Versions: versions.Versions{Store: awaitedVersions{versionsKV}}, Floor: floorVia(calls.Call), Waits: floorWaits,
|
||||
Names: versions.NamesIn(indexerPackages{packages}),
|
||||
Said: func(nb delivery.VersionNumbered) {
|
||||
if err := emitNumbered(context.WithoutCancel(ctx), js, cred, nb); err != nil {
|
||||
logger.Printf("%v", err)
|
||||
}
|
||||
}}
|
||||
// A publish that ends badly is a finding (design 51 *Health and conditions*): a holder's refused, a version no holder
|
||||
// answered within its bound (a key of the state bounds with its own time to live), a seat's outcome that does not read.
|
||||
pubBoundsKV := process.AwaitBucket(ctx, js, conn, versions.BoundsBucket, "a publish no holder answers, and a node holding the old form seven days, are not raised", logger.Printf)
|
||||
pubs := &publishes{bounds: pubBoundsKV.KV, versions: numberer.Versions, findings: found, bound: versions.PublishBound, logf: logger.Printf}
|
||||
numberer.Publishing = pubs.arm
|
||||
// (the seat verb held, novox/hq issue 1937) before it leaves the key done.
|
||||
calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer calls.Close()
|
||||
b := &build.Builder{
|
||||
Claimer: build.Claimer{Store: kvStore{builds}, By: cred.Node + "." + cred.Module},
|
||||
Asks: buildrun.Asker{Publisher: asks, Waiter: buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}, Bound: bound},
|
||||
@@ -176,52 +122,20 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
Stored: storedVia(calls.Call),
|
||||
Node: cred.Node,
|
||||
}
|
||||
tagAsksKV := process.AwaitBucket(ctx, js, conn, versions.TagAsksBucket, "builder_tag and builder_untag are not answered", logger.Printf)
|
||||
// A check's build of a head a kept tag ask names numbers its packages tagged (design 51 *Tagged versions*), and the ask
|
||||
// is marked numbered with its versions.
|
||||
b.Tags = tagsVia(tagAsksKV.KV, logger.Printf)
|
||||
b.Numbers = build.VersionNumbers{N: numberer, TagNumbered: tagNumbered(tagAsksKV.KV, logger.Printf, time.Now)}
|
||||
// An ask not answered within five minutes is narrated with what it waits on: its bound's end on the bus says when.
|
||||
boundsKV := process.AwaitBucket(ctx, js, conn, versions.TagAskBoundsBucket, "an unanswered tag ask is not narrated", logger.Printf)
|
||||
bounds := &tagBounds{asks: tagAsksKV.KV, bounds: boundsKV.KV, bound: tagAskBound, waits: floorWaits.Waiting, logf: logger.Printf, now: time.Now,
|
||||
log: func(e delivery.BuildLogEntry) { b.Log(e) }}
|
||||
// A state bound later numbers the builds looked at without it.
|
||||
versionsKV.Ready(func(jetstream.KeyValue) { b.Reconsider(ctx) })
|
||||
// The versions' hand acts (builder_publish_again, builder_floor_again) come through reasks, as builder_build_again.
|
||||
acts := versions.Acts{Numberer: numberer, Done: doneVia(b), Reconsider: b.Reconsider, Remove: removeVia(calls.Call),
|
||||
BuildAgain: func(ctx context.Context, module, fp, why, by string) (string, bool) {
|
||||
r := b.BuildAgain(ctx, module, fp, why, by, buildAgainBound)
|
||||
if r.Refused != "" {
|
||||
return r.Refused, true
|
||||
}
|
||||
if r.Failed != "" {
|
||||
return "built again, and it failed before its asks were put: " + r.Failed, false
|
||||
}
|
||||
return fmt.Sprintf("built again as revision %d", r.Revision), false
|
||||
}}
|
||||
// A candidate's preview is merged onto the trunk head the indexer previewed it onto: source.main at the candidate's base
|
||||
// revision (the gate's source state). The source's revisions are watched with their history (a direct get of one revision
|
||||
// is not what a read grants): a revision heard after the candidate that names it builds that candidate then. Where it
|
||||
// cannot be read the trunk's keys are built and the candidates wait (issue 1945).
|
||||
errs := make(chan error, 10)
|
||||
trunks := &trunkSource{ctx: ctx, js: js, heads: &sourceHeads{}, moved: func(ctx context.Context) {
|
||||
// A source revision heard is the forge's merge: it wakes a floor whose bridge_tags was silent (design 51 *The floor*).
|
||||
(&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "")
|
||||
b.TrunkMoved(ctx)
|
||||
}, errs: errs, logf: logger.Printf}
|
||||
errs := make(chan error, 6)
|
||||
trunks := &trunkSource{ctx: ctx, js: js, heads: &sourceHeads{}, moved: b.TrunkMoved, errs: errs, logf: logger.Printf}
|
||||
trunks.start()
|
||||
b.Trunk = trunks.head
|
||||
// The builder's log (ADR 0571 §1): each build's start and end in its module log `build-log`, read by the view, the
|
||||
// indexer and the assigner through their reads, and by the operator's command nox builds through the tool builder_builds_since.
|
||||
// indexer and the assigner through their reads, and by mesh-catalog through the tool builder_builds_since.
|
||||
b.Log = func(e delivery.BuildLogEntry) {
|
||||
if e.Node == "" { // a narration of no one build (a tag ask, the old form's window) is this instance's
|
||||
e.Node = cred.Node
|
||||
}
|
||||
if err := appendLog(context.WithoutCancel(ctx), js, cred, e); err != nil {
|
||||
logger.Printf("%v", err)
|
||||
}
|
||||
// A failed trunk build is the builder's finding build-failed, cleared by a later done one (DLV-B7).
|
||||
found.buildEnded(context.WithoutCancel(ctx), e)
|
||||
}
|
||||
b.Said = func(e build.AskedAgain) {
|
||||
if err := emitAskedAgain(context.WithoutCancel(ctx), js, cred, e); err != nil {
|
||||
@@ -251,219 +165,42 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// placement is settled first, so the toolchains settle through the gate already knowing it.
|
||||
gate := buildrun.NewToolchainGate([]string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, tools.Put, logger.Printf)
|
||||
watch.put = gate.Toolchain
|
||||
// What each counted node's engine reads (ADR 0618 §2, ADR 0624 §1): asked through the node-engine seat's verb as the
|
||||
// builder's own account, for every node the placement names, and kept in the state engine-reads once it is bound.
|
||||
reads := newEngineReads(calls.CallOn, gate, logger.Printf)
|
||||
// The old reference form's window over them (DLV-B9), kept in the state old-form once it is bound; builder_old_form
|
||||
// reads it and drops a retired node by hand.
|
||||
reads.ctx = context.WithoutCancel(ctx)
|
||||
reads.window = &oldForm{forms: forms.Current, findings: found, logf: logger.Printf, now: time.Now, log: func(e delivery.BuildLogEntry) { b.Log(e) },
|
||||
bounds: pubBoundsKV.KV,
|
||||
said: func(body versions.OldFormClosedBody) {
|
||||
if err := emitEvent(context.WithoutCancel(ctx), js, cred, versions.OldFormClosedEvent, "old-form-closed-"+body.At.Format(time.RFC3339Nano), body); err != nil {
|
||||
logger.Printf("saying %s: %v", versions.OldFormClosedEvent, err)
|
||||
}
|
||||
}}
|
||||
acts.DropOldForm = func(ctx context.Context, node, by, why string, at time.Time) (string, error) {
|
||||
answer, err := reads.window.drop(ctx, node, by, why, at)
|
||||
if err == nil {
|
||||
reads.stepWindow()
|
||||
}
|
||||
return answer, err
|
||||
placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, b.NodesChanged, logger.Printf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
process.AwaitBucket(ctx, js, conn, versions.OldFormBucket, "the old form's window is kept in memory only, and builder_old_form shows none", logger.Printf).
|
||||
Ready(func(kv jetstream.KeyValue) {
|
||||
reads.window.bind(context.WithoutCancel(ctx), kv)
|
||||
reads.stepWindow()
|
||||
})
|
||||
gate.OnPlaced = reads.placed
|
||||
b.Forms = reads.built
|
||||
// Until the placement's first view is answered the gate passes every report on, as before the placement is settled.
|
||||
placementErrs := make(chan error, 1)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-placement.Follower().Ready():
|
||||
}
|
||||
pe, err := buildrun.FollowPlacement(ctx, reader, gate, b.NodesChanged, logger.Printf)
|
||||
if err != nil {
|
||||
placementErrs <- err
|
||||
return
|
||||
}
|
||||
placementErrs <- <-pe
|
||||
}()
|
||||
if err := watch.start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// A build module says each time it wrote its toolchains key: its stream, and the builder's durable consumer on it, are
|
||||
// made by the controller. Where the stream is not there yet the builder says so and is started again with the build
|
||||
// module.
|
||||
// Each consumer that is not there yet is the finding family-not-heard naming its stream until it binds at a later
|
||||
// start (heard.Writer; ADR 0615 §5); one there that cannot be heard stops the builder, as before.
|
||||
notHeard := found.notHeard(ctx)
|
||||
var stopped error
|
||||
hear := func(stream, consequence string, each func(jetstream.Msg)) {
|
||||
_ = notHeard.Bind(stream, consequence, func() error {
|
||||
cons, err := cred.EventConsumer(ctx, js, stream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the consumer %s_%s on %s does not exist yet: %w", cred.Node, cred.Module, stream, err)
|
||||
}
|
||||
cc, err := process.HearEvents(cons, each, logger.Printf)
|
||||
if err != nil {
|
||||
stopped = cmp.Or(stopped, err)
|
||||
return err
|
||||
}
|
||||
go func() { <-ctx.Done(); cc.Stop() }()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
for _, module := range []string{buildrun.GoBuildModule, buildrun.NodeBuildModule} {
|
||||
hear(toolchainStreams[module], module+"'s toolchain reports are not heard until the builder starts again", func(m jetstream.Msg) { watch.Reported(m) })
|
||||
stream := toolchainStreams[module]
|
||||
cons, err := cred.EventConsumer(ctx, js, stream)
|
||||
if err != nil {
|
||||
logger.Printf("the consumer %s_%s on %s does not exist yet, so %s's reports are not heard until the builder starts again: %v", cred.Node, cred.Module, stream, module, err)
|
||||
continue
|
||||
}
|
||||
cc, err := process.HearEvents(cons, func(m jetstream.Msg) { watch.Reported(m) }, logger.Printf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cc.Stop()
|
||||
}
|
||||
// The seat mesh-artifact-sweep says artifacts-gone when it found a key's blob gone (issue 1937, ADR 0571 §2): the
|
||||
// builder's durable consumer on EVENTS, made by the grants part from the manifest's consumes. Where it is not there yet the builder says
|
||||
// so; it still asks held at its start and at every module value.
|
||||
// The same consumer carries what wakes a floor (design 51 *The floor*): the indexer's manifest-accepted and the
|
||||
// condition keeper's condition-cleared.
|
||||
floor := floorWake{b: b, waits: floorWaits, logf: logger.Printf}
|
||||
// And the four registry seats' published, refused and removed (DLV-B4), which move a numbered version.
|
||||
seats := seatHearer{n: numberer, b: b, logf: logger.Printf, pub: pubs}
|
||||
hear(eventsStream, delivery.ArtifactsGoneEvent+", what wakes a floor, the registry seats' words and manifest-reads-changed are not heard until the builder starts again", func(m jetstream.Msg) {
|
||||
if reads.heard(m, logger.Printf) || floor.heard(ctx, m) || seats.heard(ctx, m) { // reads: the node-engine seat's manifest-reads-changed (ADR 0618 §4)
|
||||
return
|
||||
}
|
||||
heardGone(ctx, b, m, logger.Printf)
|
||||
})
|
||||
if stopped != nil {
|
||||
return stopped
|
||||
}
|
||||
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "builder_first_views reads nothing and builder_first_view_again is not heard", logger.Printf))
|
||||
// The modules, once their first views and the manifests' are answered: what a module builds is read from the manifests.
|
||||
go func() { errs <- buildFromModules(ctx, b, modules, manifests, candidates) }()
|
||||
// A package's name heard in the indexer's packages looks at its module again, so a done build is numbered then.
|
||||
go func() { errs <- followPackages(ctx, packages, b, logger.Printf) }()
|
||||
// A person asks a key's build again with builder_ask_again, which writes the reasks state (issue 1945): watched, as
|
||||
// every other input. The builder asks again by itself without it.
|
||||
// A state not on the bus yet at this start is watched once it appears (issue 1790).
|
||||
process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) {
|
||||
eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, acts, kv, e, logger.Printf) }
|
||||
if rw, err := settle(ctx, kv, eachReAsk); err != nil {
|
||||
process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again")
|
||||
} else {
|
||||
go func() { errs <- follow(ctx, rw, eachReAsk) }()
|
||||
}
|
||||
})
|
||||
// A tag ask or a branch's removal by hand (builder_tag, builder_untag) is written to tag-asks and answered there
|
||||
// (design 51 *Tagged versions*); the checker's tag-wanted and the branch's end join it on their consumers (DLV-B6).
|
||||
tagAsksKV.Ready(func(kv jetstream.KeyValue) {
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
tagAsked(ctx, acts, kv, e, logger.Printf)
|
||||
if !opOf(e) {
|
||||
bounds.disarm(ctx, e.Key())
|
||||
} else if t, err := versions.DecodeTagAsk(e.Value()); err == nil {
|
||||
bounds.asked(ctx, e.Key(), t)
|
||||
}
|
||||
}
|
||||
if tw, err := settle(ctx, kv, each); err != nil {
|
||||
process.NotWatched(logger.Printf, versions.TagAsksBucket, err, "builder_tag and builder_untag are not answered until the builder starts again")
|
||||
} else {
|
||||
go func() { errs <- follow(ctx, tw, each) }()
|
||||
}
|
||||
})
|
||||
pubBoundsKV.Ready(func(kv jetstream.KeyValue) {
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
if versions.IsOldFormBound(e.Key()) {
|
||||
if e.Operation() == jetstream.KeyValuePurge {
|
||||
reads.stepWindow() // a node's seven days ended: the step raises old-form-held now
|
||||
}
|
||||
return
|
||||
}
|
||||
pubs.ended(ctx, e)
|
||||
}
|
||||
watchBounds(ctx, kv, each, found.notHeard(ctx), errs, logger.Printf)
|
||||
})
|
||||
boundsKV.Ready(func(kv jetstream.KeyValue) {
|
||||
each := func(e jetstream.KeyValueEntry) { bounds.ended(ctx, e) }
|
||||
if bw, err := settle(ctx, kv, each); err != nil {
|
||||
process.NotWatched(logger.Printf, versions.TagAskBoundsBucket, err, "an unanswered tag ask is not narrated until the builder starts again")
|
||||
} else {
|
||||
go func() { errs <- follow(ctx, bw, each) }()
|
||||
}
|
||||
})
|
||||
// A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the
|
||||
// builder alone may read the stream, so it answers there.
|
||||
process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) {
|
||||
eachRead := func(e jetstream.KeyValueEntry) { queueRead(ctx, heldOnTheBus{js}, kv, e, logger.Printf) }
|
||||
if qw, err := settle(ctx, kv, eachRead); err != nil {
|
||||
process.NotWatched(logger.Printf, build.QueuesBucket, err, "builder_seat_queue is not answered until the builder starts again")
|
||||
} else {
|
||||
go func() { errs <- follow(ctx, qw, eachRead) }()
|
||||
}
|
||||
})
|
||||
// A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): the forge bridge's merged, a pull's
|
||||
// closed or branch-deleted on the builder's durable consumer of its stream; each forge message also wakes a floor whose
|
||||
// bridge_tags was silent. The checker's tag-wanted is kept in tag-asks. A consumer not there yet is said.
|
||||
ends := &branchEnds{acts: acts, findings: found, tagAsks: tagAsksKV.KV, logf: logger.Printf, now: time.Now,
|
||||
wake: func(ctx context.Context) {
|
||||
(&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "")
|
||||
}}
|
||||
for _, c := range []struct {
|
||||
stream, what string
|
||||
hear func(jetstream.Msg)
|
||||
}{
|
||||
{forgeStream, "a branch's end", func(m jetstream.Msg) { ends.heard(ctx, m) }},
|
||||
{checkerAsksStream, "the checker's tag-wanted", func(m jetstream.Msg) {
|
||||
tagWanted(ctx, tagAsksKV.KV(), m, delivery.CheckerModule, time.Now(), logger.Printf)
|
||||
}},
|
||||
} {
|
||||
hear(c.stream, c.what+" is not heard until the builder starts again", c.hear)
|
||||
}
|
||||
if stopped != nil {
|
||||
return stopped
|
||||
}
|
||||
// The state engine-reads, new with ADR 0618 (issue 1790's kind): until it is on the bus each statement is kept in memory
|
||||
// only, and builder_engine_reads answers nothing.
|
||||
process.AwaitBucket(ctx, js, conn, engineReadsBucket, "what each node's engine reads is kept in memory only", logger.Printf).Ready(func(kv jetstream.KeyValue) {
|
||||
reads.bind(ctx, kv, errs, logger.Printf)
|
||||
})
|
||||
logger.Printf("started on %s", cred.Node)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
b.Wait()
|
||||
return nil
|
||||
case err := <-errs:
|
||||
return err
|
||||
case err := <-placementErrs:
|
||||
return err
|
||||
case err := <-watch.errs:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// The builder's own state of its first views (views.StatusState); its findings (findingsBucket) are where the condition
|
||||
// keeper raises a first view unanswered past its bound (ADR 0615 §6).
|
||||
var firstViewsBucket = delivery.BuilderModule + "_" + views.StatusState
|
||||
|
||||
// The indexer's states the builder follows (delivery.ModulesBucket, ManifestsBucket, CandidatesBucket), and the owners'
|
||||
// views families as their manifests' `streams` name them.
|
||||
const (
|
||||
indexerViews = "indexer-views"
|
||||
assignerViews = "assigner-views"
|
||||
modulesState = "modules"
|
||||
manifestsState = "manifests"
|
||||
candidatesState = "candidates"
|
||||
)
|
||||
|
||||
// buildFromModules builds from the indexer's modules and candidates once the first views of the modules, the manifests and
|
||||
// the candidates are answered (never at the start: an indexer that is down holds no other part of it), and follows them.
|
||||
func buildFromModules(ctx context.Context, b *build.Builder, modules, manifests, candidates *views.Picture) error {
|
||||
for _, p := range []*views.Picture{modules, manifests, candidates} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-p.Follower().Ready():
|
||||
switch cons, err := cred.EventConsumer(ctx, js, eventsStream); {
|
||||
case err != nil:
|
||||
logger.Printf("the consumer %s_%s on %s does not exist yet, so %s is not heard until the builder starts again: %v",
|
||||
cred.Node, cred.Module, eventsStream, delivery.ArtifactsGoneEvent, err)
|
||||
default:
|
||||
cc, err := process.HearEvents(cons, func(m jetstream.Msg) { heardGone(ctx, b, m, logger.Printf) }, logger.Printf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cc.Stop()
|
||||
}
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
@@ -488,12 +225,48 @@ func buildFromModules(ctx context.Context, b *build.Builder, modules, manifests,
|
||||
b.ForgetCandidate(e.Key())
|
||||
}
|
||||
}
|
||||
cw, err := settle(ctx, candidates, eachCandidate)
|
||||
if err != nil {
|
||||
if candidates != nil {
|
||||
cw, err := settle(ctx, candidates, eachCandidate)
|
||||
if err != nil {
|
||||
process.NotWatched(logger.Printf, delivery.CandidatesBucket, err, "no candidate's fingerprint is built until the builder starts again")
|
||||
} else {
|
||||
go func() { errs <- follow(ctx, cw, eachCandidate) }()
|
||||
}
|
||||
}
|
||||
// A person asks a key's build again with builder_ask_again, which writes the reasks state (issue 1945): watched, as
|
||||
// every other input. The builder asks again by itself without it.
|
||||
// A state not on the bus yet at this start is watched once it appears (issue 1790).
|
||||
process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) {
|
||||
eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, kv, e, logger.Printf) }
|
||||
if rw, err := settle(ctx, kv, eachReAsk); err != nil {
|
||||
process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again")
|
||||
} else {
|
||||
go func() { errs <- follow(ctx, rw, eachReAsk) }()
|
||||
}
|
||||
})
|
||||
// A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the
|
||||
// builder alone may read the stream, so it answers there.
|
||||
process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) {
|
||||
eachRead := func(e jetstream.KeyValueEntry) { queueRead(ctx, heldOnTheBus{js}, kv, e, logger.Printf) }
|
||||
if qw, err := settle(ctx, kv, eachRead); err != nil {
|
||||
process.NotWatched(logger.Printf, build.QueuesBucket, err, "builder_seat_queue is not answered until the builder starts again")
|
||||
} else {
|
||||
go func() { errs <- follow(ctx, qw, eachRead) }()
|
||||
}
|
||||
})
|
||||
logger.Printf("started on %s", cred.Node)
|
||||
go func() { errs <- follow(ctx, w, each) }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
b.Wait()
|
||||
return nil
|
||||
case err := <-errs:
|
||||
return err
|
||||
case err := <-placementErrs:
|
||||
return err
|
||||
case err := <-watch.errs:
|
||||
return err
|
||||
}
|
||||
go func() { _ = follow(ctx, cw, eachCandidate) }()
|
||||
return follow(ctx, w, each)
|
||||
}
|
||||
|
||||
// sourceHeads are the trunk heads of each revision of the gate's source.main the watch delivered, the newest
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// oldForm keeps the old reference form's window (design 51 *Every build keeps the old reference form*, DLV-B9) in the
|
||||
// state old-form: stepped on every change of what the counted nodes state, of the placement, on a hand drop and at each
|
||||
// build key written, never on a timer. A node holding it seven days is the finding old-form-held; its close is said once
|
||||
// as version.old-form-closed and in the builder's log.
|
||||
type oldForm struct {
|
||||
forms forms.ArtifactForms
|
||||
findings *findings
|
||||
// said emits version.old-form-closed.
|
||||
said func(versions.OldFormClosedBody)
|
||||
// log writes the close into the builder's build log (build.said); nil writes none.
|
||||
log func(delivery.BuildLogEntry)
|
||||
logf func(string, ...any)
|
||||
now func() time.Time
|
||||
|
||||
// bounds is the builder's state bounds: each node holding the window has a key there whose time to live ends at its
|
||||
// seven days, so old-form-held is raised then, not at the next change (review round 1); nil keeps none.
|
||||
bounds func() jetstream.KeyValue
|
||||
|
||||
mu sync.Mutex
|
||||
w versions.OldFormWindow
|
||||
kv jetstream.KeyValue
|
||||
// overdue are the nodes the finding is raised for now.
|
||||
overdue []string
|
||||
}
|
||||
|
||||
// bind takes the kept window in, so a restarted builder keeps each node's since and the nodes dropped by hand, and keeps
|
||||
// every later step in kv.
|
||||
func (o *oldForm) bind(ctx context.Context, kv jetstream.KeyValue) {
|
||||
e, err := kv.Get(ctx, versions.OldFormKey)
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
switch {
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound):
|
||||
case err != nil:
|
||||
o.logf("%s.%s cannot be read, so the window starts again: %v", versions.OldFormState, versions.OldFormKey, err)
|
||||
default:
|
||||
w, err := versions.DecodeOldFormWindow(e.Value())
|
||||
if err != nil {
|
||||
o.logf("%v; the window starts again", err)
|
||||
break
|
||||
}
|
||||
// Taken in under what was stepped before the state was bound: the kept since and drops are the older truth.
|
||||
for n, s := range o.w.Since {
|
||||
if _, ok := w.Since[n]; !ok && w.Open && slices.Contains(w.Holding, n) {
|
||||
w.Since[n] = s
|
||||
}
|
||||
}
|
||||
for _, d := range o.w.Dropped {
|
||||
w, _ = w.Drop(d.Node, d.By, d.Why, d.At)
|
||||
}
|
||||
o.w = w
|
||||
}
|
||||
o.kv = kv
|
||||
o.keep(ctx)
|
||||
}
|
||||
|
||||
// dropped is the nodes dropped by hand.
|
||||
func (o *oldForm) dropped() map[string]bool {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.w.DroppedSet()
|
||||
}
|
||||
|
||||
// step moves the window to what counted states.
|
||||
// named is every node the read named (the placement's nodes before any is dropped), counted the statements of those
|
||||
// still counted: a read naming no node judges nothing (delivery.StepWindow), so it never closes the window.
|
||||
func (o *oldForm) step(ctx context.Context, named []string, counted map[string][]string) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
before := o.w
|
||||
next, closed, overdue := o.w.Step(named, versions.Holding(o.forms, counted, o.w.DroppedSet()), o.now())
|
||||
o.w = next
|
||||
if !slices.Equal(before.Holding, next.Holding) || before.Open != next.Open || closed {
|
||||
o.keep(ctx)
|
||||
}
|
||||
for _, n := range o.overdue {
|
||||
if !slices.Contains(overdue, n) {
|
||||
o.findings.clear(ctx, versions.OldFormHeld, health.ScopeMachine, n)
|
||||
}
|
||||
}
|
||||
for _, n := range overdue {
|
||||
o.findings.raise(ctx, finding{kind: versions.OldFormHeld, scope: health.ScopeMachine, id: n,
|
||||
summary: fmt.Sprintf("the node-engine of %s has read only an older reference form since %s, so every build key still carries it",
|
||||
n, next.Since[n].Format(time.RFC3339)),
|
||||
headline: "A node holds the old reference form",
|
||||
explanation: "The builder writes an older reference form into every build key while a counted node's engine reads none of the newest (ADR 0624 §1); this node has held it seven days.",
|
||||
resolved: "The node's engine states the newest form, the node leaves the placement, or it is dropped as retired.",
|
||||
needs: "builder_old_form shows the window; a retired node is dropped there by hand; builder_engine_reads asks the node again."})
|
||||
}
|
||||
o.overdue = overdue
|
||||
o.keepBounds(ctx, before, next, overdue)
|
||||
if closed {
|
||||
body := next.Closing()
|
||||
line := fmt.Sprintf("the old reference form's window closed at %s: no counted node-engine reads only an older form (last held by %s; dropped by hand: %d); new build keys leave it out",
|
||||
body.At.Format(time.RFC3339), strings.Join(body.LastHolding, ", "), len(body.Dropped))
|
||||
o.logf("%s", line)
|
||||
// In the build log too (design 51 *The node-engine's own build*), under the builder's own key, the window's key
|
||||
// standing for the fingerprint: the close is no one build's.
|
||||
if o.log != nil {
|
||||
o.log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: delivery.BuilderModule, FP: versions.OldFormKey,
|
||||
At: body.At.UTC(), Line: line, Why: versions.SaidOldFormClosed})
|
||||
}
|
||||
if o.said != nil {
|
||||
o.said(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keepBounds writes a bound for each node holding the window and not yet overdue, ending at its seven days, and deletes
|
||||
// the bound of each node that no longer holds it. A standing bound keeps its time (a Create). Called with mu held.
|
||||
func (o *oldForm) keepBounds(ctx context.Context, before, next versions.OldFormWindow, overdue []string) {
|
||||
if o.bounds == nil {
|
||||
return
|
||||
}
|
||||
kv := o.bounds()
|
||||
if kv == nil {
|
||||
return
|
||||
}
|
||||
for _, n := range before.Holding {
|
||||
if !slices.Contains(next.Holding, n) {
|
||||
if err := kv.Delete(ctx, versions.OldFormBoundKey(n)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
o.logf("the old form's bound of %s is not deleted: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range next.Holding {
|
||||
left := next.Since[n].Add(versions.FindingAfter).Sub(o.now())
|
||||
if slices.Contains(overdue, n) || left <= 0 {
|
||||
continue
|
||||
}
|
||||
if left < time.Second {
|
||||
left = time.Second // the bus's least time to live
|
||||
}
|
||||
if _, err := kv.Create(ctx, versions.OldFormBoundKey(n), versions.EncodeBound("the seven days "+n+" holds the old form's window", o.now().Add(left)), jetstream.KeyTTL(left)); err != nil && !errors.Is(err, jetstream.ErrKeyExists) {
|
||||
o.logf("the old form's bound of %s is not written, so its seven days are seen at the next change only: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drop is builder_old_form's drop: the node leaves the count, the window is stepped by the caller.
|
||||
func (o *oldForm) drop(ctx context.Context, node, by, why string, at time.Time) (string, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
w, err := o.w.Drop(node, by, why, at)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
o.w = w
|
||||
o.keep(ctx)
|
||||
return node + " dropped from the old form's count by hand; the window is stepped now", nil
|
||||
}
|
||||
|
||||
// keep writes the window, once the state is bound; until then it is kept here only. Called with mu held.
|
||||
func (o *oldForm) keep(ctx context.Context) {
|
||||
if o.kv == nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(o.w)
|
||||
if err == nil {
|
||||
_, err = o.kv.Put(ctx, versions.OldFormKey, raw)
|
||||
}
|
||||
if err != nil {
|
||||
o.logf("%s.%s cannot be written: %v", versions.OldFormState, versions.OldFormKey, err)
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/heard"
|
||||
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// publishes raises and clears the builder's findings of a publish that ended badly (versions/publish.go): a holder's
|
||||
// refused, a version unanswered past its bound, kept as a key of the state bounds with its own time to live, and a seat's
|
||||
// outcome that does not read.
|
||||
type publishes struct {
|
||||
bounds func() jetstream.KeyValue
|
||||
versions versions.Versions
|
||||
findings *findings
|
||||
bound time.Duration
|
||||
logf func(string, ...any)
|
||||
}
|
||||
|
||||
// arm writes the version's bound when none stands, so a standing bound keeps its time. A bound that cannot be written
|
||||
// would never end, so the version is publish-unanswered at once (review round 2): the finding says why.
|
||||
func (p *publishes) arm(ctx context.Context, e versions.Entry) {
|
||||
kv := p.bounds()
|
||||
var err error
|
||||
if kv == nil {
|
||||
err = fmt.Errorf("%s is not on the bus yet", versions.BoundsBucket)
|
||||
} else if _, err = kv.Create(ctx, versions.PublishBoundKey(e.Name, e.Version), versions.EncodeBound("the publish of "+e.Name+"@"+e.Version+" to "+e.Seat, time.Now().Add(p.bound)), jetstream.KeyTTL(p.bound)); errors.Is(err, jetstream.ErrKeyExists) {
|
||||
err = nil
|
||||
}
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
p.logf("the publish bound of %s@%s is not written, so it is raised unanswered now: %v", e.Name, e.Version, err)
|
||||
p.unanswered(ctx, e, fmt.Sprintf("its bound could not be written (%v), so the builder cannot wait on the holder's word", err))
|
||||
}
|
||||
|
||||
// unanswered raises publish-unanswered about e's name, saying why.
|
||||
func (p *publishes) unanswered(ctx context.Context, v versions.Entry, why string) {
|
||||
p.findings.raise(ctx, finding{kind: versions.PublishUnanswered, scope: health.ScopeModule, id: v.Name,
|
||||
summary: fmt.Sprintf("%s said neither published nor refused of %s@%s: %s", v.Seat, v.Name, v.Version, why),
|
||||
headline: "A registry seat did not answer a publish",
|
||||
explanation: "The builder built and numbered " + v.Name + " " + v.Version + " and asked " + v.Seat + " to publish it; no word of its holder is known, so the build says done while the version may not be in the registry.",
|
||||
resolved: "That version is published, or a later version of " + v.Name + " is.",
|
||||
needs: "a look at the holder of " + v.Seat + " (its health and log); builder_publish_again asks the publish again."})
|
||||
}
|
||||
|
||||
// heard is told each holder's word the numberer took in: published clears the name's findings, refused raises
|
||||
// publish-refused with the holder's reason, and either ends the version's bound.
|
||||
func (p *publishes) heard(ctx context.Context, seat, event string, o versions.Outcome) {
|
||||
if o.Version != "" {
|
||||
if kv := p.bounds(); kv != nil {
|
||||
if err := kv.Delete(ctx, versions.PublishBoundKey(o.Name, o.Version)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
p.logf("the publish bound of %s@%s is not ended: %v", o.Name, o.Version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch event {
|
||||
case versions.HeardPublished:
|
||||
p.findings.clear(ctx, versions.PublishRefused, health.ScopeModule, o.Name)
|
||||
p.findings.clear(ctx, versions.PublishUnanswered, health.ScopeModule, o.Name)
|
||||
case versions.HeardRefused:
|
||||
reason := o.Reason
|
||||
if reason == "" {
|
||||
reason = "it gave no reason"
|
||||
}
|
||||
p.findings.raise(ctx, finding{kind: versions.PublishRefused, scope: health.ScopeModule, id: o.Name,
|
||||
summary: fmt.Sprintf("%s refused to publish %s@%s (digest %s): %s", seat, o.Name, o.Version, o.Digest, reason),
|
||||
headline: "A registry seat refused a publish",
|
||||
explanation: "The builder built and numbered " + o.Name + " " + o.Version + ", and the holder of " + seat + " refused to store it, so that version is not in the registry.",
|
||||
resolved: "That version is published, or a later version of " + o.Name + " is.",
|
||||
needs: "builder_versions shows the version and why; builder_publish_again publishes it again once the cause is mended."})
|
||||
}
|
||||
}
|
||||
|
||||
// unread raises seat-outcome-unread about seat: what it said on subject is no outcome. read clears it.
|
||||
func (p *publishes) unread(ctx context.Context, seat, subject string, why error) {
|
||||
p.findings.raise(ctx, finding{kind: versions.SeatOutcomeUnread, scope: health.ScopeModule, id: seat,
|
||||
summary: fmt.Sprintf("%s said on %s what is no outcome of a publish: %v", seat, subject, why),
|
||||
headline: "A registry seat said what the builder cannot read",
|
||||
explanation: "The builder hears each registry seat's published, refused and removed to move a numbered version; this one does not read, so the version it was about does not move.",
|
||||
resolved: "The next outcome of " + seat + " reads.",
|
||||
needs: "a look at the seat's holder: the outcome's body is its contract's (mesh-contracts)."})
|
||||
}
|
||||
|
||||
func (p *publishes) read(ctx context.Context, seat string) {
|
||||
p.findings.clear(ctx, versions.SeatOutcomeUnread, health.ScopeModule, seat)
|
||||
}
|
||||
|
||||
// ended is told each change of the state bounds: a bound whose time ended (the bus's marker) on a version still numbered
|
||||
// raises publish-unanswered.
|
||||
func (p *publishes) ended(ctx context.Context, e jetstream.KeyValueEntry) {
|
||||
key, ok := versions.PublishBoundOf(e.Key())
|
||||
if e.Operation() != jetstream.KeyValuePurge || !ok {
|
||||
return
|
||||
}
|
||||
raw, _, found, err := p.versions.Store.Get(ctx, key)
|
||||
if err != nil || !found {
|
||||
return
|
||||
}
|
||||
v, err := versions.Decode(raw)
|
||||
if err != nil || v.State != versions.Numbered {
|
||||
return
|
||||
}
|
||||
p.unanswered(ctx, v, fmt.Sprintf("nothing within %s of its build", p.bound))
|
||||
}
|
||||
|
||||
// watchBounds watches the state bounds, handing each change to each: a watch that cannot start loses every bound's end,
|
||||
// so it is the finding family-not-heard naming the state until a later start watches it (review round 2).
|
||||
func watchBounds(ctx context.Context, kv jetstream.KeyValue, each func(jetstream.KeyValueEntry), w heard.Writer, errs chan<- error, logf func(string, ...any)) {
|
||||
const consequence = "a publish no holder answers, and a node holding the old form seven days, are not raised until the builder starts again"
|
||||
pw, err := settle(ctx, kv, each)
|
||||
if err != nil {
|
||||
process.NotWatched(logf, versions.BoundsBucket, err, consequence)
|
||||
w.NotHeard(versions.BoundsState, consequence, err)
|
||||
return
|
||||
}
|
||||
w.Heard(versions.BoundsState)
|
||||
go func() { errs <- follow(ctx, pw, each) }()
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
)
|
||||
|
||||
// EngineReadsState is the builder's state of what each counted node's engine states it reads (novox/hq ADR 0618 §2, ADR
|
||||
// 0624 §1): one key `<node>`, written from the node-engine seat's verb manifest-reads at the builder's start, on the
|
||||
// seat's event manifest-reads-changed for that node, and when a person asks again (builder_engine_reads); a node that
|
||||
// is away keeps its last statement, and its key is removed when the placement no longer names the node. A counted node
|
||||
// with no key reads as stating the baseline: its first forms. No node's record is read in its place.
|
||||
const EngineReadsState = "engine-reads"
|
||||
|
||||
// engineReadsBucket is that state's bucket.
|
||||
const engineReadsBucket = delivery.BuilderModule + "_" + EngineReadsState
|
||||
|
||||
// engineReads keeps the statements and the counted nodes for the builder's forms (build.Builder.Forms).
|
||||
type engineReads struct {
|
||||
keeper *enginereads.Keeper
|
||||
gate *buildrun.ToolchainGate
|
||||
mu sync.Mutex
|
||||
counted []string
|
||||
// window is the old reference form's window over these statements (DLV-B9); nil keeps none.
|
||||
window *oldForm
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// newEngineReads asks through call (process.Conn.CallOn) and keeps each answer in the state once it is bound.
|
||||
func newEngineReads(call enginereads.Caller, gate *buildrun.ToolchainGate, logf func(string, ...any)) *engineReads {
|
||||
r := &engineReads{gate: gate, keeper: &enginereads.Keeper{Call: call, Logf: logf}}
|
||||
r.keeper.Changed = r.stepWindow
|
||||
return r
|
||||
}
|
||||
|
||||
// stepWindow moves the old form's window to what the counted nodes state now (design 51 *Every build keeps the old
|
||||
// reference form*): on a statement's change, the placement's and a hand drop, and at each build key written.
|
||||
func (r *engineReads) stepWindow() {
|
||||
if r.window == nil {
|
||||
return
|
||||
}
|
||||
ctx := r.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
_, counted := r.forms()
|
||||
r.window.step(ctx, r.named(), counted)
|
||||
}
|
||||
|
||||
// named are the nodes the placement names, none dropped: what the window's step judges a read by.
|
||||
func (r *engineReads) named() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return slices.Clone(r.counted)
|
||||
}
|
||||
|
||||
// bind keeps the statements in kv and hears it: every node's last statement at the start, a person's ask again and drop
|
||||
// later. Bound once the state is on the bus (process.AwaitBucket).
|
||||
func (r *engineReads) bind(ctx context.Context, kv jetstream.KeyValue, errs chan<- error, logf func(string, ...any)) {
|
||||
r.keeper.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err }
|
||||
r.keeper.Delete = func(node string) error { return kv.Delete(ctx, node) }
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
r.keeper.Heard(e.Key(), e.Value(), e.Operation() != jetstream.KeyValuePut)
|
||||
}
|
||||
w, err := settle(ctx, kv, each)
|
||||
if err != nil {
|
||||
process.NotWatched(logf, engineReadsBucket, err, "builder_engine_reads is not answered until the builder starts again")
|
||||
return
|
||||
}
|
||||
go func() { errs <- follow(ctx, w, each) }()
|
||||
// Every counted node asked again, so what was asked before the state was bound is kept in it too.
|
||||
r.mu.Lock()
|
||||
nodes := slices.Clone(r.counted)
|
||||
r.mu.Unlock()
|
||||
for _, node := range nodes {
|
||||
r.keeper.AskNode(node, "the state engine-reads is bound")
|
||||
}
|
||||
}
|
||||
|
||||
// placed takes the placement in: a node it names newly is asked, one it no longer names is forgotten, key and all. The
|
||||
// first call asks every counted node (the builder's start).
|
||||
func (r *engineReads) placed() {
|
||||
nodes := r.gate.Nodes()
|
||||
r.mu.Lock()
|
||||
before, first := r.counted, r.counted == nil
|
||||
r.counted = nodes
|
||||
r.mu.Unlock()
|
||||
for _, node := range nodes {
|
||||
if first || !slices.Contains(before, node) {
|
||||
r.keeper.AskNode(node, "counted: the placement names it")
|
||||
}
|
||||
}
|
||||
for _, node := range before {
|
||||
if !slices.Contains(nodes, node) {
|
||||
r.keeper.Forget(node)
|
||||
}
|
||||
}
|
||||
r.stepWindow()
|
||||
}
|
||||
|
||||
// heard takes an event of the builder's consumer on EVENTS: manifest-reads-changed asks its node again, and answers true.
|
||||
func (r *engineReads) heard(m jetstream.Msg, logf func(string, ...any)) bool {
|
||||
node, ours, err := enginereads.NodeOf(m.Subject(), m.Data())
|
||||
if !ours {
|
||||
return false
|
||||
}
|
||||
if ackErr := m.Ack(); ackErr != nil {
|
||||
logf("acknowledging %s: %v", m.Subject(), ackErr)
|
||||
}
|
||||
if err != nil {
|
||||
logf("%s holds what is not %s, and %s is asked again all the same: %v", m.Subject(), enginereads.Event, node, err)
|
||||
}
|
||||
r.keeper.AskNode(node, enginereads.Event)
|
||||
return true
|
||||
}
|
||||
|
||||
// forms is the counted nodes' statements, and those of the nodes whose engine answered; a retired node dropped by hand
|
||||
// (builder_old_form) counts no more.
|
||||
func (r *engineReads) forms() (present, counted map[string][]string) {
|
||||
r.mu.Lock()
|
||||
nodes := slices.Clone(r.counted)
|
||||
r.mu.Unlock()
|
||||
if r.window != nil {
|
||||
dropped := r.window.dropped()
|
||||
nodes = slices.DeleteFunc(nodes, func(n string) bool { return dropped[n] })
|
||||
}
|
||||
return r.keeper.Present(nodes), r.keeper.Reads(nodes)
|
||||
}
|
||||
|
||||
// built is build.Builder.Forms: forms, and the window stepped with them, so a node's seven days are seen at the next
|
||||
// build key at the latest.
|
||||
func (r *engineReads) built() (present, counted map[string][]string) {
|
||||
present, counted = r.forms()
|
||||
if r.window != nil {
|
||||
ctx := r.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
r.window.step(ctx, r.named(), counted)
|
||||
}
|
||||
return present, counted
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// askAgainer is the part of the builder a reasks value asks through.
|
||||
@@ -32,7 +31,7 @@ const buildAgainBound = 15 * time.Second
|
||||
// reAsked answers one value of the reasks state (the tool builder_ask_again, issue 1945): a value not yet answered asks the
|
||||
// key's waiting lanes again and is written back, by compare-and-set at its revision, with what the builder did. A value
|
||||
// answered already (this builder's own write) or deleted is left.
|
||||
func reAsked(ctx context.Context, b askAgainer, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
|
||||
func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
|
||||
if e.Operation() != jetstream.KeyValuePut {
|
||||
return
|
||||
}
|
||||
@@ -53,11 +52,6 @@ func reAsked(ctx context.Context, b askAgainer, acts versions.Acts, kv jetstream
|
||||
announcedAgain(ctx, b, kv, e, r, logf)
|
||||
return
|
||||
}
|
||||
if r.Mode == build.ModePublishAgain || r.Mode == build.ModeFloorAgain || r.Mode == build.ModeOldFormDrop {
|
||||
// A version's hand act (design 51 *Tools of the builder*) may build again, so it is answered beside the watch.
|
||||
go versionActed(ctx, acts, kv, e, r, logf)
|
||||
return
|
||||
}
|
||||
asked := b.AskAgain(e.Key(), r.Why)
|
||||
if len(asked) == 0 {
|
||||
r.Answer = "no ask of this key waits in this builder now (it is not building, or its build has not reached its ask yet): nothing was asked; builder_stuck shows where it stands"
|
||||
@@ -78,7 +72,21 @@ func reAsked(ctx context.Context, b askAgainer, acts versions.Acts, kv jetstream
|
||||
// emitAskedAgain says the builder's event build.asked-again (issue 1945): mesh.mod.<module>.event.build.asked-again,
|
||||
// with the SDK's envelope headers and the new ask's id as the bus's dedup id.
|
||||
func emitAskedAgain(ctx context.Context, js jetstream.JetStream, cred process.Account, e build.AskedAgain) error {
|
||||
if err := emitEvent(ctx, js, cred, build.AskedAgainEvent, "asked-again-"+e.New, e); err != nil {
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := nats.NewMsg(cred.EventSubject(build.AskedAgainEvent))
|
||||
id := "asked-again-" + e.New
|
||||
msg.Header.Set("x-event-id", id)
|
||||
msg.Header.Set("x-source", cred.Module)
|
||||
msg.Header.Set("x-node", cred.Node)
|
||||
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
|
||||
msg.Header.Set("content-type", "application/json")
|
||||
msg.Data = data
|
||||
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := js.PublishMsg(pub, msg, jetstream.WithMsgID(id)); err != nil {
|
||||
return fmt.Errorf("saying %s for %s: %w", build.AskedAgainEvent, e.Key, err)
|
||||
}
|
||||
return nil
|
||||
@@ -155,33 +163,3 @@ func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// versionActed answers a reasks value of builder_publish_again, builder_floor_again or builder_old_form's drop, a hand act said in the log.
|
||||
func versionActed(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, r build.ReAsk, logf func(string, ...any)) {
|
||||
var err error
|
||||
if r.Mode == build.ModePublishAgain {
|
||||
var ag versions.Again
|
||||
ag, r.Answer, err = acts.PublishAgain(ctx, r.Name, r.FP, r.Why, r.By, r.At)
|
||||
r.Do, r.Version = ag.Do, ag.Entry.Version
|
||||
} else if r.Mode == build.ModeOldFormDrop {
|
||||
if acts.DropOldForm == nil {
|
||||
err = errors.New("this builder keeps no old-form window")
|
||||
} else {
|
||||
r.Answer, err = acts.DropOldForm(ctx, r.Name, r.By, r.Why, r.At)
|
||||
}
|
||||
} else {
|
||||
r.Answer, err = acts.FloorAgain(ctx, r.Name)
|
||||
}
|
||||
if err != nil {
|
||||
r.Answer, r.Refused = err.Error(), true
|
||||
}
|
||||
r.Answered = time.Now().UTC()
|
||||
logf("%s: %s of %s by %s (%s): %s", e.Key(), r.Mode, r.Name, r.By, r.Why, r.Answer)
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
|
||||
logf("%s.%s: the answer could not be written: %v", build.ReAsksState, e.Key(), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,17 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/heard"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// 1945 (a part exits at start for a bucket it works without): a bus that refuses every toolchains bucket leaves the
|
||||
@@ -35,9 +21,6 @@ import (
|
||||
// its grant. The gate over the seat's placement, and its refusal, are mesh-sdk's (buildrun.ToolchainGate,
|
||||
// issue 774).
|
||||
func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) {
|
||||
t.Run("a floor's wake is heard on the events consumer (design 51 The floor)", aFloorsWakeIsHeard)
|
||||
t.Run("a failed trunk build is a finding until a later done one (DLV-B7)", aFailedTrunkBuildIsAFinding)
|
||||
t.Run("a branch's end is acknowledged once its tagged versions are removed (design 51 Tagged versions)", aBranchsEndIsHeard)
|
||||
set := &buildrun.ToolchainSet{}
|
||||
var logs bytes.Buffer
|
||||
logger := log.New(&logs, "", 0)
|
||||
@@ -62,100 +45,6 @@ func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) {
|
||||
if src.opened {
|
||||
t.Error("a refused source counts as opened, so it is never opened again")
|
||||
}
|
||||
t.Run("ADR 0624: the counted nodes' engine reads", theCountedNodesEngineReads)
|
||||
}
|
||||
|
||||
// theCountedNodesEngineReads: the builder asks the node-engine seat's verb of every node the placement names; a node away
|
||||
// and never asked has no statement and reads as the baseline's first forms, with no node record read in its place; an
|
||||
// away node keeps its last statement; a node the placement no longer names leaves the count, its key removed; and
|
||||
// builder_engine_reads drops one node's key.
|
||||
func theCountedNodesEngineReads(t *testing.T) {
|
||||
gate := buildrun.NewToolchainGate(nil, func(string, string, []byte, bool) bool { return false }, t.Logf)
|
||||
place := func(nodes ...string) {
|
||||
for _, node := range []string{"n1", "n2"} {
|
||||
pl := delivery.Placement{Node: node}
|
||||
if slices.Contains(nodes, node) {
|
||||
pl.Modules = []delivery.PlacedModule{{Module: "web"}}
|
||||
}
|
||||
values, err := delivery.PlacementValues(pl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, raw := range values {
|
||||
gate.Placement(key, raw, false, time.Now())
|
||||
}
|
||||
if !slices.Contains(nodes, node) {
|
||||
key, _ := delivery.PlacementModuleKey(node, "web")
|
||||
gate.Placement(key, nil, true, time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
away := map[string]bool{"n2": true}
|
||||
asked := map[string]int{}
|
||||
r := newEngineReads(func(node, key string, _ any) (json.RawMessage, error) {
|
||||
asked[node]++
|
||||
if away[node] {
|
||||
return nil, nats.ErrNoResponders
|
||||
}
|
||||
return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)})
|
||||
}, gate, t.Logf)
|
||||
r.keeper.Sync = true
|
||||
kept := map[string][]byte{}
|
||||
r.keeper.Put = func(node string, v []byte) error { kept[node] = v; return nil }
|
||||
r.keeper.Delete = func(node string) error { delete(kept, node); return nil }
|
||||
place("n1", "n2")
|
||||
r.placed()
|
||||
present, counted := r.forms()
|
||||
if !slices.Equal(present["n1"], []string{"form:new"}) || len(present) != 1 || len(counted) != 2 || counted["n2"] != nil {
|
||||
t.Errorf("away and never asked: present %v, counted %v", present, counted)
|
||||
}
|
||||
if s := r.keeper.Statements()["n2"]; s.From != enginereads.FromNone || s.Answered || asked["n2"] != 1 {
|
||||
t.Errorf("n2, away and never asked, is %+v (asked %d)", s, asked["n2"])
|
||||
}
|
||||
// n2 back, stating {word:module}, then away again: it keeps that statement.
|
||||
away["n2"] = false
|
||||
r.keeper.Heard("n2", mustStatement(t, enginereads.Statement{Node: "n2", From: enginereads.FromVerb, Answered: true,
|
||||
AskAgain: &enginereads.Again{By: "n1.ada", Why: "back"}}), false)
|
||||
away["n2"] = true
|
||||
r.keeper.AskNode("n2", "its event")
|
||||
if _, counted = r.forms(); !slices.Equal(counted["n2"], []string{"form:new"}) || r.keeper.Statements()["n2"].Answered {
|
||||
t.Errorf("away again, n2 reads %v (%+v)", counted["n2"], r.keeper.Statements()["n2"])
|
||||
}
|
||||
// n2 retired: it leaves the placement, and its key goes.
|
||||
place("n1")
|
||||
r.placed()
|
||||
if _, counted = r.forms(); len(counted) != 1 || kept["n2"] != nil {
|
||||
t.Errorf("retired n2: counted %v, its key %s", counted, kept["n2"])
|
||||
}
|
||||
// builder_engine_reads drops one key.
|
||||
st := &memReads{v: map[string][]byte{"n1": kept["n1"]}}
|
||||
tool := enginereads.Tool("builder_engine_reads", "the builder", st, true, time.Now)
|
||||
if _, err := tool.Run(map[string]any{"node": "n1", "drop": "true", "why": "a test"}); err != nil || st.v["n1"] != nil {
|
||||
t.Errorf("drop: %v, the key %s", err, st.v["n1"])
|
||||
}
|
||||
}
|
||||
|
||||
func mustStatement(t *testing.T, s enginereads.Statement) []byte {
|
||||
t.Helper()
|
||||
raw, err := enginereads.Encode(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// memReads is the builder's state of engine reads in memory, as the tool reads it.
|
||||
type memReads struct{ v map[string][]byte }
|
||||
|
||||
func (m *memReads) Keys() ([]string, error) { return slices.Collect(maps.Keys(m.v)), nil }
|
||||
func (m *memReads) Get(key string) ([]byte, uint64, bool, error) {
|
||||
v, ok := m.v[key]
|
||||
return v, 1, ok, nil
|
||||
}
|
||||
func (m *memReads) Put(key string, value []byte) (uint64, error) { m.v[key] = value; return 2, nil }
|
||||
func (m *memReads) Delete(key string) error { delete(m.v, key); return nil }
|
||||
func (m *memReads) Await(string, uint64, func([]byte) bool, time.Duration) ([]byte, error) {
|
||||
return nil, errors.New("no part answers here")
|
||||
}
|
||||
|
||||
// refusingBus is a bus that refuses: every open (refuseOpen), or every watch of a bucket it opens, with err.
|
||||
@@ -182,347 +71,3 @@ func (r refusedWatch) WatchAll(context.Context, ...jetstream.WatchOpt) (jetstrea
|
||||
func (r refusedWatch) Watch(context.Context, string, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
// wakeMsg is a message of the builder's events consumer: its subject and body, acknowledged.
|
||||
type wakeMsg struct {
|
||||
jetstream.Msg
|
||||
subject string
|
||||
data []byte
|
||||
acked bool
|
||||
naked bool
|
||||
delayed time.Duration
|
||||
}
|
||||
|
||||
func (m *wakeMsg) Subject() string { return m.subject }
|
||||
func (m *wakeMsg) Data() []byte { return m.data }
|
||||
func (m *wakeMsg) Ack() error { m.acked = true; return nil }
|
||||
func (m *wakeMsg) Nak() error { m.naked = true; return nil }
|
||||
func (m *wakeMsg) NakWithDelay(d time.Duration) error {
|
||||
m.delayed = d
|
||||
return nil
|
||||
}
|
||||
func (m *wakeMsg) Metadata() (*jetstream.MsgMetadata, error) {
|
||||
return nil, errors.New("no metadata")
|
||||
}
|
||||
func (m *wakeMsg) Headers() nats.Header {
|
||||
return nil
|
||||
}
|
||||
|
||||
func aFloorsWakeIsHeard(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
waits := &versions.FloorWaits{}
|
||||
waits.Wait("m/lib", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceRequirements}}, nil, time.Unix(0, 0))
|
||||
waits.Wait("m/img", versions.SeatImages, "", versions.Floor{Silent: []string{versions.SourceTags}}, nil, time.Unix(0, 0))
|
||||
f := &floorWake{b: &build.Builder{}, waits: waits, logf: log.New(&logs, "", 0).Printf}
|
||||
accepted := &wakeMsg{subject: manifestAcceptedSubject}
|
||||
if !f.heard(context.Background(), accepted) || !accepted.acked || !strings.Contains(logs.String(), "the floor of m/lib is asked again: manifest-accepted") {
|
||||
t.Fatalf("manifest-accepted: acked %v, log %q", accepted.acked, logs.String())
|
||||
}
|
||||
logs.Reset()
|
||||
cleared := &wakeMsg{subject: conditionClearedSubject, data: []byte(`{"subject":{"scope":"probe","id":"ace/mesh-delivery-forge-bridge"}}`)}
|
||||
if !f.heard(context.Background(), cleared) || !strings.Contains(logs.String(), "the floor of m/img is asked again: condition-cleared") {
|
||||
t.Fatalf("condition-cleared: log %q", logs.String())
|
||||
}
|
||||
if f.heard(context.Background(), &wakeMsg{subject: delivery.ArtifactsGoneSubject}) {
|
||||
t.Fatal("artifacts-gone was taken as a floor's wake")
|
||||
}
|
||||
// DLV-B4: a registry seat's outcome on the same consumer moves a version; a published wakes a name whose seat was
|
||||
// silent; one the state cannot take is left for the bus to give again.
|
||||
logs.Reset()
|
||||
waits.Wait("m/pkg", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceSeat}}, nil, time.Unix(0, 0))
|
||||
h := &seatHearer{n: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}, Waits: waits}, b: &build.Builder{}, logf: log.New(&logs, "", 0).Printf}
|
||||
byDigest := &wakeMsg{subject: "mesh.seat.go-package-registry.event.published", data: []byte(`{"name":"m/other","digest":"sha256:aa","kind":"package","holder":"gitea"}`)}
|
||||
if !h.heard(context.Background(), byDigest) || !byDigest.acked || !strings.Contains(logs.String(), "the floor of m/pkg is asked again: go-package-registry said published") {
|
||||
t.Fatalf("published: acked %v, log %q", byDigest.acked, logs.String())
|
||||
}
|
||||
refused := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.refused", data: []byte(`{"name":"m/program","version":"0.1.3","digest":"sha256:bb","reason":"bytes differ"}`)}
|
||||
if !h.heard(context.Background(), refused) || refused.acked || !refused.naked {
|
||||
t.Fatalf("a refusal the state cannot take: acked %v, naked %v, log %q", refused.acked, refused.naked, logs.String())
|
||||
}
|
||||
if h.heard(context.Background(), &wakeMsg{subject: manifestAcceptedSubject}) {
|
||||
t.Fatal("manifest-accepted was taken as a seat's outcome")
|
||||
}
|
||||
}
|
||||
|
||||
// noVersions is a state versions that holds nothing.
|
||||
type noVersions struct{}
|
||||
|
||||
func (noVersions) Get(context.Context, string) ([]byte, uint64, bool, error) {
|
||||
return nil, 0, false, nil
|
||||
}
|
||||
func (noVersions) Create(context.Context, string, []byte) (uint64, error) {
|
||||
return 0, errors.New("read only")
|
||||
}
|
||||
func (noVersions) Update(context.Context, string, []byte, uint64) (uint64, error) {
|
||||
return 0, errors.New("read only")
|
||||
}
|
||||
func (noVersions) KeysOf(context.Context, string) ([]string, error) { return nil, nil }
|
||||
|
||||
func aBranchsEndIsHeard(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
f forgeEnd
|
||||
want bool
|
||||
}{
|
||||
{forgeEnd{Kind: delivery.ForgeMerged, Repository: "o/r", Branch: "feat/x"}, true},
|
||||
{forgeEnd{Kind: forgeBranchDeleted, Repository: "o/r", Branch: "feat/x"}, true},
|
||||
{forgeEnd{Kind: delivery.ForgePull, Repository: "o/r", Branch: "feat/x", Action: delivery.PullClosed}, true},
|
||||
{forgeEnd{Kind: delivery.ForgePull, Repository: "o/r", Branch: "feat/x", Action: delivery.PullOpened}, false},
|
||||
{forgeEnd{Kind: delivery.ForgeMerged, Repository: "o/r"}, false},
|
||||
} {
|
||||
if got := c.f.ended(); got != c.want {
|
||||
t.Errorf("%+v ended = %v, want %v", c.f, got, c.want)
|
||||
}
|
||||
}
|
||||
var logs bytes.Buffer
|
||||
woken := 0
|
||||
h := &branchEnds{acts: versions.Acts{Numberer: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}}}, findings: &findings{logf: t.Logf},
|
||||
tagAsks: func() jetstream.KeyValue { return nil }, wake: func(context.Context) { woken++ }, logf: log.New(&logs, "", 0).Printf, now: time.Now}
|
||||
open := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.pull", data: []byte(`{"kind":"pull","repository":"o/r","branch":"feat/x","action":"opened"}`)}
|
||||
h.heard(context.Background(), open)
|
||||
merged := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.merged", data: []byte(`{"kind":"merged","repository":"o/r","branch":"feat/x"}`)}
|
||||
h.heard(context.Background(), merged)
|
||||
if !open.acked || !merged.acked || woken != 2 {
|
||||
t.Errorf("an open pull acked %v, a merge with nothing tagged acked %v, floors woken %d times", open.acked, merged.acked, woken)
|
||||
}
|
||||
// The checker's family holds its verdicts beside tag-wanted: a verdict is acknowledged and not kept; a tag-wanted with
|
||||
// tag-asks not bound is heard again.
|
||||
verdict := &wakeMsg{subject: "mesh.stream.mesh-delivery-checker.event.verdict", data: []byte(`{"repository":"o/r"}`)}
|
||||
wanted := &wakeMsg{subject: tagWantedSubject, data: []byte(`{"repository":"o/r","branch":"feat/x","head":"abc","tag":"feat-x"}`)}
|
||||
for _, m := range []*wakeMsg{verdict, wanted} {
|
||||
tagWanted(context.Background(), nil, m, delivery.CheckerModule, time.Now(), t.Logf)
|
||||
}
|
||||
if !verdict.acked || verdict.delayed != 0 || wanted.acked || wanted.delayed != versions.RemoveAgainAfter || !strings.HasPrefix(tagWantedSubject, "mesh.stream.") {
|
||||
t.Errorf("a verdict acked %v (delayed %v), a tag-wanted acked %v (delayed %v)", verdict.acked, verdict.delayed, wanted.acked, wanted.delayed)
|
||||
}
|
||||
unreadable := &wakeMsg{subject: merged.subject, data: merged.data}
|
||||
h.acts.Numberer.Versions.Store = failingVersions{}
|
||||
h.heard(context.Background(), unreadable)
|
||||
if unreadable.acked || unreadable.delayed != versions.RemoveAgainAfter {
|
||||
t.Errorf("a branch's end the versions cannot answer: acked %v, given again after %v", unreadable.acked, unreadable.delayed)
|
||||
}
|
||||
}
|
||||
|
||||
// failingVersions is a state versions that cannot be read.
|
||||
type failingVersions struct{ noVersions }
|
||||
|
||||
func (failingVersions) KeysOf(context.Context, string) ([]string, error) {
|
||||
return nil, errors.New("down")
|
||||
}
|
||||
|
||||
func aFailedTrunkBuildIsAFinding(t *testing.T) {
|
||||
st := &memFindings{vals: map[string][]byte{}}
|
||||
f := &findings{store: st, logf: t.Logf, now: func() time.Time { return time.Unix(0, 0) }}
|
||||
ended := func(outcome, ref string) delivery.BuildLogEntry {
|
||||
return delivery.BuildLogEntry{Event: delivery.BuildEndedEvent, Module: "m", FP: "f1", Commit: "c", Ref: ref, Outcome: outcome}
|
||||
}
|
||||
key := health.FindingKey(health.ScopeModule, "m", BuildFailed)
|
||||
f.buildEnded(context.Background(), ended(delivery.BuildFailed, "refs/pull/3/head"))
|
||||
if len(st.vals) != 0 {
|
||||
t.Fatalf("a candidate's failed build is a finding: %v", st.vals)
|
||||
}
|
||||
f.buildEnded(context.Background(), ended(delivery.BuildFailed, ""))
|
||||
got, err := health.DecodeFinding(st.vals[key])
|
||||
if err != nil || got.Observer != delivery.BuilderModule || !strings.Contains(got.Summary, "trunk build of m at f1") {
|
||||
t.Fatalf("the finding = %+v, %v", got, err)
|
||||
}
|
||||
f.buildEnded(context.Background(), ended(delivery.BuildDone, ""))
|
||||
if _, ok := st.vals[key]; ok {
|
||||
t.Error("a later done trunk build leaves build-failed")
|
||||
}
|
||||
// The floor finding: raised while a name waits on its floor, cleared once it is known.
|
||||
fw := &versions.FloorWaits{Said: f.floorSaid(context.Background())}
|
||||
fw.Wait("m/lib", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceTags}}, nil, time.Unix(0, 0))
|
||||
floorKey := health.FindingKey(health.ScopeModule, "m/lib", versions.FloorSilent)
|
||||
if got, err := health.DecodeFinding(st.vals[floorKey]); err != nil || !strings.Contains(got.Summary, "bridge_tags did not answer") {
|
||||
t.Fatalf("the floor finding = %+v, %v", got, err)
|
||||
}
|
||||
fw.Answered("m/lib")
|
||||
if _, ok := st.vals[floorKey]; ok {
|
||||
t.Error("a known floor leaves floor-silent")
|
||||
}
|
||||
// Design 51 *Health and conditions* (review round 1): a holder's refused is publish-refused with its reason, a version
|
||||
// no holder answered within its bound publish-unanswered, a seat's outcome that does not read seat-outcome-unread;
|
||||
// a later published of the name clears the first two, the seat's next outcome that reads the third.
|
||||
vs := versions.Versions{Store: memVersions{}}
|
||||
for _, v := range []string{"0.1.3", "0.1.4"} {
|
||||
raw, err := versions.Encode(versions.Entry{Name: "m/program", Kind: versions.KindBundle, Series: "0.1", Version: v, Seat: "oci-artifact-registry", State: versions.Numbered})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vs.Store.(memVersions)[versions.Key("m/program", v)] = raw
|
||||
}
|
||||
pubs := &publishes{bounds: func() jetstream.KeyValue { return nil }, versions: vs, findings: f, bound: versions.PublishBound, logf: t.Logf}
|
||||
h := &seatHearer{n: &versions.Numberer{Versions: vs}, b: &build.Builder{}, logf: t.Logf, pub: pubs}
|
||||
refusedKey := health.FindingKey(health.ScopeModule, "m/program", versions.PublishRefused)
|
||||
refused := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.refused", data: []byte(`{"name":"m/program","version":"0.1.3","digest":"sha256:bb","reason":"bytes differ"}`)}
|
||||
if !h.heard(context.Background(), refused) || !refused.acked {
|
||||
t.Fatalf("a refusal: acked %v", refused.acked)
|
||||
}
|
||||
if got, err := health.DecodeFinding(st.vals[refusedKey]); err != nil || !strings.Contains(got.Summary, "refused to publish m/program@0.1.3") || !strings.Contains(got.Summary, "bytes differ") {
|
||||
t.Fatalf("a holder's refused: the finding = %+v, %v", got, err)
|
||||
}
|
||||
pubs.ended(context.Background(), purged{key: versions.PublishBoundKey("m/program", "0.1.4")})
|
||||
unansweredKey := health.FindingKey(health.ScopeModule, "m/program", versions.PublishUnanswered)
|
||||
if got, err := health.DecodeFinding(st.vals[unansweredKey]); err != nil || !strings.Contains(got.Summary, "neither published nor refused of m/program@0.1.4") {
|
||||
t.Fatalf("a bound ended unanswered: the finding = %+v, %v", got, err)
|
||||
}
|
||||
// Review round 2: a bound that cannot be written is publish-unanswered at once; a watch of bounds that cannot start
|
||||
// is family-not-heard naming the state.
|
||||
delete(st.vals, unansweredKey)
|
||||
pubs.arm(context.Background(), versions.Entry{Name: "m/program", Version: "0.1.4", Seat: "oci-artifact-registry"})
|
||||
if got, err := health.DecodeFinding(st.vals[unansweredKey]); err != nil || !strings.Contains(got.Summary, "its bound could not be written") {
|
||||
t.Fatalf("a bound not written: the finding = %+v, %v", got, err)
|
||||
}
|
||||
watchBounds(context.Background(), refusedWatch{err: errors.New("permissions violation")}, func(jetstream.KeyValueEntry) {}, f.notHeard(context.Background()), nil, t.Logf)
|
||||
if got, err := health.DecodeFinding(st.vals[heard.Key(delivery.BuilderModule, versions.BoundsState)]); err != nil || !strings.Contains(got.Summary, "permissions violation") {
|
||||
t.Fatalf("bounds not watched: the finding = %+v, %v", got, err)
|
||||
}
|
||||
delete(st.vals, heard.Key(delivery.BuilderModule, versions.BoundsState))
|
||||
malformed := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.published", data: []byte(`{"version":"0.1.4"}`)}
|
||||
unreadKey := health.FindingKey(health.ScopeModule, "oci-artifact-registry", versions.SeatOutcomeUnread)
|
||||
if !h.heard(context.Background(), malformed) || !malformed.acked || st.vals[unreadKey] == nil {
|
||||
t.Fatalf("an outcome that does not read: acked %v, findings %v", malformed.acked, st.vals)
|
||||
}
|
||||
published := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.published", data: []byte(`{"name":"m/program","version":"0.1.4","digest":"sha256:cc"}`)}
|
||||
if !h.heard(context.Background(), published) || st.vals[refusedKey] != nil || st.vals[unansweredKey] != nil || st.vals[unreadKey] != nil {
|
||||
t.Fatalf("a later published leaves the findings: %v", st.vals)
|
||||
}
|
||||
// ADR 0615 §5 (review round 1): a consumer not there yet is family-not-heard naming its stream, cleared when it binds.
|
||||
notHeard := f.notHeard(context.Background())
|
||||
unheardKey := heard.Key(delivery.BuilderModule, "FORGE")
|
||||
_ = notHeard.Bind("FORGE", "a branch's end is not heard", func() error { return errors.New("consumer not found") })
|
||||
if got, err := health.DecodeFinding(st.vals[unheardKey]); err != nil || got.Kind != heard.Kind || !strings.Contains(got.Summary, "does not hear FORGE") {
|
||||
t.Fatalf("a consumer not there: the finding = %+v, %v", got, err)
|
||||
}
|
||||
_ = notHeard.Bind("FORGE", "a branch's end is not heard", func() error { return nil })
|
||||
if _, ok := st.vals[unheardKey]; ok {
|
||||
t.Error("a consumer bound leaves family-not-heard")
|
||||
}
|
||||
// DLV-B9, the old form's window: one counted node reading only the first form holds it; seven days on it is
|
||||
// old-form-held about that node; the node stating the newest form closes it, said once, the finding cleared; a
|
||||
// retired node dropped by hand leaves the count.
|
||||
now := time.Unix(0, 0)
|
||||
fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}}
|
||||
var closings []versions.OldFormClosedBody
|
||||
var logged []delivery.BuildLogEntry
|
||||
marks := &memBounds{keys: map[string]bool{}}
|
||||
window := &oldForm{forms: fs, findings: f, logf: t.Logf, now: func() time.Time { return now }, bounds: func() jetstream.KeyValue { return marks },
|
||||
said: func(b versions.OldFormClosedBody) { closings = append(closings, b) },
|
||||
log: func(e delivery.BuildLogEntry) { logged = append(logged, e) }}
|
||||
reads := &engineReads{keeper: &enginereads.Keeper{}, window: window, counted: []string{"a", "b"}}
|
||||
newest := []string{forms.FormNeed("oci")}
|
||||
counted := map[string][]string{"a": newest, "b": nil}
|
||||
window.step(context.Background(), []string{"a", "b"}, counted)
|
||||
heldKey := health.FindingKey(health.ScopeMachine, "b", versions.OldFormHeld)
|
||||
if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || st.vals[heldKey] != nil {
|
||||
t.Fatalf("one node lacking the newest form: %+v", window.w)
|
||||
}
|
||||
// Review round 1: the node holding it has a bound in the state bounds ending at its seven days, so old-form-held is
|
||||
// raised then, not at the next change.
|
||||
if !marks.keys[versions.OldFormBoundKey("b")] || marks.keys[versions.OldFormBoundKey("a")] {
|
||||
t.Fatalf("the old form's bounds = %v, want b's alone", marks.keys)
|
||||
}
|
||||
// Security-upgrade F3 (review round 1): an empty node_haves read, naming no node, leaves the window open.
|
||||
window.step(context.Background(), nil, map[string][]string{})
|
||||
if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || len(closings) != 0 {
|
||||
t.Fatalf("an empty read closed the window: %+v, closings %+v", window.w, closings)
|
||||
}
|
||||
now = now.Add(versions.FindingAfter)
|
||||
window.step(context.Background(), []string{"a", "b"}, counted)
|
||||
if got, err := health.DecodeFinding(st.vals[heldKey]); err != nil || !strings.Contains(got.Summary, "the node-engine of b") {
|
||||
t.Fatalf("seven days held: the finding = %+v, %v", got, err)
|
||||
}
|
||||
counted["b"] = newest
|
||||
window.step(context.Background(), []string{"a", "b"}, counted)
|
||||
window.step(context.Background(), []string{"a", "b"}, counted)
|
||||
if marks.keys[versions.OldFormBoundKey("b")] {
|
||||
t.Error("a node no longer holding the window keeps its bound")
|
||||
}
|
||||
if window.w.Open || len(closings) != 1 || !slices.Equal(closings[0].LastHolding, []string{"b"}) || st.vals[heldKey] != nil {
|
||||
t.Errorf("the node back with the newest form: %+v, closings %+v, finding left %v", window.w, closings, st.vals[heldKey] != nil)
|
||||
}
|
||||
if len(logged) != 1 || logged[0].Event != delivery.BuildSaidEvent || logged[0].Why != versions.SaidOldFormClosed ||
|
||||
!strings.Contains(logged[0].Line, "last held by b") {
|
||||
t.Errorf("the build log = %+v, want the close said once (build.said)", logged)
|
||||
} else if _, err := delivery.EncodeBuildLogEntry(logged[0]); err != nil {
|
||||
t.Errorf("the close is no entry the build log takes: %v", err)
|
||||
}
|
||||
if _, err := window.drop(context.Background(), "b", "op@shanks", "retired", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, c := reads.forms(); len(c) != 1 || c["b"] != nil {
|
||||
t.Errorf("a node dropped by hand is still counted: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// memVersions is a state versions in memory.
|
||||
type memVersions map[string][]byte
|
||||
|
||||
func (m memVersions) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
v, ok := m[key]
|
||||
return v, 1, ok, nil
|
||||
}
|
||||
func (m memVersions) Create(_ context.Context, key string, v []byte) (uint64, error) {
|
||||
m[key] = v
|
||||
return 1, nil
|
||||
}
|
||||
func (m memVersions) Update(_ context.Context, key string, v []byte, _ uint64) (uint64, error) {
|
||||
m[key] = v
|
||||
return 2, nil
|
||||
}
|
||||
func (m memVersions) KeysOf(context.Context, string) ([]string, error) {
|
||||
return slices.Collect(maps.Keys(m)), nil
|
||||
}
|
||||
|
||||
// memBounds is the state bounds in memory: which keys stand.
|
||||
type memBounds struct {
|
||||
jetstream.KeyValue
|
||||
keys map[string]bool
|
||||
}
|
||||
|
||||
func (m *memBounds) Create(_ context.Context, key string, _ []byte, _ ...jetstream.KVCreateOpt) (uint64, error) {
|
||||
if m.keys[key] {
|
||||
return 0, jetstream.ErrKeyExists
|
||||
}
|
||||
m.keys[key] = true
|
||||
return 1, nil
|
||||
}
|
||||
func (m *memBounds) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error {
|
||||
delete(m.keys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// purged is the bus's marker of a key whose time to live ended.
|
||||
type purged struct {
|
||||
jetstream.KeyValueEntry
|
||||
key string
|
||||
}
|
||||
|
||||
func (p purged) Key() string { return p.key }
|
||||
func (p purged) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePurge }
|
||||
|
||||
// memFindings is a findings bucket in memory.
|
||||
type memFindings struct{ vals map[string][]byte }
|
||||
|
||||
type memEntry struct {
|
||||
jetstream.KeyValueEntry
|
||||
v []byte
|
||||
}
|
||||
|
||||
func (e memEntry) Value() []byte { return e.v }
|
||||
|
||||
func (m *memFindings) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) {
|
||||
v, ok := m.vals[key]
|
||||
if !ok {
|
||||
return nil, jetstream.ErrKeyNotFound
|
||||
}
|
||||
return memEntry{v: v}, nil
|
||||
}
|
||||
func (m *memFindings) Put(_ context.Context, key string, v []byte) (uint64, error) {
|
||||
m.vals[key] = v
|
||||
return 1, nil
|
||||
}
|
||||
func (m *memFindings) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error {
|
||||
delete(m.vals, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// seatEvent is one registry seat's event the builder hears: the seat and the event's name.
|
||||
type seatEvent struct{ seat, event string }
|
||||
|
||||
// seatEvents are the four registry seats' published, refused, removed and kept, by subject (design 51 *How a publish ends*,
|
||||
// DLV-B4): each a seat event `<seat>.<event>` on the shared events stream, heard on the builder's durable consumer of
|
||||
// it through the manifest's consumes. The subjects are the seats' contracts' (mesh-contracts).
|
||||
var seatEvents = map[string]seatEvent{
|
||||
containerimageregistry.SubjectPublished: {containerimageregistry.Seat, containerimageregistry.EventPublished},
|
||||
containerimageregistry.SubjectRefused: {containerimageregistry.Seat, containerimageregistry.EventRefused},
|
||||
containerimageregistry.SubjectRemoved: {containerimageregistry.Seat, containerimageregistry.EventRemoved},
|
||||
containerimageregistry.SubjectKept: {containerimageregistry.Seat, containerimageregistry.EventKept},
|
||||
ociartifactregistry.SubjectPublished: {ociartifactregistry.Seat, ociartifactregistry.EventPublished},
|
||||
ociartifactregistry.SubjectRefused: {ociartifactregistry.Seat, ociartifactregistry.EventRefused},
|
||||
ociartifactregistry.SubjectRemoved: {ociartifactregistry.Seat, ociartifactregistry.EventRemoved},
|
||||
ociartifactregistry.SubjectKept: {ociartifactregistry.Seat, ociartifactregistry.EventKept},
|
||||
gopackageregistry.SubjectPublished: {gopackageregistry.Seat, gopackageregistry.EventPublished},
|
||||
gopackageregistry.SubjectRefused: {gopackageregistry.Seat, gopackageregistry.EventRefused},
|
||||
gopackageregistry.SubjectRemoved: {gopackageregistry.Seat, gopackageregistry.EventRemoved},
|
||||
gopackageregistry.SubjectKept: {gopackageregistry.Seat, gopackageregistry.EventKept},
|
||||
npmpackageregistry.SubjectPublished: {npmpackageregistry.Seat, npmpackageregistry.EventPublished},
|
||||
npmpackageregistry.SubjectRefused: {npmpackageregistry.Seat, npmpackageregistry.EventRefused},
|
||||
npmpackageregistry.SubjectRemoved: {npmpackageregistry.Seat, npmpackageregistry.EventRemoved},
|
||||
npmpackageregistry.SubjectKept: {npmpackageregistry.Seat, npmpackageregistry.EventKept},
|
||||
}
|
||||
|
||||
// seatHearer takes the registry seats' outcomes in: each moves a numbered version (versions.Numberer.Heard), and a
|
||||
// `published` that wakes a name waiting on its floor looks at every module again.
|
||||
type seatHearer struct {
|
||||
n *versions.Numberer
|
||||
b *build.Builder
|
||||
logf func(string, ...any)
|
||||
// pub raises and clears the findings of a publish that ended badly; nil raises none.
|
||||
pub *publishes
|
||||
}
|
||||
|
||||
// heard takes a message of the events consumer in when it is a registry seat's outcome, and answers whether it was.
|
||||
// One the state could not take is left unacknowledged (Nak), so the bus gives it again; one that is no outcome is said
|
||||
// and dropped.
|
||||
func (h *seatHearer) heard(ctx context.Context, m jetstream.Msg) bool {
|
||||
se, ok := seatEvents[m.Subject()]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var o versions.Outcome
|
||||
if err := json.Unmarshal(m.Data(), &o); err != nil || o.Name == "" {
|
||||
if err == nil {
|
||||
err = errors.New("it names no name")
|
||||
}
|
||||
h.logf("%s holds what is no outcome of %s, left: %v", m.Subject(), se.seat, err)
|
||||
if h.pub != nil {
|
||||
h.pub.unread(ctx, se.seat, m.Subject(), err)
|
||||
}
|
||||
h.ack(m)
|
||||
return true
|
||||
}
|
||||
if h.pub != nil {
|
||||
h.pub.read(ctx, se.seat)
|
||||
}
|
||||
id := ""
|
||||
if m.Headers() != nil {
|
||||
id = m.Headers().Get("x-event-id")
|
||||
}
|
||||
wake, err := h.n.Heard(ctx, se.seat, se.event, id, o)
|
||||
if err != nil {
|
||||
h.logf("%v; heard again", err)
|
||||
if nakErr := m.Nak(); nakErr != nil {
|
||||
h.logf("leaving %s for the bus to give again: %v", m.Subject(), nakErr)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if h.pub != nil {
|
||||
h.pub.heard(ctx, se.seat, se.event, o)
|
||||
}
|
||||
h.ack(m)
|
||||
if len(wake) > 0 {
|
||||
h.logf("the floor of %s is asked again: %s said %s", strings.Join(wake, ", "), se.seat, se.event)
|
||||
h.b.Reconsider(ctx)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *seatHearer) ack(m jetstream.Msg) {
|
||||
if err := m.Ack(); err != nil {
|
||||
h.logf("acknowledging %s: %v", m.Subject(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// kvKeeper is the state floor-waits as versions.FloorKeeper.
|
||||
type kvKeeper struct {
|
||||
ctx context.Context
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (k kvKeeper) Put(key string, value []byte) error {
|
||||
_, err := k.kv.Put(k.ctx, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (k kvKeeper) Delete(key string) error {
|
||||
err := k.kv.Delete(k.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// heldFloorWaits are the waits floor-waits holds at the builder's start; a value that is no wait is said and left.
|
||||
func heldFloorWaits(ctx context.Context, kv jetstream.KeyValue, logf func(string, ...any)) []versions.FloorWait {
|
||||
l, err := kv.ListKeys(ctx)
|
||||
if err != nil {
|
||||
logf("%s cannot be listed, so a restart's waits are asked at their next claim: %v", versions.FloorWaitsState, err)
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = l.Stop() }()
|
||||
var out []versions.FloorWait
|
||||
for k := range l.Keys() {
|
||||
e, err := kv.Get(ctx, k)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w, err := versions.DecodeFloorWait(e.Value())
|
||||
if err != nil {
|
||||
logf("%s.%s holds what is no floor wait, left: %v", versions.FloorWaitsState, k, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// tagBounds narrates a tag ask the builder has not answered within five minutes, with what it waits on (design 51
|
||||
// *Tagged versions*): each unanswered ask has a key in the state tag-ask-bounds written with its own time to live, and
|
||||
// the bus's marker of its end, heard on the watch, is what narrates it and writes the bound again. No timer.
|
||||
type tagBounds struct {
|
||||
asks func() jetstream.KeyValue
|
||||
bounds func() jetstream.KeyValue
|
||||
waits func() []versions.FloorWait
|
||||
logf func(string, ...any)
|
||||
// log writes a narration into the builder's build log (build.said); nil writes none.
|
||||
log func(delivery.BuildLogEntry)
|
||||
now func() time.Time
|
||||
bound time.Duration
|
||||
}
|
||||
|
||||
// arm writes key's bound when none stands: a Create, so a standing bound keeps its time and a restart arms each
|
||||
// unanswered ask whose bound ended while the builder was away.
|
||||
func (tb *tagBounds) arm(ctx context.Context, key string) {
|
||||
kv := tb.bounds()
|
||||
if kv == nil {
|
||||
return
|
||||
}
|
||||
if _, err := kv.Create(ctx, key, []byte(`{}`), jetstream.KeyTTL(tb.bound)); err != nil && !errors.Is(err, jetstream.ErrKeyExists) {
|
||||
tb.logf("the bound of the tag ask %s is not written, so it is not narrated past five minutes: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// disarm removes key's bound: the ask was answered or ended.
|
||||
func (tb *tagBounds) disarm(ctx context.Context, key string) {
|
||||
if kv := tb.bounds(); kv != nil {
|
||||
_ = kv.Delete(ctx, key)
|
||||
}
|
||||
}
|
||||
|
||||
// asked is told each value of tag-asks: an unanswered ask is armed, any other disarmed.
|
||||
func (tb *tagBounds) asked(ctx context.Context, key string, t versions.TagAsk) {
|
||||
if t.Unanswered() {
|
||||
tb.arm(ctx, key)
|
||||
return
|
||||
}
|
||||
tb.disarm(ctx, key)
|
||||
}
|
||||
|
||||
// ended is told each change of tag-ask-bounds: a bound whose time ended (the bus's marker, a purge on the watch) on an
|
||||
// ask still unanswered narrates it and arms it again.
|
||||
func (tb *tagBounds) ended(ctx context.Context, e jetstream.KeyValueEntry) {
|
||||
if e.Operation() != jetstream.KeyValuePurge {
|
||||
return
|
||||
}
|
||||
kv := tb.asks()
|
||||
if kv == nil {
|
||||
return
|
||||
}
|
||||
got, err := kv.Get(ctx, e.Key())
|
||||
if err != nil {
|
||||
return // the ask ended with its branch
|
||||
}
|
||||
t, err := versions.DecodeTagAsk(got.Value())
|
||||
if err != nil || !t.Unanswered() {
|
||||
return
|
||||
}
|
||||
var waits []versions.FloorWait
|
||||
if tb.waits != nil {
|
||||
waits = tb.waits()
|
||||
}
|
||||
line := versions.NarrateUnanswered(t, waits, tb.now())
|
||||
tb.logf("%s", line)
|
||||
// In the build log too (design 51 *Tagged versions*): under the builder's own key, no build being claimed yet, the
|
||||
// ask's key standing for the fingerprint.
|
||||
if tb.log != nil {
|
||||
tb.log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: delivery.BuilderModule, FP: e.Key(), Commit: t.Head,
|
||||
At: tb.now().UTC(), Line: line, Why: versions.SaidUnanswered})
|
||||
}
|
||||
tb.arm(ctx, e.Key())
|
||||
}
|
||||
|
||||
// tagNumbered marks the tag ask of a check's build's head numbered with its versions in tag-asks, by compare-and-set.
|
||||
func tagNumbered(kv func() jetstream.KeyValue, logf func(string, ...any), now func() time.Time) func(context.Context, versions.BuildClaim, versions.Numbering) {
|
||||
return func(ctx context.Context, c versions.BuildClaim, got versions.Numbering) {
|
||||
store := kv()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
lister, err := store.ListKeysFiltered(ctx, versions.EscapeToken(c.Repository)+".>")
|
||||
if err != nil {
|
||||
logf("the tag ask of %s at %s is not marked numbered: %v", c.Repository, c.Head, err)
|
||||
return
|
||||
}
|
||||
for key := range lister.Keys() {
|
||||
for range 3 {
|
||||
e, err := store.Get(ctx, key)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
t, err := versions.DecodeTagAsk(e.Value())
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
t, changed := t.WithNumbered(c.Module, c.Head, got.Versions, got.Names, now())
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
data, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if _, err = store.Update(ctx, key, data, e.Revision()); err == nil {
|
||||
logf("%s: tag ask of %s %s: %s", key, t.Repository, t.Branch, t.Answer)
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
|
||||
logf("the tag ask %s is not marked numbered: %v", key, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// The floor's two module tools (novox/hq design 51 *The floor*); the seat's is `seat:<registry>.versions`.
|
||||
const (
|
||||
bridgeTagsTool = "mesh-delivery-forge-bridge.bridge_tags"
|
||||
requirementsTool = "mesh-delivery-indexer.indexer_requirements"
|
||||
)
|
||||
|
||||
// awaitedVersions is the state versions as versions.Store: bound once the bus holds it (the grants part makes it, issue
|
||||
// 1790); until then every use is an error, so a trunk build is built unnumbered and said (build.Builder.number).
|
||||
type awaitedVersions struct{ b *process.AwaitedBucket }
|
||||
|
||||
var errVersionsMissing = errors.New("the state " + versions.Bucket + " is not on the bus yet")
|
||||
|
||||
func (s awaitedVersions) kv() (jetstream.KeyValue, error) {
|
||||
if kv := s.b.KV(); kv != nil {
|
||||
return kv, nil
|
||||
}
|
||||
return nil, errVersionsMissing
|
||||
}
|
||||
|
||||
func (s awaitedVersions) Get(ctx context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
kv, err := s.kv()
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value(), e.Revision(), true, nil
|
||||
}
|
||||
|
||||
// indexerPackages is the indexer's packages state as the builder reads a package's name in its ecosystem from it (design
|
||||
// 32): the picture of its read tool's first view and its views family (ADR 0615 §1), never its bucket. Until the first
|
||||
// view is answered a package is built unnumbered and said (versions.Numberer.Names), and numbered once its name is heard
|
||||
// (followPackages).
|
||||
type indexerPackages struct{ kv jetstream.KeyValue }
|
||||
|
||||
func (s indexerPackages) Keys(ctx context.Context) ([]string, error) {
|
||||
keys, err := s.kv.Keys(ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (s indexerPackages) Get(ctx context.Context, key string) ([]byte, bool, error) {
|
||||
e, err := s.kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value(), true, nil
|
||||
}
|
||||
|
||||
// followPackages watches the picture of the indexer's packages state: each series key heard that names a package's name
|
||||
// (versions.NamedModule), its first view's included, looks at its module again (build.Builder.PackageNamed), so a done
|
||||
// build that left the package unnumbered is numbered then with its digests. Woken by the views family, never a timer; a
|
||||
// key heard again naming the name and module it named (the indexer writes a series at each publish) looks at nothing.
|
||||
func followPackages(ctx context.Context, kv jetstream.KeyValue, b *build.Builder, logf func(string, ...any)) error {
|
||||
w, err := kv.WatchAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
heard := map[string]string{}
|
||||
return follow(ctx, w, func(e jetstream.KeyValueEntry) {
|
||||
if e.Operation() != jetstream.KeyValuePut {
|
||||
delete(heard, e.Key())
|
||||
return
|
||||
}
|
||||
module, name, named := versions.NamedModule(e.Key(), e.Value())
|
||||
if named && heard[e.Key()] != module+" "+name {
|
||||
heard[e.Key()] = module + " " + name
|
||||
logf("%s.%s names a package of %s: its build is looked at again", delivery.PackagesBucket, e.Key(), module)
|
||||
b.PackageNamed(ctx, module)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func versionsConflict(err error) error {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
|
||||
return versions.ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s awaitedVersions) Create(ctx context.Context, key string, value []byte) (uint64, error) {
|
||||
kv, err := s.kv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := kv.Create(ctx, key, value)
|
||||
return rev, versionsConflict(err)
|
||||
}
|
||||
|
||||
func (s awaitedVersions) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) {
|
||||
kv, err := s.kv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := kv.Update(ctx, key, value, revision)
|
||||
return rev, versionsConflict(err)
|
||||
}
|
||||
|
||||
func (s awaitedVersions) KeysOf(ctx context.Context, filter string) ([]string, error) {
|
||||
kv, err := s.kv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l, err := kv.ListKeysFiltered(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = l.Stop() }()
|
||||
var out []string
|
||||
for k := range l.Keys() {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// floorVia is the floor's three sources as calls of the builder's own account (design 51 *The floor*): the seat's verb
|
||||
// versions, the forge bridge's bridge_tags and the indexer's indexer_requirements. A call that fails is that source
|
||||
// silent.
|
||||
func floorVia(call toolCaller) versions.FloorSources {
|
||||
return versions.FloorSources{
|
||||
Seat: func(_ context.Context, seat, name string) ([]string, error) {
|
||||
raw, err := call("seat:"+seat+".versions", map[string]any{"name": name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a struct {
|
||||
Versions []struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"versions"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("%s's versions answered what is no list of versions: %w", seat, err)
|
||||
}
|
||||
var out []string
|
||||
for _, v := range a.Versions {
|
||||
if v.Version != "" {
|
||||
out = append(out, v.Version)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
Tags: func(_ context.Context, repository, prefix string) ([]string, error) {
|
||||
raw, err := call(bridgeTagsTool, map[string]any{"repository": repository, "prefix": prefix})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("bridge_tags answered what is no list of tags: %w", err)
|
||||
}
|
||||
return a.Tags, nil
|
||||
},
|
||||
Requirements: func(_ context.Context, name string) ([]string, error) {
|
||||
raw, err := call(requirementsTool, map[string]any{"package": name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a struct {
|
||||
Requirements []string `json:"requirements"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("indexer_requirements answered what is no list of versions: %w", err)
|
||||
}
|
||||
return a.Requirements, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// emitNumbered says the builder's event version.numbered (design 51 *Events of the builder*), on its own family apart
|
||||
// from its builds' ends; the name and version are the bus's dedup id.
|
||||
func emitNumbered(ctx context.Context, js jetstream.JetStream, cred process.Account, b delivery.VersionNumbered) error {
|
||||
id := "numbered-" + versions.Key(b.Name, b.Version)
|
||||
if b.Again != "" {
|
||||
id += "-again-" + b.Again
|
||||
}
|
||||
if err := emitEvent(ctx, js, cred, delivery.VersionNumberedEvent, id, b); err != nil {
|
||||
return fmt.Errorf("saying %s for %s@%s: %w", delivery.VersionNumberedEvent, b.Name, b.Version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// emitEvent says one of the builder's events, mesh.mod.<module>.event.<event>, with the SDK's envelope headers and id as
|
||||
// the bus's dedup id.
|
||||
func emitEvent(ctx context.Context, js jetstream.JetStream, cred process.Account, event, id string, body any) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := nats.NewMsg(cred.EventSubject(event))
|
||||
msg.Header.Set("x-event-id", id)
|
||||
msg.Header.Set("x-source", cred.Module)
|
||||
msg.Header.Set("x-node", cred.Node)
|
||||
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
|
||||
msg.Header.Set("content-type", "application/json")
|
||||
msg.Data = data
|
||||
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
_, err = js.PublishMsg(pub, msg, jetstream.WithMsgID(id))
|
||||
return err
|
||||
}
|
||||
|
||||
// The events on the builder's consumer of EVENTS that wake a floor (design 51 *The floor*, DLV-B3b).
|
||||
var (
|
||||
manifestAcceptedSubject = "mesh.mod." + delivery.IndexerModule + ".event." + delivery.ManifestAcceptedEvent
|
||||
conditionClearedSubject = "mesh.mod." + health.KeeperModule + ".event." + health.EventCleared
|
||||
)
|
||||
|
||||
// floorWake asks the floor of the names an event wakes again, by looking at every module again: a trunk build is
|
||||
// numbered before its claim (build.Builder.number).
|
||||
type floorWake struct {
|
||||
b *build.Builder
|
||||
waits *versions.FloorWaits
|
||||
logf func(string, ...any)
|
||||
}
|
||||
|
||||
// heard takes a message of the consumer in when it is one that wakes a floor, acknowledged, and answers whether it was.
|
||||
func (f *floorWake) heard(ctx context.Context, m jetstream.Msg) bool {
|
||||
switch m.Subject() {
|
||||
case manifestAcceptedSubject:
|
||||
f.ack(m)
|
||||
f.wake(ctx, versions.WakeAccepted, "", "")
|
||||
case conditionClearedSubject:
|
||||
f.ack(m)
|
||||
var e health.Event
|
||||
if err := json.Unmarshal(m.Data(), &e); err != nil {
|
||||
f.logf("%s holds what is no condition, left: %v", m.Subject(), err)
|
||||
return true
|
||||
}
|
||||
for _, module := range conditionModules(e.Subject.ID) {
|
||||
f.wake(ctx, versions.WakeCleared, "", module)
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (f *floorWake) ack(m jetstream.Msg) {
|
||||
if err := m.Ack(); err != nil {
|
||||
f.logf("acknowledging %s: %v", m.Subject(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// wake looks at every module again when the event wakes a name, and says which.
|
||||
func (f *floorWake) wake(ctx context.Context, kind, seat, module string) {
|
||||
names := f.waits.Wake(kind, seat, module, "")
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
f.logf("the floor of %s is asked again: %s", strings.Join(names, ", "), kind)
|
||||
f.b.Reconsider(ctx)
|
||||
}
|
||||
|
||||
// conditionModules are the tokens of a condition's subject id (`<node>/<module>`, `<module>.<check>`): a module named
|
||||
// among them is the one whose condition cleared.
|
||||
func conditionModules(id string) []string {
|
||||
return strings.FieldsFunc(id, func(r rune) bool { return r == '/' || r == '.' || r == ':' })
|
||||
}
|
||||
|
||||
// doneVia says whether a build key is done, read from the builder's own builds state: the build's file is held.
|
||||
func doneVia(b *build.Builder) func(ctx context.Context, module, fp string) (bool, error) {
|
||||
return func(ctx context.Context, module, fp string) (bool, error) {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
raw, _, found, err := b.Claimer.Store.Get(ctx, key)
|
||||
if err != nil || !found {
|
||||
return false, err
|
||||
}
|
||||
cur, err := delivery.DecodeBuilderBuild(raw)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cur.State == delivery.BuildDone, nil
|
||||
}
|
||||
}
|
||||
|
||||
// removeVia calls a registry seat's remove for one tagged version, as the builder's own account (design 51 *Tagged
|
||||
// versions*): the reason tag-gone; the seat says removed on its events, or answers kept when another tag names the
|
||||
// digest (the registry part's RemoveAnswer: kept, reason, tags), which ends the remove.
|
||||
func removeVia(call toolCaller) func(ctx context.Context, seat string, e versions.Entry) (versions.RemoveOutcome, error) {
|
||||
return func(_ context.Context, seat string, e versions.Entry) (versions.RemoveOutcome, error) {
|
||||
raw, err := call("seat:"+seat+".remove", map[string]any{"name": e.Name, "version": e.Version, "tag": e.Tag, "reason": "tag-gone"})
|
||||
if err != nil {
|
||||
return versions.RemoveOutcome{}, err
|
||||
}
|
||||
var a ociartifactregistry.RemoveAnswer // the four registry seats share the part's answer
|
||||
if len(raw) > 0 && json.Unmarshal(raw, &a) != nil {
|
||||
return versions.RemoveOutcome{}, fmt.Errorf("%s's remove answered what is no answer of it: %s", seat, raw)
|
||||
}
|
||||
return versions.RemoveOutcome{Kept: a.Kept, Reason: a.Reason, Tags: a.Tags}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// tagAsked answers an unanswered value of tag-asks by compare-and-set at its revision, said in the log.
|
||||
func tagAsked(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
|
||||
if e.Operation() != jetstream.KeyValuePut {
|
||||
return
|
||||
}
|
||||
t, err := versions.DecodeTagAsk(e.Value())
|
||||
if err != nil {
|
||||
logf("%s.%s holds what is not a tag ask, left: %v", versions.TagAsksState, e.Key(), err)
|
||||
return
|
||||
}
|
||||
if !t.Answered.IsZero() {
|
||||
return
|
||||
}
|
||||
t = acts.TagAsked(ctx, t, time.Now())
|
||||
logf("%s: %s of %s %s by %s (%s): %s", e.Key(), map[bool]string{true: "untag", false: "tag ask"}[t.Untag], t.Repository, t.Branch, t.By, t.Why, t.Answer)
|
||||
data, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
|
||||
logf("%s.%s: the answer could not be written: %v", versions.TagAsksState, e.Key(), err)
|
||||
}
|
||||
}
|
||||
@@ -3,44 +3,25 @@ module mesh-delivery-builder
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459
|
||||
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ./buildrun
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459 h1:DA4SFG/gKyG+aPJbfUpAUad2VeITIiKjJ6qOPmDhSA8=
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459/go.mod h1:zR1HsAKZVbQsBIY5wPyrqI2WHInK268tITzGl2oqHro=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:I6uVDO9E+i3D9/n6KQ2RWYRs3kXYCqueiKXynifQo8w=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:ER2NVrd81uJo1lN0YCGA7OBxL910IHRgWYtxpsnHGF0=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:Rs5jOa7EWlpKuaH6+ExyGLN9k9UUi8N7upP9Ac3k+GY=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:fNfjFkP1+suYb12HJkapQe5W3cF98JsTcLBI/syoLHc=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:VG+sZltyBIKrd9cwFJyKkInz04SxWBxUlJWfAuo6774=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Nq7glLZSZ8zOLTh1pXDnKZi+MxsMRyHw/bZODC3mvrY=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639 h1:ROS9UG/Dg0NpsaDuEmDFV9CsIC1R92sOm3QauF7IvL0=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/Pke1v2VnSFcR8ic+fo/kNCg3JFYK6ILTal72Ca9520=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 h1:5HeZzk6tKzcSwVivD3IcIvQ/2WIvysZvNvNKyq/oIBA=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:pPxwhTMb3hIk7UUrBicJnGQCqfTEoBGJvNFspEK2U18=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639 h1:oAXU1t8A1rGIr82JPBaNYSGLicXqhWglKaEAtPmrD0c=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:Y5kgBh0hBbL/hgc+abCF9Vbhov+vCsB0ckpRzUw1D+0=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 h1:YQ6eJvc+RYUVIVf+oX+R4TC1dYL8TLLMZ0/JPDU1tp4=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639 h1:ILh7uctNwgx/89qHoR/H2kz5TpWeKEeIH0O2pDPE7Iw=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:JRtTjIjMTkzn59IN4vCYei9ZwJGFnN1Puwame4V/sD0=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 h1:+bkN8op2yrAlBYrOB+hYnIoIZkbjXsNmc/4//uMnL4E=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:ooQa2CiP8JOvhP0bdQvwurV5wskRE7Cm68ecURBbCwU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg=
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2 h1:MOuutmbKBL1GtTZU9W1+ja/K7TL05EFU6hEt3Swf/oE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
@@ -45,5 +25,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
@@ -114,11 +114,12 @@ func TestClaimCreatesTheKeyBuildingAndASecondBuilderGetsNothing(t *testing.T) {
|
||||
if second, err := b.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}); err != nil || second != nil {
|
||||
t.Errorf("second builder's claim = %v, %v, want none: the same source is never built twice", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A claim losing the create race gets nothing (merged into this test for the test budget, design 51).
|
||||
st = newMem()
|
||||
func TestClaimLosingTheCreateRaceGetsNothing(t *testing.T) { // 1607/AC3
|
||||
st := newMem()
|
||||
st.before = func() { seed(t, st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.b"}) }
|
||||
got, err = Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
|
||||
got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
|
||||
if err != nil || got != nil {
|
||||
t.Errorf("claim = %v, %v, want none: the create failed because the key exists", got, err)
|
||||
}
|
||||
@@ -196,11 +197,12 @@ func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing
|
||||
if st.writes != writes {
|
||||
t.Errorf("the same value was written again (%d writes, was %d)", st.writes, writes)
|
||||
}
|
||||
}
|
||||
|
||||
// Finish after the key changed is a conflict, not an overwrite (merged into this test for the test budget).
|
||||
st = newMem()
|
||||
c = Claimer{Store: st, By: "novox.a"}
|
||||
got, _ = c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
|
||||
func TestFinishAfterTheKeyChangedIsAConflictNotAnOverwrite(t *testing.T) { // 1607/AC3
|
||||
st := newMem()
|
||||
c := Claimer{Store: st, By: "novox.a"}
|
||||
got, _ := c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
|
||||
st.put(got.Key, []byte(`{"v":1,"state":"failed","error":"someone else"}`))
|
||||
err := c.Finish(context.Background(), got, delivery.BuilderBuild{State: delivery.BuildFailed, Error: "mine"})
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
|
||||
@@ -34,45 +34,3 @@ func importsABuildAnswersReachTheDoneKeyAndTheLog(t *testing.T) {
|
||||
t.Fatalf("log %+v, want the imports on the end alone", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// Design 26 (the lead's ruling on A2i's finding 2): the out-of-step files a contracts module's build module answers, in a
|
||||
// failed step's result too, reach the log's end entry, sorted and each once, where the indexer raises its finding; a
|
||||
// build that answers none names none.
|
||||
func outOfStepFilesABuildAnswersReachTheLog(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
fail bool
|
||||
files []string
|
||||
want string
|
||||
}{
|
||||
{"a passed build naming files", false, []string{"go/b.go", "go/a.go", "go/b.go"}, "go/a.go,go/b.go"},
|
||||
{"a failed build naming files", true, []string{"ts/x.ts"}, "ts/x.ts"},
|
||||
{"a build naming none", false, nil, ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
r := newRig(t, goBundle)
|
||||
r.asks.Answer = func(a delivery.StepAsk) delivery.Step {
|
||||
step := r.answer(a)
|
||||
answer := map[string]any{}
|
||||
_ = json.Unmarshal(step.Result, &answer)
|
||||
if row.files != nil {
|
||||
answer["out-of-step"] = row.files
|
||||
}
|
||||
step.Result, _ = json.Marshal(answer)
|
||||
if row.fail {
|
||||
step.State, step.Reason = delivery.StepFailed, "the generated files are out of step"
|
||||
}
|
||||
return step
|
||||
}
|
||||
var logged []delivery.BuildLogEntry
|
||||
var mu sync.Mutex
|
||||
r.b.Node, r.b.Log = "novox", func(e delivery.BuildLogEntry) { mu.Lock(); logged = append(logged, e); mu.Unlock() }
|
||||
r.consider(t, "modules/m")
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(logged) != 2 || strings.Join(logged[1].OutOfStep, ",") != row.want || len(logged[0].OutOfStep) != 0 {
|
||||
t.Fatalf("log %+v, want the end naming %q", logged, row.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// Numbers numbers a trunk build at its claim (novox/hq design 51 *The versions*, ADR 0606, ADR 0617 §1) and records the
|
||||
// digests it was built with. Nil numbers nothing.
|
||||
type Numbers interface {
|
||||
// tag is a check's build's tag (TagOf), empty for a trunk build; done the digests of its key when it is done already
|
||||
// (versions.BuildClaim.Done), nil otherwise.
|
||||
Number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string, done map[string]string) (versions.Numbering, error)
|
||||
Built(ctx context.Context, module string, m delivery.Module, plan Plan, got versions.Numbering, made []delivery.BuiltArtifact) error
|
||||
}
|
||||
|
||||
// VersionNumbers is Numbers over the builder's state versions.
|
||||
type VersionNumbers struct {
|
||||
N *versions.Numberer
|
||||
// TagNumbered is told the versions a check's build of a tag ask's head was numbered (the ask is then marked numbered
|
||||
// in tag-asks, design 51 *Tagged versions*); nil tells nobody.
|
||||
TagNumbered func(ctx context.Context, c versions.BuildClaim, got versions.Numbering)
|
||||
}
|
||||
|
||||
// ClaimOf is the claim a module value's trunk build is numbered by: a package per source revision (Src), the rest per
|
||||
// fingerprint, in merge order (Src).
|
||||
func ClaimOf(module string, m delivery.Module, plan Plan) versions.BuildClaim {
|
||||
c := versions.BuildClaim{Module: module, Path: m.Path, Repository: delivery.RepositoryName(m.Repository), Fingerprint: m.FP2,
|
||||
Commit: m.Commit, Merge: m.Src}
|
||||
if m.Src != 0 {
|
||||
c.Revision = strconv.FormatUint(m.Src, 10)
|
||||
}
|
||||
for _, t := range plan.Tasks {
|
||||
c.Artifacts = append(c.Artifacts, versions.ClaimArtifact{Artifact: t.Artifact, Kind: t.Kind, Language: t.Language})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (v VersionNumbers) Number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string, done map[string]string) (versions.Numbering, error) {
|
||||
c := ClaimOf(module, m, plan)
|
||||
c.Done = done
|
||||
if tag != "" {
|
||||
// A check's build of a head a tag ask names: its packages tagged, below the next version (design 51 *Tagged versions*).
|
||||
c.Tag, c.Head, c.Revision, c.Merge = tag, m.Commit, "", 0
|
||||
}
|
||||
got, err := v.N.Number(ctx, c)
|
||||
if err == nil && tag != "" && len(got.Versions) > 0 && v.TagNumbered != nil {
|
||||
v.TagNumbered(ctx, c, got)
|
||||
}
|
||||
return got, err
|
||||
}
|
||||
|
||||
func (v VersionNumbers) Built(ctx context.Context, module string, m delivery.Module, plan Plan, got versions.Numbering, made []delivery.BuiltArtifact) error {
|
||||
digests := map[string]string{}
|
||||
for _, a := range made {
|
||||
digests[a.Name] = a.Digest
|
||||
}
|
||||
return v.N.Built(ctx, ClaimOf(module, m, plan), got, digests)
|
||||
}
|
||||
|
||||
// TagOf answers the cleaned tag a kept tag ask names for a repository's head (the builder's state tag-asks, design 51
|
||||
// *Tagged versions*): a check's build of that head numbers its packages tagged at its claim.
|
||||
type TagOf func(ctx context.Context, repository, head string) (tag string, ok bool)
|
||||
|
||||
// number numbers a build before its claim, a trunk build's artifacts or a check's build's packages under its tag: false
|
||||
// when it waits for its floor (a package, design 51 *The floor*), and then nothing is claimed; the floor's wake looks at
|
||||
// the module again.
|
||||
func (b *Builder) number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string) (versions.Numbering, bool) {
|
||||
if b.Numbers == nil {
|
||||
return versions.Numbering{}, true
|
||||
}
|
||||
var done map[string]string
|
||||
if tag == "" {
|
||||
done = b.doneDigests(ctx, module, m.FP2)
|
||||
}
|
||||
got, err := b.Numbers.Number(ctx, module, m, plan, tag, done)
|
||||
for _, s := range got.Said {
|
||||
b.logf("%s at %s: %s", module, m.FP2, s.Line)
|
||||
// And in the build log (design 51 *The versions*: it says a version was kept, and why), between the build's start and end.
|
||||
// A look at a key already done builds nothing and has no start or end: a version kept there says nothing new, and is
|
||||
// not logged (each later look, a first view answered or a name heard, would add one).
|
||||
if b.Log != nil && !(done != nil && s.Why == versions.SaidKept) {
|
||||
b.Log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: module, FP: m.FP2, Commit: m.Commit, Node: b.Node,
|
||||
At: time.Now().UTC(), Line: s.Line, Why: s.Why})
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Said, and built as before: an unnumbered build is published by its digest (ADR 0617 §1), numbered at its next look.
|
||||
b.logf("%s at %s: %v", module, m.FP2, fmt.Errorf("not numbered, the versions cannot be used: %w", err))
|
||||
return versions.Numbering{}, true
|
||||
}
|
||||
if got.Wait != "" {
|
||||
b.logf("%s at %s is not built yet: %s", module, m.FP2, got.Wait)
|
||||
return got, false
|
||||
}
|
||||
return got, true
|
||||
}
|
||||
|
||||
// doneDigests are the digests of a trunk build's key by artifact when the key is done, nil otherwise or when it cannot be
|
||||
// read: a look at a done build numbers what it left unnumbered with them (versions.BuildClaim.Done).
|
||||
func (b *Builder) doneDigests(ctx context.Context, module, fp string) map[string]string {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil || b.Claimer.Store == nil {
|
||||
return nil
|
||||
}
|
||||
raw, _, found, err := b.Claimer.Store.Get(ctx, key)
|
||||
if err != nil || !found {
|
||||
return nil
|
||||
}
|
||||
cur, err := delivery.DecodeBuilderBuild(raw)
|
||||
if err != nil || cur.State != delivery.BuildDone {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, a := range cur.Artifacts {
|
||||
if a.Digest != "" {
|
||||
out[a.Name] = a.Digest
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PackageNamed looks at a module again when the indexer's packages state names a name for one of its packages
|
||||
// (versions.NamedModule): a done trunk build that left the package unnumbered, its name not known at its claim, is
|
||||
// numbered then with the done build's digests, and version.numbered says them for its build module's late publish.
|
||||
func (b *Builder) PackageNamed(ctx context.Context, module string) {
|
||||
b.init()
|
||||
b.mu.Lock()
|
||||
m, ok := b.known[module]
|
||||
b.mu.Unlock()
|
||||
if ok {
|
||||
b.consider(ctx, module, m)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,10 @@ package build
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
@@ -2,9 +2,8 @@ package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
@@ -14,12 +14,9 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// ToolchainEntries are the nodes' toolchain entries of a language (buildrun.ToolchainSet).
|
||||
@@ -82,16 +79,6 @@ type Builder struct {
|
||||
// time.Now. Node is the node the builder runs on, which asked the build.
|
||||
Now func() time.Time
|
||||
Node string
|
||||
// Numbers numbers each trunk build at its claim and records what it was built with (design 51 *The versions*); nil
|
||||
// numbers nothing.
|
||||
Numbers Numbers
|
||||
// Tags answers a check's build's tag from the kept tag asks; nil numbers no check's build.
|
||||
Tags TagOf
|
||||
// Forms answers what each node's engine states it reads (ADR 0618 §2, ADR 0624 §1): present, the nodes whose engine
|
||||
// answered when last asked, and counted, every node the placement names, present or away, a node with no statement
|
||||
// stating nothing (the baseline's first forms). A done key names each artifact in the forms these keep, the oldest in
|
||||
// its single reference, and its needs. Nil: no node states anything, and the first forms are kept.
|
||||
Forms func() (present, counted map[string][]string)
|
||||
|
||||
once sync.Once
|
||||
sem chan struct{}
|
||||
@@ -103,10 +90,8 @@ type Builder struct {
|
||||
plans map[string]planned // by manifest digest
|
||||
cands map[string]delivery.Candidate // by candidates key (candidates.go)
|
||||
via map[string]via // by build key: a candidate's fetch (candidates.go)
|
||||
// outOfStep are, by build key, the out-of-step files a build's lanes answered, until its end entry takes them.
|
||||
outOfStep map[string][]string
|
||||
trunkFP map[string]bool // by build key: a trunk module value carries this fingerprint
|
||||
tried map[string]bool // by build key: looked at since this process started
|
||||
trunkFP map[string]bool // by build key: a trunk module value carries this fingerprint
|
||||
tried map[string]bool // by build key: looked at since this process started
|
||||
// published are the package versions `<name>@<version>` a done key of this bucket names as a package's reference: a key
|
||||
// waiting for one is built once it is here (ADR 0500 clause 6).
|
||||
published map[string]bool
|
||||
@@ -445,17 +430,6 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
|
||||
first := !b.tried[key]
|
||||
b.tried[key] = true
|
||||
b.mu.Unlock()
|
||||
var numbering versions.Numbering
|
||||
tag := ""
|
||||
if !trunk && b.Tags != nil && b.refOf(module, m.FP2) != "" {
|
||||
tag, _ = b.Tags(ctx, delivery.RepositoryName(m.Repository), m.Commit)
|
||||
}
|
||||
if (trunk || tag != "") && planErr == nil && !nothing {
|
||||
var goOn bool
|
||||
if numbering, goOn = b.number(ctx, module, m, plan, tag); !goOn {
|
||||
return
|
||||
}
|
||||
}
|
||||
cl, err := b.Claimer.Claim(ctx, module, m.FP2, m.Manifest, ClaimOptions{TakeOver: first, ImageBuilder: imageSeat, Trunk: trunk, Published: b.isPublished,
|
||||
Stored: b.storedOnce(key)})
|
||||
if err != nil {
|
||||
@@ -516,14 +490,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
|
||||
b.logf("%s: the build could not be written: %v", cl.Key, err)
|
||||
return
|
||||
}
|
||||
ended := LogEnded(module, m, b.refOf(module, m.FP2), b.Node, b.now(), result)
|
||||
ended.OutOfStep = b.takeOutOfStep(key) // the generated files the build module's compare found out of step (design 26)
|
||||
b.logBuild(ended)
|
||||
if result.State == delivery.BuildDone && b.Numbers != nil && len(numbering.Versions) > 0 {
|
||||
if err := b.Numbers.Built(ctx, module, m, plan, numbering, result.Artifacts); err != nil {
|
||||
b.logf("%s: %v", cl.Key, err)
|
||||
}
|
||||
}
|
||||
b.logBuild(LogEnded(module, m, b.refOf(module, m.FP2), b.Node, b.now(), result))
|
||||
if result.State == delivery.BuildFailed {
|
||||
b.logf("%s: failed: %s", cl.Key, result.Error)
|
||||
}
|
||||
@@ -569,7 +536,6 @@ type lane struct {
|
||||
digests map[string]string // artifact -> digest
|
||||
references map[string]string // artifact -> the reference an image was pushed to (a trunk build only)
|
||||
imports []string // the mesh packages the lane's programs import, as its build module answered them
|
||||
outOfStep []string // the generated files its build module's compare found out of step, passed or failed
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -625,9 +591,6 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
go func() { defer wg.Done(); b.runLane(ctx, l, repository, m, module, asked) }()
|
||||
}
|
||||
wg.Wait()
|
||||
if key, err := delivery.BuilderBuildKey(module, m.FP2); err == nil {
|
||||
b.keepOutOfStep(key, lanes)
|
||||
}
|
||||
var errs []string
|
||||
for _, l := range lanes {
|
||||
if l.err != nil {
|
||||
@@ -643,10 +606,6 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
return failed("%s", keyError(errs))
|
||||
}
|
||||
var artifacts []delivery.BuiltArtifact
|
||||
var present, counted map[string][]string
|
||||
if b.Forms != nil {
|
||||
present, counted = b.Forms()
|
||||
}
|
||||
keptModule := map[string]bool{}
|
||||
for _, bm := range kept {
|
||||
keptModule[bm] = true
|
||||
@@ -675,7 +634,6 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
return failed("%s", err)
|
||||
}
|
||||
built := delivery.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d}
|
||||
var answered []string
|
||||
for _, l := range lanes {
|
||||
if l.module != t.Module {
|
||||
continue
|
||||
@@ -687,11 +645,10 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
return failed("%s%s on %s: the build answered no reference, so its push to the artifact store was not answered", delivery.NotStored, t.Artifact, l.node)
|
||||
}
|
||||
if r != "" {
|
||||
answered = append(answered, r)
|
||||
built.Reference = r
|
||||
}
|
||||
}
|
||||
// The forms a counted node's engine reads are kept, the oldest in the single reference (ADR 0624 §1, §3).
|
||||
artifacts = append(artifacts, forms.Current.Choose(built, answered, present, counted))
|
||||
artifacts = append(artifacts, built)
|
||||
}
|
||||
var steps []string
|
||||
for _, l := range lanes {
|
||||
@@ -713,7 +670,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
used = nil
|
||||
}
|
||||
done := delivery.BuilderBuild{State: delivery.BuildDone, By: b.Claimer.By, Manifest: m.Manifest, Step: step, Artifacts: artifacts, Toolchains: used,
|
||||
Imports: importsOf(lanes), Needs: forms.Current.Needs(artifacts)} // the forms it is written in (ADR 0618 §1)
|
||||
Imports: importsOf(lanes)}
|
||||
if _, err := delivery.EncodeBuilderBuild(done); err != nil {
|
||||
// A reference the build module answered that does not name the artifact and its digest is no reference.
|
||||
return failed("%s%v", delivery.NotStored, err)
|
||||
@@ -808,7 +765,7 @@ func (n *askNotes) note(ctx context.Context, language, id, step string) error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
v := n.cl.Build
|
||||
v.State, v.By, v.Step, v.Error, v.Artifacts, v.Needs = delivery.BuildBuilding, n.b.Claimer.By, step, "", nil, nil
|
||||
v.State, v.By, v.Step, v.Error, v.Artifacts = delivery.BuildBuilding, n.b.Claimer.By, step, "", nil
|
||||
v.Asks = maps.Clone(v.Asks)
|
||||
if v.Asks == nil {
|
||||
v.Asks = map[string]string{}
|
||||
@@ -904,12 +861,6 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m del
|
||||
return
|
||||
}
|
||||
l.node = step.Node
|
||||
// The build module's compare of a contracts module's generated files (design 26, CAT-BG5/BN2) answers the files out
|
||||
// of step as delivery.BuildAnswer.OutOfStep, in a failed step's result too: read before the step's state is.
|
||||
var compared delivery.BuildAnswer
|
||||
if len(step.Result) > 0 && json.Unmarshal(step.Result, &compared) == nil {
|
||||
l.outOfStep = compared.OutOfStep
|
||||
}
|
||||
if step.State != delivery.StepPassed {
|
||||
l.err = fmt.Errorf("%s on %s: the ask %s %s: %s", l.module, step.Node, ask.ID, step.State, step.Reason)
|
||||
return
|
||||
@@ -993,7 +944,7 @@ func LogStarted(module string, m delivery.Module, ref, by string, at time.Time)
|
||||
}
|
||||
|
||||
// LogEnded is the log's entry of that build ending: the outcome (done, failed or queued), the manifest built, the bases it
|
||||
// was built against and each artifact made, as the done key has them: what the operator's command nox builds shows of a build.
|
||||
// was built against and each artifact made, as the done key has them: what mesh-catalog registers a build from.
|
||||
func LogEnded(module string, m delivery.Module, ref, by string, at time.Time, result delivery.BuilderBuild) delivery.BuildLogEntry {
|
||||
e := LogStarted(module, m, ref, by, at)
|
||||
e.Event, e.Outcome, e.Manifest = delivery.BuildEndedEvent, result.State, m.Manifest
|
||||
@@ -1008,36 +959,6 @@ func LogEnded(module string, m delivery.Module, ref, by string, at time.Time, re
|
||||
return e
|
||||
}
|
||||
|
||||
// keepOutOfStep holds, by build key, the out-of-step files the lanes' build modules answered, sorted and each once, until
|
||||
// the build's end entry takes them (takeOutOfStep); a build with none holds nothing.
|
||||
func (b *Builder) keepOutOfStep(key string, lanes []*lane) {
|
||||
seen := map[string]bool{}
|
||||
for _, l := range lanes {
|
||||
for _, f := range l.outOfStep {
|
||||
seen[f] = true
|
||||
}
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if len(seen) == 0 {
|
||||
delete(b.outOfStep, key)
|
||||
return
|
||||
}
|
||||
if b.outOfStep == nil {
|
||||
b.outOfStep = map[string][]string{}
|
||||
}
|
||||
b.outOfStep[key] = slices.Sorted(maps.Keys(seen))
|
||||
}
|
||||
|
||||
// takeOutOfStep answers the out-of-step files held for a build key and forgets them.
|
||||
func (b *Builder) takeOutOfStep(key string) []string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
files := b.outOfStep[key]
|
||||
delete(b.outOfStep, key)
|
||||
return files
|
||||
}
|
||||
|
||||
// importsOf are the mesh packages the lanes' programs import, as their build modules answered them (novox/hq ADR 0593
|
||||
// §2): sorted and each once; nil when none answered any. A lane kept from a prior build answered none, so a build that
|
||||
// kept one reports only what it built.
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -15,12 +14,9 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// buildModules simulates the two build modules on the nodes: a long tool answers a step and key at once and writes the
|
||||
@@ -226,8 +222,6 @@ var (
|
||||
|
||||
func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7, ADR 0478 clauses 1, 4
|
||||
t.Run("the imports a build answers reach the done key and the log", importsABuildAnswersReachTheDoneKeyAndTheLog)
|
||||
t.Run("the out-of-step files a build answers reach the log", outOfStepFilesABuildAnswersReachTheLog)
|
||||
t.Run("ADR 0624: a done key keeps each form a counted node reads", aDoneKeyKeepsEachFormACountedNodeReads)
|
||||
r := newRig(t, goBundle, archiveA, tsBundle)
|
||||
// ADR 0584: a third node agrees with novox and ace's typescript is the odd one; the build is done and its step says ace sits out.
|
||||
r.tools.Put(buildrun.NodeBuildModule, "g14.typescript", tcValue(t, "typescript", "tsc5"), false)
|
||||
@@ -284,11 +278,6 @@ func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7
|
||||
if v.Artifacts[0].Kind != "archive" || v.Artifacts[1].Kind != "bundle" {
|
||||
t.Errorf("kinds = %+v", v.Artifacts)
|
||||
}
|
||||
// ADR 0618 §1: the done key names the forms its references are written in, here the first form alone, and keeps no list
|
||||
// beside the single reference while no counted node needs another form.
|
||||
if !slices.Equal(v.Needs, []string{"form:artifact-store"}) || v.Artifacts[0].References != nil {
|
||||
t.Errorf("needs = %v, references = %v", v.Needs, v.Artifacts[0].References)
|
||||
}
|
||||
if got := v.Toolchains["go"]; got.Version != "go1.27.1" || got.Recipe != "1" {
|
||||
t.Errorf("toolchains = %+v, want go and typescript named", v.Toolchains)
|
||||
}
|
||||
@@ -429,19 +418,8 @@ func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T)
|
||||
r := newRig(t, imageArt)
|
||||
r.puller = "ace"
|
||||
trunkModule(t, r)
|
||||
// DLV-B6: a check's build of a head a kept tag ask names is numbered under that tag at its claim.
|
||||
nums := &fakeNumbers{}
|
||||
var tagAsked []string
|
||||
r.b.Numbers = nums
|
||||
r.b.Tags = func(_ context.Context, repository, head string) (string, bool) {
|
||||
tagAsked = append(tagAsked, repository+"@"+head)
|
||||
return "feat-x", head == headX
|
||||
}
|
||||
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
|
||||
r.b.Wait()
|
||||
if !slices.Equal(nums.tags, []string{"feat-x"}) || len(tagAsked) != 1 || !strings.HasSuffix(tagAsked[0], "@"+headX) {
|
||||
t.Errorf("the check's build numbered with tags %v (asked %v), want feat-x for its head", nums.tags, tagAsked)
|
||||
}
|
||||
v := valueOf(t, r.st, r.key)
|
||||
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" {
|
||||
t.Fatalf("build = %+v, want done with no reference", v)
|
||||
@@ -455,69 +433,14 @@ func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b; design 51 *The versions*: numbered at the claim
|
||||
func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b
|
||||
r := newRig(t, imageArt)
|
||||
r.puller = "ace"
|
||||
nums := &fakeNumbers{wait: "m/img: the package waits for its floor"}
|
||||
r.b.Numbers = nums
|
||||
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
|
||||
r.b.Wait()
|
||||
if _, ok := r.st.vals[r.key]; ok || nums.numbered != 1 {
|
||||
t.Fatalf("a build waiting for its floor was claimed (numbered %d times)", nums.numbered)
|
||||
}
|
||||
nums.wait, nums.versions = "", map[string]string{"img": "0.1.4"}
|
||||
r.consider(t, "modules/m")
|
||||
calls := r.bm.callsOf("build_image_build")
|
||||
if len(calls) != 1 || calls[0].body.(map[string]any)["publish"] != true {
|
||||
t.Errorf("calls = %+v, want publish true", calls)
|
||||
}
|
||||
if nums.built["img"] != digest {
|
||||
t.Errorf("the built digest recorded = %v, want %s for img", nums.built, digest)
|
||||
}
|
||||
if nums.done != nil {
|
||||
t.Errorf("a build still to make was numbered with done digests %v", nums.done)
|
||||
}
|
||||
// The indexer's packages state names a package of the module (PackageNamed): the done build is looked at again and
|
||||
// numbered with its key's digests, so version.numbered carries them for the late publish; nothing is built again.
|
||||
looks := nums.numbered
|
||||
r.b.PackageNamed(context.Background(), "m")
|
||||
r.b.Wait()
|
||||
if nums.numbered != looks+1 || nums.done["img"] != digest || len(r.bm.callsOf("build_image_build")) != 1 {
|
||||
t.Errorf("a named package's done build: numbered %d more times with %v, %d image builds; want once with img %s and no build",
|
||||
nums.numbered-looks, nums.done, len(r.bm.callsOf("build_image_build")), digest)
|
||||
}
|
||||
r.b.PackageNamed(context.Background(), "other")
|
||||
r.b.Wait()
|
||||
if nums.numbered != looks+1 {
|
||||
t.Errorf("a module the builder does not know was looked at")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeNumbers is Numbers that answers what it is set to and keeps the digests a done build recorded.
|
||||
type fakeNumbers struct {
|
||||
wait string
|
||||
versions map[string]string
|
||||
numbered int
|
||||
built map[string]string
|
||||
tags []string
|
||||
done map[string]string // the done key's digests the last look numbered with
|
||||
}
|
||||
|
||||
func (f *fakeNumbers) Number(_ context.Context, _ string, _ delivery.Module, _ Plan, tag string, done map[string]string) (versions.Numbering, error) {
|
||||
f.tags = append(f.tags, tag)
|
||||
f.done = done
|
||||
f.numbered++
|
||||
return versions.Numbering{Wait: f.wait, Versions: f.versions}, nil
|
||||
}
|
||||
|
||||
func (f *fakeNumbers) Built(_ context.Context, _ string, _ delivery.Module, _ Plan, got versions.Numbering, made []delivery.BuiltArtifact) error {
|
||||
f.built = map[string]string{}
|
||||
for _, a := range made {
|
||||
if got.Versions[a.Name] != "" {
|
||||
f.built[a.Name] = a.Digest
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAnUnpinnedUpstreamFailsNotPinnedAndIsNeverBuiltAgain(t *testing.T) { // B3, ADR 0498 clause 4
|
||||
@@ -642,7 +565,6 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt
|
||||
r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", old))
|
||||
r.b.Wait()
|
||||
if len(r.st.vals) != 0 || len(r.bm.calls) != 0 {
|
||||
t.Run("a recipe that moves builds the module again under its new fingerprint", aRecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint)
|
||||
t.Errorf("keys %v and %d calls for a fingerprint of go1.26.0 while the nodes agree on go1.27.1, want neither", r.st.vals, len(r.bm.calls))
|
||||
}
|
||||
// A recipe that differs is a different toolchain too, and a value that names none (written before) is not built.
|
||||
@@ -667,45 +589,6 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0603 §3 (D9): a source revision changing the Go build module's recipe and a Go module builds the module at once with
|
||||
// the recipe the nodes run and again, a second key under the new fingerprint, once the agreement moves to the new recipe;
|
||||
// the first key is neither rebuilt nor waited on.
|
||||
func aRecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint(t *testing.T) {
|
||||
r := newRig(t, goBundle)
|
||||
r.consider(t, "modules/m")
|
||||
calls := len(r.bm.calls)
|
||||
fp2 := "src2:" + hex64('2')
|
||||
for _, n := range []string{"ace", "novox"} {
|
||||
b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "2", At: time.Now()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.tools.Put(buildrun.GoBuildModule, n+".go", b, false)
|
||||
}
|
||||
r.b.Reconsider(context.Background())
|
||||
r.b.Wait()
|
||||
if len(r.bm.calls) != calls {
|
||||
t.Errorf("%d calls, was %d: the first fingerprint was built again for the new recipe", len(r.bm.calls), calls)
|
||||
}
|
||||
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), FP2: fp2,
|
||||
Manifest: mdigest('a'), Path: "modules/m", Toolchains: map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "2"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.b.Module(context.Background(), "m", data)
|
||||
r.b.Wait()
|
||||
second, err := delivery.BuilderBuildKey("m", fp2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := valueOf(t, r.st, second); v.State != delivery.BuildDone || v.Toolchains["go"].Recipe != "2" {
|
||||
t.Errorf("second build = %+v, want done with recipe 2", v)
|
||||
}
|
||||
if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone {
|
||||
t.Errorf("first build = %+v, want it left done", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesTheyDisagreeOnLeaveTheKeyUnwritten(t *testing.T) { // 1615/AC4
|
||||
r := newRig(t, goBundle)
|
||||
r.tools.Put(buildrun.GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false)
|
||||
@@ -1311,117 +1194,3 @@ type silentWatch struct{}
|
||||
func (silentWatch) WatchAsk(context.Context, string, string) (<-chan delivery.Step, error) {
|
||||
return make(chan delivery.Step), nil
|
||||
}
|
||||
|
||||
// aDoneKeyKeepsEachFormACountedNodeReads holds the builder's rows of ADR 0624's check: every build keeps each form a
|
||||
// counted node's engine reads, present or away, a node stating nothing reading the baseline's first form; the oldest kept
|
||||
// form is the single reference; a retired node leaves the count. old is the first form, new a fixture second form read
|
||||
// only by an engine stating form:new.
|
||||
func aDoneKeyKeepsEachFormACountedNodeReads(t *testing.T) {
|
||||
fs := forms.ArtifactForms{
|
||||
{Name: "old", First: true, Kinds: []string{"bundle"}, Is: func(r string) bool { return strings.HasPrefix(r, "artifact-store://") }},
|
||||
{Name: "new", Kinds: []string{"bundle"}, Is: func(r string) bool { return strings.HasPrefix(r, "10.0.0.1:5000/") }},
|
||||
}
|
||||
both, nothing, onlyNew := []string{"form:new", "word:module"}, []string(nil), []string{"form:new"}
|
||||
oldRef, newRef := "artifact-store://m/code/blobs/"+digest, "10.0.0.1:5000/m/code@"+digest
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
present, counted map[string][]string
|
||||
kept []string
|
||||
reference string
|
||||
references []string
|
||||
}{
|
||||
{"A present {old,new}, B away {old}: both forms, the old in the single reference",
|
||||
map[string][]string{"a": both}, map[string][]string{"a": both, "b": {"word:module"}}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
|
||||
{"A present {new}, B away stating nothing: the baseline's form stays",
|
||||
map[string][]string{"a": onlyNew}, map[string][]string{"a": onlyNew, "b": nothing}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
|
||||
{"A present {old,new}, B away and never asked (no key): the baseline's form stays",
|
||||
map[string][]string{"a": both}, map[string][]string{"a": both, "b": nil}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
|
||||
{"B retired: the new form only", map[string][]string{"a": both}, map[string][]string{"a": both}, []string{"new"}, newRef, nil},
|
||||
{"B present stating nothing: the new form is not written",
|
||||
map[string][]string{"a": both, "b": nothing}, map[string][]string{"a": both, "b": nothing}, []string{"old"}, oldRef, nil},
|
||||
{"no node present: the first form", nil, map[string][]string{"a": both}, []string{"old"}, oldRef, nil},
|
||||
} {
|
||||
if got := fs.Kept("bundle", row.present, row.counted); !slices.Equal(got, row.kept) {
|
||||
t.Errorf("%s: kept %v, want %v", row.name, got, row.kept)
|
||||
}
|
||||
a := fs.Choose(delivery.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{newRef, oldRef}, row.present, row.counted)
|
||||
if a.Reference != row.reference || !slices.Equal(a.References, row.references) {
|
||||
t.Errorf("%s: %q %v, want %q %v", row.name, a.Reference, a.References, row.reference, row.references)
|
||||
}
|
||||
// An engine that predates the list reads the single reference, in a form every counted node reads (ADR 0624 §3).
|
||||
if form, _ := fs.FormOf("bundle", a.Reference); form != row.kept[0] {
|
||||
t.Errorf("%s: the single reference is in %q, want the oldest kept %q", row.name, form, row.kept[0])
|
||||
}
|
||||
}
|
||||
// An answered reference no kept form names still names where the artifact is (ADR 0498 clause 1).
|
||||
if a := fs.Choose(delivery.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{oldRef}, map[string][]string{"a": both},
|
||||
map[string][]string{"a": both}); a.Reference != oldRef {
|
||||
t.Errorf("a lone answered reference was dropped: %+v", a)
|
||||
}
|
||||
withBoth := []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: digest, Reference: oldRef, References: []string{oldRef, newRef}}}
|
||||
if got := fs.Needs(withBoth); !slices.Equal(got, []string{"form:new", "form:old"}) {
|
||||
t.Errorf("needs %v", got)
|
||||
}
|
||||
// The builder's own forms (forms.Current): a bundle a build step answers in the registry seat's form (mesh-build-go's
|
||||
// oci-artifact-registry/<module>/<artifact>@<digest>) keeps the artifact-store form it derives for each counted node
|
||||
// whose engine does not state form:seat-reference, that one in the single reference (ADR 0624 §1, §3).
|
||||
seat := []string{forms.FormNeed(forms.FormSeatReference), "word:module"}
|
||||
// Each kind the seat form names (a bundle through oci-artifact-registry, an image and an upstream copy through
|
||||
// container-image-registry, the copy under the holder's own upstream/<host>/<path>), with the artifact-store form it derives.
|
||||
for _, k := range []struct{ kind, seatRef, storeRef string }{
|
||||
{"bundle", "oci-artifact-registry/m/code@" + digest, "artifact-store://m/code/blobs/" + digest},
|
||||
{"image", "container-image-registry/m/code@" + digest, "artifact-store://m/code@" + digest},
|
||||
{"upstream", "container-image-registry/upstream/quay.io:443/org/app@" + digest, "artifact-store://upstream/quay.io:443/org/app@" + digest},
|
||||
} {
|
||||
seatRef, storeRef := k.seatRef, k.storeRef
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
present, counted map[string][]string
|
||||
reference string
|
||||
references []string
|
||||
needs []string
|
||||
}{
|
||||
{"seat form answered, B away stating nothing: the derived store form stays, in the single reference",
|
||||
map[string][]string{"a": seat}, map[string][]string{"a": seat, "b": nil}, storeRef, []string{storeRef, seatRef},
|
||||
[]string{"form:artifact-store", "form:seat-reference"}},
|
||||
{"seat form answered, every counted engine reads it: the seat form only",
|
||||
map[string][]string{"a": seat}, map[string][]string{"a": seat, "b": seat}, seatRef, nil, []string{"form:seat-reference"}},
|
||||
{"seat form answered, a present engine stating nothing: the store form only",
|
||||
map[string][]string{"a": seat, "b": nil}, map[string][]string{"a": seat, "b": nil}, storeRef, nil, []string{"form:artifact-store"}},
|
||||
{"seat form answered, no node present: the store form", nil, map[string][]string{"a": seat}, storeRef, nil, []string{"form:artifact-store"}},
|
||||
} {
|
||||
a := forms.Current.Choose(delivery.BuiltArtifact{Name: "code", Kind: k.kind, Digest: digest}, []string{seatRef}, row.present, row.counted)
|
||||
if a.Reference != row.reference || !slices.Equal(a.References, row.references) {
|
||||
t.Errorf("%s %s: %q %v, want %q %v", k.kind, row.name, a.Reference, a.References, row.reference, row.references)
|
||||
}
|
||||
needs := forms.Current.Needs([]delivery.BuiltArtifact{a})
|
||||
if !slices.Equal(needs, row.needs) {
|
||||
t.Errorf("%s %s: needs %v, want %v", k.kind, row.name, needs, row.needs)
|
||||
}
|
||||
for node, stated := range row.counted {
|
||||
if forms.Current.Held(needs, stated) {
|
||||
t.Errorf("%s %s: the want of %s is held", k.kind, row.name, node)
|
||||
}
|
||||
}
|
||||
if _, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.TrimPrefix(digest, "sha256:"), Artifacts: []delivery.BuiltArtifact{a}, Needs: needs}); err != nil {
|
||||
t.Errorf("%s %s: %v", k.kind, row.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// What the assigner holds a want by (ADR 0624 §2), from the same forms.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
needs []string
|
||||
stated []string
|
||||
held bool
|
||||
}{
|
||||
{"a node stating {old}, a build carrying {old,new}: moves", []string{"form:new", "form:old"}, []string{"word:module"}, false},
|
||||
{"a node stating nothing, a build carrying the baseline's form: moves", []string{"form:old"}, nil, false},
|
||||
{"a build carrying {new} only, the node stating {old}: held", []string{"form:new"}, []string{"word:module"}, true},
|
||||
{"a build naming no form: moves", nil, nil, false},
|
||||
} {
|
||||
if got := fs.Held(row.needs, row.stated); got != row.held {
|
||||
t.Errorf("%s: held %v", row.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -56,67 +54,6 @@ type ReAsk struct {
|
||||
Failed string `json:"failed,omitempty"`
|
||||
Revision uint64 `json:"revision,omitempty"`
|
||||
Asks map[string]string `json:"asks,omitempty"`
|
||||
// Name is the numbered name (`<module>/<artifact>`) of a ModePublishAgain or ModeFloorAgain value; Do and Version the
|
||||
// builder's answer: what it did (versions.Again*) and the version published.
|
||||
Name string `json:"name,omitempty"`
|
||||
Do string `json:"do,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// ModePublishAgain is the Mode of a reasks value of builder_publish_again (design 51 *Tools of the builder*).
|
||||
const ModePublishAgain = "publish-again"
|
||||
|
||||
// ModeFloorAgain is the Mode of a reasks value of builder_floor_again (design 51 *The floor*).
|
||||
const ModeFloorAgain = "floor-again"
|
||||
|
||||
// NewPublishAgain is the value builder_publish_again writes for a name's fingerprint: the key of the name's module and
|
||||
// fingerprint, and the value.
|
||||
func NewPublishAgain(name, fp, why, by string, at time.Time) (string, []byte, error) {
|
||||
module, artifact, ok := strings.Cut(strings.TrimSpace(name), "/")
|
||||
if !ok || module == "" || artifact == "" {
|
||||
return "", nil, fmt.Errorf("name %q is not a numbered name, <module>/<artifact>, as builder_versions lists it", name)
|
||||
}
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return "", nil, errors.New("say why it is published again (why)")
|
||||
}
|
||||
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModePublishAgain, Module: module, FP: fp, Name: strings.TrimSpace(name)})
|
||||
return key, data, err
|
||||
}
|
||||
|
||||
// NewFloorAgain is the value builder_floor_again writes for a name: the key `floor.<name escaped>`, never a build key,
|
||||
// and the value.
|
||||
func NewFloorAgain(name, why, by string, at time.Time) (string, []byte, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if module, artifact, ok := strings.Cut(name, "/"); !ok || module == "" || artifact == "" {
|
||||
return "", nil, fmt.Errorf("name %q is not a numbered name, <module>/<artifact>, as builder_versions lists it", name)
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return "", nil, errors.New("say why the floor is asked again (why)")
|
||||
}
|
||||
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeFloorAgain, Name: name})
|
||||
return "floor." + versions.EscapeToken(name), data, err
|
||||
}
|
||||
|
||||
// ModeOldFormDrop is the Mode of a reasks value of builder_old_form's drop (design 51 *Every build keeps the old
|
||||
// reference form*): a retired node dropped from the count by hand; Name is the node.
|
||||
const ModeOldFormDrop = "old-form-drop"
|
||||
|
||||
// NewOldFormDrop is the value builder_old_form writes to drop a retired node: the key `old-form.<node>`, never a build
|
||||
// key, and the value.
|
||||
func NewOldFormDrop(node, why, by string, at time.Time) (string, []byte, error) {
|
||||
node = strings.TrimSpace(node)
|
||||
if node == "" || strings.ContainsAny(node, ". *>") {
|
||||
return "", nil, fmt.Errorf("node %q is not a node's name", node)
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return "", nil, errors.New("say why the node leaves the count (why)")
|
||||
}
|
||||
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeOldFormDrop, Name: node})
|
||||
return "old-form." + node, data, err
|
||||
}
|
||||
|
||||
// ModeBuild is the Mode of a reasks value that asks the key to be built again, not its waiting asks to be asked again.
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// What builder_publish_again does with one fingerprint of a name (design 51 *Tools of the builder*), after a holder failed.
|
||||
const (
|
||||
// AgainPublish says version.numbered again for the recorded version: the build module publishes the held digest.
|
||||
AgainPublish = "publish"
|
||||
// AgainBuild builds the fingerprint again under the same number: the build's file is gone and no published was said
|
||||
// for the number, so the new digest replaces the recorded one (Numberer.Built).
|
||||
AgainBuild = "build"
|
||||
// AgainRenumber takes a new number: a published was said for the number, so other bytes never go under it.
|
||||
AgainRenumber = "renumber"
|
||||
)
|
||||
|
||||
// Again is what builder_publish_again does, with the entry it acts on and why.
|
||||
type Again struct {
|
||||
Do string `json:"do"`
|
||||
Entry Entry `json:"entry"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// PublishAgain decides builder_publish_again for fingerprint among a name's entries; built says whether the builder still
|
||||
// holds the build's file (its done key). It refuses a fingerprint not numbered, and a version refused for other bytes
|
||||
// than the ones recorded under it.
|
||||
func PublishAgain(entries []Entry, fingerprint string, built bool) (Again, error) {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
if fingerprint == "" {
|
||||
return Again{}, errors.New("fp is required: the fingerprint whose version is published again")
|
||||
}
|
||||
for _, e := range latestFirst(entries) {
|
||||
if e.Tag != "" || e.Fingerprint != fingerprint {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case e.State == Refused && e.RefusedDigest != "" && e.Digest != "" && e.RefusedDigest != e.Digest:
|
||||
return Again{}, fmt.Errorf("%s@%s was refused for other bytes (%s, recorded %s): it is not published again; a new build takes a new number",
|
||||
e.Name, e.Version, e.RefusedDigest, e.Digest)
|
||||
case e.State == Published || e.State == Removed:
|
||||
return Again{Do: AgainRenumber, Entry: e, Why: fmt.Sprintf("%s@%s was %s, so the fingerprint takes a new number", e.Name, e.Version, e.State)}, nil
|
||||
case !built:
|
||||
return Again{Do: AgainBuild, Entry: e, Why: fmt.Sprintf("the build's file is gone and no published was said for %s@%s: built again under the same number", e.Name, e.Version)}, nil
|
||||
}
|
||||
return Again{Do: AgainPublish, Entry: e, Why: fmt.Sprintf("%s@%s (%s) is said numbered again, so its build module publishes the held digest", e.Name, e.Version, e.State)}, nil
|
||||
}
|
||||
return Again{}, fmt.Errorf("fingerprint %s is not numbered for this name: builder_versions lists what is", fingerprint)
|
||||
}
|
||||
|
||||
// The builder's state of tag asks (design 51 *Tagged versions*): one key per repository and branch, removed when the
|
||||
// branch is merged or deleted or its pull request closed.
|
||||
const (
|
||||
TagAsksState = "tag-asks"
|
||||
TagAsksBucket = "mesh-delivery-builder_tag-asks"
|
||||
)
|
||||
|
||||
// The states of a tag ask.
|
||||
const (
|
||||
TagAsked = "asked"
|
||||
TagNumbered = "numbered"
|
||||
TagRemoving = "removing"
|
||||
TagRemoved = "removed"
|
||||
)
|
||||
|
||||
// TagAsk is one value of tag-asks: an ask of a tagged version for a branch's head, from the checker's tag-wanted or by
|
||||
// hand (builder_tag), or by hand the removal of the branch's tagged versions (builder_untag, Untag).
|
||||
type TagAsk struct {
|
||||
V int `json:"v"`
|
||||
Repository string `json:"repository"`
|
||||
Branch string `json:"branch"`
|
||||
Head string `json:"head,omitempty"`
|
||||
Tag string `json:"tag"`
|
||||
By string `json:"by,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Untag bool `json:"untag,omitempty"`
|
||||
// State, Versions (name to version), Left (what a removal still waits on, name@version to its seat) and Answer are
|
||||
// the builder's.
|
||||
State string `json:"state,omitempty"`
|
||||
Versions map[string]string `json:"versions,omitempty"`
|
||||
Left map[string]string `json:"left,omitempty"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Answered time.Time `json:"answered,omitzero"`
|
||||
}
|
||||
|
||||
// TagAskKey is the key of a repository's branch in tag-asks.
|
||||
func TagAskKey(repository, branch string) string {
|
||||
return EscapeToken(repository) + "." + EscapeToken(branch)
|
||||
}
|
||||
|
||||
// NewTagAsk is the value builder_tag writes: its key and the value. The tag is the branch's, cleaned (CleanTag).
|
||||
func NewTagAsk(repository, branch, head, why, by string, at time.Time) (string, []byte, error) {
|
||||
return newTagAsk(repository, branch, head, why, by, at, false)
|
||||
}
|
||||
|
||||
// NewUntag is the value builder_untag writes: its key and the value.
|
||||
func NewUntag(repository, branch, why, by string, at time.Time) (string, []byte, error) {
|
||||
return newTagAsk(repository, branch, "", why, by, at, true)
|
||||
}
|
||||
|
||||
func newTagAsk(repository, branch, head, why, by string, at time.Time, untag bool) (string, []byte, error) {
|
||||
repository, branch, head, why = strings.TrimSpace(repository), strings.TrimSpace(branch), strings.TrimSpace(head), strings.TrimSpace(why)
|
||||
switch {
|
||||
case repository == "" || !strings.Contains(repository, "/"):
|
||||
return "", nil, errors.New("repository is required: <owner>/<name>")
|
||||
case branch == "":
|
||||
return "", nil, errors.New("branch is required")
|
||||
case !untag && head == "":
|
||||
return "", nil, errors.New("head is required: the commit the tagged version is built from")
|
||||
case why == "":
|
||||
return "", nil, errors.New("say why (why): a hand act is recorded")
|
||||
}
|
||||
data, err := json.Marshal(TagAsk{V: 1, Repository: repository, Branch: branch, Head: head, Tag: CleanTag(branch), By: by, Why: why,
|
||||
At: at.UTC(), Untag: untag, State: TagAsked})
|
||||
return TagAskKey(repository, branch), data, err
|
||||
}
|
||||
|
||||
// DecodeTagAsk reads a tag-asks value.
|
||||
func DecodeTagAsk(data []byte) (TagAsk, error) {
|
||||
var a TagAsk
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if a.V != 1 {
|
||||
return a, fmt.Errorf("a tag-asks value of version %d, not 1", a.V)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// TagAsksAnswer is what builder_tag_asks answers: the open asks, oldest first.
|
||||
type TagAsksAnswer struct {
|
||||
Asks []TagAsk `json:"asks"`
|
||||
}
|
||||
|
||||
// AnswerTagAsks is builder_tag_asks over the state read, only repository's when it is given.
|
||||
func AnswerTagAsks(r Reader, repository string) (TagAsksAnswer, error) {
|
||||
keys, err := r.Keys()
|
||||
if err != nil {
|
||||
return TagAsksAnswer{}, fmt.Errorf("%s cannot be listed: %w", TagAsksState, err)
|
||||
}
|
||||
out := TagAsksAnswer{Asks: []TagAsk{}}
|
||||
for _, k := range keys {
|
||||
raw, ok, err := r.Get(k)
|
||||
if err != nil {
|
||||
return TagAsksAnswer{}, fmt.Errorf("%s.%s: %w", TagAsksState, k, err)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
a, err := DecodeTagAsk(raw)
|
||||
if err != nil {
|
||||
return TagAsksAnswer{}, fmt.Errorf("%s.%s holds what is not a tag ask: %w", TagAsksState, k, err)
|
||||
}
|
||||
if repository == "" || a.Repository == repository {
|
||||
out.Asks = append(out.Asks, a)
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Asks, func(i, j int) bool { return out.Asks[i].At.Before(out.Asks[j].At) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Untagged are repository's tagged versions of tag that are not removed (a group's branch spans repositories, each
|
||||
// ending apart): what a branch's end, or builder_untag, removes through
|
||||
// each one's seat (design 51 *Tagged versions*). A version with no tag is never among them.
|
||||
func Untagged(entries []Entry, repository, tag string) []Entry {
|
||||
tag = CleanTag(tag)
|
||||
var out []Entry
|
||||
for _, e := range entries {
|
||||
if e.Tag != "" && e.Repository == repository && CleanTag(e.Tag) == tag && e.State != Removed && e.State != Kept {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return InOrder(out)
|
||||
}
|
||||
|
||||
// The tools' descriptions.
|
||||
const (
|
||||
PublishAgainDescription = "Publish one fingerprint's version again after its holder failed (design 51): with the build's file held, the " +
|
||||
"builder says version.numbered again and the build module publishes the held digest; with the file gone, it builds the fingerprint " +
|
||||
"again under the same number, the new digest replacing the recorded one, since no published was said for it; after a published (or " +
|
||||
"a removal) it takes a new number. It refuses a fingerprint not numbered and a version the holder refused for other bytes. A hand " +
|
||||
"act, recorded with its caller and why in the builder's log."
|
||||
FloorAgainDescription = "Ask the floor of one name again now, by hand (design 51 *The floor*): the seat's versions, the forge bridge's " +
|
||||
"bridge_tags and the indexer's indexer_requirements, as the builder asks them by itself when a silent source says something. " +
|
||||
"Answers which sources were silent and whether the floor is now known. A name the builder's versions already hold needs no floor: " +
|
||||
"refused, saying so. A hand act."
|
||||
TagDescription = "Ask a tagged version for a branch's head by hand, as the checker's tag-wanted asks it (design 51 *Tagged versions*): " +
|
||||
"the builder keeps the ask in its state tag-asks until the branch is merged or deleted, or its pull request closed, and numbers " +
|
||||
"each package the head builds below the next trunk version (0.1.15-<tag>.2). A hand act."
|
||||
TagAsksDescription = "The open tag asks of the builder's state tag-asks, oldest first: repository, branch, head, tag, who asked and why, " +
|
||||
"its state (asked, numbered, removing, removed), the versions numbered for it and, while its tagged versions are removed, the " +
|
||||
"seats that have not yet said removed. With repository, that repository's only. Read-only."
|
||||
UntagDescription = "Remove a branch's tagged versions by hand, as its merge, its pull request's close or its deletion does (design 51 " +
|
||||
"*Tagged versions*): the builder calls each seat's remove with the reason tag-gone and marks each version removed once its seat says " +
|
||||
"removed. Answers the versions it removes and the seats still waited on. A version with no tag is never removed. A hand act."
|
||||
)
|
||||
|
||||
// Acts are the builder's hand acts on its versions, answered by its program (the tools bundle writes them to reasks).
|
||||
type Acts struct {
|
||||
Numberer *Numberer
|
||||
// Done says whether the build key of module and fingerprint is done: the build's file is held.
|
||||
Done func(ctx context.Context, module, fp string) (bool, error)
|
||||
// BuildAgain builds a failed key again (builder_build_again's path): its answer, or why it refused.
|
||||
BuildAgain func(ctx context.Context, module, fp, why, by string) (answer string, refused bool)
|
||||
// Reconsider looks at every module again, so a claim asks a name's floor.
|
||||
Reconsider func(ctx context.Context)
|
||||
// Remove calls a seat's verb remove for one tagged version, reason tag-gone: what it answered, kept or asked.
|
||||
Remove func(ctx context.Context, seat string, e Entry) (RemoveOutcome, error)
|
||||
// DropOldForm drops a retired node from the old form's count by hand (builder_old_form): its answer, or why not.
|
||||
DropOldForm func(ctx context.Context, node, by, why string, at time.Time) (string, error)
|
||||
}
|
||||
|
||||
// TagAsked answers one unanswered value of tag-asks. An untag calls remove on each of the branch's tagged versions'
|
||||
// seats and leaves each in Left until its seat says removed (Heard, on the seat's events); a call that fails stays in
|
||||
// Left with its error, asked again by the branch's end. A tag ask is kept: its versions are numbered when the head's
|
||||
// packages are claimed for the group's check.
|
||||
func (a Acts) TagAsked(ctx context.Context, t TagAsk, now time.Time) TagAsk {
|
||||
t.Answered = now.UTC()
|
||||
if !t.Untag {
|
||||
t.State = TagAsked
|
||||
t.Answer = "kept: each package the head builds is numbered " + "<next>-" + t.Tag + ".<n> when its build is claimed for the check"
|
||||
return t
|
||||
}
|
||||
all, err := a.Numberer.Versions.All(ctx)
|
||||
if err != nil {
|
||||
t.Answer = "nothing removed: " + err.Error()
|
||||
return t
|
||||
}
|
||||
t.Left, t.Versions = map[string]string{}, map[string]string{}
|
||||
for _, e := range Untagged(all, t.Repository, t.Tag) {
|
||||
t.Versions[e.Name] = e.Version
|
||||
at := e.Name + "@" + e.Version
|
||||
t.Left[at] = e.Seat
|
||||
if left, ok := a.removeOne(ctx, e); ok {
|
||||
delete(t.Left, at)
|
||||
} else if left != "" {
|
||||
t.Left[at] = left
|
||||
}
|
||||
}
|
||||
if len(t.Left) == 0 {
|
||||
t.State, t.Left, t.Answer = TagRemoved, nil, "the branch holds no tagged version: nothing to remove"
|
||||
return t
|
||||
}
|
||||
t.State = TagRemoving
|
||||
t.Answer = fmt.Sprintf("remove asked of %d tagged versions; each is marked removed when its seat says removed", len(t.Left))
|
||||
return t
|
||||
}
|
||||
|
||||
// removeOne asks e's seat to remove it: done when the seat kept it whole (recorded Kept, no retry); otherwise left, with
|
||||
// the error of a call that failed.
|
||||
func (a Acts) removeOne(ctx context.Context, e Entry) (left string, done bool) {
|
||||
got, err := a.Remove(ctx, e.Seat, e)
|
||||
if err != nil {
|
||||
return e.Seat + ": " + err.Error(), false
|
||||
}
|
||||
if !got.Kept {
|
||||
return "", false
|
||||
}
|
||||
if err := a.Numberer.keep(ctx, e, got.Reason, got.Tags); err != nil {
|
||||
return e.Seat + ": kept, and not recorded so: " + err.Error(), false
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// PublishAgain is builder_publish_again of name's fingerprint: what it did, the version, and its answer; an error refuses.
|
||||
func (a Acts) PublishAgain(ctx context.Context, name, fp, why, by string, at time.Time) (Again, string, error) {
|
||||
module, _, _ := strings.Cut(name, "/")
|
||||
entries, err := a.Numberer.Versions.Of(ctx, name)
|
||||
if err != nil {
|
||||
return Again{}, "", err
|
||||
}
|
||||
done, err := a.Done(ctx, module, fp)
|
||||
if err != nil {
|
||||
return Again{}, "", fmt.Errorf("the build key of %s at %s cannot be read: %w", module, fp, err)
|
||||
}
|
||||
ag, err := PublishAgain(entries, fp, done)
|
||||
if err != nil {
|
||||
return Again{}, "", err
|
||||
}
|
||||
e := ag.Entry
|
||||
if ag.Do == AgainRenumber {
|
||||
_, ne, err := a.Numberer.Versions.Number(ctx, Claim{Name: e.Name, Kind: e.Kind, Series: e.Series, Module: e.Module, Fingerprint: e.Fingerprint,
|
||||
Commit: e.Commit, Revision: e.Revision, Seat: e.Seat, Merge: e.Merge, Fresh: true})
|
||||
if err != nil {
|
||||
return Again{}, "", err
|
||||
}
|
||||
ag.Entry = ne
|
||||
}
|
||||
answer := ag.Why
|
||||
if ag.Do == AgainBuild || (ag.Do == AgainRenumber && !done) {
|
||||
said, refused := a.BuildAgain(ctx, module, fp, why, by)
|
||||
if refused {
|
||||
return Again{}, "", fmt.Errorf("%s; and the build again was refused: %s", ag.Why, said)
|
||||
}
|
||||
answer += "; " + said
|
||||
}
|
||||
if ag.Do == AgainPublish || (ag.Do == AgainRenumber && done) {
|
||||
if a.Numberer.Said != nil {
|
||||
b := NumberedOf(ag.Entry, "")
|
||||
b.Again = at.UTC().Format(time.RFC3339Nano)
|
||||
a.Numberer.Said(b)
|
||||
}
|
||||
answer += "; " + delivery.VersionNumberedEvent + " said for " + ag.Entry.Version
|
||||
}
|
||||
return ag, answer, nil
|
||||
}
|
||||
|
||||
// FloorAgain is builder_floor_again of name: the floor counts only for a name the versions hold nothing of.
|
||||
func (a Acts) FloorAgain(ctx context.Context, name string) (string, error) {
|
||||
entries, err := a.Numberer.Versions.Of(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return "", fmt.Errorf("%s holds %d versions: its floor counts only for its first number, so nothing is asked", name, len(entries))
|
||||
}
|
||||
answer := name + " waits on no floor in this builder: the floor is asked at its next claim"
|
||||
if a.Numberer.Waits != nil {
|
||||
for _, w := range a.Numberer.Waits.Waiting() {
|
||||
if w.Name == name {
|
||||
answer = fmt.Sprintf("the floor of %s is asked again (%s were silent)", name, strings.Join(w.Silent, ", "))
|
||||
}
|
||||
}
|
||||
a.Numberer.Waits.Wake(WakeByHand, "", "", name)
|
||||
}
|
||||
a.Reconsider(ctx)
|
||||
return answer + "; builder_versions shows its version once numbered", nil
|
||||
}
|
||||
|
||||
// TagFor is the cleaned tag a kept tag ask names for repository's head: the ask a check's build of that head is
|
||||
// numbered under (design 51 *Tagged versions*). An untag, or an ask whose branch ended, names none.
|
||||
func TagFor(asks []TagAsk, repository, head string) (string, bool) {
|
||||
for _, t := range asks {
|
||||
if t.Untag || t.State == TagRemoving || t.State == TagRemoved || t.Head == "" {
|
||||
continue
|
||||
}
|
||||
if t.Repository == repository && t.Head == head {
|
||||
return t.Tag, t.Tag != ""
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): on the forge bridge's `merged`, a
|
||||
// pull's `closed` or `branch-deleted`, heard on the builder's durable consumer of the stream `forge`, the builder calls
|
||||
// each seat's `remove` and acknowledges the message only once every seat said `removed` (Heard marks the entry); until
|
||||
// then the bus gives it again each minute (RemoveAgainAfter) and, past RemovalFindingAfter, the builder's finding names
|
||||
// the seats still owing.
|
||||
const (
|
||||
RemoveAgainAfter = time.Minute
|
||||
RemovalFindingAfter = 15 * time.Minute
|
||||
// RemovalNotDone is the finding's kind.
|
||||
RemovalNotDone = "tag-removal-not-done"
|
||||
)
|
||||
|
||||
// BranchEnd is what one hearing of a branch's end did: the versions still not removed (name@version to its seat, with
|
||||
// the error of a remove that failed), and whether all are gone so the message is acknowledged.
|
||||
type BranchEnd struct {
|
||||
Left map[string]string
|
||||
Done bool
|
||||
}
|
||||
|
||||
// Seats are the seats still owing a removal, sorted, each once.
|
||||
func (b BranchEnd) Seats() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range b.Left {
|
||||
seat, _, _ := strings.Cut(s, ":")
|
||||
if !seen[seat] {
|
||||
seen[seat] = true
|
||||
out = append(out, seat)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Finding is the removal finding's words: what is left, since when.
|
||||
func (b BranchEnd) Finding(repository, branch string, since time.Time) string {
|
||||
var at []string
|
||||
for v, s := range b.Left {
|
||||
at = append(at, v+" on "+s)
|
||||
}
|
||||
sort.Strings(at)
|
||||
return fmt.Sprintf("the tagged versions of %s %s are not all removed since its end at %s: %s; the builder asks remove again each minute",
|
||||
repository, branch, since.UTC().Format(time.RFC3339), strings.Join(at, ", "))
|
||||
}
|
||||
|
||||
// BranchEnded removes repository's tagged versions of branch: every one not yet removed is asked of its seat again.
|
||||
// A store that cannot be read is the error: nothing is acknowledged then.
|
||||
func (a Acts) BranchEnded(ctx context.Context, repository, branch string) (BranchEnd, error) {
|
||||
all, err := a.Numberer.Versions.All(ctx)
|
||||
if err != nil {
|
||||
return BranchEnd{}, err
|
||||
}
|
||||
left := map[string]string{}
|
||||
for _, e := range Untagged(all, repository, branch) {
|
||||
at := e.Name + "@" + e.Version
|
||||
left[at] = e.Seat
|
||||
if a.Remove == nil {
|
||||
continue
|
||||
}
|
||||
// A seat that kept it whole (another tag names its digest) ends it here: recorded kept, never asked again.
|
||||
if l, done := a.removeOne(ctx, e); done {
|
||||
delete(left, at)
|
||||
} else if l != "" {
|
||||
left[at] = l
|
||||
}
|
||||
}
|
||||
return BranchEnd{Left: left, Done: len(left) == 0}, nil
|
||||
}
|
||||
|
||||
// TagWanted is the body of the checker's `tag-wanted` (design 51 *Tagged versions*): a group member's head to number
|
||||
// tagged versions for.
|
||||
type TagWanted struct {
|
||||
Repository string `json:"repository"`
|
||||
Branch string `json:"branch"`
|
||||
Head string `json:"head"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
// TagWantedAsk is the tag-asks value a tag-wanted becomes, as builder_tag writes it by hand: by the checker.
|
||||
func TagWantedAsk(t TagWanted, by string, at time.Time) (string, []byte, error) {
|
||||
return NewTagAsk(t.Repository, t.Branch, t.Head, "the checker's tag-wanted for a group member's check", by, at)
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The registry seats a version is published to (design 51 *The seats*), by what is published.
|
||||
const (
|
||||
SeatImages = "container-image-registry"
|
||||
SeatArtifacts = "oci-artifact-registry"
|
||||
SeatGoPackages = "go-package-registry"
|
||||
SeatNPM = "npm-package-registry"
|
||||
)
|
||||
|
||||
// SeatOf is the registry seat an artifact of kind in language is published to, and the kind the builder numbers it as;
|
||||
// ok is false for what is not numbered (an upstream copy is mirrored by its pinned digest, design 51 *Publishing*).
|
||||
func SeatOf(kind, language string) (seat, numberedAs string, ok bool) {
|
||||
switch kind {
|
||||
case KindImage:
|
||||
return SeatImages, KindImage, true
|
||||
case KindBundle, KindArchive:
|
||||
return SeatArtifacts, kind, true
|
||||
case KindPackage:
|
||||
switch language {
|
||||
case "go":
|
||||
return SeatGoPackages, KindPackage, true
|
||||
case "typescript", "javascript", "node":
|
||||
return SeatNPM, KindPackage, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// NameOf is the name a module's image, bundle or archive is numbered under: `<module>/<artifact>`, the name the seat
|
||||
// answers it by (ADR 0605 §9's `<registry>/<module>/<artifact>@…`). A package is numbered under its name in its own
|
||||
// ecosystem instead (Numberer.Names).
|
||||
func NameOf(module, artifact string) string { return module + "/" + artifact }
|
||||
|
||||
// PackageNames answers a package's name in its own ecosystem (design 32: a Go package's module path, an npm package's
|
||||
// package.json name) for the module and artifact it is built from, as the indexer's packages state holds it; known is
|
||||
// false when the state names none for it. An error is a state that cannot be read.
|
||||
type PackageNames func(ctx context.Context, module, artifact string) (name string, known bool, err error)
|
||||
|
||||
// TagPrefix is the git tag prefix of a package in the module's directory path: `go/v` for mesh-sdk's `go`, `v` at the
|
||||
// root (design 51: the builder tags each Go version `go/v0.1.<n>`).
|
||||
func TagPrefix(path string) string {
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" || path == "." {
|
||||
return "v"
|
||||
}
|
||||
return path + "/v"
|
||||
}
|
||||
|
||||
// ClaimArtifact is one artifact of a trunk build to number.
|
||||
type ClaimArtifact struct {
|
||||
Artifact, Kind, Language string
|
||||
}
|
||||
|
||||
// BuildClaim is a trunk build at its claim: what Numberer.Number numbers.
|
||||
type BuildClaim struct {
|
||||
Module, Path, Repository, Fingerprint, Commit, Series string
|
||||
// Revision is the source revision of the merge (a package is numbered per revision); Merge its sequence, for order.
|
||||
Revision string
|
||||
Merge uint64
|
||||
Artifacts []ClaimArtifact
|
||||
// Tag numbers a check's build of a head a tag ask names (design 51 *Tagged versions*): its packages only, each a
|
||||
// tagged version kept with Repository for its branch's end; Head is the head, said in numbered. Empty for a trunk build.
|
||||
Tag, Head string
|
||||
// Done are the digests of the build's done key, by artifact, when the claim is a look at a build already done: what it
|
||||
// left unnumbered (a package whose name was not known yet, a floor that was silent) is numbered with them, so
|
||||
// version.numbered carries the digest its build module publishes late. Nil for a build still to make.
|
||||
Done map[string]string
|
||||
}
|
||||
|
||||
// Numbering is what the claim got.
|
||||
type Numbering struct {
|
||||
// Versions are each artifact's version, by artifact; an artifact built unnumbered (its floor not known) has none.
|
||||
Versions map[string]string
|
||||
// Unnumbered are the artifacts built and published by digest, numbered once their floor answers (ADR 0617 §1).
|
||||
Unnumbered []string
|
||||
// Wait is why the build waits: a package whose floor is not known (design 51 *The floor*). Nothing is claimed.
|
||||
Wait string
|
||||
// Said are the builder's narrations of each decision, for its process log and its build log (build.said).
|
||||
Said []Narration
|
||||
// Names are the names each numbered artifact was numbered under, by artifact (NameOf, or a package's name in its
|
||||
// ecosystem).
|
||||
Names map[string]string
|
||||
}
|
||||
|
||||
// NameOf is the name artifact of module was numbered under in this numbering.
|
||||
func (g Numbering) NameOf(module, artifact string) string {
|
||||
if name := g.Names[artifact]; name != "" {
|
||||
return name
|
||||
}
|
||||
return NameOf(module, artifact)
|
||||
}
|
||||
|
||||
// Narration is one line the builder says in its build log (design 51: the build log says a version was kept, and
|
||||
// why), and what it narrates.
|
||||
type Narration struct{ Line, Why string }
|
||||
|
||||
// What a narration of the build log narrates (the build.said entry's why).
|
||||
const (
|
||||
SaidNumbered = "numbered"
|
||||
SaidKept = "kept"
|
||||
SaidWaiting = "waiting"
|
||||
SaidUnnumbered = "unnumbered"
|
||||
SaidUnanswered = "tag ask unanswered"
|
||||
SaidOldFormClosed = "old-form-closed"
|
||||
)
|
||||
|
||||
// narrationOf is a decision's narration.
|
||||
func narrationOf(name string, d Decision) Narration {
|
||||
why := SaidNumbered
|
||||
switch {
|
||||
case d.Keep:
|
||||
why = SaidKept
|
||||
case d.Wait:
|
||||
why = SaidWaiting
|
||||
case d.Unnumbered:
|
||||
why = SaidUnnumbered
|
||||
}
|
||||
return Narration{Line: name + ": " + d.Why, Why: why}
|
||||
}
|
||||
|
||||
// Numberer numbers trunk builds at their claim (design 51 *The versions*, DLV-B2, B3): it asks a name's floor only
|
||||
// when versions holds nothing for it, records a silent floor as a wait (FloorWaits, woken by its source's event), and
|
||||
// says each new number (Said, the event version.numbered).
|
||||
type Numberer struct {
|
||||
Versions Versions
|
||||
Floor FloorSources
|
||||
Waits *FloorWaits
|
||||
// Holder is the module holding a seat, whose health condition's clear wakes a silent seat source; nil names none.
|
||||
Holder func(seat string) string
|
||||
// Said is told each version newly numbered; nil tells nobody.
|
||||
Said func(delivery.VersionNumbered)
|
||||
// Names answers a package's name in its ecosystem; nil numbers a package as NameOf names it.
|
||||
Names PackageNames
|
||||
// Publishing is told each version Built recorded that is still numbered, its holder's word not heard yet: the
|
||||
// builder arms its publish bound (publish.go); nil arms none.
|
||||
Publishing func(ctx context.Context, e Entry)
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Number numbers every numbered artifact of c. An error is a store that cannot be read or written: the claim is then
|
||||
// built as before, unnumbered, and said.
|
||||
func (n *Numberer) Number(ctx context.Context, c BuildClaim) (Numbering, error) {
|
||||
out := Numbering{Versions: map[string]string{}, Names: map[string]string{}}
|
||||
arts := append([]ClaimArtifact(nil), c.Artifacts...)
|
||||
sort.Slice(arts, func(i, j int) bool { return arts[i].Artifact < arts[j].Artifact })
|
||||
for _, a := range arts {
|
||||
seat, kind, ok := SeatOf(a.Kind, a.Language)
|
||||
if !ok || (c.Tag != "" && kind != KindPackage) {
|
||||
continue
|
||||
}
|
||||
name := NameOf(c.Module, a.Artifact)
|
||||
if kind == KindPackage && n.Names != nil {
|
||||
// A package is numbered, asked for its floor and published under its name in its ecosystem (design 32), the
|
||||
// name the holder's builder_versions question and the seat's versions name it by.
|
||||
named, known, err := n.Names(ctx, c.Module, a.Artifact)
|
||||
if err != nil || !known {
|
||||
why := "the indexer's packages state names no name of it in its ecosystem yet (it writes one at the module's next trunk merge)"
|
||||
if err != nil {
|
||||
why = "the indexer's packages state cannot be read: " + err.Error()
|
||||
}
|
||||
out.Unnumbered = append(out.Unnumbered, a.Artifact)
|
||||
out.Said = append(out.Said, Narration{Line: name + ": built unnumbered: " + why, Why: SaidUnnumbered})
|
||||
continue
|
||||
}
|
||||
name = named
|
||||
}
|
||||
out.Names[a.Artifact] = name
|
||||
entries, err := n.Versions.Of(ctx, name)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var floor Floor
|
||||
var why map[string]string
|
||||
if len(entries) == 0 {
|
||||
floor, why = n.askFloor(ctx, seat, name, c)
|
||||
}
|
||||
d, e, err := n.Versions.Number(ctx, Claim{Name: name, Kind: kind, Series: c.Series, Module: c.Module, Fingerprint: c.Fingerprint,
|
||||
Commit: c.Commit, Revision: c.Revision, Digest: c.Done[a.Artifact], Seat: seat, Merge: c.Merge, Floor: floor, Tag: c.Tag, Repository: c.Repository})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Said = append(out.Said, narrationOf(name, d))
|
||||
switch {
|
||||
case d.Wait:
|
||||
n.wait(name, seat, floor, why)
|
||||
out.Wait = name + ": " + d.Why
|
||||
return out, nil
|
||||
case d.Unnumbered:
|
||||
n.wait(name, seat, floor, why)
|
||||
out.Unnumbered = append(out.Unnumbered, a.Artifact)
|
||||
continue
|
||||
}
|
||||
if n.Waits != nil {
|
||||
n.Waits.Answered(name)
|
||||
}
|
||||
out.Versions[a.Artifact] = d.Version
|
||||
if !d.Keep && n.Said != nil {
|
||||
n.Said(NumberedOf(e, c.Head))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (n *Numberer) askFloor(ctx context.Context, seat, name string, c BuildClaim) (Floor, map[string]string) {
|
||||
return n.Floor.Ask(ctx, FloorOf{Seat: seat, Name: name, Repository: c.Repository, Prefix: TagPrefix(c.Path)})
|
||||
}
|
||||
|
||||
func (n *Numberer) wait(name, seat string, f Floor, why map[string]string) {
|
||||
if n.Waits == nil {
|
||||
return
|
||||
}
|
||||
holder := ""
|
||||
if n.Holder != nil {
|
||||
holder = n.Holder(seat)
|
||||
}
|
||||
now := time.Now()
|
||||
if n.Now != nil {
|
||||
now = n.Now()
|
||||
}
|
||||
n.Waits.Wait(name, seat, holder, f, why, now)
|
||||
}
|
||||
|
||||
// Built records the digest each numbered artifact was built with, on an entry that holds none yet or was never published: what the holder's
|
||||
// builder_versions question compares (ADR 0605 §4).
|
||||
func (n *Numberer) Built(ctx context.Context, c BuildClaim, got Numbering, digests map[string]string) error {
|
||||
var errs []string
|
||||
for artifact, version := range got.Versions {
|
||||
d := digests[artifact]
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
e, err := n.Versions.Mark(ctx, got.NameOf(c.Module, artifact), version, func(e *Entry) {
|
||||
// The new digest replaces the recorded one only while no published was said for the number (design 51
|
||||
// *Tools of the builder*): a rebuild of a gone file under the same number.
|
||||
if e.Digest == "" || e.State == Numbered || e.State == Refused {
|
||||
e.Digest = d
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
if e.State == Numbered && n.Publishing != nil {
|
||||
n.Publishing(ctx, e)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
sort.Strings(errs)
|
||||
return fmt.Errorf("the built digests could not be recorded: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FloorSources are the three sources of a name's floor (design 51 *The floor*): each a tool call that answers the
|
||||
// versions it knows of a name, or an error when it does not answer.
|
||||
type FloorSources struct {
|
||||
// Seat is `seat:<registry>.versions` of the registry seat the name is published to.
|
||||
Seat func(ctx context.Context, seat, name string) ([]string, error)
|
||||
// Tags is the forge bridge's bridge_tags: the git tags of the repository the name is built from, under prefix.
|
||||
Tags func(ctx context.Context, repository, prefix string) ([]string, error)
|
||||
// Requirements is the indexer's indexer_requirements: the highest pseudo-versions consumers require.
|
||||
Requirements func(ctx context.Context, name string) ([]string, error)
|
||||
}
|
||||
|
||||
// FloorOf names what a floor is asked for: the name on its seat, and the repository and tag prefix of its git tags.
|
||||
type FloorOf struct{ Seat, Name, Repository, Prefix string }
|
||||
|
||||
// Ask asks the three sources of a name; a source that errs, or is not wired, is named in Silent with its error in why.
|
||||
func (s FloorSources) Ask(ctx context.Context, of FloorOf) (Floor, map[string]string) {
|
||||
seat, name := of.Seat, of.Name
|
||||
var f Floor
|
||||
why := map[string]string{}
|
||||
ask := func(source string, call func() ([]string, error), into *[]string) {
|
||||
if call == nil {
|
||||
f.Silent, why[source] = append(f.Silent, source), "not asked: the builder has no path to it"
|
||||
return
|
||||
}
|
||||
got, err := call()
|
||||
if err != nil {
|
||||
f.Silent, why[source] = append(f.Silent, source), err.Error()
|
||||
return
|
||||
}
|
||||
*into = got
|
||||
}
|
||||
var seatCall, tagsCall, reqCall func() ([]string, error)
|
||||
if s.Seat != nil {
|
||||
seatCall = func() ([]string, error) { return s.Seat(ctx, seat, name) }
|
||||
}
|
||||
if s.Tags != nil {
|
||||
tagsCall = func() ([]string, error) { return s.Tags(ctx, of.Repository, of.Prefix) }
|
||||
}
|
||||
if s.Requirements != nil {
|
||||
reqCall = func() ([]string, error) { return s.Requirements(ctx, name) }
|
||||
}
|
||||
ask(SourceSeat, seatCall, &f.Seat)
|
||||
ask(SourceTags, tagsCall, &f.Tags)
|
||||
ask(SourceRequirements, reqCall, &f.Requirements)
|
||||
return f, why
|
||||
}
|
||||
|
||||
// What wakes a name waiting on its floor, never a timer (design 51, DLV-B3b): the source that was silent saying
|
||||
// something, or its module's health condition clearing.
|
||||
const (
|
||||
// WakePublished is the holder's `published` on the builder's durable consumer of `<seat>.events`.
|
||||
WakePublished = "published"
|
||||
// WakeForge is the forge bridge's next message on its stream `forge`.
|
||||
WakeForge = "forge"
|
||||
// WakeAccepted is the indexer's next `manifest-accepted`.
|
||||
WakeAccepted = "manifest-accepted"
|
||||
// WakeCleared is the condition keeper's clear of a silent module's health condition on `mesh-conditions`.
|
||||
WakeCleared = "condition-cleared"
|
||||
// WakeByHand is builder_floor_again, a hand act.
|
||||
WakeByHand = "by hand"
|
||||
)
|
||||
|
||||
// sourceOfWake is the floor source each event says something of; a clear names the source by its module.
|
||||
var sourceOfWake = map[string]string{WakePublished: SourceSeat, WakeForge: SourceTags, WakeAccepted: SourceRequirements}
|
||||
|
||||
// ModuleOfSource is the module whose health condition's clear wakes a silent source.
|
||||
var ModuleOfSource = map[string]string{
|
||||
SourceTags: "mesh-delivery-forge-bridge",
|
||||
SourceRequirements: "mesh-delivery-indexer",
|
||||
}
|
||||
|
||||
// FloorWait is one name waiting on its floor: the sources still silent and why, since when; the finding the builder
|
||||
// writes names them, cleared once all three answer.
|
||||
type FloorWait struct {
|
||||
Name string `json:"name"`
|
||||
Seat string `json:"seat"`
|
||||
Silent []string `json:"silent"`
|
||||
Why map[string]string `json:"why,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
// Holder is the module holding the name's seat: its condition's clear wakes the seat source.
|
||||
Holder string `json:"holder,omitempty"`
|
||||
}
|
||||
|
||||
// Finding is the floor finding's words.
|
||||
func (w FloorWait) Finding() string {
|
||||
var parts []string
|
||||
for _, s := range w.Silent {
|
||||
p := s
|
||||
if why := w.Why[s]; why != "" {
|
||||
p += " (" + why + ")"
|
||||
}
|
||||
parts = append(parts, p)
|
||||
}
|
||||
return fmt.Sprintf("the floor of %s is not known since %s: %s did not answer; nothing of it is numbered until all three answer",
|
||||
w.Name, w.Since.UTC().Format(time.RFC3339), strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// The builder's state floor-waits (DLV-B4): each name waiting on its floor, keyed by its escaped name, so a restarted
|
||||
// builder still knows which names its silent sources' events wake; builder_floor_waits reads it.
|
||||
const (
|
||||
FloorWaitsState = "floor-waits"
|
||||
FloorWaitsBucket = "mesh-delivery-builder_floor-waits"
|
||||
FloorWaitsTool = "builder_floor_waits"
|
||||
)
|
||||
|
||||
// FloorWaitKey is a wait's key in floor-waits.
|
||||
func FloorWaitKey(name string) string { return EscapeToken(name) }
|
||||
|
||||
// FloorKeeper keeps the waits on the bus: Put a wait's value, Delete its key.
|
||||
type FloorKeeper interface {
|
||||
Put(key string, value []byte) error
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// FloorWaits are the names waiting on their floor. Wake answers which names to ask again for an event, Answered drops
|
||||
// a name once all three answered; the builder's finding for each is the wait's Finding. Once Kept, every change is
|
||||
// written to the state floor-waits as well.
|
||||
type FloorWaits struct {
|
||||
mu sync.Mutex
|
||||
waits map[string]FloorWait
|
||||
keeper FloorKeeper
|
||||
// Logf says a wait that could not be kept; nil says nothing.
|
||||
Logf func(string, ...any)
|
||||
// Said is told each wait recorded (raised) and each answered (not raised): the builder's finding floor-silent.
|
||||
Said func(w FloorWait, raised bool)
|
||||
}
|
||||
|
||||
// FloorSilent is the kind of the floor finding.
|
||||
const FloorSilent = "floor-silent"
|
||||
|
||||
// Keep binds the state: the waits it holds that are not known here are taken in (a restarted builder's), every wait
|
||||
// known here is written to it, and every change from now on is.
|
||||
func (fw *FloorWaits) Keep(k FloorKeeper, held []FloorWait) {
|
||||
fw.mu.Lock()
|
||||
if fw.waits == nil {
|
||||
fw.waits = map[string]FloorWait{}
|
||||
}
|
||||
for _, w := range held {
|
||||
if _, had := fw.waits[w.Name]; !had && w.Name != "" {
|
||||
fw.waits[w.Name] = w
|
||||
}
|
||||
}
|
||||
fw.keeper = k
|
||||
all := make([]FloorWait, 0, len(fw.waits))
|
||||
for _, w := range fw.waits {
|
||||
all = append(all, w)
|
||||
}
|
||||
fw.mu.Unlock()
|
||||
for _, w := range all {
|
||||
fw.put(k, w)
|
||||
}
|
||||
}
|
||||
|
||||
func (fw *FloorWaits) put(k FloorKeeper, w FloorWait) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(w)
|
||||
if err == nil {
|
||||
err = k.Put(FloorWaitKey(w.Name), data)
|
||||
}
|
||||
if err != nil && fw.Logf != nil {
|
||||
fw.Logf("the floor wait of %s is not kept in %s (a restart forgets it until its next claim): %v", w.Name, FloorWaitsState, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DecodeFloorWait reads one value of floor-waits.
|
||||
func DecodeFloorWait(raw []byte) (FloorWait, error) {
|
||||
var w FloorWait
|
||||
if err := json.Unmarshal(raw, &w); err != nil {
|
||||
return FloorWait{}, err
|
||||
}
|
||||
if w.Name == "" {
|
||||
return FloorWait{}, errors.New("a floor wait names no name")
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Wait records that name's floor is not known.
|
||||
func (fw *FloorWaits) Wait(name, seat, holder string, f Floor, why map[string]string, now time.Time) FloorWait {
|
||||
fw.mu.Lock()
|
||||
if fw.waits == nil {
|
||||
fw.waits = map[string]FloorWait{}
|
||||
}
|
||||
w, had := fw.waits[name]
|
||||
if !had {
|
||||
w.Since = now
|
||||
}
|
||||
w.Name, w.Seat, w.Holder, w.Silent, w.Why = name, seat, holder, slices.Clone(f.Silent), why
|
||||
fw.waits[name] = w
|
||||
k := fw.keeper
|
||||
fw.mu.Unlock()
|
||||
fw.put(k, w)
|
||||
if fw.Said != nil {
|
||||
fw.Said(w, true)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Answered drops name: its floor is known. It says whether the name waited, so its finding is cleared.
|
||||
func (fw *FloorWaits) Answered(name string) bool {
|
||||
fw.mu.Lock()
|
||||
w, had := fw.waits[name]
|
||||
delete(fw.waits, name)
|
||||
k := fw.keeper
|
||||
fw.mu.Unlock()
|
||||
if had && fw.Said != nil {
|
||||
fw.Said(w, false)
|
||||
}
|
||||
if had && k != nil {
|
||||
if err := k.Delete(FloorWaitKey(name)); err != nil && fw.Logf != nil {
|
||||
fw.Logf("the floor wait of %s is not cleared from %s: %v", name, FloorWaitsState, err)
|
||||
}
|
||||
}
|
||||
return had
|
||||
}
|
||||
|
||||
// FloorWaitsAnswer is what builder_floor_waits answers: every name waiting on its floor, oldest first, with its finding.
|
||||
type FloorWaitsAnswer struct {
|
||||
Waits []FloorWaitRead `json:"waits"`
|
||||
}
|
||||
|
||||
// FloorWaitRead is one wait as builder_floor_waits shows it.
|
||||
type FloorWaitRead struct {
|
||||
FloorWait
|
||||
Finding string `json:"finding"`
|
||||
}
|
||||
|
||||
// AnswerFloorWaits is builder_floor_waits, read from the state floor-waits.
|
||||
func AnswerFloorWaits(r Reader) (FloorWaitsAnswer, error) {
|
||||
keys, err := r.Keys()
|
||||
if err != nil {
|
||||
return FloorWaitsAnswer{}, err
|
||||
}
|
||||
out := FloorWaitsAnswer{Waits: []FloorWaitRead{}}
|
||||
for _, k := range keys {
|
||||
raw, ok, err := r.Get(k)
|
||||
if err != nil {
|
||||
return FloorWaitsAnswer{}, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
w, err := DecodeFloorWait(raw)
|
||||
if err != nil {
|
||||
return FloorWaitsAnswer{}, fmt.Errorf("%s.%s: %w", FloorWaitsState, k, err)
|
||||
}
|
||||
out.Waits = append(out.Waits, FloorWaitRead{FloorWait: w, Finding: w.Finding()})
|
||||
}
|
||||
sort.Slice(out.Waits, func(i, j int) bool {
|
||||
if !out.Waits[i].Since.Equal(out.Waits[j].Since) {
|
||||
return out.Waits[i].Since.Before(out.Waits[j].Since)
|
||||
}
|
||||
return out.Waits[i].Name < out.Waits[j].Name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FloorWaitsDescription is builder_floor_waits' description.
|
||||
const FloorWaitsDescription = "The names waiting on their floor (the builder's state floor-waits, design 51 *The floor*), oldest first: " +
|
||||
"each name, its registry seat, the sources still silent and why, since when, and the finding the builder says for it. " +
|
||||
"Nothing of a waiting name is numbered until all three sources answer; builder_floor_again asks one again by hand. Read-only."
|
||||
|
||||
// Wake answers the names to ask their floor again on an event: kind one of the Wake constants; seat the seat a
|
||||
// `published` came on; module the module whose condition cleared. A name is woken only when the event says something
|
||||
// of a source it waits on; WakeByHand with a name wakes that name alone.
|
||||
func (fw *FloorWaits) Wake(kind, seat, module, name string) []string {
|
||||
fw.mu.Lock()
|
||||
defer fw.mu.Unlock()
|
||||
var out []string
|
||||
for n, w := range fw.waits {
|
||||
switch kind {
|
||||
case WakeByHand:
|
||||
if n == name {
|
||||
out = append(out, n)
|
||||
}
|
||||
case WakeCleared:
|
||||
for _, s := range w.Silent {
|
||||
if ModuleOfSource[s] == module || (s == SourceSeat && w.Holder != "" && w.Holder == module) {
|
||||
out = append(out, n)
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
src := sourceOfWake[kind]
|
||||
if slices.Contains(w.Silent, src) && (kind != WakePublished || seat == "" || seat == w.Seat) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Waiting is every wait, by name.
|
||||
func (fw *FloorWaits) Waiting() []FloorWait {
|
||||
fw.mu.Lock()
|
||||
defer fw.mu.Unlock()
|
||||
out := make([]FloorWait, 0, len(fw.waits))
|
||||
for _, w := range fw.waits {
|
||||
out = append(out, w)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The registry seats' events the builder hears on its durable consumer of each `<seat>.events` (design 51 *How a
|
||||
// publish ends*, DLV-B4).
|
||||
const (
|
||||
HeardPublished = "published"
|
||||
HeardRefused = "refused"
|
||||
HeardRemoved = "removed"
|
||||
HeardKept = "kept"
|
||||
)
|
||||
|
||||
// Outcome is the body of a registry seat's published, refused and removed (mesh-contracts parts/registry Outcome), the
|
||||
// fields the builder reads.
|
||||
type Outcome struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Digest string `json:"digest"`
|
||||
Kind string `json:"kind"`
|
||||
Holder string `json:"holder"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Tags are, on kept, the other tags still naming its digest.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// RemoveOutcome is what a seat's remove answered: kept whole (another tag names its digest), with why and those tags; or
|
||||
// asked, the seat saying removed on its events.
|
||||
type RemoveOutcome struct {
|
||||
Kept bool
|
||||
Reason string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// keep marks a tagged version kept by its seat, so it is asked no more.
|
||||
func (n *Numberer) keep(ctx context.Context, e Entry, reason string, tags []string) error {
|
||||
if reason == "" {
|
||||
reason = "shared-digest"
|
||||
}
|
||||
_, err := n.Versions.Mark(ctx, e.Name, e.Version, func(x *Entry) {
|
||||
x.State, x.Reason, x.KeptWith = Kept, reason, append([]string(nil), tags...)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Heard takes one seat event in: a numbered version's entry turns published (with its digest when it held none),
|
||||
// refused with the holder's reason, removed with its reason and the event's id, or kept (another tag names its digest)
|
||||
// with the other tags; a by-digest publish (no version)
|
||||
// marks nothing. It answers the names waiting on their floor that the event wakes: a `published` says the seat
|
||||
// answers (design 51 *The floor*).
|
||||
func (n *Numberer) Heard(ctx context.Context, seat, event, id string, o Outcome) ([]string, error) {
|
||||
var wake []string
|
||||
if event == HeardPublished && n.Waits != nil {
|
||||
wake = n.Waits.Wake(WakePublished, seat, "", "")
|
||||
}
|
||||
if o.Version == "" {
|
||||
return wake, nil
|
||||
}
|
||||
var change func(*Entry)
|
||||
switch event {
|
||||
case HeardPublished:
|
||||
change = func(e *Entry) {
|
||||
e.State, e.Reason = Published, ""
|
||||
if e.Digest == "" {
|
||||
e.Digest = o.Digest
|
||||
}
|
||||
}
|
||||
case HeardRefused:
|
||||
change = func(e *Entry) {
|
||||
if e.State != Published {
|
||||
e.State, e.Reason, e.RefusedDigest = Refused, o.Reason, o.Digest
|
||||
}
|
||||
}
|
||||
case HeardRemoved:
|
||||
change = func(e *Entry) { e.State, e.Reason, e.RemovedBy = Removed, o.Reason, id }
|
||||
case HeardKept:
|
||||
change = func(e *Entry) {
|
||||
if e.State != Removed {
|
||||
e.State, e.Reason, e.KeptWith = Kept, o.Reason, append([]string(nil), o.Tags...)
|
||||
if e.Reason == "" {
|
||||
e.Reason = "shared-digest"
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return wake, fmt.Errorf("%s says %q, which the builder does not hear", seat, event)
|
||||
}
|
||||
if _, err := n.Versions.Mark(ctx, o.Name, o.Version, change); err != nil {
|
||||
return wake, fmt.Errorf("%s's %s of %s@%s: %w", seat, event, o.Name, o.Version, err)
|
||||
}
|
||||
return wake, nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// PackagesState is the indexer's packages state as the builder reads it (its manifest's reads grant): its keys, and one
|
||||
// key's value (found false when it holds none).
|
||||
type PackagesState interface {
|
||||
Keys(ctx context.Context) ([]string, error)
|
||||
Get(ctx context.Context, key string) (value []byte, found bool, err error)
|
||||
}
|
||||
|
||||
// NamesIn are a package's names in its ecosystem as the indexer's packages state holds them (design 32): the name of the
|
||||
// series key that names module and artifact as what it is built from. A series key is named `<name>.<series>`; a key
|
||||
// with no dot is a mesh Go module's published packages, not a series. A value that does not decode is passed over.
|
||||
func NamesIn(s PackagesState) PackageNames {
|
||||
return func(ctx context.Context, module, artifact string) (string, bool, error) {
|
||||
keys, err := s.Keys(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
built := NameOf(module, artifact)
|
||||
for _, key := range keys {
|
||||
if !delivery.IsSeriesPackageKey(key) {
|
||||
continue
|
||||
}
|
||||
value, found, err := s.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
v, err := delivery.DecodeSeriesPackage(value) // the indexer's value of a series key (mesh-sdk's contract, J4)
|
||||
if err != nil || v.Package != built {
|
||||
continue
|
||||
}
|
||||
return v.Name, true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NamedModule is the module a value of the indexer's packages state names a package's name for (design 32), and that
|
||||
// name: a series key whose value names a name and the <module>/<artifact> it is built from. The builder looks at that module again when
|
||||
// the value is heard, so a done build that left the package unnumbered (its name not known at its claim) is numbered then.
|
||||
func NamedModule(key string, value []byte) (module, name string, ok bool) {
|
||||
if !delivery.IsSeriesPackageKey(key) {
|
||||
return "", "", false
|
||||
}
|
||||
v, err := delivery.DecodeSeriesPackage(value)
|
||||
if err != nil || v.Name == "" {
|
||||
return "", "", false
|
||||
}
|
||||
module, artifact, cut := strings.Cut(v.Package, "/")
|
||||
if !cut || module == "" || artifact == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return module, v.Name, true
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624 §1, ADR 0618 §2-§4,
|
||||
// DLV-B9). Which forms a build key keeps is mesh-delivery's sdk/forms (Kept, Choose), the counted nodes' statements the
|
||||
// builder's state engine-reads; this is only the window over them: open while a counted node-engine, present or away,
|
||||
// reads of some artifact kind none of the newest form the builder knows for it, so an older form stays in new build keys
|
||||
// for it; closed, said once with `old-form-closed` and a log line, when none does. It moves on what moves a statement
|
||||
// (the node-engine seat's verb `manifest-reads` and its event `manifest-reads-changed`), the placement, a hand drop and
|
||||
// each build key written; never on a timer.
|
||||
|
||||
// OldFormState is the builder's state of the window: one key, OldFormKey, an OldFormWindow. builder_old_form reads it.
|
||||
const (
|
||||
OldFormState = "old-form"
|
||||
OldFormKey = "window"
|
||||
)
|
||||
|
||||
// OldFormBucket is that state's bucket.
|
||||
const OldFormBucket = delivery.BuilderModule + "_" + OldFormState
|
||||
|
||||
// OldFormHeld is the finding of a node holding the window open for FindingAfter, about that node, cleared when the
|
||||
// window closes or the node no longer holds it.
|
||||
const OldFormHeld = "old-form-held"
|
||||
|
||||
// Holding are the counted nodes, those dropped by hand left out, whose engine reads, of some artifact kind fs knows, not
|
||||
// the newest form of that kind: the nodes for which an older form stays in new build keys (forms.ArtifactForms.Kept). A
|
||||
// node stating nothing reads the first forms (fs.Reads), sorted.
|
||||
func Holding(fs forms.ArtifactForms, counted map[string][]string, dropped map[string]bool) []string {
|
||||
newest := map[string]string{} // kind -> newest form
|
||||
var kinds []string
|
||||
for _, f := range fs {
|
||||
for _, k := range f.Kinds {
|
||||
if _, ok := newest[k]; !ok {
|
||||
kinds = append(kinds, k)
|
||||
}
|
||||
newest[k] = f.Name
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for node, stated := range counted {
|
||||
if dropped[node] {
|
||||
continue
|
||||
}
|
||||
for _, k := range kinds {
|
||||
if !fs.Reads(stated, newest[k]) {
|
||||
out = append(out, node)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// OldFormWindow is the window's record: whether it is open, who holds it and since when (delivery.WindowHeld, the part
|
||||
// every window keeper shares), and the nodes dropped by hand; what builder_old_form answers and what `old-form-closed`
|
||||
// carries when it closes.
|
||||
type OldFormWindow struct {
|
||||
delivery.WindowHeld
|
||||
Dropped []Dropped `json:"dropped,omitempty"`
|
||||
}
|
||||
|
||||
// Dropped is one retired node dropped from the count by hand (builder_old_form): a hand act with who and why.
|
||||
type Dropped struct {
|
||||
Node string `json:"node"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// OldFormClosedBody is the body of OldFormClosedEvent (design 51): when, the nodes that held it last and the nodes dropped
|
||||
// by hand.
|
||||
type OldFormClosedBody struct {
|
||||
At time.Time `json:"at"`
|
||||
LastHolding []string `json:"last_holding,omitempty"`
|
||||
Dropped []Dropped `json:"dropped,omitempty"`
|
||||
}
|
||||
|
||||
// FindingAfter is how long a node holds the window open before it is the builder's finding naming the node.
|
||||
const FindingAfter = 7 * 24 * time.Hour
|
||||
|
||||
// Step moves the window by mesh-sdk's one window rule (delivery.StepWindow): named is every counted node the read named,
|
||||
// those dropped by hand included, and holding those of them holding the window (Holding). A read naming no node (no
|
||||
// placement read yet, an empty node_haves) judges nothing, so it never closes the window. It answers the new window,
|
||||
// whether it closed in this step (so the builder says `old-form-closed` and its log line once), and the nodes holding it
|
||||
// past FindingAfter (the finding, cleared for every other node).
|
||||
func (w OldFormWindow) Step(named, holding []string, now time.Time) (next OldFormWindow, closed bool, overdue []string) {
|
||||
held, closed, overdue := delivery.StepWindow(w.WindowHeld, named, holding, now, FindingAfter)
|
||||
return OldFormWindow{WindowHeld: held, Dropped: w.Dropped}, closed, overdue
|
||||
}
|
||||
|
||||
// Drop drops a retired node from the count by hand; dropping it again changes nothing.
|
||||
func (w OldFormWindow) Drop(node, by, why string, at time.Time) (OldFormWindow, error) {
|
||||
node, why = strings.TrimSpace(node), strings.TrimSpace(why)
|
||||
if node == "" || why == "" {
|
||||
return w, errors.New("name the retired node (node) and say why it leaves the count (why)")
|
||||
}
|
||||
if w.DroppedSet()[node] {
|
||||
return w, nil
|
||||
}
|
||||
w.Dropped = append(slices.Clone(w.Dropped), Dropped{Node: node, By: by, Why: why, At: at.UTC()})
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// DroppedSet is the dropped nodes, as Holding takes them.
|
||||
func (w OldFormWindow) DroppedSet() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, d := range w.Dropped {
|
||||
out[d.Node] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Closing is the event's body of a window that closed in a Step.
|
||||
func (w OldFormWindow) Closing() OldFormClosedBody {
|
||||
return OldFormClosedBody{At: w.Closed, LastHolding: w.LastHolding, Dropped: w.Dropped}
|
||||
}
|
||||
|
||||
// DecodeOldFormWindow reads the state's value; none is a closed window no node holds.
|
||||
func DecodeOldFormWindow(raw []byte) (OldFormWindow, error) {
|
||||
var w OldFormWindow
|
||||
if len(raw) == 0 {
|
||||
return w, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &w); err != nil {
|
||||
return OldFormWindow{}, fmt.Errorf("the old-form window is not readable: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// OldFormDescription is builder_old_form's description.
|
||||
const OldFormDescription = "The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624): whether it is open, " +
|
||||
"the counted nodes, present or away, whose node-engine reads an older reference form alone and since when (a node holding it seven days " +
|
||||
"is the builder's finding " + OldFormHeld + "), when it last closed and who held it then, and the retired nodes dropped from the count by " +
|
||||
"hand. With drop (a node) and why, drops that retired node from the count by hand, a hand act the builder answers; a node still placed " +
|
||||
"leaves the count by itself only when the placement no longer names it. builder_engine_reads shows what each node's engine states."
|
||||
|
||||
// AnswerOldForm is builder_old_form's read: the window as the state holds it.
|
||||
func AnswerOldForm(r Reader) (OldFormWindow, error) {
|
||||
raw, ok, err := r.Get(OldFormKey)
|
||||
if err != nil || !ok {
|
||||
return OldFormWindow{}, err
|
||||
}
|
||||
return DecodeOldFormWindow(raw)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A publish that ends badly is loud** (design 51 *Health and conditions*; review round 1): the builder raises the
|
||||
// finding publish-refused when a holder says refused, and publish-unanswered when a version it built and numbered is
|
||||
// neither published nor refused within PublishBound. Each is about the name, so a later published of any version of it
|
||||
// clears both. A seat's outcome that does not read is the finding seat-outcome-unread about the seat, cleared by its
|
||||
// next outcome that reads.
|
||||
//
|
||||
// The bound is a key of the builder's state bounds per version (PublishBoundKey), written with its own time to live
|
||||
// when the build is done and the version is still numbered, deleted at the holder's word; the bus's marker of its end,
|
||||
// heard on the watch, is what raises publish-unanswered while the version is still numbered. No timer.
|
||||
const (
|
||||
// BoundsState is the builder's state of its expiring keys, each with its own time to live: a publish's bound
|
||||
// (PublishBoundKey) and a node's seven days holding the old form's window (OldFormBoundKey). Its end, the bus's
|
||||
// marker heard on the watch, is what acts.
|
||||
BoundsState = "bounds"
|
||||
BoundsBucket = "mesh-delivery-builder_bounds"
|
||||
// PublishBound is the build module's own wait for the holder's word (design 51 *How a publish ends*: at most
|
||||
// 10 minutes), so a version unanswered past it is one the holder never answered.
|
||||
PublishBound = 10 * time.Minute
|
||||
|
||||
PublishRefused = "publish-refused"
|
||||
PublishUnanswered = "publish-unanswered"
|
||||
SeatOutcomeUnread = "seat-outcome-unread"
|
||||
)
|
||||
|
||||
// The prefixes of the keys of BoundsState.
|
||||
const (
|
||||
publishBoundPrefix = "publish."
|
||||
oldFormBoundPrefix = "old-form."
|
||||
)
|
||||
|
||||
// PublishBoundKey is the bound of name's version: `publish.<versions key>`.
|
||||
func PublishBoundKey(name, version string) string { return publishBoundPrefix + Key(name, version) }
|
||||
|
||||
// OldFormBoundKey is the bound of node's seven days holding the old form's window: `old-form.<node>`.
|
||||
func OldFormBoundKey(node string) string { return oldFormBoundPrefix + EscapeToken(node) }
|
||||
|
||||
// PublishBoundOf is the versions key a publish's bound names, and whether key is one; a key that is no publish's
|
||||
// bound is an old form's (IsOldFormBound) or nothing the builder wrote.
|
||||
func PublishBoundOf(key string) (string, bool) { return strings.CutPrefix(key, publishBoundPrefix) }
|
||||
|
||||
// IsOldFormBound says key is a node's bound of the old form's window: its end steps the window, which raises the
|
||||
// finding for every node past FindingAfter.
|
||||
func IsOldFormBound(key string) bool { return strings.HasPrefix(key, oldFormBoundPrefix) }
|
||||
|
||||
// BoundsTool is builder_bounds: every key of BoundsState, what it bounds and when it ends.
|
||||
const BoundsTool = "builder_bounds"
|
||||
|
||||
// BoundsDescription is builder_bounds' description.
|
||||
const BoundsDescription = "The builder's expiring keys, its state " + BoundsState + " (design 51 *Health and conditions*): each key with what it " +
|
||||
"bounds (a version's publish, whose end unanswered is the finding " + PublishUnanswered + "; or a node's seven days holding the old " +
|
||||
"form's window, whose end is the finding " + OldFormHeld + "), when it ends, and how long is left. The bus ends each key by its own time to live."
|
||||
|
||||
// Bound is a key's value in BoundsState: what it bounds, in plain words, and when it ends.
|
||||
type Bound struct {
|
||||
Bounds string `json:"bounds"`
|
||||
Ends time.Time `json:"ends"`
|
||||
}
|
||||
|
||||
// EncodeBound is a Bound's bytes.
|
||||
func EncodeBound(what string, ends time.Time) []byte {
|
||||
raw, _ := json.Marshal(Bound{Bounds: what, Ends: ends.UTC()})
|
||||
return raw
|
||||
}
|
||||
|
||||
// BoundRow is one row of builder_bounds.
|
||||
type BoundRow struct {
|
||||
Key string `json:"key"`
|
||||
Bounds string `json:"bounds"`
|
||||
Ends time.Time `json:"ends,omitzero"`
|
||||
Left string `json:"left,omitempty"`
|
||||
}
|
||||
|
||||
// AnswerBounds is builder_bounds: every key, sorted, with what it bounds and when it ends; a value that does not read
|
||||
// says so in bounds.
|
||||
func AnswerBounds(r Reader, now time.Time) ([]BoundRow, error) {
|
||||
keys, err := r.Keys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the state %s cannot be listed: %w", BoundsState, err)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := []BoundRow{}
|
||||
for _, k := range keys {
|
||||
raw, ok, err := r.Get(k)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
var b Bound
|
||||
row := BoundRow{Key: k}
|
||||
if json.Unmarshal(raw, &b) != nil || b.Bounds == "" {
|
||||
row.Bounds = "a value that does not read: " + string(raw)
|
||||
} else {
|
||||
row.Bounds, row.Ends, row.Left = b.Bounds, b.Ends, b.Ends.Sub(now).Round(time.Second).String()
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AllFilter is the key filter of every version of every name: two tokens.
|
||||
const AllFilter = "*.*"
|
||||
|
||||
// ErrConflict is what a Store answers when a Create finds the key or an Update finds another revision.
|
||||
var ErrConflict = errors.New("the key exists or changed")
|
||||
|
||||
// Store is the bucket: create and compare-and-set, as the builder's builds bucket (internal/build.Store).
|
||||
type Store interface {
|
||||
Get(ctx context.Context, key string) (value []byte, revision uint64, found bool, err error)
|
||||
Create(ctx context.Context, key string, value []byte) (uint64, error)
|
||||
Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error)
|
||||
// KeysOf are the keys under a filter (NameFilter, or AllFilter for every key).
|
||||
KeysOf(ctx context.Context, filter string) ([]string, error)
|
||||
}
|
||||
|
||||
// Versions is the builder's state.
|
||||
type Versions struct {
|
||||
Store Store
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Of is every entry of name.
|
||||
func (v Versions) Of(ctx context.Context, name string) ([]Entry, error) {
|
||||
keys, err := v.Store.KeysOf(ctx, NameFilter(name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the versions of %s cannot be listed: %w", name, err)
|
||||
}
|
||||
var out []Entry
|
||||
for _, k := range keys {
|
||||
raw, _, found, err := v.Store.Get(ctx, k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", k, err)
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
e, err := Decode(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s holds what is not a version: %w", k, err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return InOrder(out), nil
|
||||
}
|
||||
|
||||
// Claim is one numbering asked of the builder.
|
||||
type Claim struct {
|
||||
Name, Kind, Series, Module, Fingerprint, Commit, Revision, Digest, Seat string
|
||||
// Repository is the module's repository, kept on a tagged version for its branch's end.
|
||||
Repository string
|
||||
// Tag asks a tagged version for this cleaned tag; empty for a trunk version.
|
||||
Tag string
|
||||
Merge uint64
|
||||
Floor Floor
|
||||
// Fresh numbers anew a fingerprint or revision already numbered: builder_publish_again after a published (design 51
|
||||
// *Tools of the builder*).
|
||||
Fresh bool
|
||||
}
|
||||
|
||||
// Number numbers a claim under compare-and-set: two claims of one name never get one number, the second reading the
|
||||
// first's key and taking the next (ADR 0606 §2). It answers the decision and, when it numbered or kept a version, its
|
||||
// entry. Attempts are bounded by the claims the name can meet at once; a store that keeps conflicting is an error.
|
||||
func (v Versions) Number(ctx context.Context, c Claim) (Decision, Entry, error) {
|
||||
if c.Series == "" {
|
||||
c.Series = DefaultSeries
|
||||
}
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
entries, err := v.Of(ctx, c.Name)
|
||||
if err != nil {
|
||||
return Decision{}, Entry{}, err
|
||||
}
|
||||
var d Decision
|
||||
if c.Tag != "" {
|
||||
if !c.Floor.Known() && len(entries) == 0 {
|
||||
return Decision{Wait: true, Why: "the tagged version waits for its floor"}, Entry{}, nil
|
||||
}
|
||||
ver := Tagged(entries, c.Series, c.Tag, c.Floor)
|
||||
d = Decision{Version: ver, Publish: true, Why: "tagged " + ver}
|
||||
for _, e := range entries {
|
||||
// The same fingerprint claimed again for its tag (a check's build looked at again) keeps its number.
|
||||
if e.Tag == CleanTag(c.Tag) && e.Fingerprint == c.Fingerprint && e.State != Removed {
|
||||
d = Decision{Version: e.Version, Keep: true, Publish: true, Why: "tagged " + e.Version + ", kept for this fingerprint"}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
d = Decide(fresh(entries, c), c.Kind, c.Series, c.Fingerprint, c.Revision, c.Floor)
|
||||
}
|
||||
if d.Version == "" {
|
||||
return d, Entry{}, nil
|
||||
}
|
||||
if d.Keep {
|
||||
for _, e := range entries {
|
||||
if e.Version == d.Version {
|
||||
return d, e, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
e := Entry{Name: c.Name, Kind: c.Kind, Series: c.Series, Version: d.Version, Tag: c.Tag, Module: c.Module,
|
||||
Fingerprint: c.Fingerprint, Commit: c.Commit, Revision: c.Revision, Digest: c.Digest, Seat: c.Seat,
|
||||
State: Numbered, At: v.now(), Merge: c.Merge}
|
||||
if c.Tag != "" {
|
||||
e.Repository = c.Repository
|
||||
}
|
||||
raw, err := Encode(e)
|
||||
if err != nil {
|
||||
return Decision{}, Entry{}, err
|
||||
}
|
||||
_, err = v.Store.Create(ctx, Key(c.Name, d.Version), raw)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
continue // another claim took this number: read again and take the next
|
||||
}
|
||||
if err != nil {
|
||||
return Decision{}, Entry{}, fmt.Errorf("%s@%s: %w", c.Name, d.Version, err)
|
||||
}
|
||||
return d, e, nil
|
||||
}
|
||||
return Decision{}, Entry{}, fmt.Errorf("%s: the versions kept changing under 16 claims", c.Name)
|
||||
}
|
||||
|
||||
// Mark moves one version to a new state by compare-and-set, as the holders' events say (published, refused, removed):
|
||||
// change edits the entry read; a version the state does not hold is an error naming it.
|
||||
func (v Versions) Mark(ctx context.Context, name, version string, change func(*Entry)) (Entry, error) {
|
||||
key := Key(name, version)
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
raw, rev, found, err := v.Store.Get(ctx, key)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
if !found {
|
||||
return Entry{}, fmt.Errorf("%s@%s is not numbered by the builder", name, version)
|
||||
}
|
||||
e, err := Decode(raw)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("%s holds what is not a version: %w", key, err)
|
||||
}
|
||||
change(&e)
|
||||
e.At = v.now()
|
||||
out, err := Encode(e)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if _, err := v.Store.Update(ctx, key, out, rev); errors.Is(err, ErrConflict) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return Entry{}, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
return Entry{}, fmt.Errorf("%s@%s kept changing under 16 marks", name, version)
|
||||
}
|
||||
|
||||
// Recorded is the holder's question through builder_versions (ADR 0605 §4): whether name, version and digest are a
|
||||
// version the builder numbered. It answers the entry when they are, and why not otherwise.
|
||||
func (v Versions) Recorded(ctx context.Context, name, version, digest string) (Entry, bool, string, error) {
|
||||
raw, _, found, err := v.Store.Get(ctx, Key(name, version))
|
||||
if err != nil {
|
||||
return Entry{}, false, "", err
|
||||
}
|
||||
if !found {
|
||||
return Entry{}, false, fmt.Sprintf("%s@%s is not a version the builder numbered", name, version), nil
|
||||
}
|
||||
e, err := Decode(raw)
|
||||
if err != nil {
|
||||
return Entry{}, false, "", err
|
||||
}
|
||||
switch {
|
||||
case e.State == Removed:
|
||||
return e, false, fmt.Sprintf("%s@%s was removed (%s)", name, version, e.Reason), nil
|
||||
case e.Digest != "" && digest != "" && e.Digest != digest:
|
||||
return e, false, fmt.Sprintf("%s@%s is recorded with digest %s, not %s", name, version, e.Digest, digest), nil
|
||||
}
|
||||
return e, true, "", nil
|
||||
}
|
||||
|
||||
func (v Versions) now() time.Time {
|
||||
if v.Now != nil {
|
||||
return v.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// fresh is entries as Decide reads them for c: with Fresh, the entries of c's fingerprint and revision still count for the
|
||||
// next number but keep none.
|
||||
func fresh(entries []Entry, c Claim) []Entry {
|
||||
if !c.Fresh {
|
||||
return entries
|
||||
}
|
||||
out := make([]Entry, len(entries))
|
||||
for i, e := range entries {
|
||||
if e.Tag == "" && (e.Fingerprint == c.Fingerprint || (c.Revision != "" && e.Revision == c.Revision)) {
|
||||
e.Fingerprint, e.Revision = "", ""
|
||||
}
|
||||
out[i] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// All is every entry of every name, by name in order: what builder_untag and the tag asks read.
|
||||
func (v Versions) All(ctx context.Context) ([]Entry, error) {
|
||||
keys, err := v.Store.KeysOf(ctx, AllFilter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the versions cannot be listed: %w", err)
|
||||
}
|
||||
var out []Entry
|
||||
for _, k := range keys {
|
||||
raw, _, found, err := v.Store.Get(ctx, k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", k, err)
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
e, err := Decode(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s holds what is not a version: %w", k, err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The bound of an unanswered tag ask (design 51 *Tagged versions*: "an ask the builder has not answered within five
|
||||
// minutes is narrated in the build log with what it waits on"): a key of the state tag-ask-bounds, written with its own
|
||||
// time to live, whose expiry the builder hears on its watch of the state. No timer: the bus ends the bound.
|
||||
const (
|
||||
TagAskBoundsState = "tag-ask-bounds"
|
||||
TagAskBoundsBucket = "mesh-delivery-builder_tag-ask-bounds"
|
||||
TagAskBound = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Unanswered says whether a tag ask still waits for its versions: kept, not an untag, none numbered yet.
|
||||
func (t TagAsk) Unanswered() bool {
|
||||
return !t.Untag && t.State == TagAsked && len(t.Versions) == 0
|
||||
}
|
||||
|
||||
// WithNumbered is the tag ask once a check's build of its head numbered module's packages under its tag: numbered, each
|
||||
// version by the name it was numbered under (names, by artifact; NameOf where it names none), the answer naming them. An
|
||||
// untag, or an ask of another head, is left as it is.
|
||||
func (t TagAsk) WithNumbered(module, head string, got, names map[string]string, at time.Time) (TagAsk, bool) {
|
||||
if t.Untag || t.Head != head || len(got) == 0 || t.State == TagRemoving || t.State == TagRemoved {
|
||||
return t, false
|
||||
}
|
||||
if t.Versions == nil {
|
||||
t.Versions = map[string]string{}
|
||||
}
|
||||
changed := false
|
||||
for artifact, v := range got {
|
||||
name := Numbering{Names: names}.NameOf(module, artifact)
|
||||
if t.Versions[name] != v {
|
||||
t.Versions[name], changed = v, true
|
||||
}
|
||||
}
|
||||
if !changed && t.State == TagNumbered {
|
||||
return t, false
|
||||
}
|
||||
var said []string
|
||||
for _, name := range slices.Sorted(maps.Keys(t.Versions)) {
|
||||
said = append(said, name+" "+t.Versions[name])
|
||||
}
|
||||
t.State, t.Answered = TagNumbered, at.UTC()
|
||||
t.Answer = "numbered: " + strings.Join(said, ", ")
|
||||
return t, true
|
||||
}
|
||||
|
||||
// NarrateUnanswered is the line said for a tag ask its bound found unanswered: since when, and what it waits on, the
|
||||
// names waiting on their floor named with their silent sources.
|
||||
func NarrateUnanswered(t TagAsk, waits []FloorWait, now time.Time) string {
|
||||
on := "the check's build of " + short(t.Head) + " is not claimed yet (the checker builds a group member's head once it asks)"
|
||||
var floors []string
|
||||
for _, w := range waits {
|
||||
floors = append(floors, w.Name+" waits on its floor ("+strings.Join(w.Silent, ", ")+" silent)")
|
||||
}
|
||||
if len(floors) > 0 {
|
||||
slices.Sort(floors)
|
||||
on += "; " + strings.Join(floors, "; ")
|
||||
}
|
||||
return fmt.Sprintf("the tag ask of %s %s at %s (%s) is not answered after %s: it waits on %s",
|
||||
t.Repository, t.Branch, short(t.Head), t.Tag, now.Sub(t.At).Round(time.Second), on)
|
||||
}
|
||||
|
||||
func short(head string) string {
|
||||
if len(head) > 12 {
|
||||
return head[:12]
|
||||
}
|
||||
return head
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The builder's tools of its versions (design 51 *Events of the builder*, *Tools of the builder*).
|
||||
const (
|
||||
VersionsTool = "builder_versions"
|
||||
PublishAgainTool = "builder_publish_again"
|
||||
FloorAgainTool = "builder_floor_again"
|
||||
TagTool = "builder_tag"
|
||||
TagAsksTool = "builder_tag_asks"
|
||||
UntagTool = "builder_untag"
|
||||
OldFormTool = "builder_old_form"
|
||||
// OldFormClosedEvent is, with delivery.VersionNumberedEvent, the builder's own family `version`, kept seven days,
|
||||
// apart from its builds' ends, so a waiter on a build's end does not wake on them.
|
||||
OldFormClosedEvent = "version.old-form-closed"
|
||||
)
|
||||
|
||||
// NumberedOf is the version.numbered body of an entry (delivery.VersionNumbered, the contract a build module reads).
|
||||
func NumberedOf(e Entry, head string) delivery.VersionNumbered {
|
||||
return delivery.VersionNumbered{Name: e.Name, Kind: e.Kind, Version: e.Version, Tag: e.Tag, Module: e.Module, Fingerprint: e.Fingerprint,
|
||||
Commit: e.Commit, Digest: e.Digest, Seat: e.Seat, Head: head}
|
||||
}
|
||||
|
||||
// Reader reads the state as the tools bundle may: every key, and one.
|
||||
type Reader struct {
|
||||
Keys func() ([]string, error)
|
||||
Get func(key string) (json.RawMessage, bool, error)
|
||||
}
|
||||
|
||||
// readStore is a Store over a Reader that refuses every write: the tools bundle only reads versions.
|
||||
type readStore struct{ r Reader }
|
||||
|
||||
var errReadOnly = errors.New("the versions are written by the builder alone")
|
||||
|
||||
func (s readStore) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
v, ok, err := s.r.Get(key)
|
||||
return v, 0, ok, err
|
||||
}
|
||||
func (readStore) Create(context.Context, string, []byte) (uint64, error) { return 0, errReadOnly }
|
||||
func (readStore) Update(context.Context, string, []byte, uint64) (uint64, error) {
|
||||
return 0, errReadOnly
|
||||
}
|
||||
func (s readStore) KeysOf(_ context.Context, filter string) ([]string, error) {
|
||||
all, err := s.r.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter == AllFilter {
|
||||
return all, nil
|
||||
}
|
||||
prefix := strings.TrimSuffix(filter, "*")
|
||||
var out []string
|
||||
for _, k := range all {
|
||||
if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// VersionsAnswer is what builder_versions answers.
|
||||
type VersionsAnswer struct {
|
||||
Name string `json:"name"`
|
||||
Versions []Entry `json:"versions"`
|
||||
// Asked, Recorded and Why answer a holder's question for one version and digest (ADR 0605 §4).
|
||||
Asked string `json:"asked,omitempty"`
|
||||
Recorded *bool `json:"recorded,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// AnswerVersions is builder_versions: a name's versions in order, in one call; with a version (and a digest), whether
|
||||
// the builder numbered it with that digest, which the holder asks before it stores (ADR 0605 §4).
|
||||
func AnswerVersions(ctx context.Context, r Reader, name, version, digest string) (VersionsAnswer, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return VersionsAnswer{}, errors.New("name is required: the package, image, bundle or archive name")
|
||||
}
|
||||
v := Versions{Store: readStore{r}}
|
||||
all, err := v.Of(ctx, name)
|
||||
if err != nil {
|
||||
return VersionsAnswer{}, err
|
||||
}
|
||||
out := VersionsAnswer{Name: name, Versions: all}
|
||||
if out.Versions == nil {
|
||||
out.Versions = []Entry{}
|
||||
}
|
||||
if version = strings.TrimSpace(version); version != "" {
|
||||
_, ok, why, err := v.Recorded(ctx, name, version, strings.TrimSpace(digest))
|
||||
if err != nil {
|
||||
return VersionsAnswer{}, fmt.Errorf("%s@%s: %w", name, version, err)
|
||||
}
|
||||
out.Asked, out.Recorded, out.Why = version, &ok, why
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// VersionsDescription is builder_versions' description.
|
||||
const VersionsDescription = "A name's versions as the builder numbered them (its state versions, design 51): every trunk and tagged " +
|
||||
"version of a package, image, bundle or archive in order, each with its kind, series, fingerprint, commit, source revision, digest, " +
|
||||
"seat, state (numbered, published, refused with its reason, removed with its time, reason and the event that removed it, or kept: its seat kept it whole on remove, its digest shared, with the reason and the other tags naming it, kept_with) and when. " +
|
||||
"With version, and digest, it also answers recorded: whether the builder numbered that version with that digest, and why not: the " +
|
||||
"question a registry seat's holder asks before it stores a publish (ADR 0605 §4). Read-only."
|
||||
@@ -1,396 +0,0 @@
|
||||
// Package versions is the builder's state `versions` (novox/hq design 51 *The versions*, ADR 0606): one key per name,
|
||||
// tag and version, written only by the builder under compare-and-set, read by others only through the builder's tool
|
||||
// builder_versions or its events (ADR 0596 §2). It holds what each version is (its fingerprint, commit, source revision
|
||||
// and digest) and where it stands: numbered, published, refused with its reason, or removed with its time, reason and the
|
||||
// event that removed it.
|
||||
//
|
||||
// The state is declared in the builder's old `state` section (ADR 0619: the builder is one of the foundation's nine
|
||||
// parts), history one; its cap is 64 MiB, the most the template state allows (ADR 0595 §3), and past 80 % of it the
|
||||
// builder writes a finding, cleared below it (Fill).
|
||||
package versions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State is the builder state's name; Bucket the bucket the bus keeps it in.
|
||||
const (
|
||||
State = "versions"
|
||||
Bucket = "mesh-delivery-builder_versions"
|
||||
// Cap is the bucket's cap in bytes (64 MiB); FullPercent the share of it past which the builder writes its finding.
|
||||
Cap = 64 << 20
|
||||
FullPercent = 80
|
||||
// DefaultSeries is the series of a name whose manifest declares none (design 32).
|
||||
DefaultSeries = "0.1"
|
||||
)
|
||||
|
||||
// The states a version is in.
|
||||
const (
|
||||
Numbered = "numbered"
|
||||
Published = "published"
|
||||
Refused = "refused"
|
||||
Removed = "removed"
|
||||
// Kept is a tagged version its seat kept whole on remove: another tag names its digest, so the store cannot remove the
|
||||
// tag alone (review round 2; the registry seats' `kept`). Its Reason is the seat's (shared-digest), KeptWith the
|
||||
// other tags; it is asked no more.
|
||||
Kept = "kept"
|
||||
)
|
||||
|
||||
// The kinds the builder numbers: a package per source revision, the rest per fingerprint (design 51).
|
||||
const (
|
||||
KindPackage = "package"
|
||||
KindImage = "image"
|
||||
KindBundle = "bundle"
|
||||
KindArchive = "archive"
|
||||
)
|
||||
|
||||
// Entry is one key of the state: a version of a name, tagged or not.
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Series string `json:"series"`
|
||||
Version string `json:"version"`
|
||||
// Tag is the cleaned tag of a tagged version (`feat-x`); empty for a trunk version.
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Module string `json:"module"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
// Revision is the source revision a package's version is numbered for (one version per revision, design 51).
|
||||
Revision string `json:"revision,omitempty"`
|
||||
// Digest is the digest the build recorded (an image's manifest, a blob's, Go's h1: hash).
|
||||
Digest string `json:"digest,omitempty"`
|
||||
// Seat is the registry seat it is published to.
|
||||
Seat string `json:"seat,omitempty"`
|
||||
State string `json:"state"`
|
||||
At time.Time `json:"at"`
|
||||
// Reason is why it was refused or removed: the holder's reason, a branch's end, retention, the sweep.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Repository is a tagged version's repository: its branch's end removes it (Untagged).
|
||||
Repository string `json:"repository,omitempty"`
|
||||
// KeptWith are the other tags naming its digest, when its seat kept it (Kept).
|
||||
KeptWith []string `json:"kept_with,omitempty"`
|
||||
// RemovedBy is the id of the event that removed it.
|
||||
RemovedBy string `json:"removed_by,omitempty"`
|
||||
// RefusedDigest is the digest the holder refused: one other than Digest means the seat was offered other bytes under
|
||||
// this number, which builder_publish_again refuses to publish again (design 51 *Tools of the builder*).
|
||||
RefusedDigest string `json:"refused_digest,omitempty"`
|
||||
// Merge is the forge bridge's sequence of the merge that numbered it, for merge order.
|
||||
Merge uint64 `json:"merge,omitempty"`
|
||||
}
|
||||
|
||||
// Valid says what is wrong with an entry, or nil.
|
||||
func (e Entry) Valid() error {
|
||||
switch {
|
||||
case strings.TrimSpace(e.Name) == "":
|
||||
return errors.New("a version names no name")
|
||||
case e.Version == "":
|
||||
return fmt.Errorf("%s: a version with no version", e.Name)
|
||||
}
|
||||
switch e.State {
|
||||
case Numbered, Published, Refused, Removed, Kept:
|
||||
default:
|
||||
return fmt.Errorf("%s@%s: unknown state %q", e.Name, e.Version, e.State)
|
||||
}
|
||||
switch e.Kind {
|
||||
case KindPackage, KindImage, KindBundle, KindArchive:
|
||||
default:
|
||||
return fmt.Errorf("%s@%s: unknown kind %q", e.Name, e.Version, e.Kind)
|
||||
}
|
||||
if _, _, err := SplitSeries(e.Series); err != nil {
|
||||
return fmt.Errorf("%s@%s: %w", e.Name, e.Version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encode and Decode are the value's bytes.
|
||||
func Encode(e Entry) ([]byte, error) {
|
||||
if err := e.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
func Decode(raw []byte) (Entry, error) {
|
||||
var e Entry
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
return e, e.Valid()
|
||||
}
|
||||
|
||||
// Key is the state's key of a name's version: two tokens, the name and the version each escaped (EscapeToken), so a
|
||||
// name's versions are the keys under NameFilter and no name's keys fall under another's.
|
||||
func Key(name, version string) string { return EscapeToken(name) + "." + EscapeToken(version) }
|
||||
|
||||
// NameFilter is the key filter of every version of name.
|
||||
func NameFilter(name string) string { return EscapeToken(name) + ".*" }
|
||||
|
||||
// EscapeToken makes s one token of a bus key: letters, digits, '-', '_' and '/' stay; '=' and every other byte become
|
||||
// '=' and two hex digits ('.' is "=2E", '@' "=40"). Two different strings never escape alike.
|
||||
func EscapeToken(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_', c == '/':
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
fmt.Fprintf(&b, "=%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SplitSeries reads a series `<major>.<minor>`.
|
||||
func SplitSeries(series string) (major, minor int, err error) {
|
||||
a, b, ok := strings.Cut(series, ".")
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
|
||||
}
|
||||
if major, err = strconv.Atoi(a); err != nil || major < 0 {
|
||||
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
|
||||
}
|
||||
if minor, err = strconv.Atoi(b); err != nil || minor < 0 {
|
||||
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
|
||||
}
|
||||
return major, minor, nil
|
||||
}
|
||||
|
||||
// Patch is the count of a trunk version of series (`0.1.14` → 14), read leniently: a leading 'v' and a path prefix
|
||||
// (`go/v0.1.13`) are dropped; ok is false for a version of another series or a tagged one.
|
||||
func Patch(series, version string) (int, bool) {
|
||||
if i := strings.LastIndex(version, "/"); i >= 0 {
|
||||
version = version[i+1:]
|
||||
}
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
if strings.ContainsAny(version, "-+") {
|
||||
return 0, false
|
||||
}
|
||||
rest, ok := strings.CutPrefix(version, series+".")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(rest)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// Floor is what the three sources answered for a name (design 51 *The floor*, ADR 0606 §3): the seat's versions, the
|
||||
// package's git tags (the forge bridge's bridge_tags) and the pseudo-versions consumers require (the indexer's
|
||||
// indexer_requirements). A source not answered is listed in Silent.
|
||||
type Floor struct {
|
||||
Seat []string `json:"seat,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Requirements []string `json:"requirements,omitempty"`
|
||||
// Silent names the sources that did not answer: "seat", "bridge_tags", "indexer_requirements".
|
||||
Silent []string `json:"silent,omitempty"`
|
||||
}
|
||||
|
||||
// The floor's sources, as Floor.Silent names them.
|
||||
const (
|
||||
SourceSeat = "seat"
|
||||
SourceTags = "bridge_tags"
|
||||
SourceRequirements = "indexer_requirements"
|
||||
)
|
||||
|
||||
// Known is whether every source answered.
|
||||
func (f Floor) Known() bool { return len(f.Silent) == 0 }
|
||||
|
||||
// Highest is the highest count of series the floor's three sources name, -1 when none names one. A pseudo-version
|
||||
// (`v0.1.14-0.2026…-abc`) counts as the version below it was cut from, so its base (`0.1.13`) counts: Go orders it
|
||||
// before `0.1.14`.
|
||||
func (f Floor) Highest(series string) int {
|
||||
high := -1
|
||||
for _, list := range [][]string{f.Seat, f.Tags, f.Requirements} {
|
||||
for _, v := range list {
|
||||
n, ok := Patch(series, v)
|
||||
if !ok {
|
||||
n, ok = pseudoBase(series, v)
|
||||
}
|
||||
if ok && n > high {
|
||||
high = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return high
|
||||
}
|
||||
|
||||
// pseudoBase is the count a Go pseudo-version stands on: `v0.1.14-0.<time>-<hash>` is cut after `v0.1.13`.
|
||||
func pseudoBase(series, version string) (int, bool) {
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
base, rest, ok := strings.Cut(version, "-")
|
||||
if !ok || !strings.HasPrefix(rest, "0.") || strings.Count(rest, "-") != 1 {
|
||||
return 0, false
|
||||
}
|
||||
n, ok := Patch(series, base)
|
||||
if !ok || n == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n - 1, true
|
||||
}
|
||||
|
||||
// Next is the next trunk version of name in series: one above the highest of the trunk versions entries hold and the
|
||||
// floor (design 51: a name with none starts at `<series>.0`).
|
||||
func Next(entries []Entry, series string, floor Floor) string {
|
||||
high := floor.Highest(series)
|
||||
for _, e := range entries {
|
||||
if e.Tag != "" || e.Series != series {
|
||||
continue
|
||||
}
|
||||
if n, ok := Patch(series, e.Version); ok && n > high {
|
||||
high = n
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s.%d", series, high+1)
|
||||
}
|
||||
|
||||
// Decision is what the builder does with a trunk build at its claim.
|
||||
type Decision struct {
|
||||
// Version is the version the build is published under; empty when it is built unnumbered (Unnumbered) or waits (Wait).
|
||||
Version string
|
||||
// Keep is true when the version is one entries already hold for this fingerprint or source revision.
|
||||
Keep bool
|
||||
// Publish is false only for a package's revision held published: it publishes nothing (design 51).
|
||||
Publish bool
|
||||
// Unnumbered: an image, bundle or archive whose floor is not known is built and published by its digest, numbered
|
||||
// once the floor answers (ADR 0617 §1).
|
||||
Unnumbered bool
|
||||
// Wait: a package whose floor is not known waits for it (a package is published only under its version).
|
||||
Wait bool
|
||||
// Why is said in the build log.
|
||||
Why string
|
||||
}
|
||||
|
||||
// Decide numbers a trunk build of kind for name in series at fingerprint and source revision, from what entries hold of
|
||||
// the name and what the floor answered (design 51 *The versions*, ADR 0606, ADR 0617 §1):
|
||||
// - a package is numbered per source revision: a revision held published keeps its version and publishes nothing; a
|
||||
// revision numbered or refused but never stored is published again under the same version;
|
||||
// - an image, bundle or archive is numbered per fingerprint: a fingerprint already numbered keeps its number;
|
||||
// - otherwise the next version, when the floor is known; else an image, bundle or archive is built unnumbered and a
|
||||
// package waits.
|
||||
func Decide(entries []Entry, kind, series, fingerprint, revision string, floor Floor) Decision {
|
||||
for _, e := range latestFirst(entries) {
|
||||
if e.Tag != "" || e.State == Removed {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case kind == KindPackage && revision != "" && e.Revision == revision:
|
||||
if e.State == Published {
|
||||
return Decision{Version: e.Version, Keep: true, Why: fmt.Sprintf("version %s kept: source revision %s is published under it, and a package's content is its source", e.Version, revision)}
|
||||
}
|
||||
return Decision{Version: e.Version, Keep: true, Publish: true, Why: fmt.Sprintf("version %s kept: source revision %s was numbered %s but never stored, so it is published under it", e.Version, revision, e.State)}
|
||||
case kind != KindPackage && e.Fingerprint == fingerprint:
|
||||
return Decision{Version: e.Version, Keep: true, Publish: e.State != Published, Why: fmt.Sprintf("version %s kept: fingerprint %s is numbered under it", e.Version, fingerprint)}
|
||||
}
|
||||
}
|
||||
if !floor.Known() {
|
||||
silent := strings.Join(floor.Silent, ", ")
|
||||
if kind == KindPackage {
|
||||
return Decision{Wait: true, Why: "the package waits for its floor: " + silent + " did not answer"}
|
||||
}
|
||||
return Decision{Unnumbered: true, Publish: true, Why: "built unnumbered and published by its digest: the floor is not known (" + silent + " did not answer); numbered once it answers"}
|
||||
}
|
||||
v := Next(entries, series, floor)
|
||||
return Decision{Version: v, Publish: true, Why: "numbered " + v}
|
||||
}
|
||||
|
||||
// CleanTag makes a branch or tag name a semver prerelease identifier: lower case, every run of characters other than
|
||||
// letters and digits one '-', none at either end; "tag" when nothing is left.
|
||||
func CleanTag(tag string) string {
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range strings.ToLower(tag) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
continue
|
||||
}
|
||||
if !dash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
out := strings.TrimSuffix(b.String(), "-")
|
||||
if out == "" {
|
||||
return "tag"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Tagged is the next tagged version of name for tag: below the next trunk version, never the latest
|
||||
// (`0.1.15-feat-x.2`, design 51 *Tagged versions*); a package new in a group is numbered from its floor
|
||||
// (`0.1.0-feat-x.1`).
|
||||
func Tagged(entries []Entry, series, tag string, floor Floor) string {
|
||||
base := Next(entries, series, floor)
|
||||
tag = CleanTag(tag)
|
||||
n := 0
|
||||
prefix := base + "-" + tag + "."
|
||||
for _, e := range entries {
|
||||
if rest, ok := strings.CutPrefix(e.Version, prefix); ok {
|
||||
if k, err := strconv.Atoi(rest); err == nil && k > n {
|
||||
n = k
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s%d", prefix, n+1)
|
||||
}
|
||||
|
||||
// Fill is whether a bucket holding bytes of cap is past its finding's mark.
|
||||
func Fill(bytes, capacity uint64) (share float64, full bool) {
|
||||
if capacity == 0 {
|
||||
return 0, false
|
||||
}
|
||||
share = float64(bytes) / float64(capacity)
|
||||
return share, bytes*100 >= capacity*FullPercent
|
||||
}
|
||||
|
||||
// InOrder sorts a name's entries by version, trunk versions by count and tagged ones after the trunk version they stand
|
||||
// below: the order builder_versions answers.
|
||||
func InOrder(entries []Entry) []Entry {
|
||||
out := append([]Entry(nil), entries...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
ki, kj := orderKey(out[i]), orderKey(out[j])
|
||||
if ki.n != kj.n {
|
||||
return ki.n < kj.n
|
||||
}
|
||||
if ki.tagged != kj.tagged {
|
||||
return ki.tagged // a tagged version stands below the trunk version of its count
|
||||
}
|
||||
return out[i].Version < out[j].Version
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
type order struct {
|
||||
n int
|
||||
tagged bool
|
||||
}
|
||||
|
||||
func orderKey(e Entry) order {
|
||||
v := e.Version
|
||||
tagged := false
|
||||
if i := strings.IndexByte(v, '-'); i >= 0 {
|
||||
v, tagged = v[:i], true
|
||||
}
|
||||
n, ok := Patch(e.Series, v)
|
||||
if !ok {
|
||||
n = -1
|
||||
}
|
||||
return order{n: n, tagged: tagged}
|
||||
}
|
||||
|
||||
func latestFirst(entries []Entry) []Entry {
|
||||
out := InOrder(entries)
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,813 +0,0 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// mem is a Store in memory; before runs once before the next Create, to stage a race.
|
||||
type mem struct {
|
||||
mu sync.Mutex
|
||||
vals map[string][]byte
|
||||
revs map[string]uint64
|
||||
rev uint64
|
||||
before func()
|
||||
}
|
||||
|
||||
func newMem() *mem { return &mem{vals: map[string][]byte{}, revs: map[string]uint64{}} }
|
||||
|
||||
func (m *mem) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v, ok := m.vals[key]
|
||||
return v, m.revs[key], ok, nil
|
||||
}
|
||||
|
||||
func (m *mem) Create(ctx context.Context, key string, value []byte) (uint64, error) {
|
||||
if f := m.before; f != nil {
|
||||
m.before = nil
|
||||
f()
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.vals[key]; ok {
|
||||
return 0, ErrConflict
|
||||
}
|
||||
m.rev++
|
||||
m.vals[key], m.revs[key] = value, m.rev
|
||||
return m.rev, nil
|
||||
}
|
||||
|
||||
func (m *mem) Update(_ context.Context, key string, value []byte, revision uint64) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.revs[key] != revision {
|
||||
return 0, ErrConflict
|
||||
}
|
||||
m.rev++
|
||||
m.vals[key], m.revs[key] = value, m.rev
|
||||
return m.rev, nil
|
||||
}
|
||||
|
||||
func (m *mem) KeysOf(_ context.Context, filter string) ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
prefix := strings.TrimSuffix(filter, "*")
|
||||
var out []string
|
||||
for k := range m.vals {
|
||||
if filter == AllFilter {
|
||||
out = append(out, k)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// keeperMem is the state floor-waits in memory.
|
||||
type keeperMem map[string][]byte
|
||||
|
||||
func (k keeperMem) Put(key string, v []byte) error { k[key] = v; return nil }
|
||||
func (k keeperMem) Delete(key string) error { delete(k, key); return nil }
|
||||
func (k keeperMem) keys() ([]string, error) {
|
||||
var out []string
|
||||
for key := range k {
|
||||
out = append(out, key)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (k keeperMem) get(key string) (json.RawMessage, bool, error) { v, ok := k[key]; return v, ok, nil }
|
||||
|
||||
// packagesMem is the indexer's packages state as the builder reads it, in memory.
|
||||
type packagesMem map[string][]byte
|
||||
|
||||
func (p packagesMem) Keys(context.Context) ([]string, error) {
|
||||
if p == nil {
|
||||
return nil, errors.New("not readable")
|
||||
}
|
||||
return slices.Collect(maps.Keys(p)), nil
|
||||
}
|
||||
|
||||
func (p packagesMem) Get(_ context.Context, key string) ([]byte, bool, error) {
|
||||
v, ok := p[key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (p packagesMem) failing() packagesMem { return nil }
|
||||
|
||||
func known(seat, tags, reqs []string) Floor { return Floor{Seat: seat, Tags: tags, Requirements: reqs} }
|
||||
|
||||
// TestTheBuildersVersions holds design 51 *The versions*, *The floor* and *Tagged versions* (DLV-B1, DLV-B2, DLV-B3):
|
||||
// one table per rule, the unit cases of the builder's numbering.
|
||||
func TestTheBuildersVersions(t *testing.T) {
|
||||
t.Run("keys", func(t *testing.T) {
|
||||
names := []string{"@novox/mesh-sdk", "git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-sdk", "a=40b", "a@b"}
|
||||
seen := map[string]string{}
|
||||
for _, n := range names {
|
||||
k := Key(n, "0.1.14")
|
||||
if strings.Count(k, ".") != 1 {
|
||||
t.Errorf("Key(%q) = %q, want two tokens", n, k)
|
||||
}
|
||||
if o, dup := seen[k]; dup {
|
||||
t.Errorf("%q and %q escape alike: %q", n, o, k)
|
||||
}
|
||||
seen[k] = n
|
||||
if !strings.HasPrefix(k, strings.TrimSuffix(NameFilter(n), "*")) {
|
||||
t.Errorf("Key(%q) = %q is not under NameFilter %q", n, k, NameFilter(n))
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("floor", func(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
floor Floor
|
||||
want string
|
||||
}{
|
||||
{"none starts at 0", known(nil, nil, nil), "0.1.0"},
|
||||
{"the Go tag of today gives v0.1.14", known(nil, []string{"go/v0.1.13"}, []string{"v0.1.14-0.20261103221822-85ba0a9a70d8"}), "0.1.14"},
|
||||
{"npm's 0.1.17 on the seat gives 0.1.18", known([]string{"0.1.17", "0.1.3"}, nil, nil), "0.1.18"},
|
||||
{"another series and a tagged version count nothing", known([]string{"0.2.40", "0.1.9-feat-x.1"}, nil, nil), "0.1.0"},
|
||||
{"the highest of the three", known([]string{"0.1.4"}, []string{"v0.1.9"}, []string{"v0.1.7"}), "0.1.10"},
|
||||
} {
|
||||
if got := Next(nil, "0.1", c.floor); got != c.want {
|
||||
t.Errorf("%s: Next = %s, want %s", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := Next([]Entry{{Version: "0.1.20", Series: "0.1"}, {Version: "0.1.21-x.1", Series: "0.1", Tag: "x"}}, "0.1", known([]string{"0.1.3"}, nil, nil)); got != "0.1.21" {
|
||||
t.Errorf("Next above the state's own = %s, want 0.1.21", got)
|
||||
}
|
||||
})
|
||||
t.Run("decide", func(t *testing.T) {
|
||||
pub := Entry{Name: "n", Kind: KindPackage, Series: "0.1", Version: "0.1.5", Revision: "r1", Fingerprint: "fA", State: Published}
|
||||
ref := pub
|
||||
ref.State, ref.Version, ref.Revision = Refused, "0.1.6", "r2"
|
||||
img := Entry{Name: "i", Kind: KindBundle, Series: "0.1", Version: "0.1.2", Fingerprint: "fB", State: Published}
|
||||
silent := Floor{Silent: []string{SourceSeat}}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
entries []Entry
|
||||
kind, fp, rev string
|
||||
floor Floor
|
||||
version string
|
||||
keep, publish, unnum bool
|
||||
wait bool
|
||||
}{
|
||||
{"a package's revision held published keeps its version and publishes nothing", []Entry{pub}, KindPackage, "fX", "r1", silent, "0.1.5", true, false, false, false},
|
||||
{"a revision numbered but refused is published by its second build under the same version", []Entry{pub, ref}, KindPackage, "fY", "r2", known(nil, nil, nil), "0.1.6", true, true, false, false},
|
||||
{"a new revision takes the next", []Entry{pub, ref}, KindPackage, "fZ", "r3", known(nil, nil, nil), "0.1.7", false, true, false, false},
|
||||
{"a bundle's fingerprint keeps its number", []Entry{img}, KindBundle, "fB", "", silent, "0.1.2", true, false, false, false},
|
||||
{"an unknown floor builds a bundle unnumbered", nil, KindBundle, "fC", "", silent, "", false, true, true, false},
|
||||
{"an unknown floor builds an image unnumbered", nil, KindImage, "fC", "", silent, "", false, true, true, false},
|
||||
{"an unknown floor makes a package wait", nil, KindPackage, "fC", "r9", silent, "", false, false, false, true},
|
||||
} {
|
||||
d := Decide(c.entries, c.kind, "0.1", c.fp, c.rev, c.floor)
|
||||
if d.Version != c.version || d.Keep != c.keep || d.Publish != c.publish || d.Unnumbered != c.unnum || d.Wait != c.wait || d.Why == "" {
|
||||
t.Errorf("%s: Decide = %+v", c.name, d)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("two claims never one number", func(t *testing.T) {
|
||||
st := newMem()
|
||||
v := Versions{Store: st, Now: func() time.Time { return time.Unix(0, 0) }}
|
||||
ctx := context.Background()
|
||||
st.before = func() { // a second claim takes 0.1.0 between the first's read and its create
|
||||
if _, _, err := (Versions{Store: st}).Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f2"}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
d, e, err := v.Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f1"})
|
||||
if err != nil || d.Version != "0.1.1" || e.State != Numbered || e.Series != DefaultSeries {
|
||||
t.Fatalf("Number = %+v, %+v, %v, want 0.1.1 after the other claim's 0.1.0", d, e, err)
|
||||
}
|
||||
if d, _, _ := v.Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f1"}); !d.Keep || d.Version != "0.1.1" {
|
||||
t.Errorf("a numbered fingerprint claimed again = %+v, want it kept", d)
|
||||
}
|
||||
got, err := v.Mark(ctx, "n", "0.1.1", func(e *Entry) { e.State = Published })
|
||||
if err != nil || got.State != Published {
|
||||
t.Errorf("Mark = %+v, %v", got, err)
|
||||
}
|
||||
if _, err := v.Mark(ctx, "n", "0.9.9", func(*Entry) {}); err == nil {
|
||||
t.Error("marking a version never numbered is no error")
|
||||
}
|
||||
})
|
||||
t.Run("recorded", func(t *testing.T) {
|
||||
st := newMem()
|
||||
v := Versions{Store: st}
|
||||
ctx := context.Background()
|
||||
if _, _, err := v.Number(ctx, Claim{Name: "n", Kind: KindImage, Fingerprint: "f", Digest: "sha256:aa", Floor: known(nil, nil, nil)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
version, digest string
|
||||
ok bool
|
||||
}{{"0.1.0", "sha256:aa", true}, {"0.1.0", "sha256:bb", false}, {"0.1.7", "sha256:aa", false}} {
|
||||
if _, ok, why, err := v.Recorded(ctx, "n", c.version, c.digest); err != nil || ok != c.ok || (!ok && why == "") {
|
||||
t.Errorf("Recorded(%s, %s) = %v, %q, %v; want %v", c.version, c.digest, ok, why, err, c.ok)
|
||||
}
|
||||
}
|
||||
// builder_versions answers the same through the tools bundle's read-only view.
|
||||
r := Reader{Keys: func() ([]string, error) {
|
||||
var all []string
|
||||
for k := range st.vals {
|
||||
all = append(all, k)
|
||||
}
|
||||
return all, nil
|
||||
}, Get: func(k string) (json.RawMessage, bool, error) {
|
||||
v, _, ok, err := st.Get(ctx, k)
|
||||
return v, ok, err
|
||||
}}
|
||||
a, err := AnswerVersions(ctx, r, "n", "0.1.0", "sha256:bb")
|
||||
if err != nil || len(a.Versions) != 1 || a.Recorded == nil || *a.Recorded || a.Why == "" {
|
||||
t.Errorf("builder_versions = %+v, %v; want one version and not recorded for another digest", a, err)
|
||||
}
|
||||
if _, err := AnswerVersions(ctx, r, " ", "", ""); err == nil {
|
||||
t.Error("builder_versions with no name answered")
|
||||
}
|
||||
})
|
||||
t.Run("tagged", func(t *testing.T) {
|
||||
es := []Entry{{Version: "0.1.14", Series: "0.1"}, {Version: "0.1.15-feat-x.1", Series: "0.1", Tag: "feat-x"}}
|
||||
if got := Tagged(es, "0.1", "feat/X", known(nil, nil, nil)); got != "0.1.15-feat-x.2" {
|
||||
t.Errorf("Tagged = %s, want 0.1.15-feat-x.2", got)
|
||||
}
|
||||
if got := Tagged(nil, "0.1", "feat-x", known(nil, nil, nil)); got != "0.1.0-feat-x.1" {
|
||||
t.Errorf("a package new in a group = %s, want 0.1.0-feat-x.1", got)
|
||||
}
|
||||
if got := CleanTag("--Fix/ä_1965--"); got != "fix-1965" {
|
||||
t.Errorf("CleanTag = %s", got)
|
||||
}
|
||||
})
|
||||
t.Run("the floor wakes on its silent source, never a timer", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := FloorSources{
|
||||
Seat: func(context.Context, string, string) ([]string, error) { return []string{"0.1.3"}, nil },
|
||||
Tags: func(context.Context, string, string) ([]string, error) { return nil, errors.New("no answer") },
|
||||
Requirements: func(context.Context, string) ([]string, error) { return nil, nil },
|
||||
}
|
||||
f, why := src.Ask(ctx, FloorOf{Seat: "oci-artifact-registry", Name: "m/program"})
|
||||
if f.Known() || !reflect.DeepEqual(f.Silent, []string{SourceTags}) || why[SourceTags] != "no answer" {
|
||||
t.Fatalf("Ask = %+v, %v; want bridge_tags silent", f, why)
|
||||
}
|
||||
var fw FloorWaits
|
||||
w := fw.Wait("m/program", "oci-artifact-registry", "distribution", f, why, time.Unix(0, 0))
|
||||
if !strings.Contains(w.Finding(), "bridge_tags (no answer)") {
|
||||
t.Errorf("finding = %q", w.Finding())
|
||||
}
|
||||
for _, c := range []struct {
|
||||
kind, seat, module, name string
|
||||
want []string
|
||||
}{
|
||||
{WakePublished, "oci-artifact-registry", "", "", nil}, // the seat answered already: a publish wakes nothing
|
||||
{WakeAccepted, "", "", "", nil},
|
||||
{WakeForge, "", "", "", []string{"m/program"}},
|
||||
{WakeCleared, "", "mesh-delivery-forge-bridge", "", []string{"m/program"}}, // recovered with nothing to say
|
||||
{WakeCleared, "", "mesh-delivery-indexer", "", nil},
|
||||
{WakeByHand, "", "", "m/program", []string{"m/program"}},
|
||||
{WakeByHand, "", "", "other", nil},
|
||||
} {
|
||||
if got := fw.Wake(c.kind, c.seat, c.module, c.name); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("Wake(%s %s %s %s) = %v, want %v", c.kind, c.seat, c.module, c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
seatDown := Floor{Silent: []string{SourceSeat}}
|
||||
fw.Wait("distribution/program", "container-image-registry", "distribution", seatDown, nil, time.Unix(0, 0))
|
||||
if got := fw.Wake(WakePublished, "container-image-registry", "", ""); !reflect.DeepEqual(got, []string{"distribution/program"}) {
|
||||
t.Errorf("the holder's published woke %v, want its own image's name", got)
|
||||
}
|
||||
if got := fw.Wake(WakeCleared, "", "distribution", ""); !reflect.DeepEqual(got, []string{"distribution/program"}) {
|
||||
t.Errorf("the holder's clear woke %v", got)
|
||||
}
|
||||
if !fw.Answered("m/program") || fw.Answered("m/program") || len(fw.Waiting()) != 1 {
|
||||
t.Error("Answered drops the wait once")
|
||||
}
|
||||
// DLV-B4: the waits are kept in floor-waits; a restarted builder takes them in, and its sources' events still wake them.
|
||||
kept := keeperMem{}
|
||||
restarted := &FloorWaits{}
|
||||
restarted.Keep(kept, []FloorWait{{Name: "m/held", Seat: SeatImages, Silent: []string{SourceTags}, Since: time.Unix(5, 0)}})
|
||||
if got := restarted.Wake(WakeForge, "", "", ""); !reflect.DeepEqual(got, []string{"m/held"}) {
|
||||
t.Errorf("a wait held in floor-waits woke %v after a restart, want m/held", got)
|
||||
}
|
||||
restarted.Wait("m/new", SeatNPM, "", Floor{Silent: []string{SourceSeat}}, nil, time.Unix(9, 0))
|
||||
ans, err := AnswerFloorWaits(Reader{Keys: kept.keys, Get: kept.get})
|
||||
if err != nil || len(ans.Waits) != 2 || ans.Waits[0].Name != "m/held" || ans.Waits[1].Name != "m/new" || !strings.Contains(ans.Waits[1].Finding, "seat did not answer") {
|
||||
t.Fatalf("builder_floor_waits = %+v, %v", ans, err)
|
||||
}
|
||||
restarted.Answered("m/held")
|
||||
if _, ok := kept[FloorWaitKey("m/held")]; ok {
|
||||
t.Error("an answered floor stays in floor-waits")
|
||||
}
|
||||
// The lead's ruling (review round 1): builder_bounds lists each key of the state bounds with what it bounds and
|
||||
// when it ends.
|
||||
at := time.Date(2026, 11, 4, 17, 0, 0, 0, time.UTC)
|
||||
bounds := keeperMem{PublishBoundKey("m/program", "0.1.4"): EncodeBound("the publish of m/program@0.1.4 to oci-artifact-registry", at.Add(PublishBound)),
|
||||
OldFormBoundKey("g14"): EncodeBound("the seven days g14 holds the old form's window", at.Add(FindingAfter))}
|
||||
rows, err := AnswerBounds(Reader{Keys: bounds.keys, Get: bounds.get}, at)
|
||||
if err != nil || len(rows) != 2 || rows[0].Key != OldFormBoundKey("g14") || rows[1].Left != "10m0s" ||
|
||||
!strings.Contains(rows[1].Bounds, "m/program@0.1.4") || !rows[0].Ends.Equal(at.Add(FindingAfter)) {
|
||||
t.Fatalf("builder_bounds = %+v, %v", rows, err)
|
||||
}
|
||||
})
|
||||
t.Run("old form", func(t *testing.T) {
|
||||
// One kind, a first form and a newer one.
|
||||
fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}}
|
||||
both := []string{forms.FormNeed("oci")}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
counted map[string][]string
|
||||
dropped map[string]bool
|
||||
want []string
|
||||
}{
|
||||
{"one node lacking the new form holds the old", map[string][]string{"a": both, "b": nil}, nil, []string{"b"}},
|
||||
{"an away node counts, stating its last", map[string][]string{"a": both, "c": {"bus-state"}}, nil, []string{"c"}},
|
||||
{"every counted node reads the newest: closed", map[string][]string{"a": both, "c": both}, nil, nil},
|
||||
{"a retired node dropped by hand counts no more", map[string][]string{"a": both, "c": nil}, map[string]bool{"c": true}, nil},
|
||||
{"the builder's own forms: no window", nil, nil, nil},
|
||||
} {
|
||||
if got := Holding(fs, c.counted, c.dropped); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s: Holding = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
// The builder's own forms: the registry seat's form of a bundle is not a first form, so a node stating nothing
|
||||
// holds the window and one stating form:seat-reference does not.
|
||||
if got := Holding(forms.Current, map[string][]string{"a": nil, "b": {forms.FormNeed(forms.FormSeatReference)}}, nil); !reflect.DeepEqual(got, []string{"a"}) {
|
||||
t.Errorf("forms.Current, a node stating nothing and one stating form:seat-reference: Holding = %v, want a", got)
|
||||
}
|
||||
t0 := time.Unix(0, 0)
|
||||
named := []string{"c", "d"}
|
||||
w, closed, overdue := OldFormWindow{}.Step(named, []string{"c"}, t0)
|
||||
if closed || len(overdue) != 0 || !w.Open {
|
||||
t.Fatalf("Step = %+v %v %v", w, closed, overdue)
|
||||
}
|
||||
w, _, overdue = w.Step(named, []string{"c"}, t0.Add(FindingAfter))
|
||||
if !reflect.DeepEqual(overdue, []string{"c"}) {
|
||||
t.Errorf("seven days held: overdue %v, want c", overdue)
|
||||
}
|
||||
w, err := w.Drop("d", "op@shanks", "retired", t0)
|
||||
if err != nil || !w.DroppedSet()["d"] {
|
||||
t.Fatalf("Drop = %+v, %v", w, err)
|
||||
}
|
||||
if again, _ := w.Drop("d", "op@shanks", "again", t0); len(again.Dropped) != 1 {
|
||||
t.Errorf("a node dropped twice is kept twice: %+v", again.Dropped)
|
||||
}
|
||||
if _, err := w.Drop("e", "op@shanks", "", t0); err == nil {
|
||||
t.Error("a drop with no why is taken")
|
||||
}
|
||||
// Security-upgrade F3 (review round 1), by mesh-sdk's delivery.StepWindow: a read naming no node (an empty
|
||||
// node_haves, the placement not read yet) never closes the window, and its holder's seven days still count.
|
||||
if held, shut, late := w.Step(nil, nil, t0.Add(FindingAfter+time.Minute)); shut || !held.Open || !reflect.DeepEqual(late, []string{"c"}) {
|
||||
t.Fatalf("a read naming no node: %+v closed=%v overdue %v, want the window open and c overdue", held, shut, late)
|
||||
}
|
||||
w, closed, _ = w.Step(named, nil, t0.Add(FindingAfter+time.Hour))
|
||||
if !closed || w.Open || !reflect.DeepEqual(w.LastHolding, []string{"c"}) || w.Closing().Dropped[0].Node != "d" {
|
||||
t.Errorf("closing: %+v closed=%v", w, closed)
|
||||
}
|
||||
raw, _ := json.Marshal(w)
|
||||
if back, err := DecodeOldFormWindow(raw); err != nil || !back.Closed.Equal(w.Closed) {
|
||||
t.Errorf("the window read back = %+v, %v", back, err)
|
||||
}
|
||||
if _, again, _ := w.Step(named, nil, t0.Add(FindingAfter+2*time.Hour)); again {
|
||||
t.Error("a closed window closes again: old-form-closed would be said twice")
|
||||
}
|
||||
})
|
||||
t.Run("at the claim", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tagsUp := false
|
||||
var asked []string
|
||||
src := FloorSources{
|
||||
Seat: func(context.Context, string, string) ([]string, error) { return []string{"0.1.3"}, nil },
|
||||
Tags: func(_ context.Context, repo, prefix string) ([]string, error) {
|
||||
asked = append(asked, repo+" "+prefix)
|
||||
if !tagsUp {
|
||||
return nil, errors.New("no answer")
|
||||
}
|
||||
return []string{"go/v0.1.13"}, nil
|
||||
},
|
||||
Requirements: func(context.Context, string) ([]string, error) { return nil, nil },
|
||||
}
|
||||
var said []delivery.VersionNumbered
|
||||
fw := &FloorWaits{}
|
||||
n := &Numberer{Versions: Versions{Store: newMem()}, Floor: src, Waits: fw, Said: func(b delivery.VersionNumbered) { said = append(said, b) }}
|
||||
c := BuildClaim{Module: "m", Path: "go", Repository: "novox/m", Fingerprint: "fp1", Commit: "c1", Revision: "7", Merge: 7,
|
||||
Artifacts: []ClaimArtifact{{Artifact: "program", Kind: KindBundle, Language: "go"}, {Artifact: "base", Kind: "upstream"}}}
|
||||
got, err := n.Number(ctx, c)
|
||||
if err != nil || len(got.Versions) != 0 || !reflect.DeepEqual(got.Unnumbered, []string{"program"}) || got.Wait != "" {
|
||||
t.Fatalf("silent floor: %+v, %v; want program unnumbered, the upstream copy left out", got, err)
|
||||
}
|
||||
if !reflect.DeepEqual(asked, []string{"novox/m go/v"}) || len(fw.Waiting()) != 1 || len(said) != 0 {
|
||||
t.Fatalf("asked %v, waits %+v, said %v", asked, fw.Waiting(), said)
|
||||
}
|
||||
pkg := c
|
||||
pkg.Artifacts = []ClaimArtifact{{Artifact: "lib", Kind: KindPackage, Language: "go"}}
|
||||
if got, _ := n.Number(ctx, pkg); got.Wait == "" || len(got.Versions) != 0 {
|
||||
t.Fatalf("a package with no floor = %+v; want it to wait", got)
|
||||
}
|
||||
tagsUp = true
|
||||
got, err = n.Number(ctx, c)
|
||||
if err != nil || got.Versions["program"] != "0.1.14" || len(said) != 1 || said[0].Version != "0.1.14" || len(fw.Waiting()) != 1 {
|
||||
t.Fatalf("floor answered: %+v, %v, said %v, waits %+v; want 0.1.14 said once, lib still waiting", got, err, said, fw.Waiting())
|
||||
}
|
||||
// DLV-B6: a check's build of a head a tag ask names numbers only its packages, tagged below the next version, kept
|
||||
// with its repository for the branch's end, said with the head; claimed again it keeps its number.
|
||||
tagged := c
|
||||
tagged.Tag, tagged.Head, tagged.Revision, tagged.Merge = "feat-x", "h1", "", 0
|
||||
tagged.Artifacts = append(tagged.Artifacts, ClaimArtifact{Artifact: "tlib", Kind: KindPackage, Language: "go"})
|
||||
saidBefore, askedBefore := said, asked
|
||||
said = nil
|
||||
got, err = n.Number(ctx, tagged)
|
||||
if err != nil || len(got.Versions) != 1 || !strings.Contains(got.Versions["tlib"], "-feat-x.1") || len(said) != 1 || said[0].Head != "h1" || said[0].Tag != "feat-x" {
|
||||
t.Fatalf("tagged claim: %+v, %v, said %+v; want tlib alone tagged, said with its head", got, err, said)
|
||||
}
|
||||
if es, _ := n.Versions.Of(ctx, "m/tlib"); len(es) != 1 || es[0].Repository != "novox/m" {
|
||||
t.Errorf("the tagged version kept = %+v, want its repository", es)
|
||||
}
|
||||
if again, _ := n.Number(ctx, tagged); again.Versions["tlib"] != got.Versions["tlib"] || len(said) != 1 {
|
||||
t.Errorf("claimed again: %+v, said %d; want the same number, said once", again, len(said))
|
||||
}
|
||||
said, asked = saidBefore, askedBefore
|
||||
got, _ = n.Number(ctx, c)
|
||||
var publishing []string // review round 1: a version built and still numbered arms its publish bound
|
||||
n.Publishing = func(_ context.Context, e Entry) { publishing = append(publishing, e.Name+"@"+e.Version) }
|
||||
if err := n.Built(ctx, c, got, map[string]string{"program": "sha256:aa"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Publishing = nil; !reflect.DeepEqual(publishing, []string{"m/program@0.1.14"}) {
|
||||
t.Fatalf("the publish bounds armed: %v, want m/program@0.1.14", publishing)
|
||||
}
|
||||
again, _ := n.Number(ctx, c)
|
||||
if again.Versions["program"] != "0.1.14" || len(said) != 1 || len(asked) != 3 {
|
||||
t.Fatalf("again: %+v, said %d, asked %v; want the number kept, nothing said, the floor not asked", again, len(said), asked)
|
||||
}
|
||||
if _, ok, why, _ := n.Versions.Recorded(ctx, "m/program", "0.1.14", "sha256:bb"); ok {
|
||||
t.Fatalf("another digest recorded: %s", why)
|
||||
}
|
||||
// DLV-B4: the seat's events move the entry; a published wakes the names waiting on that seat.
|
||||
if wake, err := n.Heard(ctx, SeatArtifacts, HeardPublished, "e1", Outcome{Name: "m/other", Digest: "sha256:cc"}); err != nil || len(wake) != 0 {
|
||||
t.Fatalf("a by-digest publish of another seat's wait: %v, %v", wake, err)
|
||||
}
|
||||
n.Waits.Wait("m/lib", SeatGoPackages, "", Floor{Silent: []string{SourceSeat}}, nil, time.Unix(0, 0))
|
||||
if wake, _ := n.Heard(ctx, SeatGoPackages, HeardPublished, "e2", Outcome{Name: "m/x", Digest: "sha256:dd"}); !reflect.DeepEqual(wake, []string{"m/lib"}) {
|
||||
t.Fatalf("a published on the Go seat woke %v, want m/lib, whose seat was silent", wake)
|
||||
}
|
||||
for _, h := range []struct{ event, state, reason string }{{HeardRefused, Refused, "bytes differ"}, {HeardPublished, Published, ""}, {HeardRefused, Published, ""}, {HeardRemoved, Removed, "retention"}} {
|
||||
if _, err := n.Heard(ctx, SeatArtifacts, h.event, "e3", Outcome{Name: "m/program", Version: "0.1.14", Digest: "sha256:aa", Reason: h.reason}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, _ := n.Versions.Of(ctx, "m/program")
|
||||
if all[0].State != h.state {
|
||||
t.Fatalf("after %s: %+v, want %s", h.event, all[0], h.state)
|
||||
}
|
||||
}
|
||||
if _, err := n.Heard(ctx, SeatArtifacts, HeardPublished, "e4", Outcome{Name: "m/none", Version: "0.1.0"}); err == nil {
|
||||
t.Fatal("a published of a version the builder never numbered is not said")
|
||||
}
|
||||
})
|
||||
t.Run("a package is numbered under its name in its ecosystem", func(t *testing.T) {
|
||||
// design 32: the indexer's packages state names, in each series key, the package's name and the <module>/<artifact>
|
||||
// it is built from; the builder numbers, asks the floor of and records the package under that name.
|
||||
ctx := context.Background()
|
||||
state := packagesMem{
|
||||
"git=2enovox=2ebe/novox/mesh-sdk/go.0.1": []byte(`{"v":1,"name":"git.novox.be/novox/mesh-sdk/go","package":"mesh-sdk/go","series":"0.1"}`),
|
||||
"@novox/mesh-sdk.0.1": []byte(`{"v":1,"name":"@novox/mesh-sdk","package":"mesh-sdk/ts","series":"0.1"}`),
|
||||
"git=2enovox=2ebe/novox/mesh-sdk/go": []byte(`{"v":1,"module":"git.novox.be/novox/mesh-sdk/go"}`),
|
||||
"broken.0.1": []byte(`not json`),
|
||||
}
|
||||
var floorOf []string
|
||||
src := FloorSources{
|
||||
Seat: func(_ context.Context, _, name string) ([]string, error) {
|
||||
floorOf = append(floorOf, name)
|
||||
return []string{"0.1.3"}, nil
|
||||
},
|
||||
Tags: func(context.Context, string, string) ([]string, error) { return nil, nil },
|
||||
Requirements: func(_ context.Context, name string) ([]string, error) {
|
||||
floorOf = append(floorOf, name)
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
var said []delivery.VersionNumbered
|
||||
n := &Numberer{Versions: Versions{Store: newMem()}, Floor: src, Names: NamesIn(state), Said: func(b delivery.VersionNumbered) { said = append(said, b) }}
|
||||
c := BuildClaim{Module: "mesh-sdk", Path: "go", Repository: "novox/mesh-sdk", Fingerprint: "fp1", Commit: "c1", Revision: "3", Merge: 3,
|
||||
Artifacts: []ClaimArtifact{{Artifact: "go", Kind: KindPackage, Language: "go"}, {Artifact: "ts", Kind: KindPackage, Language: "typescript"},
|
||||
{Artifact: "new", Kind: KindPackage, Language: "go"}, {Artifact: "program", Kind: KindBundle, Language: "go"}}}
|
||||
got, err := n.Number(ctx, c)
|
||||
if err != nil || got.Versions["go"] != "0.1.4" || got.Versions["ts"] != "0.1.4" || !reflect.DeepEqual(got.Unnumbered, []string{"new"}) {
|
||||
t.Fatalf("Number = %+v, %v; want go and ts numbered, new unnumbered (no name yet)", got, err)
|
||||
}
|
||||
if got.Names["go"] != "git.novox.be/novox/mesh-sdk/go" || got.Names["ts"] != "@novox/mesh-sdk" || got.Names["program"] != "mesh-sdk/program" {
|
||||
t.Errorf("names = %v", got.Names)
|
||||
}
|
||||
if !slices.Contains(floorOf, "git.novox.be/novox/mesh-sdk/go") || slices.Contains(floorOf, "mesh-sdk/go") {
|
||||
t.Errorf("the floor was asked of %v, want the Go module path", floorOf)
|
||||
}
|
||||
if es, _ := n.Versions.Of(ctx, "git.novox.be/novox/mesh-sdk/go"); len(es) != 1 || es[0].Version != "0.1.4" {
|
||||
t.Errorf("versions of the Go module path = %+v", es)
|
||||
}
|
||||
if es, _ := n.Versions.Of(ctx, "mesh-sdk/go"); len(es) != 0 {
|
||||
t.Errorf("numbered under the builder's name too: %+v", es)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, b := range said {
|
||||
names[b.Name] = true
|
||||
}
|
||||
if !names["git.novox.be/novox/mesh-sdk/go"] || !names["@novox/mesh-sdk"] {
|
||||
t.Errorf("version.numbered said %v, want the ecosystem names", names)
|
||||
}
|
||||
if err := n.Built(ctx, c, got, map[string]string{"go": "sha256:aa"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if es, _ := n.Versions.Of(ctx, "git.novox.be/novox/mesh-sdk/go"); len(es) != 1 || es[0].Digest != "sha256:aa" {
|
||||
t.Errorf("the built digest is not recorded under the name: %+v", es)
|
||||
}
|
||||
tagged, _ := TagAsk{Head: "h", State: TagAsked}.WithNumbered("mesh-sdk", "h", map[string]string{"go": "0.1.4-x.1"}, got.Names, time.Unix(1, 0))
|
||||
if tagged.Versions["git.novox.be/novox/mesh-sdk/go"] != "0.1.4-x.1" {
|
||||
t.Errorf("a tag ask names %v, want the Go module path", tagged.Versions)
|
||||
}
|
||||
// The indexer writes the name of `new` later: its series key names the module, and the look at the done build
|
||||
// numbers it with the done key's digest, said in version.numbered for the late publish; the rest are kept.
|
||||
state["new.0.1"] = []byte(`{"v":1,"name":"new","package":"mesh-sdk/new","series":"0.1"}`)
|
||||
for key, want := range map[string]string{"new.0.1": "mesh-sdk", "git=2enovox=2ebe/novox/mesh-sdk/go": "", "broken.0.1": ""} {
|
||||
if module, name, ok := NamedModule(key, state[key]); module != want || ok != (want != "") || (ok && name != "new") {
|
||||
t.Errorf("NamedModule(%s) = %q, %q, %v, want %q", key, module, name, ok, want)
|
||||
}
|
||||
}
|
||||
said = nil
|
||||
done := c
|
||||
done.Done = map[string]string{"go": "sha256:aa", "new": "sha256:bb"}
|
||||
late, err := n.Number(ctx, done)
|
||||
if err != nil || late.Versions["new"] != "0.1.4" || late.Versions["go"] != "0.1.4" || len(late.Unnumbered) != 0 {
|
||||
t.Fatalf("a look at the done build = %+v, %v; want new numbered and go kept", late, err)
|
||||
}
|
||||
if len(said) != 1 || said[0].Name != "new" || said[0].Digest != "sha256:bb" {
|
||||
t.Errorf("version.numbered said %+v, want new once with the done build's digest", said)
|
||||
}
|
||||
if es, _ := n.Versions.Of(ctx, "new"); len(es) != 1 || es[0].Digest != "sha256:bb" {
|
||||
t.Errorf("versions of new = %+v, want its done digest recorded", es)
|
||||
}
|
||||
if _, _, err := NamesIn(packagesMem(nil).failing())(ctx, "m", "a"); err == nil {
|
||||
t.Error("a packages state that cannot be read is no error")
|
||||
}
|
||||
// A packages state not on the bus yet leaves the package unnumbered, said, and numbers the rest of the claim.
|
||||
n.Names = NamesIn(packagesMem(nil).failing())
|
||||
bare, err := n.Number(ctx, c)
|
||||
if err != nil || !reflect.DeepEqual(bare.Unnumbered, []string{"go", "new", "ts"}) || bare.Versions["program"] == "" ||
|
||||
!strings.Contains(bare.Said[0].Line, "cannot be read") {
|
||||
t.Errorf("an unreadable packages state: %+v, %v", bare, err)
|
||||
}
|
||||
})
|
||||
t.Run("fill", func(t *testing.T) {
|
||||
if _, full := Fill(Cap*79/100, Cap); full {
|
||||
t.Error("79 % is full")
|
||||
}
|
||||
if _, full := Fill((Cap*80+99)/100, Cap); !full {
|
||||
t.Error("80 % is not full")
|
||||
}
|
||||
})
|
||||
t.Run("publish again", func(t *testing.T) {
|
||||
num := Entry{Name: "m/p", Kind: KindBundle, Version: "0.1.3", Fingerprint: "f1", Digest: "sha256:aa", State: Numbered}
|
||||
ref := num
|
||||
ref.State, ref.RefusedDigest = Refused, "sha256:aa"
|
||||
other := ref
|
||||
other.RefusedDigest = "sha256:bb"
|
||||
pub := num
|
||||
pub.State = Published
|
||||
gone := pub
|
||||
gone.State = Removed
|
||||
tagged := num
|
||||
tagged.Tag, tagged.Version = "feat-x", "0.1.3-feat-x.1"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
entries []Entry
|
||||
fp string
|
||||
built bool
|
||||
do string
|
||||
}{
|
||||
{"a held build is said numbered again", []Entry{num}, "f1", true, AgainPublish},
|
||||
{"refused for its own bytes is published again", []Entry{ref}, "f1", true, AgainPublish},
|
||||
{"a gone file is built again under the same number", []Entry{ref}, "f1", false, AgainBuild},
|
||||
{"after a published it takes a new number", []Entry{pub}, "f1", true, AgainRenumber},
|
||||
{"after a removal it takes a new number", []Entry{gone}, "f1", false, AgainRenumber},
|
||||
{"refused for other bytes is refused", []Entry{other}, "f1", true, ""},
|
||||
{"a fingerprint not numbered is refused", []Entry{num}, "f2", true, ""},
|
||||
{"a tagged version is not a trunk fingerprint's", []Entry{tagged}, "f1", true, ""},
|
||||
} {
|
||||
a, err := PublishAgain(c.entries, c.fp, c.built)
|
||||
if (err != nil) != (c.do == "") || a.Do != c.do {
|
||||
t.Errorf("%s: PublishAgain = %+v, %v; want %q", c.name, a, err, c.do)
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
// A fresh claim of a published fingerprint takes the next number; the old one stays.
|
||||
st := newMem()
|
||||
v := Versions{Store: st}
|
||||
raw, _ := Encode(Entry{Name: "m/p", Kind: KindBundle, Series: "0.1", Version: "0.1.3", Fingerprint: "f1", State: Published, At: time.Unix(1, 0)})
|
||||
_, _ = st.Create(ctx, Key("m/p", "0.1.3"), raw)
|
||||
if d, _, err := v.Number(ctx, Claim{Name: "m/p", Kind: KindBundle, Fingerprint: "f1"}); err != nil || d.Version != "0.1.3" || !d.Keep {
|
||||
t.Fatalf("a published fingerprint keeps its number: %+v %v", d, err)
|
||||
}
|
||||
if d, e, err := v.Number(ctx, Claim{Name: "m/p", Kind: KindBundle, Fingerprint: "f1", Fresh: true}); err != nil || d.Version != "0.1.4" || d.Keep || e.Fingerprint != "f1" {
|
||||
t.Fatalf("a fresh claim takes the next number: %+v %+v %v", d, e, err)
|
||||
}
|
||||
// A refusal records the digest refused; Built replaces a digest no published was said for, never a published one.
|
||||
n := &Numberer{Versions: v}
|
||||
_, _ = n.Heard(ctx, SeatArtifacts, HeardRefused, "r1", Outcome{Name: "m/p", Version: "0.1.4", Digest: "sha256:old", Reason: "x"})
|
||||
if es, _ := v.Of(ctx, "m/p"); es[1].RefusedDigest != "sha256:old" {
|
||||
t.Errorf("refused records no digest: %+v", es[1])
|
||||
}
|
||||
_, _ = v.Mark(ctx, "m/p", "0.1.4", func(e *Entry) { e.Digest = "sha256:old" })
|
||||
bc := BuildClaim{Module: "m"}
|
||||
_ = n.Built(ctx, bc, Numbering{Versions: map[string]string{"p": "0.1.4"}}, map[string]string{"p": "sha256:new"})
|
||||
_, _ = v.Mark(ctx, "m/p", "0.1.3", func(e *Entry) { e.Digest = "sha256:held" })
|
||||
_ = n.Built(ctx, bc, Numbering{Versions: map[string]string{"p": "0.1.3"}}, map[string]string{"p": "sha256:other"})
|
||||
es, _ := v.Of(ctx, "m/p")
|
||||
if es[0].Digest != "sha256:held" || es[1].Digest != "sha256:new" {
|
||||
t.Errorf("Built: published %s (want sha256:held), refused %s (want sha256:new)", es[0].Digest, es[1].Digest)
|
||||
}
|
||||
if all, err := v.All(ctx); err != nil || len(all) != 2 {
|
||||
t.Errorf("All = %d entries, %v", len(all), err)
|
||||
}
|
||||
// The hand acts: a held build is said numbered once more under a fresh dedup id; a gone one is built again; a
|
||||
// published one takes a new number; a name with versions asks no floor.
|
||||
if _, _, err := (Acts{Numberer: &Numberer{Versions: v}, Done: func(context.Context, string, string) (bool, error) { return true, nil }}).PublishAgain(ctx, "m/p", "f1", "w", "", time.Unix(4, 0)); err == nil {
|
||||
t.Error("a version refused for other bytes is published again")
|
||||
}
|
||||
_, _ = v.Mark(ctx, "m/p", "0.1.4", func(e *Entry) { e.State = Published })
|
||||
var said []delivery.VersionNumbered
|
||||
var rebuilt []string
|
||||
done := true
|
||||
acts := Acts{Numberer: &Numberer{Versions: v, Said: func(b delivery.VersionNumbered) { said = append(said, b) }, Waits: &FloorWaits{}},
|
||||
Done: func(context.Context, string, string) (bool, error) { return done, nil },
|
||||
BuildAgain: func(_ context.Context, m, fp, _, _ string) (string, bool) {
|
||||
rebuilt = append(rebuilt, m+" "+fp)
|
||||
return "built again", false
|
||||
},
|
||||
Reconsider: func(context.Context) {}}
|
||||
if ag, _, err := acts.PublishAgain(ctx, "m/p", "f1", "w", "me", time.Unix(5, 0)); err != nil || ag.Do != AgainRenumber || ag.Entry.Version != "0.1.5" ||
|
||||
len(said) != 1 || said[0].Version != "0.1.5" || said[0].Again == "" || len(rebuilt) != 0 {
|
||||
t.Errorf("publish again of a published, held build: %+v %v said %+v rebuilt %v", ag, err, said, rebuilt)
|
||||
}
|
||||
done = false
|
||||
if ag, _, err := acts.PublishAgain(ctx, "m/p", "f1", "w", "me", time.Unix(6, 0)); err != nil || ag.Do != AgainBuild || ag.Entry.Version != "0.1.5" || len(rebuilt) != 1 || len(said) != 1 {
|
||||
t.Errorf("publish again of a gone build: %+v %v said %d rebuilt %v", ag, err, len(said), rebuilt)
|
||||
}
|
||||
if _, err := acts.FloorAgain(ctx, "m/p"); err == nil {
|
||||
t.Error("a name with versions asks its floor again")
|
||||
}
|
||||
if ans, err := acts.FloorAgain(ctx, "m/new"); err != nil || ans == "" {
|
||||
t.Errorf("floor again of a new name: %q %v", ans, err)
|
||||
}
|
||||
})
|
||||
t.Run("tag asks", func(t *testing.T) {
|
||||
at := time.Unix(100, 0)
|
||||
k, raw, err := NewTagAsk("novox/mesh-sdk", "feat/X y", "abc", "a check", "me@shanks", at)
|
||||
if err != nil || k != TagAskKey("novox/mesh-sdk", "feat/X y") || strings.Count(k, ".") != 1 {
|
||||
t.Fatalf("NewTagAsk = %q, %v", k, err)
|
||||
}
|
||||
a, err := DecodeTagAsk(raw)
|
||||
if err != nil || a.Tag != "feat-x-y" || a.State != TagAsked || a.Untag {
|
||||
t.Fatalf("DecodeTagAsk = %+v, %v", a, err)
|
||||
}
|
||||
for _, bad := range [][4]string{{"mesh-sdk", "b", "h", "w"}, {"o/r", "", "h", "w"}, {"o/r", "b", "", "w"}, {"o/r", "b", "h", ""}} {
|
||||
if _, _, err := NewTagAsk(bad[0], bad[1], bad[2], bad[3], "", at); err == nil {
|
||||
t.Errorf("NewTagAsk%v is not refused", bad)
|
||||
}
|
||||
}
|
||||
_, uraw, err := NewUntag("o/r", "feat/x", "branch gone by hand", "", at.Add(time.Second))
|
||||
if u, _ := DecodeTagAsk(uraw); err != nil || !u.Untag {
|
||||
t.Fatalf("NewUntag = %+v, %v", u, err)
|
||||
}
|
||||
vals := map[string][]byte{"a": uraw, "b": raw}
|
||||
r := Reader{Keys: func() ([]string, error) { return []string{"a", "b"}, nil },
|
||||
Get: func(k string) (json.RawMessage, bool, error) { return vals[k], true, nil }}
|
||||
if got, err := AnswerTagAsks(r, ""); err != nil || len(got.Asks) != 2 || got.Asks[0].Repository != "novox/mesh-sdk" {
|
||||
t.Errorf("AnswerTagAsks = %+v, %v", got, err)
|
||||
}
|
||||
if got, _ := AnswerTagAsks(r, "o/r"); len(got.Asks) != 1 || !got.Asks[0].Untag {
|
||||
t.Errorf("AnswerTagAsks(o/r) = %+v", got)
|
||||
}
|
||||
es := []Entry{{Name: "n", Version: "0.1.2-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/r"}, {Name: "n", Version: "0.1.2-feat-x.2", Tag: "feat-x", Series: "0.1", State: Removed, Repository: "o/r"},
|
||||
{Name: "n2", Version: "0.1.2-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/other"},
|
||||
{Name: "n", Version: "0.1.1", Series: "0.1"}, {Name: "n", Version: "0.1.2-feat-y.1", Tag: "feat-y", Series: "0.1"}}
|
||||
for _, c := range []struct {
|
||||
asks []TagAsk
|
||||
repo, head string
|
||||
want string
|
||||
}{
|
||||
{[]TagAsk{a}, "novox/mesh-sdk", "abc", "feat-x-y"},
|
||||
{[]TagAsk{a}, "novox/mesh-sdk", "other", ""},
|
||||
{[]TagAsk{{Repository: "novox/mesh-sdk", Head: "abc", Tag: "t", State: TagRemoved}}, "novox/mesh-sdk", "abc", ""},
|
||||
} {
|
||||
if got, _ := TagFor(c.asks, c.repo, c.head); got != c.want {
|
||||
t.Errorf("TagFor(%s, %s) = %q, want %q", c.repo, c.head, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := Untagged(es, "o/r", "feat/x"); len(got) != 1 || got[0].Version != "0.1.2-feat-x.1" {
|
||||
t.Errorf("Untagged = %+v", got)
|
||||
}
|
||||
st := newMem()
|
||||
for _, e := range append(es, Entry{Name: "n3", Version: "0.1.0-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/r", Seat: SeatNPM}) {
|
||||
e.Kind, e.Module, e.Fingerprint, e.At = KindPackage, "m", "f", at
|
||||
if e.State == "" {
|
||||
e.State = Numbered
|
||||
}
|
||||
raw, err := Encode(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = st.Create(context.Background(), Key(e.Name, e.Version), raw)
|
||||
}
|
||||
var removed []string
|
||||
var kept map[string]bool // review round 2: the names whose seat keeps them whole (a shared digest)
|
||||
acts := Acts{Numberer: &Numberer{Versions: Versions{Store: st}}, Remove: func(_ context.Context, seat string, e Entry) (RemoveOutcome, error) {
|
||||
removed = append(removed, e.Name+"@"+e.Version)
|
||||
if kept[e.Name] {
|
||||
return RemoveOutcome{Kept: true, Reason: "shared-digest", Tags: []string{"0.1.2"}}, nil
|
||||
}
|
||||
if seat == SeatNPM {
|
||||
return RemoveOutcome{}, errors.New("down")
|
||||
}
|
||||
return RemoveOutcome{}, nil
|
||||
}}
|
||||
u, _ := DecodeTagAsk(uraw)
|
||||
got := acts.TagAsked(context.Background(), u, at)
|
||||
if got.State != TagRemoving || len(removed) != 2 || len(got.Left) != 2 || !strings.Contains(got.Left["n3@0.1.0-feat-x.1"], "down") || got.Answered.IsZero() {
|
||||
t.Errorf("untag: %+v removed %v", got, removed)
|
||||
}
|
||||
if k := acts.TagAsked(context.Background(), a, at); k.State != TagAsked || k.Answer == "" {
|
||||
t.Errorf("a tag ask is not kept: %+v", k)
|
||||
}
|
||||
// DLV-B6: a branch's end asks remove of every tagged version not removed, and is done once each seat said removed.
|
||||
removed = nil
|
||||
end, err := acts.BranchEnded(context.Background(), "o/r", "feat/x")
|
||||
if err != nil || end.Done || len(removed) != 2 || !reflect.DeepEqual(end.Seats(), []string{"", SeatNPM}) || !strings.Contains(end.Finding("o/r", "feat/x", at), "n3@0.1.0-feat-x.1 on npm-package-registry: down") {
|
||||
t.Fatalf("BranchEnded = %+v %v, removed %v", end, err, removed)
|
||||
}
|
||||
for _, v := range [][2]string{{"n", "0.1.2-feat-x.1"}, {"n3", "0.1.0-feat-x.1"}} {
|
||||
if _, err := acts.Numberer.Heard(context.Background(), SeatNPM, HeardRemoved, "rm", Outcome{Name: v[0], Version: v[1], Reason: "tag-gone"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if end, _ := acts.BranchEnded(context.Background(), "o/r", "feat/x"); !end.Done {
|
||||
t.Errorf("every seat said removed, and the branch's end is not done: %+v", end)
|
||||
}
|
||||
// Review round 2: a remove its seat answers kept (another tag names the digest) ends there, with no retry: the
|
||||
// version is recorded kept on the store with the reason and the other tags, which builder_versions shows.
|
||||
raw, _ = Encode(Entry{Name: "n4", Kind: KindPackage, Series: "0.1", Version: "0.1.2-feat-x.1", Tag: "feat-x", Repository: "o/r", Seat: SeatNPM, State: Numbered})
|
||||
_, _ = st.Create(context.Background(), Key("n4", "0.1.2-feat-x.1"), raw)
|
||||
kept, removed = map[string]bool{"n4": true}, nil
|
||||
if end, err := acts.BranchEnded(context.Background(), "o/r", "feat/x"); err != nil || !end.Done || len(removed) != 1 {
|
||||
t.Fatalf("a kept remove: %+v %v, removed %v; want done at once", end, err, removed)
|
||||
}
|
||||
if es, _ := acts.Numberer.Versions.Of(context.Background(), "n4"); len(es) != 1 || es[0].State != Kept || es[0].Reason != "shared-digest" || !reflect.DeepEqual(es[0].KeptWith, []string{"0.1.2"}) {
|
||||
t.Errorf("the kept version = %+v, want kept, shared-digest, with 0.1.2", es)
|
||||
}
|
||||
if removed = nil; func() bool {
|
||||
end, _ := acts.BranchEnded(context.Background(), "o/r", "feat/x")
|
||||
return !end.Done || len(removed) != 0
|
||||
}() {
|
||||
t.Errorf("a kept version is asked again: removed %v", removed)
|
||||
}
|
||||
if _, err := acts.Numberer.Heard(context.Background(), SeatNPM, HeardKept, "k", Outcome{Name: "n", Version: "0.1.2-feat-x.1", Tags: []string{"0.1.2"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if es, _ := acts.Numberer.Versions.Of(context.Background(), "n"); slices.ContainsFunc(es, func(e Entry) bool { return e.Version == "0.1.2-feat-x.1" && e.State == Kept }) {
|
||||
t.Error("a seat's kept turned a removed version back to kept")
|
||||
}
|
||||
// B6: a check's build of the ask's head marks it numbered with its versions; another head, or an untag, is left.
|
||||
if !a.Unanswered() {
|
||||
t.Errorf("a kept ask with no version is answered: %+v", a)
|
||||
}
|
||||
if _, changed := a.WithNumbered("mesh-sdk", "other", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at); changed {
|
||||
t.Error("an ask is marked numbered by a build of another head")
|
||||
}
|
||||
if _, changed := u.WithNumbered("o", "", map[string]string{"go": "1"}, nil, at); changed {
|
||||
t.Error("an untag is marked numbered")
|
||||
}
|
||||
n, changed := a.WithNumbered("mesh-sdk", "abc", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at.Add(time.Minute))
|
||||
if !changed || n.State != TagNumbered || n.Versions["mesh-sdk/go"] != "0.1.15-feat-x-y.1" || n.Unanswered() || !strings.Contains(n.Answer, "mesh-sdk/go 0.1.15-feat-x-y.1") {
|
||||
t.Errorf("WithNumbered = %+v, %v", n, changed)
|
||||
}
|
||||
if _, again := n.WithNumbered("mesh-sdk", "abc", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at); again {
|
||||
t.Error("the same versions mark the ask again")
|
||||
}
|
||||
said := NarrateUnanswered(a, []FloorWait{{Name: "mesh-sdk/go", Silent: []string{"bridge_tags"}}}, at.Add(6*time.Minute))
|
||||
for _, w := range []string{"novox/mesh-sdk feat/X y", "after 6m0s", "not claimed yet", "mesh-sdk/go waits on its floor (bridge_tags silent)"} {
|
||||
if !strings.Contains(said, w) {
|
||||
t.Errorf("NarrateUnanswered = %q, want %q in it", said, w)
|
||||
}
|
||||
}
|
||||
if k, v, err := TagWantedAsk(TagWanted{Repository: "o/r", Branch: "feat/x", Head: "abc"}, "mesh-delivery-checker", at); err != nil || k != TagAskKey("o/r", "feat/x") || !strings.Contains(string(v), `"by":"mesh-delivery-checker"`) {
|
||||
t.Errorf("TagWantedAsk = %q %s %v", k, v, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
type state struct {
|
||||
Name string `json:"name"`
|
||||
History int `json:"history,omitempty"`
|
||||
KeyTTL bool `json:"key-ttl,omitempty"`
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
@@ -94,13 +93,13 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
|
||||
if m.Module != "mesh-delivery-builder" {
|
||||
t.Errorf("module = %q", m.Module)
|
||||
}
|
||||
if want := []state{{Name: "builds", History: 5}, {Name: "engine-reads", History: 1}, {Name: "findings", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "old-form", History: 1}, {Name: "bounds", History: 1, KeyTTL: true}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-ask-bounds", History: 1, KeyTTL: true}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
|
||||
if want := []state{{Name: "builds", History: 5}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
|
||||
t.Errorf("state = %+v, want %+v", m.State, want)
|
||||
}
|
||||
// 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and
|
||||
// holds no capacity; it puts asks on the three build seats and reads their steps (ADR 0525 §1, §4, §5).
|
||||
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-build-node.toolchains",
|
||||
"mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) {
|
||||
"mesh-delivery-assigner.placement", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules"}; !reflect.DeepEqual(sorted(m.Reads), want) {
|
||||
t.Errorf("reads = %v, want %v", m.Reads, want)
|
||||
}
|
||||
if want := []string{"node-build-go", "node-build-image", "node-build-node"}; !reflect.DeepEqual(sorted(m.Uses), want) {
|
||||
@@ -109,35 +108,13 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
|
||||
// 1937/AC3: the builder asks the controller whether the artifact store still holds a done key's blobs (held), and no
|
||||
// other tool; it holds no capacity.
|
||||
// 1955/L7 (ADR 0571 §2): the seat mesh-artifact-sweep's held, the controller's `held` is no longer asked (issue 1955).
|
||||
// DLV-M1 (ADR 0615 §1): the indexer's modules, manifests and candidates and the assigner's placement are asked by their
|
||||
// read tools and followed by their views families, never read; so are the indexer's packages, whose names a package is
|
||||
// numbered under (design 32).
|
||||
for _, r := range m.Reads {
|
||||
if strings.HasPrefix(r, "mesh-delivery-indexer.") || strings.HasPrefix(r, "mesh-delivery-assigner.") {
|
||||
t.Errorf("reads %s: the indexer's and the assigner's state is read by their tools and views families alone", r)
|
||||
}
|
||||
}
|
||||
// Design 51 *The floor*: the floor's three sources, the registry seats' versions, bridge_tags and indexer_requirements.
|
||||
if want := []string{"mesh-delivery-assigner.assigner_read_placement", "mesh-delivery-forge-bridge.bridge_tags", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests",
|
||||
"mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_requirements", "seat:container-image-registry.remove", "seat:container-image-registry.versions",
|
||||
"seat:go-package-registry.remove", "seat:go-package-registry.versions", "seat:mesh-artifact-sweep.held", "seat:mesh-hand-acts.hand-act", "seat:node-engine.manifest-reads", "seat:npm-package-registry.remove",
|
||||
"seat:npm-package-registry.versions", "seat:oci-artifact-registry.remove", "seat:oci-artifact-registry.versions"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
|
||||
if want := []string{"seat:mesh-artifact-sweep.held"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
|
||||
t.Errorf("invokes = %v, config = %v, want %v and no config", m.Invokes, m.Config, want)
|
||||
}
|
||||
// The builder is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it
|
||||
// starts; and by the seat mesh-artifact-sweep's artifacts-gone, a blob the sweep found gone (1937/AC4, 1955/L7), the
|
||||
// controller's no longer (1126/B9, ADR 0590).
|
||||
// Design 51 *The floor*: the indexer's manifest-accepted and the condition keeper's condition-cleared wake a silent floor.
|
||||
// DLV-B4: the four registry seats' outcomes, each a seat event `<seat>.<event>` on the shared events stream.
|
||||
// DLV-M1: the indexer's and the assigner's views families of the states it follows.
|
||||
if want := []string{"container-image-registry.kept", "container-image-registry.published", "container-image-registry.refused", "container-image-registry.removed",
|
||||
"go-package-registry.kept", "go-package-registry.published", "go-package-registry.refused", "go-package-registry.removed",
|
||||
"mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported",
|
||||
"mesh-conditions.condition-cleared", "mesh-delivery-assigner.views.placement", "mesh-delivery-checker.tag-wanted", "mesh-delivery-forge-bridge.forge.**",
|
||||
"mesh-delivery-indexer.manifest-accepted", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules",
|
||||
"mesh-delivery-indexer.views.packages", "node-engine.manifest-reads-changed",
|
||||
"npm-package-registry.kept", "npm-package-registry.published", "npm-package-registry.refused", "npm-package-registry.removed",
|
||||
"oci-artifact-registry.kept", "oci-artifact-registry.published", "oci-artifact-registry.refused", "oci-artifact-registry.removed"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
|
||||
if want := []string{"mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
|
||||
t.Errorf("consumes = %v, want %v", m.Consumes, want)
|
||||
}
|
||||
if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" {
|
||||
@@ -147,12 +124,10 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
|
||||
t.Errorf("secrets-owner = %q", m.SecretsOwner)
|
||||
}
|
||||
// 1945: the builder's tools (builder_seat_queue: note 62), served by its tools bundle, and the event it says when it asks an ask again.
|
||||
if want := []string{"builder_announce_again", "builder_ask_again", "builder_bounds", "builder_build_again", "builder_builds_since", "builder_engine_reads", "builder_first_view_again", "builder_first_views",
|
||||
"builder_floor_again", "builder_floor_waits", "builder_old_form", "builder_publish_again", "builder_seat_queue", "builder_stuck", "builder_tag", "builder_tag_asks", "builder_untag", "builder_versions"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_seat_queue", "builder_stuck"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
t.Errorf("tools = %v, want %v", m.Tools, want)
|
||||
}
|
||||
// Design 51 *Events of the builder*: numbered and old-form-closed on the family version, apart from the builds' ends.
|
||||
if want := []string{"build.asked-again", "version.numbered", "version.old-form-closed"}; !reflect.DeepEqual(m.Emits, want) {
|
||||
if want := []string{"build.asked-again"}; !reflect.DeepEqual(m.Emits, want) {
|
||||
t.Errorf("emits = %v, want %v", m.Emits, want)
|
||||
}
|
||||
// 1955/L7 (ADR 0571 §1): its module log, each build's start and end, kept 30 days; no bare stream.
|
||||
|
||||
@@ -5,94 +5,28 @@
|
||||
"tools": [
|
||||
"builder_announce_again",
|
||||
"builder_ask_again",
|
||||
"builder_bounds",
|
||||
"builder_build_again",
|
||||
"builder_builds_since",
|
||||
"builder_engine_reads",
|
||||
"builder_floor_again",
|
||||
"builder_floor_waits",
|
||||
"builder_old_form",
|
||||
"builder_publish_again",
|
||||
"builder_seat_queue",
|
||||
"builder_stuck",
|
||||
"builder_tag",
|
||||
"builder_tag_asks",
|
||||
"builder_untag",
|
||||
"builder_versions",
|
||||
"builder_first_views",
|
||||
"builder_first_view_again"
|
||||
"builder_stuck"
|
||||
],
|
||||
"emits": [
|
||||
"build.asked-again",
|
||||
"version.numbered",
|
||||
"version.old-form-closed"
|
||||
"build.asked-again"
|
||||
],
|
||||
"consumes": [
|
||||
"container-image-registry.kept",
|
||||
"container-image-registry.published",
|
||||
"container-image-registry.refused",
|
||||
"container-image-registry.removed",
|
||||
"go-package-registry.kept",
|
||||
"go-package-registry.published",
|
||||
"go-package-registry.refused",
|
||||
"go-package-registry.removed",
|
||||
"mesh-artifact-sweep.artifacts-gone",
|
||||
"mesh-build-go.toolchain.reported",
|
||||
"mesh-build-node.toolchain.reported",
|
||||
"mesh-conditions.condition-cleared",
|
||||
"mesh-delivery-checker.tag-wanted",
|
||||
"mesh-delivery-forge-bridge.forge.**",
|
||||
"mesh-delivery-indexer.manifest-accepted",
|
||||
"node-engine.manifest-reads-changed",
|
||||
"npm-package-registry.kept",
|
||||
"npm-package-registry.published",
|
||||
"npm-package-registry.refused",
|
||||
"npm-package-registry.removed",
|
||||
"oci-artifact-registry.kept",
|
||||
"oci-artifact-registry.published",
|
||||
"oci-artifact-registry.refused",
|
||||
"oci-artifact-registry.removed",
|
||||
"mesh-delivery-assigner.views.placement",
|
||||
"mesh-delivery-indexer.views.candidates",
|
||||
"mesh-delivery-indexer.views.manifests",
|
||||
"mesh-delivery-indexer.views.modules",
|
||||
"mesh-delivery-indexer.views.packages"
|
||||
"mesh-build-node.toolchain.reported"
|
||||
],
|
||||
"invokes": [
|
||||
"mesh-delivery-forge-bridge.bridge_tags",
|
||||
"mesh-delivery-indexer.indexer_requirements",
|
||||
"seat:container-image-registry.remove",
|
||||
"seat:container-image-registry.versions",
|
||||
"seat:go-package-registry.remove",
|
||||
"seat:go-package-registry.versions",
|
||||
"seat:mesh-artifact-sweep.held",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:node-engine.manifest-reads",
|
||||
"seat:npm-package-registry.remove",
|
||||
"seat:npm-package-registry.versions",
|
||||
"seat:oci-artifact-registry.remove",
|
||||
"seat:oci-artifact-registry.versions",
|
||||
"mesh-delivery-assigner.assigner_read_placement",
|
||||
"mesh-delivery-indexer.indexer_read_candidates",
|
||||
"mesh-delivery-indexer.indexer_read_manifests",
|
||||
"mesh-delivery-indexer.indexer_read_modules",
|
||||
"mesh-delivery-indexer.indexer_read_packages"
|
||||
"seat:mesh-artifact-sweep.held"
|
||||
],
|
||||
"state": [
|
||||
{ "name": "builds", "history": 5 },
|
||||
{ "name": "engine-reads", "history": 1 },
|
||||
{ "name": "findings", "history": 5 },
|
||||
{ "name": "floor-waits", "history": 1 },
|
||||
{ "name": "hearing", "history": 1 },
|
||||
{ "name": "instances", "history": 1 },
|
||||
{ "name": "old-form", "history": 1 },
|
||||
{ "name": "bounds", "history": 1, "key-ttl": true },
|
||||
{ "name": "queues", "history": 1 },
|
||||
{ "name": "reasks", "history": 1 },
|
||||
{ "name": "tag-ask-bounds", "history": 1, "key-ttl": true },
|
||||
{ "name": "tag-asks", "history": 1 },
|
||||
{ "name": "versions", "history": 1 },
|
||||
{ "name": "first-views", "history": 1 }
|
||||
{ "name": "reasks", "history": 1 }
|
||||
],
|
||||
"logs": [
|
||||
{ "name": "build-log", "max-age-days": 30 }
|
||||
@@ -103,7 +37,11 @@
|
||||
"mesh-build-image.steps",
|
||||
"mesh-build-node.steps",
|
||||
"mesh-build-node.toolchains",
|
||||
"mesh-delivery-gate.source"
|
||||
"mesh-delivery-assigner.placement",
|
||||
"mesh-delivery-gate.source",
|
||||
"mesh-delivery-indexer.candidates",
|
||||
"mesh-delivery-indexer.manifests",
|
||||
"mesh-delivery-indexer.modules"
|
||||
],
|
||||
"uses": [
|
||||
"node-build-go",
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
@@ -246,9 +244,7 @@ func Tools(r check.Reader) []stdio.Tool {
|
||||
"build seats' streams, whose backlog the delivery view shows (novox/hq ADR 0525). held_by_turn names the candidates whose check an approved " +
|
||||
"merge's turn holds off (novox/hq ADR 0548 §3), with the repository and the merge that waits; the gate's turns tool reads every turn and its " +
|
||||
"hand-now tool steers one. dead_letters says whether the checker hears the asks the build seats give up on (issue 734), and why not: " +
|
||||
"deaf, a try or check whose ask is given up waits until its bound. builds_not_ended names, of each running check, the builder's builds of " +
|
||||
"its candidate's changed modules that are neither done nor failed: the check waits on them before it composes, within its build-wait-bound " +
|
||||
"(novox/hq ADR 0603 §10); a failed one fails the check: rebuild it with the builder's builder_build_again, and the check asks itself again when the build is done. Read-only.",
|
||||
"deaf, a try or check whose ask is given up waits until its bound. Read-only.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
Run: func(map[string]any) (any, error) { return r.Status() }},
|
||||
}
|
||||
@@ -287,7 +283,7 @@ func main() {
|
||||
StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))},
|
||||
HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(),
|
||||
TurnState: read(stdio.State("mesh-delivery-gate.turns")), HearingState: read(stdio.State(buildrun.HearingState)),
|
||||
AskState: read(stdio.State(check.AsksState)), BuildState: read(stdio.State("mesh-delivery-builder.builds"))}
|
||||
AskState: read(stdio.State(check.AsksState))}
|
||||
r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it
|
||||
tries := stdio.State(check.TriesState)
|
||||
source := stdio.State("mesh-delivery-gate.source")
|
||||
@@ -328,9 +324,6 @@ func main() {
|
||||
return err
|
||||
}}
|
||||
tools := append(append(append(Tools(r), TryTools(trying)...), SeatTryTools(trying)...), RecoveryTools(r, written)...)
|
||||
// checker_first_views and checker_first_view_again: where the checker's first views of the indexer's and the assigner's
|
||||
// states stand, and asking one again by hand (ADR 0615, DLV-M2).
|
||||
tools = append(tools, views.StatusTools(delivery.CheckerModule, "checker_first_views", "checker_first_view_again", stdio.State(views.StatusState), handacts.Recorder{Ask: stdio.Ask}, time.Now)...)
|
||||
if err := stdio.Serve("", logged(tools)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
@@ -25,14 +23,14 @@ import (
|
||||
// composition code, the composer built from its head run on the Go build seat.
|
||||
|
||||
// composeCandidateKey is the seat verb's call key, asked of one node (process.Conn.CallOn).
|
||||
var composeCandidateKey = "seat:" + catalogue.EngineSeat + "." + ne.VerbComposeCandidate
|
||||
var composeCandidateKey = "seat:" + catalogue.EngineSeat + "." + delivery.EngineComposeCandidateVerb
|
||||
|
||||
// engineAsks asks one node's engine over the bus, its answer waited for at most ctx's deadline (the answer bound).
|
||||
type engineAsks struct {
|
||||
call func(node, key string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (e engineAsks) ComposeCandidate(ctx context.Context, node string, ask ne.ComposeCandidateInput) (ne.ComposeCandidateAnswer, error) {
|
||||
func (e engineAsks) ComposeCandidate(ctx context.Context, node string, ask delivery.ComposeCandidateAsk) (delivery.ComposeCandidateAnswer, error) {
|
||||
type answer struct {
|
||||
raw json.RawMessage
|
||||
err error
|
||||
@@ -44,12 +42,12 @@ func (e engineAsks) ComposeCandidate(ctx context.Context, node string, ask ne.Co
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ne.ComposeCandidateAnswer{}, fmt.Errorf("no answer within its bound: %w", ctx.Err())
|
||||
return delivery.ComposeCandidateAnswer{}, fmt.Errorf("no answer within its bound: %w", ctx.Err())
|
||||
case a := <-got:
|
||||
if a.err != nil {
|
||||
return ne.ComposeCandidateAnswer{}, a.err
|
||||
return delivery.ComposeCandidateAnswer{}, a.err
|
||||
}
|
||||
return nec.DecodeAnswer(a.raw)
|
||||
return delivery.DecodeComposeCandidateAnswer(a.raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +59,7 @@ type placedNodes struct {
|
||||
}
|
||||
|
||||
func (p placedNodes) Placed() ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nec.AnswerBound)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound)
|
||||
defer cancel()
|
||||
kv, err := p.kv(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -23,16 +22,10 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
gobuild "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
|
||||
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views/viewstest"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
@@ -98,8 +91,6 @@ type fakeBuildModules struct {
|
||||
beside map[string][]string
|
||||
// names are the nodes' names the last check ask carried, for the names check (ADR 0567 §12).
|
||||
names []string
|
||||
// packages are, by repository, the package versions the last Go check ask of it carried (StepAsk.Packages).
|
||||
packages map[string]delivery.PackageVersions
|
||||
}
|
||||
|
||||
func (f *fakeBuildModules) count(tool string) int {
|
||||
@@ -206,12 +197,6 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key
|
||||
if a.Check != nil {
|
||||
f.mu.Lock()
|
||||
f.names = a.Check.NodeNames
|
||||
if repository, _ := a.Check.Fetch["repository"].(string); a.Language == delivery.AskGo {
|
||||
if f.packages == nil {
|
||||
f.packages = map[string]delivery.PackageVersions{}
|
||||
}
|
||||
f.packages[repository] = a.Packages
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
return gobuild.RunAsk(ctx, steps, a)
|
||||
@@ -235,15 +220,6 @@ func (b *bed) serveForge(t *testing.T, nc *nats.Conn) {
|
||||
var a map[string]string
|
||||
_ = json.Unmarshal(m.Data, &a)
|
||||
switch {
|
||||
case tool == "gitea_get_file" && strings.HasSuffix(a["path"], "module.json"):
|
||||
// A manifest at a member's head (design 51 *Tagged versions*): the bed's own, else the forge's 404.
|
||||
if content, ok := b.heads.Load(a["owner"] + "/" + a["repo"] + " " + a["path"] + " " + a["ref"]); ok {
|
||||
answer, _ := json.Marshal(map[string]any{"result": map[string]any{"file": map[string]any{"content": content}}, "error": "", "node": "n0"})
|
||||
_ = m.Respond(answer)
|
||||
return
|
||||
}
|
||||
answer, _ := json.Marshal(map[string]any{"result": nil, "error": "Gitea API /repos/x/contents/" + a["path"] + ": 404 Not Found", "node": "n0"})
|
||||
_ = m.Respond(answer)
|
||||
case tool == "gitea_get_file" && b.stalePin.Load():
|
||||
content := strings.Replace(string(mustRead(t, "forge-get-file-go-mod")), "cb54756c3865", "9aaa5c80ac5c", 1)
|
||||
var result map[string]any
|
||||
@@ -276,8 +252,6 @@ func mustRead(t *testing.T, name string) []byte {
|
||||
|
||||
type bed struct {
|
||||
stalePin atomic.Bool
|
||||
// heads are the manifests the forge holds at a member's head, by "<repository> <path> <ref>".
|
||||
heads sync.Map
|
||||
ctx context.Context
|
||||
js jetstream.JetStream
|
||||
buckets map[string]jetstream.KeyValue
|
||||
@@ -310,7 +284,7 @@ func (b *bed) said(t *testing.T, words string) {
|
||||
|
||||
// engine places each node and serves compose-candidate for the first one, as its node-engine does on the bus (answered on
|
||||
// the seat verb's caller-named and plain subjects); the others never answer. It answers what it was asked.
|
||||
func (b *bed) engine(t *testing.T, nodes ...string) func() []ne.ComposeCandidateInput {
|
||||
func (b *bed) engine(t *testing.T, nodes ...string) func() []delivery.ComposeCandidateAsk {
|
||||
t.Helper()
|
||||
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: delivery.PlacementBucket, History: 1})
|
||||
if err != nil {
|
||||
@@ -329,9 +303,9 @@ func (b *bed) engine(t *testing.T, nodes ...string) func() []ne.ComposeCandidate
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
var mu sync.Mutex
|
||||
var asked []ne.ComposeCandidateInput
|
||||
var asked []delivery.ComposeCandidateAsk
|
||||
answer := func(m *nats.Msg) {
|
||||
ask, err := nec.DecodeAsk(m.Data)
|
||||
ask, err := delivery.DecodeComposeCandidateAsk(m.Data)
|
||||
if err != nil {
|
||||
_ = m.Respond([]byte(`{"error":"` + err.Error() + `"}`))
|
||||
return
|
||||
@@ -339,9 +313,9 @@ func (b *bed) engine(t *testing.T, nodes ...string) func() []ne.ComposeCandidate
|
||||
mu.Lock()
|
||||
asked = append(asked, ask)
|
||||
mu.Unlock()
|
||||
a := ne.ComposeCandidateAnswer{Node: nodes[0], Candidate: ask.Candidate, Verdict: nec.Composed,
|
||||
Own: ne.Module{Module: nec.Own, State: nec.ModuleComposed, Digest: "sha256:" + strings.Repeat("b", 64)}}
|
||||
raw, err := nec.EncodeAnswer(a)
|
||||
a := delivery.ComposeCandidateAnswer{Node: nodes[0], Candidate: ask.Candidate, Verdict: delivery.ComposeCandidateComposed,
|
||||
Own: delivery.ModuleComposed{Module: "node", State: delivery.ModuleComposedState, Digest: "sha256:" + strings.Repeat("b", 64)}}
|
||||
raw, err := delivery.EncodeComposeCandidateAnswer(a)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
@@ -353,7 +327,7 @@ func (b *bed) engine(t *testing.T, nodes ...string) func() []ne.ComposeCandidate
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return func() []ne.ComposeCandidateInput {
|
||||
return func() []delivery.ComposeCandidateAsk {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return slices.Clone(asked)
|
||||
@@ -416,39 +390,21 @@ func startBed(t *testing.T) *bed {
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: buildrun.DeadLettersStream, Subjects: []string{"mesh.events.dead.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The checker's streams as its manifest declares them (ADR 0619, ADR 0620): checks, which the indexer and the forge
|
||||
// bridge hear, and asks, with verdict and tag-wanted at the subjects the mesh-delivery-checker definition names.
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "CHECKS", Subjects: []string{meshdeliverychecker.HolderSubjectPreviewWritten(delivery.CheckerModule)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ASKS", Subjects: []string{meshdeliverychecker.HolderSubjectVerdict(delivery.CheckerModule),
|
||||
meshdeliverychecker.HolderSubjectTagWanted(delivery.CheckerModule)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The shared events stream with the package registry seats' published, and the checker's durable consumer of it.
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{gopackageregistry.SubjectPublished, npmpackageregistry.SubjectPublished}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateOrUpdateConsumer(ctx, process.EventsStream, jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-checker", AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
|
||||
// The checker's stream of events, which the indexer and the forge bridge hear.
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "CHECKS", Subjects: []string{"mesh.stream.mesh-delivery-checker.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, history := range map[string]int{delivery.CheckerVerdictsBucket: delivery.CheckerVerdictsHistory, delivery.CheckerKeptBucket: delivery.CheckerKeptHistory,
|
||||
delivery.CheckerOwedBucket: delivery.CheckerOwedHistory, delivery.PreviewsBucket: delivery.PreviewsHistory, delivery.CandidatesBucket: delivery.CandidatesHistory,
|
||||
delivery.ModulesBucket: delivery.ModulesHistory, delivery.ManifestsBucket: delivery.ManifestsHistory, delivery.SourceBucket: delivery.SourceHistory,
|
||||
delivery.PresenceBucket: delivery.PresenceHistory, delivery.HaveBucket: delivery.HaveHistory, check.TriesBucket: 1,
|
||||
delivery.TurnsBucket: delivery.TurnsHistory, delivery.BuilderBuildsBucket: 1} {
|
||||
delivery.TurnsBucket: delivery.TurnsHistory} {
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: uint8(history)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.buckets[name] = kv
|
||||
}
|
||||
// The indexer and the assigner as the owners of the states the checker follows: their read tools and views families
|
||||
// (ADR 0615, DLV-M2).
|
||||
for _, st := range []string{candidatesState, modulesState, manifestsState, delivery.PackagesState, delivery.IndexerPredefinedState, seatDefinitionsState} {
|
||||
viewstest.Serve(t, ctx, js, ctl, delivery.IndexerModule, st, delivery.IndexerModule+".indexer_read_"+strings.ReplaceAll(st, "-", "_"), indexerViews, "n1_"+delivery.CheckerModule)
|
||||
}
|
||||
viewstest.Serve(t, ctx, js, ctl, delivery.AssignerModule, delivery.PlacementState, delivery.AssignerModule+".assigner_read_placement", assignerViews, "n1_"+delivery.CheckerModule)
|
||||
// 1929/AC1: the indexer's value of the repository's module, with no TypeScript manifest, so the Go build module alone
|
||||
// judges the repository (an unknown repository is judged by both, issue 1887 A12).
|
||||
known, err := delivery.EncodeModule(delivery.Module{Repository: repo, Commit: trunk, Path: "modules/m", None2: "the fingerprint is computed with its chain"})
|
||||
@@ -622,72 +578,20 @@ func (b *bed) owed(t *testing.T, number int, head, state string) delivery.Owed {
|
||||
// TestCheckerJudgesACandidateAndOwesItsStatus: a candidate the indexer wrote, one pull request of mesh-delivery, is fetched
|
||||
// merged onto the trunk the gate names, digested and tested at both levels on the node, kept, previewed; the verdict passes
|
||||
// with the counts of both levels and the composition not run, and the status mesh/check is owed on the head as success.
|
||||
func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300/AC5, 1445/AC1, 1929/AC1, ADR 0603 §10
|
||||
func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300/AC5, 1445/AC1, 1929/AC1
|
||||
b := startBed(t)
|
||||
// ADR 0603 §10: the candidate changes the module m, whose manifest the trunk holds; the builder has not built m's
|
||||
// candidate fingerprint yet, so the check waits on the builder's key before it composes.
|
||||
manifest := `{"module":"m"}`
|
||||
digest := delivery.ManifestDigest([]byte(manifest))
|
||||
stored, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: manifest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mkey, _ := delivery.ManifestsKey(digest)
|
||||
if _, err := b.buckets[delivery.ManifestsBucket].Put(b.ctx, mkey, stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, err := delivery.EncodeModule(delivery.Module{Repository: repo, Commit: trunk, Path: "modules/m", Manifest: digest, None2: "the fingerprint is computed with its chain"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.buckets[delivery.ModulesBucket].Put(b.ctx, "m", known); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fp := "src2:" + strings.Repeat("f", 64)
|
||||
p := pull(repo, 7, head1)
|
||||
p.Modules, p.Fps = []string{"m"}, map[string]delivery.CandidateModule{"m": {FP2: fp, Manifest: digest}}
|
||||
key := b.candidate(t, "feat/x", p)
|
||||
key := b.candidate(t, "feat/x", pull(repo, 7, head1))
|
||||
// ADR 0593 §7, §9 (D12): n1 and n2 are placed; n1's engine answers compose-candidate on the bus, n2's never does.
|
||||
asked := b.engine(t, "n1", "n2")
|
||||
b.start(t, nil)
|
||||
b.said(t, "waits on the builder's builds of m before it composes")
|
||||
if got := asked(); len(got) != 0 {
|
||||
t.Fatalf("the engines were asked %+v before m's build ended", got)
|
||||
}
|
||||
bkey, _ := delivery.BuilderBuildKey("m", fp)
|
||||
building, _ := json.Marshal(delivery.BuilderBuild{V: 1, State: delivery.BuildBuilding})
|
||||
if _, err := b.buckets[delivery.BuilderBuildsBucket].Put(b.ctx, bkey, building); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := b.verdict(t, key, func(delivery.Verdict) bool { return true }); v.State != delivery.VerdictRunning || len(asked()) != 0 {
|
||||
t.Fatalf("verdict %s and %d asks while m builds: the check composes only once its build ended", v.State, len(asked()))
|
||||
}
|
||||
// m's build fails: the check fails naming it. Built again (builder_build_again writes the key done), the check asks
|
||||
// itself again for the same heads, with no recheck, and passes.
|
||||
failedBuild, _ := json.Marshal(delivery.BuilderBuild{V: 1, State: delivery.BuildFailed, Error: "go build: undefined: x"})
|
||||
if _, err := b.buckets[delivery.BuilderBuildsBucket].Put(b.ctx, bkey, failedBuild); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := b.verdict(t, key, ended); v.State != delivery.VerdictFail || v.Failed.Test != check.BuildFailedTest+"m" {
|
||||
t.Fatalf("verdict %+v, want it failed on m's build", v)
|
||||
}
|
||||
built, _ := json.Marshal(delivery.BuilderBuild{V: 1, State: delivery.BuildDone, Manifest: digest})
|
||||
if _, err := b.buckets[delivery.BuilderBuildsBucket].Put(b.ctx, bkey, built); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.said(t, "the builds of m it failed on are done again, so it is checked again")
|
||||
v := b.verdict(t, key, func(v delivery.Verdict) bool { return v.State == delivery.VerdictPass })
|
||||
// The check asked again takes the packages the failed check ran from their kept verdicts.
|
||||
if v.State != delivery.VerdictPass || v.Levels[delivery.LevelUnit].Kept != 2 || v.Levels[delivery.LevelIntegration].Kept != 2 || v.Trunk[repo] != trunk {
|
||||
v := b.verdict(t, key, ended)
|
||||
if v.State != delivery.VerdictPass || v.Levels[delivery.LevelUnit].Run != 2 || v.Levels[delivery.LevelIntegration].Run != 2 || v.Trunk[repo] != trunk {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
if c := v.Composition; c == nil || c.State != delivery.CompositionPass || !slices.Equal(c.Machines, []string{"n1"}) ||
|
||||
!strings.Contains(c.Words, "n2: "+nec.NoAnswer) || len(v.Machines) != 1 || v.Machines[0] != "n1" {
|
||||
!strings.Contains(c.Words, "n2: "+delivery.ComposeNoAnswer) || len(v.Machines) != 1 || v.Machines[0] != "n1" {
|
||||
t.Errorf("composition %+v machines %v", v.Composition, v.Machines)
|
||||
}
|
||||
if v.Composition != nil && strings.Contains(v.Composition.Words, "did not end") {
|
||||
t.Errorf("the composition says %q: m's build ended within the bound", v.Composition.Words)
|
||||
}
|
||||
if got := asked(); len(got) == 0 || got[0].Candidate != key {
|
||||
t.Errorf("n1's engine was asked %+v, want the check's candidate %s", got, key)
|
||||
}
|
||||
@@ -698,11 +602,10 @@ func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300
|
||||
t.Errorf("the check ask named the nodes %v, want [n1] from the have's node record", names)
|
||||
}
|
||||
o := b.owed(t, 7, head1, "success")
|
||||
if o.Context != delivery.StatusContextCheck || !strings.HasPrefix(o.Text, "pass 111111111111 on aaaaaaaaaaaa") || !strings.Contains(o.Text, "unit 0 run, 2 kept") {
|
||||
if o.Context != delivery.StatusContextCheck || !strings.HasPrefix(o.Text, "pass 111111111111 on aaaaaaaaaaaa") || !strings.Contains(o.Text, "unit 2 run") {
|
||||
t.Errorf("owed = %+v", o)
|
||||
}
|
||||
// Two checks of the same heads: the one failed on m's build and the one it asked itself again; the second runs no test.
|
||||
for tool, want := range map[string]int{"mesh-build-go.build_go_fetch": 2, "mesh-build-go.build_go_digest": 4, "mesh-build-go.build_go_test": 2} {
|
||||
for tool, want := range map[string]int{"mesh-build-go.build_go_fetch": 1, "mesh-build-go.build_go_digest": 2, "mesh-build-go.build_go_test": 2} {
|
||||
if got := b.fake.count(tool); got != want {
|
||||
t.Errorf("%s called %d times, want %d", tool, got, want)
|
||||
}
|
||||
@@ -874,81 +777,6 @@ func TestAMemberNamingAMeshSDKCommitOffTheTrunkIsJudgedAndNotRefused(t *testing.
|
||||
if v := b.verdict(t, key, ended); v.State != delivery.VerdictPass {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
|
||||
// DLV-C3, DLV-C6 (design 51 *Tagged versions*, ADR 0607 §3, §5, ADR 0620): a group whose mesh-sdk member declares a Go
|
||||
// package, read at its head with no preview yet, says tag-wanted on the checker's stream asks, at the subject and in the body the mesh-delivery-checker
|
||||
// definition names; once the Go seat's published of the tagged version is heard, the other member is built against it.
|
||||
const sdkRepo = "novox/mesh-sdk"
|
||||
b.setTrunk(t, map[string]string{repo: trunk, sdkRepo: trunk})
|
||||
// The member carries no preview of its merge yet, so its manifest is read at its head (the package new in the group).
|
||||
b.heads.Store(sdkRepo+" module.json "+head2, `{"module":"mesh-sdk","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]}]}}`)
|
||||
asks, err := b.js.OrderedConsumer(b.ctx, "ASKS", jetstream.OrderedConsumerConfig{FilterSubjects: []string{meshdeliverychecker.HolderSubjectTagWanted(delivery.CheckerModule)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sdk := pull(sdkRepo, 12, head2)
|
||||
sdk.Modules, sdk.Fps = []string{"mesh-sdk"}, map[string]delivery.CandidateModule{"mesh-sdk": {FP2: "src2:" + strings.Repeat("e", 64)}}
|
||||
key = b.candidate(t, "feat/x", pull(repo, 8, head1), sdk)
|
||||
msg, err := asks.Next(jetstream.FetchMaxWait(20 * time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("no tag-wanted on the stream asks: %v", err)
|
||||
}
|
||||
var wanted meshdeliverychecker.TagWantedHolderEvent
|
||||
dec := json.NewDecoder(bytes.NewReader(msg.Data()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&wanted); err != nil {
|
||||
t.Fatalf("tag-wanted %s is not the definition's body: %v", msg.Data(), err)
|
||||
}
|
||||
if want := (meshdeliverychecker.TagWantedHolderEvent{Repository: sdkRepo, Branch: "feat/x", Head: head2, Module: "mesh-sdk", Package: "go", Pull: 12}); wanted != want {
|
||||
t.Errorf("tag-wanted = %+v, want %+v", wanted, want)
|
||||
}
|
||||
if v, err := b.buckets[delivery.CheckerVerdictsBucket].Get(b.ctx, key); err == nil && !strings.Contains(string(v.Value()), `"running"`) {
|
||||
t.Fatalf("the group was judged before its tagged version was published: %s", v.Value())
|
||||
}
|
||||
published, _ := json.Marshal(gopackageregistry.PublishedEvent{Holder: "gitea", Kind: "package", Name: "mesh-sdk/go", Module: check.SDKModulePath, Commit: head2, Tag: "feat-x", Version: "0.1.15-feat-x.1",
|
||||
Digest: "sha256:" + strings.Repeat("0", 64)})
|
||||
if _, err := b.js.Publish(b.ctx, gopackageregistry.SubjectPublished, published); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := b.verdict(t, key, ended); v.State != delivery.VerdictPass {
|
||||
t.Fatalf("the group's verdict = %+v", v)
|
||||
}
|
||||
b.fake.mu.Lock()
|
||||
got := b.fake.packages[repo]
|
||||
b.fake.mu.Unlock()
|
||||
if got[check.SDKModulePath] != "v0.1.15-feat-x.1" {
|
||||
t.Errorf("mesh-delivery was built against %v, want mesh-sdk's Go module at the group's tagged version v0.1.15-feat-x.1", got)
|
||||
}
|
||||
|
||||
// DLV-C2, DLV-C6 (design 26 *Out of step*, ADR 0620): each check's end says one verdict per pull request on the stream
|
||||
// asks, at the definition's subject and in its body (decoded strictly): the lone pull request's, then the group's two.
|
||||
verdicts, err := b.js.OrderedConsumer(b.ctx, "ASKS", jetstream.OrderedConsumerConfig{FilterSubjects: []string{meshdeliverychecker.HolderSubjectVerdict(delivery.CheckerModule)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said := map[int64]meshdeliverychecker.VerdictHolderEvent{}
|
||||
for len(said) < 3 {
|
||||
msg, err := verdicts.Next(jetstream.FetchMaxWait(20 * time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("verdicts said %+v, then none: %v", said, err)
|
||||
}
|
||||
var e meshdeliverychecker.VerdictHolderEvent
|
||||
dec := json.NewDecoder(bytes.NewReader(msg.Data()))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&e); err != nil {
|
||||
t.Fatalf("verdict %s is not the definition's body: %v", msg.Data(), err)
|
||||
}
|
||||
if _, twice := said[e.Pull]; twice {
|
||||
t.Errorf("a second verdict of pull %d: %+v", e.Pull, e)
|
||||
}
|
||||
said[e.Pull] = e
|
||||
}
|
||||
for pull, want := range map[int64]meshdeliverychecker.VerdictHolderEvent{7: {Repository: repo, Pull: 7, Head: head1, Verdict: delivery.VerdictPass},
|
||||
8: {Repository: repo, Pull: 8, Head: head1, Verdict: delivery.VerdictPass}, 12: {Repository: sdkRepo, Pull: 12, Head: head2, Verdict: delivery.VerdictPass}} {
|
||||
if got := said[pull]; got.Repository != want.Repository || got.Head != want.Head || got.Verdict != want.Verdict || len(got.OutOfStep) != 0 {
|
||||
t.Errorf("verdict of pull %d = %+v, want %+v", pull, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// triesStore is the tries bucket as the tools write it.
|
||||
@@ -1057,9 +885,7 @@ func TestATryIsJudgedAndItsEndWrittenWithNoStatusOwed(t *testing.T) {
|
||||
// the readable ones, which is what grants the forge module its read of owed (ADR 0495 §2-§3, ADR 0521; R4e finding R2-1).
|
||||
func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1
|
||||
b := startBed(t)
|
||||
// The bed's shared events stream carries the registry seats' published; the module events go on it too.
|
||||
if _, err := b.js.UpdateStream(b.ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{"mesh.mod.*.event.>",
|
||||
gopackageregistry.SubjectPublished, npmpackageregistry.SubjectPublished}}); err != nil {
|
||||
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.start(t, nil)
|
||||
@@ -1080,7 +906,7 @@ func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1
|
||||
}
|
||||
if said.Module != "mesh-delivery-checker" || said.Node != "n1" || !said.Keeps("owed") || !said.Readable("owed") ||
|
||||
!said.Readable("previews") || !said.Readable("verdicts") || !said.Readable("kept") || !said.Readable("tries") || said.Readable("hearing") ||
|
||||
!said.Keeps("asks") || said.Readable("asks") || !said.Keeps("first-views") || said.Readable("first-views") || !said.Keeps("findings") || len(said.States) != 9 {
|
||||
t.Fatalf("the checker said %+v, want its nine states, asks, hearing and first-views its own (DLV-M2), with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said)
|
||||
!said.Keeps("asks") || said.Readable("asks") || len(said.States) != 7 {
|
||||
t.Fatalf("the checker said %+v, want its seven states, asks and hearing its own, with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,28 +6,21 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
// judgeDeps are the parts the judge of a check is made of.
|
||||
type judgeDeps struct {
|
||||
asks check.Asks
|
||||
renames check.Renames
|
||||
seatDefs check.SeatDefinitions
|
||||
tools *buildrun.ToolchainSet
|
||||
trunks *check.Trunks
|
||||
kept check.KV
|
||||
@@ -38,26 +31,24 @@ type judgeDeps struct {
|
||||
forge check.Forge
|
||||
announce check.Announcer
|
||||
builds check.BuildStates
|
||||
// ends waits on the builder's builds of a candidate before the check composes (ADR 0603 §10); nil composes without waiting.
|
||||
ends check.BuildEnds
|
||||
// buildWait is the setting build-wait-bound; check.DefaultBuildWait when 0.
|
||||
buildWait time.Duration
|
||||
// compositions makes a check's composition from the engines' answers (ADR 0593 §7); nil leaves it not run.
|
||||
compositions check.CompositionReader
|
||||
// nodes names every node with a record; nil hands no names.
|
||||
nodes *nodeNames
|
||||
// predefined is the indexer's kept list of predefined keys; nil reads none.
|
||||
predefined check.PredefinedList
|
||||
// tags says a group's tag-wanted and hears its packages' published (design 51 *Tagged versions*); nil asks none.
|
||||
tags check.TagAsks
|
||||
logf func(format string, args ...any)
|
||||
logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// keptPredefined reads the indexer's key predefined.list (delivery.PredefinedKept), bound once its state appears.
|
||||
type keptPredefined struct{ kv jetstream.KeyValue }
|
||||
type keptPredefined struct{ b *process.AwaitedBucket }
|
||||
|
||||
func (k keptPredefined) PredefinedList(ctx context.Context) (json.RawMessage, error) {
|
||||
e, err := k.kv.Get(ctx, delivery.PredefinedListKey)
|
||||
kv := k.b.KV()
|
||||
if kv == nil {
|
||||
return nil, process.ErrNotYet
|
||||
}
|
||||
e, err := kv.Get(ctx, delivery.PredefinedListKey)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -71,68 +62,6 @@ func (k keptPredefined) PredefinedList(ctx context.Context) (json.RawMessage, er
|
||||
return kept.List, nil
|
||||
}
|
||||
|
||||
// keptSeatDefinitions reads a pull request's entries of the indexer's seat definitions from its first view: each whose
|
||||
// `unparsed` says why its head's definition does not parse (the entry's shape is the indexer's read tool's answer).
|
||||
type keptSeatDefinitions struct {
|
||||
keys func(ctx context.Context) ([]string, error)
|
||||
get func(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// seatDefinitionsOf reads the state's picture (a key gone between the keys and its get is none).
|
||||
func seatDefinitionsOf(kv jetstream.KeyValue) keptSeatDefinitions {
|
||||
return keptSeatDefinitions{keys: func(ctx context.Context) ([]string, error) {
|
||||
keys, err := kv.Keys(ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return keys, err
|
||||
}, get: func(ctx context.Context, key string) ([]byte, error) {
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value(), nil
|
||||
}}
|
||||
}
|
||||
|
||||
func (k keptSeatDefinitions) Unparsed(ctx context.Context, repository string, pull int) ([]check.UnparsedSeat, error) {
|
||||
suffix := ".pull." + delivery.EscapeName(repository) + "." + strconv.Itoa(pull)
|
||||
keys, err := k.keys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var out []check.UnparsedSeat
|
||||
for _, key := range keys {
|
||||
if !strings.HasSuffix(key, suffix) {
|
||||
continue
|
||||
}
|
||||
raw, err := k.get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
var v struct {
|
||||
Seat string `json:"seat"`
|
||||
Module string `json:"module"`
|
||||
Directory string `json:"directory"`
|
||||
Unparsed string `json:"unparsed"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, fmt.Errorf("%s is no seat definition entry: %w", key, err)
|
||||
}
|
||||
if v.Unparsed != "" {
|
||||
out = append(out, check.UnparsedSeat{Seat: v.Seat, Module: v.Module, Directory: v.Directory, Why: v.Unparsed})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// nodeNames reads every node's name from the node-engine's have, one key `<node>.node` per node with a record (novox/hq
|
||||
// ADR 0567 §12), at each ask: a node enrolled since is named in the next check.
|
||||
type nodeNames struct{ kv jetstream.KeyValue }
|
||||
@@ -160,58 +89,6 @@ func (n nodeNames) Names(ctx context.Context) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runningServes says what the running builds serve (novox/hq ADR 0614 §7), read when a check asks it: every module's have
|
||||
// in the node-engine's have that runs healthy, the builder's build of its fingerprint, and the verbs its manifest serves
|
||||
// (graph.Served). A build whose manifest cannot be read serves nothing here, said in the log.
|
||||
type runningServes struct {
|
||||
have *nodeNames
|
||||
builds check.BuildStates
|
||||
read check.ManifestReader
|
||||
logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
func (r runningServes) Served(ctx context.Context) (func(string) bool, error) {
|
||||
if r.have == nil || r.have.kv == nil || r.builds == nil || r.read == nil {
|
||||
return nil, fmt.Errorf("the node-engine's have, the builder's builds or the indexer's manifests cannot be read")
|
||||
}
|
||||
lister, err := r.have.kv.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
haves := map[string]map[string]delivery.Have{}
|
||||
for key := range lister.Keys() {
|
||||
node, module, isNode, err := delivery.ParseHaveKey(key)
|
||||
if err != nil || isNode {
|
||||
continue
|
||||
}
|
||||
e, err := r.have.kv.Get(ctx, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if h, err := delivery.DecodeHave(e.Value()); err == nil {
|
||||
if haves[node] == nil {
|
||||
haves[node] = map[string]delivery.Have{}
|
||||
}
|
||||
haves[node][module] = h
|
||||
}
|
||||
}
|
||||
served, unread := graph.Served(haves, func(module, fp string) ([]string, bool) {
|
||||
b, found, err := r.builds.Build(ctx, module, fp)
|
||||
if err != nil || !found || b.Manifest == "" {
|
||||
return nil, false
|
||||
}
|
||||
m, err := r.read.Manifest(ctx, b.Manifest)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return graph.ServesOf(m), true
|
||||
}, nil)
|
||||
if len(unread) > 0 && r.logf != nil {
|
||||
r.logf("the manifest of the running build of %v cannot be read, so it serves nothing the check counts (ADR 0614 §7)", unread)
|
||||
}
|
||||
return served, nil
|
||||
}
|
||||
|
||||
func newJudge(d judgeDeps) check.Judge {
|
||||
var nodes interface {
|
||||
Names(ctx context.Context) ([]string, error)
|
||||
@@ -219,12 +96,8 @@ func newJudge(d judgeDeps) check.Judge {
|
||||
if d.nodes != nil {
|
||||
nodes = d.nodes
|
||||
}
|
||||
ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools,
|
||||
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, KnownPackages: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, Logf: d.logf}
|
||||
if d.nodes != nil && d.builds != nil && d.repos != nil {
|
||||
ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf}
|
||||
}
|
||||
return ex
|
||||
return &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools,
|
||||
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Predefined: d.predefined, Logf: d.logf}
|
||||
}
|
||||
|
||||
// manifestKV reads a manifest by its digest.
|
||||
@@ -249,42 +122,6 @@ type repoModules struct {
|
||||
repoOf map[string]string // by module: its repository
|
||||
commit map[string]string // by module: the commit its record was computed at (the commit the mesh runs)
|
||||
typed map[string]string // by module: its manifest digest
|
||||
parsed map[string]catalogue.Manifest // by manifest digest: the manifest as read, once (a digest's manifest never changes)
|
||||
}
|
||||
|
||||
// Manifest is the kept manifest of a digest, read from the indexer's manifests state once and then from memory.
|
||||
func (r *repoModules) Manifest(ctx context.Context, digest string) (catalogue.Manifest, error) {
|
||||
r.mu.Lock()
|
||||
m, known := r.parsed[digest]
|
||||
r.mu.Unlock()
|
||||
if known {
|
||||
return m, nil
|
||||
}
|
||||
key, err := delivery.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
}
|
||||
raw, _, found, err := r.manifests.Get(ctx, key)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
}
|
||||
if !found {
|
||||
return catalogue.Manifest{}, fmt.Errorf("the indexer keeps no manifest %s", digest)
|
||||
}
|
||||
kept, err := delivery.DecodeManifest(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
}
|
||||
if m, err = parseManifest(kept.JSON); err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.parsed == nil {
|
||||
r.parsed = map[string]catalogue.Manifest{}
|
||||
}
|
||||
r.parsed[digest] = m
|
||||
r.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Put takes a change of the indexer's modules bucket in: a module's repository and the languages its manifest names.
|
||||
@@ -351,15 +188,6 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// ModuleDir is a module's directory in its repository as the indexer's modules hold it (check.Examiner.Dirs); false for a
|
||||
// module the indexer holds no directory of.
|
||||
func (r *repoModules) ModuleDir(module string) (string, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
d, ok := r.path[module]
|
||||
return d, ok && d != ""
|
||||
}
|
||||
|
||||
// PackagePaths are the paths, relative to the repository, that the TypeScript package artifacts of a repository's modules
|
||||
// are built from, sorted: the named paths the indexer keeps in each module's value (delivery.Module.ArtifactPaths; only the
|
||||
// indexer reads them from a manifest, the lead's decision L8), under the module's directory. None for a module whose value
|
||||
@@ -429,69 +257,6 @@ func (r buildReader) Build(ctx context.Context, module, fp2 string) (delivery.Bu
|
||||
return b, err == nil, err
|
||||
}
|
||||
|
||||
// buildEnds hears the builder's build keys of a candidate on the bus (mesh-delivery-builder_builds): a watch of exactly
|
||||
// those keys gives each one's value now and every write the builder makes after, so the check waits on the part that owns
|
||||
// the fact and polls nothing (novox/hq ADR 0603 §10).
|
||||
type buildEnds struct{ kv jetstream.KeyValue }
|
||||
|
||||
func (e buildEnds) Ended(ctx context.Context, builds map[string]string) (map[string]delivery.BuilderBuild, error) {
|
||||
return e.until(ctx, builds, func(b delivery.BuilderBuild) bool {
|
||||
return b.State == delivery.BuildDone || b.State == delivery.BuildFailed
|
||||
})
|
||||
}
|
||||
|
||||
func (e buildEnds) Done(ctx context.Context, builds map[string]string) bool {
|
||||
done, err := e.until(ctx, builds, func(b delivery.BuilderBuild) bool { return b.State == delivery.BuildDone })
|
||||
return err == nil && len(done) == len(builds)
|
||||
}
|
||||
|
||||
// until hears the builds' keys until each holds a build that ended holds, or ctx ends, and answers those that do, by module.
|
||||
func (e buildEnds) until(ctx context.Context, builds map[string]string, ended func(delivery.BuilderBuild) bool) (map[string]delivery.BuilderBuild, error) {
|
||||
byKey := map[string]string{}
|
||||
keys := make([]string, 0, len(builds))
|
||||
for module, fp2 := range builds {
|
||||
key, err := delivery.BuilderBuildKey(module, fp2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byKey[key] = module
|
||||
keys = append(keys, key)
|
||||
}
|
||||
out := map[string]delivery.BuilderBuild{}
|
||||
if len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
w, err := e.kv.WatchFiltered(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = w.Stop() }()
|
||||
for len(out) < len(keys) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return out, nil // the bound: the builds not ended are left out
|
||||
case entry, open := <-w.Updates():
|
||||
if !open {
|
||||
return out, nil
|
||||
}
|
||||
if entry == nil || entry.Operation() != jetstream.KeyValuePut {
|
||||
continue // the end of the values now, or a key deleted: it has not ended
|
||||
}
|
||||
module, ours := byKey[entry.Key()]
|
||||
if !ours {
|
||||
continue
|
||||
}
|
||||
b, err := delivery.DecodeBuilderBuild(entry.Value())
|
||||
if err == nil && ended(b) {
|
||||
out[module] = b
|
||||
} else {
|
||||
delete(out, module) // asked again (builder_build_again), or not a build the check can read: not ended
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ManifestDigests is the manifest digest of every module the indexer holds, by module: the catalogue the indexer's judge
|
||||
// judges a change's manifests in (novox/hq ADR 0578 §2).
|
||||
func (r *repoModules) ManifestDigests() map[string]string {
|
||||
|
||||
@@ -4,18 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
type memManifests map[string][]byte
|
||||
@@ -124,14 +120,6 @@ func TestTheTypeScriptPackagesPathsComeFromTheIndexersModulesNotTheManifest(t *t
|
||||
// there and computed nowhere here: a key's module at its commit, a deleted key gone, an unreadable value left out.
|
||||
func TestThePublishedVersionsAreTheIndexersPackagesState(t *testing.T) {
|
||||
var p publishedPackages
|
||||
// Not read yet, the state names no package and judges none; read and empty, it is an empty list judged against.
|
||||
if got := p.PackageNames(); got != nil {
|
||||
t.Errorf("before the first reading the known packages = %v, want nil", got)
|
||||
}
|
||||
p.Answered()
|
||||
if got := p.PackageNames(); got == nil || len(got) != 0 {
|
||||
t.Errorf("read and empty, the known packages = %#v, want an empty map", got)
|
||||
}
|
||||
put := func(module, repository, dir, commit string) {
|
||||
key, _ := delivery.PackagesKey(module)
|
||||
raw, err := delivery.EncodePublishedPackages(delivery.PublishedPackages{Module: module, Repository: repository, Dir: dir,
|
||||
@@ -154,30 +142,6 @@ func TestThePublishedVersionsAreTheIndexersPackagesState(t *testing.T) {
|
||||
if got := p.LatestPackages(); len(got) != 1 || got[sdk] == "" {
|
||||
t.Errorf("after a delete: %v", got)
|
||||
}
|
||||
// A series key names its package by its ecosystem name, the judge's known packages (DLV-I14, design 32), and is no
|
||||
// module's published version.
|
||||
series, err := delivery.EncodeSeriesPackage(delivery.SeriesPackage{Name: sdk, Package: "mesh-sdk/go", Series: "0.1", At: time.Now().UTC()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Put(delivery.SeriesPackageKey(sdk, "0.1"), series, false)
|
||||
if got := p.PackageNames(); !reflect.DeepEqual(got, map[string]string{sdk: "mesh-sdk/go"}) || len(p.LatestPackages()) != 1 {
|
||||
t.Errorf("the known packages = %v, the latest %v", got, p.LatestPackages())
|
||||
}
|
||||
t.Run("a pull request's unparsed seat definitions are its entries of the indexer's seat-definitions", func(t *testing.T) {
|
||||
state := map[string]string{
|
||||
"web-api.trunk": `{"seat":"web-api","source":"trunk","digest":"d"}`,
|
||||
"web-api.pull." + delivery.EscapeName("novox/web") + ".7": `{"seat":"web-api","source":"pull","module":"web-contracts","directory":"go","unparsed":"seat.json: bad"}`,
|
||||
"mail-api.pull." + delivery.EscapeName("novox/web") + ".7": `{"seat":"mail-api","source":"pull","digest":"e"}`,
|
||||
"web-api.pull." + delivery.EscapeName("novox/web") + ".17": `{"seat":"web-api","source":"pull","unparsed":"another pull request's"}`,
|
||||
}
|
||||
k := keptSeatDefinitions{keys: func(context.Context) ([]string, error) { return slices.Collect(maps.Keys(state)), nil },
|
||||
get: func(_ context.Context, key string) ([]byte, error) { return []byte(state[key]), nil }}
|
||||
got, err := k.Unparsed(context.Background(), "novox/web", 7)
|
||||
if want := []check.UnparsedSeat{{Seat: "web-api", Module: "web-contracts", Directory: "go", Why: "seat.json: bad"}}; err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("unparsed of novox/web#7 = %+v, %v; want %+v", got, err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A manifest whose config value falls back to a predefined key (ADR 0348 §7) is read, not logged as unreadable for the
|
||||
|
||||
@@ -8,7 +8,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -25,7 +24,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
@@ -63,26 +61,6 @@ func durationEnv(getenv func(string) string, name string, def time.Duration) (ti
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// The checker's own states of its first views (views.StatusState) and of its findings, where the condition keeper raises a
|
||||
// first view unanswered past its bound (ADR 0615 §6); and the indexer's states it follows (delivery.CandidatesBucket,
|
||||
// ModulesBucket, ManifestsBucket).
|
||||
var (
|
||||
firstViewsBucket = delivery.CheckerModule + "_" + views.StatusState
|
||||
findingsBucket = delivery.CheckerModule + "_findings"
|
||||
)
|
||||
|
||||
const (
|
||||
candidatesState = "candidates"
|
||||
// seatDefinitionsState is the indexer's state of the seat definitions (design 26), keyed `<seat>.trunk` and
|
||||
// `<seat>.pull.<escaped repository>.<number>`.
|
||||
seatDefinitionsState = "seat-definitions"
|
||||
// The owners' views families, as their manifests' `streams` name them.
|
||||
indexerViews = "indexer-views"
|
||||
assignerViews = "assigner-views"
|
||||
modulesState = "modules"
|
||||
manifestsState = "manifests"
|
||||
)
|
||||
|
||||
// run is the program: it returns when ctx ends or the bus cannot be used.
|
||||
// keptStates is every state the checker keeps and which others may read (ADR 0495 §2-§3): owed by the forge module,
|
||||
// which writes mesh/check from it (ADR 0521), and by the forge bridge; previews by the indexer; verdicts by the gate; tries by the delivery view.
|
||||
@@ -99,10 +77,6 @@ var keptStates = []announce.State{
|
||||
// The tries are read by the delivery view, which tells a try's asks from a candidate's by the asks each names (build_usage).
|
||||
{Name: "tries", Readable: true},
|
||||
{Name: "verdicts", Readable: true},
|
||||
// Where its first views of the indexer's and the assigner's states stand (read by checker_first_views alone), and its
|
||||
// findings of one unanswered past its bound, which the condition keeper observes as it does every reader's (ADR 0615 §6).
|
||||
{Name: views.StatusState},
|
||||
{Name: "findings"},
|
||||
}
|
||||
|
||||
func run(ctx context.Context, getenv func(string) string, logOut io.Writer) error {
|
||||
@@ -116,10 +90,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
// Its own states: a check without one of them would be unwritten, so each stops the start, naming its grant (issue 1945).
|
||||
// Its own states, the candidates it checks, and the modules and manifests that say which build modules judge a repository:
|
||||
// a check without one of them would be wrong or unwritten, so each stops the start, naming its grant (issue 1945).
|
||||
buckets := map[string]jetstream.KeyValue{}
|
||||
for _, name := range []string{delivery.CheckerVerdictsBucket, delivery.CheckerKeptBucket, delivery.CheckerOwedBucket, delivery.PreviewsBucket,
|
||||
check.TriesBucket} {
|
||||
delivery.CandidatesBucket, delivery.ModulesBucket, delivery.ManifestsBucket, check.TriesBucket} {
|
||||
if buckets[name], err = process.EssentialBucket(ctx, js, "checker", name, "judge or keep a check"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -136,31 +111,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
if err := caller.Keeps(keptStates...); err != nil {
|
||||
logger.Printf("what the checker keeps could not be said on the bus, so readers of owed, previews and verdicts are not granted until it is: %v", err)
|
||||
}
|
||||
// The candidates it checks, the modules and manifests that say which build modules judge a repository, the published
|
||||
// packages, the predefined keys and the placement are their owners' states (the indexer's, the assigner's), read by the
|
||||
// first view of their read tools and then their views families, never their buckets (ADR 0615 §1, ADR 0596, DLV-M2).
|
||||
// An owner that is down never holds the start: the checks start once every first view is answered, and until then the
|
||||
// unanswered first view is the checker's finding.
|
||||
reader := &views.ProcessReader{Module: delivery.CheckerModule, Call: caller.Call, Logf: logger.Printf}
|
||||
reader.Findings = process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf).KV
|
||||
// Each by the owner's read tool and views family as the owner's manifest names them.
|
||||
var followed error
|
||||
followOwner := func(owner, state, tool, stream string) *views.Picture {
|
||||
p, err := reader.Follow(owner, state, owner+"."+tool, stream)
|
||||
followed = errors.Join(followed, err)
|
||||
return p
|
||||
}
|
||||
candidates := followOwner(delivery.IndexerModule, candidatesState, "indexer_read_candidates", indexerViews)
|
||||
modules := followOwner(delivery.IndexerModule, modulesState, "indexer_read_modules", indexerViews)
|
||||
manifests := followOwner(delivery.IndexerModule, manifestsState, "indexer_read_manifests", indexerViews)
|
||||
packages := followOwner(delivery.IndexerModule, delivery.PackagesState, "indexer_read_packages", indexerViews)
|
||||
predefined := followOwner(delivery.IndexerModule, delivery.IndexerPredefinedState, "indexer_read_predefined", indexerViews)
|
||||
placement := followOwner(delivery.AssignerModule, delivery.PlacementState, "assigner_read_placement", assignerViews)
|
||||
// The seventh: the indexer's seat definitions, so a pull request whose head's definition does not parse fails.
|
||||
seatDefs := followOwner(delivery.IndexerModule, seatDefinitionsState, "indexer_read_seat_definitions", indexerViews)
|
||||
if followed != nil {
|
||||
return followed
|
||||
}
|
||||
|
||||
stepBound, err := durationEnv(getenv, "MESH_CHECKER_STEP_BOUND", check.DefaultStepBound)
|
||||
if err != nil {
|
||||
@@ -216,24 +166,19 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// The builder's builds say how the candidates' fingerprints stand (issue 1684); where its bucket is not there yet the line
|
||||
// says nothing of the builds, until the checker starts again.
|
||||
var builds check.BuildStates
|
||||
var ends check.BuildEnds
|
||||
if kv := process.OptionalBucket(ctx, js, delivery.BuilderBuildsBucket, "the line says nothing of the builds, and a check composes without waiting on them, until the checker starts again", logger.Printf); kv != nil {
|
||||
builds, ends = buildReader{kvStore{kv}}, buildEnds{kv}
|
||||
}
|
||||
if ends != nil {
|
||||
ch.Rebuilds = ends // a check failed on a build is asked again once that build is done again (ADR 0603 §10)
|
||||
if kv := process.OptionalBucket(ctx, js, delivery.BuilderBuildsBucket, "the line says nothing of the builds until the checker starts again", logger.Printf); kv != nil {
|
||||
builds = buildReader{kvStore{kv}}
|
||||
}
|
||||
// The check's composition is the checker's, from what each placed node's engine answers (ADR 0593 §7, §9).
|
||||
set, err := readSettings(getenv("MESH_CHECKER_SETTINGS"))
|
||||
answerBound, err := composeAnswerBound(getenv("MESH_CHECKER_SETTINGS"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
answerBound := set.answer
|
||||
holdCallsFor(answerBound)
|
||||
verdicts := &manifestVerdicts{}
|
||||
compositions := check.EngineCompositions{
|
||||
Asker: engineAsks{call: caller.CallOn},
|
||||
Placed: placedNodes{kv: func(context.Context) (jetstream.KeyValue, error) { return placement, nil }}.Placed,
|
||||
Placed: placedNodes{kv: func(at context.Context) (jetstream.KeyValue, error) { return js.KeyValue(at, delivery.PlacementBucket) }}.Placed,
|
||||
Bound: answerBound,
|
||||
Sandbox: seatSandbox{asker: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}},
|
||||
Verdicts: verdicts.all,
|
||||
@@ -246,42 +191,32 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
logger.Printf("%s of %s was written and the part that reads it could not be told: %v", event, key, err)
|
||||
}
|
||||
}
|
||||
repos := &repoModules{manifests: kvStore{manifests}, logf: logger.Printf, verdicts: verdicts}
|
||||
repos := &repoModules{manifests: kvStore{buckets[delivery.ManifestsBucket]}, logf: logger.Printf, verdicts: verdicts}
|
||||
compositions.Composer = func() string { return repos.CommitOf(check.NodeEngineModule) }
|
||||
compositions.Manifests = repos
|
||||
// The indexer's packages state (ADR 0593 §2), so a Go check builds every mesh package at its published version: read
|
||||
// once the first views are answered, before any check starts (afterFirstViews).
|
||||
// The indexer's packages state (ADR 0593 §2): followed once the bus has it, so a Go check builds every mesh package at
|
||||
// its published version; until then a check builds each at the version its go.mod names.
|
||||
published := &publishedPackages{}
|
||||
ch.Runs = repos // the node-engine a check reads beside at its running commit (ADR 0548 §6)
|
||||
// A group's tagged versions (design 51 *Tagged versions*): tag-wanted on the checker's stream asks, and the package
|
||||
// registry seats' published heard on the checker's durable consumer of the shared events stream. Where that consumer is
|
||||
// not there yet a group's check asks and waits in vain until its bound, said in its verdict.
|
||||
tags := tagAsks{js: js, node: cred.Node, heard: &check.PublishedHeard{}, logf: logger.Printf, now: time.Now}
|
||||
ch.Says = tags // a pull request's verdict event, on the same stream asks (design 26 *Out of step*)
|
||||
switch cons, err := cred.EventConsumer(ctx, js, process.EventsStream); {
|
||||
case err != nil:
|
||||
logger.Printf("the consumer %s_%s on %s does not exist yet, so no package registry's published is heard and a group's check fails at its publish bound until the checker starts again: %v",
|
||||
cred.Node, cred.Module, process.EventsStream, err)
|
||||
default:
|
||||
cc, err := process.HearEvents(cons, func(m jetstream.Msg) {
|
||||
if !tags.heardPublished(m) {
|
||||
_ = m.Ack() // nothing else the checker consumes rides the shared stream
|
||||
}
|
||||
}, logger.Printf)
|
||||
process.AwaitBucket(ctx, js, conn, delivery.PackagesBucket, "a Go check builds each mesh package at the version its go.mod names",
|
||||
logger.Printf).Ready(func(kv jetstream.KeyValue) {
|
||||
w, err := settle(ctx, kv, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
|
||||
if err != nil {
|
||||
return err
|
||||
logger.Printf("%s cannot be watched, so a Go check builds each mesh package at the version its go.mod names: %v", delivery.PackagesBucket, err)
|
||||
return
|
||||
}
|
||||
defer cc.Stop()
|
||||
}
|
||||
go func() {
|
||||
_ = follow(ctx, w, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
|
||||
}()
|
||||
})
|
||||
ch.Runs = repos // the node-engine a check reads beside at its running commit (ADR 0548 §6)
|
||||
ch.Judge = newJudge(judgeDeps{
|
||||
asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks,
|
||||
kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]},
|
||||
repos: repos, published: published, builds: builds, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf,
|
||||
announce: announce, compositions: compositions, tags: tags,
|
||||
nodes: openNodeNames(ctx, js, logger.Printf),
|
||||
predefined: keptPredefined{predefined},
|
||||
renames: seatRenames{call: caller.Call},
|
||||
seatDefs: seatDefinitionsOf(seatDefs),
|
||||
repos: repos, published: published, builds: builds, forge: forgeReads{call: caller.Call}, logf: logger.Printf,
|
||||
announce: announce, compositions: compositions,
|
||||
nodes: openNodeNames(ctx, js, logger.Printf),
|
||||
predefined: keptPredefined{process.AwaitBucket(ctx, js, conn, delivery.IndexerModule+"_"+delivery.IndexerPredefinedState,
|
||||
"a change's manifests are judged with no predefined keys, each fallback as its value declares it", logger.Printf)},
|
||||
})
|
||||
ch.Announce = announce
|
||||
tryer.Judge = ch.Judge
|
||||
@@ -292,21 +227,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// placement is settled first, so the toolchains settle through the gate already knowing it.
|
||||
gate := buildrun.NewToolchainGate([]string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, tools.Put, logger.Printf)
|
||||
watch.put = gate.Toolchain
|
||||
// Until the placement's first view is answered the gate passes every report on, as before the placement is settled.
|
||||
placementErrs := make(chan error, 1)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-placement.Follower().Ready():
|
||||
}
|
||||
pe, err := buildrun.FollowPlacement(ctx, reader, gate, ch.NodesChanged, logger.Printf)
|
||||
if err != nil {
|
||||
placementErrs <- err
|
||||
return
|
||||
}
|
||||
placementErrs <- <-pe
|
||||
}()
|
||||
placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, ch.NodesChanged, logger.Printf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := watch.start(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -389,90 +313,65 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return nil
|
||||
}
|
||||
lazy.ensure()
|
||||
afterFirstViews := func() error {
|
||||
// The modules say which repository holds a TypeScript module, so the Node build module judges it too.
|
||||
pw, err := settle(ctx, packages, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
published.Answered() // read whole, an empty state too: its names are judged from here on
|
||||
go func() {
|
||||
errs <- follow(ctx, pw, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
|
||||
}()
|
||||
mw, err := settle(ctx, modules, func(e jetstream.KeyValueEntry) { repos.Put(ctx, e.Key(), e.Value(), !opOf(e)) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
errs <- follow(ctx, mw, func(e jetstream.KeyValueEntry) {
|
||||
before := repos.CommitOf(check.NodeEngineModule)
|
||||
repos.Put(ctx, e.Key(), e.Value(), !opOf(e))
|
||||
if after := repos.CommitOf(check.NodeEngineModule); after != before {
|
||||
ch.TrunkMoved(ctx) // the node-engine the mesh runs moved: a verdict that read it beside is carried (ADR 0548 §6)
|
||||
}
|
||||
})
|
||||
}()
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
ch.Candidate(ctx, e.Key(), e.Value())
|
||||
} else {
|
||||
ch.Forget(e.Key())
|
||||
}
|
||||
}
|
||||
w, err := settle(ctx, candidates, each)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A key left running for heads no candidate holds is closed; the candidates are all known now.
|
||||
if err := ch.Sweep(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// The tries: a queued one starts, one left running is taken over, an ended one past its keeping is removed.
|
||||
tw, err := settle(ctx, buckets[check.TriesBucket], func(jetstream.KeyValueEntry) {})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tryer.Reconsider(ctx)
|
||||
go func() {
|
||||
errs <- follow(ctx, tw, func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
tryer.Put(ctx, e.Key(), e.Value())
|
||||
}
|
||||
})
|
||||
}()
|
||||
// The checker's own verdicts, updates only, in one watch: one checker_ask_again wrote asks its check again
|
||||
// (Checker.VerdictWritten); a deleted key of heads a candidate holds is judged again (Checker.VerdictDeleted, issue 1733).
|
||||
vw, err := buckets[delivery.CheckerVerdictsBucket].WatchAll(ctx, jetstream.UpdatesOnly())
|
||||
if err != nil {
|
||||
return fmt.Errorf("the checker's verdicts cannot be watched, so no check can be asked again by hand and a deleted verdict would never be written again: %w", err)
|
||||
}
|
||||
go func() {
|
||||
errs <- follow(ctx, vw, func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
ch.VerdictWritten(ctx, e.Key(), e.Value())
|
||||
} else {
|
||||
ch.VerdictDeleted(ctx, e.Key())
|
||||
}
|
||||
})
|
||||
}()
|
||||
go func() { errs <- follow(ctx, w, each) }()
|
||||
return nil
|
||||
// The modules say which repository holds a TypeScript module, so the Node build module judges it too.
|
||||
mw, err := settle(ctx, buckets[delivery.ModulesBucket], func(e jetstream.KeyValueEntry) { repos.Put(ctx, e.Key(), e.Value(), !opOf(e)) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "checker_first_views reads nothing and checker_first_view_again is not heard", logger.Printf))
|
||||
// The checks, the tries and the hand asks, once every first view is answered (the lead's ruling for DLV-M2): a check
|
||||
// judged without the candidates, the modules, the manifests, the published packages, the predefined keys or the
|
||||
// placement would be another check.
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-reader.Ready():
|
||||
}
|
||||
if err := afterFirstViews(); err != nil {
|
||||
errs <- err
|
||||
errs <- follow(ctx, mw, func(e jetstream.KeyValueEntry) {
|
||||
before := repos.CommitOf(check.NodeEngineModule)
|
||||
repos.Put(ctx, e.Key(), e.Value(), !opOf(e))
|
||||
if after := repos.CommitOf(check.NodeEngineModule); after != before {
|
||||
ch.TrunkMoved(ctx) // the node-engine the mesh runs moved: a verdict that read it beside is carried (ADR 0548 §6)
|
||||
}
|
||||
})
|
||||
}()
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
ch.Candidate(ctx, e.Key(), e.Value())
|
||||
} else {
|
||||
ch.Forget(e.Key())
|
||||
}
|
||||
}
|
||||
w, err := settle(ctx, buckets[delivery.CandidatesBucket], each)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A key left running for heads no candidate holds is closed; the candidates are all known now.
|
||||
if err := ch.Sweep(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// The tries: a queued one starts, one left running is taken over, an ended one past its keeping is removed.
|
||||
tw, err := settle(ctx, buckets[check.TriesBucket], func(jetstream.KeyValueEntry) {})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tryer.Reconsider(ctx)
|
||||
go func() {
|
||||
errs <- follow(ctx, tw, func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
tryer.Put(ctx, e.Key(), e.Value())
|
||||
}
|
||||
})
|
||||
}()
|
||||
// The checker's own verdicts, updates only, in one watch: one checker_ask_again wrote asks its check again
|
||||
// (Checker.VerdictWritten); a deleted key of heads a candidate holds is judged again (Checker.VerdictDeleted, issue 1733).
|
||||
vw, err := buckets[delivery.CheckerVerdictsBucket].WatchAll(ctx, jetstream.UpdatesOnly())
|
||||
if err != nil {
|
||||
return fmt.Errorf("the checker's verdicts cannot be watched, so no check can be asked again by hand and a deleted verdict would never be written again: %w", err)
|
||||
}
|
||||
go func() {
|
||||
errs <- follow(ctx, vw, func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
ch.VerdictWritten(ctx, e.Key(), e.Value())
|
||||
} else {
|
||||
ch.VerdictDeleted(ctx, e.Key())
|
||||
}
|
||||
})
|
||||
}()
|
||||
logger.Printf("started on %s", cred.Node)
|
||||
go func() { errs <- follow(ctx, w, each) }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ch.Wait()
|
||||
|
||||
@@ -10,24 +10,10 @@ import (
|
||||
// module, the latest published commit of its repository the indexer read its packages at. The indexer alone computes a
|
||||
// package's version; the checker reads it, so a Go check builds every mesh package at the version the mesh publishes
|
||||
// (check.Examiner.Published) and computes none.
|
||||
//
|
||||
// Its series keys (delivery.SeriesPackage, decoded with delivery.DecodeSeriesPackage) name each package by its name in its
|
||||
// own ecosystem and the `<module>/<artifact>` it is built from (design 32): the known packages the judge takes
|
||||
// (PackageNames).
|
||||
type publishedPackages struct {
|
||||
mu sync.Mutex
|
||||
byKey map[string]delivery.PublishedPackages
|
||||
series map[string]delivery.SeriesPackage
|
||||
unread map[string]string
|
||||
// answered is whether the state's first reading ended (Answered): until then the checker knows no package's name.
|
||||
answered bool
|
||||
}
|
||||
|
||||
// Answered marks the state's first reading as ended, so PackageNames names what it holds, none included.
|
||||
func (p *publishedPackages) Answered() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.answered = true
|
||||
}
|
||||
|
||||
// Put takes one key of the state in: its value, or its deletion. A value that does not decode is kept out and remembered
|
||||
@@ -36,23 +22,13 @@ func (p *publishedPackages) Put(key string, raw []byte, deleted bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.byKey == nil {
|
||||
p.byKey, p.series, p.unread = map[string]delivery.PublishedPackages{}, map[string]delivery.SeriesPackage{}, map[string]string{}
|
||||
p.byKey, p.unread = map[string]delivery.PublishedPackages{}, map[string]string{}
|
||||
}
|
||||
delete(p.byKey, key)
|
||||
delete(p.series, key)
|
||||
delete(p.unread, key)
|
||||
if deleted {
|
||||
return
|
||||
}
|
||||
if delivery.IsSeriesPackageKey(key) {
|
||||
s, err := delivery.DecodeSeriesPackage(raw)
|
||||
if err != nil {
|
||||
p.unread[key] = err.Error()
|
||||
return
|
||||
}
|
||||
p.series[key] = s
|
||||
return
|
||||
}
|
||||
v, err := delivery.DecodePublishedPackages(raw)
|
||||
if err != nil {
|
||||
p.unread[key] = err.Error()
|
||||
@@ -75,21 +51,3 @@ func (p *publishedPackages) LatestPackages() delivery.PackageVersions {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PackageNames are the packages the state's series keys name, by the name in its own ecosystem: the `<module>/<artifact>`
|
||||
// each is built from (empty when the key does not say). Nil while the state is not read yet (Answered), which judges no
|
||||
// name; once read never nil, an empty state included, so the judge judges every name against it.
|
||||
func (p *publishedPackages) PackageNames() map[string]string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if !p.answered {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, s := range p.series {
|
||||
if out[s.Name] == "" {
|
||||
out[s.Name] = s.Package
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
// seatRenames asks the seat mesh-config's verb renames as the checker's own account (its `invokes`
|
||||
// seat:mesh-config.renames; design 32). A call nobody answers is a holder serving no renames (the verb is optional), said
|
||||
// as check.ErrRenamesNotServed with the bus's words; any other error is an ask that could not be made.
|
||||
type seatRenames struct {
|
||||
call func(key string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s seatRenames) Renames(_ context.Context, module string) (meshconfig.RenamesAnswer, error) {
|
||||
raw, err := s.call(check.RenamesSeat, meshconfig.RenamesInput{Module: module})
|
||||
if errors.Is(err, nats.ErrNoResponders) {
|
||||
return meshconfig.RenamesAnswer{}, fmt.Errorf("%w (nothing answers it: %v)", check.ErrRenamesNotServed, err)
|
||||
}
|
||||
if err != nil {
|
||||
return meshconfig.RenamesAnswer{}, err
|
||||
}
|
||||
var a meshconfig.RenamesAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return meshconfig.RenamesAnswer{}, fmt.Errorf("%s answered what is not its answer: %w", check.RenamesSeat, err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -8,59 +8,40 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
// The checker's own settings (its config, written by the node-engine into ${dir:state}/settings.json, named to the
|
||||
// process by MESH_CHECKER_SETTINGS): compose-answer-bound, the seconds a check waits for a node's engine to answer
|
||||
// compose-candidate (novox/hq ADR 0593 §7, §9: 60 by default), and build-wait-bound, the seconds a check waits for the
|
||||
// builder's builds of its candidate before it composes (ADR 0603 §10: 1800 by default). The checker owns both.
|
||||
// compose-candidate (novox/hq ADR 0593 §7, §9: 60 by default; the checker owns it).
|
||||
|
||||
// settings are the checker's settings as read.
|
||||
type settings struct {
|
||||
// answer is compose-answer-bound; buildWait is build-wait-bound.
|
||||
answer, buildWait time.Duration
|
||||
}
|
||||
|
||||
// readSettings reads the settings file at path: each value's default where no file or no value is there; a value that is
|
||||
// no whole number of seconds within its range is refused, naming it.
|
||||
func readSettings(path string) (settings, error) {
|
||||
out := settings{answer: nec.AnswerBound, buildWait: check.DefaultBuildWait}
|
||||
// composeAnswerBound reads compose-answer-bound from the settings file at path: delivery.ComposeAnswerBound where no
|
||||
// file or no value is there; a value that is no number of seconds from 1 to 600 is refused, naming it.
|
||||
func composeAnswerBound(path string) (time.Duration, error) {
|
||||
if path == "" {
|
||||
return out, nil
|
||||
return delivery.ComposeAnswerBound, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return out, nil
|
||||
return delivery.ComposeAnswerBound, nil
|
||||
}
|
||||
if err != nil {
|
||||
return settings{}, err
|
||||
return 0, err
|
||||
}
|
||||
var s struct {
|
||||
Answer *float64 `json:"compose-answer-bound"`
|
||||
BuildWait *float64 `json:"build-wait-bound"`
|
||||
Bound *float64 `json:"compose-answer-bound"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return settings{}, fmt.Errorf("%s is no settings file: %w", path, err)
|
||||
return 0, fmt.Errorf("%s is no settings file: %w", path, err)
|
||||
}
|
||||
for _, v := range []struct {
|
||||
name string
|
||||
value *float64
|
||||
min, max float64
|
||||
into *time.Duration
|
||||
}{{"compose-answer-bound", s.Answer, 1, 600, &out.answer}, {"build-wait-bound", s.BuildWait, 60, 7200, &out.buildWait}} {
|
||||
if v.value == nil {
|
||||
continue
|
||||
}
|
||||
if *v.value < v.min || *v.value > v.max || *v.value != float64(int(*v.value)) {
|
||||
return settings{}, fmt.Errorf("%s is %v: a whole number of seconds from %v to %v", v.name, *v.value, v.min, v.max)
|
||||
}
|
||||
*v.into = time.Duration(*v.value) * time.Second
|
||||
if s.Bound == nil {
|
||||
return delivery.ComposeAnswerBound, nil
|
||||
}
|
||||
return out, nil
|
||||
if *s.Bound < 1 || *s.Bound > 600 || *s.Bound != float64(int(*s.Bound)) {
|
||||
return 0, fmt.Errorf("compose-answer-bound is %v: a whole number of seconds from 1 to 600", *s.Bound)
|
||||
}
|
||||
return time.Duration(*s.Bound) * time.Second, nil
|
||||
}
|
||||
|
||||
// holdCallsFor makes a bus call wait the compose-answer-bound (novox/hq ADR 0593 §7), not the SDK's CallTimeout of 30 s:
|
||||
|
||||
@@ -6,27 +6,21 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
// ADR 0593 §7: the answer bound is the checker's setting compose-answer-bound, 60 s when the file or the value is
|
||||
// absent; a value that is no whole number of seconds from 1 to 600 stops the start, naming it. ADR 0603 §10: the wait for
|
||||
// the candidate's builds is build-wait-bound, 30 minutes when absent, from 60 to 7200 seconds.
|
||||
// absent; a value that is no whole number of seconds from 1 to 600 stops the start, naming it.
|
||||
func TestTheAnswerBoundIsTheCheckersSetting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, row := range []struct {
|
||||
body string
|
||||
want time.Duration
|
||||
err bool
|
||||
buildWait time.Duration
|
||||
}{{"", nec.AnswerBound, false, check.DefaultBuildWait}, {`{}`, nec.AnswerBound, false, check.DefaultBuildWait},
|
||||
{`{"compose-answer-bound": 90}`, 90 * time.Second, false, check.DefaultBuildWait}, {`{"compose-answer-bound": 0}`, 0, true, 0},
|
||||
{`{"compose-answer-bound": 1.5}`, 0, true, 0},
|
||||
{`{"compose-answer-bound": 60, "build-wait-bound": 600}`, 60 * time.Second, false, 10 * time.Minute},
|
||||
{`{"build-wait-bound": 30}`, 0, true, 0}} {
|
||||
body string
|
||||
want time.Duration
|
||||
err bool
|
||||
}{{"", delivery.ComposeAnswerBound, false}, {`{}`, delivery.ComposeAnswerBound, false},
|
||||
{`{"compose-answer-bound": 90}`, 90 * time.Second, false}, {`{"compose-answer-bound": 0}`, 0, true},
|
||||
{`{"compose-answer-bound": 1.5}`, 0, true}} {
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
os.Remove(path)
|
||||
if row.body != "" {
|
||||
@@ -34,10 +28,9 @@ func TestTheAnswerBoundIsTheCheckersSetting(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
set, err := readSettings(path)
|
||||
got := set.answer
|
||||
if (err != nil) != row.err || got != row.want || set.buildWait != row.buildWait {
|
||||
t.Errorf("%q: %s, %s, %v", row.body, got, set.buildWait, err)
|
||||
got, err := composeAnswerBound(path)
|
||||
if (err != nil) != row.err || got != row.want {
|
||||
t.Errorf("%q: %s, %v", row.body, got, err)
|
||||
}
|
||||
// Issue 1126: the SDK's call gives up at CallTimeout (30 s), so the checker's calls must wait the bound read
|
||||
// (an answer at 31 s is heard under 60 s), and the bound never shortens the SDK's own.
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
// publishedSeats are the two package registry seats' published, by subject (their contracts in mesh-contracts): heard on
|
||||
// the checker's durable consumer of the shared events stream, made from the manifest's consumes.
|
||||
var publishedSeats = map[string]string{
|
||||
gopackageregistry.SubjectPublished: gopackageregistry.Seat,
|
||||
npmpackageregistry.SubjectPublished: npmpackageregistry.Seat,
|
||||
}
|
||||
|
||||
// tagAsks is check.TagAsks and check.VerdictSayer on the bus: tag-wanted and verdict published on the checker's own stream
|
||||
// asks, and the published heard.
|
||||
type tagAsks struct {
|
||||
js jetstream.JetStream
|
||||
node string
|
||||
heard *check.PublishedHeard
|
||||
logf func(string, ...any)
|
||||
now func() time.Time
|
||||
nextID func() string
|
||||
}
|
||||
|
||||
// Want publishes one tag-wanted with the checker's event headers; the message id makes a second publish of the same
|
||||
// member's head and package within the stream's duplicate window one event.
|
||||
func (t tagAsks) Want(ctx context.Context, w check.TagWanted) error {
|
||||
body, err := json.Marshal(w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := nats.NewMsg(eventSubject(check.EventTagWanted))
|
||||
id := fmt.Sprintf("tag-wanted.%s.%s.%s.%s", w.Repository, w.Head, w.Module, w.Package)
|
||||
msg.Header.Set("x-event-id", id)
|
||||
msg.Header.Set("x-source", delivery.CheckerModule)
|
||||
msg.Header.Set("x-node", t.node)
|
||||
msg.Header.Set("x-time", t.now().UTC().Format(time.RFC3339Nano))
|
||||
msg.Header.Set("content-type", "application/json")
|
||||
msg.Data = body
|
||||
_, err = t.js.PublishMsg(ctx, msg, jetstream.WithMsgID(id))
|
||||
return err
|
||||
}
|
||||
|
||||
// Say publishes one verdict on the checker's own stream asks with the checker's event headers; the message id makes a
|
||||
// second publish of the same pull request's head, verdict and out-of-step files within the duplicate window one event.
|
||||
func (t tagAsks) Say(ctx context.Context, v check.VerdictEvent) error {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := nats.NewMsg(eventSubject(check.EventVerdict))
|
||||
id := fmt.Sprintf("verdict.%s.%d.%s.%s.%x", v.Repository, v.Pull, v.Head, v.Verdict, sha256.Sum256([]byte(strings.Join(v.OutOfStep, "\n"))))
|
||||
msg.Header.Set("x-event-id", id)
|
||||
msg.Header.Set("x-source", delivery.CheckerModule)
|
||||
msg.Header.Set("x-node", t.node)
|
||||
msg.Header.Set("x-time", t.now().UTC().Format(time.RFC3339Nano))
|
||||
msg.Header.Set("content-type", "application/json")
|
||||
msg.Data = body
|
||||
_, err = t.js.PublishMsg(ctx, msg, jetstream.WithMsgID(id))
|
||||
return err
|
||||
}
|
||||
|
||||
func (t tagAsks) Await(ctx context.Context, seat, head string, names []string) (check.TaggedPublished, bool) {
|
||||
return t.heard.Await(ctx, seat, head, names)
|
||||
}
|
||||
|
||||
// heardPublished takes a message of the events consumer in when it is a package registry seat's published, and answers
|
||||
// whether it was: a tagged version wakes the checks waiting on it; any other published is passed over.
|
||||
func (t tagAsks) heardPublished(m jetstream.Msg) bool {
|
||||
seat, ok := publishedSeats[m.Subject()]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var p check.TaggedPublished
|
||||
if err := json.Unmarshal(m.Data(), &p); err != nil {
|
||||
t.logf("%s holds what is no published of %s, left: %v", m.Subject(), seat, err)
|
||||
} else if t.heard.Heard(seat, p) {
|
||||
t.logf("%s published %s %s from %s (tag %s)", seat, p.Name, p.Version, p.Commit, p.Tag)
|
||||
}
|
||||
if err := m.Ack(); err != nil {
|
||||
t.logf("%s cannot be acknowledged, so the bus gives it again: %v", m.Subject(), err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
{
|
||||
"compose-answer-bound": 60,
|
||||
"build-wait-bound": 1800
|
||||
"compose-answer-bound": 60
|
||||
}
|
||||
|
||||
@@ -3,45 +3,25 @@ module mesh-delivery-checker
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459
|
||||
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ../mesh-delivery-builder/buildrun
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
|
||||
|
||||
@@ -1,43 +1,17 @@
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459 h1:DA4SFG/gKyG+aPJbfUpAUad2VeITIiKjJ6qOPmDhSA8=
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261104174638-125929dbc459/go.mod h1:zR1HsAKZVbQsBIY5wPyrqI2WHInK268tITzGl2oqHro=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:I6uVDO9E+i3D9/n6KQ2RWYRs3kXYCqueiKXynifQo8w=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:ER2NVrd81uJo1lN0YCGA7OBxL910IHRgWYtxpsnHGF0=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:Rs5jOa7EWlpKuaH6+ExyGLN9k9UUi8N7upP9Ac3k+GY=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:fNfjFkP1+suYb12HJkapQe5W3cF98JsTcLBI/syoLHc=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639 h1:3FTxvHrv3Hv+ZUbp/ezTrndqdgiS+Z6EUG2XGYJNJr8=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/V/1wfyQwjE9VbPLsFtkf6uX+SHKUXX34s5JscDuGAs=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639 h1:VG+sZltyBIKrd9cwFJyKkInz04SxWBxUlJWfAuo6774=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Nq7glLZSZ8zOLTh1pXDnKZi+MxsMRyHw/bZODC3mvrY=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639 h1:ROS9UG/Dg0NpsaDuEmDFV9CsIC1R92sOm3QauF7IvL0=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:/Pke1v2VnSFcR8ic+fo/kNCg3JFYK6ILTal72Ca9520=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 h1:5HeZzk6tKzcSwVivD3IcIvQ/2WIvysZvNvNKyq/oIBA=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:pPxwhTMb3hIk7UUrBicJnGQCqfTEoBGJvNFspEK2U18=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639 h1:oAXU1t8A1rGIr82JPBaNYSGLicXqhWglKaEAtPmrD0c=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:Y5kgBh0hBbL/hgc+abCF9Vbhov+vCsB0ckpRzUw1D+0=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-20261104174344-ae5266e77639 h1:G211QsOsnL9e8WJjhd9sE8hTRxs/UZlS3hT0M0faJG0=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-20261104174344-ae5266e77639/go.mod h1:n0f3CXlnvKt2qAPjQu+FTBnGjZuwd9E1xXy7/GsiauQ=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker v0.0.0-20261104174344-ae5266e77639 h1:Khu2mBhPbQhUQPNpqIb18mH7mUFW6GQvSjEf5lM/uDU=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker v0.0.0-20261104174344-ae5266e77639/go.mod h1:Fr+FPZ5xC57oQlL9jnfHBz0yKhDn8Cw7w56SU8lIeDw=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639 h1:YQ6eJvc+RYUVIVf+oX+R4TC1dYL8TLLMZ0/JPDU1tp4=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-20261104174344-ae5266e77639/go.mod h1:TB20TsQuOd5aer+cXL6PbaYyg2RvgL1R4MzD61Oks3A=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639 h1:ILh7uctNwgx/89qHoR/H2kz5TpWeKEeIH0O2pDPE7Iw=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:JRtTjIjMTkzn59IN4vCYei9ZwJGFnN1Puwame4V/sD0=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 h1:+bkN8op2yrAlBYrOB+hYnIoIZkbjXsNmc/4//uMnL4E=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:ooQa2CiP8JOvhP0bdQvwurV5wskRE7Cm68ecURBbCwU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg=
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2 h1:MOuutmbKBL1GtTZU9W1+ja/K7TL05EFU6hEt3Swf/oE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103120252-42bd7b802ac2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
@@ -51,5 +25,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// What the check builds of a candidate before it composes (novox/hq ADR 0603 §10). The builder builds every fingerprint the
|
||||
// indexer's candidate carries, as a key of its own (builds.<module>.<fingerprint>), with the build modules that run; the
|
||||
// check adds no build path of its own and waits on those keys (awaitBuilds), so the node-engines compose the candidate's
|
||||
// modules with their builds. It does not wait on two kinds of changed module, and names them in its composition's warnings:
|
||||
//
|
||||
// - one built by a build module the change alters (the recipe of mesh-build-go or mesh-build-node enters the fingerprint
|
||||
// of every module it builds, ADR 0530 §1): its candidate build is the running build module's, and the check cannot run
|
||||
// the candidate's build module to build it (it runs it only for its own digest and tests, ADR 0535 §1), so it is not
|
||||
// composed with a build of the changed build module; it is built again once the agreed toolchain moves (ADR 0603 §3).
|
||||
// The build module's own build is waited on, as any other: the running build module builds its new source.
|
||||
// - one that stands on a module the change alters (build.on): its candidate fingerprint names the base's trunk
|
||||
// fingerprint, and no build module builds on another module's artifact (ADR 0487 §1), so its build stands on the
|
||||
// trunk's base; the base's own build is waited on.
|
||||
//
|
||||
// A module the trunk holds no record of yet is not built by the builder until it is merged, so it is not waited on either.
|
||||
//
|
||||
// A changed build module whose manifest invokes a seat verb or module tool no running build serves is one the check cannot
|
||||
// run (ADR 0614 §7): the modules it builds are composed from their manifests, as above, and the words name the verb.
|
||||
|
||||
// buildsOfTheCheck are the builds the check waits on before it composes (module to fingerprint) and the words for the
|
||||
// changed modules it composes without waiting on their build, one line each, sorted. A manifest that cannot be read leaves
|
||||
// every build waited on and nothing said of build modules or bases (nothing is said that was not read).
|
||||
func (st *state) buildsOfTheCheck(ctx context.Context) (map[string]string, []string) {
|
||||
wait := map[string]string{}
|
||||
changed := map[string]catalogue.Manifest{}
|
||||
readable := true
|
||||
for _, p := range st.c.Pulls {
|
||||
for module, cm := range p.Fps {
|
||||
if cm.FP2 == "" || cm.Manifest == "" {
|
||||
continue // the builder builds no fingerprint of it (candidates.go: none of the new form, or no manifest)
|
||||
}
|
||||
wait[module] = cm.FP2
|
||||
if !readable || st.j.Read == nil {
|
||||
readable = false
|
||||
continue
|
||||
}
|
||||
m, err := st.j.Read.Manifest(ctx, cm.Manifest)
|
||||
if err != nil {
|
||||
st.j.logf("%s: the manifest of %s cannot be read, so the modules built with a changed build module or base are not named and every build is waited on: %v", st.c.Key, module, err)
|
||||
readable = false
|
||||
continue
|
||||
}
|
||||
changed[module] = m
|
||||
}
|
||||
}
|
||||
var words []string
|
||||
if st.j.Runs != nil {
|
||||
var unheld []string
|
||||
for _, module := range slices.Sorted(maps.Keys(wait)) {
|
||||
if st.j.Runs.CommitOf(module) == "" {
|
||||
unheld = append(unheld, module)
|
||||
delete(wait, module)
|
||||
}
|
||||
}
|
||||
if len(unheld) > 0 {
|
||||
words = append(words, fmt.Sprintf("%s: the trunk holds no record of it yet, so the builder builds it once it is merged and the check composes it without a build (ADR 0603 §10)",
|
||||
strings.Join(unheld, ", ")))
|
||||
}
|
||||
}
|
||||
if !readable {
|
||||
return wait, words
|
||||
}
|
||||
for _, bm := range slices.Sorted(maps.Keys(buildrun.LanguageOf)) {
|
||||
if _, alters := changed[bm]; !alters {
|
||||
continue
|
||||
}
|
||||
var built []string
|
||||
for module, m := range changed {
|
||||
if _, isBuildModule := buildrun.LanguageOf[module]; !isBuildModule && buildsWith(m, buildrun.LanguageOf[bm]) {
|
||||
built = append(built, module)
|
||||
delete(wait, module)
|
||||
}
|
||||
}
|
||||
slices.Sort(built)
|
||||
if len(built) > 0 {
|
||||
words = append(words, fmt.Sprintf("the change alters the build module %s: %s, which it builds, are built by the running %s, not the changed one, which the check cannot run, so the check does not wait on their builds; they are built again once the agreed toolchain moves (ADR 0603 §3, §10)",
|
||||
bm, strings.Join(built, ", "), bm))
|
||||
}
|
||||
if unserved := st.unservedVerbs(ctx, changed[bm]); len(unserved) > 0 {
|
||||
what := "nothing it builds is in the change"
|
||||
if len(built) > 0 {
|
||||
what = strings.Join(built, ", ") + ", which it builds, are composed from their manifests"
|
||||
}
|
||||
words = append(words, fmt.Sprintf("the changed build module %s invokes %s, which no running build serves, so the check cannot run it: %s (ADR 0614 §7)",
|
||||
bm, strings.Join(unserved, ", "), what))
|
||||
}
|
||||
}
|
||||
g := graph.OfManifests(changed)
|
||||
for _, base := range slices.Sorted(maps.Keys(changed)) {
|
||||
if on := g.Dependents(base); len(on) > 0 {
|
||||
for _, module := range on {
|
||||
delete(wait, module)
|
||||
}
|
||||
words = append(words, fmt.Sprintf("the change alters %s, a base of %s: their builds stand on the trunk's %s, since no build module builds on a candidate's base, so the check waits on the build of %s alone (ADR 0603 §10)",
|
||||
base, strings.Join(on, ", "), base, base))
|
||||
}
|
||||
}
|
||||
return wait, words
|
||||
}
|
||||
|
||||
// buildsWith says whether a manifest has a bundle in the language: the artifacts whose fingerprint names that language's
|
||||
// toolchain and its build module's recipe (an archive is packed with no toolchain, ADR 0478 clause 1).
|
||||
func buildsWith(m catalogue.Manifest, language string) bool {
|
||||
return m.Build != nil && slices.ContainsFunc(m.Build.Artifacts, func(a catalogue.Artifact) bool {
|
||||
return a.Kind == catalogue.ArtifactBundle && a.Language == language
|
||||
})
|
||||
}
|
||||
|
||||
// unservedVerbs are the seat verbs and module tools m invokes (graph.InvokesOf) that no running build serves, sorted; none
|
||||
// when nothing reads what runs, or what runs cannot be read (said in the log: nothing is said that was not read).
|
||||
func (st *state) unservedVerbs(ctx context.Context, m catalogue.Manifest) []string {
|
||||
if st.j.Serving == nil {
|
||||
return nil
|
||||
}
|
||||
served, err := st.j.Serving.Served(ctx)
|
||||
if err != nil {
|
||||
st.j.logf("%s: what the running builds serve cannot be read, so no build module is named for a verb it invokes: %v", st.c.Key, err)
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, v := range graph.InvokesOf(m) {
|
||||
if !served(v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2,12 +2,7 @@ package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -69,102 +64,3 @@ func (j *Examiner) buildNotes(ctx context.Context, c *Case) []BuildNote {
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// DefaultBuildWait is how long a check waits for the candidate's builds to end before it composes (the setting
|
||||
// build-wait-bound, novox/hq ADR 0603 §10): past it the check composes without the builds not ended and names them.
|
||||
const DefaultBuildWait = 30 * time.Minute
|
||||
|
||||
// BuildEnds waits on the builder's build keys (builds.<module>.<fingerprint>), hearing each key's changes on the bus as the
|
||||
// builder writes them: the builder owns the fact, and nothing is polled.
|
||||
type BuildEnds interface {
|
||||
// Ended waits until the build of every module at its fingerprint (module to fingerprint) is done or failed, or ctx ends,
|
||||
// and answers the builds that ended, by module. An error is only builds that cannot be heard at all.
|
||||
Ended(ctx context.Context, builds map[string]string) (map[string]delivery.BuilderBuild, error)
|
||||
// Done waits until the build of every module at its fingerprint is done (a failed one built again, builder_build_again),
|
||||
// and says whether they all are; false when ctx ends first or they cannot be heard.
|
||||
Done(ctx context.Context, builds map[string]string) bool
|
||||
}
|
||||
|
||||
// BuildFailedTest is what a failure of the check names a failed build of the candidate by: the test `build: <module>`.
|
||||
// A verdict failed on builds is asked again by the checker itself once each is built again (Checker.Rebuilt).
|
||||
const BuildFailedTest = "build: "
|
||||
|
||||
// failedBuilds are the candidate's builds a failed verdict names as failed (BuildFailedTest), by module, at the fingerprint
|
||||
// the case carries for it; none for a verdict that names no failed build.
|
||||
func failedBuilds(v delivery.Verdict, cs *Case) map[string]string {
|
||||
if v.State != delivery.VerdictFail {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, f := range append([]delivery.FailedFirst{v.Failed}, v.Failures...) {
|
||||
module, named := strings.CutPrefix(f.Test, BuildFailedTest)
|
||||
if !named {
|
||||
continue
|
||||
}
|
||||
for _, p := range cs.Pulls {
|
||||
if cm, ok := p.Fps[module]; ok && cm.FP2 != "" {
|
||||
out[module] = cm.FP2
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (j *Examiner) buildWait() time.Duration {
|
||||
if j.BuildWait > 0 {
|
||||
return j.BuildWait
|
||||
}
|
||||
return DefaultBuildWait
|
||||
}
|
||||
|
||||
// awaitBuilds waits for the candidate's builds before the check composes (ADR 0603 §10): the check builds what the builder
|
||||
// builds of the candidate and composes with it. A failed build fails the check, naming the module and the builder's words;
|
||||
// a build not ended within the bound is named, and the check composes without it. It answers the words of the composition's
|
||||
// warnings: what the check composes without waiting on its build, and why.
|
||||
func (st *state) awaitBuilds(ctx context.Context, v *delivery.Verdict) []string {
|
||||
wait, words := st.buildsOfTheCheck(ctx)
|
||||
if len(wait) == 0 {
|
||||
return words
|
||||
}
|
||||
modules := slices.Sorted(maps.Keys(wait))
|
||||
if v.State == delivery.VerdictFail {
|
||||
// The check has failed already: its red status is never held back by a build (the lead, 2026-11-03).
|
||||
return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", ")))
|
||||
}
|
||||
if st.j.BuildEnds == nil {
|
||||
return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", ")))
|
||||
}
|
||||
bound := st.j.buildWait()
|
||||
waits := fmt.Sprintf("waits on the builder's builds of %s before it composes, at most %s (ADR 0603 §10)", strings.Join(modules, ", "), bound)
|
||||
st.c.note(waits)
|
||||
st.j.logf("%s %s", st.c.Key, waits)
|
||||
at, cancel := context.WithTimeout(ctx, bound)
|
||||
ended, err := st.j.BuildEnds.Ended(at, wait)
|
||||
cancel()
|
||||
if err != nil {
|
||||
st.j.logf("%s: the builds of %s cannot be heard: %v", st.c.Key, strings.Join(modules, ", "), err)
|
||||
return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err))
|
||||
}
|
||||
var late []string
|
||||
for _, module := range modules {
|
||||
b, done := ended[module]
|
||||
switch {
|
||||
case !done:
|
||||
late = append(late, module)
|
||||
case b.State == delivery.BuildFailed:
|
||||
f := delivery.FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)}
|
||||
if v.State != delivery.VerdictFail {
|
||||
v.State, v.Failed = delivery.VerdictFail, f
|
||||
continue
|
||||
}
|
||||
addFailure(v, f)
|
||||
}
|
||||
}
|
||||
if len(late) > 0 && ctx.Err() == nil {
|
||||
words = append(words, fmt.Sprintf("the builds of %s did not end within the build-wait-bound of %s, so the check composes without them (ADR 0603 §10)", strings.Join(late, ", "), bound))
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
@@ -45,11 +45,7 @@ type Case struct {
|
||||
notesMu sync.Mutex
|
||||
// warnings are, by pull request, what part of its check did not run (Case.warn).
|
||||
warnings map[string]string
|
||||
// seriesOf are, by pull request, the changes of a package series it makes (series.go), named in its status line.
|
||||
seriesOf map[string][]string
|
||||
// seriesChanges are, by pull request, the same changes as structures (series.go), named in the verdict and its event.
|
||||
seriesChanges map[string][]delivery.SeriesChange
|
||||
notes []string
|
||||
notes []string
|
||||
// asks are the ids of the asks the check put on the build seats' streams, in order: a try names them (Try.Asks).
|
||||
asks []string
|
||||
// Watch, when set, hears each ask the check puts, as it is put (step nil), and each value its step key takes while
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -48,9 +46,6 @@ type Outcome struct {
|
||||
// Beside are the siblings the check read at their trunk's head, by repository (ADR 0548 §6): the verdict names them, and
|
||||
// a move of one is carried.
|
||||
Beside map[string]string
|
||||
// OutOfStep are, by pull request (pullKey), the generated files out of step with their seat definitions that its steps
|
||||
// named (design 26): the verdict event carries them.
|
||||
OutOfStep map[string][]string
|
||||
}
|
||||
|
||||
// BuildNote is the state of a candidate's module build, by module: built, failed or waiting (ADR 0484 clause 7).
|
||||
@@ -84,19 +79,14 @@ type Checker struct {
|
||||
}
|
||||
// Bound is how long one check may run; none when 0 (DefaultBound).
|
||||
Bound time.Duration
|
||||
// Rebuilds hears the builder's build keys a failed verdict names (failedBuilds): once each is written done, the check of
|
||||
// those heads is asked again by the checker itself (ADR 0603 §10), as a failure of the nodes is; nil asks none again.
|
||||
Rebuilds BuildEnds
|
||||
// Asks keeps the asks each running check put, by verdict key (AsksState), for checker_ask_again; nil keeps none.
|
||||
Asks AsksKV
|
||||
// Link is the base of the address a status points at; the verdict key follows.
|
||||
Link string
|
||||
// Announce says a status was written (EventOwedWritten); nil says nothing.
|
||||
Announce Announcer
|
||||
// Says publishes the verdict event of each pull request whose check ended (EventVerdict); nil says none.
|
||||
Says VerdictSayer
|
||||
Now func() time.Time
|
||||
Logf func(format string, args ...any)
|
||||
Now func() time.Time
|
||||
Logf func(format string, args ...any)
|
||||
// Settle is how long a group's heads must stay the same after the candidate last changed them before its check starts
|
||||
// (issue 1890): the members of a group are announced one after another, and a check of each partial set is thrown
|
||||
// away at the next. None when 0; DefaultSettle in the module.
|
||||
@@ -114,8 +104,6 @@ type Checker struct {
|
||||
held map[string]string // by verdict key: the turn that holds its check off, as last said
|
||||
live map[string]liveCheck // by verdict key: the check running in this process, ended when its heads go
|
||||
settling map[string]*window // by candidates key: the window its heads settle in
|
||||
rebuilt map[string]bool // by verdict key: every failed build its verdict names is done again
|
||||
awaiting map[string]bool // by verdict key: its failed builds are heard (awaitRebuilt)
|
||||
// outsideSaid are the candidates outside the mesh said in the log, by group and heads.
|
||||
outsideSaid map[string]bool
|
||||
}
|
||||
@@ -174,7 +162,6 @@ func (ch *Checker) init() {
|
||||
ch.known, ch.inflight, ch.again, ch.tried = map[string]delivery.Candidate{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
ch.nodesFail, ch.held = map[string]uint64{}, map[string]string{}
|
||||
ch.live, ch.settling = map[string]liveCheck{}, map[string]*window{}
|
||||
ch.rebuilt, ch.awaiting = map[string]bool{}, map[string]bool{}
|
||||
ch.outsideSaid = map[string]bool{}
|
||||
})
|
||||
}
|
||||
@@ -393,9 +380,8 @@ func (ch *Checker) consider(ctx context.Context, key string, c delivery.Candidat
|
||||
// Every check is asked at once: the build seats' streams hold the backlog, a new head's check and a check after a
|
||||
// failure of the nodes on check, a re-judge after a trunk move on recheck, pulled after them (ADR 0525 §4, §9;
|
||||
// ADR 0536 §4). The checker holds no place and no capacity of its own.
|
||||
ch.prepare(ctx, key, cs)
|
||||
ch.prepare(ctx, cs)
|
||||
ch.try(ctx, run, key, cs)
|
||||
ch.hearFailedBuilds(ctx, key, cs) // a check it failed on a build is asked again once that build is done again
|
||||
ch.mu.Lock()
|
||||
more := ch.again[vkey]
|
||||
delete(ch.again, vkey)
|
||||
@@ -532,7 +518,7 @@ const (
|
||||
// or sibling commits that moved since is carried, its packages run only where the move changed their digest, and a pass's
|
||||
// commits are named as the carry's CarriedFrom. A verdict that stands is ranked with the carries: its claim finds nothing
|
||||
// to do.
|
||||
func (ch *Checker) prepare(ctx context.Context, group string, cs *Case) {
|
||||
func (ch *Checker) prepare(ctx context.Context, cs *Case) {
|
||||
cs.Kind, cs.CarriedFrom, cs.Composition = KindNew, nil, nil
|
||||
value, _, found, err := ch.Verdicts.Get(ctx, cs.Key)
|
||||
if err != nil || !found {
|
||||
@@ -545,10 +531,6 @@ func (ch *Checker) prepare(ctx context.Context, group string, cs *Case) {
|
||||
case IsNodesFailure(v):
|
||||
cs.Kind = KindNodes
|
||||
return
|
||||
case failedBuilds(v, cs) != nil:
|
||||
cs.Kind = KindNodes // asked again on check once its builds are done again (awaitRebuilt), never by a recheck
|
||||
ch.awaitRebuilt(ctx, group, cs.Key, failedBuilds(v, cs))
|
||||
return
|
||||
case IsConflictFailure(v):
|
||||
return // a head git refused: its next check is whole (ADR 0548 §7)
|
||||
case IsEnded(v), IsUntrunked(v):
|
||||
@@ -705,8 +687,6 @@ func (ch *Checker) claim(ctx context.Context, cs *Case, first bool, epoch uint64
|
||||
return nil, nil
|
||||
case IsEnded(cur), ch.stale(cur, cs):
|
||||
retake = true // heads held again after their check was ended unjudged (issue 1890), or a trunk that moved
|
||||
case cur.State == delivery.VerdictFail && ch.takeRebuilt(cs.Key):
|
||||
retake = true // every failed build it names is done again (ADR 0603 §10)
|
||||
case cur.State == delivery.VerdictFail && IsNodesFailure(cur):
|
||||
ch.mu.Lock()
|
||||
at, ok := ch.nodesFail[cs.Key]
|
||||
@@ -893,7 +873,6 @@ func (ch *Checker) try(ctx, run context.Context, key string, cs *Case) {
|
||||
return
|
||||
}
|
||||
ch.writeOwed(ctx, cs, v, out)
|
||||
ch.sayVerdict(ctx, cs, v, out)
|
||||
ch.logf("%s: %s", cs.Key, v.State)
|
||||
}
|
||||
|
||||
@@ -1091,61 +1070,3 @@ func (ch *Checker) movedSince(cs *Case) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// awaitRebuilt hears, once per verdict key, the builder's keys of the builds a failed verdict names: when each is written
|
||||
// done again (builder_build_again), the check of those heads is asked again (claim retakes it), as a failure of the nodes
|
||||
// is after the nodes changed, so no recheck by hand is needed (ADR 0603 §10). Heard on the builder's own writes, never
|
||||
// polled; it ends with the checker.
|
||||
func (ch *Checker) awaitRebuilt(ctx context.Context, group, vkey string, builds map[string]string) {
|
||||
if ch.Rebuilds == nil {
|
||||
return
|
||||
}
|
||||
ch.mu.Lock()
|
||||
if ch.awaiting[vkey] {
|
||||
ch.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ch.awaiting[vkey] = true
|
||||
ch.mu.Unlock()
|
||||
ch.wg.Add(1)
|
||||
go func() {
|
||||
defer ch.wg.Done()
|
||||
done := ch.Rebuilds.Done(ctx, builds)
|
||||
ch.mu.Lock()
|
||||
delete(ch.awaiting, vkey)
|
||||
if done {
|
||||
ch.rebuilt[vkey] = true
|
||||
}
|
||||
c, known := ch.known[group]
|
||||
ch.mu.Unlock()
|
||||
if done && known && ctx.Err() == nil {
|
||||
ch.logf("%s: the builds of %s it failed on are done again, so it is checked again (ADR 0603 §10)", vkey, strings.Join(slices.Sorted(maps.Keys(builds)), ", "))
|
||||
ch.consider(ctx, group, c)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// hearFailedBuilds starts awaitRebuilt for the case's verdict when it names failed builds (failedBuilds).
|
||||
func (ch *Checker) hearFailedBuilds(ctx context.Context, group string, cs *Case) {
|
||||
if ch.Rebuilds == nil || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
value, _, found, err := ch.Verdicts.Get(ctx, cs.Key)
|
||||
if err != nil || !found {
|
||||
return
|
||||
}
|
||||
if v, err := delivery.DecodeVerdict(value); err == nil {
|
||||
if builds := failedBuilds(v, cs); builds != nil {
|
||||
ch.awaitRebuilt(ctx, group, cs.Key, builds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// takeRebuilt says whether every failed build the verdict of vkey names is done again, and forgets it: one retake each.
|
||||
func (ch *Checker) takeRebuilt(vkey string) bool {
|
||||
ch.mu.Lock()
|
||||
defer ch.mu.Unlock()
|
||||
was := ch.rebuilt[vkey]
|
||||
delete(ch.rebuilt, vkey)
|
||||
return was
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -431,13 +430,9 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T
|
||||
raw, _, _, _ := r.owed.Get(ctx, key)
|
||||
o, _ := delivery.DecodeCheckerOwed(raw)
|
||||
seen = append(seen, o.State)
|
||||
c.series(repoA+"#7", []string{"web/pkg series 0.1 → 0.2"}, []delivery.SeriesChange{{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}})
|
||||
return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}},
|
||||
OutOfStep: map[string][]string{repoA + "#7": {"go/seats/x/contract.go"}}}, nil
|
||||
return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}}}, nil
|
||||
}}
|
||||
r = newRig(t, j)
|
||||
says := &verdictsSaid{}
|
||||
r.ch.Says = says
|
||||
r.ch.Candidate(ctx, "feat/x", candidateOf(t, "feat/x", pullOf(repoA, 7, head1)))
|
||||
r.ch.Wait()
|
||||
key, _ := delivery.CheckerOwedKey(repoA, 7, head1)
|
||||
@@ -449,27 +444,6 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T
|
||||
if len(seen) != 1 || seen[0] != "pending" || o.State != "failure" || o.Context != delivery.StatusContextCheck || !strings.HasPrefix(o.Target, "https://view.invalid/checks/") {
|
||||
t.Errorf("while running %v, at the end %+v", seen, o)
|
||||
}
|
||||
// DLV-C2 (design 26 *Out of step*): the check's end says one verdict event for the pull request, naming its out-of-step
|
||||
// files; the running status says none.
|
||||
if len(says.said) != 1 || says.said[0].Repository != repoA || says.said[0].Pull != 7 || says.said[0].Head != head1 ||
|
||||
says.said[0].Verdict != delivery.VerdictFail || strings.Join(says.said[0].OutOfStep, ",") != "go/seats/x/contract.go" ||
|
||||
// The join (A2j finding 2): and its series changes, as the seat's event names them.
|
||||
len(says.said[0].Series) != 1 || says.said[0].Series[0] != (meshdeliverychecker.VerdictHolderEventSeriesItem{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}) {
|
||||
t.Errorf("verdict events %+v", says.said)
|
||||
}
|
||||
}
|
||||
|
||||
// verdictsSaid is a VerdictSayer that keeps what it was told.
|
||||
type verdictsSaid struct {
|
||||
mu sync.Mutex
|
||||
said []VerdictEvent
|
||||
}
|
||||
|
||||
func (s *verdictsSaid) Say(_ context.Context, v VerdictEvent) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.said = append(s.said, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// No check of heads no candidate holds is left running (issue 1890): at start the sweep ends a key left running; while the
|
||||
|
||||
@@ -3,12 +3,9 @@ package check
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -33,7 +30,6 @@ func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (delivery.Compo
|
||||
// 1297/AC3 (its unit half): a check whose tests pass takes the assigner's composition of it, by the check's own key,
|
||||
// into its verdict: a pass says it passed with the mesh-sdk and machines it composed, and is no longer "not run".
|
||||
func TestAPassingCheckTakesTheAssignersComposition(t *testing.T) {
|
||||
t.Run("a change to a build module or base names the modules built on it", aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt)
|
||||
b := newBench(t)
|
||||
f := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "v0.1.14-x", Machines: []string{"g14"}, Took: 40}}
|
||||
b.ex.Compositions = f
|
||||
@@ -103,165 +99,3 @@ func TestACompositionNotHadIsNotRun(t *testing.T) {
|
||||
type fixedTrunks struct{ heads map[string]string }
|
||||
|
||||
func (f fixedTrunks) Heads() map[string]string { return f.heads }
|
||||
|
||||
// fakeEnds answers the builder's builds of a candidate as fakeBuilds holds them, recording the builds waited on; the
|
||||
// composition must not have been asked yet when it is (ADR 0603 §10: the check composes with the builds).
|
||||
type fakeEnds struct {
|
||||
builds fakeBuilds
|
||||
asked map[string]string
|
||||
composed *fakeCompositions
|
||||
early bool
|
||||
}
|
||||
|
||||
func (f *fakeEnds) Ended(_ context.Context, builds map[string]string) (map[string]delivery.BuilderBuild, error) {
|
||||
f.asked = builds
|
||||
f.early = f.composed != nil && len(f.composed.asked) > 0
|
||||
out := map[string]delivery.BuilderBuild{}
|
||||
for module, fp := range builds {
|
||||
if b, ok := f.builds[module+" "+fp]; ok && (b.State == delivery.BuildDone || b.State == delivery.BuildFailed) {
|
||||
out[module] = b
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeEnds) Done(context.Context, map[string]string) bool { return false }
|
||||
|
||||
// fixedServing are the verbs the running builds serve.
|
||||
type fixedServing []string
|
||||
|
||||
func (f fixedServing) Served(context.Context) (func(string) bool, error) {
|
||||
return func(v string) bool { return slices.Contains(f, v) }, nil
|
||||
}
|
||||
|
||||
// knownRuns are the modules the trunk holds a record of (the indexer's modules): any commit.
|
||||
type knownRuns []string
|
||||
|
||||
func (k knownRuns) CommitOf(module string) string {
|
||||
if slices.Contains(k, module) {
|
||||
return trunkA
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ADR 0603 §10: before it composes, the check waits on the builder's builds of the candidate's changed modules and composes
|
||||
// with them; a failed build fails the check naming it, and one not ended within the bound is named. It does not wait on a
|
||||
// module built by a build module the change alters, nor on one standing on a base the change alters, nor on one the trunk
|
||||
// holds no record of, and names each in the composition's warnings (and so the check's line); a change that alters none of
|
||||
// these says nothing.
|
||||
func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) {
|
||||
dig := func(c string) string { return strings.Repeat(c, 64) }
|
||||
fp := "src2:" + dig("f")
|
||||
goBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "bin", Kind: catalogue.ArtifactBundle, Language: "go"}}}
|
||||
tsBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "app", Kind: catalogue.ArtifactBundle, Language: "typescript"}}}
|
||||
done := delivery.BuilderBuild{State: delivery.BuildDone}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
manifest map[string]catalogue.Manifest
|
||||
builds map[string]delivery.BuilderBuild // by module, at fp; nil leaves the check without the builds' ends
|
||||
runs knownRuns // nil: the trunk holds every module
|
||||
want []string
|
||||
waited string // the modules waited on, sorted
|
||||
failed string // the failure's test, when the check fails on a build
|
||||
tests bool // the tests fail: the check fails before it would wait
|
||||
served []string // what the running builds serve (ADR 0614 §7); nil reads nothing of what runs
|
||||
}{
|
||||
{name: "the Go build module invokes a verb no running build serves",
|
||||
manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild,
|
||||
Invokes: []string{"seat:package-registry.publish", "seat:artifact-store.put"}}, "web": {Module: "web", Build: goBuild}},
|
||||
builds: map[string]delivery.BuilderBuild{"mesh-build-go": done}, served: []string{"seat:artifact-store.put"},
|
||||
want: []string{"the changed build module mesh-build-go invokes seat:package-registry.publish, which no running build serves, " +
|
||||
"so the check cannot run it: web, which it builds, are composed from their manifests (ADR 0614 §7)"}, waited: "mesh-build-go"},
|
||||
{name: "the Go build module changes",
|
||||
manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild}, "web": {Module: "web", Build: goBuild},
|
||||
"mail": {Module: "mail", Build: goBuild}, "docs": {Module: "docs"}},
|
||||
builds: map[string]delivery.BuilderBuild{"mesh-build-go": done, "docs": done},
|
||||
want: []string{"the build module mesh-build-go: mail, web, which it builds"}, waited: "docs, mesh-build-go"},
|
||||
{name: "the Node build module changes",
|
||||
manifest: map[string]catalogue.Manifest{"mesh-build-node": {Module: "mesh-build-node", Build: goBuild}, "panel": {Module: "panel", Build: tsBuild},
|
||||
"web": {Module: "web", Build: goBuild}},
|
||||
builds: map[string]delivery.BuilderBuild{"mesh-build-node": done, "web": done},
|
||||
want: []string{"the build module mesh-build-node: panel, which it builds"}, waited: "mesh-build-node, web"},
|
||||
{name: "a base changes",
|
||||
manifest: map[string]catalogue.Manifest{"image": {Module: "image"},
|
||||
"web": {Module: "web", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "image", Artifact: "img"}}}}},
|
||||
builds: map[string]delivery.BuilderBuild{"image": done},
|
||||
want: []string{"alters image, a base of web", "waits on the build of image alone"}, waited: "image"},
|
||||
{name: "neither changes", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}},
|
||||
builds: map[string]delivery.BuilderBuild{"web": done}, waited: "web"},
|
||||
{name: "a build fails", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "mail": {Module: "mail", Build: goBuild}},
|
||||
builds: map[string]delivery.BuilderBuild{"web": {State: delivery.BuildFailed, Error: "go build: undefined: x"}, "mail": done},
|
||||
waited: "mail, web", failed: "build: web"},
|
||||
{name: "the tests fail", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}},
|
||||
builds: map[string]delivery.BuilderBuild{"web": {State: delivery.BuildBuilding}}, tests: true,
|
||||
want: []string{"composes at once, without waiting on the builds of web"}},
|
||||
{name: "a build does not end within the bound", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}},
|
||||
builds: map[string]delivery.BuilderBuild{"web": {State: delivery.BuildBuilding}},
|
||||
want: []string{"the builds of web did not end within the build-wait-bound"}, waited: "web"},
|
||||
{name: "a module the trunk does not hold", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "new": {Module: "new", Build: goBuild}},
|
||||
builds: map[string]delivery.BuilderBuild{"web": done}, runs: knownRuns{"web"},
|
||||
want: []string{"new: the trunk holds no record of it yet"}, waited: "web"},
|
||||
{name: "the builds cannot be heard", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}},
|
||||
want: []string{"the builds of web cannot be heard"}},
|
||||
} {
|
||||
b := candidateBench(t, true)
|
||||
if row.tests {
|
||||
b.test = func(c stepCall) (any, error) {
|
||||
r := captured[Tested](t, "go-test-failing")
|
||||
r.Level, _ = c.args["level"].(string)
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
comps := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"g14"}}}
|
||||
b.ex.Compositions = comps
|
||||
var ends *fakeEnds
|
||||
if row.builds != nil {
|
||||
ends = &fakeEnds{builds: fakeBuilds{}, composed: comps}
|
||||
for module, build := range row.builds {
|
||||
ends.builds[module+" "+fp] = build
|
||||
}
|
||||
b.ex.BuildEnds = ends
|
||||
}
|
||||
if row.runs != nil {
|
||||
b.ex.Runs = row.runs
|
||||
}
|
||||
if row.served != nil {
|
||||
b.ex.Serving = fixedServing(row.served)
|
||||
}
|
||||
read, fps := fixedManifests{}, map[string]delivery.CandidateModule{}
|
||||
for module, m := range row.manifest {
|
||||
d := dig(string(rune('a' + len(read))))
|
||||
read[d], fps[module] = m, delivery.CandidateModule{FP2: fp, Manifest: d}
|
||||
}
|
||||
b.ex.Read = read
|
||||
p := pullOf(catalogueRepository, 9, head1)
|
||||
p.Fps, p.Modules = fps, slices.Sorted(maps.Keys(fps))
|
||||
v := b.judge(t, caseOf(t, p)).Verdict
|
||||
if v.Composition == nil || len(comps.asked) != 1 {
|
||||
t.Fatalf("%s: composed %d times: %+v", row.name, len(comps.asked), v)
|
||||
}
|
||||
if ends != nil {
|
||||
if got := strings.Join(slices.Sorted(maps.Keys(ends.asked)), ", "); got != row.waited || ends.early {
|
||||
t.Errorf("%s: waited on %q (after the composition: %v), want %q before it composes", row.name, got, ends.early, row.waited)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case row.failed != "" && (v.State != delivery.VerdictFail || v.Failed.Test != row.failed || !strings.Contains(v.Failed.Words, "undefined: x") ||
|
||||
!strings.Contains(v.Failed.Words, "the check asks itself again when the build is done")):
|
||||
t.Errorf("%s: the verdict is %s failing %+v, want it failed naming %s, the builder's words and how it is asked again", row.name, v.State, v.Failed, row.failed)
|
||||
case row.tests && v.State != delivery.VerdictFail:
|
||||
t.Errorf("%s: %+v, want the tests' failure", row.name, v)
|
||||
case row.failed == "" && !row.tests && v.State != delivery.VerdictPass:
|
||||
t.Errorf("%s: %+v", row.name, v)
|
||||
}
|
||||
words := compositionWords(v.Composition, v.Took.Composition)
|
||||
for _, w := range row.want {
|
||||
if !strings.Contains(words, w) {
|
||||
t.Errorf("%s: the line says %q, want it to name %q", row.name, words, w)
|
||||
}
|
||||
}
|
||||
if row.want == nil && v.Composition.Words != "" {
|
||||
t.Errorf("%s: warned %q though the check waited on every build of the change", row.name, v.Composition.Words)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -25,12 +23,12 @@ import (
|
||||
//
|
||||
// **A group that changes the node-engine's composition code** (a member of the node-engine's repository) is not judged by
|
||||
// the running engines, which cannot judge their own next code: each node's engine answers the composition input it
|
||||
// composed from (ne.ComposeCandidateInput.Input), and the composer program built from that member's head composes it in a
|
||||
// composed from (ComposeCandidateAsk.Input), and the composer program built from that member's head composes it in a
|
||||
// build module's sandbox (Sandbox), with every manifest of the catalogue as the case leaves it and the indexer's verdicts.
|
||||
|
||||
// EngineAsker asks one node's engine to compose a check's candidate (the verb node-engine.compose-candidate on that node).
|
||||
type EngineAsker interface {
|
||||
ComposeCandidate(ctx context.Context, node string, ask ne.ComposeCandidateInput) (ne.ComposeCandidateAnswer, error)
|
||||
ComposeCandidate(ctx context.Context, node string, ask delivery.ComposeCandidateAsk) (delivery.ComposeCandidateAnswer, error)
|
||||
}
|
||||
|
||||
// Sandbox runs a compose job on the Go build seat and answers the composer's verdict.
|
||||
@@ -44,7 +42,7 @@ type EngineCompositions struct {
|
||||
// Placed names every node in placement now (the seat mesh-placement's state), or why it cannot be read: then no node
|
||||
// can be asked, and the composition is not made (an error), never passed with nobody asked.
|
||||
Placed func() ([]string, error)
|
||||
// Bound is the answer bound (the setting compose-answer-bound); zero is nec.AnswerBound.
|
||||
// Bound is the answer bound (the setting compose-answer-bound); zero is delivery.ComposeAnswerBound.
|
||||
Bound time.Duration
|
||||
// Composer is the commit of the node-engine the mesh runs, named as what composed (ADR 0548 §6's running commit).
|
||||
Composer func() string
|
||||
@@ -80,11 +78,11 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
|
||||
}
|
||||
bound := e.Bound
|
||||
if bound <= 0 {
|
||||
bound = nec.AnswerBound
|
||||
bound = delivery.ComposeAnswerBound
|
||||
}
|
||||
head := composerHead(c)
|
||||
sandboxed := head != "" && e.Sandbox != nil
|
||||
answers := map[string]ne.ComposeCandidateAnswer{}
|
||||
answers := map[string]delivery.ComposeCandidateAnswer{}
|
||||
silent := map[string]string{}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
@@ -94,13 +92,13 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
|
||||
defer wg.Done()
|
||||
at, cancel := context.WithTimeout(ctx, bound)
|
||||
defer cancel()
|
||||
a, err := e.Asker.ComposeCandidate(at, node, ne.ComposeCandidateInput{Candidate: key, Input: sandboxed})
|
||||
if err == nil && sandboxed && a.Verdict != nec.Unready {
|
||||
a, err := e.Asker.ComposeCandidate(at, node, delivery.ComposeCandidateAsk{Candidate: key, Input: sandboxed})
|
||||
if err == nil && sandboxed && a.Verdict != delivery.ComposeCandidateUnready {
|
||||
// The composer's build and run are the check's to bound, not the engine's answer bound. A composer that
|
||||
// cannot run is no silent node (ADR 0593 §9 lets only that pass): the node's composition fails, naming
|
||||
// the head.
|
||||
if composed, serr := e.inSandbox(ctx, c, head, node, a); serr != nil {
|
||||
a = ne.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: nec.Refused,
|
||||
a = delivery.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: delivery.ComposeCandidateRefused,
|
||||
Refused: "the composer at the node-engine's head " + head + " could not compose it: " + serr.Error()}
|
||||
} else {
|
||||
a = composed
|
||||
@@ -111,7 +109,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
|
||||
switch {
|
||||
case err != nil:
|
||||
silent[node] = err.Error()
|
||||
case a.Verdict == nec.Unready:
|
||||
case a.Verdict == delivery.ComposeCandidateUnready:
|
||||
silent[node] = "unready"
|
||||
default:
|
||||
answers[node] = a
|
||||
@@ -137,8 +135,8 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
|
||||
// JudgeEngines is the check's composition verdict from the engines' answers (ADR 0593 §7, §9): failed when any node's
|
||||
// answer is refused, naming each node and each module refused with its words (the first refusing node is the machine);
|
||||
// passed otherwise. Every node in nodes without an answer (silent, by node with why, for the journal only) is named
|
||||
// with nec.NoAnswer as a warning. composer names what composed, for the line. Pure.
|
||||
func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]ne.ComposeCandidateAnswer, silent map[string]string) delivery.CompositionVerdict {
|
||||
// with delivery.ComposeNoAnswer as a warning. composer names what composed, for the line. Pure.
|
||||
func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]delivery.ComposeCandidateAnswer, silent map[string]string) delivery.CompositionVerdict {
|
||||
v := delivery.CompositionVerdict{Group: group, Heads: heads, State: delivery.CompositionPass, SDK: composerName}
|
||||
sorted := append([]string(nil), nodes...)
|
||||
sort.Strings(sorted)
|
||||
@@ -146,17 +144,11 @@ func JudgeEngines(group, heads, composerName string, nodes []string, answers map
|
||||
for _, node := range sorted {
|
||||
a, answered := answers[node]
|
||||
if !answered {
|
||||
v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer)
|
||||
continue
|
||||
}
|
||||
if a.Verdict == nec.NotJudged {
|
||||
// Composed in the sandbox and not judged (ADR 0593 §9): named as no answer, with the composer's words; decided by the
|
||||
// verdict, never by the words (ADR 0601 §4's kind).
|
||||
v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer+" ("+a.Refused+")")
|
||||
v.Warnings = append(v.Warnings, node+": "+delivery.ComposeNoAnswer)
|
||||
continue
|
||||
}
|
||||
v.Machines = append(v.Machines, node)
|
||||
if a.Verdict != nec.Refused {
|
||||
if a.Verdict != delivery.ComposeCandidateRefused {
|
||||
continue
|
||||
}
|
||||
if v.Machine == "" {
|
||||
@@ -166,8 +158,8 @@ func JudgeEngines(group, heads, composerName string, nodes []string, answers map
|
||||
if a.Refused != "" {
|
||||
words = append(words, a.Refused)
|
||||
}
|
||||
for _, m := range append(append([]ne.Module(nil), a.Modules...), a.Own) {
|
||||
if m.State == nec.ModuleRefused {
|
||||
for _, m := range append(append([]delivery.ModuleComposed(nil), a.Modules...), a.Own) {
|
||||
if m.State == delivery.ModuleRefusedState {
|
||||
words = append(words, m.Module+": "+m.Words)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +180,7 @@ func JudgeEngines(group, heads, composerName string, nodes []string, answers map
|
||||
// inSandbox composes a node's answer again with the composer built from the node-engine member's head, over the input the
|
||||
// node's engine composed from: the node's answer becomes the sandbox's, refused with the composer's words where the
|
||||
// composer refuses the node.
|
||||
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node string, a ne.ComposeCandidateAnswer) (ne.ComposeCandidateAnswer, error) {
|
||||
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node string, a delivery.ComposeCandidateAnswer) (delivery.ComposeCandidateAnswer, error) {
|
||||
if len(a.Input) == 0 {
|
||||
return a, fmt.Errorf("the engine of %s answered no composition input, so the node-engine's head cannot compose it", node)
|
||||
}
|
||||
@@ -216,13 +208,7 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s
|
||||
delete(verdicts, name) // a manifest the change brings is the checker's own judge's (ADR 0578 §2)
|
||||
}
|
||||
}
|
||||
// The answer carries its input as the object the seat's contract names, decoded with its numbers as written
|
||||
// (json.Number), so encoding it again gives the composer the same values the engine sent.
|
||||
input, err := json.Marshal(a.Input)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("the engine of %s answered a composition input that cannot be encoded again: %w", node, err)
|
||||
}
|
||||
req := composer.Request{V: composer.Version, Input: input, Depth: composer.DepthCompose, Machines: []string{node},
|
||||
req := composer.Request{V: composer.Version, Input: a.Input, Depth: composer.DepthCompose, Machines: []string{node},
|
||||
Cases: []composer.Case{{Name: "change", Manifests: manifests, Verdicts: verdicts}}}
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -234,25 +220,19 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("the composer at the node-engine's head %s did not answer for %s: %w", head, node, err)
|
||||
}
|
||||
out := ne.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: nec.Composed}
|
||||
notJudged := ""
|
||||
out := delivery.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: delivery.ComposeCandidateComposed}
|
||||
for _, cv := range verdict.Cases {
|
||||
if m, ok := cv.Machines[node]; ok && m.NotJudged != "" {
|
||||
notJudged = m.NotJudged // the composer did not judge the node: neither composed nor refused (ADR 0593 §9)
|
||||
} else if ok && !m.Composes {
|
||||
out.Verdict = nec.Refused
|
||||
if m, ok := cv.Machines[node]; ok && !m.Composes {
|
||||
out.Verdict = delivery.ComposeCandidateRefused
|
||||
out.Refused = strings.Join(append(append([]string{m.Reason}, m.Problems...), m.Refusals...), "; ")
|
||||
}
|
||||
for name, read := range cv.Manifests {
|
||||
if !read.Accepted {
|
||||
out.Verdict = nec.Refused
|
||||
out.Modules = append(out.Modules, ne.Module{Module: name, State: nec.ModuleRefused, Words: read.Refused})
|
||||
out.Verdict = delivery.ComposeCandidateRefused
|
||||
out.Modules = append(out.Modules, delivery.ModuleComposed{Module: name, State: delivery.ModuleRefusedState, Words: read.Refused})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Modules, func(i, j int) bool { return out.Modules[i].Module < out.Modules[j].Module })
|
||||
if notJudged != "" && out.Verdict == nec.Composed {
|
||||
out.Verdict, out.Refused = nec.NotJudged, notJudged
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -18,26 +16,26 @@ import (
|
||||
// fakeEngines answers compose-candidate by node from memory; a node it holds no answer for never answers.
|
||||
type fakeEngines struct {
|
||||
mu sync.Mutex
|
||||
answers map[string]ne.ComposeCandidateAnswer
|
||||
answers map[string]delivery.ComposeCandidateAnswer
|
||||
errs map[string]error
|
||||
asked map[string]ne.ComposeCandidateInput
|
||||
asked map[string]delivery.ComposeCandidateAsk
|
||||
}
|
||||
|
||||
func (f *fakeEngines) ComposeCandidate(ctx context.Context, node string, ask ne.ComposeCandidateInput) (ne.ComposeCandidateAnswer, error) {
|
||||
func (f *fakeEngines) ComposeCandidate(ctx context.Context, node string, ask delivery.ComposeCandidateAsk) (delivery.ComposeCandidateAnswer, error) {
|
||||
f.mu.Lock()
|
||||
if f.asked == nil {
|
||||
f.asked = map[string]ne.ComposeCandidateInput{}
|
||||
f.asked = map[string]delivery.ComposeCandidateAsk{}
|
||||
}
|
||||
f.asked[node] = ask
|
||||
a, ok := f.answers[node]
|
||||
err := f.errs[node]
|
||||
f.mu.Unlock()
|
||||
if err != nil {
|
||||
return ne.ComposeCandidateAnswer{}, err
|
||||
return delivery.ComposeCandidateAnswer{}, err
|
||||
}
|
||||
if !ok {
|
||||
<-ctx.Done()
|
||||
return ne.ComposeCandidateAnswer{}, ctx.Err()
|
||||
return delivery.ComposeCandidateAnswer{}, ctx.Err()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -74,10 +72,10 @@ func (d digests) ManifestDigests() map[string]string { return d }
|
||||
// warning, decided by the verdict and never by its words; a group with a node-engine member is composed by the composer
|
||||
// built at that member's head, over the input each node's engine composed from.
|
||||
func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
composed := func(node string) ne.ComposeCandidateAnswer {
|
||||
return ne.ComposeCandidateAnswer{Node: node, Verdict: nec.Composed,
|
||||
Modules: []ne.Module{{Module: "web", State: nec.ModuleComposed, Digest: "sha256:" + strings.Repeat("a", 64)}},
|
||||
Own: ne.Module{Module: nec.Own, State: nec.ModuleComposed, Digest: "sha256:" + strings.Repeat("b", 64)}}
|
||||
composed := func(node string) delivery.ComposeCandidateAnswer {
|
||||
return delivery.ComposeCandidateAnswer{Node: node, Verdict: delivery.ComposeCandidateComposed,
|
||||
Modules: []delivery.ModuleComposed{{Module: "web", State: delivery.ModuleComposedState, Digest: "sha256:" + strings.Repeat("a", 64)}},
|
||||
Own: delivery.ModuleComposed{Module: "node", State: delivery.ModuleComposedState, Digest: "sha256:" + strings.Repeat("b", 64)}}
|
||||
}
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1))
|
||||
key, _ := delivery.CompositionKey(cs.Group, cs.HeadsHash)
|
||||
@@ -94,10 +92,10 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
|
||||
t.Run("one node refusing fails the composition, naming the node and the module", func(t *testing.T) {
|
||||
refusing := composed("laptop")
|
||||
refusing.Verdict = nec.Refused
|
||||
refusing.Modules = []ne.Module{{Module: "web", State: nec.ModuleRefused, Words: "a required value is unset"},
|
||||
{Module: "mail", State: nec.ModuleHeld, Words: "held as today"}}
|
||||
e := &fakeEngines{answers: map[string]ne.ComposeCandidateAnswer{"anchor": composed("anchor"), "laptop": refusing}}
|
||||
refusing.Verdict = delivery.ComposeCandidateRefused
|
||||
refusing.Modules = []delivery.ModuleComposed{{Module: "web", State: delivery.ModuleRefusedState, Words: "a required value is unset"},
|
||||
{Module: "mail", State: delivery.ModuleHeldState, Words: "held as today"}}
|
||||
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": composed("anchor"), "laptop": refusing}}
|
||||
v := compose(e, "anchor", "laptop")
|
||||
if v.State != delivery.CompositionFail || v.Machine != "laptop" || !strings.Contains(v.Words, "not composed on laptop: web: a required value is unset") ||
|
||||
strings.Contains(v.Words, "mail") || len(v.Machines) != 2 {
|
||||
@@ -114,8 +112,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("a silent, unready or unreachable node is no answer, and the check passes naming it", func(t *testing.T) {
|
||||
unready := ne.ComposeCandidateAnswer{Node: "g14", Verdict: nec.Unready, Refused: "a module refused it"}
|
||||
e := &fakeEngines{answers: map[string]ne.ComposeCandidateAnswer{"anchor": composed("anchor"), "g14": unready},
|
||||
unready := delivery.ComposeCandidateAnswer{Node: "g14", Verdict: delivery.ComposeCandidateUnready, Refused: "a module refused it"}
|
||||
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": composed("anchor"), "g14": unready},
|
||||
errs: map[string]error{"ace": errors.New("no responders")}}
|
||||
began := time.Now()
|
||||
v := compose(e, "anchor", "laptop", "g14", "ace")
|
||||
@@ -126,14 +124,14 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
for _, node := range []string{"ace", "g14", "laptop"} {
|
||||
if !strings.Contains(strings.Join(v.Warnings, "; "), node+": "+nec.NoAnswer) {
|
||||
if !strings.Contains(strings.Join(v.Warnings, "; "), node+": "+delivery.ComposeNoAnswer) {
|
||||
t.Errorf("%s is not named: %v", node, v.Warnings)
|
||||
}
|
||||
}
|
||||
var ver delivery.Verdict
|
||||
ver.State = delivery.VerdictPass
|
||||
withComposition(&ver, v)
|
||||
if ver.State != delivery.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+nec.NoAnswer) {
|
||||
if ver.State != delivery.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+delivery.ComposeNoAnswer) {
|
||||
t.Errorf("the line = %q", composition(ver.Composition, 0, false))
|
||||
}
|
||||
})
|
||||
@@ -145,8 +143,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
}
|
||||
host := CaseOf(c, map[string]string{repoA: trunkA, nodeEngineRepo: trunkA})
|
||||
withInput := composed("anchor")
|
||||
withInput.Input = map[string]any{"nodes": []any{map[string]any{"name": "anchor"}}}
|
||||
e := &fakeEngines{answers: map[string]ne.ComposeCandidateAnswer{"anchor": withInput}}
|
||||
withInput.Input = json.RawMessage(`{"nodes":[{"name":"anchor"}]}`)
|
||||
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": withInput}}
|
||||
sb := &fakeSandbox{v: func(node string) composer.Verdict {
|
||||
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change",
|
||||
Machines: map[string]composer.Machine{node: {Composes: false, Reason: "the new composer refuses it"}}}}}
|
||||
@@ -164,31 +162,12 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
var req composer.Request
|
||||
_ = json.Unmarshal(job.Request, &req)
|
||||
if job.Fetch["repository"] != nodeEngineRepo || job.Fetch["commit"] != head2 || job.Package != composer.Package ||
|
||||
string(req.Input) != `{"nodes":[{"name":"anchor"}]}` || req.Cases[0].Manifests["web"].Digest == "" {
|
||||
string(req.Input) != string(withInput.Input) || req.Cases[0].Manifests["web"].Digest == "" {
|
||||
t.Errorf("job = %+v, request %+v", job, req)
|
||||
}
|
||||
if v.State != delivery.CompositionFail || v.SDK != head2 || !strings.Contains(v.Words, "the new composer refuses it") {
|
||||
t.Errorf("verdict = %+v", v)
|
||||
}
|
||||
// ADR 0593 §9 (ADR 0601 §4's kind): a node the composer did not judge is named as no answer with its words, by the
|
||||
// contract's field, and the check passes; words in Reason alone are no such mark.
|
||||
sb.err = nil
|
||||
for _, row := range []struct {
|
||||
m composer.Machine
|
||||
pass bool
|
||||
}{
|
||||
{composer.Machine{NotJudged: "its node-engine sends no running state for web"}, true},
|
||||
{composer.Machine{Reason: "not judged: its node-engine sends no running state for web"}, false},
|
||||
} {
|
||||
sb.v = func(node string) composer.Verdict {
|
||||
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change", Machines: map[string]composer.Machine{node: row.m}}}}
|
||||
}
|
||||
v, err = ec.Compose(context.Background(), host)
|
||||
named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+nec.NoAnswer+" (its node-engine sends no running state for web)")
|
||||
if err != nil || (v.State == delivery.CompositionPass) != row.pass || named != row.pass {
|
||||
t.Errorf("%+v: verdict %+v, %v", row.m, v, err)
|
||||
}
|
||||
}
|
||||
// Review finding 3 (ADR 0593 §9 lets only a node that does not answer pass): a composer at the head that cannot
|
||||
// run fails the composition, naming the head, never passing as a silent node.
|
||||
sb.err = errors.New("the build of mesh-compose failed")
|
||||
|
||||
@@ -55,19 +55,6 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) {
|
||||
t.Errorf("%s: the verdict is not written: %v", name, err)
|
||||
}
|
||||
}
|
||||
// Design 26 *Out of step* (DLV-C2): the out-of-step files the compare step names, in each result, are the outcome's for
|
||||
// the pull request, each once and sorted; a level naming none leaves the outcome with none.
|
||||
step := func(files ...string) json.RawMessage {
|
||||
raw, _ := json.Marshal(Tested{Level: delivery.LevelUnit, Tests: 1, Passed: false, Why: "out of step", OutOfStep: files})
|
||||
return raw
|
||||
}
|
||||
_, st := judgeLevel(t, step("go/seats/b/contract.go", "go/seats/a/contract.go"), step("go/seats/a/contract.go"))
|
||||
if got := st.outcome(delivery.Verdict{State: delivery.VerdictFail}).OutOfStep["novox/x#0"]; strings.Join(got, ",") != "go/seats/a/contract.go,go/seats/b/contract.go" {
|
||||
t.Errorf("out of step %v", got)
|
||||
}
|
||||
if _, st := judgeLevel(t, failingResult(t, 2, a)); st.outcome(delivery.Verdict{State: delivery.VerdictFail}).OutOfStep != nil {
|
||||
t.Errorf("a level naming no out-of-step file has %v", st.outOfStep)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: past delivery.MaxFailures the list names the first fifty and how many more, the status line says how many
|
||||
|
||||
@@ -19,9 +19,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
gobuild "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -1002,17 +1000,6 @@ func (k keptList) PredefinedList(context.Context) (json.RawMessage, error) {
|
||||
return json.RawMessage(k), nil
|
||||
}
|
||||
|
||||
// fixedManifests are the indexer's kept manifests by digest as a test gives them.
|
||||
type fixedManifests map[string]catalogue.Manifest
|
||||
|
||||
func (f fixedManifests) Manifest(_ context.Context, digest string) (catalogue.Manifest, error) {
|
||||
m, ok := f[digest]
|
||||
if !ok {
|
||||
return catalogue.Manifest{}, errors.New("no manifest " + digest)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// fixedDigests are the indexer's manifest digests as a test gives them.
|
||||
type fixedDigests map[string]string
|
||||
|
||||
@@ -1030,123 +1017,17 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
// untouched is the judge's refusal of mail, which the change leaves as it is, and trunkMail its refusal on the trunk:
|
||||
// a problem the trunk had is a warning, one the change causes fails (ADR 0579 §2)
|
||||
untouched, trunkMail string
|
||||
// cycle reads the manifests, so the change's module graph is worked out (ADR 0603 §2): with "provisions" the change's
|
||||
// web awaits mail's site while mail awaits web's database, and trunkCycle has the trunk's web await it too; with "bases"
|
||||
// each is built on the other; with "verbs" each invokes the other's tool, and with "a verb and a provision" web invokes
|
||||
// mail's tool while mail awaits web's database: both left out, so neither is refused (ADR 0614 §3, §6).
|
||||
cycle string
|
||||
trunkCycle bool
|
||||
// series is the series of web's package the change raises it to, and the words its status line names (DLV-C4).
|
||||
series, seriesWords string
|
||||
// renames is what the change does to web's renamed-from ("bring", "drop") and what the seat mesh-config's verb
|
||||
// renames answers ("served", "open" on g14, "not served", "unasked" with no seat to ask, "error"); DLV-C5.
|
||||
renames, seat string
|
||||
// unparsed is the indexer's words of why the pull request's head's seat definition of web-api does not parse.
|
||||
unparsed string
|
||||
// known are the judge's known packages the request hands it (DLV-I14, design 32): the packages state's names and,
|
||||
// of web's package new in the group, the name its go.mod says at the member's head.
|
||||
known []string
|
||||
// emptyState is a packages state answered and empty, no member declaring a package: the judge is handed an empty
|
||||
// list, never nil, so every name a manifest's packages names is refused (review round 1, DLV-I14).
|
||||
emptyState bool
|
||||
}{
|
||||
{name: "a seat definition that does not parse", unparsed: "seats/web-api/seat.json: unexpected end of JSON input",
|
||||
fails: "novox/mesh-delivery#7: the seat definition of web-api (module web-contracts, go) does not parse: seats/web-api/seat.json: unexpected end of JSON input"},
|
||||
{name: "a rename brought, the verb not served", renames: "bring", seat: "not served",
|
||||
fails: "web (novox/mesh-delivery#7) renames new-key from old-key: refused, the holder of the seat mesh-config does not serve its verb renames"},
|
||||
{name: "a rename brought, the verb served", renames: "bring", seat: "served"},
|
||||
{name: "a renamed-from dropped while its window is open", renames: "drop", seat: "open",
|
||||
fails: "web (novox/mesh-delivery#7) drops renamed-from old-key of new-key while its rename window is open on g14"},
|
||||
{name: "a renamed-from dropped once its window closed", renames: "drop", seat: "served"},
|
||||
{name: "a rename the check cannot ask of", renames: "bring", seat: "error", fails: "the check cannot ask seat:mesh-config.renames: the bus said no"},
|
||||
{name: "a rename with no seat to ask", renames: "drop", seat: "unasked", fails: "this checker cannot ask seat:mesh-config.renames"},
|
||||
{name: "accepted"},
|
||||
{name: "a package series raised", series: "0.2", seriesWords: "web/pkg series 0.1 → 0.2"},
|
||||
{name: "a package series kept", series: "0.1"},
|
||||
{name: "a package new in the group", series: "0.1", known: []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/web/go"}},
|
||||
{name: "an answered packages state holding no name", emptyState: true, known: []string{}},
|
||||
{name: "an untouched manifest's problem the trunk had", untouched: "mail says no credential form", trunkMail: "mail says no credential form"},
|
||||
{name: "a problem the change causes in an untouched manifest", untouched: "mail reads a state web no longer keeps",
|
||||
fails: "refuses the manifest of mail: mail reads a state web no longer keeps"},
|
||||
{name: "a cycle the change makes", cycle: "provisions", fails: "the change's module graph has the cycle mail -> web -> mail"},
|
||||
{name: "a cycle the trunk has", cycle: "provisions", trunkCycle: true},
|
||||
{name: "a cycle of bases", cycle: "bases", fails: "the change's module graph has the cycle mail -> web -> mail"},
|
||||
{name: "a cycle of verbs", cycle: "verbs"},
|
||||
{name: "a cycle of a verb and a provision", cycle: "a verb and a provision"},
|
||||
{name: "refused", refused: "web requires \"amqp\"\nweb has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web requires "amqp"; web has no id`},
|
||||
} {
|
||||
b := newBench(t)
|
||||
b.ex.Manifests = fixedDigests{"mail": strings.Repeat("a", 64), "web": strings.Repeat("b", 64)}
|
||||
b.ex.Trunks = fixedTrunks{heads: map[string]string{DeliveryRepository: trunk}}
|
||||
b.ex.Predefined = keptList(`{"font-size": {"type": "number", "description": "the size"}}`)
|
||||
if row.cycle != "" {
|
||||
offer := func(name string) []catalogue.Offer {
|
||||
return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
|
||||
}
|
||||
on := func(module string) *catalogue.Build {
|
||||
return &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: module, Artifact: "img"}}}
|
||||
}
|
||||
trunkWeb := catalogue.Manifest{Module: "web", Provides: offer("database")}
|
||||
if row.trunkCycle {
|
||||
trunkWeb.Requires = []string{"site"}
|
||||
}
|
||||
mail := catalogue.Manifest{Module: "mail", Provides: offer("site"), Requires: []string{"database"}}
|
||||
changed := catalogue.Manifest{Module: "web", Provides: offer("database"), Requires: []string{"site"}}
|
||||
switch row.cycle {
|
||||
case "bases":
|
||||
mail = catalogue.Manifest{Module: "mail", Build: on("web")}
|
||||
changed = catalogue.Manifest{Module: "web", Build: on("mail")}
|
||||
case "verbs":
|
||||
mail = catalogue.Manifest{Module: "mail", Tools: []string{"mail_send"}, Invokes: []string{"web.web_page"}}
|
||||
changed = catalogue.Manifest{Module: "web", Tools: []string{"web_page"}, Invokes: []string{"mail.mail_send"}}
|
||||
case "a verb and a provision":
|
||||
mail = catalogue.Manifest{Module: "mail", Tools: []string{"mail_send"}, Requires: []string{"database"}}
|
||||
changed = catalogue.Manifest{Module: "web", Provides: offer("database"), Invokes: []string{"mail.mail_send"}}
|
||||
}
|
||||
b.ex.Read = fixedManifests{strings.Repeat("a", 64): mail, strings.Repeat("b", 64): trunkWeb, web: changed}
|
||||
}
|
||||
if row.series != "" {
|
||||
pkg := func(series string) *catalogue.Build {
|
||||
return &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "pkg", Kind: catalogue.ArtifactPackage, Language: "go", Series: series}}}
|
||||
}
|
||||
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web", Build: pkg("")},
|
||||
web: {Module: "web", Build: pkg(row.series)}}
|
||||
}
|
||||
if row.known != nil {
|
||||
b.ex.KnownPackages = knownNames{"git.novox.be/novox/mesh-sdk/go": "mesh-sdk/go"}
|
||||
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web"}, web: {Module: "web",
|
||||
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "pkg", Kind: catalogue.ArtifactPackage, Language: "go", Paths: []string{"go"}}}}}}
|
||||
b.ex.Forge = headFiles{repoA + " modules/web/go/go.mod " + head1: "module git.novox.be/novox/web/go\n\ngo 1.26\n"}
|
||||
}
|
||||
if row.emptyState {
|
||||
b.ex.KnownPackages = knownNames{}
|
||||
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web"}, web: {Module: "web"}}
|
||||
}
|
||||
if row.renames != "" {
|
||||
renamed := map[string]catalogue.SettingDeclaration{"new-key": {Type: catalogue.ConfigString, RenamedFrom: "old-key"}}
|
||||
plain := map[string]catalogue.SettingDeclaration{"new-key": {Type: catalogue.ConfigString}}
|
||||
trunkWeb, headWeb := catalogue.Manifest{Module: "web", Settings: plain}, catalogue.Manifest{Module: "web", Settings: renamed}
|
||||
if row.renames == "drop" {
|
||||
trunkWeb, headWeb = headWeb, trunkWeb
|
||||
}
|
||||
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): trunkWeb, web: headWeb}
|
||||
// The verb faked from its definition: the answer of the seat's fixture (mesh-contracts seats/mesh-config).
|
||||
fake := fakeRenames{}
|
||||
switch row.seat {
|
||||
case "not served":
|
||||
fake.err = fmt.Errorf("%w (nothing answers it)", ErrRenamesNotServed)
|
||||
case "error":
|
||||
fake.err = errors.New("the bus said no")
|
||||
case "open":
|
||||
fake.answer = meshconfig.RenamesAnswer{Renames: []meshconfig.RenamesAnswerRenamesItem{{Module: "web", From: "old-key", To: "new-key", At: "2026-11-04T12:00:00Z", Open: []string{"g14"}}}}
|
||||
}
|
||||
if row.seat != "unasked" {
|
||||
b.ex.Renames = fake
|
||||
}
|
||||
}
|
||||
if row.unparsed != "" {
|
||||
b.ex.SeatDefinitions = fakeSeatDefinitions{"novox/mesh-delivery#7": {{Seat: "web-api", Module: "web-contracts", Directory: "go", Why: row.unparsed}}}
|
||||
}
|
||||
var judged []delivery.ComposeJob
|
||||
answer := b.asks.Answer
|
||||
b.asks.Answer = func(a delivery.StepAsk) delivery.Step {
|
||||
@@ -1164,19 +1045,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
p := pullOf(repoA, 7, head1)
|
||||
p.Modules = []string{"web"}
|
||||
p.Fps = map[string]delivery.CandidateModule{"web": {FP2: "src2:" + strings.Repeat("f", 64), Manifest: web}}
|
||||
cs := caseOf(t, p)
|
||||
v := b.judge(t, cs).Verdict
|
||||
if line := Line(v, cs, cs.Pulls[0], nil, nil, nil); row.seriesWords != "" && !strings.Contains(line, row.seriesWords) || row.seriesWords == "" && strings.Contains(line, "series") {
|
||||
t.Errorf("%s: the line %q, want it to name %q", row.name, line, row.seriesWords)
|
||||
}
|
||||
// The join (A2j finding 2): the verdict names each series change as {module, artifact, from, to}.
|
||||
if want := []delivery.SeriesChange(nil); row.series == "0.2" {
|
||||
if want = []delivery.SeriesChange{{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}}; !reflect.DeepEqual(v.Series, want) {
|
||||
t.Errorf("%s: the verdict's series %+v, want %+v", row.name, v.Series, want)
|
||||
}
|
||||
} else if len(v.Series) != 0 {
|
||||
t.Errorf("%s: the verdict's series %+v, want none", row.name, v.Series)
|
||||
}
|
||||
v := b.judge(t, caseOf(t, p)).Verdict
|
||||
if len(judged) != 1 || judged[0].Fetch["repository"] != DeliveryRepository || judged[0].Fetch["commit"] != trunk ||
|
||||
judged[0].Package != JudgePackage || judged[0].Dir != JudgeDir {
|
||||
t.Fatalf("%s: judged %+v (verdict %+v)", row.name, judged, v)
|
||||
@@ -1188,10 +1057,6 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
req.Cases[0].Manifests["mail"].Digest != strings.Repeat("a", 64) {
|
||||
t.Fatalf("%s: the request %+v: %v", row.name, req, err)
|
||||
}
|
||||
if !reflect.DeepEqual(req.Packages, row.known) || len(req.Input) != 0 && string(req.Input) != "null" {
|
||||
t.Errorf("%s: the judge's known packages %v and input %s, want the known packages %v and no input", row.name,
|
||||
req.Packages, req.Input, row.known)
|
||||
}
|
||||
switch {
|
||||
case row.fails == "" && v.State != delivery.VerdictPass:
|
||||
t.Errorf("%s: %+v", row.name, v)
|
||||
@@ -1205,25 +1070,3 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
t.Error("which merge changes the judge")
|
||||
}
|
||||
}
|
||||
|
||||
// knownNames are the packages state's names as a test gives them, each with the <module>/<artifact> it is built from.
|
||||
type knownNames map[string]string
|
||||
|
||||
func (k knownNames) PackageNames() map[string]string { return k }
|
||||
|
||||
// fakeRenames is the seat mesh-config's verb renames as its definition answers it.
|
||||
type fakeRenames struct {
|
||||
answer meshconfig.RenamesAnswer
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeRenames) Renames(context.Context, string) (meshconfig.RenamesAnswer, error) {
|
||||
return f.answer, f.err
|
||||
}
|
||||
|
||||
// fakeSeatDefinitions are the indexer's unparsed seat definitions by pull request (pullKey).
|
||||
type fakeSeatDefinitions map[string][]UnparsedSeat
|
||||
|
||||
func (f fakeSeatDefinitions) Unparsed(_ context.Context, repository string, pull int) ([]UnparsedSeat, error) {
|
||||
return f[fmt.Sprintf("%s#%d", repository, pull)], nil
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each
|
||||
// wait for the other for good, through their bases, the provisions and bus state they await, or the verb edges the graph
|
||||
// keeps (ADR 0614 §6). A verb edge on a cycle is left out of the graph, so it is never refused (§3), and a package or a
|
||||
// build module's edge is a trigger, never a wait, so a cycle through one is not refused either (§6). The graph is worked out from the
|
||||
// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's),
|
||||
// with the contract's ManifestGraph, the way the indexer works out its graph's edges. A cycle the trunk already has among
|
||||
// the same modules is the trunk's: said in the verdict's notes, not the change's failure.
|
||||
|
||||
// ManifestReader reads a kept manifest by its digest (the indexer's manifests state).
|
||||
type ManifestReader interface {
|
||||
Manifest(ctx context.Context, digest string) (catalogue.Manifest, error)
|
||||
}
|
||||
|
||||
// refuseCycle fails the check for a cycle of the change's module graph, naming it; nil when the case touches no manifest,
|
||||
// when nothing reads manifests, or when there is none. A manifest that cannot be read fails the check, said, never passes it.
|
||||
func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict {
|
||||
if st.j.Manifests == nil || st.j.Read == nil || st.c.Try != nil {
|
||||
return nil
|
||||
}
|
||||
trunk := st.j.Manifests.ManifestDigests()
|
||||
req, touched := judgeRequest(trunk, st.c.Pulls)
|
||||
if len(touched) == 0 {
|
||||
return nil
|
||||
}
|
||||
read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) {
|
||||
out := map[string]catalogue.Manifest{}
|
||||
for _, module := range slices.Sorted(maps.Keys(digests)) {
|
||||
m, err := st.j.Read.Manifest(ctx, digests[module].Digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the manifest %s of %s: %w", digests[module].Digest, module, err)
|
||||
}
|
||||
out[module] = m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
change, err := read(req.Cases[0].Manifests)
|
||||
if err != nil {
|
||||
return fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error())
|
||||
}
|
||||
cycle := graph.OfManifests(change).Cycle()
|
||||
if cycle == nil {
|
||||
st.c.note("the change's module graph has no cycle")
|
||||
return nil
|
||||
}
|
||||
words := strings.Join(cycle, " -> ")
|
||||
if before, err := read(req.Cases[1].Manifests); err == nil {
|
||||
among := graph.Graph{}
|
||||
for name, n := range graph.OfManifests(before) {
|
||||
if slices.Contains(cycle, name) {
|
||||
among[name] = n
|
||||
}
|
||||
}
|
||||
if among.Cycle() != nil {
|
||||
st.c.note("the module graph has the cycle " + words + ", which the trunk already has among the same modules (ADR 0603 §2)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fail(goModule, fmt.Sprintf("the change's module graph has the cycle %s: these modules would each wait for the other for "+
|
||||
"good, through their bases, the provisions and bus state they await, or the verbs they invoke (ADR 0603 §2, ADR 0614 §6)", words))
|
||||
}
|
||||
@@ -99,32 +99,11 @@ type Examiner struct {
|
||||
Published interface {
|
||||
LatestPackages() delivery.PackageVersions
|
||||
}
|
||||
// KnownPackages names the packages the indexer's packages state knows (its series keys, delivery.SeriesPackage), by the
|
||||
// name in its own ecosystem, each with the `<module>/<artifact>` it is built from: the names a manifest's `packages` is
|
||||
// judged against (manifests_judge.go, design 32). Nil, or PackageNames answering nil while the state is not read yet,
|
||||
// hands the judge no names, and it judges none; an empty map is a state read and empty, against which every name fails.
|
||||
KnownPackages interface {
|
||||
PackageNames() map[string]string
|
||||
}
|
||||
// Builds says how the build of a fingerprint the candidate carries stands (the builder's builds bucket); nil says nothing
|
||||
// of the builds.
|
||||
Builds BuildStates
|
||||
// BuildEnds waits on the builder's builds of the candidate before the check composes (ADR 0603 §10); nil composes
|
||||
// without waiting, said in the composition's warnings.
|
||||
BuildEnds BuildEnds
|
||||
// BuildWait is how long the check waits for those builds (the setting build-wait-bound); DefaultBuildWait when 0.
|
||||
BuildWait time.Duration
|
||||
// Announce says a preview was written under the key (EventPreviewWritten); nil says nothing.
|
||||
Announce Announcer
|
||||
// Tags says a group's tag-wanted and hears its packages' published (design 51 *Tagged versions*); nil asks none.
|
||||
Tags TagAsks
|
||||
// PublishBound is how long a group's check waits for each package's published; DefaultPublishBound when 0.
|
||||
PublishBound time.Duration
|
||||
// Dirs names a module's directory in its repository, from the indexer's modules, so a manifest is read at a member's
|
||||
// head (tagwanted.go); nil, or a module it does not know, tries modules/<module> and the repository's root.
|
||||
Dirs interface {
|
||||
ModuleDir(module string) (string, bool)
|
||||
}
|
||||
// Packages names, of a repository, the paths (repository-relative) its TypeScript package artifacts are built from, from
|
||||
// the indexer's modules (delivery.Module.ArtifactPaths, lead decision L8); nil serves no group package.
|
||||
Packages interface {
|
||||
@@ -135,20 +114,6 @@ type Examiner struct {
|
||||
// Manifests are the indexer's manifest digests by module: the catalogue a change's manifests are judged in by the
|
||||
// indexer's judge (manifests_judge.go, novox/hq ADR 0578 §2); nil judges no manifest.
|
||||
Manifests TrunkManifests
|
||||
// SeatDefinitions reads the indexer's seat definitions of a pull request (seatdefs.go, design 26); nil reads none.
|
||||
SeatDefinitions SeatDefinitions
|
||||
// Renames asks the seat mesh-config's verb renames for a change that brings or drops a `renamed-from` (renames.go,
|
||||
// design 32); nil fails such a change, saying it cannot ask.
|
||||
Renames Renames
|
||||
// Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq
|
||||
// ADR 0603 §2); nil works out no graph.
|
||||
Read ManifestReader
|
||||
// Serving says, from what runs, whether a running healthy build serves a seat verb or module tool (novox/hq ADR 0614
|
||||
// §7): the node-engines' haves, the builder's build of each, its manifest (graph.Served). Nil reads nothing, and no
|
||||
// changed build module is named for a verb it invokes.
|
||||
Serving interface {
|
||||
Served(ctx context.Context) (func(invoke string) bool, error)
|
||||
}
|
||||
// Predefined reads the catalogue's predefined keys the indexer keeps (manifests_judge.go); nil reads none.
|
||||
Predefined PredefinedList
|
||||
// Compositions makes a check's composition (ADR 0593 §7): the engines' answers (EngineCompositions); nil leaves the
|
||||
@@ -267,10 +232,6 @@ type state struct {
|
||||
// unpreviewed are the members (pullKey) with no preview of a merge: a head that does not merge, or a fetch that did not
|
||||
// answer. The indexer holds no manifest of them, so the assigner's composition would only wait: it is not awaited.
|
||||
unpreviewed []string
|
||||
// tags are the group's tagged versions its members are built against (ADR 0607 §3), once the check heard them.
|
||||
tags groupVersions
|
||||
// outOfStep are, by pull request (pullKey), the generated files a level's compare step found out of step (design 26).
|
||||
outOfStep map[string][]string
|
||||
}
|
||||
|
||||
// Judge runs the case (it is the Judge of a Checker). A failure of the change, of a merge or of a step is a verdict; an error is only a case that cannot
|
||||
@@ -281,8 +242,7 @@ func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) {
|
||||
// Every member and level runs to its end, and every manifest the change touches is judged by the indexer's judge (ADR
|
||||
// 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02).
|
||||
tests := st.judgeAll(ctx)
|
||||
st.seriesChanges(ctx) // DLV-C4: named in the verdict's line, whatever the verdict
|
||||
if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx), st.judgeRenames(ctx), st.refuseUnparsed(ctx))); v != nil {
|
||||
if v := settled(joinFailures(tests, st.judgeManifests(ctx))); v != nil {
|
||||
return st.finish(ctx, *v), nil
|
||||
}
|
||||
return st.finish(ctx, delivery.Verdict{State: delivery.VerdictPass}), nil
|
||||
@@ -317,7 +277,6 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
|
||||
strings.Join(st.unpreviewed, ", ") + " (its head does not merge or its fetch did not answer), so the change cannot be composed"}
|
||||
return
|
||||
}
|
||||
words := st.awaitBuilds(ctx, v) // ADR 0603 §10: the check composes with the candidate's builds
|
||||
comp, err := st.j.Compositions.Compose(ctx, st.c)
|
||||
if err != nil {
|
||||
// Said in the verdict, never left as a composition not yet run (review finding 4).
|
||||
@@ -325,7 +284,6 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
|
||||
v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: "not made: " + err.Error()}
|
||||
return
|
||||
}
|
||||
comp.Warnings = append(comp.Warnings, words...) // ADR 0603 §10
|
||||
withComposition(v, comp)
|
||||
if v.Composition != nil && v.Composition.State != delivery.CompositionNotRun {
|
||||
v.Composition.FPSet = st.c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5)
|
||||
@@ -334,7 +292,6 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
|
||||
|
||||
// outcome fills the verdict's levels, times, machines and composition from what ran.
|
||||
func (st *state) outcome(v delivery.Verdict) Outcome {
|
||||
v.Series = st.c.allSeries() // the series changes, as structures (A2j finding 2)
|
||||
if v.State == delivery.VerdictPass || len(v.Levels) == 0 {
|
||||
v.Levels = map[string]delivery.LevelResult{}
|
||||
for level, a := range st.levels {
|
||||
@@ -369,7 +326,7 @@ func (st *state) outcome(v delivery.Verdict) Outcome {
|
||||
slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed))
|
||||
}
|
||||
}
|
||||
out := Outcome{Verdict: v, Slowest: slow, OutOfStep: st.outOfStep}
|
||||
out := Outcome{Verdict: v, Slowest: slow}
|
||||
if len(st.beside) > 0 {
|
||||
out.Beside = st.beside
|
||||
}
|
||||
@@ -384,20 +341,6 @@ func (st *state) outcome(v delivery.Verdict) Outcome {
|
||||
return out
|
||||
}
|
||||
|
||||
// stepOutOfStep keeps the out-of-step files a step of the pull request named, each once, sorted.
|
||||
func (st *state) stepOutOfStep(p Pull, files []string) {
|
||||
if len(files) == 0 {
|
||||
return
|
||||
}
|
||||
if st.outOfStep == nil {
|
||||
st.outOfStep = map[string][]string{}
|
||||
}
|
||||
k := pullKey(Pull{Repository: p.Repository, Number: p.Number})
|
||||
all := append(st.outOfStep[k], files...)
|
||||
slices.Sort(all)
|
||||
st.outOfStep[k] = slices.Compact(all)
|
||||
}
|
||||
|
||||
// fail is a verdict of failure at a machine.
|
||||
func fail(machine, words string) *delivery.Verdict {
|
||||
v := failure(machine, words)
|
||||
@@ -429,11 +372,6 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
|
||||
}
|
||||
return 0
|
||||
})
|
||||
// A group's members are built only after each package they declare is published as the group's tagged version
|
||||
// (design 51 *Tagged versions*): a package not published within the bound fails the check before any member is built.
|
||||
if v := st.tagged(ctx); v != nil {
|
||||
return v
|
||||
}
|
||||
var groupPackage map[string]any
|
||||
var failures []*delivery.Verdict
|
||||
for _, p := range pulls {
|
||||
@@ -452,12 +390,8 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
|
||||
t.agreed = &delivery.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe}
|
||||
}
|
||||
args := map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number), "base": p.Trunk}
|
||||
if bm == buildrun.NodeBuildModule {
|
||||
if np, ok := st.tags.nodePackageFor(p.Repository); ok {
|
||||
args["package"] = map[string]any{"name": np.Name, "version": np.Version} // the group's tagged version (ADR 0607 §3)
|
||||
} else if groupPackage != nil && p.Repository != SDKRepository {
|
||||
args["package"] = groupPackage
|
||||
}
|
||||
if bm == buildrun.NodeBuildModule && groupPackage != nil && p.Repository != SDKRepository {
|
||||
args["package"] = groupPackage
|
||||
}
|
||||
if bm == buildrun.GoBuildModule {
|
||||
beside, err := st.j.besideFor(ctx, st.c, p)
|
||||
@@ -490,10 +424,7 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
|
||||
st.unpreviewed = append(st.unpreviewed, pullKey(p))
|
||||
}
|
||||
st.judgedByMember(p, merged)
|
||||
// The old form (ADR 0500 §5): the group's package member packed by the Node fetch, kept only while no tagged
|
||||
// TypeScript version is heard, the form the running checker hands while the new build modules are delivered. Its
|
||||
// removal is a subtask of the milestone after this one's delivery (issue 1966).
|
||||
if p.Repository == SDKRepository && len(st.c.Pulls) > 1 && len(st.tags.node) == 0 && st.j.changesPackage(p, merged) {
|
||||
if p.Repository == SDKRepository && len(st.c.Pulls) > 1 && st.j.changesPackage(p, merged) {
|
||||
groupPackage = map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number)}
|
||||
}
|
||||
}
|
||||
@@ -745,7 +676,6 @@ func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer)
|
||||
if !res.Passed {
|
||||
failedResults = append(failedResults, res)
|
||||
}
|
||||
st.stepOutOfStep(t.pull, res.OutOfStep)
|
||||
}
|
||||
if len(failedResults) > 0 {
|
||||
return levelFailure(t, level, failedResults...), elapsed
|
||||
|
||||
@@ -117,20 +117,18 @@ type pieces struct {
|
||||
carried string
|
||||
// warning says that a part of the check did not run, for instance that an older build module could not refuse a compiled program; never dropped.
|
||||
warning string
|
||||
// series names each change of a package series the pull request makes (design 32, DLV-C4); never dropped.
|
||||
series string
|
||||
}
|
||||
|
||||
// pullKey names a pull request of a case: `<owner/repo>#<number>`.
|
||||
func pullKey(p Pull) string { return fmt.Sprintf("%s#%d", p.Repository, p.Number) }
|
||||
|
||||
func (p pieces) join(drop int) string {
|
||||
parts := []string{p.head, p.commits, p.carried, p.warning, p.counts, p.judged, p.series}
|
||||
parts := []string{p.head, p.commits, p.carried, p.warning, p.counts, p.judged}
|
||||
switch {
|
||||
case drop < 3 && p.times != "":
|
||||
parts = append(parts, p.times)
|
||||
case drop == 3 && p.shortTimes != "":
|
||||
parts = []string{p.head, p.commits, p.carried, p.warning, p.shortCounts, p.judged, p.series, p.shortTimes}
|
||||
parts = []string{p.head, p.commits, p.carried, p.warning, p.shortCounts, p.judged, p.shortTimes}
|
||||
}
|
||||
if p.over != "" {
|
||||
parts = append(parts, p.over)
|
||||
@@ -246,7 +244,6 @@ func carriedWords(from map[string]string, v delivery.Verdict, cs *Case, p Pull)
|
||||
func parts(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) pieces {
|
||||
var pc pieces
|
||||
pc.judged = judged[pullKey(p)]
|
||||
pc.series = seriesWords(cs, p)
|
||||
if cs != nil {
|
||||
pc.warning = cs.Warning(pullKey(p))
|
||||
}
|
||||
@@ -359,7 +356,7 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
|
||||
pc := parts(v, cs, p, builds, slowest, judged)
|
||||
if v.State == delivery.VerdictFail {
|
||||
// The head gives way first to the commits and what is never dropped: its count and "and N more" stay whole.
|
||||
kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged, pc.series)
|
||||
kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged)
|
||||
pc.head = failedHead(v, MaxLine-utf8.RuneCountInString(" | "+kept))
|
||||
}
|
||||
for drop := 0; drop <= 4; drop++ {
|
||||
@@ -367,13 +364,13 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
|
||||
return s
|
||||
}
|
||||
}
|
||||
if pc.judged != "" || pc.series != "" {
|
||||
if pc.judged != "" {
|
||||
for _, counts := range []string{pc.shortCounts, ""} {
|
||||
if s := joinSet(pc.head, pc.commits, counts, pc.judged, pc.series); utf8.RuneCountInString(s) <= MaxLine {
|
||||
if s := joinSet(pc.head, pc.commits, counts, pc.judged); utf8.RuneCountInString(s) <= MaxLine {
|
||||
return s
|
||||
}
|
||||
}
|
||||
rest := " | " + joinSet(pc.commits, pc.judged, pc.series)
|
||||
rest := " | " + joinSet(pc.commits, pc.judged)
|
||||
room := MaxLine - utf8.RuneCountInString(rest) - 1
|
||||
if head := []rune(pc.head); room > 0 && len(head) > room {
|
||||
return string(head[:room]) + "…" + rest
|
||||
@@ -381,7 +378,7 @@ func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []st
|
||||
}
|
||||
if v.State == delivery.VerdictFail {
|
||||
// A failure's head, sized above, and the commits fit: the counts and the composition are in the report.
|
||||
if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged, pc.series); utf8.RuneCountInString(s) <= MaxLine {
|
||||
if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged); utf8.RuneCountInString(s) <= MaxLine {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package check
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
@@ -13,8 +12,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/packagename"
|
||||
)
|
||||
|
||||
// A change's manifests are judged by the indexer's judge (novox/hq ADR 0578 §2): the checker asks the Go build seat to
|
||||
@@ -123,7 +120,6 @@ func (st *state) judgeManifests(ctx context.Context) *delivery.Verdict {
|
||||
req.Predefined = list
|
||||
st.c.note("the manifests are judged against the predefined keys of " + from)
|
||||
}
|
||||
req.Packages = st.knownPackages(ctx)
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fail(goModule, err.Error())
|
||||
@@ -261,64 +257,3 @@ func (st *state) judgedByMember(p Pull, merged []Fetched) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// knownPackages are the packages a manifest's `packages` may name, sorted (DLV-I14, design 32): every name the indexer's
|
||||
// packages state holds, and, of each package a member of the case declares that the state does not know by its
|
||||
// `<module>/<artifact>`, the name read at that member's head through the forge (packagename.Of, the indexer's own reader
|
||||
// at a merge). A name that cannot be read is said and left out, so a manifest naming it is refused naming it. Nil, which
|
||||
// judges no name, when the checker follows no packages state or has not read it yet (PackageNames nil); an answered
|
||||
// state that holds no name is an empty list, never nil, so every name is judged against it.
|
||||
func (st *state) knownPackages(ctx context.Context) []string {
|
||||
if st.j.KnownPackages == nil {
|
||||
return nil
|
||||
}
|
||||
names := st.j.KnownPackages.PackageNames()
|
||||
if names == nil {
|
||||
return nil
|
||||
}
|
||||
built := map[string]bool{}
|
||||
for _, b := range names {
|
||||
built[b] = true
|
||||
}
|
||||
known := make([]string, 0, len(names))
|
||||
known = append(known, slices.Collect(maps.Keys(names))...)
|
||||
declared, err := st.declaredPackages(ctx)
|
||||
if err != nil {
|
||||
st.c.note("the packages the group's members declare are not known, so only the packages state's names are known to the judge: " + err.Error())
|
||||
}
|
||||
for _, d := range declared {
|
||||
if built[d.module+"/"+d.name] {
|
||||
continue
|
||||
}
|
||||
if st.j.Forge == nil {
|
||||
st.c.note(fmt.Sprintf("the name of the package %s/%s new in the group is not read: this checker reads no forge", d.module, d.name))
|
||||
continue
|
||||
}
|
||||
read := func(ctx context.Context, repository, commit, file string) ([]byte, error) {
|
||||
text, err := st.j.Forge.File(ctx, repository, file, commit)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(text), err
|
||||
}
|
||||
dirs := []string{d.dir}
|
||||
if d.dir == "" {
|
||||
dirs = []string{"modules/" + d.module, delivery.RootPath}
|
||||
}
|
||||
var why []string
|
||||
for _, dir := range dirs {
|
||||
a := catalogue.Artifact{Name: d.name, Kind: catalogue.ArtifactPackage, Language: d.language, Paths: d.paths}
|
||||
name, err := packagename.Of(ctx, read, d.pull.Repository, d.pull.Head, dir, a)
|
||||
if err == nil {
|
||||
known, why = append(known, name), nil
|
||||
break
|
||||
}
|
||||
why = append(why, err.Error())
|
||||
}
|
||||
if len(why) > 0 {
|
||||
st.c.note(fmt.Sprintf("the name of the package %s/%s new in the group is not read at the head of %s: %s", d.module, d.name, pullKey(d.pull), strings.Join(why, "; ")))
|
||||
}
|
||||
}
|
||||
slices.Sort(known)
|
||||
return slices.Compact(known)
|
||||
}
|
||||
|
||||
@@ -31,12 +31,10 @@ type Forge interface {
|
||||
File(ctx context.Context, repository, path, ref string) (string, error)
|
||||
}
|
||||
|
||||
// packagesOf is the version of each mesh package a Go check of repository builds against (ADR 0573 §2, §4, as ADR 0607
|
||||
// §5 changes §4): the latest published version of each package the mesh's modules use (Published); for a package a
|
||||
// member of the case publishes, the group's tagged version of it once the check heard it (design 51 *Tagged versions*,
|
||||
// also a package new in the group, which has no published version yet), else that member's head (mesh-sdk's Go module
|
||||
// whenever mesh-sdk is a member). A package of repository itself comes with the tree the check fetched, and is left out.
|
||||
// Nil for none.
|
||||
// packagesOf is the version of each mesh package a Go check of repository builds against (ADR 0573 §2, §4): the latest
|
||||
// published version of each package the mesh's modules use (Published), and for a package one of the case's pull
|
||||
// requests publishes, that pull request's head (mesh-sdk's Go module whenever mesh-sdk is a member). A package of
|
||||
// repository itself comes with the tree the check fetched, and is left out. Nil for none.
|
||||
func (st *state) packagesOf(repository string) delivery.PackageVersions {
|
||||
versions := delivery.PackageVersions{}
|
||||
if st.j.Published != nil {
|
||||
@@ -47,18 +45,13 @@ func (st *state) packagesOf(repository string) delivery.PackageVersions {
|
||||
versions[SDKModulePath] = ""
|
||||
}
|
||||
}
|
||||
for pkg := range st.tags.goPackages {
|
||||
versions[pkg] = ""
|
||||
}
|
||||
for pkg := range versions {
|
||||
repo, _, ok := delivery.PackageRepository(pkg)
|
||||
if !ok || delivery.SameRepository(repo, repository) {
|
||||
delete(versions, pkg)
|
||||
continue
|
||||
}
|
||||
if tagged := st.tags.goPackages[pkg]; tagged != "" {
|
||||
versions[pkg] = tagged
|
||||
} else if m := st.c.member(repo); m != nil && len(m.Head) == 40 {
|
||||
if m := st.c.member(repo); m != nil && len(m.Head) == 40 {
|
||||
versions[pkg] = m.Head
|
||||
}
|
||||
if versions[pkg] == "" {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user