Skip to main content

sk_core/
config.rs

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                // User provided paths
92                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                // No user paths, use default if available
99                _ => {
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    // Test all supported defaults in KNOWN_GVKS
206    #[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}