From 7ad65a6ae3fd0b33e10530189cb078975437dd98 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Thu, 26 Jun 2025 18:22:03 -0400 Subject: [PATCH] split main package into focused packages * extend unit test coverage to check for values yaml merges and verify helm discovery logic * add test coverage to ci --- .github/workflows/ci.yaml | 11 +- README.md | 56 ++- src/cmd/init.go | 67 +++ src/cmd/root.go | 46 ++ src/commands.go | 42 -- src/fileutils.go | 107 ----- src/go.mod | 10 +- src/go.sum | 14 + src/internal/fileutils/fileutils.go | 135 ++++++ src/{ => internal/helm}/helm.go | 12 +- src/internal/helm/helm_test.go | 268 ++++++++++++ src/internal/pattern/pattern.go | 138 ++++++ src/internal/pattern/pattern_test.go | 405 ++++++++++++++++++ .../types/clustergroup.go} | 6 +- .../types/global.go} | 6 +- src/main.go | 41 +- src/main_test.go | 116 ++--- src/pattern.go | 121 ------ 18 files changed, 1187 insertions(+), 414 deletions(-) create mode 100644 src/cmd/init.go create mode 100644 src/cmd/root.go delete mode 100644 src/commands.go delete mode 100644 src/fileutils.go create mode 100644 src/internal/fileutils/fileutils.go rename src/{ => internal/helm}/helm.go (84%) create mode 100644 src/internal/helm/helm_test.go create mode 100644 src/internal/pattern/pattern.go create mode 100644 src/internal/pattern/pattern_test.go rename src/{values_cluster_group.go => internal/types/clustergroup.go} (95%) rename src/{values_global.go => internal/types/global.go} (91%) delete mode 100644 src/pattern.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9c33147..d42377b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -67,7 +67,16 @@ jobs: working-directory: ./src - name: Run unit tests - run: go test -v ./... + run: | + echo "Running unit tests for all packages..." + go test -v ./... + working-directory: ./src + + - name: Generate test coverage report + run: | + echo "Generating test coverage report..." + go test ./... -coverprofile=coverage.out + go tool cover -func=coverage.out working-directory: ./src - name: Run integration tests diff --git a/README.md b/README.md index 49f9c15..f6c152b 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ cd src go test -v ./... # Run integration tests (requires built binary) +cd .. ./test/integration_test.sh ``` @@ -155,10 +156,28 @@ go vet ./... ### Unit Tests -Located in `src/*_test.go`, these test core functionality: -- Resource path resolution -- Default values generation -- Secrets integration logic +The project includes comprehensive unit tests located in `src/*_test.go` and `src/internal/*/` packages: + +**Core Functionality Tests:** +- Resource path resolution and environment variable handling +- Default values generation for global and cluster group configurations +- Secrets integration logic and template handling + +**Helm Chart Discovery Tests:** +- `FindTopLevelCharts()` correctly identifies top-level Helm charts +- Properly skips sub-charts, hidden directories, and invalid chart structures +- `IsHelmChart()` validates chart structure (Chart.yaml, values.yaml, templates/) + +**Pattern Processing Tests:** +- URL parsing for SSH, HTTPS, and HTTP Git repository formats +- Error handling for invalid or unsupported URL formats +- Field preservation during YAML processing (custom user fields are never overwritten) +- Proper merging of defaults with existing configuration files + +**Field Preservation Verification:** +- Tests ensure that custom fields in `values-global.yaml` are preserved +- Tests verify that custom fields in cluster group values files are maintained +- Tests confirm that nested custom fields and arrays are properly handled ### Integration Tests @@ -193,16 +212,29 @@ All code must pass linting and tests before being merged or deployed. ## Architecture -The CLI is organized into focused modules: +The CLI is organized into focused packages following Go best practices: + +**Main Package (`src/`):** +- `main.go` - Application entry point + +**Command Package (`src/cmd/`):** +- `root.go` - Cobra CLI setup and root command +- `init.go` - Initialization command logic and orchestration + +**Internal Packages (`src/internal/`):** +- `fileutils/` - File operations, resource management, and path resolution +- `helm/` - Helm chart discovery and validation +- `pattern/` - Core pattern processing, Git operations, and URL parsing +- `types/` - YAML structure definitions and default value constructors -- `main.go` - CLI setup and command definitions (Cobra) -- `commands.go` - Command logic and orchestration -- `fileutils.go` - File operations and resource management -- `pattern.go` - Core pattern processing and Git operations -- `helm.go` - Helm chart discovery -- `values_*.go` - YAML structure definitions +**Key Design Principles:** +- **Separation of Concerns**: Each package has a single, well-defined responsibility +- **Testability**: All packages are thoroughly unit tested with comprehensive coverage +- **Field Preservation**: YAML processing preserves all user-defined custom fields +- **Error Handling**: Comprehensive error handling with descriptive messages +- **Modularity**: Clean interfaces between packages for maintainability -This modular design makes the codebase maintainable and testable. +This modular design makes the codebase maintainable, testable, and extensible. --- diff --git a/src/cmd/init.go b/src/cmd/init.go new file mode 100644 index 0000000..3028ec1 --- /dev/null +++ b/src/cmd/init.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/dminnear-rh/patternizer/internal/fileutils" + "github.com/dminnear-rh/patternizer/internal/helm" + "github.com/dminnear-rh/patternizer/internal/pattern" +) + +// runInit handles the initialization logic for the init command. +func runInit(withSecrets bool) error { + // Get pattern name and repository root + patternName, repoRoot, err := pattern.GetPatternNameAndRepoRoot() + if err != nil { + return fmt.Errorf("error getting pattern information: %w", err) + } + + // Find Helm charts in the repository + chartPaths, err := helm.FindTopLevelCharts(repoRoot) + if err != nil { + return fmt.Errorf("error finding Helm charts: %w", err) + } + + // Process values-global.yaml + actualPatternName, clusterGroupName, err := pattern.ProcessGlobalValues(patternName, repoRoot) + if err != nil { + return fmt.Errorf("error processing global values: %w", err) + } + + // Process cluster group values using the actual pattern name and cluster group name from the global values + if err := pattern.ProcessClusterGroupValues(actualPatternName, clusterGroupName, repoRoot, chartPaths, withSecrets); err != nil { + return fmt.Errorf("error processing cluster group values: %w", err) + } + + // Copy pattern.sh from resources + resourcesDir, err := fileutils.GetResourcePath() + if err != nil { + return fmt.Errorf("error getting resource path: %w", err) + } + + patternShSrc := filepath.Join(resourcesDir, "pattern.sh") + patternShDst := filepath.Join(repoRoot, "pattern.sh") + if err := fileutils.CopyFile(patternShSrc, patternShDst); err != nil { + return fmt.Errorf("error copying pattern.sh: %w", err) + } + + // Set USE_SECRETS in pattern.sh based on the flag + if err := fileutils.ModifyPatternShScript(patternShDst, withSecrets); err != nil { + return fmt.Errorf("error modifying pattern.sh: %w", err) + } + + // Handle secrets setup if requested + if withSecrets { + if err := fileutils.HandleSecretsSetup(resourcesDir, repoRoot); err != nil { + return fmt.Errorf("error setting up secrets: %w", err) + } + } + + fmt.Printf("Successfully initialized pattern '%s' in %s\n", actualPatternName, repoRoot) + if withSecrets { + fmt.Println("Secrets configuration has been enabled.") + } + + return nil +} diff --git a/src/cmd/root.go b/src/cmd/root.go new file mode 100644 index 0000000..02d0045 --- /dev/null +++ b/src/cmd/root.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + var withSecrets bool + + var rootCmd = &cobra.Command{ + Use: "patternizer", + Short: "A CLI tool for initializing Validated Patterns", + Long: `patternizer is a CLI tool for creating and managing validated pattern configurations. +It helps generate the necessary YAML files and setup for Validated Patterns including +values-global.yaml, values-.yaml, and optional secrets configuration.`, + } + + var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize pattern files", + Long: `Initialize pattern files creates or updates the necessary YAML configuration files +for a validated pattern, including values-global.yaml and values-.yaml. + +When --with-secrets is specified, it also copies the secrets template and +configures the pattern.sh script for secrets usage.`, + RunE: func(cmd *cobra.Command, args []string) error { + // Check if "help" is passed as an argument + if len(args) > 0 && args[0] == "help" { + return cmd.Help() + } + return runInit(withSecrets) + }, + } + + initCmd.Flags().BoolVar(&withSecrets, "with-secrets", false, "Include secrets template and configure pattern for secrets usage") + + rootCmd.AddCommand(initCmd) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/src/commands.go b/src/commands.go deleted file mode 100644 index f1bcade..0000000 --- a/src/commands.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "fmt" - "log" -) - -// runInit executes the initialization logic -func runInit(withSecrets bool) error { - patternName, repoRoot, err := getPatternNameAndRepoRoot() - if err != nil { - return fmt.Errorf("error determining pattern name or repo root: %w", err) - } - - log.Printf("Determined pattern name: '%s'", patternName) - - globalValues, err := processGlobalValues(patternName) - if err != nil { - return fmt.Errorf("error processing global values: %w", err) - } - - log.Printf("Secrets will%s be added to the cluster group values file", map[bool]string{true: "", false: " not"}[withSecrets]) - - if err := processClusterGroupValues(globalValues, repoRoot, withSecrets); err != nil { - return fmt.Errorf("error processing cluster group values: %w", err) - } - - // Always copy pattern.sh - if err := copyPatternScript(); err != nil { - return fmt.Errorf("error copying pattern.sh: %w", err) - } - - // Handle secrets setup if requested - if withSecrets { - if err := handleSecretsSetup(); err != nil { - return fmt.Errorf("error setting up secrets: %w", err) - } - } - - log.Println("All configuration files processed successfully.") - return nil -} diff --git a/src/fileutils.go b/src/fileutils.go deleted file mode 100644 index 8988900..0000000 --- a/src/fileutils.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "fmt" - "io" - "log" - "os" - "path/filepath" - "strings" -) - -// getResourcePath returns the path to a resource file, checking the PATTERNIZER_RESOURCES_DIR -// environment variable first, then falling back to the current directory -func getResourcePath(filename string) string { - if resourcesDir := os.Getenv("PATTERNIZER_RESOURCES_DIR"); resourcesDir != "" { - return filepath.Join(resourcesDir, filename) - } - return filename -} - -// copyFile copies a file from src to dst, preserving file permissions -func copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return fmt.Errorf("failed to open source file %s: %w", src, err) - } - defer sourceFile.Close() - - // Get source file info to preserve permissions - sourceInfo, err := sourceFile.Stat() - if err != nil { - return fmt.Errorf("failed to get source file info %s: %w", src, err) - } - - destFile, err := os.Create(dst) - if err != nil { - return fmt.Errorf("failed to create destination file %s: %w", dst, err) - } - defer destFile.Close() - - _, err = io.Copy(destFile, sourceFile) - if err != nil { - return fmt.Errorf("failed to copy file: %w", err) - } - - // Preserve the original file permissions - err = os.Chmod(dst, sourceInfo.Mode()) - if err != nil { - return fmt.Errorf("failed to set permissions on %s: %w", dst, err) - } - - return nil -} - -// copyPatternScript copies the pattern.sh script to the current directory -func copyPatternScript() error { - patternShPath := getResourcePath("pattern.sh") - - if _, err := os.Stat(patternShPath); err == nil { - log.Println("Copying pattern.sh script") - if err := copyFile(patternShPath, "pattern.sh"); err != nil { - return fmt.Errorf("failed to copy pattern.sh: %w", err) - } - } else { - return fmt.Errorf("pattern.sh not found at %s", patternShPath) - } - - return nil -} - -// modifyPatternShScript modifies the pattern.sh file to set USE_SECRETS=true by default -func modifyPatternShScript(filePath string) error { - content, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("failed to read pattern.sh: %w", err) - } - - modifiedContent := strings.Replace(string(content), "${USE_SECRETS:=false}", "${USE_SECRETS:=true}", 1) - - err = os.WriteFile(filePath, []byte(modifiedContent), 0o755) - if err != nil { - return fmt.Errorf("failed to write modified pattern.sh: %w", err) - } - - return nil -} - -// handleSecretsSetup handles copying the secrets template and modifying pattern.sh when --with-secrets is used -func handleSecretsSetup() error { - templatePath := getResourcePath("values-secret.yaml.template") - - if _, err := os.Stat(templatePath); err == nil { - log.Println("Copying secrets template") - if err := copyFile(templatePath, "values-secret.yaml.template"); err != nil { - return fmt.Errorf("failed to copy secrets template: %w", err) - } - } else { - return fmt.Errorf("secrets template not found at %s", templatePath) - } - - log.Println("Modifying pattern.sh for secrets usage") - if err := modifyPatternShScript("pattern.sh"); err != nil { - return fmt.Errorf("failed to modify pattern.sh: %w", err) - } - - return nil -} diff --git a/src/go.mod b/src/go.mod index 432a570..bd4c42b 100644 --- a/src/go.mod +++ b/src/go.mod @@ -2,9 +2,15 @@ module github.com/dminnear-rh/patternizer go 1.24.4 +require ( + github.com/spf13/cobra v1.9.1 + gopkg.in/yaml.v3 v3.0.1 +) + require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/cobra v1.9.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.6 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/src/go.sum b/src/go.sum index 65f8c12..12f8359 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,11 +1,25 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/internal/fileutils/fileutils.go b/src/internal/fileutils/fileutils.go new file mode 100644 index 0000000..5cb733e --- /dev/null +++ b/src/internal/fileutils/fileutils.go @@ -0,0 +1,135 @@ +package fileutils + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" +) + +// CopyFile copies a file from src to dst. If dst already exists, it will be overwritten. +// The function also preserves the file permissions of the source file. +func CopyFile(src, dst string) error { + sourceFileStat, err := os.Stat(src) + if err != nil { + return err + } + + if !sourceFileStat.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", src) + } + + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destinationFile, err := os.Create(dst) + if err != nil { + return err + } + defer destinationFile.Close() + + _, err = io.Copy(destinationFile, sourceFile) + if err != nil { + return err + } + + // Preserve the file permissions from the source file + err = os.Chmod(dst, sourceFileStat.Mode()) + if err != nil { + return err + } + + return nil +} + +// ModifyPatternShScript modifies the pattern.sh script to set USE_SECRETS to the desired value. +func ModifyPatternShScript(patternShPath string, useSecrets bool) error { + file, err := os.Open(patternShPath) + if err != nil { + return err + } + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + file.Close() + + if err := scanner.Err(); err != nil { + return err + } + + // Regex to match the USE_SECRETS line + regex := regexp.MustCompile(`^\s*:\s*"\$\{USE_SECRETS:=(.+)\}"`) + + for i, line := range lines { + if matches := regex.FindStringSubmatch(line); matches != nil { + if useSecrets { + lines[i] = strings.Replace(line, matches[1], "true", 1) + } else { + lines[i] = strings.Replace(line, matches[1], "false", 1) + } + break + } + } + + output, err := os.Create(patternShPath) + if err != nil { + return err + } + defer output.Close() + + for _, line := range lines { + _, err := output.WriteString(line + "\n") + if err != nil { + return err + } + } + + return nil +} + +// HandleSecretsSetup handles the setup for secrets usage by copying the secrets template +// and modifying the pattern.sh script. +func HandleSecretsSetup(resourcesDir, repoRoot string) (err error) { + // Copy the values-secret.yaml.template file to the pattern root + secretsTemplateSrc := filepath.Join(resourcesDir, "values-secret.yaml.template") + secretsTemplateDst := filepath.Join(repoRoot, "values-secret.yaml.template") + + if err = CopyFile(secretsTemplateSrc, secretsTemplateDst); err != nil { + return fmt.Errorf("error copying secrets template: %w", err) + } + + // Modify the pattern.sh script to set USE_SECRETS=true + patternShPath := filepath.Join(repoRoot, "pattern.sh") + if err = ModifyPatternShScript(patternShPath, true); err != nil { + return fmt.Errorf("error modifying pattern.sh for secrets: %w", err) + } + + return nil +} + +// GetResourcePath returns the path to the resources directory. +// It checks the PATTERNIZER_RESOURCES_DIR environment variable first, +// and falls back to the current working directory. +func GetResourcePath() (path string, err error) { + path = os.Getenv("PATTERNIZER_RESOURCES_DIR") + if path != "" { + return path, nil + } + + // Fall back to current directory + path, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + return path, nil +} diff --git a/src/helm.go b/src/internal/helm/helm.go similarity index 84% rename from src/helm.go rename to src/internal/helm/helm.go index 9287985..b4b18f5 100644 --- a/src/helm.go +++ b/src/internal/helm/helm.go @@ -1,4 +1,4 @@ -package main +package helm import ( "io/fs" @@ -7,8 +7,8 @@ import ( "strings" ) -// isHelmChart checks if a given directory path contains a valid Helm chart. -func isHelmChart(path string) bool { +// IsHelmChart checks if a given directory path contains a valid Helm chart. +func IsHelmChart(path string) bool { chartYamlPath := filepath.Join(path, "Chart.yaml") valuesYamlPath := filepath.Join(path, "values.yaml") templatesDirPath := filepath.Join(path, "templates") @@ -28,9 +28,9 @@ func isHelmChart(path string) bool { return true } -// findTopLevelCharts walks the filesystem from rootDir to find all top-level Helm charts. +// FindTopLevelCharts walks the filesystem from rootDir to find all top-level Helm charts. // It intelligently skips sub-chart directories. -func findTopLevelCharts(rootDir string) ([]string, error) { +func FindTopLevelCharts(rootDir string) ([]string, error) { var charts []string err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { @@ -46,7 +46,7 @@ func findTopLevelCharts(rootDir string) ([]string, error) { return filepath.SkipDir } - if isHelmChart(path) { + if IsHelmChart(path) { relPath, _ := filepath.Rel(rootDir, path) charts = append(charts, relPath) // Once we identify a chart, we don't need to look at its subdirectories. diff --git a/src/internal/helm/helm_test.go b/src/internal/helm/helm_test.go new file mode 100644 index 0000000..631ca70 --- /dev/null +++ b/src/internal/helm/helm_test.go @@ -0,0 +1,268 @@ +package helm + +import ( + "os" + "path/filepath" + "testing" +) + +// createTestChartStructure creates a comprehensive test directory structure for helm chart testing. +// It returns the temp directory path that should be cleaned up by the caller. +func createTestChartStructure(t *testing.T) string { + tempDir, err := os.MkdirTemp("", "helm-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Create test directory structure: + // tempDir/ + // ├── chart1/ (valid top-level chart) + // │ ├── Chart.yaml + // │ ├── values.yaml + // │ └── templates/ + // ├── chart2/ (valid top-level chart) + // │ ├── Chart.yaml + // │ ├── values.yaml + // │ ├── templates/ + // │ └── charts/ (sub-chart directory) + // │ └── subchart/ (sub-chart - should be ignored) + // │ ├── Chart.yaml + // │ ├── values.yaml + // │ └── templates/ + // ├── incomplete-chart/ (invalid chart - missing templates) + // │ ├── Chart.yaml + // │ └── values.yaml + // ├── missing-chart-yaml/ (invalid chart - missing Chart.yaml) + // │ ├── values.yaml + // │ └── templates/ + // ├── missing-values-yaml/ (invalid chart - missing values.yaml) + // │ ├── Chart.yaml + // │ └── templates/ + // ├── templates-is-file/ (invalid chart - templates is a file) + // │ ├── Chart.yaml + // │ ├── values.yaml + // │ └── templates (file, not directory) + // ├── .hidden-chart/ (hidden directory - should be ignored) + // │ ├── Chart.yaml + // │ ├── values.yaml + // │ └── templates/ + // └── not-a-chart/ (not a chart directory) + // └── some-file.txt + + // Create chart1 (valid top-level chart) + chart1Dir := filepath.Join(tempDir, "chart1") + if err := os.MkdirAll(filepath.Join(chart1Dir, "templates"), 0o755); err != nil { + t.Fatalf("Failed to create chart1 structure: %v", err) + } + if err := os.WriteFile(filepath.Join(chart1Dir, "Chart.yaml"), []byte("name: chart1\nversion: 1.0.0\n"), 0o644); err != nil { + t.Fatalf("Failed to create Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(chart1Dir, "values.yaml"), []byte("# values\n"), 0o644); err != nil { + t.Fatalf("Failed to create values.yaml: %v", err) + } + + // Create chart2 (valid top-level chart with sub-chart) + chart2Dir := filepath.Join(tempDir, "chart2") + chart2TemplatesDir := filepath.Join(chart2Dir, "templates") + chart2SubchartDir := filepath.Join(chart2Dir, "charts", "subchart") + subchartTemplatesDir := filepath.Join(chart2SubchartDir, "templates") + + if err := os.MkdirAll(chart2TemplatesDir, 0o755); err != nil { + t.Fatalf("Failed to create chart2 structure: %v", err) + } + if err := os.MkdirAll(subchartTemplatesDir, 0o755); err != nil { + t.Fatalf("Failed to create subchart structure: %v", err) + } + + // Chart2 files + if err := os.WriteFile(filepath.Join(chart2Dir, "Chart.yaml"), []byte("name: chart2\nversion: 1.0.0\n"), 0o644); err != nil { + t.Fatalf("Failed to create chart2 Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(chart2Dir, "values.yaml"), []byte("# values\n"), 0o644); err != nil { + t.Fatalf("Failed to create chart2 values.yaml: %v", err) + } + + // Sub-chart files (should be ignored by FindTopLevelCharts) + if err := os.WriteFile(filepath.Join(chart2SubchartDir, "Chart.yaml"), []byte("name: subchart\nversion: 1.0.0\n"), 0o644); err != nil { + t.Fatalf("Failed to create subchart Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(chart2SubchartDir, "values.yaml"), []byte("# subchart values\n"), 0o644); err != nil { + t.Fatalf("Failed to create subchart values.yaml: %v", err) + } + + // Create incomplete-chart (missing templates directory) + incompleteChartDir := filepath.Join(tempDir, "incomplete-chart") + if err := os.MkdirAll(incompleteChartDir, 0o755); err != nil { + t.Fatalf("Failed to create incomplete-chart structure: %v", err) + } + if err := os.WriteFile(filepath.Join(incompleteChartDir, "Chart.yaml"), []byte("name: incomplete\nversion: 1.0.0\n"), 0o644); err != nil { + t.Fatalf("Failed to create incomplete Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(incompleteChartDir, "values.yaml"), []byte("# values\n"), 0o644); err != nil { + t.Fatalf("Failed to create incomplete values.yaml: %v", err) + } + + // Create missing-chart-yaml (missing Chart.yaml) + missingChartYamlDir := filepath.Join(tempDir, "missing-chart-yaml") + if err := os.MkdirAll(filepath.Join(missingChartYamlDir, "templates"), 0o755); err != nil { + t.Fatalf("Failed to create missing-chart-yaml structure: %v", err) + } + if err := os.WriteFile(filepath.Join(missingChartYamlDir, "values.yaml"), []byte("# values\n"), 0o644); err != nil { + t.Fatalf("Failed to create missing-chart-yaml values.yaml: %v", err) + } + + // Create missing-values-yaml (missing values.yaml) + missingValuesYamlDir := filepath.Join(tempDir, "missing-values-yaml") + if err := os.MkdirAll(filepath.Join(missingValuesYamlDir, "templates"), 0o755); err != nil { + t.Fatalf("Failed to create missing-values-yaml structure: %v", err) + } + if err := os.WriteFile(filepath.Join(missingValuesYamlDir, "Chart.yaml"), []byte("name: missing-values\nversion: 1.0.0\n"), 0o644); err != nil { + t.Fatalf("Failed to create missing-values-yaml Chart.yaml: %v", err) + } + + // Create templates-is-file (templates is a file, not directory) + templatesIsFileDir := filepath.Join(tempDir, "templates-is-file") + if err := os.MkdirAll(templatesIsFileDir, 0o755); err != nil { + t.Fatalf("Failed to create templates-is-file structure: %v", err) + } + if err := os.WriteFile(filepath.Join(templatesIsFileDir, "Chart.yaml"), []byte("name: templates-file\nversion: 1.0.0\n"), 0o644); err != nil { + t.Fatalf("Failed to create templates-is-file Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(templatesIsFileDir, "values.yaml"), []byte("# values\n"), 0o644); err != nil { + t.Fatalf("Failed to create templates-is-file values.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(templatesIsFileDir, "templates"), []byte("not a directory\n"), 0o644); err != nil { + t.Fatalf("Failed to create templates-is-file templates file: %v", err) + } + + // Create hidden chart (should be ignored by FindTopLevelCharts) + hiddenChartDir := filepath.Join(tempDir, ".hidden-chart") + if err := os.MkdirAll(filepath.Join(hiddenChartDir, "templates"), 0o755); err != nil { + t.Fatalf("Failed to create hidden chart structure: %v", err) + } + if err := os.WriteFile(filepath.Join(hiddenChartDir, "Chart.yaml"), []byte("name: hidden\nversion: 1.0.0\n"), 0o644); err != nil { + t.Fatalf("Failed to create hidden Chart.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(hiddenChartDir, "values.yaml"), []byte("# values\n"), 0o644); err != nil { + t.Fatalf("Failed to create hidden values.yaml: %v", err) + } + + // Create not-a-chart directory + notChartDir := filepath.Join(tempDir, "not-a-chart") + if err := os.MkdirAll(notChartDir, 0o755); err != nil { + t.Fatalf("Failed to create not-a-chart structure: %v", err) + } + if err := os.WriteFile(filepath.Join(notChartDir, "some-file.txt"), []byte("not a chart\n"), 0o644); err != nil { + t.Fatalf("Failed to create some-file.txt: %v", err) + } + + return tempDir +} + +// TestFindTopLevelCharts tests that FindTopLevelCharts only returns top-level charts +// and properly skips sub-charts and non-chart directories. +func TestFindTopLevelCharts(t *testing.T) { + tempDir := createTestChartStructure(t) + defer os.RemoveAll(tempDir) + + // Run FindTopLevelCharts + charts, err := FindTopLevelCharts(tempDir) + if err != nil { + t.Fatalf("FindTopLevelCharts failed: %v", err) + } + + // Verify results + expectedCharts := []string{"chart1", "chart2"} + if len(charts) != len(expectedCharts) { + t.Fatalf("Expected %d charts, got %d: %v", len(expectedCharts), len(charts), charts) + } + + // Convert to map for easier checking + foundCharts := make(map[string]bool) + for _, chart := range charts { + foundCharts[chart] = true + } + + // Verify each expected chart was found + for _, expected := range expectedCharts { + if !foundCharts[expected] { + t.Errorf("Expected chart '%s' not found in results: %v", expected, charts) + } + } + + // Verify that sub-charts, incomplete charts, and hidden charts were NOT found + unexpectedCharts := []string{"charts/subchart", "subchart", "incomplete-chart", ".hidden-chart", "not-a-chart"} + for _, unexpected := range unexpectedCharts { + if foundCharts[unexpected] { + t.Errorf("Unexpected chart '%s' found in results: %v", unexpected, charts) + } + } +} + +// TestIsHelmChart tests the IsHelmChart function with various directory structures. +func TestIsHelmChart(t *testing.T) { + tempDir := createTestChartStructure(t) + defer os.RemoveAll(tempDir) + + tests := []struct { + name string + chartDir string + expectedResult bool + }{ + { + name: "valid helm chart 1", + chartDir: "chart1", + expectedResult: true, + }, + { + name: "valid helm chart 2", + chartDir: "chart2", + expectedResult: true, + }, + { + name: "valid subchart (still valid helm chart)", + chartDir: "chart2/charts/subchart", + expectedResult: true, + }, + { + name: "hidden chart (still valid helm chart)", + chartDir: ".hidden-chart", + expectedResult: true, + }, + { + name: "incomplete chart - missing templates", + chartDir: "incomplete-chart", + expectedResult: false, + }, + { + name: "missing Chart.yaml", + chartDir: "missing-chart-yaml", + expectedResult: false, + }, + { + name: "missing values.yaml", + chartDir: "missing-values-yaml", + expectedResult: false, + }, + { + name: "templates is a file not directory", + chartDir: "templates-is-file", + expectedResult: false, + }, + { + name: "not a chart directory", + chartDir: "not-a-chart", + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testDir := filepath.Join(tempDir, tt.chartDir) + result := IsHelmChart(testDir) + if result != tt.expectedResult { + t.Errorf("IsHelmChart('%s') = %v, expected %v", tt.chartDir, result, tt.expectedResult) + } + }) + } +} diff --git a/src/internal/pattern/pattern.go b/src/internal/pattern/pattern.go new file mode 100644 index 0000000..da2156b --- /dev/null +++ b/src/internal/pattern/pattern.go @@ -0,0 +1,138 @@ +package pattern + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/dminnear-rh/patternizer/internal/types" +) + +// GetPatternNameAndRepoRoot returns the pattern name and repository root directory. +// It attempts to detect the pattern name from the Git repository URL, +// falling back to the directory name if Git is not available. +func GetPatternNameAndRepoRoot() (patternName, repoRoot string, err error) { + // Get the current working directory + repoRoot, err = os.Getwd() + if err != nil { + return "", "", fmt.Errorf("failed to get current directory: %w", err) + } + + // Try to get the remote URL using git command + cmd := exec.Command("git", "remote", "get-url", "origin") + cmd.Dir = repoRoot + output, err := cmd.Output() + if err != nil { + // If we can't get the remote URL, use the directory name as pattern name + patternName = filepath.Base(repoRoot) + return patternName, repoRoot, nil + } + + // Extract pattern name from the remote URL + remoteURL := strings.TrimSpace(string(output)) + patternName, err = extractPatternNameFromURL(remoteURL) + if err != nil { + return "", "", fmt.Errorf("failed to extract pattern name from git remote URL '%s': %w", remoteURL, err) + } + + return patternName, repoRoot, nil +} + +// extractPatternNameFromURL extracts the pattern name from a Git repository URL. +// Returns an error if the URL format is not recognized. +func extractPatternNameFromURL(url string) (string, error) { + // Handle SSH URLs: git@github.com:user/repo.git + if strings.HasPrefix(url, "git@") { + parts := strings.Split(url, ":") + if len(parts) >= 2 { + repoPath := parts[1] + repoName := filepath.Base(repoPath) + return strings.TrimSuffix(repoName, ".git"), nil + } + return "", fmt.Errorf("invalid SSH URL format") + } + + // Handle HTTPS URLs: https://github.com/user/repo.git + if strings.HasPrefix(url, "https://") { + repoName := filepath.Base(url) + return strings.TrimSuffix(repoName, ".git"), nil + } + + // Handle HTTP URLs: http://github.com/user/repo.git + if strings.HasPrefix(url, "http://") { + repoName := filepath.Base(url) + return strings.TrimSuffix(repoName, ".git"), nil + } + + return "", fmt.Errorf("unsupported URL format: expected git@host:user/repo.git, https://host/user/repo.git, or http://host/user/repo.git") +} + +// ProcessGlobalValues processes the global values YAML file. +// It returns the pattern name and cluster group name that should be used (from the file if they exist, or the detected/default names). +func ProcessGlobalValues(patternName, repoRoot string) (actualPatternName, clusterGroupName string, err error) { + globalValuesPath := filepath.Join(repoRoot, "values-global.yaml") + values := types.NewDefaultValuesGlobal() + + // Try to read existing file + yamlFile, err := os.ReadFile(globalValuesPath) + if err != nil && !os.IsNotExist(err) { + return "", "", fmt.Errorf("failed to read %s: %w", globalValuesPath, err) + } + + if err == nil { + // File exists, unmarshal into our defaults (natural merging) + if err = yaml.Unmarshal(yamlFile, values); err != nil { + return "", "", fmt.Errorf("failed to unmarshal YAML from %s: %w", globalValuesPath, err) + } + } + + // Set pattern name if not already set + if values.Global.Pattern == "" { + values.Global.Pattern = patternName + } + + // Write back the merged values + finalYamlBytes, err := yaml.Marshal(values) + if err != nil { + return "", "", fmt.Errorf("failed to marshal global values: %w", err) + } + if err = os.WriteFile(globalValuesPath, finalYamlBytes, 0o644); err != nil { + return "", "", fmt.Errorf("failed to write to %s: %w", globalValuesPath, err) + } + + return values.Global.Pattern, values.Main.ClusterGroupName, nil +} + +// ProcessClusterGroupValues processes the cluster group values YAML file. +func ProcessClusterGroupValues(patternName, clusterGroupName, repoRoot string, chartPaths []string, useSecrets bool) error { + clusterGroupValuesPath := filepath.Join(repoRoot, fmt.Sprintf("values-%s.yaml", clusterGroupName)) + values := types.NewDefaultValuesClusterGroup(patternName, clusterGroupName, chartPaths, useSecrets) + + // Try to read existing file + yamlFile, err := os.ReadFile(clusterGroupValuesPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", clusterGroupValuesPath, err) + } + + if err == nil { + // File exists, unmarshal into our defaults (natural merging) + if err = yaml.Unmarshal(yamlFile, values); err != nil { + return fmt.Errorf("failed to unmarshal YAML from %s: %w", clusterGroupValuesPath, err) + } + } + + // Write back the merged values + finalYamlBytes, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("failed to marshal cluster group values: %w", err) + } + if err = os.WriteFile(clusterGroupValuesPath, finalYamlBytes, 0o644); err != nil { + return fmt.Errorf("failed to write to %s: %w", clusterGroupValuesPath, err) + } + + return nil +} diff --git a/src/internal/pattern/pattern_test.go b/src/internal/pattern/pattern_test.go new file mode 100644 index 0000000..09aa6a0 --- /dev/null +++ b/src/internal/pattern/pattern_test.go @@ -0,0 +1,405 @@ +package pattern + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/dminnear-rh/patternizer/internal/types" +) + +// TestExtractPatternNameFromURL tests URL parsing for different Git URL formats. +func TestExtractPatternNameFromURL(t *testing.T) { + tests := []struct { + name string + url string + expected string + expectError bool + }{ + { + name: "SSH URL with .git suffix", + url: "git@github.com:user/my-pattern.git", + expected: "my-pattern", + }, + { + name: "SSH URL without .git suffix", + url: "git@github.com:user/my-pattern", + expected: "my-pattern", + }, + { + name: "HTTPS URL with .git suffix", + url: "https://github.com/user/my-pattern.git", + expected: "my-pattern", + }, + { + name: "HTTPS URL without .git suffix", + url: "https://github.com/user/my-pattern", + expected: "my-pattern", + }, + { + name: "HTTP URL with .git suffix", + url: "http://github.com/user/my-pattern.git", + expected: "my-pattern", + }, + { + name: "HTTP URL without .git suffix", + url: "http://github.com/user/my-pattern", + expected: "my-pattern", + }, + { + name: "GitLab SSH URL", + url: "git@gitlab.com:group/subgroup/my-pattern.git", + expected: "my-pattern", + }, + { + name: "GitLab HTTPS URL", + url: "https://gitlab.com/group/subgroup/my-pattern.git", + expected: "my-pattern", + }, + { + name: "Invalid SSH URL format", + url: "git@github.com", + expectError: true, + }, + { + name: "Unsupported protocol", + url: "ftp://github.com/user/repo.git", + expectError: true, + }, + { + name: "Empty URL", + url: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := extractPatternNameFromURL(tt.url) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error for URL '%s', but got none", tt.url) + } + return + } + + if err != nil { + t.Errorf("Unexpected error for URL '%s': %v", tt.url, err) + return + } + + if result != tt.expected { + t.Errorf("extractPatternNameFromURL('%s') = '%s', expected '%s'", tt.url, result, tt.expected) + } + }) + } +} + +// TestProcessGlobalValuesPreservesFields tests that ProcessGlobalValues preserves existing user fields. +func TestProcessGlobalValuesPreservesFields(t *testing.T) { + tempDir, err := os.MkdirTemp("", "pattern-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create initial values-global.yaml with custom fields + initialValues := map[string]interface{}{ + "global": map[string]interface{}{ + "pattern": "existing-pattern", + "customField": "customValue", + "nestedCustom": map[string]interface{}{ + "key1": "value1", + "key2": 42, + }, + }, + "main": map[string]interface{}{ + "clusterGroupName": "custom-cluster-group", + "multiSourceConfig": map[string]interface{}{ + "enabled": false, // Different from default + "clusterGroupChartVersion": "1.0.*", // Different from default + "customMultiSource": "customValue", + }, + "customMainField": "mainCustomValue", + }, + "customTopLevel": map[string]interface{}{ + "someKey": "someValue", + "anotherKey": []string{"item1", "item2"}, + }, + } + + valuesPath := filepath.Join(tempDir, "values-global.yaml") + initialYaml, err := yaml.Marshal(initialValues) + if err != nil { + t.Fatalf("Failed to marshal initial values: %v", err) + } + if err := os.WriteFile(valuesPath, initialYaml, 0o644); err != nil { + t.Fatalf("Failed to write initial values file: %v", err) + } + + // Process the values + actualPatternName, clusterGroupName, err := ProcessGlobalValues("new-pattern", tempDir) + if err != nil { + t.Fatalf("ProcessGlobalValues failed: %v", err) + } + + // Verify return values + if actualPatternName != "existing-pattern" { + t.Errorf("Expected pattern name 'existing-pattern', got '%s'", actualPatternName) + } + if clusterGroupName != "custom-cluster-group" { + t.Errorf("Expected cluster group name 'custom-cluster-group', got '%s'", clusterGroupName) + } + + // Read the processed file + processedData, err := os.ReadFile(valuesPath) + if err != nil { + t.Fatalf("Failed to read processed file: %v", err) + } + + var processedValues map[string]interface{} + if err := yaml.Unmarshal(processedData, &processedValues); err != nil { + t.Fatalf("Failed to unmarshal processed values: %v", err) + } + + // Verify all custom fields are preserved + tests := []struct { + path []string + expected interface{} + }{ + {[]string{"global", "pattern"}, "existing-pattern"}, + {[]string{"global", "customField"}, "customValue"}, + {[]string{"global", "nestedCustom", "key1"}, "value1"}, + {[]string{"global", "nestedCustom", "key2"}, 42}, + {[]string{"main", "clusterGroupName"}, "custom-cluster-group"}, + {[]string{"main", "multiSourceConfig", "enabled"}, false}, + {[]string{"main", "multiSourceConfig", "clusterGroupChartVersion"}, "1.0.*"}, + {[]string{"main", "multiSourceConfig", "customMultiSource"}, "customValue"}, + {[]string{"main", "customMainField"}, "mainCustomValue"}, + {[]string{"customTopLevel", "someKey"}, "someValue"}, + } + + for _, tt := range tests { + value := getNestedValue(processedValues, tt.path) + if value != tt.expected { + t.Errorf("Field %v = %v, expected %v", tt.path, value, tt.expected) + } + } + + // Verify array field is preserved + customTopLevel, ok := processedValues["customTopLevel"].(map[string]interface{}) + if !ok { + t.Fatalf("customTopLevel is not a map") + } + anotherKey, ok := customTopLevel["anotherKey"].([]interface{}) + if !ok { + t.Fatalf("anotherKey is not an array") + } + expectedArray := []interface{}{"item1", "item2"} + if len(anotherKey) != len(expectedArray) { + t.Errorf("anotherKey length = %d, expected %d", len(anotherKey), len(expectedArray)) + } + for i, expected := range expectedArray { + if i < len(anotherKey) && anotherKey[i] != expected { + t.Errorf("anotherKey[%d] = %v, expected %v", i, anotherKey[i], expected) + } + } +} + +// TestProcessClusterGroupValuesPreservesFields tests that ProcessClusterGroupValues preserves existing user fields. +func TestProcessClusterGroupValuesPreservesFields(t *testing.T) { + tempDir, err := os.MkdirTemp("", "pattern-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create initial values-prod.yaml with custom fields + initialValues := map[string]interface{}{ + "clusterGroup": map[string]interface{}{ + "name": "prod", + "isHubCluster": true, + "namespaces": []interface{}{"custom-ns1", "custom-ns2"}, + "projects": []interface{}{"custom-proj1", "custom-proj2"}, + "subscriptions": map[string]interface{}{ + "custom-operator": map[string]interface{}{ + "name": "custom-operator", + "namespace": "custom-namespace", + "channel": "stable", + "source": "community-operators", + }, + }, + "applications": map[string]interface{}{ + "custom-app": map[string]interface{}{ + "name": "custom-app", + "namespace": "custom-namespace", + "project": "custom-project", + "path": "custom/path", + "customAppField": "customAppValue", + }, + }, + "customClusterField": "customClusterValue", + }, + "customTopLevel": map[string]interface{}{ + "customKey": "customValue", + }, + } + + valuesPath := filepath.Join(tempDir, "values-prod.yaml") + initialYaml, err := yaml.Marshal(initialValues) + if err != nil { + t.Fatalf("Failed to marshal initial values: %v", err) + } + if err := os.WriteFile(valuesPath, initialYaml, 0o644); err != nil { + t.Fatalf("Failed to write initial values file: %v", err) + } + + // Process the values + chartPaths := []string{"charts/app1", "charts/app2"} + err = ProcessClusterGroupValues("test-pattern", "prod", tempDir, chartPaths, false) + if err != nil { + t.Fatalf("ProcessClusterGroupValues failed: %v", err) + } + + // Read the processed file + processedData, err := os.ReadFile(valuesPath) + if err != nil { + t.Fatalf("Failed to read processed file: %v", err) + } + + var processedValues map[string]interface{} + if err := yaml.Unmarshal(processedData, &processedValues); err != nil { + t.Fatalf("Failed to unmarshal processed values: %v", err) + } + + // Verify custom fields are preserved + tests := []struct { + path []string + expected interface{} + }{ + {[]string{"clusterGroup", "name"}, "prod"}, + {[]string{"clusterGroup", "isHubCluster"}, true}, + {[]string{"clusterGroup", "customClusterField"}, "customClusterValue"}, + {[]string{"customTopLevel", "customKey"}, "customValue"}, + } + + for _, tt := range tests { + value := getNestedValue(processedValues, tt.path) + if value != tt.expected { + t.Errorf("Field %v = %v, expected %v", tt.path, value, tt.expected) + } + } + + // Verify custom application fields are preserved + clusterGroup, ok := processedValues["clusterGroup"].(map[string]interface{}) + if !ok { + t.Fatalf("clusterGroup is not a map") + } + applications, ok := clusterGroup["applications"].(map[string]interface{}) + if !ok { + t.Fatalf("applications is not a map") + } + customApp, ok := applications["custom-app"].(map[string]interface{}) + if !ok { + t.Fatalf("custom-app is not a map") + } + if customApp["customAppField"] != "customAppValue" { + t.Errorf("custom-app customAppField = %v, expected 'customAppValue'", customApp["customAppField"]) + } + + // Verify custom subscription is preserved + subscriptions, ok := clusterGroup["subscriptions"].(map[string]interface{}) + if !ok { + t.Fatalf("subscriptions is not a map") + } + customSub, ok := subscriptions["custom-operator"].(map[string]interface{}) + if !ok { + t.Fatalf("custom-operator subscription is not a map") + } + if customSub["channel"] != "stable" { + t.Errorf("custom-operator channel = %v, expected 'stable'", customSub["channel"]) + } + + // Verify new applications were added while preserving existing ones + if _, exists := applications["app1"]; !exists { + t.Error("Expected new application 'app1' to be added") + } + if _, exists := applications["app2"]; !exists { + t.Error("Expected new application 'app2' to be added") + } + if _, exists := applications["custom-app"]; !exists { + t.Error("Expected existing application 'custom-app' to be preserved") + } +} + +// TestProcessGlobalValuesWithNewFile tests ProcessGlobalValues when no existing file exists. +func TestProcessGlobalValuesWithNewFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "pattern-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Process values without existing file + actualPatternName, clusterGroupName, err := ProcessGlobalValues("test-pattern", tempDir) + if err != nil { + t.Fatalf("ProcessGlobalValues failed: %v", err) + } + + // Verify return values + if actualPatternName != "test-pattern" { + t.Errorf("Expected pattern name 'test-pattern', got '%s'", actualPatternName) + } + if clusterGroupName != "prod" { // Default cluster group name + t.Errorf("Expected cluster group name 'prod', got '%s'", clusterGroupName) + } + + // Verify file was created with defaults + valuesPath := filepath.Join(tempDir, "values-global.yaml") + if _, err := os.Stat(valuesPath); os.IsNotExist(err) { + t.Fatal("values-global.yaml was not created") + } + + // Read and verify content + data, err := os.ReadFile(valuesPath) + if err != nil { + t.Fatalf("Failed to read created file: %v", err) + } + + var values types.ValuesGlobal + if err := yaml.Unmarshal(data, &values); err != nil { + t.Fatalf("Failed to unmarshal created file: %v", err) + } + + if values.Global.Pattern != "test-pattern" { + t.Errorf("Global pattern = %s, expected 'test-pattern'", values.Global.Pattern) + } + if values.Main.ClusterGroupName != "prod" { + t.Errorf("Main clusterGroupName = %s, expected 'prod'", values.Main.ClusterGroupName) + } + if !values.Main.MultiSourceConfig.Enabled { + t.Error("MultiSourceConfig.Enabled should be true by default") + } + if values.Main.MultiSourceConfig.ClusterGroupChartVersion != "0.9.*" { + t.Errorf("ClusterGroupChartVersion = %s, expected '0.9.*'", values.Main.MultiSourceConfig.ClusterGroupChartVersion) + } +} + +// getNestedValue is a helper function to get nested values from a map using a path. +func getNestedValue(m map[string]interface{}, path []string) interface{} { + current := m + for i, key := range path { + if i == len(path)-1 { + return current[key] + } + next, ok := current[key].(map[string]interface{}) + if !ok { + return nil + } + current = next + } + return nil +} diff --git a/src/values_cluster_group.go b/src/internal/types/clustergroup.go similarity index 95% rename from src/values_cluster_group.go rename to src/internal/types/clustergroup.go index 202a54d..d3a10a9 100644 --- a/src/values_cluster_group.go +++ b/src/internal/types/clustergroup.go @@ -1,4 +1,4 @@ -package main +package types import "path/filepath" @@ -39,9 +39,9 @@ type ValuesClusterGroup struct { OtherFields map[string]interface{} `yaml:",inline"` } -// newDefaultValuesClusterGroup creates a default configuration for a cluster group. +// NewDefaultValuesClusterGroup creates a default configuration for a cluster group. // It conditionally includes secrets-related resources based on the useSecrets flag. -func newDefaultValuesClusterGroup(patternName, clusterGroupName string, chartPaths []string, useSecrets bool) *ValuesClusterGroup { +func NewDefaultValuesClusterGroup(patternName, clusterGroupName string, chartPaths []string, useSecrets bool) *ValuesClusterGroup { namespaces := []string{patternName} projects := []string{clusterGroupName, patternName} applications := make(map[string]Application) diff --git a/src/values_global.go b/src/internal/types/global.go similarity index 91% rename from src/values_global.go rename to src/internal/types/global.go index 9fdf523..93e168d 100644 --- a/src/values_global.go +++ b/src/internal/types/global.go @@ -1,4 +1,4 @@ -package main +package types // Global represents the 'global' section of the YAML file. type Global struct { @@ -27,8 +27,8 @@ type ValuesGlobal struct { OtherFields map[string]interface{} `yaml:",inline"` } -// newDefaultValuesGlobal creates a ValuesGlobal struct with all the default values. -func newDefaultValuesGlobal() *ValuesGlobal { +// NewDefaultValuesGlobal creates a ValuesGlobal struct with all the default values. +func NewDefaultValuesGlobal() *ValuesGlobal { return &ValuesGlobal{ Global: Global{}, Main: Main{ diff --git a/src/main.go b/src/main.go index 0c39911..97b28fb 100644 --- a/src/main.go +++ b/src/main.go @@ -1,44 +1,7 @@ package main -import ( - "os" - - "github.com/spf13/cobra" -) +import "github.com/dminnear-rh/patternizer/cmd" func main() { - var withSecrets bool - - var rootCmd = &cobra.Command{ - Use: "patternizer", - Short: "A CLI tool for initializing Validated Patterns", - Long: `patternizer is a CLI tool for creating and managing validated pattern configurations. -It helps generate the necessary YAML files and setup for Validated Patterns including -values-global.yaml, values-.yaml, and optional secrets configuration.`, - } - - var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize pattern files", - Long: `Initialize pattern files creates or updates the necessary YAML configuration files -for a validated pattern, including values-global.yaml and values-.yaml. - -When --with-secrets is specified, it also copies the secrets template and -configures the pattern.sh script for secrets usage.`, - RunE: func(cmd *cobra.Command, args []string) error { - // Check if "help" is passed as an argument - if len(args) > 0 && args[0] == "help" { - return cmd.Help() - } - return runInit(withSecrets) - }, - } - - initCmd.Flags().BoolVar(&withSecrets, "with-secrets", false, "Include secrets template and configure pattern for secrets usage") - - rootCmd.AddCommand(initCmd) - - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } + cmd.Execute() } diff --git a/src/main_test.go b/src/main_test.go index 9e1ba06..a73c430 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -2,123 +2,83 @@ package main import ( "os" - "path/filepath" "testing" + + "github.com/dminnear-rh/patternizer/internal/fileutils" + "github.com/dminnear-rh/patternizer/internal/types" ) func TestGetResourcePath(t *testing.T) { // Test with environment variable set - os.Setenv("PATTERNIZER_RESOURCES_DIR", "/test/resources") - result := getResourcePath("test.yaml") - expected := "/test/resources/test.yaml" - if result != expected { - t.Errorf("Expected %s, got %s", expected, result) + os.Setenv("PATTERNIZER_RESOURCES_DIR", "/tmp/test") + path, err := fileutils.GetResourcePath() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if path != "/tmp/test" { + t.Fatalf("Expected /tmp/test, got %s", path) } - // Test without environment variable (fallback) + // Test with environment variable unset os.Unsetenv("PATTERNIZER_RESOURCES_DIR") - result = getResourcePath("test.yaml") - expected = "test.yaml" - if result != expected { - t.Errorf("Expected %s, got %s", expected, result) + path, err = fileutils.GetResourcePath() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + // Should return current directory + if path == "" { + t.Fatalf("Expected non-empty path") } } func TestNewDefaultValuesGlobal(t *testing.T) { - values := newDefaultValuesGlobal() - - if values == nil { - t.Fatal("Expected non-nil ValuesGlobal") - } + values := types.NewDefaultValuesGlobal() if values.Main.ClusterGroupName != "prod" { - t.Errorf("Expected default cluster group name 'prod', got '%s'", values.Main.ClusterGroupName) + t.Errorf("Expected clusterGroupName to be 'prod', got '%s'", values.Main.ClusterGroupName) } if !values.Main.MultiSourceConfig.Enabled { - t.Error("Expected MultiSourceConfig.Enabled to be true") + t.Error("Expected multiSourceConfig.enabled to be true") } if values.Main.MultiSourceConfig.ClusterGroupChartVersion != "0.9.*" { - t.Errorf("Expected chart version '0.9.*', got '%s'", values.Main.MultiSourceConfig.ClusterGroupChartVersion) + t.Errorf("Expected clusterGroupChartVersion to be '0.9.*', got '%s'", values.Main.MultiSourceConfig.ClusterGroupChartVersion) } } func TestNewDefaultValuesClusterGroup(t *testing.T) { - patternName := "test-pattern" - clusterGroupName := "test-cluster" - chartPaths := []string{"charts/app1", "charts/app2"} - - values := newDefaultValuesClusterGroup(patternName, clusterGroupName, chartPaths, false) - - if values == nil { - t.Fatal("Expected non-nil ValuesClusterGroup") - } + // Test without secrets + values := types.NewDefaultValuesClusterGroup("test-pattern", "test-group", []string{"charts/app1", "charts/app2"}, false) - if values.ClusterGroup.Name != clusterGroupName { - t.Errorf("Expected cluster group name '%s', got '%s'", clusterGroupName, values.ClusterGroup.Name) + if values.ClusterGroup.Name != "test-group" { + t.Errorf("Expected name to be 'test-group', got '%s'", values.ClusterGroup.Name) } - expectedNamespaces := []string{patternName} + expectedNamespaces := []string{"test-pattern"} if len(values.ClusterGroup.Namespaces) != len(expectedNamespaces) { t.Errorf("Expected %d namespaces, got %d", len(expectedNamespaces), len(values.ClusterGroup.Namespaces)) } - expectedProjects := []string{clusterGroupName, patternName} + expectedProjects := []string{"test-group", "test-pattern"} if len(values.ClusterGroup.Projects) != len(expectedProjects) { t.Errorf("Expected %d projects, got %d", len(expectedProjects), len(values.ClusterGroup.Projects)) } - if len(values.ClusterGroup.Applications) != len(chartPaths) { - t.Errorf("Expected %d applications, got %d", len(chartPaths), len(values.ClusterGroup.Applications)) - } - - // Check that applications are created correctly - for _, chartPath := range chartPaths { - chartName := filepath.Base(chartPath) - app, exists := values.ClusterGroup.Applications[chartName] - if !exists { - t.Errorf("Expected application '%s' to exist", chartName) - continue - } - - if app.Name != chartName { - t.Errorf("Expected app name '%s', got '%s'", chartName, app.Name) - } - - if app.Path != chartPath { - t.Errorf("Expected app path '%s', got '%s'", chartPath, app.Path) - } - - if app.Namespace != patternName { - t.Errorf("Expected app namespace '%s', got '%s'", patternName, app.Namespace) - } - - if app.Project != patternName { - t.Errorf("Expected app project '%s', got '%s'", patternName, app.Project) - } - } -} + // Test with secrets + valuesWithSecrets := types.NewDefaultValuesClusterGroup("test-pattern", "test-group", []string{"charts/app1"}, true) -func TestNewDefaultValuesClusterGroupWithSecrets(t *testing.T) { - patternName := "test-pattern" - clusterGroupName := "test-cluster" - chartPaths := []string{"charts/app1"} - - values := newDefaultValuesClusterGroup(patternName, clusterGroupName, chartPaths, true) - - // Should have additional namespaces for secrets - expectedNamespaces := []string{patternName, "vault", "golang-external-secrets"} - if len(values.ClusterGroup.Namespaces) != len(expectedNamespaces) { - t.Errorf("Expected %d namespaces with secrets, got %d", len(expectedNamespaces), len(values.ClusterGroup.Namespaces)) + expectedNamespacesWithSecrets := []string{"test-pattern", "vault", "golang-external-secrets"} + if len(valuesWithSecrets.ClusterGroup.Namespaces) != len(expectedNamespacesWithSecrets) { + t.Errorf("Expected %d namespaces with secrets, got %d", len(expectedNamespacesWithSecrets), len(valuesWithSecrets.ClusterGroup.Namespaces)) } - // Should have vault and golang-external-secrets applications - if _, exists := values.ClusterGroup.Applications["vault"]; !exists { - t.Error("Expected vault application to exist when secrets enabled") + // Check that vault and golang-external-secrets applications are added + if _, exists := valuesWithSecrets.ClusterGroup.Applications["vault"]; !exists { + t.Error("Expected vault application to be present with secrets") } - if _, exists := values.ClusterGroup.Applications["golang-external-secrets"]; !exists { - t.Error("Expected golang-external-secrets application to exist when secrets enabled") + if _, exists := valuesWithSecrets.ClusterGroup.Applications["golang-external-secrets"]; !exists { + t.Error("Expected golang-external-secrets application to be present with secrets") } } diff --git a/src/pattern.go b/src/pattern.go deleted file mode 100644 index 101fb50..0000000 --- a/src/pattern.go +++ /dev/null @@ -1,121 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - - "gopkg.in/yaml.v3" -) - -// getPatternNameAndRepoRoot determines the pattern's canonical name from the git remote URL. -// It also returns the local path to the repository root for filesystem scans. -func getPatternNameAndRepoRoot() (patternName, repoRoot string, err error) { - repoRoot, err = getRepoRoot() - if err != nil { - return "", "", fmt.Errorf("could not find repo root: %w", err) - } - - urlBytes, err := exec.Command("git", "remote", "get-url", "origin").Output() - if err != nil { - log.Printf("Could not get git remote 'origin'. Falling back to local directory name.") - patternName = filepath.Base(repoRoot) - return patternName, repoRoot, nil - } - - urlString := strings.TrimSpace(string(urlBytes)) - nameWithSuffix := filepath.Base(urlString) - patternName = strings.TrimSuffix(nameWithSuffix, ".git") - - return patternName, repoRoot, nil -} - -// getRepoRoot finds the top-level directory of the current git repository. -func getRepoRoot() (string, error) { - pathBytes, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("not a git repository: %s", string(exitError.Stderr)) - } - return "", fmt.Errorf("could not execute git command: %w", err) - } - return strings.TrimSpace(string(pathBytes)), nil -} - -// processGlobalValues handles the creation and updating of the values-global.yaml file. -func processGlobalValues(patternName string) (*ValuesGlobal, error) { - const filename = "values-global.yaml" - values := newDefaultValuesGlobal() - - yamlFile, err := os.ReadFile(filename) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read %s: %w", filename, err) - } - - if err == nil { - log.Printf("Found existing '%s', reading and merging.", filename) - if err = yaml.Unmarshal(yamlFile, values); err != nil { - return nil, fmt.Errorf("failed to unmarshal YAML from %s: %w", filename, err) - } - } else { - log.Printf("'%s' not found, will create with default values.", filename) - } - - if values.Global.Pattern == "" { - values.Global.Pattern = patternName - } - - finalYamlBytes, err := yaml.Marshal(values) - if err != nil { - return nil, fmt.Errorf("failed to marshal global values: %w", err) - } - if err = os.WriteFile(filename, finalYamlBytes, 0o644); err != nil { - return nil, fmt.Errorf("failed to write to %s: %w", filename, err) - } - - log.Printf("Successfully processed '%s'.", filename) - return values, nil -} - -// processClusterGroupValues handles the creation/update of the values-.yaml file. -func processClusterGroupValues(globalValues *ValuesGlobal, repoRoot string, useSecrets bool) error { - clusterGroupName := globalValues.Main.ClusterGroupName - patternName := globalValues.Global.Pattern - filename := fmt.Sprintf("values-%s.yaml", clusterGroupName) - - chartPaths, err := findTopLevelCharts(repoRoot) - if err != nil { - return fmt.Errorf("failed to find helm charts: %w", err) - } - log.Printf("Found %d top-level charts.", len(chartPaths)) - - values := newDefaultValuesClusterGroup(patternName, clusterGroupName, chartPaths, useSecrets) - - yamlFile, err := os.ReadFile(filename) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read %s: %w", filename, err) - } - - if err == nil { - log.Printf("Found existing '%s', reading and merging.", filename) - if err = yaml.Unmarshal(yamlFile, values); err != nil { - return fmt.Errorf("failed to unmarshal YAML from %s: %w", filename, err) - } - } else { - log.Printf("'%s' not found, will create with default values.", filename) - } - - finalYamlBytes, err := yaml.Marshal(values) - if err != nil { - return fmt.Errorf("failed to marshal cluster group values: %w", err) - } - if err = os.WriteFile(filename, finalYamlBytes, 0o644); err != nil { - return fmt.Errorf("failed to write to %s: %w", filename, err) - } - - log.Printf("Successfully processed '%s'.", filename) - return nil -}