1use std::time::Duration;
2
3use anyhow::bail;
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use crate::pretty_duration::PrettyDuration;
9
10use super::{EntityDescriptorConst, JobDefinition};
11
12pub type CronJobId = Uuid;
13
14#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, schemars::JsonSchema)]
16pub struct CronJobSpecV1 {
17 pub schedule: String,
23
24 pub max_schedule_drift: Option<PrettyDuration>,
30
31 #[serde(flatten)]
32 pub job: JobDefinition,
33}
34
35impl CronJobSpecV1 {
36 pub fn parse_schedule(&self) -> Result<CronSchedule, CronTabParseError> {
37 self.schedule.parse()
38 }
39}
40
41impl EntityDescriptorConst for CronJobSpecV1 {
42 const NAMESPACE: &'static str = "wasmer.io";
43 const NAME: &'static str = "CronJob";
44 const VERSION: &'static str = "v1-alpha1";
45 const KIND: &'static str = "wasmer.io/CronJob.v1-alpha1";
46 type Spec = Self;
47 type State = ();
48}
49
50#[derive(PartialEq, Eq, Clone, Debug)]
51pub enum CronSchedule {
52 Interval(std::time::Duration),
53 CronTab(CronTab),
54}
55
56impl CronSchedule {
57 pub fn next(
58 &self,
59 last: Option<time::OffsetDateTime>,
60 drift: Option<Duration>,
61 ) -> Result<OffsetDateTime, anyhow::Error> {
62 match self {
63 CronSchedule::Interval(duration) => {
64 if let Some(last) = last {
65 Ok(last + *duration)
66 } else {
67 Ok(OffsetDateTime::now_utc())
68 }
69 }
70 CronSchedule::CronTab(c) => c.next(last, drift),
71 }
72 }
73
74 pub fn max_timewindow(&self) -> Duration {
76 match self {
77 CronSchedule::Interval(duration) => *duration,
78 CronSchedule::CronTab(c) => c.max_timewindow(),
79 }
80 }
81}
82
83impl std::fmt::Display for CronSchedule {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 match self {
86 CronSchedule::Interval(d) => write!(f, "{}", PrettyDuration::from(*d)),
87 CronSchedule::CronTab(c) => c.fmt(f),
88 }
89 }
90}
91
92impl std::str::FromStr for CronSchedule {
93 type Err = CronTabParseError;
94
95 fn from_str(s: &str) -> Result<Self, Self::Err> {
96 match s.parse::<crate::pretty_duration::PrettyDuration>() {
97 Ok(d) => Ok(Self::Interval(d.0)),
98 Err(_) => match s.parse::<CronTab>() {
99 Ok(c) => Ok(Self::CronTab(c)),
100 Err(_) => Err(CronTabParseError::new(
101 s,
102 "invalid cron schedule - expected either an interval like '1m10s' or a valid crontab".to_string(),
103 )),
104 },
105 }
106 }
107}
108
109#[derive(PartialEq, Eq, Clone, Debug)]
110enum CronTabValue {
111 All,
112 Value(u8),
113}
114
115impl std::fmt::Display for CronTabValue {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 CronTabValue::All => write!(f, "*"),
119 CronTabValue::Value(x) => write!(f, "{x}"),
120 }
121 }
122}
123
124#[derive(PartialEq, Eq, Clone, Debug)]
138pub struct CronTab {
139 minute: CronTabValue,
143 hour: CronTabValue,
145 day_of_month: CronTabValue,
147 month: CronTabValue,
149 day_of_week: CronTabValue,
151}
152
153impl CronTab {
154 pub fn next(
155 &self,
156 _last: Option<time::OffsetDateTime>,
157 _drift: Option<Duration>,
158 ) -> Result<OffsetDateTime, anyhow::Error> {
159 let now = OffsetDateTime::now_utc();
160
161 let mut target =
163 now.replace_nanosecond(0)? + std::time::Duration::from_secs(60 - now.second() as u64);
164
165 match self.minute {
166 CronTabValue::All => {}
167 CronTabValue::Value(val) => {
168 if target.minute() < val {
169 let diff = 60 - val - target.minute();
170 target += Duration::from_secs(diff as u64 * 60);
171 } else {
172 target = target.replace_minute(val)?;
173 }
174 }
175 }
176
177 match self.hour {
178 CronTabValue::All => {}
179 CronTabValue::Value(hour) => {
180 if target.hour() < hour {
181 let diff = 24 - hour - target.hour();
182 target += Duration::from_secs(diff as u64 * 60 * 60);
183 } else {
184 target = target.replace_hour(hour)?;
185 }
186 }
187 }
188
189 match self.month {
190 CronTabValue::All => {}
191 CronTabValue::Value(month) => {
192 let cur_month: u8 = target.month().into();
193 if month < cur_month {
194 let diff = 12 - cur_month - month;
195 target += Duration::from_secs(diff as u64 * 60 * 60 * 24 * 30);
196 } else {
197 target = target.replace_month(month.try_into()?)?;
198 }
199 }
200 }
201
202 match self.day_of_week {
203 CronTabValue::All => {}
204 CronTabValue::Value(_dm) => {
205 bail!("day of week schedule not supported yet");
207 }
208 }
209
210 Ok(target)
211 }
212
213 pub fn max_timewindow(&self) -> Duration {
214 Duration::from_secs(60 * 5)
215 }
216}
217
218impl std::str::FromStr for CronTab {
219 type Err = CronTabParseError;
220
221 fn from_str(s: &str) -> Result<Self, Self::Err> {
222 let mut parts = s.split_whitespace();
223
224 let part = parts
225 .next()
226 .ok_or_else(|| CronTabParseError::new(s, "missing minute specifier"))?;
227
228 let minute = match part {
229 "*" => CronTabValue::All,
230 x => {
231 let x = x.parse::<u8>().map_err(|err| {
232 CronTabParseError::new(
233 s,
234 format!("invalid minute specifier '{x}' - expected * or [0-59]: '{err}'"),
235 )
236 })?;
237
238 if x > 59 {
239 return Err(CronTabParseError::new(s, format!("invalid minute specifier '{x}': expected * or a number between 0 and 59")));
240 }
241
242 CronTabValue::Value(x)
243 }
244 };
245
246 let part = parts
247 .next()
248 .ok_or_else(|| CronTabParseError::new(s, "missing hour specifier"))?;
249 let hour = match part {
250 "*" => CronTabValue::All,
251 x => {
252 let x = x.parse::<u8>().map_err(|err| {
253 CronTabParseError::new(
254 s,
255 format!("invalid hour specifier '{x}' - expected * or [0-23]: '{err}'"),
256 )
257 })?;
258
259 if x > 23 {
260 return Err(CronTabParseError::new(
261 s,
262 format!(
263 "invalid hour specifier '{x}': expected * or a number between 0 and 23"
264 ),
265 ));
266 }
267
268 CronTabValue::Value(x)
269 }
270 };
271
272 let part = parts
273 .next()
274 .ok_or_else(|| CronTabParseError::new(s, "missing day of month specifier"))?;
275 let day_of_month = match part {
276 "*" => CronTabValue::All,
277 x => {
278 let x = x.parse::<u8>().map_err(|err| {
279 CronTabParseError::new(
280 s,
281 format!(
282 "invalid day of month specifier '{x}' - expected * or [1-31]: '{err}'",
283 ),
284 )
285 })?;
286
287 if x < 1 || x > 31 {
288 return Err(CronTabParseError::new(
289 s,
290 format!(
291 "invalid day of month specifier '{x}': expected * or a number between 1 and 31",
292 ),
293 ));
294 }
295
296 CronTabValue::Value(x)
297 }
298 };
299
300 let part = parts
301 .next()
302 .ok_or_else(|| CronTabParseError::new(s, "missing month specifier"))?;
303 let month = match part {
304 "*" => CronTabValue::All,
305 x => {
306 let x = x.parse::<u8>().map_err(|err| {
307 CronTabParseError::new(
308 s,
309 format!("invalid month specifier '{x}' - expected * or [1-12]: '{err}'"),
310 )
311 })?;
312
313 if x < 1 || x > 12 {
314 return Err(CronTabParseError::new(
315 s,
316 format!(
317 "invalid month specifier '{x}': expected * or a number between 1 and 12",
318 ),
319 ));
320 }
321
322 CronTabValue::Value(x)
323 }
324 };
325
326 let part = parts
327 .next()
328 .ok_or_else(|| CronTabParseError::new(s, "missing day of week specifier"))?;
329 let day_of_week = match part {
330 "*" => CronTabValue::All,
331 x => {
332 let x = x.parse::<u8>().map_err(|err| {
333 CronTabParseError::new(
334 s,
335 format!(
336 "invalid day of week specifier '{x}' - expected * or [0-6]: '{err}'",
337 ),
338 )
339 })?;
340
341 if x > 6 {
342 return Err(CronTabParseError::new(
343 s,
344 format!(
345 "invalid day of week specifier '{x}': expected * or a number between 0 and 6",
346 ),
347 ));
348 }
349
350 CronTabValue::Value(x)
351 }
352 };
353
354 Ok(Self {
355 minute,
356 hour,
357 day_of_month,
358 month,
359 day_of_week,
360 })
361 }
362}
363
364impl std::fmt::Display for CronTab {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 write!(
367 f,
368 "{minute} {hour} {day_of_month} {month} {day_of_week}",
369 minute = self.minute,
370 hour = self.hour,
371 day_of_month = self.day_of_month,
372 month = self.month,
373 day_of_week = self.day_of_week,
374 )
375 }
376}
377
378#[derive(Debug)]
379pub struct CronTabParseError {
380 error: String,
381 value: String,
382}
383
384impl CronTabParseError {
385 pub fn new(tab: impl Into<String>, error: impl Into<String>) -> Self {
386 Self {
387 value: tab.into(),
388 error: error.into(),
389 }
390 }
391}
392
393impl std::fmt::Display for CronTabParseError {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 write!(f, "Invalid cron tab '{}': {}", self.value, self.error,)
396 }
397}
398
399impl std::error::Error for CronTabParseError {}
400
401#[cfg(test)]
402mod tests {
403 use std::{str::FromStr, time::Duration};
404
405 use super::*;
406
407 #[test]
408 fn test_parse_schedule() {
409 assert_eq!(
410 CronSchedule::from_str("1m").unwrap(),
411 CronSchedule::Interval(Duration::from_secs(60)),
412 );
413
414 assert_eq!(
415 CronSchedule::from_str("* * * * *").unwrap(),
416 CronSchedule::CronTab(CronTab {
417 minute: CronTabValue::All,
418 hour: CronTabValue::All,
419 day_of_month: CronTabValue::All,
420 month: CronTabValue::All,
421 day_of_week: CronTabValue::All,
422 })
423 );
424 }
425
426 #[test]
427 fn test_parse_crontab() {
428 assert_eq!(
429 CronTab::from_str("* * * * *").unwrap(),
430 CronTab {
431 minute: CronTabValue::All,
432 hour: CronTabValue::All,
433 day_of_month: CronTabValue::All,
434 month: CronTabValue::All,
435 day_of_week: CronTabValue::All,
436 },
437 );
438
439 assert_eq!(
440 CronTab::from_str("1 1 1 1 1").unwrap(),
441 CronTab {
442 minute: CronTabValue::Value(1),
443 hour: CronTabValue::Value(1),
444 day_of_month: CronTabValue::Value(1),
445 month: CronTabValue::Value(1),
446 day_of_week: CronTabValue::Value(1),
447 },
448 );
449 }
450}