Read build capacity as an integer and build hours as one window, default 0: no repository names a node (ADR 0567 §12, issue 1126)

This commit is contained in:
2026-10-28 18:13:18 +01:00
parent 7a82384e21
commit fed64292f0
11 changed files with 99 additions and 171 deletions
+31 -37
View File
@@ -8,14 +8,15 @@ import (
"time"
)
// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: "we should only use g14 as a builder
// outside office hours", "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting names, per
// node, the window in the node's local time in which it takes asks; outside it the capacity judged by is 0: steps running
// finish, no new one is taken. A node it does not name, or an empty setting, builds at any hour. The puller judges it at
// every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of another part).
// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: the laptop builds only outside
// office hours, "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting is the node's window
// in its local time, a config value set on each node's layer of mesh-config (ADR 0567 §12: no repository names a node);
// outside it the capacity judged by is 0: steps running finish, no new one is taken. An empty value builds at any hour.
// The puller judges it at every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of
// another part).
// BuildHoursSetting is the build modules' setting of the nodes' build windows: `<node>=HH:MM-HH:MM` pairs separated by
// commas, in the node's local time; an end before its start crosses midnight (g14=18:00-06:00).
// BuildHoursSetting is the build modules' setting of the node's build window: HH:MM-HH:MM in the node's local time; an
// end before its start crosses midnight (18:00-06:00).
const BuildHoursSetting = "build-hours"
// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive.
@@ -51,35 +52,28 @@ func (w BuildWindow) NextEdge(t time.Time) time.Time {
return next
}
// ParseBuildHours is node's window in a build-hours setting: nil when the setting is empty or does not name the node.
func ParseBuildHours(setting, node string) (*BuildWindow, error) {
var found *BuildWindow
for _, pair := range strings.Split(setting, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
name, span, ok := strings.Cut(pair, "=")
from, to, ok2 := strings.Cut(span, "-")
if !ok || !ok2 || strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("the build hours %q: %q is not <node>=HH:MM-HH:MM", setting, pair)
}
start, err := minuteOf(from)
if err != nil {
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
}
end, err := minuteOf(to)
if err != nil {
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
}
if start == end {
return nil, fmt.Errorf("the build hours %q: %s opens and closes at the same minute", setting, pair)
}
if strings.TrimSpace(name) == node {
found = &BuildWindow{Start: start, End: end}
}
// ParseBuildWindow reads a build-hours value, HH:MM-HH:MM: nil when it is empty.
func ParseBuildWindow(setting string) (*BuildWindow, error) {
setting = strings.TrimSpace(setting)
if setting == "" {
return nil, nil
}
return found, nil
from, to, ok := strings.Cut(setting, "-")
if !ok {
return nil, fmt.Errorf("the build hours %q are not HH:MM-HH:MM", setting)
}
start, err := minuteOf(from)
if err != nil {
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
}
end, err := minuteOf(to)
if err != nil {
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
}
if start == end {
return nil, fmt.Errorf("the build hours %q open and close at the same minute", setting)
}
return &BuildWindow{Start: start, End: end}, nil
}
// minuteOf reads HH:MM as minutes after midnight.
@@ -92,7 +86,7 @@ func minuteOf(s string) (int, error) {
return h*60 + m, nil
}
// NodeBuildHours is node's build window, read from the module's settings file at path at every call, and why it is
// NodeBuildHours is the node's build window, read from the module's settings file at path at every call, and why it is
// none when the value cannot be read. No path, no file value or an empty one is no window and no fault. A value that
// cannot be read is no window too, so the node builds at any hour; the setting is a preference of hours, not a safety
// bound (a node that must never build has capacity 0). It is loud: the fault is said once per distinct fault through
@@ -125,7 +119,7 @@ func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*Build
if v, ok := s[BuildHoursSetting]; ok && json.Unmarshal(v, &setting) != nil {
return fault(fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, v))
}
w, err := ParseBuildHours(setting, node)
w, err := ParseBuildWindow(setting)
if err != nil {
return fault(err.Error())
}
+31 -34
View File
@@ -4,45 +4,38 @@ import (
"encoding/json"
"fmt"
"os"
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
)
// CapacitySetting is the build modules' setting of each node's build capacity (novox/hq ADR 0525 §5): `<node>=<asks at
// once>` pairs separated by commas, as the builder's was (ADR 0485 §1). Each build module declares it with
// DefaultCapacities as its default, and the controller lays it into the module's settings file.
// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how
// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names
// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config)
// lays the node's value into the module's settings file.
const CapacitySetting = "capacity"
// DefaultCapacities is the setting's default, the builder's before R4g: the control-node (novox) and the laptop (g14)
// build nothing.
const DefaultCapacities = "shanks=3,ace=2,g14=0,novox=0"
// NodeCapacity is node's build capacity, read from the module's settings file at path at every call, so a change takes
// effect at the next pull; with no path it reads fallback. A node the setting does not name runs one ask at a time (as
// the builder's did). A file or a setting that cannot be read gives 0 and says why: pulling on a setting nobody can read
// could build on a node that must not.
func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func() int {
// NodeCapacity is the node's build capacity, read from the module's settings file at path at every call, so a change
// takes effect at the next pull. No path, no file value, or a value that is not a whole number of at least 0 gives 0 and
// a value that cannot be read says why: pulling on a setting nobody can read could build on a node that must not.
func NodeCapacity(path, node string, logf func(string, ...any)) func() int {
say := func(format string, args ...any) {
if logf != nil {
logf(format, args...)
}
}
return func() int {
setting := fallback
if path != "" {
raw, err := os.ReadFile(path)
if err != nil {
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
return 0
}
var s map[string]any
if err := json.Unmarshal(raw, &s); err != nil {
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
return 0
}
setting, _ = s[CapacitySetting].(string)
if path == "" {
return 0
}
n, err := capacityOf(setting, node)
raw, err := os.ReadFile(path)
if err != nil {
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
return 0
}
var s map[string]json.RawMessage
if err := json.Unmarshal(raw, &s); err != nil {
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
return 0
}
n, err := capacityOf(s[CapacitySetting])
if err != nil {
say("%v, so %s pulls nothing", err, node)
return 0
@@ -51,11 +44,15 @@ func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func()
}
}
// capacityOf is node's capacity in a setting's `<node>=<asks>` pairs: DefaultCapacity for a node it does not name.
func capacityOf(setting, node string) (int, error) {
caps, err := buildrun.ParseCapacity(setting)
if err != nil {
return 0, fmt.Errorf("the build capacity: %w", err)
// capacityOf is the capacity a config value holds: absent or null is 0; anything but a whole number of at least 0 is
// refused, naming it.
func capacityOf(value json.RawMessage) (int, error) {
if len(value) == 0 || string(value) == "null" {
return 0, nil
}
return (&buildrun.Places{Capacity: caps}).CapacityOf(node), nil
var n int
if err := json.Unmarshal(value, &n); err != nil || n < 0 {
return 0, fmt.Errorf("the build capacity %s is not a whole number of asks at once, 0 or more", value)
}
return n, nil
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
)
// **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26
// 19:41: under the 50 % cap shanks' mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
// 19:41: under the 50 % cap the workstation's mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
// each step ran past the 30-minute step bound with no test run). The configured capacity is an upper bound; the node's
// build slice bounds it again by the cores a step needs: floor(the slice's quota in cores / cores-per-step), one at
// least. The quota is read live from the cgroup at every judgement, so a changed cap is followed at the next take
@@ -27,7 +27,7 @@ import (
const CoresPerStepSetting = "cores-per-step"
// DefaultCoresPerStep are the build modules' defaults by language, from measured steps (2026-10-26): a full mesh-catalog
// check on mesh-build-go takes what it is given (three at once took 965 % of shanks' 1200 % slice, about 4 cores each,
// check on mesh-build-go takes what it is given (three at once took 965 % of the workstation's 1200 % slice, about 4 cores each,
// and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a
// test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2.
var DefaultCoresPerStep = map[string]float64{delivery.AskGo: 6, delivery.AskNode: 2, delivery.AskImage: 2}
+24 -33
View File
@@ -14,30 +14,20 @@ import (
// issue 1806's follow-up: the capacity judged by is the configured one bounded by the build slice's cores over the cores
// a step needs (the three build nodes as they stand on 2026-10-26), and cores-per-step is read from the settings file.
func TestNodeCapacityAndLimits(t *testing.T) {
if got := NodeCapacity("", DefaultCapacities, "novox", nil)(); got != 0 {
t.Errorf("novox = %d by default, want 0", got)
}
if got := NodeCapacity("", DefaultCapacities, "shanks", nil)(); got != 3 {
t.Errorf("shanks = %d by default, want 3", got)
// ADR 0567 §12: the capacity is the node's integer, its default 0, and no default names a node.
if got := NodeCapacity("", "laptop", nil)(); got != 0 {
t.Errorf("no settings file = %d, want 0", got)
}
file := filepath.Join(t.TempDir(), "settings.json")
os.WriteFile(file, []byte(`{"capacity":"shanks=1,ace=2"}`), 0o600)
capacity := NodeCapacity(file, DefaultCapacities, "shanks", nil)
if got := capacity(); got != 1 {
t.Errorf("shanks = %d from the file, want 1", got)
capacity := NodeCapacity(file, "anchor", nil)
for value, want := range map[string]int{`{"capacity":1}`: 1, `{"capacity":4}`: 4, `{}`: 0, `{"capacity":null}`: 0,
`{"capacity":"anchor=3"}`: 0, `{"capacity":-1}`: 0, `{"capacity":1.5}`: 0} {
os.WriteFile(file, []byte(value), 0o600)
if got := capacity(); got != want {
t.Errorf("%s = %d, want %d", value, got, want)
}
}
os.WriteFile(file, []byte(`{"capacity":"shanks=4"}`), 0o600)
if got := capacity(); got != 4 {
t.Errorf("shanks = %d after the change, want 4", got)
}
if got := NodeCapacity(file, "", "pi", nil)(); got != 1 {
t.Errorf("a node not named = %d, want 1", got)
}
os.WriteFile(file, []byte(`{"capacity":"shanks=lots"}`), 0o600)
if got := capacity(); got != 0 {
t.Errorf("an unreadable setting = %d, want 0", got)
}
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), DefaultCapacities, "shanks", nil)(); got != 0 {
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), "anchor", nil)(); got != 0 {
t.Errorf("a missing file = %d, want 0", got)
}
t.Setenv("MESH_BUILD_CPU_SHARE", "")
@@ -74,7 +64,7 @@ func TestNodeCapacityAndLimits(t *testing.T) {
for _, c := range []struct {
file string
want float64
}{{`{"capacity":"shanks=2"}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} {
}{{`{"capacity":2}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} {
os.WriteFile(file, []byte(c.file), 0o600)
if got := cores(); got != c.want {
t.Errorf("cores-per-step from %s = %g, want %g", c.file, got, c.want)
@@ -100,18 +90,19 @@ func TestNodeCapacityAndLimits(t *testing.T) {
bad bool
ineligible string // what the module's Eligible says; "" for eligible
}{
{"g14 in the office", "g14=18:00-06:00", "g14", day(10, 0), 0, "18:00", false, ""},
{"g14 in the evening", "g14=18:00-06:00", "g14", day(18, 0), 2, "06:00", false, ""},
{"g14 past midnight", "g14=18:00-06:00", "g14", day(5, 59), 2, "06:00", false, ""},
{"g14 at the close", "g14=18:00-06:00", "g14", day(6, 0), 0, "18:00", false, ""},
{"a day window", "shanks=09:00-17:30,g14=18:00-06:00", "shanks", day(17, 29), 2, "17:30", false, ""},
{"a node not named", "g14=18:00-06:00", "ace", day(10, 0), 2, "", false, ""},
{"a bad time", "g14=18:00-6", "g14", day(10, 0), 2, "", true, ""},
{"no span", "g14=18:00-18:00", "g14", day(10, 0), 2, "", true, ""},
{"ace without /dev/net/tun", "", "ace", day(10, 0), 0, "", false, "/dev/net/tun cannot be opened: no such device"},
{"not eligible within its hours", "g14=18:00-06:00", "g14", day(19, 0), 0, "06:00", false, "/dev/net/tun cannot be opened: no such device"},
{"the laptop in the office", "18:00-06:00", "laptop", day(10, 0), 0, "18:00", false, ""},
{"the laptop in the evening", "18:00-06:00", "laptop", day(18, 0), 2, "06:00", false, ""},
{"the laptop past midnight", "18:00-06:00", "laptop", day(5, 59), 2, "06:00", false, ""},
{"the laptop at the close", "18:00-06:00", "laptop", day(6, 0), 0, "18:00", false, ""},
{"a day window", "09:00-17:30", "anchor", day(17, 29), 2, "17:30", false, ""},
{"no window", "", "anchor", day(10, 0), 2, "", false, ""},
{"a bad time", "18:00-6", "laptop", day(10, 0), 2, "", true, ""},
{"no span", "18:00-18:00", "laptop", day(10, 0), 2, "", true, ""},
{"the old form naming a node", "laptop=18:00-06:00", "laptop", day(10, 0), 2, "", true, ""},
{"a node without /dev/net/tun", "", "anchor", day(10, 0), 0, "", false, "/dev/net/tun cannot be opened: no such device"},
{"not eligible within its hours", "18:00-06:00", "laptop", day(19, 0), 0, "06:00", false, "/dev/net/tun cannot be opened: no such device"},
} {
w, err := ParseBuildHours(c.setting, c.node)
w, err := ParseBuildWindow(c.setting)
if (err != nil) != c.bad {
t.Errorf("%s: parse %v", c.name, err)
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on shanks ran 30 minutes
// **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on the workstation ran 30 minutes
// and its module's journal held no line naming its ask, so nobody could tell whether it fetched, linted or tested). The
// puller hands every ask's run a phase reporter in its context; RunAsk and a module's own step bodies call Phase as they
// move (fetch and merge, a level's digest, lint, a level's tests), and each phase is logged with the ask's id and its
+3 -9
View File
@@ -610,7 +610,7 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
}
}
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: g14's
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: the laptop's
// puller started at capacity 0 sat parked with capacity 2 set, because nothing woke it to read its settings file
// again). The value comes as mesh-config resolved it for this node, the event of the change itself: the runtime hands
// the value now and then each change its machine's provisioner client says moved, so nothing polls, and the puller does
@@ -659,7 +659,7 @@ func (p *Puller) watchHours() {
why = fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, c.Value)
default:
var err error
if w, err = ParseBuildHours(setting, p.Node); err != nil {
if w, err = ParseBuildWindow(setting); err != nil {
why = err.Error()
}
}
@@ -689,13 +689,7 @@ func (p *Puller) capacityFrom(c stdio.ProvisionChange) (int, string) {
if c.Refused != "" {
return 0, "its capacity is no longer answered: " + c.Refused
}
setting := ""
if len(c.Value) > 0 && string(c.Value) != "null" {
if err := json.Unmarshal(c.Value, &setting); err != nil {
return 0, fmt.Sprintf("its capacity %s is not a string", c.Value)
}
}
n, err := capacityOf(setting, p.Node)
n, err := capacityOf(c.Value)
if err != nil {
return 0, err.Error()
}
+2 -2
View File
@@ -22,7 +22,7 @@ func (w *configWatch) watch(key string, handle func(stdio.ProvisionChange) error
w.asked, w.handle = key, handle
value := w.value
w.mu.Unlock()
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)})
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(value)})
}
func (w *configWatch) set(t *testing.T, value string) {
@@ -33,7 +33,7 @@ func (w *configWatch) set(t *testing.T, value string) {
if handle == nil {
t.Fatal("the puller watches no config value")
}
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)}); err != nil {
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(value)}); err != nil {
t.Fatal(err)
}
}
+2 -2
View File
@@ -282,7 +282,7 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
t.Run("config watch wakes", func(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
w := &configWatch{value: "shanks=3,g14=0"}
w := &configWatch{value: "0"}
p := &Puller{Source: src, Language: delivery.AskGo, Node: "g14", Put: st.put, PullWait: 10 * time.Millisecond,
Capacity: func() int { return 0 }, // the settings file, not refilled yet
WatchConfig: w.watch,
@@ -298,7 +298,7 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
if w.asked != CapacitySetting {
t.Fatalf("the puller watches %q, want %q", w.asked, CapacitySetting)
}
w.set(t, "shanks=3,g14=2")
w.set(t, "2")
waitFor(t, "the ask taken once the capacity moved to 2", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
})
for _, c := range []struct {
+1 -1
View File
@@ -149,7 +149,7 @@ func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why
// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its
// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running
// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it
// held one of shanks' two slots).
// held one of a build node's two slots).
// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it.
// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before
// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its
-26
View File
@@ -4,38 +4,12 @@ import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
)
// DefaultCapacity is the build capacity of a node that has no setting: one build step at a time.
const DefaultCapacity = 1
// ParseCapacity reads a capacity setting: `node=steps` pairs separated by commas, such as
// `shanks=3,ace=2`. A node without a pair has DefaultCapacity. A pair with no node, a count that is not a whole number of
// at least 0, and a node named twice are refused. A count of 0 keeps the part off the node: it is never chosen.
func ParseCapacity(s string) (map[string]int, error) {
out := map[string]int{}
for _, pair := range strings.Split(s, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
node, count, ok := strings.Cut(pair, "=")
node, count = strings.TrimSpace(node), strings.TrimSpace(count)
n, err := strconv.Atoi(count)
if !ok || node == "" || err != nil || n < 0 {
return nil, fmt.Errorf("the capacity %q is not <node>=<steps at once, 0 or more>", pair)
}
if _, dup := out[node]; dup {
return nil, fmt.Errorf("the capacity names %s twice", node)
}
out[node] = n
}
return out, nil
}
// Places counts the build steps a part runs on each node and holds a lane back while its node is at its
// capacity (the operator's decision of 2026-10-21; ADR 0478 clause 3). A lane is the fetch and the builds of one build
// module on one node, run one step at a time, so a lane holds one place while it runs. A node is chosen for a lane by
+2 -24
View File
@@ -7,21 +7,6 @@ import (
"time"
)
func TestParseCapacityReadsNodeEqualsCountPairs(t *testing.T) {
c, err := ParseCapacity(" shanks=3, ace=2 ")
if err != nil || c["shanks"] != 3 || c["ace"] != 2 || len(c) != 2 {
t.Errorf("capacity = %v, %v", c, err)
}
if c, err := ParseCapacity(""); err != nil || len(c) != 0 {
t.Errorf("empty: %v, %v", c, err)
}
for _, bad := range []string{"shanks", "shanks=x", "=2", "a=1,a=2", "shanks=-1"} {
if _, err := ParseCapacity(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
func acquireAsync(p *Places, node string) chan func() {
got := make(chan func(), 1)
go func() {
@@ -95,17 +80,10 @@ func TestAWaitingStepStopsWhenItsContextEnds(t *testing.T) { // round 3
// The operator's decision of 2026-10-21 13:40: the builder builds on shanks and ace only; a capacity of 0 keeps it off a node.
func TestACapacityOfZeroKeepsThePartOffTheNodeAndANodeNotNamedKeepsOne(t *testing.T) { // issue 1661
c, err := ParseCapacity("shanks=3,ace=2,g14=0,novox=0")
if err != nil || c["g14"] != 0 || c["novox"] != 0 || c["shanks"] != 3 || len(c) != 4 {
t.Fatalf("capacity = %v, %v", c, err)
}
p := &Places{Capacity: c}
p := &Places{Capacity: map[string]int{"shanks": 3, "ace": 2, "g14": 0, "novox": 0}}
if p.CapacityOf("g14") != 0 || p.CapacityOf("novox") != 0 || p.CapacityOf("ace") != 2 || p.CapacityOf("other") != DefaultCapacity {
t.Errorf("capacities = %d %d %d %d, want 0 0 2 and the default for a node not named", p.CapacityOf("g14"), p.CapacityOf("novox"), p.CapacityOf("ace"), p.CapacityOf("other"))
}
if _, err := ParseCapacity("shanks=-1"); err == nil {
t.Error("a negative capacity accepted")
}
entries := tcs("go", "go1.27.1", "ace", "g14", "novox", "shanks", "other")
for i := 0; i < 6; i++ {
nodes, _, err := ChooseNodes("go", entries, p, nil, 2)
@@ -119,7 +97,7 @@ func TestACapacityOfZeroKeepsThePartOffTheNodeAndANodeNotNamedKeepsOne(t *testin
}
}
// Only nodes of capacity 0 report: nothing qualifies, and the build says why.
_, _, err = ChooseNodes("go", tcs("go", "go1.27.1", "g14", "novox"), p, nil, 2)
_, _, err := ChooseNodes("go", tcs("go", "go1.27.1", "g14", "novox"), p, nil, 2)
if err == nil || !strings.Contains(err.Error(), "capacity") {
t.Errorf("only capacity-0 nodes: error = %v, want it to name the capacity", err)
}