A build seat's puller takes nothing while its module finds its node cannot build, and says why
mesh/delivery merged: source.main moved to novox/mesh-sdk@3d021445f743
mesh/delivery-group group fix/ace-tun merged: every member is merged
mesh/check pass 1618837ea9fe on 6097e4f3e040 | carried from mesh-catalog 25f615456423 | unit 2293 run, 3984 kept, 16 skipped | integration 2331 run, 4…

A node whose module proves a need of the machine missing (ace's /dev/net/tun, 2026-10-28) pulled
image asks and failed each one; judged ineligible, its capacity is 0 with the reason, as build hours
are, so the asks go to a node that can build them (issue 1126 note).
This commit is contained in:
2026-10-28 12:50:16 +01:00
parent 6097e4f3e0
commit 1618837ea9
3 changed files with 39 additions and 11 deletions
+20 -10
View File
@@ -86,6 +86,8 @@ func TestNodeCapacityAndLimits(t *testing.T) {
// The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the // The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the
// capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour. // capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour.
// And a node its module proves cannot build (ace without /dev/net/tun, 2026-10-28) judges by 0 at every hour, saying
// why, so its asks go to a node that can.
cet := time.FixedZone("CET", 3600) cet := time.FixedZone("CET", 3600)
day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) } day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) }
was := now was := now
@@ -96,15 +98,18 @@ func TestNodeCapacityAndLimits(t *testing.T) {
effective int effective int
edge string // the window's next edge, HH:MM; "" for no window edge string // the window's next edge, HH:MM; "" for no window
bad bool 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 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 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 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}, {"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 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 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}, {"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}, {"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"},
} { } {
w, err := ParseBuildHours(c.setting, c.node) w, err := ParseBuildHours(c.setting, c.node)
if (err != nil) != c.bad { if (err != nil) != c.bad {
@@ -121,11 +126,16 @@ func TestNodeCapacityAndLimits(t *testing.T) {
file := filepath.Join(t.TempDir(), "settings.json") file := filepath.Join(t.TempDir(), "settings.json")
os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600) os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600)
now = func() time.Time { return c.at } now = func() time.Time { return c.at }
p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil)} p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil),
Eligible: func() string { return c.ineligible }}
got := p.CapacityDetail() got := p.CapacityDetail()
if got.Effective != c.effective || got.OutsideHours != (c.effective == 0) || (c.effective == 0 && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) { outside := c.effective == 0 && c.ineligible == ""
if got.Effective != c.effective || got.OutsideHours != outside || (outside && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) {
t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective) t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective)
} }
if got.Ineligible != c.ineligible || (c.ineligible != "" && !strings.HasPrefix(got.Why, "0, not eligible: "+c.ineligible+" (otherwise ")) {
t.Errorf("%s: not eligible is not said: %+v", c.name, got)
}
// A value that cannot be read builds at any hour, loudly: the record names the value and says why. // A value that cannot be read builds at any hour, loudly: the record names the value and says why.
if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) { if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) {
t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got) t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got)
+16 -1
View File
@@ -86,6 +86,13 @@ type Puller struct {
// mesh-config resolves for `build-hours` takes its place once handed. Outside the window the capacity judged by is 0: // mesh-config resolves for `build-hours` takes its place once handed. Outside the window the capacity judged by is 0:
// steps running finish, no new one is taken; the puller wakes at the window's next edge. Nil builds at any hour. // steps running finish, no new one is taken; the puller wakes at the window's next edge. Nil builds at any hour.
BuildHours func() (*BuildWindow, string) BuildHours func() (*BuildWindow, string)
// Eligible says why this node cannot run the seat's asks at all now, and "" when it can: what the module proves of
// its machine and finds missing (mesh-build-image: /dev/net/tun cannot be opened, so buildah's network for a RUN line
// cannot be made; 2026-10-28 on ace). Read at every judgement, so it is cheap (a module answers its last look).
// While it says a reason the capacity judged by is 0, said with the reason: steps running finish, no new one is
// taken, and the asks go to a node that can run them instead of failing here. The module calls Wake when its answer
// changes. Nil is always eligible.
Eligible func() string
// Has says whether this node has a need an ask names (delivery.AskNeeds, issue 890). An ask needing what it lacks is // Has says whether this node has a need an ask names (delivery.AskNeeds, issue 890). An ask needing what it lacks is
// declined: its step ends refused as declined (delivery.StepDeclined), the message is dropped for its asker to put // declined: its step ends refused as declined (delivery.StepDeclined), the message is dropped for its asker to put
// again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need // again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need
@@ -291,6 +298,13 @@ func (p *Puller) CapacityDetail() delivery.Capacity {
c.Why = fmt.Sprintf("%s; within its build hours %s until %s", c.Why, c.BuildHours, w.NextEdge(at).Format("15:04")) c.Why = fmt.Sprintf("%s; within its build hours %s until %s", c.Why, c.BuildHours, w.NextEdge(at).Format("15:04"))
} }
} }
if p.Eligible != nil {
if why := p.Eligible(); why != "" {
c.Ineligible = why
c.Why = fmt.Sprintf("0, not eligible: %s (otherwise %s)", why, c.Why)
c.Effective = 0
}
}
p.sayCapacity(c) p.sayCapacity(c)
return c return c
} }
@@ -326,7 +340,8 @@ func (p *Puller) sayCapacity(c delivery.Capacity) {
p.mu.Lock() p.mu.Lock()
same := p.hasJudged && p.judged.Effective == c.Effective && p.judged.Configured == c.Configured && p.judged.ByCPU == c.ByCPU && same := p.hasJudged && p.judged.Effective == c.Effective && p.judged.Configured == c.Configured && p.judged.ByCPU == c.ByCPU &&
p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores && p.judged.OutsideHours == c.OutsideHours && p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores && p.judged.OutsideHours == c.OutsideHours &&
p.judged.BuildHours == c.BuildHours && p.judged.BuildHoursFault == c.BuildHoursFault p.judged.BuildHours == c.BuildHours && p.judged.BuildHoursFault == c.BuildHoursFault &&
p.judged.Ineligible == c.Ineligible
if !same { if !same {
p.judged, p.hasJudged, p.capWritten = c, true, false p.judged, p.hasJudged, p.capWritten = c, true, false
} }
+3
View File
@@ -730,6 +730,9 @@ type Capacity struct {
OutsideHours bool `json:"outside-hours,omitempty"` OutsideHours bool `json:"outside-hours,omitempty"`
// BuildHoursFault is why the node's build-hours value cannot be read, naming the value: it then builds at any hour. // BuildHoursFault is why the node's build-hours value cannot be read, naming the value: it then builds at any hour.
BuildHoursFault string `json:"build-hours-fault,omitempty"` BuildHoursFault string `json:"build-hours-fault,omitempty"`
// Ineligible is why the module found its node cannot run its asks (buildstep.Puller.Eligible), absent when it can:
// Effective is then 0 at every hour.
Ineligible string `json:"ineligible,omitempty"`
// Why is the capacity in words: what bounds it. // Why is the capacity in words: what bounds it.
Why string `json:"why"` Why string `json:"why"`
At time.Time `json:"at,omitzero"` At time.Time `json:"at,omitzero"`