package buildstep import ( "os" "path/filepath" "strings" "testing" "time" ) // 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls // nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing. // ADR 0553 §4: a step's threads are the composer's share over the module's own capacity; without the word, none. And // 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) { // 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") 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) } } 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", "") if l := NodeLimits(3); l.Threads != 0 || l.Slots != 3 { t.Errorf("without the word: %+v", l) } t.Setenv("MESH_BUILD_CPU_SHARE", "75") if l := NodeLimits(3); l.Percent != 75 || l.Slots != 3 || l.Threads < 1 { t.Errorf("from the word: %+v", l) } for _, c := range []struct { name string configured, quota int cores float64 effective, byCPU int }{ {"shanks: 12 cores, configured 3", 3, 1200, 6, 2, 2}, {"ace: 18 cores, configured 2", 2, 1800, 6, 2, 3}, {"g14: 8 cores, configured 2", 2, 800, 6, 1, 1}, {"node on shanks: 12 cores at 2", 3, 1200, 2, 3, 6}, {"no quota: the configured one", 3, 0, 6, 3, 0}, {"configured 0 pulls nothing", 0, 1200, 6, 0, 0}, {"a quota under one step still builds one", 2, 100, 6, 1, 1}, {"no cores a step is one", 2, 150, 0, 1, 1}, } { got := EffectiveCapacity(c.configured, c.quota, "test", c.cores) if got.Effective != c.effective || got.ByCPU != c.byCPU || got.Why == "" { t.Errorf("%s: %+v, want effective %d, by CPU %d", c.name, got, c.effective, c.byCPU) } } file = filepath.Join(t.TempDir(), "settings.json") var said []string cores := NodeCoresPerStep(file, 6, func(f string, a ...any) { said = append(said, f) }) for _, c := range []struct { file string want float64 }{{`{"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) } } if len(said) != 1 { t.Errorf("a value not above zero is said once, said %d times", len(said)) } // 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. // 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) day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) } was := now defer func() { now = was }() for _, c := range []struct { name, setting, node string at time.Time effective int edge string // the window's next edge, HH:MM; "" for no window bad bool ineligible string // what the module's Eligible says; "" for eligible }{ {"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 := ParseBuildWindow(c.setting) if (err != nil) != c.bad { t.Errorf("%s: parse %v", c.name, err) } if edge := ""; w != nil { edge = w.NextEdge(c.at).Format("15:04") if edge != c.edge { t.Errorf("%s: next edge %s, want %s", c.name, edge, c.edge) } } else if c.edge != "" { t.Errorf("%s: no window, want one", c.name) } file := filepath.Join(t.TempDir(), "settings.json") os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600) 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), Eligible: func() string { return c.ineligible }} got := p.CapacityDetail() 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) } 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. 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) } } }