diff --git a/cmd/alertmanager/main.go b/cmd/alertmanager/main.go index e975de416e..641029603b 100644 --- a/cmd/alertmanager/main.go +++ b/cmd/alertmanager/main.go @@ -60,6 +60,7 @@ import ( "github.com/prometheus/alertmanager/provider/mem" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/types" "github.com/prometheus/alertmanager/ui" ) @@ -413,6 +414,12 @@ func run() int { integrationsNum += len(integrations) } + // Build the map of time interval names to mute time definitions. + muteTimes := make(map[string][]timeinterval.TimeInterval, len(conf.MuteTimeIntervals)) + for _, ti := range conf.MuteTimeIntervals { + muteTimes[ti.Name] = ti.TimeIntervals + } + inhibitor.Stop() disp.Stop() @@ -423,6 +430,7 @@ func run() int { waitFunc, inhibitor, silencer, + muteTimes, notificationLog, peer, ) diff --git a/config/config.go b/config/config.go index c1a873cc40..440b505f33 100644 --- a/config/config.go +++ b/config/config.go @@ -31,6 +31,7 @@ import ( "gopkg.in/yaml.v2" "github.com/prometheus/alertmanager/pkg/labels" + "github.com/prometheus/alertmanager/timeinterval" ) const secretToken = "" @@ -219,13 +220,32 @@ func resolveFilepaths(baseDir string, cfg *Config) { } } +// MuteTimeInterval represents a named set of time intervals for which a route should be muted. +type MuteTimeInterval struct { + Name string `yaml:"name"` + TimeIntervals []timeinterval.TimeInterval `yaml:"time_intervals"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for MuteTimeInterval. +func (mt *MuteTimeInterval) UnmarshalYAML(unmarshal func(interface{}) error) error { + type plain MuteTimeInterval + if err := unmarshal((*plain)(mt)); err != nil { + return err + } + if mt.Name == "" { + return fmt.Errorf("missing name in mute time interval") + } + return nil +} + // Config is the top-level configuration for Alertmanager's config files. type Config struct { - Global *GlobalConfig `yaml:"global,omitempty" json:"global,omitempty"` - Route *Route `yaml:"route,omitempty" json:"route,omitempty"` - InhibitRules []*InhibitRule `yaml:"inhibit_rules,omitempty" json:"inhibit_rules,omitempty"` - Receivers []*Receiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` - Templates []string `yaml:"templates" json:"templates"` + Global *GlobalConfig `yaml:"global,omitempty" json:"global,omitempty"` + Route *Route `yaml:"route,omitempty" json:"route,omitempty"` + InhibitRules []*InhibitRule `yaml:"inhibit_rules,omitempty" json:"inhibit_rules,omitempty"` + Receivers []*Receiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` + Templates []string `yaml:"templates" json:"templates"` + MuteTimeIntervals []MuteTimeInterval `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty"` // original is the input from which the config was parsed. original string @@ -411,9 +431,23 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { if len(c.Route.Match) > 0 || len(c.Route.MatchRE) > 0 { return fmt.Errorf("root route must not have any matchers") } + if len(c.Route.MuteTimeIntervals) > 0 { + return fmt.Errorf("root route must not have any mute time intervals") + } // Validate that all receivers used in the routing tree are defined. - return checkReceiver(c.Route, names) + if err := checkReceiver(c.Route, names); err != nil { + return err + } + + tiNames := make(map[string]struct{}) + for _, mt := range c.MuteTimeIntervals { + if _, ok := tiNames[mt.Name]; ok { + return fmt.Errorf("mute time interval %q is not unique", mt.Name) + } + tiNames[mt.Name] = struct{}{} + } + return checkTimeInterval(c.Route, tiNames) } // checkReceiver returns an error if a node in the routing tree @@ -433,6 +467,23 @@ func checkReceiver(r *Route, receivers map[string]struct{}) error { return nil } +func checkTimeInterval(r *Route, timeIntervals map[string]struct{}) error { + for _, sr := range r.Routes { + if err := checkTimeInterval(sr, timeIntervals); err != nil { + return err + } + } + if len(r.MuteTimeIntervals) == 0 { + return nil + } + for _, mt := range r.MuteTimeIntervals { + if _, ok := timeIntervals[mt]; !ok { + return fmt.Errorf("undefined time interval %q used in route", mt) + } + } + return nil +} + // DefaultGlobalConfig returns GlobalConfig with default values. func DefaultGlobalConfig() GlobalConfig { return GlobalConfig{ @@ -582,10 +633,11 @@ type Route struct { // Deprecated. Remove before v1.0 release. Match map[string]string `yaml:"match,omitempty" json:"match,omitempty"` // Deprecated. Remove before v1.0 release. - MatchRE MatchRegexps `yaml:"match_re,omitempty" json:"match_re,omitempty"` - Matchers Matchers `yaml:"matchers,omitempty" json:"matchers,omitempty"` - Continue bool `yaml:"continue" json:"continue,omitempty"` - Routes []*Route `yaml:"routes,omitempty" json:"routes,omitempty"` + MatchRE MatchRegexps `yaml:"match_re,omitempty" json:"match_re,omitempty"` + Matchers Matchers `yaml:"matchers,omitempty" json:"matchers,omitempty"` + MuteTimeIntervals []string `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty"` + Continue bool `yaml:"continue" json:"continue,omitempty"` + Routes []*Route `yaml:"routes,omitempty" json:"routes,omitempty"` GroupWait *model.Duration `yaml:"group_wait,omitempty" json:"group_wait,omitempty"` GroupInterval *model.Duration `yaml:"group_interval,omitempty" json:"group_interval,omitempty"` diff --git a/config/config_test.go b/config/config_test.go index 946fef9307..ce8c6fef10 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -151,6 +151,103 @@ receivers: } +func TestMuteTimeExists(t *testing.T) { + in := ` +route: + receiver: team-Y + routes: + - match: + severity: critical + mute_time_intervals: + - business_hours + +receivers: +- name: 'team-Y' +` + _, err := Load(in) + + expected := "undefined time interval \"business_hours\" used in route" + + if err == nil { + t.Fatalf("no error returned, expected:\n%q", expected) + } + if err.Error() != expected { + t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) + } + +} + +func TestMuteTimeHasName(t *testing.T) { + in := ` +mute_time_intervals: +- name: + time_intervals: + - times: + - start_time: '09:00' + end_time: '17:00' + +receivers: +- name: 'team-X-mails' + +route: + receiver: 'team-X-mails' + routes: + - match: + severity: critical + mute_time_intervals: + - business_hours +` + _, err := Load(in) + + expected := "missing name in mute time interval" + + if err == nil { + t.Fatalf("no error returned, expected:\n%q", expected) + } + if err.Error() != expected { + t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) + } + +} + +func TestMuteTimeNoDuplicates(t *testing.T) { + in := ` +mute_time_intervals: +- name: duplicate + time_intervals: + - times: + - start_time: '09:00' + end_time: '17:00' +- name: duplicate + time_intervals: + - times: + - start_time: '10:00' + end_time: '14:00' + +receivers: +- name: 'team-X-mails' + +route: + receiver: 'team-X-mails' + routes: + - match: + severity: critical + mute_time_intervals: + - business_hours +` + _, err := Load(in) + + expected := "mute time interval \"duplicate\" is not unique" + + if err == nil { + t.Fatalf("no error returned, expected:\n%q", expected) + } + if err.Error() != expected { + t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) + } + +} + func TestGroupByHasNoDuplicatedLabels(t *testing.T) { in := ` route: @@ -231,6 +328,36 @@ receivers: } +func TestRootRouteNoMuteTimes(t *testing.T) { + in := ` +mute_time_intervals: +- name: my_mute_time + time_intervals: + - times: + - start_time: '09:00' + end_time: '17:00' + +receivers: +- name: 'team-X-mails' + +route: + receiver: 'team-X-mails' + mute_time_intervals: + - my_mute_time +` + _, err := Load(in) + + expected := "root route must not have any mute time intervals" + + if err == nil { + t.Fatalf("no error returned, expected:\n%q", expected) + } + if err.Error() != expected { + t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) + } + +} + func TestRootRouteHasNoMatcher(t *testing.T) { in := ` route: diff --git a/dispatch/dispatch.go b/dispatch/dispatch.go index d5233b5065..b030046d6d 100644 --- a/dispatch/dispatch.go +++ b/dispatch/dispatch.go @@ -404,6 +404,7 @@ func (ag *aggrGroup) run(nf notifyFunc) { ctx = notify.WithGroupLabels(ctx, ag.labels) ctx = notify.WithReceiverName(ctx, ag.opts.Receiver) ctx = notify.WithRepeatInterval(ctx, ag.opts.RepeatInterval) + ctx = notify.WithMuteTimeIntervals(ctx, ag.opts.MuteTimeIntervals) // Wait the configured interval before calling flush again. ag.mtx.Lock() diff --git a/dispatch/route.go b/dispatch/route.go index bb6477ddaa..f892f264b2 100644 --- a/dispatch/route.go +++ b/dispatch/route.go @@ -29,11 +29,12 @@ import ( // DefaultRouteOpts are the defaulting routing options which apply // to the root route of a routing tree. var DefaultRouteOpts = RouteOpts{ - GroupWait: 30 * time.Second, - GroupInterval: 5 * time.Minute, - RepeatInterval: 4 * time.Hour, - GroupBy: map[model.LabelName]struct{}{}, - GroupByAll: false, + GroupWait: 30 * time.Second, + GroupInterval: 5 * time.Minute, + RepeatInterval: 4 * time.Hour, + GroupBy: map[model.LabelName]struct{}{}, + GroupByAll: false, + MuteTimeIntervals: []string{}, } // A Route is a node that contains definitions of how to handle alerts. @@ -65,6 +66,7 @@ func NewRoute(cr *config.Route, parent *Route) *Route { if cr.Receiver != "" { opts.Receiver = cr.Receiver } + if cr.GroupBy != nil { opts.GroupBy = map[model.LabelName]struct{}{} for _, ln := range cr.GroupBy { @@ -115,6 +117,8 @@ func NewRoute(cr *config.Route, parent *Route) *Route { sort.Sort(matchers) + opts.MuteTimeIntervals = cr.MuteTimeIntervals + route := &Route{ parent: parent, RouteOpts: opts, @@ -203,6 +207,9 @@ type RouteOpts struct { GroupWait time.Duration GroupInterval time.Duration RepeatInterval time.Duration + + // A list of time intervals for which the route is muted. + MuteTimeIntervals []string } func (ro *RouteOpts) String() string { diff --git a/docs/configuration.md b/docs/configuration.md index 303de67c49..96cac65246 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -113,6 +113,10 @@ receivers: # A list of inhibition rules. inhibit_rules: [ - ... ] + +# A list of mute time intervals for muting routes. +mute_time_intervals: + [ - ... ] ``` ## `` @@ -168,6 +172,15 @@ match_re: # been sent successfully for an alert. (Usually ~3h or more). [ repeat_interval: | default = 4h ] +# Times when the route should be muted. These must match the name of a +# mute time interval defined in the mute_time_intervals section. +# Additionally, the root node cannot have any mute times. +# When a route is muted it will not send any notifications, but +# otherwise acts normally (including ending the route-matching process +# if the `continue` option is not set.) +mute_time_intervals: + [ - ...] + # Zero or more child routes. routes: [ - ... ] @@ -202,6 +215,67 @@ route: team: frontend ``` +## `` + +A `mute_time_interval` specifies a named interval of time that may be referenced +in the routing tree to mute particular routes for particular times of the day. + +```yaml +name: +time_intervals: + [ - ... ] +``` +## `` +A `time_interval` contains the actual definition for an interval of time. The syntax +supports the following fields: + +```yaml +- times: + [ - ...] + weekdays: + [ - ...] + days_of_month: + [ - ...] + months: + [ - ...] + years: + [ - ...] +``` + +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. + +`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. +For example, start_time: '17:00' and end_time: '24:00' will begin at 17:00 and finish +immediately before 24:00. They are specified like so: + + times: + - start_time: HH:MM + end_time: HH:MM + +`weeekday_range`: A list of days of the week, where the week begins on Sunday and ends on Saturday. +Days should be specified by name (e.g. ‘Sunday’). For convenience, ranges are also accepted +of the form : and are inclusive on both ends. For example: +`[‘monday:wednesday','saturday', 'sunday']` + +`days_of_month_ramge`: A list of numerical days in the month. Days begin at 1. +Negative values are also accepted which begin at the end of the month, +e.g. -1 during January would represent January 31. For example: `['1:5', '-3:-1']`. +Extending past the start or end of the month will cause it to be clamped. E.g. specifying +`['1:31']` during February will clamp the actual end date to 28 or 29 depending on leap years. +Inclusive on both ends. + +`month_range`: A list of calendar months identified by a case-insentive name (e.g. ‘January’) or by number, +where January = 1. Ranges are also accepted. For example, `['1:3', 'may:august', 'december']`. +Inclusive on both ends. + +`year_range`: A numerical list of years. Ranges are accepted. For example, `['2020:2022', '2030']`. +Inclusive on both ends. + ## `` An inhibition rule mutes an alert (target) matching a set of matchers diff --git a/notify/notify.go b/notify/notify.go index 0be8b488be..3a75886760 100644 --- a/notify/notify.go +++ b/notify/notify.go @@ -33,6 +33,7 @@ import ( "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/silence" + "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/types" ) @@ -108,6 +109,7 @@ const ( keyFiringAlerts keyResolvedAlerts keyNow + keyMuteTimeIntervals ) // WithReceiverName populates a context with a receiver name. @@ -145,6 +147,11 @@ func WithRepeatInterval(ctx context.Context, t time.Duration) context.Context { return context.WithValue(ctx, keyRepeatInterval, t) } +// WithMuteTimeIntervals populates a context with a slice of mute time names. +func WithMuteTimeIntervals(ctx context.Context, mt []string) context.Context { + return context.WithValue(ctx, keyMuteTimeIntervals, mt) +} + // RepeatInterval extracts a repeat interval from the context. Iff none exists, the // second argument is false. func RepeatInterval(ctx context.Context) (time.Duration, bool) { @@ -194,6 +201,13 @@ func ResolvedAlerts(ctx context.Context) ([]uint64, bool) { return v, ok } +// MuteTimeIntervalNames extracts a slice of mute time names from the context. Iff none exists, the +// second argument is false. +func MuteTimeIntervalNames(ctx context.Context) ([]string, bool) { + v, ok := ctx.Value(keyMuteTimeIntervals).([]string) + return v, ok +} + // A Stage processes alerts under the constraints of the given context. type Stage interface { Exec(ctx context.Context, l log.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) @@ -289,6 +303,7 @@ func (pb *PipelineBuilder) New( wait func() time.Duration, inhibitor *inhibit.Inhibitor, silencer *silence.Silencer, + muteTimes map[string][]timeinterval.TimeInterval, notificationLog NotificationLog, peer *cluster.Peer, ) RoutingStage { @@ -297,10 +312,11 @@ func (pb *PipelineBuilder) New( ms := NewGossipSettleStage(peer) is := NewMuteStage(inhibitor) ss := NewMuteStage(silencer) + tms := NewTimeMuteStage(muteTimes) for name := range receivers { st := createReceiverStage(name, receivers[name], wait, notificationLog, pb.metrics) - rs[name] = MultiStage{ms, is, ss, st} + rs[name] = MultiStage{ms, is, tms, ss, st} } return rs } @@ -755,3 +771,45 @@ func (n SetNotifiesStage) Exec(ctx context.Context, l log.Logger, alerts ...*typ return ctx, alerts, n.nflog.Log(n.recv, gkey, firing, resolved) } + +type TimeMuteStage struct { + muteTimes map[string][]timeinterval.TimeInterval +} + +func NewTimeMuteStage(mt map[string][]timeinterval.TimeInterval) *TimeMuteStage { + return &TimeMuteStage{mt} +} + +// Exec implements the stage interface for TimeMuteStage. +// TimeMuteStage is responsible for muting alerts whose route is not in an active time. +func (tms TimeMuteStage) Exec(ctx context.Context, l log.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { + muteTimeIntervalNames, ok := MuteTimeIntervalNames(ctx) + if !ok { + return ctx, alerts, nil + } + now, ok := Now(ctx) + if !ok { + return ctx, alerts, errors.New("missing now timestamp") + } + + muted := false +Loop: + for _, mtName := range muteTimeIntervalNames { + mt, ok := tms.muteTimes[mtName] + if !ok { + return ctx, alerts, errors.Errorf("mute time %s doesn't exist in config", mtName) + } + for _, ti := range mt { + if ti.ContainsTime(now) { + muted = true + break Loop + } + } + } + // If the current time is inside a mute time, all alerts are removed from the pipeline. + if muted { + level.Debug(l).Log("msg", "Notifications not sent, route is within mute time") + return ctx, nil, nil + } + return ctx, alerts, nil +} diff --git a/notify/notify_test.go b/notify/notify_test.go index d286ef0a78..94e1a14023 100644 --- a/notify/notify_test.go +++ b/notify/notify_test.go @@ -26,11 +26,13 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/silence/silencepb" + "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/types" ) @@ -719,3 +721,86 @@ func TestMuteStageWithSilences(t *testing.T) { t.Fatalf("Unmuting failed, expected: %v\ngot %v", in, got) } } + +func TestTimeMuteStage(t *testing.T) { + // Route mutes alerts outside business hours. + muteIn := ` +--- +- weekdays: ['monday:friday'] + times: + - start_time: '00:00' + end_time: '09:00' + - start_time: '17:00' + end_time: '24:00' +- weekdays: ['saturday', 'sunday']` + + cases := []struct { + fireTime string + labels model.LabelSet + shouldMute bool + }{ + { + // Friday during business hours + fireTime: "01 Jan 21 09:00 GMT", + labels: model.LabelSet{"foo": "bar"}, + shouldMute: false, + }, + { + // Tuesday before 5pm + fireTime: "01 Dec 20 16:59 GMT", + labels: model.LabelSet{"dont": "mute"}, + shouldMute: false, + }, + { + // Saturday + fireTime: "17 Oct 20 10:00 GMT", + labels: model.LabelSet{"mute": "me"}, + shouldMute: true, + }, + { + // Wednesday before 9am + fireTime: "14 Oct 20 05:00 GMT", + labels: model.LabelSet{"mute": "me"}, + shouldMute: true, + }, + } + var intervals []timeinterval.TimeInterval + err := yaml.Unmarshal([]byte(muteIn), &intervals) + if err != nil { + t.Fatalf("Couldn't unmarshal time interval %s", err) + } + m := map[string][]timeinterval.TimeInterval{"test": intervals} + stage := NewTimeMuteStage(m) + + outAlerts := []*types.Alert{} + nonMuteCount := 0 + for _, tc := range cases { + now, err := time.Parse(time.RFC822, tc.fireTime) + if err != nil { + t.Fatalf("Couldn't parse fire time %s %s", tc.fireTime, err) + } + // Count alerts with shouldMute == false and compare to ensure none are muted incorrectly + if !tc.shouldMute { + nonMuteCount++ + } + a := model.Alert{Labels: tc.labels} + alerts := []*types.Alert{{Alert: a}} + ctx := context.Background() + ctx = WithNow(ctx, now) + ctx = WithMuteTimeIntervals(ctx, []string{"test"}) + + _, out, err := stage.Exec(ctx, log.NewNopLogger(), alerts...) + if err != nil { + t.Fatalf("Unexpected error in time mute stage %s", err) + } + outAlerts = append(outAlerts, out...) + } + for _, alert := range outAlerts { + if _, ok := alert.Alert.Labels["mute"]; ok { + t.Fatalf("Expected alert to be muted %+v", alert.Alert) + } + } + if len(outAlerts) != nonMuteCount { + t.Fatalf("Expected %d alerts after time mute stage but got %d", nonMuteCount, len(outAlerts)) + } +} diff --git a/timeinterval/timeinterval.go b/timeinterval/timeinterval.go new file mode 100644 index 0000000000..24c4a4ea03 --- /dev/null +++ b/timeinterval/timeinterval.go @@ -0,0 +1,542 @@ +// 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" + "regexp" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v2" +) + +// 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"` +} + +// 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 +} + +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 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-seperated 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 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 +} + +// TimeLayout specifies the layout to be used in time.Parse() calls for time intervals. +const TimeLayout = "15:04" + +var validTime string = "^((([01][0-9])|(2[0-3])):[0-5][0-9])$|(^24:00$)" +var validTimeRE *regexp.Regexp = 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.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 +} diff --git a/timeinterval/timeinterval_test.go b/timeinterval/timeinterval_test.go new file mode 100644 index 0000000000..47b8e0b849 --- /dev/null +++ b/timeinterval/timeinterval_test.go @@ -0,0 +1,606 @@ +// 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" + "reflect" + "testing" + "time" + + "gopkg.in/yaml.v2" +) + +var timeIntervalTestCases = []struct { + validTimeStrings []string + invalidTimeStrings []string + timeInterval TimeInterval +}{ + { + timeInterval: TimeInterval{}, + validTimeStrings: []string{ + "02 Jan 06 15:04 MST", + "03 Jan 07 10:04 MST", + "04 Jan 06 09:04 MST", + }, + invalidTimeStrings: []string{}, + }, + { + // 9am to 5pm, monday to friday + timeInterval: TimeInterval{ + Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, + 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", + }, + invalidTimeStrings: []string{ + "03 May 20 15:04 MST", + "04 May 20 08:59 MST", + "05 May 20 05:00 MST", + }, + }, + { + // Easter 2020 + timeInterval: TimeInterval{ + DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: 4, End: 6}}}, + Months: []MonthRange{{InclusiveRange{Begin: 4, End: 4}}}, + 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", + }, + 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", + }, + }, + { + // Check negative days of month, last 3 days of each month + timeInterval: TimeInterval{ + 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", + }, + 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", + }, + }, + { + // Check out of bound days are clamped to month boundaries + timeInterval: TimeInterval{ + Months: []MonthRange{{InclusiveRange{Begin: 6, End: 6}}}, + DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: -31, End: 31}}}, + }, + validTimeStrings: []string{ + "30 Jun 20 00:00 MST", + "01 Jun 20 00:00 MST", + }, + invalidTimeStrings: []string{ + "31 May 20 00:00 MST", + "1 Jul 20 00:00 MST", + }, + }, +} + +var timeStringTestCases = []struct { + timeString string + TimeRange TimeRange + expectError bool +}{ + { + timeString: "{'start_time': '00:00', 'end_time': '24:00'}", + TimeRange: TimeRange{StartMinute: 0, EndMinute: 1440}, + expectError: false, + }, + { + timeString: "{'start_time': '01:35', 'end_time': '17:39'}", + TimeRange: TimeRange{StartMinute: 95, EndMinute: 1059}, + expectError: false, + }, + { + timeString: "{'start_time': '09:35', 'end_time': '09:39'}", + TimeRange: TimeRange{StartMinute: 575, EndMinute: 579}, + expectError: false, + }, + { + // Error: Begin and End times are the same + timeString: "{'start_time': '17:31', 'end_time': '17:31'}", + TimeRange: TimeRange{}, + expectError: true, + }, + { + // Error: End time out of range + timeString: "{'start_time': '12:30', 'end_time': '24:01'}", + TimeRange: TimeRange{}, + expectError: true, + }, + { + // Error: Start time greater than End time + timeString: "{'start_time': '09:30', 'end_time': '07:41'}", + TimeRange: TimeRange{}, + expectError: true, + }, + { + // Error: Start time out of range and greater than End time + timeString: "{'start_time': '24:00', 'end_time': '17:41'}", + TimeRange: TimeRange{}, + expectError: true, + }, + { + // Error: No range specified + timeString: "{'start_time': '14:03'}", + TimeRange: TimeRange{}, + expectError: true, + }, +} + +var yamlUnmarshalTestCases = []struct { + in string + intervals []TimeInterval + contains []string + excludes []string + expectError bool + err string +}{ + { + // Simple business hours test + in: ` +--- +- weekdays: ['monday:friday'] + times: + - start_time: '09:00' + end_time: '17:00' +`, + intervals: []TimeInterval{ + { + Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, + Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, + }, + }, + contains: []string{ + "08 Jul 20 09:00 MST", + "08 Jul 20 16:59 MST", + }, + excludes: []string{ + "08 Jul 20 05:00 MST", + "08 Jul 20 08:59 MST", + }, + expectError: false, + }, + { + // More advanced test with negative indices and ranges + in: ` +--- + # Last week, excluding Saturday, of the first quarter of the year during business hours from 2020 to 2025 and 2030-2035 +- weekdays: ['monday:friday', 'sunday'] + months: ['january:march'] + days_of_month: ['-7:-1'] + years: ['2020:2025', '2030:2035'] + times: + - start_time: '09:00' + end_time: '17:00' +`, + intervals: []TimeInterval{ + { + Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}, {InclusiveRange{Begin: 0, End: 0}}}, + Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, + Months: []MonthRange{{InclusiveRange{1, 3}}}, + DaysOfMonth: []DayOfMonthRange{{InclusiveRange{-7, -1}}}, + Years: []YearRange{{InclusiveRange{2020, 2025}}, {InclusiveRange{2030, 2035}}}, + }, + }, + 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", + }, + 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 + }, + expectError: false, + }, + { + in: ` +--- +- weekdays: ['monday:friday'] + times: + - start_time: '09:00' + end_time: '17:00'`, + intervals: []TimeInterval{ + { + Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, + Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, + }, + }, + contains: []string{ + "01 Apr 21 13:00 GMT", + }, + }, + { + // Invalid start time. + in: ` +--- +- times: + - start_time: '01:99' + end_time: '23:59'`, + expectError: true, + err: "couldn't parse timestamp 01:99, invalid format", + }, + { + // Invalid end time. + in: ` +--- +- times: + - start_time: '00:00' + end_time: '99:99'`, + expectError: true, + err: "couldn't parse timestamp 99:99, invalid format", + }, + { + // Start day before end day. + in: ` +--- +- weekdays: ['friday:monday']`, + expectError: true, + err: "start day cannot be before end day", + }, + { + // Invalid weekdays. + in: ` +--- +- weekdays: ['blurgsday:flurgsday'] +`, + expectError: true, + err: "blurgsday is not a valid weekday", + }, + { + // Numeric weekdays aren't allowed. + in: ` +--- +- weekdays: ['1:3'] +`, + expectError: true, + err: "1 is not a valid weekday", + }, + { + // Negative numeric weekdays aren't allowed. + in: ` +--- +- weekdays: ['-2:-1'] +`, + expectError: true, + err: "-2 is not a valid weekday", + }, + { + // 0 day of month. + in: ` +--- +- days_of_month: ['0'] +`, + expectError: true, + err: "0 is not a valid day of the month: out of range", + }, + { + // Start day of month < 0. + in: ` +--- +- days_of_month: ['-50:-20'] +`, + expectError: true, + err: "-50 is not a valid day of the month: out of range", + }, + { + // End day of month > 31. + in: ` +--- +- days_of_month: ['1:50'] +`, + expectError: true, + err: "50 is not a valid day of the month: out of range", + }, + { + // Negative indices should work. + in: ` +--- +- days_of_month: ['1:-1'] +`, + intervals: []TimeInterval{ + { + DaysOfMonth: []DayOfMonthRange{{InclusiveRange{1, -1}}}, + }, + }, + expectError: false, + }, + { + // End day must be negative if begin day is negative. + in: ` +--- +- days_of_month: ['-15:5'] +`, + expectError: true, + err: "end day must be negative if start day is negative", + }, + { + // Negative end date before positive postive start date. + in: ` +--- +- days_of_month: ['10:-25'] +`, + expectError: true, + err: "end day -25 is always before start day 10", + }, + { + // Months should work regardless of case + in: ` +--- +- months: ['January:december'] +`, + expectError: false, + intervals: []TimeInterval{ + { + Months: []MonthRange{{InclusiveRange{1, 12}}}, + }, + }, + }, + { + // Invalid start month. + in: ` +--- +- months: ['martius:june'] +`, + expectError: true, + err: "martius is not a valid month", + }, + { + // Invalid end month. + in: ` +--- +- months: ['march:junius'] +`, + expectError: true, + err: "junius is not a valid month", + }, + { + // Start month after end month. + in: ` +--- +- months: ['december:january'] +`, + expectError: true, + err: "end month january is before start month december", + }, + { + // Start year after end year. + in: ` +--- +- years: ['2022:2020'] +`, + expectError: true, + err: "end year 2020 is before start year 2022", + }, +} + +func TestYamlUnmarshal(t *testing.T) { + for _, tc := range yamlUnmarshalTestCases { + var ti []TimeInterval + err := yaml.Unmarshal([]byte(tc.in), &ti) + if err != nil && !tc.expectError { + t.Errorf("Received unexpected error: %v when parsing %v", err, tc.in) + } else if err == nil && tc.expectError { + t.Errorf("Expected error when unmarshalling %s but didn't receive one", tc.in) + } else if err != nil && tc.expectError { + if err.Error() != tc.err { + t.Errorf("Incorrect error: Want %s, got %s", tc.err, err.Error()) + } + continue + } + if !reflect.DeepEqual(ti, tc.intervals) { + 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) + isContained := false + for _, interval := range ti { + if interval.ContainsTime(_t) { + isContained = true + } + } + if !isContained { + t.Errorf("Expected intervals to contain time %s", _t) + } + } + for _, ts := range tc.excludes { + _t, _ := time.Parse(time.RFC822, ts) + isContained := false + for _, interval := range ti { + if interval.ContainsTime(_t) { + isContained = true + } + } + if isContained { + t.Errorf("Expected intervals to exclude time %s", _t) + } + } + } +} + +func TestContainsTime(t *testing.T) { + for _, tc := range timeIntervalTestCases { + for _, ts := range tc.validTimeStrings { + _t, _ := time.Parse(time.RFC822, 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) + if tc.timeInterval.ContainsTime(_t) { + t.Errorf("Period %+v not expected to contain %+v", tc.timeInterval, _t) + } + } + } +} + +func TestParseTimeString(t *testing.T) { + for _, tc := range timeStringTestCases { + var tr TimeRange + err := yaml.Unmarshal([]byte(tc.timeString), &tr) + if err != nil && !tc.expectError { + t.Errorf("Received unexpected error: %v when parsing %v", err, tc.timeString) + } else if err == nil && tc.expectError { + t.Errorf("Expected error for invalid string %s but didn't receive one", tc.timeString) + } else if !reflect.DeepEqual(tr, tc.TimeRange) { + t.Errorf("Error parsing time string %s: Want %+v, got %+v", tc.timeString, tc.TimeRange, tr) + } + } +} + +func TestYamlMarshal(t *testing.T) { + for _, tc := range yamlUnmarshalTestCases { + if tc.expectError { + continue + } + var ti []TimeInterval + err := yaml.Unmarshal([]byte(tc.in), &ti) + if err != nil { + t.Error(err) + } + out, err := yaml.Marshal(&ti) + if err != nil { + t.Error(err) + } + var ti2 []TimeInterval + yaml.Unmarshal(out, &ti2) + if !reflect.DeepEqual(ti, ti2) { + t.Errorf("Re-marshalling %s produced a different TimeInterval.", tc.in) + } + } +} + +// Test JSON marshalling by marshalling a time interval +// and then unmarshalling to ensure they're identical +func TestJsonMarshal(t *testing.T) { + for _, tc := range yamlUnmarshalTestCases { + if tc.expectError { + continue + } + var ti []TimeInterval + err := yaml.Unmarshal([]byte(tc.in), &ti) + if err != nil { + t.Error(err) + } + out, err := json.Marshal(&ti) + if err != nil { + t.Error(err) + } + var ti2 []TimeInterval + json.Unmarshal(out, &ti2) + if !reflect.DeepEqual(ti, ti2) { + t.Errorf("Re-marshalling %s produced a different TimeInterval. Used:\n%s and got:\n%v", tc.in, out, ti2) + } + } +} + +var completeTestCases = []struct { + in string + contains []string + excludes []string +}{ + { + in: ` +--- +weekdays: ['monday:wednesday', 'saturday', 'sunday'] +times: + - start_time: '13:00' + end_time: '15:00' +days_of_month: ['1', '10', '20:-1'] +years: ['2020:2023'] +months: ['january:march'] +`, + contains: []string{ + "10 Jan 21 13:00 GMT", + "30 Jan 21 14:24 GMT", + }, + excludes: []string{ + "09 Jan 21 13:00 GMT", + "20 Jan 21 12:59 GMT", + "02 Feb 21 13:00 GMT", + }, + }, + { + // Check for broken clamping (clamping begin date after end of month to the end of the month) + in: ` +--- +days_of_month: ['30:31'] +years: ['2020:2023'] +months: ['february'] +`, + excludes: []string{ + "28 Feb 21 13:00 GMT", + }, + }, +} + +// Tests the entire flow from unmarshalling to containing a time +func TestTimeIntervalComplete(t *testing.T) { + for _, tc := range completeTestCases { + var ti TimeInterval + if err := yaml.Unmarshal([]byte(tc.in), &ti); err != nil { + t.Error(err) + } + for _, ts := range tc.contains { + tt, err := time.Parse(time.RFC822, ts) + if err != nil { + t.Error(err) + } + if !ti.ContainsTime(tt) { + t.Errorf("Expected %s to contain %s", tc.in, ts) + } + } + for _, ts := range tc.excludes { + tt, err := time.Parse(time.RFC822, ts) + if err != nil { + t.Error(err) + } + if ti.ContainsTime(tt) { + t.Errorf("Expected %s to exclude %s", tc.in, ts) + } + } + } +}