1use serde::{Deserialize, Serialize};
2
3use crate::ipc::jobs::JobCategory;
4use crate::wire::{RunAs, Shell, Staleness};
5
6#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
18#[serde(deny_unknown_fields)]
19pub struct Manifest {
20 pub id: String,
21 pub version: String,
22 #[serde(default)]
23 pub description: Option<String>,
24 pub execute: Execute,
25 #[serde(default)]
26 pub require_approval: bool,
27 #[serde(default)]
33 pub inventory: Option<InventoryHint>,
34 #[serde(default)]
48 pub emit: Option<EmitConfig>,
49 #[serde(default)]
63 pub check: Option<CheckHint>,
64 #[serde(default)]
72 pub staleness: Staleness,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub client: Option<ClientHint>,
91}
92
93#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
98pub struct FanoutPlan {
99 #[serde(default)]
100 pub target: Target,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub rollout: Option<Rollout>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub jitter: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
123}
124
125#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
138pub struct InventoryHint {
139 pub display: Vec<DisplayField>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub summary: Option<Vec<DisplayField>>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub explode: Option<Vec<ExplodeSpec>>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub history_scalars: Option<Vec<String>>,
172}
173
174#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
197#[serde(deny_unknown_fields)]
198pub struct CheckHint {
199 pub name: String,
203 #[serde(default = "default_status_field")]
208 pub status_field: String,
209 #[serde(default = "default_detail_field")]
213 pub detail_field: String,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub troubleshoot: Option<String>,
220 #[serde(default = "default_fleet")]
226 pub fleet: bool,
227}
228
229fn default_status_field() -> String {
230 "status".to_string()
231}
232
233fn default_detail_field() -> String {
234 "detail".to_string()
235}
236
237fn default_fleet() -> bool {
238 true
239}
240
241#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
254#[serde(deny_unknown_fields)]
255pub struct ClientHint {
256 pub name: String,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub description: Option<String>,
267 pub category: JobCategory,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub icon: Option<String>,
278}
279
280#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
285#[serde(deny_unknown_fields)]
286pub struct EmitConfig {
287 #[serde(rename = "type")]
292 pub kind: EmitKind,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub watermark_path: Option<String>,
303}
304
305#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
308#[serde(rename_all = "lowercase")]
309pub enum EmitKind {
310 Events,
314}
315
316#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
323pub struct ExplodeSpec {
324 pub field: String,
327 pub table: String,
332 pub primary_key: Vec<String>,
345 pub columns: Vec<ExplodeColumn>,
347 #[serde(default)]
359 pub track_history: bool,
360}
361
362#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
364pub struct ExplodeColumn {
365 pub field: String,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
373 #[serde(rename = "type")]
374 pub kind: Option<String>,
375 #[serde(default)]
380 pub index: bool,
381}
382
383#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
384pub struct DisplayField {
385 pub field: String,
387 pub label: String,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
397 #[serde(rename = "type")]
398 pub kind: Option<String>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub columns: Option<Vec<DisplayField>>,
408}
409
410#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
411pub struct Rollout {
412 #[serde(default)]
413 pub strategy: RolloutStrategy,
414 pub waves: Vec<Wave>,
415}
416
417#[derive(
418 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
419)]
420#[serde(rename_all = "lowercase")]
421pub enum RolloutStrategy {
422 #[default]
423 Wave,
424}
425
426#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
427pub struct Wave {
428 pub group: String,
429 pub delay: String,
432}
433
434#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
435pub struct Target {
436 #[serde(default)]
437 pub groups: Vec<String>,
438 #[serde(default)]
439 pub pcs: Vec<String>,
440 #[serde(default)]
441 pub all: bool,
442}
443
444impl Target {
445 pub fn is_specified(&self) -> bool {
447 self.all || !self.groups.is_empty() || !self.pcs.is_empty()
448 }
449}
450
451#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
452#[serde(deny_unknown_fields)]
453pub struct Execute {
454 pub shell: ExecuteShell,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub script: Option<String>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub script_file: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub script_object: Option<String>,
495 pub timeout: String,
498 #[serde(default)]
502 pub run_as: RunAs,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub cwd: Option<String>,
514}
515
516impl Execute {
517 fn has_inline_script(&self) -> bool {
521 matches!(&self.script, Some(s) if !s.is_empty())
522 }
523
524 pub fn validate_script_source(&self) -> Result<(), String> {
532 let inline = self.has_inline_script();
533 let file = self.script_file.is_some();
534 let obj = self.script_object.is_some();
535 let set = [inline, file, obj].into_iter().filter(|b| *b).count();
536 match set {
537 1 => Ok(()),
538 0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
539 _ => Err(format!(
540 "execute: only one of `script` / `script_file` / `script_object` may be set \
541 (got script={inline}, script_file={file}, script_object={obj})"
542 )),
543 }
544 }
545}
546
547impl Manifest {
548 pub fn validate(&self) -> Result<(), String> {
553 self.execute.validate_script_source()?;
554 if self.emit.is_some() && (self.inventory.is_some() || self.check.is_some()) {
561 return Err(
562 "`emit:` is incompatible with `inventory:` / `check:` — emit's stdout is NDJSON \
563 timeline events (and omitted from the result), while inventory/check read a \
564 single JSON object from stdout"
565 .to_string(),
566 );
567 }
568 if let Some(check) = &self.check {
574 for (label, value) in [
575 ("check.name", &check.name),
576 ("check.status_field", &check.status_field),
577 ("check.detail_field", &check.detail_field),
578 ] {
579 if value.trim().is_empty() {
580 return Err(format!("{label} must not be empty"));
581 }
582 }
583 if let Some(troubleshoot) = &check.troubleshoot {
587 if troubleshoot.trim().is_empty() {
588 return Err("check.troubleshoot must not be empty when set".to_string());
589 }
590 }
591 }
592 if let Some(client) = &self.client {
598 if client.name.trim().is_empty() {
599 return Err("client.name must not be empty".to_string());
600 }
601 for (label, value) in [
607 ("client.description", &client.description),
608 ("client.icon", &client.icon),
609 ] {
610 if let Some(v) = value {
611 if v.trim().is_empty() {
612 return Err(format!("{label} must not be empty when set"));
613 }
614 }
615 }
616 }
617 Ok(())
618 }
619}
620
621#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
622#[serde(rename_all = "lowercase")]
623pub enum ExecuteShell {
624 Powershell,
625 Cmd,
626}
627
628impl From<ExecuteShell> for Shell {
629 fn from(s: ExecuteShell) -> Self {
630 match s {
631 ExecuteShell::Powershell => Shell::Powershell,
632 ExecuteShell::Cmd => Shell::Cmd,
633 }
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 #[test]
646 fn example_check_job_yamls_parse_and_validate() {
647 let jobs = [
648 (
649 "check-bitlocker",
650 include_str!("../../../configs/jobs/check-bitlocker.yaml"),
651 ),
652 (
653 "check-av-signature",
654 include_str!("../../../configs/jobs/check-av-signature.yaml"),
655 ),
656 (
657 "check-cert-expiry",
658 include_str!("../../../configs/jobs/check-cert-expiry.yaml"),
659 ),
660 ];
661 for (name, yaml) in jobs {
662 let m: Manifest =
663 serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} parse: {e}"));
664 m.validate()
665 .unwrap_or_else(|e| panic!("{name} validate: {e}"));
666 let check = m
667 .check
668 .as_ref()
669 .unwrap_or_else(|| panic!("{name} must carry a check: hint"));
670 assert!(!check.name.trim().is_empty(), "{name} check.name empty");
671 assert_eq!(
676 m.execute.run_as,
677 RunAs::System,
678 "{name} should run_as system"
679 );
680 }
681 }
682
683 #[test]
688 fn example_client_job_yamls_parse_and_validate() {
689 let jobs = [
690 (
691 "fix-teams-cache",
692 JobCategory::Troubleshoot,
693 include_str!("../../../configs/jobs/fix-teams-cache.yaml"),
694 ),
695 (
696 "chrome-update",
697 JobCategory::SoftwareUpdate,
698 include_str!("../../../configs/jobs/chrome-update.yaml"),
699 ),
700 (
701 "install-slack",
702 JobCategory::Catalog,
703 include_str!("../../../configs/jobs/install-slack.yaml"),
704 ),
705 ];
706 for (id, category, yaml) in jobs {
707 let m: Manifest =
708 serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
709 m.validate()
710 .unwrap_or_else(|e| panic!("{id} validate: {e}"));
711 assert_eq!(m.id, id, "{id} id mismatch");
712 let client = m
713 .client
714 .as_ref()
715 .unwrap_or_else(|| panic!("{id} must carry a client: block"));
716 assert!(!client.name.trim().is_empty(), "{id} client.name empty");
717 assert_eq!(client.category, category, "{id} category");
718 }
719 }
720
721 #[test]
722 fn example_check_schedule_yamls_parse_and_validate() {
723 let schedules = [
724 (
725 "check-bitlocker",
726 include_str!("../../../configs/schedules/check-bitlocker.yaml"),
727 ),
728 (
729 "check-av-signature",
730 include_str!("../../../configs/schedules/check-av-signature.yaml"),
731 ),
732 (
733 "check-cert-expiry",
734 include_str!("../../../configs/schedules/check-cert-expiry.yaml"),
735 ),
736 ];
737 for (name, yaml) in schedules {
738 let s: Schedule =
739 serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
740 s.validate()
741 .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
742 assert_eq!(s.job_id, name, "{name} schedule must reference its job");
743 }
744 }
745
746 #[test]
747 fn target_is_specified_requires_at_least_one_field() {
748 let empty = Target::default();
749 assert!(!empty.is_specified());
750
751 let with_all = Target {
752 all: true,
753 ..Target::default()
754 };
755 assert!(with_all.is_specified());
756
757 let with_groups = Target {
758 groups: vec!["canary".into()],
759 ..Target::default()
760 };
761 assert!(with_groups.is_specified());
762
763 let with_pcs = Target {
764 pcs: vec!["pc-01".into()],
765 ..Target::default()
766 };
767 assert!(with_pcs.is_specified());
768 }
769
770 #[test]
771 fn manifest_deserialises_minimal_yaml() {
772 let yaml = r#"
775id: echo-test
776version: 0.0.1
777execute:
778 shell: powershell
779 script: "echo 'kanade'"
780 timeout: 30s
781"#;
782 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
783 assert_eq!(m.id, "echo-test");
784 assert_eq!(m.version, "0.0.1");
785 assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
786 assert_eq!(
787 m.execute.script.as_deref().map(str::trim),
788 Some("echo 'kanade'")
789 );
790 assert!(m.execute.script_file.is_none());
791 assert!(m.execute.script_object.is_none());
792 assert_eq!(m.execute.timeout, "30s");
793 assert!(!m.require_approval);
794 m.validate()
795 .expect("inline-script manifest passes validation");
796 }
797
798 #[test]
799 fn manifest_parses_check_job_and_validates() {
800 let yaml = r#"
803id: check-bitlocker
804version: 0.1.0
805execute:
806 shell: powershell
807 run_as: system
808 timeout: 15s
809 script: |
810 [pscustomobject]@{ status = 'ok'; detail = 'all volumes protected' } | ConvertTo-Json -Compress
811check:
812 name: bitlocker
813 troubleshoot: fix-bitlocker
814"#;
815 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
816 let check = m.check.as_ref().expect("check hint present");
817 assert_eq!(check.name, "bitlocker");
818 assert_eq!(check.troubleshoot.as_deref(), Some("fix-bitlocker"));
819 assert_eq!(check.status_field, "status");
821 assert_eq!(check.detail_field, "detail");
822 assert!(m.inventory.is_none() && m.emit.is_none());
823 m.validate().expect("check-only manifest passes validation");
824 }
825
826 #[test]
827 fn manifest_check_defaults_and_custom_fields() {
828 let m: Manifest = serde_yaml::from_str(
830 r#"
831id: check-disk
832version: 0.1.0
833execute:
834 shell: powershell
835 script: "[pscustomobject]@{ status = 'ok' } | ConvertTo-Json -Compress"
836 timeout: 10s
837check:
838 name: disk_free
839"#,
840 )
841 .expect("parse");
842 let c = m.check.as_ref().unwrap();
843 assert_eq!(c.name, "disk_free");
844 assert_eq!(c.status_field, "status");
845 assert_eq!(c.detail_field, "detail");
846 assert!(c.troubleshoot.is_none());
847 m.validate().expect("validates");
848
849 let m2: Manifest = serde_yaml::from_str(
852 r#"
853id: check-custom
854version: 0.1.0
855execute:
856 shell: powershell
857 script: "echo x"
858 timeout: 10s
859check:
860 name: patch_level
861 status_field: compliance
862 detail_field: summary
863"#,
864 )
865 .expect("parse");
866 let c2 = m2.check.as_ref().unwrap();
867 assert_eq!(c2.status_field, "compliance");
868 assert_eq!(c2.detail_field, "summary");
869 }
870
871 #[test]
872 fn manifest_allows_check_composed_with_inventory() {
873 let yaml = r#"
877id: check-bitlocker-detailed
878version: 0.1.0
879execute:
880 shell: powershell
881 script: "echo x"
882 timeout: 10s
883check:
884 name: bitlocker
885inventory:
886 display:
887 - { field: status, label: Status }
888"#;
889 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
890 assert!(m.check.is_some() && m.inventory.is_some());
891 m.validate().expect("check + inventory compose");
892 }
893
894 #[test]
895 fn manifest_rejects_check_combined_with_emit() {
896 let yaml = r#"
900id: bad-mix
901version: 0.1.0
902execute:
903 shell: powershell
904 script: "echo x"
905 timeout: 10s
906check:
907 name: bitlocker
908emit:
909 type: events
910"#;
911 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
912 let err = m.validate().expect_err("emit + check must fail");
913 assert!(err.contains("incompatible"), "err: {err}");
914 }
915
916 #[test]
917 fn manifest_rejects_emit_combined_with_inventory() {
918 let yaml = r#"
920id: bad-mix-2
921version: 0.1.0
922execute:
923 shell: powershell
924 script: "echo x"
925 timeout: 10s
926emit:
927 type: events
928inventory:
929 display:
930 - { field: status, label: Status }
931"#;
932 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
933 let err = m.validate().expect_err("emit + inventory must fail");
934 assert!(err.contains("incompatible"), "err: {err}");
935 }
936
937 #[test]
938 fn manifest_rejects_empty_check_field_names() {
939 let base = |inner: &str| {
943 format!(
944 "id: c\nversion: 0.1.0\nexecute:\n shell: powershell\n script: \"echo x\"\n timeout: 10s\ncheck:\n{inner}"
945 )
946 };
947 for inner in [
948 " name: \"\"\n",
949 " name: ok\n status_field: \"\"\n",
950 " name: ok\n detail_field: \" \"\n",
951 " name: ok\n troubleshoot: \" \"\n",
953 ] {
954 let m: Manifest = serde_yaml::from_str(&base(inner)).expect("parse");
955 let err = m.validate().expect_err("empty field must fail");
956 assert!(err.contains("must not be empty"), "err: {err}");
957 }
958 }
959
960 #[test]
961 fn manifest_client_absent_by_default() {
962 let yaml = r#"
966id: echo-test
967version: 0.0.1
968execute:
969 shell: powershell
970 script: "echo 'kanade'"
971 timeout: 30s
972"#;
973 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
974 assert!(m.client.is_none());
975 m.validate().expect("operator-only job validates");
976 }
977
978 #[test]
979 fn manifest_client_parses_and_validates() {
980 let yaml = r#"
984id: fix-teams-cache
985version: 1.0.0
986execute:
987 shell: powershell
988 script: "echo clearing"
989 timeout: 60s
990client:
991 name: "Teams のキャッシュをクリア"
992 description: "Teams が重いときに試してください"
993 category: troubleshoot
994 icon: brush-cleaning
995"#;
996 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
997 let c = m.client.as_ref().expect("client block present");
998 assert_eq!(c.name, "Teams のキャッシュをクリア");
999 assert_eq!(
1000 c.description.as_deref(),
1001 Some("Teams が重いときに試してください")
1002 );
1003 assert_eq!(c.category, JobCategory::Troubleshoot);
1004 assert_eq!(c.icon.as_deref(), Some("brush-cleaning"));
1005 m.validate().expect("user-invokable job validates");
1006 }
1007
1008 #[test]
1009 fn manifest_client_minimal_only_name_and_category() {
1010 let yaml = r#"
1013id: install-slack
1014version: 1.0.0
1015execute:
1016 shell: powershell
1017 script: "echo install"
1018 timeout: 600s
1019client:
1020 name: Slack
1021 category: catalog
1022"#;
1023 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1024 let c = m.client.as_ref().expect("client present");
1025 assert_eq!(c.category, JobCategory::Catalog);
1026 assert!(c.description.is_none() && c.icon.is_none());
1027 m.validate().expect("minimal client validates");
1028 }
1029
1030 #[test]
1031 fn manifest_client_rejects_blank_name() {
1032 let yaml = r#"
1035id: j
1036version: 1.0.0
1037execute:
1038 shell: powershell
1039 script: "echo x"
1040 timeout: 30s
1041client:
1042 name: " "
1043 category: catalog
1044"#;
1045 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1046 let err = m.validate().expect_err("blank name must fail");
1047 assert!(err.contains("client.name"), "err: {err}");
1048 }
1049
1050 #[test]
1051 fn manifest_client_rejects_blank_optional_fields() {
1052 for (field, line) in [
1056 ("client.description", " description: \" \"\n"),
1057 ("client.icon", " icon: \"\"\n"),
1058 ] {
1059 let yaml = format!(
1060 "id: j\nversion: 1.0.0\nexecute:\n shell: powershell\n script: \"echo x\"\n timeout: 30s\nclient:\n name: A\n category: catalog\n{line}"
1061 );
1062 let m: Manifest = serde_yaml::from_str(&yaml).expect("parse");
1063 let err = m.validate().expect_err("blank optional field must fail");
1064 assert!(err.contains(field), "expected {field} in err: {err}");
1065 }
1066 }
1067
1068 #[test]
1069 fn manifest_client_requires_category_at_parse() {
1070 let yaml = r#"
1073id: j
1074version: 1.0.0
1075execute:
1076 shell: powershell
1077 script: "echo x"
1078 timeout: 30s
1079client:
1080 name: "A job"
1081"#;
1082 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
1083 assert!(
1084 r.is_err(),
1085 "missing category must be a parse error, got {r:?}"
1086 );
1087 }
1088
1089 #[test]
1090 fn manifest_client_rejects_unknown_field() {
1091 let yaml = r#"
1094id: j
1095version: 1.0.0
1096execute:
1097 shell: powershell
1098 script: "echo x"
1099 timeout: 30s
1100client:
1101 name: "A job"
1102 category: catalog
1103 displayname: oops
1104"#;
1105 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
1106 assert!(
1107 r.is_err(),
1108 "unknown client field must be a parse error, got {r:?}"
1109 );
1110 }
1111
1112 fn execute_with(
1113 script: Option<&str>,
1114 script_file: Option<&str>,
1115 script_object: Option<&str>,
1116 ) -> Execute {
1117 Execute {
1118 shell: ExecuteShell::Powershell,
1119 script: script.map(str::to_owned),
1120 script_file: script_file.map(str::to_owned),
1121 script_object: script_object.map(str::to_owned),
1122 timeout: "30s".into(),
1123 run_as: RunAs::default(),
1124 cwd: None,
1125 }
1126 }
1127
1128 #[test]
1129 fn validate_accepts_inline_script() {
1130 let e = execute_with(Some("echo hi"), None, None);
1131 assert!(e.validate_script_source().is_ok());
1132 }
1133
1134 #[test]
1135 fn validate_accepts_script_file_alone() {
1136 let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
1137 assert!(e.validate_script_source().is_ok());
1138 }
1139
1140 #[test]
1141 fn validate_accepts_script_object_alone() {
1142 let e = execute_with(None, None, Some("cleanup/1.0.0"));
1143 assert!(e.validate_script_source().is_ok());
1144 }
1145
1146 #[test]
1147 fn validate_treats_empty_inline_script_as_unset() {
1148 let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
1152 assert!(e.validate_script_source().is_ok());
1153 }
1154
1155 #[test]
1156 fn validate_rejects_zero_sources() {
1157 let e = execute_with(None, None, None);
1158 let err = e.validate_script_source().unwrap_err();
1159 assert!(err.contains("must be set"), "got: {err}");
1160 }
1161
1162 #[test]
1163 fn validate_rejects_empty_inline_only() {
1164 let e = execute_with(Some(""), None, None);
1165 let err = e.validate_script_source().unwrap_err();
1166 assert!(err.contains("must be set"), "got: {err}");
1167 }
1168
1169 #[test]
1170 fn validate_rejects_inline_plus_file() {
1171 let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
1172 let err = e.validate_script_source().unwrap_err();
1173 assert!(err.contains("only one of"), "got: {err}");
1174 }
1175
1176 #[test]
1177 fn validate_rejects_inline_plus_object() {
1178 let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
1179 let err = e.validate_script_source().unwrap_err();
1180 assert!(err.contains("only one of"), "got: {err}");
1181 }
1182
1183 #[test]
1184 fn validate_rejects_file_plus_object() {
1185 let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
1186 let err = e.validate_script_source().unwrap_err();
1187 assert!(err.contains("only one of"), "got: {err}");
1188 }
1189
1190 #[test]
1191 fn validate_rejects_all_three() {
1192 let e = execute_with(
1193 Some("echo hi"),
1194 Some("scripts/cleanup.ps1"),
1195 Some("cleanup/1.0.0"),
1196 );
1197 let err = e.validate_script_source().unwrap_err();
1198 assert!(err.contains("only one of"), "got: {err}");
1199 }
1200
1201 #[test]
1202 fn manifest_deserialises_script_object_yaml() {
1203 let yaml = r#"
1206id: cleanup-disk-temp
1207version: 1.0.1
1208execute:
1209 shell: powershell
1210 script_object: cleanup-disk-temp/1.0.1
1211 timeout: 600s
1212"#;
1213 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1214 assert_eq!(
1215 m.execute.script_object.as_deref(),
1216 Some("cleanup-disk-temp/1.0.1")
1217 );
1218 assert!(m.execute.script.is_none());
1219 m.validate()
1220 .expect("script_object-only manifest passes validation");
1221 }
1222
1223 #[test]
1224 fn manifest_rejects_typo_in_script_field_name() {
1225 let yaml = r#"
1229id: typo
1230version: 1.0.0
1231execute:
1232 shell: powershell
1233 script_objectt: oops
1234 timeout: 30s
1235"#;
1236 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
1237 assert!(r.is_err(), "expected parse error, got {r:?}");
1238 }
1239
1240 #[test]
1241 fn schedule_carries_target_and_rollout() {
1242 let yaml = r#"
1243id: hourly-cleanup-canary
1244when:
1245 per_pc: { every: 1h }
1246job_id: cleanup
1247enabled: true
1248target:
1249 groups: [canary, wave1]
1250jitter: 30s
1251rollout:
1252 strategy: wave
1253 waves:
1254 - { group: canary, delay: 0s }
1255 - { group: wave1, delay: 5s }
1256"#;
1257 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1258 assert_eq!(s.id, "hourly-cleanup-canary");
1259 assert_eq!(s.job_id, "cleanup");
1260 assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
1261 assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
1262 let rollout = s.plan.rollout.expect("rollout present");
1263 assert_eq!(rollout.waves.len(), 2);
1264 assert_eq!(rollout.waves[0].group, "canary");
1265 assert_eq!(rollout.waves[1].delay, "5s");
1266 assert_eq!(rollout.strategy, RolloutStrategy::Wave);
1267 }
1268
1269 #[test]
1270 fn schedule_minimal_target_all() {
1271 let yaml = r#"
1272id: kitting
1273when:
1274 per_pc: once
1275enabled: true
1276job_id: scheduled-echo
1277target: { all: true }
1278"#;
1279 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1280 assert_eq!(s.id, "kitting");
1281 assert_eq!(s.when, When::PerPc(PerPolicy::Once(OnceLiteral::Once)));
1282 assert!(s.enabled);
1283 assert_eq!(s.job_id, "scheduled-echo");
1284 assert!(s.plan.target.all);
1285 assert!(s.plan.rollout.is_none());
1286 assert!(s.plan.jitter.is_none());
1287 assert!(s.active.is_empty());
1288 }
1289
1290 #[test]
1291 fn schedule_enabled_defaults_to_true() {
1292 let yaml = r#"
1293id: x
1294when:
1295 per_pc: once
1296job_id: y
1297target: { all: true }
1298"#;
1299 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1300 assert!(s.enabled);
1301 }
1302
1303 fn schedule_yaml_with(when_block: &str) -> String {
1306 format!(
1307 r#"
1308id: x
1309when:
1310{when_block}
1311job_id: y
1312target: {{ all: true }}
1313"#
1314 )
1315 }
1316
1317 #[test]
1318 fn when_per_pc_every_parses_unquoted_humantime() {
1319 let s: Schedule =
1322 serde_yaml::from_str(&schedule_yaml_with(" per_pc: { every: 6h }")).expect("parse");
1323 assert_eq!(
1324 s.when,
1325 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() }))
1326 );
1327 }
1328
1329 #[test]
1330 fn when_per_target_every_parses() {
1331 let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(" per_target: { every: 24h }"))
1332 .expect("parse");
1333 assert_eq!(
1334 s.when,
1335 When::PerTarget(PerPolicy::Every(EverySpec {
1336 every: "24h".into()
1337 }))
1338 );
1339 }
1340
1341 #[test]
1342 fn when_per_target_once_parses() {
1343 let s: Schedule =
1347 serde_yaml::from_str(&schedule_yaml_with(" per_target: once")).expect("parse");
1348 assert_eq!(s.when, When::PerTarget(PerPolicy::Once(OnceLiteral::Once)));
1349 }
1350
1351 #[test]
1352 fn when_calendar_time_parses() {
1353 let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(
1354 " calendar:\n at: \"09:00\"\n days: [mon-fri]",
1355 ))
1356 .expect("parse");
1357 match &s.when {
1358 When::Calendar(c) => {
1359 assert_eq!(c.at, "09:00");
1360 assert_eq!(c.days, vec!["mon-fri"]);
1361 }
1362 other => panic!("expected calendar, got {other:?}"),
1363 }
1364 }
1365
1366 #[test]
1367 fn when_calendar_days_default_empty() {
1368 let s: Schedule =
1369 serde_yaml::from_str(&schedule_yaml_with(" calendar:\n at: \"09:00\""))
1370 .expect("parse");
1371 match &s.when {
1372 When::Calendar(c) => assert!(c.days.is_empty(), "days defaults to empty (= daily)"),
1373 other => panic!("expected calendar, got {other:?}"),
1374 }
1375 }
1376
1377 #[test]
1378 fn when_calendar_datetime_parses_all_separators() {
1379 for at in ["2026-06-10 09:00", "2026-06-10T09:00", "2026/06/10 09:00"] {
1381 let block = format!(" calendar:\n at: \"{at}\"");
1382 let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(&block))
1383 .unwrap_or_else(|e| panic!("parse '{at}': {e}"));
1384 match &s.when {
1385 When::Calendar(c) => {
1386 use chrono::Datelike;
1387 let p = c.parse_at().expect("parse_at");
1388 let d = p.date.expect("datetime at carries a date");
1389 assert_eq!((d.year(), d.month(), d.day()), (2026, 6, 10), "for '{at}'");
1390 }
1391 other => panic!("expected calendar, got {other:?}"),
1392 }
1393 }
1394 }
1395
1396 #[test]
1397 fn when_rejects_bad_once_keyword() {
1398 let r: Result<Schedule, _> = serde_yaml::from_str(&schedule_yaml_with(" per_pc: onec"));
1402 assert!(r.is_err(), "expected parse error, got {r:?}");
1403 }
1404
1405 #[test]
1406 fn when_rejects_unknown_key_in_every() {
1407 let r: Result<Schedule, _> =
1410 serde_yaml::from_str(&schedule_yaml_with(" per_pc: { evry: 6h }"));
1411 assert!(r.is_err(), "expected parse error, got {r:?}");
1412 }
1413
1414 #[test]
1415 fn when_rejects_unknown_variant() {
1416 let r: Result<Schedule, _> =
1417 serde_yaml::from_str(&schedule_yaml_with(" per_galaxy: once"));
1418 assert!(r.is_err(), "expected parse error, got {r:?}");
1419 }
1420
1421 #[test]
1422 fn when_rejects_old_top_level_cron_field() {
1423 let yaml = r#"
1427id: x
1428cron: "* * * * * *"
1429job_id: y
1430target: { all: true }
1431"#;
1432 let r: Result<Schedule, _> = serde_yaml::from_str(yaml);
1433 assert!(r.is_err(), "expected parse error, got {r:?}");
1434 }
1435
1436 #[test]
1437 fn when_rejects_retired_cron_escape_hatch() {
1438 let r: Result<Schedule, _> =
1442 serde_yaml::from_str(&schedule_yaml_with(" cron: \"0 0 9 * * mon-fri\""));
1443 assert!(
1444 r.is_err(),
1445 "expected parse error for retired cron, got {r:?}"
1446 );
1447 }
1448
1449 #[test]
1450 fn when_round_trips_json_and_yaml() {
1451 for when in [
1456 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1457 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1458 When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1459 When::PerTarget(PerPolicy::Every(EverySpec {
1460 every: "24h".into(),
1461 })),
1462 calendar("09:00", &["mon-fri"]),
1463 calendar("2026-06-10 09:00", &[]),
1464 ] {
1465 let s = schedule_with(when.clone(), RunsOn::Backend);
1466
1467 let json = serde_json::to_string(&s).expect("json serialise");
1468 let back: Schedule = serde_json::from_str(&json).expect("json deserialise");
1469 assert_eq!(back.when, when, "json round-trip for {when}");
1470
1471 let yaml = serde_yaml::to_string(&s).expect("yaml serialise");
1472 assert!(
1473 !yaml.contains('!'),
1474 "yaml must use the map shape, not tags: {yaml}"
1475 );
1476 let back: Schedule = serde_yaml::from_str(&yaml).expect("yaml deserialise");
1477 assert_eq!(back.when, when, "yaml round-trip for {when}");
1478 }
1479 }
1480
1481 #[test]
1482 fn when_once_serialises_as_bare_keyword() {
1483 let json = serde_json::to_value(When::PerPc(PerPolicy::Once(OnceLiteral::Once)))
1486 .expect("serialise");
1487 assert_eq!(json, serde_json::json!({ "per_pc": "once" }));
1488 }
1489
1490 #[test]
1491 fn when_displays_operator_summary() {
1492 for (when, expected) in [
1493 (
1494 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1495 "per_pc once",
1496 ),
1497 (
1498 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1499 "per_pc every 6h",
1500 ),
1501 (
1502 When::PerTarget(PerPolicy::Every(EverySpec {
1503 every: "24h".into(),
1504 })),
1505 "per_target every 24h",
1506 ),
1507 (calendar("09:00", &["mon-fri"]), "at 09:00 [mon-fri]"),
1508 (calendar("2026-06-10 09:00", &[]), "at 2026-06-10 09:00"),
1509 ] {
1510 assert_eq!(when.to_string(), expected);
1511 }
1512 }
1513
1514 fn schedule_with(when: When, runs_on: RunsOn) -> Schedule {
1517 Schedule {
1518 id: "x".into(),
1519 when,
1520 job_id: "y".into(),
1521 plan: FanoutPlan::default(),
1522 active: Active::default(),
1523 constraints: Constraints::default(),
1524 on_failure: OnFailure::default(),
1525 tz: ScheduleTz::default(),
1526 starting_deadline: None,
1527 runs_on,
1528 enabled: true,
1529 }
1530 }
1531
1532 fn calendar(at: &str, days: &[&str]) -> When {
1533 When::Calendar(CalendarSpec {
1534 at: at.into(),
1535 days: days.iter().map(|d| (*d).to_string()).collect(),
1536 })
1537 }
1538
1539 #[test]
1540 fn lowering_matches_the_418_table() {
1541 let cases = [
1542 (
1543 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1544 (POLL_CRON, ExecMode::OncePerPc, None),
1545 ),
1546 (
1547 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1548 (POLL_CRON, ExecMode::OncePerPc, Some("6h")),
1549 ),
1550 (
1551 When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1552 (POLL_CRON, ExecMode::OncePerTarget, None),
1553 ),
1554 (
1555 When::PerTarget(PerPolicy::Every(EverySpec {
1556 every: "24h".into(),
1557 })),
1558 (POLL_CRON, ExecMode::OncePerTarget, Some("24h")),
1559 ),
1560 (
1562 calendar("09:00", &["mon-fri"]),
1563 ("0 0 9 * * mon-fri", ExecMode::EveryTick, None),
1564 ),
1565 (
1567 calendar("18:30", &[]),
1568 ("0 30 18 * * *", ExecMode::EveryTick, None),
1569 ),
1570 (
1572 calendar("2026-06-10 09:00", &[]),
1573 ("0 0 9 10 6 * 2026", ExecMode::EveryTick, None),
1574 ),
1575 ];
1576 for (when, (cron, mode, cooldown)) in cases {
1577 let l = schedule_with(when.clone(), RunsOn::Backend).lowered();
1578 assert_eq!(l.cron, cron, "cron for {when}");
1579 assert_eq!(l.mode, mode, "mode for {when}");
1580 assert_eq!(l.cooldown.as_deref(), cooldown, "cooldown for {when}");
1581 }
1582 }
1583
1584 #[test]
1585 fn lowered_carries_schedule_tz() {
1586 for (tz, want) in [
1587 (ScheduleTz::Local, ScheduleTz::Local),
1588 (ScheduleTz::Utc, ScheduleTz::Utc),
1589 ] {
1590 let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
1591 s.tz = tz;
1592 assert_eq!(s.lowered().tz, want, "calendar carries tz");
1593 let mut s = schedule_with(
1595 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1596 RunsOn::Backend,
1597 );
1598 s.tz = tz;
1599 assert_eq!(s.lowered().tz, want, "reconcile carries tz");
1600 }
1601 }
1602
1603 #[test]
1604 fn poll_cron_is_accepted_by_the_engine_parser() {
1605 croner::parser::CronParser::builder()
1610 .seconds(croner::parser::Seconds::Required)
1611 .dom_and_dow(true)
1612 .build()
1613 .parse(POLL_CRON)
1614 .expect("POLL_CRON must parse");
1615 }
1616
1617 #[test]
1620 fn validate_accepts_reconcile_shapes() {
1621 for when in [
1622 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1623 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1624 When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1625 When::PerTarget(PerPolicy::Every(EverySpec {
1626 every: "24h".into(),
1627 })),
1628 ] {
1629 schedule_with(when.clone(), RunsOn::Backend)
1630 .validate()
1631 .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
1632 }
1633 }
1634
1635 #[test]
1636 fn validate_accepts_per_pc_on_agent() {
1637 schedule_with(
1638 When::PerPc(PerPolicy::Every(EverySpec { every: "1h".into() })),
1639 RunsOn::Agent,
1640 )
1641 .validate()
1642 .expect("per_pc + agent is the offline-inventory shape");
1643 }
1644
1645 #[test]
1646 fn validate_rejects_per_target_on_agent() {
1647 let err = schedule_with(
1648 When::PerTarget(PerPolicy::Every(EverySpec {
1649 every: "24h".into(),
1650 })),
1651 RunsOn::Agent,
1652 )
1653 .validate()
1654 .unwrap_err();
1655 assert!(err.contains("per_target"), "got: {err}");
1656 assert!(err.contains("runs_on: agent"), "got: {err}");
1657
1658 let err = schedule_with(
1660 When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1661 RunsOn::Agent,
1662 )
1663 .validate()
1664 .unwrap_err();
1665 assert!(err.contains("per_target"), "got (once): {err}");
1666 assert!(err.contains("runs_on: agent"), "got (once): {err}");
1667 }
1668
1669 #[test]
1670 fn validate_rejects_bad_every_duration() {
1671 let err = schedule_with(
1672 When::PerPc(PerPolicy::Every(EverySpec { every: "6x".into() })),
1673 RunsOn::Backend,
1674 )
1675 .validate()
1676 .unwrap_err();
1677 assert!(err.contains("when.every"), "got: {err}");
1678 }
1679
1680 #[test]
1681 fn validate_rejects_bad_jitter_and_starting_deadline() {
1682 let mut s = schedule_with(
1683 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1684 RunsOn::Backend,
1685 );
1686 s.plan.jitter = Some("5x".into());
1687 let err = s.validate().unwrap_err();
1688 assert!(err.contains("jitter"), "got: {err}");
1689
1690 let mut s = schedule_with(
1691 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1692 RunsOn::Backend,
1693 );
1694 s.starting_deadline = Some("soon".into());
1695 let err = s.validate().unwrap_err();
1696 assert!(err.contains("starting_deadline"), "got: {err}");
1697 }
1698
1699 #[test]
1700 fn validate_accepts_calendar_shapes() {
1701 for when in [
1702 calendar("09:00", &["mon-fri"]), calendar("00:00", &["sun"]), calendar("18:30", &[]), calendar("2026-06-10 09:00", &[]), calendar("2026/12/25 00:00", &[]), ] {
1708 schedule_with(when.clone(), RunsOn::Backend)
1709 .validate()
1710 .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
1711 }
1712 }
1713
1714 #[test]
1715 fn validate_rejects_bad_at() {
1716 for bad in ["25:00", "09:60", "9", "noon", "2026-13-01 09:00"] {
1717 let err = schedule_with(calendar(bad, &[]), RunsOn::Backend)
1718 .validate()
1719 .unwrap_err();
1720 assert!(err.contains("when.at"), "for '{bad}', got: {err}");
1721 }
1722 }
1723
1724 #[test]
1725 fn validate_rejects_datetime_at_with_days() {
1726 let err = schedule_with(calendar("2026-06-10 09:00", &["mon"]), RunsOn::Backend)
1729 .validate()
1730 .unwrap_err();
1731 assert!(
1732 err.contains("one-shot") && err.contains("days"),
1733 "got: {err}"
1734 );
1735 }
1736
1737 #[test]
1738 fn validate_rejects_bad_day_name() {
1739 let err = schedule_with(calendar("09:00", &["funday"]), RunsOn::Backend)
1743 .validate()
1744 .unwrap_err();
1745 assert!(err.contains("when.days"), "got: {err}");
1746 assert!(err.contains("funday"), "names the bad token: {err}");
1747 let err = schedule_with(calendar("09:00", &["mon-"]), RunsOn::Backend)
1750 .validate()
1751 .unwrap_err();
1752 assert!(err.contains("'mon-'"), "names the whole token: {err}");
1753 for ok in [
1755 calendar("09:00", &["mon-fri"]),
1756 calendar("09:00", &["mon", "wed", "sun"]),
1757 calendar("09:00", &["1-5"]),
1758 ] {
1759 schedule_with(ok.clone(), RunsOn::Backend)
1760 .validate()
1761 .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
1762 }
1763 }
1764
1765 #[test]
1766 fn calendar_oneshot_instant_detects_past() {
1767 use chrono::TimeZone;
1768 let c = CalendarSpec {
1770 at: "2024-01-01 09:00".into(),
1771 days: vec![],
1772 };
1773 let t = c
1774 .oneshot_instant(ScheduleTz::Utc)
1775 .expect("one-shot instant");
1776 assert_eq!(
1777 t,
1778 chrono::Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()
1779 );
1780 assert!(t < chrono::Utc::now(), "2024 is in the past");
1781 let rep = CalendarSpec {
1783 at: "09:00".into(),
1784 days: vec!["mon-fri".into()],
1785 };
1786 assert!(rep.oneshot_instant(ScheduleTz::Utc).is_none());
1787 }
1788
1789 fn schedule_with_active(from: Option<&str>, until: Option<&str>) -> Schedule {
1790 let mut s = schedule_with(
1791 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1792 RunsOn::Backend,
1793 );
1794 s.active = Active {
1795 from: from.map(str::to_owned),
1796 until: until.map(str::to_owned),
1797 };
1798 s
1799 }
1800
1801 #[test]
1802 fn validate_accepts_active_window() {
1803 schedule_with_active(Some("2026-07-01"), Some("2026-08-01T12:00:00+09:00"))
1804 .validate()
1805 .expect("date + rfc3339 bounds should validate");
1806 }
1807
1808 #[test]
1809 fn validate_rejects_unparseable_active_bound() {
1810 let err = schedule_with_active(Some("July 1st"), None)
1811 .validate()
1812 .unwrap_err();
1813 assert!(err.contains("active"), "got: {err}");
1814 }
1815
1816 #[test]
1817 fn validate_rejects_from_not_before_until() {
1818 let err = schedule_with_active(Some("2026-08-01"), Some("2026-07-01"))
1819 .validate()
1820 .unwrap_err();
1821 assert!(err.contains("strictly before"), "got: {err}");
1822
1823 let err = schedule_with_active(Some("2026-07-01"), Some("2026-07-01"))
1824 .validate()
1825 .unwrap_err();
1826 assert!(err.contains("strictly before"), "got: {err}");
1827 }
1828
1829 #[test]
1832 fn active_window_is_half_open() {
1833 use chrono::TimeZone;
1834 let active = Active {
1835 from: Some("2026-07-01".into()),
1836 until: Some("2026-08-01".into()),
1837 };
1838 let at = |y, m, d, h| chrono::Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap();
1840 let c = |t| active.contains(t, ScheduleTz::Utc);
1841 assert!(!c(at(2026, 6, 30, 23)), "before from");
1842 assert!(c(at(2026, 7, 1, 0)), "at from (inclusive)");
1843 assert!(c(at(2026, 7, 15, 12)), "inside");
1844 assert!(!c(at(2026, 8, 1, 0)), "at until (exclusive)");
1845 assert!(!c(at(2026, 8, 2, 0)), "after until");
1846 }
1847
1848 #[test]
1849 fn active_empty_window_is_always_active() {
1850 assert!(Active::default().contains(chrono::Utc::now(), ScheduleTz::Local));
1851 }
1852
1853 #[test]
1854 fn active_rfc3339_bound_honours_offset_regardless_of_tz() {
1855 use chrono::TimeZone;
1856 let active = Active {
1857 from: Some("2026-07-01T09:00:00+09:00".into()),
1858 until: None,
1859 };
1860 for tz in [ScheduleTz::Utc, ScheduleTz::Local] {
1863 assert!(
1864 !active.contains(
1865 chrono::Utc
1866 .with_ymd_and_hms(2026, 6, 30, 23, 59, 0)
1867 .unwrap(),
1868 tz
1869 )
1870 );
1871 assert!(active.contains(
1872 chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
1873 tz
1874 ));
1875 }
1876 }
1877
1878 #[test]
1879 fn active_date_bound_respects_tz() {
1880 use chrono::TimeZone;
1884 let utc = Active::parse_bound("2026-07-01", ScheduleTz::Utc).expect("utc");
1885 assert_eq!(
1886 utc,
1887 chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()
1888 );
1889
1890 let local = Active::parse_bound("2026-07-01", ScheduleTz::Local).expect("local");
1897 let want = chrono::Local
1898 .with_ymd_and_hms(2026, 7, 1, 0, 0, 0)
1899 .single()
1900 .expect("local midnight is unambiguous")
1901 .with_timezone(&chrono::Utc);
1902 assert_eq!(local, want, "date bound resolved in host-local tz");
1903 }
1904
1905 #[test]
1906 fn active_empty_is_skipped_when_serialising() {
1907 let s = schedule_with(
1908 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1909 RunsOn::Backend,
1910 );
1911 let json = serde_json::to_value(&s).expect("serialise");
1912 assert!(
1913 json.get("active").is_none(),
1914 "empty active must not appear on the wire: {json}"
1915 );
1916 }
1917
1918 fn with_window(win: &str) -> Schedule {
1921 let mut s = schedule_with(
1922 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1923 RunsOn::Backend,
1924 );
1925 s.constraints.window = Some(win.into());
1926 s
1927 }
1928
1929 #[test]
1930 fn constraints_window_parses_and_round_trips() {
1931 let yaml = r#"
1932id: x
1933when:
1934 per_pc: { every: 6h }
1935job_id: y
1936target: { all: true }
1937constraints:
1938 window: "22:00-05:00"
1939"#;
1940 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1941 assert_eq!(s.constraints.window.as_deref(), Some("22:00-05:00"));
1942 let back: Schedule =
1943 serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
1944 assert_eq!(back.constraints.window.as_deref(), Some("22:00-05:00"));
1945 }
1946
1947 #[test]
1948 fn constraints_empty_is_skipped_when_serialising() {
1949 let s = schedule_with(
1950 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1951 RunsOn::Backend,
1952 );
1953 let json = serde_json::to_value(&s).expect("serialise");
1954 assert!(
1955 json.get("constraints").is_none(),
1956 "empty constraints must not appear on the wire: {json}"
1957 );
1958 }
1959
1960 #[test]
1961 fn window_no_constraint_always_allows() {
1962 let c = Constraints::default();
1963 assert!(c.allows(chrono::Utc::now(), ScheduleTz::Local));
1964 }
1965
1966 #[test]
1967 fn window_same_day_is_half_open() {
1968 use chrono::TimeZone;
1969 let s = with_window("09:00-17:00");
1970 let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
1971 let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
1972 assert!(!a(at(8, 59)), "before start");
1973 assert!(a(at(9, 0)), "at start (inclusive)");
1974 assert!(a(at(16, 59)), "inside");
1975 assert!(!a(at(17, 0)), "at end (exclusive)");
1976 assert!(!a(at(23, 0)), "after end");
1977 }
1978
1979 #[test]
1980 fn window_crossing_midnight() {
1981 use chrono::TimeZone;
1982 let s = with_window("22:00-05:00");
1983 let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
1984 let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
1985 assert!(a(at(22, 0)), "at start tonight");
1986 assert!(a(at(23, 30)), "late tonight");
1987 assert!(a(at(3, 0)), "early tomorrow");
1988 assert!(!a(at(5, 0)), "at end (exclusive)");
1989 assert!(!a(at(12, 0)), "midday outside");
1990 assert!(!a(at(21, 59)), "just before start");
1991 }
1992
1993 #[test]
1994 fn window_respects_tz() {
1995 use chrono::TimeZone;
2000 let s = with_window("09:00-17:00");
2001 let noon_utc = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap();
2002 assert!(s.constraints.allows(noon_utc, ScheduleTz::Utc));
2004 let local_t = noon_utc.with_timezone(&chrono::Local).time();
2007 let in_local = local_t >= chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap()
2008 && local_t < chrono::NaiveTime::from_hms_opt(17, 0, 0).unwrap();
2009 assert_eq!(s.constraints.allows(noon_utc, ScheduleTz::Local), in_local);
2010 }
2011
2012 #[test]
2013 fn validate_accepts_good_window() {
2014 for w in ["09:00-17:00", "22:00-05:00", "00:00-23:59"] {
2015 with_window(w)
2016 .validate()
2017 .unwrap_or_else(|e| panic!("'{w}' should validate: {e}"));
2018 }
2019 }
2020
2021 #[test]
2022 fn validate_rejects_bad_window() {
2023 for bad in ["9-5", "22:00", "22:00-22:00", "25:00-05:00", "09:00_17:00"] {
2024 let err = with_window(bad).validate().unwrap_err();
2025 assert!(
2026 err.contains("constraints.window"),
2027 "for '{bad}', got: {err}"
2028 );
2029 }
2030 }
2031
2032 fn with_max_concurrent(max: u32, runs_on: RunsOn) -> Schedule {
2035 let mut s = schedule_with(
2036 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2037 runs_on,
2038 );
2039 s.constraints.max_concurrent = Some(max);
2040 s
2041 }
2042
2043 #[test]
2044 fn validate_accepts_backend_max_concurrent() {
2045 with_max_concurrent(5, RunsOn::Backend)
2046 .validate()
2047 .expect("backend max_concurrent should validate");
2048 }
2049
2050 #[test]
2051 fn validate_rejects_max_concurrent_on_agent() {
2052 let err = with_max_concurrent(5, RunsOn::Agent)
2055 .validate()
2056 .unwrap_err();
2057 assert!(err.contains("constraints.max_concurrent"), "got: {err}");
2058 assert!(err.contains("runs_on: agent"), "got: {err}");
2059 }
2060
2061 #[test]
2062 fn validate_rejects_zero_max_concurrent() {
2063 let err = with_max_concurrent(0, RunsOn::Backend)
2064 .validate()
2065 .unwrap_err();
2066 assert!(err.contains("max_concurrent must be >= 1"), "got: {err}");
2067 }
2068
2069 #[test]
2070 fn max_concurrent_round_trips_and_skips_when_absent() {
2071 let s = with_max_concurrent(3, RunsOn::Backend);
2072 let json = serde_json::to_value(&s.constraints).expect("ser");
2073 assert_eq!(json.get("max_concurrent").and_then(|v| v.as_u64()), Some(3));
2074 let bare = schedule_with(
2076 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2077 RunsOn::Backend,
2078 );
2079 assert!(bare.constraints.is_empty());
2080 }
2081
2082 #[test]
2083 fn window_fail_closed_on_corrupt_blob() {
2084 let s = with_window("22:00_05:00");
2088 assert!(
2089 !s.constraints.allows(chrono::Utc::now(), ScheduleTz::Utc),
2090 "corrupt window fails closed"
2091 );
2092 assert!(
2094 s.bad_window().is_some(),
2095 "bad_window reports the parse error"
2096 );
2097 assert!(with_window("22:00-05:00").bad_window().is_none());
2098 }
2099
2100 #[test]
2101 fn calendar_outside_window_is_flagged() {
2102 let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
2104 s.constraints.window = Some("22:00-05:00".into());
2105 assert!(s.calendar_outside_window(), "09:00 is not in 22:00-05:00");
2106
2107 let mut s = schedule_with(calendar("23:00", &[]), RunsOn::Backend);
2109 s.constraints.window = Some("22:00-05:00".into());
2110 assert!(!s.calendar_outside_window(), "23:00 is in 22:00-05:00");
2111
2112 let mut s = schedule_with(
2114 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2115 RunsOn::Backend,
2116 );
2117 s.constraints.window = Some("22:00-05:00".into());
2118 assert!(!s.calendar_outside_window(), "reconcile is unaffected");
2119
2120 let s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
2122 assert!(!s.calendar_outside_window());
2123 }
2124
2125 fn with_retry(max: u32, backoff: &str) -> Schedule {
2128 let mut s = schedule_with(
2129 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2130 RunsOn::Backend,
2131 );
2132 s.on_failure.retry = Some(Retry {
2133 max,
2134 backoff: backoff.into(),
2135 });
2136 s
2137 }
2138
2139 #[test]
2140 fn on_failure_parses_and_round_trips() {
2141 let yaml = r#"
2142id: x
2143when:
2144 per_pc: { every: 6h }
2145job_id: y
2146target: { all: true }
2147on_failure:
2148 retry: { max: 3, backoff: 10m }
2149"#;
2150 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
2151 let r = s.on_failure.retry.as_ref().expect("retry present");
2152 assert_eq!(r.max, 3);
2153 assert_eq!(r.backoff, "10m");
2154 let back: Schedule =
2155 serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
2156 assert_eq!(back.on_failure, s.on_failure);
2157 }
2158
2159 #[test]
2160 fn on_failure_empty_is_skipped_when_serialising() {
2161 let s = schedule_with(
2162 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2163 RunsOn::Backend,
2164 );
2165 let json = serde_json::to_value(&s).expect("serialise");
2166 assert!(
2167 json.get("on_failure").is_none(),
2168 "empty on_failure must not appear on the wire: {json}"
2169 );
2170 }
2171
2172 #[test]
2173 fn validate_accepts_good_retry() {
2174 for (max, backoff) in [(1, "30s"), (3, "10m"), (10, "1h")] {
2175 with_retry(max, backoff)
2176 .validate()
2177 .unwrap_or_else(|e| panic!("retry {{max:{max}, backoff:{backoff}}}: {e}"));
2178 }
2179 }
2180
2181 #[test]
2182 fn validate_rejects_bad_backoff() {
2183 let err = with_retry(3, "soon").validate().unwrap_err();
2184 assert!(err.contains("on_failure.retry.backoff"), "got: {err}");
2185 }
2186
2187 #[test]
2188 fn validate_rejects_sub_second_backoff() {
2189 for bad in ["500ms", "0s", "999ms"] {
2193 let err = with_retry(3, bad).validate().unwrap_err();
2194 assert!(
2195 err.contains("on_failure.retry.backoff must be >= 1s"),
2196 "for '{bad}', got: {err}"
2197 );
2198 }
2199 }
2200
2201 #[test]
2202 fn validate_rejects_out_of_range_max() {
2203 for bad in [0u32, 11, 1000] {
2204 let err = with_retry(bad, "10m").validate().unwrap_err();
2205 assert!(
2206 err.contains("on_failure.retry.max"),
2207 "for max={bad}, got: {err}"
2208 );
2209 }
2210 }
2211
2212 #[test]
2213 fn lowered_retry_reduces_backoff_to_seconds() {
2214 let s = with_retry(3, "10m");
2215 let spec = s.on_failure.lowered_retry().expect("a retry policy");
2216 assert_eq!(spec.max, 3);
2217 assert_eq!(spec.backoff_secs, 600);
2218 }
2219
2220 #[test]
2221 fn lowered_retry_is_none_without_policy() {
2222 let s = schedule_with(
2223 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2224 RunsOn::Backend,
2225 );
2226 assert!(s.on_failure.lowered_retry().is_none());
2227 }
2228
2229 #[test]
2232 fn freeze_empty_window_is_always_active() {
2233 let f = Freeze::default();
2235 assert!(f.is_active(chrono::Utc::now()));
2236 }
2237
2238 #[test]
2239 fn freeze_window_is_half_open() {
2240 use chrono::TimeZone;
2241 let f = Freeze {
2242 from: Some("2026-12-20T00:00:00+00:00".into()),
2243 until: Some("2027-01-05T00:00:00+00:00".into()),
2244 reason: Some("year-end".into()),
2245 tz: ScheduleTz::Utc,
2246 };
2247 let at = |y, mo, d| chrono::Utc.with_ymd_and_hms(y, mo, d, 0, 0, 0).unwrap();
2248 assert!(!f.is_active(at(2026, 12, 19)), "before from = not frozen");
2249 assert!(f.is_active(at(2026, 12, 20)), "from is inclusive");
2250 assert!(f.is_active(at(2026, 12, 31)), "inside window");
2251 assert!(!f.is_active(at(2027, 1, 5)), "until is exclusive");
2252 assert!(!f.is_active(at(2027, 1, 6)), "after until = not frozen");
2253 }
2254
2255 #[test]
2256 fn freeze_fails_closed_on_corrupt_bound() {
2257 let f = Freeze {
2262 from: Some("not-a-date".into()),
2263 until: None,
2264 reason: None,
2265 tz: ScheduleTz::Utc,
2266 };
2267 assert!(f.is_active(chrono::Utc::now()), "corrupt bound → frozen");
2268 }
2269
2270 #[test]
2271 fn freeze_validate_accepts_good_bounds() {
2272 Freeze {
2273 from: Some("2026-12-20".into()),
2274 until: Some("2027-01-05T12:00:00+09:00".into()),
2275 reason: None,
2276 tz: ScheduleTz::Local,
2277 }
2278 .validate()
2279 .expect("date + rfc3339 bounds should validate");
2280 Freeze::default().validate().expect("empty freeze is valid");
2282 }
2283
2284 #[test]
2285 fn freeze_validate_rejects_bad_bound_and_inverted_window() {
2286 let err = Freeze {
2287 from: Some("never".into()),
2288 ..Default::default()
2289 }
2290 .validate()
2291 .unwrap_err();
2292 assert!(err.contains("freeze:"), "got: {err}");
2293
2294 let inverted = Freeze {
2295 from: Some("2027-01-05".into()),
2296 until: Some("2026-12-20".into()),
2297 ..Default::default()
2298 }
2299 .validate()
2300 .unwrap_err();
2301 assert!(inverted.contains("freeze.from"), "got: {inverted}");
2302 }
2303
2304 #[test]
2305 fn freeze_round_trips_and_skips_empty_fields() {
2306 let f = Freeze {
2307 from: None,
2308 until: Some("2027-01-05".into()),
2309 reason: Some("INC-1234".into()),
2310 tz: ScheduleTz::Utc,
2311 };
2312 let json = serde_json::to_value(&f).expect("serialise");
2313 assert!(json.get("from").is_none(), "empty from omitted: {json}");
2314 let back: Freeze = serde_json::from_value(json).expect("round-trip");
2315 assert_eq!(back, f);
2316 }
2317
2318 #[test]
2319 fn shipped_schedule_configs_parse_and_validate() {
2320 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs/schedules");
2325 let mut seen = 0;
2326 for entry in std::fs::read_dir(&dir).expect("read configs/schedules") {
2327 let path = entry.expect("dir entry").path();
2328 if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
2329 continue;
2330 }
2331 let body = std::fs::read_to_string(&path).expect("read yaml");
2332 let s: Schedule = serde_yaml::from_str(&body)
2333 .unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display()));
2334 s.validate()
2335 .unwrap_or_else(|e| panic!("{} failed validate(): {e}", path.display()));
2336 seen += 1;
2337 }
2338 assert!(seen > 0, "no schedule YAMLs found in {}", dir.display());
2339 }
2340
2341 #[test]
2344 fn exec_mode_serialises_snake_case() {
2345 for (mode, expected) in [
2346 (ExecMode::EveryTick, "every_tick"),
2347 (ExecMode::OncePerPc, "once_per_pc"),
2348 (ExecMode::OncePerTarget, "once_per_target"),
2349 ] {
2350 let s = serde_json::to_value(mode).expect("serialise");
2351 assert_eq!(s, serde_json::Value::String(expected.into()));
2352 let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
2353 .expect("deserialise");
2354 assert_eq!(back, mode, "round-trip for {expected}");
2355 }
2356 }
2357
2358 #[test]
2359 fn schedule_runs_on_defaults_to_backend() {
2360 let yaml = r#"
2361id: x
2362when:
2363 per_pc: once
2364job_id: y
2365target: { all: true }
2366"#;
2367 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
2368 assert_eq!(s.runs_on, RunsOn::Backend);
2369 }
2370
2371 #[test]
2372 fn schedule_runs_on_agent_parses() {
2373 let yaml = r#"
2374id: offline-inv
2375when:
2376 per_pc: { every: 1h }
2377job_id: inventory-hw
2378target: { all: true }
2379runs_on: agent
2380"#;
2381 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
2382 assert_eq!(s.runs_on, RunsOn::Agent);
2383 assert_eq!(s.lowered().mode, ExecMode::OncePerPc);
2384 }
2385
2386 #[test]
2387 fn runs_on_serialises_snake_case() {
2388 for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
2389 let s = serde_json::to_value(mode).expect("serialise");
2390 assert_eq!(s, serde_json::Value::String(expected.into()));
2391 let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
2392 .expect("deserialise");
2393 assert_eq!(back, mode);
2394 }
2395 }
2396
2397 #[test]
2398 fn execute_shell_into_wire_shell() {
2399 assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
2400 assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
2401 }
2402
2403 #[test]
2404 fn manifest_staleness_defaults_to_cached() {
2405 let yaml = r#"
2406id: x
2407version: 1.0.0
2408execute:
2409 shell: powershell
2410 script: "echo"
2411 timeout: 1s
2412"#;
2413 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2414 assert_eq!(m.staleness, Staleness::Cached);
2415 }
2416
2417 #[test]
2418 fn manifest_strict_staleness_parses() {
2419 let yaml = r#"
2420id: urgent-patch
2421version: 2.5.1
2422execute:
2423 shell: powershell
2424 script: Install-Hotfix
2425 timeout: 5m
2426staleness:
2427 mode: strict
2428 max_cache_age: 0s
2429"#;
2430 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2431 match m.staleness {
2432 Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
2433 other => panic!("expected strict, got {other:?}"),
2434 }
2435 }
2436
2437 #[test]
2438 fn manifest_unchecked_staleness_parses() {
2439 let yaml = r#"
2440id: legacy
2441version: 0.1.0
2442execute:
2443 shell: cmd
2444 script: "echo"
2445 timeout: 1s
2446staleness:
2447 mode: unchecked
2448"#;
2449 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2450 assert_eq!(m.staleness, Staleness::Unchecked);
2451 }
2452
2453 #[test]
2454 fn missing_required_field_errors() {
2455 let yaml = r#"
2457version: 1.0.0
2458target: { all: true }
2459execute:
2460 shell: powershell
2461 script: "echo"
2462 timeout: 1s
2463"#;
2464 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
2465 assert!(r.is_err(), "expected error, got {:?}", r);
2466 }
2467
2468 #[test]
2469 fn display_field_table_kind_round_trips_with_nested_columns() {
2470 let yaml = r#"
2476id: inv-hw
2477version: 1.0.0
2478execute:
2479 shell: powershell
2480 script: "echo"
2481 timeout: 60s
2482inventory:
2483 display:
2484 - field: hostname
2485 label: Hostname
2486 - field: disks
2487 label: Disks
2488 type: table
2489 columns:
2490 - field: device_id
2491 label: Drive
2492 - field: size_bytes
2493 label: Size
2494 type: bytes
2495 - field: free_bytes
2496 label: Free
2497 type: bytes
2498 - field: file_system
2499 label: FS
2500"#;
2501 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2502 let inv = m.inventory.as_ref().expect("inventory hint");
2503 let disks = inv
2504 .display
2505 .iter()
2506 .find(|d| d.field == "disks")
2507 .expect("disks display row");
2508 assert_eq!(disks.kind.as_deref(), Some("table"));
2509 let cols = disks.columns.as_ref().expect("table needs columns");
2510 assert_eq!(cols.len(), 4);
2511 assert_eq!(cols[1].field, "size_bytes");
2512 assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
2513 }
2514
2515 #[test]
2516 fn display_field_scalar_kind_keeps_columns_none() {
2517 let yaml = r#"
2522id: x
2523version: 1.0.0
2524execute:
2525 shell: powershell
2526 script: "echo"
2527 timeout: 5s
2528inventory:
2529 display:
2530 - { field: ram_bytes, label: RAM, type: bytes }
2531"#;
2532 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2533 let inv = m.inventory.as_ref().unwrap();
2534 assert!(inv.display[0].columns.is_none());
2535 }
2536}
2537
2538#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
2552pub struct Schedule {
2553 pub id: String,
2554 #[serde(with = "serde_yaml::with::singleton_map")]
2564 #[schemars(with = "When")]
2565 pub when: When,
2566 pub job_id: String,
2569 #[serde(flatten)]
2573 pub plan: FanoutPlan,
2574 #[serde(default, skip_serializing_if = "Active::is_empty")]
2581 pub active: Active,
2582 #[serde(default, skip_serializing_if = "Constraints::is_empty")]
2589 pub constraints: Constraints,
2590 #[serde(default, skip_serializing_if = "OnFailure::is_empty")]
2597 pub on_failure: OnFailure,
2598 #[serde(default)]
2607 pub tz: ScheduleTz,
2608 #[serde(default, skip_serializing_if = "Option::is_none")]
2619 pub starting_deadline: Option<String>,
2620 #[serde(default)]
2630 pub runs_on: RunsOn,
2631 #[serde(default = "default_true")]
2632 pub enabled: bool,
2633}
2634
2635#[derive(
2637 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2638)]
2639#[serde(rename_all = "snake_case")]
2640pub enum RunsOn {
2641 #[default]
2647 Backend,
2648 Agent,
2654}
2655
2656#[derive(
2658 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2659)]
2660#[serde(rename_all = "snake_case")]
2661pub enum ExecMode {
2662 #[default]
2665 EveryTick,
2666 OncePerPc,
2670 OncePerTarget,
2675}
2676
2677#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2694#[serde(rename_all = "snake_case")]
2695pub enum When {
2696 PerPc(PerPolicy),
2701 PerTarget(PerPolicy),
2707 Calendar(CalendarSpec),
2712}
2713
2714#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2719#[serde(deny_unknown_fields)]
2720pub struct CalendarSpec {
2721 pub at: String,
2726 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2732 pub days: Vec<String>,
2733}
2734
2735struct ParsedAt {
2738 minute: u32,
2739 hour: u32,
2740 date: Option<chrono::NaiveDate>,
2741}
2742
2743impl CalendarSpec {
2744 fn parse_at(&self) -> Result<ParsedAt, String> {
2747 use chrono::Timelike;
2748 let s = self.at.trim();
2749 for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y/%m/%d %H:%M"] {
2750 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
2751 return Ok(ParsedAt {
2752 minute: dt.minute(),
2753 hour: dt.hour(),
2754 date: Some(dt.date()),
2755 });
2756 }
2757 }
2758 if let Ok(t) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
2759 return Ok(ParsedAt {
2760 minute: t.minute(),
2761 hour: t.hour(),
2762 date: None,
2763 });
2764 }
2765 Err(format!(
2766 "when.at: unparseable '{}' (want HH:MM or YYYY-MM-DD HH:MM)",
2767 self.at
2768 ))
2769 }
2770
2771 fn validate_days(&self) -> Result<(), String> {
2777 const NAMES: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
2778 for tok in &self.days {
2779 let invalid = |reason: &str| {
2783 Err(format!(
2784 "when.days: invalid day token '{tok}' ({reason}; \
2785 want mon..sun, 0-7, a range like mon-fri, or *)"
2786 ))
2787 };
2788 for part in tok.split('-') {
2789 let p = part.trim().to_ascii_lowercase();
2790 if p.is_empty() {
2791 return invalid("empty range bound");
2792 }
2793 let ok = p == "*"
2794 || NAMES.contains(&p.as_str())
2795 || p.parse::<u8>().map(|n| n <= 7).unwrap_or(false);
2796 if !ok {
2797 return invalid(&format!("'{part}' is not a day"));
2798 }
2799 }
2800 }
2801 Ok(())
2802 }
2803
2804 pub fn oneshot_instant(&self, tz: ScheduleTz) -> Option<chrono::DateTime<chrono::Utc>> {
2809 let p = self.parse_at().ok()?;
2810 let date = p.date?;
2811 let naive = date.and_hms_opt(p.hour, p.minute, 0)?;
2812 tz.naive_to_utc(naive)
2813 }
2814
2815 pub fn fire_time(&self) -> Option<chrono::NaiveTime> {
2820 let p = self.parse_at().ok()?;
2821 chrono::NaiveTime::from_hms_opt(p.hour, p.minute, 0)
2822 }
2823
2824 fn to_cron(&self) -> Result<String, String> {
2829 use chrono::Datelike;
2830 let ParsedAt { minute, hour, date } = self.parse_at()?;
2831 match date {
2832 Some(d) => {
2833 if !self.days.is_empty() {
2834 return Err(
2835 "when.at with a date is a one-shot and cannot be combined with days".into(),
2836 );
2837 }
2838 Ok(format!(
2839 "0 {minute} {hour} {} {} * {}",
2840 d.day(),
2841 d.month(),
2842 d.year()
2843 ))
2844 }
2845 None => {
2846 let dow = if self.days.is_empty() {
2847 "*".to_string()
2848 } else {
2849 self.validate_days()?;
2850 self.days.join(",")
2851 };
2852 Ok(format!("0 {minute} {hour} * * {dow}"))
2853 }
2854 }
2855 }
2856}
2857
2858#[derive(
2861 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2862)]
2863#[serde(rename_all = "snake_case")]
2864pub enum ScheduleTz {
2865 #[default]
2868 Local,
2869 Utc,
2871}
2872
2873impl ScheduleTz {
2874 fn naive_to_utc(self, naive: chrono::NaiveDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
2883 use chrono::TimeZone;
2884 match self {
2885 ScheduleTz::Utc => Some(chrono::DateTime::from_naive_utc_and_offset(
2886 naive,
2887 chrono::Utc,
2888 )),
2889 ScheduleTz::Local => chrono::Local
2890 .from_local_datetime(&naive)
2891 .earliest()
2892 .map(|dt| dt.with_timezone(&chrono::Utc)),
2893 }
2894 }
2895
2896 fn wall_time(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveTime {
2901 match self {
2902 ScheduleTz::Utc => now.time(),
2903 ScheduleTz::Local => now.with_timezone(&chrono::Local).time(),
2904 }
2905 }
2906}
2907
2908#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2912#[serde(untagged)]
2913pub enum PerPolicy {
2914 Once(OnceLiteral),
2917 Every(EverySpec),
2919}
2920
2921#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
2924#[serde(rename_all = "snake_case")]
2925pub enum OnceLiteral {
2926 Once,
2927}
2928
2929#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2934#[serde(deny_unknown_fields)]
2935pub struct EverySpec {
2936 pub every: String,
2939}
2940
2941impl PerPolicy {
2942 fn cooldown(&self) -> Option<String> {
2945 match self {
2946 PerPolicy::Once(_) => None,
2947 PerPolicy::Every(EverySpec { every }) => Some(every.clone()),
2948 }
2949 }
2950}
2951
2952impl std::fmt::Display for When {
2953 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2957 let policy = |p: &PerPolicy| match p {
2958 PerPolicy::Once(_) => "once".to_string(),
2959 PerPolicy::Every(EverySpec { every }) => format!("every {every}"),
2960 };
2961 match self {
2962 When::PerPc(p) => write!(f, "per_pc {}", policy(p)),
2963 When::PerTarget(p) => write!(f, "per_target {}", policy(p)),
2964 When::Calendar(c) if c.days.is_empty() => write!(f, "at {}", c.at),
2965 When::Calendar(c) => write!(f, "at {} [{}]", c.at, c.days.join(",")),
2966 }
2967 }
2968}
2969
2970#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
2982#[serde(deny_unknown_fields)]
2983pub struct Active {
2984 #[serde(default, skip_serializing_if = "Option::is_none")]
2986 pub from: Option<String>,
2987 #[serde(default, skip_serializing_if = "Option::is_none")]
2989 pub until: Option<String>,
2990}
2991
2992impl Active {
2993 pub fn is_empty(&self) -> bool {
2996 self.from.is_none() && self.until.is_none()
2997 }
2998
2999 pub fn parse_bound(s: &str, tz: ScheduleTz) -> Result<chrono::DateTime<chrono::Utc>, String> {
3002 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
3003 return Ok(dt.with_timezone(&chrono::Utc));
3004 }
3005 if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
3006 let midnight = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
3007 return tz.naive_to_utc(midnight).ok_or_else(|| {
3008 format!("active: bound '{s}' falls in a DST gap for the schedule's tz")
3009 });
3010 }
3011 Err(format!(
3012 "active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
3013 ))
3014 }
3015
3016 pub fn contains(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
3021 let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s, tz).ok());
3022 if bound(&self.from).is_some_and(|from| now < from) {
3023 return false;
3024 }
3025 if bound(&self.until).is_some_and(|until| now >= until) {
3026 return false;
3027 }
3028 true
3029 }
3030}
3031
3032#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
3039#[serde(deny_unknown_fields)]
3040pub struct Constraints {
3041 #[serde(default, skip_serializing_if = "Option::is_none")]
3048 pub window: Option<String>,
3049 #[serde(default, skip_serializing_if = "Option::is_none")]
3064 pub max_concurrent: Option<u32>,
3065}
3066
3067impl Constraints {
3068 pub fn is_empty(&self) -> bool {
3071 self.window.is_none() && self.max_concurrent.is_none()
3072 }
3073
3074 pub fn parse_window(s: &str) -> Result<(chrono::NaiveTime, chrono::NaiveTime), String> {
3078 let (a, b) = s
3079 .split_once('-')
3080 .ok_or_else(|| format!("constraints.window: '{s}' must be 'HH:MM-HH:MM'"))?;
3081 let parse = |part: &str| {
3082 chrono::NaiveTime::parse_from_str(part.trim(), "%H:%M")
3083 .map_err(|e| format!("constraints.window: invalid time '{}': {e}", part.trim()))
3084 };
3085 let (start, end) = (parse(a)?, parse(b)?);
3086 if start == end {
3087 return Err(format!(
3088 "constraints.window: start and end are equal ('{s}'); omit window for 'always'"
3089 ));
3090 }
3091 Ok((start, end))
3092 }
3093
3094 pub fn allows(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
3108 match self.window.as_deref() {
3109 None => true,
3111 Some(_) => self.window_contains(tz.wall_time(now)).unwrap_or(false),
3114 }
3115 }
3116
3117 fn window_contains(&self, t: chrono::NaiveTime) -> Option<bool> {
3121 let (start, end) = Self::parse_window(self.window.as_deref()?).ok()?;
3122 Some(if start <= end {
3123 start <= t && t < end
3124 } else {
3125 t >= start || t < end
3126 })
3127 }
3128}
3129
3130#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
3136#[serde(deny_unknown_fields)]
3137pub struct OnFailure {
3138 #[serde(default, skip_serializing_if = "Option::is_none")]
3144 pub retry: Option<Retry>,
3145}
3146
3147impl OnFailure {
3148 pub fn is_empty(&self) -> bool {
3151 self.retry.is_none()
3152 }
3153
3154 pub fn lowered_retry(&self) -> Option<crate::wire::RetrySpec> {
3164 let r = self.retry.as_ref()?;
3165 let backoff_secs = humantime::parse_duration(&r.backoff).ok()?.as_secs();
3166 Some(crate::wire::RetrySpec {
3167 max: r.max,
3168 backoff_secs,
3169 })
3170 }
3171}
3172
3173#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
3181#[serde(deny_unknown_fields)]
3182pub struct Retry {
3183 pub max: u32,
3188 pub backoff: String,
3190}
3191
3192#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
3211#[serde(deny_unknown_fields)]
3212pub struct Freeze {
3213 #[serde(default, skip_serializing_if = "Option::is_none")]
3216 pub from: Option<String>,
3217 #[serde(default, skip_serializing_if = "Option::is_none")]
3220 pub until: Option<String>,
3221 #[serde(default, skip_serializing_if = "Option::is_none")]
3224 pub reason: Option<String>,
3225 #[serde(default)]
3229 pub tz: ScheduleTz,
3230}
3231
3232impl Freeze {
3233 pub fn is_active(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
3243 let bound = |s: &Option<String>| -> Result<Option<chrono::DateTime<chrono::Utc>>, ()> {
3247 match s.as_deref() {
3248 None => Ok(None),
3249 Some(raw) => Active::parse_bound(raw, self.tz).map(Some).map_err(|_| ()),
3250 }
3251 };
3252 let (from, until) = match (bound(&self.from), bound(&self.until)) {
3253 (Ok(f), Ok(u)) => (f, u),
3254 _ => return true,
3256 };
3257 if from.is_some_and(|f| now < f) {
3258 return false;
3259 }
3260 if until.is_some_and(|u| now >= u) {
3261 return false;
3262 }
3263 true
3264 }
3265
3266 pub fn validate(&self) -> Result<(), String> {
3269 let from = self
3270 .from
3271 .as_deref()
3272 .map(|s| Active::parse_bound(s, self.tz))
3273 .transpose()
3274 .map_err(|e| e.replace("active:", "freeze:"))?;
3275 let until = self
3276 .until
3277 .as_deref()
3278 .map(|s| Active::parse_bound(s, self.tz))
3279 .transpose()
3280 .map_err(|e| e.replace("active:", "freeze:"))?;
3281 if let (Some(f), Some(u)) = (from, until) {
3282 if f >= u {
3283 return Err(format!(
3284 "freeze.from ({}) must be strictly before freeze.until ({})",
3285 self.from.as_deref().unwrap_or_default(),
3286 self.until.as_deref().unwrap_or_default(),
3287 ));
3288 }
3289 }
3290 Ok(())
3291 }
3292}
3293
3294pub const POLL_CRON: &str = "0 * * * * *";
3300
3301pub struct Lowered {
3306 pub cron: String,
3309 pub mode: ExecMode,
3311 pub cooldown: Option<String>,
3314 pub tz: ScheduleTz,
3319}
3320
3321impl Schedule {
3322 pub fn bad_window(&self) -> Option<String> {
3327 let w = self.constraints.window.as_deref()?;
3328 Constraints::parse_window(w).err()
3329 }
3330
3331 pub fn calendar_outside_window(&self) -> bool {
3339 let When::Calendar(c) = &self.when else {
3340 return false;
3341 };
3342 let Some(t) = c.fire_time() else {
3343 return false;
3344 };
3345 matches!(self.constraints.window_contains(t), Some(false))
3346 }
3347
3348 pub fn lowered(&self) -> Lowered {
3352 let tz = self.tz;
3353 match &self.when {
3354 When::PerPc(p) => Lowered {
3355 cron: POLL_CRON.into(),
3356 mode: ExecMode::OncePerPc,
3357 cooldown: p.cooldown(),
3358 tz,
3359 },
3360 When::PerTarget(p) => Lowered {
3361 cron: POLL_CRON.into(),
3362 mode: ExecMode::OncePerTarget,
3363 cooldown: p.cooldown(),
3364 tz,
3365 },
3366 When::Calendar(c) => Lowered {
3372 cron: c
3373 .to_cron()
3374 .unwrap_or_else(|_| "# invalid calendar at".into()),
3375 mode: ExecMode::EveryTick,
3376 cooldown: None,
3377 tz,
3378 },
3379 }
3380 }
3381
3382 pub fn validate(&self) -> Result<(), String> {
3390 if matches!(self.runs_on, RunsOn::Agent) && matches!(self.when, When::PerTarget(_)) {
3391 return Err(
3392 "when.per_target needs fleet-wide completion data and is backend-only; \
3393 it cannot be combined with runs_on: agent (each agent self-schedules, \
3394 so per-target dedup would be deduping across a target of 1)"
3395 .into(),
3396 );
3397 }
3398 if let Some(cd) = self.lowered().cooldown.as_deref() {
3399 humantime::parse_duration(cd)
3400 .map_err(|e| format!("when.every: invalid duration '{cd}': {e}"))?;
3401 }
3402 if let When::Calendar(c) = &self.when {
3403 let cron = c.to_cron()?;
3410 croner::parser::CronParser::builder()
3411 .seconds(croner::parser::Seconds::Required)
3412 .dom_and_dow(true)
3413 .build()
3414 .parse(&cron)
3415 .map_err(|e| format!("when.at lowered to invalid cron '{cron}': {e}"))?;
3416 }
3417 if let Some(j) = &self.plan.jitter {
3423 humantime::parse_duration(j)
3424 .map_err(|e| format!("jitter: invalid duration '{j}': {e}"))?;
3425 }
3426 if let Some(sd) = &self.starting_deadline {
3427 humantime::parse_duration(sd)
3428 .map_err(|e| format!("starting_deadline: invalid duration '{sd}': {e}"))?;
3429 }
3430 let from = self
3431 .active
3432 .from
3433 .as_deref()
3434 .map(|s| Active::parse_bound(s, self.tz))
3435 .transpose()?;
3436 let until = self
3437 .active
3438 .until
3439 .as_deref()
3440 .map(|s| Active::parse_bound(s, self.tz))
3441 .transpose()?;
3442 if let (Some(f), Some(u)) = (from, until) {
3443 if f >= u {
3444 return Err(format!(
3445 "active.from ({}) must be strictly before active.until ({})",
3446 self.active.from.as_deref().unwrap_or_default(),
3447 self.active.until.as_deref().unwrap_or_default(),
3448 ));
3449 }
3450 }
3451 if let Some(w) = self.constraints.window.as_deref() {
3454 Constraints::parse_window(w)?;
3455 }
3456 if let Some(mc) = self.constraints.max_concurrent {
3460 if matches!(self.runs_on, RunsOn::Agent) {
3465 return Err(
3466 "constraints.max_concurrent needs a central counter and is backend-only; \
3467 it cannot be combined with runs_on: agent (each agent self-schedules, \
3468 so there is no fleet-wide count to cap against)"
3469 .into(),
3470 );
3471 }
3472 if mc == 0 {
3473 return Err(
3474 "constraints.max_concurrent must be >= 1 (0 would never fire; \
3475 omit it for no cap)"
3476 .into(),
3477 );
3478 }
3479 }
3480 if let Some(r) = &self.on_failure.retry {
3484 let backoff = humantime::parse_duration(&r.backoff).map_err(|e| {
3485 format!(
3486 "on_failure.retry.backoff: invalid duration '{}': {e}",
3487 r.backoff
3488 )
3489 })?;
3490 if backoff.as_secs() < 1 {
3495 return Err(format!(
3496 "on_failure.retry.backoff must be >= 1s (got '{}'); sub-second backoffs \
3497 round to 0 on the wire",
3498 r.backoff
3499 ));
3500 }
3501 if !(1..=10).contains(&r.max) {
3502 return Err(format!(
3503 "on_failure.retry.max must be 1..=10 (got {}); it counts additional \
3504 attempts after the first run",
3505 r.max
3506 ));
3507 }
3508 }
3509 Ok(())
3510 }
3511}
3512
3513fn default_true() -> bool {
3514 true
3515}