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) { 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) } 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) } 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 { 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":"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}} { 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. 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 }{ {"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}, } { w, err := ParseBuildHours(c.setting, c.node) 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)} 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")) { t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective) } // 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) } } }