Pull the Node build seat's asks beside the tools, delivery first and at most the node's capacity (issue 1887 A3node, ADR 0525 §2-§6)

This commit is contained in:
2026-10-23 15:39:30 +02:00
parent 6e823d1f9b
commit 8356d30e1b
7 changed files with 226 additions and 6 deletions
@@ -0,0 +1,72 @@
package main
import (
"context"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **The module pulls its seat's asks** (novox/hq ADR 0525 §2-§6, ADR 0536 §4; issue 1887 A3node), as mesh-build-go
// does: the seat node-build-node, delivery first, then check, then recheck, at most the node's build capacity at once
// (its setting `capacity`); each ask runs with the step bodies its tools run, behind the same guard, and ends on the key
// <node>.<ask id>. The tools stay as the hand path (ADR 0525 §8).
// Seat is the node seat this module holds.
const Seat = "node-build-node"
func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
byName := map[string]buildstep.StepFunc{}
for _, s := range steps {
run := s.Run
byName[s.Name] = func(ctx context.Context, args map[string]any) (any, error) {
defer m.holdArg(args)()
return run(ctx, args)
}
}
return buildstep.AskSteps{
Fetch: byName["build_node_fetch"], Build: byName["build_node_build"], Test: byName["build_node_test"],
Toolchain: func(ctx context.Context) (delivery.ToolchainUsed, error) {
installed, err := m.Probe(ctx)
if err != nil {
return delivery.ToolchainUsed{}, err
}
return delivery.ToolchainUsed{Version: installed, Recipe: m.recipe().Version()}, nil
},
Release: func(ws string) error { return m.spaces().Release(ws) },
Logf: m.Say,
}
}
// Puller is the module's pull of its seat's asks, on source (the runtime's seat pull when nil).
func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep.Puller {
if source == nil {
source = buildstep.SeatAsks{Language: delivery.AskNode}
}
capacity := m.Capacity
if capacity == nil {
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
}
ask := m.askSteps(steps)
var load func(string) ([]byte, error)
if m.ReadSteps.Get != nil {
load = func(key string) ([]byte, error) { return m.ReadSteps.Get(key) }
}
return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, Load: load,
Capacity: capacity, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Prepare(ctx); err != nil {
return nil, err
}
if err := m.room(); err != nil {
return nil, err
}
return buildstep.RunAsk(ctx, ask, a)
}}
}
// SeatTools are the seat's verbs on this node (ADR 0525 §10).
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
return buildstep.SeatTools(Seat, "Node", p)
}
@@ -0,0 +1,106 @@
package main
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const askCommit = "0123456789abcdef0123456789abcdef01234567"
func fakeSteps(calls *[]string) []StepTool {
answer := func(name string, out any) StepTool {
return StepTool{Name: name, Run: func(ctx context.Context, args map[string]any) (any, error) {
*calls = append(*calls, name)
return out, nil
}}
}
return []StepTool{
answer("build_node_fetch", map[string]any{"workspace": "ws1", "modules": []string{"modules/gitea"}}),
answer("build_node_build", map[string]any{"digest": "sha256:bb", "reference": "artifact-store://gitea/code@sha256:bb"}),
answer("build_node_test", map[string]any{"passed": true}),
}
}
func nodeAsk(kind string) delivery.StepAsk {
a := delivery.StepAsk{ID: buildstep.NewID(), Kind: kind, Language: delivery.AskNode, Asker: "mesh-delivery-checker", At: time.Now()}
fetch := map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit}
if kind == delivery.AskDelivery {
a.Asker = "mesh-delivery-builder"
a.Build = &delivery.BuildJob{Module: "gitea", Path: "modules/gitea", Fetch: fetch, Artifacts: []delivery.AskArtifact{{Artifact: "code"}}}
} else {
a.Check = &delivery.CheckJob{Fetch: fetch, Level: delivery.LevelUnit}
}
return a
}
// 1887/A3node: a pulled build ask runs fetch then build and its key names the reference; a check ask tests each module
// directory the fetch named.
func TestAPulledNodeAskRunsItsJob(t *testing.T) {
states := newStates()
m := moduleWith(states, "v26.10.0")
m.Capacity = func() int { return 1 }
var calls []string
src := buildstep.NewFakeAsks()
build, check := nodeAsk(delivery.AskDelivery), nodeAsk(delivery.AskCheck)
_ = src.PublishAsk(check)
_ = src.PublishAsk(build)
p := m.Puller(fakeSteps(&calls), src)
p.PullWait = 10 * time.Millisecond
ctx, stop := context.WithCancel(context.Background())
defer stop()
go p.Pull(ctx)
for deadline := time.Now().Add(3 * time.Second); ; time.Sleep(5 * time.Millisecond) {
if acked, _, _ := src.Fates(); len(acked) == 2 {
break
}
if time.Now().After(deadline) {
t.Fatalf("the asks were not both acked: calls %v", calls)
}
}
if got := strings.Join(calls, ","); got != "build_node_fetch,build_node_build,build_node_fetch,build_node_test" {
t.Errorf("calls = %s, want the delivery ask's job first, then the check's", got)
}
key, _ := delivery.AskStepKey("ace", build.ID)
states.mu.Lock()
v := states.steps[key]
states.mu.Unlock()
if len(v) == 0 || !strings.Contains(string(v[len(v)-1].Result), "artifact-store://gitea/code@sha256:bb") {
t.Fatalf("the step %s = %v", key, v)
}
}
// 1887/A3node, A7: the manifest takes the node's build capacity as a setting, with the builder's default, in a settings
// file; the seat's verbs are served.
func TestManifestTakesTheBuildCapacity(t *testing.T) {
m := readManifest(t)
raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct {
Config map[string]struct {
Default string `json:"default"`
} `json:"config"`
}
_ = json.Unmarshal(raw, &doc)
if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q", doc.Config[buildstep.CapacitySetting].Default)
}
if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" {
t.Error("no settings file")
}
m0 := &Module{Node: "ace", Capacity: func() int { return 0 }}
served := map[string]bool{}
for _, tool := range m0.SeatTools(m0.Puller(nil, buildstep.NewFakeAsks())) {
served[tool.Name] = true
}
for _, verb := range []string{"current", "pause", "resume"} {
if !served[Seat+"."+verb] {
t.Errorf("%s.%s is not served", Seat, verb)
}
}
}
@@ -55,6 +55,7 @@ func main() {
RegistryFile: filepath.Join(home, "registry"),
}
m.Floor = floorOf(m.Node)
m.Settings = filepath.Join(home, "settings.json")
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
if m.Node != "" {
go m.Start(ctx)
@@ -65,7 +66,13 @@ func main() {
logf("announcing the states it keeps: %v", err)
os.Exit(1)
}
if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil {
steps := m.StepTools()
puller := m.Puller(steps, nil)
if m.Node != "" {
// The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again.
go func() { _ = puller.Pull(ctx) }()
}
if err := stdio.Serve("", append(m.Tools(steps), m.SeatTools(puller)...)); err != nil {
logf("%v", err)
os.Exit(1)
}
@@ -86,9 +86,10 @@ func TestWorkAndCacheDirectoriesLieOutsideTheHome(t *testing.T) {
}
}
// The states are the three the module keeps, no seat is claimed (ADR 0469 §6), and the health is the module's own
// tool, which the manifest also lists among its tools.
func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) {
// The states are the three the module keeps, the one seat it claims is its language's build seat (ADR 0525 §2, which
// changes ADR 0469 §6's "no seat"), and the health is the module's own tool, which the manifest also lists among its
// tools.
func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *testing.T) {
m := readManifest(t)
states := []string{}
for _, s := range m.State {
@@ -98,8 +99,8 @@ func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T
if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if len(m.Claims) != 0 {
t.Errorf("claims = %v, want no seat", m.Claims)
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) {
t.Errorf("claims = %v, want the seat %s alone", m.Claims, Seat)
}
if !slices.Contains(m.Tools, HealthTool) {
t.Errorf("tools = %v lack %s", m.Tools, HealthTool)
@@ -63,6 +63,11 @@ type Module struct {
// sets floorOf(node), and a zero Floor checks nothing.
Floor buildstep.Floor
// Settings is the module's settings file, which holds the node's build capacity (buildstep.CapacitySetting); Capacity,
// when set, answers it instead (a test).
Settings string
Capacity func() int
spacesOnce sync.Once
workspaces *buildstep.Workspaces
mu sync.Mutex // guards Recipe
@@ -0,0 +1,3 @@
{
"capacity": "shanks=3,ace=2,g14=0,novox=0"
}
+26
View File
@@ -12,6 +12,21 @@
"keys",
"steps"
],
"claims": [
{
"name": "node-build-node",
"scope": "node",
"serves": ["current", "pause", "resume"]
}
],
"config": {
"capacity": {
"type": "string",
"description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0"
}
},
"tools": [
"build_node_fetch",
"build_node_test",
@@ -76,6 +91,17 @@
"place": ".",
"unknown": "program"
},
{
"id": "settings",
"trusted": false,
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "mesh-build-node",
"takes-config": ["capacity"],
"merge": "json",
"template": "files/settings.json"
},
{
"id": "work",
"type": "directory",