1use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17
18use chrono::{
19 DateTime, Datelike, Duration as ChronoDuration, Local, SecondsFormat, Timelike, Utc, Weekday,
20};
21use scc::HashMap as SccHashMap;
22use serde::{Deserialize, Serialize};
23use tokio::sync::broadcast;
24
25use crate::agent_os::AgentOs;
26use crate::config::{ScheduleDriver, ScheduleEntry, ScheduleHandle};
27use crate::error::ClientError;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum CronOverlap {
37 #[default]
38 Allow,
39 Skip,
40 Queue,
41}
42
43#[derive(Clone)]
45pub enum CronAction {
46 Exec { command: String, args: Vec<String> },
48 Callback {
50 #[allow(clippy::type_complexity)]
51 callback: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
52 },
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "lowercase")]
59pub enum CronActionInfo {
60 Exec {
61 command: String,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 args: Vec<String>,
64 },
65 Callback,
66}
67
68impl From<&CronAction> for CronActionInfo {
69 fn from(action: &CronAction) -> Self {
70 match action {
71 CronAction::Exec { command, args } => Self::Exec {
72 command: command.clone(),
73 args: args.clone(),
74 },
75 CronAction::Callback { .. } => Self::Callback,
76 }
77 }
78}
79
80impl std::fmt::Debug for CronAction {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 CronAction::Exec { command, args } => f
84 .debug_struct("Exec")
85 .field("command", command)
86 .field("args", args)
87 .finish(),
88 CronAction::Callback { .. } => f.debug_struct("Callback").finish_non_exhaustive(),
89 }
90 }
91}
92
93#[derive(Clone)]
95pub struct CronJobOptions {
96 pub id: Option<String>,
98 pub schedule: String,
100 pub action: CronAction,
101 pub overlap: Option<CronOverlap>,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct CronJobInfo {
109 pub id: String,
110 pub schedule: String,
111 pub action: CronActionInfo,
112 pub overlap: CronOverlap,
113 pub last_run: Option<String>,
114 pub next_run: Option<String>,
115 pub run_count: u64,
116 pub running: bool,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(tag = "type")]
122pub enum CronEvent {
123 #[serde(rename = "cron:fire", rename_all = "camelCase")]
124 Fire { job_id: String, time: String },
125 #[serde(rename = "cron:complete", rename_all = "camelCase")]
126 Complete {
127 job_id: String,
128 time: String,
129 duration_ms: f64,
130 },
131 #[serde(rename = "cron:error", rename_all = "camelCase")]
132 Error {
133 job_id: String,
134 time: String,
135 error: String,
136 },
137}
138
139#[derive(Clone)]
141pub struct CronJobHandle {
142 pub id: String,
143 pub(crate) manager: Arc<CronManager>,
144}
145
146impl CronJobHandle {
147 pub fn cancel(&self) {
149 self.manager.cancel_job(&self.id);
150 }
151}
152
153pub(crate) struct CronJobState {
159 pub schedule: String,
160 pub action: CronAction,
161 pub overlap: CronOverlap,
162 pub last_run: parking_lot::Mutex<Option<DateTime<Utc>>>,
163 pub next_run: parking_lot::Mutex<Option<DateTime<Utc>>>,
164 pub run_count: std::sync::atomic::AtomicU64,
165 pub running: AtomicBool,
166 pub queued: AtomicBool,
169 pub handle: ScheduleHandle,
172}
173
174pub struct CronManager {
176 pub(crate) jobs: SccHashMap<String, CronJobState>,
177 pub(crate) schedule_lock: parking_lot::Mutex<()>,
178 pub(crate) driver: Arc<dyn ScheduleDriver>,
179 pub(crate) event_tx: broadcast::Sender<CronEvent>,
180}
181
182impl CronManager {
183 pub(crate) fn new(driver: Arc<dyn ScheduleDriver>) -> Self {
185 let (event_tx, _rx) = broadcast::channel(256);
186 Self {
187 jobs: SccHashMap::new(),
188 schedule_lock: parking_lot::Mutex::new(()),
189 driver,
190 event_tx,
191 }
192 }
193
194 pub(crate) fn cancel_job(&self, id: &str) {
199 let _guard = self.schedule_lock.lock();
200 if let Some((_, state)) = self.jobs.remove(id) {
201 self.driver.cancel(&state.handle);
202 }
203 }
204
205 pub(crate) fn dispose(&self) {
210 let _guard = self.schedule_lock.lock();
211 self.jobs.scan(|_, state| {
212 self.driver.cancel(&state.handle);
213 });
214 self.jobs.clear();
215 self.driver.dispose();
216 }
217}
218
219fn execute_job(
228 manager: Arc<CronManager>,
229 vm: AgentOs,
230 id: String,
231) -> futures::future::BoxFuture<'static, ()> {
232 Box::pin(execute_job_inner(manager, vm, id))
233}
234
235async fn execute_job_inner(manager: Arc<CronManager>, vm: AgentOs, id: String) {
236 let manager = &manager;
237 let vm = &vm;
238 let id = id.as_str();
239 {
242 let mut should_return = false;
243 let mut should_queue = false;
244 manager.jobs.read(id, |_, state| {
245 if state.running.load(Ordering::SeqCst) {
246 match state.overlap {
247 CronOverlap::Allow => {}
248 CronOverlap::Skip => should_return = true,
249 CronOverlap::Queue => should_queue = true,
250 }
251 }
252 });
253 if should_return {
254 return;
255 }
256 if should_queue {
257 manager.jobs.read(id, |_, state| {
258 state.queued.store(true, Ordering::SeqCst);
259 });
260 return;
261 }
262 }
263
264 let action = match manager.jobs.read(id, |_, state| {
266 state.running.store(true, Ordering::SeqCst);
267 *state.last_run.lock() = Some(Utc::now());
268 state.run_count.fetch_add(1, Ordering::SeqCst);
269 state.action.clone()
270 }) {
271 Some(action) => action,
272 None => return,
273 };
274
275 let _ = manager.event_tx.send(CronEvent::Fire {
276 job_id: id.to_string(),
277 time: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
278 });
279
280 let start = Utc::now();
282 let result = run_action(vm, &action).await;
283 let duration_ms = (Utc::now() - start).num_milliseconds() as f64;
284
285 match result {
286 Ok(()) => {
287 let _ = manager.event_tx.send(CronEvent::Complete {
288 job_id: id.to_string(),
289 time: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
290 duration_ms,
291 });
292 }
293 Err(error) => {
294 let _ = manager.event_tx.send(CronEvent::Error {
295 job_id: id.to_string(),
296 time: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
297 error: error.to_string(),
298 });
299 }
300 }
301
302 let mut run_queued = false;
304 manager.jobs.read(id, |_, state| {
305 state.running.store(false, Ordering::SeqCst);
306 *state.next_run.lock() = compute_next_time(&state.schedule, Utc::now());
307 if state.queued.swap(false, Ordering::SeqCst) {
308 run_queued = true;
309 }
310 });
311
312 if run_queued {
313 let manager = Arc::clone(manager);
314 let vm = vm.clone();
315 let id = id.to_string();
316 tokio::spawn(execute_job(manager, vm, id));
317 }
318}
319
320async fn run_action(vm: &AgentOs, action: &CronAction) -> Result<(), ClientError> {
326 match action {
327 CronAction::Exec { command, args } => {
328 vm.exec_argv_process(command, args, crate::process::ExecOptions::default())
333 .await
334 .map_err(|err| ClientError::Sidecar(err.to_string()))?;
335 Ok(())
336 }
337 CronAction::Callback { callback } => {
338 callback().await;
339 Ok(())
340 }
341 }
342}
343
344pub(crate) enum ParsedSchedule {
352 Date(DateTime<Utc>),
354 Cron(CronExpr),
356}
357
358impl ParsedSchedule {
359 pub(crate) fn is_cron(&self) -> bool {
361 matches!(self, ParsedSchedule::Cron(_))
362 }
363}
364
365pub(crate) fn resolve_next_run(
369 parsed: &ParsedSchedule,
370 now: DateTime<Utc>,
371) -> Option<DateTime<Utc>> {
372 match parsed {
373 ParsedSchedule::Cron(cron) => cron.next_after(now),
374 ParsedSchedule::Date(date) => {
375 if date.timestamp_millis() > now.timestamp_millis() {
376 Some(*date)
377 } else {
378 None
379 }
380 }
381 }
382}
383
384fn looks_like_one_shot(schedule: &str) -> bool {
390 let bytes = schedule.as_bytes();
391 let mut i = 0usize;
392
393 let is_digit = |b: u8| b.is_ascii_digit();
394
395 let take_digits = |bytes: &[u8], i: &mut usize, n: usize| -> bool {
396 for _ in 0..n {
397 match bytes.get(*i) {
398 Some(&b) if is_digit(b) => *i += 1,
399 _ => return false,
400 }
401 }
402 true
403 };
404 let take_lit = |bytes: &[u8], i: &mut usize, lit: u8| -> bool {
405 match bytes.get(*i) {
406 Some(&b) if b == lit => {
407 *i += 1;
408 true
409 }
410 _ => false,
411 }
412 };
413
414 if !take_digits(bytes, &mut i, 4) {
415 return false;
416 }
417 if !take_lit(bytes, &mut i, b'-') {
418 return false;
419 }
420 if !take_digits(bytes, &mut i, 2) {
421 return false;
422 }
423 if !take_lit(bytes, &mut i, b'-') {
424 return false;
425 }
426 if !take_digits(bytes, &mut i, 2) {
427 return false;
428 }
429
430 if i == bytes.len() {
432 return true;
433 }
434 match bytes.get(i) {
435 Some(b'T') | Some(b' ') => i += 1,
436 _ => return false,
437 }
438 if !take_digits(bytes, &mut i, 2) {
439 return false;
440 }
441 if !take_lit(bytes, &mut i, b':') {
442 return false;
443 }
444 if !take_digits(bytes, &mut i, 2) {
445 return false;
446 }
447
448 if take_lit(bytes, &mut i, b':') {
450 if !take_digits(bytes, &mut i, 2) {
451 return false;
452 }
453 if take_lit(bytes, &mut i, b'.') {
458 let mut frac = 0;
459 while matches!(bytes.get(i), Some(&b) if is_digit(b)) {
460 i += 1;
461 frac += 1;
462 }
463 if frac == 0 {
464 return false;
465 }
466 }
467 }
468
469 match bytes.get(i) {
471 None => return true,
472 Some(b'Z') => {
473 i += 1;
474 }
475 Some(b'+') | Some(b'-') => {
476 i += 1;
477 if !take_digits(bytes, &mut i, 2) {
478 return false;
479 }
480 if !take_lit(bytes, &mut i, b':') {
481 return false;
482 }
483 if !take_digits(bytes, &mut i, 2) {
484 return false;
485 }
486 }
487 _ => return false,
488 }
489
490 i == bytes.len()
491}
492
493fn parse_one_shot(schedule: &str) -> Option<DateTime<Utc>> {
500 use chrono::TimeZone;
501
502 if let Ok(dt) = DateTime::parse_from_rfc3339(schedule) {
504 return Some(dt.with_timezone(&Utc));
505 }
506
507 let normalized = schedule.replacen(' ', "T", 1);
509
510 for fmt in [
512 "%Y-%m-%dT%H:%M:%S%.f",
513 "%Y-%m-%dT%H:%M:%S",
514 "%Y-%m-%dT%H:%M",
515 ] {
516 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(&normalized, fmt) {
517 return match Local.from_local_datetime(&naive) {
518 chrono::LocalResult::Single(dt) => Some(dt.with_timezone(&Utc)),
519 chrono::LocalResult::Ambiguous(dt, _) => Some(dt.with_timezone(&Utc)),
520 chrono::LocalResult::None => None,
521 };
522 }
523 }
524
525 if let Ok(date) = chrono::NaiveDate::parse_from_str(schedule, "%Y-%m-%d") {
527 let naive = date.and_hms_opt(0, 0, 0)?;
528 return Some(DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc));
529 }
530
531 None
532}
533
534pub(crate) fn parse_schedule(schedule: &str) -> std::result::Result<ParsedSchedule, ClientError> {
536 let normalized = schedule.trim();
537 if looks_like_one_shot(normalized) {
538 return match parse_one_shot(normalized) {
539 Some(date) => Ok(ParsedSchedule::Date(date)),
540 None => Err(ClientError::InvalidSchedule(schedule.to_string())),
541 };
542 }
543
544 match CronExpr::parse(normalized) {
545 Ok(cron) => Ok(ParsedSchedule::Cron(cron)),
546 Err(_) => Err(ClientError::InvalidSchedule(schedule.to_string())),
547 }
548}
549
550pub(crate) fn compute_next_time(schedule: &str, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
554 let parsed = parse_schedule(schedule).ok()?;
555 resolve_next_run(&parsed, now)
556}
557
558pub(crate) fn validate_schedule(
565 schedule: &str,
566 now: DateTime<Utc>,
567) -> std::result::Result<Option<DateTime<Utc>>, ClientError> {
568 let parsed = parse_schedule(schedule)?;
569 match parsed {
570 ParsedSchedule::Cron(cron) => Ok(cron.next_after(now)),
571 ParsedSchedule::Date(date) => {
572 if date.timestamp_millis() > now.timestamp_millis() {
573 Ok(Some(date))
574 } else {
575 Err(ClientError::PastSchedule(schedule.to_string()))
576 }
577 }
578 }
579}
580
581pub(crate) struct CronExpr {
595 seconds: Vec<u32>,
596 minutes: Vec<u32>,
597 hours: Vec<u32>,
598 days_of_month: Vec<u32>,
599 months: Vec<u32>,
600 days_of_week: Vec<u32>,
601 years: Option<Vec<u32>>,
602 dom_restricted: bool,
603 dow_restricted: bool,
604 dom_last: bool,
606 dom_last_weekday: bool,
608 dom_nearest_weekday: Option<u32>,
610 dow_last: Option<u32>,
612 dow_nth: Option<(u32, u32)>,
614}
615
616const MONTH_NAMES: [&str; 12] = [
617 "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
618];
619const WEEKDAY_NAMES: [&str; 7] = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
620
621impl CronExpr {
622 fn parse(expr: &str) -> std::result::Result<Self, ()> {
623 let fields: Vec<&str> = expr.split_whitespace().collect();
624
625 let (sec, min, hour, dom, month, dow, year): (
628 &str,
629 &str,
630 &str,
631 &str,
632 &str,
633 &str,
634 Option<&str>,
635 ) = match fields.len() {
636 5 => (
637 "0", fields[0], fields[1], fields[2], fields[3], fields[4], None,
638 ),
639 6 => (
640 fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], None,
641 ),
642 7 => (
643 fields[0],
644 fields[1],
645 fields[2],
646 fields[3],
647 fields[4],
648 fields[5],
649 Some(fields[6]),
650 ),
651 _ => return Err(()),
652 };
653
654 let seconds = parse_field(sec, 0, 59, FieldKind::Plain)?;
655 let minutes = parse_field(min, 0, 59, FieldKind::Plain)?;
656 let hours = parse_field(hour, 0, 23, FieldKind::Plain)?;
657
658 let mut dom_last = false;
659 let mut dom_last_weekday = false;
660 let mut dom_nearest_weekday = None;
661 let days_of_month = parse_dom_field(
662 dom,
663 &mut dom_last,
664 &mut dom_last_weekday,
665 &mut dom_nearest_weekday,
666 )?;
667
668 let months = parse_field(month, 1, 12, FieldKind::Month)?;
669
670 let mut dow_last = None;
671 let mut dow_nth = None;
672 let days_of_week = parse_dow_field(dow, &mut dow_last, &mut dow_nth)?;
673
674 let years = match year {
675 Some(y) => Some(parse_field(y, 1970, 2099, FieldKind::Plain)?),
676 None => None,
677 };
678
679 let dom_restricted = dom != "*" && dom != "?";
681 let dow_restricted = dow != "*" && dow != "?";
682
683 Ok(Self {
684 seconds,
685 minutes,
686 hours,
687 days_of_month,
688 months,
689 days_of_week,
690 years,
691 dom_restricted,
692 dow_restricted,
693 dom_last,
694 dom_last_weekday,
695 dom_nearest_weekday,
696 dow_last,
697 dow_nth,
698 })
699 }
700
701 fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
705 let local_after = after.with_timezone(&Local);
706
707 let by_seconds = self.seconds != vec![0];
709
710 let step = if by_seconds {
711 ChronoDuration::seconds(1)
712 } else {
713 ChronoDuration::minutes(1)
714 };
715
716 let mut candidate = if by_seconds {
717 local_after.with_nanosecond(0)? + ChronoDuration::seconds(1)
718 } else {
719 local_after.with_second(0)?.with_nanosecond(0)? + ChronoDuration::minutes(1)
720 };
721
722 let max_iterations: u64 = if by_seconds {
724 2u64 * 366 * 24 * 60 * 60
726 } else {
727 6u64 * 366 * 24 * 60
729 };
730 for _ in 0..max_iterations {
731 if self.matches_local(&candidate) {
732 return Some(candidate.with_timezone(&Utc));
733 }
734 candidate += step;
735 }
736 None
737 }
738
739 fn matches_local(&self, dt: &DateTime<Local>) -> bool {
740 if !self.seconds.contains(&dt.second()) {
741 return false;
742 }
743 if !self.minutes.contains(&dt.minute()) {
744 return false;
745 }
746 if !self.hours.contains(&dt.hour()) {
747 return false;
748 }
749 if !self.months.contains(&dt.month()) {
750 return false;
751 }
752 if let Some(years) = &self.years {
753 let year = dt.year();
754 if year < 0 || !years.contains(&(year as u32)) {
755 return false;
756 }
757 }
758
759 let dom_match = self.dom_matches(dt);
760 let dow_match = self.dow_matches(dt);
761
762 match (self.dom_restricted, self.dow_restricted) {
765 (true, true) => dom_match || dow_match,
766 (true, false) => dom_match,
767 (false, true) => dow_match,
768 (false, false) => true,
769 }
770 }
771
772 fn dom_matches(&self, dt: &DateTime<Local>) -> bool {
773 let dom = dt.day();
774 if self.dom_last && dom == last_day_of_month(dt.year(), dt.month()) {
775 return true;
776 }
777 if self.dom_last_weekday {
778 if is_nearest_weekday(dt, last_day_of_month(dt.year(), dt.month())) {
781 return true;
782 }
783 }
784 if let Some(target) = self.dom_nearest_weekday {
785 if is_nearest_weekday(dt, target) {
786 return true;
787 }
788 }
789 self.days_of_month.contains(&dom)
790 }
791
792 fn dow_matches(&self, dt: &DateTime<Local>) -> bool {
793 let dow = weekday_sun0(dt.weekday());
794
795 if let Some(target) = self.dow_last {
796 if dow == target {
798 let next_week = *dt + ChronoDuration::days(7);
799 if next_week.month() != dt.month() {
800 return true;
801 }
802 }
803 }
804 if let Some((target, n)) = self.dow_nth {
805 if dow == target {
806 let occurrence = (dt.day() - 1) / 7 + 1;
808 if occurrence == n {
809 return true;
810 }
811 }
812 }
813 self.days_of_week.contains(&dow)
814 }
815}
816
817fn weekday_sun0(weekday: Weekday) -> u32 {
819 weekday.num_days_from_sunday()
820}
821
822fn last_day_of_month(year: i32, month: u32) -> u32 {
824 let (ny, nm) = if month == 12 {
825 (year + 1, 1)
826 } else {
827 (year, month + 1)
828 };
829 let first_next = chrono::NaiveDate::from_ymd_opt(ny, nm, 1).expect("valid first-of-month");
830 (first_next - ChronoDuration::days(1)).day()
831}
832
833fn is_nearest_weekday(dt: &DateTime<Local>, target: u32) -> bool {
838 let last = last_day_of_month(dt.year(), dt.month());
839 let target = target.min(last);
840 let target_date = chrono::NaiveDate::from_ymd_opt(dt.year(), dt.month(), target);
841 let target_date = match target_date {
842 Some(d) => d,
843 None => return false,
844 };
845 let target_weekday = target_date.weekday();
846 let resolved_day = match target_weekday {
847 Weekday::Sat => {
848 if target > 1 {
849 target - 1
850 } else {
851 target + 2
853 }
854 }
855 Weekday::Sun => {
856 if target < last {
857 target + 1
858 } else {
859 target - 2
861 }
862 }
863 Weekday::Mon | Weekday::Tue | Weekday::Wed | Weekday::Thu | Weekday::Fri => target,
864 };
865 dt.day() == resolved_day
866}
867
868#[derive(Clone, Copy, PartialEq, Eq)]
869enum FieldKind {
870 Plain,
871 Month,
872 Weekday,
873}
874
875fn parse_field(
879 field: &str,
880 min: u32,
881 max: u32,
882 kind: FieldKind,
883) -> std::result::Result<Vec<u32>, ()> {
884 if field == "?" {
885 return Ok((min..=max).collect());
887 }
888 let mut values: Vec<u32> = Vec::new();
889 for part in field.split(',') {
890 if part.is_empty() {
891 return Err(());
892 }
893 parse_field_part(part, min, max, kind, &mut values)?;
894 }
895 if values.is_empty() {
896 return Err(());
897 }
898 values.sort_unstable();
899 values.dedup();
900 Ok(values)
901}
902
903fn parse_dom_field(
907 field: &str,
908 dom_last: &mut bool,
909 dom_last_weekday: &mut bool,
910 dom_nearest_weekday: &mut Option<u32>,
911) -> std::result::Result<Vec<u32>, ()> {
912 let upper = field.to_ascii_uppercase();
913 if upper == "L" {
914 *dom_last = true;
915 return Ok(Vec::new());
917 }
918 if upper == "LW" {
919 *dom_last_weekday = true;
920 return Ok(Vec::new());
921 }
922 if let Some(stripped) = upper.strip_suffix('W') {
923 let day: u32 = stripped.parse().map_err(|_| ())?;
924 if !(1..=31).contains(&day) {
925 return Err(());
926 }
927 *dom_nearest_weekday = Some(day);
928 return Ok(Vec::new());
929 }
930 parse_field(field, 1, 31, FieldKind::Plain)
931}
932
933fn parse_dow_field(
936 field: &str,
937 dow_last: &mut Option<u32>,
938 dow_nth: &mut Option<(u32, u32)>,
939) -> std::result::Result<Vec<u32>, ()> {
940 let upper = field.to_ascii_uppercase();
941
942 if let Some((wd, nth)) = upper.split_once('#') {
944 let weekday = parse_weekday_token(wd)?;
945 let n: u32 = nth.parse().map_err(|_| ())?;
946 if !(1..=5).contains(&n) {
947 return Err(());
948 }
949 *dow_nth = Some((weekday, n));
950 return Ok(Vec::new());
951 }
952
953 if let Some(stripped) = upper.strip_suffix('L') {
955 let weekday = parse_weekday_token(stripped)?;
956 *dow_last = Some(weekday);
957 return Ok(Vec::new());
958 }
959
960 if upper == "?" || upper == "*" {
961 let mut v = parse_field(field, 0, 7, FieldKind::Plain)?;
962 fold_sunday(&mut v);
963 return Ok(v);
964 }
965
966 let mut values = parse_field(field, 0, 7, FieldKind::Weekday)?;
967 fold_sunday(&mut values);
968 Ok(values)
969}
970
971fn fold_sunday(values: &mut Vec<u32>) {
973 for v in values.iter_mut() {
974 if *v == 7 {
975 *v = 0;
976 }
977 }
978 values.sort_unstable();
979 values.dedup();
980}
981
982fn parse_weekday_token(token: &str) -> std::result::Result<u32, ()> {
984 let upper = token.to_ascii_uppercase();
985 if let Some(idx) = WEEKDAY_NAMES.iter().position(|name| *name == upper) {
986 return Ok(idx as u32);
987 }
988 let v: u32 = upper.parse().map_err(|_| ())?;
989 match v {
990 0..=6 => Ok(v),
991 7 => Ok(0),
992 _ => Err(()),
993 }
994}
995
996impl FieldKind {
998 fn resolve_name(self, token: &str) -> Option<u32> {
999 let upper = token.to_ascii_uppercase();
1000 match self {
1001 FieldKind::Plain => None,
1002 FieldKind::Month => MONTH_NAMES
1003 .iter()
1004 .position(|name| *name == upper)
1005 .map(|i| (i + 1) as u32),
1006 FieldKind::Weekday => WEEKDAY_NAMES
1007 .iter()
1008 .position(|name| *name == upper)
1009 .map(|i| i as u32),
1010 }
1011 }
1012}
1013
1014fn parse_field_part(
1015 part: &str,
1016 min: u32,
1017 max: u32,
1018 kind: FieldKind,
1019 out: &mut Vec<u32>,
1020) -> std::result::Result<(), ()> {
1021 let (range_spec, step) = match part.split_once('/') {
1023 Some((range_spec, step_str)) => {
1024 let step: u32 = step_str.parse().map_err(|_| ())?;
1025 if step == 0 {
1026 return Err(());
1027 }
1028 (range_spec, Some(step))
1029 }
1030 None => (part, None),
1031 };
1032
1033 let (start, end) = if range_spec == "*" {
1035 (min, max)
1036 } else if let Some((lo, hi)) = range_spec.split_once('-') {
1037 let lo = parse_value_token(lo, kind)?;
1038 let hi = parse_value_token(hi, kind)?;
1039 (lo, hi)
1040 } else {
1041 if step.is_some() {
1044 return Err(());
1045 }
1046 let v = parse_value_token(range_spec, kind)?;
1047 (v, v)
1048 };
1049
1050 if start < min || end > max || start > end {
1051 return Err(());
1052 }
1053
1054 let step = step.unwrap_or(1);
1055 let mut v = start;
1056 while v <= end {
1057 out.push(v);
1058 v += step;
1059 }
1060 Ok(())
1061}
1062
1063fn parse_value_token(token: &str, kind: FieldKind) -> std::result::Result<u32, ()> {
1066 match kind {
1067 FieldKind::Weekday => parse_weekday_token(token),
1068 FieldKind::Month => {
1069 if let Some(v) = kind.resolve_name(token) {
1070 return Ok(v);
1071 }
1072 token.parse().map_err(|_| ())
1073 }
1074 FieldKind::Plain => token.parse().map_err(|_| ()),
1075 }
1076}
1077
1078impl AgentOs {
1083 pub fn schedule_cron(
1092 &self,
1093 options: CronJobOptions,
1094 ) -> std::result::Result<CronJobHandle, ClientError> {
1095 let cron = self.cron();
1096 let now = Utc::now();
1097
1098 let next_run = validate_schedule(&options.schedule, now)?;
1100
1101 let id = options
1102 .id
1103 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1104 let overlap = options.overlap.unwrap_or_default();
1105
1106 let manager = Arc::clone(cron);
1109 let vm = self.clone();
1110 let callback_id = id.clone();
1111 let callback: crate::config::ScheduleCallback = Arc::new(move || {
1112 let manager = Arc::clone(&manager);
1113 let vm = vm.clone();
1114 let id = callback_id.clone();
1115 Box::pin(async move {
1116 execute_job(manager, vm, id).await;
1117 })
1118 });
1119
1120 register_cron_job(
1121 cron,
1122 id,
1123 options.schedule,
1124 options.action,
1125 overlap,
1126 next_run,
1127 callback,
1128 )
1129 }
1130
1131 pub fn list_cron_jobs(&self) -> Vec<CronJobInfo> {
1133 let mut result = Vec::new();
1134 self.cron().jobs.scan(|id, state| {
1135 result.push(CronJobInfo {
1136 id: id.clone(),
1137 schedule: state.schedule.clone(),
1138 action: CronActionInfo::from(&state.action),
1139 overlap: state.overlap,
1140 last_run: state
1141 .last_run
1142 .lock()
1143 .as_ref()
1144 .map(|time| time.to_rfc3339_opts(SecondsFormat::Millis, true)),
1145 next_run: state
1146 .next_run
1147 .lock()
1148 .as_ref()
1149 .map(|time| time.to_rfc3339_opts(SecondsFormat::Millis, true)),
1150 run_count: state.run_count.load(Ordering::SeqCst),
1151 running: state.running.load(Ordering::SeqCst),
1152 });
1153 });
1154 result
1155 }
1156
1157 pub fn cancel_cron_job(&self, id: &str) {
1159 self.cron().cancel_job(id);
1160 }
1161
1162 pub fn cron_events(&self) -> broadcast::Receiver<CronEvent> {
1165 self.cron().event_tx.subscribe()
1166 }
1167}
1168
1169fn ensure_cron_capacity(cron: &CronManager, id: &str) -> std::result::Result<(), ClientError> {
1170 if cron.jobs.contains(id) || cron.jobs.len() < crate::CRON_JOB_LIMIT {
1171 return Ok(());
1172 }
1173
1174 Err(ClientError::Sidecar(format!(
1175 "cron job limit exceeded: at most {} jobs can be scheduled per VM",
1176 crate::CRON_JOB_LIMIT
1177 )))
1178}
1179
1180fn register_cron_job(
1181 cron: &Arc<CronManager>,
1182 id: String,
1183 schedule: String,
1184 action: CronAction,
1185 overlap: CronOverlap,
1186 next_run: Option<DateTime<Utc>>,
1187 callback: crate::config::ScheduleCallback,
1188) -> std::result::Result<CronJobHandle, ClientError> {
1189 let _guard = cron.schedule_lock.lock();
1190 ensure_cron_capacity(cron, &id)?;
1191
1192 if let Some((_, old)) = cron.jobs.remove(&id) {
1196 cron.driver.cancel(&old.handle);
1197 }
1198
1199 let handle = cron.driver.schedule(ScheduleEntry {
1200 id: id.clone(),
1201 schedule: schedule.clone(),
1202 callback,
1203 });
1204
1205 let state = CronJobState {
1206 schedule,
1207 action,
1208 overlap,
1209 last_run: parking_lot::Mutex::new(None),
1210 next_run: parking_lot::Mutex::new(next_run),
1211 run_count: std::sync::atomic::AtomicU64::new(0),
1212 running: AtomicBool::new(false),
1213 queued: AtomicBool::new(false),
1214 handle,
1215 };
1216
1217 let _ = cron.jobs.insert(id.clone(), state);
1218
1219 Ok(CronJobHandle {
1220 id,
1221 manager: Arc::clone(cron),
1222 })
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::{
1228 ensure_cron_capacity, register_cron_job, CronAction, CronJobState, CronManager,
1229 CronOverlap, ScheduleDriver, ScheduleEntry, ScheduleHandle,
1230 };
1231 use crate::CRON_JOB_LIMIT;
1232 use std::sync::atomic::AtomicBool;
1233 use std::sync::Arc;
1234
1235 #[derive(Default)]
1236 struct RecordingScheduleDriver {
1237 calls: parking_lot::Mutex<Vec<String>>,
1238 }
1239
1240 impl ScheduleDriver for RecordingScheduleDriver {
1241 fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle {
1242 self.calls.lock().push(format!("schedule:{}", entry.id));
1243 ScheduleHandle { id: entry.id }
1244 }
1245
1246 fn cancel(&self, handle: &ScheduleHandle) {
1247 self.calls.lock().push(format!("cancel:{}", handle.id));
1248 }
1249
1250 fn dispose(&self) {}
1251 }
1252
1253 fn dummy_state(id: String) -> CronJobState {
1254 CronJobState {
1255 schedule: "0 0 * * *".to_string(),
1256 action: CronAction::Callback {
1257 callback: Arc::new(|| Box::pin(async {})),
1258 },
1259 overlap: CronOverlap::Allow,
1260 last_run: parking_lot::Mutex::new(None),
1261 next_run: parking_lot::Mutex::new(None),
1262 run_count: std::sync::atomic::AtomicU64::new(0),
1263 running: AtomicBool::new(false),
1264 queued: AtomicBool::new(false),
1265 handle: ScheduleHandle { id },
1266 }
1267 }
1268
1269 #[test]
1270 fn cron_capacity_rejects_new_jobs_at_limit_but_allows_replacements() {
1271 let manager = CronManager::new(Arc::new(RecordingScheduleDriver::default()));
1272 for index in 0..CRON_JOB_LIMIT {
1273 let id = format!("job-{index}");
1274 assert!(
1275 manager.jobs.insert(id.clone(), dummy_state(id)).is_ok(),
1276 "seed cron job"
1277 );
1278 }
1279
1280 let error = ensure_cron_capacity(&manager, "overflow").expect_err("limit should reject");
1281 assert!(
1282 error.to_string().contains("cron job limit exceeded"),
1283 "unexpected limit error: {error}"
1284 );
1285 ensure_cron_capacity(&manager, "job-0").expect("replacement should be allowed");
1286 }
1287
1288 #[test]
1307 fn cron_exec_action_argv_is_not_shell_re_split_or_evaluated() {
1308 fn cron_exec_argv(command: &str, args: &[&str]) -> (String, Vec<String>) {
1311 (
1312 command.to_string(),
1313 args.iter().map(|a| a.to_string()).collect(),
1314 )
1315 }
1316
1317 fn buggy_join_then_resolve(command: &str, args: &[&str]) -> (String, Vec<String>) {
1319 let joined = if args.is_empty() {
1320 command.to_string()
1321 } else {
1322 format!("{} {}", command, args.join(" "))
1323 };
1324 crate::command_line::resolve_exec_command(&joined).expect("line must resolve")
1325 }
1326
1327 let (cmd, args) = cron_exec_argv("printenv", &["a b"]);
1329 assert_eq!(
1330 (cmd.as_str(), args.as_slice()),
1331 ("printenv", &["a b".to_string()][..]),
1332 "N-007: structured argv element \"a b\" must survive as a single argv element"
1333 );
1334 let (_, buggy_args) = buggy_join_then_resolve("printenv", &["a b"]);
1336 assert_eq!(
1337 buggy_args,
1338 vec!["a".to_string(), "b".to_string()],
1339 "N-007 negative control: the old join+resolve path re-split \"a b\" into two argv elements"
1340 );
1341
1342 let (cmd, args) = cron_exec_argv("printenv", &["$(id)"]);
1344 assert_eq!(
1345 (cmd.as_str(), args.as_slice()),
1346 ("printenv", &["$(id)".to_string()][..]),
1347 "N-007: command-substitution argv element \"$(id)\" must NOT be promoted to `sh -c`"
1348 );
1349 let (buggy_cmd, _) = buggy_join_then_resolve("printenv", &["$(id)"]);
1351 assert_eq!(
1352 buggy_cmd, "sh",
1353 "N-007 negative control: the old path promoted the `$(id)` line to a `sh -c` shell"
1354 );
1355
1356 let (cmd, args) = cron_exec_argv("printenv", &["`id`"]);
1358 assert_eq!(
1359 (cmd.as_str(), args.as_slice()),
1360 ("printenv", &["`id`".to_string()][..]),
1361 "N-007: backtick argv element \"`id`\" must NOT be promoted to `sh -c`"
1362 );
1363 let (buggy_cmd, _) = buggy_join_then_resolve("printenv", &["`id`"]);
1364 assert_eq!(
1365 buggy_cmd, "sh",
1366 "N-007 negative control: the old path promoted the backtick line to a `sh -c` shell"
1367 );
1368 }
1369
1370 #[test]
1379 fn schedule_cron_public_path_rejects_jobs_beyond_cron_job_limit() {
1380 let driver = Arc::new(RecordingScheduleDriver::default());
1381 let manager = Arc::new(CronManager::new(driver.clone()));
1382
1383 let make_callback =
1384 || -> crate::config::ScheduleCallback { Arc::new(|| Box::pin(async {})) };
1385
1386 for index in 0..CRON_JOB_LIMIT {
1388 register_cron_job(
1389 &manager,
1390 format!("flood-{index}"),
1391 "0 0 * * *".to_string(),
1392 CronAction::Callback {
1393 callback: make_callback(),
1394 },
1395 CronOverlap::Allow,
1396 None,
1397 make_callback(),
1398 )
1399 .unwrap_or_else(|err| panic!("seed job {index} should register: {err}"));
1400 }
1401 assert_eq!(manager.jobs.len(), CRON_JOB_LIMIT);
1402
1403 let overflow = match register_cron_job(
1406 &manager,
1407 "flood-overflow".to_string(),
1408 "0 0 * * *".to_string(),
1409 CronAction::Callback {
1410 callback: make_callback(),
1411 },
1412 CronOverlap::Allow,
1413 None,
1414 make_callback(),
1415 ) {
1416 Ok(_) => {
1417 panic!("AOSCLIENT-P2-cron-cap: the job beyond CRON_JOB_LIMIT must be rejected")
1418 }
1419 Err(err) => err,
1420 };
1421 assert!(
1422 overflow.to_string().contains("cron job limit exceeded"),
1423 "AOSCLIENT-P2-cron-cap: overflow rejection must report the cron job limit, got: {overflow}"
1424 );
1425 assert_eq!(
1426 manager.jobs.len(),
1427 CRON_JOB_LIMIT,
1428 "AOSCLIENT-P2-cron-cap: a rejected overflow job must not be inserted"
1429 );
1430
1431 register_cron_job(
1433 &manager,
1434 "flood-0".to_string(),
1435 "0 1 * * *".to_string(),
1436 CronAction::Callback {
1437 callback: make_callback(),
1438 },
1439 CronOverlap::Allow,
1440 None,
1441 make_callback(),
1442 )
1443 .expect("AOSCLIENT-P2-cron-cap: replacing an existing id at the cap must be allowed");
1444 assert_eq!(
1445 manager.jobs.len(),
1446 CRON_JOB_LIMIT,
1447 "AOSCLIENT-P2-cron-cap: replacing an existing id must not grow the registry past the cap"
1448 );
1449 }
1450
1451 #[test]
1452 fn cron_replacement_cancels_old_timer_before_scheduling_new_timer() {
1453 let driver = Arc::new(RecordingScheduleDriver::default());
1454 let manager = Arc::new(CronManager::new(driver.clone()));
1455 let callback: crate::config::ScheduleCallback = Arc::new(|| Box::pin(async {}));
1456
1457 register_cron_job(
1458 &manager,
1459 "same-id".to_string(),
1460 "0 0 * * *".to_string(),
1461 CronAction::Callback {
1462 callback: callback.clone(),
1463 },
1464 CronOverlap::Allow,
1465 None,
1466 callback.clone(),
1467 )
1468 .expect("initial schedule");
1469 register_cron_job(
1470 &manager,
1471 "same-id".to_string(),
1472 "0 1 * * *".to_string(),
1473 CronAction::Callback { callback },
1474 CronOverlap::Allow,
1475 None,
1476 Arc::new(|| Box::pin(async {})),
1477 )
1478 .expect("replacement schedule");
1479
1480 assert_eq!(
1481 *driver.calls.lock(),
1482 vec!["schedule:same-id", "cancel:same-id", "schedule:same-id"]
1483 );
1484 assert_eq!(manager.jobs.len(), 1);
1485 }
1486}