130 lines
4.5 KiB
Go
130 lines
4.5 KiB
Go
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: 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 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.
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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.
|
|
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 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
|
|
// 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 := ParseBuildWindow(setting)
|
|
if err != nil {
|
|
return fault(err.Error())
|
|
}
|
|
said = ""
|
|
return w, ""
|
|
}
|
|
}
|