1use serde::{Deserialize, Serialize};
2
3use crate::wire::{RunAs, Shell, Staleness};
4
5#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
17#[serde(deny_unknown_fields)]
18pub struct Manifest {
19 pub id: String,
20 pub version: String,
21 #[serde(default)]
22 pub description: Option<String>,
23 pub execute: Execute,
24 #[serde(default)]
25 pub require_approval: bool,
26 #[serde(default)]
32 pub inventory: Option<InventoryHint>,
33 #[serde(default)]
47 pub emit: Option<EmitConfig>,
48 #[serde(default)]
62 pub check: Option<CheckHint>,
63 #[serde(default)]
71 pub staleness: Staleness,
72}
73
74#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
79pub struct FanoutPlan {
80 #[serde(default)]
81 pub target: Target,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub rollout: Option<Rollout>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub jitter: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
104}
105
106#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
119pub struct InventoryHint {
120 pub display: Vec<DisplayField>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub summary: Option<Vec<DisplayField>>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub explode: Option<Vec<ExplodeSpec>>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub history_scalars: Option<Vec<String>>,
153}
154
155#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
178#[serde(deny_unknown_fields)]
179pub struct CheckHint {
180 pub name: String,
184 #[serde(default = "default_status_field")]
189 pub status_field: String,
190 #[serde(default = "default_detail_field")]
194 pub detail_field: String,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub troubleshoot: Option<String>,
201}
202
203fn default_status_field() -> String {
204 "status".to_string()
205}
206
207fn default_detail_field() -> String {
208 "detail".to_string()
209}
210
211#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
216#[serde(deny_unknown_fields)]
217pub struct EmitConfig {
218 #[serde(rename = "type")]
223 pub kind: EmitKind,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub watermark_path: Option<String>,
234}
235
236#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
239#[serde(rename_all = "lowercase")]
240pub enum EmitKind {
241 Events,
245}
246
247#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
254pub struct ExplodeSpec {
255 pub field: String,
258 pub table: String,
263 pub primary_key: Vec<String>,
276 pub columns: Vec<ExplodeColumn>,
278 #[serde(default)]
290 pub track_history: bool,
291}
292
293#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
295pub struct ExplodeColumn {
296 pub field: String,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
304 #[serde(rename = "type")]
305 pub kind: Option<String>,
306 #[serde(default)]
311 pub index: bool,
312}
313
314#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
315pub struct DisplayField {
316 pub field: String,
318 pub label: String,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
328 #[serde(rename = "type")]
329 pub kind: Option<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub columns: Option<Vec<DisplayField>>,
339}
340
341#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
342pub struct Rollout {
343 #[serde(default)]
344 pub strategy: RolloutStrategy,
345 pub waves: Vec<Wave>,
346}
347
348#[derive(
349 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
350)]
351#[serde(rename_all = "lowercase")]
352pub enum RolloutStrategy {
353 #[default]
354 Wave,
355}
356
357#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
358pub struct Wave {
359 pub group: String,
360 pub delay: String,
363}
364
365#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
366pub struct Target {
367 #[serde(default)]
368 pub groups: Vec<String>,
369 #[serde(default)]
370 pub pcs: Vec<String>,
371 #[serde(default)]
372 pub all: bool,
373}
374
375impl Target {
376 pub fn is_specified(&self) -> bool {
378 self.all || !self.groups.is_empty() || !self.pcs.is_empty()
379 }
380}
381
382#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
383#[serde(deny_unknown_fields)]
384pub struct Execute {
385 pub shell: ExecuteShell,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub script: Option<String>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub script_file: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub script_object: Option<String>,
426 pub timeout: String,
429 #[serde(default)]
433 pub run_as: RunAs,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub cwd: Option<String>,
445}
446
447impl Execute {
448 fn has_inline_script(&self) -> bool {
452 matches!(&self.script, Some(s) if !s.is_empty())
453 }
454
455 pub fn validate_script_source(&self) -> Result<(), String> {
463 let inline = self.has_inline_script();
464 let file = self.script_file.is_some();
465 let obj = self.script_object.is_some();
466 let set = [inline, file, obj].into_iter().filter(|b| *b).count();
467 match set {
468 1 => Ok(()),
469 0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
470 _ => Err(format!(
471 "execute: only one of `script` / `script_file` / `script_object` may be set \
472 (got script={inline}, script_file={file}, script_object={obj})"
473 )),
474 }
475 }
476}
477
478impl Manifest {
479 pub fn validate(&self) -> Result<(), String> {
484 self.execute.validate_script_source()?;
485 if self.emit.is_some() && (self.inventory.is_some() || self.check.is_some()) {
492 return Err(
493 "`emit:` is incompatible with `inventory:` / `check:` — emit's stdout is NDJSON \
494 timeline events (and omitted from the result), while inventory/check read a \
495 single JSON object from stdout"
496 .to_string(),
497 );
498 }
499 if let Some(check) = &self.check {
505 for (label, value) in [
506 ("check.name", &check.name),
507 ("check.status_field", &check.status_field),
508 ("check.detail_field", &check.detail_field),
509 ] {
510 if value.trim().is_empty() {
511 return Err(format!("{label} must not be empty"));
512 }
513 }
514 if let Some(troubleshoot) = &check.troubleshoot {
518 if troubleshoot.trim().is_empty() {
519 return Err("check.troubleshoot must not be empty when set".to_string());
520 }
521 }
522 }
523 Ok(())
524 }
525}
526
527#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
528#[serde(rename_all = "lowercase")]
529pub enum ExecuteShell {
530 Powershell,
531 Cmd,
532}
533
534impl From<ExecuteShell> for Shell {
535 fn from(s: ExecuteShell) -> Self {
536 match s {
537 ExecuteShell::Powershell => Shell::Powershell,
538 ExecuteShell::Cmd => Shell::Cmd,
539 }
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
552 fn example_check_job_yamls_parse_and_validate() {
553 let jobs = [
554 (
555 "check-bitlocker",
556 include_str!("../../../configs/jobs/check-bitlocker.yaml"),
557 ),
558 (
559 "check-av-signature",
560 include_str!("../../../configs/jobs/check-av-signature.yaml"),
561 ),
562 (
563 "check-cert-expiry",
564 include_str!("../../../configs/jobs/check-cert-expiry.yaml"),
565 ),
566 ];
567 for (name, yaml) in jobs {
568 let m: Manifest =
569 serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} parse: {e}"));
570 m.validate()
571 .unwrap_or_else(|e| panic!("{name} validate: {e}"));
572 let check = m
573 .check
574 .as_ref()
575 .unwrap_or_else(|| panic!("{name} must carry a check: hint"));
576 assert!(!check.name.trim().is_empty(), "{name} check.name empty");
577 assert_eq!(
582 m.execute.run_as,
583 RunAs::System,
584 "{name} should run_as system"
585 );
586 }
587 }
588
589 #[test]
590 fn example_check_schedule_yamls_parse_and_validate() {
591 let schedules = [
592 (
593 "check-bitlocker",
594 include_str!("../../../configs/schedules/check-bitlocker.yaml"),
595 ),
596 (
597 "check-av-signature",
598 include_str!("../../../configs/schedules/check-av-signature.yaml"),
599 ),
600 (
601 "check-cert-expiry",
602 include_str!("../../../configs/schedules/check-cert-expiry.yaml"),
603 ),
604 ];
605 for (name, yaml) in schedules {
606 let s: Schedule =
607 serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
608 s.validate()
609 .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
610 assert_eq!(s.job_id, name, "{name} schedule must reference its job");
611 }
612 }
613
614 #[test]
615 fn target_is_specified_requires_at_least_one_field() {
616 let empty = Target::default();
617 assert!(!empty.is_specified());
618
619 let with_all = Target {
620 all: true,
621 ..Target::default()
622 };
623 assert!(with_all.is_specified());
624
625 let with_groups = Target {
626 groups: vec!["canary".into()],
627 ..Target::default()
628 };
629 assert!(with_groups.is_specified());
630
631 let with_pcs = Target {
632 pcs: vec!["pc-01".into()],
633 ..Target::default()
634 };
635 assert!(with_pcs.is_specified());
636 }
637
638 #[test]
639 fn manifest_deserialises_minimal_yaml() {
640 let yaml = r#"
643id: echo-test
644version: 0.0.1
645execute:
646 shell: powershell
647 script: "echo 'kanade'"
648 timeout: 30s
649"#;
650 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
651 assert_eq!(m.id, "echo-test");
652 assert_eq!(m.version, "0.0.1");
653 assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
654 assert_eq!(
655 m.execute.script.as_deref().map(str::trim),
656 Some("echo 'kanade'")
657 );
658 assert!(m.execute.script_file.is_none());
659 assert!(m.execute.script_object.is_none());
660 assert_eq!(m.execute.timeout, "30s");
661 assert!(!m.require_approval);
662 m.validate()
663 .expect("inline-script manifest passes validation");
664 }
665
666 #[test]
667 fn manifest_parses_check_job_and_validates() {
668 let yaml = r#"
671id: check-bitlocker
672version: 0.1.0
673execute:
674 shell: powershell
675 run_as: system
676 timeout: 15s
677 script: |
678 [pscustomobject]@{ status = 'ok'; detail = 'all volumes protected' } | ConvertTo-Json -Compress
679check:
680 name: bitlocker
681 troubleshoot: fix-bitlocker
682"#;
683 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
684 let check = m.check.as_ref().expect("check hint present");
685 assert_eq!(check.name, "bitlocker");
686 assert_eq!(check.troubleshoot.as_deref(), Some("fix-bitlocker"));
687 assert_eq!(check.status_field, "status");
689 assert_eq!(check.detail_field, "detail");
690 assert!(m.inventory.is_none() && m.emit.is_none());
691 m.validate().expect("check-only manifest passes validation");
692 }
693
694 #[test]
695 fn manifest_check_defaults_and_custom_fields() {
696 let m: Manifest = serde_yaml::from_str(
698 r#"
699id: check-disk
700version: 0.1.0
701execute:
702 shell: powershell
703 script: "[pscustomobject]@{ status = 'ok' } | ConvertTo-Json -Compress"
704 timeout: 10s
705check:
706 name: disk_free
707"#,
708 )
709 .expect("parse");
710 let c = m.check.as_ref().unwrap();
711 assert_eq!(c.name, "disk_free");
712 assert_eq!(c.status_field, "status");
713 assert_eq!(c.detail_field, "detail");
714 assert!(c.troubleshoot.is_none());
715 m.validate().expect("validates");
716
717 let m2: Manifest = serde_yaml::from_str(
720 r#"
721id: check-custom
722version: 0.1.0
723execute:
724 shell: powershell
725 script: "echo x"
726 timeout: 10s
727check:
728 name: patch_level
729 status_field: compliance
730 detail_field: summary
731"#,
732 )
733 .expect("parse");
734 let c2 = m2.check.as_ref().unwrap();
735 assert_eq!(c2.status_field, "compliance");
736 assert_eq!(c2.detail_field, "summary");
737 }
738
739 #[test]
740 fn manifest_allows_check_composed_with_inventory() {
741 let yaml = r#"
745id: check-bitlocker-detailed
746version: 0.1.0
747execute:
748 shell: powershell
749 script: "echo x"
750 timeout: 10s
751check:
752 name: bitlocker
753inventory:
754 display:
755 - { field: status, label: Status }
756"#;
757 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
758 assert!(m.check.is_some() && m.inventory.is_some());
759 m.validate().expect("check + inventory compose");
760 }
761
762 #[test]
763 fn manifest_rejects_check_combined_with_emit() {
764 let yaml = r#"
768id: bad-mix
769version: 0.1.0
770execute:
771 shell: powershell
772 script: "echo x"
773 timeout: 10s
774check:
775 name: bitlocker
776emit:
777 type: events
778"#;
779 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
780 let err = m.validate().expect_err("emit + check must fail");
781 assert!(err.contains("incompatible"), "err: {err}");
782 }
783
784 #[test]
785 fn manifest_rejects_emit_combined_with_inventory() {
786 let yaml = r#"
788id: bad-mix-2
789version: 0.1.0
790execute:
791 shell: powershell
792 script: "echo x"
793 timeout: 10s
794emit:
795 type: events
796inventory:
797 display:
798 - { field: status, label: Status }
799"#;
800 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
801 let err = m.validate().expect_err("emit + inventory must fail");
802 assert!(err.contains("incompatible"), "err: {err}");
803 }
804
805 #[test]
806 fn manifest_rejects_empty_check_field_names() {
807 let base = |inner: &str| {
811 format!(
812 "id: c\nversion: 0.1.0\nexecute:\n shell: powershell\n script: \"echo x\"\n timeout: 10s\ncheck:\n{inner}"
813 )
814 };
815 for inner in [
816 " name: \"\"\n",
817 " name: ok\n status_field: \"\"\n",
818 " name: ok\n detail_field: \" \"\n",
819 " name: ok\n troubleshoot: \" \"\n",
821 ] {
822 let m: Manifest = serde_yaml::from_str(&base(inner)).expect("parse");
823 let err = m.validate().expect_err("empty field must fail");
824 assert!(err.contains("must not be empty"), "err: {err}");
825 }
826 }
827
828 fn execute_with(
829 script: Option<&str>,
830 script_file: Option<&str>,
831 script_object: Option<&str>,
832 ) -> Execute {
833 Execute {
834 shell: ExecuteShell::Powershell,
835 script: script.map(str::to_owned),
836 script_file: script_file.map(str::to_owned),
837 script_object: script_object.map(str::to_owned),
838 timeout: "30s".into(),
839 run_as: RunAs::default(),
840 cwd: None,
841 }
842 }
843
844 #[test]
845 fn validate_accepts_inline_script() {
846 let e = execute_with(Some("echo hi"), None, None);
847 assert!(e.validate_script_source().is_ok());
848 }
849
850 #[test]
851 fn validate_accepts_script_file_alone() {
852 let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
853 assert!(e.validate_script_source().is_ok());
854 }
855
856 #[test]
857 fn validate_accepts_script_object_alone() {
858 let e = execute_with(None, None, Some("cleanup/1.0.0"));
859 assert!(e.validate_script_source().is_ok());
860 }
861
862 #[test]
863 fn validate_treats_empty_inline_script_as_unset() {
864 let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
868 assert!(e.validate_script_source().is_ok());
869 }
870
871 #[test]
872 fn validate_rejects_zero_sources() {
873 let e = execute_with(None, None, None);
874 let err = e.validate_script_source().unwrap_err();
875 assert!(err.contains("must be set"), "got: {err}");
876 }
877
878 #[test]
879 fn validate_rejects_empty_inline_only() {
880 let e = execute_with(Some(""), None, None);
881 let err = e.validate_script_source().unwrap_err();
882 assert!(err.contains("must be set"), "got: {err}");
883 }
884
885 #[test]
886 fn validate_rejects_inline_plus_file() {
887 let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
888 let err = e.validate_script_source().unwrap_err();
889 assert!(err.contains("only one of"), "got: {err}");
890 }
891
892 #[test]
893 fn validate_rejects_inline_plus_object() {
894 let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
895 let err = e.validate_script_source().unwrap_err();
896 assert!(err.contains("only one of"), "got: {err}");
897 }
898
899 #[test]
900 fn validate_rejects_file_plus_object() {
901 let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
902 let err = e.validate_script_source().unwrap_err();
903 assert!(err.contains("only one of"), "got: {err}");
904 }
905
906 #[test]
907 fn validate_rejects_all_three() {
908 let e = execute_with(
909 Some("echo hi"),
910 Some("scripts/cleanup.ps1"),
911 Some("cleanup/1.0.0"),
912 );
913 let err = e.validate_script_source().unwrap_err();
914 assert!(err.contains("only one of"), "got: {err}");
915 }
916
917 #[test]
918 fn manifest_deserialises_script_object_yaml() {
919 let yaml = r#"
922id: cleanup-disk-temp
923version: 1.0.1
924execute:
925 shell: powershell
926 script_object: cleanup-disk-temp/1.0.1
927 timeout: 600s
928"#;
929 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
930 assert_eq!(
931 m.execute.script_object.as_deref(),
932 Some("cleanup-disk-temp/1.0.1")
933 );
934 assert!(m.execute.script.is_none());
935 m.validate()
936 .expect("script_object-only manifest passes validation");
937 }
938
939 #[test]
940 fn manifest_rejects_typo_in_script_field_name() {
941 let yaml = r#"
945id: typo
946version: 1.0.0
947execute:
948 shell: powershell
949 script_objectt: oops
950 timeout: 30s
951"#;
952 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
953 assert!(r.is_err(), "expected parse error, got {r:?}");
954 }
955
956 #[test]
957 fn schedule_carries_target_and_rollout() {
958 let yaml = r#"
959id: hourly-cleanup-canary
960when:
961 per_pc: { every: 1h }
962job_id: cleanup
963enabled: true
964target:
965 groups: [canary, wave1]
966jitter: 30s
967rollout:
968 strategy: wave
969 waves:
970 - { group: canary, delay: 0s }
971 - { group: wave1, delay: 5s }
972"#;
973 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
974 assert_eq!(s.id, "hourly-cleanup-canary");
975 assert_eq!(s.job_id, "cleanup");
976 assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
977 assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
978 let rollout = s.plan.rollout.expect("rollout present");
979 assert_eq!(rollout.waves.len(), 2);
980 assert_eq!(rollout.waves[0].group, "canary");
981 assert_eq!(rollout.waves[1].delay, "5s");
982 assert_eq!(rollout.strategy, RolloutStrategy::Wave);
983 }
984
985 #[test]
986 fn schedule_minimal_target_all() {
987 let yaml = r#"
988id: kitting
989when:
990 per_pc: once
991enabled: true
992job_id: scheduled-echo
993target: { all: true }
994"#;
995 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
996 assert_eq!(s.id, "kitting");
997 assert_eq!(s.when, When::PerPc(PerPolicy::Once(OnceLiteral::Once)));
998 assert!(s.enabled);
999 assert_eq!(s.job_id, "scheduled-echo");
1000 assert!(s.plan.target.all);
1001 assert!(s.plan.rollout.is_none());
1002 assert!(s.plan.jitter.is_none());
1003 assert!(s.active.is_empty());
1004 }
1005
1006 #[test]
1007 fn schedule_enabled_defaults_to_true() {
1008 let yaml = r#"
1009id: x
1010when:
1011 per_pc: once
1012job_id: y
1013target: { all: true }
1014"#;
1015 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1016 assert!(s.enabled);
1017 }
1018
1019 fn schedule_yaml_with(when_block: &str) -> String {
1022 format!(
1023 r#"
1024id: x
1025when:
1026{when_block}
1027job_id: y
1028target: {{ all: true }}
1029"#
1030 )
1031 }
1032
1033 #[test]
1034 fn when_per_pc_every_parses_unquoted_humantime() {
1035 let s: Schedule =
1038 serde_yaml::from_str(&schedule_yaml_with(" per_pc: { every: 6h }")).expect("parse");
1039 assert_eq!(
1040 s.when,
1041 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() }))
1042 );
1043 }
1044
1045 #[test]
1046 fn when_per_target_every_parses() {
1047 let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(" per_target: { every: 24h }"))
1048 .expect("parse");
1049 assert_eq!(
1050 s.when,
1051 When::PerTarget(PerPolicy::Every(EverySpec {
1052 every: "24h".into()
1053 }))
1054 );
1055 }
1056
1057 #[test]
1058 fn when_per_target_once_parses() {
1059 let s: Schedule =
1063 serde_yaml::from_str(&schedule_yaml_with(" per_target: once")).expect("parse");
1064 assert_eq!(s.when, When::PerTarget(PerPolicy::Once(OnceLiteral::Once)));
1065 }
1066
1067 #[test]
1068 fn when_calendar_time_parses() {
1069 let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(
1070 " calendar:\n at: \"09:00\"\n days: [mon-fri]",
1071 ))
1072 .expect("parse");
1073 match &s.when {
1074 When::Calendar(c) => {
1075 assert_eq!(c.at, "09:00");
1076 assert_eq!(c.days, vec!["mon-fri"]);
1077 }
1078 other => panic!("expected calendar, got {other:?}"),
1079 }
1080 }
1081
1082 #[test]
1083 fn when_calendar_days_default_empty() {
1084 let s: Schedule =
1085 serde_yaml::from_str(&schedule_yaml_with(" calendar:\n at: \"09:00\""))
1086 .expect("parse");
1087 match &s.when {
1088 When::Calendar(c) => assert!(c.days.is_empty(), "days defaults to empty (= daily)"),
1089 other => panic!("expected calendar, got {other:?}"),
1090 }
1091 }
1092
1093 #[test]
1094 fn when_calendar_datetime_parses_all_separators() {
1095 for at in ["2026-06-10 09:00", "2026-06-10T09:00", "2026/06/10 09:00"] {
1097 let block = format!(" calendar:\n at: \"{at}\"");
1098 let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(&block))
1099 .unwrap_or_else(|e| panic!("parse '{at}': {e}"));
1100 match &s.when {
1101 When::Calendar(c) => {
1102 use chrono::Datelike;
1103 let p = c.parse_at().expect("parse_at");
1104 let d = p.date.expect("datetime at carries a date");
1105 assert_eq!((d.year(), d.month(), d.day()), (2026, 6, 10), "for '{at}'");
1106 }
1107 other => panic!("expected calendar, got {other:?}"),
1108 }
1109 }
1110 }
1111
1112 #[test]
1113 fn when_rejects_bad_once_keyword() {
1114 let r: Result<Schedule, _> = serde_yaml::from_str(&schedule_yaml_with(" per_pc: onec"));
1118 assert!(r.is_err(), "expected parse error, got {r:?}");
1119 }
1120
1121 #[test]
1122 fn when_rejects_unknown_key_in_every() {
1123 let r: Result<Schedule, _> =
1126 serde_yaml::from_str(&schedule_yaml_with(" per_pc: { evry: 6h }"));
1127 assert!(r.is_err(), "expected parse error, got {r:?}");
1128 }
1129
1130 #[test]
1131 fn when_rejects_unknown_variant() {
1132 let r: Result<Schedule, _> =
1133 serde_yaml::from_str(&schedule_yaml_with(" per_galaxy: once"));
1134 assert!(r.is_err(), "expected parse error, got {r:?}");
1135 }
1136
1137 #[test]
1138 fn when_rejects_old_top_level_cron_field() {
1139 let yaml = r#"
1143id: x
1144cron: "* * * * * *"
1145job_id: y
1146target: { all: true }
1147"#;
1148 let r: Result<Schedule, _> = serde_yaml::from_str(yaml);
1149 assert!(r.is_err(), "expected parse error, got {r:?}");
1150 }
1151
1152 #[test]
1153 fn when_rejects_retired_cron_escape_hatch() {
1154 let r: Result<Schedule, _> =
1158 serde_yaml::from_str(&schedule_yaml_with(" cron: \"0 0 9 * * mon-fri\""));
1159 assert!(
1160 r.is_err(),
1161 "expected parse error for retired cron, got {r:?}"
1162 );
1163 }
1164
1165 #[test]
1166 fn when_round_trips_json_and_yaml() {
1167 for when in [
1172 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1173 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1174 When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1175 When::PerTarget(PerPolicy::Every(EverySpec {
1176 every: "24h".into(),
1177 })),
1178 calendar("09:00", &["mon-fri"]),
1179 calendar("2026-06-10 09:00", &[]),
1180 ] {
1181 let s = schedule_with(when.clone(), RunsOn::Backend);
1182
1183 let json = serde_json::to_string(&s).expect("json serialise");
1184 let back: Schedule = serde_json::from_str(&json).expect("json deserialise");
1185 assert_eq!(back.when, when, "json round-trip for {when}");
1186
1187 let yaml = serde_yaml::to_string(&s).expect("yaml serialise");
1188 assert!(
1189 !yaml.contains('!'),
1190 "yaml must use the map shape, not tags: {yaml}"
1191 );
1192 let back: Schedule = serde_yaml::from_str(&yaml).expect("yaml deserialise");
1193 assert_eq!(back.when, when, "yaml round-trip for {when}");
1194 }
1195 }
1196
1197 #[test]
1198 fn when_once_serialises_as_bare_keyword() {
1199 let json = serde_json::to_value(When::PerPc(PerPolicy::Once(OnceLiteral::Once)))
1202 .expect("serialise");
1203 assert_eq!(json, serde_json::json!({ "per_pc": "once" }));
1204 }
1205
1206 #[test]
1207 fn when_displays_operator_summary() {
1208 for (when, expected) in [
1209 (
1210 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1211 "per_pc once",
1212 ),
1213 (
1214 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1215 "per_pc every 6h",
1216 ),
1217 (
1218 When::PerTarget(PerPolicy::Every(EverySpec {
1219 every: "24h".into(),
1220 })),
1221 "per_target every 24h",
1222 ),
1223 (calendar("09:00", &["mon-fri"]), "at 09:00 [mon-fri]"),
1224 (calendar("2026-06-10 09:00", &[]), "at 2026-06-10 09:00"),
1225 ] {
1226 assert_eq!(when.to_string(), expected);
1227 }
1228 }
1229
1230 fn schedule_with(when: When, runs_on: RunsOn) -> Schedule {
1233 Schedule {
1234 id: "x".into(),
1235 when,
1236 job_id: "y".into(),
1237 plan: FanoutPlan::default(),
1238 active: Active::default(),
1239 constraints: Constraints::default(),
1240 tz: ScheduleTz::default(),
1241 starting_deadline: None,
1242 runs_on,
1243 enabled: true,
1244 }
1245 }
1246
1247 fn calendar(at: &str, days: &[&str]) -> When {
1248 When::Calendar(CalendarSpec {
1249 at: at.into(),
1250 days: days.iter().map(|d| (*d).to_string()).collect(),
1251 })
1252 }
1253
1254 #[test]
1255 fn lowering_matches_the_418_table() {
1256 let cases = [
1257 (
1258 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1259 (POLL_CRON, ExecMode::OncePerPc, None),
1260 ),
1261 (
1262 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1263 (POLL_CRON, ExecMode::OncePerPc, Some("6h")),
1264 ),
1265 (
1266 When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1267 (POLL_CRON, ExecMode::OncePerTarget, None),
1268 ),
1269 (
1270 When::PerTarget(PerPolicy::Every(EverySpec {
1271 every: "24h".into(),
1272 })),
1273 (POLL_CRON, ExecMode::OncePerTarget, Some("24h")),
1274 ),
1275 (
1277 calendar("09:00", &["mon-fri"]),
1278 ("0 0 9 * * mon-fri", ExecMode::EveryTick, None),
1279 ),
1280 (
1282 calendar("18:30", &[]),
1283 ("0 30 18 * * *", ExecMode::EveryTick, None),
1284 ),
1285 (
1287 calendar("2026-06-10 09:00", &[]),
1288 ("0 0 9 10 6 * 2026", ExecMode::EveryTick, None),
1289 ),
1290 ];
1291 for (when, (cron, mode, cooldown)) in cases {
1292 let l = schedule_with(when.clone(), RunsOn::Backend).lowered();
1293 assert_eq!(l.cron, cron, "cron for {when}");
1294 assert_eq!(l.mode, mode, "mode for {when}");
1295 assert_eq!(l.cooldown.as_deref(), cooldown, "cooldown for {when}");
1296 }
1297 }
1298
1299 #[test]
1300 fn lowered_carries_schedule_tz() {
1301 for (tz, want) in [
1302 (ScheduleTz::Local, ScheduleTz::Local),
1303 (ScheduleTz::Utc, ScheduleTz::Utc),
1304 ] {
1305 let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
1306 s.tz = tz;
1307 assert_eq!(s.lowered().tz, want, "calendar carries tz");
1308 let mut s = schedule_with(
1310 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1311 RunsOn::Backend,
1312 );
1313 s.tz = tz;
1314 assert_eq!(s.lowered().tz, want, "reconcile carries tz");
1315 }
1316 }
1317
1318 #[test]
1319 fn poll_cron_is_accepted_by_the_engine_parser() {
1320 croner::parser::CronParser::builder()
1325 .seconds(croner::parser::Seconds::Required)
1326 .dom_and_dow(true)
1327 .build()
1328 .parse(POLL_CRON)
1329 .expect("POLL_CRON must parse");
1330 }
1331
1332 #[test]
1335 fn validate_accepts_reconcile_shapes() {
1336 for when in [
1337 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1338 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1339 When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1340 When::PerTarget(PerPolicy::Every(EverySpec {
1341 every: "24h".into(),
1342 })),
1343 ] {
1344 schedule_with(when.clone(), RunsOn::Backend)
1345 .validate()
1346 .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
1347 }
1348 }
1349
1350 #[test]
1351 fn validate_accepts_per_pc_on_agent() {
1352 schedule_with(
1353 When::PerPc(PerPolicy::Every(EverySpec { every: "1h".into() })),
1354 RunsOn::Agent,
1355 )
1356 .validate()
1357 .expect("per_pc + agent is the offline-inventory shape");
1358 }
1359
1360 #[test]
1361 fn validate_rejects_per_target_on_agent() {
1362 let err = schedule_with(
1363 When::PerTarget(PerPolicy::Every(EverySpec {
1364 every: "24h".into(),
1365 })),
1366 RunsOn::Agent,
1367 )
1368 .validate()
1369 .unwrap_err();
1370 assert!(err.contains("per_target"), "got: {err}");
1371 assert!(err.contains("runs_on: agent"), "got: {err}");
1372
1373 let err = schedule_with(
1375 When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1376 RunsOn::Agent,
1377 )
1378 .validate()
1379 .unwrap_err();
1380 assert!(err.contains("per_target"), "got (once): {err}");
1381 assert!(err.contains("runs_on: agent"), "got (once): {err}");
1382 }
1383
1384 #[test]
1385 fn validate_rejects_bad_every_duration() {
1386 let err = schedule_with(
1387 When::PerPc(PerPolicy::Every(EverySpec { every: "6x".into() })),
1388 RunsOn::Backend,
1389 )
1390 .validate()
1391 .unwrap_err();
1392 assert!(err.contains("when.every"), "got: {err}");
1393 }
1394
1395 #[test]
1396 fn validate_rejects_bad_jitter_and_starting_deadline() {
1397 let mut s = schedule_with(
1398 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1399 RunsOn::Backend,
1400 );
1401 s.plan.jitter = Some("5x".into());
1402 let err = s.validate().unwrap_err();
1403 assert!(err.contains("jitter"), "got: {err}");
1404
1405 let mut s = schedule_with(
1406 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1407 RunsOn::Backend,
1408 );
1409 s.starting_deadline = Some("soon".into());
1410 let err = s.validate().unwrap_err();
1411 assert!(err.contains("starting_deadline"), "got: {err}");
1412 }
1413
1414 #[test]
1415 fn validate_accepts_calendar_shapes() {
1416 for when in [
1417 calendar("09:00", &["mon-fri"]), calendar("00:00", &["sun"]), calendar("18:30", &[]), calendar("2026-06-10 09:00", &[]), calendar("2026/12/25 00:00", &[]), ] {
1423 schedule_with(when.clone(), RunsOn::Backend)
1424 .validate()
1425 .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
1426 }
1427 }
1428
1429 #[test]
1430 fn validate_rejects_bad_at() {
1431 for bad in ["25:00", "09:60", "9", "noon", "2026-13-01 09:00"] {
1432 let err = schedule_with(calendar(bad, &[]), RunsOn::Backend)
1433 .validate()
1434 .unwrap_err();
1435 assert!(err.contains("when.at"), "for '{bad}', got: {err}");
1436 }
1437 }
1438
1439 #[test]
1440 fn validate_rejects_datetime_at_with_days() {
1441 let err = schedule_with(calendar("2026-06-10 09:00", &["mon"]), RunsOn::Backend)
1444 .validate()
1445 .unwrap_err();
1446 assert!(
1447 err.contains("one-shot") && err.contains("days"),
1448 "got: {err}"
1449 );
1450 }
1451
1452 #[test]
1453 fn validate_rejects_bad_day_name() {
1454 let err = schedule_with(calendar("09:00", &["funday"]), RunsOn::Backend)
1458 .validate()
1459 .unwrap_err();
1460 assert!(err.contains("when.days"), "got: {err}");
1461 assert!(err.contains("funday"), "names the bad token: {err}");
1462 let err = schedule_with(calendar("09:00", &["mon-"]), RunsOn::Backend)
1465 .validate()
1466 .unwrap_err();
1467 assert!(err.contains("'mon-'"), "names the whole token: {err}");
1468 for ok in [
1470 calendar("09:00", &["mon-fri"]),
1471 calendar("09:00", &["mon", "wed", "sun"]),
1472 calendar("09:00", &["1-5"]),
1473 ] {
1474 schedule_with(ok.clone(), RunsOn::Backend)
1475 .validate()
1476 .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
1477 }
1478 }
1479
1480 #[test]
1481 fn calendar_oneshot_instant_detects_past() {
1482 use chrono::TimeZone;
1483 let c = CalendarSpec {
1485 at: "2024-01-01 09:00".into(),
1486 days: vec![],
1487 };
1488 let t = c
1489 .oneshot_instant(ScheduleTz::Utc)
1490 .expect("one-shot instant");
1491 assert_eq!(
1492 t,
1493 chrono::Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()
1494 );
1495 assert!(t < chrono::Utc::now(), "2024 is in the past");
1496 let rep = CalendarSpec {
1498 at: "09:00".into(),
1499 days: vec!["mon-fri".into()],
1500 };
1501 assert!(rep.oneshot_instant(ScheduleTz::Utc).is_none());
1502 }
1503
1504 fn schedule_with_active(from: Option<&str>, until: Option<&str>) -> Schedule {
1505 let mut s = schedule_with(
1506 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1507 RunsOn::Backend,
1508 );
1509 s.active = Active {
1510 from: from.map(str::to_owned),
1511 until: until.map(str::to_owned),
1512 };
1513 s
1514 }
1515
1516 #[test]
1517 fn validate_accepts_active_window() {
1518 schedule_with_active(Some("2026-07-01"), Some("2026-08-01T12:00:00+09:00"))
1519 .validate()
1520 .expect("date + rfc3339 bounds should validate");
1521 }
1522
1523 #[test]
1524 fn validate_rejects_unparseable_active_bound() {
1525 let err = schedule_with_active(Some("July 1st"), None)
1526 .validate()
1527 .unwrap_err();
1528 assert!(err.contains("active"), "got: {err}");
1529 }
1530
1531 #[test]
1532 fn validate_rejects_from_not_before_until() {
1533 let err = schedule_with_active(Some("2026-08-01"), Some("2026-07-01"))
1534 .validate()
1535 .unwrap_err();
1536 assert!(err.contains("strictly before"), "got: {err}");
1537
1538 let err = schedule_with_active(Some("2026-07-01"), Some("2026-07-01"))
1539 .validate()
1540 .unwrap_err();
1541 assert!(err.contains("strictly before"), "got: {err}");
1542 }
1543
1544 #[test]
1547 fn active_window_is_half_open() {
1548 use chrono::TimeZone;
1549 let active = Active {
1550 from: Some("2026-07-01".into()),
1551 until: Some("2026-08-01".into()),
1552 };
1553 let at = |y, m, d, h| chrono::Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap();
1555 let c = |t| active.contains(t, ScheduleTz::Utc);
1556 assert!(!c(at(2026, 6, 30, 23)), "before from");
1557 assert!(c(at(2026, 7, 1, 0)), "at from (inclusive)");
1558 assert!(c(at(2026, 7, 15, 12)), "inside");
1559 assert!(!c(at(2026, 8, 1, 0)), "at until (exclusive)");
1560 assert!(!c(at(2026, 8, 2, 0)), "after until");
1561 }
1562
1563 #[test]
1564 fn active_empty_window_is_always_active() {
1565 assert!(Active::default().contains(chrono::Utc::now(), ScheduleTz::Local));
1566 }
1567
1568 #[test]
1569 fn active_rfc3339_bound_honours_offset_regardless_of_tz() {
1570 use chrono::TimeZone;
1571 let active = Active {
1572 from: Some("2026-07-01T09:00:00+09:00".into()),
1573 until: None,
1574 };
1575 for tz in [ScheduleTz::Utc, ScheduleTz::Local] {
1578 assert!(
1579 !active.contains(
1580 chrono::Utc
1581 .with_ymd_and_hms(2026, 6, 30, 23, 59, 0)
1582 .unwrap(),
1583 tz
1584 )
1585 );
1586 assert!(active.contains(
1587 chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
1588 tz
1589 ));
1590 }
1591 }
1592
1593 #[test]
1594 fn active_date_bound_respects_tz() {
1595 use chrono::TimeZone;
1599 let utc = Active::parse_bound("2026-07-01", ScheduleTz::Utc).expect("utc");
1600 assert_eq!(
1601 utc,
1602 chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()
1603 );
1604
1605 let local = Active::parse_bound("2026-07-01", ScheduleTz::Local).expect("local");
1612 let want = chrono::Local
1613 .with_ymd_and_hms(2026, 7, 1, 0, 0, 0)
1614 .single()
1615 .expect("local midnight is unambiguous")
1616 .with_timezone(&chrono::Utc);
1617 assert_eq!(local, want, "date bound resolved in host-local tz");
1618 }
1619
1620 #[test]
1621 fn active_empty_is_skipped_when_serialising() {
1622 let s = schedule_with(
1623 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1624 RunsOn::Backend,
1625 );
1626 let json = serde_json::to_value(&s).expect("serialise");
1627 assert!(
1628 json.get("active").is_none(),
1629 "empty active must not appear on the wire: {json}"
1630 );
1631 }
1632
1633 fn with_window(win: &str) -> Schedule {
1636 let mut s = schedule_with(
1637 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1638 RunsOn::Backend,
1639 );
1640 s.constraints.window = Some(win.into());
1641 s
1642 }
1643
1644 #[test]
1645 fn constraints_window_parses_and_round_trips() {
1646 let yaml = r#"
1647id: x
1648when:
1649 per_pc: { every: 6h }
1650job_id: y
1651target: { all: true }
1652constraints:
1653 window: "22:00-05:00"
1654"#;
1655 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1656 assert_eq!(s.constraints.window.as_deref(), Some("22:00-05:00"));
1657 let back: Schedule =
1658 serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
1659 assert_eq!(back.constraints.window.as_deref(), Some("22:00-05:00"));
1660 }
1661
1662 #[test]
1663 fn constraints_empty_is_skipped_when_serialising() {
1664 let s = schedule_with(
1665 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1666 RunsOn::Backend,
1667 );
1668 let json = serde_json::to_value(&s).expect("serialise");
1669 assert!(
1670 json.get("constraints").is_none(),
1671 "empty constraints must not appear on the wire: {json}"
1672 );
1673 }
1674
1675 #[test]
1676 fn window_no_constraint_always_allows() {
1677 let c = Constraints::default();
1678 assert!(c.allows(chrono::Utc::now(), ScheduleTz::Local));
1679 }
1680
1681 #[test]
1682 fn window_same_day_is_half_open() {
1683 use chrono::TimeZone;
1684 let s = with_window("09:00-17:00");
1685 let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
1686 let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
1687 assert!(!a(at(8, 59)), "before start");
1688 assert!(a(at(9, 0)), "at start (inclusive)");
1689 assert!(a(at(16, 59)), "inside");
1690 assert!(!a(at(17, 0)), "at end (exclusive)");
1691 assert!(!a(at(23, 0)), "after end");
1692 }
1693
1694 #[test]
1695 fn window_crossing_midnight() {
1696 use chrono::TimeZone;
1697 let s = with_window("22:00-05:00");
1698 let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
1699 let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
1700 assert!(a(at(22, 0)), "at start tonight");
1701 assert!(a(at(23, 30)), "late tonight");
1702 assert!(a(at(3, 0)), "early tomorrow");
1703 assert!(!a(at(5, 0)), "at end (exclusive)");
1704 assert!(!a(at(12, 0)), "midday outside");
1705 assert!(!a(at(21, 59)), "just before start");
1706 }
1707
1708 #[test]
1709 fn window_respects_tz() {
1710 use chrono::TimeZone;
1715 let s = with_window("09:00-17:00");
1716 let noon_utc = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap();
1717 assert!(s.constraints.allows(noon_utc, ScheduleTz::Utc));
1719 let local_t = noon_utc.with_timezone(&chrono::Local).time();
1722 let in_local = local_t >= chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap()
1723 && local_t < chrono::NaiveTime::from_hms_opt(17, 0, 0).unwrap();
1724 assert_eq!(s.constraints.allows(noon_utc, ScheduleTz::Local), in_local);
1725 }
1726
1727 #[test]
1728 fn validate_accepts_good_window() {
1729 for w in ["09:00-17:00", "22:00-05:00", "00:00-23:59"] {
1730 with_window(w)
1731 .validate()
1732 .unwrap_or_else(|e| panic!("'{w}' should validate: {e}"));
1733 }
1734 }
1735
1736 #[test]
1737 fn validate_rejects_bad_window() {
1738 for bad in ["9-5", "22:00", "22:00-22:00", "25:00-05:00", "09:00_17:00"] {
1739 let err = with_window(bad).validate().unwrap_err();
1740 assert!(
1741 err.contains("constraints.window"),
1742 "for '{bad}', got: {err}"
1743 );
1744 }
1745 }
1746
1747 #[test]
1748 fn window_fail_closed_on_corrupt_blob() {
1749 let s = with_window("22:00_05:00");
1753 assert!(
1754 !s.constraints.allows(chrono::Utc::now(), ScheduleTz::Utc),
1755 "corrupt window fails closed"
1756 );
1757 assert!(
1759 s.bad_window().is_some(),
1760 "bad_window reports the parse error"
1761 );
1762 assert!(with_window("22:00-05:00").bad_window().is_none());
1763 }
1764
1765 #[test]
1766 fn calendar_outside_window_is_flagged() {
1767 let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
1769 s.constraints.window = Some("22:00-05:00".into());
1770 assert!(s.calendar_outside_window(), "09:00 is not in 22:00-05:00");
1771
1772 let mut s = schedule_with(calendar("23:00", &[]), RunsOn::Backend);
1774 s.constraints.window = Some("22:00-05:00".into());
1775 assert!(!s.calendar_outside_window(), "23:00 is in 22:00-05:00");
1776
1777 let mut s = schedule_with(
1779 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1780 RunsOn::Backend,
1781 );
1782 s.constraints.window = Some("22:00-05:00".into());
1783 assert!(!s.calendar_outside_window(), "reconcile is unaffected");
1784
1785 let s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
1787 assert!(!s.calendar_outside_window());
1788 }
1789
1790 #[test]
1791 fn shipped_schedule_configs_parse_and_validate() {
1792 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs/schedules");
1797 let mut seen = 0;
1798 for entry in std::fs::read_dir(&dir).expect("read configs/schedules") {
1799 let path = entry.expect("dir entry").path();
1800 if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
1801 continue;
1802 }
1803 let body = std::fs::read_to_string(&path).expect("read yaml");
1804 let s: Schedule = serde_yaml::from_str(&body)
1805 .unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display()));
1806 s.validate()
1807 .unwrap_or_else(|e| panic!("{} failed validate(): {e}", path.display()));
1808 seen += 1;
1809 }
1810 assert!(seen > 0, "no schedule YAMLs found in {}", dir.display());
1811 }
1812
1813 #[test]
1816 fn exec_mode_serialises_snake_case() {
1817 for (mode, expected) in [
1818 (ExecMode::EveryTick, "every_tick"),
1819 (ExecMode::OncePerPc, "once_per_pc"),
1820 (ExecMode::OncePerTarget, "once_per_target"),
1821 ] {
1822 let s = serde_json::to_value(mode).expect("serialise");
1823 assert_eq!(s, serde_json::Value::String(expected.into()));
1824 let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
1825 .expect("deserialise");
1826 assert_eq!(back, mode, "round-trip for {expected}");
1827 }
1828 }
1829
1830 #[test]
1831 fn schedule_runs_on_defaults_to_backend() {
1832 let yaml = r#"
1833id: x
1834when:
1835 per_pc: once
1836job_id: y
1837target: { all: true }
1838"#;
1839 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1840 assert_eq!(s.runs_on, RunsOn::Backend);
1841 }
1842
1843 #[test]
1844 fn schedule_runs_on_agent_parses() {
1845 let yaml = r#"
1846id: offline-inv
1847when:
1848 per_pc: { every: 1h }
1849job_id: inventory-hw
1850target: { all: true }
1851runs_on: agent
1852"#;
1853 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1854 assert_eq!(s.runs_on, RunsOn::Agent);
1855 assert_eq!(s.lowered().mode, ExecMode::OncePerPc);
1856 }
1857
1858 #[test]
1859 fn runs_on_serialises_snake_case() {
1860 for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
1861 let s = serde_json::to_value(mode).expect("serialise");
1862 assert_eq!(s, serde_json::Value::String(expected.into()));
1863 let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
1864 .expect("deserialise");
1865 assert_eq!(back, mode);
1866 }
1867 }
1868
1869 #[test]
1870 fn execute_shell_into_wire_shell() {
1871 assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
1872 assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
1873 }
1874
1875 #[test]
1876 fn manifest_staleness_defaults_to_cached() {
1877 let yaml = r#"
1878id: x
1879version: 1.0.0
1880execute:
1881 shell: powershell
1882 script: "echo"
1883 timeout: 1s
1884"#;
1885 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1886 assert_eq!(m.staleness, Staleness::Cached);
1887 }
1888
1889 #[test]
1890 fn manifest_strict_staleness_parses() {
1891 let yaml = r#"
1892id: urgent-patch
1893version: 2.5.1
1894execute:
1895 shell: powershell
1896 script: Install-Hotfix
1897 timeout: 5m
1898staleness:
1899 mode: strict
1900 max_cache_age: 0s
1901"#;
1902 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1903 match m.staleness {
1904 Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
1905 other => panic!("expected strict, got {other:?}"),
1906 }
1907 }
1908
1909 #[test]
1910 fn manifest_unchecked_staleness_parses() {
1911 let yaml = r#"
1912id: legacy
1913version: 0.1.0
1914execute:
1915 shell: cmd
1916 script: "echo"
1917 timeout: 1s
1918staleness:
1919 mode: unchecked
1920"#;
1921 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1922 assert_eq!(m.staleness, Staleness::Unchecked);
1923 }
1924
1925 #[test]
1926 fn missing_required_field_errors() {
1927 let yaml = r#"
1929version: 1.0.0
1930target: { all: true }
1931execute:
1932 shell: powershell
1933 script: "echo"
1934 timeout: 1s
1935"#;
1936 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
1937 assert!(r.is_err(), "expected error, got {:?}", r);
1938 }
1939
1940 #[test]
1941 fn display_field_table_kind_round_trips_with_nested_columns() {
1942 let yaml = r#"
1948id: inv-hw
1949version: 1.0.0
1950execute:
1951 shell: powershell
1952 script: "echo"
1953 timeout: 60s
1954inventory:
1955 display:
1956 - field: hostname
1957 label: Hostname
1958 - field: disks
1959 label: Disks
1960 type: table
1961 columns:
1962 - field: device_id
1963 label: Drive
1964 - field: size_bytes
1965 label: Size
1966 type: bytes
1967 - field: free_bytes
1968 label: Free
1969 type: bytes
1970 - field: file_system
1971 label: FS
1972"#;
1973 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1974 let inv = m.inventory.as_ref().expect("inventory hint");
1975 let disks = inv
1976 .display
1977 .iter()
1978 .find(|d| d.field == "disks")
1979 .expect("disks display row");
1980 assert_eq!(disks.kind.as_deref(), Some("table"));
1981 let cols = disks.columns.as_ref().expect("table needs columns");
1982 assert_eq!(cols.len(), 4);
1983 assert_eq!(cols[1].field, "size_bytes");
1984 assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
1985 }
1986
1987 #[test]
1988 fn display_field_scalar_kind_keeps_columns_none() {
1989 let yaml = r#"
1994id: x
1995version: 1.0.0
1996execute:
1997 shell: powershell
1998 script: "echo"
1999 timeout: 5s
2000inventory:
2001 display:
2002 - { field: ram_bytes, label: RAM, type: bytes }
2003"#;
2004 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2005 let inv = m.inventory.as_ref().unwrap();
2006 assert!(inv.display[0].columns.is_none());
2007 }
2008}
2009
2010#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
2024pub struct Schedule {
2025 pub id: String,
2026 #[serde(with = "serde_yaml::with::singleton_map")]
2036 #[schemars(with = "When")]
2037 pub when: When,
2038 pub job_id: String,
2041 #[serde(flatten)]
2045 pub plan: FanoutPlan,
2046 #[serde(default, skip_serializing_if = "Active::is_empty")]
2053 pub active: Active,
2054 #[serde(default, skip_serializing_if = "Constraints::is_empty")]
2061 pub constraints: Constraints,
2062 #[serde(default)]
2071 pub tz: ScheduleTz,
2072 #[serde(default, skip_serializing_if = "Option::is_none")]
2083 pub starting_deadline: Option<String>,
2084 #[serde(default)]
2094 pub runs_on: RunsOn,
2095 #[serde(default = "default_true")]
2096 pub enabled: bool,
2097}
2098
2099#[derive(
2101 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2102)]
2103#[serde(rename_all = "snake_case")]
2104pub enum RunsOn {
2105 #[default]
2111 Backend,
2112 Agent,
2118}
2119
2120#[derive(
2122 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2123)]
2124#[serde(rename_all = "snake_case")]
2125pub enum ExecMode {
2126 #[default]
2129 EveryTick,
2130 OncePerPc,
2134 OncePerTarget,
2139}
2140
2141#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2158#[serde(rename_all = "snake_case")]
2159pub enum When {
2160 PerPc(PerPolicy),
2165 PerTarget(PerPolicy),
2171 Calendar(CalendarSpec),
2176}
2177
2178#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2183#[serde(deny_unknown_fields)]
2184pub struct CalendarSpec {
2185 pub at: String,
2190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2196 pub days: Vec<String>,
2197}
2198
2199struct ParsedAt {
2202 minute: u32,
2203 hour: u32,
2204 date: Option<chrono::NaiveDate>,
2205}
2206
2207impl CalendarSpec {
2208 fn parse_at(&self) -> Result<ParsedAt, String> {
2211 use chrono::Timelike;
2212 let s = self.at.trim();
2213 for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y/%m/%d %H:%M"] {
2214 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
2215 return Ok(ParsedAt {
2216 minute: dt.minute(),
2217 hour: dt.hour(),
2218 date: Some(dt.date()),
2219 });
2220 }
2221 }
2222 if let Ok(t) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
2223 return Ok(ParsedAt {
2224 minute: t.minute(),
2225 hour: t.hour(),
2226 date: None,
2227 });
2228 }
2229 Err(format!(
2230 "when.at: unparseable '{}' (want HH:MM or YYYY-MM-DD HH:MM)",
2231 self.at
2232 ))
2233 }
2234
2235 fn validate_days(&self) -> Result<(), String> {
2241 const NAMES: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
2242 for tok in &self.days {
2243 let invalid = |reason: &str| {
2247 Err(format!(
2248 "when.days: invalid day token '{tok}' ({reason}; \
2249 want mon..sun, 0-7, a range like mon-fri, or *)"
2250 ))
2251 };
2252 for part in tok.split('-') {
2253 let p = part.trim().to_ascii_lowercase();
2254 if p.is_empty() {
2255 return invalid("empty range bound");
2256 }
2257 let ok = p == "*"
2258 || NAMES.contains(&p.as_str())
2259 || p.parse::<u8>().map(|n| n <= 7).unwrap_or(false);
2260 if !ok {
2261 return invalid(&format!("'{part}' is not a day"));
2262 }
2263 }
2264 }
2265 Ok(())
2266 }
2267
2268 pub fn oneshot_instant(&self, tz: ScheduleTz) -> Option<chrono::DateTime<chrono::Utc>> {
2273 let p = self.parse_at().ok()?;
2274 let date = p.date?;
2275 let naive = date.and_hms_opt(p.hour, p.minute, 0)?;
2276 tz.naive_to_utc(naive)
2277 }
2278
2279 pub fn fire_time(&self) -> Option<chrono::NaiveTime> {
2284 let p = self.parse_at().ok()?;
2285 chrono::NaiveTime::from_hms_opt(p.hour, p.minute, 0)
2286 }
2287
2288 fn to_cron(&self) -> Result<String, String> {
2293 use chrono::Datelike;
2294 let ParsedAt { minute, hour, date } = self.parse_at()?;
2295 match date {
2296 Some(d) => {
2297 if !self.days.is_empty() {
2298 return Err(
2299 "when.at with a date is a one-shot and cannot be combined with days".into(),
2300 );
2301 }
2302 Ok(format!(
2303 "0 {minute} {hour} {} {} * {}",
2304 d.day(),
2305 d.month(),
2306 d.year()
2307 ))
2308 }
2309 None => {
2310 let dow = if self.days.is_empty() {
2311 "*".to_string()
2312 } else {
2313 self.validate_days()?;
2314 self.days.join(",")
2315 };
2316 Ok(format!("0 {minute} {hour} * * {dow}"))
2317 }
2318 }
2319 }
2320}
2321
2322#[derive(
2325 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2326)]
2327#[serde(rename_all = "snake_case")]
2328pub enum ScheduleTz {
2329 #[default]
2332 Local,
2333 Utc,
2335}
2336
2337impl ScheduleTz {
2338 fn naive_to_utc(self, naive: chrono::NaiveDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
2347 use chrono::TimeZone;
2348 match self {
2349 ScheduleTz::Utc => Some(chrono::DateTime::from_naive_utc_and_offset(
2350 naive,
2351 chrono::Utc,
2352 )),
2353 ScheduleTz::Local => chrono::Local
2354 .from_local_datetime(&naive)
2355 .earliest()
2356 .map(|dt| dt.with_timezone(&chrono::Utc)),
2357 }
2358 }
2359
2360 fn wall_time(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveTime {
2365 match self {
2366 ScheduleTz::Utc => now.time(),
2367 ScheduleTz::Local => now.with_timezone(&chrono::Local).time(),
2368 }
2369 }
2370}
2371
2372#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2376#[serde(untagged)]
2377pub enum PerPolicy {
2378 Once(OnceLiteral),
2381 Every(EverySpec),
2383}
2384
2385#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
2388#[serde(rename_all = "snake_case")]
2389pub enum OnceLiteral {
2390 Once,
2391}
2392
2393#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2398#[serde(deny_unknown_fields)]
2399pub struct EverySpec {
2400 pub every: String,
2403}
2404
2405impl PerPolicy {
2406 fn cooldown(&self) -> Option<String> {
2409 match self {
2410 PerPolicy::Once(_) => None,
2411 PerPolicy::Every(EverySpec { every }) => Some(every.clone()),
2412 }
2413 }
2414}
2415
2416impl std::fmt::Display for When {
2417 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2421 let policy = |p: &PerPolicy| match p {
2422 PerPolicy::Once(_) => "once".to_string(),
2423 PerPolicy::Every(EverySpec { every }) => format!("every {every}"),
2424 };
2425 match self {
2426 When::PerPc(p) => write!(f, "per_pc {}", policy(p)),
2427 When::PerTarget(p) => write!(f, "per_target {}", policy(p)),
2428 When::Calendar(c) if c.days.is_empty() => write!(f, "at {}", c.at),
2429 When::Calendar(c) => write!(f, "at {} [{}]", c.at, c.days.join(",")),
2430 }
2431 }
2432}
2433
2434#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
2446#[serde(deny_unknown_fields)]
2447pub struct Active {
2448 #[serde(default, skip_serializing_if = "Option::is_none")]
2450 pub from: Option<String>,
2451 #[serde(default, skip_serializing_if = "Option::is_none")]
2453 pub until: Option<String>,
2454}
2455
2456impl Active {
2457 pub fn is_empty(&self) -> bool {
2460 self.from.is_none() && self.until.is_none()
2461 }
2462
2463 pub fn parse_bound(s: &str, tz: ScheduleTz) -> Result<chrono::DateTime<chrono::Utc>, String> {
2466 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
2467 return Ok(dt.with_timezone(&chrono::Utc));
2468 }
2469 if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
2470 let midnight = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
2471 return tz.naive_to_utc(midnight).ok_or_else(|| {
2472 format!("active: bound '{s}' falls in a DST gap for the schedule's tz")
2473 });
2474 }
2475 Err(format!(
2476 "active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
2477 ))
2478 }
2479
2480 pub fn contains(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
2485 let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s, tz).ok());
2486 if bound(&self.from).is_some_and(|from| now < from) {
2487 return false;
2488 }
2489 if bound(&self.until).is_some_and(|until| now >= until) {
2490 return false;
2491 }
2492 true
2493 }
2494}
2495
2496#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
2503#[serde(deny_unknown_fields)]
2504pub struct Constraints {
2505 #[serde(default, skip_serializing_if = "Option::is_none")]
2512 pub window: Option<String>,
2513}
2514
2515impl Constraints {
2516 pub fn is_empty(&self) -> bool {
2519 self.window.is_none()
2520 }
2521
2522 pub fn parse_window(s: &str) -> Result<(chrono::NaiveTime, chrono::NaiveTime), String> {
2526 let (a, b) = s
2527 .split_once('-')
2528 .ok_or_else(|| format!("constraints.window: '{s}' must be 'HH:MM-HH:MM'"))?;
2529 let parse = |part: &str| {
2530 chrono::NaiveTime::parse_from_str(part.trim(), "%H:%M")
2531 .map_err(|e| format!("constraints.window: invalid time '{}': {e}", part.trim()))
2532 };
2533 let (start, end) = (parse(a)?, parse(b)?);
2534 if start == end {
2535 return Err(format!(
2536 "constraints.window: start and end are equal ('{s}'); omit window for 'always'"
2537 ));
2538 }
2539 Ok((start, end))
2540 }
2541
2542 pub fn allows(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
2556 match self.window.as_deref() {
2557 None => true,
2559 Some(_) => self.window_contains(tz.wall_time(now)).unwrap_or(false),
2562 }
2563 }
2564
2565 fn window_contains(&self, t: chrono::NaiveTime) -> Option<bool> {
2569 let (start, end) = Self::parse_window(self.window.as_deref()?).ok()?;
2570 Some(if start <= end {
2571 start <= t && t < end
2572 } else {
2573 t >= start || t < end
2574 })
2575 }
2576}
2577
2578pub const POLL_CRON: &str = "0 * * * * *";
2584
2585pub struct Lowered {
2590 pub cron: String,
2593 pub mode: ExecMode,
2595 pub cooldown: Option<String>,
2598 pub tz: ScheduleTz,
2603}
2604
2605impl Schedule {
2606 pub fn bad_window(&self) -> Option<String> {
2611 let w = self.constraints.window.as_deref()?;
2612 Constraints::parse_window(w).err()
2613 }
2614
2615 pub fn calendar_outside_window(&self) -> bool {
2623 let When::Calendar(c) = &self.when else {
2624 return false;
2625 };
2626 let Some(t) = c.fire_time() else {
2627 return false;
2628 };
2629 matches!(self.constraints.window_contains(t), Some(false))
2630 }
2631
2632 pub fn lowered(&self) -> Lowered {
2636 let tz = self.tz;
2637 match &self.when {
2638 When::PerPc(p) => Lowered {
2639 cron: POLL_CRON.into(),
2640 mode: ExecMode::OncePerPc,
2641 cooldown: p.cooldown(),
2642 tz,
2643 },
2644 When::PerTarget(p) => Lowered {
2645 cron: POLL_CRON.into(),
2646 mode: ExecMode::OncePerTarget,
2647 cooldown: p.cooldown(),
2648 tz,
2649 },
2650 When::Calendar(c) => Lowered {
2656 cron: c
2657 .to_cron()
2658 .unwrap_or_else(|_| "# invalid calendar at".into()),
2659 mode: ExecMode::EveryTick,
2660 cooldown: None,
2661 tz,
2662 },
2663 }
2664 }
2665
2666 pub fn validate(&self) -> Result<(), String> {
2674 if matches!(self.runs_on, RunsOn::Agent) && matches!(self.when, When::PerTarget(_)) {
2675 return Err(
2676 "when.per_target needs fleet-wide completion data and is backend-only; \
2677 it cannot be combined with runs_on: agent (each agent self-schedules, \
2678 so per-target dedup would be deduping across a target of 1)"
2679 .into(),
2680 );
2681 }
2682 if let Some(cd) = self.lowered().cooldown.as_deref() {
2683 humantime::parse_duration(cd)
2684 .map_err(|e| format!("when.every: invalid duration '{cd}': {e}"))?;
2685 }
2686 if let When::Calendar(c) = &self.when {
2687 let cron = c.to_cron()?;
2694 croner::parser::CronParser::builder()
2695 .seconds(croner::parser::Seconds::Required)
2696 .dom_and_dow(true)
2697 .build()
2698 .parse(&cron)
2699 .map_err(|e| format!("when.at lowered to invalid cron '{cron}': {e}"))?;
2700 }
2701 if let Some(j) = &self.plan.jitter {
2707 humantime::parse_duration(j)
2708 .map_err(|e| format!("jitter: invalid duration '{j}': {e}"))?;
2709 }
2710 if let Some(sd) = &self.starting_deadline {
2711 humantime::parse_duration(sd)
2712 .map_err(|e| format!("starting_deadline: invalid duration '{sd}': {e}"))?;
2713 }
2714 let from = self
2715 .active
2716 .from
2717 .as_deref()
2718 .map(|s| Active::parse_bound(s, self.tz))
2719 .transpose()?;
2720 let until = self
2721 .active
2722 .until
2723 .as_deref()
2724 .map(|s| Active::parse_bound(s, self.tz))
2725 .transpose()?;
2726 if let (Some(f), Some(u)) = (from, until) {
2727 if f >= u {
2728 return Err(format!(
2729 "active.from ({}) must be strictly before active.until ({})",
2730 self.active.from.as_deref().unwrap_or_default(),
2731 self.active.until.as_deref().unwrap_or_default(),
2732 ));
2733 }
2734 }
2735 if let Some(w) = self.constraints.window.as_deref() {
2738 Constraints::parse_window(w)?;
2739 }
2740 Ok(())
2741 }
2742}
2743
2744fn default_true() -> bool {
2745 true
2746}