@@ -3,8 +3,6 @@ package rules
33import (
44 "database/sql/driver"
55 "encoding/json"
6- "slices"
7- "strings"
86 "time"
97
108 "github.com/pkg/errors"
@@ -84,6 +82,16 @@ const (
8482 RepeatOnSaturday RepeatOn = "saturday"
8583)
8684
85+ var RepeatOnAllMap = map [RepeatOn ]time.Weekday {
86+ RepeatOnSunday : time .Sunday ,
87+ RepeatOnMonday : time .Monday ,
88+ RepeatOnTuesday : time .Tuesday ,
89+ RepeatOnWednesday : time .Wednesday ,
90+ RepeatOnThursday : time .Thursday ,
91+ RepeatOnFriday : time .Friday ,
92+ RepeatOnSaturday : time .Saturday ,
93+ }
94+
8795type Recurrence struct {
8896 StartTime time.Time `json:"startTime"`
8997 EndTime * time.Time `json:"endTime,omitempty"`
@@ -211,7 +219,7 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
211219}
212220
213221func (m * PlannedMaintenance ) shouldSkip (ruleID string , now time.Time ) bool {
214-
222+ // Check if the alert ID is in the maintenance window
215223 found := false
216224 if m .AlertIds != nil {
217225 for _ , alertID := range * m .AlertIds {
@@ -227,97 +235,162 @@ func (m *PlannedMaintenance) shouldSkip(ruleID string, now time.Time) bool {
227235 found = true
228236 }
229237
230- if found {
238+ if ! found {
239+ return false
240+ }
231241
232- zap .L ().Info ("alert found in maintenance" , zap .String ("alert" , ruleID ), zap .Any ("maintenance" , m .Name ))
242+ zap .L ().Info ("alert found in maintenance" , zap .String ("alert" , ruleID ), zap .String ("maintenance" , m .Name ))
233243
234- // If alert is found, we check if it should be skipped based on the schedule
235- // If it should be skipped, we return true
236- // If it should not be skipped, we return false
244+ // If alert is found, we check if it should be skipped based on the schedule
245+ loc , err := time .LoadLocation (m .Schedule .Timezone )
246+ if err != nil {
247+ zap .L ().Error ("Error loading location" , zap .String ("timezone" , m .Schedule .Timezone ), zap .Error (err ))
248+ return false
249+ }
237250
238- // fixed schedule
239- if ! m .Schedule .StartTime .IsZero () && ! m .Schedule .EndTime .IsZero () {
240- // if the current time in the timezone is between the start and end time
241- loc , err := time .LoadLocation (m .Schedule .Timezone )
242- if err != nil {
243- zap .L ().Error ("Error loading location" , zap .String ("timezone" , m .Schedule .Timezone ), zap .Error (err ))
244- return false
245- }
251+ currentTime := now .In (loc )
246252
247- currentTime := now .In (loc )
248- zap .L ().Info ("checking fixed schedule" , zap .Any ("rule" , ruleID ), zap .String ("maintenance" , m .Name ), zap .Time ("currentTime" , currentTime ), zap .Time ("startTime" , m .Schedule .StartTime ), zap .Time ("endTime" , m .Schedule .EndTime ))
249- if currentTime .After (m .Schedule .StartTime ) && currentTime .Before (m .Schedule .EndTime ) {
250- return true
251- }
253+ // fixed schedule
254+ if ! m .Schedule .StartTime .IsZero () && ! m .Schedule .EndTime .IsZero () {
255+ zap .L ().Info ("checking fixed schedule" ,
256+ zap .String ("rule" , ruleID ),
257+ zap .String ("maintenance" , m .Name ),
258+ zap .Time ("currentTime" , currentTime ),
259+ zap .Time ("startTime" , m .Schedule .StartTime ),
260+ zap .Time ("endTime" , m .Schedule .EndTime ))
261+
262+ startTime := m .Schedule .StartTime .In (loc )
263+ endTime := m .Schedule .EndTime .In (loc )
264+ if currentTime .Equal (startTime ) || currentTime .Equal (endTime ) ||
265+ (currentTime .After (startTime ) && currentTime .Before (endTime )) {
266+ return true
252267 }
268+ }
253269
254- // recurring schedule
255- if m .Schedule .Recurrence != nil {
256- zap .L ().Info ("evaluating recurrence schedule" )
257- start := m .Schedule .Recurrence .StartTime
258- end := m .Schedule .Recurrence .StartTime .Add (time .Duration (m .Schedule .Recurrence .Duration ))
259- // if the current time in the timezone is between the start and end time
260- loc , err := time .LoadLocation (m .Schedule .Timezone )
261- if err != nil {
262- zap .L ().Error ("Error loading location" , zap .String ("timezone" , m .Schedule .Timezone ), zap .Error (err ))
270+ // recurring schedule
271+ if m .Schedule .Recurrence != nil {
272+ start := m .Schedule .Recurrence .StartTime
273+ duration := time .Duration (m .Schedule .Recurrence .Duration )
274+
275+ zap .L ().Info ("checking recurring schedule base info" ,
276+ zap .String ("rule" , ruleID ),
277+ zap .String ("maintenance" , m .Name ),
278+ zap .Time ("startTime" , start ),
279+ zap .Duration ("duration" , duration ))
280+
281+ // Make sure the recurrence has started
282+ if currentTime .Before (start .In (loc )) {
283+ zap .L ().Info ("current time is before recurrence start time" ,
284+ zap .String ("rule" , ruleID ),
285+ zap .String ("maintenance" , m .Name ))
286+ return false
287+ }
288+
289+ // Check if recurrence has expired
290+ if m .Schedule .Recurrence .EndTime != nil {
291+ endTime := * m .Schedule .Recurrence .EndTime
292+ if ! endTime .IsZero () && currentTime .After (endTime .In (loc )) {
293+ zap .L ().Info ("current time is after recurrence end time" ,
294+ zap .String ("rule" , ruleID ),
295+ zap .String ("maintenance" , m .Name ))
263296 return false
264297 }
265- currentTime := now . In ( loc )
298+ }
266299
267- zap .L ().Info ("checking recurring schedule" , zap .Any ("rule" , ruleID ), zap .String ("maintenance" , m .Name ), zap .Time ("currentTime" , currentTime ), zap .Time ("startTime" , start ), zap .Time ("endTime" , end ))
300+ switch m .Schedule .Recurrence .RepeatType {
301+ case RepeatTypeDaily :
302+ return m .checkDaily (currentTime , m .Schedule .Recurrence , loc )
303+ case RepeatTypeWeekly :
304+ return m .checkWeekly (currentTime , m .Schedule .Recurrence , loc )
305+ case RepeatTypeMonthly :
306+ return m .checkMonthly (currentTime , m .Schedule .Recurrence , loc )
307+ }
308+ }
268309
269- // make sure the start time is not after the current time
270- if currentTime .Before (start .In (loc )) {
271- zap .L ().Info ("current time is before start time" , zap .Any ("rule" , ruleID ), zap .String ("maintenance" , m .Name ), zap .Time ("currentTime" , currentTime ), zap .Time ("startTime" , start .In (loc )))
272- return false
273- }
310+ return false
311+ }
274312
275- var endTime time.Time
276- if m .Schedule .Recurrence .EndTime != nil {
277- endTime = * m .Schedule .Recurrence .EndTime
278- }
279- if ! endTime .IsZero () && currentTime .After (endTime .In (loc )) {
280- zap .L ().Info ("current time is after end time" , zap .Any ("rule" , ruleID ), zap .String ("maintenance" , m .Name ), zap .Time ("currentTime" , currentTime ), zap .Time ("endTime" , end .In (loc )))
281- return false
282- }
313+ // checkDaily rebases the recurrence start to today (or yesterday if needed)
314+ // and returns true if currentTime is within [candidate, candidate+Duration].
315+ func (m * PlannedMaintenance ) checkDaily (currentTime time.Time , rec * Recurrence , loc * time.Location ) bool {
316+ candidate := time .Date (
317+ currentTime .Year (), currentTime .Month (), currentTime .Day (),
318+ rec .StartTime .Hour (), rec .StartTime .Minute (), 0 , 0 ,
319+ loc ,
320+ )
321+ if candidate .After (currentTime ) {
322+ candidate = candidate .AddDate (0 , 0 , - 1 )
323+ }
324+ return currentTime .Sub (candidate ) <= time .Duration (rec .Duration )
325+ }
283326
284- switch m .Schedule .Recurrence .RepeatType {
285- case RepeatTypeDaily :
286- // take the hours and minutes from the start time and add them to the current time
287- startTime := time .Date (currentTime .Year (), currentTime .Month (), currentTime .Day (), start .Hour (), start .Minute (), 0 , 0 , loc )
288- endTime := time .Date (currentTime .Year (), currentTime .Month (), currentTime .Day (), end .Hour (), end .Minute (), 0 , 0 , loc )
289- zap .L ().Info ("checking daily schedule" , zap .Any ("rule" , ruleID ), zap .String ("maintenance" , m .Name ), zap .Time ("currentTime" , currentTime ), zap .Time ("startTime" , startTime ), zap .Time ("endTime" , endTime ))
290-
291- if currentTime .After (startTime ) && currentTime .Before (endTime ) {
292- return true
293- }
294- case RepeatTypeWeekly :
295- // if the current time in the timezone is between the start and end time on the RepeatOn day
296- startTime := time .Date (currentTime .Year (), currentTime .Month (), currentTime .Day (), start .Hour (), start .Minute (), 0 , 0 , loc )
297- endTime := time .Date (currentTime .Year (), currentTime .Month (), currentTime .Day (), end .Hour (), end .Minute (), 0 , 0 , loc )
298- zap .L ().Info ("checking weekly schedule" , zap .Any ("rule" , ruleID ), zap .String ("maintenance" , m .Name ), zap .Time ("currentTime" , currentTime ), zap .Time ("startTime" , startTime ), zap .Time ("endTime" , endTime ))
299- if currentTime .After (startTime ) && currentTime .Before (endTime ) {
300- if len (m .Schedule .Recurrence .RepeatOn ) == 0 {
301- return true
302- } else if slices .Contains (m .Schedule .Recurrence .RepeatOn , RepeatOn (strings .ToLower (currentTime .Weekday ().String ()))) {
303- return true
304- }
305- }
306- case RepeatTypeMonthly :
307- // if the current time in the timezone is between the start and end time on the day of the current month
308- startTime := time .Date (currentTime .Year (), currentTime .Month (), start .Day (), start .Hour (), start .Minute (), 0 , 0 , loc )
309- endTime := time .Date (currentTime .Year (), currentTime .Month (), end .Day (), end .Hour (), end .Minute (), 0 , 0 , loc )
310- zap .L ().Info ("checking monthly schedule" , zap .Any ("rule" , ruleID ), zap .String ("maintenance" , m .Name ), zap .Time ("currentTime" , currentTime ), zap .Time ("startTime" , startTime ), zap .Time ("endTime" , endTime ))
311- if currentTime .After (startTime ) && currentTime .Before (endTime ) && currentTime .Day () == start .Day () {
312- return true
313- }
314- }
327+ // checkWeekly finds the most recent allowed occurrence by rebasing the recurrence’s
328+ // time-of-day onto the allowed weekday. It does this for each allowed day and returns true
329+ // if the current time falls within the candidate window.
330+ func (m * PlannedMaintenance ) checkWeekly (currentTime time.Time , rec * Recurrence , loc * time.Location ) bool {
331+ // If no days specified, treat as every day (like daily).
332+ if len (rec .RepeatOn ) == 0 {
333+ return m .checkDaily (currentTime , rec , loc )
334+ }
335+
336+ for _ , day := range rec .RepeatOn {
337+ allowedDay , ok := RepeatOnAllMap [day ]
338+ if ! ok {
339+ continue // skip invalid days
340+ }
341+ // Compute the day difference: allowedDay - current weekday.
342+ delta := int (allowedDay ) - int (currentTime .Weekday ())
343+ // Build a candidate occurrence by rebasing today's date to the allowed weekday.
344+ candidate := time .Date (
345+ currentTime .Year (), currentTime .Month (), currentTime .Day (),
346+ rec .StartTime .Hour (), rec .StartTime .Minute (), 0 , 0 ,
347+ loc ,
348+ ).AddDate (0 , 0 , delta )
349+ // If the candidate is in the future, subtract 7 days.
350+ if candidate .After (currentTime ) {
351+ candidate = candidate .AddDate (0 , 0 , - 7 )
352+ }
353+ if currentTime .Sub (candidate ) <= time .Duration (rec .Duration ) {
354+ return true
315355 }
316356 }
317- // If alert is not found, we return false
318357 return false
319358}
320359
360+ // checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
361+ // If the candidate for the current month is in the future, it uses the previous month.
362+ func (m * PlannedMaintenance ) checkMonthly (currentTime time.Time , rec * Recurrence , loc * time.Location ) bool {
363+ refDay := rec .StartTime .Day ()
364+ year , month , _ := currentTime .Date ()
365+ lastDay := time .Date (year , month + 1 , 0 , 0 , 0 , 0 , 0 , loc ).Day ()
366+ day := refDay
367+ if refDay > lastDay {
368+ day = lastDay
369+ }
370+ candidate := time .Date (year , month , day ,
371+ rec .StartTime .Hour (), rec .StartTime .Minute (), rec .StartTime .Second (), rec .StartTime .Nanosecond (),
372+ loc ,
373+ )
374+ if candidate .After (currentTime ) {
375+ // Use previous month.
376+ candidate = candidate .AddDate (0 , - 1 , 0 )
377+ y , m , _ := candidate .Date ()
378+ lastDayPrev := time .Date (y , m + 1 , 0 , 0 , 0 , 0 , 0 , loc ).Day ()
379+ if refDay > lastDayPrev {
380+ candidate = time .Date (y , m , lastDayPrev ,
381+ rec .StartTime .Hour (), rec .StartTime .Minute (), rec .StartTime .Second (), rec .StartTime .Nanosecond (),
382+ loc ,
383+ )
384+ } else {
385+ candidate = time .Date (y , m , refDay ,
386+ rec .StartTime .Hour (), rec .StartTime .Minute (), rec .StartTime .Second (), rec .StartTime .Nanosecond (),
387+ loc ,
388+ )
389+ }
390+ }
391+ return currentTime .Sub (candidate ) <= time .Duration (rec .Duration )
392+ }
393+
321394func (m * PlannedMaintenance ) IsActive (now time.Time ) bool {
322395 ruleID := "maintenance"
323396 if m .AlertIds != nil && len (* m .AlertIds ) > 0 {
@@ -327,7 +400,14 @@ func (m *PlannedMaintenance) IsActive(now time.Time) bool {
327400}
328401
329402func (m * PlannedMaintenance ) IsUpcoming () bool {
330- now := time .Now ().In (time .FixedZone (m .Schedule .Timezone , 0 ))
403+ loc , err := time .LoadLocation (m .Schedule .Timezone )
404+ if err != nil {
405+ // handle error appropriately, for example log and return false or fallback to UTC
406+ zap .L ().Error ("Error loading timezone" , zap .String ("timezone" , m .Schedule .Timezone ), zap .Error (err ))
407+ return false
408+ }
409+ now := time .Now ().In (loc )
410+
331411 if ! m .Schedule .StartTime .IsZero () && ! m .Schedule .EndTime .IsZero () {
332412 return now .Before (m .Schedule .StartTime )
333413 }
0 commit comments