diff --git a/kubectl_kustomize_test.go b/kubectl_kustomize_test.go new file mode 100644 index 0000000..fb16b84 --- /dev/null +++ b/kubectl_kustomize_test.go @@ -0,0 +1,164 @@ +package chartify + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// stubKubectlScript is a minimal sh script that acts as a kubectl stub for tests. +// It handles `kubectl kustomize [-o|-o FILE|--output FILE]` by writing +// a minimal valid Kubernetes Deployment YAML to the specified output file. +const stubKubectlScript = `#!/bin/sh +if [ "$1" = "kustomize" ]; then + shift + OUTPUT="" + while [ $# -gt 0 ]; do + case "$1" in + -o) OUTPUT="$2"; shift 2;; + --output) OUTPUT="$2"; shift 2;; + *) shift;; + esac + done + if [ -n "$OUTPUT" ]; then + printf 'apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: stub\n' > "$OUTPUT" + fi +fi +` + +// writeStubKubectl creates a stub kubectl script in dir. +func writeStubKubectl(t *testing.T, dir string) { + t.Helper() + p := filepath.Join(dir, "kubectl") + require.NoError(t, os.WriteFile(p, []byte(stubKubectlScript), 0755)) +} + +// TestKubectlKustomize tests behavior when kubectl kustomize is explicitly configured +// via KustomizeBin("kubectl kustomize"). The automatic fallback selection is tested +// in TestKustomizeBin. +func TestKubectlKustomize(t *testing.T) { + t.Run("KustomizeBuild succeeds with kubectl kustomize option", func(t *testing.T) { + // Create a stub kubectl so the test is self-contained and always runs in CI. + stubDir := t.TempDir() + writeStubKubectl(t, stubDir) + + origPath := os.Getenv("PATH") + defer os.Setenv("PATH", origPath) + os.Setenv("PATH", stubDir+string(os.PathListSeparator)+origPath) + + tmpDir := t.TempDir() + srcDir := t.TempDir() + + kustomizationContent := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- deployment.yaml +` + deploymentContent := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: test +spec: + replicas: 1 + selector: + matchLabels: + app: test + template: + metadata: + labels: + app: test + spec: + containers: + - name: test + image: test:latest +` + + templatesDir := filepath.Join(tmpDir, "templates") + require.NoError(t, os.MkdirAll(templatesDir, 0755)) + + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "kustomization.yaml"), []byte(kustomizationContent), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "deployment.yaml"), []byte(deploymentContent), 0644)) + + r := New(KustomizeBin("kubectl kustomize")) + + outputFile, err := r.KustomizeBuild(srcDir, tmpDir) + require.NoError(t, err) + require.FileExists(t, outputFile) + }) + + t.Run("Patch succeeds with kubectl kustomize option", func(t *testing.T) { + // Create a stub kubectl so the test is self-contained and always runs in CI. + stubDir := t.TempDir() + writeStubKubectl(t, stubDir) + + origPath := os.Getenv("PATH") + defer os.Setenv("PATH", origPath) + os.Setenv("PATH", stubDir+string(os.PathListSeparator)+origPath) + + tempDir := t.TempDir() + + // Write a minimal manifest file that Patch() will reference. + manifestPath := filepath.Join(tempDir, "templates", "deploy.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(manifestPath), 0755)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: test +`), 0644)) + + r := New(KustomizeBin("kubectl kustomize")) + err := r.Patch(tempDir, []string{manifestPath}, &PatchOpts{}) + require.NoError(t, err) + }) + + t.Run("edit commands not supported with kubectl kustomize", func(t *testing.T) { + tmpDir := t.TempDir() + srcDir := t.TempDir() + + kustomizationContent := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- deployment.yaml +` + deploymentContent := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: test +spec: + replicas: 1 + selector: + matchLabels: + app: test + template: + metadata: + labels: + app: test + spec: + containers: + - name: test + image: test:latest +` + + templatesDir := filepath.Join(tmpDir, "templates") + valuesDir := t.TempDir() + valuesFile := filepath.Join(valuesDir, "values.yaml") + valuesContent := `images: +- name: test + newName: newtest + newTag: v2 +` + + require.NoError(t, os.MkdirAll(templatesDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "kustomization.yaml"), []byte(kustomizationContent), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "deployment.yaml"), []byte(deploymentContent), 0644)) + require.NoError(t, os.WriteFile(valuesFile, []byte(valuesContent), 0644)) + + r := New(KustomizeBin("kubectl kustomize")) + + _, err := r.KustomizeBuild(srcDir, tmpDir, &KustomizeBuildOpts{ValuesFiles: []string{valuesFile}}) + require.Error(t, err) + require.Contains(t, err.Error(), "setting images via Chartify values files or kustomize build options is not supported when using 'kubectl kustomize'") + }) +} diff --git a/kustomize.go b/kustomize.go index f81f3fd..5a92256 100644 --- a/kustomize.go +++ b/kustomize.go @@ -84,6 +84,10 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize panic("--set is not yet supported for kustomize-based apps! Use -f/--values flag instead.") } + // Resolve the kustomize binary once so PATH lookups are not repeated for every check. + bin := r.kustomizeBin() + usingKubectl := bin == "kubectl kustomize" + prevDir, err := os.Getwd() if err != nil { return "", err @@ -110,46 +114,62 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize } if len(kustomizeOpts.Images) > 0 { + if usingKubectl { + return "", fmt.Errorf("setting images via Chartify values files or kustomize build options is not supported when using 'kubectl kustomize'. Please set images directly in your kustomization.yaml file") + } args := []string{"edit", "set", "image"} for _, image := range kustomizeOpts.Images { args = append(args, image.String()) } - _, err := r.runInDir(tempDir, r.kustomizeBin(), args...) + _, err := r.runInDir(tempDir, bin, args...) if err != nil { return "", err } } if kustomizeOpts.NamePrefix != "" { - _, err := r.runInDir(tempDir, r.kustomizeBin(), "edit", "set", "nameprefix", kustomizeOpts.NamePrefix) + if usingKubectl { + return "", fmt.Errorf("setting namePrefix via Chartify values files or kustomize build options is not supported when using 'kubectl kustomize'. Please set namePrefix directly in your kustomization.yaml file") + } + _, err := r.runInDir(tempDir, bin, "edit", "set", "nameprefix", kustomizeOpts.NamePrefix) if err != nil { fmt.Println(err) return "", err } } if kustomizeOpts.NameSuffix != "" { + if usingKubectl { + return "", fmt.Errorf("setting nameSuffix via Chartify values files or kustomize build options is not supported when using 'kubectl kustomize'. Please set nameSuffix directly in your kustomization.yaml file") + } // "--" is there to avoid `namesuffix -acme` to fail due to `-a` being considered as a flag - _, err := r.runInDir(tempDir, r.kustomizeBin(), "edit", "set", "namesuffix", "--", kustomizeOpts.NameSuffix) + _, err := r.runInDir(tempDir, bin, "edit", "set", "namesuffix", "--", kustomizeOpts.NameSuffix) if err != nil { return "", err } } if kustomizeOpts.Namespace != "" { - _, err := r.runInDir(tempDir, r.kustomizeBin(), "edit", "set", "namespace", kustomizeOpts.Namespace) + if usingKubectl { + return "", fmt.Errorf("setting namespace via Chartify values files or kustomize build options is not supported when using 'kubectl kustomize'. Please set namespace directly in your kustomization.yaml file") + } + _, err := r.runInDir(tempDir, bin, "edit", "set", "namespace", kustomizeOpts.Namespace) if err != nil { return "", err } } outputFile := filepath.Join(tempDir, "templates", "kustomized.yaml") - kustomizeArgs := []string{"-o", outputFile, "build"} + kustomizeArgs := []string{"-o", outputFile} + + if !usingKubectl { + kustomizeArgs = append(kustomizeArgs, "build") + } if u.EnableAlphaPlugins { - f, err := r.kustomizeEnableAlphaPluginsFlag() + f, err := r.kustomizeEnableAlphaPluginsFlag(usingKubectl) if err != nil { return "", err } kustomizeArgs = append(kustomizeArgs, f) } - f, err := r.kustomizeLoadRestrictionsNoneFlag() + f, err := r.kustomizeLoadRestrictionsNoneFlag(usingKubectl) if err != nil { return "", err } @@ -159,7 +179,7 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize kustomizeArgs = append(kustomizeArgs, "--helm-command="+u.HelmBinary) } - out, err := r.runInDir(tempDir, r.kustomizeBin(), append(kustomizeArgs, tempDir)...) + out, err := r.runInDir(tempDir, bin, append(kustomizeArgs, tempDir)...) if err != nil { return "", err } @@ -193,7 +213,10 @@ func (r *Runner) kustomizeVersion() (*semver.Version, error) { // kustomizeEnableAlphaPluginsFlag returns the kustomize binary alpha plugin argument. // Above Kustomize v3, it is `--enable-alpha-plugins`. // Below Kustomize v3 (including v3), it is `--enable_alpha_plugins`. -func (r *Runner) kustomizeEnableAlphaPluginsFlag() (string, error) { +func (r *Runner) kustomizeEnableAlphaPluginsFlag(usingKubectl bool) (string, error) { + if usingKubectl { + return "--enable-alpha-plugins", nil + } version, err := r.kustomizeVersion() if err != nil { return "", err @@ -208,7 +231,10 @@ func (r *Runner) kustomizeEnableAlphaPluginsFlag() (string, error) { // the root argument. // Above Kustomize v3, it is `--load-restrictor=LoadRestrictionsNone`. // Below Kustomize v3 (including v3), it is `--load_restrictor=none`. -func (r *Runner) kustomizeLoadRestrictionsNoneFlag() (string, error) { +func (r *Runner) kustomizeLoadRestrictionsNoneFlag(usingKubectl bool) (string, error) { + if usingKubectl { + return "--load-restrictor=LoadRestrictionsNone", nil + } version, err := r.kustomizeVersion() if err != nil { return "", err diff --git a/patch.go b/patch.go index e41c2eb..0dbebdf 100644 --- a/patch.go +++ b/patch.go @@ -43,6 +43,10 @@ func (r *Runner) Patch(tempDir string, generatedManifestFiles []string, opts ... } } + // Resolve the kustomize binary once so PATH lookups are not repeated for every check. + bin := r.kustomizeBin() + usingKubectl := bin == "kubectl kustomize" + r.Logf("patching files: %v", generatedManifestFiles) // Detect if CRDs originally came from templates/ directory @@ -181,19 +185,26 @@ resources: renderedFileName := "all.patched.yaml" renderedFile := filepath.Join(tempDir, renderedFileName) - r.Logf("Generating %s", renderedFile) + r.Logf("Generating %s", renderedFileName) + + kustomizeArgs := []string{"--output", renderedFile} - kustomizeArgs := []string{"build", tempDir, "--output", renderedFile} + if !usingKubectl { + kustomizeArgs = append([]string{"build", tempDir}, kustomizeArgs...) + } else { + // kubectl kustomize does not use the "build" subcommand; pass tempDir as the target directly. + kustomizeArgs = append([]string{tempDir}, kustomizeArgs...) + } if u.EnableAlphaPlugins { - f, err := r.kustomizeEnableAlphaPluginsFlag() + f, err := r.kustomizeEnableAlphaPluginsFlag(usingKubectl) if err != nil { return err } kustomizeArgs = append(kustomizeArgs, f) } - _, err := r.run(nil, r.kustomizeBin(), kustomizeArgs...) + _, err := r.run(nil, bin, kustomizeArgs...) if err != nil { return err } diff --git a/runner.go b/runner.go index 61eb0ca..8bf0827 100644 --- a/runner.go +++ b/runner.go @@ -108,6 +108,15 @@ func (r *Runner) kustomizeBin() string { if r.KustomizeBinary != "" { return r.KustomizeBinary } + if env := os.Getenv("KUSTOMIZE_BIN"); env != "" { + return env + } + if _, err := exec.LookPath("kustomize"); err == nil { + return "kustomize" + } + if _, err := exec.LookPath("kubectl"); err == nil { + return "kubectl kustomize" + } return "kustomize" } @@ -140,7 +149,7 @@ func (r *Runner) runBytes(envs map[string]string, dir, cmd string, args ...strin name := nameArgs[0] - if len(nameArgs) > 2 { + if len(nameArgs) > 1 { a := append([]string{}, nameArgs[1:]...) a = append(a, args...) diff --git a/util_test.go b/util_test.go index 6a944c4..92550fa 100644 --- a/util_test.go +++ b/util_test.go @@ -1,9 +1,12 @@ package chartify import ( + "os" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" ) func TestCreateFlagChain(t *testing.T) { @@ -107,3 +110,122 @@ func TestFindSemVerInfo(t *testing.T) { }) } } + +func TestKustomizeBin(t *testing.T) { + t.Run("KustomizeBinary option is set", func(t *testing.T) { + r := New(KustomizeBin("/custom/kustomize")) + got := r.kustomizeBin() + want := "/custom/kustomize" + if got != want { + t.Errorf("kustomizeBin() = %v, want %v", got, want) + } + }) + + t.Run("KUSTOMIZE_BIN environment variable", func(t *testing.T) { + origVal, hadVal := os.LookupEnv("KUSTOMIZE_BIN") + defer func() { + if hadVal { + os.Setenv("KUSTOMIZE_BIN", origVal) + } else { + os.Unsetenv("KUSTOMIZE_BIN") + } + }() + os.Setenv("KUSTOMIZE_BIN", "/custom/kustomize") + r := New() + got := r.kustomizeBin() + want := "/custom/kustomize" + if got != want { + t.Errorf("kustomizeBin() = %v, want %v", got, want) + } + }) + + t.Run("fallback to kubectl kustomize when kustomize not found", func(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + require.NoError(t, os.MkdirAll(binDir, 0755)) + + kubectlPath := filepath.Join(binDir, "kubectl") + kubectlContent := []byte("#!/bin/sh\necho 'kubectl version'\n") + require.NoError(t, os.WriteFile(kubectlPath, kubectlContent, 0755)) + + origPath := os.Getenv("PATH") + defer os.Setenv("PATH", origPath) + os.Setenv("PATH", binDir) + + // Ensure KUSTOMIZE_BIN does not override PATH-based lookup. + origKustomizeBin, hadKustomizeBin := os.LookupEnv("KUSTOMIZE_BIN") + defer func() { + if hadKustomizeBin { + os.Setenv("KUSTOMIZE_BIN", origKustomizeBin) + } + }() + os.Unsetenv("KUSTOMIZE_BIN") + + r := New() + got := r.kustomizeBin() + want := "kubectl kustomize" + if got != want { + t.Errorf("kustomizeBin() = %v, want %v", got, want) + } + }) + + t.Run("use kustomize when both kustomize and kubectl exist in PATH", func(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + require.NoError(t, os.MkdirAll(binDir, 0755)) + + kustomizePath := filepath.Join(binDir, "kustomize") + kustomizeContent := []byte("#!/bin/sh\necho 'kustomize version'\n") + require.NoError(t, os.WriteFile(kustomizePath, kustomizeContent, 0755)) + + kubectlPath := filepath.Join(binDir, "kubectl") + kubectlContent := []byte("#!/bin/sh\necho 'kubectl version'\n") + require.NoError(t, os.WriteFile(kubectlPath, kubectlContent, 0755)) + + origPath := os.Getenv("PATH") + defer os.Setenv("PATH", origPath) + os.Setenv("PATH", binDir) + + // Ensure KUSTOMIZE_BIN does not override PATH-based lookup. + origKustomizeBin, hadKustomizeBin := os.LookupEnv("KUSTOMIZE_BIN") + defer func() { + if hadKustomizeBin { + os.Setenv("KUSTOMIZE_BIN", origKustomizeBin) + } + }() + os.Unsetenv("KUSTOMIZE_BIN") + + r := New() + got := r.kustomizeBin() + want := "kustomize" + if got != want { + t.Errorf("kustomizeBin() = %v, want %v", got, want) + } + }) + + t.Run("return kustomize as fallback when neither kustomize nor kubectl exist", func(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + require.NoError(t, os.MkdirAll(binDir, 0755)) + + origPath := os.Getenv("PATH") + defer os.Setenv("PATH", origPath) + os.Setenv("PATH", binDir) + + // Ensure KUSTOMIZE_BIN does not override PATH-based lookup. + origKustomizeBin, hadKustomizeBin := os.LookupEnv("KUSTOMIZE_BIN") + defer func() { + if hadKustomizeBin { + os.Setenv("KUSTOMIZE_BIN", origKustomizeBin) + } + }() + os.Unsetenv("KUSTOMIZE_BIN") + + r := New() + got := r.kustomizeBin() + want := "kustomize" + if got != want { + t.Errorf("kustomizeBin() = %v, want %v", got, want) + } + }) +}