From 809c9e931c488e8a4418957a447666b0cdf8efcc Mon Sep 17 00:00:00 2001 From: shubham-n-khanna Date: Wed, 3 Dec 2025 21:42:21 +0530 Subject: [PATCH] jinja support in caterpillar --- internal/pkg/config/config.go | 96 ++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 6fba6d0..29d04c1 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -1,9 +1,13 @@ package config import ( + "fmt" "os" + "path/filepath" + "regexp" "strings" "text/template" + "time" "github.com/itchyny/gojq" "gopkg.in/yaml.v3" @@ -17,6 +21,11 @@ const ( placeholderRegex = "([a-zA-Z0-9_]+?)" ) +var ( + // Jinja-style include directive: {% include 'path/to/file' %} + includeRegex = regexp.MustCompile(`\{\%\s*include\s+['"]([^'"]+)['"]\s*\%\}`) +) + // Generic String type that can evaluate both macro and context templates type String string @@ -58,15 +67,23 @@ func Load(configFile string, obj interface{}) error { return err } + // Preprocess Jinja-style includes + configDir := filepath.Dir(configFile) + processedContent, err := processIncludes(string(content), configDir, make(map[string]bool)) + if err != nil { + return fmt.Errorf("failed to process includes: %w", err) + } + // inject secrets configTemplate := template.New(templateName).Funcs(template.FuncMap{ "env": getEnvironmentVariable, // returns environment variable "macro": setMacroPlaceholder, // set placeholder string for macro replacement "secret": getSecret, // we use this template function to inject secrets from parameter store "context": setContextPlaceholder, // set placeholder string for context replacement + "ds": getDateString, // returns date string (YYYY-MM-DD) from env var or current date }) - parsedTemplate, err := configTemplate.Parse(string(content)) + parsedTemplate, err := configTemplate.Parse(processedContent) if err != nil { return err } @@ -84,3 +101,80 @@ func Load(configFile string, obj interface{}) error { return nil } + +// processIncludes processes Jinja-style {% include 'path' %} directives +// It recursively includes files and prevents circular includes +func processIncludes(content string, baseDir string, visited map[string]bool) (string, error) { + // Find all include directives + matches := includeRegex.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return content, nil + } + + result := content + for _, match := range matches { + if len(match) != 2 { + continue + } + + includePath := match[0] // Full match: {% include 'path' %} + filePath := match[1] // Captured group: path + + // Resolve the file path relative to baseDir + // If the path starts with /, it's absolute, otherwise relative to baseDir + var resolvedPath string + if filepath.IsAbs(filePath) { + resolvedPath = filePath + } else { + resolvedPath = filepath.Join(baseDir, filePath) + } + + // Normalize the path to handle .. and . correctly + resolvedPath = filepath.Clean(resolvedPath) + + // Check for circular includes + absPath, err := filepath.Abs(resolvedPath) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute path for %s: %w", filePath, err) + } + + if visited[absPath] { + return "", fmt.Errorf("circular include detected: %s", absPath) + } + + // Read the included file + includedContent, err := os.ReadFile(resolvedPath) + if err != nil { + return "", fmt.Errorf("failed to read included file %s: %w", filePath, err) + } + + // Recursively process includes in the included file + visited[absPath] = true + processedContent, err := processIncludes(string(includedContent), filepath.Dir(resolvedPath), visited) + if err != nil { + return "", err + } + delete(visited, absPath) + + // Replace the include directive with the file content + result = strings.ReplaceAll(result, includePath, processedContent) + } + + return result, nil +} + +// getDateString returns a date string in YYYY-MM-DD format +// It first checks for the DS or EXECUTION_DATE environment variable +// If not set, it defaults to the current date +func getDateString() (string, error) { + // Check for DS environment variable first (common in data pipelines) + if ds := os.Getenv("DS"); ds != "" { + return ds, nil + } + // Check for EXECUTION_DATE (alternative common name) + if execDate := os.Getenv("EXECUTION_DATE"); execDate != "" { + return execDate, nil + } + // Default to current date + return time.Now().Format("2006-01-02"), nil +}