1use std::collections::HashMap;
2use std::fs::File;
3use std::ops::Not;
4
5use serde::{
6 Deserialize,
7 Serialize,
8};
9use thiserror::Error;
10use tracing::*;
11
12use crate::constants::KNOWN_GVKS_METADATA;
13use crate::k8s::GVK;
14
15#[derive(Debug, Error)]
16pub enum ConfigError {
17 #[error("Invalid path for {0}")]
18 InvalidPath(GVK),
19 #[error("Missing pod spec path for {0}")]
20 MissingPath(GVK),
21}
22
23#[derive(Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25struct TrackedObjectConfigWithDeprecatedFields {
26 #[deprecated]
27 pub pod_spec_template_path: Option<String>,
28 pub pod_spec_template_paths: Option<Vec<String>>,
29
30 #[serde(default)]
31 pub track_lifecycle: bool,
32
33 #[serde(default)]
34 pub skip_owned: bool,
35}
36
37#[derive(Clone, Debug, Default, Deserialize, Serialize)]
38#[serde(rename_all = "camelCase", from = "TrackedObjectConfigWithDeprecatedFields")]
39pub struct TrackedObjectConfig {
40 pub pod_spec_template_paths: Option<Vec<String>>,
41
42 #[serde(skip_serializing_if = "<&bool>::not")]
43 pub track_lifecycle: bool,
44
45 #[serde(skip_serializing_if = "<&bool>::not")]
46 pub skip_owned: bool,
47}
48
49impl From<TrackedObjectConfigWithDeprecatedFields> for TrackedObjectConfig {
50 fn from(input: TrackedObjectConfigWithDeprecatedFields) -> Self {
51 let mut output = TrackedObjectConfig {
52 pod_spec_template_paths: input.pod_spec_template_paths.clone(),
53 track_lifecycle: input.track_lifecycle,
54 skip_owned: input.skip_owned,
55 };
56
57 #[allow(deprecated)]
58 if let Some(pstp) = input.pod_spec_template_path {
59 warn!(
60 "tracked object config field podSpecTemplatePath is deprecated \
61 and will be removed in a future version of SimKube. Please use \
62 podSpecTemplatePaths instead."
63 );
64
65 if input.pod_spec_template_paths.as_ref().is_some_and(|p| !p.is_empty()) {
66 warn!(
67 "both podSpecTemplatePath and podSpecTemplatePaths are set; \
68 ignoring the deprecated field."
69 );
70 } else {
71 output.pod_spec_template_paths = Some(vec![pstp]);
72 }
73 }
74
75 output
76 }
77}
78
79#[derive(Clone, Debug, Default, Deserialize, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct TracerConfig {
82 pub tracked_objects: HashMap<GVK, TrackedObjectConfig>,
83}
84
85impl TracerConfig {
86 pub fn normalize(mut self) -> Result<Self, ConfigError> {
87 let mut normalized_objects = HashMap::new();
88 for (gvk, mut obj) in self.tracked_objects {
89 let maybe_default = KNOWN_GVKS_METADATA.get(&gvk).cloned();
90 let resolved_paths = match obj.pod_spec_template_paths {
91 Some(paths) if !paths.is_empty() => {
93 if maybe_default.as_ref().is_some_and(|default| paths != *default) {
94 return Err(ConfigError::InvalidPath(gvk.clone()));
95 }
96 paths
97 },
98 _ => {
100 let default = maybe_default
101 .filter(|d| !d.is_empty())
102 .ok_or_else(|| ConfigError::MissingPath(gvk.clone()))?;
103 default.iter().map(|s| s.to_string()).collect()
104 },
105 };
106 obj.pod_spec_template_paths = Some(resolved_paths);
107 normalized_objects.insert(gvk, obj);
108 }
109 self.tracked_objects = normalized_objects;
110 Ok(self)
111 }
112
113 pub fn load(filename: &str) -> anyhow::Result<TracerConfig> {
114 Ok(serde_yaml::from_reader(File::open(filename)?)?)
115 }
116
117 pub fn pod_spec_template_paths(&self, gvk: &GVK) -> Option<&[String]> {
118 self.tracked_objects.get(gvk)?.pod_spec_template_paths.as_deref()
119 }
120
121 pub fn track_lifecycle_for(&self, gvk: &GVK) -> bool {
122 self.tracked_objects.get(gvk).is_some_and(|obj| obj.track_lifecycle)
123 }
124
125 pub fn skip_owned_for(&self, gvk: &GVK) -> bool {
126 self.tracked_objects.get(gvk).is_some_and(|obj| obj.skip_owned)
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use assertables::*;
133 use sk_testutils::*;
134
135 use super::*;
136
137 #[rstest]
138 #[case::none(None, vec!["/foo/bar".into()])]
139 #[case::empty(Some(vec![]), vec!["/foo/bar".into()])]
140 #[case::full(Some(vec!["/asdf".into()]), vec!["/asdf".into()])]
141 fn test_deprecated_config(#[case] pod_spec_template_paths: Option<Vec<String>>, #[case] expected: Vec<String>) {
142 let gvk = GVK::new("fake", "v1", "Resource");
143 let mut config_yml = "
144---
145trackedObjects:
146 fake/v1.Resource:
147 podSpecTemplatePath: /foo/bar
148"
149 .to_string();
150
151 if let Some(pstps) = pod_spec_template_paths
152 && pstps.len() > 0
153 {
154 let pstp = pstps[0].clone();
155 config_yml.push_str(&format!(" podSpecTemplatePaths:\n - {pstp}"));
156 }
157
158 let config: TracerConfig = serde_yaml::from_str(&config_yml).unwrap();
159
160 assert_eq!(config.tracked_objects[&gvk].pod_spec_template_paths, Some(expected));
161 }
162
163 #[rstest]
164 fn test_correct_config() {
165 let gvk = GVK::new("fake", "v1", "Resource");
166 let config_yml = "
167---
168trackedObjects:
169 fake/v1.Resource:
170 podSpecTemplatePaths:
171 - /foo/bar
172"
173 .to_string();
174
175 let config: TracerConfig = serde_yaml::from_str(&config_yml).unwrap();
176 assert_eq!(config.tracked_objects[&gvk].pod_spec_template_paths, Some(vec!["/foo/bar".into()]));
177 }
178
179 enum Expected {
180 Ok(Vec<&'static str>),
181 InvalidPath,
182 MissingPath,
183 }
184
185 fn config_with(gvk: &GVK, paths: Option<Vec<&str>>) -> TracerConfig {
186 let mut map = HashMap::new();
187 map.insert(
188 gvk.clone(),
189 TrackedObjectConfig {
190 pod_spec_template_paths: paths.map(|pstps| pstps.into_iter().map(|pstp| pstp.to_string()).collect()),
191 ..Default::default()
192 },
193 );
194
195 TracerConfig { tracked_objects: map }
196 }
197
198 #[rstest]
199 #[case::known_gvk_with_valid_paths(("batch","v1","CronJob"), Some(vec!["/spec/jobTemplate/spec/template"]), Expected::Ok(vec!["/spec/jobTemplate/spec/template"]))]
200 #[case::known_gvk_with_invalid_paths(("batch","v1","CronJob"), Some(vec!["/invalid/path"]), Expected::InvalidPath)]
201 #[case::known_gvk_with_empty_paths(("apps","v1","DaemonSet"), Some(vec![]), Expected::Ok(vec!["/spec/template"]))]
202 #[case::unknown_gvk_with_paths(("fake","v1","Resource"), Some(vec!["/foo/bar"]), Expected::Ok(vec!["/foo/bar"]))]
203 #[case::unknown_gvk_with_empty_paths(("fake","v1","Resource"), Some(vec![]), Expected::MissingPath)]
204 #[case::unknown_gvk_with_none_paths(("fake","v1","Resource"), None, Expected::MissingPath)]
205 #[case::cronjob_none_paths(("batch","v1","CronJob"), None, Expected::Ok(vec!["/spec/jobTemplate/spec/template"]))]
207 #[case::daemonset_none_paths(("apps","v1","DaemonSet"), None, Expected::Ok(vec!["/spec/template"]))]
208 #[case::deployment_none_paths(("apps","v1","Deployment"), None, Expected::Ok(vec!["/spec/template"]))]
209 #[case::job_none_paths(("batch","v1","Job"), None, Expected::Ok(vec!["/spec/template"]))]
210 #[case::replicaset_none_paths(("apps","v1","ReplicaSet"), None, Expected::Ok(vec!["/spec"]))]
211 #[case::statefulset_none_paths(("apps","v1","StatefulSet"), None, Expected::Ok(vec!["/spec/template"]))]
212 #[case::pod_none_paths(("","v1","Pod"), None, Expected::Ok(vec![""]))]
213
214 fn test_normalize(
215 #[case] input_gvk: (&str, &str, &str),
216 #[case] input_paths: Option<Vec<&str>>,
217 #[case] expected: Expected,
218 ) {
219 let gvk = GVK::new(input_gvk.0, input_gvk.1, input_gvk.2);
220 let config = config_with(&gvk, input_paths);
221 let result = config.normalize();
222
223 match expected {
224 Expected::InvalidPath => {
225 assert_matches!(result, Err(ConfigError::InvalidPath(..)))
226 },
227 Expected::MissingPath => {
228 assert_matches!(result, Err(ConfigError::MissingPath(..)))
229 },
230 Expected::Ok(expected_paths) => {
231 let validated = result.expect("expected success");
232 let actual = validated.tracked_objects[&gvk].pod_spec_template_paths.as_ref().unwrap();
233
234 let expected: Vec<String> = expected_paths.into_iter().map(|s| s.to_string()).collect();
235 assert_eq!(actual, &expected)
236 },
237 }
238 }
239}