package preflight import ( "bytes" "fmt" "os" "strings" "text/template" "github.com/Masterminds/sprig/v3" "github.com/pkg/errors" "helm.sh/helm/v3/pkg/strvals" yaml "sigs.k8s.io/yaml" ) // RunTemplate processes a templated preflight spec file with provided values func RunTemplate(templateFile string, valuesFiles []string, setValues []string, outputFile string) error { // Read the template file templateContent, err := os.ReadFile(templateFile) if err != nil { return errors.Wrapf(err, "failed to read template file %s", templateFile) } // Prepare the values map values := make(map[string]interface{}) // Load values from files if provided for _, valuesFile := range valuesFiles { if valuesFile == "" { continue } fileValues, err := loadValuesFile(valuesFile) if err != nil { return errors.Wrapf(err, "failed to load values file %s", valuesFile) } values = MergeMaps(values, fileValues) } // Apply --set values (Helm semantics) for _, setValue := range setValues { if err := applySetValue(values, setValue); err != nil { return errors.Wrapf(err, "failed to apply set value: %s", setValue) } } // Choose engine based on apiVersion apiVersion := detectAPIVersion(string(templateContent)) var rendered string if strings.HasSuffix(apiVersion, "/v1beta3") || apiVersion == "v1beta3" { // For v1beta3 templates, pre-seed default false values for any referenced // .Values.*.(enabled|create) booleans to avoid nil pointer dereferences. SeedDefaultBooleans(string(templateContent), values) // Also ensure parent maps exist for all .Values. references so nested lookups // don't panic when optional maps are omitted from values files. SeedParentMapsForValueRefs(string(templateContent), values) // Helm for v1beta3 rendered, err = RenderWithHelmTemplate(string(templateContent), values) if err != nil { return errors.Wrap(err, "failed to render template using Helm") } } else { // Legacy renderer for older API versions rendered, err = renderLegacyTemplate(string(templateContent), values) if err != nil { return errors.Wrap(err, "failed to render template using legacy renderer") } } // Output the result if outputFile != "" { if err := os.WriteFile(outputFile, []byte(rendered), 0644); err != nil { return errors.Wrapf(err, "failed to write output file %s", outputFile) } fmt.Printf("Template rendered successfully to %s\n", outputFile) } else { fmt.Print(rendered) } return nil } // loadValuesFile loads values from a YAML file func loadValuesFile(filename string) (map[string]interface{}, error) { data, err := os.ReadFile(filename) if err != nil { return nil, err } var values map[string]interface{} if err := yaml.Unmarshal(data, &values); err != nil { return nil, errors.Wrap(err, "failed to parse values file as YAML") } return values, nil } // applySetValue applies a single --set value to the values map using Helm semantics func applySetValue(values map[string]interface{}, setValue string) error { // Normalize optional "Values." prefix so both --set test.enabled and --set Values.test.enabled work if idx := strings.Index(setValue, "="); idx > 0 { key := setValue[:idx] val := setValue[idx+1:] if strings.HasPrefix(key, "Values.") { key = strings.TrimPrefix(key, "Values.") setValue = key + "=" + val } } if err := strvals.ParseInto(setValue, values); err != nil { return fmt.Errorf("parsing --set: %w", err) } return nil } // detectAPIVersion attempts to read apiVersion from the raw YAML header func detectAPIVersion(content string) string { lines := strings.Split(content, "\n") for _, line := range lines { l := strings.TrimSpace(line) if strings.HasPrefix(l, "apiVersion:") { parts := strings.SplitN(l, ":", 2) if len(parts) == 2 { return strings.TrimSpace(parts[1]) } } if strings.HasPrefix(l, "kind:") || strings.HasPrefix(l, "metadata:") { break } } return "" } // renderLegacyTemplate uses Go text/template with Sprig and passes values at root func renderLegacyTemplate(templateContent string, values map[string]interface{}) (string, error) { tmpl := template.New("preflight").Funcs(sprig.FuncMap()) tmpl, err := tmpl.Parse(templateContent) if err != nil { return "", errors.Wrap(err, "failed to parse template") } var buf bytes.Buffer if err := tmpl.Execute(&buf, values); err != nil { return "", errors.Wrap(err, "failed to execute template") } return cleanRenderedYAML(buf.String()), nil } func cleanRenderedYAML(content string) string { lines := strings.Split(content, "\n") var cleaned []string var lastWasEmpty bool for _, line := range lines { trimmed := strings.TrimRight(line, " \t") if trimmed == "" { if !lastWasEmpty { cleaned = append(cleaned, "") lastWasEmpty = true } } else { cleaned = append(cleaned, trimmed) lastWasEmpty = false } } for len(cleaned) > 0 && cleaned[len(cleaned)-1] == "" { cleaned = cleaned[:len(cleaned)-1] } return strings.Join(cleaned, "\n") + "\n" } // MergeMaps recursively merges two maps, with overlay taking precedence func MergeMaps(base, overlay map[string]interface{}) map[string]interface{} { result := make(map[string]interface{}) // Copy base map for k, v := range base { result[k] = v } // Overlay values for k, v := range overlay { if baseVal, exists := result[k]; exists { // If both are maps, merge recursively if baseMap, ok := baseVal.(map[string]interface{}); ok { if overlayMap, ok := v.(map[string]interface{}); ok { result[k] = MergeMaps(baseMap, overlayMap) continue } } } result[k] = v } return result }