Read a build module's steps and toolchains states with its own tools, so a step's result and the reported toolchain can be checked live (issue 1562)
mesh/merge-gate head c33b83dce7d5 merged into trunk 940594b07478 — pass: builds mesh-build-go, mesh-build-node → g14, novox, shanks; no bus step; every mac…
mesh/repo-check head c33b83dce7d5 merged into trunk 940594b07478 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@e023f69989ab (merged as b5dbfbb7 into main, walk plan-17925769516…
mesh/merge-gate head c33b83dce7d5 merged into trunk 940594b07478 — pass: builds mesh-build-go, mesh-build-node → g14, novox, shanks; no bus step; every mac…
mesh/repo-check head c33b83dce7d5 merged into trunk 940594b07478 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@e023f69989ab (merged as b5dbfbb7 into main, walk plan-17925769516…
This commit is contained in:
@@ -29,6 +29,8 @@ cause named, and the health is then unhealthy with the same text, so the control
|
||||
`build_go_digest`, `build_go_build`) are added by their own issues to `StepTools` and run behind the guard: a long
|
||||
step answers at once with its id and ends on `<node>.<id>` of `steps` (ADR 0469 §2); the digest answers in its call.
|
||||
|
||||
Three read tools answer what the module stored in its own states, without the guard and without a grant (issue 1562): `build_go_step {id}` is a step's stored value (state, reason, result, such as a fetch's workspace id), `build_go_steps {limit}` this machine's latest steps without their results, and `build_go_toolchains` every `<node>.<language>` key of `toolchains`.
|
||||
|
||||
## Fetch
|
||||
|
||||
`build_go_fetch {repository, commit, ref?}` fetches the commit over SSH as the module's account (`mesh-forge` is the
|
||||
|
||||
@@ -31,15 +31,17 @@ func main() {
|
||||
}
|
||||
ctx := context.Background()
|
||||
m := &Module{
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Recipe: Current(),
|
||||
Toolchains: stdio.State(delivery.ToolchainsState).Put,
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
Work: WorkDir,
|
||||
Cache: CacheDir,
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Recipe: Current(),
|
||||
Toolchains: stdio.State(delivery.ToolchainsState).Put,
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
ReadSteps: readOf(stdio.State(delivery.StepsState)),
|
||||
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
Work: WorkDir,
|
||||
Cache: CacheDir,
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
m.Home = u.HomeDir
|
||||
|
||||
@@ -24,6 +24,8 @@ type Module struct {
|
||||
Probe func(ctx context.Context) (string, error)
|
||||
// Toolchains and Steps are the Put of the states of those names.
|
||||
Toolchains, Steps buildstep.Put
|
||||
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
||||
ReadSteps, ReadToolchainsState StateRead
|
||||
// Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of
|
||||
// the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444).
|
||||
Emit func(eventType string, body any) error
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
|
||||
{Name: "short_probe", Run: run},
|
||||
}, m.StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
// The module's read tools (novox/hq issue 1562): what the module wrote to its own `steps` and `toolchains` states,
|
||||
// answered as stored. A long step ends on its key in the steps state (ADR 0469 §2) and its result, such as the
|
||||
// workspace a fetch made, is only there; a module reading its own bucket needs no grant.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The read tools, named as the module is: build_go_ and the verb.
|
||||
const (
|
||||
StepReadTool = "build_go_step"
|
||||
StepsReadTool = "build_go_steps"
|
||||
ToolchainsReadTool = "build_go_toolchains"
|
||||
)
|
||||
|
||||
// StateRead reads one of the module's own states: its keys and a key's stored value (nil for a key with none).
|
||||
type StateRead struct {
|
||||
Keys func() ([]string, error)
|
||||
Get func(key string) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// readOf is the read of a state the process keeps.
|
||||
func readOf(s stdio.KeptState) StateRead {
|
||||
return StateRead{Keys: s.Keys, Get: func(key string) (json.RawMessage, error) {
|
||||
e, err := s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
|
||||
func (m *Module) stepKey(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if node, rest, ok := strings.Cut(id, "."); ok {
|
||||
if node != m.Node {
|
||||
return "", fmt.Errorf("the step %q is of node %q, this module runs on %q", id, node, m.Node)
|
||||
}
|
||||
id = rest
|
||||
}
|
||||
return delivery.StepKey(m.Node, id)
|
||||
}
|
||||
|
||||
// ReadStep is the stored value of this node's step id (state, reason, result).
|
||||
func (m *Module) ReadStep(id string) (delivery.Step, error) {
|
||||
key, err := m.stepKey(id)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
if m.ReadSteps.Get == nil {
|
||||
return delivery.Step{}, fmt.Errorf("the steps state cannot be read here")
|
||||
}
|
||||
raw, err := m.ReadSteps.Get(key)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
|
||||
}
|
||||
return delivery.DecodeStep(raw)
|
||||
}
|
||||
|
||||
// StepLine is a step without its result: the list answers these.
|
||||
type StepLine struct {
|
||||
ID string `json:"id"`
|
||||
Tool string `json:"tool"`
|
||||
State string `json:"state"`
|
||||
Started string `json:"started"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
|
||||
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
|
||||
return nil, fmt.Errorf("the steps state cannot be read here")
|
||||
}
|
||||
keys, err := m.ReadSteps.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var steps []delivery.Step
|
||||
for _, k := range keys {
|
||||
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
|
||||
continue
|
||||
}
|
||||
raw, err := m.ReadSteps.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
s, err := delivery.DecodeStep(raw)
|
||||
if err != nil {
|
||||
continue // a value this module cannot read is not one of its own steps
|
||||
}
|
||||
steps = append(steps, s)
|
||||
}
|
||||
sort.SliceStable(steps, func(i, j int) bool { return steps[i].Started.After(steps[j].Started) })
|
||||
if len(steps) > limit {
|
||||
steps = steps[:limit]
|
||||
}
|
||||
out := make([]StepLine, 0, len(steps))
|
||||
for _, s := range steps {
|
||||
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
|
||||
if !s.Ended.IsZero() {
|
||||
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadToolchains is every key of the toolchains state (<node>.<language>) with its stored value.
|
||||
func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
|
||||
if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil {
|
||||
return nil, fmt.Errorf("the toolchains state cannot be read here")
|
||||
}
|
||||
keys, err := m.ReadToolchainsState.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]delivery.Toolchain{}
|
||||
for _, k := range keys {
|
||||
raw, err := m.ReadToolchainsState.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
t, err := delivery.DecodeToolchain(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", k, err)
|
||||
}
|
||||
out[k] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadTools are the module's read tools. All are read-only.
|
||||
func (m *Module) ReadTools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: StepReadTool,
|
||||
Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " +
|
||||
"(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " +
|
||||
"(a fetch's workspace id is there). id is the step id a step tool answered, or the whole key <node>.<id>. Read-only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"id"}, "properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "the step id, or the key <node>.<id>"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, _ := args["id"].(string)
|
||||
return m.ReadStep(id)
|
||||
}},
|
||||
{Name: StepsReadTool,
|
||||
Description: "This machine's latest steps from the module's steps state, newest first, each with its id, tool, state, " +
|
||||
"times and reason but not its result (read one with " + StepReadTool + "). Read-only.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
steps, err := m.RecentSteps(intArg(args["limit"]))
|
||||
return map[string]any{"node": m.Node, "steps": steps}, err
|
||||
}},
|
||||
{Name: ToolchainsReadTool,
|
||||
Description: "What the module's toolchains state holds: each key <node>.<language> with the version installed, the " +
|
||||
"recipe version and when the module saw them. Read-only.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
keys, err := m.ReadToolchains()
|
||||
return map[string]any{"node": m.Node, "toolchains": keys}, err
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// intArg is a tool argument that is a number, or a string of one; zero otherwise.
|
||||
func intArg(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case string:
|
||||
i, _ := strconv.Atoi(n)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// fakeState is a state as stored: key to raw value.
|
||||
type fakeState map[string]json.RawMessage
|
||||
|
||||
func (f fakeState) read() StateRead {
|
||||
return StateRead{
|
||||
Keys: func() ([]string, error) {
|
||||
var k []string
|
||||
for key := range f {
|
||||
k = append(k, key)
|
||||
}
|
||||
return k, nil
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, error) { return f[key], nil },
|
||||
}
|
||||
}
|
||||
|
||||
func putStep(t *testing.T, f fakeState, s delivery.Step) {
|
||||
t.Helper()
|
||||
b, err := delivery.EncodeStep(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.StepKey(s.Node, s.ID)
|
||||
f[key] = b
|
||||
}
|
||||
|
||||
func readModule(steps, toolchains fakeState) *Module {
|
||||
m := moduleWith(newStates(), "go1.27.1")
|
||||
m.ReadSteps, m.ReadToolchainsState = steps.read(), toolchains.read()
|
||||
return m
|
||||
}
|
||||
|
||||
func tool(t *testing.T, m *Module, name string) func(map[string]any) (any, error) {
|
||||
t.Helper()
|
||||
for _, tl := range m.Tools(m.StepTools()) {
|
||||
if tl.Name == name {
|
||||
return tl.Run
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1562/AC1: build_go_step answers the stored value of a step: state, reason and result (a fetch's workspace id).
|
||||
func TestStepToolAnswersTheStoredStep(t *testing.T) {
|
||||
steps := fakeState{}
|
||||
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
|
||||
putStep(t, steps, delivery.Step{ID: "abc1", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0.Add(time.Second),
|
||||
Result: json.RawMessage(`{"workspace":"w42"}`)})
|
||||
putStep(t, steps, delivery.Step{ID: "abc2", Tool: "build_go_test", Node: "ace", State: delivery.StepRefused, Started: t0, Reason: "go cannot be run"})
|
||||
m := readModule(steps, fakeState{})
|
||||
for _, id := range []string{"abc1", "ace.abc1"} {
|
||||
out, err := tool(t, m, StepReadTool)(map[string]any{"id": id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := out.(delivery.Step)
|
||||
if st.State != delivery.StepPassed || string(st.Result) != `{"workspace":"w42"}` || st.Tool != "build_go_fetch" {
|
||||
t.Errorf("id %s: step = %+v", id, st)
|
||||
}
|
||||
}
|
||||
out, _ := tool(t, m, StepReadTool)(map[string]any{"id": "abc2"})
|
||||
if st := out.(delivery.Step); st.State != delivery.StepRefused || st.Reason != "go cannot be run" {
|
||||
t.Errorf("refused step = %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepToolRefusesAnUnknownOrForeignOrBadID(t *testing.T) {
|
||||
m := readModule(fakeState{}, fakeState{})
|
||||
for id, want := range map[string]string{"nothere": "no step ace.nothere", "g14.abc1": "of node", "Bad Id": "lower-case"} {
|
||||
_, err := tool(t, m, StepReadTool)(map[string]any{"id": id})
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("id %q: err = %v, want it to say %q", id, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1562: build_go_steps lists this node's steps newest first without results, at most limit.
|
||||
func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
|
||||
steps := fakeState{}
|
||||
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
|
||||
putStep(t, steps, delivery.Step{ID: "a", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)})
|
||||
putStep(t, steps, delivery.Step{ID: "b", Tool: "build_go_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)})
|
||||
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"})
|
||||
putStep(t, steps, delivery.Step{ID: "d", Tool: "build_go_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)})
|
||||
m := readModule(steps, fakeState{})
|
||||
out, err := tool(t, m, StepsReadTool)(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := out.(map[string]any)["steps"].([]StepLine)
|
||||
var ids []string
|
||||
for _, l := range lines {
|
||||
ids = append(ids, l.ID)
|
||||
}
|
||||
if !slices.Equal(ids, []string{"c", "b", "a"}) {
|
||||
t.Errorf("ids = %v, want c b a", ids)
|
||||
}
|
||||
if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed {
|
||||
t.Errorf("first = %+v", lines[0])
|
||||
}
|
||||
out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)})
|
||||
if n := len(out.(map[string]any)["steps"].([]StepLine)); n != 1 {
|
||||
t.Errorf("limit 1 answered %d steps", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 1562 (1483/AC5): build_go_toolchains answers each toolchains key as stored.
|
||||
func TestToolchainsToolAnswersTheStoredKeys(t *testing.T) {
|
||||
tcs := fakeState{}
|
||||
b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "1", At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tcs["ace.go"] = b
|
||||
m := readModule(fakeState{}, tcs)
|
||||
out, err := tool(t, m, ToolchainsReadTool)(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.(map[string]any)["toolchains"].(map[string]delivery.Toolchain)
|
||||
if got["ace.go"].Version != "go1.27.1" || got["ace.go"].Recipe != "1" {
|
||||
t.Errorf("toolchains = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) {
|
||||
m := readModule(fakeState{}, fakeState{})
|
||||
m.Probe = func(context.Context) (string, error) { return "", context.DeadlineExceeded }
|
||||
// a node whose go cannot be run can still be asked what its steps said
|
||||
if _, err := tool(t, m, StepsReadTool)(map[string]any{}); err != nil {
|
||||
t.Errorf("the read refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
|
||||
}
|
||||
tools = append(tools, t)
|
||||
}
|
||||
tools = append(tools, m.ReadTools()...)
|
||||
return append(tools,
|
||||
stdio.Tool{Name: StatusTool,
|
||||
Description: "What the Go build module is on this machine: the Go version its recipe builds with and the one installed, " +
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"build_go_test",
|
||||
"build_go_digest",
|
||||
"build_go_build",
|
||||
"build_go_step",
|
||||
"build_go_steps",
|
||||
"build_go_toolchains",
|
||||
"build_go_status",
|
||||
"build_go_health"
|
||||
],
|
||||
|
||||
@@ -41,6 +41,8 @@ state. Whatever the version, the module is healthy and `build_node_health` (the
|
||||
`steps` with the cause named; the health is then unhealthy with the same text, so the controller raises the machine's
|
||||
condition. A step runs only after the toolchain directory is installed.
|
||||
|
||||
Three read tools answer what the module stored in its own states, without a grant (issue 1562): `build_node_step {id}` is a step's stored value (state, reason, result), `build_node_steps {limit}` this machine's latest steps without their results, and `build_node_toolchains` every `<node>.<language>` key of `toolchains`.
|
||||
|
||||
## Tools
|
||||
|
||||
`build_node_status` and `build_node_health` answer in their call. The step tools run behind the guard and the
|
||||
|
||||
@@ -38,17 +38,19 @@ func main() {
|
||||
return ReadRegistry(filepath.Join(home, "package-registry.json"), filepath.Join(home, "package-registry.secret"))
|
||||
}
|
||||
m := &Module{
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Reload: func() (Recipe, error) { return ReadRecipe(paths.LockPath()) },
|
||||
Probe: ProbeNode,
|
||||
Toolchain: &Toolchain{Paths: paths, Run: forgekey.ExecRunnerClean(0), Registry: registry},
|
||||
Toolchains: stdio.State(delivery.ToolchainsState).Put,
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
Work: WorkDir,
|
||||
Run: forgekey.ExecRunnerClean(0),
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Reload: func() (Recipe, error) { return ReadRecipe(paths.LockPath()) },
|
||||
Probe: ProbeNode,
|
||||
Toolchain: &Toolchain{Paths: paths, Run: forgekey.ExecRunnerClean(0), Registry: registry},
|
||||
Toolchains: stdio.State(delivery.ToolchainsState).Put,
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
ReadSteps: readOf(stdio.State(delivery.StepsState)),
|
||||
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
|
||||
Work: WorkDir,
|
||||
Run: forgekey.ExecRunnerClean(0),
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
}
|
||||
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
|
||||
if m.Node != "" {
|
||||
|
||||
@@ -36,6 +36,8 @@ type Module struct {
|
||||
Clock func() time.Time
|
||||
// Toolchains and Steps are the Put of the states of those names.
|
||||
Toolchains, Steps buildstep.Put
|
||||
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
||||
ReadSteps, ReadToolchainsState StateRead
|
||||
// Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of
|
||||
// the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444).
|
||||
Emit func(eventType string, body any) error
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T)
|
||||
{Name: "short_probe", Run: run},
|
||||
}, (&Module{}).StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
// The module's read tools (novox/hq issue 1562): what the module wrote to its own `steps` and `toolchains` states,
|
||||
// answered as stored. A long step ends on its key in the steps state (ADR 0469 §2) and its result, such as the
|
||||
// workspace a fetch made, is only there; a module reading its own bucket needs no grant.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The read tools, named as the module is: build_node_ and the verb.
|
||||
const (
|
||||
StepReadTool = "build_node_step"
|
||||
StepsReadTool = "build_node_steps"
|
||||
ToolchainsReadTool = "build_node_toolchains"
|
||||
)
|
||||
|
||||
// StateRead reads one of the module's own states: its keys and a key's stored value (nil for a key with none).
|
||||
type StateRead struct {
|
||||
Keys func() ([]string, error)
|
||||
Get func(key string) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// readOf is the read of a state the process keeps.
|
||||
func readOf(s stdio.KeptState) StateRead {
|
||||
return StateRead{Keys: s.Keys, Get: func(key string) (json.RawMessage, error) {
|
||||
e, err := s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
|
||||
func (m *Module) stepKey(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if node, rest, ok := strings.Cut(id, "."); ok {
|
||||
if node != m.Node {
|
||||
return "", fmt.Errorf("the step %q is of node %q, this module runs on %q", id, node, m.Node)
|
||||
}
|
||||
id = rest
|
||||
}
|
||||
return delivery.StepKey(m.Node, id)
|
||||
}
|
||||
|
||||
// ReadStep is the stored value of this node's step id (state, reason, result).
|
||||
func (m *Module) ReadStep(id string) (delivery.Step, error) {
|
||||
key, err := m.stepKey(id)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
if m.ReadSteps.Get == nil {
|
||||
return delivery.Step{}, fmt.Errorf("the steps state cannot be read here")
|
||||
}
|
||||
raw, err := m.ReadSteps.Get(key)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
|
||||
}
|
||||
return delivery.DecodeStep(raw)
|
||||
}
|
||||
|
||||
// StepLine is a step without its result: the list answers these.
|
||||
type StepLine struct {
|
||||
ID string `json:"id"`
|
||||
Tool string `json:"tool"`
|
||||
State string `json:"state"`
|
||||
Started string `json:"started"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
|
||||
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
|
||||
return nil, fmt.Errorf("the steps state cannot be read here")
|
||||
}
|
||||
keys, err := m.ReadSteps.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var steps []delivery.Step
|
||||
for _, k := range keys {
|
||||
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
|
||||
continue
|
||||
}
|
||||
raw, err := m.ReadSteps.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
s, err := delivery.DecodeStep(raw)
|
||||
if err != nil {
|
||||
continue // a value this module cannot read is not one of its own steps
|
||||
}
|
||||
steps = append(steps, s)
|
||||
}
|
||||
sort.SliceStable(steps, func(i, j int) bool { return steps[i].Started.After(steps[j].Started) })
|
||||
if len(steps) > limit {
|
||||
steps = steps[:limit]
|
||||
}
|
||||
out := make([]StepLine, 0, len(steps))
|
||||
for _, s := range steps {
|
||||
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
|
||||
if !s.Ended.IsZero() {
|
||||
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadToolchains is every key of the toolchains state (<node>.<language>) with its stored value.
|
||||
func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
|
||||
if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil {
|
||||
return nil, fmt.Errorf("the toolchains state cannot be read here")
|
||||
}
|
||||
keys, err := m.ReadToolchainsState.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]delivery.Toolchain{}
|
||||
for _, k := range keys {
|
||||
raw, err := m.ReadToolchainsState.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
t, err := delivery.DecodeToolchain(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", k, err)
|
||||
}
|
||||
out[k] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadTools are the module's read tools. All are read-only.
|
||||
func (m *Module) ReadTools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: StepReadTool,
|
||||
Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " +
|
||||
"(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " +
|
||||
"(a fetch's workspace id is there). id is the step id a step tool answered, or the whole key <node>.<id>. Read-only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"id"}, "properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "the step id, or the key <node>.<id>"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, _ := args["id"].(string)
|
||||
return m.ReadStep(id)
|
||||
}},
|
||||
{Name: StepsReadTool,
|
||||
Description: "This machine's latest steps from the module's steps state, newest first, each with its id, tool, state, " +
|
||||
"times and reason but not its result (read one with " + StepReadTool + "). Read-only.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
steps, err := m.RecentSteps(intArg(args["limit"]))
|
||||
return map[string]any{"node": m.Node, "steps": steps}, err
|
||||
}},
|
||||
{Name: ToolchainsReadTool,
|
||||
Description: "What the module's toolchains state holds: each key <node>.<language> with the version installed, the " +
|
||||
"recipe version and when the module saw them. Read-only.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
keys, err := m.ReadToolchains()
|
||||
return map[string]any{"node": m.Node, "toolchains": keys}, err
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// intArg is a tool argument that is a number, or a string of one; zero otherwise.
|
||||
func intArg(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case string:
|
||||
i, _ := strconv.Atoi(n)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// fakeState is a state as stored: key to raw value.
|
||||
type fakeState map[string]json.RawMessage
|
||||
|
||||
func (f fakeState) read() StateRead {
|
||||
return StateRead{
|
||||
Keys: func() ([]string, error) {
|
||||
var k []string
|
||||
for key := range f {
|
||||
k = append(k, key)
|
||||
}
|
||||
return k, nil
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, error) { return f[key], nil },
|
||||
}
|
||||
}
|
||||
|
||||
func putStep(t *testing.T, f fakeState, s delivery.Step) {
|
||||
t.Helper()
|
||||
b, err := delivery.EncodeStep(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.StepKey(s.Node, s.ID)
|
||||
f[key] = b
|
||||
}
|
||||
|
||||
func readModule(steps, toolchains fakeState) *Module {
|
||||
m := moduleWith(newStates(), "v22.1.0")
|
||||
m.ReadSteps, m.ReadToolchainsState = steps.read(), toolchains.read()
|
||||
return m
|
||||
}
|
||||
|
||||
func tool(t *testing.T, m *Module, name string) func(map[string]any) (any, error) {
|
||||
t.Helper()
|
||||
for _, tl := range m.Tools(m.StepTools()) {
|
||||
if tl.Name == name {
|
||||
return tl.Run
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1562/AC1: build_node_step answers the stored value of a step: state, reason and result (a fetch's workspace id).
|
||||
func TestStepToolAnswersTheStoredStep(t *testing.T) {
|
||||
steps := fakeState{}
|
||||
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
|
||||
putStep(t, steps, delivery.Step{ID: "abc1", Tool: "build_node_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0.Add(time.Second),
|
||||
Result: json.RawMessage(`{"workspace":"w42"}`)})
|
||||
putStep(t, steps, delivery.Step{ID: "abc2", Tool: "build_node_test", Node: "ace", State: delivery.StepRefused, Started: t0, Reason: "node cannot be run"})
|
||||
m := readModule(steps, fakeState{})
|
||||
for _, id := range []string{"abc1", "ace.abc1"} {
|
||||
out, err := tool(t, m, StepReadTool)(map[string]any{"id": id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := out.(delivery.Step)
|
||||
if st.State != delivery.StepPassed || string(st.Result) != `{"workspace":"w42"}` || st.Tool != "build_node_fetch" {
|
||||
t.Errorf("id %s: step = %+v", id, st)
|
||||
}
|
||||
}
|
||||
out, _ := tool(t, m, StepReadTool)(map[string]any{"id": "abc2"})
|
||||
if st := out.(delivery.Step); st.State != delivery.StepRefused || st.Reason != "node cannot be run" {
|
||||
t.Errorf("refused step = %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepToolRefusesAnUnknownOrForeignOrBadID(t *testing.T) {
|
||||
m := readModule(fakeState{}, fakeState{})
|
||||
for id, want := range map[string]string{"nothere": "no step ace.nothere", "g14.abc1": "of node", "Bad Id": "lower-case"} {
|
||||
_, err := tool(t, m, StepReadTool)(map[string]any{"id": id})
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("id %q: err = %v, want it to say %q", id, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1562: build_node_steps lists this node's steps newest first without results, at most limit.
|
||||
func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
|
||||
steps := fakeState{}
|
||||
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
|
||||
putStep(t, steps, delivery.Step{ID: "a", Tool: "build_node_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)})
|
||||
putStep(t, steps, delivery.Step{ID: "b", Tool: "build_node_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)})
|
||||
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_node_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"})
|
||||
putStep(t, steps, delivery.Step{ID: "d", Tool: "build_node_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)})
|
||||
m := readModule(steps, fakeState{})
|
||||
out, err := tool(t, m, StepsReadTool)(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := out.(map[string]any)["steps"].([]StepLine)
|
||||
var ids []string
|
||||
for _, l := range lines {
|
||||
ids = append(ids, l.ID)
|
||||
}
|
||||
if !slices.Equal(ids, []string{"c", "b", "a"}) {
|
||||
t.Errorf("ids = %v, want c b a", ids)
|
||||
}
|
||||
if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed {
|
||||
t.Errorf("first = %+v", lines[0])
|
||||
}
|
||||
out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)})
|
||||
if n := len(out.(map[string]any)["steps"].([]StepLine)); n != 1 {
|
||||
t.Errorf("limit 1 answered %d steps", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 1562 (1483/AC5): build_node_toolchains answers each toolchains key as stored.
|
||||
func TestToolchainsToolAnswersTheStoredKeys(t *testing.T) {
|
||||
tcs := fakeState{}
|
||||
b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "typescript", Version: "v22.1.0", Recipe: "1", At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tcs["ace.typescript"] = b
|
||||
m := readModule(fakeState{}, tcs)
|
||||
out, err := tool(t, m, ToolchainsReadTool)(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.(map[string]any)["toolchains"].(map[string]delivery.Toolchain)
|
||||
if got["ace.typescript"].Version != "v22.1.0" || got["ace.typescript"].Recipe != "1" {
|
||||
t.Errorf("toolchains = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) {
|
||||
m := readModule(fakeState{}, fakeState{})
|
||||
m.Probe = func(context.Context) (string, error) { return "", context.DeadlineExceeded }
|
||||
// a node whose go cannot be run can still be asked what its steps said
|
||||
if _, err := tool(t, m, StepsReadTool)(map[string]any{}); err != nil {
|
||||
t.Errorf("the read refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
|
||||
}
|
||||
tools = append(tools, t)
|
||||
}
|
||||
tools = append(tools, m.ReadTools()...)
|
||||
return append(tools,
|
||||
stdio.Tool{Name: StatusTool,
|
||||
Description: "What the Node build module is on this machine: the Node version its recipe builds with and the one installed, " +
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"build_node_fetch",
|
||||
"build_node_test",
|
||||
"build_node_build",
|
||||
"build_node_step",
|
||||
"build_node_steps",
|
||||
"build_node_toolchains",
|
||||
"build_node_status",
|
||||
"build_node_health"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user