diff --git a/properties/properties.go b/properties/properties.go index 5fc374a..82b87d5 100644 --- a/properties/properties.go +++ b/properties/properties.go @@ -53,6 +53,7 @@ package properties import ( "bufio" + "encoding/json" "errors" "fmt" "log/slog" @@ -65,8 +66,12 @@ import ( "github.com/fsnotify/fsnotify" "github.com/spf13/pflag" + + "github.com/flanksource/commons/timeinterval" ) +const businessHoursKey = "business_hours" + var commandlineProperties map[string]string // Global is the default properties instance used by package-level functions. @@ -225,6 +230,7 @@ func Update(props map[string]string) { func On(def bool, keys ...string) bool { return Global.On(def, keys...) } + func Duration(def time.Duration, keys ...string) time.Duration { return Global.Duration(def, keys...) } @@ -237,6 +243,36 @@ func Int(def int, key string) int { return Global.Int(def, key) } +func BusinessHours() (timeinterval.TimeIntervals, error) { + hours, err := Global.TimeIntervals(businessHoursKey) + if err != nil { + return nil, err + } + + if len(hours) == 0 { + return []timeinterval.TimeInterval{{ + Times: []timeinterval.TimeRange{ + { + StartMinute: 540, // 9am + EndMinute: 1020, // 5pm + }, + }, + Weekdays: []timeinterval.WeekdayRange{ + {InclusiveRange: timeinterval.InclusiveRange{ + Begin: 1, // Monday + End: 5, // Friday + }}, + }, + }}, nil + } + + return hours, nil +} + +func TimeIntervals(keys ...string) (timeinterval.TimeIntervals, error) { + return Global.TimeIntervals(keys...) +} + func (p *Properties) On(def bool, keys ...string) bool { for _, key := range keys { if v := p.Get(key); v != "" { @@ -276,6 +312,34 @@ func (p *Properties) Int(def int, key string) int { return def } +// TimeIntervals returns the parsed time intervals from the specified property keys. +// Properties are looked up under "time_interval." prefix. +// If no keys are provided, defaults to "business_hours". +// The property value should be a JSON array in alertmanager time interval format. +// Example: [{"weekdays":["monday:friday"],"times":[{"start_time":"09:00","end_time":"17:00"}]}] +func (p *Properties) TimeIntervals(keys ...string) (timeinterval.TimeIntervals, error) { + if len(keys) == 0 { + keys = []string{businessHoursKey} + } + + var output []timeinterval.TimeInterval + for _, key := range keys { + propKey := "time_interval." + key + v := p.Get(propKey) + if v == "" { + continue + } + + var intervals []timeinterval.TimeInterval + if err := json.Unmarshal([]byte(v), &intervals); err != nil { + return nil, fmt.Errorf("failed to parse time interval from property %s: %w", propKey, err) + } + output = append(output, intervals...) + } + + return output, nil +} + func (p *Properties) Watch() func() { if p.close != nil { return p.close diff --git a/timeinterval/timeinterval.go b/timeinterval/timeinterval.go new file mode 100644 index 0000000..5d8ff6b --- /dev/null +++ b/timeinterval/timeinterval.go @@ -0,0 +1,644 @@ +// Copyright 2020 Prometheus Team +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package timeinterval + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Intervener determines whether a given time and active route time interval should mute outgoing notifications. +// It implements the TimeMuter interface. +type Intervener struct { + intervals map[string][]TimeInterval +} + +// Mutes implements the TimeMuter interface. +func (i *Intervener) Mutes(names []string, now time.Time) (bool, []string, error) { + var in []string + for _, name := range names { + interval, ok := i.intervals[name] + if !ok { + return false, nil, fmt.Errorf("time interval %s doesn't exist in config", name) + } + + for _, ti := range interval { + if ti.ContainsTime(now.UTC()) { + in = append(in, name) + } + } + } + + return len(in) > 0, in, nil +} + +func NewIntervener(ti map[string][]TimeInterval) *Intervener { + return &Intervener{ + intervals: ti, + } +} + +type TimeIntervals []TimeInterval + +func (t TimeIntervals) ContainsTime(target time.Time) bool { + for _, ti := range t { + if ti.ContainsTime(target) { + return true + } + } + + return false +} + +// TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained +// within the interval. +type TimeInterval struct { + Times []TimeRange `yaml:"times,omitempty" json:"times,omitempty"` + Weekdays []WeekdayRange `yaml:"weekdays,flow,omitempty" json:"weekdays,omitempty"` + DaysOfMonth []DayOfMonthRange `yaml:"days_of_month,flow,omitempty" json:"days_of_month,omitempty"` + Months []MonthRange `yaml:"months,flow,omitempty" json:"months,omitempty"` + Years []YearRange `yaml:"years,flow,omitempty" json:"years,omitempty"` + Location *Location `yaml:"location,flow,omitempty" json:"location,omitempty"` +} + +// TimeRange represents a range of minutes within a 1440 minute day, exclusive of the End minute. A day consists of 1440 minutes. +// For example, 4:00PM to End of the day would Begin at 1020 and End at 1440. +type TimeRange struct { + StartMinute int + EndMinute int +} + +// InclusiveRange is used to hold the Beginning and End values of many time interval components. +type InclusiveRange struct { + Begin int + End int +} + +// A WeekdayRange is an inclusive range between [0, 6] where 0 = Sunday. +type WeekdayRange struct { + InclusiveRange +} + +// A DayOfMonthRange is an inclusive range that may have negative Beginning/End values that represent distance from the End of the month Beginning at -1. +type DayOfMonthRange struct { + InclusiveRange +} + +// A MonthRange is an inclusive range between [1, 12] where 1 = January. +type MonthRange struct { + InclusiveRange +} + +// A YearRange is a positive inclusive range. +type YearRange struct { + InclusiveRange +} + +// A Location is a container for a time.Location, used for custom unmarshalling/validation logic. +type Location struct { + *time.Location +} + +type yamlTimeRange struct { + StartTime string `yaml:"start_time" json:"start_time"` + EndTime string `yaml:"end_time" json:"end_time"` +} + +// A range with a Beginning and End that can be represented as strings. +type stringableRange interface { + setBegin(int) + setEnd(int) + // Try to map a member of the range into an integer. + memberFromString(string) (int, error) +} + +func (ir *InclusiveRange) setBegin(n int) { + ir.Begin = n +} + +func (ir *InclusiveRange) setEnd(n int) { + ir.End = n +} + +func (ir *InclusiveRange) memberFromString(in string) (out int, err error) { + out, err = strconv.Atoi(in) + if err != nil { + return -1, err + } + return out, nil +} + +func (r *WeekdayRange) memberFromString(in string) (out int, err error) { + out, ok := daysOfWeek[in] + if !ok { + return -1, fmt.Errorf("%s is not a valid weekday", in) + } + return out, nil +} + +func (r *MonthRange) memberFromString(in string) (out int, err error) { + out, ok := months[in] + if !ok { + out, err = strconv.Atoi(in) + if err != nil { + return -1, fmt.Errorf("%s is not a valid month", in) + } + } + return out, nil +} + +var daysOfWeek = map[string]int{ + "sunday": 0, + "monday": 1, + "tuesday": 2, + "wednesday": 3, + "thursday": 4, + "friday": 5, + "saturday": 6, +} + +var daysOfWeekInv = map[int]string{ + 0: "sunday", + 1: "monday", + 2: "tuesday", + 3: "wednesday", + 4: "thursday", + 5: "friday", + 6: "saturday", +} + +var months = map[string]int{ + "january": 1, + "february": 2, + "march": 3, + "april": 4, + "may": 5, + "june": 6, + "july": 7, + "august": 8, + "september": 9, + "october": 10, + "november": 11, + "december": 12, +} + +var monthsInv = map[int]string{ + 1: "january", + 2: "february", + 3: "march", + 4: "april", + 5: "may", + 6: "june", + 7: "july", + 8: "august", + 9: "september", + 10: "october", + 11: "november", + 12: "december", +} + +// UnmarshalYAML implements the Unmarshaller interface for Location. +func (tz *Location) UnmarshalYAML(unmarshal func(any) error) error { + var str string + if err := unmarshal(&str); err != nil { + return err + } + + loc, err := time.LoadLocation(str) + if err != nil { + if runtime.GOOS == "windows" { + if zoneinfo := os.Getenv("ZONEINFO"); zoneinfo != "" { + return fmt.Errorf("%w (ZONEINFO=%q)", err, zoneinfo) + } + return fmt.Errorf("%w (on Windows platforms, you may have to pass the time zone database using the ZONEINFO environment variable, see https://pkg.go.dev/time#LoadLocation for details)", err) + } + return err + } + + *tz = Location{loc} + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface for Location. +// It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. +func (tz *Location) UnmarshalJSON(in []byte) error { + return yaml.Unmarshal(in, tz) +} + +// UnmarshalYAML implements the Unmarshaller interface for WeekdayRange. +func (r *WeekdayRange) UnmarshalYAML(unmarshal func(any) error) error { + var str string + if err := unmarshal(&str); err != nil { + return err + } + if err := stringableRangeFromString(str, r); err != nil { + return err + } + if r.Begin > r.End { + return errors.New("start day cannot be before end day") + } + if r.Begin < 0 || r.Begin > 6 { + return fmt.Errorf("%s is not a valid day of the week: out of range", str) + } + if r.End < 0 || r.End > 6 { + return fmt.Errorf("%s is not a valid day of the week: out of range", str) + } + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface for WeekdayRange. +// It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. +func (r *WeekdayRange) UnmarshalJSON(in []byte) error { + return yaml.Unmarshal(in, r) +} + +// UnmarshalYAML implements the Unmarshaller interface for DayOfMonthRange. +func (r *DayOfMonthRange) UnmarshalYAML(unmarshal func(any) error) error { + var str string + if err := unmarshal(&str); err != nil { + return err + } + if err := stringableRangeFromString(str, r); err != nil { + return err + } + // Check beginning <= end accounting for negatives day of month indices as well. + // Months != 31 days can't be addressed here and are clamped, but at least we can catch blatant errors. + if r.Begin == 0 || r.Begin < -31 || r.Begin > 31 { + return fmt.Errorf("%d is not a valid day of the month: out of range", r.Begin) + } + if r.End == 0 || r.End < -31 || r.End > 31 { + return fmt.Errorf("%d is not a valid day of the month: out of range", r.End) + } + // Restricting here prevents errors where begin > end in longer months but not shorter months. + if r.Begin < 0 && r.End > 0 { + return fmt.Errorf("end day must be negative if start day is negative") + } + // Check begin <= end. We can't know this for sure when using negative indices + // but we can prevent cases where its always invalid (using 28 day minimum length). + checkBegin := r.Begin + checkEnd := r.End + if r.Begin < 0 { + checkBegin = 28 + r.Begin + } + if r.End < 0 { + checkEnd = 28 + r.End + } + if checkBegin > checkEnd { + return fmt.Errorf("end day %d is always before start day %d", r.End, r.Begin) + } + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface for DayOfMonthRange. +// It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. +func (r *DayOfMonthRange) UnmarshalJSON(in []byte) error { + return yaml.Unmarshal(in, r) +} + +// UnmarshalYAML implements the Unmarshaller interface for MonthRange. +func (r *MonthRange) UnmarshalYAML(unmarshal func(any) error) error { + var str string + if err := unmarshal(&str); err != nil { + return err + } + if err := stringableRangeFromString(str, r); err != nil { + return err + } + if r.Begin > r.End { + begin := monthsInv[r.Begin] + end := monthsInv[r.End] + return fmt.Errorf("end month %s is before start month %s", end, begin) + } + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface for MonthRange. +// It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. +func (r *MonthRange) UnmarshalJSON(in []byte) error { + return yaml.Unmarshal(in, r) +} + +// UnmarshalYAML implements the Unmarshaller interface for YearRange. +func (r *YearRange) UnmarshalYAML(unmarshal func(any) error) error { + var str string + if err := unmarshal(&str); err != nil { + return err + } + if err := stringableRangeFromString(str, r); err != nil { + return err + } + if r.Begin > r.End { + return fmt.Errorf("end year %d is before start year %d", r.End, r.Begin) + } + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface for YearRange. +// It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. +func (r *YearRange) UnmarshalJSON(in []byte) error { + return yaml.Unmarshal(in, r) +} + +// UnmarshalYAML implements the Unmarshaller interface for TimeRanges. +func (tr *TimeRange) UnmarshalYAML(unmarshal func(any) error) error { + var y yamlTimeRange + if err := unmarshal(&y); err != nil { + return err + } + if y.EndTime == "" || y.StartTime == "" { + return errors.New("both start and end times must be provided") + } + start, err := parseTime(y.StartTime) + if err != nil { + return err + } + end, err := parseTime(y.EndTime) + if err != nil { + return err + } + if start >= end { + return errors.New("start time cannot be equal or greater than end time") + } + tr.StartMinute, tr.EndMinute = start, end + return nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface for Timerange. +// It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. +func (tr *TimeRange) UnmarshalJSON(in []byte) error { + return yaml.Unmarshal(in, tr) +} + +// MarshalYAML implements the yaml.Marshaler interface for WeekdayRange. +func (r WeekdayRange) MarshalYAML() (any, error) { + bytes, err := r.MarshalText() + return string(bytes), err +} + +// MarshalText implements the econding.TextMarshaler interface for WeekdayRange. +// It converts the range into a colon-separated string, or a single weekday if possible. +// E.g. "monday:friday" or "saturday". +func (r WeekdayRange) MarshalText() ([]byte, error) { + beginStr, ok := daysOfWeekInv[r.Begin] + if !ok { + return nil, fmt.Errorf("unable to convert %d into weekday string", r.Begin) + } + if r.Begin == r.End { + return []byte(beginStr), nil + } + endStr, ok := daysOfWeekInv[r.End] + if !ok { + return nil, fmt.Errorf("unable to convert %d into weekday string", r.End) + } + rangeStr := fmt.Sprintf("%s:%s", beginStr, endStr) + return []byte(rangeStr), nil +} + +// MarshalYAML implements the yaml.Marshaler interface for TimeRange. +func (tr TimeRange) MarshalYAML() (out any, err error) { + startHr := tr.StartMinute / 60 + endHr := tr.EndMinute / 60 + startMin := tr.StartMinute % 60 + endMin := tr.EndMinute % 60 + + startStr := fmt.Sprintf("%02d:%02d", startHr, startMin) + endStr := fmt.Sprintf("%02d:%02d", endHr, endMin) + + yTr := yamlTimeRange{startStr, endStr} + return any(yTr), err +} + +// MarshalJSON implements the json.Marshaler interface for TimeRange. +func (tr TimeRange) MarshalJSON() (out []byte, err error) { + startHr := tr.StartMinute / 60 + endHr := tr.EndMinute / 60 + startMin := tr.StartMinute % 60 + endMin := tr.EndMinute % 60 + + startStr := fmt.Sprintf("%02d:%02d", startHr, startMin) + endStr := fmt.Sprintf("%02d:%02d", endHr, endMin) + + yTr := yamlTimeRange{startStr, endStr} + return json.Marshal(yTr) +} + +// MarshalText implements the econding.TextMarshaler interface for Location. +// It marshals a Location back into a string that represents a time.Location. +func (tz Location) MarshalText() ([]byte, error) { + if tz.Location == nil { + return nil, fmt.Errorf("unable to convert nil location into string") + } + return []byte(tz.String()), nil +} + +// MarshalYAML implements the yaml.Marshaler interface for Location. +func (tz Location) MarshalYAML() (any, error) { + bytes, err := tz.MarshalText() + return string(bytes), err +} + +// MarshalJSON implements the json.Marshaler interface for Location. +func (tz Location) MarshalJSON() (out []byte, err error) { + return json.Marshal(tz.String()) +} + +// MarshalText implements the encoding.TextMarshaler interface for InclusiveRange. +// It converts the struct into a colon-separated string, or a single element if +// appropriate. E.g. "monday:friday" or "monday". +func (ir InclusiveRange) MarshalText() ([]byte, error) { + if ir.Begin == ir.End { + return []byte(strconv.Itoa(ir.Begin)), nil + } + out := fmt.Sprintf("%d:%d", ir.Begin, ir.End) + return []byte(out), nil +} + +// MarshalYAML implements the yaml.Marshaler interface for InclusiveRange. +func (ir InclusiveRange) MarshalYAML() (any, error) { + bytes, err := ir.MarshalText() + return string(bytes), err +} + +var ( + validTime = "^((([01][0-9])|(2[0-3])):[0-5][0-9])$|(^24:00$)" + validTimeRE = regexp.MustCompile(validTime) +) + +// Given a time, determines the number of days in the month that time occurs in. +func daysInMonth(t time.Time) int { + monthStart := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) + monthEnd := monthStart.AddDate(0, 1, 0) + diff := monthEnd.Sub(monthStart) + return int(diff.Hours() / 24) +} + +func clamp(n, min, max int) int { + if n <= min { + return min + } + if n >= max { + return max + } + return n +} + +// ContainsTime returns true if the TimeInterval contains the given time, otherwise returns false. +func (tp TimeInterval) ContainsTime(t time.Time) bool { + if tp.Location != nil { + t = t.In(tp.Location.Location) + } + if tp.Times != nil { + in := false + for _, validMinutes := range tp.Times { + if (t.Hour()*60+t.Minute()) >= validMinutes.StartMinute && (t.Hour()*60+t.Minute()) < validMinutes.EndMinute { + in = true + break + } + } + if !in { + return false + } + } + if tp.DaysOfMonth != nil { + in := false + for _, validDates := range tp.DaysOfMonth { + var begin, end int + daysInMonth := daysInMonth(t) + if validDates.Begin < 0 { + begin = daysInMonth + validDates.Begin + 1 + } else { + begin = validDates.Begin + } + if validDates.End < 0 { + end = daysInMonth + validDates.End + 1 + } else { + end = validDates.End + } + // Skip clamping if the beginning date is after the end of the month. + if begin > daysInMonth { + continue + } + // Clamp to the boundaries of the month to prevent crossing into other months. + begin = clamp(begin, -1*daysInMonth, daysInMonth) + end = clamp(end, -1*daysInMonth, daysInMonth) + if t.Day() >= begin && t.Day() <= end { + in = true + break + } + } + if !in { + return false + } + } + if tp.Months != nil { + in := false + for _, validMonths := range tp.Months { + if t.Month() >= time.Month(validMonths.Begin) && t.Month() <= time.Month(validMonths.End) { + in = true + break + } + } + if !in { + return false + } + } + if tp.Weekdays != nil { + in := false + for _, validDays := range tp.Weekdays { + if t.Weekday() >= time.Weekday(validDays.Begin) && t.Weekday() <= time.Weekday(validDays.End) { + in = true + break + } + } + if !in { + return false + } + } + if tp.Years != nil { + in := false + for _, validYears := range tp.Years { + if t.Year() >= validYears.Begin && t.Year() <= validYears.End { + in = true + break + } + } + if !in { + return false + } + } + return true +} + +// Converts a string of the form "HH:MM" into the number of minutes elapsed in the day. +func parseTime(in string) (mins int, err error) { + if !validTimeRE.MatchString(in) { + return 0, fmt.Errorf("couldn't parse timestamp %s, invalid format", in) + } + timestampComponents := strings.Split(in, ":") + if len(timestampComponents) != 2 { + return 0, fmt.Errorf("invalid timestamp format: %s", in) + } + timeStampHours, err := strconv.Atoi(timestampComponents[0]) + if err != nil { + return 0, err + } + timeStampMinutes, err := strconv.Atoi(timestampComponents[1]) + if err != nil { + return 0, err + } + if timeStampHours < 0 || timeStampHours > 24 || timeStampMinutes < 0 || timeStampMinutes > 60 { + return 0, fmt.Errorf("timestamp %s out of range", in) + } + // Timestamps are stored as minutes elapsed in the day, so multiply hours by 60. + mins = timeStampHours*60 + timeStampMinutes + return mins, nil +} + +// Converts a range that can be represented as strings (e.g. monday:wednesday) into an equivalent integer-represented range. +func stringableRangeFromString(in string, r stringableRange) (err error) { + in = strings.ToLower(in) + if strings.ContainsRune(in, ':') { + components := strings.Split(in, ":") + if len(components) != 2 { + return fmt.Errorf("couldn't parse range %s, invalid format", in) + } + start, err := r.memberFromString(components[0]) + if err != nil { + return err + } + End, err := r.memberFromString(components[1]) + if err != nil { + return err + } + r.setBegin(start) + r.setEnd(End) + return nil + } + val, err := r.memberFromString(in) + if err != nil { + return err + } + r.setBegin(val) + r.setEnd(val) + return nil +}