diff --git a/docs/configuration.md b/docs/configuration.md index d769ad4d..c1871ef2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -285,13 +285,14 @@ supports the following fields: [ - ...] years: [ - ...] + location: ``` All fields are lists. Within each non-empty list, at least one element must be satisfied to match the field. If a field is left unspecified, any value will match the field. For an instant of time to match a complete time interval, all fields must match. -Some fields support ranges and negative indices, and are detailed below. All definitions are -taken to be in UTC, no other timezones are currently supported. +Some fields support ranges and negative indices, and are detailed below. If a time zone is not +specified, then the times are taken to be in UTC. `time_range`: Ranges inclusive of the starting time and exclusive of the end time to make it easy to represent times that start/end on hour boundaries. @@ -321,6 +322,25 @@ Inclusive on both ends. `year_range`: A numerical list of years. Ranges are accepted. For example, `['2020:2022', '2030']`. Inclusive on both ends. +`location`: A string that matches a location in the IANA time zone database. For +example, `'Australia/Sydney'`. The location provides the time zone for the time +interval. For example, a time interval with a location of `'Australia/Sydney'` that +contained something like: + + times: + - start_time: 09:00 + end_time: 17:00 + weekdays: ['monday:friday'] + +would include any time that fell between the hours of 9:00AM and 5:00PM, between Monday +and Friday, using the local time in Sydney, Australia. + +You may also use `'Local'` as a location to use the local time of the machine where +Alertmanager is running, or `'UTC'` for UTC time. If no timezone is provided, the time +interval is taken to be in UTC time.**Note:** On Windows, only `Local` or `UTC` are +supported unless you provide a custom time zone database using the `ZONEINFO` +environment variable. + ## `` An inhibition rule mutes an alert (target) matching a set of matchers diff --git a/notify/notify_test.go b/notify/notify_test.go index 5beaf182..32bd29a1 100644 --- a/notify/notify_test.go +++ b/notify/notify_test.go @@ -724,16 +724,20 @@ func TestMuteStageWithSilences(t *testing.T) { } func TestTimeMuteStage(t *testing.T) { - // Route mutes alerts outside business hours if it is a mute_time_interval + // Route mutes alerts outside business hours in November, using the +1100 timezone. muteIn := ` --- - weekdays: ['monday:friday'] + location: 'Australia/Sydney' + months: ['November'] times: - start_time: '00:00' end_time: '09:00' - start_time: '17:00' end_time: '24:00' -- weekdays: ['saturday', 'sunday']` +- weekdays: ['saturday', 'sunday'] + months: ['November'] + location: 'Australia/Sydney'` cases := []struct { fireTime string @@ -742,40 +746,49 @@ func TestTimeMuteStage(t *testing.T) { }{ { // Friday during business hours - fireTime: "01 Jan 21 09:00 +0000", + fireTime: "19 Nov 21 13:00 +1100", labels: model.LabelSet{"foo": "bar"}, shouldMute: false, }, { // Tuesday before 5pm - fireTime: "01 Dec 20 16:59 +0000", + fireTime: "16 Nov 21 16:59 +1100", labels: model.LabelSet{"dont": "mute"}, shouldMute: false, }, { // Saturday - fireTime: "17 Oct 20 10:00 +0000", + fireTime: "20 Nov 21 10:00 +1100", labels: model.LabelSet{"mute": "me"}, shouldMute: true, }, { // Wednesday before 9am - fireTime: "14 Oct 20 05:00 +0000", + fireTime: "17 Nov 21 05:00 +1100", labels: model.LabelSet{"mute": "me"}, shouldMute: true, }, { - // Ensure comparisons are UTC only. 12:00 KST should be muted (03:00 UTC) - fireTime: "14 Oct 20 12:00 +0900", + // Ensure comparisons with other time zones work as expected. + fireTime: "14 Nov 21 20:00 +0900", labels: model.LabelSet{"mute": "kst"}, shouldMute: true, }, { - // Ensure comparisons are UTC only. 22:00 KST should not be muted (13:00 UTC) - fireTime: "14 Oct 20 22:00 +0900", + fireTime: "14 Nov 21 21:30 +0000", + labels: model.LabelSet{"mute": "utc"}, + shouldMute: true, + }, + { + fireTime: "15 Nov 22 14:30 +0900", labels: model.LabelSet{"kst": "dont_mute"}, shouldMute: false, }, + { + fireTime: "15 Nov 21 02:00 -0500", + labels: model.LabelSet{"mute": "0500"}, + shouldMute: true, + }, } var intervals []timeinterval.TimeInterval err := yaml.Unmarshal([]byte(muteIn), &intervals) diff --git a/timeinterval/timeinterval.go b/timeinterval/timeinterval.go index 7d1586ac..66d91784 100644 --- a/timeinterval/timeinterval.go +++ b/timeinterval/timeinterval.go @@ -17,7 +17,9 @@ import ( "encoding/json" "errors" "fmt" + "os" "regexp" + "runtime" "strconv" "strings" "time" @@ -33,6 +35,7 @@ type TimeInterval struct { 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. @@ -68,6 +71,11 @@ 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"` @@ -166,6 +174,34 @@ var monthsInv = map[int]string{ 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 @@ -363,6 +399,26 @@ func (tr TimeRange) MarshalJSON() (out []byte, err error) { 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" @@ -408,6 +464,9 @@ func clamp(n, min, max int) int { // 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 { diff --git a/timeinterval/timeinterval_test.go b/timeinterval/timeinterval_test.go index f8eeca54..9c3a2c1d 100644 --- a/timeinterval/timeinterval_test.go +++ b/timeinterval/timeinterval_test.go @@ -30,9 +30,9 @@ var timeIntervalTestCases = []struct { { timeInterval: TimeInterval{}, validTimeStrings: []string{ - "02 Jan 06 15:04 MST", - "03 Jan 07 10:04 MST", - "04 Jan 06 09:04 MST", + "02 Jan 06 15:04 +0000", + "03 Jan 07 10:04 +0000", + "04 Jan 06 09:04 +0000", }, invalidTimeStrings: []string{}, }, @@ -43,14 +43,14 @@ var timeIntervalTestCases = []struct { Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, }, validTimeStrings: []string{ - "04 May 20 15:04 MST", - "05 May 20 10:04 MST", - "09 Jun 20 09:04 MST", + "04 May 20 15:04 +0000", + "05 May 20 10:04 +0000", + "09 Jun 20 09:04 +0000", }, invalidTimeStrings: []string{ - "03 May 20 15:04 MST", - "04 May 20 08:59 MST", - "05 May 20 05:00 MST", + "03 May 20 15:04 +0000", + "04 May 20 08:59 +0000", + "05 May 20 05:00 +0000", }, }, { @@ -61,16 +61,16 @@ var timeIntervalTestCases = []struct { Years: []YearRange{{InclusiveRange{Begin: 2020, End: 2020}}}, }, validTimeStrings: []string{ - "04 Apr 20 15:04 MST", - "05 Apr 20 00:00 MST", - "06 Apr 20 23:05 MST", + "04 Apr 20 15:04 +0000", + "05 Apr 20 00:00 +0000", + "06 Apr 20 23:05 +0000", }, invalidTimeStrings: []string{ - "03 May 18 15:04 MST", - "03 Apr 20 23:59 MST", - "04 Jun 20 23:59 MST", - "06 Apr 19 23:59 MST", - "07 Apr 20 00:00 MST", + "03 May 18 15:04 +0000", + "03 Apr 20 23:59 +0000", + "04 Jun 20 23:59 +0000", + "06 Apr 19 23:59 +0000", + "07 Apr 20 00:00 +0000", }, }, { @@ -79,20 +79,20 @@ var timeIntervalTestCases = []struct { DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: -3, End: -1}}}, }, validTimeStrings: []string{ - "31 Jan 20 15:04 MST", - "30 Jan 20 15:04 MST", - "29 Jan 20 15:04 MST", - "30 Jun 20 00:00 MST", - "29 Feb 20 23:05 MST", + "31 Jan 20 15:04 +0000", + "30 Jan 20 15:04 +0000", + "29 Jan 20 15:04 +0000", + "30 Jun 20 00:00 +0000", + "29 Feb 20 23:05 +0000", }, invalidTimeStrings: []string{ - "03 May 18 15:04 MST", - "27 Jan 20 15:04 MST", - "03 Apr 20 23:59 MST", - "04 Jun 20 23:59 MST", - "06 Apr 19 23:59 MST", - "07 Apr 20 00:00 MST", - "01 Mar 20 00:00 MST", + "03 May 18 15:04 +0000", + "27 Jan 20 15:04 +0000", + "03 Apr 20 23:59 +0000", + "04 Jun 20 23:59 +0000", + "06 Apr 19 23:59 +0000", + "07 Apr 20 00:00 +0000", + "01 Mar 20 00:00 +0000", }, }, { @@ -102,12 +102,43 @@ var timeIntervalTestCases = []struct { DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: -31, End: 31}}}, }, validTimeStrings: []string{ - "30 Jun 20 00:00 MST", - "01 Jun 20 00:00 MST", + "30 Jun 20 00:00 +0000", + "01 Jun 20 00:00 +0000", }, invalidTimeStrings: []string{ - "31 May 20 00:00 MST", - "1 Jul 20 00:00 MST", + "31 May 20 00:00 +0000", + "1 Jul 20 00:00 +0000", + }, + }, + { + // Check alternative timezones can be used to compare times. + // AEST 9AM to 5PM, Monday to Friday. + timeInterval: TimeInterval{ + Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, + Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, + Location: &Location{mustLoadLocation("Australia/Sydney")}, + }, + validTimeStrings: []string{ + "06 Apr 21 13:00 +1000", + }, + invalidTimeStrings: []string{ + "06 Apr 21 13:00 +0000", + }, + }, + { + // Check an alternative timezone during daylight savings time. + timeInterval: TimeInterval{ + Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, + Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, + Months: []MonthRange{{InclusiveRange{Begin: 11, End: 11}}}, + Location: &Location{mustLoadLocation("Australia/Sydney")}, + }, + validTimeStrings: []string{ + "01 Nov 21 09:00 +1100", + "31 Oct 21 22:00 +0000", + }, + invalidTimeStrings: []string{ + "31 Oct 21 21:00 +0000", }, }, } @@ -188,12 +219,12 @@ var yamlUnmarshalTestCases = []struct { }, }, contains: []string{ - "08 Jul 20 09:00 MST", - "08 Jul 20 16:59 MST", + "08 Jul 20 09:00 +0000", + "08 Jul 20 16:59 +0000", }, excludes: []string{ - "08 Jul 20 05:00 MST", - "08 Jul 20 08:59 MST", + "08 Jul 20 05:00 +0000", + "08 Jul 20 08:59 +0000", }, expectError: false, }, @@ -220,18 +251,18 @@ var yamlUnmarshalTestCases = []struct { }, }, contains: []string{ - "27 Jan 21 09:00 MST", - "28 Jan 21 16:59 MST", - "29 Jan 21 13:00 MST", - "31 Mar 25 13:00 MST", - "31 Mar 25 13:00 MST", - "31 Jan 35 13:00 MST", + "27 Jan 21 09:00 +0000", + "28 Jan 21 16:59 +0000", + "29 Jan 21 13:00 +0000", + "31 Mar 25 13:00 +0000", + "31 Mar 25 13:00 +0000", + "31 Jan 35 13:00 +0000", }, excludes: []string{ - "30 Jan 21 13:00 MST", // Saturday - "01 Apr 21 13:00 MST", // 4th month - "30 Jan 26 13:00 MST", // 2026 - "31 Jan 35 17:01 MST", // After 5pm + "30 Jan 21 13:00 +0000", // Saturday + "01 Apr 21 13:00 +0000", // 4th month + "30 Jan 26 13:00 +0000", // 2026 + "31 Jan 35 17:01 +0000", // After 5pm }, expectError: false, }, @@ -249,7 +280,7 @@ var yamlUnmarshalTestCases = []struct { }, }, contains: []string{ - "01 Apr 21 13:00 GMT", + "01 Apr 21 13:00 +0000", }, }, { @@ -378,6 +409,21 @@ var yamlUnmarshalTestCases = []struct { }, }, }, + { + // Time zones may be specified by location. + in: ` +--- +- years: ['2020:2022'] + location: 'Australia/Sydney' +`, + expectError: false, + intervals: []TimeInterval{ + { + Years: []YearRange{{InclusiveRange{2020, 2022}}}, + Location: &Location{mustLoadLocation("Australia/Sydney")}, + }, + }, + }, { // Invalid start month. in: ` @@ -434,7 +480,7 @@ func TestYamlUnmarshal(t *testing.T) { t.Errorf("Error unmarshalling %s: Want %+v, got %+v", tc.in, tc.intervals, ti) } for _, ts := range tc.contains { - _t, _ := time.Parse(time.RFC822, ts) + _t, _ := time.Parse(time.RFC822Z, ts) isContained := false for _, interval := range ti { if interval.ContainsTime(_t) { @@ -446,7 +492,7 @@ func TestYamlUnmarshal(t *testing.T) { } } for _, ts := range tc.excludes { - _t, _ := time.Parse(time.RFC822, ts) + _t, _ := time.Parse(time.RFC822Z, ts) isContained := false for _, interval := range ti { if interval.ContainsTime(_t) { @@ -463,13 +509,13 @@ func TestYamlUnmarshal(t *testing.T) { func TestContainsTime(t *testing.T) { for _, tc := range timeIntervalTestCases { for _, ts := range tc.validTimeStrings { - _t, _ := time.Parse(time.RFC822, ts) + _t, _ := time.Parse(time.RFC822Z, ts) if !tc.timeInterval.ContainsTime(_t) { t.Errorf("Expected period %+v to contain %+v", tc.timeInterval, _t) } } for _, ts := range tc.invalidTimeStrings { - _t, _ := time.Parse(time.RFC822, ts) + _t, _ := time.Parse(time.RFC822Z, ts) if tc.timeInterval.ContainsTime(_t) { t.Errorf("Period %+v not expected to contain %+v", tc.timeInterval, _t) } @@ -554,13 +600,13 @@ years: ['2020:2023'] months: ['january:march'] `, contains: []string{ - "10 Jan 21 13:00 GMT", - "30 Jan 21 14:24 GMT", + "10 Jan 21 13:00 +0000", + "30 Jan 21 14:24 +0000", }, excludes: []string{ - "09 Jan 21 13:00 GMT", - "20 Jan 21 12:59 GMT", - "02 Feb 21 13:00 GMT", + "09 Jan 21 13:00 +0000", + "20 Jan 21 12:59 +0000", + "02 Feb 21 13:00 +0000", }, }, { @@ -572,7 +618,7 @@ years: ['2020:2023'] months: ['february'] `, excludes: []string{ - "28 Feb 21 13:00 GMT", + "28 Feb 21 13:00 +0000", }, }, } @@ -585,7 +631,7 @@ func TestTimeIntervalComplete(t *testing.T) { t.Error(err) } for _, ts := range tc.contains { - tt, err := time.Parse(time.RFC822, ts) + tt, err := time.Parse(time.RFC822Z, ts) if err != nil { t.Error(err) } @@ -594,7 +640,7 @@ func TestTimeIntervalComplete(t *testing.T) { } } for _, ts := range tc.excludes { - tt, err := time.Parse(time.RFC822, ts) + tt, err := time.Parse(time.RFC822Z, ts) if err != nil { t.Error(err) } @@ -604,3 +650,12 @@ func TestTimeIntervalComplete(t *testing.T) { } } } + +// Utility function for declaring time locations in test cases. Panic if the location can't be loaded. +func mustLoadLocation(name string) *time.Location { + loc, err := time.LoadLocation(name) + if err != nil { + panic(err) + } + return loc +}