From 69c9a2d20d8572dfead0486aa3a51bae1ac90250 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sun, 12 Oct 2025 11:30:52 -0400 Subject: [PATCH] feature(blueprint): Mixed Feature interpolation It is now possible to mix strings and interpolations in Feature inputs, i.e., ``` domain: grafana.${dns.domain} ``` --- pkg/blueprint/feature_evaluator.go | 38 +++++++++- pkg/blueprint/feature_evaluator_test.go | 97 +++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/pkg/blueprint/feature_evaluator.go b/pkg/blueprint/feature_evaluator.go index e747e7960..504e0425e 100644 --- a/pkg/blueprint/feature_evaluator.go +++ b/pkg/blueprint/feature_evaluator.go @@ -110,6 +110,9 @@ func (e *FeatureEvaluator) evaluateDefaultValue(value any, config map[string]any if expr := e.extractExpression(v); expr != "" { return e.EvaluateValue(expr, config) } + if strings.Contains(v, "${") { + return e.interpolateString(v, config) + } return v, nil case map[string]any: @@ -139,7 +142,7 @@ func (e *FeatureEvaluator) evaluateDefaultValue(value any, config map[string]any } } -// extractExpression checks if a string contains an expression in ${} syntax. +// extractExpression checks if a string contains a single expression spanning the entire string. // If found, returns the expression content. Otherwise returns empty string. func (e *FeatureEvaluator) extractExpression(s string) string { if !strings.Contains(s, "${") { @@ -161,3 +164,36 @@ func (e *FeatureEvaluator) extractExpression(s string) string { return "" } + +// interpolateString replaces all ${expression} occurrences in a string with their evaluated values. +func (e *FeatureEvaluator) interpolateString(s string, config map[string]any) (string, error) { + result := s + + for strings.Contains(result, "${") { + start := strings.Index(result, "${") + end := strings.Index(result[start:], "}") + + if end == -1 { + return "", fmt.Errorf("unclosed expression in string: %s", s) + } + + end += start + expr := result[start+2 : end] + + value, err := e.EvaluateValue(expr, config) + if err != nil { + return "", fmt.Errorf("failed to evaluate expression '${%s}': %w", expr, err) + } + + var replacement string + if value == nil { + replacement = "" + } else { + replacement = fmt.Sprintf("%v", value) + } + + result = result[:start] + replacement + result[end+1:] + } + + return result, nil +} diff --git a/pkg/blueprint/feature_evaluator_test.go b/pkg/blueprint/feature_evaluator_test.go index c493b3884..320af15fd 100644 --- a/pkg/blueprint/feature_evaluator_test.go +++ b/pkg/blueprint/feature_evaluator_test.go @@ -517,6 +517,103 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { t.Errorf("Expected literal_number to be 42, got %v", result["literal_number"]) } }) + + t.Run("InterpolatesStringsWithSingleExpression", func(t *testing.T) { + defaults := map[string]any{ + "domain": "grafana.${dns.domain}", + } + + config := map[string]any{ + "dns": map[string]any{ + "domain": "example.com", + }, + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result["domain"] != "grafana.example.com" { + t.Errorf("Expected domain to be 'grafana.example.com', got %v", result["domain"]) + } + }) + + t.Run("InterpolatesStringsWithMultipleExpressions", func(t *testing.T) { + defaults := map[string]any{ + "url": "${protocol}://${dns.domain}:${port}", + } + + config := map[string]any{ + "protocol": "https", + "dns": map[string]any{ + "domain": "example.com", + }, + "port": 8080, + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result["url"] != "https://example.com:8080" { + t.Errorf("Expected url to be 'https://example.com:8080', got %v", result["url"]) + } + }) + + t.Run("InterpolatesStringsWithNumbers", func(t *testing.T) { + defaults := map[string]any{ + "label": "worker-${cluster.workers.count}", + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result["label"] != "worker-3" { + t.Errorf("Expected label to be 'worker-3', got %v", result["label"]) + } + }) + + t.Run("FailsOnUnclosedInterpolationExpression", func(t *testing.T) { + defaults := map[string]any{ + "bad": "prefix-${dns.domain", + } + + config := map[string]any{ + "dns": map[string]any{ + "domain": "example.com", + }, + } + + _, err := evaluator.EvaluateDefaults(defaults, config) + if err == nil { + t.Fatal("Expected error for unclosed expression, got nil") + } + }) + + t.Run("FailsOnInvalidInterpolationExpression", func(t *testing.T) { + defaults := map[string]any{ + "bad": "prefix-${invalid + }", + } + + config := map[string]any{} + + _, err := evaluator.EvaluateDefaults(defaults, config) + if err == nil { + t.Fatal("Expected error for invalid interpolation expression, got nil") + } + }) } // =============================================================================