From f77c8c801c458aad269fc41148291978d7608a56 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:25:19 -0500 Subject: [PATCH] fix(blueprint): Include Feature inputs when processing user blueprints Since moving to terraform inputs on Features only, we lost the mechanism for determing template inputs when regenerating terraform module shims `terraform.tfvars` file. As a result, these inputs were lost when running a second `windsor init` or `windsor up`. Now, feature inputs are re-evaluated for all terraform components defined in Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- pkg/composer/blueprint/blueprint_handler.go | 318 +++++++--- .../blueprint_handler_private_test.go | 582 ++++++++++++++++-- .../blueprint_handler_public_test.go | 158 +++++ 3 files changed, 935 insertions(+), 123 deletions(-) diff --git a/pkg/composer/blueprint/blueprint_handler.go b/pkg/composer/blueprint/blueprint_handler.go index aac79eabe..8bdbf0a48 100644 --- a/pkg/composer/blueprint/blueprint_handler.go +++ b/pkg/composer/blueprint/blueprint_handler.go @@ -106,6 +106,9 @@ func (b *BaseBlueprintHandler) LoadBlueprint(blueprintURL ...string) error { if err := b.loadConfig(); err != nil { return fmt.Errorf("failed to load blueprint config: %w", err) } + if err := b.processOCISources(); err != nil { + return fmt.Errorf("failed to process OCI sources from blueprint: %w", err) + } return nil } } @@ -415,7 +418,7 @@ func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) } } - if err := b.processFeatures(templateData, config); err != nil { + if err := b.processFeatures(templateData, config, false); err != nil { return nil, fmt.Errorf("failed to process features: %w", err) } @@ -545,6 +548,23 @@ func (b *BaseBlueprintHandler) resolveBlueprintReference(blueprintURL ...string) // processOCIArtifact processes blueprint data from an OCI artifact. // It loads the schema, gets context values, processes features, and sets the OCI source on components. func (b *BaseBlueprintHandler) processOCIArtifact(templateData map[string][]byte, blueprintRef string) error { + if err := b.processArtifactTemplateData(templateData); err != nil { + return err + } + + ociInfo, _ := artifact.ParseOCIReference(blueprintRef) + if ociInfo != nil { + b.setOCISource(ociInfo) + } + + return nil +} + +// processArtifactTemplateData processes template data from an artifact by loading schema, building feature template data, +// setting it on the feature evaluator, and processing features. This common functionality is shared by processOCIArtifact, +// processLocalArtifact, and processOCISources. +// If sourceName is provided and non-empty, it sets the Source on components and kustomizations from Features that don't have a Source set. +func (b *BaseBlueprintHandler) processArtifactTemplateData(templateData map[string][]byte, sourceName ...string) error { if schemaData, exists := templateData["_template/schema.yaml"]; exists { if err := b.runtime.ConfigHandler.LoadSchemaFromBytes(schemaData); err != nil { return fmt.Errorf("failed to load schema from artifact: %w", err) @@ -572,15 +592,10 @@ func (b *BaseBlueprintHandler) processOCIArtifact(templateData map[string][]byte b.featureEvaluator.SetTemplateData(templateData) - if err := b.processFeatures(featureTemplateData, config); err != nil { + if err := b.processFeatures(featureTemplateData, config, false, sourceName...); err != nil { return fmt.Errorf("failed to process features: %w", err) } - ociInfo, _ := artifact.ParseOCIReference(blueprintRef) - if ociInfo != nil { - b.setOCISource(ociInfo) - } - return nil } @@ -644,6 +659,100 @@ func (b *BaseBlueprintHandler) pullOCISources() error { return nil } +// processOCISources processes all OCI sources listed in the blueprint's Sources section. +// It extracts Features from each OCI artifact and applies their evaluated Inputs to existing components in the blueprint. +// This ensures that Features and their Inputs are processed even when a blueprint.yaml already exists. +// Components are not added from Features; only Inputs are applied to existing components. +func (b *BaseBlueprintHandler) processOCISources() error { + sources := b.getSources() + if len(sources) == 0 { + return nil + } + + if b.artifactBuilder == nil { + return nil + } + + for _, source := range sources { + if !strings.HasPrefix(source.Url, "oci://") { + continue + } + + ociURL := b.buildOCIURLWithRef(source) + + templateData, err := b.artifactBuilder.GetTemplateData(ociURL) + if err != nil { + return fmt.Errorf("failed to get template data from OCI source %s: %w", ociURL, err) + } + + if schemaData, exists := templateData["_template/schema.yaml"]; exists { + if err := b.runtime.ConfigHandler.LoadSchemaFromBytes(schemaData); err != nil { + return fmt.Errorf("failed to load schema from artifact: %w", err) + } + } + + config, err := b.runtime.ConfigHandler.GetContextValues() + if err != nil { + return fmt.Errorf("failed to load context values: %w", err) + } + + featureTemplateData := make(map[string][]byte) + for k, v := range templateData { + if featureKey, ok := strings.CutPrefix(k, "_template/"); ok { + if featureKey == "blueprint.yaml" { + featureTemplateData["blueprint"] = v + } else { + featureTemplateData[featureKey] = v + } + } + } + if blueprintData, exists := templateData["blueprint"]; exists { + featureTemplateData["blueprint"] = blueprintData + } + + b.featureEvaluator.SetTemplateData(templateData) + + if err := b.processFeatures(featureTemplateData, config, true, source.Name); err != nil { + return fmt.Errorf("failed to process OCI source %s: %w", source.Url, err) + } + } + + return nil +} + +// getSourceRef extracts the reference (commit, semver, tag, or branch) from a source in priority order. +func (b *BaseBlueprintHandler) getSourceRef(source blueprintv1alpha1.Source) string { + ref := source.Ref.Commit + if ref == "" { + ref = source.Ref.SemVer + } + if ref == "" { + ref = source.Ref.Tag + } + if ref == "" { + ref = source.Ref.Branch + } + return ref +} + +// buildOCIURLWithRef constructs a full OCI URL from a source, appending the ref as a tag if present and not already in the URL. +func (b *BaseBlueprintHandler) buildOCIURLWithRef(source blueprintv1alpha1.Source) string { + ociURL := source.Url + ref := b.getSourceRef(source) + if ref != "" { + ociPrefix := "oci://" + if strings.HasPrefix(ociURL, ociPrefix) { + urlWithoutProtocol := ociURL[len(ociPrefix):] + if !strings.Contains(urlWithoutProtocol, ":") { + ociURL = ociURL + ":" + ref + } + } else if !strings.Contains(ociURL, ":") { + ociURL = ociURL + ":" + ref + } + } + return ociURL +} + // loadBlueprintConfigOverrides loads blueprint configuration overrides from the config root if they exist. func (b *BaseBlueprintHandler) loadBlueprintConfigOverrides() error { configRoot := b.runtime.ConfigRoot @@ -795,13 +904,16 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir string, templ return nil } -// processFeatures applies blueprint features by evaluating conditional expressions and merging matching feature content into the blueprint. -// It loads the base blueprint from the template data (from canonical or alternate blueprint file keys), unmarshals and merges it. -// Then, it loads all features from template data, sorts them deterministically by feature name, and for each feature that matches -// its condition (`When`), merges its Terraform components and Kustomizations that also match their conditions. Component and kustomization -// inputs, substitutions, and patches are evaluated and processed per strategy, and the resulting objects are merged or replaced in the blueprint -// according to the specified merge strategy. -func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, config map[string]any) error { +// processFeatures evaluates and applies blueprint features by processing conditional logic and merging matching feature content into the target blueprint. +// The method loads the base blueprint from provided template data (supporting both canonical and alternate keys), unmarshals it, and initially merges it with the handler's blueprint. +// It then loads all feature files from the template data, sorting them deterministically by feature name to ensure consistent merge order. +// For each feature whose conditional `When` expression evaluates to true, the function processes its Terraform components and Kustomizations, which may themselves have additional conditional logic. +// Inputs, substitutions, and patches for each component and kustomization are evaluated and applied according to a merge or replace strategy as specified. +// If sourceName is provided and non-empty, it sets the Source field on new components and Kustomizations if not already set from the feature definition. +// If applyOnly is true, features will only apply Inputs and settings to already-existing components in the target blueprint; new resources are not added. +// Merges and substitutions are performed in accordance with each merge strategy, ensuring correct accumulation of substitutions for later use. +// Returns an error if conditional logic fails, unmarshalling fails, or a merge operation encounters an error. +func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, config map[string]any, applyOnly bool, sourceName ...string) error { blueprintData, _ := templateData["_template/blueprint.yaml"] if blueprintData == nil { blueprintData, _ = templateData["blueprint"] @@ -843,6 +955,19 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c return features[i].Metadata.Name < features[j].Metadata.Name }) + // Initialize featureBlueprint only if needed for accumulation + var featureBlueprint *blueprintv1alpha1.Blueprint + featureSubstitutionsByKustomization := make(map[string]map[string]string) + + // Determine the target blueprint for feature application + // In applyOnly mode, we accumulate into featureBlueprint to later selectively apply to b.blueprint + // In normal mode, we apply directly to b.blueprint + targetBlueprint := &b.blueprint + if applyOnly { + featureBlueprint = &blueprintv1alpha1.Blueprint{} + targetBlueprint = featureBlueprint + } + for _, feature := range features { if feature.When != "" { matches, err := evaluator.EvaluateExpression(feature.When, config, feature.Path) @@ -867,6 +992,10 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c component := terraformComponent.TerraformComponent + if len(sourceName) > 0 && sourceName[0] != "" && component.Source == "" { + component.Source = sourceName[0] + } + if len(terraformComponent.Inputs) > 0 { evaluatedInputs, err := evaluator.EvaluateDefaults(terraformComponent.Inputs, config, feature.Path) if err != nil { @@ -881,15 +1010,7 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c } if len(filteredInputs) > 0 { - if component.Inputs == nil { - component.Inputs = make(map[string]any) - } - - if terraformComponent.Strategy == "replace" { - component.Inputs = filteredInputs - } else { - component.Inputs = b.deepMergeMaps(component.Inputs, filteredInputs) - } + component.Inputs = filteredInputs } } else { component.Inputs = nil @@ -901,14 +1022,14 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c } if strategy == "replace" { - if err := b.blueprint.ReplaceTerraformComponent(component); err != nil { + if err := targetBlueprint.ReplaceTerraformComponent(component); err != nil { return fmt.Errorf("failed to replace terraform component: %w", err) } } else { tempBlueprint := &blueprintv1alpha1.Blueprint{ TerraformComponents: []blueprintv1alpha1.TerraformComponent{component}, } - if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { + if err := targetBlueprint.StrategicMerge(tempBlueprint); err != nil { return fmt.Errorf("failed to merge terraform component: %w", err) } } @@ -927,17 +1048,21 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c kustomizationCopy := kustomization.Kustomization - if len(kustomization.Substitutions) > 0 { - if b.featureSubstitutions[kustomizationCopy.Name] == nil { - b.featureSubstitutions[kustomizationCopy.Name] = make(map[string]string) - } + if len(sourceName) > 0 && sourceName[0] != "" && kustomizationCopy.Source == "" { + kustomizationCopy.Source = sourceName[0] + } + if len(kustomization.Substitutions) > 0 { 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) } - maps.Copy(b.featureSubstitutions[kustomizationCopy.Name], evaluatedSubstitutions) + if existingSubs, exists := featureSubstitutionsByKustomization[kustomizationCopy.Name]; exists { + maps.Copy(existingSubs, evaluatedSubstitutions) + } else { + featureSubstitutionsByKustomization[kustomizationCopy.Name] = evaluatedSubstitutions + } } for j := range kustomizationCopy.Patches { @@ -958,20 +1083,84 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c } if strategy == "replace" { - if err := b.blueprint.ReplaceKustomization(kustomizationCopy); err != nil { + if err := targetBlueprint.ReplaceKustomization(kustomizationCopy); err != nil { return fmt.Errorf("failed to replace kustomization: %w", err) } } else { tempBlueprint := &blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{kustomizationCopy}, } - if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { + if err := targetBlueprint.StrategicMerge(tempBlueprint); err != nil { return fmt.Errorf("failed to merge kustomization: %w", err) } } } } + if applyOnly { + featureComponentsByPath := make(map[string]blueprintv1alpha1.TerraformComponent) + for _, c := range featureBlueprint.TerraformComponents { + key := c.Path + if c.Source != "" { + key = c.Source + ":" + c.Path + } + featureComponentsByPath[key] = c + } + + for i := range b.blueprint.TerraformComponents { + component := &b.blueprint.TerraformComponents[i] + key := component.Path + if component.Source != "" { + key = component.Source + ":" + component.Path + } else if len(sourceName) > 0 && sourceName[0] != "" { + key = sourceName[0] + ":" + component.Path + } + + if featureComp, exists := featureComponentsByPath[key]; exists { + if component.Inputs == nil { + component.Inputs = make(map[string]any) + } + component.Inputs = b.deepMergeMaps(featureComp.Inputs, component.Inputs) + + if component.Source == "" && len(sourceName) > 0 && sourceName[0] != "" { + component.Source = sourceName[0] + } + } + } + + featureKustomizationsByName := make(map[string]blueprintv1alpha1.Kustomization) + for _, k := range featureBlueprint.Kustomizations { + featureKustomizationsByName[k.Name] = k + } + + for i := range b.blueprint.Kustomizations { + kustomization := &b.blueprint.Kustomizations[i] + if featureKustom, exists := featureKustomizationsByName[kustomization.Name]; exists { + if len(featureKustom.Patches) > 0 { + kustomization.Patches = append(featureKustom.Patches, kustomization.Patches...) + } + if substitutions, exists := featureSubstitutionsByKustomization[kustomization.Name]; exists { + if b.featureSubstitutions[kustomization.Name] == nil { + b.featureSubstitutions[kustomization.Name] = make(map[string]string) + } + maps.Copy(b.featureSubstitutions[kustomization.Name], substitutions) + } + if kustomization.Source == "" && len(sourceName) > 0 && sourceName[0] != "" { + kustomization.Source = sourceName[0] + } + } + } + + } else { + // Normal Mode: Apply all collected substitutions (components were already merged into b.blueprint via targetBlueprint) + for name, substitutions := range featureSubstitutionsByKustomization { + if b.featureSubstitutions[name] == nil { + b.featureSubstitutions[name] = make(map[string]string) + } + maps.Copy(b.featureSubstitutions[name], substitutions) + } + } + return nil } @@ -1040,21 +1229,16 @@ func (b *BaseBlueprintHandler) resolveComponentSources(blueprint *blueprintv1alp pathPrefix = "terraform" } - ref := source.Ref.Commit - if ref == "" { - ref = source.Ref.SemVer - } - if ref == "" { - ref = source.Ref.Tag - } - if ref == "" { - ref = source.Ref.Branch - } + ref := b.getSourceRef(source) if strings.HasPrefix(source.Url, "oci://") { baseURL := source.Url - if ref != "" && !strings.Contains(baseURL, ":") { - baseURL = baseURL + ":" + ref + if ref != "" { + ociPrefix := "oci://" + urlWithoutProtocol := baseURL[len(ociPrefix):] + if !strings.Contains(urlWithoutProtocol, ":") { + baseURL = baseURL + ":" + ref + } } resolvedComponents[i].Source = baseURL + "//" + pathPrefix + "/" + component.Path } else { @@ -1175,35 +1359,8 @@ func (b *BaseBlueprintHandler) processLocalArtifact(templateData map[string][]by } } - if schemaData, exists := templateData["_template/schema.yaml"]; exists { - if err := b.runtime.ConfigHandler.LoadSchemaFromBytes(schemaData); err != nil { - return fmt.Errorf("failed to load schema from artifact: %w", err) - } - } - - config, err := b.runtime.ConfigHandler.GetContextValues() - if err != nil { - return fmt.Errorf("failed to load context values: %w", err) - } - - featureTemplateData := make(map[string][]byte) - for k, v := range templateData { - if featureKey, ok := strings.CutPrefix(k, "_template/"); ok { - if featureKey == "blueprint.yaml" { - featureTemplateData["blueprint"] = v - } else { - featureTemplateData[featureKey] = v - } - } - } - if blueprintData, exists := templateData["blueprint"]; exists { - featureTemplateData["blueprint"] = blueprintData - } - - b.featureEvaluator.SetTemplateData(templateData) - - if err := b.processFeatures(featureTemplateData, config); err != nil { - return fmt.Errorf("failed to process features: %w", err) + if err := b.processArtifactTemplateData(templateData); err != nil { + return err } fileSource := blueprintv1alpha1.Source{ @@ -1482,9 +1639,10 @@ func (b *BaseBlueprintHandler) mergeLegacySpecialVariables(mergedCommonValues ma } } -// validateValuesForSubstitution checks that all values are valid for Flux post-build variable substitution. -// Permitted types are string, numeric, and boolean. Allows one level of map nesting if all nested values are scalar. -// Slices and nested complex types are not allowed. Returns an error if any value is not a supported type. +// validateValuesForSubstitution validates that the given values map contains only types supported for Flux post-build variable substitution. +// Permitted types are string, numeric, and boolean scalars. A single level of map[string]any is allowed if all nested values are scalar. +// Slices and nested complex types are not allowed. Returns an error describing the first unsupported value encountered, +// or nil if all values are supported. func (b *BaseBlueprintHandler) validateValuesForSubstitution(values map[string]any) error { var validate func(map[string]any, string, int) error validate = func(values map[string]any, parentKey string, depth int) error { @@ -1493,26 +1651,19 @@ func (b *BaseBlueprintHandler) validateValuesForSubstitution(values map[string]a if parentKey != "" { currentKey = parentKey + "." + key } - - // Handle nil values first to avoid panic in reflect.TypeOf if value == nil { return fmt.Errorf("values for post-build substitution cannot contain nil values, key '%s'", currentKey) } - - // Check if the value is a slice using reflection if reflect.TypeOf(value).Kind() == reflect.Slice { return fmt.Errorf("values for post-build substitution cannot contain slices, key '%s' has type %T", currentKey, value) } - switch v := value.(type) { case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: continue case map[string]any: - // Post-build substitution should only allow flat key/value maps, no nesting at all if depth >= 1 { return fmt.Errorf("values for post-build substitution cannot contain nested maps, key '%s' has type %T", currentKey, v) } - // Validate that the nested map only contains scalar values (no further nesting) for nestedKey, nestedValue := range v { nestedCurrentKey := currentKey + "." + nestedKey switch nestedValue.(type) { @@ -1521,7 +1672,6 @@ func (b *BaseBlueprintHandler) validateValuesForSubstitution(values map[string]a case nil: return fmt.Errorf("values for post-build substitution cannot contain nil values, key '%s'", nestedCurrentKey) default: - // Check if it's a slice if reflect.TypeOf(nestedValue).Kind() == reflect.Slice { return fmt.Errorf("values for post-build substitution cannot contain slices, key '%s' has type %T", nestedCurrentKey, nestedValue) } diff --git a/pkg/composer/blueprint/blueprint_handler_private_test.go b/pkg/composer/blueprint/blueprint_handler_private_test.go index bda632c7b..85a96f8cd 100644 --- a/pkg/composer/blueprint/blueprint_handler_private_test.go +++ b/pkg/composer/blueprint/blueprint_handler_private_test.go @@ -695,10 +695,8 @@ func TestBaseBlueprintHandler_resolveComponentSources(t *testing.T) { // When resolving component sources handler.resolveComponentSources(blueprint) - // Then OCI source should be resolved (tag added only if URL doesn't contain ":" after "oci://") - // The code checks if baseURL contains ":", and "oci://registry.example.com/repo" contains ":" from "oci://" - // So tag won't be added unless we use a URL without ":" in the path portion - expectedSource := "oci://registry.example.com/repo//terraform/test-module" + // Then OCI source should be resolved with tag appended (since URL doesn't contain ":" after "oci://") + expectedSource := "oci://registry.example.com/repo:v1.0.0//terraform/test-module" if blueprint.TerraformComponents[0].Source != expectedSource { t.Errorf("Expected source '%s', got '%s'", expectedSource, blueprint.TerraformComponents[0].Source) } @@ -2577,7 +2575,7 @@ terraform: "provider": "aws", } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -2617,7 +2615,7 @@ terraform: "provider": "gcp", } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -2657,7 +2655,7 @@ terraform: "provider": "none", } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -2677,7 +2675,7 @@ terraform: config := map[string]any{} // When processing features - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) // Then it should return an error if err == nil { @@ -2698,7 +2696,7 @@ terraform: config := map[string]any{} // When processing features - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) // Then it should return an error if err == nil { @@ -2731,7 +2729,7 @@ terraform: } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err == nil { t.Fatal("Expected error when EvaluateDefaults fails") @@ -2769,7 +2767,7 @@ terraform: } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err == nil { t.Fatal("Expected error when StrategicMerge fails due to dependency cycle") @@ -2800,7 +2798,7 @@ kustomize: } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err == nil { t.Fatal("Expected error when EvaluateExpression fails for kustomization") @@ -2832,7 +2830,7 @@ kustomize: } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err == nil { t.Fatal("Expected error when evaluateSubstitutions fails") @@ -2872,7 +2870,7 @@ kustomize: } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err == nil { t.Fatal("Expected error when StrategicMerge fails due to dependency cycle") @@ -2901,7 +2899,7 @@ when: invalid expression syntax [[[`) config := map[string]any{} // When processing features - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) // Then it should return an error if err == nil { @@ -2943,7 +2941,7 @@ terraform: }, } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -2996,7 +2994,7 @@ terraform: }, } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3039,7 +3037,7 @@ terraform: config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3085,7 +3083,7 @@ kustomize: }, } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3113,7 +3111,7 @@ metadata: config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3140,7 +3138,7 @@ terraform: config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3175,7 +3173,7 @@ terraform: config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err == nil { t.Error("Expected error for invalid condition, got nil") @@ -3229,7 +3227,7 @@ terraform: }, } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3315,7 +3313,7 @@ terraform: }, } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err == nil { t.Fatal("Expected error for invalid expression, got nil") @@ -3363,7 +3361,7 @@ terraform: config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3430,7 +3428,7 @@ terraform: config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3495,7 +3493,7 @@ kustomize: config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3564,7 +3562,7 @@ kustomize: config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3594,7 +3592,7 @@ metadata: } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3619,12 +3617,12 @@ terraform: key2: "value" `) templateData := map[string][]byte{ - "blueprint.yaml": baseBlueprint, + "_template/blueprint.yaml": baseBlueprint, "_template/features/test.yaml": feature, } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3662,12 +3660,12 @@ terraform: new: "value" `) templateData := map[string][]byte{ - "blueprint.yaml": baseBlueprint, + "_template/blueprint.yaml": baseBlueprint, "_template/features/test.yaml": feature, } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3705,12 +3703,12 @@ kustomize: - new-component `) templateData := map[string][]byte{ - "blueprint.yaml": baseBlueprint, + "_template/blueprint.yaml": baseBlueprint, "_template/features/test.yaml": feature, } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3750,7 +3748,7 @@ kustomize: key: ${value} `) templateData := map[string][]byte{ - "blueprint.yaml": baseBlueprint, + "_template/blueprint.yaml": baseBlueprint, "_template/features/test.yaml": feature, } config := map[string]any{ @@ -3758,7 +3756,7 @@ kustomize: "value": "test-value", } - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err != nil { t.Fatalf("Expected no error, got %v", err) @@ -3796,12 +3794,12 @@ kustomize: - patch: ${invalid expression [[[ `) templateData := map[string][]byte{ - "blueprint.yaml": baseBlueprint, + "_template/blueprint.yaml": baseBlueprint, "_template/features/test.yaml": feature, } config := map[string]any{} - err := handler.processFeatures(templateData, config) + err := handler.processFeatures(templateData, config, false) if err == nil { t.Fatal("Expected error when InterpolateString fails") @@ -5686,3 +5684,509 @@ metadata: } }) } + +func TestBaseBlueprintHandler_getSourceRef(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("ReturnsCommitWhenPresent", func(t *testing.T) { + handler := setup(t) + source := blueprintv1alpha1.Source{ + Ref: blueprintv1alpha1.Reference{ + Commit: "abc123", + SemVer: "1.0.0", + Tag: "v1.0.0", + Branch: "main", + }, + } + + ref := handler.getSourceRef(source) + + if ref != "abc123" { + t.Errorf("Expected commit 'abc123', got: %s", ref) + } + }) + + t.Run("ReturnsSemVerWhenCommitEmpty", func(t *testing.T) { + handler := setup(t) + source := blueprintv1alpha1.Source{ + Ref: blueprintv1alpha1.Reference{ + SemVer: "1.0.0", + Tag: "v1.0.0", + Branch: "main", + }, + } + + ref := handler.getSourceRef(source) + + if ref != "1.0.0" { + t.Errorf("Expected semver '1.0.0', got: %s", ref) + } + }) + + t.Run("ReturnsTagWhenCommitAndSemVerEmpty", func(t *testing.T) { + handler := setup(t) + source := blueprintv1alpha1.Source{ + Ref: blueprintv1alpha1.Reference{ + Tag: "v1.0.0", + Branch: "main", + }, + } + + ref := handler.getSourceRef(source) + + if ref != "v1.0.0" { + t.Errorf("Expected tag 'v1.0.0', got: %s", ref) + } + }) + + t.Run("ReturnsBranchWhenOthersEmpty", func(t *testing.T) { + handler := setup(t) + source := blueprintv1alpha1.Source{ + Ref: blueprintv1alpha1.Reference{ + Branch: "main", + }, + } + + ref := handler.getSourceRef(source) + + if ref != "main" { + t.Errorf("Expected branch 'main', got: %s", ref) + } + }) + + t.Run("ReturnsEmptyWhenAllEmpty", func(t *testing.T) { + handler := setup(t) + source := blueprintv1alpha1.Source{ + Ref: blueprintv1alpha1.Reference{}, + } + + ref := handler.getSourceRef(source) + + if ref != "" { + t.Errorf("Expected empty string, got: %s", ref) + } + }) +} + +func TestBaseBlueprintHandler_buildOCIURLWithRef(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("AppendsRefWhenNotPresent", func(t *testing.T) { + handler := setup(t) + source := blueprintv1alpha1.Source{ + Url: "oci://ghcr.io/test/repo", + Ref: blueprintv1alpha1.Reference{ + Tag: "v1.0.0", + }, + } + + ociURL := handler.buildOCIURLWithRef(source) + + expected := "oci://ghcr.io/test/repo:v1.0.0" + if ociURL != expected { + t.Errorf("Expected %s, got: %s", expected, ociURL) + } + }) + + t.Run("DoesNotAppendRefWhenAlreadyPresent", func(t *testing.T) { + handler := setup(t) + source := blueprintv1alpha1.Source{ + Url: "oci://ghcr.io/test/repo:latest", + Ref: blueprintv1alpha1.Reference{ + Tag: "v1.0.0", + }, + } + + ociURL := handler.buildOCIURLWithRef(source) + + expected := "oci://ghcr.io/test/repo:latest" + if ociURL != expected { + t.Errorf("Expected %s, got: %s", expected, ociURL) + } + }) + + t.Run("ReturnsOriginalURLWhenNoRef", func(t *testing.T) { + handler := setup(t) + source := blueprintv1alpha1.Source{ + Url: "oci://ghcr.io/test/repo", + Ref: blueprintv1alpha1.Reference{}, + } + + ociURL := handler.buildOCIURLWithRef(source) + + expected := "oci://ghcr.io/test/repo" + if ociURL != expected { + t.Errorf("Expected %s, got: %s", expected, ociURL) + } + }) +} + +func TestBaseBlueprintHandler_processOCISources(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler, mocks + } + + t.Run("ProcessesOCISourcesSuccessfully", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{"test": "value"}, nil + } + mocks.ConfigHandler.(*config.MockConfigHandler).LoadSchemaFromBytesFunc = func(data []byte) error { + return nil + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://ghcr.io/test/repo:latest", + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Path: "test", + Source: "oci-source", + Inputs: map[string]any{ + "user_key": "user_value", + "conflict_key": "user_value", + }, + }, + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Source: "oci-source", + Patches: []blueprintv1alpha1.BlueprintPatch{ + {Patch: "user-patch"}, + }, + }, + }, + } + + templateData := map[string][]byte{ + "_template/schema.yaml": []byte("schema: test"), + "_template/features/base.yaml": []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +terraform: + - path: test + source: oci-source + inputs: + feature_key: "feature_value" + conflict_key: "feature_value" +kustomize: + - name: test-kustomization + source: oci-source + patches: + - patch: "feature-patch" + substitutions: + feature_sub: "feature_val"`), + } + + mockArtifactBuilder := handler.artifactBuilder.(*artifact.MockArtifact) + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + if url == "oci://ghcr.io/test/repo:latest" { + return templateData, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + } + + err := handler.processOCISources() + + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Verify Terraform Components + components := handler.blueprint.TerraformComponents + foundComponent := false + for _, component := range components { + if component.Path == "test" { + foundComponent = true + if component.Source != "oci-source" { + t.Errorf("Expected component Source to be 'oci-source', got: %s", component.Source) + } + if component.Inputs == nil { + t.Error("Expected component Inputs to be set") + } else { + if component.Inputs["user_key"] != "user_value" { + t.Errorf("Expected user_key to be 'user_value', got: %v", component.Inputs["user_key"]) + } + if component.Inputs["feature_key"] != "feature_value" { + t.Errorf("Expected feature_key to be 'feature_value', got: %v", component.Inputs["feature_key"]) + } + if component.Inputs["conflict_key"] != "user_value" { + t.Errorf("Expected conflict_key to be 'user_value' (user precedence), got: %v", component.Inputs["conflict_key"]) + } + } + break + } + } + if !foundComponent { + t.Error("Expected to find component 'test' in blueprint") + } + + // Verify Kustomizations + kustomizations := handler.blueprint.Kustomizations + foundKustomization := false + for _, k := range kustomizations { + if k.Name == "test-kustomization" { + foundKustomization = true + // Verify Patches (Feature should be prepended, so [Feature, User]) + if len(k.Patches) != 2 { + t.Errorf("Expected 2 patches, got %d", len(k.Patches)) + } else { + if k.Patches[0].Patch != "feature-patch" { + t.Errorf("Expected first patch to be 'feature-patch', got '%s'", k.Patches[0].Patch) + } + if k.Patches[1].Patch != "user-patch" { + t.Errorf("Expected second patch to be 'user-patch', got '%s'", k.Patches[1].Patch) + } + } + + // Verify Substitutions in handler map (since they are not written to blueprint kustomization object directly in this flow) + subs := handler.featureSubstitutions["test-kustomization"] + if subs == nil { + t.Error("Expected feature substitutions to be present") + } else { + if subs["feature_sub"] != "feature_val" { + t.Errorf("Expected feature_sub to be 'feature_val', got '%s'", subs["feature_sub"]) + } + } + break + } + } + if !foundKustomization { + t.Error("Expected to find kustomization 'test-kustomization' in blueprint") + } + }) + + t.Run("SkipsNonOCISources", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "git-source", + Url: "git::https://example.com/repo.git", + }, + }, + } + + mockArtifactBuilder := handler.artifactBuilder.(*artifact.MockArtifact) + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + t.Error("GetTemplateData should not be called for non-OCI sources") + return nil, nil + } + + err := handler.processOCISources() + + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + }) + + t.Run("ReturnsNilWhenNoSources", func(t *testing.T) { + handler, _ := setup(t) + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{}, + } + + err := handler.processOCISources() + + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + }) + + t.Run("ReturnsNilWhenArtifactBuilderNil", func(t *testing.T) { + handler, _ := setup(t) + handler.artifactBuilder = nil + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://ghcr.io/test/repo:latest", + }, + }, + } + + err := handler.processOCISources() + + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + }) + + t.Run("HandlesGetTemplateDataError", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://ghcr.io/test/repo:latest", + }, + }, + } + + expectedError := fmt.Errorf("template data error") + mockArtifactBuilder := handler.artifactBuilder.(*artifact.MockArtifact) + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + return nil, expectedError + } + + err := handler.processOCISources() + + if err == nil { + t.Fatal("Expected error when GetTemplateData fails") + } + if !strings.Contains(err.Error(), "failed to get template data from OCI source") { + t.Errorf("Expected error about getting template data, got: %v", err) + } + }) + + t.Run("HandlesProcessArtifactTemplateDataError", func(t *testing.T) { + handler, mocks := setup(t) + expectedError := fmt.Errorf("context values error") + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return nil, expectedError + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://ghcr.io/test/repo:latest", + }, + }, + } + + mockArtifactBuilder := handler.artifactBuilder.(*artifact.MockArtifact) + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + return map[string][]byte{}, nil + } + + err := handler.processOCISources() + + if err == nil { + t.Fatal("Expected error when processing OCI source fails") + } + if !strings.Contains(err.Error(), "failed to load context values") && !strings.Contains(err.Error(), "failed to process OCI source") { + t.Errorf("Expected error about loading context values or processing OCI source, got: %v", err) + } + }) + + t.Run("ProcessesMultipleOCISources", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + mocks.ConfigHandler.(*config.MockConfigHandler).LoadSchemaFromBytesFunc = func(data []byte) error { + return nil + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source-1", + Url: "oci://ghcr.io/test/repo1:latest", + }, + { + Name: "oci-source-2", + Url: "oci://ghcr.io/test/repo2:v1.0.0", + }, + }, + } + + callCount := 0 + mockArtifactBuilder := handler.artifactBuilder.(*artifact.MockArtifact) + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + callCount++ + return map[string][]byte{}, nil + } + + err := handler.processOCISources() + + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if callCount != 2 { + t.Errorf("Expected GetTemplateData to be called 2 times, got: %d", callCount) + } + }) + + t.Run("AppendsRefToOCIURL", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + mocks.ConfigHandler.(*config.MockConfigHandler).LoadSchemaFromBytesFunc = func(data []byte) error { + return nil + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://ghcr.io/test/repo", + Ref: blueprintv1alpha1.Reference{ + Tag: "v1.0.0", + }, + }, + }, + } + + var calledURL string + mockArtifactBuilder := handler.artifactBuilder.(*artifact.MockArtifact) + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + calledURL = url + return map[string][]byte{}, nil + } + + err := handler.processOCISources() + + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + expectedURL := "oci://ghcr.io/test/repo:v1.0.0" + if calledURL != expectedURL { + t.Errorf("Expected URL %s, got: %s", expectedURL, calledURL) + } + }) +} diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index f2cb7f704..72265cc2b 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -2114,6 +2114,164 @@ metadata: } }) + t.Run("ProcessesOCISourcesWhenBlueprintYamlExists", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + blueprintPath := filepath.Join(mocks.Runtime.ConfigRoot, "blueprint.yaml") + blueprintContent := `apiVersion: v1alpha1 +kind: Blueprint +metadata: + name: test-blueprint +sources: + - name: oci-source + url: oci://ghcr.io/test/repo:latest +terraformComponents: [] +kustomizations: [] +` + if err := os.WriteFile(blueprintPath, []byte(blueprintContent), 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return nil, os.ErrNotExist + } + if path == blueprintPath { + return &mockFileInfo{name: "blueprint.yaml", isDir: false}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + if path == blueprintPath { + return []byte(blueprintContent), nil + } + return nil, os.ErrNotExist + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{"test": "value"}, nil + } + mocks.ConfigHandler.(*config.MockConfigHandler).LoadSchemaFromBytesFunc = func(data []byte) error { + return nil + } + + templateData := map[string][]byte{ + "_template/schema.yaml": []byte("schema: test"), + "_template/features/base.yaml": []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +terraform: + - path: test-component + inputs: + test_input: "test_value"`), + } + + callCount := 0 + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + callCount++ + if url == "oci://ghcr.io/test/repo:latest" { + return templateData, nil + } + return nil, fmt.Errorf("unexpected URL: %s", url) + } + + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return yaml.Unmarshal(data, v) + } + + err = handler.LoadBlueprint() + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if callCount == 0 { + t.Error("Expected GetTemplateData to be called to process OCI sources") + } + }) + + t.Run("HandlesProcessOCISourcesErrorWhenBlueprintYamlExists", func(t *testing.T) { + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + mocks.Runtime.ConfigRoot = tmpDir + + blueprintPath := filepath.Join(mocks.Runtime.ConfigRoot, "blueprint.yaml") + blueprintContent := `apiVersion: v1alpha1 +kind: Blueprint +metadata: + name: test-blueprint +sources: + - name: oci-source + url: oci://ghcr.io/test/repo:latest +terraformComponents: [] +kustomizations: [] +` + if err := os.WriteFile(blueprintPath, []byte(blueprintContent), 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == mocks.Runtime.TemplateRoot { + return nil, os.ErrNotExist + } + if path == blueprintPath { + return &mockFileInfo{name: "blueprint.yaml", isDir: false}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + if path == blueprintPath { + return []byte(blueprintContent), nil + } + return nil, os.ErrNotExist + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + + expectedError := fmt.Errorf("template data error") + mockArtifactBuilder.GetTemplateDataFunc = func(url string) (map[string][]byte, error) { + return nil, expectedError + } + + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return yaml.Unmarshal(data, v) + } + + err = handler.LoadBlueprint() + + if err == nil { + t.Fatal("Expected error when processOCISourcesFromBlueprint fails") + } + if !strings.Contains(err.Error(), "failed to process OCI sources from blueprint") { + t.Errorf("Expected error about processing OCI sources, got: %v", err) + } + }) + t.Run("HandlesGetTemplateDataErrorWhenNoLocalBlueprint", func(t *testing.T) { mocks := setupBlueprintMocks(t) mockArtifactBuilder := artifact.NewMockArtifact()