mirror of
https://github.com/prometheus/alertmanager
synced 2025-02-16 18:47:10 +00:00
Merge pull request #2393 from benridley/dev_time_interval
Add time-based muting to routing tree
This commit is contained in:
commit
e66c8033ec
@ -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,
|
||||
)
|
||||
|
@ -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"`
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
542
timeinterval/timeinterval.go
Normal file
542
timeinterval/timeinterval.go
Normal 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
|
||||
}
|
606
timeinterval/timeinterval_test.go
Normal file
606
timeinterval/timeinterval_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user