Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add timezone support to time intervals. #2782

Merged
merged 14 commits into from Sep 22, 2022
Merged
24 changes: 22 additions & 2 deletions docs/configuration.md
Expand Up @@ -283,13 +283,14 @@ supports the following fields:
[ - <month_range> ...]
years:
[ - <year_range> ...]
location: <string>
```

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.
Expand Down Expand Up @@ -319,6 +320,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.

## `<inhibit_rule>`

An inhibition rule mutes an alert (target) matching a set of matchers
Expand Down
33 changes: 23 additions & 10 deletions notify/notify_test.go
Expand Up @@ -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']
time_zone: 'Australia/Sydney'
benridley marked this conversation as resolved.
Show resolved Hide resolved
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']
time_zone: 'Australia/Sydney'`
benridley marked this conversation as resolved.
Show resolved Hide resolved

cases := []struct {
fireTime string
Expand All @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions timeinterval/timeinterval.go
Expand Up @@ -17,7 +17,9 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"time"
Expand All @@ -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:"time_zone,flow,omitempty" json:"time_zone,omitempty"`
benridley marked this conversation as resolved.
Show resolved Hide resolved
}

// TimeRange represents a range of minutes within a 1440 minute day, exclusive of the End minute. A day consists of 1440 minutes.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -165,6 +173,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
Expand Down Expand Up @@ -362,6 +398,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"
Expand Down Expand Up @@ -405,6 +461,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 {
Expand Down