use std::collections::HashMap;
use std::fs::File;
use std::ops::Not;
use serde::{
Deserialize,
Serialize,
};
use thiserror::Error;
use tracing::*;
use crate::constants::GVK_POD_SPEC_TEMPLATE_PATHS;
use crate::k8s::GVK;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Invalid path for {0}")]
InvalidPath(GVK),
#[error("Missing pod spec path for {0}")]
MissingPath(GVK),
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct TrackedObjectConfigWithDeprecatedFields {
#[deprecated]
pub pod_spec_template_path: Option<String>,
pub pod_spec_template_paths: Option<Vec<String>>,
#[serde(default)]
pub track_lifecycle: bool,
#[serde(default)]
pub skip_owned: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", from = "TrackedObjectConfigWithDeprecatedFields")]
pub struct TrackedObjectConfig {
pub pod_spec_template_paths: Option<Vec<String>>,
#[serde(skip_serializing_if = "<&bool>::not")]
pub track_lifecycle: bool,
#[serde(skip_serializing_if = "<&bool>::not")]
pub skip_owned: bool,
}
impl From<TrackedObjectConfigWithDeprecatedFields> for TrackedObjectConfig {
fn from(input: TrackedObjectConfigWithDeprecatedFields) -> Self {
let mut output = TrackedObjectConfig {
pod_spec_template_paths: input.pod_spec_template_paths.clone(),
track_lifecycle: input.track_lifecycle,
skip_owned: input.skip_owned,
};
#[allow(deprecated)]
if let Some(pstp) = input.pod_spec_template_path {
warn!(
"tracked object config field podSpecTemplatePath is deprecated \
and will be removed in a future version of SimKube. Please use \
podSpecTemplatePaths instead."
);
if input.pod_spec_template_paths.as_ref().is_some_and(|p| !p.is_empty()) {
warn!(
"both podSpecTemplatePath and podSpecTemplatePaths are set; \
ignoring the deprecated field."
);
} else {
output.pod_spec_template_paths = Some(vec![pstp]);
}
}
output
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TracerConfig {
pub tracked_objects: HashMap<GVK, TrackedObjectConfig>,
}
impl TracerConfig {
pub fn normalize(mut self) -> Result<Self, ConfigError> {
let mut normalized_objects = HashMap::new();
for (gvk, mut obj) in self.tracked_objects {
let maybe_default = GVK_POD_SPEC_TEMPLATE_PATHS.get(&gvk).cloned();
let resolved_paths = match obj.pod_spec_template_paths {
Some(paths) if !paths.is_empty() => {
if maybe_default.as_ref().is_some_and(|default| paths != *default) {
return Err(ConfigError::InvalidPath(gvk.clone()));
}
paths
},
_ => {
let default = maybe_default
.filter(|d| !d.is_empty())
.ok_or_else(|| ConfigError::MissingPath(gvk.clone()))?;
default.iter().map(|s| s.to_string()).collect()
},
};
obj.pod_spec_template_paths = Some(resolved_paths);
normalized_objects.insert(gvk, obj);
}
self.tracked_objects = normalized_objects;
Ok(self)
}
pub fn load(filename: &str) -> anyhow::Result<TracerConfig> {
Ok(serde_yaml::from_reader(File::open(filename)?)?)
}
pub fn pod_spec_template_paths(&self, gvk: &GVK) -> Option<&[String]> {
self.tracked_objects.get(gvk)?.pod_spec_template_paths.as_deref()
}
pub fn track_lifecycle_for(&self, gvk: &GVK) -> bool {
self.tracked_objects.get(gvk).is_some_and(|obj| obj.track_lifecycle)
}
pub fn skip_owned_for(&self, gvk: &GVK) -> bool {
self.tracked_objects.get(gvk).is_some_and(|obj| obj.skip_owned)
}
}
#[cfg(test)]
mod tests {
use assertables::*;
use sk_testutils::*;
use super::*;
#[rstest]
#[case::none(None, vec!["/foo/bar".into()])]
#[case::empty(Some(vec![]), vec!["/foo/bar".into()])]
#[case::full(Some(vec!["/asdf".into()]), vec!["/asdf".into()])]
fn test_deprecated_config(#[case] pod_spec_template_paths: Option<Vec<String>>, #[case] expected: Vec<String>) {
let gvk = GVK::new("fake", "v1", "Resource");
let mut config_yml = "
---
trackedObjects:
fake/v1.Resource:
podSpecTemplatePath: /foo/bar
"
.to_string();
if let Some(pstps) = pod_spec_template_paths
&& pstps.len() > 0
{
let pstp = pstps[0].clone();
config_yml.push_str(&format!(" podSpecTemplatePaths:\n - {pstp}"));
}
let config: TracerConfig = serde_yaml::from_str(&config_yml).unwrap();
assert_eq!(config.tracked_objects[&gvk].pod_spec_template_paths, Some(expected));
}
#[rstest]
fn test_correct_config() {
let gvk = GVK::new("fake", "v1", "Resource");
let config_yml = "
---
trackedObjects:
fake/v1.Resource:
podSpecTemplatePaths:
- /foo/bar
"
.to_string();
let config: TracerConfig = serde_yaml::from_str(&config_yml).unwrap();
assert_eq!(config.tracked_objects[&gvk].pod_spec_template_paths, Some(vec!["/foo/bar".into()]));
}
enum Expected {
Ok(Vec<&'static str>),
InvalidPath,
MissingPath,
}
fn config_with(gvk: &GVK, paths: Option<Vec<&str>>) -> TracerConfig {
let mut map = HashMap::new();
map.insert(
gvk.clone(),
TrackedObjectConfig {
pod_spec_template_paths: paths.map(|pstps| pstps.into_iter().map(|pstp| pstp.to_string()).collect()),
..Default::default()
},
);
TracerConfig { tracked_objects: map }
}
#[rstest]
#[case::known_gvk_with_valid_paths(("batch","v1","CronJob"), Some(vec!["/spec/jobTemplate/spec/template"]), Expected::Ok(vec!["/spec/jobTemplate/spec/template"]))]
#[case::known_gvk_with_invalid_paths(("batch","v1","CronJob"), Some(vec!["/invalid/path"]), Expected::InvalidPath)]
#[case::known_gvk_with_empty_paths(("apps","v1","DaemonSet"), Some(vec![]), Expected::Ok(vec!["/spec/template"]))]
#[case::unknown_gvk_with_paths(("fake","v1","Resource"), Some(vec!["/foo/bar"]), Expected::Ok(vec!["/foo/bar"]))]
#[case::unknown_gvk_with_empty_paths(("fake","v1","Resource"), Some(vec![]), Expected::MissingPath)]
#[case::unknown_gvk_with_none_paths(("fake","v1","Resource"), None, Expected::MissingPath)]
#[case::cronjob_none_paths(("batch","v1","CronJob"), None, Expected::Ok(vec!["/spec/jobTemplate/spec/template"]))]
#[case::daemonset_none_paths(("apps","v1","DaemonSet"), None, Expected::Ok(vec!["/spec/template"]))]
#[case::deployment_none_paths(("apps","v1","Deployment"), None, Expected::Ok(vec!["/spec/template"]))]
#[case::job_none_paths(("batch","v1","Job"), None, Expected::Ok(vec!["/spec/template"]))]
#[case::replicaset_none_paths(("apps","v1","ReplicaSet"), None, Expected::Ok(vec!["/spec"]))]
#[case::statefulset_none_paths(("apps","v1","StatefulSet"), None, Expected::Ok(vec!["/spec/template"]))]
#[case::pod_none_paths(("","v1","Pod"), None, Expected::Ok(vec![""]))]
fn test_normalize(
#[case] input_gvk: (&str, &str, &str),
#[case] input_paths: Option<Vec<&str>>,
#[case] expected: Expected,
) {
let gvk = GVK::new(input_gvk.0, input_gvk.1, input_gvk.2);
let config = config_with(&gvk, input_paths);
let result = config.normalize();
match expected {
Expected::InvalidPath => {
assert_matches!(result, Err(ConfigError::InvalidPath(..)))
},
Expected::MissingPath => {
assert_matches!(result, Err(ConfigError::MissingPath(..)))
},
Expected::Ok(expected_paths) => {
let validated = result.expect("expected success");
let actual = validated.tracked_objects[&gvk].pod_spec_template_paths.as_ref().unwrap();
let expected: Vec<String> = expected_paths.into_iter().map(|s| s.to_string()).collect();
assert_eq!(actual, &expected)
},
}
}
}