Pull the image build seat's asks beside the tools, at most the node's capacity (issue 1887 A3image, ADR 0525 §2-§6)

This commit is contained in:
2026-10-23 15:40:43 +02:00
parent 8356d30e1b
commit c304d3fb97
7 changed files with 184 additions and 5 deletions
@@ -0,0 +1,64 @@
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; issue 1887 A3image), as mesh-build-go does: the seat
// node-build-image, at most the node's build capacity at once (its setting `capacity`); a delivery or check ask is a build
// (fetch, build, or copy of a pinned image), never a check of tests. The tools stay as the hand path (ADR 0525 §8).
// Seat is the node seat this module holds.
const Seat = "node-build-image"
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_image_fetch"], Build: byName["build_image_build"], Copy: byName["build_image_copy"],
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.AskImage}
}
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.AskImage, 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.Check(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, "image", p)
}
@@ -0,0 +1,74 @@
package main
import (
"context"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const askCommit = "0123456789abcdef0123456789abcdef01234567"
// 1887/A3image: a pulled image ask copies a pinned image with no fetch, then fetches and builds the module's own, and its
// key <node>.<ask id> names each reference; the seat's verbs are served.
func TestAPulledImageAskCopiesThenBuilds(t *testing.T) {
states := newStates()
m := moduleWith(t, newFake(t), states)
m.Capacity = func() int { return 1 }
var calls []string
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
}}
}
steps := []StepTool{
answer("build_image_fetch", map[string]any{"workspace": "ws1"}),
answer("build_image_build", map[string]any{"digest": "sha256:cc", "reference": "registry/m/server@sha256:cc"}),
answer("build_image_copy", map[string]any{"digest": "sha256:dd", "reference": "registry/m/base@sha256:dd"}),
}
src := buildstep.NewFakeAsks()
a := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskDelivery, Language: delivery.AskImage, Asker: "mesh-delivery-builder",
At: time.Now(), Build: &delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "docker.io/library/alpine@sha256:00"}, {Artifact: "server"}}}}
_ = src.PublishAsk(a)
p := m.Puller(steps, src)
p.PullWait = 10 * time.Millisecond
ctx, stop := context.WithCancel(context.Background())
defer stop()
go p.Pull(ctx)
key, _ := delivery.AskStepKey("ace", a.ID)
var step delivery.Step
for deadline := time.Now().Add(3 * time.Second); ; time.Sleep(5 * time.Millisecond) {
states.mu.Lock()
v := states.steps[key]
states.mu.Unlock()
if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning {
step = v[len(v)-1]
break
}
if time.Now().After(deadline) {
t.Fatalf("the ask's step never ended: calls %v", calls)
}
}
if step.State != delivery.StepPassed || !strings.Contains(string(step.Result), "registry/m/server@sha256:cc") ||
!strings.Contains(string(step.Result), "registry/m/base@sha256:dd") {
t.Fatalf("the step = %+v", step)
}
if got := strings.Join(calls, ","); got != "build_image_copy,build_image_fetch,build_image_build" {
t.Errorf("calls = %s", got)
}
served := map[string]bool{}
for _, tool := range m.SeatTools(p) {
served[tool.Name] = true
}
for _, verb := range []string{"current", "pause", "resume"} {
if !served[Seat+"."+verb] {
t.Errorf("%s.%s is not served", Seat, verb)
}
}
}
@@ -42,6 +42,7 @@ func main() {
m.Home, m.Account, m.UID = u.HomeDir, u.Username, u.Uid
}
m.RegistryFile = m.Home + "/registry"
m.Settings = m.Home + "/settings.json"
m.Run = StepRunner(m.Home)
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
if m.Node != "" {
@@ -53,7 +54,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)
}
@@ -90,9 +90,9 @@ func TestWorkAndStoreDirectoriesLieOutsideTheHome(t *testing.T) {
}
}
// The states are the two the module keeps, no seat is claimed, the health is its own tool, which the manifest also lists,
// The states are the two the module keeps, the one seat it claims is its build seat (ADR 0525 §2), the health is its own tool, which the manifest also lists,
// and the registry's address is bound into a file of the home that the program reads.
func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) {
func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *testing.T) {
m := readManifest(t)
states := []string{}
for _, s := range m.State {
@@ -102,8 +102,8 @@ func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T
if want := []string{"keys", "steps"}; !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)
}
raw, _ := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if !strings.Contains(string(raw), `"tool": "`+HealthTool+`"`) {
@@ -50,6 +50,11 @@ type Module struct {
// 1839); main 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
}
@@ -0,0 +1,3 @@
{
"capacity": "shanks=3,ace=2,g14=0,novox=0"
}
+26
View File
@@ -11,6 +11,21 @@
"keys",
"steps"
],
"claims": [
{
"name": "node-build-image",
"scope": "node",
"serves": ["current", "pause", "resume"]
}
],
"config": {
"capacity": {
"type": "string",
"description": "How many asks of the image 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_image_fetch",
"build_image_build",
@@ -54,6 +69,17 @@
"place": ".",
"unknown": "program"
},
{
"id": "settings",
"trusted": false,
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "mesh-build-image",
"takes-config": ["capacity"],
"merge": "json",
"template": "files/settings.json"
},
{
"id": "work",
"type": "directory",