1use indexmap::IndexMap;
7use minijinja::Environment;
8use sherpack_core::{LoadedPack, SandboxedFileProvider, TemplateContext};
9use std::collections::HashMap;
10
11use crate::error::{EngineError, RenderReport, RenderResultWithReport, Result, TemplateError};
12use crate::files_object::create_files_value_from_provider;
13use crate::filters;
14use crate::functions;
15
16const HELPER_TEMPLATE_PREFIX: char = '_';
18
19const NOTES_TEMPLATE_PATTERN: &str = "notes";
21
22#[derive(Debug)]
24pub struct RenderResult {
25 pub manifests: IndexMap<String, String>,
27
28 pub notes: Option<String>,
30}
31
32pub struct EngineBuilder {
34 strict_mode: bool,
35 secret_state: Option<crate::secrets::SecretFunctionState>,
36 lookup_state: Option<crate::cluster_reader::LookupState>,
37}
38
39impl Default for EngineBuilder {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl EngineBuilder {
46 pub fn new() -> Self {
47 Self {
48 strict_mode: true,
49 secret_state: None,
50 lookup_state: None,
51 }
52 }
53
54 pub fn strict(mut self, strict: bool) -> Self {
56 self.strict_mode = strict;
57 self
58 }
59
60 pub fn with_secret_state(mut self, state: crate::secrets::SecretFunctionState) -> Self {
65 self.secret_state = Some(state);
66 self
67 }
68
69 pub fn with_cluster_reader(
83 mut self,
84 reader: std::sync::Arc<dyn crate::cluster_reader::ClusterReader>,
85 ) -> Self {
86 self.lookup_state = Some(crate::cluster_reader::LookupState::new(reader));
87 self
88 }
89
90 pub fn build(self) -> Engine {
92 Engine {
93 strict_mode: self.strict_mode,
94 secret_state: self.secret_state,
95 lookup_state: self.lookup_state,
96 }
97 }
98}
99
100pub struct Engine {
102 strict_mode: bool,
103 secret_state: Option<crate::secrets::SecretFunctionState>,
104 lookup_state: Option<crate::cluster_reader::LookupState>,
105}
106
107impl Engine {
108 pub fn new(strict_mode: bool) -> Self {
117 Self {
118 strict_mode,
119 secret_state: None,
120 lookup_state: None,
121 }
122 }
123
124 #[must_use]
129 pub fn strict() -> Self {
130 Self {
131 strict_mode: true,
132 secret_state: None,
133 lookup_state: None,
134 }
135 }
136
137 #[must_use]
142 pub fn lenient() -> Self {
143 Self {
144 strict_mode: false,
145 secret_state: None,
146 lookup_state: None,
147 }
148 }
149
150 #[must_use]
152 pub fn builder() -> EngineBuilder {
153 EngineBuilder::new()
154 }
155
156 pub fn secret_state(&self) -> Option<&crate::secrets::SecretFunctionState> {
158 self.secret_state.as_ref()
159 }
160
161 fn create_environment(&self) -> Environment<'static> {
163 let mut env = Environment::new();
164
165 if self.strict_mode {
170 env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
171 } else {
172 env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
173 }
174
175 env.add_filter("toyaml", filters::toyaml);
177 env.add_filter("tojson", filters::tojson);
178 env.add_filter("tojson_pretty", filters::tojson_pretty);
179 env.add_filter("fromjson", filters::fromjson);
180 env.add_filter("fromyaml", filters::fromyaml);
181 env.add_filter("b64encode", filters::b64encode);
182 env.add_filter("b64decode", filters::b64decode);
183 env.add_filter("quote", filters::quote);
184 env.add_filter("squote", filters::squote);
185 env.add_filter("nindent", filters::nindent);
186 env.add_filter("indent", filters::indent);
187 env.add_filter("required", filters::required);
188 env.add_filter("empty", filters::empty);
189 env.add_filter("haskey", filters::haskey);
190 env.add_filter("keys", filters::keys);
191 env.add_filter("merge", filters::merge);
192 env.add_filter("sha256", filters::sha256sum);
193 env.add_filter("trunc", filters::trunc);
194 env.add_filter("trimprefix", filters::trimprefix);
195 env.add_filter("trimsuffix", filters::trimsuffix);
196 env.add_filter("snakecase", filters::snakecase);
197 env.add_filter("kebabcase", filters::kebabcase);
198 env.add_filter("tostrings", filters::tostrings);
199 env.add_filter("semver_match", filters::semver_match);
200 env.add_filter("int", filters::int);
201 env.add_filter("float", filters::float);
202 env.add_filter("abs", filters::abs);
203
204 env.add_filter("basename", filters::basename);
206 env.add_filter("dirname", filters::dirname);
207 env.add_filter("extname", filters::extname);
208 env.add_filter("cleanpath", filters::cleanpath);
209
210 env.add_filter("regex_match", filters::regex_match);
212 env.add_filter("regex_replace", filters::regex_replace);
213 env.add_filter("regex_find", filters::regex_find);
214 env.add_filter("regex_find_all", filters::regex_find_all);
215
216 env.add_filter("values", filters::values);
218 env.add_filter("pick", filters::pick);
219 env.add_filter("omit", filters::omit);
220
221 env.add_filter("append", filters::append);
223 env.add_filter("prepend", filters::prepend);
224 env.add_filter("concat", filters::concat);
225 env.add_filter("without", filters::without);
226 env.add_filter("compact", filters::compact);
227
228 env.add_filter("floor", filters::floor);
230 env.add_filter("ceil", filters::ceil);
231
232 env.add_filter("sha1", filters::sha1sum);
234 env.add_filter("sha512", filters::sha512sum);
235 env.add_filter("md5", filters::md5sum);
236
237 env.add_filter("repeat", filters::repeat);
239 env.add_filter("camelcase", filters::camelcase);
240 env.add_filter("pascalcase", filters::pascalcase);
241 env.add_filter("substr", filters::substr);
242 env.add_filter("wrap", filters::wrap);
243 env.add_filter("hasprefix", filters::hasprefix);
244 env.add_filter("hassuffix", filters::hassuffix);
245
246 env.add_function("fail", functions::fail);
248 env.add_function("dict", functions::dict);
249 env.add_function("list", functions::list);
250 env.add_function("get", functions::get);
251 env.add_function("set", functions::set);
252 env.add_function("unset", functions::unset);
253 env.add_function("dig", functions::dig);
254 env.add_function("coalesce", functions::coalesce);
255 env.add_function("ternary", functions::ternary);
256 env.add_function("uuidv4", functions::uuidv4);
257 env.add_function("tostring", functions::tostring);
258 env.add_function("toint", functions::toint);
259 env.add_function("tofloat", functions::tofloat);
260 env.add_function("now", functions::now);
261 env.add_function("printf", functions::printf);
262 env.add_function("tpl", functions::tpl);
263 env.add_function("tpl_ctx", functions::tpl_ctx);
264 env.add_function("lookup", functions::lookup);
265 env.add_function("fromjson", filters::fromjson);
266 env.add_function("fromyaml", filters::fromyaml);
267
268 if let Some(ref secret_state) = self.secret_state {
270 secret_state.register(&mut env);
271 }
272
273 if let Some(ref lookup_state) = self.lookup_state {
276 lookup_state.register(&mut env);
277 }
278
279 env
280 }
281
282 pub fn lookup_state(&self) -> Option<&crate::cluster_reader::LookupState> {
287 self.lookup_state.as_ref()
288 }
289
290 pub fn render_string(
292 &self,
293 template: &str,
294 context: &TemplateContext,
295 template_name: &str,
296 ) -> Result<String> {
297 let env = self.create_environment();
298
299 let mut env = env;
301 env.add_template_owned(template_name.to_string(), template.to_string())
302 .map_err(|e| {
303 EngineError::Template(Box::new(TemplateError::from_minijinja(
304 e,
305 template_name,
306 template,
307 )))
308 })?;
309
310 let tmpl = env.get_template(template_name).map_err(|e| {
312 EngineError::Template(Box::new(TemplateError::from_minijinja(
313 e,
314 template_name,
315 template,
316 )))
317 })?;
318
319 let ctx = minijinja::context! {
321 values => &context.values,
322 release => &context.release,
323 pack => &context.pack,
324 capabilities => &context.capabilities,
325 template => &context.template,
326 };
327
328 tmpl.render(ctx).map_err(|e| {
329 EngineError::Template(Box::new(TemplateError::from_minijinja(
330 e,
331 template_name,
332 template,
333 )))
334 })
335 }
336
337 pub fn render_pack(
342 &self,
343 pack: &LoadedPack,
344 context: &TemplateContext,
345 ) -> Result<RenderResult> {
346 let result = self.render_pack_collect_errors(pack, context);
347
348 if result.report.has_errors() {
350 let first_error = result
352 .report
353 .errors_by_template
354 .into_values()
355 .next()
356 .and_then(|errors| errors.into_iter().next());
357
358 return Err(match first_error {
359 Some(err) => EngineError::Template(Box::new(err)),
360 None => {
361 EngineError::Template(Box::new(TemplateError::simple("Unknown template error")))
362 }
363 });
364 }
365
366 Ok(RenderResult {
367 manifests: result.manifests,
368 notes: result.notes,
369 })
370 }
371
372 pub fn render_pack_collect_errors(
377 &self,
378 pack: &LoadedPack,
379 context: &TemplateContext,
380 ) -> RenderResultWithReport {
381 let mut report = RenderReport::new();
382 let mut manifests = IndexMap::new();
383 let mut notes = None;
384
385 let template_files = match pack.template_files() {
386 Ok(files) => files,
387 Err(e) => {
388 report.add_error(
389 "<pack>".to_string(),
390 TemplateError::simple(format!("Failed to list templates: {}", e)),
391 );
392 return RenderResultWithReport {
393 manifests,
394 notes,
395 report,
396 };
397 }
398 };
399
400 let mut env = self.create_environment();
402 let templates_dir = &pack.templates_dir;
403
404 let mut template_sources: HashMap<String, String> = HashMap::new();
406
407 for file_path in &template_files {
409 let rel_path = file_path.strip_prefix(templates_dir).unwrap_or(file_path);
410 let template_name = rel_path.to_string_lossy().into_owned();
411
412 let content = match std::fs::read_to_string(file_path) {
413 Ok(c) => c,
414 Err(e) => {
415 report.add_error(
416 template_name,
417 TemplateError::simple(format!("Failed to read template: {}", e)),
418 );
419 continue;
420 }
421 };
422
423 if let Err(e) = env.add_template_owned(template_name.clone(), content.clone()) {
426 report.add_error(
427 template_name.clone(),
428 TemplateError::from_minijinja_enhanced(
429 e,
430 &template_name,
431 &content,
432 Some(&context.values),
433 ),
434 );
435 }
436 template_sources.insert(template_name, content);
438 }
439
440 env.add_global("values", minijinja::Value::from_serialize(&context.values));
443 env.add_global(
444 "release",
445 minijinja::Value::from_serialize(&context.release),
446 );
447 env.add_global("pack", minijinja::Value::from_serialize(&context.pack));
448 env.add_global(
449 "capabilities",
450 minijinja::Value::from_serialize(&context.capabilities),
451 );
452 env.add_global(
453 "template",
454 minijinja::Value::from_serialize(&context.template),
455 );
456
457 match SandboxedFileProvider::new(&pack.root) {
460 Ok(provider) => {
461 env.add_global("files", create_files_value_from_provider(provider));
462 }
463 Err(e) => {
464 report.add_warning(
465 "files_api",
466 format!(
467 "Files API unavailable: {}. Templates using `files.*` will fail.",
468 e
469 ),
470 );
471 }
472 }
473
474 let ctx = minijinja::context! {
476 values => &context.values,
477 release => &context.release,
478 pack => &context.pack,
479 capabilities => &context.capabilities,
480 template => &context.template,
481 };
482
483 for file_path in &template_files {
485 let rel_path = file_path.strip_prefix(templates_dir).unwrap_or(file_path);
486 let template_name = rel_path.to_string_lossy().into_owned();
487
488 let file_stem = rel_path
490 .file_name()
491 .map(|s| s.to_string_lossy())
492 .unwrap_or_default();
493
494 if file_stem.starts_with(HELPER_TEMPLATE_PREFIX) {
495 continue;
496 }
497
498 let tmpl = match env.get_template(&template_name) {
500 Ok(t) => t,
501 Err(_) => {
502 continue;
504 }
505 };
506
507 match tmpl.render(&ctx) {
509 Ok(rendered) => {
510 if template_name
512 .to_lowercase()
513 .contains(NOTES_TEMPLATE_PATTERN)
514 {
515 notes = Some(rendered);
516 } else {
517 let trimmed = rendered.trim();
518 if !trimmed.is_empty() && trimmed != "---" {
519 let output_name = template_name
520 .trim_end_matches(".j2")
521 .trim_end_matches(".jinja2");
522 manifests.insert(output_name.to_string(), rendered);
523 }
524 }
525 report.add_success(template_name);
526 }
527 Err(e) => {
528 let content = template_sources
531 .get(&template_name)
532 .map(String::as_str)
533 .unwrap_or("");
534
535 report.add_error(
536 template_name.clone(),
537 TemplateError::from_minijinja_enhanced(
538 e,
539 &template_name,
540 content,
541 Some(&context.values),
542 ),
543 );
544 }
545 }
546 }
547
548 RenderResultWithReport {
549 manifests,
550 notes,
551 report,
552 }
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use semver::Version;
560 use sherpack_core::{PackMetadata, ReleaseInfo, Values};
561
562 fn create_test_context() -> TemplateContext {
563 let values = Values::from_yaml(
564 r#"
565image:
566 repository: nginx
567 tag: "1.25"
568replicas: 3
569"#,
570 )
571 .unwrap();
572
573 let release = ReleaseInfo::for_install("myapp", "default");
574
575 let pack = PackMetadata {
576 name: "mypack".to_string(),
577 version: Version::new(1, 0, 0),
578 description: None,
579 app_version: Some("2.0.0".to_string()),
580 kube_version: None,
581 home: None,
582 icon: None,
583 sources: vec![],
584 keywords: vec![],
585 maintainers: vec![],
586 annotations: Default::default(),
587 };
588
589 TemplateContext::new(values, release, &pack)
590 }
591
592 #[test]
593 fn test_render_simple() {
594 let engine = Engine::new(true);
595 let ctx = create_test_context();
596
597 let template = "replicas: {{ values.replicas }}";
598 let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
599
600 assert_eq!(result, "replicas: 3");
601 }
602
603 #[test]
604 fn test_render_with_filters() {
605 let engine = Engine::new(true);
606 let ctx = create_test_context();
607
608 let template = r#"image: {{ values.image | toyaml | nindent(2) }}"#;
609 let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
610
611 assert!(result.contains("repository: nginx"));
612 assert!(result.contains("tag:"));
613 }
614
615 #[test]
616 fn test_render_release_info() {
617 let engine = Engine::new(true);
618 let ctx = create_test_context();
619
620 let template = "name: {{ release.name }}\nnamespace: {{ release.namespace }}";
621 let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
622
623 assert!(result.contains("name: myapp"));
624 assert!(result.contains("namespace: default"));
625 }
626
627 #[test]
628 fn test_chainable_undefined_returns_empty() {
629 let engine = Engine::new(true);
632 let ctx = create_test_context();
633
634 let template = "value: {{ values.undefined_key }}";
635 let result = engine.render_string(template, &ctx, "test.yaml");
636
637 assert!(result.is_ok());
639 let output = result.unwrap();
640 assert_eq!(output.trim(), "value:");
641 }
642
643 #[test]
644 fn test_chainable_typo_returns_empty() {
645 let engine = Engine::new(true);
648 let ctx = create_test_context();
649
650 let template = "name: {{ value.app.name }}";
652 let result = engine.render_string(template, &ctx, "test.yaml");
653
654 assert!(result.is_ok());
657 let output = result.unwrap();
658 assert_eq!(output.trim(), "name:");
659 }
660
661 #[test]
662 fn test_render_string_unknown_filter() {
663 let engine = Engine::new(true);
664 let ctx = create_test_context();
665
666 let template = "name: {{ values.image.repository | unknownfilter }}";
667 let result = engine.render_string(template, &ctx, "test.yaml");
668
669 assert!(result.is_err());
670 }
671
672 #[test]
673 fn test_render_result_with_report_structure() {
674 use crate::error::{RenderReport, RenderResultWithReport};
675
676 let result = RenderResultWithReport {
678 manifests: {
679 let mut m = IndexMap::new();
680 m.insert("deployment.yaml".to_string(), "apiVersion: v1".to_string());
681 m
682 },
683 notes: Some("Install notes".to_string()),
684 report: RenderReport::new(),
685 };
686
687 assert!(result.is_success());
688 assert_eq!(result.manifests.len(), 1);
689 assert!(result.notes.is_some());
690 }
691
692 #[test]
693 fn test_render_result_partial_success() {
694 use crate::error::{RenderReport, RenderResultWithReport, TemplateError};
695
696 let mut report = RenderReport::new();
697 report.add_success("good.yaml".to_string());
698 report.add_error(
699 "bad.yaml".to_string(),
700 TemplateError::simple("undefined variable"),
701 );
702
703 let result = RenderResultWithReport {
704 manifests: {
705 let mut m = IndexMap::new();
706 m.insert("good.yaml".to_string(), "content".to_string());
707 m
708 },
709 notes: None,
710 report,
711 };
712
713 assert!(!result.is_success());
715 assert_eq!(result.manifests.len(), 1);
717 assert!(result.manifests.contains_key("good.yaml"));
718 }
719
720 #[test]
721 fn test_engine_with_secret_state() {
722 use crate::secrets::SecretFunctionState;
723
724 let secret_state = SecretFunctionState::new();
726 let engine = Engine::builder()
727 .strict(true)
728 .with_secret_state(secret_state.clone())
729 .build();
730
731 let ctx = create_test_context();
732
733 let template = r#"password: {{ generate_secret("db-password", 16) }}"#;
735 let result = engine.render_string(template, &ctx, "test.yaml").unwrap();
736
737 assert!(result.starts_with("password: "));
739 let password = result.strip_prefix("password: ").unwrap();
740 assert_eq!(password.len(), 16);
741 assert!(password.chars().all(|c| c.is_ascii_alphanumeric()));
742
743 assert!(secret_state.is_dirty());
745
746 let result2 = engine.render_string(template, &ctx, "test.yaml").unwrap();
748 assert_eq!(result, result2);
749 }
750
751 #[test]
752 fn test_engine_without_secret_state() {
753 let engine = Engine::strict();
755 let ctx = create_test_context();
756
757 let template = r#"password: {{ generate_secret("test", 16) }}"#;
758 let result = engine.render_string(template, &ctx, "test.yaml");
759
760 assert!(result.is_err());
762 }
763
764 #[test]
765 fn test_engine_with_loaded_secret_state() {
766 use crate::secrets::SecretFunctionState;
767 use sherpack_core::SecretState;
768
769 let secret_state1 = SecretFunctionState::new();
771 let engine1 = Engine::builder()
772 .with_secret_state(secret_state1.clone())
773 .build();
774
775 let ctx = create_test_context();
776 let template = r#"{{ generate_secret("api-key", 32) }}"#;
777 let secret = engine1.render_string(template, &ctx, "test.yaml").unwrap();
778
779 let persisted = secret_state1.take_state();
781 let json = serde_json::to_string(&persisted).unwrap();
782
783 let loaded: SecretState = serde_json::from_str(&json).unwrap();
785 let secret_state2 = SecretFunctionState::with_state(loaded);
786 let engine2 = Engine::builder()
787 .with_secret_state(secret_state2.clone())
788 .build();
789
790 let secret2 = engine2.render_string(template, &ctx, "test.yaml").unwrap();
792 assert_eq!(secret, secret2);
793
794 assert!(!secret_state2.is_dirty());
796 }
797
798 struct MockClusterReader {
802 data: std::collections::HashMap<(String, String, String, String), serde_json::Value>,
803 }
804
805 impl crate::cluster_reader::ClusterReader for MockClusterReader {
806 fn lookup_one(&self, av: &str, k: &str, ns: &str, n: &str) -> Option<serde_json::Value> {
807 self.data
808 .get(&(av.into(), k.into(), ns.into(), n.into()))
809 .cloned()
810 }
811
812 fn lookup_list(&self, _: &str, _: &str, _: &str) -> Vec<serde_json::Value> {
813 Vec::new()
814 }
815 }
816
817 #[test]
818 fn test_lookup_returns_empty_without_reader() {
819 let engine = Engine::new(true);
820 let ctx = create_test_context();
821 let template =
822 r#"{% set s = lookup("v1", "Secret", "default", "tls") %}got: {{ s | tojson }}"#;
823 let out = engine.render_string(template, &ctx, "t.yaml").unwrap();
824 assert_eq!(out, "got: {}");
825 }
826
827 #[test]
828 fn test_lookup_uses_reader_when_set() {
829 let mut data = std::collections::HashMap::new();
830 data.insert(
831 (
832 "v1".to_string(),
833 "Secret".to_string(),
834 "default".to_string(),
835 "tls".to_string(),
836 ),
837 serde_json::json!({"data": {"tls.crt": "xyz"}}),
838 );
839 let reader = std::sync::Arc::new(MockClusterReader { data });
840
841 let engine = Engine::builder().with_cluster_reader(reader).build();
842 let ctx = create_test_context();
843 let template = r#"{% set s = lookup("v1", "Secret", "default", "tls") %}cert: {{ s.data["tls.crt"] }}"#;
844 let out = engine.render_string(template, &ctx, "t.yaml").unwrap();
845 assert_eq!(out, "cert: xyz");
846
847 let warnings = engine.lookup_state().unwrap().take_warnings();
849 assert_eq!(warnings.len(), 1);
850 }
851
852 #[test]
853 fn test_lookup_missing_resource_returns_empty_dict() {
854 let reader = std::sync::Arc::new(MockClusterReader {
855 data: std::collections::HashMap::new(),
856 });
857 let engine = Engine::builder().with_cluster_reader(reader).build();
858 let ctx = create_test_context();
859 let template = r#"{% set s = lookup("v1", "Secret", "default", "missing") %}{% if s %}has{% else %}empty{% endif %}"#;
860 let out = engine.render_string(template, &ctx, "t.yaml").unwrap();
861 assert_eq!(out, "empty");
862 assert!(engine.lookup_state().unwrap().take_warnings().is_empty());
864 }
865}