BL18: build modules found through their build seats' holders on the seat list

The builder finds a build module as the indexer's seed does: the build seat whose acting rule
`build` has an `on` matching the artifact's language or kind:image, and that seat's holder, read
from the seat list's definitions and the claims of the catalog half's manifests. Task.Module, every
lane key, the unpublished modules, the toolchain sources, the ends watched, the sayer and
builder_stuck (through `<seat>.steps`) derive from it; the floor's sources are the holders of the
seats git and mesh-catalog. buildrun.GoBuildModule, NodeBuildModule, LanguageOf,
buildstep.BuildModuleOf use, build.ImageBuildModule and StuckBuildModules are gone from the builder
(the checker keeps its own names in two small files until its part reads them from the list).

Builds: design 47 G §7, §428, §704, §204, A-82
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 00:04:34 +01:00
parent 8ea78f709a
commit 84bcdb687a
60 changed files with 903 additions and 274 deletions
@@ -36,6 +36,8 @@ type AskWaiter interface {
type Asker struct {
Publisher AskPublisher
Waiter AskWaiter
// Seats finds the build module of an ask's language (design 47 G A-82).
Seats *BuildSeats
// Bound is how long a running step (a build node took the ask, its key written running) may stand still, with no new phase,
// before the asker stops waiting; a step that keeps moving is waited for up to 3 Bounds from its start (issue 1930); zero waits as long as ctx. An ask waiting in its stream is not bounded: the stream's backlog is no fault
// (ADR 0525 §6, §9), and the bound starts again when another node takes the ask.
@@ -142,10 +144,11 @@ func (k Asker) Put(ctx context.Context, a buildstep.StepAsk) error {
// putting them twice) and answers it when it ended, whatever its state. The bound runs from the step's start on the node
// that took the ask, read from its running key, so an ask that waits in the stream for a free slot waits as long as ctx.
func (k Asker) Wait(ctx context.Context, a buildstep.StepAsk) (buildstep.Step, error) {
module, err := buildstep.BuildModuleOf(a.Language)
bm, err := k.Seats.OfAskLanguage(a.Language)
if err != nil {
return buildstep.Step{}, err
}
module := bm.Module
watch, cancel := context.WithCancel(ctx)
defer cancel()
steps, err := k.Waiter.WatchAsk(watch, module, a.ID)
@@ -34,7 +34,7 @@ func TestAnAskIsPutOnceAndItsStepIsRead(t *testing.T) {
t.Fatal(err)
}
bus := JetStreamAsks{JS: js}
k := Asker{Publisher: bus, Waiter: bus, Bound: 5 * time.Second}
k := Asker{Seats: testSeats(), Publisher: bus, Waiter: bus, Bound: 5 * time.Second}
a := goAsk("mvl0frv18d1960")
for i := 0; i < 2; i++ {
if err := k.Put(ctx, a); err != nil {
@@ -31,7 +31,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
return buildstep.Step{ID: a.ID, Node: "ace", State: buildstep.StepPassed, Result: []byte(`{"artifacts":[]}`)}
}}
subjects := &subjectsOf{inner: f}
k := Asker{Publisher: subjects, Waiter: f}
k := Asker{Seats: testSeats(), Publisher: subjects, Waiter: f}
var seen, also []buildstep.Step // issue 1890: a waiter that asks to see each value of the step sees it
// Issue 1921: a second seen on the same context (a check's begin bound inside a try) is added, never in place of the first.
both := WithStepSeen(WithStepSeen(context.Background(), func(s buildstep.Step) { seen = append(seen, s) }),
@@ -68,7 +68,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
if c.transport {
restore = busretry.Waits(20*time.Millisecond, time.Millisecond, time.Millisecond) // a bound this row passes
}
err := (Asker{Publisher: p, Waiter: p.inner}).Put(context.Background(), goAsk("mvl0frv18d1960"))
err := (Asker{Seats: testSeats(), Publisher: p, Waiter: p.inner}).Put(context.Background(), goAsk("mvl0frv18d1960"))
restore()
switch {
case c.transport:
@@ -94,7 +94,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
for _, st := range order {
h.Write(st)
}
if got, err := (Asker{Publisher: h, Waiter: h, Bound: time.Minute}).Wait(context.Background(), goAsk("mvl0frv18d1971")); err != nil || got.State != buildstep.StepPassed {
if got, err := (Asker{Seats: testSeats(), Publisher: h, Waiter: h, Bound: time.Minute}).Wait(context.Background(), goAsk("mvl0frv18d1971")); err != nil || got.State != buildstep.StepPassed {
t.Errorf("%s: %+v, %v", name, got, err)
}
}
@@ -106,12 +106,12 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
}
h.Write(st)
}
if _, err := (Asker{Publisher: h, Waiter: h, Bound: time.Minute}).Ask(context.Background(), goAsk("mvl0frv18d1972")); err == nil || !strings.Contains(err.Error(), "a transport fault on 3 runs (ace, g14, shanks): lookup proxy: i/o timeout") {
if _, err := (Asker{Seats: testSeats(), Publisher: h, Waiter: h, Bound: time.Minute}).Ask(context.Background(), goAsk("mvl0frv18d1972")); err == nil || !strings.Contains(err.Error(), "a transport fault on 3 runs (ace, g14, shanks): lookup proxy: i/o timeout") {
t.Errorf("three runs then failed: %v", err)
}
h = &FakeAsks{}
h.Write(buildstep.Step{ID: "mvl0frv18d1973", Node: "ace", State: buildstep.StepAgain, Run: 1, AgainAt: time.Now(), Started: time.Now()})
if _, err := (Asker{Publisher: h, Waiter: h, Bound: 20 * time.Millisecond}).Wait(context.Background(), goAsk("mvl0frv18d1973")); err == nil || !strings.Contains(err.Error(), "given again after a transport fault") {
if _, err := (Asker{Seats: testSeats(), Publisher: h, Waiter: h, Bound: 20 * time.Millisecond}).Wait(context.Background(), goAsk("mvl0frv18d1973")); err == nil || !strings.Contains(err.Error(), "given again after a transport fault") {
t.Errorf("an ask never given again: %v", err)
}
// ADR 0664 §5: that ask is dropped; a wait ended by a named cause is dropped too, an ask no node took included (it waits in
@@ -119,19 +119,19 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
drops := &seatDrops{}
h = &FakeAsks{}
h.Write(buildstep.Step{ID: "mvl0frv18d1974", Node: "ace", State: buildstep.StepAgain, Run: 1, AgainAt: time.Now(), Started: time.Now()})
(Asker{Publisher: h, Waiter: h, Bound: 20 * time.Millisecond, Dropper: drops}).Wait(context.Background(), goAsk("mvl0frv18d1974"))
(Asker{Seats: testSeats(), Publisher: h, Waiter: h, Bound: 20 * time.Millisecond, Dropper: drops}).Wait(context.Background(), goAsk("mvl0frv18d1974"))
named, stop := context.WithCancelCause(context.Background())
h.Write(buildstep.Step{ID: "mvl0frv18d1975", Node: "g14", State: buildstep.StepRunning, Started: time.Now()})
seenStep := WithStepSeen(named, func(buildstep.Step) { stop(Withdrawn{Why: "superseded by a newer fingerprint"}) })
(Asker{Publisher: h, Waiter: h, Bound: time.Hour, Dropper: drops}).Wait(seenStep, goAsk("mvl0frv18d1975"))
(Asker{Seats: testSeats(), Publisher: h, Waiter: h, Bound: time.Hour, Dropper: drops}).Wait(seenStep, goAsk("mvl0frv18d1975"))
shut, off := context.WithCancel(context.Background())
off()
(Asker{Publisher: h, Waiter: h, Bound: time.Hour, Dropper: drops}).Wait(shut, goAsk("mvl0frv18d1977"))
(Asker{Publisher: &FakeAsks{}, Waiter: &FakeAsks{}, Bound: time.Hour, Dropper: drops}).Wait(named, goAsk("mvl0frv18d1976"))
(Asker{Seats: testSeats(), Publisher: h, Waiter: h, Bound: time.Hour, Dropper: drops}).Wait(shut, goAsk("mvl0frv18d1977"))
(Asker{Seats: testSeats(), Publisher: &FakeAsks{}, Waiter: &FakeAsks{}, Bound: time.Hour, Dropper: drops}).Wait(named, goAsk("mvl0frv18d1976"))
// Review 1 (finding 1): a cause that is no withdrawal (the builder's check bound passing, which waits on) drops nothing.
bounded, passed := context.WithCancelCause(context.Background())
passed(errors.New("the check bound passed"))
(Asker{Publisher: &FakeAsks{}, Waiter: &FakeAsks{}, Bound: time.Hour, Dropper: drops}).Wait(bounded, goAsk("mvl0frv18d1978"))
(Asker{Seats: testSeats(), Publisher: &FakeAsks{}, Waiter: &FakeAsks{}, Bound: time.Hour, Dropper: drops}).Wait(bounded, goAsk("mvl0frv18d1978"))
if got := strings.Join(drops.got, "|"); !strings.Contains(got, "mvl0frv18d1974") || !strings.Contains(got, "mvl0frv18d1975 superseded by a newer fingerprint") || !strings.Contains(got, "mvl0frv18d1976 superseded by a newer fingerprint") || len(drops.got) != 3 {
t.Errorf("drops %q, want the ask never given again, the superseded one and the one no node took, and no other", got)
}
@@ -139,20 +139,20 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
// how many runs; a drop that failed is said, not discarded.
late := &FakeAsks{}
late.Write(buildstep.Step{ID: "mvl0frv18d1979", Node: "shanks", State: buildstep.StepFailed, Passing: true, Run: 3, Reason: "lookup proxy: i/o timeout"})
if st, err := (Asker{Publisher: late, Waiter: late, Bound: time.Minute}).Wait(context.Background(), goAsk("mvl0frv18d1979")); err != nil || !strings.Contains(st.Reason, "a transport fault on 3 runs") {
if st, err := (Asker{Seats: testSeats(), Publisher: late, Waiter: late, Bound: time.Minute}).Wait(context.Background(), goAsk("mvl0frv18d1979")); err != nil || !strings.Contains(st.Reason, "a transport fault on 3 runs") {
t.Errorf("a failed step of run 3 read alone says %q (%v), want it names 3 runs", st.Reason, err)
}
var said []string
bad, off2 := context.WithCancelCause(context.Background())
off2(Withdrawn{Why: "withdrawn"})
(Asker{Publisher: &FakeAsks{}, Waiter: &FakeAsks{}, Bound: time.Hour, Dropper: failingDrops{}, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }}).Wait(bad, goAsk("mvl0frv18d1980"))
(Asker{Seats: testSeats(), Publisher: &FakeAsks{}, Waiter: &FakeAsks{}, Bound: time.Hour, Dropper: failingDrops{}, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }}).Wait(bad, goAsk("mvl0frv18d1980"))
if len(said) != 1 || !strings.Contains(said[0], "mvl0frv18d1980") || !strings.Contains(said[0], "no holder") {
t.Errorf("a drop that failed said %q, want it named with its cause", said)
}
// review round 2 item 1: an ask that waits in its stream for a free slot is not bounded; once a node writes its step
// running, the step is bounded from its start, and the error names the node.
g := &FakeAsks{}
k = Asker{Publisher: g, Waiter: g, Bound: 20 * time.Millisecond}
k = Asker{Seats: testSeats(), Publisher: g, Waiter: g, Bound: 20 * time.Millisecond}
waiting, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, err := k.Wait(waiting, goAsk("mvl0frv18d1959")); err != context.DeadlineExceeded {
@@ -170,7 +170,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
// the phase; one three bounds from its start ends, said as kept moving. The steps' own times stand for the clock, so the
// bound is an hour no loaded machine reaches and no case waits on a pause (the check of 2026-11-02 22:35 tripped one
// of 60 ms).
k = Asker{Publisher: g, Waiter: g, Bound: time.Hour}
k = Asker{Seats: testSeats(), Publisher: g, Waiter: g, Bound: time.Hour}
now := time.Now()
g.Write(buildstep.Step{ID: "mvl0frv18d1980", Node: "ace", State: buildstep.StepRunning, Started: now.Add(-90 * time.Minute), Phase: "pb", PhaseAt: now})
g.Write(buildstep.Step{ID: "mvl0frv18d1980", Node: "ace", State: buildstep.StepPassed})
@@ -190,7 +190,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
// first is answered as the node wrote it.
dead := &DeadLetters{}
lw := LostAskWaiter{Inner: g, Dead: dead}
k = Asker{Publisher: g, Waiter: lw, Bound: time.Minute}
k = Asker{Seats: testSeats(), Publisher: g, Waiter: lw, Bound: time.Minute}
g.Write(buildstep.Step{ID: "mvl0frv18d1970", Node: "ace", State: buildstep.StepRunning, Started: time.Now()})
given, ok := GivenUpOf(map[string][]string{"Mesh-Dead-Msg-Id": {"mvl0frv18d1970"}, "Mesh-Dead-Deliveries": {"5"},
"Mesh-Dead-Stream": {"SEAT_NODE_BUILD_GO"}, "Mesh-Dead-Gave-Up": {"2026-10-26T12:26:40Z"}})
@@ -225,7 +225,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
if why := hearing.Deaf(); !strings.Contains(why, "SEAT_NODE_BUILD_GO: permissions violation") {
t.Fatalf("a refused hearing says %q", why)
}
k = Asker{Publisher: g, Waiter: LostAskWaiter{Inner: g, Dead: dead, Hearing: hearing}, Bound: time.Minute}
k = Asker{Seats: testSeats(), Publisher: g, Waiter: LostAskWaiter{Inner: g, Dead: dead, Hearing: hearing}, Bound: time.Minute}
g.Write(buildstep.Step{ID: "mvl0frv18d1973", Node: "g14", State: buildstep.StepPassed})
if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1973")); err != nil {
t.Fatal(err)
@@ -245,25 +245,25 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
// ended) and answered; a nudge of another build module reads nothing; a watch that closes is opened again.
d := &FakeAsks{}
heard := &HeardEnds{Inner: deafWatch{}, Read: d}
k = Asker{Publisher: d, Waiter: heard, Bound: time.Minute}
k = Asker{Seats: testSeats(), Publisher: d, Waiter: heard, Bound: time.Minute}
d.Write(buildstep.Step{ID: "mvl0frv18d1980", Node: "shanks", State: buildstep.StepPassed})
got := make(chan buildstep.Step, 1)
go func() { s, _ := k.Wait(context.Background(), goAsk("mvl0frv18d1980")); got <- s }()
for heard.Waiting() == 0 {
time.Sleep(time.Millisecond)
}
heard.Nudge(NodeBuildModule)
heard.Nudge(testNode)
select {
case s := <-got:
t.Fatalf("a nudge of another build module answered %+v", s)
case <-time.After(20 * time.Millisecond):
}
heard.Nudge(GoBuildModule)
heard.Nudge(testGo)
if s := <-got; s.State != buildstep.StepPassed || s.Node != "shanks" {
t.Fatalf("a step its watch never handed over: %+v", s)
}
closing := &HeardEnds{Inner: &closingWatch{inner: d}, Read: d}
if s, err := (Asker{Publisher: d, Waiter: closing}).Wait(context.Background(), goAsk("mvl0frv18d1980")); err != nil || s.State != buildstep.StepPassed {
if s, err := (Asker{Seats: testSeats(), Publisher: d, Waiter: closing}).Wait(context.Background(), goAsk("mvl0frv18d1980")); err != nil || s.State != buildstep.StepPassed {
t.Fatalf("a watch that closed once: %+v, %v", s, err)
}
for _, row := range []struct {
@@ -287,7 +287,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
// bound is reached only by a wait that kept the first run's start.
func TestARedeliveryToTheSameNodeRestartsTheBound(t *testing.T) {
f := &FakeAsks{}
k := Asker{Publisher: f, Waiter: f, Bound: time.Hour}
k := Asker{Seats: testSeats(), Publisher: f, Waiter: f, Bound: time.Hour}
const id = "mvl0frv18d1962"
now := time.Now()
f.Write(buildstep.Step{ID: id, Node: "ace", State: buildstep.StepRunning, Started: now})
@@ -0,0 +1,166 @@
package buildrun
import (
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// **The builder names no build module** (design 47 G §7, §428, §704; A-82): a build module is the holder of the build seat
// whose acting rule `build` has an `on` matching an artifact's language or `kind:image`, found on the seat list (the seat
// definitions) and the catalog half (the manifest that claims the seat), as the indexer's seed finds it. Every task's module and
// every lane's key is that holder's name as the seat list and the catalog half give it.
// BuildModule is one build seat and the module that holds it.
type BuildModule struct {
Seat string // the seat whose acting rule builds
Module string // the module holding the seat, empty while the catalog half names none
// Toolchain is the language of the toolchain the rule's `on` names, empty for a rule on `kind:image` (no toolchain).
Toolchain string
// ByKind is whether the rule is on `kind:image`: its artifacts have no language and its nodes come from the node-engine's have.
ByKind bool
}
// AskLanguage is the language of the seat's asks (sdkstep.AskSubject): the seat is named node-build-<language>.
func (m BuildModule) AskLanguage() string { return strings.TrimPrefix(m.Seat, "node-build-") }
// BuildSeats finds the build modules from the seat definitions and the holders of seats, both read at each call.
type BuildSeats struct {
// Defs are the seat definitions of the seat list; Holder is a seat's holder on the catalog half.
defs func() map[string]seatdef.Definition
holder func(seat string) (string, bool)
}
// NewBuildSeats is the finder over defs (the seat list's definitions, nil before it is read) and holder.
func NewBuildSeats(defs func() map[string]seatdef.Definition, holder func(seat string) (string, bool)) *BuildSeats {
return &BuildSeats{defs: defs, holder: holder}
}
func (s *BuildSeats) all() []BuildModule {
if s == nil || s.defs == nil {
return nil
}
defs := s.defs()
var out []BuildModule
for _, seat := range slices.Sorted(maps.Keys(defs)) {
for _, r := range defs[seat].Acts {
if r.Step != seatdef.StepBuild || r.Acting() == seatdef.OnEvery {
continue
}
m := BuildModule{Seat: seat, ByKind: r.On == seatdef.OnKindImage}
if !m.ByKind {
m.Toolchain = r.Acting()
}
if s.holder != nil {
m.Module, _ = s.holder(seat)
}
out = append(out, m)
break
}
}
return out
}
// All are the build seats with the module holding each, by seat name; a seat no catalog entry claims has no module.
func (s *BuildSeats) All() []BuildModule { return s.all() }
// Modules are the holders of the build seats, sorted, none that is unnamed.
func (s *BuildSeats) Modules() []string {
var out []string
for _, m := range s.all() {
if m.Module != "" {
out = append(out, m.Module)
}
}
slices.Sort(out)
return slices.Compact(out)
}
// OfModule is the build seat held by module.
func (s *BuildSeats) OfModule(module string) (BuildModule, bool) {
for _, m := range s.all() {
if m.Module == module && module != "" {
return m, true
}
}
return BuildModule{}, false
}
// OfSeat is the build seat named seat.
func (s *BuildSeats) OfSeat(seat string) (BuildModule, bool) {
for _, m := range s.all() {
if m.Seat == seat {
return m, true
}
}
return BuildModule{}, false
}
// For is the build module of an artifact: the build seat whose rule's `on` is language, or `kind:image` when the artifact's
// kind is image. None, or more than one, or a seat no entry claims, is the error.
func (s *BuildSeats) For(language, kind string) (BuildModule, error) {
var found []BuildModule
for _, m := range s.all() {
if (kind == "image" && m.ByKind) || (!m.ByKind && language != "" && strings.EqualFold(m.Toolchain, strings.TrimSpace(language))) {
found = append(found, m)
}
}
switch len(found) {
case 0:
return BuildModule{}, fmt.Errorf("no seat builds an artifact of language %q and kind %q", language, kind)
case 1:
if found[0].Module == "" {
return BuildModule{}, fmt.Errorf("no module claims %s, which builds an artifact of language %q and kind %q", found[0].Seat, language, kind)
}
return found[0], nil
}
return BuildModule{}, fmt.Errorf("the seats %v each build an artifact of language %q and kind %q", seatsOf(found), language, kind)
}
func seatsOf(ms []BuildModule) []string {
var out []string
for _, m := range ms {
out = append(out, m.Seat)
}
return out
}
// LanguageOf is the language of the toolchain the build module builds with, false for a module that is no build seat's holder
// or builds with none.
func (s *BuildSeats) LanguageOf(module string) (string, bool) {
m, ok := s.OfModule(module)
return m.Toolchain, ok && m.Toolchain != ""
}
// Languages are the languages of the toolchains the build seats' rules name, sorted.
func (s *BuildSeats) Languages() []string {
var out []string
for _, m := range s.all() {
if m.Toolchain != "" {
out = append(out, m.Toolchain)
}
}
slices.Sort(out)
return slices.Compact(out)
}
// OfAskLanguage is the build module whose seat takes the asks of an ask language (go, node or image): the seat the sdk names
// for it (buildstep.BuildSeat), held by the module the catalog half names.
func (s *BuildSeats) OfAskLanguage(language string) (BuildModule, error) {
seat, err := buildstep.BuildSeat(language)
if err != nil {
return BuildModule{}, err
}
m, ok := s.OfSeat(seat)
if !ok {
return BuildModule{}, fmt.Errorf("the seat list holds no build seat %s", seat)
}
if m.Module == "" {
return BuildModule{}, fmt.Errorf("no module claims %s", seat)
}
return m, nil
}
@@ -0,0 +1,60 @@
package buildrun
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The build module of an artifact is the holder of the build seat whose rule `on` names its language, or kind:image, as the seat
// list and the catalog half name it (design 47 G A-82): no module name is in the code.
func TestTheBuildModuleIsTheHolderOfTheBuildSeat(t *testing.T) {
rule := func(step, on string) seatdef.Definition {
return seatdef.Definition{Acts: []seatdef.ActingRule{{Step: step, On: on}}}
}
defs := map[string]seatdef.Definition{
"node-build-go": rule(seatdef.StepBuild, "go"), "node-build-node": rule(seatdef.StepBuild, "typescript"),
"node-build-image": rule(seatdef.StepBuild, seatdef.OnKindImage), "node-build-rust": rule(seatdef.StepBuild, "go"),
"mesh-checks": rule(seatdef.StepCheck, "go"), "node-builder-every": rule(seatdef.StepBuild, "every"),
}
holders := map[string]string{"node-build-go": "acme-go", "node-build-node": "acme-ts", "node-build-image": "acme-img"}
seats := NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(s string) (string, bool) { h, ok := holders[s]; return h, ok })
for _, c := range []struct{ language, kind, module, fail string }{
{"typescript", "bundle", "acme-ts", ""},
{"", "image", "acme-img", ""},
{"python", "bundle", "", "no seat builds"},
{"go", "bundle", "", "each build"}, // node-build-go and node-build-rust both build go
} {
got, err := seats.For(c.language, c.kind)
switch {
case c.fail != "" && (err == nil || !strings.Contains(err.Error(), c.fail)):
t.Errorf("For(%q, %q) = %+v, %v; want the error %q", c.language, c.kind, got, err, c.fail)
case c.fail == "" && (err != nil || got.Module != c.module):
t.Errorf("For(%q, %q) = %+v, %v; want %s", c.language, c.kind, got, err, c.module)
}
}
delete(defs, "node-build-rust")
if m, err := seats.For("go", "bundle"); err != nil || m.Module != "acme-go" || m.Toolchain != "go" || m.AskLanguage() != "go" {
t.Errorf("For(go) = %+v, %v", m, err)
}
if l, ok := seats.LanguageOf("acme-ts"); !ok || l != "typescript" {
t.Errorf("LanguageOf(acme-ts) = %q, %v", l, ok)
}
if _, ok := seats.LanguageOf("acme-img"); ok {
t.Error("an image build module has a toolchain language")
}
if got := strings.Join(seats.Modules(), ","); got != "acme-go,acme-img,acme-ts" {
t.Errorf("Modules = %s", got)
}
delete(holders, "node-build-go") // no catalog entry claims the seat: no build module, said by name
if _, err := seats.For("go", "bundle"); err == nil || !strings.Contains(err.Error(), "no module claims node-build-go") {
t.Errorf("a seat with no holder: %v", err)
}
if m, err := seats.OfAskLanguage("image"); err != nil || m.Module != "acme-img" {
t.Errorf("OfAskLanguage(image) = %+v, %v", m, err)
}
if got := NewBuildSeats(func() map[string]seatdef.Definition { return nil }, nil).Modules(); len(got) != 0 {
t.Errorf("before the seat list is read: %v", got)
}
}
@@ -36,6 +36,9 @@ type HeardEnds struct {
mu sync.Mutex
waiting map[string]*waitedAsk // by ask id
watched map[string]bool // the modules whose steps are watched
asks JetStreamAsks
ctx context.Context
}
type waitedAsk struct {
@@ -180,16 +183,31 @@ func HearEnds(ctx context.Context, asks JetStreamAsks, inner AskWaiter, modules
if logf == nil {
logf = func(string, ...any) {}
}
h := &HeardEnds{Inner: inner, Read: asks, Logf: logf}
h := &HeardEnds{Inner: inner, Read: asks, Logf: logf, asks: asks, ctx: ctx, watched: map[string]bool{}}
h.Watch(modules)
return h
}
// Watch starts the watch of the steps of each module not watched yet: the build modules are the holders of the build seats the
// seat list and the catalog half name (design 47 G A-82), known only once both are read, and again at each change of them.
func (h *HeardEnds) Watch(modules []string) {
for _, module := range modules {
kv, err := asks.JS.KeyValue(ctx, StepsBucket(module))
if err != nil {
logf("the steps of %s cannot be read (%v): an ask whose own watch goes deaf is read only at a reconnect", module, err)
h.mu.Lock()
done := h.watched[module]
h.mu.Unlock()
if done {
continue
}
go h.follow(ctx, kv, module)
kv, err := h.asks.JS.KeyValue(h.ctx, StepsBucket(module))
if err != nil {
h.logf("the steps of %s cannot be read (%v): an ask whose own watch goes deaf is read only at a reconnect", module, err)
continue
}
h.mu.Lock()
h.watched[module] = true
h.mu.Unlock()
go h.follow(h.ctx, kv, module)
}
return h
}
func (h *HeardEnds) follow(ctx context.Context, kv jetstream.KeyValue, module string) {
@@ -1,7 +0,0 @@
package buildrun
// The build modules the mesh runs, by the language each builds (ADR 0469).
const (
GoBuildModule = "mesh-build-go"
NodeBuildModule = "mesh-build-node"
)
@@ -0,0 +1,22 @@
package buildrun
import (
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The build modules of the fixture seat list: the holders of the three build seats (design 47 G A-82).
const (
testGo = "mesh-build-go"
testNode = "mesh-build-node"
testImage = "mesh-build-image"
)
// testSeats is the finder over a seat list whose build seats act on go, typescript and kind:image, each held by its module.
func testSeats() *BuildSeats {
rule := func(on string) seatdef.Definition {
return seatdef.Definition{Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: on}}}
}
defs := map[string]seatdef.Definition{"node-build-go": rule("go"), "node-build-node": rule("typescript"), "node-build-image": rule(seatdef.OnKindImage)}
holders := map[string]string{"node-build-go": testGo, "node-build-node": testNode, "node-build-image": testImage}
return NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok })
}
@@ -41,7 +41,7 @@ func TestPlacedNodes(t *testing.T) {
t.Run("the assignments names the counted nodes once it is settled", func(t *testing.T) {
gate := NewPlacedNodes(func(string, ...any) {})
for _, node := range three {
k, v := placing(t, node, NodeBuildModule)
k, v := placing(t, node, testNode)
if gate.Assignments(k, v, false, old) {
t.Fatal("an assignments before it is settled changed the counted nodes")
}
@@ -50,7 +50,7 @@ func TestPlacedNodes(t *testing.T) {
if got := strings.Join(gate.Nodes(), ","); got != "g14,novox,shanks" {
t.Fatalf("nodes = %q", got)
}
k, v := placing(t, "ace", GoBuildModule)
k, v := placing(t, "ace", testGo)
if !gate.Assignments(k, v, false, old) || len(gate.Nodes()) != 4 {
t.Fatalf("ace placed: nodes %v", gate.Nodes())
}
@@ -65,7 +65,7 @@ func TestPlacedNodes(t *testing.T) {
at := old.Add(time.Duration(n) * time.Hour)
r := meshwants.AssignmentRevision{Revision: n, Kind: "trunk", At: &at}
for _, node := range nodes {
r.Entries = append(r.Entries, meshwants.NodeEntryRef{Node: node, Module: NodeBuildModule, Digest: "d-" + node})
r.Entries = append(r.Entries, meshwants.NodeEntryRef{Node: node, Module: testNode, Digest: "d-" + node})
}
return r
}
@@ -42,16 +42,16 @@ func TestToolchainGate(t *testing.T) {
placed := time.Date(2026, 10, 25, 19, 56, 45, 0, time.UTC) // mesh-build-node placed on ace again
three := []string{"g14", "novox", "shanks"}
start := func(t *testing.T, seeded bool) (*ToolchainGate, *ToolchainSet, *[]string) {
set := &ToolchainSet{}
set := &ToolchainSet{Seats: testSeats()}
said := &[]string{}
gate := NewToolchainGate([]string{GoBuildModule, NodeBuildModule}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) })
gate := NewToolchainGate([]string{testGo, testNode}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) })
for _, node := range three {
if seeded {
key, value := placing(t, node, NodeBuildModule)
key, value := placing(t, node, testNode)
gate.Assignments(key, value, false, old.Add(-time.Hour))
}
key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute))
gate.Toolchain(NodeBuildModule, key, value, false)
gate.Toolchain(testNode, key, value, false)
}
return gate, set, said
}
@@ -59,11 +59,11 @@ func TestToolchainGate(t *testing.T) {
t.Run("not placed on ace: its report is no builder's", func(t *testing.T) {
gate, set, said := start(t, true)
key, value := gateReport(t, "ace", "v26.12.0", old)
gate.Toolchain(NodeBuildModule, key, value, false)
gate.Toolchain(testNode, key, value, false)
if a := buildstep.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out
t.Fatalf("before the assignments is settled every report counts; got %+v", a)
}
k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter
k, v := placing(t, "ace", testGo) // another build module on ace: no matter
gate.Assignments(k, v, false, old)
if !gate.Settled() {
t.Fatal("settling left ace's report out, so what was passed on changed")
@@ -74,11 +74,11 @@ func TestToolchainGate(t *testing.T) {
if len(*said) != 1 || !strings.Contains((*said)[0], "does not place mesh-build-node on ace") {
t.Fatalf("ace's report is said left out once; said %q", *said)
}
if gate.Toolchain(NodeBuildModule, key, value, false) {
if gate.Toolchain(testNode, key, value, false) {
t.Fatal("a report of a node not placed changed what was passed on")
}
// Placed on ace with a report written since: it counts, and the builders really disagree.
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
k, v = placing(t, "ace", testGo, testNode)
gate.Assignments(k, v, false, old.Add(-time.Minute))
if a := buildstep.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" {
t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a)
@@ -92,24 +92,24 @@ func TestToolchainGate(t *testing.T) {
}
// A report deleted from the bucket goes whether its node is placed or not.
key, _ = gateReport(t, "g14", "v26.12.1", placed)
if !gate.Toolchain(NodeBuildModule, key, nil, true) || len(set.Entries("typescript")) != 2 {
if !gate.Toolchain(testNode, key, nil, true) || len(set.Entries("typescript")) != 2 {
t.Fatalf("deleting g14's report: %d entries left, want novox and shanks", len(set.Entries("typescript")))
}
})
t.Run("build modules given later pass their reports on when the assignments moves", func(t *testing.T) {
set := &ToolchainSet{}
set := &ToolchainSet{Seats: testSeats()}
gate := NewToolchainGate(nil, set.Put, func(string, ...any) {})
gate.Settled()
k, v := placing(t, "novox", GoBuildModule)
k, v := placing(t, "novox", testGo)
gate.Assignments(k, v, false, old)
key, value := gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute))
gate.Toolchain(NodeBuildModule, key, value, false) // ace is not placed: left out
k, v = placing(t, "ace", NodeBuildModule)
gate.Toolchain(testNode, key, value, false) // ace is not placed: left out
k, v = placing(t, "ace", testNode)
if gate.Assignments(k, v, false, placed) || len(set.Entries("typescript")) != 0 {
t.Fatalf("a module the gate was not given passed on at the assignments: %v", set.Entries("typescript"))
}
if !gate.UseModules([]string{NodeBuildModule}) || len(set.Entries("typescript")) != 1 {
if !gate.UseModules([]string{testNode}) || len(set.Entries("typescript")) != 1 {
t.Fatalf("given mesh-build-node, ace's report placed since is passed on; entries %v", set.Entries("typescript"))
}
})
@@ -127,16 +127,16 @@ func TestToolchainGate(t *testing.T) {
k, v := placing(t, "ace") // ace's key, first heard placing nothing
gate.Assignments(k, v, false, old.Add(-time.Hour))
gate.Settled()
k, v = placing(t, "ace", NodeBuildModule)
k, v = placing(t, "ace", testNode)
if gate.Assignments(k, v, false, placed) {
t.Fatal("assignments with no report passed something on")
}
key, value := gateReport(t, "ace", "v26.12.0", c.at)
if got := gate.Toolchain(NodeBuildModule, key, value, false); got != c.counts {
if got := gate.Toolchain(testNode, key, value, false); got != c.counts {
t.Fatalf("ace's report passed on: %v, want %v", got, c.counts)
}
// ace's assignments written again with another module beside: its standing does not move, nothing is said again.
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
k, v = placing(t, "ace", testGo, testNode)
if gate.Assignments(k, v, false, placed.Add(time.Minute)) {
t.Fatal("the same assignments of mesh-build-node changed what was passed on")
}
@@ -150,7 +150,7 @@ func TestToolchainGate(t *testing.T) {
t.Fatalf("said %q, want once why ace's report is left out", *said)
}
key, value = gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute))
if !gate.Toolchain(NodeBuildModule, key, value, false) || len(set.Entries("typescript")) != 4 {
if !gate.Toolchain(testNode, key, value, false) || len(set.Entries("typescript")) != 4 {
t.Fatal("ace's new report was not passed on")
}
})
@@ -159,12 +159,12 @@ func TestToolchainGate(t *testing.T) {
t.Run("no assignments held: every report counts", func(t *testing.T) {
gate, set, _ := start(t, false)
key, value := gateReport(t, "ace", "v26.12.0", old)
gate.Toolchain(NodeBuildModule, key, value, false)
gate.Toolchain(testNode, key, value, false)
gate.Settled()
if n := len(set.Entries("typescript")); n != 4 {
t.Fatalf("before the first assignments file every report counts; got %d", n)
}
k, v := placing(t, "g14", NodeBuildModule)
k, v := placing(t, "g14", testNode)
if !gate.Assignments(k, v, false, old.Add(-time.Hour)) || len(set.Entries("typescript")) != 1 {
t.Fatalf("the first assignments leaves every node it does not place out; got %d", len(set.Entries("typescript")))
}
@@ -177,20 +177,20 @@ func TestToolchainGate(t *testing.T) {
// leaves out a node no revision places the build module on, and counts a node placed in a later revision only by a
// report written since that revision's time.
t.Run("fed by the assignment half", func(t *testing.T) {
set := &ToolchainSet{}
set := &ToolchainSet{Seats: testSeats()}
said := &[]string{}
gate := NewToolchainGate([]string{GoBuildModule, NodeBuildModule}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) })
gate := NewToolchainGate([]string{testGo, testNode}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) })
for _, node := range append([]string{"ace"}, three...) {
key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute))
if node == "ace" {
key, value = gateReport(t, node, "v26.12.0", old)
}
gate.Toolchain(NodeBuildModule, key, value, false)
gate.Toolchain(testNode, key, value, false)
}
revision := func(n int64, at time.Time, nodes ...string) meshwants.AssignmentRevision {
r := meshwants.AssignmentRevision{Revision: n, Kind: "trunk", At: &at}
for _, node := range nodes {
r.Entries = append(r.Entries, meshwants.NodeEntryRef{Node: node, Module: NodeBuildModule, Digest: "d-" + node})
r.Entries = append(r.Entries, meshwants.NodeEntryRef{Node: node, Module: testNode, Digest: "d-" + node})
}
return r
}
@@ -207,7 +207,7 @@ func TestToolchainGate(t *testing.T) {
t.Fatalf("said %q, want why ace's old report is left out", *said)
}
key, value := gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute))
if !gate.Toolchain(NodeBuildModule, key, value, false) || len(set.Entries("typescript")) != 4 {
if !gate.Toolchain(testNode, key, value, false) || len(set.Entries("typescript")) != 4 {
t.Fatal("ace's report written since revision 8 is not passed on")
}
if !gate.Revision(revision(9, placed.Add(time.Hour), "ace", "novox", "shanks")) || len(set.Entries("typescript")) != 3 || len(gate.Nodes()) != 3 {
@@ -218,27 +218,27 @@ func TestToolchainGate(t *testing.T) {
t.Run("the history: a module in the first revision heard of a key stands since before it", func(t *testing.T) {
p := meshmoduleassignerclient.NewPlaced()
// ace's node key is the first revision heard of it: its module stands since before.
k, v := placing(t, "ace", NodeBuildModule)
k, v := placing(t, "ace", testNode)
p.Put(k, v, false, old)
if _, changed, _ := p.Put("ace."+NodeBuildModule, []byte("{}"), false, placed); len(changed) != 0 {
if _, changed, _ := p.Put("ace."+testNode, []byte("{}"), false, placed); len(changed) != 0 {
t.Errorf("a module key says which modules stand: %v", changed)
}
k2, v2 := placing(t, "g14")
p.Put(k2, v2, false, old)
k2, v2 = placing(t, "g14", NodeBuildModule)
if _, changed, _ := p.Put(k2, v2, false, placed); strings.Join(changed, ",") != NodeBuildModule {
k2, v2 = placing(t, "g14", testNode)
if _, changed, _ := p.Put(k2, v2, false, placed); strings.Join(changed, ",") != testNode {
t.Errorf("a module placed says changed %v", changed)
}
if _, _, err := p.Put("novox.node", []byte("not json"), false, old); err == nil {
t.Error("a value that does not read was not said")
}
if s, ok := p.Since("ace", NodeBuildModule); !ok || !s.IsZero() {
if s, ok := p.Since("ace", testNode); !ok || !s.IsZero() {
t.Errorf("ace's module stands since %v, %v; want the zero time", s, ok)
}
if s, ok := p.Since("g14", NodeBuildModule); !ok || !s.Equal(placed) {
if s, ok := p.Since("g14", testNode); !ok || !s.Equal(placed) {
t.Errorf("g14's module stands since %v, %v; want the revision that placed it", s, ok)
}
if got := p.PlacedNodes(NodeBuildModule); strings.Join(got, ",") != "ace,g14" || len(p.ByNode()) != 2 || !p.Seeded() {
if got := p.PlacedNodes(testNode); strings.Join(got, ",") != "ace,g14" || len(p.ByNode()) != 2 || !p.Seeded() {
t.Errorf("PlacedNodes = %v, ByNode = %v", got, p.ByNode())
}
})
@@ -7,9 +7,6 @@ import (
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
)
// LanguageOf is the language each build module's toolchain reports hold.
var LanguageOf = map[string]string{GoBuildModule: "go", NodeBuildModule: "typescript"}
// ToolchainsHeardState is the builder's old state section of the toolchains it heard (design 47 G §604, §546): one key
// <node>.<language>, the toolchain as the build seat said it.
const (
@@ -20,8 +17,10 @@ const (
// ToolchainSet holds each node's toolchain of a language, as the builder heard it (design 47 G §604). Safe for use from
// the consumer and from a build at once.
type ToolchainSet struct {
mu sync.Mutex
by map[string]map[string]buildstep.Toolchain // language -> node -> entry
// Seats finds the build module of a language: a report of a module that holds no build seat is left out (design 47 G A-82).
Seats *BuildSeats
mu sync.Mutex
by map[string]map[string]buildstep.Toolchain // language -> node -> entry
}
// Set takes a node's toolchain in and answers whether the language's entries changed (a node's version, recipe or
@@ -96,7 +95,7 @@ const (
// delete. It answers whether the language's entries changed (a node's version or recipe, or a node appearing or going).
// A key or a value that is not an entry of the module's language is left out.
func (s *ToolchainSet) Put(module, key string, value []byte, deleted bool) bool {
language, ok := LanguageOf[module]
language, ok := s.Seats.LanguageOf(module)
if !ok {
return false
}
@@ -22,6 +22,8 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads"
@@ -336,12 +338,8 @@ const (
)
func main() {
steps := map[string]build.StateRead{}
for _, bm := range build.StuckBuildModules {
steps[bm] = read(stdio.State(bm + ".steps"))
}
reasks := stdio.State(build.ReAsksState)
r := build.StuckReader{Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] "+f+"\n", a...) }, Builds: read(stdio.State("builds")), ReAsks: read(reasks), Steps: steps,
r := build.StuckReader{Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] "+f+"\n", a...) }, Builds: read(stdio.State("builds")), ReAsks: read(reasks), StepsOf: func(seat string) build.StateRead { return read(stdio.State(seat + ".steps")) },
Hearing: read(stdio.State(buildrun.HearingState))}
put := func(key string, value []byte) error {
_, err := reasks.Put(key, json.RawMessage(value))
@@ -400,6 +398,19 @@ func main() {
os.Exit(1)
}
pk.Start(context.Background())
// The build modules are no names (design 47 G A-82): the build seats on the seat list, each held by the module the catalog
// half's manifests say; the seat list and its two tools wired once by mesh-sdk (§204).
gate := seatlist.NewPart("builder", []string{stdio.AnyPerson, "node-tools", "mesh-healers"})
go func() {
<-stdio.Served()
gate.Follow(seatlist.StdioBus(), nil, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] "+f+"\n", a...) })
}()
r.Seats = buildrun.NewBuildSeats(func() map[string]seatdef.Definition {
if rd := gate.Reader(); rd != nil {
return rd.Definitions()
}
return nil
}, versions.HolderOf(branches))
carriers := versions.CarriersFrom(versions.EntriesOf(branches))
released := func(_ context.Context, module string) ([]meshcatalog.VersionEntry, error) {
raw, err := stdio.Ask(catalogVersionsVerb, meshcatalog.VersionsInput{Module: module})
@@ -445,6 +456,7 @@ func main() {
// 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(builds.BuilderModule, "builder_first_views", "builder_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...)
tools = append(tools, gate.Tools()...)
if err := stdio.Serve("", tools); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] %v\n", err)
os.Exit(1)
@@ -74,6 +74,8 @@ type catalogHalf struct {
halves map[string]*views.Follower
once sync.Once
ctx context.Context
// holders is told each entry change: a claim of a build seat may have moved (design 47 G A-82).
holders func()
}
// first says whether branch is the first watched branch of module's registration (design 47 G §371); a module with no
@@ -86,6 +88,9 @@ func (c *catalogHalf) first(branch, module string) bool {
// Changed is the follower's Changed: one module's entry of a branch came, or went.
func (c *catalogHalf) Changed(ctx context.Context) func(branch, module string, entry json.RawMessage, deleted bool, at time.Time) {
return func(branch, module string, entry json.RawMessage, deleted bool, _ time.Time) {
if c.holders != nil {
defer c.holders()
}
first := c.first(branch, module)
if deleted {
c.b.EntryGone(branch, first, module)
@@ -5,6 +5,8 @@ package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
@@ -14,6 +16,9 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
)
// catalogHolder is the holder of the seat mesh-catalog as the bed needs it (design 47 G §348, §614): it answers `revision` for the
@@ -92,3 +97,48 @@ func (h *catalogHolder) put(t *testing.T, e meshcatalog.CatalogEntry) {
t.Fatal(err)
}
}
// buildSeats is the seat list and the catalog half as the builder finds its build modules from them (design 47 G A-82): the seat
// mesh-catalog's verb seat-definitions answers the three build seats' definitions, each acting on its language (the image seat on
// kind:image), and an entry of each holder's manifest claims its seat.
func (h *catalogHolder) buildSeats(t *testing.T, nc *nats.Conn, holders map[string]string) {
t.Helper()
defs := map[string]seatdef.Definition{}
for _, d := range seatlisttest.Definitions() {
if _, ok := holders[d.Seat]; ok {
defs[d.Seat] = d
}
}
for seat, on := range map[string]string{"node-build-go": "go", "node-build-node": "typescript", "node-build-image": seatdef.OnKindImage} {
d := defs[seat]
d.Acts = []seatdef.ActingRule{{Step: seatdef.StepBuild, On: on}}
defs[seat] = d
}
s, err := nc.Subscribe("mesh.seat."+meshcatalog.Seat+".call."+seatlist.Verb+".>", func(m *nats.Msg) {
var in struct {
Seat string `json:"seat"`
}
_ = json.Unmarshal(m.Data, &in)
var result any
if in.Seat == "" {
l := seatlist.Listing{Revision: 1}
for seat := range defs {
l.Seats = append(l.Seats, seatlist.Entry{Seat: seat, Source: seatlist.SourceTrunk, Digest: "sha256:" + seat})
}
result = l
} else {
raw, _ := json.Marshal(defs[in.Seat])
result = seatlist.Answer{Seat: in.Seat, Found: true, Revision: 1, Entry: &seatlist.Entry{Seat: in.Seat, Source: seatlist.SourceTrunk, Digest: "sha256:" + in.Seat, Definition: raw}}
}
reply, _ := json.Marshal(map[string]any{"result": result, "node": "ctl"})
_ = m.Respond(reply)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Unsubscribe() })
for seat, module := range holders {
h.put(t, meshcatalog.CatalogEntry{Module: module, Repository: "novox/mesh-catalog", Commit: "c0", Fp: "src2:" + strings.Repeat(fmt.Sprintf("%x", len(seat)%16), 64), ManifestDigest: "sha256:claims-" + module,
Manifest: map[string]any{"module": module, "claims": []any{map[string]any{"name": seat}}}})
}
}
@@ -19,7 +19,10 @@ import (
// heldOnTheBus says whether an ask a build key names is on its build seat's stream, or a build node wrote a step for it
// (issue 1910), read once at the take-over of a key at start; and, at the builder's check bound, whether an ask can still
// be answered (issue 1945). Never polled.
type heldOnTheBus struct{ js jetstream.JetStream }
type heldOnTheBus struct {
js jetstream.JetStream
seats *buildrun.BuildSeats // the build modules (design 47 G A-82)
}
// Held is build.Builder's Held: a step key `<node>.<ask id>` in the build module's steps, or a message of the stream under
// the ask's subject whose Nats-Msg-Id is the ask's id.
@@ -51,10 +54,11 @@ const (
// the ask is on its stream.
func (h heldOnTheBus) seen(ctx context.Context, a buildstep.StepAsk, later bool) (build.AskSeen, error) {
seen := build.AskSeen{Kind: a.Kind, At: build.AskedAt(a.ID)}
module, err := buildstep.BuildModuleOf(a.Language)
bm, err := h.seats.OfAskLanguage(a.Language)
if err != nil {
return seen, err
}
module := bm.Module
steps, err := h.js.KeyValue(ctx, buildrun.StepsBucket(module))
if err != nil {
return seen, fmt.Errorf("the bucket %s: %w", buildrun.StepsBucket(module), err)
@@ -259,10 +263,11 @@ func (h heldOnTheBus) Drop(ctx context.Context, a buildstep.StepAsk) error {
// every kind, oldest first and at most limit of each kind, each with its step's state and whether it still waits.
func (h heldOnTheBus) Queue(ctx context.Context, language string, limit int) (build.SeatQueue, error) {
q := build.SeatQueue{Language: language, Note: build.QueueNote}
module, err := buildstep.BuildModuleOf(language)
bm, err := h.seats.OfAskLanguage(language)
if err != nil {
return q, err
}
module := bm.Module
stream, name, _, err := h.stream(ctx, language, buildstep.AskDelivery)
q.Stream = name
if errors.Is(err, jetstream.ErrStreamNotFound) {
@@ -303,6 +303,8 @@ func startBedWith(t *testing.T, closed bool) *bed {
// verbs and events (design 47 G §348, §614), the assignments by their read tool and views family (ADR 0615, DLV-M1); n1 and n2
// each have their durable consumer.
b.catalog = newCatalogHolder(t, ctx, js, ctl)
b.catalog.buildSeats(t, ctl, map[string]string{"node-build-go": testGo, "node-build-node": testNode, "node-build-image": testImage,
"git": "mesh-delivery-forge-bridge", "mesh-catalog": "mesh-delivery-indexer"}) // the floor's sources are the holders of git and mesh-catalog
readers := []string{"n1_" + builds.BuilderModule, "n2_" + builds.BuilderModule}
viewstest.Serve(t, ctx, js, ctl, assignerModule, "assignments", assignerModule+".assigner_read_assignments", readers...)
b.builds = mk(builds.BuilderBuildsBucket, builds.BuilderBuildsHistory)
@@ -1001,7 +1003,7 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) {
}
// 1945 note 62, on the real bus: the stream read as builder_seat_queue reads it shows the Go ask waiting; the ask can
// still be answered (nothing was taken since); and a message dropped as an ask asked again is gone from the stream.
held := heldOnTheBus{b.js}
held := heldOnTheBus{js: b.js, seats: testSeats()}
goAsk := sdkstep.StepAsk{ID: v.Asks[sdkstep.AskGo], Language: sdkstep.AskGo, Kind: sdkstep.AskDelivery}
stale := sdkstep.StepAsk{ID: "mvo53jcm70b5f7", Language: sdkstep.AskGo, Kind: sdkstep.AskDelivery}
subject, _ := sdkstep.AskSubjectBy(sdkstep.AskGo, sdkstep.AskDelivery, sdkstep.AskerBuilder)
@@ -38,6 +38,7 @@ import (
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-delivery/sdk/trunk"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-builder/internal/build"
@@ -115,6 +116,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
branches.Logf = logger.Printf
half.branches = branches
// The build modules are no names (design 47 G A-82): the holders of the build seats, the seat list's definitions and the
// catalog half's claims, found at each use.
seatGate := newSeatGate()
seats := buildrun.NewBuildSeats(seatGate.definitions, versions.HolderOf(branches))
assignments, err := reader.Follow(assignerModule, "assignments", assignerModule+".assigner_read_assignments")
if err != nil {
return err
@@ -125,10 +130,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return fmt.Errorf("MESH_BUILDER_STEP_BOUND: %w", err)
}
}
tools := &buildrun.ToolchainSet{}
tools := &buildrun.ToolchainSet{Seats: seats}
// 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.
asks := buildrun.JetStreamAsks{JS: js}
// The steps of each build module are watched once the seat list and the catalog half name it (seatsChanged).
// The asks the build seats gave up on (issue 734): heard from DEAD_LETTERS, each ends the wait for its step at once,
// and the lane asks its part again (Builder.waitOnce) instead of waiting for the check bound.
// Heard in the background, and again at a reconnect or an ask while it is not; whether it is heard is kept in the
@@ -142,13 +148,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
defer hearing.Stop()
// Each ask's step is heard by its own watch and, at every step end of its build module and every reconnect, read
// (buildrun.HearEnds): a watch that goes deaf loses no end (the lost check of 2026-10-28).
var buildModules []string
for _, l := range buildstep.AskLanguages {
if m, err := buildstep.BuildModuleOf(l); err == nil {
buildModules = append(buildModules, m)
}
}
heard := buildrun.HearEnds(ctx, asks, asks, buildModules, logger.Printf)
heard := buildrun.HearEnds(ctx, asks, asks, seats.Modules(), 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.
@@ -156,7 +156,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// the bus holds it; the floor's three sources asked as its own account.
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{w: fw, logf: logger.Printf, now: time.Now}
floorWaits := &versions.FloorWaits{Logf: logger.Printf, Said: found.floorSaid(context.WithoutCancel(ctx))}
floorWaits := &versions.FloorWaits{Holder: versions.HolderOf(branches), 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).
@@ -164,19 +164,27 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
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 catalog half's entries (design 32, design 47 G §348).
numberer := &versions.Numberer{Versions: versions.Versions{Store: awaitedVersions{b: versionsKV}}, Floor: floorVia(calls.Call), Waits: floorWaits,
numberer := &versions.Numberer{Versions: versions.Versions{Store: awaitedVersions{b: versionsKV}}, Floor: floorVia(calls.Call, versions.HolderOf(branches)), Waits: floorWaits,
Names: versions.NamesFrom(versions.EntriesOf(branches))}
// 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
b := &build.Builder{
var b *build.Builder
seatsChanged := func(ctx context.Context) {
heard.Watch(seats.Modules())
if b != nil {
b.Reconsider(ctx)
}
}
b = &build.Builder{
Claimer: build.Claimer{Store: kvStore{buildsKV}, By: cred.Node + "." + cred.Module},
Asks: buildrun.Asker{Publisher: asks, Waiter: buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}, Bound: bound, Dropper: buildrun.SeatDrops{Caller: calls}},
Held: heldOnTheBus{js},
Seats: seats,
Asks: buildrun.Asker{Seats: seats, Publisher: asks, Waiter: buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}, Bound: bound, Dropper: buildrun.SeatDrops{Caller: calls}},
Held: heldOnTheBus{js: js, seats: seats},
// An ask that can no longer be answered is asked again (issue 1945), read at the step bound: a context's bound.
Gone: heldOnTheBus{js},
Gone: heldOnTheBus{js: js, seats: seats},
CheckAfter: bound,
Toolchains: tools,
Manifests: half.manifests,
@@ -266,7 +274,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
switch kv := process.OptionalBucket(ctx, js, instancesBucket, "the controller is not told that this builder writes references until it starts again", logger.Printf); {
case kv == nil:
default:
says := newSayer(ctx, kv, cred.Node, []string{buildrun.GoBuildModule, buildrun.NodeBuildModule, build.ImageBuildModule}, logger.Printf)
says := newSayer(ctx, kv, cred.Node, seats.Modules, logger.Printf)
b.Answered = says.Answered
defer func() {
stopping, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
@@ -369,9 +377,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// 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, res: reserved}
seatHeard := seatHearer{n: numberer, b: b, logf: logger.Printf, pub: pubs, res: reserved}
events := func(m jetstream.Msg) {
if reads.heard(m, logger.Printf) || heardTools.Reported(ctx, m) || heardTools.Running(ctx, m) || floor.heard(ctx, m) || reserved.heard(ctx, m) || seats.heard(ctx, m) { // reads: the node-engine seat's manifest-reads-changed (ADR 0618 §4)
if reads.heard(m, logger.Printf) || heardTools.Reported(ctx, m) || heardTools.Running(ctx, m) || floor.heard(ctx, m) || reserved.heard(ctx, m) || seatHeard.heard(ctx, m) { // reads: the node-engine seat's manifest-reads-changed (ADR 0618 §4)
return
}
heardGone(ctx, b, m, logger.Printf)
@@ -380,6 +388,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// manifest-reads-changed and the seat mesh-conditions' condition-cleared: seatEventPatterns); the owners' views events and the
// catalog half's catalog-revision and registration-changed and the gate's source-moved are the views reader's (a message has
// one taker, so no pattern here covers them).
// The seat list over the connection and heard on the hub: a change of it, as of the catalog half's holders, names the build
// modules again (design 47 G A-82, §204).
seatGate.Follow(seatlist.ProcessBus(calls.Call, hub), func() { seatsChanged(ctx) }, logger.Printf)
half.holders = func() { seatsChanged(ctx) }
for _, pattern := range seatEventPatterns {
if err := hub.Handle(pattern, events); err != nil {
return err
@@ -451,7 +463,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// 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) }
eachRead := func(e jetstream.KeyValueEntry) {
queueRead(ctx, heldOnTheBus{js: js, seats: seats}, 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 {
@@ -27,7 +27,7 @@ func TestTheNamesAreTheManifests(t *testing.T) {
}
for _, read := range []string{
gateModule + ".source",
"mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps",
"node-build-go.steps", "node-build-image.steps", "node-build-node.steps",
} {
if !slices.Contains(m.Reads, read) {
t.Errorf("the builder reads %s, its manifest's reads are %v", read, m.Reads)
@@ -258,7 +258,8 @@ func (m *wakeMsg) Headers() nats.Header {
func aFloorsWakeIsHeard(t *testing.T) {
var logs bytes.Buffer
waits := &versions.FloorWaits{}
holders := map[string]string{"git": "mesh-delivery-forge-bridge", "mesh-catalog": "mesh-delivery-indexer"}
waits := &versions.FloorWaits{Holder: func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok }}
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}
@@ -31,7 +31,7 @@ type sayer struct {
ctx context.Context
store sayStore
node string
need []string
need func() []string
logf func(string, ...any)
mu sync.Mutex
@@ -39,7 +39,7 @@ type sayer struct {
said bool
}
func newSayer(ctx context.Context, store sayStore, node string, need []string, logf func(string, ...any)) *sayer {
func newSayer(ctx context.Context, store sayStore, node string, need func() []string, logf func(string, ...any)) *sayer {
return &sayer{ctx: ctx, store: store, node: node, need: need, logf: logf, answered: map[string]bool{}}
}
@@ -51,7 +51,7 @@ func (s *sayer) Answered(module string) {
if s.said {
return
}
for _, m := range s.need {
for _, m := range s.need() {
if !s.answered[m] {
return
}
@@ -24,7 +24,7 @@ func (s saysOf) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt
// one; stopped, it says nothing any more.
func TestTheBuilderSaysItWritesReferencesOnceEveryBuildModuleAnsweredOne(t *testing.T) {
s := saysOf{}
say := newSayer(t.Context(), s, "shanks", []string{"mesh-build-go", "mesh-build-node", "mesh-build-image"}, t.Logf)
say := newSayer(t.Context(), s, "shanks", func() []string { return []string{"mesh-build-go", "mesh-build-node", "mesh-build-image"} }, t.Logf)
say.Answered("mesh-build-go")
say.Answered("mesh-build-node")
if v, ok := s["shanks"]; ok {
@@ -0,0 +1,26 @@
package main
import (
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/seatlist"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The builder reads the seat list as every part does, through mesh-sdk's one wiring (go/seatlist.Part; design 47 G §204): its
// definitions name the build seats (the seats whose acting rule is the step build), its tools `builder_seat_list` and
// `builder_seat_list_again`. A call through the mesh MCP server is proved only as its machine's tool runner (issue 627).
var seatListAgainCallers = []string{stdio.AnyPerson, "node-tools", "mesh-healers"}
// seatGate is the builder's wiring of the seat list.
type seatGate struct{ *seatlist.Part }
func newSeatGate() *seatGate { return &seatGate{seatlist.NewPart("builder", seatListAgainCallers)} }
// definitions are the seat definitions the list holds, none before it is read.
func (g *seatGate) definitions() map[string]seatdef.Definition {
if r := g.Reader(); r != nil {
return r.Definitions()
}
return nil
}
@@ -0,0 +1,23 @@
package main
import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The build modules of the fixture seat list: the holders of the three build seats (design 47 G A-82).
const (
testGo = "mesh-build-go"
testNode = "mesh-build-node"
testImage = "mesh-build-image"
)
// testSeats is the finder over a seat list whose build seats act on go, typescript and kind:image, each held by its module.
func testSeats() *buildrun.BuildSeats {
rule := func(on string) seatdef.Definition {
return seatdef.Definition{Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: on}}}
}
defs := map[string]seatdef.Definition{"node-build-go": rule("go"), "node-build-node": rule("typescript"), "node-build-image": rule(seatdef.OnKindImage)}
holders := map[string]string{"node-build-go": testGo, "node-build-node": testNode, "node-build-image": testImage}
return buildrun.NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok })
}
@@ -104,7 +104,7 @@ func (h *toolchainsHeard) bind(ctx context.Context, state heardState) {
h.mu.Lock()
h.kept = state
h.mu.Unlock()
for _, language := range buildrun.LanguageOf {
for _, language := range h.set.Seats.Languages() {
for _, e := range h.set.Entries(language) {
h.keep(e.Node, e.Toolchain)
}
@@ -192,7 +192,7 @@ func (h *toolchainsHeard) running(ctx context.Context, data []byte) {
h.runAt[id] = r.At
kept := h.kept
h.mu.Unlock()
language, build := buildrun.LanguageOf[r.Module]
language, build := h.set.Seats.LanguageOf(r.Module)
if !build || r.Fingerprint != "" {
return
}
@@ -224,7 +224,7 @@ func (h *toolchainsHeard) Dropped(ctx context.Context, key string) {
// agree says toolchain-agreed for each language whose agreed version is not the one last said (design 47 G §472: said
// only when the agreed version changes, ADR 0584 §1).
func (h *toolchainsHeard) agree(ctx context.Context) {
for _, language := range buildrun.LanguageOf {
for _, language := range h.set.Seats.Languages() {
entries := h.set.Entries(language)
tc, none := buildstep.AgreedToolchain(entries)
if none != "" {
@@ -75,7 +75,7 @@ type heardRig struct {
func newHeardRig(t *testing.T, state *memState) *heardRig {
t.Helper()
r := &heardRig{state: state}
r.h = newToolchainsHeard(&buildrun.ToolchainSet{}, func(context.Context) { r.changed++ },
r.h = newToolchainsHeard(&buildrun.ToolchainSet{Seats: testSeats()}, func(context.Context) { r.changed++ },
func(_ context.Context, e meshbuilds.ToolchainAgreed) error { r.said = append(r.said, e); return nil }, log.New(io.Discard, "", 0))
r.h.bind(context.Background(), state)
return r
@@ -130,18 +130,18 @@ func TestTheBuilderKeepsTheToolchainsItHeardAndForgetsThemOnStopped(t *testing.T
t.Fatalf("a changed agreed version was not said: %+v", again.said)
}
at := time.Now()
again.running(t, "ace", buildrun.GoBuildModule, "src2:x", at) // running: nothing is forgotten
again.running(t, "ace", testGo, "src2:x", at) // running: nothing is forgotten
if k, _ := state.Keys(); len(k) != 3 {
t.Fatalf("a running forgot a key: %v", k)
}
again.running(t, "ace", buildrun.GoBuildModule, "", at.Add(time.Second)) // stopped: no fingerprint
again.running(t, "ace", testGo, "", at.Add(time.Second)) // stopped: no fingerprint
if k, _ := state.Keys(); len(k) != 2 || k[0] != "g14.go" {
t.Fatalf("stopped on ace left %v, want g14.go and novox.go", k)
}
// An older running read after (the start's read of the stream kept last per subject) changes nothing.
before := again.changed
again.running(t, "g14", buildrun.GoBuildModule, "", at)
again.running(t, "g14", buildrun.GoBuildModule, "src2:y", at.Add(-time.Hour))
again.running(t, "g14", testGo, "", at)
again.running(t, "g14", testGo, "src2:y", at.Add(-time.Hour))
if k, _ := state.Keys(); len(k) != 1 || k[0] != "novox.go" || again.changed != before+1 {
t.Fatalf("after g14 stopped and an older running: %v", k)
}
@@ -22,8 +22,8 @@ import (
// 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"
bridgeTagsTool = "bridge_tags"
requirementsTool = "indexer_requirements"
)
// awaitedVersions is the state versions as versions.Store: bound once the bus holds it (the grants part makes it, issue
@@ -106,7 +106,15 @@ func (s awaitedVersions) KeysOf(ctx context.Context, filter string) ([]string, e
// 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 {
func floorVia(call toolCaller, holder func(seat string) (string, bool)) versions.FloorSources {
sourceTool := func(source, tool string) (string, error) {
seat := versions.SourceSeats[source]
module, ok := holder(seat)
if !ok {
return "", fmt.Errorf("no module is known to hold the seat %s, the source of %s", seat, tool)
}
return module + "." + tool, nil
}
return versions.FloorSources{
Seat: func(_ context.Context, seat, name string) ([]string, error) {
raw, err := call("seat:"+seat+".versions", map[string]any{"name": name})
@@ -130,7 +138,11 @@ func floorVia(call toolCaller) versions.FloorSources {
return out, nil
},
Tags: func(_ context.Context, repository, prefix string) ([]string, error) {
raw, err := call(bridgeTagsTool, map[string]any{"repository": repository, "prefix": prefix})
tool, err := sourceTool(versions.SourceTags, bridgeTagsTool)
if err != nil {
return nil, err
}
raw, err := call(tool, map[string]any{"repository": repository, "prefix": prefix})
if err != nil {
return nil, err
}
@@ -143,7 +155,11 @@ func floorVia(call toolCaller) versions.FloorSources {
return a.Tags, nil
},
Requirements: func(_ context.Context, name string) ([]string, error) {
raw, err := call(requirementsTool, map[string]any{"package": name})
tool, err := sourceTool(versions.SourceRequirements, requirementsTool)
if err != nil {
return nil, err
}
raw, err := call(tool, map[string]any{"package": name})
if err != nil {
return nil, err
}
@@ -10,7 +10,6 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
)
@@ -131,7 +130,7 @@ func (b *Builder) agreedFor(ctx context.Context, manifest string) map[string]bui
if !plan.NeedsToolchain(bm) {
continue
}
language := buildrun.LanguageOf[bm]
language, _ := b.Seats.LanguageOf(bm)
if tc, none := buildstep.AgreedToolchain(b.Toolchains.Entries(language)); none == "" {
out[language] = builds.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe}
}
@@ -57,6 +57,8 @@ type ClaimOptions struct {
// ImageBuilder says whether a node runs a healthy image build module now: a key failed for NoImageBuilderPrefix is taken
// again only then. Nil means none does.
ImageBuilder func() bool
// Seats finds the build modules of the artifacts a done key holds unpublished (design 47 G A-82).
Seats *buildrun.BuildSeats
// Trunk is whether the trunk's record names this fingerprint. A done key whose image or package has no reference was
// built as a candidate's, which never pushes or publishes (ADR 0487 clause 12, ADR 0498 clauses 5 and 6); on the trunk it
// is taken again to publish them, an image only when an image builder runs (ImageBuilder).
@@ -93,11 +95,13 @@ func GoneStep(gone []string) string {
// Unpublished is whether a done build holds an image built without a push or a package built without a publish: it has
// no reference (a candidate's, ADR 0487 clause 12, ADR 0498 clauses 5 and 6).
func Unpublished(b builds.BuilderBuild) bool { return len(UnpublishedModules(b)) > 0 }
func Unpublished(b builds.BuilderBuild, seats *buildrun.BuildSeats) bool {
return len(UnpublishedModules(b, seats)) > 0
}
// UnpublishedModules are the build modules that publish what a done build holds unpublished: the image build module for
// an image, the Node build module for a package; sorted, none when it holds nothing unpublished.
func UnpublishedModules(b builds.BuilderBuild) []string {
func UnpublishedModules(b builds.BuilderBuild, seats *buildrun.BuildSeats) []string {
if b.State != builds.BuildDone {
return nil
}
@@ -108,10 +112,14 @@ func UnpublishedModules(b builds.BuilderBuild) []string {
}
var out []string
if image {
out = append(out, ImageBuildModule)
if bm, err := seats.For("", "image"); err == nil {
out = append(out, bm.Module)
}
}
if pkg {
out = append(out, buildrun.NodeBuildModule)
if bm, err := seats.For("typescript", catalogue.ArtifactPackage); err == nil {
out = append(out, bm.Module)
}
}
sort.Strings(out)
return out
@@ -119,10 +127,10 @@ func UnpublishedModules(b builds.BuilderBuild) []string {
// publishable are the build modules of UnpublishedModules that can publish now: the image build module only while a node
// runs it (imageBuilder).
func publishable(b builds.BuilderBuild, imageBuilder func() bool) []string {
func publishable(b builds.BuilderBuild, seats *buildrun.BuildSeats, imageBuilder func() bool) []string {
var out []string
for _, bm := range UnpublishedModules(b) {
if bm != ImageBuildModule || (imageBuilder != nil && imageBuilder()) {
for _, bm := range UnpublishedModules(b, seats) {
if seat, _ := seats.OfModule(bm); !seat.ByKind || (imageBuilder != nil && imageBuilder()) {
out = append(out, bm)
}
}
@@ -191,7 +199,7 @@ func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string
if err != nil {
err = fmt.Errorf("%s: whether the artifact store still holds %s cannot be asked, so the key is "+
"left done (the controller's take-in refuses it if one is gone): %w", key, strings.Join(refs, ", "), err)
if len(publishable(cur, opt.ImageBuilder)) > 0 {
if len(publishable(cur, opt.Seats, opt.ImageBuilder)) > 0 {
return &Claim{Key: key, Rev: rev, Build: cur, Unasked: err}, nil
}
return nil, err
@@ -207,7 +215,7 @@ func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string
}
}
}
if opt.Trunk && len(publishable(cur, opt.ImageBuilder)) > 0 {
if opt.Trunk && len(publishable(cur, opt.Seats, opt.ImageBuilder)) > 0 {
return &Claim{Key: key, Rev: rev, Build: cur}, nil
}
return nil, nil
@@ -13,11 +13,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ImageBuildModule is the build module that builds an image artifact (novox/hq ADR 0487): buildah, rootless, on the nodes that
// run it. It has no toolchain language; the builder finds its nodes from the node-engine's have.
const ImageBuildModule = "mesh-build-image"
// NoImageBuilderPrefix opens the error of a build key failed because no node runs ImageBuildModule (ADR 0487 clause 5): such a
// NoImageBuilderPrefix opens the error of a build key failed because no node runs the image build module (ADR 0487 clause 5): such a
// key is not built again as a new revision, only when a node reports the module (ClaimOptions.ImageBuilder) or the fingerprint
// changes.
const NoImageBuilderPrefix = "no image builder:"
@@ -82,34 +78,55 @@ func (n *NotPinned) Error() string {
return fmt.Sprintf("%s%s: %q names no digest (@sha256:), so what would be copied is whatever its tag means today", builds.NotPinned, n.Artifact, n.From)
}
// NoBuildModule is the error of an artifact whose build seat or its holder the seat list and the catalog half do not name (yet):
// the plan is not kept, so it is made again when they are read (design 47 G A-82).
type NoBuildModule struct {
Artifact string
Err error
}
func (n *NoBuildModule) Error() string { return fmt.Sprintf("artifact %s: %v", n.Artifact, n.Err) }
func (n *NoBuildModule) Unwrap() error { return n.Err }
// PlanOf is the plan of a manifest (ADR 0478 clauses 1 and 2, ADR 0498 clauses 4 and 5): a Go bundle and an archive are
// built by the Go build module, an image by the image build module (ADR 0487), which also copies an upstream image pinned
// by its digest, and a TypeScript bundle or package by the Node one, each in the order of the artifacts' names. A manifest
// with an upstream artifact pinned by no digest, or a bundle or a package of another language, or a kind no build module
// builds, is refused as a whole, naming the first such artifact by name. A manifest with no artifact has an empty plan.
func PlanOf(m catalogue.Manifest) (Plan, error) {
func PlanOf(m catalogue.Manifest, seats *buildrun.BuildSeats) (Plan, error) {
if m.Build == nil {
return Plan{}, nil
}
artifacts := append([]catalogue.Artifact(nil), m.Build.Artifacts...)
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
p := Plan{Series: m.Version}
task := func(a catalogue.Artifact, language, kind string) error {
bm, err := seats.For(language, kind)
if err != nil {
return &NoBuildModule{Artifact: a.Name, Err: err}
}
t := Task{Artifact: a.Name, Kind: a.Kind, Module: bm.Module, Language: bm.Toolchain, From: a.From}
if a.Kind != catalogue.ArtifactUpstream {
t.From = ""
}
p.Tasks = append(p.Tasks, t)
return nil
}
for _, a := range artifacts {
var err error
switch {
case a.Kind == catalogue.ArtifactArchive:
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.GoBuildModule, Language: "go"})
case a.Kind == catalogue.ArtifactBundle && a.Language == "go":
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.GoBuildModule, Language: "go"})
case a.Kind == catalogue.ArtifactBundle && a.Language == "typescript":
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.NodeBuildModule, Language: "typescript"})
err = task(a, "go", a.Kind) // an archive is packed by the Go build seat's holder, which the indexer gives no language
case a.Kind == catalogue.ArtifactBundle && (a.Language == "go" || a.Language == "typescript"):
err = task(a, a.Language, a.Kind)
case a.Kind == catalogue.ArtifactImage:
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: ImageBuildModule})
err = task(a, "", "image")
case a.Kind == catalogue.ArtifactUpstream && !strings.Contains(a.From, "@sha256:"):
return Plan{}, &NotPinned{Artifact: a.Name, From: a.From}
case a.Kind == catalogue.ArtifactUpstream:
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: ImageBuildModule, From: a.From})
err = task(a, "", "image")
case a.Kind == catalogue.ArtifactPackage && a.Language == "typescript":
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.NodeBuildModule, Language: "typescript"})
err = task(a, a.Language, a.Kind)
case a.Kind == catalogue.ArtifactBundle:
return Plan{}, &Refused{Artifact: a.Name, What: fmt.Sprintf("bundle of language %q", a.Language)}
case a.Kind == catalogue.ArtifactPackage:
@@ -117,6 +134,9 @@ func PlanOf(m catalogue.Manifest) (Plan, error) {
default:
return Plan{}, &Refused{Artifact: a.Name, What: a.Kind}
}
if err != nil {
return Plan{}, err
}
}
return p, nil
}
@@ -10,6 +10,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// manifestOf is a manifest holding these artifacts: the plan reads only their name, kind and language.
@@ -22,14 +23,14 @@ func TestPlanRoutesEachKindToItsBuildModule(t *testing.T) { // 1607/AC2
catalogue.Artifact{Name: "tool", Kind: "bundle", Language: "go"},
catalogue.Artifact{Name: "web", Kind: "bundle", Language: "typescript"},
catalogue.Artifact{Name: "files", Kind: "archive"})
p, err := PlanOf(m)
p, err := PlanOf(m, testSeats())
if err != nil {
t.Fatal(err)
}
want := []Task{
{Artifact: "files", Kind: "archive", Module: buildrun.GoBuildModule, Language: "go"},
{Artifact: "tool", Kind: "bundle", Module: buildrun.GoBuildModule, Language: "go"},
{Artifact: "web", Kind: "bundle", Module: buildrun.NodeBuildModule, Language: "typescript"},
{Artifact: "files", Kind: "archive", Module: testGo, Language: "go"},
{Artifact: "tool", Kind: "bundle", Module: testGo, Language: "go"},
{Artifact: "web", Kind: "bundle", Module: testNode, Language: "typescript"},
}
if len(p.Tasks) != len(want) {
t.Fatalf("tasks = %+v, want %+v", p.Tasks, want)
@@ -39,7 +40,7 @@ func TestPlanRoutesEachKindToItsBuildModule(t *testing.T) { // 1607/AC2
t.Errorf("task %d = %+v, want %+v", i, p.Tasks[i], want[i])
}
}
if modules := p.Modules(); len(modules) != 2 || modules[0] != buildrun.GoBuildModule || modules[1] != buildrun.NodeBuildModule {
if modules := p.Modules(); len(modules) != 2 || modules[0] != testGo || modules[1] != testNode {
t.Errorf("modules = %v", modules)
}
}
@@ -47,24 +48,24 @@ func TestPlanRoutesEachKindToItsBuildModule(t *testing.T) { // 1607/AC2
const pinned = "nats:2.11.17-alpine@sha256:3333333333333333333333333333333333333333333333333333333333333333"
func TestPlanSendsAPinnedUpstreamToTheImageBuildModulesCopyAndRefusesAnUnpinnedOne(t *testing.T) { // B3, ADR 0498 clause 4
p, err := PlanOf(manifestOf(catalogue.Artifact{Name: "ok", Kind: "archive"}, catalogue.Artifact{Name: "up", Kind: "upstream", From: pinned}))
p, err := PlanOf(manifestOf(catalogue.Artifact{Name: "ok", Kind: "archive"}, catalogue.Artifact{Name: "up", Kind: "upstream", From: pinned}), testSeats())
if err != nil || len(p.Tasks) != 2 {
t.Fatalf("plan %+v, error %v", p, err)
}
if up := p.Tasks[1]; up.Artifact != "up" || up.Kind != "upstream" || up.Module != ImageBuildModule || up.From != pinned || up.Language != "" {
t.Errorf("task = %+v, want the upstream sent to %s with its pinned reference and no toolchain", up, ImageBuildModule)
if up := p.Tasks[1]; up.Artifact != "up" || up.Kind != "upstream" || up.Module != testImage || up.From != pinned || up.Language != "" {
t.Errorf("task = %+v, want the upstream sent to %s with its pinned reference and no toolchain", up, testImage)
}
if p.NeedsToolchain(ImageBuildModule) {
if p.NeedsToolchain(testImage) {
t.Error("a copy needs no toolchain")
}
_, err = PlanOf(manifestOf(catalogue.Artifact{Name: "up", Kind: "upstream", From: "nats:2.11.17-alpine"}))
_, err = PlanOf(manifestOf(catalogue.Artifact{Name: "up", Kind: "upstream", From: "nats:2.11.17-alpine"}), testSeats())
if err == nil || !strings.HasPrefix(err.Error(), builds.NotPinned+"up") || !builds.FailedUntilChanged(err.Error()) {
t.Errorf("an upstream with no digest: error = %v, want it to open %q naming the artifact", err, builds.NotPinned)
}
}
func TestPlanRefusesAPackageOfAnotherLanguageByName(t *testing.T) { // ADR 0498 clause 5
_, err := PlanOf(manifestOf(catalogue.Artifact{Name: "ok", Kind: "archive"}, catalogue.Artifact{Name: "pkg", Kind: "package", Language: "python"}))
_, err := PlanOf(manifestOf(catalogue.Artifact{Name: "ok", Kind: "archive"}, catalogue.Artifact{Name: "pkg", Kind: "package", Language: "python"}), testSeats())
var r *Refused
if err == nil || !asRefused(err, &r) || !strings.HasPrefix(err.Error(), builds.RefusedKind) || !strings.Contains(err.Error(), "python") || !strings.Contains(err.Error(), "pkg") {
t.Errorf("error = %v, want it to open %q naming the artifact and its language", err, builds.RefusedKind)
@@ -72,10 +73,10 @@ func TestPlanRefusesAPackageOfAnotherLanguageByName(t *testing.T) { // ADR 0498
}
func TestPlanRefusesABundleOfAnotherLanguageAndAModuleWithNoArtifact(t *testing.T) {
if _, err := PlanOf(manifestOf(catalogue.Artifact{Name: "x", Kind: "bundle", Language: "python"})); err == nil || !strings.HasPrefix(err.Error(), builds.RefusedKind) {
if _, err := PlanOf(manifestOf(catalogue.Artifact{Name: "x", Kind: "bundle", Language: "python"}), testSeats()); err == nil || !strings.HasPrefix(err.Error(), builds.RefusedKind) {
t.Errorf("a python bundle: error = %v, want a refusal", err)
}
p, err := PlanOf(manifestOf())
p, err := PlanOf(manifestOf(), testSeats())
if err != nil || len(p.Tasks) != 0 {
t.Errorf("no artifact: plan %+v error %v, want an empty plan", p, err)
}
@@ -84,14 +85,36 @@ func TestPlanRefusesABundleOfAnotherLanguageAndAModuleWithNoArtifact(t *testing.
func asRefused(err error, target **Refused) bool { return errors.As(err, target) }
func TestPlanSendsAnImageToMeshBuildImageWithNoToolchain(t *testing.T) { // 1705a, ADR 0487 clause 5
p, err := PlanOf(manifestOf(catalogue.Artifact{Name: "img", Kind: "image"}, catalogue.Artifact{Name: "files", Kind: "archive"}))
p, err := PlanOf(manifestOf(catalogue.Artifact{Name: "img", Kind: "image"}, catalogue.Artifact{Name: "files", Kind: "archive"}), testSeats())
if err != nil || len(p.Tasks) != 2 {
t.Fatalf("plan %+v, error %v", p, err)
}
if p.Tasks[1].Artifact != "img" || p.Tasks[1].Module != ImageBuildModule {
t.Errorf("task = %+v, want the image on %s", p.Tasks[1], ImageBuildModule)
if p.Tasks[1].Artifact != "img" || p.Tasks[1].Module != testImage {
t.Errorf("task = %+v, want the image on %s", p.Tasks[1], testImage)
}
if p.NeedsToolchain(ImageBuildModule) {
if p.NeedsToolchain(testImage) {
t.Error("an image needs no toolchain")
}
}
// A plan's task names the holder of the build seat the seat list and the catalog half give, and a plan made before they name one
// is no refusal and is not kept (design 47 G A-82).
func TestAPlanNamesTheHoldersOfTheBuildSeats(t *testing.T) {
m := manifestOf(catalogue.Artifact{Name: "tool", Kind: "bundle", Language: "go"}, catalogue.Artifact{Name: "img", Kind: "image"})
defs := map[string]seatdef.Definition{
"node-build-go": {Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: "go"}}},
"node-build-image": {Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: seatdef.OnKindImage}}},
}
holders := map[string]string{}
seats := buildrun.NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(s string) (string, bool) { h, ok := holders[s]; return h, ok })
_, err := PlanOf(m, seats)
var none *NoBuildModule
if !errors.As(err, &none) {
t.Fatalf("with no holder: %v, want NoBuildModule", err)
}
holders["node-build-go"], holders["node-build-image"] = "acme-go", "acme-img"
p, err := PlanOf(m, seats)
if err != nil || len(p.Tasks) != 2 || p.Tasks[0].Module != "acme-img" || p.Tasks[1].Module != "acme-go" || p.Tasks[1].Language != "go" || p.Tasks[0].Language != "" {
t.Fatalf("plan = %+v, %v", p, err)
}
}
@@ -42,12 +42,12 @@ func TestAnArchiveOnlyModuleIsBuiltAlthoughItsFingerprintNamesNoToolchain(t *tes
}
func TestABundleAndAnArchiveOfOneBuildModuleStillNeedTheAgreedToolchain(t *testing.T) { // review round 4, 1
p, err := PlanOf(catalogue.Manifest{Module: "m", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{goBundle, archiveA}}})
if err != nil || !p.NeedsToolchain(buildrun.GoBuildModule) {
t.Errorf("a plan with a Go bundle: %v, %v", p.NeedsToolchain(buildrun.GoBuildModule), err)
p, err := PlanOf(catalogue.Manifest{Module: "m", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{goBundle, archiveA}}}, testSeats())
if err != nil || !p.NeedsToolchain(testGo) {
t.Errorf("a plan with a Go bundle: %v, %v", p.NeedsToolchain(testGo), err)
}
p, _ = PlanOf(catalogue.Manifest{Module: "m", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{archiveA}}})
if p.NeedsToolchain(buildrun.GoBuildModule) {
p, _ = PlanOf(catalogue.Manifest{Module: "m", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{archiveA}}}, testSeats())
if p.NeedsToolchain(testGo) {
t.Error("a plan of an archive needs a toolchain")
}
}
@@ -41,6 +41,8 @@ type ToolchainEntries interface {
// change of the toolchains.
type Builder struct {
Claimer Claimer
// Seats finds the build modules: the holders of the build seats on the seat list and the catalog half (design 47 G A-82).
Seats *buildrun.BuildSeats
// Asks puts each build module's part of a build as one ask on its build seat's stream and waits for its step (novox/hq
// ADR 0525 §1, §3, §6; issue 1887 A4): the builder chooses no node, whichever build node pulls the ask builds it.
Asks interface {
@@ -499,7 +501,11 @@ func (b *Builder) planOf(ctx context.Context, digest string) (Plan, error, bool)
if err != nil {
return Plan{}, fmt.Errorf("its manifest %s cannot be read: %w", digest, err), false
}
plan, err := PlanOf(m)
plan, err := PlanOf(m, b.Seats)
var none *NoBuildModule
if errors.As(err, &none) {
return plan, err, false // the seat list or the catalog half names no build module yet: made again when they do
}
b.mu.Lock()
b.plans[digest] = planned{plan, err}
b.mu.Unlock()
@@ -575,6 +581,13 @@ func (b *Builder) try(ctx context.Context, module string, m meshcatalog.ModulesB
defer b.handEnded(key)
defer b.unrelease(key)
plan, planErr, read := b.planOf(ctx, m.Manifest)
var noModule *NoBuildModule
if errors.As(planErr, &noModule) {
// The seat list or the catalog half does not name the build module yet: nothing is claimed, and the look is made again
// when they do (Reconsider, at each change of either; design 47 G A-82).
b.logf("%s at %s is not built yet: %v", module, m.Fp2, planErr)
return
}
nothing := read && planErr == nil && len(plan.Tasks) == 0
if planErr == nil && !nothing && !b.isReleased(key) {
if why := b.stale(m, plan); why != "" {
@@ -617,7 +630,7 @@ func (b *Builder) try(ctx context.Context, module string, m meshcatalog.ModulesB
}
// A key built again by hand is taken over as it stands too: one left building by a look that ended without its write
// (issue 1966) is the hand tool's to finish.
cl, err := b.Claimer.Claim(ctx, module, m.Fp2, m.Manifest, ClaimOptions{TakeOver: first || byHand, ByHand: byHand, ImageBuilder: imageSeat, Trunk: trunk, Published: b.isPublished,
cl, err := b.Claimer.Claim(ctx, module, m.Fp2, m.Manifest, ClaimOptions{TakeOver: first || byHand, ByHand: byHand, ImageBuilder: imageSeat, Seats: b.Seats, Trunk: trunk, Published: b.isPublished,
Stored: b.storedOnce(key)})
if err != nil {
b.logf("%s: %v", module, err)
@@ -643,7 +656,7 @@ func (b *Builder) try(ctx context.Context, module string, m meshcatalog.ModulesB
if cl.Build.State == builds.BuildDone {
prior, only = &cl.Build, map[string]bool{}
if trunk {
for _, bm := range publishable(cl.Build, imageSeat) {
for _, bm := range publishable(cl.Build, b.Seats, imageSeat) {
only[bm] = true
publishing = true
}
@@ -744,7 +757,7 @@ func (b *Builder) stale(m meshcatalog.ModulesByModuleValue, plan Plan) string {
if !plan.NeedsToolchain(bm) {
continue // an archive is packed with no toolchain
}
language := buildrun.LanguageOf[bm]
language, _ := b.Seats.LanguageOf(bm)
ag := sdkstep.ToolchainAgreement(b.Toolchains.Entries(language))
if ag.Wait != "" {
return fmt.Sprintf("the %s toolchain is not agreed: %s", language, ag.Wait)
@@ -798,9 +811,9 @@ func (b *Builder) build(ctx context.Context, module string, m meshcatalog.Module
continue
}
var toolchain *builds.ToolchainUsed
if bm != ImageBuildModule && plan.NeedsToolchain(bm) {
if plan.NeedsToolchain(bm) {
// The ask names the toolchain the fingerprint was computed with; a node building with another refuses it.
language := buildrun.LanguageOf[bm]
language, _ := b.Seats.LanguageOf(bm)
ag := sdkstep.ToolchainAgreement(b.Toolchains.Entries(language))
if ag.Wait != "" {
return failed("the %s toolchain is not agreed: %s", language, ag.Wait)
@@ -910,8 +923,9 @@ func (b *Builder) build(ctx context.Context, module string, m meshcatalog.Module
}
for _, bm := range kept {
steps = append(steps, bm+" kept")
kept, _ := b.Seats.LanguageOf(bm)
for lang, u := range prior.Toolchains {
if _, ok := used[lang]; !ok && buildrun.LanguageOf[bm] == lang {
if _, ok := used[lang]; !ok && kept == lang {
used[lang] = u
}
}
@@ -990,9 +1004,6 @@ func keyError(errs []string) string {
// want of an image builder before R4g is taken again (ADR 0525 §1).
func imageSeat() bool { return true }
// askLanguage is the build seat language of each build module (sdkstep.AskSubject).
var askLanguage = map[string]string{buildrun.GoBuildModule: sdkstep.AskGo, buildrun.NodeBuildModule: sdkstep.AskNode, ImageBuildModule: sdkstep.AskImage}
// askNotes writes a claimed key's asks into it, building, before each is put (ADR 0525 §1; issue 1887 A4b), and holds
// the asks a key taken over at start already names, so they are waited for and never put again.
type askNotes struct {
@@ -1062,7 +1073,8 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m mes
sort.Strings(req.Paths)
}
candidate := req.Base != ""
language := askLanguage[l.module]
seat, _ := b.Seats.OfModule(l.module)
language := seat.AskLanguage()
kind := sdkstep.AskDelivery
if candidate {
kind = sdkstep.AskCheck
@@ -145,7 +145,7 @@ type rig struct {
}
// toolPrefix is the start of each build module's tool names, as the fake build modules answer them.
var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun.NodeBuildModule: "build_node_", ImageBuildModule: "build_image_"}
var toolPrefix = map[string]string{testGo: "build_go_", testNode: "build_node_", testImage: "build_image_"}
// answer runs an ask as the build module that pulls it does (runAsk), with the fake build modules' tools on the
// rig's pulling node, and answers the step that node writes.
@@ -178,14 +178,14 @@ func (r *rig) answer(a buildstep.StepAsk) buildstep.Step {
func newRig(t *testing.T, artifacts ...catalogue.Artifact) *rig {
t.Helper()
r := &rig{st: newMem(), bm: newBuildModules(), tools: &buildrun.ToolchainSet{}, mf: &fakeManifests{byDigest: map[string]catalogue.Manifest{mdigest('a'): {Module: "m", Build: &catalogue.Build{Artifacts: artifacts}}}}}
r := &rig{st: newMem(), bm: newBuildModules(), tools: &buildrun.ToolchainSet{Seats: testSeats()}, mf: &fakeManifests{byDigest: map[string]catalogue.Manifest{mdigest('a'): {Module: "m", Build: &catalogue.Build{Artifacts: artifacts}}}}}
for _, n := range []string{"ace", "novox"} {
r.tools.Set(n, tcOf("go", "go1.27.1"))
r.tools.Set(n, tcOf("typescript", "tsc5"))
}
r.asks = &buildrun.FakeAsks{Answer: r.answer}
r.b = &Builder{
Claimer: Claimer{Store: r.st, By: "novox.builder"}, Asks: buildrun.Asker{Publisher: r.asks, Waiter: r.asks, Bound: 2 * time.Second},
Seats: testSeats(), Claimer: Claimer{Store: r.st, By: "novox.builder"}, Asks: buildrun.Asker{Seats: testSeats(), Publisher: r.asks, Waiter: r.asks, Bound: 2 * time.Second},
Toolchains: r.tools, Manifests: r.mf, Logf: t.Logf,
Trunk: func(context.Context, uint64, string) (string, error) { return trunkHead, nil },
}
@@ -660,7 +660,7 @@ func aNewerFingerprintWithdrawsTheBuildOfTheOlderOne(t *testing.T) {
}
return r.answer(a)
}
r.b.Asks = buildrun.Asker{Publisher: r.asks, Waiter: r.asks, Bound: 2 * time.Second, Dropper: drops}
r.b.Asks = buildrun.Asker{Seats: testSeats(), Publisher: r.asks, Waiter: r.asks, Bound: 2 * time.Second, Dropper: drops}
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
<-running
fp2 := "src2:" + hex64('2')
@@ -908,7 +908,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) {
t.Run("waits in its stream: never asked again", func(t *testing.T) {
r := newRig(t, goBundle)
r.asks.Answer = nil
r.b.Asks = buildrun.Asker{Publisher: r.asks, Waiter: r.asks, Bound: 20 * time.Millisecond}
r.b.Asks = buildrun.Asker{Seats: testSeats(), Publisher: r.asks, Waiter: r.asks, Bound: 20 * time.Millisecond}
gone := &goneAsks{why: func(buildstep.StepAsk) string { return "" }}
r.b.Gone, r.b.CheckAfter = gone, 5*time.Millisecond
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
@@ -1115,12 +1115,12 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) {
gone := &goneAsks{}
dead := &buildrun.DeadLetters{}
if c.dead {
r.b.Asks = buildrun.Asker{Publisher: r.asks, Waiter: buildrun.LostAskWaiter{Inner: r.asks, Dead: dead}, Bound: 2 * time.Second}
r.b.Asks = buildrun.Asker{Seats: testSeats(), Publisher: r.asks, Waiter: buildrun.LostAskWaiter{Inner: r.asks, Dead: dead}, Bound: 2 * time.Second}
}
var heard *buildrun.HeardEnds
if c.deaf {
heard = &buildrun.HeardEnds{Inner: silentWatch{}, Read: r.asks}
r.b.Asks = buildrun.Asker{Publisher: r.asks, Waiter: heard, Bound: 2 * time.Second}
r.b.Asks = buildrun.Asker{Seats: testSeats(), Publisher: r.asks, Waiter: heard, Bound: 2 * time.Second}
}
if !c.byHand && !c.dead && !c.deaf {
gone.why = func(a buildstep.StepAsk) string {
@@ -1151,7 +1151,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) {
t.Fatal("no ask is waited on")
}
}
heard.Nudge(buildrun.GoBuildModule) // a step of mesh-build-go ended
heard.Nudge(testGo) // a step of mesh-build-go ended
}
if c.done && !c.deaf {
asks := waitForAsks(t, r, c.puts)
@@ -1262,7 +1262,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) {
running, _ := json.Marshal(buildstep.Step{V: keys.Version, ID: "mvo53jcm70b5f8", Tool: "delivery ask", Node: "g14", State: buildstep.StepRunning, Started: at})
_, reask, _ := NewReAsk("m", fp1, "stuck", "shanks.lead", at)
r := StuckReader{Builds: state(map[string]string{key: string(building), done: string(finished)}), ReAsks: state(map[string]string{key: string(reask)}),
Steps: map[string]StateRead{buildrun.GoBuildModule: state(map[string]string{}), ImageBuildModule: state(map[string]string{"g14.mvo53jcm70b5f8": string(running)})},
Seats: testSeats(), Steps: map[string]StateRead{"node-build-go": state(map[string]string{}), "node-build-image": state(map[string]string{"g14.mvo53jcm70b5f8": string(running)})},
Hearing: state(map[string]string{buildrun.HearingKey: `{"heard":false,"streams":["SEAT_NODE_BUILD_GO"],"why":"DEAD_LETTERS is not heard: SEAT_NODE_BUILD_GO: permissions violation","at":"2026-10-26T13:00:00Z"}`}),
Now: func() time.Time { return at.Add(time.Hour) }}
out, err := r.Stuck("")
@@ -0,0 +1,23 @@
package build
import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The build modules of the fixture seat list: the holders of the three build seats (design 47 G A-82).
const (
testGo = "mesh-build-go"
testNode = "mesh-build-node"
testImage = "mesh-build-image"
)
// testSeats is the finder over a seat list whose build seats act on go, typescript and kind:image, each held by its module.
func testSeats() *buildrun.BuildSeats {
rule := func(on string) seatdef.Definition {
return seatdef.Definition{Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: on}}}
}
defs := map[string]seatdef.Definition{"node-build-go": rule("go"), "node-build-node": rule("typescript"), "node-build-image": rule(seatdef.OnKindImage)}
holders := map[string]string{"node-build-go": testGo, "node-build-node": testNode, "node-build-image": testImage}
return buildrun.NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok })
}
@@ -239,8 +239,13 @@ type Stuck struct {
type StuckReader struct {
Builds StateRead
ReAsks StateRead
// Steps are the build modules' steps states, by build module.
// Steps are the build seats' steps states (`<seat>.steps`, design 47 G A-82), by build seat.
Steps map[string]StateRead
// StepsOf, when set, reads the steps state of a build seat the seat list names (`<seat>.steps`), instead of Steps: the seats
// are known only once the list is read.
StepsOf func(seat string) StateRead
// Seats finds the build module of a build seat, whose name the key's step words give.
Seats *buildrun.BuildSeats
// Hearing is the builder's state hearing: whether it hears DEAD_LETTERS (issue 734); without it none is said.
Hearing StateRead
Now func() time.Time
@@ -318,7 +323,14 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) {
}
// Steps are found by the ask's id, which their key ends in: one index per build module, no value read until an ask names it.
stepKeys := map[string]map[string]string{}
for bm, s := range r.Steps {
steps := r.Steps
if r.StepsOf != nil {
steps = map[string]StateRead{}
for _, m := range r.Seats.All() {
steps[m.Seat] = r.StepsOf(m.Seat)
}
}
for bm, s := range steps {
ks, err := s.Keys()
if err != nil {
return nil, fmt.Errorf("the steps of %s: %w", bm, err)
@@ -385,13 +397,14 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) {
s.Age = now().Sub(at).Round(time.Second).String()
}
for language, id := range v.Asks {
bm, _ := buildstep.BuildModuleOf(language)
a := StuckAsk{Language: language, BuildModule: bm, ID: id, Asking: askingOf(v.Step, bm), Step: "none"}
seat, _ := buildstep.BuildSeat(language)
bm, _ := r.Seats.OfSeat(seat)
a := StuckAsk{Language: language, BuildModule: bm.Module, ID: id, Asking: askingOf(v.Step, bm.Module), Step: "none"}
if t, ok := idTime(id); ok {
a.Asked = t
}
if st, ok := r.Steps[bm]; ok {
if k, ok := stepKeys[bm][id]; ok {
if st, ok := steps[seat]; ok {
if k, ok := stepKeys[seat][id]; ok {
reads++
if raw, _, err := st.Get(k); err == nil && raw != nil {
if step, err := buildstep.DecodeStep(raw); err == nil {
@@ -432,9 +445,6 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) {
return answer, nil
}
// StuckBuildModules are the build modules whose steps builder_stuck reads.
var StuckBuildModules = []string{buildrun.GoBuildModule, buildrun.NodeBuildModule, ImageBuildModule}
// idTime is when a step id was made (buildstep.NewID: the time in milliseconds in base 36, then six hex digits).
func idTime(id string) (time.Time, bool) {
if len(id) <= 6 {
@@ -9,7 +9,6 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
)
@@ -57,7 +56,7 @@ func (b *Builder) toolchainWait(module string, m meshcatalog.ModulesByModuleValu
if !plan.NeedsToolchain(bm) {
continue
}
language := buildrun.LanguageOf[bm]
language, _ := b.Seats.LanguageOf(bm)
ag := sdkstep.ToolchainAgreement(b.Toolchains.Entries(language))
if want, ok := m.Toolchains[language]; ag.Wait == "" && ok && want.Version != ag.Toolchain.Version {
return ToolchainWait{Module: module, Fingerprint: m.Fp2, Language: language, Version: want.Version, Since: b.now()}, true
@@ -2,11 +2,15 @@ package versions
import (
"context"
"encoding/json"
"slices"
"sort"
"strings"
"sync"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Entries are the catalog graph entries of the halves the builder follows, one per module and watched branch (design 47 G §348,
@@ -80,3 +84,54 @@ func EntriesOf(branches *meshcatalogclient.Branches) Entries {
return out
}
}
// HolderOf finds the module that holds a seat: the one whose entry in the first followed branch's half (the trunk's) has a
// manifest that claims it (design 47 G A-82; the seat list answers seat definitions alone, §527). More than one claimant, or
// none, is no holder.
func HolderOf(branches *meshcatalogclient.Branches) func(seat string) (string, bool) {
var mu sync.Mutex
claims := map[string][]string{} // manifest digest -> the seats it claims
claimed := func(e meshcatalog.CatalogEntry) []string {
mu.Lock()
defer mu.Unlock()
if seats, ok := claims[e.ManifestDigest]; ok && e.ManifestDigest != "" {
return seats
}
var seats []string
if raw, err := json.Marshal(e.Manifest); err == nil {
if mf, err := catalogue.Decode(raw); err == nil {
for _, c := range mf.Claims {
seats = append(seats, c.Name)
}
}
}
if e.ManifestDigest != "" {
claims[e.ManifestDigest] = seats
}
return seats
}
return func(seat string) (string, bool) {
followed := branches.Followed()
if len(followed) == 0 {
return "", false
}
cat, ok := branches.Catalog(followed[0])
if !ok {
return "", false
}
half, ok := cat.View()
if !ok {
return "", false
}
var holders []string
for _, e := range half.Entries {
if slices.Contains(claimed(e), seat) {
holders = append(holders, e.Module)
}
}
if len(holders) != 1 {
return "", false
}
return holders[0], true
}
}
@@ -77,11 +77,9 @@ const (
// 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",
}
// SourceSeats are the seats whose holders are the floor's tag and requirement sources (design 47 G A-82): the seat git (tags)
// and the seat mesh-catalog (requirements), the holders read from the seat list and the catalog half.
var SourceSeats = map[string]string{SourceTags: "git", SourceRequirements: "mesh-catalog"}
// 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.
@@ -135,6 +133,8 @@ type FloorWaits struct {
keeper FloorKeeper
// Logf says a wait that could not be kept; nil says nothing.
Logf func(string, ...any)
// Holder finds the module holding a seat (the catalog half's claim); a source's module is the holder of its SourceSeats seat.
Holder func(seat string) (string, bool)
// Said is told each wait recorded (raised) and each answered (not raised): the builder's finding floor-silent.
Said func(w FloorWait, raised bool)
}
@@ -290,7 +290,7 @@ func (fw *FloorWaits) Wake(kind, seat, module, name string) []string {
}
case WakeCleared:
for _, s := range w.Silent {
if ModuleOfSource[s] == module || (s == SourceSeat && w.Holder != "" && w.Holder == module) {
if fw.holds(SourceSeats[s], module) || (s == SourceSeat && w.Holder != "" && w.Holder == module) {
out = append(out, n)
break
}
@@ -317,3 +317,12 @@ func (fw *FloorWaits) Waiting() []FloorWait {
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// holds is whether module holds seat on the catalog half.
func (fw *FloorWaits) holds(seat, module string) bool {
if seat == "" || module == "" || fw.Holder == nil {
return false
}
holder, ok := fw.Holder(seat)
return ok && holder == module
}
@@ -251,7 +251,8 @@ func TestTheBuildersVersions(t *testing.T) {
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
holders := map[string]string{SourceSeats[SourceTags]: "mesh-delivery-forge-bridge", SourceSeats[SourceRequirements]: "mesh-delivery-indexer"}
fw := FloorWaits{Holder: func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok }}
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())
@@ -118,8 +118,8 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
// 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). Whether a
// node's machine is heard it asks the watchdogs seat's presence (issue 1966, ADR 0596 §2, ADR 0668 §3).
if want := []string{"mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps",
"mesh-delivery-assigner.assignments", "mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) {
if want := []string{"mesh-delivery-assigner.assignments", "mesh-delivery-gate.source",
"node-build-go.steps", "node-build-image.steps", "node-build-node.steps"}; !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) {
@@ -140,7 +140,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
}
// Design 51 *The floor*: the floor's three sources, the registry seats' versions, bridge_tags and indexer_requirements.
// The watchdogs seat's presence says a node's machine is heard (issue 1966, ADR 0596 §2, ADR 0668 §3).
if want := []string{"mesh-delivery-assigner.assigner_read_assignments", "mesh-delivery-forge-bridge.bridge_tags", "mesh-delivery-indexer.indexer_requirements", "seat:mesh-catalog.entry", "seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-delivery.source", "seat:container-image-registry.remove", "seat:container-image-registry.versions",
if want := []string{"mesh-delivery-assigner.assigner_read_assignments", "mesh-delivery-forge-bridge.bridge_tags", "mesh-delivery-indexer.indexer_requirements", "seat:mesh-catalog.entry", "seat:mesh-catalog.registrations", "seat:mesh-catalog.seat-definitions", "seat:mesh-catalog.revision", "seat:mesh-delivery.source", "seat:container-image-registry.remove", "seat:container-image-registry.versions",
"seat:git.tag",
"seat:go-package-registry.remove", "seat:go-package-registry.versions", "seat:mesh-artifact-sweep.held", "seat:mesh-builds.reserved", "seat:mesh-catalog.versions", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence", "seat:node-build-go.drop", "seat:node-build-image.drop", "seat:node-build-node.drop", "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), sorted(want)) {
@@ -154,7 +154,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
// 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-catalog.manifest-accepted", "mesh-catalog.version-released", "mesh-catalog.versions-filled",
"mesh-artifact-sweep.artifacts-gone", "mesh-catalog.manifest-accepted", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-catalog.version-released", "mesh-catalog.versions-filled",
"mesh-conditions.condition-cleared", "mesh-conditions.findings-asked", "mesh-delivery-assigner.views.assignments", "mesh-delivery-forge-bridge.forge.**",
"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "node-build-go.toolchain-reported", "node-build-node.toolchain-reported", "node-engine.manifest-reads-changed", "node-engine.running",
"npm-package-registry.kept", "npm-package-registry.published", "npm-package-registry.refused", "npm-package-registry.removed",
@@ -169,7 +169,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
}
// 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_release", "builder_reserved", "builder_reserved_drop", "builder_seat_queue", "builder_stuck", "builder_toolchains", "builder_toolchains_drop", "builder_versions", "builder_waits", "reserved"}; !reflect.DeepEqual(sorted(m.Tools), want) {
"builder_floor_again", "builder_floor_waits", "builder_old_form", "builder_publish_again", "builder_release", "builder_reserved", "builder_reserved_drop", "builder_seat_list", "builder_seat_list_again", "builder_seat_queue", "builder_stuck", "builder_toolchains", "builder_toolchains_drop", "builder_versions", "builder_waits", "reserved"}; !reflect.DeepEqual(sorted(m.Tools), want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
// Design 47 G §672: the toolchain waits' stream, kept 7 days within 16 MiB, and the bound's setting, 60 minutes.
+8 -3
View File
@@ -13,6 +13,8 @@
],
"tools": [
"builder_announce_again",
"builder_seat_list",
"builder_seat_list_again",
"builder_ask_again",
"builder_bounds",
"builder_build_again",
@@ -51,6 +53,8 @@
"go-package-registry.removed",
"mesh-artifact-sweep.artifacts-gone",
"mesh-catalog.manifest-accepted",
"mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed",
"mesh-catalog.version-released",
"mesh-catalog.versions-filled",
"mesh-conditions.condition-cleared",
@@ -96,6 +100,7 @@
"mesh-delivery-assigner.assigner_read_assignments",
"seat:mesh-catalog.entry",
"seat:mesh-catalog.registrations",
"seat:mesh-catalog.seat-definitions",
"seat:mesh-catalog.revision",
"seat:mesh-delivery.source"
],
@@ -134,9 +139,9 @@
{ "name": "build-log", "max-age-days": 30, "max-mib": 3072 }
],
"reads": [
"mesh-build-go.steps",
"mesh-build-image.steps",
"mesh-build-node.steps",
"node-build-go.steps",
"node-build-image.steps",
"node-build-node.steps",
"mesh-delivery-assigner.assignments",
"mesh-delivery-gate.source"
],
@@ -0,0 +1,23 @@
package main
import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The checker's names of the build modules, kept until its part finds them on the seat list as the builder does (design 47 G
// A-82 is the builder's change; the builder's package holds no name of them any more): the holders of the three build seats.
const (
goBuildModule = "mesh-build-go"
nodeBuildModule = "mesh-build-node"
)
// checkerBuildSeats is the finder over the build seats those names hold.
var checkerBuildSeats = func() *buildrun.BuildSeats {
rule := func(on string) seatdef.Definition {
return seatdef.Definition{Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: on}}}
}
defs := map[string]seatdef.Definition{"node-build-go": rule("go"), "node-build-node": rule("typescript")}
holders := map[string]string{"node-build-go": goBuildModule, "node-build-node": nodeBuildModule}
return buildrun.NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok })
}()
@@ -404,7 +404,7 @@ func (r *repoModules) PackagePaths(repository string) []string {
// is judged by both, each finding its own directories in the tree (go.mod files; package.json files), so a repository
// with tests in either language and no manifest is judged whole (issue 1887 A12).
func (r *repoModules) BuildModules(repository string) []string {
out := []string{buildrun.GoBuildModule}
out := []string{goBuildModule}
r.mu.Lock()
defer r.mu.Unlock()
known := false
@@ -414,12 +414,12 @@ func (r *repoModules) BuildModules(repository string) []string {
}
known = true
if r.langs[r.typed[module]]["typescript"] {
out = append(out, buildrun.NodeBuildModule)
out = append(out, nodeBuildModule)
break
}
}
if !known {
out = append(out, buildrun.NodeBuildModule)
out = append(out, nodeBuildModule)
}
sort.Strings(out)
return out
@@ -14,7 +14,6 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/keys"
@@ -70,10 +69,10 @@ func TestARepositoryWithATypeScriptModuleIsJudgedByTheNodeBuildModuleToo(t *test
r := &repoModules{manifests: store}
putModule(t, r, "mesh-delivery-checker", "novox/mesh-delivery", goManifest)
putModule(t, r, "mesh-ui", "novox/mesh-catalog", tsManifest)
if got := r.BuildModules("novox/mesh-delivery"); strings.Join(got, ",") != buildrun.GoBuildModule {
if got := r.BuildModules("novox/mesh-delivery"); strings.Join(got, ",") != goBuildModule {
t.Errorf("mesh-delivery: %v, want the Go build module alone", got)
}
if got := r.BuildModules("novox/mesh-catalog"); strings.Join(got, ",") != buildrun.GoBuildModule+","+buildrun.NodeBuildModule {
if got := r.BuildModules("novox/mesh-catalog"); strings.Join(got, ",") != goBuildModule+","+nodeBuildModule {
t.Errorf("mesh-catalog: %v, want both", got)
}
if got := r.CommitOf("mesh-delivery-checker"); got != strings.Repeat("a", 40) || r.CommitOf("gone") != "" {
@@ -81,7 +80,7 @@ func TestARepositoryWithATypeScriptModuleIsJudgedByTheNodeBuildModuleToo(t *test
}
// 1887/A12: a repository with no module the mesh knows (mesh-lab: nested go.mod files and a root package.json) is
// judged by both build modules.
if got := r.BuildModules("novox/mesh-lab"); strings.Join(got, ",") != buildrun.GoBuildModule+","+buildrun.NodeBuildModule {
if got := r.BuildModules("novox/mesh-lab"); strings.Join(got, ",") != goBuildModule+","+nodeBuildModule {
t.Errorf("a repository with no module: %v, want both build modules", got)
}
}
@@ -210,7 +210,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if link == "" {
link = defaultLink
}
tools := &buildrun.ToolchainSet{}
tools := &buildrun.ToolchainSet{Seats: checkerBuildSeats}
presence := &buildrun.PresenceSet{}
trunks := &check.Trunks{}
// A check is one ask per pull request and build module on the build seats' check stream, pulled by a build node with a
@@ -228,7 +228,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
defer hearing.Stop()
// Each ask's step is heard by its own watch and, at every step end of its build module and every reconnect, read
// (buildrun.HearEnds): a watch that goes deaf loses no end (the lost check of 2026-10-28).
heard := buildrun.HearEnds(ctx, asks, asks, []string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, logger.Printf)
heard := buildrun.HearEnds(ctx, asks, asks, []string{goBuildModule, nodeBuildModule}, logger.Printf)
conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") })
waiter := buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}
asksKV := process.AwaitBucket(ctx, js, conn, check.AsksBucket, "checker_ask_again refuses every check, as it cannot tell whether its ask waits in a stream", logger.Printf)
@@ -285,7 +285,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
Asker: engineAsks{call: caller.CallOn},
Placed: placedNodes{kv: func(context.Context) (jetstream.KeyValue, error) { return assignments, nil }}.Placed,
Bound: answerBound,
Sandbox: seatSandbox{asker: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound, Dropper: buildrun.SeatDrops{Caller: caller}}},
Sandbox: seatSandbox{asker: buildrun.Asker{Seats: checkerBuildSeats, Publisher: asks, Waiter: waiter, Bound: stepBound, Dropper: buildrun.SeatDrops{Caller: caller}}},
Verdicts: verdicts.all,
// The node-engine's trunk head: a group with no node-engine member composes with the composer built there (ADR 0656 §5).
Composer: func() string { return trunks.Heads()[check.NodeEngineRepository] },
@@ -314,7 +314,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
ch.Runs = repos // the node-engine a check reads beside at its running commit (ADR 0548 §6)
ch.Says = verdictSays{js: js, node: cred.Node, now: time.Now} // a pull request's verdict event, on the stream asks (design 26 *Out of step*)
ch.Judge = newJudge(judgeDeps{
asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound, Dropper: buildrun.SeatDrops{Caller: caller}}, tools: tools, trunks: trunks,
asks: buildrun.Asker{Seats: checkerBuildSeats, Publisher: asks, Waiter: waiter, Bound: stepBound, Dropper: buildrun.SeatDrops{Caller: caller}}, tools: tools, trunks: trunks,
kept: kvStore{buckets[check.CheckerKeptBucket]}, previews: kvStore{buckets[check.PreviewsBucket]}, candidates: tryCandidates{candidates},
repos: repos, published: published, builds: buildStates, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf,
announce: announce, compositions: compositions, inputs: runInputs{awaitedKV{inputsKV}}, beginBound: beginBound,
@@ -331,7 +331,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
watch := newToolchainWatch(ctx, js, tools, ch.NodesChanged, logger)
// Only the toolchain reports of nodes the catalogue places the build module on count (issue 1940, ADR 0549 §3): the
// assignments 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)
gate := buildrun.NewToolchainGate([]string{goBuildModule, nodeBuildModule}, tools.Put, logger.Printf)
watch.put = gate.Toolchain
// Until the assignments's first view is answered the gate passes every report on, as before the assignments is settled.
assignmentsErrs := make(chan error, 1)
@@ -354,7 +354,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// A build module says each time it wrote its toolchains key: its stream, and the checker's durable consumer on it, are
// made by the controller. Where the stream is not there yet the checker says so and is started again with the build
// module.
for _, module := range []string{buildrun.GoBuildModule, buildrun.NodeBuildModule} {
for _, module := range []string{goBuildModule, nodeBuildModule} {
stream := toolchainStreams[module]
cons, err := cred.EventConsumer(ctx, js, stream)
if err != nil {
@@ -20,7 +20,7 @@ import (
// its grant. The gate over the seat's assignments, and its refusal, are mesh-sdk's (buildrun.ToolchainGate,
// issue 774).
func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) {
set := &buildrun.ToolchainSet{}
set := &buildrun.ToolchainSet{Seats: checkerBuildSeats}
var logs bytes.Buffer
logger := log.New(&logs, "", 0)
refused := refusingBus{err: errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-build-node_toolchains")}
@@ -17,7 +17,7 @@ import (
// toolchainStreams are the stream each build module carries its toolchain.reported event on, as the bus names it
// (declared by the build modules, heard by this module through consumes).
var toolchainStreams = map[string]string{buildrun.GoBuildModule: "TOOLCHAINS_GO", buildrun.NodeBuildModule: "TOOLCHAINS_NODE"}
var toolchainStreams = map[string]string{goBuildModule: "TOOLCHAINS_GO", nodeBuildModule: "TOOLCHAINS_NODE"}
// toolchainWatch keeps the build modules' toolchains in a buildrun.ToolchainSet and tells the builder when one changed. A
// build module's bucket is made when it is assigned, after the builder started, and the builder holds no timer (ADR 0437
@@ -37,12 +37,12 @@ type toolchainWatch struct {
}
func newToolchainWatch(ctx context.Context, js bucketOpener, set *buildrun.ToolchainSet, changed func(context.Context), logger *log.Logger) *toolchainWatch {
return &toolchainWatch{ctx: ctx, js: js, set: set, put: set.Put, changed: changed, logger: logger, errs: make(chan error, len(buildrun.LanguageOf)), watched: map[string]bool{}}
return &toolchainWatch{ctx: ctx, js: js, set: set, put: set.Put, changed: changed, logger: logger, errs: make(chan error, len(checkerBuildSeats.Modules())), watched: map[string]bool{}}
}
// start watches the buckets that exist now; a missing one is said and waits for its build module's event.
func (w *toolchainWatch) start() error {
for module := range buildrun.LanguageOf {
for _, module := range checkerBuildSeats.Modules() {
if err := w.ensure(module); err != nil {
return err
}
@@ -96,7 +96,7 @@ type delivered interface {
// terminated; a failure stays unacknowledged, and the server delivers it again.
func (w *toolchainWatch) Reported(m delivered) {
module, _, _ := strings.Cut(strings.TrimPrefix(m.Subject(), "mesh.stream."), ".event.")
if _, known := buildrun.LanguageOf[module]; !known || !strings.HasPrefix(m.Subject(), "mesh.stream.") {
if _, known := checkerBuildSeats.LanguageOf(module); !known || !strings.HasPrefix(m.Subject(), "mesh.stream.") {
w.logger.Printf("%s: not a build module's event, dropped", m.Subject())
_ = m.Term()
return
@@ -7,7 +7,6 @@ import (
"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"
@@ -75,13 +74,13 @@ func (st *state) buildsOfTheCheck(ctx context.Context) (map[string]string, []str
if !readable {
return wait, words
}
for _, bm := range slices.Sorted(maps.Keys(buildrun.LanguageOf)) {
for _, bm := range slices.Sorted(maps.Keys(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]) {
if _, isBuildModule := languageOf[module]; !isBuildModule && buildsWith(m, languageOf[bm]) {
built = append(built, module)
delete(wait, module)
}
@@ -0,0 +1,11 @@
package check
// The checker's names of the build modules, kept until its part finds them on the seat list as the builder does (design 47 G
// A-82 is the builder's change; the builder's package holds no name of them any more).
const (
goBuildModule = "mesh-build-go"
nodeBuildModule = "mesh-build-node"
)
// languageOf is the language each build module's toolchain reports hold.
var languageOf = map[string]string{goBuildModule: "go", nodeBuildModule: "typescript"}
@@ -0,0 +1,16 @@
package check
import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// testSeats is the finder over the build seats the checker's names hold (buildmodules.go).
func testSeats() *buildrun.BuildSeats {
rule := func(on string) seatdef.Definition {
return seatdef.Definition{Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: on}}}
}
defs := map[string]seatdef.Definition{"node-build-go": rule("go"), "node-build-node": rule("typescript")}
holders := map[string]string{"node-build-go": goBuildModule, "node-build-node": nodeBuildModule}
return buildrun.NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok })
}
@@ -450,7 +450,7 @@ func TestARepositoryWithNoTrunkCannotBeJudgedLoudlyUntilItsTrunkIsKnown(t *testi
done := make(chan struct{})
go func() {
defer close(done)
(buildrun.Asker{Publisher: fake, Waiter: fake, Bound: time.Hour, Dropper: drops}).Wait(run, buildstep.StepAsk{ID: "mvl0frv18d2000", Language: buildstep.AskGo, Kind: buildstep.AskCheck})
(buildrun.Asker{Seats: testSeats(), Publisher: fake, Waiter: fake, Bound: time.Hour, Dropper: drops}).Wait(run, buildstep.StepAsk{ID: "mvl0frv18d2000", Language: buildstep.AskGo, Kind: buildstep.AskCheck})
}()
ch.Forget("g")
<-done
@@ -106,7 +106,7 @@ func (l langs) BuildModules(repository string) []string {
if m, ok := l[repository]; ok {
return m
}
return []string{buildrun.GoBuildModule}
return []string{goBuildModule}
}
func goEntries(nodes ...string) entries {
@@ -207,7 +207,7 @@ func refsOf(v any) []BesideRef {
}
// toolPrefix is the start of each build module's tool names, as the fake build modules answer them.
var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun.NodeBuildModule: "build_node_"}
var toolPrefix = map[string]string{goBuildModule: "build_go_", nodeBuildModule: "build_node_"}
// answer runs a check ask as the build module that pulls it does (gobuild.RunAsk), with the fake tools on shanks and the
// bench's kept verdicts, and answers the step it writes.
@@ -228,7 +228,7 @@ func (b *bench) answer(a buildstep.StepAsk) buildstep.Step {
k, found, err := (Kept{Store: b.kept}).Lookup(ctx, level, digest)
return int(k.Tests), found, err
}}
if module == buildrun.GoBuildModule {
if module == goBuildModule {
steps.Digest = tool("digest")
}
began := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC)
@@ -278,7 +278,7 @@ func newBench(t testing.TB) *bench {
}
now := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC)
b.asks = &buildrun.FakeAsks{Answer: b.answer}
b.ex = &Examiner{Asks: buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 5 * time.Second}, Toolchains: goEntries("shanks"), Kept: Kept{Store: b.kept}, Previews: b.prev,
b.ex = &Examiner{Asks: buildrun.Asker{Seats: testSeats(), Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 5 * time.Second}, Toolchains: goEntries("shanks"), Kept: Kept{Store: b.kept}, Previews: b.prev,
Modules: langs{}, Trunks: &Trunks{heads: map[string]string{DeliveryRepository: trunkD}}, Inputs: memInputs{b.inputs}, Now: func() time.Time { now = now.Add(50 * time.Millisecond); return now }}
return b
}
@@ -559,7 +559,7 @@ func TestAReAskRunsOnlyWhatFailedAndWhatChanged(t *testing.T) { // 1288/AC2, AC3
func TestANodePackageIsNeverKeptAndRunsEveryCheck(t *testing.T) { // 1288/AC4, 1292/AC1
b := newBench(t)
b.ex.Modules = langs{repoA: {buildrun.NodeBuildModule}}
b.ex.Modules = langs{repoA: {nodeBuildModule}}
b.fetch = captured[Fetched](t, "node-fetch-clean")
b.test = func(c stepCall) (any, error) {
return Tested{Level: c.args["level"].(string), Module: "modules/m", Passed: true, Elapsed: 0.4, Bound: 5}, nil
@@ -991,7 +991,7 @@ func (p pkgPaths) PackagePaths(repository string) []string { return p[repository
// groupPackageBench is a group of a TypeScript member (mesh-delivery) and the mesh-sdk member, whose merge changes sdkChanged.
func groupPackageBench(t *testing.T, sdkChanged []string) *bench {
b := newBench(t)
b.ex.Modules = langs{repoA: {buildrun.GoBuildModule, buildrun.NodeBuildModule}, repoSDK: {buildrun.GoBuildModule, buildrun.NodeBuildModule}}
b.ex.Modules = langs{repoA: {goBuildModule, nodeBuildModule}, repoSDK: {goBuildModule, nodeBuildModule}}
b.ex.Packages = pkgPaths{repoSDK: {"src", "test", "package.json", "package-lock.json", "tsconfig.json"}}
fetch := func(c stepCall) (any, error) {
f := b.fetch
@@ -1006,7 +1006,7 @@ func groupPackageBench(t *testing.T, sdkChanged []string) *bench {
func nodeFetchOf(b *bench, repo string) stepCall {
for _, c := range b.steps.tool("fetch") {
if c.module == buildrun.NodeBuildModule && c.args["repository"] == repo {
if c.module == nodeBuildModule && c.args["repository"] == repo {
return c
}
}
@@ -1065,7 +1065,7 @@ func TestAnAskWhoseStepNeverEndsEndsTheCheckLoudly(t *testing.T) {
b.asks.Answer = func(a buildstep.StepAsk) buildstep.Step {
return buildstep.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: buildstep.StepRunning, Started: time.Now()}
}
b.ex.Asks = buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond}
b.ex.Asks = buildrun.Asker{Seats: testSeats(), Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond}
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
if f := out.Verdict.Failed; out.Verdict.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(f.Words, "on shanks did not end within") {
t.Fatalf("verdict = %+v", out.Verdict)
@@ -1105,7 +1105,7 @@ func TestACheckAskWaitingInItsStreamIsNotBounded(t *testing.T) {
b := newBench(t)
answer := b.asks.Answer
b.asks.Answer = nil
b.ex.Asks = buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond}
b.ex.Asks = buildrun.Asker{Seats: testSeats(), Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond}
done := make(chan Outcome, 1)
go func() { done <- b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) }()
for deadline := time.Now().Add(10 * time.Second); len(b.asks.Asks()) == 0; time.Sleep(time.Millisecond) {
@@ -19,7 +19,7 @@ import (
)
// goModule is the build module the Go repositories are judged by.
const goModule = buildrun.GoBuildModule
const goModule = goBuildModule
// RunnerWorkspace is the runner the Go build module's digest and test steps are asked with when the merge changes the build
// module itself: the installed module compiles the candidate's program from the merged tree and runs the step as it, shut
@@ -69,7 +69,7 @@ type BuildModules interface {
}
// askLanguage is the build seat language of each build module the checker asks.
var askLanguage = map[string]string{buildrun.GoBuildModule: buildstep.AskGo, buildrun.NodeBuildModule: buildstep.AskNode}
var askLanguage = map[string]string{goBuildModule: buildstep.AskGo, nodeBuildModule: buildstep.AskNode}
// Examiner judges a case through the build seats (ADR 0484 clauses 3, 4, 10, 13; ADR 0525 §1, §3): for each pull request
// and build module it puts one check ask whose job fetches the head merged onto its trunk head, then runs the unit level
@@ -545,17 +545,17 @@ func (st *state) judgeAll(ctx context.Context) {
modules := st.j.Modules.BuildModules(p.Repository)
for _, bm := range modules {
t := &target{pull: p, module: bm}
if tc, none := buildstep.AgreedToolchain(st.j.Toolchains.Entries(buildrun.LanguageOf[bm])); none == "" {
if tc, none := buildstep.AgreedToolchain(st.j.Toolchains.Entries(languageOf[bm])); none == "" {
t.toolchain = tc.Version
t.agreed = &buildstep.ToolchainUsed{Language: buildrun.LanguageOf[bm], Version: tc.Version, Recipe: tc.Recipe}
t.agreed = &buildstep.ToolchainUsed{Language: languageOf[bm], Version: tc.Version, Recipe: tc.Recipe}
}
args := buildstep.Fetch{Repository: p.Repository, Commit: p.Head, Ref: refOf(p.Number), Base: p.Trunk}
if bm == buildrun.NodeBuildModule {
if bm == nodeBuildModule {
if groupPackage != nil && p.Repository != SDKRepository {
args.Package = groupPackage
}
}
if bm == buildrun.GoBuildModule {
if bm == goBuildModule {
beside, err := st.j.besideFor(ctx, st.c, p)
if err != nil {
st.fail(forgeMachine(err, bm), err.Error()+unheardWords(err))
@@ -797,7 +797,7 @@ func unmetOf(levels []buildstep.LevelAnswer) string {
// one that did not pass, so the verdict names every failed test of the level (issue 1921). It answers the level's
// running time (its tests' and setup's).
func (st *state) level(ctx context.Context, t *target, la buildstep.LevelAnswer) float64 {
if t.runner != "" && t.module == buildrun.GoBuildModule && la.Digest != nil {
if t.runner != "" && t.module == goBuildModule && la.Digest != nil {
t.judgedBy(la.Digest.JudgedBy) // the installed module judged it: the runner is not used again
}
if t.runner != "" && la.Judged != "" {
@@ -806,7 +806,7 @@ func (st *state) level(ctx context.Context, t *target, la buildstep.LevelAnswer)
_, elapsed, results := st.levels.Level(t.pull.Repository, t.pull.Number, t.module, t.node, la)
st.facts = append(st.facts, rules.Step{Repository: t.pull.Repository, Number: t.pull.Number, Module: t.module, Node: t.node, Level: rules.LevelFact(la)})
var digests map[string]string
if t.module == buildrun.GoBuildModule && la.Digest != nil {
if t.module == goBuildModule && la.Digest != nil {
// The packages come as a list, never keyed by a package path (issue 1930); an answer stored before reads too.
digests = la.Digest.Map()
}
@@ -63,7 +63,7 @@ func (l *losingAsks) PublishAsk(ctx context.Context, subject, id string, data []
func losing(b *bench, lose int) *losingAsks {
l := &losingAsks{FakeAsks: &buildrun.FakeAsks{}, answer: b.answer, dead: &buildrun.DeadLetters{}, lose: lose}
b.ex.Asks = buildrun.Asker{Publisher: b.asker(l, l.FakeAsks.Write), Waiter: buildrun.LostAskWaiter{Inner: l.FakeAsks, Dead: l.dead}, Bound: 5 * time.Second}
b.ex.Asks = buildrun.Asker{Seats: testSeats(), Publisher: b.asker(l, l.FakeAsks.Write), Waiter: buildrun.LostAskWaiter{Inner: l.FakeAsks, Dead: l.dead}, Bound: 5 * time.Second}
return l
}
@@ -288,7 +288,7 @@ func TestACheckWhoseAskJudgedNothingIsAskedAgainOrRead(t *testing.T) {
tr = &troubled{FakeAsks: &buildrun.FakeAsks{}, answer: b.answer, restarts: row.restarts, deaf: row.deaf,
deafIDs: map[string]bool{}, steps: map[string]buildstep.Step{}}
heard = &buildrun.HeardEnds{Inner: tr, Read: tr}
b.ex.Asks = buildrun.Asker{Publisher: b.asker(tr, tr.Write), Waiter: heard, Bound: 5 * time.Second}
b.ex.Asks = buildrun.Asker{Seats: testSeats(), Publisher: b.asker(tr, tr.Write), Waiter: heard, Bound: 5 * time.Second}
asked = func() int { return len(tr.Asks()) }
}
r := newRig(t, b.ex)
@@ -307,23 +307,23 @@ func TestACheckWhoseAskJudgedNothingIsAskedAgainOrRead(t *testing.T) {
}
switch row.how {
case "nudge":
heard.Nudge(buildrun.NodeBuildModule) // another build module's step: not this one's to read
heard.Nudge(buildrun.GoBuildModule)
heard.Nudge(nodeBuildModule) // another build module's step: not this one's to read
heard.Nudge(goBuildModule)
case "hand":
cands := newMemKV()
ckey, _ := meshcatalogclient.CandidatesKey("feat/x")
_, _ = cands.Create(ctx, ckey, cand)
none := StateRead{Keys: func() ([]string, error) { return nil, nil }, Get: func(string) (json.RawMessage, error) { return nil, nil }}
rd := Reader{CandidateState: kvRead(cands), VerdictState: kvRead(r.verdicts), AskState: kvRead(asksKept),
StepStates: map[string]StateRead{buildrun.GoBuildModule: none}} // no node took its ask yet
StepStates: map[string]StateRead{goBuildModule: none}} // no node took its ask yet
if _, err := rd.AskAgain(asVerdicts{r.verdicts}, "feat/x", "its watch went deaf"); err == nil || !strings.Contains(err.Error(), "waits in its build seat's stream") {
t.Errorf("asked again while its ask waits in the stream (issue 1791's kind): %v", err)
}
rd.StepStates[buildrun.GoBuildModule] = tr.Steps(true)
rd.StepStates[goBuildModule] = tr.Steps(true)
if _, err := rd.AskAgain(asVerdicts{r.verdicts}, "feat/x", "its watch went deaf"); err == nil || !strings.Contains(err.Error(), "a step of it runs") {
t.Errorf("asked again while its step runs: %v", err)
}
rd.StepStates[buildrun.GoBuildModule] = tr.Steps(false)
rd.StepStates[goBuildModule] = tr.Steps(false)
if st, _ := rd.Status(); !strings.Contains(st.Lost["feat/x"], AskAgainTool) {
t.Errorf("status lost = %v, want feat/x named with %s", st.Lost, AskAgainTool)
}
@@ -368,7 +368,7 @@ func TestACheckWhoseAskJudgedNothingIsAskedAgainOrRead(t *testing.T) {
id := fmt.Sprint("mvl0frv18d19", 80+i)
vals["ace."+id], _ = buildstep.EncodeStep(buildstep.Step{ID: id, Node: "ace", Tool: "check ask", State: buildstep.StepFailed, Passing: p, Ended: time.Now(), What: "check " + repoA + "@" + short12(head1)})
}
rd := Reader{CandidateState: kvRead(cands2(t, cand)), VerdictState: kvRead(failedKV), AskState: kvRead(newMemKV()), StepStates: map[string]StateRead{buildrun.GoBuildModule: {
rd := Reader{CandidateState: kvRead(cands2(t, cand)), VerdictState: kvRead(failedKV), AskState: kvRead(newMemKV()), StepStates: map[string]StateRead{goBuildModule: {
Keys: func() ([]string, error) { return slices.Collect(maps.Keys(vals)), nil }, Get: func(k string) (json.RawMessage, error) { return vals[k], nil }}}}
if got, err := rd.AskAgain(asVerdicts{failedKV}, "feat/x", "the network"); (err == nil) != c.ok || (c.ok && !strings.HasPrefix(got.Said, AskedAgainWords)) {
t.Errorf("%s: asked again = %+v, %v, want asked again %v", c.name, got, err, c.ok)
@@ -9,7 +9,6 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-delivery/sdk/grouppackages"
"git.novox.be/novox/mesh-sdk/go/catalogue"
@@ -119,7 +118,7 @@ func TestAGroupMemberIsBuiltAgainstTheSDKMembersHeadWhateverItsGoModNames(t *tes
b = newBench(t)
b.fetch.Modules = []string{"modules/m"}
b.ex.Read = fixedManifests{digest: sdk}
b.ex.Modules = langs{repoA: {buildrun.GoBuildModule, buildrun.NodeBuildModule}}
b.ex.Modules = langs{repoA: {goBuildModule, nodeBuildModule}}
if v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1), sdkPull)).Verdict; v.State != meshdeliverycheckerclient.VerdictPass {
t.Errorf("a group's verdict = %+v", v)
}
@@ -0,0 +1,8 @@
package rules
// The checker's names of the build modules, kept until its part finds them on the seat list as the builder does (design 47 G
// A-82 is the builder's change; the builder's package holds no name of them any more).
const (
goBuildModule = "mesh-build-go"
nodeBuildModule = "mesh-build-node"
)
+1 -2
View File
@@ -15,7 +15,6 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
)
@@ -159,7 +158,7 @@ func (t Tally) Level(repository string, number int, module, node string, la buil
}
results = append(results, res)
elapsed += res.Elapsed + res.Setup + res.Lint
if module == buildrun.NodeBuildModule {
if module == nodeBuildModule {
if !res.NoTests {
a.Run++ // a Node package is counted whole: its build module counts no tests (ADR 0484 clause 10)
}
+1 -3
View File
@@ -4,8 +4,6 @@ import (
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
)
// The module graph's cycle rule (novox/hq ADR 0603 §2, ADR 0614 §3, §6), judged by the checking program (ADR 0656 §5):
@@ -60,7 +58,7 @@ func JudgeGraph(g Graph) (*FailedFirst, []string) {
return nil, []string{"the module graph has the cycle " + words + ", which the trunk already has among the same modules (ADR 0603 §2)"}
}
}
return &FailedFirst{Machine: buildrun.GoBuildModule, Words: fmt.Sprintf("the change's module graph has the cycle %s: these modules would "+
return &FailedFirst{Machine: goBuildModule, Words: 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)}, nil
}
@@ -12,7 +12,6 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-delivery/sdk/composer"
)
@@ -21,7 +20,7 @@ import (
// (milestone 47/B0, D1b): the running checker asks and records; these rules give the verdict.
// manifestsMachine is the machine a manifest's failure names: the build seat that ran the manifest validator.
const manifestsMachine = buildrun.GoBuildModule
const manifestsMachine = goBuildModule
// CatalogSeat is the seat the indexer holds: the module whose manifest claims it is the indexer, found by the claim and
// never by its name (novox/hq ADR 0633 clause 6, ADR 0601 §4).
+1 -2
View File
@@ -6,7 +6,6 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
)
@@ -183,7 +182,7 @@ func LevelFailure(repository, module, level string, results ...Tested) *meshdeli
}
for _, f := range res.Failed {
name := FailedName(f)
if module == buildrun.NodeBuildModule && f.Package == "" {
if module == nodeBuildModule && f.Package == "" {
name = res.Module
}
all = append(all, FailedFirst{Test: level + ": " + name, Words: FailureWordsAfter(f, HidWords(module, res.By, f))})