use indexmap::IndexMap;
use minijinja::Environment;
use sherpack_core::{LoadedPack, SandboxedFileProvider, TemplateContext};
use std::collections::HashMap;
use crate::error::{EngineError, RenderReport, RenderResultWithReport, Result, TemplateError};
use crate::files_object::create_files_value_from_provider;
use crate::filters;
use crate::functions;
const HELPER_TEMPLATE_PREFIX: char = '_';
const NOTES_TEMPLATE_PATTERN: &str = "notes";
#[derive(Debug)]
pub struct RenderResult {
pub manifests: IndexMap<String, String>,
pub notes: Option<String>,
}
pub struct EngineBuilder {
strict_mode: bool,
secret_state: Option<crate::secrets::SecretFunctionState>,
lookup_state: Option<crate::cluster_reader::LookupState>,
}
impl Default for EngineBuilder {
fn default() -> Self {
Self::new()
}
}
impl EngineBuilder {
pub fn new() -> Self {
Self {
strict_mode: true,
secret_state: None,
lookup_state: None,
}
}
pub fn strict(mut self, strict: bool) -> Self {
self.strict_mode = strict;
self
}
pub fn with_secret_state(mut self, state: crate::secrets::SecretFunctionState) -> Self {
self.secret_state = Some(state);
self
}
pub fn with_cluster_reader(
mut self,
reader: std::sync::Arc<dyn crate::cluster_reader::ClusterReader>,
) -> Self {
self.lookup_state = Some(crate::cluster_reader::LookupState::new(reader));
self
}
pub fn build(self) -> Engine {
Engine {
strict_mode: self.strict_mode,
secret_state: self.secret_state,
lookup_state: self.lookup_state,
}
}
}
pub struct Engine {
strict_mode: bool,
secret_state: Option<crate::secrets::SecretFunctionState>,
lookup_state: Option<crate::cluster_reader::LookupState>,
}
impl Engine {
pub fn new(strict_mode: bool) -> Self {
Self {
strict_mode,
secret_state: None,
lookup_state: None,
}
}
#[must_use]
pub fn strict() -> Self {
Self {
strict_mode: true,
secret_state: None,
lookup_state: None,
}
}
#[must_use]
pub fn lenient() -> Self {
Self {
strict_mode: false,
secret_state: None,
lookup_state: None,
}
}
#[must_use]
pub fn builder() -> EngineBuilder {
EngineBuilder::new()
}
pub fn secret_state(&self) -> Option<&crate::secrets::SecretFunctionState> {
self.secret_state.as_ref()
}
fn create_environment(&self) -> Environment<'static> {
let mut env = Environment::new();
if self.strict_mode {
env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
} else {
env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
}
env.add_filter("toyaml", filters::toyaml);
env.add_filter("tojson", filters::tojson);
env.add_filter("tojson_pretty", filters::tojson_pretty);
env.add_filter("fromjson", filters::fromjson);
env.add_filter("fromyaml", filters::fromyaml);
env.add_filter("b64encode", filters::b64encode);
env.add_filter("b64decode", filters::b64decode);
env.add_filter("quote", filters::quote);
env.add_filter("squote", filters::squote);
env.add_filter("nindent", filters::nindent);
env.add_filter("indent", filters::indent);
env.add_filter("required", filters::required);
env.add_filter("empty", filters::empty);
env.add_filter("haskey", filters::haskey);
env.add_filter("keys", filters::keys);
env.add_filter("merge", filters::merge);
env.add_filter("sha256", filters::sha256sum);
env.add_filter("trunc", filters::trunc);
env.add_filter("trimprefix", filters::trimprefix);
env.add_filter("trimsuffix", filters::trimsuffix);
env.add_filter("snakecase", filters::snakecase);
env.add_filter("kebabcase", filters::kebabcase);
env.add_filter("tostrings", filters::tostrings);
env.add_filter("semver_match", filters::semver_match);
env.add_filter("int", filters::int);
env.add_filter("float", filters::float);
env.add_filter("abs", filters::abs);
env.add_filter("basename", filters::basename);
env.add_filter("dirname", filters::dirname);
env.add_filter("extname", filters::extname);
env.add_filter("cleanpath", filters::cleanpath);
env.add_filter("regex_match", filters::regex_match);
env.add_filter("regex_replace", filters::regex_replace);
env.add_filter("regex_find", filters::regex_find);
env.add_filter("regex_find_all", filters::regex_find_all);
env.add_filter("values", filters::values);
env.add_filter("pick", filters::pick);
env.add_filter("omit", filters::omit);
env.add_filter("append", filters::append);
env.add_filter("prepend", filters::prepend);
env.add_filter("concat", filters::concat);
env.add_filter("without", filters::without);
env.add_filter("compact", filters::compact);
env.add_filter("floor", filters::floor);
env.add_filter("ceil", filters::ceil);
env.add_filter("sha1", filters::sha1sum);
env.add_filter("sha512", filters::sha512sum);
env.add_filter("md5", filters::md5sum);
env.add_filter("repeat", filters::repeat);
env.add_filter("camelcase", filters::camelcase);
env.add_filter("pascalcase", filters::pascalcase);
env.add_filter("substr", filters::substr);
env.add_filter("wrap", filters::wrap);
env.add_filter("hasprefix", filters::hasprefix);
env.add_filter("hassuffix", filters::hassuffix);
env.add_function("fail", functions::fail);
env.add_function("dict", functions::dict);
env.add_function("list", functions::list);
env.add_function("get", functions::get);
env.add_function("set", functions::set);
env.add_function("unset", functions::unset);
env.add_function("dig", functions::dig);
env.add_function("coalesce", functions::coalesce);
env.add_function("ternary", functions::ternary);
env.add_function("uuidv4", functions::uuidv4);
env.add_function("tostring", functions::tostring);
env.add_function("toint", functions::toint);
env.add_function("tofloat", functions::tofloat);
env.add_function("now", functions::now);
env.add_function("printf", functions::printf);
env.add_function("tpl", functions::tpl);
env.add_function("tpl_ctx", functions::tpl_ctx);
env.add_function("lookup", functions::lookup);
env.add_function("fromjson", filters::fromjson);
env.add_function("fromyaml", filters::fromyaml);
if let Some(ref secret_state) = self.secret_state {
secret_state.register(&mut env);
}
if let Some(ref lookup_state) = self.lookup_state {
lookup_state.register(&mut env);
}
env
}
pub fn lookup_state(&self) -> Option<&crate::cluster_reader::LookupState> {
self.lookup_state.as_ref()
}
pub fn render_string(
&self,
template: &str,
context: &TemplateContext,
template_name: &str,
) -> Result<String> {
let env = self.create_environment();
let mut env = env;
env.add_template_owned(template_name.to_string(), template.to_string())
.map_err(|e| {
EngineError::Template(Box::new(TemplateError::from_minijinja(
e,
template_name,
template,
)))
})?;
let tmpl = env.get_template(template_name).map_err(|e| {
EngineError::Template(Box::new(TemplateError::from_minijinja(
e,
template_name,
template,
)))
})?;
let ctx = minijinja::context! {
values => &context.values,
release => &context.release,
pack => &context.pack,
capabilities => &context.capabilities,
template => &context.template,
};
tmpl.render(ctx).map_err(|e| {
EngineError::Template(Box::new(TemplateError::from_minijinja(
e,
template_name,
template,
)))
})
}
pub fn render_pack(
&self,
pack: &LoadedPack,
context: &TemplateContext,
) -> Result<RenderResult> {
let result = self.render_pack_collect_errors(pack, context);
if result.report.has_errors() {
let first_error = result
.report
.errors_by_template
.into_values()
.next()
.and_then(|errors| errors.into_iter().next());
return Err(match first_error {
Some(err) => EngineError::Template(Box::new(err)),
None => {
EngineError::Template(Box::new(TemplateError::simple("Unknown template error")))
}
});
}
Ok(RenderResult {
manifests: result.manifests,
notes: result.notes,
})
}
pub fn render_pack_collect_errors(
&self,
pack: &LoadedPack,
context: &TemplateContext,
) -> RenderResultWithReport {
let mut report = RenderReport::new();
let mut manifests = IndexMap::new();
let mut notes = None;
let template_files = match pack.template_files() {
Ok(files) => files,
Err(e) => {
report.add_error(
"<pack>".to_string(),
TemplateError::simple(format!("Failed to list templates: {}", e)),
);
return RenderResultWithReport {
manifests,
notes,
report,
};
}
};
let mut env = self.create_environment();
let templates_dir = &pack.templates_dir;
let mut template_sources: HashMap<String, String> = HashMap::new();
for file_path in &template_files {
let rel_path = file_path.strip_prefix(templates_dir).unwrap_or(file_path);
let template_name = rel_path.to_string_lossy().into_owned();
let content = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
report.add_error(
template_name,
TemplateError::simple(format!("Failed to read template: {}", e)),
);
continue;
}
};
if let Err(e) = env.add_template_owned(template_name.clone(), content.clone()) {
report.add_error(
template_name.clone(),
TemplateError::from_minijinja_enhanced(
e,
&template_name,
&content,
Some(&context.values),
),
);
}
template_sources.insert(template_name, content);
}
env.add_global("values", minijinja::Value::from_serialize(&context.values));
env.add_global(
"release",
minijinja::Value::from_serialize(&context.release),
);
env.add_global("pack", minijinja::Value::from_serialize(&context.pack));
env.add_global(
"capabilities",
minijinja::Value::from_serialize(&context.capabilities),
);
env.add_global(
"template",
minijinja::Value::from_serialize(&context.template),
);
match SandboxedFileProvider::new(&pack.root) {
Ok(provider) => {
env.add_global("files", create_files_value_from_provider(provider));
}
Err(e) => {
report.add_warning(
"files_api",
format!(
"Files API unavailable: {}. Templates using `files.*` will fail.",
e
),
);
}
}
let ctx = minijinja::context! {
values => &context.values,
release => &context.release,
pack => &context.pack,
capabilities => &context.capabilities,
template => &context.template,
};
for file_path in &template_files {
let rel_path = file_path.strip_prefix(templates_dir).unwrap_or(file_path);
let template_name = rel_path.to_string_lossy().into_owned();
let file_stem = rel_path
.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default();
if file_stem.starts_with(HELPER_TEMPLATE_PREFIX) {
continue;
}
let tmpl = match env.get_template(&template_name) {
Ok(t) => t,
Err(_) => {
continue;
}
};
match tmpl.render(&ctx) {
Ok(rendered) => {
if template_name
.to_lowercase()
.contains(NOTES_TEMPLATE_PATTERN)
{
notes = Some(rendered);
} else {
let trimmed = rendered.trim();
if !trimmed.is_empty() && trimmed != "---" {
let output_name = template_name
.trim_end_matches(".j2")
.trim_end_matches(".jinja2");
manifests.insert(output_name.to_string(), rendered);
}
}
report.add_success(template_name);
}
Err(e) => {
let content = template_sources
.get(&template_name)
.map(String::as_str)
.unwrap_or("");
report.add_error(
template_name.clone(),
TemplateError::from_minijinja_enhanced(
e,
&template_name,
content,
Some(&context.values),
),
);
}
}
}
RenderResultWithReport {
manifests,
notes,
report,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use semver::Version;
use sherpack_core::{PackMetadata, ReleaseInfo, Values};
fn create_test_context() -> TemplateContext {
let values = Values::from_yaml(
r#"
image:
repository: nginx
tag: "1.25"
replicas: 3
"#,
)
.unwrap();
let release = ReleaseInfo::for_install("myapp", "default");
let pack = PackMetadata {
name: "mypack".to_string(),
version: Version::new(1, 0, 0),
description: None,
app_version: Some("2.0.0".to_string()),
kube_version: None,
home: None,
icon: None,
sources: vec![],
keywords: vec![],
maintainers: vec![],
annotations: Default::default(),
};
TemplateContext::new(values, release, &pack)
}
#[test]
fn test_render_simple() {
let engine = Engine::new(true);
let ctx = create_test_context();
let template = "replicas: {{ values.replicas }}";
let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
assert_eq!(result, "replicas: 3");
}
#[test]
fn test_render_with_filters() {
let engine = Engine::new(true);
let ctx = create_test_context();
let template = r#"image: {{ values.image | toyaml | nindent(2) }}"#;
let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
assert!(result.contains("repository: nginx"));
assert!(result.contains("tag:"));
}
#[test]
fn test_render_release_info() {
let engine = Engine::new(true);
let ctx = create_test_context();
let template = "name: {{ release.name }}\nnamespace: {{ release.namespace }}";
let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
assert!(result.contains("name: myapp"));
assert!(result.contains("namespace: default"));
}
#[test]
fn test_chainable_undefined_returns_empty() {
let engine = Engine::new(true);
let ctx = create_test_context();
let template = "value: {{ values.undefined_key }}";
let result = engine.render_string(template, &ctx, "test.yaml");
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.trim(), "value:");
}
#[test]
fn test_chainable_typo_returns_empty() {
let engine = Engine::new(true);
let ctx = create_test_context();
let template = "name: {{ value.app.name }}";
let result = engine.render_string(template, &ctx, "test.yaml");
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.trim(), "name:");
}
#[test]
fn test_render_string_unknown_filter() {
let engine = Engine::new(true);
let ctx = create_test_context();
let template = "name: {{ values.image.repository | unknownfilter }}";
let result = engine.render_string(template, &ctx, "test.yaml");
assert!(result.is_err());
}
#[test]
fn test_render_result_with_report_structure() {
use crate::error::{RenderReport, RenderResultWithReport};
let result = RenderResultWithReport {
manifests: {
let mut m = IndexMap::new();
m.insert("deployment.yaml".to_string(), "apiVersion: v1".to_string());
m
},
notes: Some("Install notes".to_string()),
report: RenderReport::new(),
};
assert!(result.is_success());
assert_eq!(result.manifests.len(), 1);
assert!(result.notes.is_some());
}
#[test]
fn test_render_result_partial_success() {
use crate::error::{RenderReport, RenderResultWithReport, TemplateError};
let mut report = RenderReport::new();
report.add_success("good.yaml".to_string());
report.add_error(
"bad.yaml".to_string(),
TemplateError::simple("undefined variable"),
);
let result = RenderResultWithReport {
manifests: {
let mut m = IndexMap::new();
m.insert("good.yaml".to_string(), "content".to_string());
m
},
notes: None,
report,
};
assert!(!result.is_success());
assert_eq!(result.manifests.len(), 1);
assert!(result.manifests.contains_key("good.yaml"));
}
#[test]
fn test_engine_with_secret_state() {
use crate::secrets::SecretFunctionState;
let secret_state = SecretFunctionState::new();
let engine = Engine::builder()
.strict(true)
.with_secret_state(secret_state.clone())
.build();
let ctx = create_test_context();
let template = r#"password: {{ generate_secret("db-password", 16) }}"#;
let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
assert!(result.starts_with("password: "));
let password = result.strip_prefix("password: ").unwrap();
assert_eq!(password.len(), 16);
assert!(password.chars().all(|c| c.is_ascii_alphanumeric()));
assert!(secret_state.is_dirty());
let result2 = engine.render_string(template, &ctx, "test.yaml").unwrap();
assert_eq!(result, result2);
}
#[test]
fn test_engine_without_secret_state() {
let engine = Engine::strict();
let ctx = create_test_context();
let template = r#"password: {{ generate_secret("test", 16) }}"#;
let result = engine.render_string(template, &ctx, "test.yaml");
assert!(result.is_err());
}
#[test]
fn test_engine_with_loaded_secret_state() {
use crate::secrets::SecretFunctionState;
use sherpack_core::SecretState;
let secret_state1 = SecretFunctionState::new();
let engine1 = Engine::builder()
.with_secret_state(secret_state1.clone())
.build();
let ctx = create_test_context();
let template = r#"{{ generate_secret("api-key", 32) }}"#;
let secret = engine1.render_string(template, &ctx, "test.yaml").unwrap();
let persisted = secret_state1.take_state();
let json = serde_json::to_string(&persisted).unwrap();
let loaded: SecretState = serde_json::from_str(&json).unwrap();
let secret_state2 = SecretFunctionState::with_state(loaded);
let engine2 = Engine::builder()
.with_secret_state(secret_state2.clone())
.build();
let secret2 = engine2.render_string(template, &ctx, "test.yaml").unwrap();
assert_eq!(secret, secret2);
assert!(!secret_state2.is_dirty());
}
struct MockClusterReader {
data: std::collections::HashMap<(String, String, String, String), serde_json::Value>,
}
impl crate::cluster_reader::ClusterReader for MockClusterReader {
fn lookup_one(&self, av: &str, k: &str, ns: &str, n: &str) -> Option<serde_json::Value> {
self.data
.get(&(av.into(), k.into(), ns.into(), n.into()))
.cloned()
}
fn lookup_list(&self, _: &str, _: &str, _: &str) -> Vec<serde_json::Value> {
Vec::new()
}
}
#[test]
fn test_lookup_returns_empty_without_reader() {
let engine = Engine::new(true);
let ctx = create_test_context();
let template =
r#"{% set s = lookup("v1", "Secret", "default", "tls") %}got: {{ s | tojson }}"#;
let out = engine.render_string(template, &ctx, "t.yaml").unwrap();
assert_eq!(out, "got: {}");
}
#[test]
fn test_lookup_uses_reader_when_set() {
let mut data = std::collections::HashMap::new();
data.insert(
(
"v1".to_string(),
"Secret".to_string(),
"default".to_string(),
"tls".to_string(),
),
serde_json::json!({"data": {"tls.crt": "xyz"}}),
);
let reader = std::sync::Arc::new(MockClusterReader { data });
let engine = Engine::builder().with_cluster_reader(reader).build();
let ctx = create_test_context();
let template = r#"{% set s = lookup("v1", "Secret", "default", "tls") %}cert: {{ s.data["tls.crt"] }}"#;
let out = engine.render_string(template, &ctx, "t.yaml").unwrap();
assert_eq!(out, "cert: xyz");
let warnings = engine.lookup_state().unwrap().take_warnings();
assert_eq!(warnings.len(), 1);
}
#[test]
fn test_lookup_missing_resource_returns_empty_dict() {
let reader = std::sync::Arc::new(MockClusterReader {
data: std::collections::HashMap::new(),
});
let engine = Engine::builder().with_cluster_reader(reader).build();
let ctx = create_test_context();
let template = r#"{% set s = lookup("v1", "Secret", "default", "missing") %}{% if s %}has{% else %}empty{% endif %}"#;
let out = engine.render_string(template, &ctx, "t.yaml").unwrap();
assert_eq!(out, "empty");
assert!(engine.lookup_state().unwrap().take_warnings().is_empty());
}
}