use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::error::{CoreError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Pack {
pub api_version: String,
#[serde(default)]
pub kind: PackKind,
pub metadata: PackMetadata,
#[serde(default)]
pub dependencies: Vec<Dependency>,
#[serde(default)]
pub engine: EngineConfig,
#[serde(default)]
pub crds: CrdConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CrdConfig {
#[serde(default = "default_true")]
pub install: bool,
#[serde(default)]
pub upgrade: CrdUpgradeConfig,
#[serde(default)]
pub uninstall: CrdUninstallConfig,
#[serde(default = "default_true")]
pub wait_ready: bool,
#[serde(default = "default_wait_timeout", with = "humantime_serde")]
pub wait_timeout: Duration,
}
impl Default for CrdConfig {
fn default() -> Self {
Self {
install: true,
upgrade: CrdUpgradeConfig::default(),
uninstall: CrdUninstallConfig::default(),
wait_ready: true,
wait_timeout: default_wait_timeout(),
}
}
}
fn default_wait_timeout() -> Duration {
Duration::from_secs(60)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CrdUpgradeStrategy {
#[default]
Safe,
Force,
Skip,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CrdUpgradeConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub strategy: CrdUpgradeStrategy,
}
impl Default for CrdUpgradeConfig {
fn default() -> Self {
Self {
enabled: true,
strategy: CrdUpgradeStrategy::Safe,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CrdUninstallConfig {
#[serde(default = "default_true")]
pub keep: bool,
}
impl Default for CrdUninstallConfig {
fn default() -> Self {
Self { keep: true }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum PackKind {
#[default]
Application,
Library,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackMetadata {
pub name: String,
#[serde(with = "version_serde")]
pub version: Version,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub app_version: Option<String>,
#[serde(default)]
pub kube_version: Option<String>,
#[serde(default)]
pub home: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub sources: Vec<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub maintainers: Vec<Maintainer>,
#[serde(default)]
pub annotations: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Maintainer {
pub name: String,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ResolvePolicy {
Always,
#[default]
WhenEnabled,
Never,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dependency {
pub name: String,
pub version: String,
pub repository: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub condition: Option<String>,
#[serde(default)]
pub resolve: ResolvePolicy,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub alias: Option<String>,
}
impl Dependency {
#[inline]
pub fn effective_name(&self) -> &str {
self.alias.as_deref().unwrap_or(&self.name)
}
pub fn should_resolve(&self, values: &serde_json::Value) -> bool {
if !self.enabled {
return false;
}
match self.resolve {
ResolvePolicy::Always => true,
ResolvePolicy::Never => false,
ResolvePolicy::WhenEnabled => {
let Some(condition) = &self.condition else {
return true;
};
evaluate_condition(condition, values)
}
}
}
}
fn evaluate_condition(condition: &str, values: &serde_json::Value) -> bool {
let path: Vec<&str> = condition.split('.').collect();
let mut current = values;
for part in &path {
match current.get(*part) {
Some(v) => current = v,
None => return false, }
}
match current {
serde_json::Value::Bool(b) => *b,
serde_json::Value::Null => false,
serde_json::Value::String(s) => !s.is_empty() && s != "false" && s != "0",
serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
serde_json::Value::Array(a) => !a.is_empty(),
serde_json::Value::Object(o) => !o.is_empty(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineConfig {
#[serde(default = "default_true")]
pub strict: bool,
}
impl Default for EngineConfig {
fn default() -> Self {
Self { strict: true }
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone)]
pub struct LoadedPack {
pub pack: Pack,
pub root: PathBuf,
pub templates_dir: PathBuf,
pub crds_dir: Option<PathBuf>,
pub values_path: PathBuf,
pub schema_path: Option<PathBuf>,
}
impl LoadedPack {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let root = path.as_ref().to_path_buf();
if !root.exists() {
return Err(CoreError::PackNotFound {
path: root.display().to_string(),
});
}
let pack_file = root.join("Pack.yaml");
if !pack_file.exists() {
return Err(CoreError::InvalidPack {
message: format!("Pack.yaml not found in {}", root.display()),
});
}
let pack_content = std::fs::read_to_string(&pack_file)?;
let pack: Pack = serde_yaml::from_str(&pack_content)?;
if pack.api_version != "sherpack/v1" {
return Err(CoreError::InvalidPack {
message: format!(
"Unsupported API version: {}. Expected: sherpack/v1",
pack.api_version
),
});
}
let templates_dir = root.join("templates");
let values_path = root.join("values.yaml");
let schema_path = Self::find_schema_file(&root);
let crds_dir = {
let dir = root.join("crds");
if dir.exists() && dir.is_dir() {
Some(dir)
} else {
None
}
};
Ok(Self {
pack,
root,
templates_dir,
crds_dir,
values_path,
schema_path,
})
}
fn find_schema_file(root: &Path) -> Option<PathBuf> {
let candidates = [
"values.schema.yaml", "values.schema.json", "schema.yaml",
"schema.json",
];
for candidate in candidates {
let path = root.join(candidate);
if path.exists() {
return Some(path);
}
}
None
}
pub fn load_schema(&self) -> Result<Option<crate::schema::Schema>> {
match &self.schema_path {
Some(path) => Ok(Some(crate::schema::Schema::from_file(path)?)),
None => Ok(None),
}
}
pub fn template_files(&self) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if !self.templates_dir.exists() {
return Ok(files);
}
for entry in walkdir::WalkDir::new(&self.templates_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
if matches!(
ext.as_str(),
"yaml" | "yml" | "j2" | "jinja2" | "txt" | "json"
) {
files.push(path.to_path_buf());
}
}
}
}
files.sort();
Ok(files)
}
pub fn crd_files(&self) -> Result<Vec<PathBuf>> {
let Some(crds_dir) = &self.crds_dir else {
return Ok(Vec::new());
};
let mut files = Vec::new();
for entry in walkdir::WalkDir::new(crds_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
if matches!(ext.as_str(), "yaml" | "yml") {
files.push(path.to_path_buf());
}
}
}
}
files.sort();
Ok(files)
}
pub fn has_crds(&self) -> bool {
self.crds_dir.is_some()
}
pub fn load_crds(&self) -> Result<Vec<CrdManifest>> {
let files = self.crd_files()?;
let mut crds = Vec::new();
for file_path in files {
let content = std::fs::read_to_string(&file_path)?;
let relative_path = file_path
.strip_prefix(&self.root)
.unwrap_or(&file_path)
.to_path_buf();
let file_is_templated = contains_jinja_syntax(&content);
for (idx, doc) in content.split("---").enumerate() {
let doc = doc.trim();
if doc.is_empty()
|| doc
.lines()
.all(|l| l.trim().is_empty() || l.trim().starts_with('#'))
{
continue;
}
let is_templated = file_is_templated || contains_jinja_syntax(doc);
if !is_templated {
let parsed: serde_yaml::Value = serde_yaml::from_str(doc)?;
let kind = parsed.get("kind").and_then(|k| k.as_str());
if kind != Some("CustomResourceDefinition") {
return Err(CoreError::InvalidPack {
message: format!(
"File {} contains non-CRD resource (kind: {}). Only CustomResourceDefinition is allowed in crds/ directory",
relative_path.display(),
kind.unwrap_or("unknown")
),
});
}
let name = parsed
.get("metadata")
.and_then(|m| m.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("unknown")
.to_string();
crds.push(CrdManifest {
name,
source_file: relative_path.clone(),
document_index: idx,
content: doc.to_string(),
is_templated: false,
});
} else {
crds.push(CrdManifest {
name: format!("templated-{}-{}", relative_path.display(), idx),
source_file: relative_path.clone(),
document_index: idx,
content: doc.to_string(),
is_templated: true,
});
}
}
}
Ok(crds)
}
pub fn static_crds(&self) -> Result<Vec<CrdManifest>> {
Ok(self
.load_crds()?
.into_iter()
.filter(|c| !c.is_templated)
.collect())
}
pub fn templated_crds(&self) -> Result<Vec<CrdManifest>> {
Ok(self
.load_crds()?
.into_iter()
.filter(|c| c.is_templated)
.collect())
}
pub fn has_templated_crds(&self) -> Result<bool> {
Ok(self.load_crds()?.iter().any(|c| c.is_templated))
}
}
#[derive(Debug, Clone)]
pub struct CrdManifest {
pub name: String,
pub source_file: PathBuf,
pub document_index: usize,
pub content: String,
pub is_templated: bool,
}
fn contains_jinja_syntax(content: &str) -> bool {
content.contains("{{") || content.contains("{%") || content.contains("{#")
}
mod version_serde {
use semver::Version;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(version: &Version, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&version.to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Version, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Version::parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_pack_deserialize() {
let yaml = r#"
apiVersion: sherpack/v1
kind: application
metadata:
name: myapp
version: 1.0.0
description: My application
"#;
let pack: Pack = serde_yaml::from_str(yaml).unwrap();
assert_eq!(pack.metadata.name, "myapp");
assert_eq!(pack.metadata.version.to_string(), "1.0.0");
assert_eq!(pack.kind, PackKind::Application);
}
#[test]
fn test_dependency_defaults() {
let yaml = r#"
name: redis
version: "^7.0"
repository: https://repo.example.com
"#;
let dep: Dependency = serde_yaml::from_str(yaml).unwrap();
assert_eq!(dep.name, "redis");
assert!(dep.enabled); assert_eq!(dep.resolve, ResolvePolicy::WhenEnabled); assert!(dep.condition.is_none());
assert!(dep.alias.is_none());
}
#[test]
fn test_dependency_with_all_fields() {
let yaml = r#"
name: postgresql
version: "^12.0"
repository: https://charts.bitnami.com
enabled: false
condition: database.postgresql.enabled
resolve: always
alias: db
tags:
- database
- backend
"#;
let dep: Dependency = serde_yaml::from_str(yaml).unwrap();
assert_eq!(dep.name, "postgresql");
assert!(!dep.enabled);
assert_eq!(dep.resolve, ResolvePolicy::Always);
assert_eq!(
dep.condition.as_deref(),
Some("database.postgresql.enabled")
);
assert_eq!(dep.alias.as_deref(), Some("db"));
assert_eq!(dep.effective_name(), "db");
assert_eq!(dep.tags, vec!["database", "backend"]);
}
#[test]
fn test_resolve_policy_serialization() {
assert_eq!(
serde_yaml::to_string(&ResolvePolicy::Always)
.unwrap()
.trim(),
"always"
);
assert_eq!(
serde_yaml::to_string(&ResolvePolicy::WhenEnabled)
.unwrap()
.trim(),
"when-enabled"
);
assert_eq!(
serde_yaml::to_string(&ResolvePolicy::Never).unwrap().trim(),
"never"
);
}
#[test]
fn test_evaluate_condition_simple_bool() {
let values = json!({
"redis": {
"enabled": true
},
"postgresql": {
"enabled": false
}
});
assert!(evaluate_condition("redis.enabled", &values));
assert!(!evaluate_condition("postgresql.enabled", &values));
}
#[test]
fn test_evaluate_condition_nested_path() {
let values = json!({
"features": {
"cache": {
"redis": {
"enabled": true
}
}
}
});
assert!(evaluate_condition("features.cache.redis.enabled", &values));
assert!(!evaluate_condition(
"features.cache.memcached.enabled",
&values
));
}
#[test]
fn test_evaluate_condition_missing_path() {
let values = json!({
"redis": {}
});
assert!(!evaluate_condition("redis.enabled", &values));
assert!(!evaluate_condition("nonexistent.path", &values));
}
#[test]
fn test_evaluate_condition_truthy_values() {
let values = json!({
"string_true": "yes",
"string_false": "false",
"string_zero": "0",
"string_empty": "",
"number_one": 1,
"number_zero": 0,
"array_empty": [],
"array_full": [1, 2],
"object_empty": {},
"object_full": {"key": "value"},
"null_val": null
});
assert!(evaluate_condition("string_true", &values));
assert!(!evaluate_condition("string_false", &values));
assert!(!evaluate_condition("string_zero", &values));
assert!(!evaluate_condition("string_empty", &values));
assert!(evaluate_condition("number_one", &values));
assert!(!evaluate_condition("number_zero", &values));
assert!(!evaluate_condition("array_empty", &values));
assert!(evaluate_condition("array_full", &values));
assert!(!evaluate_condition("object_empty", &values));
assert!(evaluate_condition("object_full", &values));
assert!(!evaluate_condition("null_val", &values));
}
#[test]
fn test_should_resolve_disabled() {
let dep = Dependency {
name: "redis".to_string(),
version: "^7.0".to_string(),
repository: "https://repo.example.com".to_string(),
enabled: false,
condition: None,
resolve: ResolvePolicy::Always,
tags: vec![],
alias: None,
};
assert!(!dep.should_resolve(&json!({})));
}
#[test]
fn test_should_resolve_never() {
let dep = Dependency {
name: "redis".to_string(),
version: "^7.0".to_string(),
repository: "https://repo.example.com".to_string(),
enabled: true,
condition: None,
resolve: ResolvePolicy::Never,
tags: vec![],
alias: None,
};
assert!(!dep.should_resolve(&json!({})));
}
#[test]
fn test_should_resolve_always() {
let dep = Dependency {
name: "redis".to_string(),
version: "^7.0".to_string(),
repository: "https://repo.example.com".to_string(),
enabled: true,
condition: Some("redis.enabled".to_string()),
resolve: ResolvePolicy::Always,
tags: vec![],
alias: None,
};
assert!(dep.should_resolve(&json!({"redis": {"enabled": false}})));
}
#[test]
fn test_should_resolve_when_enabled_no_condition() {
let dep = Dependency {
name: "redis".to_string(),
version: "^7.0".to_string(),
repository: "https://repo.example.com".to_string(),
enabled: true,
condition: None,
resolve: ResolvePolicy::WhenEnabled,
tags: vec![],
alias: None,
};
assert!(dep.should_resolve(&json!({})));
}
#[test]
fn test_should_resolve_when_enabled_with_condition() {
let dep = Dependency {
name: "redis".to_string(),
version: "^7.0".to_string(),
repository: "https://repo.example.com".to_string(),
enabled: true,
condition: Some("redis.enabled".to_string()),
resolve: ResolvePolicy::WhenEnabled,
tags: vec![],
alias: None,
};
assert!(dep.should_resolve(&json!({"redis": {"enabled": true}})));
assert!(!dep.should_resolve(&json!({"redis": {"enabled": false}})));
assert!(!dep.should_resolve(&json!({}))); }
#[test]
fn test_crd_config_defaults() {
let config = CrdConfig::default();
assert!(config.install);
assert!(config.upgrade.enabled);
assert_eq!(config.upgrade.strategy, CrdUpgradeStrategy::Safe);
assert!(config.uninstall.keep);
assert!(config.wait_ready);
assert_eq!(config.wait_timeout, Duration::from_secs(60));
}
#[test]
fn test_crd_config_deserialize_defaults() {
let yaml = r#"
apiVersion: sherpack/v1
kind: application
metadata:
name: test
version: 1.0.0
"#;
let pack: Pack = serde_yaml::from_str(yaml).unwrap();
assert!(pack.crds.install);
assert!(pack.crds.wait_ready);
}
#[test]
fn test_crd_config_deserialize_custom() {
let yaml = r#"
apiVersion: sherpack/v1
kind: application
metadata:
name: test
version: 1.0.0
crds:
install: false
upgrade:
enabled: true
strategy: force
uninstall:
keep: false
waitReady: true
waitTimeout: 120s
"#;
let pack: Pack = serde_yaml::from_str(yaml).unwrap();
assert!(!pack.crds.install);
assert!(pack.crds.upgrade.enabled);
assert_eq!(pack.crds.upgrade.strategy, CrdUpgradeStrategy::Force);
assert!(!pack.crds.uninstall.keep);
assert!(pack.crds.wait_ready);
assert_eq!(pack.crds.wait_timeout, Duration::from_secs(120));
}
#[test]
fn test_crd_upgrade_strategy_serialization() {
assert_eq!(
serde_yaml::to_string(&CrdUpgradeStrategy::Safe)
.unwrap()
.trim(),
"safe"
);
assert_eq!(
serde_yaml::to_string(&CrdUpgradeStrategy::Force)
.unwrap()
.trim(),
"force"
);
assert_eq!(
serde_yaml::to_string(&CrdUpgradeStrategy::Skip)
.unwrap()
.trim(),
"skip"
);
}
#[test]
fn test_crd_manifest() {
let manifest = CrdManifest {
name: "myresources.example.com".to_string(),
source_file: PathBuf::from("crds/myresource.yaml"),
document_index: 0,
content: "apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition"
.to_string(),
is_templated: false,
};
assert_eq!(manifest.name, "myresources.example.com");
assert_eq!(manifest.source_file, PathBuf::from("crds/myresource.yaml"));
assert!(!manifest.is_templated);
}
#[test]
fn test_contains_jinja_syntax() {
assert!(contains_jinja_syntax("{{ values.name }}"));
assert!(contains_jinja_syntax("{% if condition %}"));
assert!(contains_jinja_syntax("{# comment #}"));
assert!(!contains_jinja_syntax("plain: yaml"));
assert!(!contains_jinja_syntax("name: test"));
}
#[test]
fn test_crd_manifest_templated() {
let manifest = CrdManifest {
name: "templated-crd-0".to_string(),
source_file: PathBuf::from("crds/dynamic-crd.yaml"),
document_index: 0,
content: "name: {{ values.crdName }}".to_string(),
is_templated: true,
};
assert!(manifest.is_templated);
}
}