diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks.go b/modules/mesh-build-go/cmd/mesh-build-go/asks.go index eb95b65f..2c796c1c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks.go @@ -87,13 +87,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep var watch func(string, func(stdio.ProvisionChange) error) error // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. - // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes + // And the node's build hours (a node may build only in a window): outside them it takes // nothing new. var cores func() float64 var hours func() (*buildstep.BuildWindow, string) if capacity == nil { hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) - capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) watch = buildstep.WatchConfigServed cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/cpu.go b/modules/mesh-build-go/cmd/mesh-build-go/cpu.go index c1c00903..afff4ac3 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/cpu.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/cpu.go @@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits { case m.Capacity != nil: slots = m.Capacity() case m.Settings != "": - slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)() + slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() } return buildstep.NodeLimits(slots) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/integration.go b/modules/mesh-build-go/cmd/mesh-build-go/integration.go index 704dab50..62b4d0d1 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/integration.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/integration.go @@ -72,7 +72,7 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa return nil, fmt.Errorf("the integration level's bus: %w", err) } defer stopBus() - env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace)...) + env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace, nil)...) // Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run // below finds them built. Whether they compile is for the run to say; its failure is not read here. // Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/level.go b/modules/mesh-build-go/cmd/mesh-build-go/level.go index 2b6ee2b8..d755c694 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/level.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/level.go @@ -43,6 +43,7 @@ type levelSpec struct { Run string `json:"run,omitempty"` Setup time.Duration `json:"setup"` Bound time.Duration `json:"bound"` + NodeNames []string `json:"node-names,omitempty"` } // levelAnswer is the one line the process prints: the level's result or the digest, or why it could not run. @@ -228,7 +229,7 @@ func (m *Module) answer(ctx context.Context, spec levelSpec) levelAnswer { return levelAnswer{Digest: &d} case stepUnit: res, err := m.runLevel(ctx, tree, levelOpts{workspace: spec.Workspace, level: LevelUnit, packages: spec.Packages, run: spec.Run, - bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace)}) + bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace, spec.NodeNames)}) if err != nil { return levelAnswer{Error: err.Error()} } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go b/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go index 25a50c9d..afb36256 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go @@ -2,7 +2,10 @@ package main import ( "slices" + "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Issue 1309: a test that runs a step in bubblewrap is proof only where it runs. Where a build module judges (it hands @@ -29,8 +32,12 @@ func TestAMissingToolSkipsExceptBubblewrapOnABuildNode(t *testing.T) { // The levels a build module judges hand the tests the build node mark, with the siblings they read. func TestEveryLevelHandsTheTestsTheBuildNodeMark(t *testing.T) { m := &Module{Work: t.TempDir()} - if env := m.checkEnv("ws1"); !slices.Contains(env, BuildNodeEnv+"=1") { - t.Errorf("env %v lacks %s=1", env, BuildNodeEnv) + if env := m.checkEnv("ws1", nil); !slices.Contains(env, BuildNodeEnv+"=1") || slices.ContainsFunc(env, func(e string) bool { return strings.HasPrefix(e, delivery.NodeNamesEnv) }) { + t.Errorf("env %v lacks %s=1, or names nodes when none were asked", env, BuildNodeEnv) + } + // And the mesh's node names, when the ask carried them (ADR 0567 §12). + if env := m.checkEnv("ws1", []string{"a", "b"}); !slices.Contains(env, delivery.NodeNamesEnv+"=a,b") { + t.Errorf("env %v lacks the node names", env) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go index 6491c900..d165124c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -16,6 +16,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -38,7 +39,9 @@ func (m *Module) testTool() StepTool { "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, "packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "package patterns of each module, ./... when none"}, - "run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"}, + "run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"}, + delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "optional, the names of the mesh's nodes: the tests get them comma-separated in " + delivery.NodeNamesEnv + " (a check that keeps node names out of the repository reads them there)"}, "runner": runnerProperty, }, "required": []string{"workspace", "level"}, @@ -78,12 +81,16 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, err } + names, err := nodeNamesArg(args[delivery.NodeNamesArg]) + if err != nil { + return nil, err + } var res *TestResult switch { case runner == RunnerWorkspace && (level == LevelUnit || level == LevelIntegration): - return m.testAsCandidate(ctx, workspace, level, packages, pattern) + return m.testAsCandidate(ctx, workspace, level, packages, pattern, names) case level == LevelUnit: - res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(workspace)}) + res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(workspace, names)}) case level == LevelIntegration: res, err = m.integration(ctx, workspace, tree, packages, pattern) default: @@ -98,14 +105,14 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { // testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the // installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process. -func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string) (*TestResult, error) { - spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound()} +func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string, names []string) (*TestResult, error) { + spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound(), NodeNames: names} if level == LevelIntegration { if _, err := m.checkPostgres(ctx); err != nil { return nil, err // a *buildstep.Refusal: this level only } b := m.integrationBounds() - spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run} + spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run, NodeNames: names} } a, judged, err := m.asCandidate(ctx, spec) if err != nil { @@ -132,9 +139,35 @@ func (m *Module) besideEnv(workspace string) []string { // and present, so a test that needs it fails where it is missing instead of skipping (issue 1309). const BuildNodeEnv = "MESH_BUILD_NODE" -// checkEnv is what every level hands the tests it runs: the build node mark and the siblings (besideEnv). -func (m *Module) checkEnv(workspace string) []string { - return append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...) +// checkEnv is what every level hands the tests it runs: the build node mark, the siblings (besideEnv) and, when the +// ask carried them, the mesh's node names (delivery.NodeNamesEnv, novox/hq ADR 0567 §12). +func (m *Module) checkEnv(workspace string, names []string) []string { + env := append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...) + if len(names) > 0 { + env = append(env, delivery.NodeNamesEnv+"="+strings.Join(names, ",")) + } + return env +} + +// nodeNamesArg reads the node-names argument: a list of names, absent when none. +func nodeNamesArg(raw any) ([]string, error) { + switch v := raw.(type) { + case nil: + return nil, nil + case []string: + return v, nil + case []any: + var out []string + for _, e := range v { + n, ok := e.(string) + if !ok || n == "" || strings.ContainsAny(n, ", \n") { + return nil, fmt.Errorf("%s is a list of node names, not %v", delivery.NodeNamesArg, e) + } + out = append(out, n) + } + return out, nil + } + return nil, fmt.Errorf("%s is a list of node names", delivery.NodeNamesArg) } // packagesArg reads the packages argument: a list of package patterns, as []any when it came over the bus as JSON and diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index 6cd433e4..6c72f508 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -32,7 +32,7 @@ }, "build-hours": { "type": "string", - "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.", + "description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*", "default": "" }, diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks.go b/modules/mesh-build-image/cmd/mesh-build-image/asks.go index ee4edf75..204123d6 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/asks.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks.go @@ -43,13 +43,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep var watch func(string, func(stdio.ProvisionChange) error) error // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. - // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes + // And the node's build hours (a node may build only in a window): outside them it takes // nothing new. var cores func() float64 var hours func() (*buildstep.BuildWindow, string) if capacity == nil { hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) - capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) watch = buildstep.WatchConfigServed cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/cpu.go b/modules/mesh-build-image/cmd/mesh-build-image/cpu.go index 91f1d314..be28cc43 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/cpu.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/cpu.go @@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits { case m.Capacity != nil: slots = m.Capacity() case m.Settings != "": - slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)() + slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() } return buildstep.NodeLimits(slots) } diff --git a/modules/mesh-build-image/module.json b/modules/mesh-build-image/module.json index b19d36b3..3e5321ff 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -27,7 +27,7 @@ }, "build-hours": { "type": "string", - "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.", + "description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*", "default": "" }, diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks.go b/modules/mesh-build-node/cmd/mesh-build-node/asks.go index 607755e7..2e8b26cf 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/asks.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks.go @@ -51,13 +51,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep var watch func(string, func(stdio.ProvisionChange) error) error // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. - // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes + // And the node's build hours (a node may build only in a window): outside them it takes // nothing new. var cores func() float64 var hours func() (*buildstep.BuildWindow, string) if capacity == nil { hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) - capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) watch = buildstep.WatchConfigServed cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/cpu.go b/modules/mesh-build-node/cmd/mesh-build-node/cpu.go index 5936b615..432e0747 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/cpu.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/cpu.go @@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits { case m.Capacity != nil: slots = m.Capacity() case m.Settings != "": - slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)() + slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() } return buildstep.NodeLimits(slots) } diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index ebd4dd72..bbd38287 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -28,7 +28,7 @@ }, "build-hours": { "type": "string", - "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.", + "description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*", "default": "" },