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 #[test]
2033 fn window_fail_closed_on_corrupt_blob() {
2034 let s = with_window("22:00_05:00");
2038 assert!(
2039 !s.constraints.allows(chrono::Utc::now(), ScheduleTz::Utc),
2040 "corrupt window fails closed"
2041 );
2042 assert!(
2044 s.bad_window().is_some(),
2045 "bad_window reports the parse error"
2046 );
2047 assert!(with_window("22:00-05:00").bad_window().is_none());
2048 }
2049
2050 #[test]
2051 fn calendar_outside_window_is_flagged() {
2052 let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
2054 s.constraints.window = Some("22:00-05:00".into());
2055 assert!(s.calendar_outside_window(), "09:00 is not in 22:00-05:00");
2056
2057 let mut s = schedule_with(calendar("23:00", &[]), RunsOn::Backend);
2059 s.constraints.window = Some("22:00-05:00".into());
2060 assert!(!s.calendar_outside_window(), "23:00 is in 22:00-05:00");
2061
2062 let mut s = schedule_with(
2064 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2065 RunsOn::Backend,
2066 );
2067 s.constraints.window = Some("22:00-05:00".into());
2068 assert!(!s.calendar_outside_window(), "reconcile is unaffected");
2069
2070 let s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
2072 assert!(!s.calendar_outside_window());
2073 }
2074
2075 fn with_retry(max: u32, backoff: &str) -> Schedule {
2078 let mut s = schedule_with(
2079 When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2080 RunsOn::Backend,
2081 );
2082 s.on_failure.retry = Some(Retry {
2083 max,
2084 backoff: backoff.into(),
2085 });
2086 s
2087 }
2088
2089 #[test]
2090 fn on_failure_parses_and_round_trips() {
2091 let yaml = r#"
2092id: x
2093when:
2094 per_pc: { every: 6h }
2095job_id: y
2096target: { all: true }
2097on_failure:
2098 retry: { max: 3, backoff: 10m }
2099"#;
2100 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
2101 let r = s.on_failure.retry.as_ref().expect("retry present");
2102 assert_eq!(r.max, 3);
2103 assert_eq!(r.backoff, "10m");
2104 let back: Schedule =
2105 serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
2106 assert_eq!(back.on_failure, s.on_failure);
2107 }
2108
2109 #[test]
2110 fn on_failure_empty_is_skipped_when_serialising() {
2111 let s = schedule_with(
2112 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2113 RunsOn::Backend,
2114 );
2115 let json = serde_json::to_value(&s).expect("serialise");
2116 assert!(
2117 json.get("on_failure").is_none(),
2118 "empty on_failure must not appear on the wire: {json}"
2119 );
2120 }
2121
2122 #[test]
2123 fn validate_accepts_good_retry() {
2124 for (max, backoff) in [(1, "30s"), (3, "10m"), (10, "1h")] {
2125 with_retry(max, backoff)
2126 .validate()
2127 .unwrap_or_else(|e| panic!("retry {{max:{max}, backoff:{backoff}}}: {e}"));
2128 }
2129 }
2130
2131 #[test]
2132 fn validate_rejects_bad_backoff() {
2133 let err = with_retry(3, "soon").validate().unwrap_err();
2134 assert!(err.contains("on_failure.retry.backoff"), "got: {err}");
2135 }
2136
2137 #[test]
2138 fn validate_rejects_sub_second_backoff() {
2139 for bad in ["500ms", "0s", "999ms"] {
2143 let err = with_retry(3, bad).validate().unwrap_err();
2144 assert!(
2145 err.contains("on_failure.retry.backoff must be >= 1s"),
2146 "for '{bad}', got: {err}"
2147 );
2148 }
2149 }
2150
2151 #[test]
2152 fn validate_rejects_out_of_range_max() {
2153 for bad in [0u32, 11, 1000] {
2154 let err = with_retry(bad, "10m").validate().unwrap_err();
2155 assert!(
2156 err.contains("on_failure.retry.max"),
2157 "for max={bad}, got: {err}"
2158 );
2159 }
2160 }
2161
2162 #[test]
2163 fn lowered_retry_reduces_backoff_to_seconds() {
2164 let s = with_retry(3, "10m");
2165 let spec = s.on_failure.lowered_retry().expect("a retry policy");
2166 assert_eq!(spec.max, 3);
2167 assert_eq!(spec.backoff_secs, 600);
2168 }
2169
2170 #[test]
2171 fn lowered_retry_is_none_without_policy() {
2172 let s = schedule_with(
2173 When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2174 RunsOn::Backend,
2175 );
2176 assert!(s.on_failure.lowered_retry().is_none());
2177 }
2178
2179 #[test]
2182 fn freeze_empty_window_is_always_active() {
2183 let f = Freeze::default();
2185 assert!(f.is_active(chrono::Utc::now()));
2186 }
2187
2188 #[test]
2189 fn freeze_window_is_half_open() {
2190 use chrono::TimeZone;
2191 let f = Freeze {
2192 from: Some("2026-12-20T00:00:00+00:00".into()),
2193 until: Some("2027-01-05T00:00:00+00:00".into()),
2194 reason: Some("year-end".into()),
2195 tz: ScheduleTz::Utc,
2196 };
2197 let at = |y, mo, d| chrono::Utc.with_ymd_and_hms(y, mo, d, 0, 0, 0).unwrap();
2198 assert!(!f.is_active(at(2026, 12, 19)), "before from = not frozen");
2199 assert!(f.is_active(at(2026, 12, 20)), "from is inclusive");
2200 assert!(f.is_active(at(2026, 12, 31)), "inside window");
2201 assert!(!f.is_active(at(2027, 1, 5)), "until is exclusive");
2202 assert!(!f.is_active(at(2027, 1, 6)), "after until = not frozen");
2203 }
2204
2205 #[test]
2206 fn freeze_fails_closed_on_corrupt_bound() {
2207 let f = Freeze {
2212 from: Some("not-a-date".into()),
2213 until: None,
2214 reason: None,
2215 tz: ScheduleTz::Utc,
2216 };
2217 assert!(f.is_active(chrono::Utc::now()), "corrupt bound → frozen");
2218 }
2219
2220 #[test]
2221 fn freeze_validate_accepts_good_bounds() {
2222 Freeze {
2223 from: Some("2026-12-20".into()),
2224 until: Some("2027-01-05T12:00:00+09:00".into()),
2225 reason: None,
2226 tz: ScheduleTz::Local,
2227 }
2228 .validate()
2229 .expect("date + rfc3339 bounds should validate");
2230 Freeze::default().validate().expect("empty freeze is valid");
2232 }
2233
2234 #[test]
2235 fn freeze_validate_rejects_bad_bound_and_inverted_window() {
2236 let err = Freeze {
2237 from: Some("never".into()),
2238 ..Default::default()
2239 }
2240 .validate()
2241 .unwrap_err();
2242 assert!(err.contains("freeze:"), "got: {err}");
2243
2244 let inverted = Freeze {
2245 from: Some("2027-01-05".into()),
2246 until: Some("2026-12-20".into()),
2247 ..Default::default()
2248 }
2249 .validate()
2250 .unwrap_err();
2251 assert!(inverted.contains("freeze.from"), "got: {inverted}");
2252 }
2253
2254 #[test]
2255 fn freeze_round_trips_and_skips_empty_fields() {
2256 let f = Freeze {
2257 from: None,
2258 until: Some("2027-01-05".into()),
2259 reason: Some("INC-1234".into()),
2260 tz: ScheduleTz::Utc,
2261 };
2262 let json = serde_json::to_value(&f).expect("serialise");
2263 assert!(json.get("from").is_none(), "empty from omitted: {json}");
2264 let back: Freeze = serde_json::from_value(json).expect("round-trip");
2265 assert_eq!(back, f);
2266 }
2267
2268 #[test]
2269 fn shipped_schedule_configs_parse_and_validate() {
2270 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs/schedules");
2275 let mut seen = 0;
2276 for entry in std::fs::read_dir(&dir).expect("read configs/schedules") {
2277 let path = entry.expect("dir entry").path();
2278 if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
2279 continue;
2280 }
2281 let body = std::fs::read_to_string(&path).expect("read yaml");
2282 let s: Schedule = serde_yaml::from_str(&body)
2283 .unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display()));
2284 s.validate()
2285 .unwrap_or_else(|e| panic!("{} failed validate(): {e}", path.display()));
2286 seen += 1;
2287 }
2288 assert!(seen > 0, "no schedule YAMLs found in {}", dir.display());
2289 }
2290
2291 #[test]
2294 fn exec_mode_serialises_snake_case() {
2295 for (mode, expected) in [
2296 (ExecMode::EveryTick, "every_tick"),
2297 (ExecMode::OncePerPc, "once_per_pc"),
2298 (ExecMode::OncePerTarget, "once_per_target"),
2299 ] {
2300 let s = serde_json::to_value(mode).expect("serialise");
2301 assert_eq!(s, serde_json::Value::String(expected.into()));
2302 let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
2303 .expect("deserialise");
2304 assert_eq!(back, mode, "round-trip for {expected}");
2305 }
2306 }
2307
2308 #[test]
2309 fn schedule_runs_on_defaults_to_backend() {
2310 let yaml = r#"
2311id: x
2312when:
2313 per_pc: once
2314job_id: y
2315target: { all: true }
2316"#;
2317 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
2318 assert_eq!(s.runs_on, RunsOn::Backend);
2319 }
2320
2321 #[test]
2322 fn schedule_runs_on_agent_parses() {
2323 let yaml = r#"
2324id: offline-inv
2325when:
2326 per_pc: { every: 1h }
2327job_id: inventory-hw
2328target: { all: true }
2329runs_on: agent
2330"#;
2331 let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
2332 assert_eq!(s.runs_on, RunsOn::Agent);
2333 assert_eq!(s.lowered().mode, ExecMode::OncePerPc);
2334 }
2335
2336 #[test]
2337 fn runs_on_serialises_snake_case() {
2338 for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
2339 let s = serde_json::to_value(mode).expect("serialise");
2340 assert_eq!(s, serde_json::Value::String(expected.into()));
2341 let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
2342 .expect("deserialise");
2343 assert_eq!(back, mode);
2344 }
2345 }
2346
2347 #[test]
2348 fn execute_shell_into_wire_shell() {
2349 assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
2350 assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
2351 }
2352
2353 #[test]
2354 fn manifest_staleness_defaults_to_cached() {
2355 let yaml = r#"
2356id: x
2357version: 1.0.0
2358execute:
2359 shell: powershell
2360 script: "echo"
2361 timeout: 1s
2362"#;
2363 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2364 assert_eq!(m.staleness, Staleness::Cached);
2365 }
2366
2367 #[test]
2368 fn manifest_strict_staleness_parses() {
2369 let yaml = r#"
2370id: urgent-patch
2371version: 2.5.1
2372execute:
2373 shell: powershell
2374 script: Install-Hotfix
2375 timeout: 5m
2376staleness:
2377 mode: strict
2378 max_cache_age: 0s
2379"#;
2380 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2381 match m.staleness {
2382 Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
2383 other => panic!("expected strict, got {other:?}"),
2384 }
2385 }
2386
2387 #[test]
2388 fn manifest_unchecked_staleness_parses() {
2389 let yaml = r#"
2390id: legacy
2391version: 0.1.0
2392execute:
2393 shell: cmd
2394 script: "echo"
2395 timeout: 1s
2396staleness:
2397 mode: unchecked
2398"#;
2399 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2400 assert_eq!(m.staleness, Staleness::Unchecked);
2401 }
2402
2403 #[test]
2404 fn missing_required_field_errors() {
2405 let yaml = r#"
2407version: 1.0.0
2408target: { all: true }
2409execute:
2410 shell: powershell
2411 script: "echo"
2412 timeout: 1s
2413"#;
2414 let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
2415 assert!(r.is_err(), "expected error, got {:?}", r);
2416 }
2417
2418 #[test]
2419 fn display_field_table_kind_round_trips_with_nested_columns() {
2420 let yaml = r#"
2426id: inv-hw
2427version: 1.0.0
2428execute:
2429 shell: powershell
2430 script: "echo"
2431 timeout: 60s
2432inventory:
2433 display:
2434 - field: hostname
2435 label: Hostname
2436 - field: disks
2437 label: Disks
2438 type: table
2439 columns:
2440 - field: device_id
2441 label: Drive
2442 - field: size_bytes
2443 label: Size
2444 type: bytes
2445 - field: free_bytes
2446 label: Free
2447 type: bytes
2448 - field: file_system
2449 label: FS
2450"#;
2451 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2452 let inv = m.inventory.as_ref().expect("inventory hint");
2453 let disks = inv
2454 .display
2455 .iter()
2456 .find(|d| d.field == "disks")
2457 .expect("disks display row");
2458 assert_eq!(disks.kind.as_deref(), Some("table"));
2459 let cols = disks.columns.as_ref().expect("table needs columns");
2460 assert_eq!(cols.len(), 4);
2461 assert_eq!(cols[1].field, "size_bytes");
2462 assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
2463 }
2464
2465 #[test]
2466 fn display_field_scalar_kind_keeps_columns_none() {
2467 let yaml = r#"
2472id: x
2473version: 1.0.0
2474execute:
2475 shell: powershell
2476 script: "echo"
2477 timeout: 5s
2478inventory:
2479 display:
2480 - { field: ram_bytes, label: RAM, type: bytes }
2481"#;
2482 let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2483 let inv = m.inventory.as_ref().unwrap();
2484 assert!(inv.display[0].columns.is_none());
2485 }
2486}
2487
2488#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
2502pub struct Schedule {
2503 pub id: String,
2504 #[serde(with = "serde_yaml::with::singleton_map")]
2514 #[schemars(with = "When")]
2515 pub when: When,
2516 pub job_id: String,
2519 #[serde(flatten)]
2523 pub plan: FanoutPlan,
2524 #[serde(default, skip_serializing_if = "Active::is_empty")]
2531 pub active: Active,
2532 #[serde(default, skip_serializing_if = "Constraints::is_empty")]
2539 pub constraints: Constraints,
2540 #[serde(default, skip_serializing_if = "OnFailure::is_empty")]
2547 pub on_failure: OnFailure,
2548 #[serde(default)]
2557 pub tz: ScheduleTz,
2558 #[serde(default, skip_serializing_if = "Option::is_none")]
2569 pub starting_deadline: Option<String>,
2570 #[serde(default)]
2580 pub runs_on: RunsOn,
2581 #[serde(default = "default_true")]
2582 pub enabled: bool,
2583}
2584
2585#[derive(
2587 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2588)]
2589#[serde(rename_all = "snake_case")]
2590pub enum RunsOn {
2591 #[default]
2597 Backend,
2598 Agent,
2604}
2605
2606#[derive(
2608 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2609)]
2610#[serde(rename_all = "snake_case")]
2611pub enum ExecMode {
2612 #[default]
2615 EveryTick,
2616 OncePerPc,
2620 OncePerTarget,
2625}
2626
2627#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2644#[serde(rename_all = "snake_case")]
2645pub enum When {
2646 PerPc(PerPolicy),
2651 PerTarget(PerPolicy),
2657 Calendar(CalendarSpec),
2662}
2663
2664#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2669#[serde(deny_unknown_fields)]
2670pub struct CalendarSpec {
2671 pub at: String,
2676 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2682 pub days: Vec<String>,
2683}
2684
2685struct ParsedAt {
2688 minute: u32,
2689 hour: u32,
2690 date: Option<chrono::NaiveDate>,
2691}
2692
2693impl CalendarSpec {
2694 fn parse_at(&self) -> Result<ParsedAt, String> {
2697 use chrono::Timelike;
2698 let s = self.at.trim();
2699 for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y/%m/%d %H:%M"] {
2700 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
2701 return Ok(ParsedAt {
2702 minute: dt.minute(),
2703 hour: dt.hour(),
2704 date: Some(dt.date()),
2705 });
2706 }
2707 }
2708 if let Ok(t) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
2709 return Ok(ParsedAt {
2710 minute: t.minute(),
2711 hour: t.hour(),
2712 date: None,
2713 });
2714 }
2715 Err(format!(
2716 "when.at: unparseable '{}' (want HH:MM or YYYY-MM-DD HH:MM)",
2717 self.at
2718 ))
2719 }
2720
2721 fn validate_days(&self) -> Result<(), String> {
2727 const NAMES: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
2728 for tok in &self.days {
2729 let invalid = |reason: &str| {
2733 Err(format!(
2734 "when.days: invalid day token '{tok}' ({reason}; \
2735 want mon..sun, 0-7, a range like mon-fri, or *)"
2736 ))
2737 };
2738 for part in tok.split('-') {
2739 let p = part.trim().to_ascii_lowercase();
2740 if p.is_empty() {
2741 return invalid("empty range bound");
2742 }
2743 let ok = p == "*"
2744 || NAMES.contains(&p.as_str())
2745 || p.parse::<u8>().map(|n| n <= 7).unwrap_or(false);
2746 if !ok {
2747 return invalid(&format!("'{part}' is not a day"));
2748 }
2749 }
2750 }
2751 Ok(())
2752 }
2753
2754 pub fn oneshot_instant(&self, tz: ScheduleTz) -> Option<chrono::DateTime<chrono::Utc>> {
2759 let p = self.parse_at().ok()?;
2760 let date = p.date?;
2761 let naive = date.and_hms_opt(p.hour, p.minute, 0)?;
2762 tz.naive_to_utc(naive)
2763 }
2764
2765 pub fn fire_time(&self) -> Option<chrono::NaiveTime> {
2770 let p = self.parse_at().ok()?;
2771 chrono::NaiveTime::from_hms_opt(p.hour, p.minute, 0)
2772 }
2773
2774 fn to_cron(&self) -> Result<String, String> {
2779 use chrono::Datelike;
2780 let ParsedAt { minute, hour, date } = self.parse_at()?;
2781 match date {
2782 Some(d) => {
2783 if !self.days.is_empty() {
2784 return Err(
2785 "when.at with a date is a one-shot and cannot be combined with days".into(),
2786 );
2787 }
2788 Ok(format!(
2789 "0 {minute} {hour} {} {} * {}",
2790 d.day(),
2791 d.month(),
2792 d.year()
2793 ))
2794 }
2795 None => {
2796 let dow = if self.days.is_empty() {
2797 "*".to_string()
2798 } else {
2799 self.validate_days()?;
2800 self.days.join(",")
2801 };
2802 Ok(format!("0 {minute} {hour} * * {dow}"))
2803 }
2804 }
2805 }
2806}
2807
2808#[derive(
2811 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2812)]
2813#[serde(rename_all = "snake_case")]
2814pub enum ScheduleTz {
2815 #[default]
2818 Local,
2819 Utc,
2821}
2822
2823impl ScheduleTz {
2824 fn naive_to_utc(self, naive: chrono::NaiveDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
2833 use chrono::TimeZone;
2834 match self {
2835 ScheduleTz::Utc => Some(chrono::DateTime::from_naive_utc_and_offset(
2836 naive,
2837 chrono::Utc,
2838 )),
2839 ScheduleTz::Local => chrono::Local
2840 .from_local_datetime(&naive)
2841 .earliest()
2842 .map(|dt| dt.with_timezone(&chrono::Utc)),
2843 }
2844 }
2845
2846 fn wall_time(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveTime {
2851 match self {
2852 ScheduleTz::Utc => now.time(),
2853 ScheduleTz::Local => now.with_timezone(&chrono::Local).time(),
2854 }
2855 }
2856}
2857
2858#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2862#[serde(untagged)]
2863pub enum PerPolicy {
2864 Once(OnceLiteral),
2867 Every(EverySpec),
2869}
2870
2871#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
2874#[serde(rename_all = "snake_case")]
2875pub enum OnceLiteral {
2876 Once,
2877}
2878
2879#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2884#[serde(deny_unknown_fields)]
2885pub struct EverySpec {
2886 pub every: String,
2889}
2890
2891impl PerPolicy {
2892 fn cooldown(&self) -> Option<String> {
2895 match self {
2896 PerPolicy::Once(_) => None,
2897 PerPolicy::Every(EverySpec { every }) => Some(every.clone()),
2898 }
2899 }
2900}
2901
2902impl std::fmt::Display for When {
2903 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2907 let policy = |p: &PerPolicy| match p {
2908 PerPolicy::Once(_) => "once".to_string(),
2909 PerPolicy::Every(EverySpec { every }) => format!("every {every}"),
2910 };
2911 match self {
2912 When::PerPc(p) => write!(f, "per_pc {}", policy(p)),
2913 When::PerTarget(p) => write!(f, "per_target {}", policy(p)),
2914 When::Calendar(c) if c.days.is_empty() => write!(f, "at {}", c.at),
2915 When::Calendar(c) => write!(f, "at {} [{}]", c.at, c.days.join(",")),
2916 }
2917 }
2918}
2919
2920#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
2932#[serde(deny_unknown_fields)]
2933pub struct Active {
2934 #[serde(default, skip_serializing_if = "Option::is_none")]
2936 pub from: Option<String>,
2937 #[serde(default, skip_serializing_if = "Option::is_none")]
2939 pub until: Option<String>,
2940}
2941
2942impl Active {
2943 pub fn is_empty(&self) -> bool {
2946 self.from.is_none() && self.until.is_none()
2947 }
2948
2949 pub fn parse_bound(s: &str, tz: ScheduleTz) -> Result<chrono::DateTime<chrono::Utc>, String> {
2952 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
2953 return Ok(dt.with_timezone(&chrono::Utc));
2954 }
2955 if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
2956 let midnight = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
2957 return tz.naive_to_utc(midnight).ok_or_else(|| {
2958 format!("active: bound '{s}' falls in a DST gap for the schedule's tz")
2959 });
2960 }
2961 Err(format!(
2962 "active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
2963 ))
2964 }
2965
2966 pub fn contains(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
2971 let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s, tz).ok());
2972 if bound(&self.from).is_some_and(|from| now < from) {
2973 return false;
2974 }
2975 if bound(&self.until).is_some_and(|until| now >= until) {
2976 return false;
2977 }
2978 true
2979 }
2980}
2981
2982#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
2989#[serde(deny_unknown_fields)]
2990pub struct Constraints {
2991 #[serde(default, skip_serializing_if = "Option::is_none")]
2998 pub window: Option<String>,
2999}
3000
3001impl Constraints {
3002 pub fn is_empty(&self) -> bool {
3005 self.window.is_none()
3006 }
3007
3008 pub fn parse_window(s: &str) -> Result<(chrono::NaiveTime, chrono::NaiveTime), String> {
3012 let (a, b) = s
3013 .split_once('-')
3014 .ok_or_else(|| format!("constraints.window: '{s}' must be 'HH:MM-HH:MM'"))?;
3015 let parse = |part: &str| {
3016 chrono::NaiveTime::parse_from_str(part.trim(), "%H:%M")
3017 .map_err(|e| format!("constraints.window: invalid time '{}': {e}", part.trim()))
3018 };
3019 let (start, end) = (parse(a)?, parse(b)?);
3020 if start == end {
3021 return Err(format!(
3022 "constraints.window: start and end are equal ('{s}'); omit window for 'always'"
3023 ));
3024 }
3025 Ok((start, end))
3026 }
3027
3028 pub fn allows(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
3042 match self.window.as_deref() {
3043 None => true,
3045 Some(_) => self.window_contains(tz.wall_time(now)).unwrap_or(false),
3048 }
3049 }
3050
3051 fn window_contains(&self, t: chrono::NaiveTime) -> Option<bool> {
3055 let (start, end) = Self::parse_window(self.window.as_deref()?).ok()?;
3056 Some(if start <= end {
3057 start <= t && t < end
3058 } else {
3059 t >= start || t < end
3060 })
3061 }
3062}
3063
3064#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
3070#[serde(deny_unknown_fields)]
3071pub struct OnFailure {
3072 #[serde(default, skip_serializing_if = "Option::is_none")]
3078 pub retry: Option<Retry>,
3079}
3080
3081impl OnFailure {
3082 pub fn is_empty(&self) -> bool {
3085 self.retry.is_none()
3086 }
3087
3088 pub fn lowered_retry(&self) -> Option<crate::wire::RetrySpec> {
3098 let r = self.retry.as_ref()?;
3099 let backoff_secs = humantime::parse_duration(&r.backoff).ok()?.as_secs();
3100 Some(crate::wire::RetrySpec {
3101 max: r.max,
3102 backoff_secs,
3103 })
3104 }
3105}
3106
3107#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
3115#[serde(deny_unknown_fields)]
3116pub struct Retry {
3117 pub max: u32,
3122 pub backoff: String,
3124}
3125
3126#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
3145#[serde(deny_unknown_fields)]
3146pub struct Freeze {
3147 #[serde(default, skip_serializing_if = "Option::is_none")]
3150 pub from: Option<String>,
3151 #[serde(default, skip_serializing_if = "Option::is_none")]
3154 pub until: Option<String>,
3155 #[serde(default, skip_serializing_if = "Option::is_none")]
3158 pub reason: Option<String>,
3159 #[serde(default)]
3163 pub tz: ScheduleTz,
3164}
3165
3166impl Freeze {
3167 pub fn is_active(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
3177 let bound = |s: &Option<String>| -> Result<Option<chrono::DateTime<chrono::Utc>>, ()> {
3181 match s.as_deref() {
3182 None => Ok(None),
3183 Some(raw) => Active::parse_bound(raw, self.tz).map(Some).map_err(|_| ()),
3184 }
3185 };
3186 let (from, until) = match (bound(&self.from), bound(&self.until)) {
3187 (Ok(f), Ok(u)) => (f, u),
3188 _ => return true,
3190 };
3191 if from.is_some_and(|f| now < f) {
3192 return false;
3193 }
3194 if until.is_some_and(|u| now >= u) {
3195 return false;
3196 }
3197 true
3198 }
3199
3200 pub fn validate(&self) -> Result<(), String> {
3203 let from = self
3204 .from
3205 .as_deref()
3206 .map(|s| Active::parse_bound(s, self.tz))
3207 .transpose()
3208 .map_err(|e| e.replace("active:", "freeze:"))?;
3209 let until = self
3210 .until
3211 .as_deref()
3212 .map(|s| Active::parse_bound(s, self.tz))
3213 .transpose()
3214 .map_err(|e| e.replace("active:", "freeze:"))?;
3215 if let (Some(f), Some(u)) = (from, until) {
3216 if f >= u {
3217 return Err(format!(
3218 "freeze.from ({}) must be strictly before freeze.until ({})",
3219 self.from.as_deref().unwrap_or_default(),
3220 self.until.as_deref().unwrap_or_default(),
3221 ));
3222 }
3223 }
3224 Ok(())
3225 }
3226}
3227
3228pub const POLL_CRON: &str = "0 * * * * *";
3234
3235pub struct Lowered {
3240 pub cron: String,
3243 pub mode: ExecMode,
3245 pub cooldown: Option<String>,
3248 pub tz: ScheduleTz,
3253}
3254
3255impl Schedule {
3256 pub fn bad_window(&self) -> Option<String> {
3261 let w = self.constraints.window.as_deref()?;
3262 Constraints::parse_window(w).err()
3263 }
3264
3265 pub fn calendar_outside_window(&self) -> bool {
3273 let When::Calendar(c) = &self.when else {
3274 return false;
3275 };
3276 let Some(t) = c.fire_time() else {
3277 return false;
3278 };
3279 matches!(self.constraints.window_contains(t), Some(false))
3280 }
3281
3282 pub fn lowered(&self) -> Lowered {
3286 let tz = self.tz;
3287 match &self.when {
3288 When::PerPc(p) => Lowered {
3289 cron: POLL_CRON.into(),
3290 mode: ExecMode::OncePerPc,
3291 cooldown: p.cooldown(),
3292 tz,
3293 },
3294 When::PerTarget(p) => Lowered {
3295 cron: POLL_CRON.into(),
3296 mode: ExecMode::OncePerTarget,
3297 cooldown: p.cooldown(),
3298 tz,
3299 },
3300 When::Calendar(c) => Lowered {
3306 cron: c
3307 .to_cron()
3308 .unwrap_or_else(|_| "# invalid calendar at".into()),
3309 mode: ExecMode::EveryTick,
3310 cooldown: None,
3311 tz,
3312 },
3313 }
3314 }
3315
3316 pub fn validate(&self) -> Result<(), String> {
3324 if matches!(self.runs_on, RunsOn::Agent) && matches!(self.when, When::PerTarget(_)) {
3325 return Err(
3326 "when.per_target needs fleet-wide completion data and is backend-only; \
3327 it cannot be combined with runs_on: agent (each agent self-schedules, \
3328 so per-target dedup would be deduping across a target of 1)"
3329 .into(),
3330 );
3331 }
3332 if let Some(cd) = self.lowered().cooldown.as_deref() {
3333 humantime::parse_duration(cd)
3334 .map_err(|e| format!("when.every: invalid duration '{cd}': {e}"))?;
3335 }
3336 if let When::Calendar(c) = &self.when {
3337 let cron = c.to_cron()?;
3344 croner::parser::CronParser::builder()
3345 .seconds(croner::parser::Seconds::Required)
3346 .dom_and_dow(true)
3347 .build()
3348 .parse(&cron)
3349 .map_err(|e| format!("when.at lowered to invalid cron '{cron}': {e}"))?;
3350 }
3351 if let Some(j) = &self.plan.jitter {
3357 humantime::parse_duration(j)
3358 .map_err(|e| format!("jitter: invalid duration '{j}': {e}"))?;
3359 }
3360 if let Some(sd) = &self.starting_deadline {
3361 humantime::parse_duration(sd)
3362 .map_err(|e| format!("starting_deadline: invalid duration '{sd}': {e}"))?;
3363 }
3364 let from = self
3365 .active
3366 .from
3367 .as_deref()
3368 .map(|s| Active::parse_bound(s, self.tz))
3369 .transpose()?;
3370 let until = self
3371 .active
3372 .until
3373 .as_deref()
3374 .map(|s| Active::parse_bound(s, self.tz))
3375 .transpose()?;
3376 if let (Some(f), Some(u)) = (from, until) {
3377 if f >= u {
3378 return Err(format!(
3379 "active.from ({}) must be strictly before active.until ({})",
3380 self.active.from.as_deref().unwrap_or_default(),
3381 self.active.until.as_deref().unwrap_or_default(),
3382 ));
3383 }
3384 }
3385 if let Some(w) = self.constraints.window.as_deref() {
3388 Constraints::parse_window(w)?;
3389 }
3390 if let Some(r) = &self.on_failure.retry {
3394 let backoff = humantime::parse_duration(&r.backoff).map_err(|e| {
3395 format!(
3396 "on_failure.retry.backoff: invalid duration '{}': {e}",
3397 r.backoff
3398 )
3399 })?;
3400 if backoff.as_secs() < 1 {
3405 return Err(format!(
3406 "on_failure.retry.backoff must be >= 1s (got '{}'); sub-second backoffs \
3407 round to 0 on the wire",
3408 r.backoff
3409 ));
3410 }
3411 if !(1..=10).contains(&r.max) {
3412 return Err(format!(
3413 "on_failure.retry.max must be 1..=10 (got {}); it counts additional \
3414 attempts after the first run",
3415 r.max
3416 ));
3417 }
3418 }
3419 Ok(())
3420 }
3421}
3422
3423fn default_true() -> bool {
3424 true
3425}