From 17eedc00ac4d07f8b285a516e8f46e472153337f Mon Sep 17 00:00:00 2001 From: Ben Ridley Date: Thu, 22 Sep 2022 22:45:17 +1000 Subject: [PATCH] Add timezone support to time intervals. (#2782) * Add explicit UTC to time interval tests Signed-off-by: Ben Ridley * Add timezone support to time intervals Signed-off-by: Ben Ridley * Update time interval documentation with time zone info Signed-off-by: Ben Ridley * Refactor notification tests to test timezone support Signed-off-by: Ben Ridley * Make use of Local more clear Signed-off-by: Ben Ridley * Fix documentation about timezone support. Makes it clear that the default is UTC, but others are supported. Signed-off-by: Ben Ridley * Remove commented/unused function Signed-off-by: Ben Ridley * Fix tests using incorrect timezones Previously tests were using time zone names that were unsupported by the RFC822 parser. This switches the tests to use RFC822Z and specifies the zones by number. Signed-off-by: Ben Ridley * Add a few more timezone test cases Signed-off-by: Ben Ridley * Remove unnecessary if/else branch Co-authored-by: Sylvain Rabot Signed-off-by: Ben Ridley * Rename timezone to location for consistency with Go stdlib Signed-off-by: Ben Ridley * Make Windows timezone error more specific Signed-off-by: Ben Ridley * Update docs to use 'location' Signed-off-by: Ben Ridley * Apply suggestions from code review Co-authored-by: Sylvain Rabot Signed-off-by: Ben Ridley Signed-off-by: Ben Ridley Co-authored-by: Sylvain Rabot Signed-off-by: Yijie Qin --- docs/configuration.md | 24 +++- notify/notify_test.go | 33 ++++-- timeinterval/timeinterval.go | 59 ++++++++++ timeinterval/timeinterval_test.go | 175 ++++++++++++++++++++---------- 4 files changed, 219 insertions(+), 72 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 16c73dd633..01564d51c2 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 5beaf18242..32bd29a1c3 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 7d1586ac26..66d91784fa 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 f8eeca5411..9c3a2c1d87 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 +}