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 }