github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/job/periodic_parser.go (about)

     1  package job
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"hash/crc64"
     7  	"math/rand"
     8  	"strconv"
     9  	"strings"
    10  )
    11  
    12  // PeriodicParser can be used to parse @weekly and @monthly trigger arguments.
    13  // It can parse a string like "on monday between 8am and 6pm".
    14  type PeriodicParser struct{}
    15  
    16  // PeriodicSpec is the result of a successful parsing
    17  type PeriodicSpec struct {
    18  	Frequency   FrequencyKind
    19  	DaysOfMonth []int // empty for *, or a slice of acceptable days (1 to 31)
    20  	DaysOfWeek  []int // a slice of acceptable days, from 0 for sunday to 6 for saturday
    21  	AfterHour   int   // an hour between 0 and 23
    22  	BeforeHour  int   // an hour between 1 and 24
    23  }
    24  
    25  // FrequencyKind is used to tell if a periodic trigger is weekly or monthly.
    26  type FrequencyKind int
    27  
    28  const (
    29  	MonthlyKind FrequencyKind = iota
    30  	WeeklyKind
    31  	DailyKind
    32  	HourlyKind
    33  )
    34  
    35  // NewPeriodicParser creates a PeriodicParser.
    36  func NewPeriodicParser() PeriodicParser {
    37  	return PeriodicParser{}
    38  }
    39  
    40  func NewPeriodicSpec() *PeriodicSpec {
    41  	return &PeriodicSpec{
    42  		DaysOfMonth: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28},
    43  		DaysOfWeek:  []int{0, 1, 2, 3, 4, 5, 6},
    44  		AfterHour:   0,
    45  		BeforeHour:  24,
    46  	}
    47  }
    48  
    49  // Parse will transform a string like "on monday" to a PeriodicSpec, or will
    50  // return an error if the format is not supported.
    51  func (p *PeriodicParser) Parse(frequency FrequencyKind, periodic string) (*PeriodicSpec, error) {
    52  	fields := strings.Fields(periodic)
    53  	spec := NewPeriodicSpec()
    54  	spec.Frequency = frequency
    55  
    56  	for len(fields) > 0 {
    57  		switch fields[0] {
    58  		case "on":
    59  			if len(fields) == 1 {
    60  				return nil, errors.New("expecting a day after 'on' keyword")
    61  			}
    62  			if fields[1] == "the" {
    63  				if frequency != MonthlyKind {
    64  					return nil, errors.New("day if month is only available for monthly")
    65  				}
    66  				if len(fields) == 2 {
    67  					return nil, errors.New("expecting a day after 'on the' keywords")
    68  				}
    69  				dom, err := p.parseDaysOfMonth(fields[2])
    70  				if err != nil {
    71  					return nil, err
    72  				}
    73  				spec.DaysOfMonth = dom
    74  				fields = fields[3:]
    75  			} else {
    76  				if frequency != WeeklyKind {
    77  					return nil, errors.New("day of week is only available for weekly")
    78  				}
    79  				dow, err := p.parseDaysOfWeek(fields[1])
    80  				if err != nil {
    81  					return nil, err
    82  				}
    83  				spec.DaysOfWeek = dow
    84  				fields = fields[2:]
    85  			}
    86  		case "before", "and":
    87  			if len(fields) == 1 {
    88  				return nil, fmt.Errorf("expecting an hour after '%s' keyword", fields[0])
    89  			}
    90  			hour, err := p.parseHour(fields[1])
    91  			if err != nil {
    92  				return nil, err
    93  			}
    94  			if hour == 0 {
    95  				hour = 24
    96  			}
    97  			spec.BeforeHour = hour
    98  			fields = fields[2:]
    99  		case "after", "between":
   100  			if len(fields) == 1 {
   101  				return nil, fmt.Errorf("expecting an hour after '%s' keyword", fields[0])
   102  			}
   103  			hour, err := p.parseHour(fields[1])
   104  			if err != nil {
   105  				return nil, err
   106  			}
   107  			spec.AfterHour = hour
   108  			fields = fields[2:]
   109  		default:
   110  			return nil, fmt.Errorf("invalid field %q", fields[0])
   111  		}
   112  	}
   113  
   114  	if spec.AfterHour >= spec.BeforeHour {
   115  		return nil, errors.New("invalid hours range")
   116  	}
   117  
   118  	return spec, nil
   119  }
   120  
   121  func (p *PeriodicParser) parseDaysOfMonth(field string) ([]int, error) {
   122  	var days []int
   123  	parts := strings.Split(field, ",")
   124  	for _, part := range parts {
   125  		if strings.Contains(part, "-") {
   126  			splitted := strings.SplitN(part, "-", 2)
   127  			from, err := p.parseDayOfMonth(splitted[0])
   128  			if err != nil {
   129  				return nil, err
   130  			}
   131  			to, err := p.parseDayOfMonth(splitted[1])
   132  			if err != nil {
   133  				return nil, err
   134  			}
   135  			if from >= to {
   136  				return nil, errors.New("invalid range")
   137  			}
   138  			for i := from; i <= to; i++ {
   139  				days = append(days, i)
   140  			}
   141  		} else {
   142  			dow, err := p.parseDayOfMonth(part)
   143  			if err != nil {
   144  				return nil, err
   145  			}
   146  			days = append(days, dow)
   147  		}
   148  	}
   149  	return days, nil
   150  }
   151  
   152  func (p *PeriodicParser) parseDayOfMonth(day string) (int, error) {
   153  	d, err := strconv.Atoi(day)
   154  	if err != nil {
   155  		return -1, err
   156  	}
   157  	if d <= 0 || d > 31 {
   158  		return -1, errors.New("invalid day")
   159  	}
   160  	return d, nil
   161  }
   162  
   163  func (p *PeriodicParser) parseDaysOfWeek(field string) ([]int, error) {
   164  	var days []int
   165  	parts := strings.Split(field, ",")
   166  	for _, part := range parts {
   167  		if strings.Contains(part, "-") {
   168  			splitted := strings.SplitN(part, "-", 2)
   169  			from, err := p.parseDayOfWeek(splitted[0])
   170  			if err != nil {
   171  				return nil, err
   172  			}
   173  			to, err := p.parseDayOfWeek(splitted[1])
   174  			if err != nil {
   175  				return nil, err
   176  			}
   177  			if from == to {
   178  				return nil, errors.New("invalid range")
   179  			} else if from > to {
   180  				to += 7
   181  			}
   182  			for i := from; i <= to; i++ {
   183  				days = append(days, i%7)
   184  			}
   185  		} else if part == "weekday" {
   186  			days = append(days, 1, 2, 3, 4, 5)
   187  		} else if part == "weekend" {
   188  			days = append(days, 0, 6)
   189  		} else {
   190  			dow, err := p.parseDayOfWeek(part)
   191  			if err != nil {
   192  				return nil, err
   193  			}
   194  			days = append(days, dow)
   195  		}
   196  	}
   197  	return days, nil
   198  }
   199  
   200  func (p *PeriodicParser) parseDayOfWeek(day string) (int, error) {
   201  	switch day {
   202  	case "sun", "sunday":
   203  		return 0, nil
   204  	case "mon", "monday":
   205  		return 1, nil
   206  	case "tue", "tuesday":
   207  		return 2, nil
   208  	case "wed", "wednesday":
   209  		return 3, nil
   210  	case "thu", "thursday":
   211  		return 4, nil
   212  	case "fri", "friday":
   213  		return 5, nil
   214  	case "sat", "saturday":
   215  		return 6, nil
   216  	default:
   217  		return -1, fmt.Errorf("cannot parse %q as a day", day)
   218  	}
   219  }
   220  
   221  func (p *PeriodicParser) parseHour(hour string) (int, error) {
   222  	if strings.HasSuffix(hour, "am") {
   223  		h, err := strconv.Atoi(strings.TrimSuffix(hour, "am"))
   224  		if err != nil {
   225  			return -1, err
   226  		}
   227  		if h <= 0 || h > 12 {
   228  			return -1, errors.New("invalid hour")
   229  		}
   230  		if h == 12 {
   231  			return 0, nil
   232  		}
   233  		return h, nil
   234  	}
   235  
   236  	if strings.HasSuffix(hour, "pm") {
   237  		h, err := strconv.Atoi(strings.TrimSuffix(hour, "pm"))
   238  		if err != nil {
   239  			return -1, err
   240  		}
   241  		if h <= 0 || h > 12 {
   242  			return -1, errors.New("invalid hour")
   243  		}
   244  		if h == 12 {
   245  			return 12, nil
   246  		}
   247  		return h + 12, nil
   248  	}
   249  
   250  	return -1, errors.New("invalid hour")
   251  }
   252  
   253  // ToRandomCrontab generates a crontab that verifies the PeriodicSpec.
   254  // The values are taken randomly, and the random generator uses the given
   255  // seed to allow stability for a trigger, ie a weekly trigger must always
   256  // run on the same day at the same hour.
   257  func (s *PeriodicSpec) ToRandomCrontab(seed string) string {
   258  	seed64 := crc64.Checksum([]byte(seed), crc64.MakeTable(crc64.ISO))
   259  	src := rand.NewSource(int64(seed64))
   260  	rnd := rand.New(src)
   261  
   262  	second := rnd.Intn(60)
   263  	minute := rnd.Intn(60)
   264  	hour := s.AfterHour + rnd.Intn(s.BeforeHour-s.AfterHour)
   265  
   266  	if s.Frequency == MonthlyKind {
   267  		dom := s.DaysOfMonth[rnd.Intn(len(s.DaysOfMonth))]
   268  		return fmt.Sprintf("%d %d %d %d * *", second, minute, hour, dom)
   269  	}
   270  
   271  	if s.Frequency == WeeklyKind {
   272  		dow := s.DaysOfWeek[rnd.Intn(len(s.DaysOfWeek))]
   273  		return fmt.Sprintf("%d %d %d * * %d", second, minute, hour, dow)
   274  	}
   275  
   276  	if s.Frequency == HourlyKind {
   277  		return fmt.Sprintf("%d %d * * * *", second, minute)
   278  	}
   279  
   280  	return fmt.Sprintf("%d %d %d * * *", second, minute, hour)
   281  }