sherpack_engine/
pack_renderer.rs

1//! Pack renderer with subchart support
2//!
3//! This module provides `PackRenderer`, which orchestrates the rendering
4//! of a pack and all its subcharts with proper value scoping.
5
6use indexmap::IndexMap;
7use std::collections::HashMap;
8
9use serde_json::Value as JsonValue;
10use sherpack_core::{Dependency, LoadedPack, TemplateContext, Values};
11
12use crate::engine::Engine;
13use crate::error::{EngineError, RenderIssue, RenderReport, TemplateError};
14use crate::subchart::{DiscoveryResult, SubchartConfig, SubchartInfo};
15
16/// Result of rendering a pack (with or without subcharts)
17#[derive(Debug)]
18pub struct PackRenderResult {
19    /// Rendered manifests by filename (IndexMap preserves insertion order)
20    /// Subchart manifests are prefixed: "redis/deployment.yaml"
21    pub manifests: IndexMap<String, String>,
22
23    /// Post-install notes (from parent pack only)
24    pub notes: Option<String>,
25
26    /// Discovery information about subcharts
27    pub discovery: DiscoveryResult,
28}
29
30/// Orchestrates rendering of a pack and its subcharts
31pub struct PackRenderer {
32    engine: Engine,
33    config: SubchartConfig,
34}
35
36impl PackRenderer {
37    /// Create a new PackRenderer with default config
38    pub fn new(engine: Engine) -> Self {
39        Self {
40            engine,
41            config: SubchartConfig::default(),
42        }
43    }
44
45    /// Create with custom configuration
46    pub fn with_config(engine: Engine, config: SubchartConfig) -> Self {
47        Self { engine, config }
48    }
49
50    /// Create a builder for more options
51    pub fn builder() -> PackRendererBuilder {
52        PackRendererBuilder::default()
53    }
54
55    /// Get a reference to the underlying engine
56    pub fn engine(&self) -> &Engine {
57        &self.engine
58    }
59
60    /// Get a reference to the config
61    pub fn config(&self) -> &SubchartConfig {
62        &self.config
63    }
64
65    /// Discover subcharts in a pack
66    ///
67    /// This scans the subcharts directory (default: `charts/`) for valid packs
68    /// and evaluates their conditions against the provided values.
69    pub fn discover_subcharts(&self, pack: &LoadedPack, values: &JsonValue) -> DiscoveryResult {
70        let mut result = DiscoveryResult::new();
71        let subcharts_dir = pack.root.join(&self.config.subcharts_dir);
72
73        // Build a map of dependencies by name for condition lookup
74        let deps_by_name: HashMap<&str, &Dependency> = pack
75            .pack
76            .dependencies
77            .iter()
78            .map(|d| (d.effective_name(), d))
79            .collect();
80
81        // Check if subcharts directory exists
82        if !subcharts_dir.exists() {
83            // Not an error - pack may not have subcharts
84            return result;
85        }
86
87        // Scan the subcharts directory
88        let entries = match std::fs::read_dir(&subcharts_dir) {
89            Ok(e) => e,
90            Err(e) => {
91                result.warnings.push(format!(
92                    "Failed to read subcharts directory '{}': {}",
93                    subcharts_dir.display(),
94                    e
95                ));
96                return result;
97            }
98        };
99
100        for entry in entries {
101            let entry = match entry {
102                Ok(e) => e,
103                Err(e) => {
104                    result
105                        .warnings
106                        .push(format!("Failed to read directory entry: {}", e));
107                    continue;
108                }
109            };
110
111            let path = entry.path();
112            if !path.is_dir() {
113                continue;
114            }
115
116            let dir_name = match path.file_name().and_then(|n| n.to_str()) {
117                Some(n) => n.to_string(),
118                None => continue,
119            };
120
121            // Try to load as a pack
122            let subchart_pack = match LoadedPack::load(&path) {
123                Ok(p) => p,
124                Err(e) => {
125                    result
126                        .warnings
127                        .push(format!("Failed to load subchart '{}': {}", dir_name, e));
128                    continue;
129                }
130            };
131
132            // Find matching dependency definition
133            let dependency = deps_by_name.get(dir_name.as_str()).cloned().cloned();
134
135            // Determine effective name (alias if set)
136            let name = dependency
137                .as_ref()
138                .and_then(|d| d.alias.clone())
139                .unwrap_or_else(|| dir_name.clone());
140
141            // Evaluate condition
142            let (enabled, disabled_reason) = self.evaluate_condition(&dependency, values);
143
144            result.subcharts.push(SubchartInfo {
145                name,
146                path,
147                pack: subchart_pack,
148                enabled,
149                dependency,
150                disabled_reason,
151            });
152        }
153
154        // Check for missing subcharts from dependencies
155        for dep in &pack.pack.dependencies {
156            if dep.enabled {
157                let name = dep.effective_name();
158                let found = result.subcharts.iter().any(|s| s.name == name);
159                if !found {
160                    result.missing.push(name.to_string());
161                }
162            }
163        }
164
165        // Sort by name for deterministic output
166        result.subcharts.sort_by(|a, b| a.name.cmp(&b.name));
167
168        result
169    }
170
171    /// Evaluate if a subchart is enabled based on its condition
172    fn evaluate_condition(
173        &self,
174        dependency: &Option<Dependency>,
175        values: &JsonValue,
176    ) -> (bool, Option<String>) {
177        let Some(dep) = dependency else {
178            // No dependency definition = always enabled
179            return (true, None);
180        };
181
182        // Check static enabled flag
183        if !dep.enabled {
184            return (
185                false,
186                Some("Statically disabled (enabled: false)".to_string()),
187            );
188        }
189
190        // Check condition
191        if let Some(condition) = &dep.condition {
192            let condition_met = evaluate_condition_path(condition, values);
193            if !condition_met {
194                return (
195                    false,
196                    Some(format!("Condition '{}' evaluated to false", condition)),
197                );
198            }
199        }
200
201        (true, None)
202    }
203
204    /// Render a pack and all enabled subcharts
205    ///
206    /// This is the main entry point. It:
207    /// 1. Discovers all subcharts
208    /// 2. Evaluates conditions against values
209    /// 3. Renders enabled subcharts with scoped values
210    /// 4. Renders the parent pack
211    /// 5. Combines all manifests
212    pub fn render(
213        &self,
214        pack: &LoadedPack,
215        context: &TemplateContext,
216    ) -> Result<PackRenderResult, EngineError> {
217        let result = self.render_collect_errors(pack, context);
218
219        if result.report.has_errors() {
220            // Return first error
221            let first_error = result
222                .report
223                .errors_by_template
224                .into_values()
225                .next()
226                .and_then(|errors| errors.into_iter().next());
227
228            return Err(match first_error {
229                Some(err) => EngineError::Template(Box::new(err)),
230                None => EngineError::Template(Box::new(TemplateError::simple(
231                    "Unknown template error during subchart rendering",
232                ))),
233            });
234        }
235
236        Ok(PackRenderResult {
237            manifests: result.manifests,
238            notes: result.notes,
239            discovery: result.discovery,
240        })
241    }
242
243    /// Render with full error collection
244    pub fn render_collect_errors(
245        &self,
246        pack: &LoadedPack,
247        context: &TemplateContext,
248    ) -> PackRenderResultWithReport {
249        self.render_recursive(pack, context, 0)
250    }
251
252    /// Internal recursive renderer
253    fn render_recursive(
254        &self,
255        pack: &LoadedPack,
256        context: &TemplateContext,
257        depth: usize,
258    ) -> PackRenderResultWithReport {
259        let mut report = RenderReport::new();
260        let mut all_manifests = IndexMap::new();
261        let mut notes = None;
262
263        // Check depth limit
264        if depth > self.config.max_depth {
265            report.add_warning(
266                "subchart",
267                format!(
268                    "Maximum subchart depth ({}) exceeded, stopping recursion",
269                    self.config.max_depth
270                ),
271            );
272            return PackRenderResultWithReport {
273                manifests: all_manifests,
274                notes,
275                report,
276                discovery: DiscoveryResult::new(),
277            };
278        }
279
280        // Discover subcharts
281        let discovery = self.discover_subcharts(pack, &context.values);
282
283        // Add discovery warnings to report
284        for warning in &discovery.warnings {
285            report.add_warning("subchart_discovery", warning.clone());
286        }
287
288        // Handle missing subcharts
289        for missing in &discovery.missing {
290            if self.config.strict {
291                report.add_error(
292                    format!("<subchart:{}>", missing),
293                    TemplateError::simple(format!(
294                        "Missing subchart '{}' referenced in dependencies",
295                        missing
296                    )),
297                );
298            } else {
299                report.add_warning(
300                    "subchart_missing",
301                    format!(
302                        "Subchart '{}' not found in {}/",
303                        missing, self.config.subcharts_dir
304                    ),
305                );
306            }
307        }
308
309        // Render each enabled subchart
310        for subchart in &discovery.subcharts {
311            if !subchart.enabled {
312                // Log why it was skipped
313                if let Some(reason) = &subchart.disabled_reason {
314                    report.add_issue(RenderIssue::warning(
315                        "subchart_disabled",
316                        format!("Subchart '{}' disabled: {}", subchart.name, reason),
317                    ));
318                }
319                continue;
320            }
321
322            // Load subchart's default values
323            let subchart_defaults = if subchart.pack.values_path.exists() {
324                match Values::from_file(&subchart.pack.values_path) {
325                    Ok(v) => v,
326                    Err(e) => {
327                        report.add_warning(
328                            "subchart_values",
329                            format!("Failed to load values.yaml for '{}': {}", subchart.name, e),
330                        );
331                        Values::new()
332                    }
333                }
334            } else {
335                Values::new()
336            };
337
338            // Scope values for this subchart
339            let scoped_values =
340                Values::for_subchart_json(subchart_defaults, &context.values, &subchart.name);
341
342            // Create context for subchart
343            let subchart_context = TemplateContext::new(
344                scoped_values,
345                context.release.clone(),
346                &subchart.pack.pack.metadata,
347            );
348
349            // Recursively render subchart (handles its own subcharts)
350            let subchart_result =
351                self.render_recursive(&subchart.pack, &subchart_context, depth + 1);
352
353            // Merge subchart manifests with prefix
354            for (name, manifest) in subchart_result.manifests {
355                let prefixed_name = format!("{}/{}", subchart.name, name);
356                all_manifests.insert(prefixed_name, manifest);
357            }
358
359            // Merge subchart errors with prefix
360            for (template, errors) in subchart_result.report.errors_by_template {
361                let prefixed = format!("{}/{}", subchart.name, template);
362                for error in errors {
363                    report.add_error(prefixed.clone(), error);
364                }
365            }
366
367            // Merge issues
368            for issue in subchart_result.report.issues {
369                report.add_issue(issue);
370            }
371
372            // Subchart notes are typically not shown (only parent's notes)
373        }
374
375        // Render parent pack
376        let parent_result = self.engine.render_pack_collect_errors(pack, context);
377
378        // Merge parent manifests (after subcharts for proper ordering)
379        all_manifests.extend(parent_result.manifests);
380        notes = parent_result.notes;
381
382        // Merge parent report
383        for (template, errors) in parent_result.report.errors_by_template {
384            for error in errors {
385                report.add_error(template.clone(), error);
386            }
387        }
388        for issue in parent_result.report.issues {
389            report.add_issue(issue);
390        }
391        for success in parent_result.report.successful_templates {
392            report.add_success(success);
393        }
394
395        PackRenderResultWithReport {
396            manifests: all_manifests,
397            notes,
398            report,
399            discovery,
400        }
401    }
402}
403
404/// Result type that includes discovery info and error report
405#[derive(Debug)]
406pub struct PackRenderResultWithReport {
407    /// Rendered manifests (may be partial if errors occurred)
408    pub manifests: IndexMap<String, String>,
409
410    /// Post-install notes
411    pub notes: Option<String>,
412
413    /// Error and warning report
414    pub report: RenderReport,
415
416    /// Subchart discovery results
417    pub discovery: DiscoveryResult,
418}
419
420impl PackRenderResultWithReport {
421    /// Check if rendering was fully successful (no errors)
422    pub fn is_success(&self) -> bool {
423        !self.report.has_errors()
424    }
425}
426
427/// Builder for PackRenderer
428#[derive(Default)]
429pub struct PackRendererBuilder {
430    strict_mode: bool,
431    max_depth: Option<usize>,
432    subcharts_dir: Option<String>,
433}
434
435impl PackRendererBuilder {
436    /// Enable strict mode for the engine (fail on undefined variables)
437    pub fn strict(mut self, strict: bool) -> Self {
438        self.strict_mode = strict;
439        self
440    }
441
442    /// Set maximum depth for nested subcharts
443    pub fn max_depth(mut self, depth: usize) -> Self {
444        self.max_depth = Some(depth);
445        self
446    }
447
448    /// Set the subcharts directory name (default: "charts")
449    pub fn subcharts_dir(mut self, dir: impl Into<String>) -> Self {
450        self.subcharts_dir = Some(dir.into());
451        self
452    }
453
454    /// Build the PackRenderer
455    pub fn build(self) -> PackRenderer {
456        let engine = if self.strict_mode {
457            Engine::strict()
458        } else {
459            Engine::lenient()
460        };
461
462        let mut config = SubchartConfig::default();
463        if let Some(depth) = self.max_depth {
464            config.max_depth = depth;
465        }
466        if let Some(dir) = self.subcharts_dir {
467            config.subcharts_dir = dir;
468        }
469        if self.strict_mode {
470            config.strict = true;
471        }
472
473        PackRenderer { engine, config }
474    }
475}
476
477/// Evaluate a dot-path condition against values
478///
479/// Supports paths like "redis.enabled", "features.cache.memory"
480fn evaluate_condition_path(condition: &str, values: &serde_json::Value) -> bool {
481    let parts: Vec<&str> = condition.split('.').collect();
482
483    let mut current = values;
484    for part in &parts {
485        match current.get(part) {
486            Some(v) => current = v,
487            None => return false,
488        }
489    }
490
491    // Coerce to boolean
492    match current {
493        serde_json::Value::Bool(b) => *b,
494        serde_json::Value::Null => false,
495        serde_json::Value::String(s) => !s.is_empty() && s != "false" && s != "0",
496        serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
497        serde_json::Value::Array(a) => !a.is_empty(),
498        serde_json::Value::Object(o) => !o.is_empty(),
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn test_evaluate_condition_path_bool() {
508        let values = serde_json::json!({
509            "redis": {
510                "enabled": true
511            },
512            "postgresql": {
513                "enabled": false
514            }
515        });
516
517        assert!(evaluate_condition_path("redis.enabled", &values));
518        assert!(!evaluate_condition_path("postgresql.enabled", &values));
519    }
520
521    #[test]
522    fn test_evaluate_condition_path_missing() {
523        let values = serde_json::json!({
524            "redis": {}
525        });
526
527        assert!(!evaluate_condition_path("redis.enabled", &values));
528        assert!(!evaluate_condition_path("nonexistent.path", &values));
529    }
530
531    #[test]
532    fn test_evaluate_condition_path_truthy() {
533        let values = serde_json::json!({
534            "string_yes": "yes",
535            "string_empty": "",
536            "number_one": 1,
537            "number_zero": 0,
538            "array_full": [1, 2],
539            "array_empty": []
540        });
541
542        assert!(evaluate_condition_path("string_yes", &values));
543        assert!(!evaluate_condition_path("string_empty", &values));
544        assert!(evaluate_condition_path("number_one", &values));
545        assert!(!evaluate_condition_path("number_zero", &values));
546        assert!(evaluate_condition_path("array_full", &values));
547        assert!(!evaluate_condition_path("array_empty", &values));
548    }
549
550    #[test]
551    fn test_pack_renderer_builder() {
552        let renderer = PackRenderer::builder()
553            .strict(true)
554            .max_depth(5)
555            .subcharts_dir("deps")
556            .build();
557
558        assert_eq!(renderer.config.max_depth, 5);
559        assert_eq!(renderer.config.subcharts_dir, "deps");
560        assert!(renderer.config.strict);
561    }
562
563    #[test]
564    fn test_pack_render_result_with_report_success() {
565        let result = PackRenderResultWithReport {
566            manifests: IndexMap::new(),
567            notes: None,
568            report: RenderReport::new(),
569            discovery: DiscoveryResult::new(),
570        };
571
572        assert!(result.is_success());
573    }
574
575    #[test]
576    fn test_discover_subcharts_with_fixture() {
577        use std::path::PathBuf;
578
579        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
580            .parent()
581            .unwrap()
582            .parent()
583            .unwrap()
584            .join("fixtures/pack-with-subcharts");
585
586        if !fixture_path.exists() {
587            // Skip if fixture doesn't exist
588            return;
589        }
590
591        let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
592        let renderer = PackRenderer::new(Engine::lenient());
593
594        let values = serde_json::json!({
595            "redis": { "enabled": true },
596            "postgresql": { "enabled": false }
597        });
598
599        let discovery = renderer.discover_subcharts(&pack, &values);
600
601        // Should find both subcharts
602        assert_eq!(discovery.subcharts.len(), 2);
603
604        // Redis should be enabled
605        let redis = discovery.subcharts.iter().find(|s| s.name == "redis");
606        assert!(redis.is_some());
607        assert!(redis.unwrap().enabled);
608
609        // PostgreSQL should be disabled (statically disabled in Pack.yaml)
610        let pg = discovery.subcharts.iter().find(|s| s.name == "postgresql");
611        assert!(pg.is_some());
612        assert!(!pg.unwrap().enabled);
613    }
614
615    #[test]
616    fn test_render_pack_with_subcharts() {
617        use sherpack_core::ReleaseInfo;
618        use std::path::PathBuf;
619
620        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
621            .parent()
622            .unwrap()
623            .parent()
624            .unwrap()
625            .join("fixtures/pack-with-subcharts");
626
627        if !fixture_path.exists() {
628            return;
629        }
630
631        let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
632        let renderer = PackRenderer::new(Engine::lenient());
633
634        let values = Values::from_yaml(
635            r#"
636global:
637  imageRegistry: docker.io
638  pullPolicy: IfNotPresent
639app:
640  name: my-application
641  replicas: 2
642  image:
643    repository: myapp
644    tag: "1.0.0"
645redis:
646  enabled: true
647  replicas: 3
648  auth:
649    enabled: true
650    password: secret123
651postgresql:
652  enabled: false
653"#,
654        )
655        .expect("Failed to parse values");
656
657        let release = ReleaseInfo::for_install("test-release", "default");
658        let context = TemplateContext::new(values, release, &pack.pack.metadata);
659
660        let result = renderer.render(&pack, &context).expect("Render failed");
661
662        // Should have parent manifest
663        assert!(result.manifests.contains_key("deployment.yaml"));
664
665        // Should have redis subchart manifest (prefixed)
666        assert!(result.manifests.contains_key("redis/deployment.yaml"));
667
668        // Should NOT have postgresql manifest (disabled)
669        let has_postgresql = result
670            .manifests
671            .keys()
672            .any(|k| k.starts_with("postgresql/"));
673        assert!(!has_postgresql, "PostgreSQL should be disabled");
674
675        // Verify redis manifest uses scoped values
676        let redis_manifest = result.manifests.get("redis/deployment.yaml").unwrap();
677        assert!(
678            redis_manifest.contains("replicas: 3"),
679            "Should use parent's redis.replicas=3"
680        );
681        assert!(
682            redis_manifest.contains("REDIS_PASSWORD"),
683            "Auth should be enabled"
684        );
685
686        // Verify parent manifest has correct content
687        let parent_manifest = result.manifests.get("deployment.yaml").unwrap();
688        assert!(parent_manifest.contains("test-release-my-application"));
689        assert!(parent_manifest.contains("REDIS_HOST"));
690        assert!(
691            !parent_manifest.contains("DATABASE_HOST"),
692            "PostgreSQL env should not be present"
693        );
694    }
695
696    #[test]
697    fn test_subchart_global_values_passed() {
698        use sherpack_core::ReleaseInfo;
699        use std::path::PathBuf;
700
701        let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
702            .parent()
703            .unwrap()
704            .parent()
705            .unwrap()
706            .join("fixtures/pack-with-subcharts");
707
708        if !fixture_path.exists() {
709            return;
710        }
711
712        let pack = LoadedPack::load(&fixture_path).expect("Failed to load fixture");
713        let renderer = PackRenderer::new(Engine::lenient());
714
715        let values = Values::from_yaml(
716            r#"
717global:
718  imageRegistry: my-registry.io
719  pullPolicy: Always
720app:
721  name: my-app
722  replicas: 1
723  image:
724    repository: myapp
725    tag: "1.0"
726redis:
727  enabled: true
728postgresql:
729  enabled: false
730"#,
731        )
732        .expect("Failed to parse values");
733
734        let release = ReleaseInfo::for_install("test", "default");
735        let context = TemplateContext::new(values, release, &pack.pack.metadata);
736
737        let result = renderer.render(&pack, &context).expect("Render failed");
738
739        // Redis manifest should use global.imageRegistry
740        let redis_manifest = result.manifests.get("redis/deployment.yaml").unwrap();
741        assert!(
742            redis_manifest.contains("my-registry.io"),
743            "Should use global imageRegistry"
744        );
745    }
746}