alertmanager/timeinterval/timeinterval.go
George Robinson dacbf0050b
#3513: TimeMuter returns the names of time intervals (#3791)
* TimeMuter returns the names of time intervals

This commit updates the TimeMuter interface to also return the names
of the time intervals that muted the alerts.

Signed-off-by: George Robinson <george.robinson@grafana.com>

---------

Signed-off-by: George Robinson <george.robinson@grafana.com>
2024-04-30 12:47:00 +01:00

633 lines
18 KiB
Go

// 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.v2"
)
// 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,
}
}
// 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(interface{}) 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(interface{}) 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(interface{}) 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(interface{}) 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(interface{}) 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(interface{}) 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() (interface{}, 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 interface{}, 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 interface{}(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.Location.String()), nil
}
// MarshalYAML implements the yaml.Marshaler interface for Location.
func (tz Location) MarshalYAML() (interface{}, 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() (interface{}, 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
}