diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9c33147 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,116 @@ +name: CI Pipeline + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +env: + IMAGE_NAME: quay.io/dminnear/patternizer + GO_VERSION: '1.24' + GOLANGCI_LINT_VERSION: 'v2.1.6' + +jobs: + lint-and-format: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: src/go.sum + + - name: Run gofmt + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "The following files are not formatted:" + gofmt -s -l . + exit 1 + fi + working-directory: ./src + + - name: Run go vet + run: go vet ./... + working-directory: ./src + + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin ${{ env.GOLANGCI_LINT_VERSION }} + + - name: Run golangci-lint + run: $(go env GOPATH)/bin/golangci-lint run + working-directory: ./src + + build-and-test: + runs-on: ubuntu-latest + needs: lint-and-format + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: src/go.sum + + - name: Build binary + run: go build -v -o patternizer . + working-directory: ./src + + - name: Run unit tests + run: go test -v ./... + working-directory: ./src + + - name: Run integration tests + run: ./test/integration_test.sh + env: + PATTERNIZER_BINARY: ./src/patternizer + + build-container: + runs-on: ubuntu-latest + needs: build-and-test + if: github.event_name == 'push' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Quay.io + uses: docker/login-action@v3 + with: + registry: quay.io + username: ${{ secrets.QUAY_USERNAME }} + password: ${{ secrets.QUAY_PASSWORD }} + + - name: Determine tags + id: meta + run: | + echo "sha_tag=sha-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "is_tag=true" >> $GITHUB_OUTPUT + echo "git_tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "is_tag=false" >> $GITHUB_OUTPUT + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Containerfile + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.sha_tag }} + ${{ steps.meta.outputs.is_tag == 'true' && format('{0}:{1}', env.IMAGE_NAME, steps.meta.outputs.git_tag) || '' }} + ${{ steps.meta.outputs.is_tag == 'false' && format('{0}:latest', env.IMAGE_NAME) || '' }} diff --git a/.github/workflows/push-to-quay.yaml b/.github/workflows/push-to-quay.yaml deleted file mode 100644 index 99993f6..0000000 --- a/.github/workflows/push-to-quay.yaml +++ /dev/null @@ -1,60 +0,0 @@ -name: Build and Push Container - -on: - push: - branches: - - main - tags: - - 'v*' - -env: - IMAGE_NAME: quay.io/dminnear/patternizer - -jobs: - build-and-push: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.24' - - - name: Build Go application - run: go build -v - working-directory: ./src - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Quay.io - uses: docker/login-action@v3 - with: - registry: quay.io - username: ${{ secrets.QUAY_USERNAME }} - password: ${{ secrets.QUAY_PASSWORD }} - - - name: Determine tags - id: meta - run: | - echo "sha_tag=sha-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - if [[ $GITHUB_REF == refs/tags/* ]]; then - echo "is_tag=true" >> $GITHUB_OUTPUT - echo "git_tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - else - echo "is_tag=false" >> $GITHUB_OUTPUT - fi - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: ./Containerfile - push: true - tags: | - ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.sha_tag }} - ${{ steps.meta.outputs.is_tag == 'true' && format('{0}:{1}', env.IMAGE_NAME, steps.meta.outputs.git_tag) || '' }} - ${{ steps.meta.outputs.is_tag == 'false' && format('{0}:latest', env.IMAGE_NAME) || '' }} diff --git a/Containerfile b/Containerfile index 76797a7..5d3568f 100644 --- a/Containerfile +++ b/Containerfile @@ -1,16 +1,34 @@ -FROM registry.access.redhat.com/ubi10:10.0 +ARG GO_VERSION=1.24-alpine +ARG ALPINE_VERSION=latest -RUN dnf install -y git && dnf clean all +# Build stage +FROM docker.io/library/golang:${GO_VERSION} AS builder -WORKDIR /wd +WORKDIR /build + +COPY src/go.mod src/go.sum . +RUN go mod download + +COPY src/ . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o patternizer . + +# Runtime stage +FROM docker.io/library/alpine:${ALPINE_VERSION} + +RUN apk --no-cache add git + +COPY --from=builder /build/patternizer /usr/local/bin/patternizer + +ARG PATTERNIZER_RESOURCES_DIR=/tmp/resources +WORKDIR ${PATTERNIZER_RESOURCES_DIR} COPY pattern.sh . -COPY default-cmd.sh . -COPY src/patternizer . COPY values-secret.yaml.template . -WORKDIR /repo +ARG PATTERN_REPO_ROOT=/repo +WORKDIR ${PATTERN_REPO_ROOT} -ENV USE_SECRETS=false +ENV PATTERNIZER_RESOURCES_DIR=${PATTERNIZER_RESOURCES_DIR} -CMD ["/wd/default-cmd.sh"] +ENTRYPOINT ["patternizer"] +CMD ["help"] diff --git a/README.md b/README.md index b1ac88b..49f9c15 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,217 @@ # Patternizer [![Quay Repository](https://img.shields.io/badge/Quay.io-patternizer-blue?logo=quay)](https://quay.io/repository/dminnear/patternizer) -[![Container Build Status](https://github.com/dminnear-rh/patternizer/actions/workflows/push-to-quay.yaml/badge.svg?branch=main)](https://github.com/dminnear-rh/patternizer/actions/workflows/push-to-quay.yaml) +[![CI Pipeline](https://github.com/dminnear-rh/patternizer/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/dminnear-rh/patternizer/actions/workflows/ci.yaml) -**Patternizer** is a container-based utility designed to bootstrap a new Validated Pattern repository. It automatically generates the necessary `values-global.yaml` and `values-.yaml` files by inspecting the Git repository, discovering Helm charts, and applying sensible defaults. +**Patternizer** is a CLI tool and container utility designed to bootstrap Validated Pattern repositories. It automatically generates the necessary `values-global.yaml` and `values-.yaml` files by inspecting Git repositories, discovering Helm charts, and applying sensible defaults. -The utility also provides a `pattern.sh` script for installing, loading secrets, and other common operations. The entire process is containerized to ensure consistency and ease of use. +The tool provides both a standalone CLI and containerized execution for maximum flexibility and consistency across environments. --- -## Quickstart Guide +## Features -You can use the prebuilt container image from Quay to initialize a new pattern repository without building anything locally. +- 🚀 **CLI-first design** with intuitive commands and help system +- 📦 **Container support** for consistent execution across environments +- 🔍 **Auto-discovery** of Helm charts and Git repository metadata +- 🔐 **Secrets integration** with Vault and External Secrets support +- ✅ **Comprehensive testing** with unit and integration tests +- 🏗️ **Multi-stage builds** for minimal container images -1. Clone the Git repository you want to patternize: +--- - ```bash - git clone https://github.com/dminnear-rh/trivial-pattern.git - cd trivial-pattern - ```` +## CLI Usage + +### Available Commands + +```bash +# Show help and available commands +patternizer help + +# Initialize pattern files (without secrets) +patternizer init + +# Initialize pattern files with secrets support +patternizer init --with-secrets + +# Show help for the init command +patternizer init help +``` -2. Run the Patternizer container to initialize the repository: +### Output Files - * If **you do not need secrets support**, run: +The `patternizer init` command generates: - ```bash - podman run --rm -it -v .:/repo:z quay.io/dminnear/patternizer - ``` +- `values-global.yaml` - Global pattern configuration +- `values-.yaml` - Cluster group-specific configuration +- `pattern.sh` - Utility script for pattern operations - * If **you want to enable secrets support** (Vault and External Secrets), run instead: +When using `--with-secrets`: +- `values-secret.yaml.template` - Template for secrets configuration +- Modified `pattern.sh` with `USE_SECRETS=true` as default - ```bash - podman run --rm -it -e USE_SECRETS=true -v .:/repo:z quay.io/dminnear/patternizer - ``` +--- + +## Container Usage - This will generate: +Use the prebuilt container from Quay without needing to install anything locally: - * `values-global.yaml` - * `values-.yaml` - * `pattern.sh` (utility script) - * `values-secret.yaml.template` (only if `USE_SECRETS=true`) +### Basic Initialization -3. Install the Pattern +```bash +# Navigate to your pattern repository +cd /path/to/your/pattern-repo - Log into the OpenShift 4 cluster where you want to install the pattern, then run: +# Initialize without secrets +podman run --rm -it -v .:/repo:z quay.io/dminnear/patternizer init + +# Initialize with secrets support +podman run --rm -it -v .:/repo:z quay.io/dminnear/patternizer init --with-secrets +``` +--- + +## Example Workflow + +1. **Clone or create a pattern repository:** ```bash - ./pattern.sh make install + git clone https://github.com/your-org/your-pattern.git + cd your-pattern ``` - This uses the common utility container (`quay.io/dminnear/common-utility-container`) to handle shared scripts and resources, so no `common/` directory or Makefile is added directly to your repo. +2. **Initialize the pattern:** + ```bash + podman run --rm -it -v .:/repo:z quay.io/dminnear/patternizer init + ``` + +3. **Review generated files:** + ```bash + ls -la values-*.yaml pattern.sh + ``` + +4. **Install the pattern:** + ```bash + ./pattern.sh make install + ``` --- -## Development (Optional) +## Development -You only need to build the Go binary or container if you're modifying or developing Patternizer itself. +### Prerequisites -### Build the Go Binary +- Go 1.24+ +- Podman or Docker +- Git + +### Building the CLI ```bash cd src -go build +go build -o patternizer . ``` -### Build the Container Image +### Running Tests ```bash +# Run unit tests +cd src +go test -v ./... + +# Run integration tests (requires built binary) +./test/integration_test.sh +``` + +### Building the Container + +```bash +# Build with default settings podman build -t patternizer:local . + +# Build with custom alpine version +podman build --build-arg ALPINE_VERSION=3.22 -t patternizer:local . +``` + +### Code Quality + +The project uses comprehensive linting and formatting: + +```bash +cd src + +# Format code +gofmt -s -w . + +# Run linter +golangci-lint run + +# Run vet +go vet ./... +``` + +--- + +## Testing + +### Unit Tests + +Located in `src/*_test.go`, these test core functionality: +- Resource path resolution +- Default values generation +- Secrets integration logic + +### Integration Tests + +The integration test (`test/integration_test.sh`) validates the complete workflow: +- Clones the [trivial-pattern](https://github.com/dminnear-rh/trivial-pattern) repository +- Runs `patternizer init` +- Verifies generated files match expected output +- Ensures `pattern.sh` is created with correct configuration + +Run integration tests locally: +```bash +# Build the binary first +cd src && go build -o patternizer . + +# Run integration tests +cd .. && ./test/integration_test.sh ``` + +--- + +## CI/CD Pipeline + +The project uses a comprehensive CI pipeline with three stages: + +1. **Lint & Format**: Code quality checks with `gofmt`, `go vet`, and `golangci-lint` +2. **Build & Test**: Binary compilation, unit tests, and integration tests +3. **Container Build**: Multi-stage container build and push to Quay.io + +All code must pass linting and tests before being merged or deployed. + +--- + +## Architecture + +The CLI is organized into focused modules: + +- `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 + +This modular design makes the codebase maintainable and testable. + +--- + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `go test ./... && ./test/integration_test.sh` +5. Submit a pull request + +All contributions must pass the CI pipeline including linting, formatting, and comprehensive testing. diff --git a/default-cmd.sh b/default-cmd.sh deleted file mode 100755 index b879510..0000000 --- a/default-cmd.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [[ "$USE_SECRETS" != "false" ]]; then - echo "Copying template file for secrets" - cp /wd/values-secret.yaml.template . - sed -i 's|\${USE_SECRETS:=false}|\${USE_SECRETS:=true}|' /wd/pattern.sh -fi - -cp /wd/pattern.sh . - -echo "Running patternizer command to create pattern's values yaml files" -/wd/patternizer diff --git a/src/.golangci.yml b/src/.golangci.yml new file mode 100644 index 0000000..ea5d997 --- /dev/null +++ b/src/.golangci.yml @@ -0,0 +1,51 @@ +version: "2" +run: + go: "1.24" +linters: + enable: + - gocritic + - misspell + - revive + settings: + gocritic: + enabled-tags: + - diagnostic + - style + - performance + revive: + rules: + - name: exported + arguments: + - disableStutteringCheck + disabled: false + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - errcheck + path: _test\.go + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + settings: + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com/dminnear-rh/patternizer + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/src/commands.go b/src/commands.go new file mode 100644 index 0000000..f1bcade --- /dev/null +++ b/src/commands.go @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..8988900 --- /dev/null +++ b/src/fileutils.go @@ -0,0 +1,107 @@ +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 87ee54e..432a570 100644 --- a/src/go.mod +++ b/src/go.mod @@ -2,4 +2,9 @@ module github.com/dminnear-rh/patternizer go 1.24.4 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/src/go.sum b/src/go.sum index 4bc0337..65f8c12 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,3 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/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/main.go b/src/main.go index de64143..0c39911 100644 --- a/src/main.go +++ b/src/main.go @@ -1,144 +1,44 @@ package main import ( - "fmt" - "log" "os" - "os/exec" - "path/filepath" - "strings" - "gopkg.in/yaml.v3" + "github.com/spf13/cobra" ) -// 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() (string, string, 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, 0644); 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) - } +func main() { + var withSecrets bool - 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) + 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.`, } - 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, 0644); err != nil { - return fmt.Errorf("failed to write to %s: %w", filename, err) - } - - log.Printf("Successfully processed '%s'.", filename) - return nil -} + 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. -func main() { - patternName, repoRoot, err := getPatternNameAndRepoRoot() - if err != nil { - log.Fatalf("Error determining pattern name or repo root: %v", err) +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) + }, } - log.Printf("Determined pattern name: '%s'", patternName) - - globalValues, err := processGlobalValues(patternName) - if err != nil { - log.Fatalf("Error processing global values: %v", err) - } + initCmd.Flags().BoolVar(&withSecrets, "with-secrets", false, "Include secrets template and configure pattern for secrets usage") - useSecrets := os.Getenv("USE_SECRETS") != "false" - log.Printf("Secrets will%s be added to the cluster group values file", map[bool]string{true: "", false: " not"}[useSecrets]) + rootCmd.AddCommand(initCmd) - if err := processClusterGroupValues(globalValues, repoRoot, useSecrets); err != nil { - log.Fatalf("Error processing cluster group values: %v", err) + if err := rootCmd.Execute(); err != nil { + os.Exit(1) } - - log.Println("All configuration files processed successfully.") } diff --git a/src/main_test.go b/src/main_test.go new file mode 100644 index 0000000..9e1ba06 --- /dev/null +++ b/src/main_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +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) + } + + // Test without environment variable (fallback) + os.Unsetenv("PATTERNIZER_RESOURCES_DIR") + result = getResourcePath("test.yaml") + expected = "test.yaml" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } +} + +func TestNewDefaultValuesGlobal(t *testing.T) { + values := newDefaultValuesGlobal() + + if values == nil { + t.Fatal("Expected non-nil ValuesGlobal") + } + + if values.Main.ClusterGroupName != "prod" { + t.Errorf("Expected default cluster group name 'prod', got '%s'", values.Main.ClusterGroupName) + } + + if !values.Main.MultiSourceConfig.Enabled { + 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) + } +} + +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") + } + + if values.ClusterGroup.Name != clusterGroupName { + t.Errorf("Expected cluster group name '%s', got '%s'", clusterGroupName, values.ClusterGroup.Name) + } + + expectedNamespaces := []string{patternName} + if len(values.ClusterGroup.Namespaces) != len(expectedNamespaces) { + t.Errorf("Expected %d namespaces, got %d", len(expectedNamespaces), len(values.ClusterGroup.Namespaces)) + } + + expectedProjects := []string{clusterGroupName, patternName} + 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) + } + } +} + +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)) + } + + // 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") + } + + if _, exists := values.ClusterGroup.Applications["golang-external-secrets"]; !exists { + t.Error("Expected golang-external-secrets application to exist when secrets enabled") + } +} diff --git a/src/pattern.go b/src/pattern.go new file mode 100644 index 0000000..101fb50 --- /dev/null +++ b/src/pattern.go @@ -0,0 +1,121 @@ +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 +} diff --git a/test/expected_values_global.yaml b/test/expected_values_global.yaml new file mode 100644 index 0000000..e97874e --- /dev/null +++ b/test/expected_values_global.yaml @@ -0,0 +1,7 @@ +global: + pattern: trivial-pattern +main: + clusterGroupName: prod + multiSourceConfig: + enabled: true + clusterGroupChartVersion: 0.9.* diff --git a/test/expected_values_global_custom.yaml b/test/expected_values_global_custom.yaml new file mode 100644 index 0000000..5a8b638 --- /dev/null +++ b/test/expected_values_global_custom.yaml @@ -0,0 +1,7 @@ +global: + pattern: renamed-pattern +main: + clusterGroupName: renamed-cluster-group + multiSourceConfig: + enabled: true + clusterGroupChartVersion: 0.9.* diff --git a/test/expected_values_prod.yaml b/test/expected_values_prod.yaml new file mode 100644 index 0000000..04e31ac --- /dev/null +++ b/test/expected_values_prod.yaml @@ -0,0 +1,19 @@ +clusterGroup: + name: prod + namespaces: + - trivial-pattern + projects: + - prod + - trivial-pattern + subscriptions: {} + applications: + simple: + name: simple + namespace: trivial-pattern + project: trivial-pattern + path: charts/simple + trivial: + name: trivial + namespace: trivial-pattern + project: trivial-pattern + path: charts/trivial diff --git a/test/expected_values_prod_with_secrets.yaml b/test/expected_values_prod_with_secrets.yaml new file mode 100644 index 0000000..1deeaef --- /dev/null +++ b/test/expected_values_prod_with_secrets.yaml @@ -0,0 +1,33 @@ +clusterGroup: + name: prod + namespaces: + - trivial-pattern + - vault + - golang-external-secrets + projects: + - prod + - trivial-pattern + subscriptions: {} + applications: + golang-external-secrets: + name: golang-external-secrets + namespace: golang-external-secrets + project: prod + chart: golang-external-secrets + chartVersion: 0.1.* + simple: + name: simple + namespace: trivial-pattern + project: trivial-pattern + path: charts/simple + trivial: + name: trivial + namespace: trivial-pattern + project: trivial-pattern + path: charts/trivial + vault: + name: vault + namespace: vault + project: prod + chart: hashicorp-vault + chartVersion: 0.1.* diff --git a/test/expected_values_renamed_cluster_group.yaml b/test/expected_values_renamed_cluster_group.yaml new file mode 100644 index 0000000..a179008 --- /dev/null +++ b/test/expected_values_renamed_cluster_group.yaml @@ -0,0 +1,19 @@ +clusterGroup: + name: renamed-cluster-group + namespaces: + - renamed-pattern + projects: + - renamed-cluster-group + - renamed-pattern + subscriptions: {} + applications: + simple: + name: simple + namespace: renamed-pattern + project: renamed-pattern + path: charts/simple + trivial: + name: trivial + namespace: renamed-pattern + project: renamed-pattern + path: charts/trivial diff --git a/test/initial_values_global_custom.yaml b/test/initial_values_global_custom.yaml new file mode 100644 index 0000000..0f0169a --- /dev/null +++ b/test/initial_values_global_custom.yaml @@ -0,0 +1,4 @@ +global: + pattern: renamed-pattern +main: + clusterGroupName: renamed-cluster-group diff --git a/test/integration_test.sh b/test/integration_test.sh new file mode 100755 index 0000000..041bd91 --- /dev/null +++ b/test/integration_test.sh @@ -0,0 +1,242 @@ +#!/bin/bash + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test configuration +PATTERNIZER_BINARY="${PATTERNIZER_BINARY:-./src/patternizer}" +TEST_REPO_URL="https://github.com/dminnear-rh/trivial-pattern.git" +TEST_DIR="/tmp/patternizer-integration-test" +TEST_DIR_SECRETS="/tmp/patternizer-integration-test-secrets" +TEST_DIR_CUSTOM="/tmp/patternizer-integration-test-custom" + +echo -e "${YELLOW}Starting patternizer integration tests...${NC}" + +# Clean up any previous test runs +if [ -d "$TEST_DIR" ]; then + rm -rf "$TEST_DIR" +fi +if [ -d "$TEST_DIR_SECRETS" ]; then + rm -rf "$TEST_DIR_SECRETS" +fi +if [ -d "$TEST_DIR_CUSTOM" ]; then + rm -rf "$TEST_DIR_CUSTOM" +fi + +# Convert PATTERNIZER_BINARY to absolute path before changing directories +PATTERNIZER_BINARY=$(realpath "$PATTERNIZER_BINARY") + +# Get the absolute path to the repository root (where resource files are located) +REPO_ROOT=$(pwd) + +# Set absolute paths to expected files +EXPECTED_VALUES_GLOBAL="$REPO_ROOT/test/expected_values_global.yaml" +EXPECTED_VALUES_PROD="$REPO_ROOT/test/expected_values_prod.yaml" +EXPECTED_VALUES_PROD_WITH_SECRETS="$REPO_ROOT/test/expected_values_prod_with_secrets.yaml" +EXPECTED_VALUES_GLOBAL_CUSTOM="$REPO_ROOT/test/expected_values_global_custom.yaml" +EXPECTED_VALUES_RENAMED_CLUSTER_GROUP="$REPO_ROOT/test/expected_values_renamed_cluster_group.yaml" +INITIAL_VALUES_GLOBAL_CUSTOM="$REPO_ROOT/test/initial_values_global_custom.yaml" + +# Check if patternizer binary exists and is executable +if [ ! -x "$PATTERNIZER_BINARY" ]; then + echo -e "${RED}ERROR: Patternizer binary not found or not executable at: $PATTERNIZER_BINARY${NC}" + exit 1 +fi + +# Function to compare YAML files (ignoring whitespace differences) +compare_yaml() { + local expected_file="$1" + local actual_file="$2" + local description="$3" + + if [ ! -f "$actual_file" ]; then + echo -e "${RED}FAIL: $description - file not created: $actual_file${NC}" + return 1 + fi + + # Normalize YAML by sorting and removing empty lines/spaces + normalize_yaml() { + python3 -c " +import yaml, sys +try: + with open('$1', 'r') as f: + data = yaml.safe_load(f) + print(yaml.dump(data, default_flow_style=False, sort_keys=True)) +except Exception as e: + print(f'Error processing $1: {e}', file=sys.stderr) + sys.exit(1) +" + } + + # Compare normalized YAML + if normalize_yaml "$expected_file" | diff -u - <(normalize_yaml "$actual_file") > /dev/null; then + echo -e "${GREEN}PASS: $description${NC}" + return 0 + else + echo -e "${RED}FAIL: $description${NC}" + echo "Expected content (normalized):" + normalize_yaml "$expected_file" + echo "" + echo "Actual content (normalized):" + normalize_yaml "$actual_file" + echo "" + echo "Diff:" + normalize_yaml "$expected_file" | diff -u - <(normalize_yaml "$actual_file") || true + return 1 + fi +} + +# Function to check file content +check_file_content() { + local file="$1" + local pattern="$2" + local description="$3" + + if [ ! -f "$file" ]; then + echo -e "${RED}FAIL: $description - file not found: $file${NC}" + return 1 + fi + + if grep -q "$pattern" "$file"; then + echo -e "${GREEN}PASS: $description${NC}" + return 0 + else + echo -e "${RED}FAIL: $description${NC}" + echo "Pattern '$pattern' not found in $file" + echo "File contents:" + cat "$file" + return 1 + fi +} + +# Function to check file exists +check_file_exists() { + local file="$1" + local description="$2" + + if [ -f "$file" ]; then + echo -e "${GREEN}PASS: $description${NC}" + return 0 + else + echo -e "${RED}FAIL: $description - file not found: $file${NC}" + return 1 + fi +} + +# +# Test 1: Basic initialization (without secrets) +# +echo -e "${YELLOW}=== Test 1: Basic initialization (without secrets) ===${NC}" + +echo -e "${YELLOW}Cloning test repository...${NC}" +git clone "$TEST_REPO_URL" "$TEST_DIR" +cd "$TEST_DIR" + +echo -e "${YELLOW}Running patternizer init...${NC}" +PATTERNIZER_RESOURCES_DIR="$REPO_ROOT" "$PATTERNIZER_BINARY" init + +echo -e "${YELLOW}Running verification tests...${NC}" + +# Test 1.1: Check values-global.yaml +compare_yaml "$EXPECTED_VALUES_GLOBAL" "values-global.yaml" "values-global.yaml content" + +# Test 1.2: Check values-prod.yaml +compare_yaml "$EXPECTED_VALUES_PROD" "values-prod.yaml" "values-prod.yaml content" + +# Test 1.3: Check pattern.sh exists and has USE_SECRETS=false +check_file_content "pattern.sh" 'USE_SECRETS:=false' "pattern.sh contains USE_SECRETS=false" + +# Test 1.4: Verify pattern.sh is executable +if [ -x "pattern.sh" ]; then + echo -e "${GREEN}PASS: pattern.sh is executable${NC}" +else + echo -e "${RED}FAIL: pattern.sh is not executable${NC}" + exit 1 +fi + +echo -e "${GREEN}=== Test 1: Basic initialization PASSED ===${NC}" + +# +# Test 2: Initialization with secrets +# +echo -e "${YELLOW}=== Test 2: Initialization with secrets ===${NC}" + +cd "$REPO_ROOT" # Go back to repo root +echo -e "${YELLOW}Cloning test repository for secrets test...${NC}" +git clone "$TEST_REPO_URL" "$TEST_DIR_SECRETS" +cd "$TEST_DIR_SECRETS" + +echo -e "${YELLOW}Running patternizer init --with-secrets...${NC}" +PATTERNIZER_RESOURCES_DIR="$REPO_ROOT" "$PATTERNIZER_BINARY" init --with-secrets + +echo -e "${YELLOW}Running verification tests for secrets...${NC}" + +# Test 2.1: Check values-global.yaml (should be same as without secrets) +compare_yaml "$EXPECTED_VALUES_GLOBAL" "values-global.yaml" "values-global.yaml content (with secrets)" + +# Test 2.2: Check values-prod.yaml with secrets applications +compare_yaml "$EXPECTED_VALUES_PROD_WITH_SECRETS" "values-prod.yaml" "values-prod.yaml content (with secrets)" + +# Test 2.3: Check pattern.sh exists and has USE_SECRETS=true +check_file_content "pattern.sh" 'USE_SECRETS:=true' "pattern.sh contains USE_SECRETS=true" + +# Test 2.4: Verify pattern.sh is executable +if [ -x "pattern.sh" ]; then + echo -e "${GREEN}PASS: pattern.sh is executable (with secrets)${NC}" +else + echo -e "${RED}FAIL: pattern.sh is not executable (with secrets)${NC}" + exit 1 +fi + +# Test 2.5: Check values-secret.yaml.template exists +check_file_exists "values-secret.yaml.template" "values-secret.yaml.template file exists" + +echo -e "${GREEN}=== Test 2: Initialization with secrets PASSED ===${NC}" + +# +# Test 3: Custom pattern and cluster group names (merging test) +# +echo -e "${YELLOW}=== Test 3: Custom pattern and cluster group names ===${NC}" + +cd "$REPO_ROOT" # Go back to repo root +echo -e "${YELLOW}Cloning test repository for custom names test...${NC}" +git clone "$TEST_REPO_URL" "$TEST_DIR_CUSTOM" +cd "$TEST_DIR_CUSTOM" + +echo -e "${YELLOW}Setting up initial values-global.yaml with custom names...${NC}" +cp "$INITIAL_VALUES_GLOBAL_CUSTOM" "values-global.yaml" + +echo -e "${YELLOW}Running patternizer init (should preserve custom names)...${NC}" +PATTERNIZER_RESOURCES_DIR="$REPO_ROOT" "$PATTERNIZER_BINARY" init + +echo -e "${YELLOW}Running verification tests for custom names...${NC}" + +# Test 3.1: Check values-global.yaml preserves custom names and adds multiSourceConfig +compare_yaml "$EXPECTED_VALUES_GLOBAL_CUSTOM" "values-global.yaml" "values-global.yaml content (custom names)" + +# Test 3.2: Check custom cluster group file is created with correct content +compare_yaml "$EXPECTED_VALUES_RENAMED_CLUSTER_GROUP" "values-renamed-cluster-group.yaml" "values-renamed-cluster-group.yaml content" + +# Test 3.3: Check pattern.sh exists and has USE_SECRETS=false +check_file_content "pattern.sh" 'USE_SECRETS:=false' "pattern.sh contains USE_SECRETS=false (custom names)" + +# Test 3.4: Verify pattern.sh is executable +if [ -x "pattern.sh" ]; then + echo -e "${GREEN}PASS: pattern.sh is executable (custom names)${NC}" +else + echo -e "${RED}FAIL: pattern.sh is not executable (custom names)${NC}" + exit 1 +fi + +echo -e "${GREEN}=== Test 3: Custom pattern and cluster group names PASSED ===${NC}" + +echo -e "${GREEN}All integration tests passed!${NC}" + +# Clean up +cd "$REPO_ROOT" +rm -rf "$TEST_DIR" "$TEST_DIR_SECRETS" "$TEST_DIR_CUSTOM"