From ea04b900792699fe604f06809b6d6345d8d27354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 09:56:12 +0200 Subject: [PATCH 01/13] chore: add AGENTS.md --- AGENTS.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..842b796 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,125 @@ +# AGENTS.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Helm v4 post-renderer plugin that integrates Kustomize transformations into Helm chart deployments. The plugin runs as a subprocess during Helm rendering, extracting embedded Kustomize files from a special Kubernetes resource, applying them to the rendered manifests, and returning the transformed output. + +## Requirements + +- Helm v4 with subprocess runtime support for post-renderer plugins +- kubectl (for `kubectl kustomize` command) +- Go 1.21+ for development + +## Build and Test Commands + +```bash +# Build the plugin binary +make build + +# Run unit tests +make test +go test -v ./... + +# Run a specific test +go test -v ./internal/parser -run TestParseManifests + +# Run integration tests (requires Helm v4) +make test-integration + +# Run all tests (unit + integration) +make test-all + +# Install plugin into Helm +make install + +# Uninstall plugin from Helm +make uninstall + +# Development cycle: rebuild and reinstall +make reinstall + +# Clean build artifacts +make clean +``` + +## Architecture + +### Data Flow + +1. **Input**: Helm renders a chart to YAML manifests containing: + - Regular Kubernetes resources + - A special `KustomizePluginData` resource (apiVersion: `helm.plugin.kustomize/v1`, kind: `KustomizePluginData`) + +2. **Processing Pipeline** (`main.go:Run()`): + - **Parse** (`parser` package): Separates `KustomizePluginData` from other resources + - **Extract** (`extractor` package): Creates temporary directory and extracts embedded files + - **Generate**: Writes remaining Helm resources to `all.yaml` + - **Patch** (`kustomize` package): Ensures `all.yaml` is referenced in `kustomization.yaml` + - **Transform**: Runs `kubectl kustomize` on the temporary directory + - **Output**: Returns transformed manifests to Helm + +3. **Cleanup**: Temporary directory is automatically removed via defer + +### Package Structure + +- **`main.go`**: Entry point implementing Helm's `PostRenderer` interface. Orchestrates the entire pipeline. + +- **`internal/parser`**: YAML document parsing and resource separation + - Identifies `KustomizePluginData` resources by apiVersion/kind + - Validates the `files` field structure (must be `map[string]string`) + - Enforces single `KustomizePluginData` resource per chart + - Marshals remaining resources back to YAML + +- **`internal/extractor`**: Temporary filesystem management + - Uses `os.OpenRoot()` for path-constrained file operations (security feature) + - Creates directory structures from file paths (e.g., `patches/deployment.yaml`) + - Handles cleanup with graceful error reporting + +- **`internal/kustomize`**: Kustomization file manipulation and execution + - Parses `kustomization.yaml` preserving all fields via `map[string]any` + - Adds `all.yaml` to `resources` array if not present + - Executes `kubectl kustomize` command + +### Key Design Decisions + +1. **YAML Parsing Strategy**: Parse once into `map[string]any` to preserve all fields, then manually extract typed fields. This avoids double-parsing overhead while maintaining round-trip fidelity. + +2. **Type Conversions**: YAML unmarshaling into `map[string]any` always produces `[]any` for arrays, never typed slices like `[]string`. Manual iteration with type assertions is required. + +3. **Security**: Uses `os.OpenRoot()` to constrain all file operations to the temporary directory, preventing path traversal attacks from malicious file paths. + +4. **Reserved Filename**: The name `all.yaml` is reserved for Helm-rendered manifests and cannot appear in `KustomizePluginData.files`. + +5. **Error Handling**: All type conversions and validations fail fast with descriptive errors rather than silently skipping invalid data. + +## KustomizePluginData Resource + +The special resource format that triggers kustomize processing: + +```yaml +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + resources: + - all.yaml + patches: + - path: patch.yaml + + patch.yaml: | + apiVersion: apps/v1 + kind: Deployment + # ... patch content +``` + +**Important constants** (`internal/parser/parser.go`): +- `APIVersion = "helm.plugin.kustomize/v1"` +- `Kind = "KustomizePluginData"` + +## Testing + +- Unit tests use table-driven patterns with `t.Run()` subtests +- Integration tests in `test-integration.sh` test the full plugin with actual Helm charts +- Example charts in `examples/` directory serve as test fixtures and documentation \ No newline at end of file From 9c19620759e7c0bc9d3cdc5d45c7c37a7939362e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 09:56:26 +0200 Subject: [PATCH 02/13] ci: set up gh action --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++++++++++++++++ Makefile | 4 +-- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d4e4ec4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: Test + 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.25' + cache: true + + - name: Install kubectl + uses: azure/setup-kubectl@v4 + with: + version: 'latest' + + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: 'v4.0.0' + + - name: Run unit tests + run: make test-all + + lint: + name: Lint + 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.25' + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: 'v2.7.2' diff --git a/Makefile b/Makefile index 88a8dce..2d12e02 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ install: build helm plugin install . uninstall: - helm plugin uninstall kustomize + helm plugin uninstall kustomize 2>/dev/null || true # Development: uninstall, rebuild, and reinstall -reinstall: uninstall build install \ No newline at end of file +reinstall: uninstall build install From 0f027838eb79e2444f5c88044d0f0e6de8f42a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 11:13:12 +0200 Subject: [PATCH 03/13] ci: build to dist, rename plugin --- .gitignore | 6 ++++-- Makefile | 10 ++++++---- TODO.md | 2 +- plugin.yaml | 4 ++-- test-integration.sh | 6 +++--- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index bdf71ae..413a1f1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ .claude +dist +helm-kustomize-*.tgz diff --git a/Makefile b/Makefile index 2d12e02..71cf6a6 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,15 @@ .PHONY: build clean test test-integration test-all install uninstall reinstall BINARY_NAME=helm-kustomize-plugin -BUILD_DIR=. +BUILD_DIR=dist build: + mkdir -p $(BUILD_DIR) go build -o $(BUILD_DIR)/$(BINARY_NAME) . + cp plugin.yaml $(BUILD_DIR)/ clean: - rm -f $(BUILD_DIR)/$(BINARY_NAME) + rm -rf $(BUILD_DIR) go clean test: @@ -19,10 +21,10 @@ test-integration: reinstall test-all: test test-integration install: build - helm plugin install . + helm plugin install $(BUILD_DIR) uninstall: - helm plugin uninstall kustomize 2>/dev/null || true + helm plugin uninstall helm-kustomize 2>/dev/null || true # Development: uninstall, rebuild, and reinstall reinstall: uninstall build install diff --git a/TODO.md b/TODO.md index 99acead..6a61990 100644 --- a/TODO.md +++ b/TODO.md @@ -47,7 +47,7 @@ ## Build & Distribution -- [ ] Set up CI/CD pipeline +- [x] Set up CI/CD pipeline - [ ] Create release automation - [ ] Add version management diff --git a/plugin.yaml b/plugin.yaml index 4da5647..dc36051 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,9 +1,9 @@ apiVersion: v1 type: postrenderer/v1 -name: kustomize +name: helm-kustomize version: 0.1.0 description: A Helm post-renderer plugin that applies Kustomize transformations runtime: subprocess runtimeConfig: platformCommand: - - command: ${HELM_PLUGIN_DIR}/helm-kustomize-plugin \ No newline at end of file + - command: ${HELM_PLUGIN_DIR}/helm-kustomize-plugin diff --git a/test-integration.sh b/test-integration.sh index d851dc1..fb1eb00 100755 --- a/test-integration.sh +++ b/test-integration.sh @@ -14,15 +14,15 @@ if ! command -v yq &> /dev/null; then fi # Ensure plugin is installed -if ! helm plugin list | grep -q kustomize; then - echo "Error: kustomize plugin not installed" +if ! helm plugin list | grep -q helm-kustomize; then + echo "Error: helm-kustomize plugin not installed" echo "Run 'make install' first" exit 1 fi # Run helm template with the plugin echo "Testing simple-app example..." -OUTPUT=$(helm template examples/simple-app --post-renderer kustomize) +OUTPUT=$(helm template examples/simple-app --post-renderer helm-kustomize) # Check for expected transformations FAILED=0 From 245266500d0b762c358d0053f2f6847e57957e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 11:36:36 +0200 Subject: [PATCH 04/13] ci: build matrix --- .github/workflows/{ci.yml => ci.yaml} | 11 ++++++++--- TODO.md | 2 ++ 2 files changed, 10 insertions(+), 3 deletions(-) rename .github/workflows/{ci.yml => ci.yaml} (75%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yaml similarity index 75% rename from .github/workflows/ci.yml rename to .github/workflows/ci.yaml index d4e4ec4..ea73494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yaml @@ -8,9 +8,14 @@ on: jobs: test: - name: Test + name: Test (Helm ${{ matrix.helm-version }}, kubectl ${{ matrix.kubectl-version }}) runs-on: ubuntu-latest + strategy: + matrix: + helm-version: ['v4.0.4'] + kubectl-version: ['latest'] + steps: - name: Checkout code uses: actions/checkout@v4 @@ -24,12 +29,12 @@ jobs: - name: Install kubectl uses: azure/setup-kubectl@v4 with: - version: 'latest' + version: ${{ matrix.kubectl-version }} - name: Install Helm uses: azure/setup-helm@v4 with: - version: 'v4.0.0' + version: ${{ matrix.helm-version }} - name: Run unit tests run: make test-all diff --git a/TODO.md b/TODO.md index 6a61990..c1fbad4 100644 --- a/TODO.md +++ b/TODO.md @@ -50,9 +50,11 @@ - [x] Set up CI/CD pipeline - [ ] Create release automation - [ ] Add version management +- [ ] Set up renovate for ci.yaml ## Future Enhancements - [ ] Support for multiple kustomization files - [ ] Configurable resource naming (alternative to `all.yaml`) - [ ] Performance optimization for large charts +- [ ] Support helm v3 From cbcbcd3541b6baa31062e15700a058f0637e166f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 11:46:02 +0200 Subject: [PATCH 05/13] build: optimize binary by moving out the conformance check --- main.go | 9 ++------- main_interface_test.go | 6 ++++++ 2 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 main_interface_test.go diff --git a/main.go b/main.go index 87cdaa1..310769b 100644 --- a/main.go +++ b/main.go @@ -6,20 +6,15 @@ import ( "io" "os" - "helm.sh/helm/v4/pkg/postrenderer" - "github.com/owhelm/helm-kustomize-plugin/internal/extractor" "github.com/owhelm/helm-kustomize-plugin/internal/kustomize" "github.com/owhelm/helm-kustomize-plugin/internal/parser" ) -// KustomizePostRenderer implements the Helm PostRenderer interface. -// It processes manifests through kustomize transformations. +// KustomizePostRenderer processes Helm manifests through kustomize transformations. +// It implements Helm's post-renderer protocol by reading from stdin and writing to stdout. type KustomizePostRenderer struct{} -// Compile-time interface compliance check -var _ postrenderer.PostRenderer = (*KustomizePostRenderer)(nil) - func main() { // Create the post-renderer renderer := &KustomizePostRenderer{} diff --git a/main_interface_test.go b/main_interface_test.go new file mode 100644 index 0000000..1aee9c4 --- /dev/null +++ b/main_interface_test.go @@ -0,0 +1,6 @@ +package main + +import "helm.sh/helm/v4/pkg/postrenderer" + +// Compile-time interface compliance check for KustomizePostRenderer. +var _ postrenderer.PostRenderer = (*KustomizePostRenderer)(nil) From f6a8a19e0b53efbae6c4ef696673144c2f05a2f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 11:52:35 +0200 Subject: [PATCH 06/13] build: lint, fmt --- AGENTS.md | 10 ++++++++-- Makefile | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 842b796..d394647 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,13 +15,19 @@ This is a Helm v4 post-renderer plugin that integrates Kustomize transformations ## Build and Test Commands ```bash -# Build the plugin binary +# Build the plugin binary (automatically runs go fmt) make build -# Run unit tests +# Format code manually +go fmt ./... + +# Run unit tests (automatically runs golangci-lint) make test go test -v ./... +# Run linter manually +golangci-lint run + # Run a specific test go test -v ./internal/parser -run TestParseManifests diff --git a/Makefile b/Makefile index 71cf6a6..c34e04e 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ BINARY_NAME=helm-kustomize-plugin BUILD_DIR=dist build: + go fmt ./... mkdir -p $(BUILD_DIR) go build -o $(BUILD_DIR)/$(BINARY_NAME) . cp plugin.yaml $(BUILD_DIR)/ @@ -13,6 +14,7 @@ clean: go clean test: + golangci-lint run go test -v ./... test-integration: reinstall From bf1422338e03c0574334e654b0eb068cffc2e78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 12:06:26 +0200 Subject: [PATCH 07/13] build: coverage --- .github/workflows/ci.yaml | 25 +++++++++++++++++++++++-- Makefile | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ea73494..6c1bb04 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,8 +36,29 @@ jobs: with: version: ${{ matrix.helm-version }} - - name: Run unit tests - run: make test-all + - name: Run unit tests with coverage + run: make coverage-check + + - name: Display coverage summary + if: always() + run: | + echo "=== Coverage Summary ===" + go tool cover -func=coverage.out | tail -1 | awk '{print "Overall Coverage: " $3}' + echo "" + echo "=== Coverage by Package ===" + go tool cover -func=coverage.out | grep -E "^github.com/owhelm" | grep -v "total" + + - name: Run integration tests + run: make test-integration + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report-${{ matrix.helm-version }}-${{ matrix.kubectl-version }} + path: coverage/ + retention-days: 30 + if-no-files-found: ignore lint: name: Lint diff --git a/Makefile b/Makefile index c34e04e..18d6647 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,12 @@ -.PHONY: build clean test test-integration test-all install uninstall reinstall +.PHONY: build clean test test-integration test-all install uninstall reinstall \ + coverage-report coverage-check coverage-view coverage-clean BINARY_NAME=helm-kustomize-plugin BUILD_DIR=dist +COVERAGE_THRESHOLD=60 +COVERAGE_PROFILE=coverage.out +COVERAGE_HTML=coverage.html +COVERAGE_DIR=coverage build: go fmt ./... @@ -9,19 +14,45 @@ build: go build -o $(BUILD_DIR)/$(BINARY_NAME) . cp plugin.yaml $(BUILD_DIR)/ -clean: +clean: coverage-clean rm -rf $(BUILD_DIR) go clean test: golangci-lint run - go test -v ./... + go test -v -coverprofile=$(COVERAGE_PROFILE) -covermode=atomic ./... test-integration: reinstall ./test-integration.sh test-all: test test-integration +coverage-report: test + @mkdir -p $(COVERAGE_DIR) + go tool cover -html=$(COVERAGE_PROFILE) -o $(COVERAGE_DIR)/$(COVERAGE_HTML) + +coverage-check: test + @echo "Checking coverage threshold (${COVERAGE_THRESHOLD}%)..." + @bash -c 'coverage=$$(go tool cover -func=$(COVERAGE_PROFILE) | tail -1 | awk "{print int(\$$3)}"); \ + if [ $$coverage -lt $(COVERAGE_THRESHOLD) ]; then \ + echo "Coverage $$coverage% is below threshold $(COVERAGE_THRESHOLD)%"; \ + exit 1; \ + else \ + echo "Coverage $$coverage% meets threshold $(COVERAGE_THRESHOLD)%"; \ + fi' + +coverage-view: coverage-report + @if command -v open > /dev/null; then \ + open $(COVERAGE_DIR)/$(COVERAGE_HTML); \ + elif command -v xdg-open > /dev/null; then \ + xdg-open $(COVERAGE_DIR)/$(COVERAGE_HTML); \ + else \ + echo "Please open $(COVERAGE_DIR)/$(COVERAGE_HTML) in your browser"; \ + fi + +coverage-clean: + rm -rf $(COVERAGE_DIR) $(COVERAGE_PROFILE) + install: build helm plugin install $(BUILD_DIR) From e5825dfe5dbad89282127c5d1320858e4f8de9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 12:44:25 +0200 Subject: [PATCH 08/13] test: improve coverage --- Makefile | 4 +- TODO.md | 2 +- internal/extractor/extractor_test.go | 124 +++++++++++++++++++++++++++ internal/kustomize/kustomize_test.go | 46 ++++++++++ internal/parser/parser_test.go | 87 +++++++++++++++++++ 5 files changed, 260 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 18d6647..ef2ba8a 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ build: go build -o $(BUILD_DIR)/$(BINARY_NAME) . cp plugin.yaml $(BUILD_DIR)/ -clean: coverage-clean +clean: coverage-clean uninstall rm -rf $(BUILD_DIR) go clean @@ -51,7 +51,7 @@ coverage-view: coverage-report fi coverage-clean: - rm -rf $(COVERAGE_DIR) $(COVERAGE_PROFILE) + rm -rf $(COVERAGE_DIR) $(COVERAGE_PROFILE) $(COVERAGE_HTML) helm-kustomize-*.tgz install: build helm plugin install $(BUILD_DIR) diff --git a/TODO.md b/TODO.md index c1fbad4..9265598 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,7 @@ ## Testing - [ ] Makefile: use custom `HELM_PLUGINS` path when testing -- [ ] Makefile: add shortcut to verify the example simple-app +- [x] Makefile: add shortcut to verify the example simple-app - [ ] Add test case: chart without KustomizePluginData (pass-through) - [ ] Add test case: malformed KustomizePluginData resource - [ ] Add test case: nested directory structures diff --git a/internal/extractor/extractor_test.go b/internal/extractor/extractor_test.go index 48b4c5b..5f7894b 100644 --- a/internal/extractor/extractor_test.go +++ b/internal/extractor/extractor_test.go @@ -301,3 +301,127 @@ func TestTempDir_ReadFile(t *testing.T) { }) } } + +func TestTempDir_Cleanup_EmptyPath(t *testing.T) { + // Test cleanup with empty path (should be a no-op) + tempDir := &TempDir{Path: ""} + tempDir.Cleanup() // Should not panic or error +} + +func TestTempDir_Cleanup_NonExistentPath(t *testing.T) { + // Test cleanup with a path that doesn't exist (should print warning but not error) + tempDir := &TempDir{Path: "/nonexistent/path/that/does/not/exist"} + tempDir.Cleanup() // Should print warning to stderr but not panic +} + +func TestTempDir_WriteFile_ReadOnlyParent(t *testing.T) { + // Test WriteFile when we can create directories but not write files + // This is challenging to test portably, but we can try creating a scenario + // where WriteFile fails but MkdirAll succeeds + tempDir, err := NewTempDir() + if err != nil { + t.Fatalf("NewTempDir() error = %v", err) + } + defer tempDir.Cleanup() + + // Create a directory first + err = tempDir.WriteFile("subdir/test.yaml", []byte("initial")) + if err != nil { + t.Fatalf("Initial WriteFile() error = %v", err) + } + + // Now make the file read-only + filePath := filepath.Join(tempDir.Path, "subdir/test.yaml") + if err := os.Chmod(filePath, 0444); err != nil { + t.Skipf("Cannot chmod file: %v", err) + } + + // Try to overwrite the read-only file - this should fail + err = tempDir.WriteFile("subdir/test.yaml", []byte("overwrite")) + if err == nil { + t.Error("WriteFile() should fail when overwriting read-only file") + } +} + +func TestTempDir_Cleanup_RemoveAllError(t *testing.T) { + // This test attempts to trigger a RemoveAll error + // We'll create a directory with a read-only file and try to remove it + tempDir, err := NewTempDir() + if err != nil { + t.Fatalf("NewTempDir() error = %v", err) + } + // Don't defer cleanup yet + + // Create a subdirectory with a file + err = tempDir.WriteFile("subdir/file.txt", []byte("content")) + if err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + // Make the subdirectory read-only (no write/execute permissions) + // This should prevent RemoveAll from deleting the contents + subdirPath := filepath.Join(tempDir.Path, "subdir") + if err := os.Chmod(subdirPath, 0444); err != nil { + // If we can't set permissions, skip this test + tempDir.Cleanup() + t.Skipf("Cannot chmod directory: %v", err) + } + + // Attempt cleanup - this should print a warning + tempDir.Cleanup() + + // Cleanup the permissions so we can actually remove the directory + if err := os.Chmod(subdirPath, 0755); err != nil { + t.Logf("Failed to restore permissions: %v", err) + } + tempDir.Cleanup() +} + +func TestNewTempDir_MkdirTempFailure(t *testing.T) { + // Test error handling when os.MkdirTemp fails + // We'll try to set TMPDIR to a read-only directory + originalTmpDir := os.Getenv("TMPDIR") + defer func() { + if originalTmpDir != "" { + if err := os.Setenv("TMPDIR", originalTmpDir); err != nil { + t.Logf("Failed to restore TMPDIR: %v", err) + } + } else { + if err := os.Unsetenv("TMPDIR"); err != nil { + t.Logf("Failed to unset TMPDIR: %v", err) + } + } + }() + + // Create a temporary read-only directory + readOnlyDir, err := os.MkdirTemp("", "readonly-*") + if err != nil { + t.Skipf("Cannot create test directory: %v", err) + } + defer func() { + if err := os.RemoveAll(readOnlyDir); err != nil { + t.Logf("Failed to remove temp directory: %v", err) + } + }() + + // Make it read-only + if err := os.Chmod(readOnlyDir, 0444); err != nil { + t.Skipf("Cannot chmod directory: %v", err) + } + defer func() { + if err := os.Chmod(readOnlyDir, 0755); err != nil { + t.Logf("Failed to restore permissions: %v", err) + } + }() + + // Set TMPDIR to the read-only directory + if err := os.Setenv("TMPDIR", readOnlyDir); err != nil { + t.Fatalf("Failed to set TMPDIR: %v", err) + } + + // This should fail + _, err = NewTempDir() + if err == nil { + t.Error("NewTempDir() should fail when TMPDIR is read-only") + } +} diff --git a/internal/kustomize/kustomize_test.go b/internal/kustomize/kustomize_test.go index d5001dd..07af934 100644 --- a/internal/kustomize/kustomize_test.go +++ b/internal/kustomize/kustomize_test.go @@ -83,6 +83,32 @@ func TestParseKustomization_InvalidYAML(t *testing.T) { } } +func TestParseKustomization_ResourcesNotArray(t *testing.T) { + input := `resources: "not an array"` + _, err := ParseKustomization([]byte(input)) + if err == nil { + t.Fatal("ParseKustomization() should return error when resources is not an array") + } + if !strings.Contains(err.Error(), "resources field must be an array") { + t.Errorf("Error should mention resources field must be an array, got: %v", err) + } +} + +func TestParseKustomization_ResourceItemNotString(t *testing.T) { + input := `resources: + - all.yaml + - 123 + - base.yaml +` + _, err := ParseKustomization([]byte(input)) + if err == nil { + t.Fatal("ParseKustomization() should return error when resource item is not a string") + } + if !strings.Contains(err.Error(), "must be a string") { + t.Errorf("Error should mention resource must be a string, got: %v", err) + } +} + func TestKustomization_AddResource(t *testing.T) { tests := []struct { name string @@ -293,3 +319,23 @@ patches: t.Errorf("EnsureAllYamlInKustomization() output =\n%s\nwant =\n%s", string(gotYAML), string(expectedYAML)) } } + +func TestEnsureAllYamlInKustomization_ParseError(t *testing.T) { + // Test that EnsureAllYamlInKustomization returns error when ParseKustomization fails + input := `resources: "not an array"` + _, _, err := EnsureAllYamlInKustomization([]byte(input)) + if err == nil { + t.Fatal("EnsureAllYamlInKustomization() should return error when ParseKustomization fails") + } +} + +func TestBuild_Error(t *testing.T) { + // Test Build with an invalid/non-existent directory + _, err := Build("/nonexistent/directory/that/does/not/exist") + if err == nil { + t.Fatal("Build() should return error for non-existent directory") + } + if !strings.Contains(err.Error(), "kubectl kustomize failed") { + t.Errorf("Error should mention kubectl kustomize failed, got: %v", err) + } +} diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 244d5f9..5c8ad20 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -1,6 +1,7 @@ package parser import ( + "fmt" "strings" "testing" ) @@ -291,3 +292,89 @@ func TestMarshalResources_Empty(t *testing.T) { t.Errorf("Expected empty output, got %d bytes", len(data)) } } + +// errorMarshaler is a type that always fails to marshal to YAML +type errorMarshaler struct{} + +func (e errorMarshaler) MarshalYAML() (interface{}, error) { + return nil, fmt.Errorf("intentional marshal error") +} + +func TestMarshalResources_EncodingError(t *testing.T) { + // Create a resource with a field that will fail to marshal + resources := []map[string]any{ + { + "apiVersion": "v1", + "kind": "Service", + "failField": errorMarshaler{}, + }, + } + + _, err := MarshalResources(resources) + if err == nil { + t.Fatal("Expected error when marshaling type with MarshalYAML error, got nil") + } + + if !strings.Contains(err.Error(), "failed to encode resource") { + t.Errorf("Expected error about encoding failure, got: %v", err) + } +} + +func TestParseManifests_KustomizePluginData_InvalidFiles(t *testing.T) { + tests := []struct { + name string + input string + wantErrSubstr string + }{ + { + name: "files field not a map", + input: `--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: "not a map" +`, + wantErrSubstr: "files' field must be a map", + }, + { + name: "files field missing", + input: `--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +`, + wantErrSubstr: "files' field must be a map", + }, + { + name: "files value is not a string", + input: `--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + test.yaml: 123 +`, + wantErrSubstr: "files' values must be strings", + }, + { + name: "files value is a map instead of string", + input: `--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + test.yaml: + nested: value +`, + wantErrSubstr: "files' values must be strings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseManifests([]byte(tt.input)) + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Errorf("Expected error containing %q, got: %v", tt.wantErrSubstr, err) + } + }) + } +} From 1463c3aef24744ea4679725b16e6a0675cb53ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 12:47:04 +0200 Subject: [PATCH 09/13] ci: skip linting as part ot `make test` in CI --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index ef2ba8a..917e099 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,9 @@ clean: coverage-clean uninstall go clean test: +ifndef GITHUB_ACTIONS golangci-lint run +endif go test -v -coverprofile=$(COVERAGE_PROFILE) -covermode=atomic ./... test-integration: reinstall From 5aba0d4b7e5ab245fdfb3c4488b50829d3fd6090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 14:04:37 +0200 Subject: [PATCH 10/13] ci: coverage artifacts --- .github/workflows/ci.yaml | 14 +++----------- .gitignore | 1 + AGENTS.md | 7 +++++-- Makefile | 29 ++++++++++++----------------- 4 files changed, 21 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6c1bb04..5eb22f5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,20 +36,12 @@ jobs: with: version: ${{ matrix.helm-version }} - - name: Run unit tests with coverage - run: make coverage-check + - name: Run all tests with coverage + run: make test-all - name: Display coverage summary if: always() - run: | - echo "=== Coverage Summary ===" - go tool cover -func=coverage.out | tail -1 | awk '{print "Overall Coverage: " $3}' - echo "" - echo "=== Coverage by Package ===" - go tool cover -func=coverage.out | grep -E "^github.com/owhelm" | grep -v "total" - - - name: Run integration tests - run: make test-integration + run: make coverage-report - name: Upload coverage report if: always() diff --git a/.gitignore b/.gitignore index 413a1f1..8d3333d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ helm-kustomize-plugin # Code coverage profiles and other test artifacts *.out +coverage/ coverage.* *.coverprofile profile.cov diff --git a/AGENTS.md b/AGENTS.md index d394647..5b4dc63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,12 @@ go test -v ./internal/parser -run TestParseManifests # Run integration tests (requires Helm v4) make test-integration -# Run all tests (unit + integration) +# Run all tests (unit + integration) with coverage check and HTML report generation make test-all +# Display coverage summary (requires coverage.out from prior test run) +make coverage-report + # Install plugin into Helm make install @@ -46,7 +49,7 @@ make uninstall # Development cycle: rebuild and reinstall make reinstall -# Clean build artifacts +# Clean build artifacts and coverage files make clean ``` diff --git a/Makefile b/Makefile index 917e099..342cca6 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ .PHONY: build clean test test-integration test-all install uninstall reinstall \ - coverage-report coverage-check coverage-view coverage-clean + coverage-report coverage-clean BINARY_NAME=helm-kustomize-plugin BUILD_DIR=dist -COVERAGE_THRESHOLD=60 +COVERAGE_THRESHOLD=70 COVERAGE_PROFILE=coverage.out COVERAGE_HTML=coverage.html COVERAGE_DIR=coverage @@ -28,12 +28,6 @@ test-integration: reinstall ./test-integration.sh test-all: test test-integration - -coverage-report: test - @mkdir -p $(COVERAGE_DIR) - go tool cover -html=$(COVERAGE_PROFILE) -o $(COVERAGE_DIR)/$(COVERAGE_HTML) - -coverage-check: test @echo "Checking coverage threshold (${COVERAGE_THRESHOLD}%)..." @bash -c 'coverage=$$(go tool cover -func=$(COVERAGE_PROFILE) | tail -1 | awk "{print int(\$$3)}"); \ if [ $$coverage -lt $(COVERAGE_THRESHOLD) ]; then \ @@ -42,15 +36,16 @@ coverage-check: test else \ echo "Coverage $$coverage% meets threshold $(COVERAGE_THRESHOLD)%"; \ fi' - -coverage-view: coverage-report - @if command -v open > /dev/null; then \ - open $(COVERAGE_DIR)/$(COVERAGE_HTML); \ - elif command -v xdg-open > /dev/null; then \ - xdg-open $(COVERAGE_DIR)/$(COVERAGE_HTML); \ - else \ - echo "Please open $(COVERAGE_DIR)/$(COVERAGE_HTML) in your browser"; \ - fi + @mkdir -p $(COVERAGE_DIR) + @go tool cover -html=$(COVERAGE_PROFILE) -o $(COVERAGE_DIR)/$(COVERAGE_HTML) + @echo "Coverage HTML report generated at $(COVERAGE_DIR)/$(COVERAGE_HTML)" + +coverage-report: + @echo "=== Coverage Summary ===" + @go tool cover -func=$(COVERAGE_PROFILE) | tail -1 | awk '{print "Overall Coverage: " $$3}' + @echo "" + @echo "=== Coverage by Package ===" + @go tool cover -func=$(COVERAGE_PROFILE) | grep -E "^github.com/owhelm" | grep -v "total" coverage-clean: rm -rf $(COVERAGE_DIR) $(COVERAGE_PROFILE) $(COVERAGE_HTML) helm-kustomize-*.tgz From c829edf997b4cb62970158b41a00ad884984b1ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 14:32:35 +0200 Subject: [PATCH 11/13] test: improve coverage --- AGENTS.md | 15 +- Makefile | 2 +- README.md | 11 +- internal/kustomize/kustomize_test.go | 25 ++- main_test.go | 236 +++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b4dc63..bb16d68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,4 +131,17 @@ files: - Unit tests use table-driven patterns with `t.Run()` subtests - Integration tests in `test-integration.sh` test the full plugin with actual Helm charts -- Example charts in `examples/` directory serve as test fixtures and documentation \ No newline at end of file +- Example charts in `examples/` directory serve as test fixtures and documentation +- **YAML Output Assertions**: When testing functions that produce YAML output, assert against the final YAML string directly rather than parsing and checking individual fields. This ensures exact output validation and catches formatting issues. + +## Kustomize API Preferences + +- **NEVER** use deprecated `commonLabels` (deprecated in Kustomize v5.3+). Use `labels` field instead: + ```yaml + labels: + - includeSelectors: true + includeTemplates: true + pairs: + app: myapp + ``` +- `commonAnnotations` is NOT deprecated and can still be used \ No newline at end of file diff --git a/Makefile b/Makefile index 342cca6..7d93a92 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BINARY_NAME=helm-kustomize-plugin BUILD_DIR=dist -COVERAGE_THRESHOLD=70 +COVERAGE_THRESHOLD=80 COVERAGE_PROFILE=coverage.out COVERAGE_HTML=coverage.html COVERAGE_DIR=coverage diff --git a/README.md b/README.md index 809b832..e7de0b9 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,13 @@ files: resources: - all.yaml - commonLabels: - team: platform - cost-center: engineering - compliance: pci + labels: + - includeSelectors: true + includeTemplates: true + pairs: + team: platform + cost-center: engineering + compliance: pci commonAnnotations: managed-by: platform-team diff --git a/internal/kustomize/kustomize_test.go b/internal/kustomize/kustomize_test.go index 07af934..da5698a 100644 --- a/internal/kustomize/kustomize_test.go +++ b/internal/kustomize/kustomize_test.go @@ -46,8 +46,11 @@ patches: kind: Kustomization resources: - base.yaml -commonLabels: - app: myapp +labels: +- includeSelectors: true + includeTemplates: true + pairs: + app: myapp `, wantRes: []string{"base.yaml"}, wantErr: false, @@ -276,9 +279,12 @@ func TestEnsureAllYamlInKustomization_PreservesOtherFields(t *testing.T) { kind: Kustomization resources: - base.yaml -commonLabels: - app: myapp - version: v1 +labels: +- includeSelectors: true + includeTemplates: true + pairs: + app: myapp + version: v1 patches: - path: patch.yaml ` @@ -297,9 +303,12 @@ kind: Kustomization resources: - base.yaml - all.yaml -commonLabels: - app: myapp - version: v1 +labels: + - includeSelectors: true + includeTemplates: true + pairs: + app: myapp + version: v1 patches: - path: patch.yaml ` diff --git a/main_test.go b/main_test.go index c0c730c..9772a04 100644 --- a/main_test.go +++ b/main_test.go @@ -61,3 +61,239 @@ func TestKustomizePostRenderer_Run_EmptyInput(t *testing.T) { t.Errorf("Expected empty output, got %d bytes", output.Len()) } } + +func TestKustomizePostRenderer_Run_ReservedAllYamlFilename(t *testing.T) { + // Test that using the reserved filename "all.yaml" returns an error + input := bytes.NewBufferString(`--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + all.yaml: | + some content + kustomization.yaml: | + resources: + - all.yaml +`) + + renderer := &KustomizePostRenderer{} + _, err := renderer.Run(input) + if err == nil { + t.Fatal("Expected error for reserved 'all.yaml' filename, got nil") + } + if !strings.Contains(err.Error(), "all.yaml") || !strings.Contains(err.Error(), "reserved") { + t.Errorf("Expected error message about reserved 'all.yaml', got: %v", err) + } +} + +func TestKustomizePostRenderer_Run_SuccessfulTransformation(t *testing.T) { + // Test successful kustomize transformation with KustomizePluginData + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap +data: + key: value +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - all.yaml + labels: + - includeSelectors: true + includeTemplates: true + pairs: + app: test-app +`) + + renderer := &KustomizePostRenderer{} + output, err := renderer.Run(input) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + expected := `apiVersion: v1 +data: + key: value +kind: ConfigMap +metadata: + labels: + app: test-app + name: test-configmap +` + + if output.String() != expected { + t.Errorf("Output mismatch.\nExpected:\n%s\nGot:\n%s", expected, output.String()) + } +} + +func TestKustomizePostRenderer_Run_KustomizationYamlUpdated(t *testing.T) { + // Test that kustomization.yaml is updated to include all.yaml if it's missing + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +spec: + ports: + - port: 80 +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + namespace: test-namespace +`) + + renderer := &KustomizePostRenderer{} + output, err := renderer.Run(input) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + expected := `apiVersion: v1 +kind: Service +metadata: + name: test-service + namespace: test-namespace +spec: + ports: + - port: 80 +` + + if output.String() != expected { + t.Errorf("Output mismatch.\nExpected:\n%s\nGot:\n%s", expected, output.String()) + } +} + +func TestKustomizePostRenderer_Run_WithPatches(t *testing.T) { + // Test kustomize transformation with patches + input := bytes.NewBufferString(`--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment +spec: + replicas: 1 +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - all.yaml + patches: + - patch: |- + - op: replace + path: /spec/replicas + value: 3 + target: + kind: Deployment + name: test-deployment +`) + + renderer := &KustomizePostRenderer{} + output, err := renderer.Run(input) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + expected := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment +spec: + replicas: 3 +` + + if output.String() != expected { + t.Errorf("Output mismatch.\nExpected:\n%s\nGot:\n%s", expected, output.String()) + } +} + +func TestKustomizePostRenderer_Run_MultipleResources(t *testing.T) { + // Test with multiple resources and kustomize transformations + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: Service +metadata: + name: my-service +spec: + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deployment +spec: + replicas: 2 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-config +data: + config: value +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - all.yaml + commonAnnotations: + managed-by: helm-kustomize-plugin +`) + + renderer := &KustomizePostRenderer{} + output, err := renderer.Run(input) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + expected := `apiVersion: v1 +data: + config: value +kind: ConfigMap +metadata: + annotations: + managed-by: helm-kustomize-plugin + name: my-config +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + managed-by: helm-kustomize-plugin + name: my-service +spec: + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + managed-by: helm-kustomize-plugin + name: my-deployment +spec: + replicas: 2 + template: + metadata: + annotations: + managed-by: helm-kustomize-plugin +` + + if output.String() != expected { + t.Errorf("Output mismatch.\nExpected:\n%s\nGot:\n%s", expected, output.String()) + } +} From e3f4fdbe054c2a0ebcab6c26c3cb23e14c71f1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 15:03:32 +0200 Subject: [PATCH 12/13] test: improve coverage --- Makefile | 2 +- internal/kustomize/kustomize_test.go | 26 +++++ main_test.go | 148 +++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7d93a92..e621e13 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ test: ifndef GITHUB_ACTIONS golangci-lint run endif - go test -v -coverprofile=$(COVERAGE_PROFILE) -covermode=atomic ./... + go test -v -coverpkg=./... -coverprofile=$(COVERAGE_PROFILE) -covermode=atomic ./... test-integration: reinstall ./test-integration.sh diff --git a/internal/kustomize/kustomize_test.go b/internal/kustomize/kustomize_test.go index da5698a..9df748b 100644 --- a/internal/kustomize/kustomize_test.go +++ b/internal/kustomize/kustomize_test.go @@ -1,6 +1,7 @@ package kustomize import ( + "os" "slices" "strings" "testing" @@ -348,3 +349,28 @@ func TestBuild_Error(t *testing.T) { t.Errorf("Error should mention kubectl kustomize failed, got: %v", err) } } + +func TestBuild_InvalidKustomizationYaml(t *testing.T) { + // Test Build with an invalid kustomization.yaml file + // This tests the kubectl kustomize execution failure path + tempDir := t.TempDir() + + // Create an invalid kustomization.yaml that references a non-existent file + kustomizationContent := []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - nonexistent-file.yaml +`) + + if err := os.WriteFile(tempDir+"/kustomization.yaml", kustomizationContent, 0644); err != nil { + t.Fatalf("Failed to write kustomization.yaml: %v", err) + } + + _, err := Build(tempDir) + if err == nil { + t.Fatal("Build() should return error for invalid kustomization") + } + if !strings.Contains(err.Error(), "kubectl kustomize failed") { + t.Errorf("Error should mention kubectl kustomize failed, got: %v", err) + } +} diff --git a/main_test.go b/main_test.go index 9772a04..f701c0b 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "os" "strings" "testing" ) @@ -297,3 +298,150 @@ spec: t.Errorf("Output mismatch.\nExpected:\n%s\nGot:\n%s", expected, output.String()) } } + +func TestKustomizePostRenderer_Run_ExtractFilesInvalidPath(t *testing.T) { + // Test that directory traversal in file paths is rejected + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + ../../../etc/passwd: | + malicious content +`) + + renderer := &KustomizePostRenderer{} + _, err := renderer.Run(input) + if err == nil { + t.Fatal("Expected error for directory traversal attempt, got nil") + } + if !strings.Contains(err.Error(), "failed to extract files") { + t.Errorf("Expected error message about failed to extract files, got: %v", err) + } +} + +func TestKustomizePostRenderer_Run_InvalidKustomizationResources(t *testing.T) { + // Test that invalid kustomization.yaml (resources not array) returns error + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + resources: "not an array" +`) + + renderer := &KustomizePostRenderer{} + _, err := renderer.Run(input) + if err == nil { + t.Fatal("Expected error for invalid kustomization.yaml, got nil") + } + if !strings.Contains(err.Error(), "failed to update kustomization.yaml") { + t.Errorf("Expected error message about failed to update kustomization.yaml, got: %v", err) + } +} + +func TestKustomizePostRenderer_Run_InvalidKustomizationYaml(t *testing.T) { + // Test that invalid kustomization.yaml causes kubectl kustomize to fail + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - all.yaml + - nonexistent-file.yaml +`) + + renderer := &KustomizePostRenderer{} + _, err := renderer.Run(input) + if err == nil { + t.Fatal("Expected error for invalid kustomization (missing resource file), got nil") + } + if !strings.Contains(err.Error(), "failed to run kustomize") { + t.Errorf("Expected error message about failed to run kustomize, got: %v", err) + } +} + +func TestKustomizePostRenderer_Run_TempDirCreationFailure(t *testing.T) { + // Test that NewTempDir() failure is handled correctly + // We'll manipulate TMPDIR to point to a read-only directory + originalTmpDir := os.Getenv("TMPDIR") + defer func() { + if originalTmpDir != "" { + if err := os.Setenv("TMPDIR", originalTmpDir); err != nil { + t.Logf("Failed to restore TMPDIR: %v", err) + } + } else { + if err := os.Unsetenv("TMPDIR"); err != nil { + t.Logf("Failed to unset TMPDIR: %v", err) + } + } + }() + + // Create a temporary read-only directory + readOnlyDir, err := os.MkdirTemp("", "readonly-*") + if err != nil { + t.Skipf("Cannot create test directory: %v", err) + } + defer func() { + // Restore permissions before cleanup + if err := os.Chmod(readOnlyDir, 0755); err != nil { + t.Logf("Failed to restore permissions: %v", err) + } + if err := os.RemoveAll(readOnlyDir); err != nil { + t.Logf("Failed to remove temp directory: %v", err) + } + }() + + // Make it read-only + if err := os.Chmod(readOnlyDir, 0444); err != nil { + t.Skipf("Cannot chmod directory: %v", err) + } + + // Set TMPDIR to the read-only directory + if err := os.Setenv("TMPDIR", readOnlyDir); err != nil { + t.Fatalf("Failed to set TMPDIR: %v", err) + } + + // Prepare input with KustomizePluginData + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - all.yaml +`) + + renderer := &KustomizePostRenderer{} + _, err = renderer.Run(input) + if err == nil { + t.Fatal("Expected error when temp directory creation fails, got nil") + } + if !strings.Contains(err.Error(), "failed to create temp directory") { + t.Errorf("Expected error message about failed to create temp directory, got: %v", err) + } +} From b3e91a23b3cf882422774e7ae9e684fc4fe4150e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Sun, 4 Jan 2026 15:55:00 +0200 Subject: [PATCH 13/13] test: improve coverage --- TODO.md | 33 +------------ main_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 125 insertions(+), 38 deletions(-) diff --git a/TODO.md b/TODO.md index 9265598..7c23e3f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,30 +1,10 @@ # TODO List - Helm Kustomize Plugin -## Error Handling & Edge Cases - -- [ ] Handle invalid or malformed special resources with helpful error messages -- [ ] Improve kustomize execution error messages -- [ ] Add timeout handling for kustomize operations -- [ ] Test error cases: empty KustomizePluginData, missing kustomization.yaml -- [ ] Test error cases: kustomize build failures with clear output -- [ ] Add structured error messages with context -- [ ] Validate KustomizePluginData structure before processing -- [ ] Add error recovery suggestions in messages -- [ ] Test all error paths - ## Testing - [ ] Makefile: use custom `HELM_PLUGINS` path when testing -- [x] Makefile: add shortcut to verify the example simple-app -- [ ] Add test case: chart without KustomizePluginData (pass-through) -- [ ] Add test case: malformed KustomizePluginData resource -- [ ] Add test case: nested directory structures - [ ] 100% coverage -- [ ] Add more edge case tests -- [ ] Add performance tests for large charts -- [ ] Test cleanup on error conditions -- [ ] Add test for concurrent plugin usage -- [ ] Document test strategy +- [ ] Test in multiple kubectl versions (broken due to https://github.com/Azure/setup-kubectl/issues/88) ## Documentation @@ -33,17 +13,6 @@ - [ ] Installation steps - [ ] Basic usage example - [ ] Link to examples -- [ ] Add development guide - - [ ] How to build from source - - [ ] How to run tests - - [ ] How to contribute - -## Examples & Samples - -- [ ] Add example: multiple patches -- [ ] Add example: labels (not commonLabels, which is deprecated!) usage -- [ ] Add example: image transformations -- [ ] Add example: modify all resources of certain `Kind` with annotations ## Build & Distribution diff --git a/main_test.go b/main_test.go index f701c0b..c1df3d9 100644 --- a/main_test.go +++ b/main_test.go @@ -27,12 +27,8 @@ metadata: t.Fatalf("Run() error = %v, want nil", err) } - outputStr := output.String() - if !strings.Contains(outputStr, "Service") { - t.Error("Expected Service in output") - } - if !strings.Contains(outputStr, "Deployment") { - t.Error("Expected Deployment in output") + if output.String() != input.String() { + t.Fatalf("Run() output = %v, want %v", output, input) } } @@ -445,3 +441,125 @@ files: t.Errorf("Expected error message about failed to create temp directory, got: %v", err) } } + +func TestKustomizePostRenderer_Run_NoFilesSection(t *testing.T) { + // Test that KustomizePluginData without files section returns an error + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +`) + + renderer := &KustomizePostRenderer{} + _, err := renderer.Run(input) + if err == nil { + t.Fatal("Expected error for KustomizePluginData without files section, got nil") + } + if !strings.Contains(err.Error(), "failed to parse input") && !strings.Contains(err.Error(), "'files' field must be a map") { + t.Errorf("Expected error message about files field, got: %v", err) + } +} + +func TestKustomizePostRenderer_Run_NoKustomizationYaml(t *testing.T) { + // Test that files section without kustomization.yaml returns an error + input := bytes.NewBufferString(`--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + patch.yaml: | + apiVersion: v1 + kind: Service + metadata: + name: test-service + labels: + patched: "true" +`) + + renderer := &KustomizePostRenderer{} + _, err := renderer.Run(input) + if err == nil { + t.Fatal("Expected error for files without kustomization.yaml, got nil") + } + if !strings.Contains(err.Error(), "failed to run kustomize") && !strings.Contains(err.Error(), "unable to find") { + t.Errorf("Expected error message about missing kustomization.yaml, got: %v", err) + } +} + +func TestKustomizePostRenderer_Run_NestedPaths(t *testing.T) { + // Test that files with nested directory paths are handled correctly + input := bytes.NewBufferString(`--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment +spec: + replicas: 1 + selector: + matchLabels: + app: test + template: + metadata: + labels: + app: test + spec: + containers: + - name: test + image: nginx:latest +--- +apiVersion: helm.plugin.kustomize/v1 +kind: KustomizePluginData +files: + kustomization.yaml: | + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - all.yaml + patches: + - path: patches/replica-patch.yaml + patches/replica-patch.yaml: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: test-deployment + spec: + replicas: 5 +`) + + renderer := &KustomizePostRenderer{} + output, err := renderer.Run(input) + if err != nil { + t.Fatalf("Run() error = %v, want nil", err) + } + + expected := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment +spec: + replicas: 5 + selector: + matchLabels: + app: test + template: + metadata: + labels: + app: test + spec: + containers: + - image: nginx:latest + name: test +` + + if output.String() != expected { + t.Errorf("Output mismatch.\nExpected:\n%s\nGot:\n%s", expected, output.String()) + } +}