diff --git a/api/v1alpha1/feature_types.go b/api/v1alpha1/feature_types.go index 9063f28d9..c8ca07ffa 100644 --- a/api/v1alpha1/feature_types.go +++ b/api/v1alpha1/feature_types.go @@ -15,6 +15,10 @@ type Feature struct { // Metadata includes the feature's name and description. Metadata Metadata `yaml:"metadata"` + // Path is the file path where this feature was loaded from. + // This is used for resolving relative paths in jsonnet() and file() functions. + Path string `yaml:"-"` + // When is a CEL expression that determines if this feature should be applied. // The expression is evaluated against user configuration values. // Examples: "provider == 'aws'", "observability.enabled == true && observability.backend == 'quickwit'" @@ -70,6 +74,7 @@ func (f *Feature) DeepCopy() *Feature { Kind: f.Kind, ApiVersion: f.ApiVersion, Metadata: metadataCopy, + Path: f.Path, When: f.When, TerraformComponents: terraformComponentsCopy, Kustomizations: kustomizationsCopy, diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 205383a86..f3ab17851 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -71,6 +71,8 @@ type BaseBlueprintHandler struct { kubernetesManager kubernetes.KubernetesManager blueprint blueprintv1alpha1.Blueprint projectRoot string + templateRoot string + featureEvaluator *FeatureEvaluator shims *Shims kustomizeData map[string]any featureSubstitutions map[string]map[string]string @@ -82,6 +84,7 @@ type BaseBlueprintHandler struct { func NewBlueprintHandler(injector di.Injector) *BaseBlueprintHandler { return &BaseBlueprintHandler{ injector: injector, + featureEvaluator: NewFeatureEvaluator(injector), shims: NewShims(), kustomizeData: make(map[string]any), featureSubstitutions: make(map[string]map[string]string), @@ -119,6 +122,11 @@ func (b *BaseBlueprintHandler) Initialize() error { return fmt.Errorf("error getting project root: %w", err) } b.projectRoot = projectRoot + b.templateRoot = filepath.Join(projectRoot, "contexts", "_template") + + if err := b.featureEvaluator.Initialize(); err != nil { + return fmt.Errorf("error initializing feature evaluator: %w", err) + } return nil } @@ -146,10 +154,6 @@ func (b *BaseBlueprintHandler) LoadConfig() error { return err } - if err := b.setRepositoryDefaults(); err != nil { - return fmt.Errorf("error setting repository defaults: %w", err) - } - b.configLoaded = true return nil } @@ -159,7 +163,6 @@ func (b *BaseBlueprintHandler) LoadConfig() error { // The ociInfo parameter optionally provides OCI artifact source information for source resolution and tracking. // If config is already loaded from YAML, this is a no-op to preserve resolved state. func (b *BaseBlueprintHandler) LoadData(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error { - // If config is already loaded from YAML, don't overwrite with template data if b.configLoaded { return nil } @@ -180,7 +183,7 @@ func (b *BaseBlueprintHandler) LoadData(data map[string]any, ociInfo ...*artifac // If overwrite is true, the file is overwritten regardless of existence. If overwrite is false or omitted, // the file is only written if it does not already exist. The method ensures the target directory exists, // marshals the blueprint to YAML, and writes the file using the configured shims. -// Terraform variables are filtered out to prevent them from appearing in the final blueprint.yaml. +// Terraform inputs and kustomization substitutions are manually cleared to prevent them from appearing in the final blueprint.yaml. func (b *BaseBlueprintHandler) Write(overwrite ...bool) error { shouldOverwrite := false if len(overwrite) > 0 { @@ -460,18 +463,12 @@ func (b *BaseBlueprintHandler) GetDefaultTemplateData(contextName string) (map[s // values taking precedence. Returns nil if no templates exist. Keys are relative file paths, // values are file contents. func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) { - projectRoot, err := b.shell.GetProjectRoot() - if err != nil { - return nil, fmt.Errorf("failed to get project root: %w", err) - } - - templateDir := filepath.Join(projectRoot, "contexts", "_template") - if _, err := b.shims.Stat(templateDir); os.IsNotExist(err) { + if _, err := b.shims.Stat(b.templateRoot); os.IsNotExist(err) { return nil, nil } templateData := make(map[string][]byte) - if err := b.walkAndCollectTemplates(templateDir, templateDir, templateData); err != nil { + if err := b.walkAndCollectTemplates(b.templateRoot, templateData); err != nil { return nil, fmt.Errorf("failed to collect templates: %w", err) } @@ -710,7 +707,7 @@ func (b *BaseBlueprintHandler) destroyKustomizations(ctx context.Context, kustom // It updates the provided templateData map with the relative paths and content of // the .jsonnet files found. The function handles directory recursion and file reading // errors, returning an error if any operation fails. -func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot string, templateData map[string][]byte) error { +func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir string, templateData map[string][]byte) error { entries, err := b.shims.ReadDir(templateDir) if err != nil { return fmt.Errorf("failed to read template directory: %w", err) @@ -720,10 +717,13 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot entryPath := filepath.Join(templateDir, entry.Name()) if entry.IsDir() { - if err := b.walkAndCollectTemplates(entryPath, templateRoot, templateData); err != nil { + if err := b.walkAndCollectTemplates(entryPath, templateData); err != nil { return err } - } else if strings.HasSuffix(entry.Name(), ".jsonnet") || entry.Name() == "schema.yaml" || entry.Name() == "blueprint.yaml" || (strings.HasPrefix(filepath.Dir(entryPath), filepath.Join(templateRoot, "features")) && strings.HasSuffix(entry.Name(), ".yaml")) { + } else if strings.HasSuffix(entry.Name(), ".jsonnet") || + entry.Name() == "schema.yaml" || + entry.Name() == "blueprint.yaml" || + (strings.HasPrefix(filepath.Dir(entryPath), filepath.Join(b.templateRoot, "features")) && strings.HasSuffix(entry.Name(), ".yaml")) { content, err := b.shims.ReadFile(filepath.Clean(entryPath)) if err != nil { return fmt.Errorf("failed to read template file %s: %w", entryPath, err) @@ -734,7 +734,7 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot } else if entry.Name() == "blueprint.yaml" { templateData["blueprint"] = content } else { - relPath, err := filepath.Rel(templateRoot, entryPath) + relPath, err := filepath.Rel(b.templateRoot, entryPath) if err != nil { return fmt.Errorf("failed to calculate relative path for %s: %w", entryPath, err) } @@ -768,7 +768,7 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c return nil } - evaluator := NewFeatureEvaluator() + evaluator := NewFeatureEvaluator(b.injector) sort.Slice(features, func(i, j int) bool { return features[i].Metadata.Name < features[j].Metadata.Name @@ -776,7 +776,7 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c for _, feature := range features { if feature.When != "" { - matches, err := evaluator.EvaluateExpression(feature.When, config) + matches, err := evaluator.EvaluateExpression(feature.When, config, feature.Path) if err != nil { return fmt.Errorf("failed to evaluate feature condition '%s': %w", feature.When, err) } @@ -787,7 +787,7 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c for _, terraformComponent := range feature.TerraformComponents { if terraformComponent.When != "" { - matches, err := evaluator.EvaluateExpression(terraformComponent.When, config) + matches, err := evaluator.EvaluateExpression(terraformComponent.When, config, feature.Path) if err != nil { return fmt.Errorf("failed to evaluate terraform component condition '%s': %w", terraformComponent.When, err) } @@ -799,7 +799,7 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c component := terraformComponent.TerraformComponent if len(terraformComponent.Inputs) > 0 { - evaluatedInputs, err := evaluator.EvaluateDefaults(terraformComponent.Inputs, config) + evaluatedInputs, err := evaluator.EvaluateDefaults(terraformComponent.Inputs, config, feature.Path) if err != nil { return fmt.Errorf("failed to evaluate inputs for component '%s': %w", component.Path, err) } @@ -830,7 +830,7 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c for _, kustomization := range feature.Kustomizations { if kustomization.When != "" { - matches, err := evaluator.EvaluateExpression(kustomization.When, config) + matches, err := evaluator.EvaluateExpression(kustomization.When, config, feature.Path) if err != nil { return fmt.Errorf("failed to evaluate kustomization condition '%s': %w", kustomization.When, err) } @@ -846,7 +846,7 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c b.featureSubstitutions[kustomizationCopy.Name] = make(map[string]string) } - evaluatedSubstitutions, err := b.evaluateSubstitutions(kustomization.Substitutions, config) + evaluatedSubstitutions, err := b.evaluateSubstitutions(kustomization.Substitutions, config, feature.Path) if err != nil { return fmt.Errorf("failed to evaluate substitutions for kustomization '%s': %w", kustomizationCopy.Name, err) } @@ -854,6 +854,9 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c maps.Copy(b.featureSubstitutions[kustomizationCopy.Name], evaluatedSubstitutions) } + // Clear substitutions as they are used for ConfigMap generation and should not appear in the final blueprint + kustomizationCopy.Substitutions = nil + tempBlueprint := &blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{kustomizationCopy}, } @@ -879,6 +882,7 @@ func (b *BaseBlueprintHandler) loadFeatures(templateData map[string][]byte) ([]b if err != nil { return nil, fmt.Errorf("failed to parse feature %s: %w", path, err) } + feature.Path = filepath.Join(b.templateRoot, path) features = append(features, *feature) } } @@ -995,7 +999,6 @@ func (b *BaseBlueprintHandler) processBlueprintData(data []byte, blueprint *blue } kustomizations := newBlueprint.Kustomizations - sources := newBlueprint.Sources terraformComponents := newBlueprint.TerraformComponents @@ -1536,7 +1539,12 @@ func (b *BaseBlueprintHandler) applyValuesConfigMaps() error { var localVolumePath string if len(localVolumePaths) > 0 { - localVolumePath = strings.Split(localVolumePaths[0], ":")[1] + volumeParts := strings.Split(localVolumePaths[0], ":") + if len(volumeParts) > 1 { + localVolumePath = volumeParts[1] + } else { + localVolumePath = "" + } } else { localVolumePath = "" } @@ -1678,35 +1686,6 @@ func (b *BaseBlueprintHandler) createConfigMap(values map[string]any, configMapN return nil } -// evaluateSubstitutions evaluates expressions in substitution values and converts all results to strings. -// Values can use ${} syntax for expressions (e.g., "${dns.domain}") or be literals. -// All evaluated values are converted to strings as required by Flux postBuild substitution. -func (b *BaseBlueprintHandler) evaluateSubstitutions(substitutions map[string]string, config map[string]any) (map[string]string, error) { - result := make(map[string]string) - evaluator := NewFeatureEvaluator() - - for key, value := range substitutions { - if strings.Contains(value, "${") { - anyMap := map[string]any{key: value} - evaluated, err := evaluator.EvaluateDefaults(anyMap, config) - if err != nil { - return nil, fmt.Errorf("failed to evaluate substitution for key '%s': %w", key, err) - } - - evaluatedValue := evaluated[key] - if evaluatedValue == nil { - result[key] = "" - } else { - result[key] = fmt.Sprintf("%v", evaluatedValue) - } - } else { - result[key] = value - } - } - - return result, nil -} - // flattenValuesToConfigMap recursively flattens nested values into a flat map suitable for ConfigMap data. // Nested maps are flattened using dot notation (e.g., "ingress.host"). func (b *BaseBlueprintHandler) flattenValuesToConfigMap(values map[string]any, prefix string, result map[string]string) error { @@ -1782,11 +1761,8 @@ func (b *BaseBlueprintHandler) deepMergeMaps(base, overlay map[string]any) map[s // setRepositoryDefaults sets the blueprint repository URL if not already specified. // Uses development URL if dev flag is enabled, otherwise falls back to git remote origin URL. +// In dev mode, always overrides the URL even if it's already set. func (b *BaseBlueprintHandler) setRepositoryDefaults() error { - if b.blueprint.Repository.Url != "" { - return nil - } - devMode := b.configHandler.GetBool("dev") if devMode { @@ -1797,6 +1773,11 @@ func (b *BaseBlueprintHandler) setRepositoryDefaults() error { } } + // Only set from git remote if URL is not already set + if b.blueprint.Repository.Url != "" { + return nil + } + gitURL, err := b.shell.ExecSilent("git", "config", "--get", "remote.origin.url") if err == nil && gitURL != "" { b.blueprint.Repository.Url = b.normalizeGitURL(strings.TrimSpace(gitURL)) @@ -1806,6 +1787,35 @@ func (b *BaseBlueprintHandler) setRepositoryDefaults() error { return nil } +// evaluateSubstitutions evaluates expressions in substitution values and converts all results to strings. +// Values can use ${} syntax for expressions (e.g., "${dns.domain}") or be literals. +// All evaluated values are converted to strings as required by Flux postBuild substitution. +func (b *BaseBlueprintHandler) evaluateSubstitutions(substitutions map[string]string, config map[string]any, featurePath string) (map[string]string, error) { + result := make(map[string]string) + evaluator := NewFeatureEvaluator(b.injector) + + for key, value := range substitutions { + if strings.Contains(value, "${") { + anyMap := map[string]any{key: value} + evaluated, err := evaluator.EvaluateDefaults(anyMap, config, featurePath) + if err != nil { + return nil, fmt.Errorf("failed to evaluate substitution for key '%s': %w", key, err) + } + + evaluatedValue := evaluated[key] + if evaluatedValue == nil { + result[key] = "" + } else { + result[key] = fmt.Sprintf("%v", evaluatedValue) + } + } else { + result[key] = value + } + } + + return result, nil +} + // normalizeGitURL normalizes git repository URLs by prepending https:// when needed. // Preserves SSH URLs (git@...), http://, and https:// URLs as-is. func (b *BaseBlueprintHandler) normalizeGitURL(url string) string { diff --git a/pkg/blueprint/blueprint_handler_private_test.go b/pkg/blueprint/blueprint_handler_private_test.go index 545bdf590..656bbfa97 100644 --- a/pkg/blueprint/blueprint_handler_private_test.go +++ b/pkg/blueprint/blueprint_handler_private_test.go @@ -3401,6 +3401,14 @@ func TestBaseBlueprintHandler_setRepositoryDefaults(t *testing.T) { handler := setup(t) handler.blueprint.Repository.Url = "https://github.com/existing/repo" + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetBoolFunc = func(key string, defaultValue ...bool) bool { + if key == "dev" { + return false + } + return false + } + err := handler.setRepositoryDefaults() if err != nil { diff --git a/pkg/blueprint/blueprint_handler_public_test.go b/pkg/blueprint/blueprint_handler_public_test.go index 912526f75..9b7dca5c0 100644 --- a/pkg/blueprint/blueprint_handler_public_test.go +++ b/pkg/blueprint/blueprint_handler_public_test.go @@ -956,15 +956,33 @@ kustomize: []` return nil, os.ErrNotExist } + // Mock WriteFile to allow Write() to succeed + handler.shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + return nil + } + err := handler.LoadConfig() if err != nil { t.Fatalf("Expected no error, got %v", err) } + // Repository defaults are now set during Write(), not LoadConfig() + // So the URL should be empty after LoadConfig() + if handler.blueprint.Repository.Url != "" { + t.Errorf("Expected repository URL to be empty after LoadConfig(), got %s", handler.blueprint.Repository.Url) + } + + // Now test that Write() sets the repository defaults + // Use overwrite=true to ensure setRepositoryDefaults() is called + err = handler.Write(true) + if err != nil { + t.Fatalf("Expected no error during Write(), got %v", err) + } + expectedURL := "http://git.test/git/cli" if handler.blueprint.Repository.Url != expectedURL { - t.Errorf("Expected repository URL to be %s, got %s", expectedURL, handler.blueprint.Repository.Url) + t.Errorf("Expected repository URL to be %s after Write(), got %s", expectedURL, handler.blueprint.Repository.Url) } }) } @@ -2423,54 +2441,59 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { t.Run("CollectsJsonnetFilesFromTemplateDirectory", func(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet files - handler, mocks := setup(t) - projectRoot := filepath.Join("mock", "project") templateDir := filepath.Join(projectRoot, "contexts", "_template") - // Mock shell to return project root + // Set up mocks first, before initializing the handler + mocks := setupMocks(t) mocks.Shell.GetProjectRootFunc = func() (string, error) { return projectRoot, nil } + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + // Mock shims to simulate template directory with files - if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { - baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { - if path == templateDir { - return mockFileInfo{name: "_template"}, nil - } - return nil, os.ErrNotExist + baseHandler := handler + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil } + return nil, os.ErrNotExist + } - baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { - if path == templateDir { - return []os.DirEntry{ - &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, - &mockDirEntry{name: "config.yaml", isDir: false}, // Should be ignored - &mockDirEntry{name: "terraform", isDir: true}, - }, nil - } - if path == filepath.Join(templateDir, "terraform") { - return []os.DirEntry{ - &mockDirEntry{name: "cluster.jsonnet", isDir: false}, - &mockDirEntry{name: "network.jsonnet", isDir: false}, - &mockDirEntry{name: "README.md", isDir: false}, // Should be ignored - }, nil - } - return nil, fmt.Errorf("directory not found") + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + &mockDirEntry{name: "config.yaml", isDir: false}, // Should be ignored + &mockDirEntry{name: "terraform", isDir: true}, + }, nil + } + if path == filepath.Join(templateDir, "terraform") { + return []os.DirEntry{ + &mockDirEntry{name: "cluster.jsonnet", isDir: false}, + &mockDirEntry{name: "network.jsonnet", isDir: false}, + &mockDirEntry{name: "README.md", isDir: false}, // Should be ignored + }, nil } + return nil, fmt.Errorf("directory not found") + } - baseHandler.shims.ReadFile = func(path string) ([]byte, error) { - switch path { - case filepath.Join(templateDir, "blueprint.jsonnet"): - return []byte("{ kind: 'Blueprint' }"), nil - case filepath.Join(templateDir, "terraform", "cluster.jsonnet"): - return []byte("{ cluster_name: 'test' }"), nil - case filepath.Join(templateDir, "terraform", "network.jsonnet"): - return []byte("{ vpc_cidr: '10.0.0.0/16' }"), nil - default: - return nil, fmt.Errorf("file not found: %s", path) - } + baseHandler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case filepath.Join(templateDir, "blueprint.jsonnet"): + return []byte("{ kind: 'Blueprint' }"), nil + case filepath.Join(templateDir, "terraform", "cluster.jsonnet"): + return []byte("{ cluster_name: 'test' }"), nil + case filepath.Join(templateDir, "terraform", "network.jsonnet"): + return []byte("{ vpc_cidr: '10.0.0.0/16' }"), nil + default: + return nil, fmt.Errorf("file not found: %s", path) } } @@ -2520,13 +2543,26 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { } }) - t.Run("ReturnsErrorWhenGetProjectRootFails", func(t *testing.T) { - // Given a blueprint handler with shell that fails to get project root - handler, mocks := setup(t) + t.Run("ReturnsErrorWhenTemplateDirectoryReadFails", func(t *testing.T) { + // Given a blueprint handler with template directory that fails to read + handler, _ := setup(t) - // Mock shell to return error - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("failed to get project root") + // Mock shims to return error when reading template directory + if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == baseHandler.templateRoot { + return nil, fmt.Errorf("failed to read template directory") + } + return nil, os.ErrNotExist + } + + // Mock ReadDir to return error when trying to read the template directory + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == baseHandler.templateRoot { + return nil, fmt.Errorf("failed to read template directory") + } + return nil, fmt.Errorf("directory not found") + } } // When getting local template data @@ -2537,8 +2573,8 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { t.Fatal("Expected error, got nil") } - if !strings.Contains(err.Error(), "failed to get project root") { - t.Errorf("Expected error to contain 'failed to get project root', got: %v", err) + if !strings.Contains(err.Error(), "failed to read template directory") { + t.Errorf("Expected error to contain 'failed to read template directory', got: %v", err) } // And result should be nil @@ -2549,28 +2585,33 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { t.Run("ReturnsErrorWhenWalkAndCollectTemplatesFails", func(t *testing.T) { // Given a blueprint handler with template directory that fails to read - handler, mocks := setup(t) - projectRoot := filepath.Join("mock", "project") templateDir := filepath.Join(projectRoot, "contexts", "_template") - // Mock shell to return project root + // Set up mocks first, before initializing the handler + mocks := setupMocks(t) mocks.Shell.GetProjectRootFunc = func() (string, error) { return projectRoot, nil } + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + // Mock shims to simulate template directory exists but ReadDir fails - if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { - baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { - if path == templateDir { - return mockFileInfo{name: "_template"}, nil - } - return nil, os.ErrNotExist + baseHandler := handler + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil } + return nil, os.ErrNotExist + } - baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { - return nil, fmt.Errorf("failed to read directory") - } + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + return nil, fmt.Errorf("failed to read directory") } // When getting local template data @@ -2933,21 +2974,27 @@ substitutions: t.Run("HandlesContextValuesWithoutExistingValues", func(t *testing.T) { // Given a blueprint handler with only context values (no existing OCI values) - handler, mocks := setup(t) - - // Ensure the handler uses the mock shell and config handler - baseHandler := handler.(*BaseBlueprintHandler) - baseHandler.shell = mocks.Shell - baseHandler.configHandler = mocks.ConfigHandler - projectRoot := filepath.Join("mock", "project") templateDir := filepath.Join(projectRoot, "contexts", "_template") - // Mock shell to return project root + // Set up mocks first, before initializing the handler + mocks := setupMocks(t) mocks.Shell.GetProjectRootFunc = func() (string, error) { return projectRoot, nil } + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + // Ensure the handler uses the mock shell and config handler + baseHandler := handler + baseHandler.shell = mocks.Shell + baseHandler.configHandler = mocks.ConfigHandler + // Mock config handler to return context if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { mockConfigHandler.GetContextFunc = func() string { @@ -2971,30 +3018,29 @@ substitutions: } // Mock shims to simulate template directory and context values - if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { - baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { - if path == templateDir || - path == filepath.Join(projectRoot, "contexts", "test-context", "values.yaml") { - return mockFileInfo{name: "template"}, nil - } - return nil, os.ErrNotExist + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir || + path == filepath.Join(projectRoot, "contexts", "test-context", "values.yaml") { + return mockFileInfo{name: "template"}, nil } + return nil, os.ErrNotExist + } - baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { - if path == templateDir { - return []os.DirEntry{ - &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, - }, nil - } - return nil, fmt.Errorf("directory not found") + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil } + return nil, fmt.Errorf("directory not found") + } - baseHandler.shims.ReadFile = func(path string) ([]byte, error) { - switch path { - case filepath.Join(templateDir, "blueprint.jsonnet"): - return []byte("{ kind: 'Blueprint' }"), nil - case filepath.Join(projectRoot, "contexts", "test-context", "values.yaml"): - return []byte(` + baseHandler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case filepath.Join(templateDir, "blueprint.jsonnet"): + return []byte("{ kind: 'Blueprint' }"), nil + case filepath.Join(projectRoot, "contexts", "test-context", "values.yaml"): + return []byte(` external_domain: context.test context_only: context_value substitutions: @@ -3002,18 +3048,17 @@ substitutions: registry_url: registry.context.test context_sub: context_sub_value `), nil - default: - return nil, fmt.Errorf("file not found: %s", path) - } + default: + return nil, fmt.Errorf("file not found: %s", path) } + } - baseHandler.shims.YamlMarshal = func(v any) ([]byte, error) { - return yaml.Marshal(v) - } + baseHandler.shims.YamlMarshal = func(v any) ([]byte, error) { + return yaml.Marshal(v) + } - baseHandler.shims.YamlUnmarshal = func(data []byte, v any) error { - return yaml.Unmarshal(data, v) - } + baseHandler.shims.YamlUnmarshal = func(data []byte, v any) error { + return yaml.Unmarshal(data, v) } // When getting local template data @@ -3054,59 +3099,64 @@ substitutions: t.Run("HandlesErrorInLoadAndMergeContextValues", func(t *testing.T) { // Given a blueprint handler that fails to load context values - handler, mocks := setup(t) - projectRoot := filepath.Join("mock", "project") templateDir := filepath.Join(projectRoot, "contexts", "_template") - // Mock shell to return project root + // Set up mocks first, before initializing the handler + mocks := setupMocks(t) mocks.Shell.GetProjectRootFunc = func() (string, error) { return projectRoot, nil } - // Mock shell to fail when getting project root (for loadAndMergeContextValues) - if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { - // Override the shell in the base handler to return error - baseHandler.shell = &shell.MockShell{ - GetProjectRootFunc: func() (string, error) { - return "", fmt.Errorf("project root error") - }, + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + // Mock config handler to return error when getting context values + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return nil, fmt.Errorf("failed to load context values") } + } - baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { - if path == templateDir { - return mockFileInfo{name: "template"}, nil - } - return nil, os.ErrNotExist + // Mock shims to simulate template directory exists + baseHandler := handler + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "template"}, nil } + return nil, os.ErrNotExist + } - baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { - if path == templateDir { - return []os.DirEntry{ - &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, - }, nil - } - return nil, fmt.Errorf("directory not found") + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil } + return nil, fmt.Errorf("directory not found") + } - baseHandler.shims.ReadFile = func(path string) ([]byte, error) { - if path == filepath.Join(templateDir, "blueprint.jsonnet") { - return []byte("{ kind: 'Blueprint' }"), nil - } - return nil, fmt.Errorf("file not found: %s", path) + baseHandler.shims.ReadFile = func(path string) ([]byte, error) { + if path == filepath.Join(templateDir, "blueprint.jsonnet") { + return []byte("{ kind: 'Blueprint' }"), nil } + return nil, fmt.Errorf("file not found: %s", path) } // When getting local template data - _, err := handler.GetLocalTemplateData() + _, err = handler.GetLocalTemplateData() // Then an error should occur if err == nil { - t.Error("Expected error when loadAndMergeContextValues fails") + t.Error("Expected error when GetContextValues fails") } - if !strings.Contains(err.Error(), "failed to get project root") { - t.Errorf("Expected error about project root, got: %v", err) + if !strings.Contains(err.Error(), "failed to load context values") { + t.Errorf("Expected error about context values, got: %v", err) } }) } @@ -3744,7 +3794,13 @@ func TestBaseBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { func TestBaseBlueprintHandler_GetLocalTemplateData(t *testing.T) { t.Run("CollectsBlueprintAndFeatureFiles", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -3757,11 +3813,7 @@ func TestBaseBlueprintHandler_GetLocalTemplateData(t *testing.T) { return contextName } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - + projectRoot = os.Getenv("WINDSOR_PROJECT_ROOT") templateDir := filepath.Join(projectRoot, "contexts", "_template") featuresDir := filepath.Join(templateDir, "features") contextDir := filepath.Join(projectRoot, "contexts", contextName) @@ -3848,7 +3900,13 @@ metadata: }) t.Run("CollectsNestedFeatures", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -3861,11 +3919,7 @@ metadata: return contextName } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - + projectRoot = os.Getenv("WINDSOR_PROJECT_ROOT") templateDir := filepath.Join(projectRoot, "contexts", "_template") nestedFeaturesDir := filepath.Join(templateDir, "features", "aws") contextDir := filepath.Join(projectRoot, "contexts", contextName) @@ -3898,7 +3952,13 @@ metadata: }) t.Run("IgnoresNonYAMLFilesInFeatures", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -3911,11 +3971,7 @@ metadata: return contextName } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - + projectRoot = os.Getenv("WINDSOR_PROJECT_ROOT") templateDir := filepath.Join(projectRoot, "contexts", "_template") featuresDir := filepath.Join(templateDir, "features") contextDir := filepath.Join(projectRoot, "contexts", contextName) @@ -3964,7 +4020,13 @@ metadata: }) t.Run("ComposesFeaturesByEvaluatingConditions", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -3985,11 +4047,6 @@ metadata: }, nil } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - templateDir := filepath.Join(projectRoot, "contexts", "_template") featuresDir := filepath.Join(templateDir, "features") contextDir := filepath.Join(projectRoot, "contexts", contextName) @@ -4074,7 +4131,13 @@ terraform: }) t.Run("SetsMetadataFromContextName", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -4090,11 +4153,6 @@ terraform: return map[string]any{}, nil } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - templateDir := filepath.Join(projectRoot, "contexts", "_template") contextDir := filepath.Join(projectRoot, "contexts", contextName) @@ -4142,7 +4200,13 @@ terraform: }) t.Run("HandlesSubstitutionValues", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -4163,11 +4227,6 @@ terraform: }, nil } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - templateDir := filepath.Join(projectRoot, "contexts", "_template") contextDir := filepath.Join(projectRoot, "contexts", contextName) @@ -4216,18 +4275,19 @@ terraform: }) t.Run("ReturnsNilWhenNoTemplateDirectory", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { t.Fatalf("Failed to initialize handler: %v", err) } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - templateData, err := handler.GetLocalTemplateData() if err != nil { @@ -4240,7 +4300,13 @@ terraform: }) t.Run("HandlesEmptyBlueprintWithOnlyFeatures", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -4258,11 +4324,6 @@ terraform: }, nil } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - templateDir := filepath.Join(projectRoot, "contexts", "_template") featuresDir := filepath.Join(templateDir, "features") contextDir := filepath.Join(projectRoot, "contexts", contextName) @@ -4303,7 +4364,13 @@ terraform: }) t.Run("HandlesKustomizationsInFeatures", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -4323,11 +4390,6 @@ terraform: }, nil } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - templateDir := filepath.Join(projectRoot, "contexts", "_template") featuresDir := filepath.Join(templateDir, "features") contextDir := filepath.Join(projectRoot, "contexts", contextName) @@ -4381,7 +4443,13 @@ kustomize: }) t.Run("SkipsComposedBlueprintWhenEmpty", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks := setupMocks(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() if err != nil { @@ -4397,11 +4465,6 @@ kustomize: return map[string]any{}, nil } - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - templateDir := filepath.Join(projectRoot, "contexts", "_template") contextDir := filepath.Join(projectRoot, "contexts", contextName) diff --git a/pkg/blueprint/feature_evaluator.go b/pkg/blueprint/feature_evaluator.go index 504e0425e..a4c14d268 100644 --- a/pkg/blueprint/feature_evaluator.go +++ b/pkg/blueprint/feature_evaluator.go @@ -2,9 +2,17 @@ package blueprint import ( "fmt" + "maps" + "path/filepath" "strings" "github.com/expr-lang/expr" + "github.com/google/go-jsonnet" + "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/config" + "github.com/windsorcli/cli/pkg/constants" + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/shell" ) // FeatureEvaluator provides lightweight expression evaluation for blueprint feature conditions. @@ -17,15 +25,40 @@ import ( // ============================================================================= // FeatureEvaluator provides lightweight expression evaluation for feature conditions. -type FeatureEvaluator struct{} +type FeatureEvaluator struct { + injector di.Injector + configHandler config.ConfigHandler + shell shell.Shell + shims *Shims +} // ============================================================================= // Constructor // ============================================================================= -// NewFeatureEvaluator creates a new lightweight feature evaluator for expression evaluation. -func NewFeatureEvaluator() *FeatureEvaluator { - return &FeatureEvaluator{} +// NewFeatureEvaluator creates a new feature evaluator with the provided dependency injector. +func NewFeatureEvaluator(injector di.Injector) *FeatureEvaluator { + return &FeatureEvaluator{ + injector: injector, + shims: NewShims(), + } +} + +// ============================================================================= +// Initialization +// ============================================================================= + +// Initialize resolves and assigns dependencies from the injector. +func (e *FeatureEvaluator) Initialize() error { + if e.injector != nil { + if configHandler := e.injector.Resolve("configHandler"); configHandler != nil { + e.configHandler = configHandler.(config.ConfigHandler) + } + if shellService := e.injector.Resolve("shell"); shellService != nil { + e.shell = shellService.(shell.Shell) + } + } + return nil } // ============================================================================= @@ -38,8 +71,9 @@ func NewFeatureEvaluator() *FeatureEvaluator { // - Logical operators: &&, || // - Parentheses for grouping: (expression) // - Nested object access: provider, observability.enabled, vm.driver +// The featurePath is used to resolve relative paths in jsonnet() and file() functions. // Returns true if the expression evaluates to true, false otherwise. -func (e *FeatureEvaluator) EvaluateExpression(expression string, config map[string]any) (bool, error) { +func (e *FeatureEvaluator) EvaluateExpression(expression string, config map[string]any, featurePath string) (bool, error) { if expression == "" { return false, fmt.Errorf("expression cannot be empty") } @@ -64,13 +98,17 @@ func (e *FeatureEvaluator) EvaluateExpression(expression string, config map[stri // EvaluateValue evaluates an expression and returns the result as any type. // Supports arithmetic, string operations, array construction, and nested object access. +// Also supports file loading functions: jsonnet("path") and file("path"). +// The featurePath is used to resolve relative paths in jsonnet() and file() functions. // Returns the evaluated value or an error if evaluation fails. -func (e *FeatureEvaluator) EvaluateValue(expression string, config map[string]any) (any, error) { +func (e *FeatureEvaluator) EvaluateValue(expression string, config map[string]any, featurePath string) (any, error) { if expression == "" { return nil, fmt.Errorf("expression cannot be empty") } - program, err := expr.Compile(expression) + env := e.buildExprEnvironment(config, featurePath) + + program, err := expr.Compile(expression, env...) if err != nil { return nil, fmt.Errorf("failed to compile expression '%s': %w", expression, err) } @@ -85,11 +123,12 @@ func (e *FeatureEvaluator) EvaluateValue(expression string, config map[string]an // EvaluateDefaults recursively evaluates default values, treating quoted strings as literals // and unquoted values as expressions. Supports nested maps and arrays. -func (e *FeatureEvaluator) EvaluateDefaults(defaults map[string]any, config map[string]any) (map[string]any, error) { +// The featurePath is used to resolve relative paths in jsonnet() and file() functions. +func (e *FeatureEvaluator) EvaluateDefaults(defaults map[string]any, config map[string]any, featurePath string) (map[string]any, error) { result := make(map[string]any) for key, value := range defaults { - evaluated, err := e.evaluateDefaultValue(value, config) + evaluated, err := e.evaluateDefaultValue(value, config, featurePath) if err != nil { return nil, fmt.Errorf("failed to evaluate default for key '%s': %w", key, err) } @@ -99,26 +138,202 @@ func (e *FeatureEvaluator) EvaluateDefaults(defaults map[string]any, config map[ return result, nil } +// ProcessFeature evaluates feature conditions and processes its Terraform components and Kustomizations. +// If the feature has a 'When' condition, it is evaluated against the provided config and feature path. +// Features or components whose conditions do not match are skipped. The returned Feature includes only +// the components and Kustomizations whose conditions have passed. If the root feature's condition is not met, +// ProcessFeature returns nil. Errors encountered in any evaluation are returned. Inputs for Terraform components +// and substitutions for Kustomizations are evaluated and updated; nil values from evaluated inputs are omitted. +func (e *FeatureEvaluator) ProcessFeature(feature *v1alpha1.Feature, config map[string]any) (*v1alpha1.Feature, error) { + if feature.When != "" { + matches, err := e.EvaluateExpression(feature.When, config, feature.Path) + if err != nil { + return nil, fmt.Errorf("failed to evaluate feature condition '%s': %w", feature.When, err) + } + if !matches { + return nil, nil + } + } + + processedFeature := feature.DeepCopy() + + var processedTerraformComponents []v1alpha1.ConditionalTerraformComponent + for _, terraformComponent := range processedFeature.TerraformComponents { + if terraformComponent.When != "" { + matches, err := e.EvaluateExpression(terraformComponent.When, config, feature.Path) + if err != nil { + return nil, fmt.Errorf("failed to evaluate terraform component condition '%s': %w", terraformComponent.When, err) + } + if !matches { + continue + } + } + + if len(terraformComponent.Inputs) > 0 { + evaluatedInputs, err := e.EvaluateDefaults(terraformComponent.Inputs, config, feature.Path) + if err != nil { + return nil, fmt.Errorf("failed to evaluate inputs for component '%s': %w", terraformComponent.TerraformComponent.Path, err) + } + + filteredInputs := make(map[string]any) + for k, v := range evaluatedInputs { + if v != nil { + filteredInputs[k] = v + } + } + terraformComponent.Inputs = filteredInputs + } + + processedTerraformComponents = append(processedTerraformComponents, terraformComponent) + } + processedFeature.TerraformComponents = processedTerraformComponents + + var processedKustomizations []v1alpha1.ConditionalKustomization + for _, kustomization := range processedFeature.Kustomizations { + if kustomization.When != "" { + matches, err := e.EvaluateExpression(kustomization.When, config, feature.Path) + if err != nil { + return nil, fmt.Errorf("failed to evaluate kustomization condition '%s': %w", kustomization.When, err) + } + if !matches { + continue + } + } + + if len(kustomization.Substitutions) > 0 { + evaluatedSubstitutions, err := e.evaluateSubstitutions(kustomization.Substitutions, config, feature.Path) + if err != nil { + return nil, fmt.Errorf("failed to evaluate substitutions for kustomization '%s': %w", kustomization.Kustomization.Name, err) + } + kustomization.Substitutions = evaluatedSubstitutions + } + + processedKustomizations = append(processedKustomizations, kustomization) + } + processedFeature.Kustomizations = processedKustomizations + + return processedFeature, nil +} + +// MergeFeatures creates a single "mega feature" by merging multiple processed features. +// It combines all Terraform components and Kustomizations from the input features into a consolidated feature. +// If the input slice is empty, it returns nil. +// The merged feature's metadata is given a default name of "merged-features". +func (e *FeatureEvaluator) MergeFeatures(features []*v1alpha1.Feature) *v1alpha1.Feature { + if len(features) == 0 { + return nil + } + + megaFeature := &v1alpha1.Feature{ + Metadata: v1alpha1.Metadata{ + Name: "merged-features", + }, + } + + var allTerraformComponents []v1alpha1.ConditionalTerraformComponent + for _, feature := range features { + allTerraformComponents = append(allTerraformComponents, feature.TerraformComponents...) + } + megaFeature.TerraformComponents = allTerraformComponents + + var allKustomizations []v1alpha1.ConditionalKustomization + for _, feature := range features { + allKustomizations = append(allKustomizations, feature.Kustomizations...) + } + megaFeature.Kustomizations = allKustomizations + + return megaFeature +} + +// FeatureToBlueprint transforms a processed feature into a blueprint structure. +// It extracts and transfers all terraform components and kustomizations, removing +// any substitutions from the kustomization copies as those are only used for ConfigMap +// generation and are not included in the final blueprint output. Returns nil if the +// input feature is nil. +func (e *FeatureEvaluator) FeatureToBlueprint(feature *v1alpha1.Feature) *v1alpha1.Blueprint { + if feature == nil { + return nil + } + + blueprint := &v1alpha1.Blueprint{ + Kind: "Blueprint", + ApiVersion: "v1alpha1", + Metadata: v1alpha1.Metadata{ + Name: feature.Metadata.Name, + }, + } + + var terraformComponents []v1alpha1.TerraformComponent + for _, component := range feature.TerraformComponents { + terraformComponent := component.TerraformComponent + terraformComponents = append(terraformComponents, terraformComponent) + } + blueprint.TerraformComponents = terraformComponents + + var kustomizations []v1alpha1.Kustomization + for _, kustomization := range feature.Kustomizations { + kustomizationCopy := kustomization.Kustomization + kustomizations = append(kustomizations, kustomizationCopy) + } + blueprint.Kustomizations = kustomizations + + return blueprint +} + // ============================================================================= // Private Methods // ============================================================================= +// buildExprEnvironment creates an expr environment with custom functions for file loading. +func (e *FeatureEvaluator) buildExprEnvironment(config map[string]any, featurePath string) []expr.Option { + return []expr.Option{ + expr.Function( + "jsonnet", + func(params ...any) (any, error) { + if len(params) != 1 { + return nil, fmt.Errorf("jsonnet() requires exactly 1 argument, got %d", len(params)) + } + path, ok := params[0].(string) + if !ok { + return nil, fmt.Errorf("jsonnet() path must be a string, got %T", params[0]) + } + return e.evaluateJsonnetFunction(path, config, featurePath) + }, + new(func(string) any), + ), + expr.Function( + "file", + func(params ...any) (any, error) { + if len(params) != 1 { + return nil, fmt.Errorf("file() requires exactly 1 argument, got %d", len(params)) + } + path, ok := params[0].(string) + if !ok { + return nil, fmt.Errorf("file() path must be a string, got %T", params[0]) + } + return e.evaluateFileFunction(path, featurePath) + }, + new(func(string) string), + ), + } +} + // evaluateDefaultValue recursively evaluates a single default value. -func (e *FeatureEvaluator) evaluateDefaultValue(value any, config map[string]any) (any, error) { +func (e *FeatureEvaluator) evaluateDefaultValue(value any, config map[string]any, featurePath string) (any, error) { switch v := value.(type) { case string: if expr := e.extractExpression(v); expr != "" { - return e.EvaluateValue(expr, config) + return e.EvaluateValue(expr, config, featurePath) } if strings.Contains(v, "${") { - return e.interpolateString(v, config) + return e.interpolateString(v, config, featurePath) } return v, nil case map[string]any: result := make(map[string]any) for k, val := range v { - evaluated, err := e.evaluateDefaultValue(val, config) + evaluated, err := e.evaluateDefaultValue(val, config, featurePath) if err != nil { return nil, err } @@ -129,7 +344,7 @@ func (e *FeatureEvaluator) evaluateDefaultValue(value any, config map[string]any case []any: result := make([]any, len(v)) for i, val := range v { - evaluated, err := e.evaluateDefaultValue(val, config) + evaluated, err := e.evaluateDefaultValue(val, config, featurePath) if err != nil { return nil, err } @@ -166,7 +381,7 @@ func (e *FeatureEvaluator) extractExpression(s string) string { } // interpolateString replaces all ${expression} occurrences in a string with their evaluated values. -func (e *FeatureEvaluator) interpolateString(s string, config map[string]any) (string, error) { +func (e *FeatureEvaluator) interpolateString(s string, config map[string]any, featurePath string) (string, error) { result := s for strings.Contains(result, "${") { @@ -180,7 +395,7 @@ func (e *FeatureEvaluator) interpolateString(s string, config map[string]any) (s end += start expr := result[start+2 : end] - value, err := e.EvaluateValue(expr, config) + value, err := e.EvaluateValue(expr, config, featurePath) if err != nil { return "", fmt.Errorf("failed to evaluate expression '${%s}': %w", expr, err) } @@ -197,3 +412,246 @@ func (e *FeatureEvaluator) interpolateString(s string, config map[string]any) (s return result, nil } + +// evaluateJsonnetFunction loads and processes a jsonnet file from the given path. +func (e *FeatureEvaluator) evaluateJsonnetFunction(pathArg string, config map[string]any, featurePath string) (any, error) { + path := e.resolvePath(pathArg, featurePath) + + content, err := e.shims.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", path, err) + } + + enrichedConfig := e.buildContextMap(config) + + configJSON, err := e.shims.JsonMarshal(enrichedConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal config to JSON: %w", err) + } + + vm := e.shims.NewJsonnetVM() + + helpersLibrary := e.buildHelperLibrary() + vm.ExtCode("helpers", helpersLibrary) + vm.ExtCode("context", string(configJSON)) + vm.ExtCode("ociUrl", fmt.Sprintf("%q", constants.GetEffectiveBlueprintURL())) + + if dir := filepath.Dir(path); dir != "" { + vm.Importer(&jsonnet.FileImporter{ + JPaths: []string{dir}, + }) + } + + result, err := vm.EvaluateAnonymousSnippet(filepath.Base(path), string(content)) + if err != nil { + return nil, fmt.Errorf("failed to evaluate jsonnet file %s: %w", path, err) + } + + var value any + if err := e.shims.JsonUnmarshal([]byte(result), &value); err != nil { + return nil, fmt.Errorf("jsonnet file %s must output valid JSON: %w", path, err) + } + + return value, nil +} + +// buildContextMap enriches the config with name and projectName fields for consistency with main template processing. +func (e *FeatureEvaluator) buildContextMap(config map[string]any) map[string]any { + contextMap := make(map[string]any) + maps.Copy(contextMap, config) + + if e.configHandler != nil { + contextName := e.configHandler.GetContext() + contextMap["name"] = contextName + } + + if e.shell != nil { + projectRoot, err := e.shell.GetProjectRoot() + if err == nil { + contextMap["projectName"] = e.shims.FilepathBase(projectRoot) + } + } + + return contextMap +} + +// buildHelperLibrary returns a Jsonnet library string containing helper functions for safe context access and data manipulation. +func (e *FeatureEvaluator) buildHelperLibrary() string { + return `{ + get(obj, path, default=null): + if std.findSubstr(".", path) == [] then + if std.type(obj) == "object" && path in obj then obj[path] else default + else + local parts = std.split(path, "."); + local getValue(o, pathParts) = + if std.length(pathParts) == 0 then o + else if std.type(o) != "object" then null + else if !(pathParts[0] in o) then null + else getValue(o[pathParts[0]], pathParts[1:]); + local result = getValue(obj, parts); + if result == null then default else result, + + getString(obj, path, default=""): + local val = self.get(obj, path, null); + if val == null then default + else if std.type(val) == "string" then val + else error "Expected string for '" + path + "' but got " + std.type(val) + ": " + std.toString(val), + + getInt(obj, path, default=0): + local val = self.get(obj, path, null); + if val == null then default + else if std.type(val) == "number" then std.floor(val) + else error "Expected number for '" + path + "' but got " + std.type(val) + ": " + std.toString(val), + + getNumber(obj, path, default=0): + local val = self.get(obj, path, null); + if val == null then default + else if std.type(val) == "number" then val + else error "Expected number for '" + path + "' but got " + std.type(val) + ": " + std.toString(val), + + getBool(obj, path, default=false): + local val = self.get(obj, path, null); + if val == null then default + else if std.type(val) == "boolean" then val + else error "Expected boolean for '" + path + "' but got " + std.type(val) + ": " + std.toString(val), + + getObject(obj, path, default={}): + local val = self.get(obj, path, null); + if val == null then default + else if std.type(val) == "object" then val + else error "Expected object for '" + path + "' but got " + std.type(val) + ": " + std.toString(val), + + getArray(obj, path, default=[]): + local val = self.get(obj, path, null); + if val == null then default + else if std.type(val) == "array" then val + else error "Expected array for '" + path + "' but got " + std.type(val) + ": " + std.toString(val), + + has(obj, path): + self.get(obj, path, null) != null, + + baseUrl(endpoint): + if endpoint == "" then + "" + else + local withoutProtocol = if std.startsWith(endpoint, "https://") then + std.substr(endpoint, 8, std.length(endpoint) - 8) + else if std.startsWith(endpoint, "http://") then + std.substr(endpoint, 7, std.length(endpoint) - 7) + else + endpoint; + local colonPos = std.findSubstr(":", withoutProtocol); + if std.length(colonPos) > 0 then + std.substr(withoutProtocol, 0, colonPos[0]) + else + withoutProtocol, + + removeEmptyKeys(obj): + local _removeEmptyKeys(obj) = + if std.type(obj) == "object" then + local filteredFields = std.filter( + function(key) + local value = obj[key]; + if std.type(value) == "object" || std.type(value) == "array" then + local cleaned = _removeEmptyKeys(value); + if std.type(cleaned) == "object" then + std.length(std.objectFields(cleaned)) > 0 + else + std.length(cleaned) > 0 + else + value != null && (std.type(value) != "string" || value != "") + , + std.objectFields(obj) + ); + { + [key]: if std.type(obj[key]) == "object" || std.type(obj[key]) == "array" then _removeEmptyKeys(obj[key]) else obj[key] + for key in filteredFields + } + else if std.type(obj) == "array" then + local filteredElements = std.filter( + function(element) + if std.type(element) == "object" || std.type(element) == "array" then + local cleaned = _removeEmptyKeys(element); + if std.type(cleaned) == "object" then + std.length(std.objectFields(cleaned)) > 0 + else + std.length(cleaned) > 0 + else + element != null && (std.type(element) != "string" || element != "") + , + obj + ); + [ + if std.type(element) == "object" || std.type(element) == "array" then _removeEmptyKeys(element) else element + for element in filteredElements + ] + else + obj; + _removeEmptyKeys(obj), +}` +} + +// evaluateFileFunction loads raw file content from the given path. +func (e *FeatureEvaluator) evaluateFileFunction(pathArg string, featurePath string) (any, error) { + path := e.resolvePath(pathArg, featurePath) + + content, err := e.shims.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", path, err) + } + + return string(content), nil +} + +// resolvePath returns an absolute, cleaned file path based on the provided path and featurePath. +// If the path is absolute, it returns the cleaned version directly. If the path is relative and +// featurePath is non-empty, the result is the provided path joined to the feature directory. +// If featurePath is empty and a project root is available via the shell, the path is joined to +// the project root. In all cases, the result is normalized and cleaned. Paths are trimmed of +// whitespace before resolution. +func (e *FeatureEvaluator) resolvePath(path string, featurePath string) string { + path = strings.TrimSpace(path) + + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + + if featurePath != "" { + featureDir := filepath.Dir(featurePath) + return filepath.Clean(filepath.Join(featureDir, path)) + } + + if e.shell != nil { + if projectRoot, err := e.shell.GetProjectRoot(); err == nil && projectRoot != "" { + return filepath.Clean(filepath.Join(projectRoot, path)) + } + } + + return filepath.Clean(path) +} + +// evaluateSubstitutions evaluates expressions in substitution values and converts all results to strings. +func (e *FeatureEvaluator) evaluateSubstitutions(substitutions map[string]string, config map[string]any, featurePath string) (map[string]string, error) { + result := make(map[string]string) + + for key, value := range substitutions { + if strings.Contains(value, "${") { + anyMap := map[string]any{key: value} + evaluated, err := e.EvaluateDefaults(anyMap, config, featurePath) + if err != nil { + return nil, fmt.Errorf("failed to evaluate substitution for key '%s': %w", key, err) + } + + evaluatedValue := evaluated[key] + if evaluatedValue == nil { + result[key] = "" + } else { + result[key] = fmt.Sprintf("%v", evaluatedValue) + } + } else { + result[key] = value + } + } + + return result, nil +} diff --git a/pkg/blueprint/feature_evaluator_test.go b/pkg/blueprint/feature_evaluator_test.go index 320af15fd..296eb2da5 100644 --- a/pkg/blueprint/feature_evaluator_test.go +++ b/pkg/blueprint/feature_evaluator_test.go @@ -1,7 +1,15 @@ package blueprint import ( + "fmt" + "os" + "path/filepath" + "strings" "testing" + + "github.com/windsorcli/cli/pkg/config" + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/shell" ) // ============================================================================= @@ -10,7 +18,8 @@ import ( func TestNewFeatureEvaluator(t *testing.T) { t.Run("CreatesNewFeatureEvaluatorSuccessfully", func(t *testing.T) { - evaluator := NewFeatureEvaluator() + injector := di.NewInjector() + evaluator := NewFeatureEvaluator(injector) if evaluator == nil { t.Fatal("Expected evaluator, got nil") } @@ -22,7 +31,9 @@ func TestNewFeatureEvaluator(t *testing.T) { // ============================================================================= func TestFeatureEvaluator_EvaluateExpression(t *testing.T) { - evaluator := NewFeatureEvaluator() + injector := di.NewInjector() + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() tests := []struct { name string @@ -140,7 +151,7 @@ func TestFeatureEvaluator_EvaluateExpression(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := evaluator.EvaluateExpression(tt.expression, tt.config) + result, err := evaluator.EvaluateExpression(tt.expression, tt.config, "features/test.yaml") if tt.shouldError { if err == nil { @@ -163,7 +174,9 @@ func TestFeatureEvaluator_EvaluateExpression(t *testing.T) { } func TestFeatureEvaluator_EvaluateValue(t *testing.T) { - evaluator := NewFeatureEvaluator() + injector := di.NewInjector() + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() tests := []struct { name string @@ -256,7 +269,7 @@ func TestFeatureEvaluator_EvaluateValue(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := evaluator.EvaluateValue(tt.expression, tt.config) + result, err := evaluator.EvaluateValue(tt.expression, tt.config, "features/test.yaml") if tt.shouldError { if err == nil { @@ -279,7 +292,9 @@ func TestFeatureEvaluator_EvaluateValue(t *testing.T) { } func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { - evaluator := NewFeatureEvaluator() + injector := di.NewInjector() + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() t.Run("EvaluatesLiteralValues", func(t *testing.T) { defaults := map[string]any{ @@ -289,7 +304,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { config := map[string]any{} - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -317,7 +332,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -344,7 +359,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -376,7 +391,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -414,7 +429,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -444,7 +459,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -473,7 +488,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - _, err := evaluator.EvaluateDefaults(defaults, config) + _, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err == nil { t.Fatal("Expected error for invalid expression, got nil") } @@ -496,7 +511,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -529,7 +544,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -552,7 +567,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { "port": 8080, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -575,7 +590,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - result, err := evaluator.EvaluateDefaults(defaults, config) + result, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -596,7 +611,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { }, } - _, err := evaluator.EvaluateDefaults(defaults, config) + _, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err == nil { t.Fatal("Expected error for unclosed expression, got nil") } @@ -609,7 +624,7 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { config := map[string]any{} - _, err := evaluator.EvaluateDefaults(defaults, config) + _, err := evaluator.EvaluateDefaults(defaults, config, "features/test.yaml") if err == nil { t.Fatal("Expected error for invalid interpolation expression, got nil") } @@ -621,7 +636,9 @@ func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { // ============================================================================= func TestFeatureEvaluator_extractExpression(t *testing.T) { - evaluator := NewFeatureEvaluator() + injector := di.NewInjector() + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() tests := []struct { name string @@ -681,10 +698,12 @@ func TestFeatureEvaluator_extractExpression(t *testing.T) { } func TestFeatureEvaluator_evaluateDefaultValue(t *testing.T) { - evaluator := NewFeatureEvaluator() + injector := di.NewInjector() + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() t.Run("LiteralStringPassesThrough", func(t *testing.T) { - result, err := evaluator.evaluateDefaultValue("talos", map[string]any{}) + result, err := evaluator.evaluateDefaultValue("talos", map[string]any{}, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -702,7 +721,7 @@ func TestFeatureEvaluator_evaluateDefaultValue(t *testing.T) { }, } - result, err := evaluator.evaluateDefaultValue("${cluster.workers.count}", config) + result, err := evaluator.evaluateDefaultValue("${cluster.workers.count}", config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -725,7 +744,7 @@ func TestFeatureEvaluator_evaluateDefaultValue(t *testing.T) { }, } - result, err := evaluator.evaluateDefaultValue(input, config) + result, err := evaluator.evaluateDefaultValue(input, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -757,7 +776,7 @@ func TestFeatureEvaluator_evaluateDefaultValue(t *testing.T) { }, } - result, err := evaluator.evaluateDefaultValue(input, config) + result, err := evaluator.evaluateDefaultValue(input, config, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -779,7 +798,7 @@ func TestFeatureEvaluator_evaluateDefaultValue(t *testing.T) { }) t.Run("NonStringTypesPassThrough", func(t *testing.T) { - result, err := evaluator.evaluateDefaultValue(42, map[string]any{}) + result, err := evaluator.evaluateDefaultValue(42, map[string]any{}, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -787,7 +806,7 @@ func TestFeatureEvaluator_evaluateDefaultValue(t *testing.T) { t.Errorf("Expected 42, got %v", result) } - result, err = evaluator.evaluateDefaultValue(true, map[string]any{}) + result, err = evaluator.evaluateDefaultValue(true, map[string]any{}, "features/test.yaml") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -842,3 +861,837 @@ func deepEqual(a, b any) bool { return a == b } } + +// ============================================================================= +// Test File Loading Functions +// ============================================================================= + +func TestFeatureEvaluator_JsonnetFunction(t *testing.T) { + t.Run("LoadsAndEvaluatesJsonnetFile", func(t *testing.T) { + tmpDir := t.TempDir() + + jsonnetContent := `{ + name: "test-config", + replicas: 3, + enabled: true +}` + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "config.jsonnet") + if err := os.WriteFile(expectedPath, []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + config := map[string]any{} + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateValue(`jsonnet("config.jsonnet")`, config, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["name"] != "test-config" { + t.Errorf("Expected name='test-config', got %v", resultMap["name"]) + } + if resultMap["replicas"] != float64(3) { + t.Errorf("Expected replicas=3, got %v", resultMap["replicas"]) + } + if resultMap["enabled"] != true { + t.Errorf("Expected enabled=true, got %v", resultMap["enabled"]) + } + }) + + t.Run("JsonnetFileWithContextVariable", func(t *testing.T) { + tmpDir := t.TempDir() + + jsonnetContent := `local ctx = std.extVar('context'); +{ + namespace: ctx.namespace, + region: ctx.region +}` + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "context-config.jsonnet") + if err := os.WriteFile(expectedPath, []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + config := map[string]any{ + "namespace": "production", + "region": "us-west-2", + } + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateValue(`jsonnet("context-config.jsonnet")`, config, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["namespace"] != "production" { + t.Errorf("Expected namespace='production', got %v", resultMap["namespace"]) + } + if resultMap["region"] != "us-west-2" { + t.Errorf("Expected region='us-west-2', got %v", resultMap["region"]) + } + }) + + t.Run("JsonnetFileWithEnrichedContext", func(t *testing.T) { + tmpDir := t.TempDir() + projectDir := tmpDir + "/my-project" + + jsonnetContent := `local ctx = std.extVar('context'); +{ + projectName: if std.objectHas(ctx, 'projectName') then ctx.projectName else 'unknown', + environment: ctx.environment +}` + + // Create the expected directory structure and file + expectedDir := filepath.Join(projectDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "enriched-config.jsonnet") + if err := os.WriteFile(expectedPath, []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return projectDir, nil } + + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + config := map[string]any{ + "environment": "production", + } + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateValue(`jsonnet("enriched-config.jsonnet")`, config, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["projectName"] != "my-project" { + t.Errorf("Expected projectName='my-project', got %v", resultMap["projectName"]) + } + if resultMap["environment"] != "production" { + t.Errorf("Expected environment='production', got %v", resultMap["environment"]) + } + }) + + t.Run("JsonnetFileWithRelativePath", func(t *testing.T) { + tmpDir := t.TempDir() + + jsonnetContent := `{ + source: "nested" +}` + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features", "configs") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "nested.jsonnet") + if err := os.WriteFile(expectedPath, []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + config := map[string]any{} + + featurePath := filepath.Join(tmpDir, "contexts", "_template", "features", "test.yaml") + result, err := evaluator.EvaluateValue(`jsonnet("configs/nested.jsonnet")`, config, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["source"] != "nested" { + t.Errorf("Expected source='nested', got %v", resultMap["source"]) + } + }) + + t.Run("JsonnetFileNotFound", func(t *testing.T) { + tmpDir := t.TempDir() + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + config := map[string]any{} + + _, err := evaluator.EvaluateValue(`jsonnet("nonexistent.jsonnet")`, config, "features/test.yaml") + if err == nil { + t.Fatal("Expected error for nonexistent file, got nil") + } + }) + + t.Run("JsonnetFileInvalidSyntax", func(t *testing.T) { + tmpDir := t.TempDir() + + jsonnetContent := `{ + invalid syntax here +}` + jsonnetPath := tmpDir + "/invalid.jsonnet" + if err := writeTestFile(jsonnetPath, jsonnetContent); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + config := map[string]any{} + + _, err := evaluator.EvaluateValue(`jsonnet("invalid.jsonnet")`, config, "features/test.yaml") + if err == nil { + t.Fatal("Expected error for invalid jsonnet, got nil") + } + }) +} + +func TestFeatureEvaluator_FileFunction(t *testing.T) { + t.Run("LoadsRawFileContent", func(t *testing.T) { + tmpDir := t.TempDir() + + content := "Hello, World!\nThis is a test file." + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "test.txt") + if err := os.WriteFile(expectedPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateValue(`file("test.txt")`, map[string]any{}, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultStr, ok := result.(string) + if !ok { + t.Fatalf("Expected string, got %T", result) + } + + if resultStr != content { + t.Errorf("Expected content='%s', got '%s'", content, resultStr) + } + }) + + t.Run("LoadsYAMLFile", func(t *testing.T) { + tmpDir := t.TempDir() + + yamlContent := `name: test-service +version: 1.0.0 +enabled: true` + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "config.yaml") + if err := os.WriteFile(expectedPath, []byte(yamlContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateValue(`file("config.yaml")`, map[string]any{}, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultStr, ok := result.(string) + if !ok { + t.Fatalf("Expected string, got %T", result) + } + + if resultStr != yamlContent { + t.Errorf("Expected yaml content, got different content") + } + }) + + t.Run("FileNotFound", func(t *testing.T) { + tmpDir := t.TempDir() + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + _, err := evaluator.EvaluateValue(`file("nonexistent.txt")`, map[string]any{}, "features/test.yaml") + if err == nil { + t.Fatal("Expected error for nonexistent file, got nil") + } + }) +} + +func TestFeatureEvaluator_FileLoadingInDefaults(t *testing.T) { + t.Run("EvaluatesJsonnetInDefaults", func(t *testing.T) { + tmpDir := t.TempDir() + + jsonnetContent := `{ + database: { + host: "localhost", + port: 5432 + } +}` + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "db-config.jsonnet") + if err := os.WriteFile(expectedPath, []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + defaults := map[string]any{ + "db_config": `${jsonnet("db-config.jsonnet")}`, + "name": "my-service", + } + + config := map[string]any{} + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateDefaults(defaults, config, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + dbConfig, ok := result["db_config"].(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any for db_config, got %T", result["db_config"]) + } + + database, ok := dbConfig["database"].(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any for database, got %T", dbConfig["database"]) + } + + if database["host"] != "localhost" { + t.Errorf("Expected host='localhost', got %v", database["host"]) + } + if database["port"] != float64(5432) { + t.Errorf("Expected port=5432, got %v", database["port"]) + } + }) + + t.Run("EvaluatesFileInDefaults", func(t *testing.T) { + tmpDir := t.TempDir() + + fileContent := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC..." + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "key.pub") + if err := os.WriteFile(expectedPath, []byte(fileContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { return tmpDir, nil } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + defaults := map[string]any{ + "ssh_key": `${file("key.pub")}`, + } + + config := map[string]any{} + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateDefaults(defaults, config, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if result["ssh_key"] != fileContent { + t.Errorf("Expected ssh_key to contain file content") + } + }) +} + +func TestFeatureEvaluator_AbsolutePaths(t *testing.T) { + t.Run("HandlesAbsolutePathForJsonnet", func(t *testing.T) { + tmpDir := t.TempDir() + + jsonnetContent := `{ + test: "absolute" +}` + jsonnetPath := filepath.Join(tmpDir, "absolute.jsonnet") + if err := writeTestFile(jsonnetPath, jsonnetContent); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + result, err := evaluator.EvaluateValue(`jsonnet("`+strings.ReplaceAll(jsonnetPath, "\\", "\\\\")+`")`, map[string]any{}, "features/test.yaml") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["test"] != "absolute" { + t.Errorf("Expected test='absolute', got %v", resultMap["test"]) + } + }) +} + +func TestFeatureEvaluator_PathResolution(t *testing.T) { + t.Run("FallbackToProjectRootWhenNoContextRoot", func(t *testing.T) { + tmpDir := t.TempDir() + jsonnetContent := `{ + test: "project-root" +}` + if err := writeTestFile(filepath.Join(tmpDir, "config.jsonnet"), jsonnetContent); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("no context root") + } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + result, err := evaluator.EvaluateValue(`jsonnet("config.jsonnet")`, map[string]any{}, "") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["test"] != "project-root" { + t.Errorf("Expected test='project-root', got %v", resultMap["test"]) + } + }) + + t.Run("FallbackToCleanPathWhenNoShell", func(t *testing.T) { + tmpDir := t.TempDir() + jsonnetContent := `{ + test: "clean-path" +}` + testFile := filepath.Join(tmpDir, "test.jsonnet") + if err := writeTestFile(testFile, jsonnetContent); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("no context root") + } + injector.Register("configHandler", mockConfig) + + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + result, err := evaluator.EvaluateValue(`jsonnet("`+strings.ReplaceAll(testFile, "\\", "\\\\")+`")`, map[string]any{}, "features/test.yaml") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["test"] != "clean-path" { + t.Errorf("Expected test='clean-path', got %v", resultMap["test"]) + } + }) + + t.Run("FeatureDirTakesPrecedenceOverContextRoot", func(t *testing.T) { + tmpDir := t.TempDir() + featureSubDir := filepath.Join(tmpDir, "contexts", "_template", "features", "aws") + if err := os.MkdirAll(featureSubDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + featuresDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(featuresDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + jsonnetContent := `{ + test: "feature-dir" +}` + if err := os.WriteFile(filepath.Join(featuresDir, "config.jsonnet"), []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + wrongJsonnetContent := `{ + test: "wrong" +}` + if err := os.WriteFile(filepath.Join(tmpDir, "config.jsonnet"), []byte(wrongJsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { + return tmpDir, nil + } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + featurePath := filepath.Join(featuresDir, "test.yaml") + result, err := evaluator.EvaluateValue(`jsonnet("config.jsonnet")`, map[string]any{}, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["test"] != "feature-dir" { + t.Errorf("Expected test='feature-dir', got %v", resultMap["test"]) + } + }) + + t.Run("AccessNestedFieldFromJsonnetFunction", func(t *testing.T) { + tmpDir := t.TempDir() + jsonnetContent := `{ + worker_config_patches: ["patch1", "patch2"], + control_plane_patches: ["cp-patch1"], + other_config: { + nested: "value" + } +}` + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "talos-dev.jsonnet") + if err := os.WriteFile(expectedPath, []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { + return tmpDir, nil + } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateValue(`jsonnet("talos-dev.jsonnet").worker_config_patches`, map[string]any{}, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultSlice, ok := result.([]any) + if !ok { + t.Fatalf("Expected []any, got %T", result) + } + + if len(resultSlice) != 2 { + t.Errorf("Expected 2 patches, got %d", len(resultSlice)) + } + + if resultSlice[0] != "patch1" || resultSlice[1] != "patch2" { + t.Errorf("Expected ['patch1', 'patch2'], got %v", resultSlice) + } + }) + + t.Run("AccessDeeplyNestedFieldFromJsonnetFunction", func(t *testing.T) { + tmpDir := t.TempDir() + jsonnetContent := `{ + config: { + nested: { + deeply: { + value: "found it!" + } + } + } +}` + + // Create the expected directory structure and file + expectedDir := filepath.Join(tmpDir, "contexts", "_template", "features") + if err := os.MkdirAll(expectedDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + expectedPath := filepath.Join(expectedDir, "nested.jsonnet") + if err := os.WriteFile(expectedPath, []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { + return tmpDir, nil + } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + featurePath := filepath.Join(expectedDir, "test.yaml") + result, err := evaluator.EvaluateValue(`jsonnet("nested.jsonnet").config.nested.deeply.value`, map[string]any{}, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if result != "found it!" { + t.Errorf("Expected 'found it!', got %v", result) + } + }) + + t.Run("RelativePathFromFeatureFileInFeaturesDirectory", func(t *testing.T) { + tmpDir := t.TempDir() + templateRoot := filepath.Join(tmpDir, "contexts", "_template") + + configsDir := filepath.Join(templateRoot, "configs") + if err := os.MkdirAll(configsDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + featuresDir := filepath.Join(templateRoot, "features") + if err := os.MkdirAll(featuresDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + jsonnetContent := `{ + worker_config_patches: ["patch1", "patch2"] +}` + if err := os.WriteFile(filepath.Join(configsDir, "talos-dev.jsonnet"), []byte(jsonnetContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + featurePath := filepath.Join(featuresDir, "test.yaml") + result, err := evaluator.EvaluateValue(`jsonnet("../configs/talos-dev.jsonnet").worker_config_patches`, map[string]any{}, featurePath) + if err != nil { + t.Fatalf("Expected no error, got: %v\nFeatureDir: %s\nExpected file: %s", err, featuresDir, filepath.Join(configsDir, "talos-dev.jsonnet")) + } + + resultSlice, ok := result.([]any) + if !ok { + t.Fatalf("Expected []any, got %T", result) + } + + if len(resultSlice) != 2 { + t.Errorf("Expected 2 patches, got %d", len(resultSlice)) + } + + if resultSlice[0] != "patch1" || resultSlice[1] != "patch2" { + t.Errorf("Expected ['patch1', 'patch2'], got %v", resultSlice) + } + }) + + t.Run("ProjectRootErrorFallsBackToCleanPath", func(t *testing.T) { + tmpDir := t.TempDir() + jsonnetContent := `{ + test: "clean-fallback" +}` + testFile := filepath.Join(tmpDir, "fallback.jsonnet") + if err := writeTestFile(testFile, jsonnetContent); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + injector := di.NewInjector() + mockConfig := config.NewMockConfigHandler() + mockConfig.GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("no context root") + } + mockShell := shell.NewMockShell() + mockShell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("no project root") + } + injector.Register("configHandler", mockConfig) + injector.Register("shell", mockShell) + evaluator := NewFeatureEvaluator(injector) + _ = evaluator.Initialize() + + result, err := evaluator.EvaluateValue(`jsonnet("`+strings.ReplaceAll(testFile, "\\", "\\\\")+`")`, map[string]any{}, "features/test.yaml") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map[string]any, got %T", result) + } + + if resultMap["test"] != "clean-fallback" { + t.Errorf("Expected test='clean-fallback', got %v", resultMap["test"]) + } + }) +} + +func writeTestFile(path, content string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + _, err = file.WriteString(content) + return err +} + +func mkdirAll(path string) error { + return os.MkdirAll(path, 0755) +} diff --git a/pkg/blueprint/shims.go b/pkg/blueprint/shims.go index 5b58aac2a..b698c0e61 100644 --- a/pkg/blueprint/shims.go +++ b/pkg/blueprint/shims.go @@ -8,12 +8,40 @@ import ( "time" "github.com/goccy/go-yaml" + "github.com/google/go-jsonnet" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" k8syaml "sigs.k8s.io/yaml" ) +// JsonnetVM provides an interface for Jsonnet virtual machine operations +type JsonnetVM interface { + ExtCode(key, val string) + Importer(importer jsonnet.Importer) + EvaluateAnonymousSnippet(filename, snippet string) (string, error) +} + +// RealJsonnetVM is the real implementation of JsonnetVM +type RealJsonnetVM struct { + vm *jsonnet.VM +} + +// ExtCode sets external code for the Jsonnet VM +func (j *RealJsonnetVM) ExtCode(key, val string) { + j.vm.ExtCode(key, val) +} + +// Importer sets the importer for the Jsonnet VM +func (j *RealJsonnetVM) Importer(importer jsonnet.Importer) { + j.vm.Importer(importer) +} + +// EvaluateAnonymousSnippet evaluates a Jsonnet snippet +func (j *RealJsonnetVM) EvaluateAnonymousSnippet(filename, snippet string) (string, error) { + return j.vm.EvaluateAnonymousSnippet(filename, snippet) +} + // Shims provides testable wrappers around external dependencies for the blueprint package. // This enables dependency injection and mocking in unit tests while maintaining // clean separation between business logic and external system interactions. @@ -37,6 +65,7 @@ type Shims struct { JsonMarshal func(any) ([]byte, error) JsonUnmarshal func([]byte, any) error FilepathBase func(string) string + NewJsonnetVM func() JsonnetVM } // NewShims creates a new Shims instance with default implementations @@ -75,5 +104,8 @@ func NewShims() *Shims { JsonMarshal: json.Marshal, JsonUnmarshal: json.Unmarshal, FilepathBase: filepath.Base, + NewJsonnetVM: func() JsonnetVM { + return &RealJsonnetVM{vm: jsonnet.MakeVM()} + }, } } diff --git a/pkg/pipelines/pipeline_test.go b/pkg/pipelines/pipeline_test.go index 9d2e2bfe1..ebc53f5ba 100644 --- a/pkg/pipelines/pipeline_test.go +++ b/pkg/pipelines/pipeline_test.go @@ -175,8 +175,6 @@ network: t.Fatalf("Failed to write context config: %v", err) } - // Config will be loaded by pipeline initialization - // Register shims shims := setupShims(t) injector.Register("shims", shims)