diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks.go b/modules/mesh-build-image/cmd/mesh-build-image/asks.go new file mode 100644 index 00000000..e72a4d05 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks.go @@ -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) +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go b/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go new file mode 100644 index 00000000..46105474 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go @@ -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 . 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) + } + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/main.go b/modules/mesh-build-image/cmd/mesh-build-image/main.go index 169d9b10..1fc279c3 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/main.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/main.go @@ -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) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go index 5fa56c06..3a1bd523 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go @@ -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+`"`) { diff --git a/modules/mesh-build-image/cmd/mesh-build-image/module.go b/modules/mesh-build-image/cmd/mesh-build-image/module.go index 1f3f206f..71d3d1af 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/module.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/module.go @@ -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 } diff --git a/modules/mesh-build-image/files/settings.json b/modules/mesh-build-image/files/settings.json new file mode 100644 index 00000000..3175fe39 --- /dev/null +++ b/modules/mesh-build-image/files/settings.json @@ -0,0 +1,3 @@ +{ + "capacity": "shanks=3,ace=2,g14=0,novox=0" +} diff --git a/modules/mesh-build-image/module.json b/modules/mesh-build-image/module.json index 1f1f86de..2678972c 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -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",