Merge pull request #2393 from benridley/dev_time_interval

Add time-based muting to routing tree
This commit is contained in:
Björn Rabenstein 2021-03-01 17:42:54 +01:00 committed by GitHub
commit e66c8033ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1576 additions and 16 deletions

View File

@ -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,
)

View File

@ -31,6 +31,7 @@ import (
"gopkg.in/yaml.v2"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/alertmanager/timeinterval"
)
const secretToken = "<secret>"
@ -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"`

View File

@ -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:

View File

@ -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()

View File

@ -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 {

View File

@ -113,6 +113,10 @@ receivers:
# A list of inhibition rules.
inhibit_rules:
[ - <inhibit_rule> ... ]
# A list of mute time intervals for muting routes.
mute_time_intervals:
[ - <mute_time_interval> ... ]
```
## `<route>`
@ -168,6 +172,15 @@ match_re:
# been sent successfully for an alert. (Usually ~3h or more).
[ repeat_interval: <duration> | 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:
[ - <string> ...]
# Zero or more child routes.
routes:
[ - <route> ... ]
@ -202,6 +215,67 @@ route:
team: frontend
```
## `<mute_time_interval>`
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: <string>
time_intervals:
[ - <time_interval> ... ]
```
## `<time_interval>`
A `time_interval` contains the actual definition for an interval of time. The syntax
supports the following fields:
```yaml
- times:
[ - <time_range> ...]
weekdays:
[ - <weekday_range> ...]
days_of_month:
[ - <days_of_month_range> ...]
months:
[ - <month_range> ...]
years:
[ - <year_range> ...]
```
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 <start_day>:<end_day> 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.
## `<inhibit_rule>`
An inhibition rule mutes an alert (target) matching a set of matchers

View File

@ -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
}

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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)
}
}
}
}