From 8356d30e1bee9e9ae8ee81a1bef09191f535d2d0 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:39:30 +0200 Subject: [PATCH] =?UTF-8?q?Pull=20the=20Node=20build=20seat's=20asks=20bes?= =?UTF-8?q?ide=20the=20tools,=20delivery=20first=20and=20at=20most=20the?= =?UTF-8?q?=20node's=20capacity=20(issue=201887=20A3node,=20ADR=200525=20?= =?UTF-8?q?=C2=A72-=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cmd/mesh-build-node/asks.go | 72 ++++++++++++ .../cmd/mesh-build-node/asks_test.go | 106 ++++++++++++++++++ .../cmd/mesh-build-node/main.go | 9 +- .../cmd/mesh-build-node/manifest_test.go | 11 +- .../cmd/mesh-build-node/module.go | 5 + modules/mesh-build-node/files/settings.json | 3 + modules/mesh-build-node/module.json | 26 +++++ 7 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/asks.go create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/asks_test.go create mode 100644 modules/mesh-build-node/files/settings.json diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks.go b/modules/mesh-build-node/cmd/mesh-build-node/asks.go new file mode 100644 index 00000000..fa3becaf --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks.go @@ -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 +// .. 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) +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go b/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go new file mode 100644 index 00000000..94fe3b91 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go @@ -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) + } + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index b6b1f30d..0856bd77 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -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) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index 74c56cff..9f66603e 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -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) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index 65660894..a464a88e 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -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 diff --git a/modules/mesh-build-node/files/settings.json b/modules/mesh-build-node/files/settings.json new file mode 100644 index 00000000..3175fe39 --- /dev/null +++ b/modules/mesh-build-node/files/settings.json @@ -0,0 +1,3 @@ +{ + "capacity": "shanks=3,ace=2,g14=0,novox=0" +} diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index ae14c688..c3b1cf84 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -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",