diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index e331c3a4..d643a419 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -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"}, }) diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index a536bdf1..df353e50 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -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) { diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index dffd8ee6..d54d1be6 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -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" } diff --git a/modules/mesh-delivery-builder/buildrun/standing.go b/modules/mesh-delivery-builder/buildrun/standing.go index 6d1b1963..45f49453 100644 --- a/modules/mesh-delivery-builder/buildrun/standing.go +++ b/modules/mesh-delivery-builder/buildrun/standing.go @@ -130,15 +130,22 @@ func (g *ToolchainGate) placedTold() { // 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) { + return FollowAssignmentsIn(ctx, js, AssignmentsBucket, gate, changed, logf) +} + +// FollowAssignmentsIn is FollowAssignments for the state in bucket, the holder's `_assignments` where the holder is found at +// run time (design 47 G A-119): the builder follows it so; FollowAssignments, with the fixed bucket, is the checker's until its own +// change finds the holder. +func FollowAssignmentsIn(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 +158,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 +180,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) { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go index f2db1a07..2ba23cde 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go @@ -17,7 +17,6 @@ import ( "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" - "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" @@ -404,18 +403,6 @@ func main() { } 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}) - if err != nil { - return nil, err - } - var a meshcatalog.VersionsAnswer - if err := json.Unmarshal(raw, &a); err != nil { - return nil, fmt.Errorf("the seat mesh-catalog's versions answered what does not read: %w", err) - } - return a.Versions, nil - } reserve := func(name, version string) (meshbuilds.ReservedAnswer, error) { raw, err := stdio.Ask(reservedVerb, meshbuilds.ReservedInput{Name: name, Version: version}) if err != nil { @@ -427,7 +414,7 @@ func main() { tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(builds.BuilderLog).Since, time.Now, vr, fr, ofr, reserve) tools = append(tools, buildTool(&build.BuildReader{Builds: read(stdio.State("builds")), Entry: entryOf(stdio.State("builds")), Asked: read(stdio.State("asked")), Newest: newestVia(stdio.Ask), Steps: r})) - tools = append(tools, reservedTools(rr, buildAgain, time.Now, meshhandactsclient.Recorder{Ask: stdio.Ask}, carriers, released)...) + tools = append(tools, reservedTools(rr, buildAgain, time.Now, meshhandactsclient.Recorder{Ask: stdio.Ask})...) qs, as := stdio.State(versions.PublishQueueState), stdio.State(versions.ActedState) tools = append(tools, republishTools(readerOf(qs), readerOf(as), buildAgain, time.Now, meshhandactsclient.Recorder{Ask: stdio.Ask})...) ws := stdio.State(build.WaitsState) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reserved.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reserved.go index d6a45807..983a54f4 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reserved.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reserved.go @@ -1,7 +1,6 @@ package main import ( - "context" "errors" "fmt" "strings" @@ -14,31 +13,17 @@ import ( "mesh-delivery-builder/internal/versions" ) -// reservedTools are builder_reserved, builder_reserved_drop and the seat mesh-builds' verb reserved (design 47 G section -// 369), served as tools of the bundle. The drop is a hand act: it is recorded in the hand-act log first, then written to +// reservedTools are builder_reserved and builder_reserved_drop (design 47 G section 369), served as tools of the bundle. The drop is a hand act: it is recorded in the hand-act log first, then written to // reasks for the builder, which owns the reservations, to answer. // -// The verb reserved given a name finds the module from the catalog half's view the bundle follows (carriers) and, for a version -// no reservation holds, asks the seat mesh-catalog's verb versions (released); the builder's process serves no call (A-70). -func reservedTools(rr versions.Reader, ask queueAsker, now func() time.Time, acts meshhandactsclient.Recorder, carriers func(context.Context, string) ([]versions.Built, error), released versions.CatalogVersions) []stdio.Tool { +// The seat's verb reserved is the builder's process's (design 47 G A-115, A-127), not the bundle's. +func reservedTools(rr versions.Reader, ask queueAsker, now func() time.Time, acts meshhandactsclient.Recorder) []stdio.Tool { str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } return []stdio.Tool{ {Name: versions.ReservedTool, Description: versions.ReservedDescription, Input: map[string]any{"type": "object", "properties": map[string]any{ "module": map[string]any{"type": "string", "description": "only this module's reservations"}}}, Run: func(args map[string]any) (any, error) { return versions.AnswerReserved(rr, str(args, "module")) }}, - {Name: versions.ReservedVerb, Description: "The seat mesh-builds' verb reserved: a module's reserved version with each artifact's digest once built " + - "(design 47 G section 369), for a registry holder deciding whether to publish. Read-only.", - Input: map[string]any{"type": "object", "properties": map[string]any{ - "module": map[string]any{"type": "string", "description": "the module; give this or name"}, - "name": map[string]any{"type": "string", "description": "a package's or an image's name in its ecosystem, in place of module"}, - "version": map[string]any{"type": "string", "description": "the version, when asked for a particular one"}}}, - Run: func(args map[string]any) (any, error) { - if name := str(args, "name"); name != "" { - return versions.AnswerReservedByName(context.Background(), rr, carriers, released, name, str(args, "version")) - } - return versions.AnswerReservedVerb(rr, str(args, "module"), str(args, "version")) - }}, {Name: versions.ReservedDropTool, Description: versions.ReservedDropDescription, Input: map[string]any{"type": "object", "required": []string{"module", "series", "why"}, "properties": map[string]any{ "module": map[string]any{"type": "string", "description": "the module"}, diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reservedname_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reservedname_test.go index e23c2dba..26b51535 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reservedname_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reservedname_test.go @@ -1,41 +1,27 @@ package main import ( - "context" "encoding/json" "testing" "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "mesh-delivery-builder/internal/build" "mesh-delivery-builder/internal/versions" ) -// Design 47 G §572, §582, A-70: the bundle's verb reserved takes a name and answers from the view it follows and the catalog's -// versions, and builder_versions, asked by a name, calls reserved and answers what it answers. -func TestReservedByNameIsTheBundlesAndBuilderVersionsCallsIt(t *testing.T) { +// Design 47 G §572, §582, A-70: builder_versions, asked by a name, calls reserved and answers what it answers. +func TestBuilderVersionsByNameCallsReserved(t *testing.T) { none := versions.Reader{Keys: func() ([]string, error) { return nil, nil }, Get: func(string) (json.RawMessage, bool, error) { return nil, false, nil }} - carriers := func(_ context.Context, name string) ([]versions.Built, error) { - if name == "@novox/mesh-sdk" { - return []versions.Built{{Module: "mesh-sdk", Artifact: "ts"}}, nil - } - return nil, nil - } - released := func(_ context.Context, module string) ([]meshcatalog.VersionEntry, error) { - return []meshcatalog.VersionEntry{{Module: module, Version: "0.1.2", Artifacts: []meshcatalog.VersionArtifact{{Artifact: "mesh-sdk:ts", Digest: "sha256:dd"}}}}, nil - } - acts := meshhandactsclient.Recorder{Ask: func(string, any) (json.RawMessage, error) { return json.RawMessage(`{}`), nil }} - byName := map[string]func(map[string]any) (any, error){} - for _, tl := range reservedTools(none, nil, time.Now, acts, carriers, released) { - byName[tl.Name] = tl.Run - } - got, err := byName[versions.ReservedVerb](map[string]any{"name": "@novox/mesh-sdk", "version": "0.1.2"}) want := meshbuilds.ReservedAnswer{Module: "mesh-sdk", Version: "0.1.2", Released: true, Artifacts: []meshbuilds.ReservedAnswerArtifactsItem{{Artifact: "mesh-sdk:ts", Digest: "sha256:dd"}}} - if err != nil || got.(meshbuilds.ReservedAnswer).Released != want.Released || got.(meshbuilds.ReservedAnswer).Artifacts[0] != want.Artifacts[0] { - t.Fatalf("reserved by name = %+v (%v), want %+v", got, err, want) + // The bundle serves no verb reserved any more: the builder's process does (design 47 G A-115, A-127). + acts := meshhandactsclient.Recorder{Ask: func(string, any) (json.RawMessage, error) { return json.RawMessage(`{}`), nil }} + for _, tl := range reservedTools(none, nil, time.Now, acts) { + if tl.Name == "reserved" { + t.Error("the bundle serves the verb reserved, which the builder's process answers") + } } var called []string reserve := func(name, version string) (meshbuilds.ReservedAnswer, error) { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments.go new file mode 100644 index 00000000..a8dd75e1 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments.go @@ -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 `.assignments` and the +// bucket `_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 +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments_test.go new file mode 100644 index 00000000..2fc7e34a --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments_test.go @@ -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") + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go index 431f1873..fd9cd17c 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go @@ -28,6 +28,7 @@ import ( runner "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" @@ -58,6 +59,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 +291,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. @@ -545,6 +557,16 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7 if v.Toolchains["go"].Version != "go1.27.1" || v.Toolchains["typescript"].Version != "tsc5" { t.Errorf("toolchains = %+v", v.Toolchains) } + // A-115: the builder's process answers the seat mesh-builds' verb reserved over the bus; the reservation of a module it never + // built is answered in words (here that the bed's state reserved is not on the bus), never left unanswered. + if conn, err := process.Connect(process.Account{URL: b.srvURL, Module: "mesh-delivery-view", User: "ctl", Password: "pw"}); err != nil { + t.Fatal(err) + } else { + defer conn.Close() + if _, err := conn.Call("seat:mesh-builds.reserved", meshbuilds.ReservedInput{Module: "never-built"}); err == nil || !strings.Contains(err.Error(), "_reserved") { + t.Errorf("reserved of a module never built = %v, want the process's answer naming its state reserved", err) + } + } // ADR 0618 §1: the done key names the reference forms it is written in. if len(v.Needs) != 1 || v.Needs[0] != "form:artifact-store" { t.Errorf("needs = %v, want the first form", v.Needs) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index 390eb0bd..f85253dd 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -120,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 { @@ -245,8 +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{Lists: "seat:" + mma.Seat + "." + mma.VerbAssignments, ListBody: mma.AssignmentsInput{Seats: mma.AssignmentsInputSeatsTrue}, - 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"), @@ -348,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.FollowAssignmentsIn(ctx, reader, names.Bucket, gate, b.NodesChanged, logger.Printf) if err != nil { assignmentsErrs <- err return @@ -397,6 +403,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // modules again (design 47 G A-82, §204). // The runner's build step ask, the builder's one entry point for a candidate (design 47 G A-97). b.StepEntry = newEntryWaits(branches, half.manifests).entry + // The seat mesh-builds' verb reserved, answered here (A-115, A-127): the reservations, the catalog half's carriers and the seat + // mesh-catalog's versions. + stopReserved, err := calls.AnswerSeat(meshbuilds.Seat, map[string]process.SeatHandler{ + meshbuilds.VerbReserved: reservedVerb(ctx, reservedReader(ctx, reserved.r.Store), versions.CarriersFrom(versions.EntriesOf(branches)), catalogReleased(calls.Call))}) + if err != nil { + return fmt.Errorf("answering the verb reserved: %w", err) + } + defer stopReserved() if err := serveSteps(ctx, conn, js, cred, b, logger.Printf); err != nil { return err } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go index 79b2a481..d53dc33e 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go @@ -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. diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go index 0e646bd0..5447f9bc 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go @@ -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) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb.go new file mode 100644 index 00000000..49bd2b14 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/process" + + "mesh-delivery-builder/internal/versions" +) + +// The seat mesh-builds' verb reserved is answered by the builder's own process (design 47 G A-115, A-127; A-87: builder_build stays +// its tools bundle's): a module's reserved version with each artifact's digest once built, for a registry holder deciding whether to +// publish. Given a name it finds the module from the catalog half the process follows (carriers) and, for a version no reservation +// holds, asks the seat mesh-catalog's verb versions. The process's manifest declares the verb in `serves` beside its process. + +// reservedReader is the state reserved read through its store, as the verb's answers read it. +func reservedReader(ctx context.Context, store versions.Store) versions.Reader { + return versions.Reader{ + Keys: func() ([]string, error) { return store.KeysOf(ctx, versions.AllFilter) }, + Get: func(key string) (json.RawMessage, bool, error) { + v, _, found, err := store.Get(ctx, key) + return v, found, err + }} +} + +// reservedVerb is the handler of the verb reserved. +func reservedVerb(ctx context.Context, rr versions.Reader, carriers func(context.Context, string) ([]versions.Built, error), released versions.CatalogVersions) process.SeatHandler { + return func(_ string, body json.RawMessage) (any, error) { + var in meshbuilds.ReservedInput + if len(body) > 0 { + if err := json.Unmarshal(body, &in); err != nil { + return nil, fmt.Errorf("the arguments do not fit the verb reserved: %w", err) + } + } + if name := strings.TrimSpace(in.Name); name != "" { + return versions.AnswerReservedByName(ctx, rr, carriers, released, name, in.Version) + } + return versions.AnswerReservedVerb(rr, in.Module, in.Version) + } +} + +// catalogReleased are a module's released versions, asked of the seat mesh-catalog's verb versions through call. +func catalogReleased(call toolCaller) versions.CatalogVersions { + return func(_ context.Context, module string) ([]meshcatalog.VersionEntry, error) { + raw, err := call("seat:"+meshcatalog.Seat+".versions", meshcatalog.VersionsInput{Module: module}) + if err != nil { + return nil, err + } + var a meshcatalog.VersionsAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("the seat mesh-catalog's versions answered what does not read: %w", err) + } + return a.Versions, nil + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb_test.go new file mode 100644 index 00000000..ef87f65e --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + + "mesh-delivery-builder/internal/versions" +) + +// Design 47 G A-115, A-127: the builder's process answers the verb reserved, given a name, from the catalog half's carriers and the +// seat mesh-catalog's versions, and given a module from its reservations. +func TestTheProcessAnswersReserved(t *testing.T) { + none := versions.Reader{Keys: func() ([]string, error) { return nil, nil }, Get: func(string) (json.RawMessage, bool, error) { return nil, false, nil }} + carriers := func(_ context.Context, name string) ([]versions.Built, error) { + if name == "@novox/mesh-sdk" { + return []versions.Built{{Module: "mesh-sdk", Artifact: "ts"}}, nil + } + return nil, nil + } + var asked []string + released := catalogReleased(func(key string, _ any) (json.RawMessage, error) { + asked = append(asked, key) + return json.Marshal(meshcatalog.VersionsAnswer{Versions: []meshcatalog.VersionEntry{{Module: "mesh-sdk", Version: "0.1.2", + Artifacts: []meshcatalog.VersionArtifact{{Artifact: "mesh-sdk:ts", Digest: "sha256:dd"}}}}}) + }) + handle := reservedVerb(context.Background(), none, carriers, released) + got, err := handle("", json.RawMessage(`{"name":"@novox/mesh-sdk","version":"0.1.2"}`)) + a, ok := got.(meshbuilds.ReservedAnswer) + if err != nil || !ok || !a.Released || a.Module != "mesh-sdk" || len(a.Artifacts) != 1 || a.Artifacts[0].Digest != "sha256:dd" { + t.Fatalf("reserved by name = %+v (%v)", got, err) + } + if len(asked) != 1 || asked[0] != "seat:mesh-catalog.versions" { + t.Errorf("the released versions were asked of %v, want the seat mesh-catalog's versions", asked) + } + if _, err := handle("", json.RawMessage(`{"module":"mesh-sdk"}`)); err == nil { + t.Error("a module with no reservation was answered") + } + if _, err := handle("", json.RawMessage(`{"module":`)); err == nil { + t.Error("arguments that are no JSON were answered") + } +} diff --git a/modules/mesh-delivery-builder/internal/versions/republish.go b/modules/mesh-delivery-builder/internal/versions/republish.go index 07dc30cc..e5950b5d 100644 --- a/modules/mesh-delivery-builder/internal/versions/republish.go +++ b/modules/mesh-delivery-builder/internal/versions/republish.go @@ -118,6 +118,22 @@ type Republisher struct { mu sync.Mutex runs map[string]map[string][]string // node -> holder module -> the registry seats it claims busy map[string]bool + // lastQueued is the newest time a version was queued at: a clock that has not moved between two versions (a coarse clock, + // or a fixed one) must not make the queue's order the order of its keys, so each is queued after the last. + lastQueued time.Time +} + +// queuedAt is the time a version is queued at: now, or a nanosecond after the last one queued when now is not after it, so the +// order the versions were queued in (newest reservation first) is the order they are sent in. +func (r *Republisher) queuedAt() time.Time { + r.mu.Lock() + defer r.mu.Unlock() + at := r.now() + if !at.After(r.lastQueued) { + at = r.lastQueued.Add(time.Nanosecond) + } + r.lastQueued = at + return at } func (r *Republisher) now() time.Time { @@ -349,7 +365,7 @@ func (r *Republisher) enqueue(ctx context.Context, v ReservedVersion, holder str } else if found { return false, nil } - raw, _ := json.Marshal(QueuedPublish{V: 1, Holder: holder, Module: v.Module, Version: v.Version, Artifacts: arts, Queued: r.now()}) + raw, _ := json.Marshal(QueuedPublish{V: 1, Holder: holder, Module: v.Module, Version: v.Version, Artifacts: arts, Queued: r.queuedAt()}) used, err := r.Queue.Bytes(ctx) if err != nil { return false, fmt.Errorf("the size of %s cannot be read: %w", PublishQueueBucket, err) diff --git a/modules/mesh-delivery-builder/manifest_test.go b/modules/mesh-delivery-builder/manifest_test.go index 8fbbe0b4..1311e4e5 100644 --- a/modules/mesh-delivery-builder/manifest_test.go +++ b/modules/mesh-delivery-builder/manifest_test.go @@ -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)) { @@ -170,7 +170,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", "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_publish_queue", "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) { + "builder_floor_again", "builder_floor_waits", "builder_old_form", "builder_publish_again", "builder_publish_queue", "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"}; !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. @@ -195,6 +195,19 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 if len(m.Claims) != 1 || m.Claims[0].Name != "mesh-builds" || m.Claims[0].Scope != "mesh" || !reflect.DeepEqual(m.Claims[0].Serves, []string{"reserved"}) { t.Errorf("claims = %+v, want mesh-builds, scope mesh, serving reserved", m.Claims) } + // A-115: the process answers the verb itself and says so in `serves` beside its process, with no `bus`; the bundle serves no tool named so. + var resources []map[string]any + if err := json.Unmarshal(top["resources"], &resources); err != nil { + t.Fatal(err) + } + for _, r := range resources { + if r["type"] == "process" && !reflect.DeepEqual(r["serves"], []any{"seat:mesh-builds.reserved"}) { + t.Errorf("the process serves %v, want seat:mesh-builds.reserved (A-115)", r["serves"]) + } + } + if slices.Contains(m.Tools, "reserved") { + t.Error("the tools bundle serves reserved, which the process answers") + } for _, k := range []string{"provides", "requires", "defines-seats", "seat", "seats", "bus"} { if _, ok := top[k]; ok { t.Errorf("the manifest declares %q: the builder has none", k) diff --git a/modules/mesh-delivery-builder/module.json b/modules/mesh-delivery-builder/module.json index 9702b31b..74a51154 100644 --- a/modules/mesh-delivery-builder/module.json +++ b/modules/mesh-delivery-builder/module.json @@ -35,7 +35,6 @@ "builder_toolchains_drop", "builder_waits", "builder_versions", - "reserved", "builder_first_views", "builder_first_view_again" ], @@ -73,7 +72,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 +98,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 +158,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", @@ -209,6 +207,9 @@ "type": "process", "name": "mesh-delivery-builder", "artifact": "program", + "serves": [ + "seat:mesh-builds.reserved" + ], "run": [ "./mesh-delivery-builder" ], diff --git a/modules/mesh-delivery-teller/manifest_test.go b/modules/mesh-delivery-teller/manifest_test.go index bbc56f58..aca44653 100644 --- a/modules/mesh-delivery-teller/manifest_test.go +++ b/modules/mesh-delivery-teller/manifest_test.go @@ -55,6 +55,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` @@ -93,7 +94,7 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1 // answers is the teller's answer to each ask; asked is what it asked of the router, which condition it raised and which // gate key and revision each ask was made at (issue 1343); ends is the ends asked by hand with teller_end_ask (issue 774). // first-views is where its first view of the assigner's asks stands and findings its condition past the bound (ADR 0615). - if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } // The view's answers to the assigner's asks are read so the view's answer fills an ask that needs no proof (1297/B10); the @@ -133,14 +134,17 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1 t.Errorf("emits = %v, want %v", m.Emits, want) } // design 47 G §383, §387: the stream -findings, 7 days and 16 MiB; §368: version is MAJOR.MINOR. - if want := []stream{{Name: "mesh-delivery-teller-findings", Events: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, MaxAgeDays: 7, MaxMiB: 16}}; !reflect.DeepEqual(m.Streams, want) { - t.Errorf("streams = %+v, want %+v", m.Streams, want) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(1); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } if m.Version != "0.1" { t.Errorf("version = %q, want 0.1", m.Version) } // No bus or seat (ADR 0437 §4). - for _, k := range []string{"logs", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} { + for _, k := range []string{"logs", "claims", "provides", "defines-seats", "seat", "seats", "bus"} { if _, ok := top[k]; ok { t.Errorf("the manifest declares %q: the teller has none", k) } @@ -243,3 +247,22 @@ func TestNoPartHandWritesItsBusConnection(t *testing.T) { } } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index 9baa4b52..73d70683 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -15,12 +15,15 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient" + "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/modules/mesh-delivery-indexer/takein" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/busretry" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -405,6 +408,15 @@ func ownersRead(ask func(key string, body any) (json.RawMessage, error)) view.Re GraphState: indexer("indexer_read_graph")} } +// holderOf is a seat's holder as mesh-sdk's seatlist.HolderOf answers it (design 47 G A-126): false while the seat has none or +// the seat list cannot be read. +func holderOf(ask func(key string, body any) (json.RawMessage, error)) func(seat string) (string, bool) { + return func(seat string) (string, bool) { + module, err := seatlist.HolderOf(ask, seat) + return module, err == nil + } +} + func main() { r := ownersRead(stdio.Ask) r.Builds, r.HavesState = read(stdio.State("mesh-delivery-builder.builds")), read(stdio.State("node-engine.have")) @@ -416,16 +428,28 @@ func main() { r.ProvisionsState = read(stdio.State(view.ProvisionsRead)) r.BuildLog = stdio.Log(builds.BuildLogRead).Since r.Pending = pendingOf + // The build modules are no names (design 47 G A-82): the build seats on the seat list, each held by the module the seat + // list names for it; the seat list and its two tools are wired once by mesh-sdk (§204). + gate := seatlist.NewPart("view", []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-view-tools] "+f+"\n", a...) }) + }() + seats := buildrun.NewBuildSeats(func() map[string]seatdef.Definition { + if rd := gate.Reader(); rd != nil { + return rd.Definitions() + } + return nil + }, holderOf(stdio.Ask)) u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), - Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())} - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - u.Steps[lang] = read(stdio.State(module + "." + view.StepsState)) - u.Pauses[lang] = read(stdio.State(module + "." + buildstep.PausesState)) - } + Seats: seats, + StepsOf: func(seat string) view.StateRead { return read(stdio.State(seat + "." + view.StepsState)) }, + PausesOf: func(seat string) view.StateRead { return read(stdio.State(seat + "." + buildstep.PausesState)) }, + Backlog: backlogOf(socket())} // view_first_views and view_first_view_again: where the view's process stands with its first views of the indexer's and // the assigner's states (ADR 0615 §6), and a re-read by hand. - tools := append(Tools(r, u), views.StatusTools(viewstate.ViewModule, "view_first_views", "view_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) + tools := append(Tools(r, u), gate.Tools()...) + tools = append(tools, views.StatusTools(viewstate.ViewModule, "view_first_views", "view_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) if err := stdio.Serve("", tools); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] %v\n", err) os.Exit(1) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index c4231d61..1072a07e 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -3,6 +3,7 @@ package main import ( "crypto/ed25519" "encoding/json" + "errors" "fmt" "mesh-delivery-view/internal/view" "reflect" @@ -20,6 +21,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/busretry" "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/seatlist" ) type fake map[string]string @@ -625,3 +627,26 @@ func manifestReadsAsksEveryNode(t *testing.T) { t.Errorf("node_manifest_reads = %+v", rows) } } + +// Design 47 G A-82, A-74: a build seat's holder is the module the seat mesh-module-assigner's verb assignments names for it with +// the seats, and none while the seat has no holder or the list cannot be read. +func TestABuildSeatsHolderIsTheOneTheSeatListNames(t *testing.T) { + var asked []string + ask := func(key string, _ any) (json.RawMessage, error) { + asked = append(asked, key) + return json.RawMessage(`{"seats":[{"seat":"node-build-go","scope":"node","holders":[{"module":"acme-go","node":"n1"}]},{"seat":"node-build-node","scope":"node","holders":[]}]}`), nil + } + holder := holderOf(ask) + if m, ok := holder("node-build-go"); !ok || m != "acme-go" { + t.Errorf("the holder of node-build-go = %q, %v; want acme-go", m, ok) + } + if m, ok := holder("node-build-node"); ok { + t.Errorf("a seat with no holder has %q", m) + } + if len(asked) != 2 || asked[0] != seatlist.HolderKey() { + t.Errorf("asked %v; want the seat mesh-module-assigner's assignments", asked) + } + if _, ok := holderOf(func(string, any) (json.RawMessage, error) { return nil, errors.New("down") })("node-build-go"); ok { + t.Error("a seat list that cannot be read names a holder") + } +} diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index a97d1bc4..b86a0904 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -16,6 +16,7 @@ require ( git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-delivery-view/internal/view/usage.go b/modules/mesh-delivery-view/internal/view/usage.go index 190d6baa..368a2605 100644 --- a/modules/mesh-delivery-view/internal/view/usage.go +++ b/modules/mesh-delivery-view/internal/view/usage.go @@ -21,6 +21,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + buildseats "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/sdk/buildrun" "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) @@ -50,9 +51,11 @@ const ( // UsageReader reads what build_usage answers from. type UsageReader struct { - // Steps and Pauses are each build module's states by language (go, node, image). - Steps map[string]StateRead - Pauses map[string]StateRead + // Seats are the build seats, each by the acting rule that builds, with the module holding it on the seat list (design 47 G + // A-82): no build module is named here. StepsOf and PausesOf read a build seat's steps and pauses through the seat. + Seats *buildseats.BuildSeats + StepsOf func(seat string) StateRead + PausesOf func(seat string) StateRead // Resolved is mesh-config's resolved values (..); Haves the node-engine's have. Resolved StateRead Haves StateRead @@ -209,6 +212,16 @@ func (u UsageReader) Usage() UsageAnswer { note := func(f string, a ...any) { ans.Notes = append(ans.Notes, fmt.Sprintf(f, a...)) } tries := u.tries(now, &ans, note) + // The build seats once for this answer: each asks the seat list for its holder (design 47 G A-82). + buildSeats := u.Seats.All() + buildSeatOfModule := func(module string) buildseats.BuildModule { + for _, m := range buildSeats { + if m.Module == module && module != "" { + return m + } + } + return buildseats.BuildModule{} + } seats := map[string]map[string]*SeatUsage{} // node -> language -> seat seat := func(node, lang string) *SeatUsage { if seats[node] == nil { @@ -217,9 +230,13 @@ func (u UsageReader) Usage() UsageAnswer { if s := seats[node][lang]; s != nil { return s } - name, _ := buildstep.BuildSeat(lang) - module, _ := buildstep.BuildModuleOf(lang) - s := &SeatUsage{Seat: name, Module: module, Running: RunningUsage{ByKind: map[string]int{}}, LeftRunning: []StepUsage{}, EndedStale: []StepUsage{}, Steps: []StepUsage{}} + var bm buildseats.BuildModule + for _, m := range buildSeats { + if m.AskLanguage() == lang { + bm = m + } + } + s := &SeatUsage{Seat: bm.Seat, Module: bm.Module, Running: RunningUsage{ByKind: map[string]int{}}, LeftRunning: []StepUsage{}, EndedStale: []StepUsage{}, Steps: []StepUsage{}} seats[node][lang] = s return s } @@ -233,10 +250,8 @@ func (u UsageReader) Usage() UsageAnswer { } else { var want []string for _, k := range keys { - for _, lang := range buildstep.AskLanguages { - if m, _ := buildstep.BuildModuleOf(lang); strings.HasSuffix(k, "."+m) { - want = append(want, k) - } + if _, module, _ := strings.Cut(k, "."); buildSeatOfModule(module).Seat != "" { + want = append(want, k) } } raws, err := u.Haves.values(want) @@ -256,8 +271,8 @@ func (u UsageReader) Usage() UsageAnswer { } for k, h := range haves { node, module, _ := strings.Cut(k, ".") - lang := strings.TrimPrefix(module, "mesh-build-") - s := seat(node, lang) + bm := buildSeatOfModule(module) + s := seat(node, bm.AskLanguage()) s.Phase, s.Holds = string(h.Phase), h.Phase == nodeengineclient.PhaseRunning } @@ -310,19 +325,27 @@ func (u UsageReader) Usage() UsageAnswer { } for key := range unreadable { node, module, _ := strings.Cut(key, ".") - seat(node, strings.TrimPrefix(module, "mesh-build-")) + bm := buildSeatOfModule(module) + seat(node, bm.AskLanguage()) } // Every node with a resolved capacity is listed, so "is this node eligible" is answered for it. for key := range resolved { node, module, _ := strings.Cut(key, ".") - seat(node, strings.TrimPrefix(module, "mesh-build-")) + bm := buildSeatOfModule(module) + seat(node, bm.AskLanguage()) + } + if len(buildSeats) == 0 { + note("no build seat is known from the seat definitions and the seat list, so no steps or pauses are read") } // The steps of each build module. steps := map[string]map[string][]usageStep{} // node -> language -> steps - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - sr, ok := u.Steps[lang] - if !ok || sr.Keys == nil || sr.Get == nil { + for _, bm := range buildSeats { + lang, module := bm.AskLanguage(), cmpOr(bm.Module, bm.Seat) + var sr StateRead + if u.StepsOf != nil { + sr = u.StepsOf(bm.Seat) + } + if sr.Keys == nil || sr.Get == nil { note("the steps of %s are not readable here", module) continue } @@ -379,10 +402,13 @@ func (u UsageReader) Usage() UsageAnswer { // Pauses: paused. of each module; and capacity., the capacity the module says it judges by. paused := map[string]bool{} // . judged := map[string]buildstep.Capacity{} - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - pr, ok := u.Pauses[lang] - if !ok || pr.Get == nil { + for _, bm := range buildSeats { + lang, module := bm.AskLanguage(), cmpOr(bm.Module, bm.Seat) + var pr StateRead + if u.PausesOf != nil { + pr = u.PausesOf(bm.Seat) + } + if pr.Get == nil { note("the pauses of %s are not readable here, so none is said paused", module) continue } @@ -422,7 +448,8 @@ func (u UsageReader) Usage() UsageAnswer { for _, node := range nodes { nu := NodeUsage{Node: node, Seats: []SeatUsage{}, Said: []string{}, CPU: "not on the bus: the disk-load module keeps no state; " + node + "/disk-load.cpu_top reads it (the build modules' share among its units)"} - for _, lang := range buildstep.AskLanguages { + for _, bm := range buildSeats { + lang := bm.AskLanguage() s := seats[node][lang] if s == nil { continue @@ -696,3 +723,11 @@ func sumMedian(d []int64) (sum, median int64) { } return sum, s[(len(s)-1)/2] } + +// cmpOr is the first of two names that is not empty. +func cmpOr(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/modules/mesh-delivery-view/internal/view/usage_test.go b/modules/mesh-delivery-view/internal/view/usage_test.go index 2762f299..02d87032 100644 --- a/modules/mesh-delivery-view/internal/view/usage_test.go +++ b/modules/mesh-delivery-view/internal/view/usage_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + buildseats "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/seatdef" "strconv" "strings" "testing" @@ -82,8 +84,19 @@ func usageFixture() UsageReader { "shanks.mesh-config.other": `{"value":"x"}`, "g14.mesh-build-node.capacity": `{"node":"g14","module":"mesh-build-node","key":"capacity","source":"unreadable"}`, }), - Steps: map[string]StateRead{"go": states(goSteps), "node": states(map[string]string{}), "image": states(map[string]string{})}, - Pauses: map[string]StateRead{"go": states(map[string]string{"paused.ace": `{"v":1,"paused":true,"by":"lead@shanks","at":"2026-10-26T09:00:00Z"}`}), "node": states(nil), "image": states(nil)}, + Seats: buildSeatsOf(), + StepsOf: func(seat string) StateRead { + if seat == "node-build-go" { + return states(goSteps) + } + return states(map[string]string{}) + }, + PausesOf: func(seat string) StateRead { + if seat == "node-build-go" { + return states(map[string]string{"paused.ace": `{"v":1,"paused":true,"by":"lead@shanks","at":"2026-10-26T09:00:00Z"}`}) + } + return states(nil) + }, Tries: states(map[string]string{ "try-" + strconv.FormatInt(at("11:29").UnixNano(), 10): `{"id":"try-1","repository":"novox/mesh-sdk","head":"h","trunk":"t","state":"pass","asks":["` + tryAsk + `"]}`, "try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","started":"2026-10-26T11:58:00Z"}`, @@ -167,7 +180,7 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { // Nothing readable: each state is named in a note, the backlog's refusal too, and nothing is said held. none := UsageReader{Now: func() time.Time { return usageNow }, Backlog: func() ([]BacklogRow, error) { return nil, errors.New("no socket") }}.Usage() notes := strings.Join(none.Notes, " | ") - for _, want := range []string{"have is not readable", "capacity unknown to this tool: mesh-config's resolved values are not readable", "steps of mesh-build-go are not readable", "tries are not readable", "did not answer: no socket"} { + for _, want := range []string{"have is not readable", "capacity unknown to this tool: mesh-config's resolved values are not readable", "no build seat is known", "tries are not readable", "did not answer: no socket"} { if !strings.Contains(notes, want) { t.Errorf("the notes %q do not say %q", notes, want) } @@ -196,8 +209,8 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { // Issue 1806's follow-up: the module on shanks says it judges by 1 of mesh-config's 2 (its build CPU bounds it); the // seat says the module's capacity and why. And issue 1890: a running try that names its asks is said with them. u = usageFixture() - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":1,"configured":2,"quota-from":"the cpu.max of /meshyield.slice","quota-cores":6,"cores-per-step":6,"by-cpu":1,"why":"1, bounded by the CPU","at":"2026-10-26T11:00:00Z"}`, - "capacity.ace": `{"v":1,"effective":2,"configured":3,"why":"stale: mesh-config says 2 now"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":1,"configured":2,"quota-from":"the cpu.max of /meshyield.slice","quota-cores":6,"cores-per-step":6,"by-cpu":1,"why":"1, bounded by the CPU","at":"2026-10-26T11:00:00Z"}`, + "capacity.ace": `{"v":1,"effective":2,"configured":3,"why":"stale: mesh-config says 2 now"}`})) u.Tries = states(map[string]string{"try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","asks":["` + tryAsk0 + `"],"steps":[{"ask":"` + tryAsk0 + `","node":"shanks","state":"running","phase":"unit: tests of 3 packages"}]}`}) bound := u.Usage() sc := seatOf(t, bound, "shanks", "node-build-go") @@ -208,13 +221,13 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { t.Errorf("a module's word older than mesh-config's value is not taken: %+v", sa) } // The operator, 2026-10-26: outside its build hours the seat takes nothing new, and says so. - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":0,"configured":2,"build-hours":"18:00-06:00 CET","outside-hours":true,"why":"0, outside its build hours 18:00-06:00 CET until 18:00 (otherwise 2)","at":"2026-10-26T11:00:00Z"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":0,"configured":2,"build-hours":"18:00-06:00 CET","outside-hours":true,"why":"0, outside its build hours 18:00-06:00 CET until 18:00 (otherwise 2)","at":"2026-10-26T11:00:00Z"}`})) if so := seatOf(t, u.Usage(), "shanks", "node-build-go"); so.Capacity != 0 || !so.OutsideHours || so.Eligible == nil || *so.Eligible || so.WhyNot != "outside its build hours 18:00-06:00 CET on shanks: it takes nothing new until they open" { t.Errorf("a seat outside its build hours: %+v", so) } // A build-hours value the module cannot read: it builds at any hour, and the seat says the value and why. - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":2,"configured":2,"build-hours-fault":"the build hours \"shanks=18:00-6\": \"6\" is not a time HH:MM","why":"2; at any hour, because its build hours cannot be read","at":"2026-10-26T11:00:00Z"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":2,"configured":2,"build-hours-fault":"the build hours \"shanks=18:00-6\": \"6\" is not a time HH:MM","why":"2; at any hour, because its build hours cannot be read","at":"2026-10-26T11:00:00Z"}`})) if sf := seatOf(t, u.Usage(), "shanks", "node-build-go"); sf.BuildHours != "any hour" || !strings.Contains(sf.BuildHoursFault, "shanks=18:00-6") || sf.Eligible == nil || !*sf.Eligible { t.Errorf("a seat whose build hours cannot be read: %+v", sf) } @@ -225,3 +238,25 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { } var tryAsk0 = stepID(at("11:58"), "a00010") + +// buildSeatsOf is the build seats as the seat definitions and the seat list give them (design 47 G A-82): the seats whose +// acting rule builds, each held by the module the seat list names. +func buildSeatsOf() *buildseats.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": "mesh-build-go", "node-build-node": "mesh-build-node", "node-build-image": "mesh-build-image"} + return buildseats.NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok }) +} + +// setPauses replaces what one build seat's pauses read. +func setPauses(u *UsageReader, seat string, st StateRead) { + prev := u.PausesOf + u.PausesOf = func(s string) StateRead { + if s == seat { + return st + } + return prev(s) + } +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 846c0b58..d0e3e918 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -75,6 +75,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Items []item `json:"items"` Build struct { Artifacts []artifact `json:"artifacts"` @@ -105,7 +106,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-engine.have"} // no reads of the indexer's or the assigner's states (ADR 0615 §1, DLV-M4); view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-build-go.pauses", "node-build-go.steps", "node-build-image.pauses", "node-build-image.steps", "node-build-node.pauses", "node-build-node.steps", "node-engine.have"} // the build seats' states by seat, never a build module (design 47 G A-82); // no reads of the indexer's or the assigner's states (ADR 0615 §1, DLV-M4); view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -123,19 +124,24 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 "mesh-delivery-indexer.indexer_read_graph", "mesh-delivery-indexer.indexer_read_heard", "mesh-delivery-indexer.indexer_pending", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken", "seat:mesh-catalog.revision", // ADR 0663: the modules are the catalog half's, read through the seat's verb revision, never the indexer's + "seat:mesh-catalog.seat-definitions", // design 47 G A-82, §204: the build seats are read from the seat list "seat:mesh-hand-acts.hand-act", // the join: its first view asked again by hand is a hand act (views.StatusTools) + "seat:mesh-module-assigner.assignments", // A-82: the holder of each build seat "seat:node-engine.manifest-reads"}; !reflect.DeepEqual(m.Invokes, want) { // ADR 0618 §3: node_manifest_reads asks the node-engine seat's verb of every node; 1585/T24: view_tiers asks indexer_pending, a verb edge (ADR 0614 §2) t.Errorf("invokes = %v, want %v", m.Invokes, want) } // design 47 G §387: the keeper's ask for the findings is heard, the finding stream declared (§383), and §368: version is MAJOR.MINOR. - if want := []stream{{Name: "mesh-delivery-view-findings", Events: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, MaxAgeDays: 7, MaxMiB: 16}}; !reflect.DeepEqual(m.Streams, want) { - t.Errorf("streams = %+v, want %+v", m.Streams, want) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(1); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } if m.Version != "0.1" { t.Errorf("version = %q, want 0.1", m.Version) } if want := []string{"mesh-conditions.findings-asked", "mesh-delivery-assigner.views.asks", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants", - "mesh-catalog.catalog-revision", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) { + "mesh-catalog.catalog-revision", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) { t.Errorf("consumes = %v, want %v", m.Consumes, want) } // The one event the view says: a source revision is delivered (1906/D9, ADR 0539 §17), derived by it from the wants and the haves. @@ -149,7 +155,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 // The states the view writes: the operator's answers to the asks that need no proof (ADR 0528 clause 6, 1297/B9), which the // teller reads, and how far each repository reached (ADR 0556 clause 2, issue 1280), which the gate reads. // Its first views of the owners' states and the finding of one unanswered past its bound (ADR 0615 §6). - if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 { @@ -160,7 +166,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads", "view_seat_list", "view_seat_list_again"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } // D-11: the holders and buckets the view names are its manifest's (no contract names a holder, novox/hq ADR 0634 §1): a @@ -430,3 +436,22 @@ func TestEveryStateTheCommandsReadIsIssued(t *testing.T) { t.Fatal("no stdio.State literal found under cmd/: the scan reads nothing") } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +} diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index feb4694c..cb6231ff 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -27,7 +27,9 @@ "view_tiers", "view_first_views", "view_first_view_again", - "node_manifest_reads" + "node_manifest_reads", + "view_seat_list", + "view_seat_list_again" ], "emits": [ "delivered", @@ -56,6 +58,8 @@ "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants", "mesh-catalog.catalog-revision", + "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken" ], @@ -73,16 +77,18 @@ "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken", "seat:mesh-catalog.revision", + "seat:mesh-catalog.seat-definitions", "seat:mesh-hand-acts.hand-act", + "seat:mesh-module-assigner.assignments", "seat:node-engine.manifest-reads" ], "reads": [ - "mesh-build-go.pauses", - "mesh-build-go.steps", - "mesh-build-image.pauses", - "mesh-build-image.steps", - "mesh-build-node.pauses", - "mesh-build-node.steps", + "node-build-go.pauses", + "node-build-go.steps", + "node-build-image.pauses", + "node-build-image.steps", + "node-build-node.pauses", + "node-build-node.steps", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", diff --git a/modules/mesh-delivery-watchman/manifest_test.go b/modules/mesh-delivery-watchman/manifest_test.go index 70e977ac..82f31b4b 100644 --- a/modules/mesh-delivery-watchman/manifest_test.go +++ b/modules/mesh-delivery-watchman/manifest_test.go @@ -69,6 +69,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` @@ -98,7 +99,7 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 if m.Module != "mesh-delivery-watchman" || m.Moved || m.Slug != "watchman" { t.Errorf("module %q moved %v (it has no template, so it is not moved) slug %q", m.Module, m.Moved, m.Slug) } - want := []state{{Name: "presence", History: 20}, {Name: "pulse", KeyTTL: true}, {Name: "stuck", History: 20}, {Name: "findings", History: 5}, {Name: "first-views", History: 1}} + want := []state{{Name: "presence", History: 20}, {Name: "pulse", KeyTTL: true}, {Name: "stuck", History: 20}, {Name: "first-views", History: 1}} if !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } @@ -114,9 +115,11 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 t.Errorf("version = %q, want MAJOR.MINOR", m.Version) } // Design 47 G §387: the finding stream, 7 days and 16 MiB, and the keeper's ask. - if len(m.Streams) != 1 || m.Streams[0].Name != "mesh-delivery-watchman-findings" || m.Streams[0].MaxAgeDays != 7 || m.Streams[0].MaxMiB != 16 || - !reflect.DeepEqual(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) { - t.Errorf("streams = %+v, want the finding stream", m.Streams) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(5); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } // DLV-M5 (ADR 0615 §1): the assigner's wants and the indexer's modules are their owners' states, asked by their read // tools and followed by their views families, never read. @@ -272,3 +275,22 @@ func TestNoTickerTimerOrSleep(t *testing.T) { // 1135/AC7 t.Fatalf("read %d source files, want the program's", files) } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +}