1use serde::{Deserialize, Serialize};
26
27use crate::capability_report::LogsCapabilities;
28
29pub const STANDING_WORK_STATUSES: [&str; 4] = ["active", "paused", "superseded", "revoked"];
34
35pub const ONE_SHOT_WORK_STATUSES: [&str; 9] = [
37 "dispatched",
38 "accepted",
39 "running",
40 "paused",
41 "succeeded",
42 "failed",
43 "timed_out",
44 "canceled",
45 "expired",
46];
47
48pub const AGENT_REPORTABLE_WORK_STATUSES: [&str; 3] = ["running", "succeeded", "failed"];
54
55pub const ONE_SHOT_TERMINAL_STATUSES: [&str; 5] =
57 ["succeeded", "failed", "timed_out", "canceled", "expired"];
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
61#[jumo(kind = "state", domain = "Control", module = "Control.Agent.Work")]
62#[serde(rename_all = "PascalCase")]
63pub enum WorkKind {
64 Standing,
66 OneShot,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
76#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
77#[serde(deny_unknown_fields)]
78pub struct StandingWork {
79 pub work_id: String,
80 pub agent_id: String,
81 pub family: String,
83 pub spec: String,
85 pub catalog_version: i64,
87 #[serde(default)]
89 pub proposal_id: Option<String>,
90 pub plan_version: i64,
92 pub effective_from: String,
93 pub status: String,
95 pub updated_by: String,
96 pub updated_at: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
101#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
102#[serde(deny_unknown_fields)]
103pub struct OneShotWork {
104 pub work_id: String,
105 pub agent_id: String,
106 pub action: String,
108 pub spec: String,
109 pub scheduled_at: String,
111 pub deadline_at: String,
113 pub timeout_seconds: i64,
115 pub interruptible: bool,
117 pub status: String,
119 #[serde(default)]
121 pub paused_at: Option<String>,
122 pub paused_total_seconds: i64,
124 #[serde(default)]
126 pub current_step: Option<String>,
127 #[serde(default)]
128 pub completed_steps: Vec<String>,
129 pub attempt: i64,
131 pub issued_by: String,
132 pub issued_at: String,
133}
134
135impl OneShotWork {
136 pub fn is_outstanding(&self) -> bool {
138 !ONE_SHOT_TERMINAL_STATUSES.contains(&self.status.as_str())
139 }
140
141 pub fn can_pause(&self) -> bool {
146 self.interruptible && matches!(self.status.as_str(), "accepted" | "running")
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
152#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
153#[serde(deny_unknown_fields)]
154pub struct WorkReceipt {
155 pub work_id: String,
156 pub agent_id: String,
157 pub work_kind: WorkKind,
158 pub status: String,
160 pub plan_version: i64,
161 pub created_at: String,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct WorkSpec {
183 #[serde(default)]
185 pub units: Vec<WorkSpecUnit>,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct WorkSpecUnit {
196 pub unit_id: String,
197 pub capability: String,
199 #[serde(default)]
201 pub rule_ref: String,
202 #[serde(default)]
205 pub requires_privilege: String,
206 #[serde(default)]
207 pub sources: Vec<WorkSpecSource>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct WorkSpecSource {
217 pub kind: String,
218 pub target: String,
219 #[serde(default = "default_multiline")]
224 pub multiline: String,
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub exclude_globs: Vec<String>,
236 #[serde(default, skip_serializing_if = "ContainerFilters::is_empty")]
245 pub filters: ContainerFilters,
246}
247
248pub const CONTAINER_LOG_ROOT: &str = "/var/log/containers";
253
254#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct ContainerFilters {
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
267 pub namespace_in: Vec<String>,
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub pod_not_in: Vec<String>,
271}
272
273impl ContainerFilters {
274 pub fn is_empty(&self) -> bool {
276 self.namespace_in.is_empty() && self.pod_not_in.is_empty()
277 }
278
279 pub fn validate(&self) -> Result<(), String> {
285 for (field, values) in [
286 ("namespace_in", &self.namespace_in),
287 ("pod_not_in", &self.pod_not_in),
288 ] {
289 for value in values {
290 if value.trim().is_empty() {
291 return Err(format!("{field} 里有空值"));
292 }
293 if value.trim() != value {
294 return Err(format!("{field} 里 {value:?} 带首尾空白"));
295 }
296 if value.contains(['*', '?', '[', ']']) {
297 return Err(format!(
298 "{field} 里 {value:?} 含通配符(这里是精确名字,不是 pattern)"
299 ));
300 }
301 if value.len() > 253 {
302 return Err(format!("{field} 里 {value:?} 过长(>253)"));
303 }
304 }
305 }
306 Ok(())
307 }
308}
309
310fn default_multiline() -> String {
311 "none".to_string()
312}
313
314impl WorkSpec {
315 pub fn parse(spec: &str) -> Result<Self, serde_json::Error> {
320 serde_json::from_str(spec)
321 }
322
323 pub fn encode(&self) -> Result<String, serde_json::Error> {
326 serde_json::to_string(self)
327 }
328
329 pub fn metric_interval_seconds(&self) -> Option<i64> {
334 self.units
335 .iter()
336 .flat_map(|unit| unit.sources.iter())
337 .filter(|source| source.kind == "MetricInterval")
338 .filter_map(|source| parse_interval_seconds(&source.target))
339 .min()
340 }
341
342 pub fn collects_logs(&self) -> bool {
344 self.units
345 .iter()
346 .any(|unit| unit.capability == "collect_logs")
347 }
348}
349
350pub const EXECUTABLE_SOURCE_KINDS: &[&str] = &["FileGlob", "MetricInterval", "Exporter"];
356
357pub fn has_glob_meta(target: &str) -> bool {
359 target.contains(['*', '?', '['])
360}
361
362pub fn is_explicit_path(target: &str) -> bool {
372 target.starts_with('/') && !has_glob_meta(target)
373}
374
375pub fn is_glob_path(target: &str) -> bool {
381 target.starts_with('/') && has_glob_meta(target) && !target.contains("**")
382}
383
384pub fn is_file_target(target: &str) -> bool {
389 is_explicit_path(target) || is_glob_path(target)
390}
391
392pub const KERNEL_LOG_EXPORTER: &str = "journalctl-kernel";
401
402pub fn missing_log_capabilities(
414 kind: &str,
415 target: &str,
416 multiline: &str,
417 declared: &LogsCapabilities,
418) -> Option<Vec<String>> {
419 let declared_has = |bucket: &[String], value: &str| bucket.iter().any(|item| item == value);
420
421 match kind {
422 "FileGlob" => {
423 if !is_file_target(target) {
425 return None;
426 }
427 let mut missing = Vec::new();
428 let need = if is_glob_path(target) {
429 FILE_INPUT_GLOB
430 } else {
431 FILE_INPUT_ABSOLUTE_PATH
432 };
433 if !declared_has(&declared.file_inputs, need) {
434 missing.push(format!("file_inputs:{need}"));
435 }
436 if multiline != "none" && !declared_has(&declared.multiline_modes, multiline) {
438 missing.push(format!("multiline_modes:{multiline}"));
439 }
440 Some(missing)
441 }
442 "Exporter" => {
443 let (id, _) = parse_exporter_target(target)?;
444 Some(if declared_has(&declared.exporters, id) {
445 Vec::new()
446 } else {
447 vec![format!("exporters:{id}")]
448 })
449 }
450 _ => None,
451 }
452}
453
454pub const FILE_INPUT_ABSOLUTE_PATH: &str = "absolute-path";
458pub const FILE_INPUT_GLOB: &str = "glob";
460
461pub const MULTILINE_MODES: &[&str] = &["none", "indented"];
463
464pub const EXPORTER_IDS: &[&str] = &[
472 "journalctl-unit",
473 "journalctl-shutdown",
474 "last-reboot",
475 "nft-ruleset",
476 "iptables-save",
477 "smartctl",
478 "dmesg",
479 "auditd-execve",
480 KERNEL_LOG_EXPORTER,
481];
482
483pub fn parse_exporter_target(target: &str) -> Option<(&str, Option<&str>)> {
488 let (id, arg) = match target.split_once(':') {
489 Some((id, arg)) => (id, Some(arg)),
490 None => (target, None),
491 };
492 let id = id.trim();
493 if id.is_empty() || id.contains(char::is_whitespace) {
494 return None;
495 }
496 Some((id, arg))
497}
498
499pub fn is_known_exporter(target: &str) -> bool {
501 match parse_exporter_target(target) {
502 Some((id, _)) => EXPORTER_IDS.contains(&id),
503 None => false,
504 }
505}
506
507pub fn is_executable_source(kind: &str, target: &str) -> bool {
513 match kind {
514 "FileGlob" => is_file_target(target),
516 "MetricInterval" => true,
518 "Exporter" => is_known_exporter(target),
520 _ => false,
521 }
522}
523
524impl WorkSpecUnit {
525 pub fn file_sources(&self) -> Vec<&WorkSpecSource> {
530 self.sources
531 .iter()
532 .filter(|source| source.kind == "FileGlob")
533 .collect()
534 }
535
536 pub fn unsupported_sources(&self) -> Vec<&WorkSpecSource> {
538 self.sources
539 .iter()
540 .filter(|source| !is_executable_source(&source.kind, &source.target))
541 .collect()
542 }
543
544 pub fn has_executable_source(&self) -> bool {
549 self.sources
550 .iter()
551 .any(|source| is_executable_source(&source.kind, &source.target))
552 }
553}
554
555pub fn parse_interval_seconds(raw: &str) -> Option<i64> {
560 let raw = raw.trim();
561 if let Some(value) = raw.strip_suffix('s') {
562 return value.trim().parse::<i64>().ok().filter(|value| *value > 0);
563 }
564 if let Some(value) = raw.strip_suffix('m') {
565 let minutes = value.trim().parse::<i64>().ok()?;
566 return minutes.checked_mul(60).filter(|value| *value > 0);
570 }
571 None
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::capability_report::LogsCapabilities;
578
579 fn one_shot(status: &str, interruptible: bool) -> OneShotWork {
580 OneShotWork {
581 work_id: "work-1".to_string(),
582 agent_id: "agent-1".to_string(),
583 action: "upgrade".to_string(),
584 spec: "0.1.4".to_string(),
585 scheduled_at: "2026-09-23T00:00:00Z".to_string(),
586 deadline_at: "2026-09-24T00:00:00Z".to_string(),
587 timeout_seconds: 600,
588 interruptible,
589 status: status.to_string(),
590 paused_at: None,
591 paused_total_seconds: 0,
592 current_step: None,
593 completed_steps: vec![],
594 attempt: 0,
595 issued_by: "admin".to_string(),
596 issued_at: "2026-09-23T00:00:00Z".to_string(),
597 }
598 }
599
600 #[test]
601 fn terminal_one_shot_work_is_not_outstanding() {
602 for status in ONE_SHOT_TERMINAL_STATUSES {
604 assert!(!one_shot(status, true).is_outstanding(), "{status}");
605 }
606 for status in ["dispatched", "accepted", "running", "paused"] {
607 assert!(one_shot(status, true).is_outstanding(), "{status}");
608 }
609 }
610
611 #[test]
612 fn only_interruptible_running_work_can_pause() {
613 assert!(!one_shot("running", false).can_pause());
615 assert!(!one_shot("dispatched", true).can_pause());
617 assert!(!one_shot("succeeded", true).can_pause());
618 assert!(one_shot("accepted", true).can_pause());
619 assert!(one_shot("running", true).can_pause());
620 }
621
622 #[test]
623 fn work_kind_serializes_as_the_model_names() {
624 assert_eq!(
625 serde_json::to_string(&WorkKind::Standing).expect("serialize"),
626 "\"Standing\""
627 );
628 assert_eq!(
629 serde_json::to_string(&WorkKind::OneShot).expect("serialize"),
630 "\"OneShot\""
631 );
632 }
633
634 #[test]
635 fn only_explicit_absolute_paths_are_collectable_file_targets() {
636 assert!(is_explicit_path("/var/log/install.log"));
639 assert!(!is_explicit_path("/var/log/wifi.log*"));
640 assert!(!is_explicit_path("/Library/Logs/DiagnosticReports/*.ips"));
641 assert!(!is_explicit_path("~/Library/Logs/Homebrew/*"));
642 assert!(!is_explicit_path("relative/app.log"));
643 }
644
645 #[test]
646 fn file_targets_accept_single_level_globs_and_reject_recursion() {
647 assert!(is_glob_path("/var/log/containers/*.log"));
649 assert!(is_glob_path("/var/log/pods/*/*/*.log"));
650 assert!(is_glob_path("/var/lib/postgresql/*/data/log/*.csv"));
651 assert!(is_file_target("/var/log/containers/*.log"));
652 assert!(is_file_target("/var/log/auth.log"));
654 assert!(!is_glob_path("/var/log/**/*.log"));
656 assert!(!is_file_target("/var/log/**/*.log"));
657 assert!(!is_glob_path("~/Library/Logs/*.log"));
659 assert!(!is_file_target("relative/*.log"));
660 assert!(!is_file_target(""));
662 }
663
664 #[test]
665 fn executability_needs_both_a_supported_kind_and_a_supported_target() {
666 assert!(is_executable_source("MetricInterval", "15s"));
668 assert!(is_executable_source("FileGlob", "/var/log/app.log"));
670 assert!(is_executable_source(
671 "FileGlob",
672 "/var/log/containers/*.log"
673 ));
674 assert!(!is_executable_source("FileGlob", "/var/log/**/*.log"));
675 assert!(!is_executable_source("FileGlob", "~/logs/*.log"));
676 assert!(is_executable_source("Exporter", "smartctl"));
678 assert!(is_executable_source("Exporter", "dmesg:panic"));
679 assert!(!is_executable_source("Exporter", "last,lastb"));
680 assert!(!is_executable_source("UnifiedLogPredicate", "syspolicyd"));
682 }
683
684 #[test]
685 fn exporter_targets_split_into_a_known_id_and_an_optional_arg() {
686 assert_eq!(parse_exporter_target("smartctl"), Some(("smartctl", None)));
687 assert_eq!(
688 parse_exporter_target("dmesg:panic"),
689 Some(("dmesg", Some("panic")))
690 );
691 assert_eq!(parse_exporter_target(":panic"), None);
693 assert_eq!(parse_exporter_target(" dm esg"), None);
694 assert!(is_known_exporter("dmesg:nvidia-xid"));
696 assert!(!is_known_exporter("nope"));
697 }
698
699 #[test]
700 fn the_kernel_log_exporter_is_in_the_contract_vocabulary() {
701 assert!(EXPORTER_IDS.contains(&KERNEL_LOG_EXPORTER));
704 assert!(is_known_exporter(KERNEL_LOG_EXPORTER));
705 assert!(is_executable_source("Exporter", KERNEL_LOG_EXPORTER));
706 assert!(!is_known_exporter("journalctl-kernelx"));
708 }
709
710 fn full_logs() -> LogsCapabilities {
714 LogsCapabilities {
715 file_inputs: vec![
716 FILE_INPUT_ABSOLUTE_PATH.to_string(),
717 FILE_INPUT_GLOB.to_string(),
718 ],
719 multiline_modes: MULTILINE_MODES
720 .iter()
721 .map(|mode| mode.to_string())
722 .collect(),
723 exporters: EXPORTER_IDS.iter().map(|id| id.to_string()).collect(),
724 ..LogsCapabilities::default()
725 }
726 }
727
728 #[test]
729 fn capability_gaps_name_the_bucket_and_value() {
730 let old_agent = LogsCapabilities {
731 file_inputs: vec![FILE_INPUT_ABSOLUTE_PATH.to_string()],
733 ..LogsCapabilities::default()
734 };
735 assert_eq!(
736 missing_log_capabilities("FileGlob", "/var/log/app.log", "none", &old_agent),
737 Some(Vec::new())
738 );
739 assert_eq!(
740 missing_log_capabilities("FileGlob", "/var/log/app/*.log", "none", &old_agent),
741 Some(vec!["file_inputs:glob".to_string()])
742 );
743 assert_eq!(
745 missing_log_capabilities("FileGlob", "/var/log/a.log", "indented", &old_agent),
746 Some(vec!["multiline_modes:indented".to_string()])
747 );
748 assert_eq!(
750 missing_log_capabilities("Exporter", KERNEL_LOG_EXPORTER, "none", &old_agent),
751 Some(vec![format!("exporters:{KERNEL_LOG_EXPORTER}")])
752 );
753 assert_eq!(
755 missing_log_capabilities("Exporter", "dmesg:panic", "none", &full_logs()),
756 Some(Vec::new())
757 );
758 }
759
760 #[test]
761 fn an_exporter_id_outside_the_vocabulary_is_reported_as_missing() {
762 assert_eq!(
765 missing_log_capabilities("Exporter", "journalctl-kernelx", "none", &full_logs()),
766 Some(vec!["exporters:journalctl-kernelx".to_string()])
767 );
768 assert_eq!(
770 missing_log_capabilities("Exporter", "dmesg:panic", "none", &old_style_logs()),
771 Some(vec!["exporters:dmesg".to_string()])
772 );
773 }
774
775 fn old_style_logs() -> LogsCapabilities {
777 LogsCapabilities {
778 file_inputs: vec![FILE_INPUT_ABSOLUTE_PATH.to_string()],
779 exporters: vec!["smartctl".to_string()],
780 ..LogsCapabilities::default()
781 }
782 }
783
784 #[test]
785 fn capability_checks_say_nothing_about_things_they_do_not_own() {
786 assert_eq!(
788 missing_log_capabilities("MetricInterval", "15s", "none", &full_logs()),
789 None
790 );
791 assert_eq!(
792 missing_log_capabilities("FileGlob", "~/a/*.log", "none", &full_logs()),
793 None
794 );
795 assert_eq!(
796 missing_log_capabilities("UnifiedLogPredicate", "syspolicyd", "none", &full_logs()),
797 None
798 );
799 }
800
801 #[test]
802 fn a_fully_declared_agent_is_missing_nothing_the_contract_calls_executable() {
803 let full = full_logs();
806 for target in ["/var/log/a.log", "/var/log/containers/*.log"] {
807 assert_eq!(
808 missing_log_capabilities("FileGlob", target, "none", &full),
809 Some(Vec::new()),
810 "{target}"
811 );
812 }
813 for mode in MULTILINE_MODES {
814 assert_eq!(
815 missing_log_capabilities("FileGlob", "/var/log/a.log", mode, &full),
816 Some(Vec::new()),
817 "{mode}"
818 );
819 }
820 for id in EXPORTER_IDS {
821 assert_eq!(
822 missing_log_capabilities("Exporter", id, "none", &full),
823 Some(Vec::new()),
824 "{id}"
825 );
826 }
827 }
828
829 fn spec() -> WorkSpec {
832 WorkSpec {
833 units: vec![
834 WorkSpecUnit {
835 unit_id: "mac-host-metrics".to_string(),
836 capability: "collect_metrics".to_string(),
837 rule_ref: "agent_uplink".to_string(),
838 requires_privilege: "none".to_string(),
839 sources: vec![WorkSpecSource {
840 kind: "MetricInterval".to_string(),
841 target: "15s".to_string(),
842 multiline: "none".to_string(),
843 exclude_globs: Vec::new(),
844 filters: ContainerFilters::default(),
845 }],
846 },
847 WorkSpecUnit {
848 unit_id: "mac-privacy-tcc".to_string(),
849 capability: "collect_logs".to_string(),
850 rule_ref: "macos/tcc".to_string(),
851 requires_privilege: "fda".to_string(),
852 sources: vec![
853 WorkSpecSource {
854 kind: "Exporter".to_string(),
855 target: "sqlite-snapshot(TCC.db)".to_string(),
856 multiline: "none".to_string(),
857 exclude_globs: Vec::new(),
858 filters: ContainerFilters::default(),
859 },
860 WorkSpecSource {
861 kind: "FileGlob".to_string(),
862 target: "/var/log/tccd.log".to_string(),
864 multiline: "indented".to_string(),
866 exclude_globs: Vec::new(),
867 filters: ContainerFilters::default(),
868 },
869 ],
870 },
871 ],
872 }
873 }
874
875 #[test]
876 fn spec_round_trips_and_reports_what_agentd_can_do() {
877 let encoded = spec().encode().expect("encode");
878 let decoded = WorkSpec::parse(&encoded).expect("parse");
879 assert_eq!(decoded, spec());
880
881 assert!(decoded.collects_logs());
882 assert_eq!(decoded.metric_interval_seconds(), Some(15));
884 let files = decoded.units[1].file_sources();
886 assert_eq!(files.len(), 1);
887 assert_eq!(files[0].target, "/var/log/tccd.log");
888 assert_eq!(files[0].multiline, "indented");
889 assert_eq!(decoded.units[1].unsupported_sources().len(), 1);
890 assert_eq!(decoded.units[1].unsupported_sources()[0].kind, "Exporter");
891 }
892
893 #[test]
894 fn a_glob_file_source_is_collectable_but_recursive_globs_are_not() {
895 let mut unit = WorkSpecUnit {
897 unit_id: "linux-container-stdout".to_string(),
898 capability: "collect_logs".to_string(),
899 rule_ref: String::new(),
900 requires_privilege: "root".to_string(),
901 sources: vec![WorkSpecSource {
902 kind: "FileGlob".to_string(),
903 target: "/var/log/containers/*.log".to_string(),
904 multiline: "none".to_string(),
905 exclude_globs: vec!["/var/log/containers/*_kube-system_*.log".to_string()],
906 filters: ContainerFilters::default(),
907 }],
908 };
909 assert_eq!(unit.file_sources().len(), 1, "它是一条文件来源");
910 assert!(unit.unsupported_sources().is_empty());
911 assert!(unit.has_executable_source());
912
913 unit.sources[0].target = "/var/log/**/*.log".to_string();
915 assert_eq!(unit.unsupported_sources().len(), 1);
916 assert!(!unit.has_executable_source());
917 }
918
919 #[test]
920 fn exclude_globs_round_trip_and_stay_out_of_the_wire_when_empty() {
921 let mut source = WorkSpecSource {
922 kind: "FileGlob".to_string(),
923 target: "/var/log/containers/*.log".to_string(),
924 multiline: "none".to_string(),
925 exclude_globs: Vec::new(),
926 filters: ContainerFilters::default(),
927 };
928 assert_eq!(
930 serde_json::to_string(&source).expect("serialize"),
931 r#"{"kind":"FileGlob","target":"/var/log/containers/*.log","multiline":"none"}"#
932 );
933
934 source.exclude_globs = vec!["/var/log/containers/*_kube-system_*.log".to_string()];
935 let json = serde_json::to_string(&source).expect("serialize");
936 assert_eq!(
937 serde_json::from_str::<WorkSpecSource>(&json).expect("parse"),
938 source
939 );
940 let old = r#"{"kind":"FileGlob","target":"/a/*"}"#;
942 let parsed: WorkSpecSource = serde_json::from_str(old).expect("parse legacy source");
943 assert!(parsed.exclude_globs.is_empty());
944 assert!(parsed.filters.is_empty());
945 assert_eq!(parsed.multiline, "none");
946 }
947
948 #[test]
949 fn container_filters_round_trip_and_stay_out_of_the_wire_when_empty() {
950 let mut source = WorkSpecSource {
951 kind: "FileGlob".to_string(),
952 target: "/var/log/containers/*.log".to_string(),
953 multiline: "none".to_string(),
954 exclude_globs: Vec::new(),
955 filters: ContainerFilters::default(),
956 };
957 let json = serde_json::to_string(&source).expect("serialize");
959 assert!(!json.contains("filters"), "{json}");
960
961 source.filters = ContainerFilters {
962 namespace_in: vec!["prod".to_string()],
963 pod_not_in: vec!["debug-pod".to_string()],
964 };
965 let json = serde_json::to_string(&source).expect("serialize");
966 assert!(
967 json.contains(r#""filters":{"namespace_in":["prod"],"pod_not_in":["debug-pod"]}"#),
968 "{json}"
969 );
970 assert_eq!(
971 serde_json::from_str::<WorkSpecSource>(&json).expect("parse"),
972 source
973 );
974
975 let half = r#"{"kind":"FileGlob","target":"/var/log/containers/*.log","filters":{"namespace_in":["prod"]}}"#;
977 let parsed: WorkSpecSource = serde_json::from_str(half).expect("parse half filters");
978 assert_eq!(parsed.filters.namespace_in, vec!["prod".to_string()]);
979 assert!(parsed.filters.pod_not_in.is_empty());
980 }
981
982 #[test]
983 fn an_explicitly_empty_filters_object_is_not_serialized() {
984 let source = WorkSpecSource {
987 kind: "FileGlob".to_string(),
988 target: "/var/log/containers/*.log".to_string(),
989 multiline: "none".to_string(),
990 exclude_globs: Vec::new(),
991 filters: ContainerFilters {
992 namespace_in: Vec::new(),
993 pod_not_in: Vec::new(),
994 },
995 };
996 let json = serde_json::to_string(&source).expect("serialize");
997 assert!(!json.contains("filters"), "{json}");
998 }
999
1000 #[test]
1001 fn container_filters_keep_values_verbatim() {
1002 let filters = ContainerFilters {
1005 namespace_in: vec!["Prod".to_string()],
1006 pod_not_in: vec!["debug-pod".to_string()],
1007 };
1008 let json = serde_json::to_string(&filters).expect("serialize");
1009 let parsed: ContainerFilters = serde_json::from_str(&json).expect("parse");
1010 assert_eq!(parsed.namespace_in, vec!["Prod".to_string()]);
1011 assert_eq!(parsed.pod_not_in, vec!["debug-pod".to_string()]);
1012 }
1013
1014 #[test]
1015 fn container_filters_reject_unknown_keys() {
1016 assert!(serde_json::from_str::<ContainerFilters>(r#"{"namespaces":["prod"]}"#).is_err());
1019 assert!(serde_json::from_str::<ContainerFilters>(r#"{"namespace_in":["prod"]}"#).is_ok());
1020 }
1021
1022 #[test]
1023 fn container_filters_reject_values_that_can_never_match() {
1024 let ok = |namespace: &[&str], pods: &[&str]| ContainerFilters {
1025 namespace_in: namespace.iter().map(|v| v.to_string()).collect(),
1026 pod_not_in: pods.iter().map(|v| v.to_string()).collect(),
1027 };
1028
1029 assert!(
1030 ok(&["prod", "kube-system"], &["debug-pod"])
1031 .validate()
1032 .is_ok()
1033 );
1034 assert!(ok(&[], &[]).is_empty());
1035 assert!(ok(&[""], &[]).validate().is_err());
1037 assert!(ok(&[" prod"], &[]).validate().is_err());
1038 assert!(ok(&["prod*"], &[]).validate().is_err());
1039 assert!(ok(&[], &["debug?pod"]).validate().is_err());
1040 assert!(ok(&[&"x".repeat(254)], &[]).validate().is_err());
1041 }
1042
1043 #[test]
1044 fn a_source_without_multiline_decodes_as_single_line() {
1045 let json = r#"{"units":[{"unit_id":"u","capability":"collect_logs","sources":[{"kind":"FileGlob","target":"/a/*"}]}]}"#;
1048 let spec = WorkSpec::parse(json).expect("parse");
1049 assert_eq!(spec.units[0].sources[0].multiline, "none");
1050 assert_eq!(
1052 spec.encode().expect("encode"),
1053 r#"{"units":[{"unit_id":"u","capability":"collect_logs","rule_ref":"","requires_privilege":"","sources":[{"kind":"FileGlob","target":"/a/*","multiline":"none"}]}]}"#
1054 );
1055 }
1056
1057 #[test]
1058 fn a_broken_spec_is_an_error_not_an_empty_work() {
1059 assert!(WorkSpec::parse("mac-host-metrics").is_err());
1061 assert!(WorkSpec::parse("{").is_err());
1062 assert!(WorkSpec::parse(r#"{"units":[]}"#).unwrap().units.is_empty());
1064 assert_eq!(
1065 WorkSpec::parse(r#"{"units":[]}"#)
1066 .unwrap()
1067 .metric_interval_seconds(),
1068 None
1069 );
1070 }
1071
1072 #[test]
1073 fn interval_parsing_accepts_seconds_and_minutes_only() {
1074 assert_eq!(parse_interval_seconds("15s"), Some(15));
1075 assert_eq!(parse_interval_seconds(" 5s "), Some(5));
1076 assert_eq!(parse_interval_seconds("5m"), Some(300));
1077 assert_eq!(parse_interval_seconds("15"), None);
1079 assert_eq!(parse_interval_seconds("0s"), None);
1080 assert_eq!(parse_interval_seconds(""), None);
1081 }
1082
1083 #[test]
1084 fn interval_parsing_never_overflows_on_huge_values() {
1085 assert_eq!(parse_interval_seconds("922337203685477580m"), None);
1088 assert_eq!(parse_interval_seconds("153722867280912931m"), None);
1089 assert_eq!(parse_interval_seconds("99999999999999999999s"), None);
1091 assert_eq!(parse_interval_seconds("600m"), Some(36_000));
1093 }
1094
1095 #[test]
1096 fn metric_interval_ignores_unparseable_targets_instead_of_treating_them_as_zero() {
1097 let unit = |target: &str| WorkSpecUnit {
1099 unit_id: "u".to_string(),
1100 capability: "collect_metrics".to_string(),
1101 rule_ref: String::new(),
1102 requires_privilege: String::new(),
1103 sources: vec![WorkSpecSource {
1104 kind: "MetricInterval".to_string(),
1105 target: target.to_string(),
1106 multiline: "none".to_string(),
1107 exclude_globs: Vec::new(),
1108 filters: ContainerFilters::default(),
1109 }],
1110 };
1111 let mixed = WorkSpec {
1112 units: vec![unit("garbage"), unit("30s")],
1113 };
1114 assert_eq!(mixed.metric_interval_seconds(), Some(30));
1115
1116 let all_bad = WorkSpec {
1117 units: vec![unit("nope")],
1118 };
1119 assert_eq!(all_bad.metric_interval_seconds(), None);
1120 }
1121}