fix9b2-1: the builder follows the assigner's view in seat form, its holder found at run time

consumes seat:mesh-module-assigner.views.assignments, reads seat:mesh-module-assigner.assignments; the holder is asked of the seat list (seatlist.HolderOf), the first view is the holder's first-view tool (A-131), the assigner's callers list gains the builder; FollowAssignments takes the bucket.

Builds: design 47 G A-119, A-116, A-131
This commit is contained in:
2026-11-12 01:28:12 +01:00
parent 89bbd20b5a
commit 6034559dc2
14 changed files with 129 additions and 37 deletions
@@ -528,7 +528,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
// indexer its runtime of its own answers each first-view tool to the readers its manifest's callers lists name, at
// its mark, and refuses every other account, the assigner's own included.
firstViewsBeforeAnyNewFormState(t, b, map[string][]string{
"assignments": {"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer"},
"assignments": {"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer", "n1.mesh-delivery-builder"},
"seat-holders": {"n1.node-tools"},
"wants": {"n1.mesh-grants"},
})
@@ -109,7 +109,7 @@ func TestManifest(t *testing.T) {
t.Errorf("runs-as = %q: the bundle runs on the assigner's own account (ADR 0621 §1)", m.RunsAs)
}
// ADR 0621 §2: each first-view tool answers exactly its readers, never the assigner.
callers := map[string]string{"assignments": "mesh-grants,node-tools,mesh-delivery-indexer", "seat-holders": "node-tools", "wants": "mesh-grants"}
callers := map[string]string{"assignments": "mesh-grants,node-tools,mesh-delivery-indexer,mesh-delivery-builder", "seat-holders": "node-tools", "wants": "mesh-grants"}
env := m.Build.Artifacts[0].Env
for tool, list := range callers {
if env[views.CallersEnv(tool)] != list || views.ParseCallers(env[views.CallersEnv(tool)]).Allows("n1."+m.Module) {
+1 -1
View File
@@ -212,7 +212,7 @@
"mesh-delivery-assigner"
],
"env": {
"MESH_VIEW_CALLERS_ASSIGNMENTS": "mesh-grants,node-tools,mesh-delivery-indexer",
"MESH_VIEW_CALLERS_ASSIGNMENTS": "mesh-grants,node-tools,mesh-delivery-indexer,mesh-delivery-builder",
"MESH_VIEW_CALLERS_SEAT_HOLDERS": "node-tools",
"MESH_VIEW_CALLERS_WANTS": "mesh-grants"
}
@@ -125,20 +125,20 @@ func (g *ToolchainGate) placedTold() {
}
}
// FollowAssignments reads the seat mesh-module-assigner's assignments with its history into gate, says it settled, and follows it; changed
// FollowAssignments reads the seat mesh-module-assigner's assignments, in bucket (the seat's holder found at run time, `<holder>_assignments`; design 47 G A-119), with its history into gate, says it settled, and follows it; changed
// is told whenever what the gate passes on changed. Where the state cannot be read or watched (not there, or this part
// may not read it) it says so and the gate passes every report on: not fatal, since an indexer, builder or checker that
// stops for it stops every delivery (issue 1945). An error of the watch after that ends on the channel.
func FollowAssignments(ctx context.Context, js process.BucketOpener, gate assignmentsFollowed, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) {
func FollowAssignments(ctx context.Context, js process.BucketOpener, bucket string, gate assignmentsFollowed, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) {
errs := make(chan error, 1)
kv, err := js.KeyValue(ctx, AssignmentsBucket)
kv, err := js.KeyValue(ctx, bucket)
if err != nil {
logf("the state %s cannot be read, so no node is counted by the assignments: %v", AssignmentsBucket, err)
logf("the state %s cannot be read, so no node is counted by the assignments: %v", bucket, err)
return errs, nil
}
w, err := kv.WatchAll(ctx, jetstream.IncludeHistory())
if err != nil {
logf("the state %s cannot be watched, so no node is counted by the assignments: %v", AssignmentsBucket, err)
logf("the state %s cannot be watched, so no node is counted by the assignments: %v", bucket, err)
return errs, nil
}
put := func(e jetstream.KeyValueEntry) bool {
@@ -151,7 +151,7 @@ func FollowAssignments(ctx context.Context, js process.BucketOpener, gate assign
return errs, ctx.Err()
case e, ok := <-w.Updates():
if !ok {
return errs, errors.New("the watch of " + AssignmentsBucket + " ended before it settled")
return errs, errors.New("the watch of " + bucket + " ended before it settled")
}
if e == nil {
settled = true
@@ -173,7 +173,7 @@ func FollowAssignments(ctx context.Context, js process.BucketOpener, gate assign
return
case e, ok := <-w.Updates():
if !ok {
errs <- errors.New("the watch of " + AssignmentsBucket + " ended")
errs <- errors.New("the watch of " + bucket + " ended")
return
}
if e != nil && put(e) {
@@ -86,7 +86,7 @@ func TestPlacedNodes(t *testing.T) {
refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments")
gate := NewPlacedNodes(logger.Printf)
for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} {
if _, err := FollowAssignments(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil {
if _, err := FollowAssignments(context.Background(), bus, AssignmentsBucket, gate, func(context.Context) {}, logger.Printf); err != nil {
t.Errorf("a refused assignments stopped the start: %v", err)
}
}
@@ -249,7 +249,7 @@ func TestToolchainGate(t *testing.T) {
refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments")
gate, _, _ := start(t, false)
for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} {
if _, err := FollowAssignments(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil {
if _, err := FollowAssignments(context.Background(), bus, AssignmentsBucket, gate, func(context.Context) {}, logger.Printf); err != nil {
t.Errorf("a refused assignments stopped the start: %v", err)
}
}
@@ -0,0 +1,41 @@
package main
import (
"context"
"time"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/seatlist"
)
// assignerHolder is the module holding the seat mesh-module-assigner now, asked of the seat list through mesh-sdk's seatlist.HolderOf
// until it answers or ctx ends (design 47 G A-119, A-126): the builder names no assigner module, so a new holder is followed with no
// change of code. wait is the pause between two asks that failed; sleep says false when ctx ended during it.
func assignerHolder(ctx context.Context, call seatlist.HolderCaller, wait time.Duration, sleep func(context.Context, time.Duration) bool, logf func(string, ...any)) (string, bool) {
for {
holder, err := seatlist.HolderOf(call, mma.Seat)
if err == nil {
return holder, true
}
logf("who holds the seat %s is not known yet, so no node is counted by the assignments: %v", mma.Seat, err)
if !sleep(ctx, wait) {
return "", false
}
}
}
// assignmentsNames are the names made from the holder: the view's first-view tool `<holder>.assignments` and the
// bucket `<holder>_assignments` of the state, both made from the holder found at run time.
type assignmentsNames struct{ Holder, Tool, Bucket string }
func assignmentsOf(holder string) assignmentsNames {
return assignmentsNames{Holder: holder, Tool: holder + "." + mma.StateAssignments, Bucket: holder + "_" + mma.StateAssignments}
}
// sleepFor waits d, a context's bound, and says whether ctx is still alive.
func sleepFor(ctx context.Context, d time.Duration) bool {
w, cancel := context.WithTimeout(ctx, d)
defer cancel()
<-w.Done()
return ctx.Err() == nil
}
@@ -0,0 +1,37 @@
package main
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seatlist"
)
// The builder finds the assigner's holder at run time: asked again while the seat list does not answer, and the names of the view
// are made from the holder (design 47 G A-119), not fixed in code.
func TestTheAssignerHolderIsFoundAtRunTime(t *testing.T) {
asks, slept := 0, 0
call := func(key string, _ any) (json.RawMessage, error) {
if key != seatlist.HolderKey() {
t.Errorf("asked %q, want %q", key, seatlist.HolderKey())
}
if asks++; asks == 1 {
return nil, errors.New("down")
}
return json.RawMessage(`{"seats":[{"seat":"mesh-module-assigner","holders":[{"module":"acme-assigner"}]}]}`), nil
}
holder, ok := assignerHolder(context.Background(), call, time.Second, func(context.Context, time.Duration) bool { slept++; return true }, func(string, ...any) {})
if !ok || holder != "acme-assigner" || slept != 1 {
t.Fatalf("holder = %q, %v after %d pauses, want acme-assigner after one", holder, ok, slept)
}
if n := assignmentsOf(holder); n.Tool != "acme-assigner.assignments" || n.Bucket != "acme-assigner_assignments" {
t.Errorf("names = %+v", n)
}
if _, ok := assignerHolder(context.Background(), func(string, any) (json.RawMessage, error) { return nil, errors.New("down") }, time.Second,
func(context.Context, time.Duration) bool { return false }, func(string, ...any) {}); ok {
t.Error("a holder was found though ctx ended")
}
}
@@ -58,6 +58,8 @@ accounts {
}
`
const testAssigner = "acme-assigner" // a holder of the seat mesh-module-assigner no code names
const (
commit = "7a74cee83f88e6585a5511c3cc53bdf0f5303d20"
fp = "src2:3333333333333333333333333333333333333333333333333333333333333333"
@@ -288,7 +290,16 @@ func startBedWith(t *testing.T, closed bool) *bed {
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...)
// The seat mesh-module-assigner's holder is read from the seat's verb assignments, as the builder finds it at run time (A-119).
assignerSub, err := ctl.Subscribe("mesh.seat.mesh-module-assigner.call.assignments.>", func(m *nats.Msg) {
reply, _ := json.Marshal(map[string]any{"result": map[string]any{"seats": []map[string]any{{"seat": "mesh-module-assigner", "holders": []map[string]string{{"module": testAssigner}}}}}, "node": "ctl"})
_ = m.Respond(reply)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = assignerSub.Unsubscribe() })
viewstest.Serve(t, ctx, js, ctl, testAssigner, "assignments", testAssigner+".assignments", readers...)
b.builds = mk(builds.BuilderBuildsBucket, builds.BuilderBuildsHistory)
b.instances = mk(instancesBucket, 1)
// The state bounds, whose keys have each its own time to live: an ask's step bounds (design 47 G A-77, A-118) are kept in it.
@@ -18,6 +18,7 @@ import (
"syscall"
"time"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
@@ -119,10 +120,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// 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
}
bound := defaultStepBound
if v := strings.TrimSpace(getenv("MESH_BUILDER_STEP_BOUND")); v != "" {
if bound, err = time.ParseDuration(v); err != nil {
@@ -244,7 +241,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
decided := &decisions{subject: oc.SubjectDecided(cred.Module)}
retakeActed := awaitedReserved{awaitedVersions{b: actedKV, bucket: versions.ActedBucket}}
asker := &operatorask.Asker[oc.Warrant]{Module: cred.Module, Call: calls.Call, Record: calls.Ask, Publish: publishVia(ctx, js), Subscribe: decided.hear(logger.Printf),
Contract: operatorask.Contract{Seat: oc.Seat, StateAsks: oc.StateAsks},
Contract: operatorask.Contract{Seat: oc.Seat, StateAsks: oc.StateAsks},
AskSubject: oc.AcceptSubjectAsk, DecidedSubject: oc.SubjectDecided}
operator := &build.OperatorAsk{Builder: b, Acted: retakeActed, Ctx: ctx, Logf: logger.Printf, Asker: asker,
Client: &occ.Client{Module: cred.Module, State: calls.State("asked"),
@@ -346,15 +343,26 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// asked when the bound ends), is call-unanswered in the builder's findings (issue 1966, ADR 0668 §3, ADR 0596 §2).
reads.callsOf(found, pubBoundsKV.KV, heardBy(calls.CallKept), logger.Printf)
b.Forms = reads.built
// The assignments names the nodes the builder counts, once its first view is answered.
// The assignments names the nodes the builder counts, once the seat list names the seat's holder and its first view is answered:
// the view is the holder's first-view tool, followed by its views family (A-119, A-131); no assigner module is named.
assignmentsErrs := make(chan error, 1)
go func() {
holder, ok := assignerHolder(ctx, calls.Call, 5*time.Second, sleepFor, logger.Printf)
if !ok {
return
}
names := assignmentsOf(holder)
assignments, err := reader.Follow(names.Holder, mma.StateAssignments, names.Tool)
if err != nil {
assignmentsErrs <- err
return
}
select {
case <-ctx.Done():
return
case <-assignments.Follower().Ready():
}
pe, err := buildrun.FollowAssignments(ctx, reader, gate, b.NodesChanged, logger.Printf)
pe, err := buildrun.FollowAssignments(ctx, reader, names.Bucket, gate, b.NodesChanged, logger.Printf)
if err != nil {
assignmentsErrs <- err
return
@@ -2,7 +2,6 @@ package main
import (
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
)
// The holders and buckets the builder names, taken from its own manifest (module.json: its `reads`) and from the holders'
@@ -15,8 +14,6 @@ const (
// indexerModule is the holder of the states modules, manifests, candidates and packages the builder follows.
indexerModule = "mesh-delivery-indexer"
// assignerModule is the holder of the state assignments.
assignerModule = buildrun.AssignerModule
)
// namedTreePrefix opens the tree input of a module whose every artifact names its paths.
@@ -38,15 +38,14 @@ func TestTheNamesAreTheManifests(t *testing.T) {
t.Errorf("the builder follows %s, an indexer state, its manifest consumes it", c)
}
}
for _, view := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", assignerModule + ".views.assignments"} {
for _, view := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "seat:mesh-module-assigner.views.assignments"} {
if !slices.Contains(m.Consumes, view) {
t.Errorf("the builder follows %s, its manifest consumes %v", view, m.Consumes)
}
}
for want, got := range map[string]string{
"mesh-build-go_steps": buildrun.StepsBucket("mesh-build-go"),
buildrun.ToolchainsHeardBucket: "mesh-delivery-builder_toolchains-heard",
"mesh-delivery-assigner_assignments": buildrun.AssignmentsBucket,
"mesh-build-go_steps": buildrun.StepsBucket("mesh-build-go"),
buildrun.ToolchainsHeardBucket: "mesh-delivery-builder_toolchains-heard",
} {
if want != got {
t.Errorf("a bucket is %q, want %q", got, want)
@@ -119,8 +119,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-delivery-assigner.assignments",
"node-build-go.steps", "node-build-image.steps", "node-build-node.steps"}; !reflect.DeepEqual(sorted(m.Reads), want) {
if want := []string{"node-build-go.steps", "node-build-image.steps", "node-build-node.steps",
"seat:mesh-module-assigner.assignments"}; !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", "operator-channel"}; !reflect.DeepEqual(sorted(m.Uses), want) {
@@ -133,15 +133,15 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
// read; the catalog half (the modules, their manifests, their packages and the package names a package is numbered under) is
// read through the seat mesh-catalog's branch follower (design 47 G §348, §614), and the indexer's states not at all.
for _, r := range m.Reads {
// The toolchain gate follows the assigner's assignments through meshmoduleassignerclient (buildrun.AssignmentsBucket), so
// it declares that one read, as the console and route-proxy do; everything else of theirs is asked by tools.
if strings.HasPrefix(r, "mesh-delivery-indexer.") || (strings.HasPrefix(r, "mesh-delivery-assigner.") && r != "mesh-delivery-assigner.assignments") {
// The toolchain gate follows the assigner's assignments in the bucket of the seat's holder, so it declares that one read by the
// seat (A-116, A-119); no module is named and everything else of the assigner's is asked by tools.
if strings.HasPrefix(r, "mesh-delivery-indexer.") || strings.HasPrefix(r, "mesh-delivery-assigner.") {
t.Errorf("reads %s: the indexer's and the assigner's state is read by their tools and views families alone", r)
}
}
// Design 51 *The floor*: the floor's three sources, the registry seats' versions, git.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", "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.publish", "seat:container-image-registry.remove", "seat:container-image-registry.versions",
if want := []string{"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.publish", "seat:container-image-registry.remove", "seat:container-image-registry.versions",
"seat:git.tag", "seat:git.tags",
"seat:go-package-registry.publish", "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-module-assigner.assignments", "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.publish", "seat:npm-package-registry.remove",
"seat:npm-package-registry.versions", "seat:oci-artifact-registry.publish", "seat:oci-artifact-registry.remove", "seat:oci-artifact-registry.versions"}; !reflect.DeepEqual(sorted(m.Invokes), sorted(want)) {
@@ -156,7 +156,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
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.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-conditions.condition-cleared", "mesh-conditions.findings-asked", "seat:mesh-module-assigner.views.assignments",
"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",
"oci-artifact-registry.kept", "oci-artifact-registry.published", "oci-artifact-registry.refused", "oci-artifact-registry.removed", "operator-channel.decided"}; !reflect.DeepEqual(sorted(m.Consumes), sorted(want)) {
+2 -3
View File
@@ -73,7 +73,7 @@
"oci-artifact-registry.published",
"oci-artifact-registry.refused",
"oci-artifact-registry.removed",
"mesh-delivery-assigner.views.assignments",
"seat:mesh-module-assigner.views.assignments",
"mesh-catalog.catalog-revision",
"mesh-catalog.registration-changed",
"mesh-delivery.source-moved"
@@ -99,7 +99,6 @@
"seat:npm-package-registry.versions",
"seat:oci-artifact-registry.remove",
"seat:oci-artifact-registry.versions",
"mesh-delivery-assigner.assigner_read_assignments",
"seat:container-image-registry.publish",
"seat:go-package-registry.publish",
"seat:npm-package-registry.publish",
@@ -160,7 +159,7 @@
"node-build-go.steps",
"node-build-image.steps",
"node-build-node.steps",
"mesh-delivery-assigner.assignments"
"seat:mesh-module-assigner.assignments"
],
"uses": [
"node-build-go",