package buildstep import ( "encoding/json" "fmt" "os" "strings" "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). // BuildHoursSetting is the build modules' setting of the nodes' build windows: `=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). const BuildHoursSetting = "build-hours" // BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive. type BuildWindow struct { Start, End int } // String is the window as the setting writes it. func (w BuildWindow) String() string { return fmt.Sprintf("%02d:%02d-%02d:%02d", w.Start/60, w.Start%60, w.End/60, w.End%60) } // Open says whether t, in its own location, falls in the window. func (w BuildWindow) Open(t time.Time) bool { m := t.Hour()*60 + t.Minute() if w.Start < w.End { return m >= w.Start && m < w.End } return m >= w.Start || m < w.End } // NextEdge is the first time after t, in t's location, at which the window opens or closes. func (w BuildWindow) NextEdge(t time.Time) time.Time { var next time.Time for day := 0; day <= 1; day++ { for _, m := range []int{w.Start, w.End} { e := time.Date(t.Year(), t.Month(), t.Day()+day, m/60, m%60, 0, 0, t.Location()) if e.After(t) && (next.IsZero() || e.Before(next)) { next = e } } } 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 =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} } } return found, nil } // minuteOf reads HH:MM as minutes after midnight. func minuteOf(s string) (int, error) { var h, m int s = strings.TrimSpace(s) if n, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(s) != 5 { return 0, fmt.Errorf("%q is not a time HH:MM", s) } 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 // 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 // logf, and the puller writes it in the capacity record that build_usage shows. func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*BuildWindow, string) { said := "" say := func(why string) { if logf != nil && why != said { said = why logf("%s, so %s builds at any hour", why, node) } } fault := func(why string) (*BuildWindow, string) { say(why) return nil, why } return func() (*BuildWindow, string) { if path == "" { return nil, "" } raw, err := os.ReadFile(path) if err != nil { return fault(fmt.Sprintf("the setting %s cannot be read: %v", BuildHoursSetting, err)) } var s map[string]json.RawMessage if err := json.Unmarshal(raw, &s); err != nil { return fault(fmt.Sprintf("the settings file %s is not JSON: %v", path, err)) } setting := "" 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) if err != nil { return fault(err.Error()) } said = "" return w, "" } }