From 54d55d16f0b960efaa99d7e0b802594767ddb50a Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sat, 11 Oct 2025 23:52:03 -0400 Subject: [PATCH] feature(blueprint): Add complete feature composition Feature composition is now fully supported. The `Feature` type can now be used to conditionally build a final `blueprint.yaml`. --- pkg/blueprint/blueprint_handler.go | 159 +++- .../blueprint_handler_private_test.go | 682 +++++++++++++++++ .../blueprint_handler_public_test.go | 693 ++++++++++++++++++ pkg/config/config_handler.go | 16 +- pkg/config/config_handler_private_test.go | 66 ++ 5 files changed, 1612 insertions(+), 4 deletions(-) diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index ae2f1191c..829d4ceb5 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -10,6 +10,7 @@ import ( "path/filepath" "reflect" "slices" + "sort" "strings" "syscall" "time" @@ -473,11 +474,42 @@ func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) return nil, fmt.Errorf("failed to collect templates: %w", err) } + if schemaData, exists := templateData["schema"]; exists { + if err := b.configHandler.LoadSchemaFromBytes(schemaData); err != nil { + return nil, fmt.Errorf("failed to load schema: %w", err) + } + } + contextValues, err := b.configHandler.GetContextValues() if err != nil { return nil, fmt.Errorf("failed to load context values: %w", err) } + config := make(map[string]any) + for k, v := range contextValues { + if k != "substitution" { + config[k] = v + } + } + + if err := b.processFeatures(templateData, config); err != nil { + return nil, fmt.Errorf("failed to process features: %w", err) + } + + if len(b.blueprint.TerraformComponents) > 0 || len(b.blueprint.Kustomizations) > 0 { + contextName := b.configHandler.GetContext() + if contextName != "" { + b.blueprint.Metadata.Name = contextName + b.blueprint.Metadata.Description = fmt.Sprintf("Blueprint for %s context", contextName) + } + + composedBlueprintYAML, err := b.shims.YamlMarshal(b.blueprint) + if err != nil { + return nil, fmt.Errorf("failed to marshal composed blueprint: %w", err) + } + templateData["blueprint"] = composedBlueprintYAML + } + if contextValues != nil { if substitutionValues, ok := contextValues["substitution"].(map[string]any); ok && len(substitutionValues) > 0 { if existingValues, exists := templateData["substitution"]; exists { @@ -693,7 +725,7 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot if err := b.walkAndCollectTemplates(entryPath, templateRoot, templateData); err != nil { return err } - } else if strings.HasSuffix(entry.Name(), ".jsonnet") || entry.Name() == "schema.yaml" { + } 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")) { content, err := b.shims.ReadFile(filepath.Clean(entryPath)) if err != nil { return fmt.Errorf("failed to read template file %s: %w", entryPath, err) @@ -701,6 +733,8 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot if entry.Name() == "schema.yaml" { templateData["schema"] = content + } else if entry.Name() == "blueprint.yaml" { + templateData["blueprint"] = content } else { relPath, err := filepath.Rel(templateRoot, entryPath) if err != nil { @@ -716,6 +750,129 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot return nil } +// processFeatures loads the base blueprint and merges features that match evaluated conditions. +// It loads the base blueprint.yaml from templateData, loads features, evaluates their When expressions +// against the provided config, and merges matching features into the base blueprint. Features and their +// components are merged in deterministic order by feature name. +func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, config map[string]any) error { + if blueprintData, exists := templateData["blueprint"]; exists { + if err := b.processBlueprintData(blueprintData, &b.blueprint); err != nil { + return fmt.Errorf("failed to load base blueprint.yaml: %w", err) + } + } + + features, err := b.loadFeatures(templateData) + if err != nil { + return fmt.Errorf("failed to load features: %w", err) + } + + if len(features) == 0 { + return nil + } + + evaluator := NewFeatureEvaluator() + + sort.Slice(features, func(i, j int) bool { + return features[i].Metadata.Name < features[j].Metadata.Name + }) + + for _, feature := range features { + if feature.When != "" { + matches, err := evaluator.EvaluateExpression(feature.When, config) + if err != nil { + return fmt.Errorf("failed to evaluate feature condition '%s': %w", feature.When, err) + } + if !matches { + continue + } + } + + for _, terraformComponent := range feature.TerraformComponents { + if terraformComponent.When != "" { + matches, err := evaluator.EvaluateExpression(terraformComponent.When, config) + if err != nil { + return fmt.Errorf("failed to evaluate terraform component condition '%s': %w", terraformComponent.When, err) + } + if !matches { + continue + } + } + tempBlueprint := &blueprintv1alpha1.Blueprint{ + TerraformComponents: []blueprintv1alpha1.TerraformComponent{terraformComponent.TerraformComponent}, + } + if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { + return fmt.Errorf("failed to merge terraform component: %w", err) + } + } + + for _, kustomization := range feature.Kustomizations { + if kustomization.When != "" { + matches, err := evaluator.EvaluateExpression(kustomization.When, config) + if err != nil { + return fmt.Errorf("failed to evaluate kustomization condition '%s': %w", kustomization.When, err) + } + if !matches { + continue + } + } + kustomizationCopy := kustomization.Kustomization + tempBlueprint := &blueprintv1alpha1.Blueprint{ + Kustomizations: []blueprintv1alpha1.Kustomization{kustomizationCopy}, + } + if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { + return fmt.Errorf("failed to merge kustomization: %w", err) + } + } + } + + return nil +} + +// loadFeatures extracts and parses feature files from template data. +// It looks for files with paths starting with "features/" and ending with ".yaml", +// parses them as Feature objects, and returns a slice of all valid features. +// Returns an error if any feature file cannot be parsed. +func (b *BaseBlueprintHandler) loadFeatures(templateData map[string][]byte) ([]blueprintv1alpha1.Feature, error) { + var features []blueprintv1alpha1.Feature + + for path, content := range templateData { + if strings.HasPrefix(path, "features/") && strings.HasSuffix(path, ".yaml") { + feature, err := b.parseFeature(content) + if err != nil { + return nil, fmt.Errorf("failed to parse feature %s: %w", path, err) + } + features = append(features, *feature) + } + } + + return features, nil +} + +// parseFeature parses YAML content into a Feature object. +// It validates that the feature has the correct kind and apiVersion, +// and ensures required fields are present. +func (b *BaseBlueprintHandler) parseFeature(content []byte) (*blueprintv1alpha1.Feature, error) { + var feature blueprintv1alpha1.Feature + + if err := b.shims.YamlUnmarshal(content, &feature); err != nil { + return nil, fmt.Errorf("invalid YAML: %w", err) + } + + if feature.Kind != "Feature" { + return nil, fmt.Errorf("expected kind 'Feature', got '%s'", feature.Kind) + } + + if feature.ApiVersion == "" { + return nil, fmt.Errorf("apiVersion is required") + } + + if feature.Metadata.Name == "" { + return nil, fmt.Errorf("metadata.name is required") + } + + return &feature, nil +} + // resolveComponentSources transforms component source names into fully qualified URLs // with path prefix and reference information based on the associated source configuration. // It processes both OCI and Git sources, constructing appropriate URL formats for each type. diff --git a/pkg/blueprint/blueprint_handler_private_test.go b/pkg/blueprint/blueprint_handler_private_test.go index d1a167098..b6a6e1585 100644 --- a/pkg/blueprint/blueprint_handler_private_test.go +++ b/pkg/blueprint/blueprint_handler_private_test.go @@ -2810,3 +2810,685 @@ func TestBaseBlueprintHandler_applyOCIRepository(t *testing.T) { } }) } + +func TestBaseBlueprintHandler_parseFeature(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + return handler + } + + t.Run("ParseValidFeature", func(t *testing.T) { + handler := setup(t) + + featureYAML := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-observability + description: Observability stack for AWS +when: provider == "aws" +terraform: + - path: observability/quickwit + when: observability.backend == "quickwit" + values: + storage_bucket: my-bucket +kustomize: + - name: grafana + path: observability/grafana + when: observability.enabled == true +`) + + feature, err := handler.parseFeature(featureYAML) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if feature.Kind != "Feature" { + t.Errorf("Expected kind 'Feature', got '%s'", feature.Kind) + } + if feature.ApiVersion != "blueprints.windsorcli.dev/v1alpha1" { + t.Errorf("Expected apiVersion 'blueprints.windsorcli.dev/v1alpha1', got '%s'", feature.ApiVersion) + } + if feature.Metadata.Name != "aws-observability" { + t.Errorf("Expected name 'aws-observability', got '%s'", feature.Metadata.Name) + } + if feature.When != `provider == "aws"` { + t.Errorf("Expected when condition, got '%s'", feature.When) + } + if len(feature.TerraformComponents) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(feature.TerraformComponents)) + } + if len(feature.Kustomizations) != 1 { + t.Errorf("Expected 1 kustomization, got %d", len(feature.Kustomizations)) + } + }) + + t.Run("FailsOnInvalidYAML", func(t *testing.T) { + handler := setup(t) + + invalidYAML := []byte(`this is not valid yaml: [`) + + _, err := handler.parseFeature(invalidYAML) + + if err == nil { + t.Error("Expected error for invalid YAML, got nil") + } + if !strings.Contains(err.Error(), "invalid YAML") { + t.Errorf("Expected 'invalid YAML' error, got %v", err) + } + }) + + t.Run("FailsOnWrongKind", func(t *testing.T) { + handler := setup(t) + + wrongKind := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test +`) + + _, err := handler.parseFeature(wrongKind) + + if err == nil { + t.Error("Expected error for wrong kind, got nil") + } + if !strings.Contains(err.Error(), "expected kind 'Feature'") { + t.Errorf("Expected kind error, got %v", err) + } + }) + + t.Run("FailsOnMissingApiVersion", func(t *testing.T) { + handler := setup(t) + + missingVersion := []byte(`kind: Feature +metadata: + name: test +`) + + _, err := handler.parseFeature(missingVersion) + + if err == nil { + t.Error("Expected error for missing apiVersion, got nil") + } + if !strings.Contains(err.Error(), "apiVersion is required") { + t.Errorf("Expected apiVersion error, got %v", err) + } + }) + + t.Run("FailsOnMissingName", func(t *testing.T) { + handler := setup(t) + + missingName := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + description: test +`) + + _, err := handler.parseFeature(missingName) + + if err == nil { + t.Error("Expected error for missing name, got nil") + } + if !strings.Contains(err.Error(), "metadata.name is required") { + t.Errorf("Expected name error, got %v", err) + } + }) + + t.Run("ParseFeatureWithoutWhenCondition", func(t *testing.T) { + handler := setup(t) + + featureYAML := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base-feature +terraform: + - path: base/component +`) + + feature, err := handler.parseFeature(featureYAML) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if feature.When != "" { + t.Errorf("Expected empty when condition, got '%s'", feature.When) + } + if len(feature.TerraformComponents) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(feature.TerraformComponents)) + } + }) +} + +func TestBaseBlueprintHandler_loadFeatures(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + return handler + } + + t.Run("LoadMultipleFeatures", func(t *testing.T) { + handler := setup(t) + + templateData := map[string][]byte{ + "features/aws.yaml": []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +`), + "features/observability.yaml": []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: observability-feature +`), + "blueprint.jsonnet": []byte(`{}`), + "schema.yaml": []byte(`{}`), + } + + features, err := handler.loadFeatures(templateData) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(features) != 2 { + t.Errorf("Expected 2 features, got %d", len(features)) + } + names := make(map[string]bool) + for _, feature := range features { + names[feature.Metadata.Name] = true + } + if !names["aws-feature"] || !names["observability-feature"] { + t.Errorf("Expected both features to be loaded, got %v", names) + } + }) + + t.Run("LoadNoFeatures", func(t *testing.T) { + handler := setup(t) + + templateData := map[string][]byte{ + "blueprint.jsonnet": []byte(`{}`), + "schema.yaml": []byte(`{}`), + } + + features, err := handler.loadFeatures(templateData) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(features) != 0 { + t.Errorf("Expected 0 features, got %d", len(features)) + } + }) + + t.Run("IgnoresNonFeatureYAMLFiles", func(t *testing.T) { + handler := setup(t) + + templateData := map[string][]byte{ + "features/aws.yaml": []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +`), + "schema.yaml": []byte(`{}`), + "values.yaml": []byte(`key: value`), + "terraform/module.yaml": []byte(`key: value`), + } + + features, err := handler.loadFeatures(templateData) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(features) != 1 { + t.Errorf("Expected 1 feature, got %d", len(features)) + } + if features[0].Metadata.Name != "aws-feature" { + t.Errorf("Expected 'aws-feature', got '%s'", features[0].Metadata.Name) + } + }) + + t.Run("FailsOnInvalidFeature", func(t *testing.T) { + handler := setup(t) + + templateData := map[string][]byte{ + "features/valid.yaml": []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: valid-feature +`), + "features/invalid.yaml": []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + description: missing name +`), + } + + _, err := handler.loadFeatures(templateData) + + if err == nil { + t.Error("Expected error for invalid feature, got nil") + } + if !strings.Contains(err.Error(), "failed to parse feature features/invalid.yaml") { + t.Errorf("Expected parse error with path, got %v", err) + } + if !strings.Contains(err.Error(), "metadata.name is required") { + t.Errorf("Expected name requirement error, got %v", err) + } + }) + + t.Run("LoadFeaturesWithComplexStructures", func(t *testing.T) { + handler := setup(t) + + templateData := map[string][]byte{ + "features/complex.yaml": []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: complex-feature + description: Complex feature with multiple components +when: provider == "aws" && observability.enabled == true +terraform: + - path: observability/quickwit + when: observability.backend == "quickwit" + values: + storage_bucket: my-bucket + replicas: 3 + - path: observability/grafana + values: + domain: grafana.example.com +kustomize: + - name: monitoring + path: monitoring/stack + when: monitoring.enabled == true + - name: logging + path: logging/stack +`), + } + + features, err := handler.loadFeatures(templateData) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(features) != 1 { + t.Fatalf("Expected 1 feature, got %d", len(features)) + } + feature := features[0] + if feature.Metadata.Name != "complex-feature" { + t.Errorf("Expected 'complex-feature', got '%s'", feature.Metadata.Name) + } + if len(feature.TerraformComponents) != 2 { + t.Errorf("Expected 2 terraform components, got %d", len(feature.TerraformComponents)) + } + if len(feature.Kustomizations) != 2 { + t.Errorf("Expected 2 kustomizations, got %d", len(feature.Kustomizations)) + } + if feature.TerraformComponents[0].When != `observability.backend == "quickwit"` { + t.Errorf("Expected when condition on terraform component, got '%s'", feature.TerraformComponents[0].When) + } + }) +} + +func TestBaseBlueprintHandler_processFeatures(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + return handler + } + + t.Run("ProcessFeaturesWithMatchingConditions", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + awsFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +when: provider == "aws" +terraform: + - path: observability/quickwit + values: + bucket: my-bucket +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/aws.yaml": awsFeature, + } + + config := map[string]any{ + "provider": "aws", + } + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.TerraformComponents) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(handler.blueprint.TerraformComponents)) + } + if handler.blueprint.TerraformComponents[0].Path != "observability/quickwit" { + t.Errorf("Expected path 'observability/quickwit', got '%s'", handler.blueprint.TerraformComponents[0].Path) + } + }) + + t.Run("SkipsFeaturesWithNonMatchingConditions", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + awsFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +when: provider == "aws" +terraform: + - path: observability/quickwit +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/aws.yaml": awsFeature, + } + + config := map[string]any{ + "provider": "gcp", + } + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.TerraformComponents) != 0 { + t.Errorf("Expected 0 terraform components, got %d", len(handler.blueprint.TerraformComponents)) + } + }) + + t.Run("ProcessComponentLevelConditions", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + observabilityFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: observability +terraform: + - path: observability/quickwit + when: observability.backend == "quickwit" + - path: observability/grafana + when: observability.backend == "grafana" +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/observability.yaml": observabilityFeature, + } + + config := map[string]any{ + "observability": map[string]any{ + "backend": "quickwit", + }, + } + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.TerraformComponents) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(handler.blueprint.TerraformComponents)) + } + if handler.blueprint.TerraformComponents[0].Path != "observability/quickwit" { + t.Errorf("Expected 'observability/quickwit', got '%s'", handler.blueprint.TerraformComponents[0].Path) + } + }) + + t.Run("MergesMultipleMatchingFeatures", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + awsFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +when: provider == "aws" +terraform: + - path: network/vpc +`) + + observabilityFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: observability +when: observability.enabled == true +terraform: + - path: observability/quickwit +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/aws.yaml": awsFeature, + "features/observability.yaml": observabilityFeature, + } + + config := map[string]any{ + "provider": "aws", + "observability": map[string]any{ + "enabled": true, + }, + } + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.TerraformComponents) != 2 { + t.Errorf("Expected 2 terraform components, got %d", len(handler.blueprint.TerraformComponents)) + } + }) + + t.Run("SortsFeaturesDeterministically", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + featureZ := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: z-feature +terraform: + - path: z/module +`) + + featureA := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: a-feature +terraform: + - path: a/module +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/z.yaml": featureZ, + "features/a.yaml": featureA, + } + + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.TerraformComponents) != 2 { + t.Fatalf("Expected 2 terraform components, got %d", len(handler.blueprint.TerraformComponents)) + } + if handler.blueprint.TerraformComponents[0].Path != "a/module" { + t.Errorf("Expected first component 'a/module', got '%s'", handler.blueprint.TerraformComponents[0].Path) + } + if handler.blueprint.TerraformComponents[1].Path != "z/module" { + t.Errorf("Expected second component 'z/module', got '%s'", handler.blueprint.TerraformComponents[1].Path) + } + }) + + t.Run("ProcessesKustomizations", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + fluxFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: flux +when: gitops.enabled == true +kustomize: + - name: flux-system + path: gitops/flux +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/flux.yaml": fluxFeature, + } + + config := map[string]any{ + "gitops": map[string]any{ + "enabled": true, + }, + } + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.Kustomizations) != 1 { + t.Errorf("Expected 1 kustomization, got %d", len(handler.blueprint.Kustomizations)) + } + if handler.blueprint.Kustomizations[0].Name != "flux-system" { + t.Errorf("Expected 'flux-system', got '%s'", handler.blueprint.Kustomizations[0].Name) + } + }) + + t.Run("HandlesNoFeatures", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + } + + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.TerraformComponents) != 0 { + t.Errorf("Expected 0 terraform components, got %d", len(handler.blueprint.TerraformComponents)) + } + }) + + t.Run("HandlesNoBlueprint", func(t *testing.T) { + handler := setup(t) + + awsFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +terraform: + - path: network/vpc +`) + + templateData := map[string][]byte{ + "features/aws.yaml": awsFeature, + } + + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.TerraformComponents) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(handler.blueprint.TerraformComponents)) + } + }) + + t.Run("FailsOnInvalidFeatureCondition", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + badFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: bad-feature +when: invalid syntax === +terraform: + - path: test/module +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/bad.yaml": badFeature, + } + + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err == nil { + t.Error("Expected error for invalid condition, got nil") + } + if !strings.Contains(err.Error(), "failed to evaluate feature condition") { + t.Errorf("Expected condition evaluation error, got %v", err) + } + }) +} diff --git a/pkg/blueprint/blueprint_handler_public_test.go b/pkg/blueprint/blueprint_handler_public_test.go index c5df3164f..d34899bca 100644 --- a/pkg/blueprint/blueprint_handler_public_test.go +++ b/pkg/blueprint/blueprint_handler_public_test.go @@ -3696,3 +3696,696 @@ func TestBaseBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { } }) } + +func TestBaseBlueprintHandler_GetLocalTemplateData(t *testing.T) { + t.Run("CollectsBlueprintAndFeatureFiles", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + + 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) + + if err := os.MkdirAll(featuresDir, 0755); err != nil { + t.Fatalf("Failed to create features directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + blueprintContent := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base-blueprint +`) + if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), blueprintContent, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + awsFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +when: provider == "aws" +`) + if err := os.WriteFile(filepath.Join(featuresDir, "aws.yaml"), awsFeature, 0644); err != nil { + t.Fatalf("Failed to write aws feature: %v", err) + } + + observabilityFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: observability-feature +`) + if err := os.WriteFile(filepath.Join(featuresDir, "observability.yaml"), observabilityFeature, 0644); err != nil { + t.Fatalf("Failed to write observability feature: %v", err) + } + + jsonnetTemplate := []byte(`{ + terraform: { + cluster: { + node_count: 3 + } + } +}`) + if err := os.WriteFile(filepath.Join(templateDir, "terraform.jsonnet"), jsonnetTemplate, 0644); err != nil { + t.Fatalf("Failed to write jsonnet template: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if _, exists := templateData["blueprint"]; !exists { + t.Error("Expected blueprint to be collected") + } + + if _, exists := templateData["features/aws.yaml"]; !exists { + t.Error("Expected features/aws.yaml to be collected") + } + + if _, exists := templateData["features/observability.yaml"]; !exists { + t.Error("Expected features/observability.yaml to be collected") + } + + if _, exists := templateData["terraform.jsonnet"]; !exists { + t.Error("Expected terraform.jsonnet to be collected") + } + + if content, exists := templateData["blueprint"]; exists { + if !strings.Contains(string(content), "base-blueprint") { + t.Errorf("Expected blueprint content to contain 'base-blueprint', got: %s", string(content)) + } + } + + if content, exists := templateData["features/aws.yaml"]; exists { + if !strings.Contains(string(content), "aws-feature") { + t.Errorf("Expected aws feature content to contain 'aws-feature', got: %s", string(content)) + } + } + }) + + t.Run("CollectsNestedFeatures", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + + templateDir := filepath.Join(projectRoot, "contexts", "_template") + nestedFeaturesDir := filepath.Join(templateDir, "features", "aws") + contextDir := filepath.Join(projectRoot, "contexts", contextName) + + if err := os.MkdirAll(nestedFeaturesDir, 0755); err != nil { + t.Fatalf("Failed to create nested features directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + nestedFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-eks-feature +`) + if err := os.WriteFile(filepath.Join(nestedFeaturesDir, "eks.yaml"), nestedFeature, 0644); err != nil { + t.Fatalf("Failed to write nested feature: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if _, exists := templateData["features/aws/eks.yaml"]; !exists { + t.Error("Expected features/aws/eks.yaml to be collected") + } + }) + + t.Run("IgnoresNonYAMLFilesInFeatures", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + + 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) + + if err := os.MkdirAll(featuresDir, 0755); err != nil { + t.Fatalf("Failed to create features directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + validFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: valid-feature +`) + if err := os.WriteFile(filepath.Join(featuresDir, "valid.yaml"), validFeature, 0644); err != nil { + t.Fatalf("Failed to write valid feature: %v", err) + } + + if err := os.WriteFile(filepath.Join(featuresDir, "README.md"), []byte("# Features"), 0644); err != nil { + t.Fatalf("Failed to write README: %v", err) + } + + if err := os.WriteFile(filepath.Join(featuresDir, "config.json"), []byte("{}"), 0644); err != nil { + t.Fatalf("Failed to write JSON: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if _, exists := templateData["features/valid.yaml"]; !exists { + t.Error("Expected features/valid.yaml to be collected") + } + + if _, exists := templateData["features/README.md"]; exists { + t.Error("Did not expect features/README.md to be collected") + } + + if _, exists := templateData["features/config.json"]; exists { + t.Error("Did not expect features/config.json to be collected") + } + }) + + t.Run("ComposesFeaturesByEvaluatingConditions", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "provider": "aws", + "observability": map[string]any{ + "enabled": true, + }, + }, 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) + + if err := os.MkdirAll(featuresDir, 0755); err != nil { + t.Fatalf("Failed to create features directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), baseBlueprint, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + awsFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-feature +when: provider == "aws" +terraform: + - path: network/vpc + values: + cidr: 10.0.0.0/16 +`) + if err := os.WriteFile(filepath.Join(featuresDir, "aws.yaml"), awsFeature, 0644); err != nil { + t.Fatalf("Failed to write aws feature: %v", err) + } + + observabilityFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: observability +when: observability.enabled == true +terraform: + - path: observability/stack +`) + if err := os.WriteFile(filepath.Join(featuresDir, "observability.yaml"), observabilityFeature, 0644); err != nil { + t.Fatalf("Failed to write observability feature: %v", err) + } + + gcpFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: gcp-feature +when: provider == "gcp" +terraform: + - path: gcp/network +`) + if err := os.WriteFile(filepath.Join(featuresDir, "gcp.yaml"), gcpFeature, 0644); err != nil { + t.Fatalf("Failed to write gcp feature: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + composedBlueprint, exists := templateData["blueprint"] + if !exists { + t.Fatal("Expected composed blueprint in templateData") + } + + if !strings.Contains(string(composedBlueprint), "network/vpc") { + t.Error("Expected AWS VPC component to be merged") + } + if !strings.Contains(string(composedBlueprint), "observability/stack") { + t.Error("Expected observability component to be merged") + } + if strings.Contains(string(composedBlueprint), "gcp/network") { + t.Error("Did not expect GCP component to be merged (condition not met)") + } + if !strings.Contains(string(composedBlueprint), contextName) { + t.Errorf("Expected blueprint metadata to include context name '%s'", contextName) + } + }) + + t.Run("SetsMetadataFromContextName", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "production" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + 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) + + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +terraform: + - path: base/module +`) + if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), baseBlueprint, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + composedBlueprint, exists := templateData["blueprint"] + if !exists { + t.Fatal("Expected composed blueprint in templateData") + } + + var blueprint blueprintv1alpha1.Blueprint + if err := yaml.Unmarshal(composedBlueprint, &blueprint); err != nil { + t.Fatalf("Failed to unmarshal blueprint: %v", err) + } + + if blueprint.Metadata.Name != contextName { + t.Errorf("Expected metadata.name = '%s', got '%s'", contextName, blueprint.Metadata.Name) + } + expectedDesc := fmt.Sprintf("Blueprint for %s context", contextName) + if blueprint.Metadata.Description != expectedDesc { + t.Errorf("Expected metadata.description = '%s', got '%s'", expectedDesc, blueprint.Metadata.Description) + } + }) + + t.Run("HandlesSubstitutionValues", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "substitution": map[string]any{ + "domain": "example.com", + "port": 8080, + }, + }, 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) + + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + substitution, exists := templateData["substitution"] + if !exists { + t.Fatal("Expected substitution in templateData") + } + + var subValues map[string]any + if err := yaml.Unmarshal(substitution, &subValues); err != nil { + t.Fatalf("Failed to unmarshal substitution: %v", err) + } + + if subValues["domain"] != "example.com" { + t.Errorf("Expected domain = 'example.com', got '%v'", subValues["domain"]) + } + portVal, ok := subValues["port"] + if !ok { + t.Error("Expected port in substitution values") + } + switch v := portVal.(type) { + case int: + if v != 8080 { + t.Errorf("Expected port = 8080, got %d", v) + } + case uint64: + if v != 8080 { + t.Errorf("Expected port = 8080, got %d", v) + } + default: + t.Errorf("Expected port to be int or uint64, got %T", portVal) + } + }) + + t.Run("ReturnsNilWhenNoTemplateDirectory", func(t *testing.T) { + mocks := setupMocks(t) + 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 { + t.Fatalf("Expected no error, got %v", err) + } + + if templateData != nil { + t.Errorf("Expected nil templateData, got %v", templateData) + } + }) + + t.Run("HandlesEmptyBlueprintWithOnlyFeatures", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "feature": "enabled", + }, 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) + + if err := os.MkdirAll(featuresDir, 0755); err != nil { + t.Fatalf("Failed to create features directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + feature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test-feature +when: feature == "enabled" +terraform: + - path: test/module +`) + if err := os.WriteFile(filepath.Join(featuresDir, "test.yaml"), feature, 0644); err != nil { + t.Fatalf("Failed to write feature: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + composedBlueprint, exists := templateData["blueprint"] + if !exists { + t.Fatal("Expected composed blueprint in templateData") + } + + if !strings.Contains(string(composedBlueprint), "test/module") { + t.Error("Expected feature component to be in blueprint") + } + }) + + t.Run("HandlesKustomizationsInFeatures", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "gitops": map[string]any{ + "enabled": true, + }, + }, 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) + + if err := os.MkdirAll(featuresDir, 0755); err != nil { + t.Fatalf("Failed to create features directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), baseBlueprint, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + fluxFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: flux +when: gitops.enabled == true +kustomize: + - name: flux-system + path: gitops/flux +`) + if err := os.WriteFile(filepath.Join(featuresDir, "flux.yaml"), fluxFeature, 0644); err != nil { + t.Fatalf("Failed to write flux feature: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + composedBlueprint, exists := templateData["blueprint"] + if !exists { + t.Fatal("Expected composed blueprint in templateData") + } + + if !strings.Contains(string(composedBlueprint), "flux-system") { + t.Error("Expected kustomization to be in blueprint") + } + if !strings.Contains(string(composedBlueprint), "gitops/flux") { + t.Error("Expected kustomization path to be in blueprint") + } + }) + + t.Run("SkipsComposedBlueprintWhenEmpty", func(t *testing.T) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + 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) + + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: empty-base +`) + if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), baseBlueprint, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if composedBlueprint, exists := templateData["blueprint"]; exists { + if strings.Contains(string(composedBlueprint), "test-context") { + t.Error("Should not set metadata when blueprint has no components") + } + } + }) +} diff --git a/pkg/config/config_handler.go b/pkg/config/config_handler.go index 256449eb7..b8f4a1be0 100644 --- a/pkg/config/config_handler.go +++ b/pkg/config/config_handler.go @@ -688,13 +688,20 @@ func (c *configHandler) GetSchemaDefaults() (map[string]any, error) { return c.schemaValidator.GetSchemaDefaults() } -// GetContextValues returns merged context values from windsor.yaml (via GetConfig) and values.yaml -// The context config is converted to a map and deep merged with values.yaml, with values.yaml taking precedence +// GetContextValues returns merged context values from schema defaults, windsor.yaml (via GetConfig), and values.yaml +// Merge order: schema defaults (base) -> context config -> values.yaml (highest priority) func (c *configHandler) GetContextValues() (map[string]any, error) { if err := c.ensureValuesYamlLoaded(); err != nil { return nil, err } + result := make(map[string]any) + + schemaDefaults, err := c.GetSchemaDefaults() + if err == nil && schemaDefaults != nil { + result = c.deepMerge(result, schemaDefaults) + } + contextConfig := c.GetConfig() contextData, err := c.shims.YamlMarshal(contextConfig) if err != nil { @@ -706,7 +713,10 @@ func (c *configHandler) GetContextValues() (map[string]any, error) { return nil, fmt.Errorf("error unmarshalling context config to map: %w", err) } - return c.deepMerge(contextMap, c.contextValues), nil + result = c.deepMerge(result, contextMap) + result = c.deepMerge(result, c.contextValues) + + return result, nil } // GenerateContextID generates a random context ID if one doesn't exist diff --git a/pkg/config/config_handler_private_test.go b/pkg/config/config_handler_private_test.go index d5047b83e..bc51b19c4 100644 --- a/pkg/config/config_handler_private_test.go +++ b/pkg/config/config_handler_private_test.go @@ -3797,6 +3797,72 @@ func TestConfigHandler_GetContextValues(t *testing.T) { t.Error("expected custom_value to be in merged values") } }) + + t.Run("IncludesSchemaDefaults", func(t *testing.T) { + handler := setup(t) + + err := handler.Initialize() + if err != nil { + t.Fatalf("failed to initialize handler: %v", err) + } + + h := handler.(*configHandler) + h.context = "test" + h.loaded = true + h.contextValues = map[string]any{ + "override_key": "override_value", + } + + schemaContent := []byte(`{ + "$schema": "https://schemas.windsorcli.dev/blueprint-config/v1alpha1", + "type": "object", + "properties": { + "default_key": { + "type": "string", + "default": "default_value" + }, + "override_key": { + "type": "string", + "default": "default_override" + }, + "nested": { + "type": "object", + "properties": { + "nested_default": { + "type": "string", + "default": "nested_value" + } + } + } + } + }`) + + err = handler.LoadSchemaFromBytes(schemaContent) + if err != nil { + t.Fatalf("failed to load schema: %v", err) + } + + values, err := handler.GetContextValues() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if values["default_key"] != "default_value" { + t.Errorf("expected default_key from schema defaults, got %v", values["default_key"]) + } + + if values["override_key"] != "override_value" { + t.Errorf("expected override_key from values.yaml, got %v", values["override_key"]) + } + + if nested, ok := values["nested"].(map[string]any); ok { + if nested["nested_default"] != "nested_value" { + t.Errorf("expected nested.nested_default from schema, got %v", nested["nested_default"]) + } + } else { + t.Error("expected nested to be a map") + } + }) } func TestConfigHandler_deepMerge(t *testing.T) {