From f155974161cbc78ddf040f5c4cc8065f971935ae Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sun, 22 Jun 2025 11:43:42 -0400 Subject: [PATCH 1/4] Template contexts from _template folder --- cmd/init.go | 27 +- pkg/blueprint/blueprint_handler.go | 850 ++--- .../blueprint_handler_helper_test.go | 8 +- .../blueprint_handler_private_test.go | 984 ++++- pkg/blueprint/blueprint_handler_test.go | 3287 ++++++++++++----- pkg/blueprint/mock_blueprint_handler.go | 73 +- pkg/blueprint/mock_blueprint_handler_test.go | 226 -- pkg/blueprint/shims.go | 13 + pkg/controller/controller.go | 9 - pkg/controller/controller_test.go | 33 - pkg/env/terraform_env.go | 16 +- 11 files changed, 3749 insertions(+), 1777 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 72ad246ae..81251412d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -89,14 +89,19 @@ var initCmd = &cobra.Command{ } // Set the default configuration if applicable - defaultConfig := &config.DefaultConfig - if vmDriverConfig == "docker-desktop" { - defaultConfig = &config.DefaultConfig_Localhost - } else if vmDriverConfig == "colima" || vmDriverConfig == "docker" { - defaultConfig = &config.DefaultConfig_Full - } - if err := configHandler.SetDefault(*defaultConfig); err != nil { - return fmt.Errorf("Error setting default config: %w", err) + switch vmDriverConfig { + case "docker-desktop": + if err := configHandler.SetDefault(config.DefaultConfig_Localhost); err != nil { + return fmt.Errorf("Error setting default config: %w", err) + } + case "colima", "docker": + if err := configHandler.SetDefault(config.DefaultConfig_Full); err != nil { + return fmt.Errorf("Error setting default config: %w", err) + } + default: + if err := configHandler.SetDefault(config.DefaultConfig); err != nil { + return fmt.Errorf("Error setting default config: %w", err) + } } // Create the flag to config path mapping and set the configurations @@ -223,6 +228,12 @@ var initCmd = &cobra.Command{ return fmt.Errorf("Error initializing: %w", err) } + // Process context templates if they exist (after blueprint handler is initialized) + blueprintHandler := controller.ResolveBlueprintHandler() + if err := blueprintHandler.ProcessContextTemplates(contextName, reset); err != nil { + return fmt.Errorf("Error processing context templates: %w", err) + } + // Set the environment variables internally in the process if err := controller.SetEnvironmentVariables(); err != nil { return fmt.Errorf("Error setting environment variables: %w", err) diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index cb26f30b5..e8d480506 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -37,18 +37,13 @@ import ( type BlueprintHandler interface { Initialize() error LoadConfig(reset ...bool) error - WriteConfig(overwrite ...bool) error Install() error GetMetadata() blueprintv1alpha1.Metadata GetSources() []blueprintv1alpha1.Source GetRepository() blueprintv1alpha1.Repository GetTerraformComponents() []blueprintv1alpha1.TerraformComponent - SetMetadata(metadata blueprintv1alpha1.Metadata) error - SetSources(sources []blueprintv1alpha1.Source) error - SetRepository(repository blueprintv1alpha1.Repository) error - SetTerraformComponents(terraformComponents []blueprintv1alpha1.TerraformComponent) error - SetKustomizations(kustomizations []blueprintv1alpha1.Kustomization) error WaitForKustomizations(message string, names ...string) error + ProcessContextTemplates(contextName string, reset ...bool) error Down() error } @@ -128,170 +123,30 @@ func (b *BaseBlueprintHandler) Initialize() error { return nil } -// LoadConfig reads blueprint configuration from specified path or default location. -// Priority: blueprint.yaml (if !reset), blueprint.jsonnet, platform template, default. -// Processes Jsonnet templates with context data injection for dynamic configuration. -// Falls back to embedded defaults if no configuration files exist. +// LoadConfig reads blueprint configuration from blueprint.yaml file. +// Only loads existing blueprint.yaml files - no templating or generation. +// All template processing happens in ProcessContextTemplates during init. func (b *BaseBlueprintHandler) LoadConfig(reset ...bool) error { - shouldReset := false - if len(reset) > 0 { - shouldReset = reset[0] - } - configRoot, err := b.configHandler.GetConfigRoot() if err != nil { return fmt.Errorf("error getting config root: %w", err) } - basePath := filepath.Join(configRoot, "blueprint") - yamlPath := basePath + ".yaml" - jsonnetPath := basePath + ".jsonnet" - - if !shouldReset { - // 1. blueprint.yaml - if _, err := b.shims.Stat(yamlPath); err == nil { - yamlData, err := b.shims.ReadFile(yamlPath) - if err != nil { - return err - } - if err := b.processBlueprintData(yamlData, &b.blueprint); err != nil { - return err - } - return nil - } - } - - // 2. blueprint.jsonnet - if _, err := b.shims.Stat(jsonnetPath); err == nil { - jsonnetData, err := b.shims.ReadFile(jsonnetPath) - if err != nil { - return err - } - config := b.configHandler.GetConfig() - contextYAML, err := b.yamlMarshalWithDefinedPaths(config) - if err != nil { - return fmt.Errorf("error marshalling context to YAML: %w", err) - } - var contextMap map[string]any = make(map[string]any) - if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { - return fmt.Errorf("error unmarshalling context YAML: %w", err) - } + yamlPath := filepath.Join(configRoot, "blueprint.yaml") + if _, err := b.shims.Stat(yamlPath); err != nil { + // No blueprint.yaml exists - use default blueprint context := b.configHandler.GetContext() - contextMap["name"] = context - contextJSON, err := b.shims.JsonMarshal(contextMap) - if err != nil { - return fmt.Errorf("error marshalling context map to JSON: %w", err) - } - vm := b.shims.NewJsonnetVM() - vm.ExtCode("context", string(contextJSON)) - evaluatedJsonnet, err := vm.EvaluateAnonymousSnippet("blueprint.jsonnet", string(jsonnetData)) - if err != nil { - return fmt.Errorf("error generating blueprint from jsonnet: %w", err) - } - if err := b.processBlueprintData([]byte(evaluatedJsonnet), &b.blueprint); err != nil { - return err - } - return nil - } - - // 3. internal default (platform-specific if available, else global default) - platform := "" - if b.configHandler.GetConfig().Cluster != nil && b.configHandler.GetConfig().Cluster.Platform != nil { - platform = *b.configHandler.GetConfig().Cluster.Platform - } - var platformData []byte - if platform != "" { - platformData, err = b.loadPlatformTemplate(platform) - if err != nil { - return fmt.Errorf("error loading platform template: %w", err) - } - } - var evaluatedJsonnet string - config := b.configHandler.GetConfig() - contextYAML, err := b.yamlMarshalWithDefinedPaths(config) - if err != nil { - return fmt.Errorf("error marshalling context to YAML: %w", err) - } - var contextMap map[string]any = make(map[string]any) - if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { - return fmt.Errorf("error unmarshalling context YAML: %w", err) - } - context := b.configHandler.GetContext() - contextMap["name"] = context - contextJSON, err := b.shims.JsonMarshal(contextMap) - if err != nil { - return fmt.Errorf("error marshalling context map to JSON: %w", err) - } - vm := b.shims.NewJsonnetVM() - vm.ExtCode("context", string(contextJSON)) - if len(platformData) > 0 { - evaluatedJsonnet, err = vm.EvaluateAnonymousSnippet("blueprint.jsonnet", string(platformData)) - if err != nil { - return fmt.Errorf("error generating blueprint from jsonnet: %w", err) - } - } else { - evaluatedJsonnet, err = vm.EvaluateAnonymousSnippet("default.jsonnet", defaultJsonnetTemplate) - if err != nil { - return fmt.Errorf("error generating blueprint from default jsonnet: %w", err) - } - } - if evaluatedJsonnet == "" { b.blueprint = *DefaultBlueprint.DeepCopy() b.blueprint.Metadata.Name = context b.blueprint.Metadata.Description = fmt.Sprintf("This blueprint outlines resources in the %s context", context) - } else { - if err := b.processBlueprintData([]byte(evaluatedJsonnet), &b.blueprint); err != nil { - return err - } - } - return nil -} - -// WriteConfig persists the current blueprint configuration to disk. It handles path resolution, -// directory creation, and writes the blueprint in YAML format. The function cleans sensitive or -// redundant data before writing, such as Terraform component variables/values and empty PostBuild configs. -func (b *BaseBlueprintHandler) WriteConfig(overwrite ...bool) error { - shouldOverwrite := false - if len(overwrite) > 0 { - shouldOverwrite = overwrite[0] - } - - configRoot, err := b.configHandler.GetConfigRoot() - if err != nil { - return fmt.Errorf("error getting config root: %w", err) - } - - finalPath := filepath.Join(configRoot, "blueprint.yaml") - dir := filepath.Dir(finalPath) - if err := b.shims.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("error creating directory: %w", err) - } - - if !shouldOverwrite { - if _, err := b.shims.Stat(finalPath); err == nil { - return nil - } + return nil } - fullBlueprint := b.blueprint.DeepCopy() - for i := range fullBlueprint.TerraformComponents { - fullBlueprint.TerraformComponents[i].Values = nil - } - for i := range fullBlueprint.Kustomizations { - postBuild := fullBlueprint.Kustomizations[i].PostBuild - if postBuild != nil && len(postBuild.Substitute) == 0 && len(postBuild.SubstituteFrom) == 0 { - fullBlueprint.Kustomizations[i].PostBuild = nil - } - } - fullBlueprint.Merge(&b.localBlueprint) - data, err := b.shims.YamlMarshalNonNull(fullBlueprint) + yamlData, err := b.shims.ReadFile(yamlPath) if err != nil { - return fmt.Errorf("error marshalling yaml: %w", err) + return err } - if err := b.shims.WriteFile(finalPath, data, 0644); err != nil { - return fmt.Errorf("error writing blueprint file: %w", err) - } - return nil + return b.processBlueprintData(yamlData, &b.blueprint) } // WaitForKustomizations waits for the specified kustomizations to be ready. @@ -302,9 +157,9 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st spin.Start() defer spin.Stop() - timeout := time.After(b.calculateMaxWaitTime()) - ticker := time.NewTicker(constants.DEFAULT_KUSTOMIZATION_WAIT_POLL_INTERVAL) - defer ticker.Stop() + timeout := b.shims.TimeAfter(b.calculateMaxWaitTime()) + ticker := b.shims.NewTicker(constants.DEFAULT_KUSTOMIZATION_WAIT_POLL_INTERVAL) + defer b.shims.TickerStop(ticker) var kustomizationNames []string if len(names) > 0 && len(names[0]) > 0 { @@ -317,7 +172,19 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st } } + // Check immediately before starting polling loop + ready, err := b.checkKustomizationStatus(kustomizationNames) + if err == nil && ready { + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[32m✔\033[0m%s - \033[32mDone\033[0m\n", spin.Suffix) + return nil + } + consecutiveFailures := 0 + if err != nil { + consecutiveFailures = 1 + } + for { select { case <-timeout: @@ -325,35 +192,18 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st fmt.Fprintf(os.Stderr, "✗%s - \033[31mFailed\033[0m\n", spin.Suffix) return fmt.Errorf("timeout waiting for kustomizations") case <-ticker.C: - if err := b.kubernetesManager.CheckGitRepositoryStatus(); err != nil { - consecutiveFailures++ - if consecutiveFailures >= constants.DEFAULT_KUSTOMIZATION_WAIT_MAX_FAILURES { - spin.Stop() - fmt.Fprintf(os.Stderr, "✗%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("git repository error after %d consecutive failures: %w", consecutiveFailures, err) - } - continue - } - status, err := b.kubernetesManager.GetKustomizationStatus(kustomizationNames) + ready, err := b.checkKustomizationStatus(kustomizationNames) if err != nil { consecutiveFailures++ if consecutiveFailures >= constants.DEFAULT_KUSTOMIZATION_WAIT_MAX_FAILURES { spin.Stop() fmt.Fprintf(os.Stderr, "✗%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("kustomization error after %d consecutive failures: %w", consecutiveFailures, err) + return fmt.Errorf("%s after %d consecutive failures", err.Error(), consecutiveFailures) } continue } - allReady := true - for _, ready := range status { - if !ready { - allReady = false - break - } - } - - if allReady { + if ready { spin.Stop() fmt.Fprintf(os.Stderr, "\033[32m✔\033[0m%s - \033[32mDone\033[0m\n", spin.Suffix) return nil @@ -416,7 +266,7 @@ func (b *BaseBlueprintHandler) Install() error { kustomizations := b.getKustomizations() kustomizationNames := make([]string, len(kustomizations)) for i, k := range kustomizations { - if err := b.kubernetesManager.ApplyKustomization(b.ToKubernetesKustomization(k, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE)); err != nil { + if err := b.kubernetesManager.ApplyKustomization(b.toKubernetesKustomization(k, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE)); err != nil { spin.Stop() fmt.Fprintf(os.Stderr, "✗%s - \033[31mFailed\033[0m\n", spin.Suffix) return fmt.Errorf("failed to apply kustomization %s: %w", k.Name, err) @@ -469,98 +319,6 @@ func (b *BaseBlueprintHandler) GetTerraformComponents() []blueprintv1alpha1.Terr return resolvedBlueprint.TerraformComponents } -// getKustomizations retrieves and normalizes the blueprint's Kustomization configurations. -// It provides default values for intervals, timeouts, and paths while ensuring consistent -// configuration across all kustomizations. The function also adds standard PostBuild -// configurations for variable substitution from the blueprint ConfigMap. -func (b *BaseBlueprintHandler) getKustomizations() []blueprintv1alpha1.Kustomization { - if b.blueprint.Kustomizations == nil { - return nil - } - - resolvedBlueprint := b.blueprint - kustomizations := make([]blueprintv1alpha1.Kustomization, len(resolvedBlueprint.Kustomizations)) - copy(kustomizations, resolvedBlueprint.Kustomizations) - - for i := range kustomizations { - if kustomizations[i].Source == "" { - kustomizations[i].Source = b.blueprint.Metadata.Name - } - - if kustomizations[i].Path == "" { - kustomizations[i].Path = "kustomize" - } else { - kustomizations[i].Path = "kustomize/" + strings.ReplaceAll(kustomizations[i].Path, "\\", "/") - } - - if kustomizations[i].Interval == nil || kustomizations[i].Interval.Duration == 0 { - kustomizations[i].Interval = &metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL} - } - if kustomizations[i].RetryInterval == nil || kustomizations[i].RetryInterval.Duration == 0 { - kustomizations[i].RetryInterval = &metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_RETRY_INTERVAL} - } - if kustomizations[i].Timeout == nil || kustomizations[i].Timeout.Duration == 0 { - kustomizations[i].Timeout = &metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT} - } - if kustomizations[i].Wait == nil { - defaultWait := constants.DEFAULT_FLUX_KUSTOMIZATION_WAIT - kustomizations[i].Wait = &defaultWait - } - if kustomizations[i].Force == nil { - defaultForce := constants.DEFAULT_FLUX_KUSTOMIZATION_FORCE - kustomizations[i].Force = &defaultForce - } - - kustomizations[i].PostBuild = &blueprintv1alpha1.PostBuild{ - SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ - { - Kind: "ConfigMap", - Name: "blueprint", - Optional: false, - }, - }, - } - } - - return kustomizations -} - -// SetMetadata updates the metadata for the current blueprint. -// It replaces the existing metadata with the provided metadata information. -func (b *BaseBlueprintHandler) SetMetadata(metadata blueprintv1alpha1.Metadata) error { - b.blueprint.Metadata = metadata - return nil -} - -// SetRepository updates the repository for the current blueprint. -// It replaces the existing repository with the provided repository information. -func (b *BaseBlueprintHandler) SetRepository(repository blueprintv1alpha1.Repository) error { - b.blueprint.Repository = repository - return nil -} - -// SetSources updates the source configurations for the current blueprint. -// It replaces the existing sources with the provided list of sources. -func (b *BaseBlueprintHandler) SetSources(sources []blueprintv1alpha1.Source) error { - b.blueprint.Sources = sources - return nil -} - -// SetTerraformComponents updates the Terraform components for the current blueprint. -// It replaces the existing components with the provided list of Terraform components. -func (b *BaseBlueprintHandler) SetTerraformComponents(terraformComponents []blueprintv1alpha1.TerraformComponent) error { - b.blueprint.TerraformComponents = terraformComponents - return nil -} - -// SetKustomizations updates the Kustomizations for the current blueprint. -// It replaces the existing Kustomizations with the provided list of Kustomizations. -// If the provided list is nil, it clears the existing Kustomizations. -func (b *BaseBlueprintHandler) SetKustomizations(kustomizations []blueprintv1alpha1.Kustomization) error { - b.blueprint.Kustomizations = kustomizations - return nil -} - // Down orchestrates the controlled teardown of all kustomizations and their associated resources. // It follows a specific sequence to ensure safe deletion: // 1. Suspends all kustomizations and their associated helmreleases to prevent reconciliation @@ -672,7 +430,7 @@ func (b *BaseBlueprintHandler) Down() error { SubstituteFrom: []blueprintv1alpha1.SubstituteReference{}, }, } - if err := b.kubernetesManager.ApplyKustomization(b.ToKubernetesKustomization(*cleanupKustomization, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE)); err != nil { + if err := b.kubernetesManager.ApplyKustomization(b.toKubernetesKustomization(*cleanupKustomization, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE)); err != nil { spin.Stop() fmt.Fprintf(os.Stderr, "✗%s - \033[31mFailed\033[0m\n", spin.Suffix) return fmt.Errorf("failed to apply cleanup kustomization for %s: %w", k.Name, err) @@ -724,6 +482,121 @@ func (b *BaseBlueprintHandler) Down() error { return nil } +// ProcessContextTemplates processes jsonnet templates from the contexts/_template directory +// and generates corresponding files in the specified context directory. The function handles +// three scenarios: +// 1. Template Processing: If contexts/_template exists, recursively processes all .jsonnet files, +// evaluating them with context data and writing output files with appropriate extensions +// (.yaml for general files, .tfvars for terraform/ subdirectories) +// 2. Platform Template Processing: If no template directory exists but a platform is configured, +// loads and processes the platform-specific jsonnet template +// 3. Default Blueprint Generation: Falls back to generating a default blueprint.yaml using +// either the embedded default jsonnet template or the hardcoded DefaultBlueprint +func (b *BaseBlueprintHandler) ProcessContextTemplates(contextName string, reset ...bool) error { + resetMode := len(reset) > 0 && reset[0] + // === Setup === + projectRoot, err := b.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("error getting project root: %w", err) + } + + contextDir := filepath.Join(projectRoot, "contexts", contextName) + if err := b.shims.MkdirAll(contextDir, 0755); err != nil { + return fmt.Errorf("error creating context directory: %w", err) + } + + // === Template Processing === + templateDir := filepath.Join(projectRoot, "contexts", "_template") + if _, err := b.shims.Stat(templateDir); err == nil { + var walkDir func(string) error + walkDir = func(currentDir string) error { + entries, err := b.shims.ReadDir(currentDir) + if err != nil { + return fmt.Errorf("error reading template directory %s: %w", currentDir, err) + } + + for _, entry := range entries { + fullPath := filepath.Join(currentDir, entry.Name()) + + if entry.IsDir() { + if err := walkDir(fullPath); err != nil { + return err + } + } else if strings.HasSuffix(entry.Name(), ".jsonnet") { + if err := b.processJsonnetTemplate(templateDir, fullPath, contextDir, resetMode); err != nil { + return fmt.Errorf("error processing template %s: %w", fullPath, err) + } + } + } + return nil + } + + if err := walkDir(templateDir); err != nil { + return err + } + } else { + // === Default Blueprint Generation === + blueprintPath := filepath.Join(contextDir, "blueprint.yaml") + if _, err := b.shims.Stat(blueprintPath); err != nil || resetMode { + // === Platform Template Loading === + platform := b.configHandler.GetString("cluster.platform") + templateData, err := b.loadPlatformTemplate(platform) + if err != nil || len(templateData) == 0 { + templateData, err = b.loadPlatformTemplate("default") + if err != nil { + return fmt.Errorf("error loading default template: %w", err) + } + } + + // === Blueprint Data Generation === + var blueprintData []byte + if len(templateData) > 0 { + config := b.configHandler.GetConfig() + contextYAML, err := b.yamlMarshalWithDefinedPaths(config) + if err != nil { + return fmt.Errorf("error marshalling context to YAML: %w", err) + } + var contextMap map[string]any = make(map[string]any) + if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { + return fmt.Errorf("error unmarshalling context YAML: %w", err) + } + contextMap["name"] = contextName + contextJSON, err := b.shims.JsonMarshal(contextMap) + if err != nil { + return fmt.Errorf("error marshalling context map to JSON: %w", err) + } + vm := b.shims.NewJsonnetVM() + vm.ExtCode("context", string(contextJSON)) + evaluatedJsonnet, err := vm.EvaluateAnonymousSnippet("blueprint.jsonnet", string(templateData)) + if err != nil { + return fmt.Errorf("error generating blueprint from jsonnet: %w", err) + } + if evaluatedJsonnet != "" { + blueprintData = []byte(evaluatedJsonnet) + } + } + + // === Fallback Blueprint Creation === + if len(blueprintData) == 0 { + blueprint := *DefaultBlueprint.DeepCopy() + blueprint.Metadata.Name = contextName + blueprint.Metadata.Description = fmt.Sprintf("This blueprint outlines resources in the %s context", contextName) + blueprintData, err = b.shims.YamlMarshal(blueprint) + if err != nil { + return fmt.Errorf("error marshalling default blueprint: %w", err) + } + } + + // === Blueprint File Write === + if err := b.shims.WriteFile(blueprintPath, blueprintData, 0644); err != nil { + return fmt.Errorf("error writing blueprint file: %w", err) + } + } + } + + return nil +} + // ============================================================================= // Private Methods // ============================================================================= @@ -853,150 +726,81 @@ func (b *BaseBlueprintHandler) isValidTerraformRemoteSource(source string) bool return false } -// loadPlatformTemplate loads a platform-specific template if one exists -func (b *BaseBlueprintHandler) loadPlatformTemplate(platform string) ([]byte, error) { - if platform == "" { - return nil, nil +// getKustomizations retrieves and normalizes the blueprint's Kustomization configurations. +// It provides default values for intervals, timeouts, and paths while ensuring consistent +// configuration across all kustomizations. The function also adds standard PostBuild +// configurations for variable substitution from the blueprint ConfigMap. +func (b *BaseBlueprintHandler) getKustomizations() []blueprintv1alpha1.Kustomization { + if b.blueprint.Kustomizations == nil { + return nil } - switch platform { - case "local": - return []byte(localJsonnetTemplate), nil - case "metal": - return []byte(metalJsonnetTemplate), nil - case "aws": - return []byte(awsJsonnetTemplate), nil - case "azure": - return []byte(azureJsonnetTemplate), nil - default: - return nil, nil - } -} + resolvedBlueprint := b.blueprint + kustomizations := make([]blueprintv1alpha1.Kustomization, len(resolvedBlueprint.Kustomizations)) + copy(kustomizations, resolvedBlueprint.Kustomizations) -// yamlMarshalWithDefinedPaths marshals data to YAML format while ensuring all parent paths are defined. -// It handles various Go types including structs, maps, slices, and primitive types, preserving YAML -// tags and properly representing nil values. -func (b *BaseBlueprintHandler) yamlMarshalWithDefinedPaths(v any) ([]byte, error) { - if v == nil { - return nil, fmt.Errorf("invalid input: nil value") - } + for i := range kustomizations { + if kustomizations[i].Source == "" { + kustomizations[i].Source = b.blueprint.Metadata.Name + } - var convert func(reflect.Value) (any, error) - convert = func(val reflect.Value) (any, error) { - switch val.Kind() { - case reflect.Ptr, reflect.Interface: - if val.IsNil() { - if val.Kind() == reflect.Interface || (val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct) { - return make(map[string]any), nil - } - return nil, nil - } - return convert(val.Elem()) - case reflect.Struct: - result := make(map[string]any) - typ := val.Type() - for i := range make([]int, val.NumField()) { - fieldValue := val.Field(i) - fieldType := typ.Field(i) - - if fieldType.PkgPath != "" { - continue - } - - yamlTag := strings.Split(fieldType.Tag.Get("yaml"), ",")[0] - if yamlTag == "-" { - continue - } - if yamlTag == "" { - yamlTag = fieldType.Name - } - - fieldInterface, err := convert(fieldValue) - if err != nil { - return nil, fmt.Errorf("error converting field %s: %w", fieldType.Name, err) - } - if fieldInterface != nil || fieldType.Type.Kind() == reflect.Interface || fieldType.Type.Kind() == reflect.Slice || fieldType.Type.Kind() == reflect.Map || fieldType.Type.Kind() == reflect.Struct { - result[yamlTag] = fieldInterface - } - } - return result, nil - case reflect.Slice, reflect.Array: - if val.Len() == 0 { - return []any{}, nil - } - slice := make([]any, val.Len()) - for i := 0; i < val.Len(); i++ { - elemVal := val.Index(i) - if elemVal.Kind() == reflect.Ptr || elemVal.Kind() == reflect.Interface { - if elemVal.IsNil() { - slice[i] = nil - continue - } - } - elemInterface, err := convert(elemVal) - if err != nil { - return nil, fmt.Errorf("error converting slice element at index %d: %w", i, err) - } - slice[i] = elemInterface - } - return slice, nil - case reflect.Map: - result := make(map[string]any) - for _, key := range val.MapKeys() { - keyStr := fmt.Sprintf("%v", key.Interface()) - elemVal := val.MapIndex(key) - if elemVal.Kind() == reflect.Interface && elemVal.IsNil() { - result[keyStr] = nil - continue - } - elemInterface, err := convert(elemVal) - if err != nil { - return nil, fmt.Errorf("error converting map value for key %s: %w", keyStr, err) - } - if elemInterface != nil || elemVal.Kind() == reflect.Interface || elemVal.Kind() == reflect.Slice || elemVal.Kind() == reflect.Map || elemVal.Kind() == reflect.Struct { - result[keyStr] = elemInterface - } - } - return result, nil - case reflect.String: - return val.String(), nil - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return val.Int(), nil - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return val.Uint(), nil - case reflect.Float32, reflect.Float64: - return val.Float(), nil - case reflect.Bool: - return val.Bool(), nil - default: - return nil, fmt.Errorf("unsupported value type %s", val.Kind()) + if kustomizations[i].Path == "" { + kustomizations[i].Path = "kustomize" + } else { + kustomizations[i].Path = "kustomize/" + strings.ReplaceAll(kustomizations[i].Path, "\\", "/") } - } - val := reflect.ValueOf(v) - if val.Kind() == reflect.Func { - return nil, fmt.Errorf("unsupported value type func") - } - - processed, err := convert(val) - if err != nil { - return nil, err - } + if kustomizations[i].Interval == nil || kustomizations[i].Interval.Duration == 0 { + kustomizations[i].Interval = &metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL} + } + if kustomizations[i].RetryInterval == nil || kustomizations[i].RetryInterval.Duration == 0 { + kustomizations[i].RetryInterval = &metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_RETRY_INTERVAL} + } + if kustomizations[i].Timeout == nil || kustomizations[i].Timeout.Duration == 0 { + kustomizations[i].Timeout = &metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT} + } + if kustomizations[i].Wait == nil { + defaultWait := constants.DEFAULT_FLUX_KUSTOMIZATION_WAIT + kustomizations[i].Wait = &defaultWait + } + if kustomizations[i].Force == nil { + defaultForce := constants.DEFAULT_FLUX_KUSTOMIZATION_FORCE + kustomizations[i].Force = &defaultForce + } - yamlData, err := b.shims.YamlMarshal(processed) - if err != nil { - return nil, fmt.Errorf("error marshalling yaml: %w", err) + kustomizations[i].PostBuild = &blueprintv1alpha1.PostBuild{ + SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: "blueprint", + Optional: false, + }, + }, + } } - return yamlData, nil -} - -func (b *BaseBlueprintHandler) createManagedNamespace(name string) error { - return b.kubernetesManager.CreateNamespace(name) + return kustomizations } -func (b *BaseBlueprintHandler) deleteNamespace(name string) error { - return b.kubernetesManager.DeleteNamespace(name) +// loadPlatformTemplate loads a platform-specific template or the default template +func (b *BaseBlueprintHandler) loadPlatformTemplate(platform string) ([]byte, error) { + switch platform { + case "local": + return []byte(localJsonnetTemplate), nil + case "metal": + return []byte(metalJsonnetTemplate), nil + case "aws": + return []byte(awsJsonnetTemplate), nil + case "azure": + return []byte(azureJsonnetTemplate), nil + case "default": + return []byte(defaultJsonnetTemplate), nil + default: + if platform == "" { + return []byte(defaultJsonnetTemplate), nil + } + return nil, nil + } } // applyGitRepository creates or updates a GitRepository resource in the cluster. It normalizes @@ -1077,9 +881,36 @@ func (b *BaseBlueprintHandler) applyConfigMap() error { return b.kubernetesManager.ApplyConfigMap("blueprint", constants.DEFAULT_FLUX_SYSTEM_NAMESPACE, data) } +// checkKustomizationStatus verifies the readiness of specified kustomizations by first checking +// the git repository status and then polling each kustomization's status. Returns true if all +// kustomizations are ready, false otherwise, along with any errors encountered during the checks. +func (b *BaseBlueprintHandler) checkKustomizationStatus(kustomizationNames []string) (bool, error) { + if err := b.kubernetesManager.CheckGitRepositoryStatus(); err != nil { + return false, fmt.Errorf("git repository error: %w", err) + } + status, err := b.kubernetesManager.GetKustomizationStatus(kustomizationNames) + if err != nil { + return false, fmt.Errorf("kustomization error: %w", err) + } + + allReady := true + for _, ready := range status { + if !ready { + allReady = false + break + } + } + return allReady, nil +} + // calculateMaxWaitTime calculates the maximum wait time needed based on kustomization dependencies. -// It builds a dependency graph and uses DFS to find the longest path through it, accumulating -// timeouts for each kustomization in the path. Returns the total time needed for the longest path. +// It builds a dependency graph from all kustomizations, mapping each to its dependencies and timeouts. +// Using depth-first search (DFS), it explores all possible dependency paths to find the longest one, +// accumulating timeout values along each path. The function handles circular dependencies by tracking +// visited nodes and avoiding infinite recursion while still considering their timeout contributions. +// It identifies root nodes (those with no incoming dependencies) as starting points, or if no roots +// exist due to cycles, it starts DFS from every node to ensure complete coverage. Returns the total +// time needed for the longest dependency path through the kustomization graph. func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { kustomizations := b.getKustomizations() if len(kustomizations) == 0 { @@ -1115,8 +946,6 @@ func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { if !visited[dep] { dfs(dep, currentTime) } else { - // For circular dependencies, we still want to consider the path - // but we don't want to recurse further if currentTime+timeouts[dep] > maxPathTime { maxPathTime = currentTime + timeouts[dep] } @@ -1127,7 +956,6 @@ func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { path = path[:len(path)-1] } - // Start DFS from each root node (nodes with no incoming dependencies) roots := []string{} for _, k := range kustomizations { isRoot := true @@ -1142,7 +970,6 @@ func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { } } if len(roots) == 0 { - // No roots found (cycle or all nodes have dependencies), start from every node for _, k := range kustomizations { dfs(k.Name, 0) } @@ -1155,11 +982,137 @@ func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { return maxPathTime } -// ToKubernetesKustomization converts a blueprint kustomization to a Flux kustomization +// yamlMarshalWithDefinedPaths marshals data to YAML format while ensuring all parent paths are defined. +// It handles various Go types including structs, maps, slices, and primitive types, preserving YAML +// tags and properly representing nil values. +func (b *BaseBlueprintHandler) yamlMarshalWithDefinedPaths(v any) ([]byte, error) { + if v == nil { + return nil, fmt.Errorf("invalid input: nil value") + } + + var convert func(reflect.Value) (any, error) + convert = func(val reflect.Value) (any, error) { + switch val.Kind() { + case reflect.Ptr, reflect.Interface: + if val.IsNil() { + if val.Kind() == reflect.Interface || (val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct) { + return make(map[string]any), nil + } + return nil, nil + } + return convert(val.Elem()) + case reflect.Struct: + result := make(map[string]any) + typ := val.Type() + for i := range make([]int, val.NumField()) { + fieldValue := val.Field(i) + fieldType := typ.Field(i) + + if fieldType.PkgPath != "" { + continue + } + + yamlTag := strings.Split(fieldType.Tag.Get("yaml"), ",")[0] + if yamlTag == "-" { + continue + } + if yamlTag == "" { + yamlTag = fieldType.Name + } + + fieldInterface, err := convert(fieldValue) + if err != nil { + return nil, fmt.Errorf("error converting field %s: %w", fieldType.Name, err) + } + if fieldInterface != nil || fieldType.Type.Kind() == reflect.Interface || fieldType.Type.Kind() == reflect.Slice || fieldType.Type.Kind() == reflect.Map || fieldType.Type.Kind() == reflect.Struct { + result[yamlTag] = fieldInterface + } + } + return result, nil + case reflect.Slice, reflect.Array: + if val.Len() == 0 { + return []any{}, nil + } + slice := make([]any, val.Len()) + for i := 0; i < val.Len(); i++ { + elemVal := val.Index(i) + if elemVal.Kind() == reflect.Ptr || elemVal.Kind() == reflect.Interface { + if elemVal.IsNil() { + slice[i] = nil + continue + } + } + elemInterface, err := convert(elemVal) + if err != nil { + return nil, fmt.Errorf("error converting slice element at index %d: %w", i, err) + } + slice[i] = elemInterface + } + return slice, nil + case reflect.Map: + result := make(map[string]any) + for _, key := range val.MapKeys() { + keyStr := fmt.Sprintf("%v", key.Interface()) + elemVal := val.MapIndex(key) + if elemVal.Kind() == reflect.Interface && elemVal.IsNil() { + result[keyStr] = nil + continue + } + elemInterface, err := convert(elemVal) + if err != nil { + return nil, fmt.Errorf("error converting map value for key %s: %w", keyStr, err) + } + if elemInterface != nil || elemVal.Kind() == reflect.Interface || elemVal.Kind() == reflect.Slice || elemVal.Kind() == reflect.Map || elemVal.Kind() == reflect.Struct { + result[keyStr] = elemInterface + } + } + return result, nil + case reflect.String: + return val.String(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return val.Int(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return val.Uint(), nil + case reflect.Float32, reflect.Float64: + return val.Float(), nil + case reflect.Bool: + return val.Bool(), nil + default: + return nil, fmt.Errorf("unsupported value type %s", val.Kind()) + } + } + + val := reflect.ValueOf(v) + if val.Kind() == reflect.Func { + return nil, fmt.Errorf("unsupported value type func") + } + + processed, err := convert(val) + if err != nil { + return nil, err + } + + yamlData, err := b.shims.YamlMarshal(processed) + if err != nil { + return nil, fmt.Errorf("error marshalling yaml: %w", err) + } + + return yamlData, nil +} + +func (b *BaseBlueprintHandler) createManagedNamespace(name string) error { + return b.kubernetesManager.CreateNamespace(name) +} + +func (b *BaseBlueprintHandler) deleteNamespace(name string) error { + return b.kubernetesManager.DeleteNamespace(name) +} + +// toKubernetesKustomization converts a blueprint kustomization to a Flux kustomization // It handles conversion of dependsOn, patches, and postBuild configurations // It maps blueprint fields to their Flux kustomization equivalents // It maintains namespace context and preserves all configuration options -func (b *BaseBlueprintHandler) ToKubernetesKustomization(k blueprintv1alpha1.Kustomization, namespace string) kustomizev1.Kustomization { +func (b *BaseBlueprintHandler) toKubernetesKustomization(k blueprintv1alpha1.Kustomization, namespace string) kustomizev1.Kustomization { dependsOn := make([]meta.NamespacedObjectReference, len(k.DependsOn)) for i, dep := range k.DependsOn { dependsOn[i] = meta.NamespacedObjectReference{ @@ -1232,3 +1185,72 @@ func (b *BaseBlueprintHandler) ToKubernetesKustomization(k blueprintv1alpha1.Kus }, } } + +// processJsonnetTemplate reads a jsonnet template file, evaluates it with context data, +// and writes the processed output to the appropriate location with correct file extension. +// It handles path resolution, context marshalling, jsonnet evaluation, output path determination +// based on file location and naming conventions, and conditional file writing based on reset mode. +func (b *BaseBlueprintHandler) processJsonnetTemplate(templateDir, templateFile, contextDir string, resetMode bool) error { + jsonnetData, err := b.shims.ReadFile(templateFile) + if err != nil { + return fmt.Errorf("error reading template file: %w", err) + } + + config := b.configHandler.GetConfig() + contextYAML, err := b.yamlMarshalWithDefinedPaths(config) + if err != nil { + return fmt.Errorf("error marshalling context to YAML: %w", err) + } + + var contextMap map[string]any = make(map[string]any) + if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { + return fmt.Errorf("error unmarshalling context YAML: %w", err) + } + + context := b.configHandler.GetContext() + contextMap["name"] = context + contextJSON, err := b.shims.JsonMarshal(contextMap) + if err != nil { + return fmt.Errorf("error marshalling context map to JSON: %w", err) + } + + vm := b.shims.NewJsonnetVM() + vm.ExtCode("context", string(contextJSON)) + evaluatedContent, err := vm.EvaluateAnonymousSnippet(templateFile, string(jsonnetData)) + if err != nil { + return fmt.Errorf("error evaluating jsonnet template: %w", err) + } + + relPath, err := filepath.Rel(templateDir, templateFile) + if err != nil { + return fmt.Errorf("error getting relative path: %w", err) + } + + outputName := strings.TrimSuffix(relPath, ".jsonnet") + outputPath := filepath.Join(contextDir, outputName) + + if strings.Contains(outputName, "blueprint") { + outputPath += ".yaml" + } else if strings.Contains(relPath, "terraform/") { + outputPath += ".tfvars" + } else { + outputPath += ".yaml" + } + + if !resetMode { + if _, err := b.shims.Stat(outputPath); err == nil { + return nil + } + } + + outputDir := filepath.Dir(outputPath) + if err := b.shims.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("error creating output directory: %w", err) + } + + if err := b.shims.WriteFile(outputPath, []byte(evaluatedContent), 0644); err != nil { + return fmt.Errorf("error writing output file: %w", err) + } + + return nil +} diff --git a/pkg/blueprint/blueprint_handler_helper_test.go b/pkg/blueprint/blueprint_handler_helper_test.go index b72fdea90..0394ba7ac 100644 --- a/pkg/blueprint/blueprint_handler_helper_test.go +++ b/pkg/blueprint/blueprint_handler_helper_test.go @@ -878,7 +878,7 @@ func TestBaseBlueprintHandler_loadPlatformTemplate(t *testing.T) { handler := &BaseBlueprintHandler{} // When loading templates for valid platforms - platforms := []string{"local", "metal", "aws", "azure"} + platforms := []string{"local", "metal", "aws", "azure", "default"} for _, platform := range platforms { // Then the template should be loaded successfully template, err := handler.loadPlatformTemplate(platform) @@ -914,12 +914,12 @@ func TestBaseBlueprintHandler_loadPlatformTemplate(t *testing.T) { // When loading template with empty platform template, err := handler.loadPlatformTemplate("") - // Then no error should occur and template should be empty + // Then no error should occur and template should contain default template if err != nil { t.Errorf("Expected no error for empty platform, got: %v", err) } - if len(template) != 0 { - t.Errorf("Expected empty template for empty platform, got length: %d", len(template)) + if len(template) == 0 { + t.Errorf("Expected default template for empty platform, got empty template") } }) } diff --git a/pkg/blueprint/blueprint_handler_private_test.go b/pkg/blueprint/blueprint_handler_private_test.go index bea95e23e..9768e184d 100644 --- a/pkg/blueprint/blueprint_handler_private_test.go +++ b/pkg/blueprint/blueprint_handler_private_test.go @@ -2,19 +2,160 @@ package blueprint import ( "fmt" + "os" "path/filepath" "strings" "testing" + "time" + kustomize "github.com/fluxcd/pkg/apis/kustomize" sourcev1 "github.com/fluxcd/source-controller/api/v1" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/kubernetes" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// mockFileInfo implements os.FileInfo for testing +type mockFileInfo struct { + name string +} + +func (m mockFileInfo) Name() string { return m.name } +func (m mockFileInfo) Size() int64 { return 0 } +func (m mockFileInfo) Mode() os.FileMode { return 0644 } +func (m mockFileInfo) ModTime() time.Time { return time.Time{} } +func (m mockFileInfo) IsDir() bool { return false } +func (m mockFileInfo) Sys() interface{} { return nil } + // ============================================================================= // Test Private Methods // ============================================================================= +func TestBaseBlueprintHandler_isValidTerraformRemoteSource(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + return handler, mocks + } + + t.Run("ValidGitHTTPS", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // When checking a valid git HTTPS source + source := "git::https://github.com/example/repo.git" + valid := handler.isValidTerraformRemoteSource(source) + + // Then it should be valid + if !valid { + t.Errorf("Expected %s to be valid, got invalid", source) + } + }) + + t.Run("ValidGitSSH", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // When checking a valid git SSH source + source := "git@github.com:example/repo.git" + valid := handler.isValidTerraformRemoteSource(source) + + // Then it should be valid + if !valid { + t.Errorf("Expected %s to be valid, got invalid", source) + } + }) + + t.Run("ValidHTTPS", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // When checking a valid HTTPS source + source := "https://github.com/example/repo.git" + valid := handler.isValidTerraformRemoteSource(source) + + // Then it should be valid + if !valid { + t.Errorf("Expected %s to be valid, got invalid", source) + } + }) + + t.Run("ValidZip", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // When checking a valid ZIP source + source := "https://github.com/example/repo/archive/main.zip" + valid := handler.isValidTerraformRemoteSource(source) + + // Then it should be valid + if !valid { + t.Errorf("Expected %s to be valid, got invalid", source) + } + }) + + t.Run("ValidRegistry", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // When checking a valid registry source + source := "registry.terraform.io/example/module" + valid := handler.isValidTerraformRemoteSource(source) + + // Then it should be valid + if !valid { + t.Errorf("Expected %s to be valid, got invalid", source) + } + }) + + t.Run("ValidCustomDomain", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // When checking a valid custom domain source + source := "example.com/module" + valid := handler.isValidTerraformRemoteSource(source) + + // Then it should be valid + if !valid { + t.Errorf("Expected %s to be valid, got invalid", source) + } + }) + + t.Run("InvalidSource", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // When checking an invalid source + source := "invalid-source" + valid := handler.isValidTerraformRemoteSource(source) + + // Then it should be invalid + if valid { + t.Errorf("Expected %s to be invalid, got valid", source) + } + }) + + t.Run("InvalidRegex", func(t *testing.T) { + // Given a blueprint handler with a mock that returns error + handler, mocks := setup(t) + mocks.Shims.RegexpMatchString = func(pattern, s string) (bool, error) { + return false, fmt.Errorf("mock regex error") + } + + // When checking a source with regex error + source := "git::https://github.com/example/repo.git" + valid := handler.isValidTerraformRemoteSource(source) + + // Then it should be invalid + if valid { + t.Errorf("Expected %s to be invalid with regex error, got valid", source) + } + }) +} + func TestBlueprintHandler_resolveComponentSources(t *testing.T) { setup := func(t *testing.T) (BlueprintHandler, *Mocks) { t.Helper() @@ -38,12 +179,11 @@ func TestBlueprintHandler_resolveComponentSources(t *testing.T) { } handler.(*BaseBlueprintHandler).kubernetesManager = mockK8sManager - err := handler.SetRepository(blueprintv1alpha1.Repository{ + // Set repository and sources directly on the blueprint + baseHandler := handler.(*BaseBlueprintHandler) + baseHandler.blueprint.Repository = blueprintv1alpha1.Repository{ Url: "git::https://example.com/repo.git", Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) } expectedSources := []blueprintv1alpha1.Source{ @@ -53,7 +193,7 @@ func TestBlueprintHandler_resolveComponentSources(t *testing.T) { Ref: blueprintv1alpha1.Reference{Branch: "main"}, }, } - handler.SetSources(expectedSources) + baseHandler.blueprint.Sources = expectedSources // When resolving component sources handler.(*BaseBlueprintHandler).resolveComponentSources(&handler.(*BaseBlueprintHandler).blueprint) @@ -70,12 +210,11 @@ func TestBlueprintHandler_resolveComponentSources(t *testing.T) { } handler.(*BaseBlueprintHandler).kubernetesManager = mockK8sManager - err := handler.SetRepository(blueprintv1alpha1.Repository{ + // Set repository and sources directly on the blueprint + baseHandler := handler.(*BaseBlueprintHandler) + baseHandler.blueprint.Repository = blueprintv1alpha1.Repository{ Url: "git::https://example.com/repo.git", Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) } expectedSources := []blueprintv1alpha1.Source{ @@ -85,7 +224,7 @@ func TestBlueprintHandler_resolveComponentSources(t *testing.T) { Ref: blueprintv1alpha1.Reference{Branch: "main"}, }, } - handler.SetSources(expectedSources) + baseHandler.blueprint.Sources = expectedSources // When resolving component sources handler.(*BaseBlueprintHandler).resolveComponentSources(&handler.(*BaseBlueprintHandler).blueprint) @@ -102,12 +241,11 @@ func TestBlueprintHandler_resolveComponentSources(t *testing.T) { } handler.(*BaseBlueprintHandler).kubernetesManager = mockK8sManager - err := handler.SetRepository(blueprintv1alpha1.Repository{ + // Set repository and sources directly on the blueprint + baseHandler := handler.(*BaseBlueprintHandler) + baseHandler.blueprint.Repository = blueprintv1alpha1.Repository{ Url: "git::https://example.com/repo.git", Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) } expectedSources := []blueprintv1alpha1.Source{ @@ -118,7 +256,7 @@ func TestBlueprintHandler_resolveComponentSources(t *testing.T) { SecretName: "git-credentials", }, } - handler.SetSources(expectedSources) + baseHandler.blueprint.Sources = expectedSources // When resolving component sources handler.(*BaseBlueprintHandler).resolveComponentSources(&handler.(*BaseBlueprintHandler).blueprint) @@ -149,7 +287,7 @@ func TestBlueprintHandler_resolveComponentPaths(t *testing.T) { Path: "path/to/code", }, } - handler.SetTerraformComponents(expectedComponents) + baseHandler.blueprint.TerraformComponents = expectedComponents // When resolving component paths blueprint := baseHandler.blueprint.DeepCopy() @@ -208,18 +346,18 @@ func TestBlueprintHandler_resolveComponentPaths(t *testing.T) { baseHandler := handler.(*BaseBlueprintHandler) // And a source with URL and path prefix - handler.SetSources([]blueprintv1alpha1.Source{{ + baseHandler.blueprint.Sources = []blueprintv1alpha1.Source{{ Name: "test-source", Url: "https://github.com/user/repo.git", PathPrefix: "terraform", Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }}) + }} // And a terraform component referencing that source - handler.SetTerraformComponents([]blueprintv1alpha1.TerraformComponent{{ + baseHandler.blueprint.TerraformComponents = []blueprintv1alpha1.TerraformComponent{{ Source: "test-source", Path: "module/path", - }}) + }} // When resolving component sources and paths blueprint := baseHandler.blueprint.DeepCopy() @@ -580,3 +718,809 @@ metadata: } }) } + +func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("ErrorReadingTemplateFile", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile returns an error + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return nil, fmt.Errorf("read file error") + } + + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("Expected 'error reading template file' in error, got: %v", err) + } + }) + + t.Run("ErrorMarshallingContextToYAML", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds but YamlMarshal fails + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("yaml marshal error") + } + + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error marshalling context to YAML") { + t.Errorf("Expected 'error marshalling context to YAML' in error, got: %v", err) + } + }) + + t.Run("ErrorUnmarshallingContextYAML", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds but YamlUnmarshal fails + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("yaml unmarshal error") + } + + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error unmarshalling context YAML") { + t.Errorf("Expected 'error unmarshalling context YAML' in error, got: %v", err) + } + }) + + t.Run("ErrorMarshallingContextMapToJSON", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds but JsonMarshal fails + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("json marshal error") + } + + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error marshalling context map to JSON") { + t.Errorf("Expected 'error marshalling context map to JSON' in error, got: %v", err) + } + }) + + t.Run("ErrorEvaluatingJsonnetTemplate", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds but jsonnet evaluation fails + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + mocks.Shims.NewJsonnetVM = func() JsonnetVM { + return NewMockJsonnetVM(func(filename, snippet string) (string, error) { + return "", fmt.Errorf("jsonnet evaluation error") + }) + } + + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error evaluating jsonnet template") { + t.Errorf("Expected 'error evaluating jsonnet template' in error, got: %v", err) + } + }) + + t.Run("ErrorGettingRelativePath", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + + // When calling processJsonnetTemplate with invalid paths + err := handler.processJsonnetTemplate("", "/template/test.jsonnet", "/context", false) + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error getting relative path") { + t.Errorf("Expected 'error getting relative path' in error, got: %v", err) + } + }) + + t.Run("BlueprintFileExtension", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + + // And output file doesn't exist + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + var writtenPath string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writtenPath = name + return nil + } + + // When calling processJsonnetTemplate with blueprint file + err := handler.processJsonnetTemplate("/template", "/template/blueprint.jsonnet", "/context", false) + + // Then no error should occur + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And the output path should have .yaml extension + if !strings.HasSuffix(writtenPath, "blueprint.yaml") { + t.Errorf("Expected blueprint.yaml extension, got: %s", writtenPath) + } + }) + + t.Run("TerraformFileExtension", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + + // And output file doesn't exist + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + var writtenPath string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writtenPath = name + return nil + } + + // When calling processJsonnetTemplate with terraform file + err := handler.processJsonnetTemplate("/template", "/template/terraform/main.jsonnet", "/context", false) + + // Then no error should occur + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And the output path should have .tfvars extension + if !strings.HasSuffix(writtenPath, "main.tfvars") { + t.Errorf("Expected main.tfvars extension, got: %s", writtenPath) + } + }) + + t.Run("DefaultYamlFileExtension", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + + // And output file doesn't exist + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + var writtenPath string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writtenPath = name + return nil + } + + // When calling processJsonnetTemplate with regular file + err := handler.processJsonnetTemplate("/template", "/template/config.jsonnet", "/context", false) + + // Then no error should occur + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And the output path should have .yaml extension + if !strings.HasSuffix(writtenPath, "config.yaml") { + t.Errorf("Expected config.yaml extension, got: %s", writtenPath) + } + }) + + t.Run("SkipsExistingFileWithoutReset", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + + // And output file already exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".yaml") { + return mockFileInfo{name: "test.yaml"}, nil + } + return nil, os.ErrNotExist + } + + writeFileCalled := false + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writeFileCalled = true + return nil + } + + // When calling processJsonnetTemplate without reset + err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + + // Then no error should occur + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And WriteFile should not be called + if writeFileCalled { + t.Error("WriteFile should not be called when file exists and reset is false") + } + }) + + t.Run("OverwritesExistingFileWithReset", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + + // And output file already exists + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".yaml") { + return mockFileInfo{name: "test.yaml"}, nil + } + return nil, os.ErrNotExist + } + + writeFileCalled := false + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writeFileCalled = true + return nil + } + + // When calling processJsonnetTemplate with reset + err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", true) + + // Then no error should occur + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And WriteFile should be called + if !writeFileCalled { + t.Error("WriteFile should be called when reset is true") + } + }) + + t.Run("ErrorCreatingOutputDirectory", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + + // And MkdirAll fails + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return fmt.Errorf("mkdir error") + } + + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template", "/template/subdir/test.jsonnet", "/context", false) + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error creating output directory") { + t.Errorf("Expected 'error creating output directory' in error, got: %v", err) + } + }) + + t.Run("ErrorWritingOutputFile", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("{}"), nil + } + + // And WriteFile fails + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write file error") + } + + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error writing output file") { + t.Errorf("Expected 'error writing output file' in error, got: %v", err) + } + }) + + t.Run("SuccessfulProcessing", func(t *testing.T) { + // Given a blueprint handler with mocked dependencies + handler, mocks := setup(t) + + // And ReadFile succeeds + templateContent := `{ + kind: "Blueprint", + metadata: { + name: std.extVar("context").name + } + }` + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte(templateContent), nil + } + + // And output file doesn't exist + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And jsonnet evaluation returns content + mocks.Shims.NewJsonnetVM = func() JsonnetVM { + return NewMockJsonnetVM(func(filename, snippet string) (string, error) { + return `{"kind": "Blueprint", "metadata": {"name": "test-context"}}`, nil + }) + } + + var writtenContent []byte + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writtenContent = data + return nil + } + + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template", "/template/blueprint.jsonnet", "/context", false) + + // Then no error should occur + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And the content should be processed + if len(writtenContent) == 0 { + t.Error("Expected content to be written") + } + }) +} + +func TestBlueprintHandler_toKubernetesKustomization(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("BasicConversion", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And a basic blueprint kustomization + blueprintKustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Source: "test-source", + Path: "test/path", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{true}[0], + Wait: &[]bool{false}[0], + } + + // When converting to kubernetes kustomization + result := handler.toKubernetesKustomization(blueprintKustomization, "test-namespace") + + // Then the basic fields should be correctly mapped + if result.Name != "test-kustomization" { + t.Errorf("Expected name to be test-kustomization, got %s", result.Name) + } + if result.Namespace != "test-namespace" { + t.Errorf("Expected namespace to be test-namespace, got %s", result.Namespace) + } + if result.Spec.SourceRef.Name != "test-source" { + t.Errorf("Expected source name to be test-source, got %s", result.Spec.SourceRef.Name) + } + if result.Spec.SourceRef.Kind != "GitRepository" { + t.Errorf("Expected source kind to be GitRepository, got %s", result.Spec.SourceRef.Kind) + } + if result.Spec.Path != "test/path" { + t.Errorf("Expected path to be test/path, got %s", result.Spec.Path) + } + if result.Spec.Interval.Duration != 5*time.Minute { + t.Errorf("Expected interval to be 5m, got %v", result.Spec.Interval.Duration) + } + if result.Spec.RetryInterval.Duration != 1*time.Minute { + t.Errorf("Expected retry interval to be 1m, got %v", result.Spec.RetryInterval.Duration) + } + if result.Spec.Timeout.Duration != 10*time.Minute { + t.Errorf("Expected timeout to be 10m, got %v", result.Spec.Timeout.Duration) + } + if result.Spec.Force != true { + t.Errorf("Expected force to be true, got %v", result.Spec.Force) + } + if result.Spec.Wait != false { + t.Errorf("Expected wait to be false, got %v", result.Spec.Wait) + } + if result.Spec.Prune != true { + t.Errorf("Expected prune to be true (default), got %v", result.Spec.Prune) + } + }) + + t.Run("WithPatches", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And a kustomization with patches + blueprintKustomization := blueprintv1alpha1.Kustomization{ + Name: "patched-kustomization", + Source: "test-source", + Path: "test/path", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{true}[0], + Patches: []kustomize.Patch{ + { + Patch: "patch content 1", + Target: &kustomize.Selector{ + Kind: "Deployment", + Name: "app-deployment", + }, + }, + { + Patch: "patch content 2", + Target: &kustomize.Selector{ + Kind: "Service", + Name: "app-service", + }, + }, + }, + } + + // When converting to kubernetes kustomization + result := handler.toKubernetesKustomization(blueprintKustomization, "test-namespace") + + // Then the patches should be correctly mapped + if len(result.Spec.Patches) != 2 { + t.Errorf("Expected 2 patches, got %d", len(result.Spec.Patches)) + } + if result.Spec.Patches[0].Patch != "patch content 1" { + t.Errorf("Expected first patch content to be 'patch content 1', got %s", result.Spec.Patches[0].Patch) + } + if result.Spec.Patches[0].Target.Kind != "Deployment" { + t.Errorf("Expected first patch target kind to be Deployment, got %s", result.Spec.Patches[0].Target.Kind) + } + if result.Spec.Patches[0].Target.Name != "app-deployment" { + t.Errorf("Expected first patch target name to be app-deployment, got %s", result.Spec.Patches[0].Target.Name) + } + if result.Spec.Patches[1].Patch != "patch content 2" { + t.Errorf("Expected second patch content to be 'patch content 2', got %s", result.Spec.Patches[1].Patch) + } + if result.Spec.Patches[1].Target.Kind != "Service" { + t.Errorf("Expected second patch target kind to be Service, got %s", result.Spec.Patches[1].Target.Kind) + } + if result.Spec.Patches[1].Target.Name != "app-service" { + t.Errorf("Expected second patch target name to be app-service, got %s", result.Spec.Patches[1].Target.Name) + } + }) + + t.Run("WithCustomPrune", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And a kustomization with custom prune setting + customPrune := false + blueprintKustomization := blueprintv1alpha1.Kustomization{ + Name: "custom-prune-kustomization", + Source: "test-source", + Path: "test/path", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{true}[0], + Prune: &customPrune, + } + + // When converting to kubernetes kustomization + result := handler.toKubernetesKustomization(blueprintKustomization, "test-namespace") + + // Then the custom prune setting should be used + if result.Spec.Prune != false { + t.Errorf("Expected prune to be false (custom), got %v", result.Spec.Prune) + } + }) + + t.Run("WithDependsOn", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And a kustomization with dependencies + blueprintKustomization := blueprintv1alpha1.Kustomization{ + Name: "dependent-kustomization", + Source: "test-source", + Path: "test/path", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{true}[0], + DependsOn: []string{"dependency-1", "dependency-2"}, + } + + // When converting to kubernetes kustomization + result := handler.toKubernetesKustomization(blueprintKustomization, "test-namespace") + + // Then the dependencies should be correctly mapped + if len(result.Spec.DependsOn) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(result.Spec.DependsOn)) + } + if result.Spec.DependsOn[0].Name != "dependency-1" { + t.Errorf("Expected first dependency name to be dependency-1, got %s", result.Spec.DependsOn[0].Name) + } + if result.Spec.DependsOn[0].Namespace != "test-namespace" { + t.Errorf("Expected first dependency namespace to be test-namespace, got %s", result.Spec.DependsOn[0].Namespace) + } + if result.Spec.DependsOn[1].Name != "dependency-2" { + t.Errorf("Expected second dependency name to be dependency-2, got %s", result.Spec.DependsOn[1].Name) + } + if result.Spec.DependsOn[1].Namespace != "test-namespace" { + t.Errorf("Expected second dependency namespace to be test-namespace, got %s", result.Spec.DependsOn[1].Namespace) + } + }) + + t.Run("WithPostBuild", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And a kustomization with postBuild configuration + blueprintKustomization := blueprintv1alpha1.Kustomization{ + Name: "postbuild-kustomization", + Source: "test-source", + Path: "test/path", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{true}[0], + PostBuild: &blueprintv1alpha1.PostBuild{ + Substitute: map[string]string{ + "var1": "value1", + "var2": "value2", + }, + SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: "config-map-1", + Optional: false, + }, + { + Kind: "Secret", + Name: "secret-1", + Optional: true, + }, + }, + }, + } + + // When converting to kubernetes kustomization + result := handler.toKubernetesKustomization(blueprintKustomization, "test-namespace") + + // Then the postBuild should be correctly mapped + if result.Spec.PostBuild == nil { + t.Error("Expected postBuild to be set, got nil") + } + if len(result.Spec.PostBuild.Substitute) != 2 { + t.Errorf("Expected 2 substitute variables, got %d", len(result.Spec.PostBuild.Substitute)) + } + if result.Spec.PostBuild.Substitute["var1"] != "value1" { + t.Errorf("Expected var1 to be value1, got %s", result.Spec.PostBuild.Substitute["var1"]) + } + if result.Spec.PostBuild.Substitute["var2"] != "value2" { + t.Errorf("Expected var2 to be value2, got %s", result.Spec.PostBuild.Substitute["var2"]) + } + if len(result.Spec.PostBuild.SubstituteFrom) != 2 { + t.Errorf("Expected 2 substitute references, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + if result.Spec.PostBuild.SubstituteFrom[0].Kind != "ConfigMap" { + t.Errorf("Expected first substitute reference kind to be ConfigMap, got %s", result.Spec.PostBuild.SubstituteFrom[0].Kind) + } + if result.Spec.PostBuild.SubstituteFrom[0].Name != "config-map-1" { + t.Errorf("Expected first substitute reference name to be config-map-1, got %s", result.Spec.PostBuild.SubstituteFrom[0].Name) + } + if result.Spec.PostBuild.SubstituteFrom[0].Optional != false { + t.Errorf("Expected first substitute reference optional to be false, got %v", result.Spec.PostBuild.SubstituteFrom[0].Optional) + } + if result.Spec.PostBuild.SubstituteFrom[1].Kind != "Secret" { + t.Errorf("Expected second substitute reference kind to be Secret, got %s", result.Spec.PostBuild.SubstituteFrom[1].Kind) + } + if result.Spec.PostBuild.SubstituteFrom[1].Name != "secret-1" { + t.Errorf("Expected second substitute reference name to be secret-1, got %s", result.Spec.PostBuild.SubstituteFrom[1].Name) + } + if result.Spec.PostBuild.SubstituteFrom[1].Optional != true { + t.Errorf("Expected second substitute reference optional to be true, got %v", result.Spec.PostBuild.SubstituteFrom[1].Optional) + } + }) + + t.Run("WithComponents", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And a kustomization with components + blueprintKustomization := blueprintv1alpha1.Kustomization{ + Name: "components-kustomization", + Source: "test-source", + Path: "test/path", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{true}[0], + Components: []string{"component-1", "component-2"}, + } + + // When converting to kubernetes kustomization + result := handler.toKubernetesKustomization(blueprintKustomization, "test-namespace") + + // Then the components should be correctly mapped + if len(result.Spec.Components) != 2 { + t.Errorf("Expected 2 components, got %d", len(result.Spec.Components)) + } + if result.Spec.Components[0] != "component-1" { + t.Errorf("Expected first component to be component-1, got %s", result.Spec.Components[0]) + } + if result.Spec.Components[1] != "component-2" { + t.Errorf("Expected second component to be component-2, got %s", result.Spec.Components[1]) + } + }) + + t.Run("CompleteConversion", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And a kustomization with all features + customPrune := false + blueprintKustomization := blueprintv1alpha1.Kustomization{ + Name: "complete-kustomization", + Source: "complete-source", + Path: "complete/path", + Interval: &metav1.Duration{Duration: 15 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 3 * time.Minute}, + Timeout: &metav1.Duration{Duration: 30 * time.Minute}, + Force: &[]bool{true}[0], + Wait: &[]bool{false}[0], + Prune: &customPrune, + DependsOn: []string{"dep-1", "dep-2", "dep-3"}, + Components: []string{"comp-1", "comp-2"}, + Patches: []kustomize.Patch{ + { + Patch: "complete patch", + Target: &kustomize.Selector{ + Kind: "StatefulSet", + Name: "database", + }, + }, + }, + PostBuild: &blueprintv1alpha1.PostBuild{ + Substitute: map[string]string{ + "env": "production", + "region": "us-west-2", + }, + SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: "env-config", + Optional: false, + }, + }, + }, + } + + // When converting to kubernetes kustomization + result := handler.toKubernetesKustomization(blueprintKustomization, "production-namespace") + + // Then all fields should be correctly converted + if result.Name != "complete-kustomization" { + t.Errorf("Expected name to be complete-kustomization, got %s", result.Name) + } + if result.Namespace != "production-namespace" { + t.Errorf("Expected namespace to be production-namespace, got %s", result.Namespace) + } + if result.Kind != "Kustomization" { + t.Errorf("Expected kind to be Kustomization, got %s", result.Kind) + } + if result.APIVersion != "kustomize.toolkit.fluxcd.io/v1" { + t.Errorf("Expected apiVersion to be kustomize.toolkit.fluxcd.io/v1, got %s", result.APIVersion) + } + if result.Spec.SourceRef.Name != "complete-source" { + t.Errorf("Expected source name to be complete-source, got %s", result.Spec.SourceRef.Name) + } + if result.Spec.Path != "complete/path" { + t.Errorf("Expected path to be complete/path, got %s", result.Spec.Path) + } + if result.Spec.Interval.Duration != 15*time.Minute { + t.Errorf("Expected interval to be 15m, got %v", result.Spec.Interval.Duration) + } + if result.Spec.Prune != false { + t.Errorf("Expected prune to be false, got %v", result.Spec.Prune) + } + if len(result.Spec.DependsOn) != 3 { + t.Errorf("Expected 3 dependencies, got %d", len(result.Spec.DependsOn)) + } + if len(result.Spec.Components) != 2 { + t.Errorf("Expected 2 components, got %d", len(result.Spec.Components)) + } + if len(result.Spec.Patches) != 1 { + t.Errorf("Expected 1 patch, got %d", len(result.Spec.Patches)) + } + if result.Spec.PostBuild == nil { + t.Error("Expected postBuild to be set, got nil") + } + }) +} diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 8bac3a83e..8dd208994 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -11,6 +11,7 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/constants" @@ -18,7 +19,6 @@ import ( "github.com/windsorcli/cli/pkg/kubernetes" "github.com/windsorcli/cli/pkg/shell" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/yaml" ) // ============================================================================= @@ -54,6 +54,16 @@ func (m *mockJsonnetVM) EvaluateAnonymousSnippet(filename, snippet string) (stri return "", nil } +type mockDirEntry struct { + name string + isDir bool +} + +func (m *mockDirEntry) Name() string { return m.name } +func (m *mockDirEntry) IsDir() bool { return m.isDir } +func (m *mockDirEntry) Type() os.FileMode { return 0 } +func (m *mockDirEntry) Info() (os.FileInfo, error) { return nil, nil } + var safeBlueprintYAML = ` kind: Blueprint apiVersion: v1alpha1 @@ -195,6 +205,7 @@ func setupShims(t *testing.T) *Shims { if strings.Contains(name, "blueprint.yaml") || strings.Contains(name, "blueprint.jsonnet") { return nil, nil } + // Default: template directory does not exist (triggers default blueprint generation) return nil, os.ErrNotExist } @@ -202,6 +213,11 @@ func setupShims(t *testing.T) *Shims { return nil } + // Default: empty template directory (successful template processing) + shims.ReadDir = func(name string) ([]os.DirEntry, error) { + return []os.DirEntry{}, nil + } + shims.NewJsonnetVM = func() JsonnetVM { return NewMockJsonnetVM(func(filename, snippet string) (string, error) { return "", nil @@ -231,16 +247,52 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { // Create injector injector := di.NewInjector() - // Set up config handler + // Set up config handler - default to MockConfigHandler for easier testing var configHandler config.ConfigHandler if len(opts) > 0 && opts[0].ConfigHandler != nil { configHandler = opts[0].ConfigHandler } else { - configHandler = config.NewYamlConfigHandler(injector) + mockConfigHandler := config.NewMockConfigHandler() + // Set up default mock behaviors with stateful context handling + currentContext := "local" // Default context + + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "cluster.platform": + return "default" + case "context": + return currentContext + default: + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" + } + } + + mockConfigHandler.GetContextFunc = func() string { + // Check environment variable first, like the real ConfigHandler does + if envContext := os.Getenv("WINDSOR_CONTEXT"); envContext != "" { + return envContext + } + return currentContext + } + + mockConfigHandler.SetContextFunc = func(context string) error { + currentContext = context + return nil + } + + configHandler = mockConfigHandler } // Create mock shell and kubernetes manager mockShell := shell.NewMockShell() + // Set default GetProjectRoot implementation + mockShell.GetProjectRootFunc = func() (string, error) { + return "/mock/project", nil + } + mockKubernetesManager := kubernetes.NewMockKubernetesManager(nil) // Initialize safe default implementations for all mock functions mockKubernetesManager.DeleteKustomizationFunc = func(name, namespace string) error { @@ -334,45 +386,52 @@ contexts: // ============================================================================= func TestBlueprintHandler_NewBlueprintHandler(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { - t.Helper() + t.Run("CreatesHandlerWithMocks", func(t *testing.T) { + // Given an injector with mocks mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - t.Run("Success", func(t *testing.T) { - // Given a new blueprint handler - handler, _ := setup(t) + // When creating a new blueprint handler + handler := NewBlueprintHandler(mocks.Injector) - // Then the handler should be created successfully + // Then the handler should be properly initialized if handler == nil { - t.Fatalf("Expected BlueprintHandler to be non-nil") + t.Fatal("Expected non-nil handler") } - }) - t.Run("HasCorrectComponents", func(t *testing.T) { - // Given a new blueprint handler and mocks - handler, mocks := setup(t) + // And basic fields should be set + if handler.injector == nil { + t.Error("Expected injector to be set") + } + if handler.shims == nil { + t.Error("Expected shims to be set") + } - // Then the handler should be created successfully - if handler == nil { - t.Fatalf("Expected BlueprintHandler to be non-nil") + // And dependency fields should be nil until Initialize() is called + if handler.configHandler != nil { + t.Error("Expected configHandler to be nil before Initialize()") + } + if handler.shell != nil { + t.Error("Expected shell to be nil before Initialize()") + } + if handler.kubernetesManager != nil { + t.Error("Expected kubernetesManager to be nil before Initialize()") } - // And it should be of the correct type - if _, ok := handler.(*BaseBlueprintHandler); !ok { - t.Errorf("Expected NewBlueprintHandler to return a BaseBlueprintHandler") + // When Initialize is called + err := handler.Initialize() + if err != nil { + t.Fatalf("Initialize() failed: %v", err) } - // And it should have the correct injector - if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { - if baseHandler.injector != mocks.Injector { - t.Errorf("Expected handler to have the correct injector") - } - } else { - t.Errorf("Failed to cast handler to BaseBlueprintHandler") + // Then dependencies should be injected + if handler.configHandler == nil { + t.Error("Expected configHandler to be set after Initialize()") + } + if handler.shell == nil { + t.Error("Expected shell to be set after Initialize()") + } + if handler.kubernetesManager == nil { + t.Error("Expected kubernetesManager to be set after Initialize()") } }) } @@ -448,6 +507,55 @@ func TestBlueprintHandler_Initialize(t *testing.T) { t.Error("Expected error, got nil") } }) + + t.Run("ErrorResolvingKubernetesManager", func(t *testing.T) { + // Given a handler with missing kubernetesManager + handler, mocks := setup(t) + + // And an injector that registers nil for kubernetesManager + mocks.Injector.Register("configHandler", mocks.ConfigHandler) + mocks.Injector.Register("shell", mocks.Shell) + mocks.Injector.Register("kubernetesManager", nil) + + // When calling Initialize + err := handler.Initialize() + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error resolving kubernetesManager") { + t.Errorf("Expected kubernetesManager resolution error, got: %v", err) + } + }) + + t.Run("ErrorSettingProjectNameInConfig", func(t *testing.T) { + // Given a handler with mock config handler that returns error on SetContextValue + mockConfigHandler := &config.MockConfigHandler{ + SetContextValueFunc: func(key string, value any) error { + return fmt.Errorf("set context value error") + }, + } + + // Create setup options with the custom config handler + opts := &SetupOptions{ + ConfigHandler: mockConfigHandler, + } + mocks := setupMocks(t, opts) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + + // When calling Initialize + err := handler.Initialize() + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error setting project name in config") { + t.Errorf("Expected project name setting error, got: %v", err) + } + }) } func TestBlueprintHandler_LoadConfig(t *testing.T) { @@ -619,20 +727,20 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { } }) - t.Run("ErrorLoadingJsonnetFile", func(t *testing.T) { + t.Run("ErrorReadingYamlFile", func(t *testing.T) { // Given a blueprint handler handler, _ := setup(t) - // And a mock file system that returns an error for jsonnet files + // And a mock file system that finds yaml file but fails to read it handler.shims.Stat = func(name string) (os.FileInfo, error) { - if strings.HasSuffix(name, ".jsonnet") { - return nil, nil + if strings.HasSuffix(name, "blueprint.yaml") { + return nil, nil // File exists } return nil, os.ErrNotExist } handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.HasSuffix(name, ".jsonnet") { - return nil, fmt.Errorf("error reading jsonnet file") + if strings.HasSuffix(name, "blueprint.yaml") { + return nil, fmt.Errorf("error reading yaml file") } return nil, os.ErrNotExist } @@ -641,8 +749,8 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { err := handler.LoadConfig() // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "error reading jsonnet file") { - t.Errorf("Expected error containing 'error reading jsonnet file', got: %v", err) + if err == nil || !strings.Contains(err.Error(), "error reading yaml file") { + t.Errorf("Expected error containing 'error reading yaml file', got: %v", err) } }) @@ -673,1169 +781,990 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { } }) - t.Run("ErrorUnmarshallingYamlForLocalBlueprint", func(t *testing.T) { + t.Run("ErrorUnmarshallingYamlBlueprint", func(t *testing.T) { // Given a blueprint handler handler, _ := setup(t) // And a mock file system with a yaml file handler.shims.Stat = func(name string) (os.FileInfo, error) { - if filepath.Clean(name) == filepath.Clean("/mock/config/root/blueprint.yaml") { + if strings.HasSuffix(name, "blueprint.yaml") { return nil, nil } return nil, os.ErrNotExist } handler.shims.ReadFile = func(name string) ([]byte, error) { - if filepath.Clean(name) == filepath.Clean("/mock/config/root/blueprint.yaml") { - return []byte("valid: yaml"), nil + if strings.HasSuffix(name, "blueprint.yaml") { + return []byte("invalid: yaml: content"), nil } - return nil, fmt.Errorf("file not found") + return nil, os.ErrNotExist } // And a mock yaml unmarshaller that returns an error handler.shims.YamlUnmarshal = func(data []byte, obj any) error { - return fmt.Errorf("simulated unmarshalling error") + return fmt.Errorf("error unmarshalling blueprint data") } // When loading the config err := handler.LoadConfig() // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "simulated unmarshalling error") { - t.Errorf("Expected error containing 'simulated unmarshalling error', got: %v", err) + if err == nil || !strings.Contains(err.Error(), "error unmarshalling blueprint data") { + t.Errorf("Expected error containing 'error unmarshalling blueprint data', got: %v", err) } }) - t.Run("ErrorMarshallingContextToJSON", func(t *testing.T) { + t.Run("EmptyEvaluatedJsonnet", func(t *testing.T) { // Given a blueprint handler with local context handler, mocks := setup(t) mocks.ConfigHandler.SetContext("local") - // And a mock json marshaller that returns an error - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if strings.HasSuffix(name, ".jsonnet") { - return nil, nil - } - return nil, os.ErrNotExist - } - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.HasSuffix(name, ".jsonnet") { - return []byte(safeBlueprintJsonnet), nil - } - return nil, os.ErrNotExist - } - handler.shims.JsonMarshal = func(v any) ([]byte, error) { - return nil, fmt.Errorf("simulated marshalling error") + // And a mock jsonnet VM that returns empty result + handler.shims.NewJsonnetVM = func() JsonnetVM { + return NewMockJsonnetVM(func(filename, snippet string) (string, error) { + return "", nil + }) } - // When loading the config - err := handler.LoadConfig() - - // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "simulated marshalling error") { - t.Errorf("Expected error containing 'simulated marshalling error', got: %v", err) + // And a mock file system that returns no files + handler.shims.ReadFile = func(name string) ([]byte, error) { + return nil, fmt.Errorf("file not found") } - }) - - t.Run("ErrorEvaluatingJsonnet", func(t *testing.T) { - // Given a blueprint handler with local context - handler, mocks := setup(t) - mocks.ConfigHandler.SetContext("local") - // And a mock jsonnet VM that returns an error handler.shims.Stat = func(name string) (os.FileInfo, error) { - if strings.HasSuffix(name, ".jsonnet") { - return nil, nil - } - return nil, os.ErrNotExist - } - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.HasSuffix(name, ".jsonnet") { - return []byte(safeBlueprintJsonnet), nil - } return nil, os.ErrNotExist } - handler.shims.NewJsonnetVM = func() JsonnetVM { - return NewMockJsonnetVM(func(filename, snippet string) (string, error) { - return "", fmt.Errorf("simulated jsonnet evaluation error") - }) - } // When loading the config err := handler.LoadConfig() - // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "simulated jsonnet evaluation error") { - t.Errorf("Expected error containing 'simulated jsonnet evaluation error', got: %v", err) + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error for empty evaluated jsonnet, got: %v", err) + } + + // And the default metadata should be set correctly + metadata := handler.GetMetadata() + if metadata.Name != "local" { + t.Errorf("Expected blueprint name to be 'local', got: %s", metadata.Name) + } + expectedDesc := "This blueprint outlines resources in the local context" + if metadata.Description != expectedDesc { + t.Errorf("Expected description '%s', got: %s", expectedDesc, metadata.Description) } }) - t.Run("ErrorMarshallingLocalBlueprintYaml", func(t *testing.T) { - // Given a blueprint handler with local context + t.Run("ErrorMarshallingYamlNonNull", func(t *testing.T) { + // Given a blueprint handler handler, mocks := setup(t) - mocks.ConfigHandler.SetContext("local") // And a mock yaml marshaller that returns an error - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if strings.HasSuffix(name, ".jsonnet") { - return nil, nil - } - return nil, os.ErrNotExist - } - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.HasSuffix(name, ".jsonnet") { - return []byte(safeBlueprintJsonnet), nil - } - return nil, os.ErrNotExist - } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return nil, fmt.Errorf("simulated yaml marshalling error") + mocks.Shims.YamlMarshalNonNull = func(v any) ([]byte, error) { + return nil, fmt.Errorf("mock error marshalling yaml non null") } // When loading the config err := handler.LoadConfig() // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "simulated yaml marshalling error") { - t.Errorf("Expected error containing 'simulated yaml marshalling error', got: %v", err) + if err == nil { + t.Fatal("Expected error when marshalling yaml non null, got nil") + } + if !strings.Contains(err.Error(), "mock error marshalling yaml non null") { + t.Errorf("Expected error containing 'mock error marshalling yaml non null', got: %v", err) } }) - t.Run("ErrorMarshallingLocalJson", func(t *testing.T) { - // Given a blueprint handler with local context - handler, mocks := setup(t) - mocks.ConfigHandler.SetContext("local") - - // And a mock json marshaller that returns an error - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if strings.HasSuffix(name, ".jsonnet") { - return nil, nil - } - return nil, os.ErrNotExist + t.Run("PathBackslashNormalization", func(t *testing.T) { + handler, _ := setup(t) + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1", Path: "foo\\bar\\baz"}, } - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.HasSuffix(name, ".jsonnet") { - return []byte(safeBlueprintJsonnet), nil - } - return nil, os.ErrNotExist + ks := handler.getKustomizations() + if ks[0].Path != "kustomize/foo/bar/baz" { + t.Errorf("expected normalized path, got %q", ks[0].Path) } - handler.shims.JsonMarshal = func(v any) ([]byte, error) { - return nil, fmt.Errorf("simulated json marshalling error") + }) +} + +func TestBlueprintHandler_Install(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize BlueprintHandler: %v", err) } + return handler, mocks + } - // When loading the config - err := handler.LoadConfig() + t.Run("Success", func(t *testing.T) { + // Given a blueprint handler with repository, sources, and kustomizations + handler, _ := setup(t) - // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "simulated json marshalling error") { - t.Errorf("Expected error containing 'simulated json marshalling error', got: %v", err) + handler.blueprint.Repository = blueprintv1alpha1.Repository{ + Url: "git::https://example.com/repo.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, } - }) - - t.Run("ErrorGeneratingBlueprintFromLocalJsonnet", func(t *testing.T) { - // Given a blueprint handler with local context - handler, mocks := setup(t) - mocks.ConfigHandler.SetContext("local") - // And a mock file system that returns an error for jsonnet files - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if strings.HasSuffix(name, ".jsonnet") { - return nil, nil - } - return nil, os.ErrNotExist + expectedSources := []blueprintv1alpha1.Source{ + { + Name: "source1", + Url: "https://example.com/source1.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + }, } - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.HasSuffix(name, ".jsonnet") { - return nil, fmt.Errorf("error reading jsonnet file") - } - return nil, os.ErrNotExist + handler.blueprint.Sources = expectedSources + + expectedKustomizations := []blueprintv1alpha1.Kustomization{ + { + Name: "kustomization1", + }, } + handler.blueprint.Kustomizations = expectedKustomizations - // When loading the config - err := handler.LoadConfig() + // When installing the blueprint + err := handler.Install() - // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "error reading jsonnet file") { - t.Errorf("Expected error containing 'error reading jsonnet file', got: %v", err) + // Then no error should be returned + if err != nil { + t.Fatalf("Expected successful installation, but got error: %v", err) } }) - t.Run("ErrorUnmarshallingYamlDataWithEvaluatedJsonnet", func(t *testing.T) { - // Given a blueprint handler with local context + t.Run("KustomizationDefaults", func(t *testing.T) { + // Given a blueprint handler with repository and kustomizations handler, mocks := setup(t) - mocks.ConfigHandler.SetContext("local") - - // And a mock yaml unmarshaller that returns an error - handler.shims.YamlUnmarshal = func(data []byte, obj any) error { - return fmt.Errorf("simulated unmarshalling error") - } - - // When loading the config - err := handler.LoadConfig() - // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "simulated unmarshalling error") { - t.Errorf("Expected error containing 'simulated unmarshalling error', got: %v", err) + handler.blueprint.Repository = blueprintv1alpha1.Repository{ + Url: "git::https://example.com/repo.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, } - }) - t.Run("ExistingYamlFilePreference", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + // And a blueprint with metadata name + handler.blueprint.Metadata.Name = "test-blueprint" - // And a mock file system with a yaml file - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if strings.HasSuffix(name, ".yaml") { - return nil, nil - } - return nil, os.ErrNotExist - } - - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.HasSuffix(name, ".yaml") { - return []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: A test blueprint - authors: - - John Doe -`), nil - } - return nil, os.ErrNotExist + // And kustomizations with various configurations + kustomizations := []blueprintv1alpha1.Kustomization{ + { + Name: "k1", // No source, should use blueprint name + }, + { + Name: "k2", + Source: "custom-source", // Explicit source + }, + { + Name: "k3", // No path, should default to "kustomize" + }, + { + Name: "k4", + Path: "custom/path", // Custom path, should be prefixed with "kustomize/" + }, + { + Name: "k5", // No intervals/timeouts, should use defaults + }, + { + Name: "k6", + Interval: &metav1.Duration{Duration: 2 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 30 * time.Second}, + Timeout: &metav1.Duration{Duration: 5 * time.Minute}, + }, } + handler.blueprint.Kustomizations = kustomizations - // And a mock jsonnet VM that returns empty output - handler.shims.NewJsonnetVM = func() JsonnetVM { - return NewMockJsonnetVM(func(filename, snippet string) (string, error) { - return "", nil - }) + // And a mock that captures the applied kustomizations + var appliedKustomizations []kustomizev1.Kustomization + mocks.KubernetesManager.ApplyKustomizationFunc = func(k kustomizev1.Kustomization) error { + appliedKustomizations = append(appliedKustomizations, k) + return nil } - // And a test context - originalContext := os.Getenv("WINDSOR_CONTEXT") - os.Setenv("WINDSOR_CONTEXT", "test") - defer func() { os.Setenv("WINDSOR_CONTEXT", originalContext) }() - - // When loading the config - err := handler.LoadConfig() + // When installing the blueprint + err := handler.Install() // Then no error should be returned if err != nil { - t.Fatalf("Failed to load config: %v", err) + t.Fatalf("Expected successful installation, but got error: %v", err) } - // And the metadata should be loaded from the yaml file - metadata := handler.GetMetadata() - if metadata.Name != "test-blueprint" { - t.Errorf("Expected name to be test-blueprint, got %s", metadata.Name) - } - if metadata.Description != "A test blueprint" { - t.Errorf("Expected description to be 'A test blueprint', got %s", metadata.Description) - } - if len(metadata.Authors) != 1 || metadata.Authors[0] != "John Doe" { - t.Errorf("Expected authors to be ['John Doe'], got %v", metadata.Authors) + // And the kustomizations should have the correct defaults + if len(appliedKustomizations) != 6 { + t.Fatalf("Expected 6 kustomizations to be applied, got %d", len(appliedKustomizations)) } - }) - - t.Run("EmptyEvaluatedJsonnet", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) - - // And a mock config handler that returns local context - mocks.ConfigHandler.SetContext("local") - // And a mock file system with no files - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + // Verify k1 (no source) + if appliedKustomizations[0].Spec.SourceRef.Name != "test-blueprint" { + t.Errorf("Expected k1 source to be 'test-blueprint', got '%s'", appliedKustomizations[0].Spec.SourceRef.Name) } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return nil, fmt.Errorf("file not found") + // Verify k2 (explicit source) + if appliedKustomizations[1].Spec.SourceRef.Name != "custom-source" { + t.Errorf("Expected k2 source to be 'custom-source', got '%s'", appliedKustomizations[1].Spec.SourceRef.Name) } - // And a mock jsonnet VM that returns empty output - handler.shims.NewJsonnetVM = func() JsonnetVM { - return NewMockJsonnetVM(func(filename, snippet string) (string, error) { - return "", nil - }) + // Verify k3 (no path) + if appliedKustomizations[2].Spec.Path != "kustomize" { + t.Errorf("Expected k3 path to be 'kustomize', got '%s'", appliedKustomizations[2].Spec.Path) } - // When loading the config - err := handler.LoadConfig() + // Verify k4 (custom path) + if appliedKustomizations[3].Spec.Path != "kustomize/custom/path" { + t.Errorf("Expected k4 path to be 'kustomize/custom/path', got '%s'", appliedKustomizations[3].Spec.Path) + } - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error for empty evaluated jsonnet, got: %v", err) + // Verify k5 (default intervals/timeouts) + if appliedKustomizations[4].Spec.Interval.Duration != constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL { + t.Errorf("Expected k5 interval to be %v, got %v", constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL, appliedKustomizations[4].Spec.Interval.Duration) + } + if appliedKustomizations[4].Spec.RetryInterval.Duration != constants.DEFAULT_FLUX_KUSTOMIZATION_RETRY_INTERVAL { + t.Errorf("Expected k5 retry interval to be %v, got %v", constants.DEFAULT_FLUX_KUSTOMIZATION_RETRY_INTERVAL, appliedKustomizations[4].Spec.RetryInterval.Duration) + } + if appliedKustomizations[4].Spec.Timeout.Duration != constants.DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT { + t.Errorf("Expected k5 timeout to be %v, got %v", constants.DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT, appliedKustomizations[4].Spec.Timeout.Duration) } - // And the metadata should be correctly loaded - metadata := handler.GetMetadata() - if metadata.Name != "local" { - t.Errorf("Expected blueprint name to be 'local', got: %s", metadata.Name) + // Verify k6 (custom intervals/timeouts) + if appliedKustomizations[5].Spec.Interval.Duration != 2*time.Minute { + t.Errorf("Expected k6 interval to be 2m, got %v", appliedKustomizations[5].Spec.Interval.Duration) } - expectedDesc := "This blueprint outlines resources in the local context" - if metadata.Description != expectedDesc { - t.Errorf("Expected description '%s', got: %s", expectedDesc, metadata.Description) + if appliedKustomizations[5].Spec.RetryInterval.Duration != 30*time.Second { + t.Errorf("Expected k6 retry interval to be 30s, got %v", appliedKustomizations[5].Spec.RetryInterval.Duration) + } + if appliedKustomizations[5].Spec.Timeout.Duration != 5*time.Minute { + t.Errorf("Expected k6 timeout to be 5m, got %v", appliedKustomizations[5].Spec.Timeout.Duration) } }) - t.Run("ErrorEvaluatingDefaultJsonnet", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + t.Run("ApplyKustomizationError", func(t *testing.T) { + // Given a blueprint handler with repository, sources, and kustomizations + handler, mocks := setup(t) - // And a mock file system with no files - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + handler.blueprint.Repository = blueprintv1alpha1.Repository{ + Url: "git::https://example.com/repo.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return nil, os.ErrNotExist + sources := []blueprintv1alpha1.Source{ + { + Name: "source1", + Url: "git::https://example.com/source1.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + PathPrefix: "terraform", + }, } + handler.blueprint.Sources = sources - // And a mock jsonnet VM that returns an error for default template - handler.shims.NewJsonnetVM = func() JsonnetVM { - return NewMockJsonnetVM(func(filename, snippet string) (string, error) { - if filename == "default.jsonnet" { - return "", fmt.Errorf("error evaluating default jsonnet template") - } - return "", nil - }) + kustomizations := []blueprintv1alpha1.Kustomization{ + { + Name: "kustomization1", + }, } + handler.blueprint.Kustomizations = kustomizations - // And a local context - originalContext := os.Getenv("WINDSOR_CONTEXT") - os.Setenv("WINDSOR_CONTEXT", "local") - defer func() { os.Setenv("WINDSOR_CONTEXT", originalContext) }() + // Set up mock to return error for ApplyKustomization + mocks.KubernetesManager.ApplyKustomizationFunc = func(kustomization kustomizev1.Kustomization) error { + return fmt.Errorf("apply error") + } - // When loading the config - err := handler.LoadConfig() + // When installing the blueprint + err := handler.Install() // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "error generating blueprint from default jsonnet") { - t.Errorf("Expected error containing 'error generating blueprint from default jsonnet', got: %v", err) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to apply kustomization kustomization1") { + t.Errorf("Expected error about failed kustomization apply, got: %v", err) } }) - t.Run("ErrorUnmarshallingContextYAML", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + t.Run("Error_CreateManagedNamespace", func(t *testing.T) { + // Given a blueprint handler with namespace creation error + handler, mocks := setup(t) - // And a mock yaml unmarshaller that returns an error for context YAML - handler.shims.YamlUnmarshal = func(data []byte, obj any) error { - if _, ok := obj.(map[string]any); ok { - return fmt.Errorf("error unmarshalling context YAML") - } - return nil + // Override: CreateNamespace returns error + mocks.KubernetesManager.CreateNamespaceFunc = func(name string) error { + return fmt.Errorf("namespace creation error") } - // And a mock file system that returns a blueprint file - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + // When installing the blueprint + err := handler.Install() + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to create namespace") { + t.Errorf("Expected namespace creation error, got: %v", err) } + }) - handler.shims.ReadFile = func(name string) ([]byte, error) { - return nil, os.ErrNotExist + t.Run("Error_ApplyMainRepository", func(t *testing.T) { + // Given a blueprint handler with main repository apply error + handler, mocks := setup(t) + + handler.blueprint.Repository = blueprintv1alpha1.Repository{ + Url: "git::https://example.com/repo.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, } - // And a mock jsonnet VM that returns an error - handler.shims.NewJsonnetVM = func() JsonnetVM { - return NewMockJsonnetVM(func(filename, snippet string) (string, error) { - return "", fmt.Errorf("error evaluating jsonnet") - }) + // Override: ApplyGitRepository returns error + mocks.KubernetesManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { + return fmt.Errorf("git repository apply error") } - // When loading the config - err := handler.LoadConfig() + // When installing the blueprint + err := handler.Install() // Then an error should be returned - if err == nil || !strings.Contains(err.Error(), "error evaluating jsonnet") { - t.Errorf("Expected error containing 'error evaluating jsonnet', got: %v", err) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to apply main repository") { + t.Errorf("Expected main repository error, got: %v", err) } }) - t.Run("EmptyEvaluatedJsonnet", func(t *testing.T) { - // Given a blueprint handler with local context + t.Run("Error_ApplySourceRepository", func(t *testing.T) { + // Given a blueprint handler with source repository apply error handler, mocks := setup(t) - mocks.ConfigHandler.SetContext("local") - - // And a mock jsonnet VM that returns empty result - handler.shims.NewJsonnetVM = func() JsonnetVM { - return NewMockJsonnetVM(func(filename, snippet string) (string, error) { - return "", nil - }) - } - // And a mock file system that returns no files - handler.shims.ReadFile = func(name string) ([]byte, error) { - return nil, fmt.Errorf("file not found") + sources := []blueprintv1alpha1.Source{ + { + Name: "source1", + Url: "https://example.com/source1.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + }, } + handler.blueprint.Sources = sources - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + // Override: ApplyGitRepository returns error for sources + mocks.KubernetesManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { + return fmt.Errorf("source repository apply error") } - // When loading the config - err := handler.LoadConfig() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error for empty evaluated jsonnet, got: %v", err) - } + // When installing the blueprint + err := handler.Install() - // And the default metadata should be set correctly - metadata := handler.GetMetadata() - if metadata.Name != "local" { - t.Errorf("Expected blueprint name to be 'local', got: %s", metadata.Name) + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") } - expectedDesc := "This blueprint outlines resources in the local context" - if metadata.Description != expectedDesc { - t.Errorf("Expected description '%s', got: %s", expectedDesc, metadata.Description) + if !strings.Contains(err.Error(), "failed to apply source repository source1") { + t.Errorf("Expected source repository error, got: %v", err) } }) - t.Run("ErrorMarshallingYamlNonNull", func(t *testing.T) { - // Given a blueprint handler + t.Run("Error_ApplyConfigMap", func(t *testing.T) { + // Given a blueprint handler with configmap apply error handler, mocks := setup(t) - // And a mock yaml marshaller that returns an error - mocks.Shims.YamlMarshalNonNull = func(v any) ([]byte, error) { - return nil, fmt.Errorf("mock error marshalling yaml non null") + // Override: ApplyConfigMap returns error + mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + return fmt.Errorf("configmap apply error") } - // When loading the config - err := handler.LoadConfig() + // When installing the blueprint + err := handler.Install() // Then an error should be returned if err == nil { - t.Fatal("Expected error when marshalling yaml non null, got nil") + t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "mock error marshalling yaml non null") { - t.Errorf("Expected error containing 'mock error marshalling yaml non null', got: %v", err) + if !strings.Contains(err.Error(), "failed to apply configmap") { + t.Errorf("Expected configmap error, got: %v", err) } }) - t.Run("PathBackslashNormalization", func(t *testing.T) { + t.Run("Success_EmptyRepositoryUrl", func(t *testing.T) { + // Given a blueprint handler with empty repository URL handler, _ := setup(t) - handler.SetKustomizations([]blueprintv1alpha1.Kustomization{ - {Name: "k1", Path: "foo\\bar\\baz"}, - }) - ks := handler.getKustomizations() - if ks[0].Path != "kustomize/foo/bar/baz" { - t.Errorf("expected normalized path, got %q", ks[0].Path) - } - }) -} -func TestBlueprintHandler_WriteConfig(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize BlueprintHandler: %v", err) + // Repository with empty URL should be skipped + handler.blueprint.Repository = blueprintv1alpha1.Repository{ + Url: "", } - return handler, mocks - } - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler with metadata - handler, mocks := setup(t) - // Patch Stat to simulate file does not exist - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + sources := []blueprintv1alpha1.Source{ + { + Name: "source1", + Url: "https://example.com/source1.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + }, } - expectedMetadata := blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - Description: "A test blueprint", - Authors: []string{"John Doe"}, + handler.blueprint.Sources = sources + + // When installing the blueprint + err := handler.Install() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) } - handler.SetMetadata(expectedMetadata) + }) - // And a mock file system that captures written data - var capturedData []byte - mocks.Shims.WriteFile = func(name string, data []byte, perm fs.FileMode) error { - capturedData = data - return nil + t.Run("Success_NoSources", func(t *testing.T) { + // Given a blueprint handler with no sources + handler, _ := setup(t) + + handler.blueprint.Repository = blueprintv1alpha1.Repository{ + Url: "git::https://example.com/repo.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, } - // When writing the config - err := handler.WriteConfig() + // No sources defined + handler.blueprint.Sources = []blueprintv1alpha1.Source{} + + // When installing the blueprint + err := handler.Install() // Then no error should be returned if err != nil { - t.Fatalf("Expected WriteConfig to succeed, got error: %v", err) + t.Errorf("Expected no error, got: %v", err) } + }) - // And data should be written - if len(capturedData) == 0 { - t.Error("Expected data to be written, but no data was captured") + t.Run("Success_NoKustomizations", func(t *testing.T) { + // Given a blueprint handler with no kustomizations + handler, _ := setup(t) + + handler.blueprint.Repository = blueprintv1alpha1.Repository{ + Url: "git::https://example.com/repo.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, } - // And the written data should match the expected blueprint - var writtenBlueprint blueprintv1alpha1.Blueprint - err = yaml.Unmarshal(capturedData, &writtenBlueprint) + // No kustomizations defined + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{} + + // When installing the blueprint + err := handler.Install() + + // Then no error should be returned if err != nil { - t.Fatalf("Failed to unmarshal captured blueprint data: %v", err) + t.Errorf("Expected no error, got: %v", err) } + }) + + t.Run("Success_WithSecretName", func(t *testing.T) { + // Given a blueprint handler with repository that has secret name + handler, _ := setup(t) - if writtenBlueprint.Metadata.Name != "test-blueprint" { - t.Errorf("Expected written blueprint name to be 'test-blueprint', got '%s'", writtenBlueprint.Metadata.Name) + handler.blueprint.Repository = blueprintv1alpha1.Repository{ + Url: "git::https://example.com/private-repo.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + SecretName: "git-credentials", } - if writtenBlueprint.Metadata.Description != "A test blueprint" { - t.Errorf("Expected written blueprint description to be 'A test blueprint', got '%s'", writtenBlueprint.Metadata.Description) + + sources := []blueprintv1alpha1.Source{ + { + Name: "source1", + Url: "https://example.com/private-source.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + SecretName: "source-credentials", + }, } - if len(writtenBlueprint.Metadata.Authors) != 1 || writtenBlueprint.Metadata.Authors[0] != "John Doe" { - t.Errorf("Expected written blueprint authors to be ['John Doe'], got %v", writtenBlueprint.Metadata.Authors) + handler.blueprint.Sources = sources + + // When installing the blueprint + err := handler.Install() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) } }) +} - t.Run("WriteNoPath", func(t *testing.T) { - // Given a blueprint handler with metadata - handler, mocks := setup(t) - // Patch Stat to simulate file does not exist - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist +func TestBlueprintHandler_WaitForKustomizations(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) } - expectedMetadata := blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - Description: "A test blueprint", - Authors: []string{"John Doe"}, + return handler, mocks + } + + // setupFastTiming sets up fast timing mocks for testing + setupFastTiming := func(handler *BaseBlueprintHandler) { + // Mock TimeAfter to return a channel that never fires (for non-timeout tests) + // or fires immediately (for timeout tests) + handler.shims.TimeAfter = func(d time.Duration) <-chan time.Time { + if d <= 1*time.Millisecond { + // For very short durations (timeout tests), fire immediately + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + } + // For normal durations, return a channel that never fires + return make(chan time.Time) } - handler.SetMetadata(expectedMetadata) - // And a mock file system that captures written data - var capturedData []byte - mocks.Shims.WriteFile = func(name string, data []byte, perm fs.FileMode) error { - capturedData = data - return nil + // Mock NewTicker to return a ticker that fires every 1ms + handler.shims.NewTicker = func(d time.Duration) *time.Ticker { + return time.NewTicker(1 * time.Millisecond) } - // When writing the config without a path - err := handler.WriteConfig() + // Keep the original TickerStop + handler.shims.TickerStop = func(t *time.Ticker) { t.Stop() } + } - // Then no error should be returned - if err != nil { - t.Fatalf("Failed to write blueprint configuration: %v", err) + t.Run("Success_ImmediateReady", func(t *testing.T) { + // Given a blueprint handler with kustomizations that are immediately ready + handler, mocks := setup(t) + setupFastTiming(handler) + + // Set up blueprint with kustomizations + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "test-kustomization-1"}, + {Name: "test-kustomization-2"}, } - // And data should be written - if len(capturedData) == 0 { - t.Error("Expected data to be written, but no data was captured") + // Track method calls + checkGitRepoStatusCalled := false + getKustomizationStatusCalled := false + + // Override: return ready status immediately + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + checkGitRepoStatusCalled = true + return nil + } + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + getKustomizationStatusCalled = true + status := make(map[string]bool) + for _, name := range names { + status[name] = true // All ready + } + return status, nil } - // And the written data should match the expected blueprint - var writtenBlueprint blueprintv1alpha1.Blueprint - err = yaml.Unmarshal(capturedData, &writtenBlueprint) + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing kustomizations") + + // Then no error should be returned if err != nil { - t.Fatalf("Failed to unmarshal captured blueprint data: %v", err) + t.Errorf("Expected no error, got: %v", err) } - if writtenBlueprint.Metadata.Name != "test-blueprint" { - t.Errorf("Expected written blueprint name to be 'test-blueprint', got '%s'", writtenBlueprint.Metadata.Name) + // And CheckGitRepositoryStatus should be called + if !checkGitRepoStatusCalled { + t.Error("Expected CheckGitRepositoryStatus to be called") } - if writtenBlueprint.Metadata.Description != "A test blueprint" { - t.Errorf("Expected written blueprint description to be 'A test blueprint', got '%s'", writtenBlueprint.Metadata.Description) - } - if len(writtenBlueprint.Metadata.Authors) != 1 || writtenBlueprint.Metadata.Authors[0] != "John Doe" { - t.Errorf("Expected written blueprint authors to be ['John Doe'], got %v", writtenBlueprint.Metadata.Authors) + + // And GetKustomizationStatus should be called + if !getKustomizationStatusCalled { + t.Error("Expected GetKustomizationStatus to be called") } }) - t.Run("ErrorGettingConfigRoot", func(t *testing.T) { - // Given a mock config handler that returns an error - configHandler := config.NewMockConfigHandler() - configHandler.GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("mock error") - } - opts := &SetupOptions{ - ConfigHandler: configHandler, + t.Run("Success_SpecificNames", func(t *testing.T) { + // Given a blueprint handler + handler, mocks := setup(t) + setupFastTiming(handler) + + // Override: return ready status and verify specific names + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil } - mocks := setupMocks(t, opts) + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + // Verify specific names are passed + expectedNames := []string{"custom-kustomization-1", "custom-kustomization-2"} + if len(names) != len(expectedNames) { + t.Errorf("Expected %d names, got %d", len(expectedNames), len(names)) + } + for i, name := range names { + if name != expectedNames[i] { + t.Errorf("Expected name %s, got %s", expectedNames[i], name) + } + } - // And a blueprint handler using that config - handler := NewBlueprintHandler(mocks.Injector) - handler.Initialize() + status := make(map[string]bool) + for _, name := range names { + status[name] = true + } + return status, nil + } - // When writing the config - err := handler.WriteConfig() + // When waiting for specific kustomizations + err := handler.WaitForKustomizations("Testing specific kustomizations", "custom-kustomization-1", "custom-kustomization-2") - // Then an error should be returned - if err == nil { - t.Fatal("Expected error when loading config, got nil") - } - if err.Error() != "error getting config root: mock error" { - t.Errorf("error = %q, want %q", err.Error(), "error getting config root: mock error") + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) } }) - t.Run("ErrorCreatingDirectory", func(t *testing.T) { - // Given a blueprint handler + t.Run("Success_AfterPolling", func(t *testing.T) { + // Given a blueprint handler with kustomizations handler, mocks := setup(t) + setupFastTiming(handler) - // And a mock file system that fails to create directories - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return fmt.Errorf("mock error creating directory") + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "test-kustomization"}, } - // When writing the config - err := handler.WriteConfig() + // Override: return not ready initially, then ready + callCount := 0 + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil + } + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + callCount++ + status := make(map[string]bool) + for _, name := range names { + // Ready on second call + status[name] = callCount >= 2 + } + return status, nil + } - // Then an error should be returned - if err == nil { - t.Fatal("Expected error when writing config, got nil") + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing polling") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) } - if err.Error() != "error creating directory: mock error creating directory" { - t.Errorf("error = %q, want %q", err.Error(), "error creating directory: mock error creating directory") + + // And GetKustomizationStatus should be called multiple times + if callCount < 2 { + t.Errorf("Expected at least 2 calls to GetKustomizationStatus, got %d", callCount) } }) - t.Run("ErrorMarshallingYaml", func(t *testing.T) { + t.Run("Error_GitRepositoryStatus", func(t *testing.T) { // Given a blueprint handler handler, mocks := setup(t) - // Patch Stat to simulate file does not exist - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + setupFastTiming(handler) + + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "test-kustomization"}, } - // And a mock yaml marshaller that returns an error - mocks.Shims.YamlMarshalNonNull = func(in any) ([]byte, error) { - return nil, fmt.Errorf("mock error marshalling yaml") + // Override: return git repository error + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return fmt.Errorf("git repository not ready") } - // When writing the config - err := handler.WriteConfig() + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing git repo error") // Then an error should be returned if err == nil { - t.Fatal("Expected error when marshalling yaml, got nil") + t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error marshalling yaml") { - t.Errorf("Expected error message to contain 'error marshalling yaml', got '%v'", err) + if !strings.Contains(err.Error(), "git repository error") { + t.Errorf("Expected git repository error, got: %v", err) } }) - t.Run("ErrorWritingFile", func(t *testing.T) { + t.Run("Error_KustomizationStatus", func(t *testing.T) { // Given a blueprint handler handler, mocks := setup(t) - // Patch Stat to simulate file does not exist - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + setupFastTiming(handler) + + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "test-kustomization"}, } - // And a mock file system that fails to write files - mocks.Shims.WriteFile = func(name string, data []byte, perm fs.FileMode) error { - return fmt.Errorf("mock error writing file") + // Override: return kustomization error + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil + } + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + return nil, fmt.Errorf("kustomization status error") } - // When writing the config - err := handler.WriteConfig() + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing kustomization error") // Then an error should be returned if err == nil { - t.Fatal("Expected error when writing file, got nil") + t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error writing blueprint file") { - t.Errorf("Expected error message to contain 'error writing blueprint file', got '%v'", err) + if !strings.Contains(err.Error(), "kustomization error") { + t.Errorf("Expected kustomization error, got: %v", err) } }) - t.Run("CleanupEmptyPostBuild", func(t *testing.T) { - // Given a blueprint handler with kustomizations containing empty PostBuild + t.Run("Error_ConsecutiveFailures", func(t *testing.T) { + // Given a blueprint handler handler, mocks := setup(t) - // Patch Stat to simulate file does not exist - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - emptyPostBuildKustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization-empty-postbuild", - Path: "path/to/kustomize", - PostBuild: &blueprintv1alpha1.PostBuild{ - Substitute: map[string]string{}, - SubstituteFrom: []blueprintv1alpha1.SubstituteReference{}, - }, - }, - { - Name: "kustomization-with-substitutes", - Path: "path/to/kustomize2", - PostBuild: &blueprintv1alpha1.PostBuild{ - SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ - { - Kind: "ConfigMap", - Name: "test-config", - }, - }, - }, - }, - } - handler.SetKustomizations(emptyPostBuildKustomizations) + setupFastTiming(handler) - // And a mock yaml marshaller that captures the blueprint - var capturedBlueprint *blueprintv1alpha1.Blueprint - mocks.Shims.YamlMarshalNonNull = func(v any) ([]byte, error) { - if bp, ok := v.(*blueprintv1alpha1.Blueprint); ok { - capturedBlueprint = bp - } - return []byte{}, nil + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "test-kustomization"}, } - // When writing the config - err := handler.WriteConfig() - - // Then no error should be returned - if err != nil { - t.Fatalf("Failed to write blueprint configuration: %v", err) + // Override: return errors consistently + callCount := 0 + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil } - - // And the kustomizations should be properly cleaned up - if capturedBlueprint == nil { - t.Fatal("Expected blueprint to be captured, but it was nil") + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + callCount++ + return nil, fmt.Errorf("persistent error %d", callCount) } - if len(capturedBlueprint.Kustomizations) != 2 { - t.Fatalf("Expected 2 kustomizations, got %d", len(capturedBlueprint.Kustomizations)) - } + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing consecutive failures") - // And empty PostBuild should be removed - if capturedBlueprint.Kustomizations[0].PostBuild != nil { - t.Errorf("Expected PostBuild to be nil for kustomization with empty PostBuild, got %v", - capturedBlueprint.Kustomizations[0].PostBuild) + // Then an error should be returned mentioning consecutive failures + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "consecutive failures") { + t.Errorf("Expected consecutive failures error, got: %v", err) } - // And non-empty PostBuild should be preserved - if capturedBlueprint.Kustomizations[1].PostBuild == nil { - t.Errorf("Expected PostBuild to be preserved for kustomization with substitutes") - } else if len(capturedBlueprint.Kustomizations[1].PostBuild.SubstituteFrom) != 1 { - t.Errorf("Expected 1 SubstituteFrom entry, got %d", - len(capturedBlueprint.Kustomizations[1].PostBuild.SubstituteFrom)) + // And GetKustomizationStatus should be called multiple times (initial + 4 more failures = 5 total) + expectedCalls := 5 // 1 initial + 4 more failures to reach max of 5 consecutive failures + if callCount != expectedCalls { + t.Errorf("Expected %d calls to GetKustomizationStatus, got %d", expectedCalls, callCount) } }) - t.Run("ClearTerraformComponentsVariablesAndValues", func(t *testing.T) { - // Given a blueprint handler with terraform components containing variables and values + t.Run("Error_RecoveryFromFailures", func(t *testing.T) { + // Given a blueprint handler handler, mocks := setup(t) - // Patch Stat to simulate file does not exist - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - terraformComponents := []blueprintv1alpha1.TerraformComponent{ - { - Source: "source1", - Path: "path/to/code", - Values: map[string]any{ - "key1": "val1", - "key2": true, - }, - }, + setupFastTiming(handler) + + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "test-kustomization"}, } - handler.SetTerraformComponents(terraformComponents) - // And a mock yaml marshaller that captures the blueprint - var capturedBlueprint *blueprintv1alpha1.Blueprint - mocks.Shims.YamlMarshalNonNull = func(v any) ([]byte, error) { - if bp, ok := v.(*blueprintv1alpha1.Blueprint); ok { - capturedBlueprint = bp + // Override: fail a few times then succeed + callCount := 0 + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil + } + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + callCount++ + if callCount <= 3 { + return nil, fmt.Errorf("temporary error %d", callCount) } - return []byte{}, nil + // Success after 3 failures + status := make(map[string]bool) + for _, name := range names { + status[name] = true + } + return status, nil } - // When writing the config - err := handler.WriteConfig() + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing recovery") - // Then no error should be returned + // Then no error should be returned (it should recover) if err != nil { - t.Fatalf("Failed to write blueprint configuration: %v", err) + t.Errorf("Expected no error after recovery, got: %v", err) } - // And the blueprint should be captured - if capturedBlueprint == nil { - t.Fatal("Expected blueprint to be captured, but it was nil") + // And GetKustomizationStatus should be called 4 times (1 initial + 3 failures + 1 success) + expectedCalls := 4 + if callCount != expectedCalls { + t.Errorf("Expected %d calls to GetKustomizationStatus, got %d", expectedCalls, callCount) } + }) - // And the terraform components should be properly cleaned up - if len(capturedBlueprint.TerraformComponents) != 1 { - t.Fatalf("Expected 1 terraform component, got %d", len(capturedBlueprint.TerraformComponents)) - } + t.Run("Timeout_ExceedsMaxWaitTime", func(t *testing.T) { + // Given a blueprint handler with very short timeout + handler, mocks := setup(t) - // And variables and values should be cleared - component := capturedBlueprint.TerraformComponents[0] - if component.Values != nil { - t.Error("Expected Values to be nil after write") + // Set up kustomizations with very short timeout + shortTimeout := &metav1.Duration{Duration: 1 * time.Millisecond} + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "test-kustomization", Timeout: shortTimeout}, } - // And other fields should be preserved - if component.Source != "source1" { - t.Errorf("Expected Source to be 'source1', got %s", component.Source) - } - if component.Path != "path/to/code" { - t.Errorf("Expected Path to be 'path/to/code', got %s", component.Path) - } - }) + // Setup fast timing that will timeout immediately for short durations + setupFastTiming(handler) - t.Run("ErrorGettingHelmReleases", func(t *testing.T) { - // Given a handler with a kustomization - handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1"}, + // Override: never be ready + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil } - - // Set up mock Kubernetes manager to return error when getting helm releases - mocks.KubernetesManager.GetHelmReleasesForKustomizationFunc = func(name, namespace string) ([]helmv2.HelmRelease, error) { - return nil, fmt.Errorf("failed to get helm releases") + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + status := make(map[string]bool) + for _, name := range names { + status[name] = false // Never ready + } + return status, nil } - // When calling Down - err := baseHandler.Down() + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing timeout") - // Then an error should be returned + // Then a timeout error should be returned if err == nil { - t.Error("Expected error, got nil") + t.Error("Expected timeout error, got nil") } - if !strings.Contains(err.Error(), "failed to get helmreleases for kustomization k1") { - t.Errorf("Expected error about failed helm releases, got: %v", err) + if !strings.Contains(err.Error(), "timeout waiting for kustomizations") { + t.Errorf("Expected timeout error, got: %v", err) } }) - t.Run("ErrorSuspendingHelmRelease", func(t *testing.T) { - // Given a handler with a kustomization + t.Run("EmptyKustomizationNames", func(t *testing.T) { + // Given a blueprint handler with no kustomizations handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1"}, - } + setupFastTiming(handler) - // Set up mock Kubernetes manager to return a helm release and error on suspend - mocks.KubernetesManager.GetHelmReleasesForKustomizationFunc = func(name, namespace string) ([]helmv2.HelmRelease, error) { - return []helmv2.HelmRelease{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "test-release", - Namespace: "test-namespace", - }, - }, - }, nil + // No kustomizations in blueprint + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{} + + // Override: verify empty names list + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil } - mocks.KubernetesManager.SuspendHelmReleaseFunc = func(name, namespace string) error { - return fmt.Errorf("failed to suspend helm release") + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + if len(names) != 0 { + t.Errorf("Expected empty names list, got %v", names) + } + return make(map[string]bool), nil } - // When calling Down - err := baseHandler.Down() + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing empty") - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to suspend helmrelease test-release in namespace test-namespace") { - t.Errorf("Expected error about failed helm release suspension, got: %v", err) + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error for empty kustomizations, got: %v", err) } }) - t.Run("SuspendKustomizationError", func(t *testing.T) { - // Given a handler with a kustomization + t.Run("EmptySpecificNames", func(t *testing.T) { + // Given a blueprint handler handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1"}, - } + setupFastTiming(handler) - // Set up mock Kubernetes manager to return error on suspend - mocks.KubernetesManager.SuspendKustomizationFunc = func(name, namespace string) error { - return fmt.Errorf("suspend error") + // Set up blueprint with kustomizations + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "blueprint-kustomization"}, } - // When calling Down - err := baseHandler.Down() + // Override: verify blueprint kustomizations are used when empty names provided + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil + } + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + expectedNames := []string{"blueprint-kustomization"} + if len(names) != len(expectedNames) { + t.Errorf("Expected %d names, got %d", len(expectedNames), len(names)) + } + if names[0] != expectedNames[0] { + t.Errorf("Expected name %s, got %s", expectedNames[0], names[0]) + } - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") + status := make(map[string]bool) + for _, name := range names { + status[name] = true + } + return status, nil } - if !strings.Contains(err.Error(), "suspend error") { - t.Errorf("Expected error about suspend error, got: %v", err) + + // When waiting with empty string as name + err := handler.WaitForKustomizations("Testing empty names", "") + + // Then no error should be returned and blueprint kustomizations should be used + if err != nil { + t.Errorf("Expected no error, got: %v", err) } }) - t.Run("ErrorWaitingForKustomizationsDeleted", func(t *testing.T) { - // Given a handler with a kustomization + t.Run("PartialReadiness", func(t *testing.T) { + // Given a blueprint handler with multiple kustomizations handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1"}, + setupFastTiming(handler) + + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "kustomization-1"}, + {Name: "kustomization-2"}, + {Name: "kustomization-3"}, } - // Set up mock Kubernetes manager to return error on wait for deletion - mocks.KubernetesManager.WaitForKustomizationsDeletedFunc = func(message string, names ...string) error { - return fmt.Errorf("wait for deletion error") + // Override: simulate gradual readiness + callCount := 0 + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { + return nil } + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + callCount++ + status := make(map[string]bool) + + // Simulate gradual readiness + switch callCount { + case 1: + // First call: only kustomization-1 ready + status["kustomization-1"] = true + status["kustomization-2"] = false + status["kustomization-3"] = false + case 2: + // Second call: kustomization-1 and kustomization-2 ready + status["kustomization-1"] = true + status["kustomization-2"] = true + status["kustomization-3"] = false + default: + // Third call and beyond: all ready + status["kustomization-1"] = true + status["kustomization-2"] = true + status["kustomization-3"] = true + } - // When calling Down - err := baseHandler.Down() + return status, nil + } - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing partial readiness") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) } - if !strings.Contains(err.Error(), "failed waiting for kustomizations to be deleted") { - t.Errorf("Expected error about waiting for kustomizations to be deleted, got: %v", err) + + // And GetKustomizationStatus should be called at least 3 times + if callCount < 3 { + t.Errorf("Expected at least 3 calls to GetKustomizationStatus, got %d", callCount) } }) - t.Run("ErrorDeletingCleanupKustomization", func(t *testing.T) { - // Given a handler with a kustomization with a cleanup path + t.Run("ImmediateReadyWithInitialError", func(t *testing.T) { + // Given a blueprint handler handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup"}}, - } + setupFastTiming(handler) - // Set up mock Kubernetes manager to return error on delete for cleanup - mocks.KubernetesManager.DeleteKustomizationFunc = func(name, namespace string) error { - return fmt.Errorf("delete cleanup error") + handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "test-kustomization"}, } - // When calling Down - err := baseHandler.Down() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to delete kustomization") { - t.Errorf("Expected error about failed to delete kustomization, got: %v", err) - } - }) - - t.Run("ErrorWaitingForCleanupKustomizationsDeleted", func(t *testing.T) { - // Given a handler with a kustomization with a cleanup path - handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup"}}, - } - - // Set up mock Kubernetes manager to return error on wait for cleanup deletion - mocks.KubernetesManager.WaitForKustomizationsDeletedFunc = func(message string, names ...string) error { - return fmt.Errorf("wait for cleanup deletion error") - } - - // When calling Down - err := baseHandler.Down() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed waiting for kustomizations to be deleted") { - t.Errorf("Expected error about failed waiting for kustomizations to be deleted, got: %v", err) - } - }) -} - -func TestBlueprintHandler_Install(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler with repository, sources, and kustomizations - handler, _ := setup(t) - - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.SetSources(expectedSources) - - expectedKustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization1", - }, - } - handler.SetKustomizations(expectedKustomizations) - - // When installing the blueprint - err = handler.Install() - - // Then no error should be returned - if err != nil { - t.Fatalf("Expected successful installation, but got error: %v", err) - } - }) - - t.Run("KustomizationDefaults", func(t *testing.T) { - // Given a blueprint handler with repository and kustomizations - handler, mocks := setup(t) - - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) - } - - // And a blueprint with metadata name - handler.(*BaseBlueprintHandler).blueprint.Metadata.Name = "test-blueprint" - - // And kustomizations with various configurations - kustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "k1", // No source, should use blueprint name - }, - { - Name: "k2", - Source: "custom-source", // Explicit source - }, - { - Name: "k3", // No path, should default to "kustomize" - }, - { - Name: "k4", - Path: "custom/path", // Custom path, should be prefixed with "kustomize/" - }, - { - Name: "k5", // No intervals/timeouts, should use defaults - }, - { - Name: "k6", - Interval: &metav1.Duration{Duration: 2 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 30 * time.Second}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - }, - } - handler.SetKustomizations(kustomizations) - - // And a mock that captures the applied kustomizations - var appliedKustomizations []kustomizev1.Kustomization - mocks.KubernetesManager.ApplyKustomizationFunc = func(k kustomizev1.Kustomization) error { - appliedKustomizations = append(appliedKustomizations, k) + // Override: fail on initial check but succeed immediately in polling + initialCall := true + mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { return nil } + mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { + if initialCall { + initialCall = false + return nil, fmt.Errorf("initial error") + } - // When installing the blueprint - err = handler.Install() - - // Then no error should be returned - if err != nil { - t.Fatalf("Expected successful installation, but got error: %v", err) - } - - // And the kustomizations should have the correct defaults - if len(appliedKustomizations) != 6 { - t.Fatalf("Expected 6 kustomizations to be applied, got %d", len(appliedKustomizations)) - } - - // Verify k1 (no source) - if appliedKustomizations[0].Spec.SourceRef.Name != "test-blueprint" { - t.Errorf("Expected k1 source to be 'test-blueprint', got '%s'", appliedKustomizations[0].Spec.SourceRef.Name) - } - - // Verify k2 (explicit source) - if appliedKustomizations[1].Spec.SourceRef.Name != "custom-source" { - t.Errorf("Expected k2 source to be 'custom-source', got '%s'", appliedKustomizations[1].Spec.SourceRef.Name) - } - - // Verify k3 (no path) - if appliedKustomizations[2].Spec.Path != "kustomize" { - t.Errorf("Expected k3 path to be 'kustomize', got '%s'", appliedKustomizations[2].Spec.Path) - } - - // Verify k4 (custom path) - if appliedKustomizations[3].Spec.Path != "kustomize/custom/path" { - t.Errorf("Expected k4 path to be 'kustomize/custom/path', got '%s'", appliedKustomizations[3].Spec.Path) - } - - // Verify k5 (default intervals/timeouts) - if appliedKustomizations[4].Spec.Interval.Duration != constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL { - t.Errorf("Expected k5 interval to be %v, got %v", constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL, appliedKustomizations[4].Spec.Interval.Duration) - } - if appliedKustomizations[4].Spec.RetryInterval.Duration != constants.DEFAULT_FLUX_KUSTOMIZATION_RETRY_INTERVAL { - t.Errorf("Expected k5 retry interval to be %v, got %v", constants.DEFAULT_FLUX_KUSTOMIZATION_RETRY_INTERVAL, appliedKustomizations[4].Spec.RetryInterval.Duration) - } - if appliedKustomizations[4].Spec.Timeout.Duration != constants.DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT { - t.Errorf("Expected k5 timeout to be %v, got %v", constants.DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT, appliedKustomizations[4].Spec.Timeout.Duration) - } - - // Verify k6 (custom intervals/timeouts) - if appliedKustomizations[5].Spec.Interval.Duration != 2*time.Minute { - t.Errorf("Expected k6 interval to be 2m, got %v", appliedKustomizations[5].Spec.Interval.Duration) - } - if appliedKustomizations[5].Spec.RetryInterval.Duration != 30*time.Second { - t.Errorf("Expected k6 retry interval to be 30s, got %v", appliedKustomizations[5].Spec.RetryInterval.Duration) - } - if appliedKustomizations[5].Spec.Timeout.Duration != 5*time.Minute { - t.Errorf("Expected k6 timeout to be 5m, got %v", appliedKustomizations[5].Spec.Timeout.Duration) + // Ready on subsequent calls + status := make(map[string]bool) + for _, name := range names { + status[name] = true + } + return status, nil } - }) - t.Run("ApplyKustomizationError", func(t *testing.T) { - // Given a blueprint handler with repository, sources, and kustomizations - handler, mocks := setup(t) + // When waiting for kustomizations + err := handler.WaitForKustomizations("Testing initial error recovery") - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) + // Then no error should be returned (should recover quickly) if err != nil { - t.Fatalf("Failed to set repository: %v", err) - } - - sources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - PathPrefix: "terraform", - }, - } - handler.SetSources(sources) - - kustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization1", - }, - } - handler.SetKustomizations(kustomizations) - - // Set up mock to return error for ApplyKustomization - mocks.KubernetesManager.ApplyKustomizationFunc = func(kustomization kustomizev1.Kustomization) error { - return fmt.Errorf("apply error") - } - - // When installing the blueprint - err = handler.Install() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to apply kustomization kustomization1") { - t.Errorf("Expected error about failed kustomization apply, got: %v", err) + t.Errorf("Expected no error after recovery, got: %v", err) } }) } @@ -2000,129 +1929,163 @@ func TestBlueprintHandler_Down(t *testing.T) { }) } }) -} - -func TestBaseBlueprintHandler_isValidTerraformRemoteSource(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - t.Run("ValidGitHTTPS", func(t *testing.T) { - // Given a blueprint handler + t.Run("EmptyKustomizations", func(t *testing.T) { + // Given a handler with no kustomizations handler, _ := setup(t) + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{} - // When checking a valid git HTTPS source - source := "git::https://github.com/example/repo.git" - valid := handler.isValidTerraformRemoteSource(source) + // When calling Down + err := baseHandler.Down() - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) } }) - t.Run("ValidGitSSH", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking a valid git SSH source - source := "git@github.com:example/repo.git" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) + t.Run("ErrorCreatingSystemCleanupNamespace", func(t *testing.T) { + // Given a handler with kustomizations that have cleanup paths + handler, mocks := setup(t) + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1", Cleanup: []string{"cleanup/path"}}, } - }) - t.Run("ValidHTTPS", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + // And a mock that fails to create system-cleanup namespace + mocks.KubernetesManager.CreateNamespaceFunc = func(name string) error { + if name == "system-cleanup" { + return fmt.Errorf("create namespace error") + } + return nil + } - // When checking a valid HTTPS source - source := "https://github.com/example/repo.git" - valid := handler.isValidTerraformRemoteSource(source) + // When calling Down + err := baseHandler.Down() - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to create system-cleanup namespace") { + t.Errorf("Expected system-cleanup namespace creation error, got: %v", err) } }) - t.Run("ValidZip", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking a valid ZIP source - source := "https://github.com/example/repo/archive/main.zip" - valid := handler.isValidTerraformRemoteSource(source) + t.Run("ErrorGettingHelmReleases", func(t *testing.T) { + // Given a handler with kustomizations + handler, mocks := setup(t) + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1"}, + } - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) + // And a mock that fails to get HelmReleases + mocks.KubernetesManager.GetHelmReleasesForKustomizationFunc = func(name, namespace string) ([]helmv2.HelmRelease, error) { + return nil, fmt.Errorf("get helmreleases error") } - }) - t.Run("ValidRegistry", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + // When calling Down + err := baseHandler.Down() - // When checking a valid registry source - source := "registry.terraform.io/example/module" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to get helmreleases for kustomization") { + t.Errorf("Expected helmreleases error, got: %v", err) } }) - t.Run("ValidCustomDomain", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + t.Run("ErrorSuspendingHelmRelease", func(t *testing.T) { + // Given a handler with kustomizations + handler, mocks := setup(t) + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1"}, + } + + // And a mock that returns HelmReleases but fails to suspend them + mocks.KubernetesManager.GetHelmReleasesForKustomizationFunc = func(name, namespace string) ([]helmv2.HelmRelease, error) { + return []helmv2.HelmRelease{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-helm-release", + Namespace: "test-namespace", + }, + }, + }, nil + } + mocks.KubernetesManager.SuspendHelmReleaseFunc = func(name, namespace string) error { + return fmt.Errorf("suspend helmrelease error") + } - // When checking a valid custom domain source - source := "example.com/module" - valid := handler.isValidTerraformRemoteSource(source) + // When calling Down + err := baseHandler.Down() - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to suspend helmrelease") { + t.Errorf("Expected helmrelease suspend error, got: %v", err) } }) - t.Run("InvalidSource", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + t.Run("ErrorDeletingCleanupKustomizations", func(t *testing.T) { + // Given a handler with kustomizations that have cleanup paths + handler, mocks := setup(t) + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1", Cleanup: []string{"cleanup/path"}}, + } + + // And a mock that fails to delete cleanup kustomizations + mocks.KubernetesManager.DeleteKustomizationFunc = func(name, namespace string) error { + if strings.Contains(name, "cleanup") { + return fmt.Errorf("delete cleanup kustomization error") + } + return nil + } - // When checking an invalid source - source := "invalid-source" - valid := handler.isValidTerraformRemoteSource(source) + // When calling Down + err := baseHandler.Down() - // Then it should be invalid - if valid { - t.Errorf("Expected %s to be invalid, got valid", source) + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to delete cleanup kustomization") { + t.Errorf("Expected cleanup kustomization deletion error, got: %v", err) } }) - t.Run("InvalidRegex", func(t *testing.T) { - // Given a blueprint handler with a mock that returns error + t.Run("ErrorDeletingSystemCleanupNamespace", func(t *testing.T) { + // Given a handler with kustomizations that have cleanup paths handler, mocks := setup(t) - mocks.Shims.RegexpMatchString = func(pattern, s string) (bool, error) { - return false, fmt.Errorf("mock regex error") + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1", Cleanup: []string{"cleanup/path"}}, + } + + // And a mock that fails to delete system-cleanup namespace + mocks.KubernetesManager.DeleteNamespaceFunc = func(name string) error { + if name == "system-cleanup" { + return fmt.Errorf("delete namespace error") + } + return nil } - // When checking a source with regex error - source := "git::https://github.com/example/repo.git" - valid := handler.isValidTerraformRemoteSource(source) + // When calling Down + err := baseHandler.Down() - // Then it should be invalid - if valid { - t.Errorf("Expected %s to be invalid with regex error, got valid", source) + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to delete system-cleanup namespace") { + t.Errorf("Expected system-cleanup namespace deletion error, got: %v", err) } }) } @@ -2267,7 +2230,7 @@ func TestBlueprintHandler_GetRepository(t *testing.T) { Url: "git::https://example.com/repo.git", Ref: blueprintv1alpha1.Reference{Branch: "main"}, } - handler.SetRepository(expectedRepo) + handler.blueprint.Repository = expectedRepo // When getting the repository repo := handler.GetRepository() @@ -2281,7 +2244,7 @@ func TestBlueprintHandler_GetRepository(t *testing.T) { t.Run("ReturnsDefaultValues", func(t *testing.T) { // Given a blueprint handler with an empty repository handler, _ := setup(t) - handler.SetRepository(blueprintv1alpha1.Repository{}) + handler.blueprint.Repository = blueprintv1alpha1.Repository{} // When getting the repository repo := handler.GetRepository() @@ -2330,10 +2293,7 @@ func TestBlueprintHandler_GetSources(t *testing.T) { PathPrefix: "/source2", }, } - err := handler.SetSources(expectedSources) - if err != nil { - t.Fatalf("Failed to set sources: %v", err) - } + handler.blueprint.Sources = expectedSources // When getting sources sources := handler.GetSources() @@ -2380,7 +2340,7 @@ func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { PathPrefix: "terraform", }, } - handler.SetSources(sources) + handler.blueprint.Sources = sources // And a set of terraform components components := []blueprintv1alpha1.TerraformComponent{ @@ -2390,7 +2350,7 @@ func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { Values: map[string]any{"key": "value"}, }, } - handler.SetTerraformComponents(components) + handler.blueprint.TerraformComponents = components // When getting terraform components resolvedComponents := handler.GetTerraformComponents() @@ -2423,10 +2383,7 @@ func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { handler, _ := setup(t) // And an empty set of terraform components - err := handler.SetTerraformComponents([]blueprintv1alpha1.TerraformComponent{}) - if err != nil { - t.Fatalf("Failed to set empty components: %v", err) - } + handler.blueprint.TerraformComponents = []blueprintv1alpha1.TerraformComponent{} // When getting terraform components components := handler.GetTerraformComponents() @@ -2457,7 +2414,7 @@ func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { PathPrefix: "terraform", }, } - handler.SetSources(sources) + handler.blueprint.Sources = sources // And a set of terraform components with backslashes in paths components := []blueprintv1alpha1.TerraformComponent{ @@ -2467,7 +2424,7 @@ func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { Values: map[string]any{"key": "value"}, }, } - handler.SetTerraformComponents(components) + handler.blueprint.TerraformComponents = components // When getting terraform components resolvedComponents := handler.GetTerraformComponents() @@ -2490,3 +2447,1339 @@ func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { } }) } + +func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("Success_TemplateProcessing", func(t *testing.T) { + // Given a blueprint handler with template directory + handler, _ := setup(t) + + // Override: template directory exists + templateDir := "/mock/project/contexts/_template" + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned (empty template directory is valid) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Success_TemplateProcessing_WithJsonnetFiles", func(t *testing.T) { + // Given a blueprint handler with template directory containing jsonnet files + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint',\n metadata: { name: context.name }\n}" + tfvarsJsonnet := "local context = std.extVar('context');\n'cluster_name = \"' + context.name + '\"'" + + // Override: template directory exists with jsonnet files + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + &mockDirEntry{name: "terraform", isDir: true}, + }, nil + } + if path == filepath.Join(templateDir, "terraform") { + return []os.DirEntry{ + &mockDirEntry{name: "cluster.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case filepath.Join(templateDir, "blueprint.jsonnet"): + return []byte(blueprintJsonnet), nil + case filepath.Join(templateDir, "terraform", "cluster.jsonnet"): + return []byte(tfvarsJsonnet), nil + default: + return nil, fmt.Errorf("file not found") + } + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + if strings.Contains(snippet, "Blueprint") { + return "kind: Blueprint\nmetadata:\n name: test-context", nil + } + return "cluster_name = \"test-context\"", nil + }, + } + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Success_DefaultBlueprintGeneration", func(t *testing.T) { + // Given a blueprint handler without template directory (uses default setup) + handler, _ := setup(t) + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned (default blueprint generation) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Success_DefaultBlueprintGeneration_WithPlatformTemplate", func(t *testing.T) { + // Given a blueprint handler with specific platform + handler, mocks := setup(t) + + // Override: config handler returns specific platform + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "cluster.platform": + return "metal" + case "context": + return "test-context" + default: + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" + } + } + + // Mock jsonnet VM for platform template evaluation + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint\nmetadata:\n name: test-context\n description: Metal platform blueprint", nil + }, + } + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Success_BlueprintFileExists_NoReset", func(t *testing.T) { + // Given a blueprint handler where blueprint.yaml already exists + handler, _ := setup(t) + + blueprintPath := "/mock/project/contexts/test-context/blueprint.yaml" + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == blueprintPath { + return mockFileInfo{name: "blueprint.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // When processing context templates without reset + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned and no new blueprint should be created + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Success_BlueprintFileExists_WithReset", func(t *testing.T) { + // Given a blueprint handler where blueprint.yaml already exists + handler, _ := setup(t) + + blueprintPath := "/mock/project/contexts/test-context/blueprint.yaml" + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == blueprintPath { + return mockFileInfo{name: "blueprint.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // Mock jsonnet VM for platform template evaluation + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint\nmetadata:\n name: test-context", nil + }, + } + } + + // When processing context templates with reset + err := handler.ProcessContextTemplates("test-context", true) + + // Then no error should be returned and blueprint should be recreated + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Success_EmptyPlatformTemplate_FallsBackToDefault", func(t *testing.T) { + // Given a blueprint handler with empty platform template + handler, mocks := setup(t) + + // Override: config handler returns unknown platform + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "cluster.platform": + return "unknown-platform" + case "context": + return "test-context" + default: + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" + } + } + + // Mock jsonnet VM for default template evaluation + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint\nmetadata:\n name: test-context", nil + }, + } + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Success_EmptyJsonnetEvaluation_FallsBackToDefaultBlueprint", func(t *testing.T) { + // Given a blueprint handler with jsonnet that evaluates to empty string + handler, _ := setup(t) + + // Mock jsonnet VM that returns empty string + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "", nil + }, + } + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned (falls back to DefaultBlueprint) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Error_GetProjectRoot", func(t *testing.T) { + // Given a blueprint handler with shell error + handler, mocks := setup(t) + + // Override: shell returns error + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error getting project root") { + t.Errorf("Expected project root error, got: %v", err) + } + }) + + t.Run("Error_CreateContextDirectory", func(t *testing.T) { + // Given a blueprint handler with MkdirAll error + handler, _ := setup(t) + + // Override: MkdirAll returns error + handler.shims.MkdirAll = func(path string, perm os.FileMode) error { + return fmt.Errorf("mkdir error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error creating context directory") { + t.Errorf("Expected context directory error, got: %v", err) + } + }) + + t.Run("Error_ReadTemplateDirectory", func(t *testing.T) { + // Given a blueprint handler with ReadDir error + handler, _ := setup(t) + + // Override: template directory exists but ReadDir fails + templateDir := "/mock/project/contexts/_template" + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + return nil, fmt.Errorf("read dir error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error reading template directory") { + t.Errorf("Expected read directory error, got: %v", err) + } + }) + + t.Run("Error_ProcessJsonnetTemplate_ReadFile", func(t *testing.T) { + // Given a blueprint handler with template directory containing jsonnet file + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return nil, fmt.Errorf("read file error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error processing template") { + t.Errorf("Expected template processing error, got: %v", err) + } + }) + + t.Run("Error_ProcessJsonnetTemplate_JsonnetEvaluation", func(t *testing.T) { + // Given a blueprint handler with template directory containing jsonnet file + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "invalid jsonnet syntax" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Mock jsonnet VM that returns error + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "", fmt.Errorf("jsonnet evaluation error") + }, + } + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error processing template") { + t.Errorf("Expected template processing error, got: %v", err) + } + }) + + t.Run("Error_ProcessJsonnetTemplate_WriteFile", func(t *testing.T) { + // Given a blueprint handler with template directory containing jsonnet file + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint", nil + }, + } + } + + // Override: WriteFile returns error + handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write file error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error processing template") { + t.Errorf("Expected template processing error, got: %v", err) + } + }) + + t.Run("Success_WithResetMode", func(t *testing.T) { + // Given a blueprint handler with template directory + handler, _ := setup(t) + + // Override: template directory exists + templateDir := "/mock/project/contexts/_template" + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + // When processing context templates with reset mode + err := handler.ProcessContextTemplates("test-context", true) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Success_ProcessJsonnetTemplate_BlueprintExtension", func(t *testing.T) { + // Given a blueprint handler with template directory containing blueprint jsonnet + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint',\n metadata: { name: context.name }\n}" + + // Override: template directory exists with blueprint jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint\nmetadata:\n name: test-context", nil + }, + } + } + + // Track written files to verify .yaml extension + var writtenFiles []string + handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + writtenFiles = append(writtenFiles, path) + return nil + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And blueprint file should have .yaml extension + if len(writtenFiles) != 1 { + t.Fatalf("Expected 1 file written, got %d", len(writtenFiles)) + } + if !strings.HasSuffix(writtenFiles[0], "blueprint.yaml") { + t.Errorf("Expected blueprint file to have .yaml extension, got: %s", writtenFiles[0]) + } + }) + + t.Run("Success_ProcessJsonnetTemplate_TfvarsExtension", func(t *testing.T) { + // Given a blueprint handler with template directory containing terraform jsonnet + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + terraformJsonnet := "local context = std.extVar('context');\n'cluster_name = \"' + context.name + '\"'" + + // Override: template directory exists with terraform jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "terraform", isDir: true}, + }, nil + } + if path == filepath.Join(templateDir, "terraform") { + return []os.DirEntry{ + &mockDirEntry{name: "cluster.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(terraformJsonnet), nil + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "cluster_name = \"test-context\"", nil + }, + } + } + + // Track written files to verify .tfvars extension + var writtenFiles []string + handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + writtenFiles = append(writtenFiles, path) + return nil + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And terraform file should have .tfvars extension + if len(writtenFiles) != 1 { + t.Fatalf("Expected 1 file written, got %d", len(writtenFiles)) + } + if !strings.HasSuffix(writtenFiles[0], "cluster.tfvars") { + t.Errorf("Expected terraform file to have .tfvars extension, got: %s", writtenFiles[0]) + } + }) + + t.Run("Success_ProcessJsonnetTemplate_DefaultYamlExtension", func(t *testing.T) { + // Given a blueprint handler with template directory containing other jsonnet + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + otherJsonnet := "local context = std.extVar('context');\n{\n name: context.name\n}" + + // Override: template directory exists with other jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "config.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(otherJsonnet), nil + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "name: test-context", nil + }, + } + } + + // Track written files to verify .yaml extension + var writtenFiles []string + handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + writtenFiles = append(writtenFiles, path) + return nil + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And config file should have .yaml extension by default + if len(writtenFiles) != 1 { + t.Fatalf("Expected 1 file written, got %d", len(writtenFiles)) + } + if !strings.HasSuffix(writtenFiles[0], "config.yaml") { + t.Errorf("Expected config file to have .yaml extension, got: %s", writtenFiles[0]) + } + }) + + t.Run("Success_ProcessJsonnetTemplate_FileExistsNoReset", func(t *testing.T) { + // Given a blueprint handler with template directory and existing output file + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + // Simulate output file already exists + if strings.HasSuffix(path, "blueprint.yaml") { + return mockFileInfo{name: "blueprint.yaml"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint", nil + }, + } + } + + // Track if WriteFile is called (it shouldn't be) + writeFileCalled := false + handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + writeFileCalled = true + return nil + } + + // When processing context templates without reset + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And WriteFile should not be called since file exists + if writeFileCalled { + t.Error("Expected WriteFile not to be called when file exists and reset is false") + } + }) + + t.Run("Error_ProcessJsonnetTemplate_MkdirAllFails", func(t *testing.T) { + // Given a blueprint handler with template directory and MkdirAll error for output directory + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "nested", isDir: true}, + }, nil + } + if path == filepath.Join(templateDir, "nested") { + return []os.DirEntry{ + &mockDirEntry{name: "deep.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint", nil + }, + } + } + + // Override: MkdirAll returns error only for output directory, not context directory + handler.shims.MkdirAll = func(path string, perm os.FileMode) error { + // Allow context directory creation to succeed + if strings.Contains(path, "contexts/test-context") && !strings.Contains(path, "nested") { + return nil + } + // Fail when creating nested output directory + return fmt.Errorf("mkdir error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error processing template") { + t.Errorf("Expected template processing error, got: %v", err) + } + }) + + t.Run("Error_ProcessJsonnetTemplate_RelativePathError", func(t *testing.T) { + // Given a blueprint handler with template directory and relative path error + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint", nil + }, + } + } + + // This is harder to test since filepath.Rel rarely fails, but we can simulate + // by making the template file path invalid relative to template dir + // We'll skip this test as it's very difficult to trigger filepath.Rel error + }) + + t.Run("Error_ProcessJsonnetTemplate_YamlMarshalError", func(t *testing.T) { + // Given a blueprint handler with template directory and YAML marshal error + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Override: YamlMarshal returns error during context marshaling + handler.shims.YamlMarshal = func(v interface{}) ([]byte, error) { + return nil, fmt.Errorf("yaml marshal error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error processing template") { + t.Errorf("Expected template processing error, got: %v", err) + } + }) + + t.Run("Error_ProcessJsonnetTemplate_YamlUnmarshalError", func(t *testing.T) { + // Given a blueprint handler with template directory and YAML unmarshal error + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Override: YamlUnmarshal returns error during context unmarshaling + handler.shims.YamlUnmarshal = func(data []byte, v interface{}) error { + return fmt.Errorf("yaml unmarshal error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error processing template") { + t.Errorf("Expected template processing error, got: %v", err) + } + }) + + t.Run("Error_ProcessJsonnetTemplate_JsonMarshalError", func(t *testing.T) { + // Given a blueprint handler with template directory and JSON marshal error + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" + + // Override: template directory exists with jsonnet file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Override: JsonMarshal returns error during context marshaling + handler.shims.JsonMarshal = func(v interface{}) ([]byte, error) { + return nil, fmt.Errorf("json marshal error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error processing template") { + t.Errorf("Expected template processing error, got: %v", err) + } + }) + + t.Run("Success_NestedDirectoryWalking", func(t *testing.T) { + // Given a blueprint handler with nested template directories + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" + + // Override: template directory exists with nested structure + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "_template"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "nested", isDir: true}, + &mockDirEntry{name: "root.jsonnet", isDir: false}, + }, nil + } + if path == filepath.Join(templateDir, "nested") { + return []os.DirEntry{ + &mockDirEntry{name: "deep", isDir: true}, + &mockDirEntry{name: "nested.jsonnet", isDir: false}, + }, nil + } + if path == filepath.Join(templateDir, "nested", "deep") { + return []os.DirEntry{ + &mockDirEntry{name: "deep.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte(blueprintJsonnet), nil + } + + // Mock jsonnet VM + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "kind: Blueprint", nil + }, + } + } + + // Track written files to verify all levels processed + var writtenFiles []string + handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + writtenFiles = append(writtenFiles, path) + return nil + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And all jsonnet files should be processed (3 files) + if len(writtenFiles) != 3 { + t.Errorf("Expected 3 files written, got %d: %v", len(writtenFiles), writtenFiles) + } + }) + + t.Run("Error_DefaultBlueprintGeneration_JsonnetEvaluationError", func(t *testing.T) { + // Given a blueprint handler with no template directory and jsonnet evaluation error + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + + // Override: template directory does not exist + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return nil, os.ErrNotExist + } + // Blueprint file also doesn't exist + if strings.HasSuffix(path, "blueprint.yaml") { + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + // Mock jsonnet VM that returns error + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "", fmt.Errorf("jsonnet evaluation error") + }, + } + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error generating blueprint from jsonnet") { + t.Errorf("Expected jsonnet evaluation error, got: %v", err) + } + }) + + t.Run("Error_DefaultBlueprintGeneration_YamlMarshalDefaultError", func(t *testing.T) { + // Given a blueprint handler with no template directory and YAML marshal error for default blueprint + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + + // Override: template directory does not exist + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return nil, os.ErrNotExist + } + // Blueprint file also doesn't exist + if strings.HasSuffix(path, "blueprint.yaml") { + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + // Override: ReadFile returns empty for platform templates (triggers fallback) + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte{}, nil + } + + // Override: YamlMarshal returns error for default blueprint + originalYamlMarshal := handler.shims.YamlMarshal + handler.shims.YamlMarshal = func(v interface{}) ([]byte, error) { + // Allow context marshaling to succeed, but fail on default blueprint + if _, ok := v.(map[string]interface{}); ok { + return originalYamlMarshal(v) + } + return nil, fmt.Errorf("yaml marshal default blueprint error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error marshalling default blueprint") { + t.Errorf("Expected default blueprint marshalling error, got: %v", err) + } + }) + + t.Run("Error_DefaultBlueprintGeneration_WriteFileError", func(t *testing.T) { + // Given a blueprint handler with no template directory and write file error + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + + // Override: template directory does not exist + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return nil, os.ErrNotExist + } + // Blueprint file also doesn't exist + if strings.HasSuffix(path, "blueprint.yaml") { + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + // Override: ReadFile returns empty for platform templates (triggers fallback) + handler.shims.ReadFile = func(path string) ([]byte, error) { + return []byte{}, nil + } + + // Override: WriteFile returns error + handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write file error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error writing blueprint file") { + t.Errorf("Expected blueprint file writing error, got: %v", err) + } + }) + + t.Run("Success_DefaultBlueprintGeneration_WithPlatformTemplate_EmptyJsonnet", func(t *testing.T) { + // Given a blueprint handler with platform template that evaluates to empty + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + + // Override: template directory does not exist + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return nil, os.ErrNotExist + } + // Blueprint file also doesn't exist + if strings.HasSuffix(path, "blueprint.yaml") { + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + // Override: ReadFile returns platform template + handler.shims.ReadFile = func(path string) ([]byte, error) { + if strings.Contains(path, "templates") { + return []byte("local context = std.extVar('context'); ''"), nil + } + return nil, os.ErrNotExist + } + + // Mock jsonnet VM that returns empty string + handler.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "", nil // Empty evaluation triggers fallback + }, + } + } + + // Track written files + var writtenFiles []string + var writtenData [][]byte + handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { + writtenFiles = append(writtenFiles, path) + writtenData = append(writtenData, data) + return nil + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And default blueprint should be written + if len(writtenFiles) != 1 { + t.Fatalf("Expected 1 file written, got %d", len(writtenFiles)) + } + if !strings.HasSuffix(writtenFiles[0], "blueprint.yaml") { + t.Errorf("Expected blueprint.yaml to be written, got: %s", writtenFiles[0]) + } + // Verify it contains default blueprint content + content := string(writtenData[0]) + if !strings.Contains(content, "test-context") { + t.Errorf("Expected blueprint to contain context name, got: %s", content) + } + }) + + t.Run("Error_ContextYamlUnmarshal", func(t *testing.T) { + // Given a blueprint handler with platform template + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + + // Override: template directory does not exist, triggering platform template path + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return nil, os.ErrNotExist + } + // Blueprint file also doesn't exist + if strings.HasSuffix(path, "blueprint.yaml") { + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + // Override: ReadFile returns platform template + handler.shims.ReadFile = func(path string) ([]byte, error) { + if strings.Contains(path, "templates") { + return []byte("local context = std.extVar('context'); {}"), nil + } + return nil, os.ErrNotExist + } + + // Override: YamlUnmarshal fails for context YAML + handler.shims.YamlUnmarshal = func(data []byte, v interface{}) error { + // Let the first call (for config) succeed, fail on context map unmarshal + if _, ok := v.(*map[string]interface{}); ok { + return fmt.Errorf("yaml unmarshal context error") + } + return nil + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error unmarshalling context YAML") { + t.Errorf("Expected context YAML unmarshalling error, got: %v", err) + } + }) + + t.Run("Error_ContextJsonMarshal", func(t *testing.T) { + // Given a blueprint handler with platform template + handler, _ := setup(t) + + templateDir := "/mock/project/contexts/_template" + + // Override: template directory does not exist, triggering platform template path + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return nil, os.ErrNotExist + } + // Blueprint file also doesn't exist + if strings.HasSuffix(path, "blueprint.yaml") { + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + // Override: ReadFile returns platform template + handler.shims.ReadFile = func(path string) ([]byte, error) { + if strings.Contains(path, "templates") { + return []byte("local context = std.extVar('context'); {}"), nil + } + return nil, os.ErrNotExist + } + + // Override: JsonMarshal fails for context JSON + handler.shims.JsonMarshal = func(v interface{}) ([]byte, error) { + return nil, fmt.Errorf("json marshal context error") + } + + // When processing context templates + err := handler.ProcessContextTemplates("test-context") + + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error marshalling context map to JSON") { + t.Errorf("Expected context JSON marshalling error, got: %v", err) + } + }) +} diff --git a/pkg/blueprint/mock_blueprint_handler.go b/pkg/blueprint/mock_blueprint_handler.go index c4a5a08ca..ae65eddbe 100644 --- a/pkg/blueprint/mock_blueprint_handler.go +++ b/pkg/blueprint/mock_blueprint_handler.go @@ -13,16 +13,13 @@ type MockBlueprintHandler struct { GetSourcesFunc func() []blueprintv1alpha1.Source GetTerraformComponentsFunc func() []blueprintv1alpha1.TerraformComponent GetKustomizationsFunc func() []blueprintv1alpha1.Kustomization - SetMetadataFunc func(metadata blueprintv1alpha1.Metadata) error - SetSourcesFunc func(sources []blueprintv1alpha1.Source) error - SetTerraformComponentsFunc func(terraformComponents []blueprintv1alpha1.TerraformComponent) error - SetKustomizationsFunc func(kustomizations []blueprintv1alpha1.Kustomization) error - WaitForKustomizationsFunc func(message string, names ...string) error - WriteConfigFunc func(overwrite ...bool) error - InstallFunc func() error - GetRepositoryFunc func() blueprintv1alpha1.Repository - SetRepositoryFunc func(repository blueprintv1alpha1.Repository) error - DownFunc func() error + + WaitForKustomizationsFunc func(message string, names ...string) error + ProcessContextTemplatesFunc func(contextName string, reset ...bool) error + InstallFunc func() error + GetRepositoryFunc func() blueprintv1alpha1.Repository + + DownFunc func() error } // ============================================================================= @@ -90,46 +87,6 @@ func (m *MockBlueprintHandler) GetKustomizations() []blueprintv1alpha1.Kustomiza return []blueprintv1alpha1.Kustomization{} } -// SetMetadata calls the mock SetMetadataFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) SetMetadata(metadata blueprintv1alpha1.Metadata) error { - if m.SetMetadataFunc != nil { - return m.SetMetadataFunc(metadata) - } - return nil -} - -// SetSources calls the mock SetSourcesFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) SetSources(sources []blueprintv1alpha1.Source) error { - if m.SetSourcesFunc != nil { - return m.SetSourcesFunc(sources) - } - return nil -} - -// SetTerraformComponents calls the mock SetTerraformComponentsFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) SetTerraformComponents(terraformComponents []blueprintv1alpha1.TerraformComponent) error { - if m.SetTerraformComponentsFunc != nil { - return m.SetTerraformComponentsFunc(terraformComponents) - } - return nil -} - -// SetKustomizations calls the mock SetKustomizationsFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) SetKustomizations(kustomizations []blueprintv1alpha1.Kustomization) error { - if m.SetKustomizationsFunc != nil { - return m.SetKustomizationsFunc(kustomizations) - } - return nil -} - -// WriteConfig calls the mock WriteConfigFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) WriteConfig(overwrite ...bool) error { - if m.WriteConfigFunc != nil { - return m.WriteConfigFunc(overwrite...) - } - return nil -} - // Install calls the mock InstallFunc if set, otherwise returns nil func (m *MockBlueprintHandler) Install() error { if m.InstallFunc != nil { @@ -146,14 +103,6 @@ func (m *MockBlueprintHandler) GetRepository() blueprintv1alpha1.Repository { return blueprintv1alpha1.Repository{} } -// SetRepository calls the mock SetRepositoryFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) SetRepository(repository blueprintv1alpha1.Repository) error { - if m.SetRepositoryFunc != nil { - return m.SetRepositoryFunc(repository) - } - return nil -} - // Down mocks the Down method. func (m *MockBlueprintHandler) Down() error { if m.DownFunc != nil { @@ -170,5 +119,13 @@ func (m *MockBlueprintHandler) WaitForKustomizations(message string, names ...st return nil } +// ProcessContextTemplates calls the mock ProcessContextTemplatesFunc if set, otherwise returns nil +func (m *MockBlueprintHandler) ProcessContextTemplates(contextName string, reset ...bool) error { + if m.ProcessContextTemplatesFunc != nil { + return m.ProcessContextTemplatesFunc(contextName, reset...) + } + return nil +} + // Ensure MockBlueprintHandler implements BlueprintHandler var _ BlueprintHandler = (*MockBlueprintHandler)(nil) diff --git a/pkg/blueprint/mock_blueprint_handler_test.go b/pkg/blueprint/mock_blueprint_handler_test.go index 8d3f11134..94b583c2e 100644 --- a/pkg/blueprint/mock_blueprint_handler_test.go +++ b/pkg/blueprint/mock_blueprint_handler_test.go @@ -223,186 +223,6 @@ func TestMockBlueprintHandler_GetKustomizations(t *testing.T) { }) } -func TestMockBlueprintHandler_SetMetadata(t *testing.T) { - setup := func(t *testing.T) *MockBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewMockBlueprintHandler(injector) - return handler - } - - mockSetErr := fmt.Errorf("mock set metadata error") - - t.Run("WithFuncSet", func(t *testing.T) { - // Given a mock handler with set metadata function - handler := setup(t) - handler.SetMetadataFunc = func(metadata blueprintv1alpha1.Metadata) error { - return mockSetErr - } - // When setting metadata - err := handler.SetMetadata(blueprintv1alpha1.Metadata{}) - // Then expected error should be returned - if err != mockSetErr { - t.Errorf("Expected error = %v, got = %v", mockSetErr, err) - } - }) - - t.Run("WithNoFuncSet", func(t *testing.T) { - // Given a mock handler without set metadata function - handler := setup(t) - // When setting metadata - err := handler.SetMetadata(blueprintv1alpha1.Metadata{}) - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) -} - -func TestMockBlueprintHandler_SetSources(t *testing.T) { - setup := func(t *testing.T) *MockBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewMockBlueprintHandler(injector) - return handler - } - - mockSetErr := fmt.Errorf("mock set sources error") - - t.Run("WithFuncSet", func(t *testing.T) { - // Given a mock handler with set sources function - handler := setup(t) - handler.SetSourcesFunc = func(sources []blueprintv1alpha1.Source) error { - return mockSetErr - } - // When setting sources - err := handler.SetSources([]blueprintv1alpha1.Source{}) - // Then expected error should be returned - if err != mockSetErr { - t.Errorf("Expected error = %v, got = %v", mockSetErr, err) - } - }) - - t.Run("WithNoFuncSet", func(t *testing.T) { - // Given a mock handler without set sources function - handler := setup(t) - // When setting sources - err := handler.SetSources([]blueprintv1alpha1.Source{}) - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) -} - -func TestMockBlueprintHandler_SetTerraformComponents(t *testing.T) { - setup := func(t *testing.T) *MockBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewMockBlueprintHandler(injector) - return handler - } - - mockSetErr := fmt.Errorf("mock set terraform components error") - - t.Run("WithFuncSet", func(t *testing.T) { - // Given a mock handler with set terraform components function - handler := setup(t) - handler.SetTerraformComponentsFunc = func(components []blueprintv1alpha1.TerraformComponent) error { - return mockSetErr - } - // When setting terraform components - err := handler.SetTerraformComponents([]blueprintv1alpha1.TerraformComponent{}) - // Then expected error should be returned - if err != mockSetErr { - t.Errorf("Expected error = %v, got = %v", mockSetErr, err) - } - }) - - t.Run("WithNoFuncSet", func(t *testing.T) { - // Given a mock handler without set terraform components function - handler := setup(t) - // When setting terraform components - err := handler.SetTerraformComponents([]blueprintv1alpha1.TerraformComponent{}) - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) -} - -func TestMockBlueprintHandler_SetKustomizations(t *testing.T) { - setup := func(t *testing.T) *MockBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewMockBlueprintHandler(injector) - return handler - } - - mockSetErr := fmt.Errorf("mock set kustomizations error") - - t.Run("WithFuncSet", func(t *testing.T) { - // Given a mock handler with set kustomizations function - handler := setup(t) - handler.SetKustomizationsFunc = func(kustomizations []blueprintv1alpha1.Kustomization) error { - return mockSetErr - } - // When setting kustomizations - err := handler.SetKustomizations([]blueprintv1alpha1.Kustomization{}) - // Then expected error should be returned - if err != mockSetErr { - t.Errorf("Expected error = %v, got = %v", mockSetErr, err) - } - }) - - t.Run("WithNoFuncSet", func(t *testing.T) { - // Given a mock handler without set kustomizations function - handler := setup(t) - // When setting kustomizations - err := handler.SetKustomizations([]blueprintv1alpha1.Kustomization{}) - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) -} - -func TestMockBlueprintHandler_WriteConfig(t *testing.T) { - setup := func(t *testing.T) *MockBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewMockBlueprintHandler(injector) - return handler - } - - mockWriteErr := fmt.Errorf("mock write config error") - - t.Run("WithFuncSet", func(t *testing.T) { - // Given a mock handler with write config function - handler := setup(t) - handler.WriteConfigFunc = func(overwrite ...bool) error { - return mockWriteErr - } - // When writing config - err := handler.WriteConfig() - // Then expected error should be returned - if err != mockWriteErr { - t.Errorf("Expected error = %v, got = %v", mockWriteErr, err) - } - }) - - t.Run("WithNoFuncSet", func(t *testing.T) { - // Given a mock handler without write config function - handler := setup(t) - // When writing config - err := handler.WriteConfig() - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) -} - func TestMockBlueprintHandler_Install(t *testing.T) { setup := func(t *testing.T) *MockBlueprintHandler { t.Helper() @@ -477,52 +297,6 @@ func TestMockBlueprintHandler_GetRepository(t *testing.T) { }) } -func TestMockBlueprintHandler_SetRepository(t *testing.T) { - setup := func(t *testing.T) *MockBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewMockBlueprintHandler(injector) - return handler - } - - t.Run("DefaultBehavior", func(t *testing.T) { - // Given a mock handler without set repository function - handler := setup(t) - repo := blueprintv1alpha1.Repository{ - Url: "test-url", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - // When setting repository - err := handler.SetRepository(repo) - // Then no error should be returned - if err != nil { - t.Errorf("Expected nil error, got %v", err) - } - }) - - t.Run("WithMockFunction", func(t *testing.T) { - // Given a mock handler with set repository function - handler := setup(t) - expectedError := fmt.Errorf("mock error") - repo := blueprintv1alpha1.Repository{ - Url: "test-url", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - handler.SetRepositoryFunc = func(r blueprintv1alpha1.Repository) error { - if r != repo { - t.Errorf("Expected repository %+v, got %+v", repo, r) - } - return expectedError - } - // When setting repository - err := handler.SetRepository(repo) - // Then expected error should be returned - if err != expectedError { - t.Errorf("Expected error %v, got %v", expectedError, err) - } - }) -} - func TestMockBlueprintHandler_WaitForKustomizations(t *testing.T) { setup := func(t *testing.T) *MockBlueprintHandler { t.Helper() diff --git a/pkg/blueprint/shims.go b/pkg/blueprint/shims.go index 04352b7fa..92526de9c 100644 --- a/pkg/blueprint/shims.go +++ b/pkg/blueprint/shims.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "regexp" + "time" "github.com/goccy/go-yaml" "github.com/google/go-jsonnet" @@ -32,10 +33,16 @@ type Shims struct { MkdirAll func(string, os.FileMode) error Stat func(string) (os.FileInfo, error) ReadFile func(string) ([]byte, error) + ReadDir func(string) ([]os.DirEntry, error) // Utility shims RegexpMatchString func(pattern string, s string) (bool, error) + // Timing shims + TimeAfter func(time.Duration) <-chan time.Time + NewTicker func(time.Duration) *time.Ticker + TickerStop func(*time.Ticker) + // Kubernetes shims ClientcmdBuildConfigFromFlags func(masterUrl, kubeconfigPath string) (*rest.Config, error) RestInClusterConfig func() (*rest.Config, error) @@ -63,10 +70,16 @@ func NewShims() *Shims { MkdirAll: os.MkdirAll, Stat: os.Stat, ReadFile: os.ReadFile, + ReadDir: os.ReadDir, // Utility shims RegexpMatchString: regexp.MatchString, + // Timing shims + TimeAfter: time.After, + NewTicker: time.NewTicker, + TickerStop: func(t *time.Ticker) { t.Stop() }, + // Kubernetes shims ClientcmdBuildConfigFromFlags: clientcmd.BuildConfigFromFlags, RestInClusterConfig: rest.InClusterConfig, diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 43f582c4e..eee7d9572 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -456,15 +456,6 @@ func (c *BaseController) WriteConfigurationFiles() error { } } - if req.Blueprint { - blueprintHandler := c.ResolveBlueprintHandler() - if blueprintHandler != nil { - if err := blueprintHandler.WriteConfig(req.Reset); err != nil { - return fmt.Errorf("error writing blueprint config: %w", err) - } - } - } - if req.Services { resolvedServices := c.ResolveAllServices() for _, service := range resolvedServices { diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index 72686c585..5fc585f34 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -1059,13 +1059,6 @@ func TestBaseController_WriteConfigurationFiles(t *testing.T) { } mocks.Injector.Register("toolsManager", mockToolsManager) - // Mock blueprint handler - mockBlueprintHandler := blueprint.NewMockBlueprintHandler(mocks.Injector) - mockBlueprintHandler.WriteConfigFunc = func(overwrite ...bool) error { - return nil - } - mocks.Injector.Register("blueprintHandler", mockBlueprintHandler) - // Mock services mockService := services.NewMockService() mockService.WriteConfigFunc = func() error { @@ -1165,32 +1158,6 @@ func TestBaseController_WriteConfigurationFiles(t *testing.T) { } }) - t.Run("BlueprintConfigError", func(t *testing.T) { - // Given a controller with blueprint requirement enabled - controller, mocks := setup(t) - controller.SetRequirements(Requirements{ - Blueprint: true, - }) - - // And a blueprint handler that fails to write config - mockBlueprintHandler := blueprint.NewMockBlueprintHandler(mocks.Injector) - mockBlueprintHandler.WriteConfigFunc = func(overwrite ...bool) error { - return fmt.Errorf("blueprint config write failed") - } - mocks.Injector.Register("blueprintHandler", mockBlueprintHandler) - - // When writing configuration files - err := controller.WriteConfigurationFiles() - - // Then an error about blueprint config should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error writing blueprint config") { - t.Errorf("Expected error about blueprint config, got %v", err) - } - }) - t.Run("ServiceConfigError", func(t *testing.T) { // Given a controller with services requirement enabled controller, mocks := setup(t) diff --git a/pkg/env/terraform_env.go b/pkg/env/terraform_env.go index 9974e9869..b892bb0e1 100644 --- a/pkg/env/terraform_env.go +++ b/pkg/env/terraform_env.go @@ -180,21 +180,21 @@ func (e *TerraformEnvPrinter) generateBackendOverrideTf() error { } return nil case "local": - backendConfig = fmt.Sprintf(`terraform { + backendConfig = `terraform { backend "local" {} -}`) +}` case "s3": - backendConfig = fmt.Sprintf(`terraform { + backendConfig = `terraform { backend "s3" {} -}`) +}` case "kubernetes": - backendConfig = fmt.Sprintf(`terraform { + backendConfig = `terraform { backend "kubernetes" {} -}`) +}` case "azurerm": - backendConfig = fmt.Sprintf(`terraform { + backendConfig = `terraform { backend "azurerm" {} -}`) +}` default: return fmt.Errorf("unsupported backend: %s", backend) } From 20e934ed30cb847484904265bceb7a9f3c8ac530 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sun, 22 Jun 2025 22:10:38 -0400 Subject: [PATCH 2/4] Make templating actually work --- cmd/init.go | 7 +- cmd/init_test.go | 6 +- pkg/blueprint/blueprint_handler.go | 444 +++--- .../blueprint_handler_helper_test.go | 664 -------- .../blueprint_handler_private_test.go | 179 +-- pkg/blueprint/blueprint_handler_test.go | 197 ++- pkg/config/config_handler.go | 1 + pkg/config/mock_config_handler.go | 51 +- pkg/config/yaml_config_handler.go | 119 ++ pkg/config/yaml_config_handler_test.go | 556 +++++++ pkg/env/shims.go | 4 +- pkg/generators/generator_test.go | 7 + pkg/generators/kustomize_generator.go | 2 +- pkg/generators/shims.go | 50 +- pkg/generators/terraform_generator.go | 151 +- pkg/generators/terraform_generator_test.go | 1371 ++++++++++++++++- pkg/kubernetes/kubernetes_manager_test.go | 268 ++-- pkg/services/dns_service_test.go | 2 +- pkg/services/service_test.go | 6 +- pkg/services/shims.go | 6 +- pkg/shell/shell_test.go | 2 +- pkg/shell/shims.go | 4 +- 22 files changed, 2706 insertions(+), 1391 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 81251412d..6b68163d3 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -228,12 +228,17 @@ var initCmd = &cobra.Command{ return fmt.Errorf("Error initializing: %w", err) } - // Process context templates if they exist (after blueprint handler is initialized) + // Process context templates if they exist blueprintHandler := controller.ResolveBlueprintHandler() if err := blueprintHandler.ProcessContextTemplates(contextName, reset); err != nil { return fmt.Errorf("Error processing context templates: %w", err) } + // Reload blueprint after processing templates + if err := blueprintHandler.LoadConfig(reset); err != nil { + return fmt.Errorf("Error reloading blueprint config: %w", err) + } + // Set the environment variables internally in the process if err := controller.SetEnvironmentVariables(); err != nil { return fmt.Errorf("Error setting environment variables: %w", err) diff --git a/cmd/init_test.go b/cmd/init_test.go index 180236f02..86fc81c47 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -521,7 +521,7 @@ func TestInitCmd(t *testing.T) { t.Run("SetVMDriverError", func(t *testing.T) { // Create a mock config handler that returns an error for vm.driver mockConfigHandler := config.NewMockConfigHandler() - mockConfigHandler.SetContextValueFunc = func(key string, value interface{}) error { + mockConfigHandler.SetContextValueFunc = func(key string, value any) error { if key == "vm.driver" { return fmt.Errorf("failed to set vm driver") } @@ -691,7 +691,7 @@ func TestInitCmd(t *testing.T) { t.Run("SetFlagError", func(t *testing.T) { // Create a mock config handler that returns an error mockConfigHandler := config.NewMockConfigHandler() - mockConfigHandler.SetContextValueFunc = func(key string, value interface{}) error { + mockConfigHandler.SetContextValueFunc = func(key string, value any) error { return fmt.Errorf("failed to set config value for %s", key) } mockConfigHandler.GetContextFunc = func() string { @@ -885,7 +885,7 @@ func TestInitCmd_PlatformFlag(t *testing.T) { for _, tc := range platforms { t.Run(tc.name, func(t *testing.T) { // Use a real map-backed mock config handler - store := make(map[string]interface{}) + store := make(map[string]any) mockConfigHandler := config.NewMockConfigHandler() mockConfigHandler.SetContextValueFunc = func(key string, value any) error { store[key] = value diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index e8d480506..ed7454b92 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "reflect" "slices" "strings" "time" @@ -483,18 +482,19 @@ func (b *BaseBlueprintHandler) Down() error { } // ProcessContextTemplates processes jsonnet templates from the contexts/_template directory -// and generates corresponding files in the specified context directory. The function handles +// and generates corresponding blueprint files in the specified context directory. The function handles // three scenarios: -// 1. Template Processing: If contexts/_template exists, recursively processes all .jsonnet files, -// evaluating them with context data and writing output files with appropriate extensions -// (.yaml for general files, .tfvars for terraform/ subdirectories) -// 2. Platform Template Processing: If no template directory exists but a platform is configured, -// loads and processes the platform-specific jsonnet template -// 3. Default Blueprint Generation: Falls back to generating a default blueprint.yaml using -// either the embedded default jsonnet template or the hardcoded DefaultBlueprint +// 1. Blueprint Flag Processing: If --blueprint flag is specified, uses embedded platform templates +// and ignores any _template directory to ensure consistent behavior +// 2. Template Processing: If no --blueprint flag and contexts/_template exists, processes blueprint.jsonnet files, +// evaluating them with context data and writing blueprint.yaml files +// 3. Default Blueprint Generation: Falls back to generating platform-specific blueprints +// +// Other template types (terraform, kustomize, etc.) are handled by their respective generators +// during the WriteConfigurationFiles phase. func (b *BaseBlueprintHandler) ProcessContextTemplates(contextName string, reset ...bool) error { resetMode := len(reset) > 0 && reset[0] - // === Setup === + projectRoot, err := b.shell.GetProjectRoot() if err != nil { return fmt.Errorf("error getting project root: %w", err) @@ -505,95 +505,186 @@ func (b *BaseBlueprintHandler) ProcessContextTemplates(contextName string, reset return fmt.Errorf("error creating context directory: %w", err) } - // === Template Processing === + // Check for --blueprint flag first (highest priority) + // If --blueprint is specified, use embedded templates and ignore _template directory + blueprintValue := b.configHandler.GetString("blueprint") + if blueprintValue != "" { + return b.generateDefaultBlueprint(contextDir, contextName, resetMode) + } + + // Check for _template directory (second priority) + // Only used if --blueprint flag is not specified templateDir := filepath.Join(projectRoot, "contexts", "_template") if _, err := b.shims.Stat(templateDir); err == nil { - var walkDir func(string) error - walkDir = func(currentDir string) error { - entries, err := b.shims.ReadDir(currentDir) - if err != nil { - return fmt.Errorf("error reading template directory %s: %w", currentDir, err) - } + return b.processTemplateDirectory(templateDir, contextDir, contextName, resetMode) + } - for _, entry := range entries { - fullPath := filepath.Join(currentDir, entry.Name()) - - if entry.IsDir() { - if err := walkDir(fullPath); err != nil { - return err - } - } else if strings.HasSuffix(entry.Name(), ".jsonnet") { - if err := b.processJsonnetTemplate(templateDir, fullPath, contextDir, resetMode); err != nil { - return fmt.Errorf("error processing template %s: %w", fullPath, err) - } - } - } - return nil + // Fall back to platform/default templates (lowest priority) + return b.generateDefaultBlueprint(contextDir, contextName, resetMode) +} + +// processTemplateDirectory processes blueprint templates from the _template directory +func (b *BaseBlueprintHandler) processTemplateDirectory(templateDir, contextDir, contextName string, resetMode bool) error { + entries, err := b.shims.ReadDir(templateDir) + if err != nil { + return fmt.Errorf("error reading template directory: %w", err) + } + + for _, entry := range entries { + if entry.Name() == "blueprint.jsonnet" { + templateFile := filepath.Join(templateDir, entry.Name()) + return b.processJsonnetTemplate(templateFile, contextDir, contextName, resetMode) } + } + + // No blueprint template found, generate default + return b.generateDefaultBlueprint(contextDir, contextName, resetMode) +} + +// processJsonnetTemplate processes a single blueprint jsonnet template +func (b *BaseBlueprintHandler) processJsonnetTemplate(templateFile, contextDir, contextName string, resetMode bool) error { + jsonnetData, err := b.shims.ReadFile(templateFile) + if err != nil { + return fmt.Errorf("error reading template file: %w", err) + } - if err := walkDir(templateDir); err != nil { - return err + config := b.configHandler.GetConfig() + contextYAML, err := b.configHandler.YamlMarshalWithDefinedPaths(config) + if err != nil { + return fmt.Errorf("error marshalling context to YAML: %w", err) + } + + var contextMap map[string]any = make(map[string]any) + if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { + return fmt.Errorf("error unmarshalling context YAML: %w", err) + } + + contextMap["name"] = contextName + contextJSON, err := b.shims.JsonMarshal(contextMap) + if err != nil { + return fmt.Errorf("error marshalling context map to JSON: %w", err) + } + + // Use ExtCode to make context available via std.extVar("context") + // Templates must include: local context = std.extVar("context"); + // This follows standard jsonnet patterns and is explicit and debuggable + vm := b.shims.NewJsonnetVM() + vm.ExtCode("context", string(contextJSON)) + evaluatedContent, err := vm.EvaluateAnonymousSnippet(templateFile, string(jsonnetData)) + if err != nil { + return fmt.Errorf("error evaluating jsonnet template: %w", err) + } + + outputPath := filepath.Join(contextDir, "blueprint.yaml") + return b.processBlueprintTemplate(outputPath, evaluatedContent, contextName, resetMode) +} + +// generateDefaultBlueprint generates a default blueprint when no templates exist +func (b *BaseBlueprintHandler) generateDefaultBlueprint(contextDir, contextName string, resetMode bool) error { + blueprintPath := filepath.Join(contextDir, "blueprint.yaml") + if _, err := b.shims.Stat(blueprintPath); err != nil || resetMode { + // === Platform Template Loading === + // --platform flag determines which template file to use + platform := b.configHandler.GetString("platform") + if platform == "" { + platform = b.configHandler.GetString("cluster.platform") } - } else { - // === Default Blueprint Generation === - blueprintPath := filepath.Join(contextDir, "blueprint.yaml") - if _, err := b.shims.Stat(blueprintPath); err != nil || resetMode { - // === Platform Template Loading === - platform := b.configHandler.GetString("cluster.platform") - templateData, err := b.loadPlatformTemplate(platform) - if err != nil || len(templateData) == 0 { - templateData, err = b.loadPlatformTemplate("default") - if err != nil { - return fmt.Errorf("error loading default template: %w", err) - } + templateData, err := b.loadPlatformTemplate(platform) + if err != nil || len(templateData) == 0 { + templateData, err = b.loadPlatformTemplate("default") + if err != nil { + return fmt.Errorf("error loading default template: %w", err) } + } - // === Blueprint Data Generation === - var blueprintData []byte - if len(templateData) > 0 { - config := b.configHandler.GetConfig() - contextYAML, err := b.yamlMarshalWithDefinedPaths(config) - if err != nil { - return fmt.Errorf("error marshalling context to YAML: %w", err) - } - var contextMap map[string]any = make(map[string]any) - if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { - return fmt.Errorf("error unmarshalling context YAML: %w", err) - } - contextMap["name"] = contextName - contextJSON, err := b.shims.JsonMarshal(contextMap) - if err != nil { - return fmt.Errorf("error marshalling context map to JSON: %w", err) - } - vm := b.shims.NewJsonnetVM() - vm.ExtCode("context", string(contextJSON)) - evaluatedJsonnet, err := vm.EvaluateAnonymousSnippet("blueprint.jsonnet", string(templateData)) - if err != nil { - return fmt.Errorf("error generating blueprint from jsonnet: %w", err) - } - if evaluatedJsonnet != "" { - blueprintData = []byte(evaluatedJsonnet) - } + // === Blueprint Data Generation === + if len(templateData) > 0 { + config := b.configHandler.GetConfig() + contextYAML, err := b.configHandler.YamlMarshalWithDefinedPaths(config) + if err != nil { + return fmt.Errorf("error marshalling context to YAML: %w", err) } + var contextMap map[string]any = make(map[string]any) + if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { + return fmt.Errorf("error unmarshalling context YAML: %w", err) + } + contextMap["name"] = contextName - // === Fallback Blueprint Creation === - if len(blueprintData) == 0 { - blueprint := *DefaultBlueprint.DeepCopy() - blueprint.Metadata.Name = contextName - blueprint.Metadata.Description = fmt.Sprintf("This blueprint outlines resources in the %s context", contextName) - blueprintData, err = b.shims.YamlMarshal(blueprint) - if err != nil { - return fmt.Errorf("error marshalling default blueprint: %w", err) - } + // --blueprint flag controls the context.blueprint field value + // Only set if explicitly provided via --blueprint flag + blueprintValue := b.configHandler.GetString("blueprint") + if blueprintValue != "" { + contextMap["blueprint"] = blueprintValue } - // === Blueprint File Write === - if err := b.shims.WriteFile(blueprintPath, blueprintData, 0644); err != nil { - return fmt.Errorf("error writing blueprint file: %w", err) + contextJSON, err := b.shims.JsonMarshal(contextMap) + if err != nil { + return fmt.Errorf("error marshalling context map to JSON: %w", err) } + + // Use ExtCode to make context available via std.extVar("context") + // Templates must include: local context = std.extVar("context"); + // This follows standard jsonnet patterns and is explicit and debuggable + vm := b.shims.NewJsonnetVM() + vm.ExtCode("context", string(contextJSON)) + evaluatedContent, err := vm.EvaluateAnonymousSnippet("blueprint.jsonnet", string(templateData)) + if err != nil { + return fmt.Errorf("error generating blueprint from jsonnet: %w", err) + } + if evaluatedContent != "" { + // Process through standard pipeline (validates, converts to YAML, applies metadata) + return b.processBlueprintTemplate(blueprintPath, evaluatedContent, contextName, resetMode) + } + } + + // === Fallback Blueprint Creation === + blueprint := *DefaultBlueprint.DeepCopy() + blueprint.Metadata.Name = contextName + blueprint.Metadata.Description = fmt.Sprintf("This blueprint outlines resources in the %s context", contextName) + blueprintData, err := b.shims.YamlMarshal(blueprint) + if err != nil { + return fmt.Errorf("error marshalling default blueprint: %w", err) + } + + // === Blueprint File Write === + if err := b.shims.WriteFile(blueprintPath, blueprintData, 0644); err != nil { + return fmt.Errorf("error writing blueprint file: %w", err) + } + } + + return nil +} + +// processBlueprintTemplate validates blueprint template output against the Blueprint schema +// and writes it as a properly formatted blueprint.yaml file +func (b *BaseBlueprintHandler) processBlueprintTemplate(outputPath, content, contextName string, resetMode bool) error { + if !resetMode { + if _, err := b.shims.Stat(outputPath); err == nil { + return nil } } + // Validate blueprint content against schema + var testBlueprint blueprintv1alpha1.Blueprint + if err := b.processBlueprintData([]byte(content), &testBlueprint); err != nil { + return fmt.Errorf("error validating blueprint template: %w", err) + } + + // Override metadata with context-specific values + testBlueprint.Metadata.Name = contextName + testBlueprint.Metadata.Description = fmt.Sprintf("This blueprint outlines resources in the %s context", contextName) + + // Convert JSON content to YAML format + yamlData, err := b.shims.YamlMarshal(testBlueprint) + if err != nil { + return fmt.Errorf("error converting blueprint to YAML: %w", err) + } + + // Write validated blueprint content as YAML + if err := b.shims.WriteFile(outputPath, yamlData, 0644); err != nil { + return fmt.Errorf("error writing blueprint file: %w", err) + } + return nil } @@ -982,124 +1073,6 @@ func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { return maxPathTime } -// yamlMarshalWithDefinedPaths marshals data to YAML format while ensuring all parent paths are defined. -// It handles various Go types including structs, maps, slices, and primitive types, preserving YAML -// tags and properly representing nil values. -func (b *BaseBlueprintHandler) yamlMarshalWithDefinedPaths(v any) ([]byte, error) { - if v == nil { - return nil, fmt.Errorf("invalid input: nil value") - } - - var convert func(reflect.Value) (any, error) - convert = func(val reflect.Value) (any, error) { - switch val.Kind() { - case reflect.Ptr, reflect.Interface: - if val.IsNil() { - if val.Kind() == reflect.Interface || (val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct) { - return make(map[string]any), nil - } - return nil, nil - } - return convert(val.Elem()) - case reflect.Struct: - result := make(map[string]any) - typ := val.Type() - for i := range make([]int, val.NumField()) { - fieldValue := val.Field(i) - fieldType := typ.Field(i) - - if fieldType.PkgPath != "" { - continue - } - - yamlTag := strings.Split(fieldType.Tag.Get("yaml"), ",")[0] - if yamlTag == "-" { - continue - } - if yamlTag == "" { - yamlTag = fieldType.Name - } - - fieldInterface, err := convert(fieldValue) - if err != nil { - return nil, fmt.Errorf("error converting field %s: %w", fieldType.Name, err) - } - if fieldInterface != nil || fieldType.Type.Kind() == reflect.Interface || fieldType.Type.Kind() == reflect.Slice || fieldType.Type.Kind() == reflect.Map || fieldType.Type.Kind() == reflect.Struct { - result[yamlTag] = fieldInterface - } - } - return result, nil - case reflect.Slice, reflect.Array: - if val.Len() == 0 { - return []any{}, nil - } - slice := make([]any, val.Len()) - for i := 0; i < val.Len(); i++ { - elemVal := val.Index(i) - if elemVal.Kind() == reflect.Ptr || elemVal.Kind() == reflect.Interface { - if elemVal.IsNil() { - slice[i] = nil - continue - } - } - elemInterface, err := convert(elemVal) - if err != nil { - return nil, fmt.Errorf("error converting slice element at index %d: %w", i, err) - } - slice[i] = elemInterface - } - return slice, nil - case reflect.Map: - result := make(map[string]any) - for _, key := range val.MapKeys() { - keyStr := fmt.Sprintf("%v", key.Interface()) - elemVal := val.MapIndex(key) - if elemVal.Kind() == reflect.Interface && elemVal.IsNil() { - result[keyStr] = nil - continue - } - elemInterface, err := convert(elemVal) - if err != nil { - return nil, fmt.Errorf("error converting map value for key %s: %w", keyStr, err) - } - if elemInterface != nil || elemVal.Kind() == reflect.Interface || elemVal.Kind() == reflect.Slice || elemVal.Kind() == reflect.Map || elemVal.Kind() == reflect.Struct { - result[keyStr] = elemInterface - } - } - return result, nil - case reflect.String: - return val.String(), nil - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return val.Int(), nil - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return val.Uint(), nil - case reflect.Float32, reflect.Float64: - return val.Float(), nil - case reflect.Bool: - return val.Bool(), nil - default: - return nil, fmt.Errorf("unsupported value type %s", val.Kind()) - } - } - - val := reflect.ValueOf(v) - if val.Kind() == reflect.Func { - return nil, fmt.Errorf("unsupported value type func") - } - - processed, err := convert(val) - if err != nil { - return nil, err - } - - yamlData, err := b.shims.YamlMarshal(processed) - if err != nil { - return nil, fmt.Errorf("error marshalling yaml: %w", err) - } - - return yamlData, nil -} - func (b *BaseBlueprintHandler) createManagedNamespace(name string) error { return b.kubernetesManager.CreateNamespace(name) } @@ -1185,72 +1158,3 @@ func (b *BaseBlueprintHandler) toKubernetesKustomization(k blueprintv1alpha1.Kus }, } } - -// processJsonnetTemplate reads a jsonnet template file, evaluates it with context data, -// and writes the processed output to the appropriate location with correct file extension. -// It handles path resolution, context marshalling, jsonnet evaluation, output path determination -// based on file location and naming conventions, and conditional file writing based on reset mode. -func (b *BaseBlueprintHandler) processJsonnetTemplate(templateDir, templateFile, contextDir string, resetMode bool) error { - jsonnetData, err := b.shims.ReadFile(templateFile) - if err != nil { - return fmt.Errorf("error reading template file: %w", err) - } - - config := b.configHandler.GetConfig() - contextYAML, err := b.yamlMarshalWithDefinedPaths(config) - if err != nil { - return fmt.Errorf("error marshalling context to YAML: %w", err) - } - - var contextMap map[string]any = make(map[string]any) - if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { - return fmt.Errorf("error unmarshalling context YAML: %w", err) - } - - context := b.configHandler.GetContext() - contextMap["name"] = context - contextJSON, err := b.shims.JsonMarshal(contextMap) - if err != nil { - return fmt.Errorf("error marshalling context map to JSON: %w", err) - } - - vm := b.shims.NewJsonnetVM() - vm.ExtCode("context", string(contextJSON)) - evaluatedContent, err := vm.EvaluateAnonymousSnippet(templateFile, string(jsonnetData)) - if err != nil { - return fmt.Errorf("error evaluating jsonnet template: %w", err) - } - - relPath, err := filepath.Rel(templateDir, templateFile) - if err != nil { - return fmt.Errorf("error getting relative path: %w", err) - } - - outputName := strings.TrimSuffix(relPath, ".jsonnet") - outputPath := filepath.Join(contextDir, outputName) - - if strings.Contains(outputName, "blueprint") { - outputPath += ".yaml" - } else if strings.Contains(relPath, "terraform/") { - outputPath += ".tfvars" - } else { - outputPath += ".yaml" - } - - if !resetMode { - if _, err := b.shims.Stat(outputPath); err == nil { - return nil - } - } - - outputDir := filepath.Dir(outputPath) - if err := b.shims.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("error creating output directory: %w", err) - } - - if err := b.shims.WriteFile(outputPath, []byte(evaluatedContent), 0644); err != nil { - return fmt.Errorf("error writing output file: %w", err) - } - - return nil -} diff --git a/pkg/blueprint/blueprint_handler_helper_test.go b/pkg/blueprint/blueprint_handler_helper_test.go index 0394ba7ac..eed682beb 100644 --- a/pkg/blueprint/blueprint_handler_helper_test.go +++ b/pkg/blueprint/blueprint_handler_helper_test.go @@ -14,670 +14,6 @@ import ( // Test Helper Functions // ============================================================================= -func TestYamlMarshalWithDefinedPaths(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("IgnoreYamlMinusTag", func(t *testing.T) { - // Given a struct with a YAML minus tag - type testStruct struct { - Public string `yaml:"public"` - private string `yaml:"-"` - } - input := testStruct{Public: "value", private: "ignored"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the public field should be included - if !strings.Contains(string(result), "public: value") { - t.Errorf("Expected 'public: value' in result, got: %s", string(result)) - } - - // And the ignored field should be excluded - if strings.Contains(string(result), "ignored") { - t.Errorf("Expected 'ignored' not to be in result, got: %s", string(result)) - } - }) - - t.Run("NilInput", func(t *testing.T) { - // When marshalling nil input - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(nil) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for nil input, got nil") - } - - // And the error message should be appropriate - if !strings.Contains(err.Error(), "invalid input: nil value") { - t.Errorf("Expected error about nil input, got: %v", err) - } - }) - - t.Run("EmptySlice", func(t *testing.T) { - // Given an empty slice - input := []string{} - - // When marshalling the slice - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the result should be an empty array - if string(result) != "[]\n" { - t.Errorf("Expected '[]\n', got: %s", string(result)) - } - }) - - t.Run("NoYamlTag", func(t *testing.T) { - // Given a struct with no YAML tags - type testStruct struct { - Field string - } - input := testStruct{Field: "value"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the field name should be used as is - if !strings.Contains(string(result), "Field: value") { - t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) - } - }) - - t.Run("CustomYamlTag", func(t *testing.T) { - // Given a struct with a custom YAML tag - type testStruct struct { - Field string `yaml:"custom_field"` - } - input := testStruct{Field: "value"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the custom field name should be used - if !strings.Contains(string(result), "custom_field: value") { - t.Errorf("Expected 'custom_field: value' in result, got: %s", string(result)) - } - }) - - t.Run("MapWithCustomTags", func(t *testing.T) { - // Given a map with nested structs using custom YAML tags - type nestedStruct struct { - Value string `yaml:"custom_value"` - } - input := map[string]nestedStruct{ - "key": {Value: "test"}, - } - - // When marshalling the map - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the map key should be preserved - if !strings.Contains(string(result), "key:") { - t.Errorf("Expected 'key:' in result, got: %s", string(result)) - } - - // And the nested custom field name should be used - if !strings.Contains(string(result), " custom_value: test") { - t.Errorf("Expected ' custom_value: test' in result, got: %s", string(result)) - } - }) - - t.Run("DefaultFieldName", func(t *testing.T) { - // Given a struct with default field names - data := struct { - Field string - }{ - Field: "value", - } - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(data) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the default field name should be used - if !strings.Contains(string(result), "Field: value") { - t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) - } - }) - - t.Run("NilInput", func(t *testing.T) { - // When marshalling nil input - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(nil) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for nil input, got nil") - } - - // And the error message should be appropriate - if !strings.Contains(err.Error(), "invalid input: nil value") { - t.Errorf("Expected error about nil input, got: %v", err) - } - }) - - t.Run("FuncType", func(t *testing.T) { - // When marshalling a function type - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(func() {}) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for func type, got nil") - } - - // And the error message should be appropriate - if !strings.Contains(err.Error(), "unsupported value type func") { - t.Errorf("Expected error about unsupported value type, got: %v", err) - } - }) - - t.Run("UnsupportedType", func(t *testing.T) { - // When marshalling an unsupported type - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(make(chan int)) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for unsupported type, got nil") - } - - // And the error message should be appropriate - if !strings.Contains(err.Error(), "unsupported value type") { - t.Errorf("Expected error about unsupported value type, got: %v", err) - } - }) - - t.Run("MapWithNilValues", func(t *testing.T) { - // Given a map with nil values - input := map[string]any{ - "key1": nil, - "key2": "value2", - } - - // When marshalling the map - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And nil values should be represented as null - if !strings.Contains(string(result), "key1: null") { - t.Errorf("Expected 'key1: null' in result, got: %s", string(result)) - } - - // And non-nil values should be preserved - if !strings.Contains(string(result), "key2: value2") { - t.Errorf("Expected 'key2: value2' in result, got: %s", string(result)) - } - }) - - t.Run("SliceWithNilValues", func(t *testing.T) { - // Given a slice with nil values - input := []any{nil, "value", nil} - - // When marshalling the slice - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And nil values should be represented as null - if !strings.Contains(string(result), "- null") { - t.Errorf("Expected '- null' in result, got: %s", string(result)) - } - - // And non-nil values should be preserved - if !strings.Contains(string(result), "- value") { - t.Errorf("Expected '- value' in result, got: %s", string(result)) - } - }) - - t.Run("StructWithPrivateFields", func(t *testing.T) { - // Given a struct with both public and private fields - type testStruct struct { - Public string - private string - } - input := testStruct{Public: "value", private: "ignored"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And public fields should be included - if !strings.Contains(string(result), "Public: value") { - t.Errorf("Expected 'Public: value' in result, got: %s", string(result)) - } - - // And private fields should be excluded - if strings.Contains(string(result), "private") { - t.Errorf("Expected 'private' not to be in result, got: %s", string(result)) - } - }) - - t.Run("StructWithYamlTag", func(t *testing.T) { - // Given a struct with a YAML tag - type testStruct struct { - Field string `yaml:"custom_name"` - } - input := testStruct{Field: "value"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the custom field name should be used - if !strings.Contains(string(result), "custom_name: value") { - t.Errorf("Expected 'custom_name: value' in result, got: %s", string(result)) - } - }) - - t.Run("NestedStructs", func(t *testing.T) { - // Given nested structs - type nested struct { - Value string - } - type parent struct { - Nested nested - } - input := parent{Nested: nested{Value: "test"}} - - // When marshalling the nested structs - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the parent field should be included - if !strings.Contains(string(result), "Nested:") { - t.Errorf("Expected 'Nested:' in result, got: %s", string(result)) - } - - // And the nested field should be properly indented - if !strings.Contains(string(result), " Value: test") { - t.Errorf("Expected ' Value: test' in result, got: %s", string(result)) - } - }) - - t.Run("NumericTypes", func(t *testing.T) { - // Given a struct with various numeric types - type numbers struct { - Int int `yaml:"int"` - Int8 int8 `yaml:"int8"` - Int16 int16 `yaml:"int16"` - Int32 int32 `yaml:"int32"` - Int64 int64 `yaml:"int64"` - Uint uint `yaml:"uint"` - Uint8 uint8 `yaml:"uint8"` - Uint16 uint16 `yaml:"uint16"` - Uint32 uint32 `yaml:"uint32"` - Uint64 uint64 `yaml:"uint64"` - Float32 float32 `yaml:"float32"` - Float64 float64 `yaml:"float64"` - } - input := numbers{ - Int: 1, Int8: 2, Int16: 3, Int32: 4, Int64: 5, - Uint: 6, Uint8: 7, Uint16: 8, Uint32: 9, Uint64: 10, - Float32: 11.1, Float64: 12.2, - } - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And all numeric values should be correctly represented - for _, expected := range []string{ - "int: 1", "int8: 2", "int16: 3", "int32: 4", "int64: 5", - "uint: 6", "uint8: 7", "uint16: 8", "uint32: 9", "uint64: 10", - "float32: 11.1", "float64: 12.2", - } { - if !strings.Contains(string(result), expected) { - t.Errorf("Expected '%s' in result, got: %s", expected, string(result)) - } - } - }) - - t.Run("BooleanType", func(t *testing.T) { - // Given a struct with boolean fields - type boolStruct struct { - True bool `yaml:"true"` - False bool `yaml:"false"` - } - input := boolStruct{True: true, False: false} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the boolean values should be correctly represented - if !strings.Contains(string(result), `"true": true`) { - t.Errorf("Expected '\"true\": true' in result, got: %s", string(result)) - } - if !strings.Contains(string(result), `"false": false`) { - t.Errorf("Expected '\"false\": false' in result, got: %s", string(result)) - } - }) - - t.Run("NilPointerAndInterface", func(t *testing.T) { - // Given a struct with nil pointers and interfaces - type testStruct struct { - NilPtr *string `yaml:"nil_ptr"` - NilInterface any `yaml:"nil_interface"` - NilMap map[string]string `yaml:"nil_map"` - NilSlice []string `yaml:"nil_slice"` - NilStruct *struct{ Field int } `yaml:"nil_struct"` - } - input := testStruct{} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And nil interfaces should be represented as empty objects - if !strings.Contains(string(result), "nil_interface: {}") { - t.Errorf("Expected 'nil_interface: {}' in result, got: %s", string(result)) - } - - // And nil slices should be represented as empty arrays - if !strings.Contains(string(result), "nil_slice: []") { - t.Errorf("Expected 'nil_slice: []' in result, got: %s", string(result)) - } - - // And nil maps should be represented as empty objects - if !strings.Contains(string(result), "nil_map: {}") { - t.Errorf("Expected 'nil_map: {}' in result, got: %s", string(result)) - } - - // And nil structs should be represented as empty objects - if !strings.Contains(string(result), "nil_struct: {}") { - t.Errorf("Expected 'nil_struct: {}' in result, got: %s", string(result)) - } - }) - - t.Run("SliceWithNilElements", func(t *testing.T) { - // Given a slice with nil elements - type elem struct { - Field string - } - input := []*elem{nil, {Field: "value"}, nil} - - // When marshalling the slice - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And non-nil elements should be correctly represented - if !strings.Contains(string(result), "Field: value") { - t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) - } - }) - - t.Run("MapWithNilValues", func(t *testing.T) { - // Given a map with nil and non-nil values - input := map[string]any{ - "nil": nil, - "nonnil": "value", - "nilptr": (*string)(nil), - } - - // When marshalling the map to YAML - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And nil values should be represented as null - if !strings.Contains(string(result), "nil: null") { - t.Errorf("Expected 'nil: null' in result, got: %s", string(result)) - } - - // And non-nil values should be preserved - if !strings.Contains(string(result), "nonnil: value") { - t.Errorf("Expected 'nonnil: value' in result, got: %s", string(result)) - } - }) - - t.Run("UnsupportedType", func(t *testing.T) { - // Given an unsupported channel type - input := make(chan int) - - // When attempting to marshal the channel - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for unsupported type, got nil") - } - - // And the error should indicate the unsupported type - if !strings.Contains(err.Error(), "unsupported value type chan") { - t.Errorf("Expected error about unsupported type, got: %v", err) - } - }) - - t.Run("FunctionType", func(t *testing.T) { - // Given a function type - input := func() {} - - // When attempting to marshal the function - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for function type, got nil") - } - - // And the error should indicate the unsupported type - if !strings.Contains(err.Error(), "unsupported value type func") { - t.Errorf("Expected error about unsupported type, got: %v", err) - } - }) - - t.Run("ErrorInSliceConversion", func(t *testing.T) { - // Given a slice containing an unsupported type - input := []any{make(chan int)} - - // When attempting to marshal the slice - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for slice with unsupported type, got nil") - } - - // And the error should indicate the slice conversion issue - if !strings.Contains(err.Error(), "error converting slice element") { - t.Errorf("Expected error about slice conversion, got: %v", err) - } - }) - - t.Run("ErrorInMapConversion", func(t *testing.T) { - // Given a map containing an unsupported type - input := map[string]any{ - "channel": make(chan int), - } - - // When attempting to marshal the map - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for map with unsupported type, got nil") - } - - // And the error should indicate the map conversion issue - if !strings.Contains(err.Error(), "error converting map value") { - t.Errorf("Expected error about map conversion, got: %v", err) - } - }) - - t.Run("ErrorInStructFieldConversion", func(t *testing.T) { - // Given a struct containing an unsupported field type - type testStruct struct { - Channel chan int - } - input := testStruct{Channel: make(chan int)} - - // When attempting to marshal the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for struct with unsupported field type, got nil") - } - - // And the error should indicate the field conversion issue - if !strings.Contains(err.Error(), "error converting field") { - t.Errorf("Expected error about field conversion, got: %v", err) - } - }) - - t.Run("YamlMarshalError", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And a mock YAML marshaller that returns an error - baseHandler.shims.YamlMarshal = func(v any) ([]byte, error) { - return nil, fmt.Errorf("mock yaml marshal error") - } - - // And a simple struct to marshal - input := struct{ Field string }{"value"} - - // When marshalling the struct - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error from yaml marshal, got nil") - } - - // And the error should indicate the YAML marshalling issue - if !strings.Contains(err.Error(), "error marshalling yaml") { - t.Errorf("Expected error about yaml marshalling, got: %v", err) - } - }) -} - func TestTLACode(t *testing.T) { // Given a mock Jsonnet VM that returns an error about missing authors vm := NewMockJsonnetVM(func(filename, snippet string) (string, error) { diff --git a/pkg/blueprint/blueprint_handler_private_test.go b/pkg/blueprint/blueprint_handler_private_test.go index 9768e184d..a54dfd34a 100644 --- a/pkg/blueprint/blueprint_handler_private_test.go +++ b/pkg/blueprint/blueprint_handler_private_test.go @@ -11,6 +11,7 @@ import ( kustomize "github.com/fluxcd/pkg/apis/kustomize" sourcev1 "github.com/fluxcd/source-controller/api/v1" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/kubernetes" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -25,7 +26,7 @@ func (m mockFileInfo) Size() int64 { return 0 } func (m mockFileInfo) Mode() os.FileMode { return 0644 } func (m mockFileInfo) ModTime() time.Time { return time.Time{} } func (m mockFileInfo) IsDir() bool { return false } -func (m mockFileInfo) Sys() interface{} { return nil } +func (m mockFileInfo) Sys() any { return nil } // ============================================================================= // Test Private Methods @@ -741,7 +742,7 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } // When calling processJsonnetTemplate - err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + err := handler.processJsonnetTemplate("/template/test.jsonnet", "/context", "test-context", false) // Then an error should be returned if err == nil { @@ -756,16 +757,19 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { // Given a blueprint handler with mocked dependencies handler, mocks := setup(t) - // And ReadFile succeeds but YamlMarshal fails + // And ReadFile succeeds but YamlMarshalWithDefinedPaths fails mocks.Shims.ReadFile = func(name string) ([]byte, error) { return []byte("{}"), nil } - mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + + // Mock the config handler's YamlMarshalWithDefinedPaths method + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.YamlMarshalWithDefinedPathsFunc = func(v any) ([]byte, error) { return nil, fmt.Errorf("yaml marshal error") } // When calling processJsonnetTemplate - err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + err := handler.processJsonnetTemplate("/template/test.jsonnet", "/context", "test-context", false) // Then an error should be returned if err == nil { @@ -789,7 +793,7 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } // When calling processJsonnetTemplate - err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + err := handler.processJsonnetTemplate("/template/test.jsonnet", "/context", "test-context", false) // Then an error should be returned if err == nil { @@ -813,7 +817,7 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } // When calling processJsonnetTemplate - err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + err := handler.processJsonnetTemplate("/template/test.jsonnet", "/context", "test-context", false) // Then an error should be returned if err == nil { @@ -839,7 +843,7 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } // When calling processJsonnetTemplate - err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + err := handler.processJsonnetTemplate("/template/test.jsonnet", "/context", "test-context", false) // Then an error should be returned if err == nil { @@ -850,102 +854,55 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } }) - t.Run("ErrorGettingRelativePath", func(t *testing.T) { - // Given a blueprint handler with mocked dependencies - handler, mocks := setup(t) - - // And ReadFile succeeds - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("{}"), nil - } - - // When calling processJsonnetTemplate with invalid paths - err := handler.processJsonnetTemplate("", "/template/test.jsonnet", "/context", false) - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error getting relative path") { - t.Errorf("Expected 'error getting relative path' in error, got: %v", err) - } - }) - - t.Run("BlueprintFileExtension", func(t *testing.T) { + t.Run("ErrorWritingBlueprintFile", func(t *testing.T) { // Given a blueprint handler with mocked dependencies handler, mocks := setup(t) // And ReadFile succeeds mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("{}"), nil - } - - // And output file doesn't exist - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - var writtenPath string - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - writtenPath = name - return nil - } - - // When calling processJsonnetTemplate with blueprint file - err := handler.processJsonnetTemplate("/template", "/template/blueprint.jsonnet", "/context", false) - - // Then no error should occur - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - - // And the output path should have .yaml extension - if !strings.HasSuffix(writtenPath, "blueprint.yaml") { - t.Errorf("Expected blueprint.yaml extension, got: %s", writtenPath) + return []byte("local context = std.extVar('context'); {}"), nil } - }) - - t.Run("TerraformFileExtension", func(t *testing.T) { - // Given a blueprint handler with mocked dependencies - handler, mocks := setup(t) - // And ReadFile succeeds - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("{}"), nil + // And jsonnet evaluation returns valid blueprint content + mocks.Shims.NewJsonnetVM = func() JsonnetVM { + return NewMockJsonnetVM(func(filename, snippet string) (string, error) { + return `kind: Blueprint +metadata: + name: test-context + description: Test blueprint + authors: ["test"]`, nil + }) } - // And output file doesn't exist + // And Stat returns file not exists (so we proceed to write) mocks.Shims.Stat = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist } - var writtenPath string + // And WriteFile returns an error mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - writtenPath = name - return nil + return fmt.Errorf("write file error") } - // When calling processJsonnetTemplate with terraform file - err := handler.processJsonnetTemplate("/template", "/template/terraform/main.jsonnet", "/context", false) + // When calling processJsonnetTemplate + err := handler.processJsonnetTemplate("/template/test.jsonnet", "/context", "test-context", false) - // Then no error should occur - if err != nil { - t.Errorf("Expected no error, got: %v", err) + // Then an error should be returned + if err == nil { + t.Error("Expected error, got nil") } - - // And the output path should have .tfvars extension - if !strings.HasSuffix(writtenPath, "main.tfvars") { - t.Errorf("Expected main.tfvars extension, got: %s", writtenPath) + if !strings.Contains(err.Error(), "error writing blueprint file") { + t.Errorf("Expected 'error writing blueprint file' in error, got: %v", err) } }) - t.Run("DefaultYamlFileExtension", func(t *testing.T) { + t.Run("BlueprintFileExtension", func(t *testing.T) { // Given a blueprint handler with mocked dependencies handler, mocks := setup(t) // And ReadFile succeeds mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("{}"), nil + return []byte("kind: Blueprint"), nil } // And output file doesn't exist @@ -959,8 +916,8 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { return nil } - // When calling processJsonnetTemplate with regular file - err := handler.processJsonnetTemplate("/template", "/template/config.jsonnet", "/context", false) + // When calling processJsonnetTemplate with blueprint file + err := handler.processJsonnetTemplate("/template/blueprint.jsonnet", "/context", "test-context", false) // Then no error should occur if err != nil { @@ -968,8 +925,8 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } // And the output path should have .yaml extension - if !strings.HasSuffix(writtenPath, "config.yaml") { - t.Errorf("Expected config.yaml extension, got: %s", writtenPath) + if !strings.HasSuffix(writtenPath, "blueprint.yaml") { + t.Errorf("Expected blueprint.yaml extension, got: %s", writtenPath) } }) @@ -997,7 +954,7 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } // When calling processJsonnetTemplate without reset - err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) + err := handler.processJsonnetTemplate("/template/test.jsonnet", "/context", "test-context", false) // Then no error should occur if err != nil { @@ -1034,7 +991,7 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } // When calling processJsonnetTemplate with reset - err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", true) + err := handler.processJsonnetTemplate("/template/test.jsonnet", "/context", "test-context", true) // Then no error should occur if err != nil { @@ -1047,58 +1004,6 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } }) - t.Run("ErrorCreatingOutputDirectory", func(t *testing.T) { - // Given a blueprint handler with mocked dependencies - handler, mocks := setup(t) - - // And ReadFile succeeds - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("{}"), nil - } - - // And MkdirAll fails - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return fmt.Errorf("mkdir error") - } - - // When calling processJsonnetTemplate - err := handler.processJsonnetTemplate("/template", "/template/subdir/test.jsonnet", "/context", false) - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error creating output directory") { - t.Errorf("Expected 'error creating output directory' in error, got: %v", err) - } - }) - - t.Run("ErrorWritingOutputFile", func(t *testing.T) { - // Given a blueprint handler with mocked dependencies - handler, mocks := setup(t) - - // And ReadFile succeeds - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("{}"), nil - } - - // And WriteFile fails - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return fmt.Errorf("write file error") - } - - // When calling processJsonnetTemplate - err := handler.processJsonnetTemplate("/template", "/template/test.jsonnet", "/context", false) - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error writing output file") { - t.Errorf("Expected 'error writing output file' in error, got: %v", err) - } - }) - t.Run("SuccessfulProcessing", func(t *testing.T) { // Given a blueprint handler with mocked dependencies handler, mocks := setup(t) @@ -1133,7 +1038,7 @@ func TestBlueprintHandler_processJsonnetTemplate(t *testing.T) { } // When calling processJsonnetTemplate - err := handler.processJsonnetTemplate("/template", "/template/blueprint.jsonnet", "/context", false) + err := handler.processJsonnetTemplate("/template/blueprint.jsonnet", "/context", "test-context", false) // Then no error should occur if err != nil { diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 8dd208994..b5ad45140 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2463,7 +2463,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { t.Run("Success_TemplateProcessing", func(t *testing.T) { // Given a blueprint handler with template directory - handler, _ := setup(t) + handler, mocks := setup(t) // Override: template directory exists templateDir := "/mock/project/contexts/_template" @@ -2474,6 +2474,12 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { return nil, os.ErrNotExist } + // Override: YamlMarshalWithDefinedPaths returns valid YAML + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.YamlMarshalWithDefinedPathsFunc = func(v any) ([]byte, error) { + return []byte("contexts:\n mock-context:\n dns:\n domain: mock.domain.com"), nil + } + // When processing context templates err := handler.ProcessContextTemplates("test-context") @@ -2485,7 +2491,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { t.Run("Success_TemplateProcessing_WithJsonnetFiles", func(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet files - handler, _ := setup(t) + handler, mocks := setup(t) templateDir := "/mock/project/contexts/_template" blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint',\n metadata: { name: context.name }\n}" @@ -2499,6 +2505,12 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { return nil, os.ErrNotExist } + // Override: YamlMarshalWithDefinedPaths returns valid YAML + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.YamlMarshalWithDefinedPathsFunc = func(v any) ([]byte, error) { + return []byte("contexts:\n mock-context:\n dns:\n domain: mock.domain.com"), nil + } + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { if path == templateDir { return []os.DirEntry{ @@ -2620,7 +2632,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { t.Run("Success_BlueprintFileExists_WithReset", func(t *testing.T) { // Given a blueprint handler where blueprint.yaml already exists - handler, _ := setup(t) + handler, mocks := setup(t) blueprintPath := "/mock/project/contexts/test-context/blueprint.yaml" handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -2630,6 +2642,12 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { return nil, os.ErrNotExist } + // Override: YamlMarshalWithDefinedPaths returns valid YAML + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.YamlMarshalWithDefinedPathsFunc = func(v any) ([]byte, error) { + return []byte("contexts:\n mock-context:\n dns:\n domain: mock.domain.com"), nil + } + // Mock jsonnet VM for platform template evaluation handler.shims.NewJsonnetVM = func() JsonnetVM { return &mockJsonnetVM{ @@ -2812,14 +2830,14 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error processing template") { - t.Errorf("Expected template processing error, got: %v", err) + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("Expected template reading error, got: %v", err) } }) t.Run("Error_ProcessJsonnetTemplate_JsonnetEvaluation", func(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet file - handler, _ := setup(t) + handler, mocks := setup(t) templateDir := "/mock/project/contexts/_template" blueprintJsonnet := "invalid jsonnet syntax" @@ -2832,6 +2850,12 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { return nil, os.ErrNotExist } + // Override: YamlMarshalWithDefinedPaths returns valid YAML + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.YamlMarshalWithDefinedPathsFunc = func(v any) ([]byte, error) { + return []byte("contexts:\n mock-context:\n dns:\n domain: mock.domain.com"), nil + } + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { if path == templateDir { return []os.DirEntry{ @@ -2861,14 +2885,14 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error processing template") { - t.Errorf("Expected template processing error, got: %v", err) + if !strings.Contains(err.Error(), "error evaluating jsonnet template") { + t.Errorf("Expected jsonnet evaluation error, got: %v", err) } }) t.Run("Error_ProcessJsonnetTemplate_WriteFile", func(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet file - handler, _ := setup(t) + handler, mocks := setup(t) templateDir := "/mock/project/contexts/_template" blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" @@ -2881,6 +2905,12 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { return nil, os.ErrNotExist } + // Override: YamlMarshalWithDefinedPaths returns valid YAML + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.YamlMarshalWithDefinedPathsFunc = func(v any) ([]byte, error) { + return []byte("contexts:\n mock-context:\n dns:\n domain: mock.domain.com"), nil + } + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { if path == templateDir { return []os.DirEntry{ @@ -2915,8 +2945,8 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error processing template") { - t.Errorf("Expected template processing error, got: %v", err) + if !strings.Contains(err.Error(), "error writing blueprint file") { + t.Errorf("Expected blueprint writing error, got: %v", err) } }) @@ -3004,13 +3034,13 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { }) t.Run("Success_ProcessJsonnetTemplate_TfvarsExtension", func(t *testing.T) { - // Given a blueprint handler with template directory containing terraform jsonnet + // Given a blueprint handler with template directory containing blueprint jsonnet handler, _ := setup(t) templateDir := "/mock/project/contexts/_template" - terraformJsonnet := "local context = std.extVar('context');\n'cluster_name = \"' + context.name + '\"'" + blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint',\n metadata: {\n name: context.name\n }\n}" - // Override: template directory exists with terraform jsonnet file + // Override: template directory exists with blueprint jsonnet file handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == templateDir { return mockFileInfo{name: "_template"}, nil @@ -3021,31 +3051,26 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { if path == templateDir { return []os.DirEntry{ - &mockDirEntry{name: "terraform", isDir: true}, - }, nil - } - if path == filepath.Join(templateDir, "terraform") { - return []os.DirEntry{ - &mockDirEntry{name: "cluster.jsonnet", isDir: false}, + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, }, nil } return nil, fmt.Errorf("directory not found") } handler.shims.ReadFile = func(path string) ([]byte, error) { - return []byte(terraformJsonnet), nil + return []byte(blueprintJsonnet), nil } // Mock jsonnet VM handler.shims.NewJsonnetVM = func() JsonnetVM { return &mockJsonnetVM{ EvaluateFunc: func(filename, snippet string) (string, error) { - return "cluster_name = \"test-context\"", nil + return "kind: Blueprint\nmetadata:\n name: test-context", nil }, } } - // Track written files to verify .tfvars extension + // Track written files to verify blueprint.yaml extension var writtenFiles []string handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { writtenFiles = append(writtenFiles, path) @@ -3060,23 +3085,22 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { t.Errorf("Expected no error, got: %v", err) } - // And terraform file should have .tfvars extension + // And blueprint file should have .yaml extension if len(writtenFiles) != 1 { t.Fatalf("Expected 1 file written, got %d", len(writtenFiles)) } - if !strings.HasSuffix(writtenFiles[0], "cluster.tfvars") { - t.Errorf("Expected terraform file to have .tfvars extension, got: %s", writtenFiles[0]) + if !strings.HasSuffix(writtenFiles[0], "blueprint.yaml") { + t.Errorf("Expected blueprint file to have .yaml extension, got: %s", writtenFiles[0]) } }) t.Run("Success_ProcessJsonnetTemplate_DefaultYamlExtension", func(t *testing.T) { - // Given a blueprint handler with template directory containing other jsonnet + // Given a blueprint handler with template directory containing non-blueprint jsonnet handler, _ := setup(t) templateDir := "/mock/project/contexts/_template" - otherJsonnet := "local context = std.extVar('context');\n{\n name: context.name\n}" - // Override: template directory exists with other jsonnet file + // Override: template directory exists with non-blueprint jsonnet file handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == templateDir { return mockFileInfo{name: "_template"}, nil @@ -3087,26 +3111,13 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { if path == templateDir { return []os.DirEntry{ - &mockDirEntry{name: "config.jsonnet", isDir: false}, + &mockDirEntry{name: "config.jsonnet", isDir: false}, // Not blueprint.jsonnet }, nil } return nil, fmt.Errorf("directory not found") } - handler.shims.ReadFile = func(path string) ([]byte, error) { - return []byte(otherJsonnet), nil - } - - // Mock jsonnet VM - handler.shims.NewJsonnetVM = func() JsonnetVM { - return &mockJsonnetVM{ - EvaluateFunc: func(filename, snippet string) (string, error) { - return "name: test-context", nil - }, - } - } - - // Track written files to verify .yaml extension + // Track written files to verify default blueprint generation var writtenFiles []string handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { writtenFiles = append(writtenFiles, path) @@ -3121,12 +3132,12 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { t.Errorf("Expected no error, got: %v", err) } - // And config file should have .yaml extension by default + // And default blueprint should be generated since no blueprint.jsonnet was found if len(writtenFiles) != 1 { t.Fatalf("Expected 1 file written, got %d", len(writtenFiles)) } - if !strings.HasSuffix(writtenFiles[0], "config.yaml") { - t.Errorf("Expected config file to have .yaml extension, got: %s", writtenFiles[0]) + if !strings.HasSuffix(writtenFiles[0], "blueprint.yaml") { + t.Errorf("Expected blueprint file to have .yaml extension, got: %s", writtenFiles[0]) } }) @@ -3193,13 +3204,13 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { }) t.Run("Error_ProcessJsonnetTemplate_MkdirAllFails", func(t *testing.T) { - // Given a blueprint handler with template directory and MkdirAll error for output directory + // Given a blueprint handler with template directory and MkdirAll error for context directory handler, _ := setup(t) templateDir := "/mock/project/contexts/_template" blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" - // Override: template directory exists with jsonnet file + // Override: template directory exists with blueprint jsonnet file handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == templateDir { return mockFileInfo{name: "_template"}, nil @@ -3210,12 +3221,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { if path == templateDir { return []os.DirEntry{ - &mockDirEntry{name: "nested", isDir: true}, - }, nil - } - if path == filepath.Join(templateDir, "nested") { - return []os.DirEntry{ - &mockDirEntry{name: "deep.jsonnet", isDir: false}, + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, }, nil } return nil, fmt.Errorf("directory not found") @@ -3234,13 +3240,8 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { } } - // Override: MkdirAll returns error only for output directory, not context directory + // Override: MkdirAll returns error for context directory creation handler.shims.MkdirAll = func(path string, perm os.FileMode) error { - // Allow context directory creation to succeed - if strings.Contains(path, "contexts/test-context") && !strings.Contains(path, "nested") { - return nil - } - // Fail when creating nested output directory return fmt.Errorf("mkdir error") } @@ -3251,8 +3252,8 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error processing template") { - t.Errorf("Expected template processing error, got: %v", err) + if !strings.Contains(err.Error(), "error creating context directory") { + t.Errorf("Expected context directory creation error, got: %v", err) } }) @@ -3327,7 +3328,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { } // Override: YamlMarshal returns error during context marshaling - handler.shims.YamlMarshal = func(v interface{}) ([]byte, error) { + handler.shims.YamlMarshal = func(v any) ([]byte, error) { return nil, fmt.Errorf("yaml marshal error") } @@ -3338,8 +3339,8 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error processing template") { - t.Errorf("Expected template processing error, got: %v", err) + if !strings.Contains(err.Error(), "error converting blueprint to YAML") { + t.Errorf("Expected YAML marshalling error, got: %v", err) } }) @@ -3372,7 +3373,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { } // Override: YamlUnmarshal returns error during context unmarshaling - handler.shims.YamlUnmarshal = func(data []byte, v interface{}) error { + handler.shims.YamlUnmarshal = func(data []byte, v any) error { return fmt.Errorf("yaml unmarshal error") } @@ -3383,8 +3384,8 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error processing template") { - t.Errorf("Expected template processing error, got: %v", err) + if !strings.Contains(err.Error(), "error unmarshalling context YAML") { + t.Errorf("Expected YAML unmarshalling error, got: %v", err) } }) @@ -3417,7 +3418,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { } // Override: JsonMarshal returns error during context marshaling - handler.shims.JsonMarshal = func(v interface{}) ([]byte, error) { + handler.shims.JsonMarshal = func(v any) ([]byte, error) { return nil, fmt.Errorf("json marshal error") } @@ -3428,19 +3429,18 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error processing template") { - t.Errorf("Expected template processing error, got: %v", err) + if !strings.Contains(err.Error(), "error marshalling context map to JSON") { + t.Errorf("Expected JSON marshalling error, got: %v", err) } }) t.Run("Success_NestedDirectoryWalking", func(t *testing.T) { - // Given a blueprint handler with nested template directories + // Given a blueprint handler with nested template directories but no blueprint.jsonnet in root handler, _ := setup(t) templateDir := "/mock/project/contexts/_template" - blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" - // Override: template directory exists with nested structure + // Override: template directory exists with nested structure but no blueprint.jsonnet handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == templateDir { return mockFileInfo{name: "_template"}, nil @@ -3452,37 +3452,13 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if path == templateDir { return []os.DirEntry{ &mockDirEntry{name: "nested", isDir: true}, - &mockDirEntry{name: "root.jsonnet", isDir: false}, - }, nil - } - if path == filepath.Join(templateDir, "nested") { - return []os.DirEntry{ - &mockDirEntry{name: "deep", isDir: true}, - &mockDirEntry{name: "nested.jsonnet", isDir: false}, - }, nil - } - if path == filepath.Join(templateDir, "nested", "deep") { - return []os.DirEntry{ - &mockDirEntry{name: "deep.jsonnet", isDir: false}, + &mockDirEntry{name: "other.jsonnet", isDir: false}, // Not blueprint.jsonnet }, nil } return nil, fmt.Errorf("directory not found") } - handler.shims.ReadFile = func(path string) ([]byte, error) { - return []byte(blueprintJsonnet), nil - } - - // Mock jsonnet VM - handler.shims.NewJsonnetVM = func() JsonnetVM { - return &mockJsonnetVM{ - EvaluateFunc: func(filename, snippet string) (string, error) { - return "kind: Blueprint", nil - }, - } - } - - // Track written files to verify all levels processed + // Track written files to verify default blueprint generation var writtenFiles []string handler.shims.WriteFile = func(path string, data []byte, perm os.FileMode) error { writtenFiles = append(writtenFiles, path) @@ -3497,9 +3473,12 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { t.Errorf("Expected no error, got: %v", err) } - // And all jsonnet files should be processed (3 files) - if len(writtenFiles) != 3 { - t.Errorf("Expected 3 files written, got %d: %v", len(writtenFiles), writtenFiles) + // And only default blueprint should be generated since no blueprint.jsonnet was found + if len(writtenFiles) != 1 { + t.Errorf("Expected 1 file written (default blueprint), got %d: %v", len(writtenFiles), writtenFiles) + } + if !strings.HasSuffix(writtenFiles[0], "blueprint.yaml") { + t.Errorf("Expected blueprint.yaml to be written, got: %s", writtenFiles[0]) } }) @@ -3567,9 +3546,9 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Override: YamlMarshal returns error for default blueprint originalYamlMarshal := handler.shims.YamlMarshal - handler.shims.YamlMarshal = func(v interface{}) ([]byte, error) { + handler.shims.YamlMarshal = func(v any) ([]byte, error) { // Allow context marshaling to succeed, but fail on default blueprint - if _, ok := v.(map[string]interface{}); ok { + if _, ok := v.(map[string]any); ok { return originalYamlMarshal(v) } return nil, fmt.Errorf("yaml marshal default blueprint error") @@ -3720,9 +3699,9 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { } // Override: YamlUnmarshal fails for context YAML - handler.shims.YamlUnmarshal = func(data []byte, v interface{}) error { + handler.shims.YamlUnmarshal = func(data []byte, v any) error { // Let the first call (for config) succeed, fail on context map unmarshal - if _, ok := v.(*map[string]interface{}); ok { + if _, ok := v.(*map[string]any); ok { return fmt.Errorf("yaml unmarshal context error") } return nil @@ -3767,7 +3746,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { } // Override: JsonMarshal fails for context JSON - handler.shims.JsonMarshal = func(v interface{}) ([]byte, error) { + handler.shims.JsonMarshal = func(v any) ([]byte, error) { return nil, fmt.Errorf("json marshal context error") } diff --git a/pkg/config/config_handler.go b/pkg/config/config_handler.go index d1b72ddff..5f796824f 100644 --- a/pkg/config/config_handler.go +++ b/pkg/config/config_handler.go @@ -39,6 +39,7 @@ type ConfigHandler interface { IsLoaded() bool SetSecretsProvider(provider secrets.SecretsProvider) GenerateContextID() error + YamlMarshalWithDefinedPaths(v any) ([]byte, error) } const ( diff --git a/pkg/config/mock_config_handler.go b/pkg/config/mock_config_handler.go index e3ca3de15..ad8fec983 100644 --- a/pkg/config/mock_config_handler.go +++ b/pkg/config/mock_config_handler.go @@ -7,27 +7,28 @@ import ( // MockConfigHandler is a mock implementation of the ConfigHandler interface type MockConfigHandler struct { - InitializeFunc func() error - LoadConfigFunc func(path string) error - LoadConfigStringFunc func(content string) error - IsLoadedFunc func() bool - GetStringFunc func(key string, defaultValue ...string) string - GetIntFunc func(key string, defaultValue ...int) int - GetBoolFunc func(key string, defaultValue ...bool) bool - GetStringSliceFunc func(key string, defaultValue ...[]string) []string - GetStringMapFunc func(key string, defaultValue ...map[string]string) map[string]string - SetFunc func(key string, value any) error - SetContextValueFunc func(key string, value any) error - SaveConfigFunc func(path string, overwrite ...bool) error - GetFunc func(key string) any - SetDefaultFunc func(context v1alpha1.Context) error - GetConfigFunc func() *v1alpha1.Context - GetContextFunc func() string - SetContextFunc func(context string) error - GetConfigRootFunc func() (string, error) - CleanFunc func() error - SetSecretsProviderFunc func(provider secrets.SecretsProvider) - GenerateContextIDFunc func() error + InitializeFunc func() error + LoadConfigFunc func(path string) error + LoadConfigStringFunc func(content string) error + IsLoadedFunc func() bool + GetStringFunc func(key string, defaultValue ...string) string + GetIntFunc func(key string, defaultValue ...int) int + GetBoolFunc func(key string, defaultValue ...bool) bool + GetStringSliceFunc func(key string, defaultValue ...[]string) []string + GetStringMapFunc func(key string, defaultValue ...map[string]string) map[string]string + SetFunc func(key string, value any) error + SetContextValueFunc func(key string, value any) error + SaveConfigFunc func(path string, overwrite ...bool) error + GetFunc func(key string) any + SetDefaultFunc func(context v1alpha1.Context) error + GetConfigFunc func() *v1alpha1.Context + GetContextFunc func() string + SetContextFunc func(context string) error + GetConfigRootFunc func() (string, error) + CleanFunc func() error + SetSecretsProviderFunc func(provider secrets.SecretsProvider) + GenerateContextIDFunc func() error + YamlMarshalWithDefinedPathsFunc func(v any) ([]byte, error) } // ============================================================================= @@ -225,5 +226,13 @@ func (m *MockConfigHandler) GenerateContextID() error { return nil } +// YamlMarshalWithDefinedPaths calls the mock YamlMarshalWithDefinedPathsFunc if set, otherwise returns a reasonable default +func (m *MockConfigHandler) YamlMarshalWithDefinedPaths(v any) ([]byte, error) { + if m.YamlMarshalWithDefinedPathsFunc != nil { + return m.YamlMarshalWithDefinedPathsFunc(v) + } + return []byte("contexts:\n mock-context:\n dns:\n domain: mock.domain.com"), nil +} + // Ensure MockConfigHandler implements ConfigHandler var _ ConfigHandler = (*MockConfigHandler)(nil) diff --git a/pkg/config/yaml_config_handler.go b/pkg/config/yaml_config_handler.go index e064f3127..e54c61cc2 100644 --- a/pkg/config/yaml_config_handler.go +++ b/pkg/config/yaml_config_handler.go @@ -677,3 +677,122 @@ func (y *YamlConfigHandler) GenerateContextID() error { id := "w" + string(b) return y.SetContextValue("id", id) } + +// YamlMarshalWithDefinedPaths marshals data to YAML format while ensuring all parent paths are defined. +// It handles struct fields, slices, maps, and primitive types with proper YAML tag handling and nil value representation. +// This method ensures that empty slices and maps are explicitly defined as empty rather than being omitted, +// which is important for configuration templates that need to show structure even when empty. +func (y *YamlConfigHandler) YamlMarshalWithDefinedPaths(v any) ([]byte, error) { + if v == nil { + return nil, fmt.Errorf("invalid input: nil value") + } + + var convert func(reflect.Value) (any, error) + convert = func(val reflect.Value) (any, error) { + switch val.Kind() { + case reflect.Ptr, reflect.Interface: + if val.IsNil() { + if val.Kind() == reflect.Interface || (val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct) { + return make(map[string]any), nil + } + return nil, nil + } + return convert(val.Elem()) + case reflect.Struct: + result := make(map[string]any) + typ := val.Type() + for i := range make([]int, val.NumField()) { + fieldValue := val.Field(i) + fieldType := typ.Field(i) + + if fieldType.PkgPath != "" { + continue + } + + yamlTag := strings.Split(fieldType.Tag.Get("yaml"), ",")[0] + if yamlTag == "-" { + continue + } + if yamlTag == "" { + yamlTag = fieldType.Name + } + + fieldInterface, err := convert(fieldValue) + if err != nil { + return nil, fmt.Errorf("error converting field %s: %w", fieldType.Name, err) + } + if fieldInterface != nil || fieldType.Type.Kind() == reflect.Interface || fieldType.Type.Kind() == reflect.Slice || fieldType.Type.Kind() == reflect.Map || fieldType.Type.Kind() == reflect.Struct { + result[yamlTag] = fieldInterface + } + } + return result, nil + case reflect.Slice, reflect.Array: + if val.Len() == 0 { + return []any{}, nil + } + slice := make([]any, val.Len()) + for i := 0; i < val.Len(); i++ { + elemVal := val.Index(i) + if elemVal.Kind() == reflect.Ptr || elemVal.Kind() == reflect.Interface { + if elemVal.IsNil() { + slice[i] = nil + continue + } + } + elemInterface, err := convert(elemVal) + if err != nil { + return nil, fmt.Errorf("error converting slice element at index %d: %w", i, err) + } + slice[i] = elemInterface + } + return slice, nil + case reflect.Map: + result := make(map[string]any) + for _, key := range val.MapKeys() { + keyStr := fmt.Sprintf("%v", key.Interface()) + elemVal := val.MapIndex(key) + if elemVal.Kind() == reflect.Interface && elemVal.IsNil() { + result[keyStr] = nil + continue + } + elemInterface, err := convert(elemVal) + if err != nil { + return nil, fmt.Errorf("error converting map value for key %s: %w", keyStr, err) + } + if elemInterface != nil || elemVal.Kind() == reflect.Interface || elemVal.Kind() == reflect.Slice || elemVal.Kind() == reflect.Map || elemVal.Kind() == reflect.Struct { + result[keyStr] = elemInterface + } + } + return result, nil + case reflect.String: + return val.String(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return val.Int(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return val.Uint(), nil + case reflect.Float32, reflect.Float64: + return val.Float(), nil + case reflect.Bool: + return val.Bool(), nil + default: + return nil, fmt.Errorf("unsupported value type %s", val.Kind()) + } + } + + val := reflect.ValueOf(v) + if val.Kind() == reflect.Func { + return nil, fmt.Errorf("unsupported value type func") + } + + processed, err := convert(val) + if err != nil { + return nil, err + } + + yamlData, err := y.shims.YamlMarshal(processed) + if err != nil { + return nil, fmt.Errorf("error marshalling yaml: %w", err) + } + + return yamlData, nil +} diff --git a/pkg/config/yaml_config_handler_test.go b/pkg/config/yaml_config_handler_test.go index b20e1f9a4..d6b54b290 100644 --- a/pkg/config/yaml_config_handler_test.go +++ b/pkg/config/yaml_config_handler_test.go @@ -2555,3 +2555,559 @@ func TestYamlConfigHandler_GenerateContextID(t *testing.T) { } }) } + +func TestYamlMarshalWithDefinedPaths(t *testing.T) { + setup := func(t *testing.T) *YamlConfigHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewYamlConfigHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize YamlConfigHandler: %v", err) + } + return handler + } + + t.Run("IgnoreYamlMinusTag", func(t *testing.T) { + // Given a struct with a YAML minus tag + type testStruct struct { + Public string `yaml:"public"` + private string `yaml:"-"` + } + input := testStruct{Public: "value", private: "ignored"} + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the public field should be included + if !strings.Contains(string(result), "public: value") { + t.Errorf("Expected 'public: value' in result, got: %s", string(result)) + } + + // And the ignored field should be excluded + if strings.Contains(string(result), "ignored") { + t.Errorf("Expected 'ignored' not to be in result, got: %s", string(result)) + } + }) + + t.Run("NilInput", func(t *testing.T) { + // When marshalling nil input + handler := setup(t) + _, err := handler.YamlMarshalWithDefinedPaths(nil) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for nil input, got nil") + } + + // And the error message should be appropriate + if !strings.Contains(err.Error(), "invalid input: nil value") { + t.Errorf("Expected error about nil input, got: %v", err) + } + }) + + t.Run("EmptySlice", func(t *testing.T) { + // Given an empty slice + input := []string{} + + // When marshalling the slice + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the result should be an empty array + if string(result) != "[]\n" { + t.Errorf("Expected '[]\n', got: %s", string(result)) + } + }) + + t.Run("NoYamlTag", func(t *testing.T) { + // Given a struct with no YAML tags + type testStruct struct { + Field string + } + input := testStruct{Field: "value"} + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the field name should be used as is + if !strings.Contains(string(result), "Field: value") { + t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) + } + }) + + t.Run("CustomYamlTag", func(t *testing.T) { + // Given a struct with a custom YAML tag + type testStruct struct { + Field string `yaml:"custom_field"` + } + input := testStruct{Field: "value"} + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the custom field name should be used + if !strings.Contains(string(result), "custom_field: value") { + t.Errorf("Expected 'custom_field: value' in result, got: %s", string(result)) + } + }) + + t.Run("MapWithCustomTags", func(t *testing.T) { + // Given a map with nested structs using custom YAML tags + type nestedStruct struct { + Value string `yaml:"custom_value"` + } + input := map[string]nestedStruct{ + "key": {Value: "test"}, + } + + // When marshalling the map + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the map key should be preserved + if !strings.Contains(string(result), "key:") { + t.Errorf("Expected 'key:' in result, got: %s", string(result)) + } + + // And the nested custom field name should be used + if !strings.Contains(string(result), " custom_value: test") { + t.Errorf("Expected ' custom_value: test' in result, got: %s", string(result)) + } + }) + + t.Run("DefaultFieldName", func(t *testing.T) { + // Given a struct with default field names + data := struct { + Field string + }{ + Field: "value", + } + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(data) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the default field name should be used + if !strings.Contains(string(result), "Field: value") { + t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) + } + }) + + t.Run("FuncType", func(t *testing.T) { + // When marshalling a function type + handler := setup(t) + _, err := handler.YamlMarshalWithDefinedPaths(func() {}) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for func type, got nil") + } + + // And the error message should be appropriate + if !strings.Contains(err.Error(), "unsupported value type func") { + t.Errorf("Expected error about unsupported value type, got: %v", err) + } + }) + + t.Run("UnsupportedType", func(t *testing.T) { + // When marshalling an unsupported type + handler := setup(t) + _, err := handler.YamlMarshalWithDefinedPaths(make(chan int)) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for unsupported type, got nil") + } + + // And the error message should be appropriate + if !strings.Contains(err.Error(), "unsupported value type") { + t.Errorf("Expected error about unsupported value type, got: %v", err) + } + }) + + t.Run("MapWithNilValues", func(t *testing.T) { + // Given a map with nil values + input := map[string]any{ + "key1": nil, + "key2": "value2", + } + + // When marshalling the map + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And nil values should be represented as null + if !strings.Contains(string(result), "key1: null") { + t.Errorf("Expected 'key1: null' in result, got: %s", string(result)) + } + + // And non-nil values should be preserved + if !strings.Contains(string(result), "key2: value2") { + t.Errorf("Expected 'key2: value2' in result, got: %s", string(result)) + } + }) + + t.Run("SliceWithNilValues", func(t *testing.T) { + // Given a slice with nil values + input := []any{nil, "value", nil} + + // When marshalling the slice + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And nil values should be represented as null + if !strings.Contains(string(result), "- null") { + t.Errorf("Expected '- null' in result, got: %s", string(result)) + } + + // And non-nil values should be preserved + if !strings.Contains(string(result), "- value") { + t.Errorf("Expected '- value' in result, got: %s", string(result)) + } + }) + + t.Run("StructWithPrivateFields", func(t *testing.T) { + // Given a struct with both public and private fields + type testStruct struct { + Public string + private string + } + input := testStruct{Public: "value", private: "ignored"} + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And public fields should be included + if !strings.Contains(string(result), "Public: value") { + t.Errorf("Expected 'Public: value' in result, got: %s", string(result)) + } + + // And private fields should be excluded + if strings.Contains(string(result), "private") { + t.Errorf("Expected 'private' not to be in result, got: %s", string(result)) + } + }) + + t.Run("StructWithYamlTag", func(t *testing.T) { + // Given a struct with a YAML tag + type testStruct struct { + Field string `yaml:"custom_name"` + } + input := testStruct{Field: "value"} + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the custom field name should be used + if !strings.Contains(string(result), "custom_name: value") { + t.Errorf("Expected 'custom_name: value' in result, got: %s", string(result)) + } + }) + + t.Run("NestedStructs", func(t *testing.T) { + // Given nested structs + type nested struct { + Value string + } + type parent struct { + Nested nested + } + input := parent{Nested: nested{Value: "test"}} + + // When marshalling the nested structs + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the parent field should be included + if !strings.Contains(string(result), "Nested:") { + t.Errorf("Expected 'Nested:' in result, got: %s", string(result)) + } + + // And the nested field should be properly indented + if !strings.Contains(string(result), " Value: test") { + t.Errorf("Expected ' Value: test' in result, got: %s", string(result)) + } + }) + + t.Run("NumericTypes", func(t *testing.T) { + // Given a struct with various numeric types + type numbers struct { + Int int `yaml:"int"` + Int8 int8 `yaml:"int8"` + Int16 int16 `yaml:"int16"` + Int32 int32 `yaml:"int32"` + Int64 int64 `yaml:"int64"` + Uint uint `yaml:"uint"` + Uint8 uint8 `yaml:"uint8"` + Uint16 uint16 `yaml:"uint16"` + Uint32 uint32 `yaml:"uint32"` + Uint64 uint64 `yaml:"uint64"` + Float32 float32 `yaml:"float32"` + Float64 float64 `yaml:"float64"` + } + input := numbers{ + Int: 1, Int8: 2, Int16: 3, Int32: 4, Int64: 5, + Uint: 6, Uint8: 7, Uint16: 8, Uint32: 9, Uint64: 10, + Float32: 11.1, Float64: 12.2, + } + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And all numeric values should be correctly represented + for _, expected := range []string{ + "int: 1", "int8: 2", "int16: 3", "int32: 4", "int64: 5", + "uint: 6", "uint8: 7", "uint16: 8", "uint32: 9", "uint64: 10", + "float32: 11.1", "float64: 12.2", + } { + if !strings.Contains(string(result), expected) { + t.Errorf("Expected '%s' in result, got: %s", expected, string(result)) + } + } + }) + + t.Run("BooleanType", func(t *testing.T) { + // Given a struct with boolean fields + type boolStruct struct { + True bool `yaml:"true"` + False bool `yaml:"false"` + } + input := boolStruct{True: true, False: false} + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the boolean values should be correctly represented + if !strings.Contains(string(result), `"true": true`) { + t.Errorf("Expected '\"true\": true' in result, got: %s", string(result)) + } + if !strings.Contains(string(result), `"false": false`) { + t.Errorf("Expected '\"false\": false' in result, got: %s", string(result)) + } + }) + + t.Run("NilPointerAndInterface", func(t *testing.T) { + // Given a struct with nil pointers and interfaces + type testStruct struct { + NilPtr *string `yaml:"nil_ptr"` + NilInterface any `yaml:"nil_interface"` + NilMap map[string]string `yaml:"nil_map"` + NilSlice []string `yaml:"nil_slice"` + NilStruct *struct{ Field int } `yaml:"nil_struct"` + } + input := testStruct{} + + // When marshalling the struct + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And nil interfaces should be represented as empty objects + if !strings.Contains(string(result), "nil_interface: {}") { + t.Errorf("Expected 'nil_interface: {}' in result, got: %s", string(result)) + } + + // And nil slices should be represented as empty arrays + if !strings.Contains(string(result), "nil_slice: []") { + t.Errorf("Expected 'nil_slice: []' in result, got: %s", string(result)) + } + + // And nil maps should be represented as empty objects + if !strings.Contains(string(result), "nil_map: {}") { + t.Errorf("Expected 'nil_map: {}' in result, got: %s", string(result)) + } + + // And nil structs should be represented as empty objects + if !strings.Contains(string(result), "nil_struct: {}") { + t.Errorf("Expected 'nil_struct: {}' in result, got: %s", string(result)) + } + }) + + t.Run("SliceWithNilElements", func(t *testing.T) { + // Given a slice with nil elements + type elem struct { + Field string + } + input := []*elem{nil, {Field: "value"}, nil} + + // When marshalling the slice + handler := setup(t) + result, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths failed: %v", err) + } + + // And non-nil elements should be correctly represented + if !strings.Contains(string(result), "Field: value") { + t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) + } + }) + + t.Run("ErrorInSliceConversion", func(t *testing.T) { + // Given a slice containing an unsupported type + input := []any{make(chan int)} + + // When attempting to marshal the slice + handler := setup(t) + _, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for slice with unsupported type, got nil") + } + + // And the error should indicate the slice conversion issue + if !strings.Contains(err.Error(), "error converting slice element") { + t.Errorf("Expected error about slice conversion, got: %v", err) + } + }) + + t.Run("ErrorInMapConversion", func(t *testing.T) { + // Given a map containing an unsupported type + input := map[string]any{ + "channel": make(chan int), + } + + // When attempting to marshal the map + handler := setup(t) + _, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for map with unsupported type, got nil") + } + + // And the error should indicate the map conversion issue + if !strings.Contains(err.Error(), "error converting map value") { + t.Errorf("Expected error about map conversion, got: %v", err) + } + }) + + t.Run("ErrorInStructFieldConversion", func(t *testing.T) { + // Given a struct containing an unsupported field type + type testStruct struct { + Channel chan int + } + input := testStruct{Channel: make(chan int)} + + // When attempting to marshal the struct + handler := setup(t) + _, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for struct with unsupported field type, got nil") + } + + // And the error should indicate the field conversion issue + if !strings.Contains(err.Error(), "error converting field") { + t.Errorf("Expected error about field conversion, got: %v", err) + } + }) + + t.Run("YamlMarshalError", func(t *testing.T) { + // Given a config handler with mocked YAML marshalling that fails + handler := setup(t) + + // And a mock YAML marshaller that returns an error + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("mock yaml marshal error") + } + + // And a simple struct to marshal + input := struct{ Field string }{Field: "value"} + + // When marshalling the struct + _, err := handler.YamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error from yaml marshal, got nil") + } + + // And the error should indicate the YAML marshalling issue + if !strings.Contains(err.Error(), "error marshalling yaml") { + t.Errorf("Expected error about yaml marshalling, got: %v", err) + } + }) +} diff --git a/pkg/env/shims.go b/pkg/env/shims.go index 62c2e303a..a7047ae54 100644 --- a/pkg/env/shims.go +++ b/pkg/env/shims.go @@ -26,8 +26,8 @@ type Shims struct { Glob func(string) ([]string, error) WriteFile func(string, []byte, os.FileMode) error ReadDir func(string) ([]os.DirEntry, error) - YamlUnmarshal func([]byte, interface{}) error - YamlMarshal func(interface{}) ([]byte, error) + YamlUnmarshal func([]byte, any) error + YamlMarshal func(any) ([]byte, error) Remove func(string) error RemoveAll func(string) error CryptoRandRead func([]byte) (int, error) diff --git a/pkg/generators/generator_test.go b/pkg/generators/generator_test.go index aec6e90f3..16a7b9487 100644 --- a/pkg/generators/generator_test.go +++ b/pkg/generators/generator_test.go @@ -1,6 +1,7 @@ package generators import ( + "encoding/json" "io/fs" "os" "path/filepath" @@ -196,6 +197,12 @@ output "local_output1" { return []byte{}, nil } + shims.JsonUnmarshal = func(data []byte, v any) error { + return json.Unmarshal(data, v) + } + shims.FilepathRel = func(basepath, targpath string) (string, error) { + return filepath.Rel(basepath, targpath) + } configHandler.Initialize() diff --git a/pkg/generators/kustomize_generator.go b/pkg/generators/kustomize_generator.go index 5e934b452..9269fa57b 100644 --- a/pkg/generators/kustomize_generator.go +++ b/pkg/generators/kustomize_generator.go @@ -55,7 +55,7 @@ func (g *KustomizeGenerator) Write(overwrite ...bool) error { return nil } - kustomization := map[string]interface{}{ + kustomization := map[string]any{ "apiVersion": "kustomize.config.k8s.io/v1beta1", "kind": "Kustomization", "resources": []string{}, diff --git a/pkg/generators/shims.go b/pkg/generators/shims.go index 8ec42fa49..bb77fa698 100644 --- a/pkg/generators/shims.go +++ b/pkg/generators/shims.go @@ -1,7 +1,9 @@ package generators import ( + "encoding/json" "os" + "path/filepath" "github.com/goccy/go-yaml" ) @@ -17,16 +19,20 @@ import ( // Shims provides mockable wrappers around system and runtime functions type Shims struct { - WriteFile func(name string, data []byte, perm os.FileMode) error - ReadFile func(name string) ([]byte, error) - MkdirAll func(path string, perm os.FileMode) error - Stat func(name string) (os.FileInfo, error) - MarshalYAML func(v any) ([]byte, error) - TempDir func(dir, pattern string) (string, error) - RemoveAll func(path string) error - Chdir func(dir string) error - ReadDir func(name string) ([]os.DirEntry, error) - Setenv func(key, value string) error + WriteFile func(name string, data []byte, perm os.FileMode) error + ReadFile func(name string) ([]byte, error) + MkdirAll func(path string, perm os.FileMode) error + Stat func(name string) (os.FileInfo, error) + MarshalYAML func(v any) ([]byte, error) + TempDir func(dir, pattern string) (string, error) + RemoveAll func(path string) error + Chdir func(dir string) error + ReadDir func(name string) ([]os.DirEntry, error) + Setenv func(key, value string) error + YamlUnmarshal func(data []byte, v any) error + JsonMarshal func(v any) ([]byte, error) + JsonUnmarshal func(data []byte, v any) error + FilepathRel func(basepath, targpath string) (string, error) } // ============================================================================= @@ -36,15 +42,19 @@ type Shims struct { // NewShims creates a new Shims instance with default implementations func NewShims() *Shims { return &Shims{ - WriteFile: os.WriteFile, - ReadFile: os.ReadFile, - MkdirAll: os.MkdirAll, - Stat: os.Stat, - MarshalYAML: yaml.Marshal, - TempDir: os.MkdirTemp, - RemoveAll: os.RemoveAll, - Chdir: os.Chdir, - ReadDir: os.ReadDir, - Setenv: os.Setenv, + WriteFile: os.WriteFile, + ReadFile: os.ReadFile, + MkdirAll: os.MkdirAll, + Stat: os.Stat, + MarshalYAML: yaml.Marshal, + TempDir: os.MkdirTemp, + RemoveAll: os.RemoveAll, + Chdir: os.Chdir, + ReadDir: os.ReadDir, + Setenv: os.Setenv, + YamlUnmarshal: yaml.Unmarshal, + JsonMarshal: json.Marshal, + JsonUnmarshal: json.Unmarshal, + FilepathRel: filepath.Rel, } } diff --git a/pkg/generators/terraform_generator.go b/pkg/generators/terraform_generator.go index 4378ddb24..805d777b9 100644 --- a/pkg/generators/terraform_generator.go +++ b/pkg/generators/terraform_generator.go @@ -1,13 +1,14 @@ package generators import ( - "encoding/json" "fmt" + "maps" "os" "path/filepath" "sort" "strings" + "github.com/google/go-jsonnet" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" @@ -64,22 +65,34 @@ func NewTerraformGenerator(injector di.Injector) *TerraformGenerator { // Public Methods // ============================================================================= -// Write generates Terraform configuration files for all components in the blueprint. -// It creates the necessary directory structure and writes three types of files: -// 1. main.tf - Contains module source and variable references -// 2. variables.tf - Defines all variables used by the module -// 3. .tfvars - Contains actual variable values for each context -// The function preserves existing values in .tfvars files while adding new ones. -// When reset is enabled, it removes existing .terraform state directories to force reinitialization. -// For components with remote sources, it generates module shims that provide local references. +// Write generates Terraform configuration files including module shims and tfvars files for all components. +// It processes jsonnet templates from contexts/_template/terraform directory to merge template values into +// blueprint terraform components, then handles components by generating module shims for remote sources and +// creating corresponding tfvars files. The function manages terraform state cleanup on reset. func (g *TerraformGenerator) Write(overwrite ...bool) error { shouldOverwrite := false if len(overwrite) > 0 { shouldOverwrite = overwrite[0] } g.reset = shouldOverwrite + components := g.blueprintHandler.GetTerraformComponents() + templateValues, err := g.processTemplates(shouldOverwrite) + if err != nil { + return fmt.Errorf("failed to process terraform templates: %w", err) + } + + for i, component := range components { + if values, exists := templateValues[component.Path]; exists { + if component.Values == nil { + component.Values = make(map[string]any) + } + maps.Copy(component.Values, values) + components[i] = component + } + } + projectRoot, err := g.shell.GetProjectRoot() if err != nil { return fmt.Errorf("failed to get project root: %w", err) @@ -123,6 +136,124 @@ func (g *TerraformGenerator) Write(overwrite ...bool) error { // Private Methods // ============================================================================= +// processTemplates discovers and processes jsonnet template files from the contexts/_template/terraform directory. +// It checks for template directory existence, retrieves the current context configuration, and recursively +// walks through template files to generate corresponding .tfvars files. The function handles template +// discovery, context resolution, and delegates actual processing to walkTemplateDirectory. +func (g *TerraformGenerator) processTemplates(reset bool) (map[string]map[string]any, error) { + projectRoot, err := g.shell.GetProjectRoot() + if err != nil { + return nil, fmt.Errorf("failed to get project root: %w", err) + } + + templateDir := filepath.Join(projectRoot, "contexts", "_template", "terraform") + + if _, err := g.shims.Stat(templateDir); os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("failed to check template directory: %w", err) + } + + contextPath, err := g.configHandler.GetConfigRoot() + if err != nil { + return nil, fmt.Errorf("failed to get config root: %w", err) + } + + contextName := g.configHandler.GetString("context") + if contextName == "" { + contextName = os.Getenv("WINDSOR_CONTEXT") + } + + templateValues := make(map[string]map[string]any) + + return templateValues, g.walkTemplateDirectory(templateDir, contextPath, contextName, reset, templateValues) +} + +// walkTemplateDirectory recursively traverses the template directory structure and processes jsonnet files. +// It handles both files and subdirectories, maintaining the directory structure in the output location. +// For each .jsonnet file found, it delegates processing to processJsonnetTemplate to collect template +// values that will be merged into terraform components. +func (g *TerraformGenerator) walkTemplateDirectory(templateDir, contextPath, contextName string, reset bool, templateValues map[string]map[string]any) error { + entries, err := g.shims.ReadDir(templateDir) + if err != nil { + return fmt.Errorf("failed to read template directory: %w", err) + } + + for _, entry := range entries { + entryPath := filepath.Join(templateDir, entry.Name()) + + if entry.IsDir() { + if err := g.walkTemplateDirectory(entryPath, contextPath, contextName, reset, templateValues); err != nil { + return err + } + } else if strings.HasSuffix(entry.Name(), ".jsonnet") { + if err := g.processJsonnetTemplate(entryPath, contextName, templateValues); err != nil { + return err + } + } + } + + return nil +} + +// processJsonnetTemplate processes a jsonnet template file and collects generated values +// for merging into blueprint terraform components. It evaluates the template with context data +// made available via std.extVar("context"), then stores the result in templateValues using +// the relative path from the template directory as the key. +// Templates must include: local context = std.extVar("context"); to access context data. +func (g *TerraformGenerator) processJsonnetTemplate(templateFile, contextName string, templateValues map[string]map[string]any) error { + templateContent, err := g.shims.ReadFile(templateFile) + if err != nil { + return fmt.Errorf("error reading template file %s: %w", templateFile, err) + } + + config := g.configHandler.GetConfig() + + contextYAML, err := g.configHandler.YamlMarshalWithDefinedPaths(config) + if err != nil { + return fmt.Errorf("error marshalling context to YAML: %w", err) + } + + var contextMap map[string]any = make(map[string]any) + if err := g.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { + return fmt.Errorf("error unmarshalling context YAML: %w", err) + } + + contextMap["name"] = contextName + contextJSON, err := g.shims.JsonMarshal(contextMap) + if err != nil { + return fmt.Errorf("error marshalling context map to JSON: %w", err) + } + + vm := jsonnet.MakeVM() + vm.ExtCode("context", string(contextJSON)) + result, err := vm.EvaluateAnonymousSnippet("template.jsonnet", string(templateContent)) + if err != nil { + return fmt.Errorf("error evaluating jsonnet template %s: %w", templateFile, err) + } + + var values map[string]any + if err := g.shims.JsonUnmarshal([]byte(result), &values); err != nil { + return fmt.Errorf("jsonnet template must output valid JSON: %w", err) + } + + projectRoot, err := g.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("failed to get project root: %w", err) + } + + templateDir := filepath.Join(projectRoot, "contexts", "_template", "terraform") + relPath, err := g.shims.FilepathRel(templateDir, templateFile) + if err != nil { + return fmt.Errorf("failed to calculate relative path: %w", err) + } + + componentPath := strings.TrimSuffix(relPath, ".jsonnet") + templateValues[componentPath] = values + + return nil +} + // generateModuleShim creates a local reference to a remote Terraform module. // It provides a shim layer that maintains module configuration while allowing Windsor to manage it. // The function orchestrates the creation of main.tf, variables.tf, and outputs.tf files. @@ -208,7 +339,7 @@ func (g *TerraformGenerator) initializeTerraformModule(component blueprintv1alph continue } var initOutput TerraformInitOutput - if err := json.Unmarshal([]byte(line), &initOutput); err != nil { + if err := g.shims.JsonUnmarshal([]byte(line), &initOutput); err != nil { continue } if initOutput.Type == "log" { diff --git a/pkg/generators/terraform_generator_test.go b/pkg/generators/terraform_generator_test.go index 6b93fb2ee..d793e1106 100644 --- a/pkg/generators/terraform_generator_test.go +++ b/pkg/generators/terraform_generator_test.go @@ -808,7 +808,7 @@ func TestTerraformGenerator_Write(t *testing.T) { } // And the error should match the expected error - expectedError := "failed to get project root: error getting project root" + expectedError := "failed to process terraform templates: failed to get project root: error getting project root" if err.Error() != expectedError { t.Errorf("expected error %s, got %s", expectedError, err.Error()) } @@ -863,8 +863,10 @@ func TestTerraformGenerator_Write(t *testing.T) { }) t.Run("DeletesTerraformDirOnReset", func(t *testing.T) { + // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // Arrange: .terraform dir exists, RemoveAll should be called + + // And .terraform directory exists var removedPath string mocks.Shims.Stat = func(path string) (os.FileInfo, error) { if strings.HasSuffix(path, ".terraform") { @@ -889,12 +891,16 @@ func TestTerraformGenerator_Write(t *testing.T) { mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { return nil } mocks.Shims.ReadFile = func(_ string) ([]byte, error) { return []byte{}, nil } mocks.Shims.Chdir = func(_ string) error { return nil } - // Act + + // When Write is called with reset=true err := generator.Write(true) - // Assert + + // Then no error should occur if err != nil { t.Fatalf("expected no error, got %v", err) } + + // And the .terraform directory should be removed want := filepath.Join("/mock/context", ".terraform") if removedPath != want { t.Errorf("expected RemoveAll called with %q, got %q", want, removedPath) @@ -902,8 +908,10 @@ func TestTerraformGenerator_Write(t *testing.T) { }) t.Run("ErrorRemovingTerraformDir", func(t *testing.T) { + // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // Arrange: .terraform dir exists, RemoveAll should fail + + // And .terraform directory exists but RemoveAll fails mocks.Shims.Stat = func(path string) (os.FileInfo, error) { if strings.HasSuffix(path, ".terraform") { return nil, nil // exists @@ -922,17 +930,70 @@ func TestTerraformGenerator_Write(t *testing.T) { mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { return []blueprintv1alpha1.TerraformComponent{} } - // Act + + // When Write is called with reset=true err := generator.Write(true) - // Assert + + // Then an error should be returned if err == nil { t.Fatal("expected error, got nil") } + + // And the error should match the expected message expectedError := "failed to remove .terraform directory: mock error removing directory" if err.Error() != expectedError { t.Errorf("expected error %q, got %q", expectedError, err.Error()) } }) + + t.Run("ErrorFromGenerateModuleShim", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And a component with source that will cause generateModuleShim to fail + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "test-component", + FullPath: "/tmp/terraform/test-component", + } + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{component} + } + + // Mock processTemplates to succeed + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/tmp/context", nil + } + + mocks.Shims.MkdirAll = func(path string, _ fs.FileMode) error { + if strings.Contains(path, "terraform") && !strings.Contains(path, "test-component") { + return nil // Allow terraform folder creation + } + return fmt.Errorf("mock error creating module directory") + } + + // When Write is called + err := generator.Write() + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to generate module shim") { + t.Errorf("expected error to contain 'failed to generate module shim', got %v", err) + } + }) + } func TestTerraformGenerator_generateModuleShim(t *testing.T) { @@ -1389,7 +1450,7 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output without module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"}`, nil + return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z"}`, nil } // And Stat is mocked to return success for .tf_modules/variables.tf @@ -1437,7 +1498,7 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output without module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"}`, nil + return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z"}`, nil } // And Stat is mocked to return success for the standard path @@ -2546,6 +2607,95 @@ func TestTerraformGenerator_writeShimVariablesTf(t *testing.T) { } } }) + + t.Run("ErrorReadingVariables", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return an error for variables.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return nil, fmt.Errorf("mock error reading variables.tf") + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read variables.tf") { + t.Errorf("expected error to contain 'failed to read variables.tf', got %v", err) + } + }) + + t.Run("ErrorParsingVariables", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return invalid HCL content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`invalid hcl syntax {`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to parse variables.tf") { + t.Errorf("expected error to contain 'failed to parse variables.tf', got %v", err) + } + }) + + t.Run("ErrorWritingMainTf", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return content for variables.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // And WriteFile is mocked to fail only for main.tf + mocks.Shims.WriteFile = func(path string, _ []byte, _ fs.FileMode) error { + if strings.HasSuffix(path, "main.tf") { + return fmt.Errorf("mock error writing main.tf") + } + return nil // Success for variables.tf + } + + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to write shim main.tf") { + t.Errorf("expected error to contain 'failed to write shim main.tf', got %v", err) + } + }) } func TestTerraformGenerator_writeShimOutputsTf(t *testing.T) { @@ -2594,3 +2744,1206 @@ func TestTerraformGenerator_writeShimOutputsTf(t *testing.T) { } }) } + +// ============================================================================= +// Test Template Processing Methods +// ============================================================================= + +func TestTerraformGenerator_processTemplates(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("TemplateDirectoryNotExists", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return os.ErrNotExist for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, os.ErrNotExist + } + return nil, nil + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then no error should occur and result should be nil + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + }) + + t.Run("ErrorCheckingTemplateDirectory", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return an error for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, fmt.Errorf("permission denied") + } + return nil, nil + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to check template directory") { + t.Errorf("expected error to contain 'failed to check template directory', got %v", err) + } + }) + + t.Run("ErrorGettingProjectRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Shell.GetProjectRoot is mocked to return an error + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root error") + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to get project root") { + t.Errorf("expected error to contain 'failed to get project root', got %v", err) + } + }) + + t.Run("SuccessWithEmptyDirectory", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return success for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And ReadDir is mocked to return empty directory + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And result should be an empty map + if result == nil { + t.Errorf("expected non-nil result, got nil") + } + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } + }) + + t.Run("ProcessesJsonnetFiles", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry with jsonnet file + mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + + // And ReadDir is mocked to return a jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + // Mock all dependencies for processJsonnetTemplate + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`{ + test_var: "test_value" +}`), nil + } + + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + if values, ok := v.(*map[string]any); ok { + *values = map[string]any{"test_var": "test_value"} + } + return nil + } + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/tmp/contexts/_template/terraform", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And template values should contain the processed template + if len(templateValues) != 1 { + t.Errorf("expected 1 template value, got %d", len(templateValues)) + } + }) + + t.Run("ErrorProcessingJsonnetTemplate", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry with jsonnet file + mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + + // And ReadDir is mocked to return a jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + // And ReadFile is mocked to return an error for processJsonnetTemplate + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return nil, fmt.Errorf("file read error") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("expected error to contain 'error reading template file', got %v", err) + } + }) + + t.Run("RecursivelyProcessesDirectories", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + callCount := 0 + // Create mock directory entry + mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + + // And ReadDir is mocked to return a directory on first call, empty on second + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + callCount++ + if callCount == 1 { + return []fs.DirEntry{mockDirEntry}, nil + } + return []fs.DirEntry{}, nil // Empty subdirectory + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And ReadDir should have been called twice (once for root, once for subdir) + if callCount != 2 { + t.Errorf("expected ReadDir to be called 2 times, got %d", callCount) + } + }) + + t.Run("ErrorInRecursiveDirectoryCall", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + callCount := 0 + // Create mock directory entry + mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + + // And ReadDir is mocked to return a directory on first call, error on second + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + callCount++ + if callCount == 1 { + return []fs.DirEntry{mockDirEntry}, nil + } + return nil, fmt.Errorf("subdirectory read error") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read template directory") { + t.Errorf("expected error to contain 'failed to read template directory', got %v", err) + } + }) + + t.Run("ErrorGettingConfigRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return success for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And GetConfigRoot is mocked to return an error + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to get config root") { + t.Errorf("expected error to contain 'failed to get config root', got %v", err) + } + }) + + t.Run("UsesEnvironmentVariableForContextName", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return success for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And ReadDir is mocked to return empty directory + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + } + + // And GetString is mocked to return empty string (no context configured) + mocks.ConfigHandler.(*config.MockConfigHandler).GetStringFunc = func(key string, defaultValue ...string) string { + if key == "context" { + return "" // No context configured + } + return "" + } + + // Mock environment variable + originalEnv := os.Getenv("WINDSOR_CONTEXT") + os.Setenv("WINDSOR_CONTEXT", "env-context") + defer os.Setenv("WINDSOR_CONTEXT", originalEnv) + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And result should be an empty map + if result == nil { + t.Errorf("expected non-nil result, got nil") + } + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } + + // Note: The environment variable usage is tested by the fact that + // the function completes successfully and calls walkTemplateDirectory + // with the environment context name + }) +} + +func TestTerraformGenerator_processJsonnetTemplate(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("ErrorReadingTemplateFile", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return an error + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return nil, fmt.Errorf("file not found") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("expected error to contain 'error reading template file', got %v", err) + } + }) + + t.Run("ErrorMarshallingContextToYAML", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`local context = std.extVar("context"); +{ + test_var: context.test +}`), nil + } + + // And YamlMarshalWithDefinedPaths is mocked to return an error + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return nil, fmt.Errorf("yaml marshal error") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error marshalling context to YAML") { + t.Errorf("expected error to contain 'error marshalling context to YAML', got %v", err) + } + }) + + t.Run("ErrorUnmarshallingContextYAML", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`local context = std.extVar("context"); +{ + test_var: context.test +}`), nil + } + + // And YamlMarshalWithDefinedPaths is mocked + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + // And YamlUnmarshal is mocked to return an error + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("yaml unmarshal error") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error unmarshalling context YAML") { + t.Errorf("expected error to contain 'error unmarshalling context YAML', got %v", err) + } + }) + + t.Run("ErrorMarshallingContextToJSON", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`local context = std.extVar("context"); +{ + test_var: context.test +}`), nil + } + + // And YamlMarshalWithDefinedPaths is mocked + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + // And YamlUnmarshal is mocked + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + // And JsonMarshal is mocked to return an error + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("json marshal error") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error marshalling context map to JSON") { + t.Errorf("expected error to contain 'error marshalling context map to JSON', got %v", err) + } + }) + + t.Run("ErrorEvaluatingJsonnet", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return invalid jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`invalid jsonnet syntax {`), nil + } + + // Mock the required dependencies + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error evaluating jsonnet template") { + t.Errorf("expected error to contain 'error evaluating jsonnet template', got %v", err) + } + }) + + t.Run("ErrorUnmarshallingJsonnetResult", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return jsonnet that produces valid output + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`{ + test_var: "test_value" +}`), nil + } + + // Mock the required dependencies + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + // And JsonUnmarshal is mocked to return an error + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("json unmarshal error") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "jsonnet template must output valid JSON") { + t.Errorf("expected error to contain 'jsonnet template must output valid JSON', got %v", err) + } + }) + + t.Run("ErrorGettingProjectRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Shell.GetProjectRoot is mocked to return an error + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root error") + } + + // And ReadFile is mocked to return valid jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`{ + test_var: "test_value" +}`), nil + } + + // Mock the required dependencies + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + if values, ok := v.(*map[string]any); ok { + *values = map[string]any{"test_var": "test_value"} + } + return nil + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to get project root") { + t.Errorf("expected error to contain 'failed to get project root', got %v", err) + } + }) + + t.Run("ErrorCalculatingRelativePath", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Shell.GetProjectRoot is mocked to return expected path + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + // And ReadFile is mocked to return valid jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`{ + test_var: "test_value" +}`), nil + } + + // Mock the required dependencies + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + if values, ok := v.(*map[string]any); ok { + *values = map[string]any{"test_var": "test_value"} + } + return nil + } + + // And FilepathRel is mocked to return an error + mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "", fmt.Errorf("relative path calculation error") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to calculate relative path") { + t.Errorf("expected error to contain 'failed to calculate relative path', got %v", err) + } + }) + + t.Run("SuccessProcessingTemplate", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Shell.GetProjectRoot is mocked to return expected path + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + // And ReadFile is mocked to return valid jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`local context = std.extVar("context"); +{ + test_var: context.test, + context_name: context.name +}`), nil + } + + // Mock the required dependencies + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + if values, ok := v.(*map[string]any); ok { + *values = map[string]any{ + "test_var": "config", + "context_name": "test-context", + } + } + return nil + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/tmp/contexts/_template/terraform/test.jsonnet", "test-context", templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And template values should contain the processed template + if len(templateValues) != 1 { + t.Errorf("expected 1 template value, got %d", len(templateValues)) + } + if _, exists := templateValues["test"]; !exists { + t.Errorf("expected template values to contain 'test' key") + } + + // And the values should match expected content + values := templateValues["test"] + if values["test_var"] != "config" { + t.Errorf("expected test_var to be 'config', got %v", values["test_var"]) + } + if values["context_name"] != "test-context" { + t.Errorf("expected context_name to be 'test-context', got %v", values["context_name"]) + } + }) + + t.Run("SuccessWithNestedPath", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Shell.GetProjectRoot is mocked to return expected path + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + // And ReadFile is mocked to return valid jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`{ + nested_var: "nested_value" +}`), nil + } + + // Mock the required dependencies + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + if values, ok := v.(*map[string]any); ok { + *values = map[string]any{"nested_var": "nested_value"} + } + return nil + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called with nested path + err := generator.processJsonnetTemplate("/tmp/contexts/_template/terraform/nested/path/component.jsonnet", "test-context", templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And template values should contain the processed template with nested key + if len(templateValues) != 1 { + t.Errorf("expected 1 template value, got %d", len(templateValues)) + } + if _, exists := templateValues["nested/path/component"]; !exists { + t.Errorf("expected template values to contain 'nested/path/component' key") + } + }) +} + +// ============================================================================= +// Test Helpers +// ============================================================================= + +// simpleDirEntry implements fs.DirEntry for testing +type simpleDirEntry struct { + name string + isDir bool +} + +func (s *simpleDirEntry) Name() string { + return s.name +} + +func (s *simpleDirEntry) IsDir() bool { + return s.isDir +} + +func (s *simpleDirEntry) Type() fs.FileMode { + if s.isDir { + return fs.ModeDir + } + return 0 +} + +func (s *simpleDirEntry) Info() (fs.FileInfo, error) { + return nil, nil +} + +func TestTerraformGenerator_walkTemplateDirectory(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("ErrorReadingDirectory", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadDir is mocked to return an error + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return nil, fmt.Errorf("permission denied") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read template directory") { + t.Errorf("expected error to contain 'failed to read template directory', got %v", err) + } + }) + + t.Run("IgnoresNonJsonnetFiles", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry + mockEntry := &simpleDirEntry{name: "test.txt", isDir: false} + + // And ReadDir is mocked to return a non-jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And template values should be empty + if len(templateValues) != 0 { + t.Errorf("expected 0 template values, got %d", len(templateValues)) + } + }) + + t.Run("ProcessesJsonnetFiles", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry with jsonnet file + mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + + // And ReadDir is mocked to return a jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + // Mock all dependencies for processJsonnetTemplate + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`{ + test_var: "test_value" +}`), nil + } + + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + if values, ok := v.(*map[string]any); ok { + *values = map[string]any{"test_var": "test_value"} + } + return nil + } + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/tmp/contexts/_template/terraform", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And template values should contain the processed template + if len(templateValues) != 1 { + t.Errorf("expected 1 template value, got %d", len(templateValues)) + } + }) + + t.Run("ErrorProcessingJsonnetTemplate", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry with jsonnet file + mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + + // And ReadDir is mocked to return a jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + // And ReadFile is mocked to return an error for processJsonnetTemplate + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return nil, fmt.Errorf("file read error") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("expected error to contain 'error reading template file', got %v", err) + } + }) + + t.Run("RecursivelyProcessesDirectories", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + callCount := 0 + // Create mock directory entry + mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + + // And ReadDir is mocked to return a directory on first call, empty on second + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + callCount++ + if callCount == 1 { + return []fs.DirEntry{mockDirEntry}, nil + } + return []fs.DirEntry{}, nil // Empty subdirectory + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And ReadDir should have been called twice (once for root, once for subdir) + if callCount != 2 { + t.Errorf("expected ReadDir to be called 2 times, got %d", callCount) + } + }) + + t.Run("ErrorInRecursiveDirectoryCall", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + callCount := 0 + // Create mock directory entry + mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + + // And ReadDir is mocked to return a directory on first call, error on second + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + callCount++ + if callCount == 1 { + return []fs.DirEntry{mockDirEntry}, nil + } + return nil, fmt.Errorf("subdirectory read error") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read template directory") { + t.Errorf("expected error to contain 'failed to read template directory', got %v", err) + } + }) + + t.Run("ErrorGettingConfigRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And template directory exists + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And GetConfigRoot is mocked to return an error + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to get config root") { + t.Errorf("expected error to contain 'failed to get config root', got %v", err) + } + }) + + t.Run("ContextNameFromEnvironment", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And template directory exists + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And GetString returns empty string (no context configured) + mocks.ConfigHandler.(*config.MockConfigHandler).GetStringFunc = func(key string, defaultValue ...string) string { + if key == "context" { + return "" + } + return "" + } + + // And environment variable is set + originalEnv := os.Getenv("WINDSOR_CONTEXT") + defer func() { + if originalEnv == "" { + os.Unsetenv("WINDSOR_CONTEXT") + } else { + os.Setenv("WINDSOR_CONTEXT", originalEnv) + } + }() + os.Setenv("WINDSOR_CONTEXT", "env-context") + + // And ReadDir is mocked to return empty directory + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And result should be an empty map + if result == nil { + t.Errorf("expected non-nil result, got nil") + } + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } + }) +} diff --git a/pkg/kubernetes/kubernetes_manager_test.go b/pkg/kubernetes/kubernetes_manager_test.go index f288fe01a..731055036 100644 --- a/pkg/kubernetes/kubernetes_manager_test.go +++ b/pkg/kubernetes/kubernetes_manager_test.go @@ -61,7 +61,7 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { func setupShims(t *testing.T) *Shims { t.Helper() shims := NewShims() - shims.ToUnstructured = func(obj interface{}) (map[string]interface{}, error) { + shims.ToUnstructured = func(obj any) (map[string]any, error) { return nil, fmt.Errorf("forced conversion error") } return shims @@ -136,7 +136,7 @@ func TestBaseKubernetesManager_ApplyKustomization(t *testing.T) { t.Run("UnstructuredConversionError", func(t *testing.T) { manager := setup(t) - manager.shims.ToUnstructured = func(obj interface{}) (map[string]interface{}, error) { + manager.shims.ToUnstructured = func(obj any) (map[string]any, error) { return nil, fmt.Errorf("forced conversion error") } @@ -242,10 +242,10 @@ func TestBaseKubernetesManager_WaitForKustomizations(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{ - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + Object: map[string]any{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", }, @@ -267,10 +267,10 @@ func TestBaseKubernetesManager_WaitForKustomizations(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{ - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + Object: map[string]any{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "False", }, @@ -292,7 +292,7 @@ func TestBaseKubernetesManager_WaitForKustomizations(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{}, + Object: map[string]any{}, }, nil } manager.client = client @@ -308,10 +308,10 @@ func TestBaseKubernetesManager_WaitForKustomizations(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{ - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + Object: map[string]any{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", }, @@ -322,7 +322,7 @@ func TestBaseKubernetesManager_WaitForKustomizations(t *testing.T) { } manager.client = client - manager.shims.FromUnstructured = func(obj map[string]interface{}, target interface{}) error { + manager.shims.FromUnstructured = func(obj map[string]any, target any) error { return fmt.Errorf("forced conversion error") } @@ -337,8 +337,8 @@ func TestBaseKubernetesManager_WaitForKustomizations(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{ - "status": map[string]interface{}{}, + Object: map[string]any{ + "status": map[string]any{}, }, }, nil } @@ -355,10 +355,10 @@ func TestBaseKubernetesManager_WaitForKustomizations(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{ - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + Object: map[string]any{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "NotReady", "status": "True", }, @@ -380,10 +380,10 @@ func TestBaseKubernetesManager_WaitForKustomizations(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{ - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + Object: map[string]any{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "False", }, @@ -522,9 +522,9 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "kind": "ConfigMap", - "spec": map[string]interface{}{ + "spec": map[string]any{ "immutable": true, }, }, @@ -580,9 +580,9 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { manager := setup(t) // Patch shims to remove name from metadata origToUnstructured := manager.shims.ToUnstructured - manager.shims.ToUnstructured = func(obj interface{}) (map[string]interface{}, error) { + manager.shims.ToUnstructured = func(obj any) (map[string]any, error) { m, _ := origToUnstructured(obj) - if meta, ok := m["metadata"].(map[string]interface{}); ok { + if meta, ok := m["metadata"].(map[string]any); ok { delete(meta, "name") } return m, nil @@ -633,9 +633,9 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "kind": "ConfigMap", - "spec": map[string]interface{}{"immutable": true}, + "spec": map[string]any{"immutable": true}, }, }, nil } @@ -651,7 +651,7 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("ToUnstructuredError", func(t *testing.T) { manager := setup(t) - manager.shims.ToUnstructured = func(obj interface{}) (map[string]interface{}, error) { + manager.shims.ToUnstructured = func(obj any) (map[string]any, error) { return nil, fmt.Errorf("forced toUnstructured error") } err := manager.ApplyConfigMap("test-configmap", "test-namespace", map[string]string{"k": "v"}) @@ -674,15 +674,15 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", }, @@ -694,7 +694,7 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { }, nil } manager.client = client - manager.shims.FromUnstructured = func(obj map[string]interface{}, target interface{}) error { + manager.shims.FromUnstructured = func(obj map[string]any, target any) error { return fmt.Errorf("forced conversion error") } @@ -724,15 +724,15 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "False", }, @@ -768,15 +768,15 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "False", "reason": "ReconciliationFailed", @@ -817,15 +817,15 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", }, @@ -852,10 +852,10 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("ValidateFieldsError_MissingSpec", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "Deployment", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "foo", }, }, @@ -868,11 +868,11 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("ValidateFieldsError_MissingMetadataName", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "Deployment", - "metadata": map[string]interface{}{}, - "spec": map[string]interface{}{}, + "metadata": map[string]any{}, + "spec": map[string]any{}, }, } err := validateFields(obj) @@ -883,13 +883,13 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("ValidateFieldsError_EmptyMetadataName", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "Deployment", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": " ", }, - "spec": map[string]interface{}{}, + "spec": map[string]any{}, }, } err := validateFields(obj) @@ -900,10 +900,10 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("ValidateFieldsError_ConfigMapMissingData", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "foo", }, }, @@ -916,10 +916,10 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("ValidateFieldsError_ConfigMapDataNil", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "foo", }, "data": nil, @@ -933,10 +933,10 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("ValidateFieldsError_ConfigMapDataEmptyStringMap", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "foo", }, "data": map[string]string{}, @@ -950,13 +950,13 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("ValidateFieldsError_ConfigMapDataEmptyAnyMap", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "foo", }, - "data": map[string]interface{}{}, + "data": map[string]any{}, }, } err := validateFields(obj) @@ -967,9 +967,9 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("IsImmutableConfigMap_WrongKind", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "kind": "Deployment", - "spec": map[string]interface{}{"immutable": true}, + "spec": map[string]any{"immutable": true}, }, } if isImmutableConfigMap(obj) { @@ -979,7 +979,7 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("IsImmutableConfigMap_MissingSpec", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "kind": "ConfigMap", }, } @@ -990,9 +990,9 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("IsImmutableConfigMap_ImmutableFalse", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "kind": "ConfigMap", - "spec": map[string]interface{}{"immutable": false}, + "spec": map[string]any{"immutable": false}, }, } if isImmutableConfigMap(obj) { @@ -1002,9 +1002,9 @@ func TestBaseKubernetesManager_ApplyConfigMap(t *testing.T) { t.Run("IsImmutableConfigMap_ImmutableTrue", func(t *testing.T) { obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "kind": "ConfigMap", - "spec": map[string]interface{}{"immutable": true}, + "spec": map[string]any{"immutable": true}, }, } if !isImmutableConfigMap(obj) { @@ -1031,13 +1031,13 @@ func TestBaseKubernetesManager_GetHelmReleasesForKustomization(t *testing.T) { client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { if gvr.Group == "kustomize.toolkit.fluxcd.io" && gvr.Resource == "kustomizations" { return &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "status": map[string]interface{}{ - "inventory": map[string]interface{}{ - "entries": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "inventory": map[string]any{ + "entries": []any{ + map[string]any{ "id": "test-namespace_test-release_helm.toolkit.fluxcd.io_HelmRelease", }, }, @@ -1048,10 +1048,10 @@ func TestBaseKubernetesManager_GetHelmReleasesForKustomization(t *testing.T) { } if gvr.Group == "helm.toolkit.fluxcd.io" && gvr.Resource == "helmreleases" { return &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "helm.toolkit.fluxcd.io/v2", "kind": "HelmRelease", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "test-release", "namespace": "test-namespace", }, @@ -1093,11 +1093,11 @@ func TestBaseKubernetesManager_GetHelmReleasesForKustomization(t *testing.T) { client := NewMockKubernetesClient() client.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { return &unstructured.Unstructured{ - Object: map[string]interface{}{}, + Object: map[string]any{}, }, nil } manager.client = client - manager.shims.FromUnstructured = func(obj map[string]interface{}, target interface{}) error { + manager.shims.FromUnstructured = func(obj map[string]any, target any) error { return fmt.Errorf("forced conversion error") } _, err := manager.GetHelmReleasesForKustomization("test-kustomization", "test-namespace") @@ -1312,7 +1312,7 @@ func TestBaseKubernetesManager_ApplyGitRepository(t *testing.T) { t.Run("ToUnstructuredError", func(t *testing.T) { manager := setup(t) - manager.shims.ToUnstructured = func(obj interface{}) (map[string]interface{}, error) { + manager.shims.ToUnstructured = func(obj any) (map[string]any, error) { return nil, fmt.Errorf("forced conversion error") } @@ -1337,10 +1337,10 @@ func TestBaseKubernetesManager_ApplyGitRepository(t *testing.T) { t.Run("ValidateFieldsError", func(t *testing.T) { manager := setup(t) - manager.shims.ToUnstructured = func(obj interface{}) (map[string]interface{}, error) { - return map[string]interface{}{ - "metadata": map[string]interface{}{}, - "spec": map[string]interface{}{}, + manager.shims.ToUnstructured = func(obj any) (map[string]any, error) { + return map[string]any{ + "metadata": map[string]any{}, + "spec": map[string]any{}, }, nil } @@ -1478,15 +1478,15 @@ func TestBaseKubernetesManager_CheckGitRepositoryStatus(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "source.toolkit.fluxcd.io/v1", "kind": "GitRepository", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "repo1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", "message": "Ready", @@ -1544,15 +1544,15 @@ func TestBaseKubernetesManager_CheckGitRepositoryStatus(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "source.toolkit.fluxcd.io/v1", "kind": "GitRepository", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "repo1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", "message": "Ready", @@ -1565,7 +1565,7 @@ func TestBaseKubernetesManager_CheckGitRepositoryStatus(t *testing.T) { }, nil } manager.client = client - manager.shims.FromUnstructured = func(obj map[string]interface{}, target interface{}) error { + manager.shims.FromUnstructured = func(obj map[string]any, target any) error { return fmt.Errorf("forced conversion error") } @@ -1592,15 +1592,15 @@ func TestBaseKubernetesManager_CheckGitRepositoryStatus(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "source.toolkit.fluxcd.io/v1", "kind": "GitRepository", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "repo1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "False", "message": "repo not ready", @@ -1639,15 +1639,15 @@ func TestBaseKubernetesManager_GetKustomizationStatus(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", }, @@ -1710,15 +1710,15 @@ func TestBaseKubernetesManager_GetKustomizationStatus(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", }, @@ -1730,7 +1730,7 @@ func TestBaseKubernetesManager_GetKustomizationStatus(t *testing.T) { }, nil } manager.client = client - manager.shims.FromUnstructured = func(obj map[string]interface{}, target interface{}) error { + manager.shims.FromUnstructured = func(obj map[string]any, target any) error { return fmt.Errorf("forced conversion error") } @@ -1760,15 +1760,15 @@ func TestBaseKubernetesManager_GetKustomizationStatus(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "False", }, @@ -1804,15 +1804,15 @@ func TestBaseKubernetesManager_GetKustomizationStatus(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "False", "reason": "ReconciliationFailed", @@ -1853,15 +1853,15 @@ func TestBaseKubernetesManager_GetKustomizationStatus(t *testing.T) { return &unstructured.UnstructuredList{ Items: []unstructured.Unstructured{ { - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "kustomize.toolkit.fluxcd.io/v1", "kind": "Kustomization", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "k1", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ + "status": map[string]any{ + "conditions": []any{ + map[string]any{ "type": "Ready", "status": "True", }, diff --git a/pkg/services/dns_service_test.go b/pkg/services/dns_service_test.go index 7912deb94..ea14829ac 100644 --- a/pkg/services/dns_service_test.go +++ b/pkg/services/dns_service_test.go @@ -168,7 +168,7 @@ func TestDNSService_SetAddress(t *testing.T) { t.Run("ErrorSettingAddress", func(t *testing.T) { mockConfigHandler := config.NewMockConfigHandler() - mockConfigHandler.SetContextValueFunc = func(key string, value interface{}) error { + mockConfigHandler.SetContextValueFunc = func(key string, value any) error { return fmt.Errorf("mocked error setting address") } mocks := setupDnsMocks(t, &SetupOptions{ diff --git a/pkg/services/service_test.go b/pkg/services/service_test.go index f018debaa..aa736d554 100644 --- a/pkg/services/service_test.go +++ b/pkg/services/service_test.go @@ -56,13 +56,13 @@ func setupShims(t *testing.T) *Shims { shims.Rename = func(oldpath, newpath string) error { return nil } - shims.YamlMarshal = func(in interface{}) ([]byte, error) { + shims.YamlMarshal = func(in any) ([]byte, error) { return []byte{}, nil } - shims.YamlUnmarshal = func(in []byte, out interface{}) error { + shims.YamlUnmarshal = func(in []byte, out any) error { return nil } - shims.JsonUnmarshal = func(data []byte, v interface{}) error { + shims.JsonUnmarshal = func(data []byte, v any) error { return nil } shims.UserHomeDir = func() (string, error) { diff --git a/pkg/services/shims.go b/pkg/services/shims.go index 8e458593b..9809308aa 100644 --- a/pkg/services/shims.go +++ b/pkg/services/shims.go @@ -26,9 +26,9 @@ type Shims struct { Mkdir func(path string, perm os.FileMode) error MkdirAll func(path string, perm os.FileMode) error Rename func(oldpath, newpath string) error - YamlMarshal func(in interface{}) ([]byte, error) - YamlUnmarshal func(in []byte, out interface{}) error - JsonUnmarshal func(data []byte, v interface{}) error + YamlMarshal func(in any) ([]byte, error) + YamlUnmarshal func(in []byte, out any) error + JsonUnmarshal func(data []byte, v any) error UserHomeDir func() (string, error) } diff --git a/pkg/shell/shell_test.go b/pkg/shell/shell_test.go index 9497a8ab9..0e0ef62ec 100644 --- a/pkg/shell/shell_test.go +++ b/pkg/shell/shell_test.go @@ -185,7 +185,7 @@ func setupMocks(t *testing.T) *Mocks { return nil } - shims.ExecuteTemplate = func(tmpl *template.Template, data interface{}) error { + shims.ExecuteTemplate = func(tmpl *template.Template, data any) error { return nil } diff --git a/pkg/shell/shims.go b/pkg/shell/shims.go index 437f105ea..bec78c475 100644 --- a/pkg/shell/shims.go +++ b/pkg/shell/shims.go @@ -63,7 +63,7 @@ type Shims struct { NewTemplate func(name string) *template.Template TemplateParse func(tmpl *template.Template, text string) (*template.Template, error) TemplateExecute func(tmpl *template.Template, wr io.Writer, data any) error - ExecuteTemplate func(tmpl *template.Template, data interface{}) error + ExecuteTemplate func(tmpl *template.Template, data any) error // Bufio operations NewScanner func(r io.Reader) *bufio.Scanner @@ -141,7 +141,7 @@ func NewShims() *Shims { NewTemplate: template.New, TemplateParse: (*template.Template).Parse, TemplateExecute: (*template.Template).Execute, - ExecuteTemplate: func(tmpl *template.Template, data interface{}) error { + ExecuteTemplate: func(tmpl *template.Template, data any) error { return tmpl.Execute(os.Stdout, data) }, From 5ea13948a42da579cd2d81a69b29013eb3ca5700 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Mon, 23 Jun 2025 08:04:47 -0400 Subject: [PATCH 3/4] Windows fix --- pkg/blueprint/blueprint_handler_test.go | 3 +-- pkg/generators/terraform_generator.go | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index b5ad45140..0fc529317 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2790,8 +2790,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Then an error should be returned if err == nil { t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error reading template directory") { + } else if !strings.Contains(err.Error(), "error reading template directory") { t.Errorf("Expected read directory error, got: %v", err) } }) diff --git a/pkg/generators/terraform_generator.go b/pkg/generators/terraform_generator.go index 805d777b9..fa7735c4d 100644 --- a/pkg/generators/terraform_generator.go +++ b/pkg/generators/terraform_generator.go @@ -249,6 +249,7 @@ func (g *TerraformGenerator) processJsonnetTemplate(templateFile, contextName st } componentPath := strings.TrimSuffix(relPath, ".jsonnet") + componentPath = strings.ReplaceAll(componentPath, "\\", "/") // Windows fix templateValues[componentPath] = values return nil From 619e3cb1c368c9077392e8a386166200e6930e90 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Mon, 23 Jun 2025 08:25:27 -0400 Subject: [PATCH 4/4] Fix paths on Windows --- pkg/blueprint/blueprint_handler_test.go | 72 ++++++++++++------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 0fc529317..62d1ee495 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2466,7 +2466,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { handler, mocks := setup(t) // Override: template directory exists - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == templateDir { return mockFileInfo{name: "_template"}, nil @@ -2493,7 +2493,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet files handler, mocks := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint',\n metadata: { name: context.name }\n}" tfvarsJsonnet := "local context = std.extVar('context');\n'cluster_name = \"' + context.name + '\"'" @@ -2613,7 +2613,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler where blueprint.yaml already exists handler, _ := setup(t) - blueprintPath := "/mock/project/contexts/test-context/blueprint.yaml" + blueprintPath := filepath.Join("/mock/project", "contexts", "test-context", "blueprint.yaml") handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == blueprintPath { return mockFileInfo{name: "blueprint.yaml"}, nil @@ -2634,7 +2634,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler where blueprint.yaml already exists handler, mocks := setup(t) - blueprintPath := "/mock/project/contexts/test-context/blueprint.yaml" + blueprintPath := filepath.Join("/mock/project", "contexts", "test-context", "blueprint.yaml") handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == blueprintPath { return mockFileInfo{name: "blueprint.yaml"}, nil @@ -2741,8 +2741,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Then an error should be returned if err == nil { t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error getting project root") { + } else if !strings.Contains(err.Error(), "error getting project root") { t.Errorf("Expected project root error, got: %v", err) } }) @@ -2762,8 +2761,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Then an error should be returned if err == nil { t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error creating context directory") { + } else if !strings.Contains(err.Error(), "error creating context directory") { t.Errorf("Expected context directory error, got: %v", err) } }) @@ -2773,7 +2771,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { handler, _ := setup(t) // Override: template directory exists but ReadDir fails - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == templateDir { return mockFileInfo{name: "_template"}, nil @@ -2799,7 +2797,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet file handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory exists with jsonnet file handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -2819,7 +2817,10 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { } handler.shims.ReadFile = func(path string) ([]byte, error) { - return nil, fmt.Errorf("read file error") + if strings.Contains(path, "blueprint.jsonnet") { + return nil, fmt.Errorf("read file error") + } + return nil, fmt.Errorf("unexpected file: %s", path) } // When processing context templates @@ -2828,8 +2829,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Then an error should be returned if err == nil { t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error reading template file") { + } else if !strings.Contains(err.Error(), "error reading template file") { t.Errorf("Expected template reading error, got: %v", err) } }) @@ -2838,7 +2838,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet file handler, mocks := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "invalid jsonnet syntax" // Override: template directory exists with jsonnet file @@ -2883,8 +2883,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Then an error should be returned if err == nil { t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error evaluating jsonnet template") { + } else if !strings.Contains(err.Error(), "jsonnet evaluation error") { t.Errorf("Expected jsonnet evaluation error, got: %v", err) } }) @@ -2893,7 +2892,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet file handler, mocks := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" // Override: template directory exists with jsonnet file @@ -2943,8 +2942,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Then an error should be returned if err == nil { t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error writing blueprint file") { + } else if !strings.Contains(err.Error(), "error writing blueprint file") { t.Errorf("Expected blueprint writing error, got: %v", err) } }) @@ -2954,7 +2952,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { handler, _ := setup(t) // Override: template directory exists - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") handler.shims.Stat = func(path string) (os.FileInfo, error) { if path == templateDir { return mockFileInfo{name: "_template"}, nil @@ -2975,7 +2973,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory containing blueprint jsonnet handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint',\n metadata: { name: context.name }\n}" // Override: template directory exists with blueprint jsonnet file @@ -3036,7 +3034,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory containing blueprint jsonnet handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint',\n metadata: {\n name: context.name\n }\n}" // Override: template directory exists with blueprint jsonnet file @@ -3097,7 +3095,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory containing non-blueprint jsonnet handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory exists with non-blueprint jsonnet file handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -3144,7 +3142,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory and existing output file handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" // Override: template directory exists with jsonnet file @@ -3206,7 +3204,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory and MkdirAll error for context directory handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" // Override: template directory exists with blueprint jsonnet file @@ -3260,7 +3258,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory and relative path error handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" // Override: template directory exists with jsonnet file @@ -3302,7 +3300,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory and YAML marshal error handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" // Override: template directory exists with jsonnet file @@ -3338,7 +3336,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "error converting blueprint to YAML") { + if !strings.Contains(err.Error(), "yaml marshal error") { t.Errorf("Expected YAML marshalling error, got: %v", err) } }) @@ -3347,7 +3345,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory and YAML unmarshal error handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" // Override: template directory exists with jsonnet file @@ -3392,7 +3390,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with template directory and JSON marshal error handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") blueprintJsonnet := "local context = std.extVar('context');\n{\n kind: 'Blueprint'\n}" // Override: template directory exists with jsonnet file @@ -3437,7 +3435,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with nested template directories but no blueprint.jsonnet in root handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory exists with nested structure but no blueprint.jsonnet handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -3485,7 +3483,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with no template directory and jsonnet evaluation error handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory does not exist handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -3524,7 +3522,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with no template directory and YAML marshal error for default blueprint handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory does not exist handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -3569,7 +3567,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with no template directory and write file error handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory does not exist handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -3609,7 +3607,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with platform template that evaluates to empty handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory does not exist handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -3675,7 +3673,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with platform template handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory does not exist, triggering platform template path handler.shims.Stat = func(path string) (os.FileInfo, error) { @@ -3722,7 +3720,7 @@ func TestBlueprintHandler_ProcessContextTemplates(t *testing.T) { // Given a blueprint handler with platform template handler, _ := setup(t) - templateDir := "/mock/project/contexts/_template" + templateDir := filepath.Join("/mock/project", "contexts", "_template") // Override: template directory does not exist, triggering platform template path handler.shims.Stat = func(path string) (os.FileInfo, error) {