diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..5eb22f5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + 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 + + - 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: ${{ matrix.kubectl-version }} + + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: ${{ matrix.helm-version }} + + - name: Run all tests with coverage + run: make test-all + + - name: Display coverage summary + if: always() + run: make coverage-report + + - 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 + 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/.gitignore b/.gitignore index bdf71ae..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 @@ -31,6 +32,8 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ .claude +dist +helm-kustomize-*.tgz diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb16d68 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,147 @@ +# 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 (automatically runs go fmt) +make build + +# 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 + +# Run integration tests (requires Helm v4) +make test-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 + +# Uninstall plugin from Helm +make uninstall + +# Development cycle: rebuild and reinstall +make reinstall + +# Clean build artifacts and coverage files +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 +- **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 88a8dce..e621e13 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,60 @@ -.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-clean BINARY_NAME=helm-kustomize-plugin -BUILD_DIR=. +BUILD_DIR=dist +COVERAGE_THRESHOLD=80 +COVERAGE_PROFILE=coverage.out +COVERAGE_HTML=coverage.html +COVERAGE_DIR=coverage build: + go fmt ./... + mkdir -p $(BUILD_DIR) go build -o $(BUILD_DIR)/$(BINARY_NAME) . + cp plugin.yaml $(BUILD_DIR)/ -clean: - rm -f $(BUILD_DIR)/$(BINARY_NAME) +clean: coverage-clean uninstall + rm -rf $(BUILD_DIR) go clean test: - go test -v ./... +ifndef GITHUB_ACTIONS + golangci-lint run +endif + go test -v -coverpkg=./... -coverprofile=$(COVERAGE_PROFILE) -covermode=atomic ./... test-integration: reinstall ./test-integration.sh test-all: test test-integration + @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' + @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 install: build - helm plugin install . + helm plugin install $(BUILD_DIR) uninstall: - helm plugin uninstall kustomize + helm plugin uninstall helm-kustomize 2>/dev/null || true # Development: uninstall, rebuild, and reinstall -reinstall: uninstall build install \ No newline at end of file +reinstall: uninstall build install 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/TODO.md b/TODO.md index 99acead..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 -- [ ] 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,26 +13,17 @@ - [ ] 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 -- [ ] Set up CI/CD pipeline +- [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 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..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" @@ -46,8 +47,11 @@ patches: kind: Kustomization resources: - base.yaml -commonLabels: - app: myapp +labels: +- includeSelectors: true + includeTemplates: true + pairs: + app: myapp `, wantRes: []string{"base.yaml"}, wantErr: false, @@ -83,6 +87,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 @@ -250,9 +280,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 ` @@ -271,9 +304,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 ` @@ -293,3 +329,48 @@ 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) + } +} + +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/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) + } + }) + } +} 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) diff --git a/main_test.go b/main_test.go index c0c730c..c1df3d9 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "os" "strings" "testing" ) @@ -26,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) } } @@ -61,3 +58,508 @@ 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()) + } +} + +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) + } +} + +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()) + } +} 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