From bd6474c344b4047fd828922a5c5968f162ef20ad Mon Sep 17 00:00:00 2001 From: yxxhero Date: Sat, 24 Jan 2026 14:00:27 +0800 Subject: [PATCH 1/6] feat: Allow using kubectl built-in kustomize when separate kustomize binary is missing Implements #63: Automatically fallback to kubectl's built-in kustomize when the standalone kustomize binary is not available. Changes: - Modified kustomizeBin() to detect and use kubectl kustomize as fallback - Fixed runBytes() to properly handle commands with subcommands like 'kubectl kustomize' - Added isUsingKubectlKustomize() helper to detect when using kubectl kustomize - Updated kustomizeEnableAlphaPluginsFlag() and kustomizeLoadRestrictionsNoneFlag() to skip version detection when using kubectl kustomize - Added clear error messages for unsupported features (edit subcommands) when using kubectl kustomize - Modified KustomizeBuild() to omit 'build' argument when using kubectl kustomize - Modified patch.go to omit 'build' argument and tempDir when using kubectl kustomize - Added TestKustomizeBin test to verify fallback behavior - Added TestKubectlKustomizeFallback integration test Signed-off-by: yxxhero --- kubectl_kustomize_test.go | 57 +++++++++++++++++++++++++++++++++++++++ kustomize.go | 29 +++++++++++++++++++- patch.go | 8 ++++-- runner.go | 12 ++++++--- util_test.go | 57 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 kubectl_kustomize_test.go diff --git a/kubectl_kustomize_test.go b/kubectl_kustomize_test.go new file mode 100644 index 0000000..bf0e9a4 --- /dev/null +++ b/kubectl_kustomize_test.go @@ -0,0 +1,57 @@ +package chartify + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestKubectlKustomizeFallback(t *testing.T) { + if _, err := exec.LookPath("kubectl"); err != nil { + t.Skip("kubectl binary not found in PATH") + } + + t.Run("KustomizeBuild 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") + 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) + }) +} diff --git a/kustomize.go b/kustomize.go index f81f3fd..eb1c878 100644 --- a/kustomize.go +++ b/kustomize.go @@ -110,6 +110,9 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize } if len(kustomizeOpts.Images) > 0 { + if r.isUsingKubectlKustomize() { + return "", fmt.Errorf("setting images via kustomizeOpts.Images 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()) @@ -120,6 +123,9 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize } } if kustomizeOpts.NamePrefix != "" { + if r.isUsingKubectlKustomize() { + return "", fmt.Errorf("setting namePrefix via kustomizeOpts.NamePrefix is not supported when using 'kubectl kustomize'. Please set namePrefix directly in your kustomization.yaml file") + } _, err := r.runInDir(tempDir, r.kustomizeBin(), "edit", "set", "nameprefix", kustomizeOpts.NamePrefix) if err != nil { fmt.Println(err) @@ -127,6 +133,9 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize } } if kustomizeOpts.NameSuffix != "" { + if r.isUsingKubectlKustomize() { + return "", fmt.Errorf("setting nameSuffix via kustomizeOpts.NameSuffix 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) if err != nil { @@ -134,13 +143,20 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize } } if kustomizeOpts.Namespace != "" { + if r.isUsingKubectlKustomize() { + return "", fmt.Errorf("setting namespace via kustomizeOpts.Namespace is not supported when using 'kubectl kustomize'. Please set namespace directly in your kustomization.yaml file") + } _, err := r.runInDir(tempDir, r.kustomizeBin(), "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 !r.isUsingKubectlKustomize() { + kustomizeArgs = append(kustomizeArgs, "build") + } if u.EnableAlphaPlugins { f, err := r.kustomizeEnableAlphaPluginsFlag() @@ -190,10 +206,18 @@ func (r *Runner) kustomizeVersion() (*semver.Version, error) { return version, nil } +// isUsingKubectlKustomize checks if we're using kubectl's built-in kustomize +func (r *Runner) isUsingKubectlKustomize() bool { + return r.kustomizeBin() == "kubectl kustomize" +} + // 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) { + if r.isUsingKubectlKustomize() { + return "--enable-alpha-plugins", nil + } version, err := r.kustomizeVersion() if err != nil { return "", err @@ -209,6 +233,9 @@ func (r *Runner) kustomizeEnableAlphaPluginsFlag() (string, error) { // Above Kustomize v3, it is `--load-restrictor=LoadRestrictionsNone`. // Below Kustomize v3 (including v3), it is `--load_restrictor=none`. func (r *Runner) kustomizeLoadRestrictionsNoneFlag() (string, error) { + if r.isUsingKubectlKustomize() { + return "--load-restrictor=LoadRestrictionsNone", nil + } version, err := r.kustomizeVersion() if err != nil { return "", err diff --git a/patch.go b/patch.go index e41c2eb..1229564 100644 --- a/patch.go +++ b/patch.go @@ -181,9 +181,13 @@ resources: renderedFileName := "all.patched.yaml" renderedFile := filepath.Join(tempDir, renderedFileName) - r.Logf("Generating %s", renderedFile) + r.Logf("Generating %s", renderedFileName) - kustomizeArgs := []string{"build", tempDir, "--output", renderedFile} + kustomizeArgs := []string{"--output", renderedFile} + + if !r.isUsingKubectlKustomize() { + kustomizeArgs = append([]string{"build", tempDir}, kustomizeArgs...) + } if u.EnableAlphaPlugins { f, err := r.kustomizeEnableAlphaPluginsFlag() diff --git a/runner.go b/runner.go index 61eb0ca..4bc5997 100644 --- a/runner.go +++ b/runner.go @@ -108,6 +108,12 @@ func (r *Runner) kustomizeBin() string { if r.KustomizeBinary != "" { return r.KustomizeBinary } + if _, err := exec.LookPath("kustomize"); err == nil { + return "kustomize" + } + if _, err := exec.LookPath("kubectl"); err == nil { + return "kubectl kustomize" + } return "kustomize" } @@ -140,7 +146,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...) @@ -154,10 +160,10 @@ func (r *Runner) runBytes(envs map[string]string, dir, cmd string, args ...strin wrappedErr := fmt.Errorf(`%w COMMAND: -%s + %s OUTPUT: -%s`, + %s`, err, indent(c, " "), indent(string(errBytes), " "), diff --git a/util_test.go b/util_test.go index 6a944c4..f338897 100644 --- a/util_test.go +++ b/util_test.go @@ -1,6 +1,8 @@ package chartify import ( + "os" + "os/exec" "testing" "github.com/google/go-cmp/cmp" @@ -107,3 +109,58 @@ func TestFindSemVerInfo(t *testing.T) { }) } } + +func TestKustomizeBin(t *testing.T) { + t.Run("KustomizeBinary 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 binary exists in PATH", func(t *testing.T) { + if _, err := exec.LookPath("kustomize"); err != nil { + t.Skip("kustomize binary not found in PATH") + } + r := New() + got := r.kustomizeBin() + want := "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) { + if _, err := exec.LookPath("kubectl"); err != nil { + t.Skip("kubectl binary not found in PATH") + } + if _, err := exec.LookPath("kustomize"); err == nil { + t.Skip("kustomize binary found, cannot test fallback") + } + r := New() + got := r.kustomizeBin() + want := "kubectl kustomize" + if got != want { + t.Errorf("kustomizeBin() = %v, want %v", got, want) + } + }) + + t.Run("KUSTOMIZE_BIN environment variable", func(t *testing.T) { + if _, err := exec.LookPath("kustomize"); err != nil { + t.Skip("kustomize binary not found in PATH") + } + if _, ok := os.LookupEnv("KUSTOMIZE_BIN"); ok { + t.Skip("KUSTOMIZE_BIN environment variable is already set") + } + os.Setenv("KUSTOMIZE_BIN", "/custom/kustomize") + defer os.Unsetenv("KUSTOMIZE_BIN") + r := New(KustomizeBin(os.Getenv("KUSTOMIZE_BIN"))) + got := r.kustomizeBin() + want := "/custom/kustomize" + if got != want { + t.Errorf("kustomizeBin() = %v, want %v", got, want) + } + }) +} From 8fd729b740215dff3417db686e0e0aab21039673 Mon Sep 17 00:00:00 2001 From: yxxhero Date: Sat, 24 Jan 2026 14:13:57 +0800 Subject: [PATCH 2/6] refactor: Improve tests based on PR review feedback Address review comments: - Add KUSTOMIZE_BIN environment variable support to kustomizeBin() for consistency with helmBin() - Make TestKustomizeBin tests deterministic by using fake executables and controlled PATH instead of relying on system binaries - Make TestKubectlKustomizeFallback test self-contained - Add test for edit commands not supported with kubectl kustomize Signed-off-by: yxxhero --- kubectl_kustomize_test.go | 57 ++++++++++++++++++++++++++-- runner.go | 3 ++ util_test.go | 78 ++++++++++++++++++++++++++++----------- 3 files changed, 112 insertions(+), 26 deletions(-) diff --git a/kubectl_kustomize_test.go b/kubectl_kustomize_test.go index bf0e9a4..efc4ad3 100644 --- a/kubectl_kustomize_test.go +++ b/kubectl_kustomize_test.go @@ -10,11 +10,11 @@ import ( ) func TestKubectlKustomizeFallback(t *testing.T) { - if _, err := exec.LookPath("kubectl"); err != nil { - t.Skip("kubectl binary not found in PATH") - } - t.Run("KustomizeBuild with kubectl kustomize", func(t *testing.T) { + if _, err := exec.LookPath("kubectl"); err != nil { + t.Skip("kubectl binary not found in PATH") + } + tmpDir := t.TempDir() srcDir := t.TempDir() @@ -54,4 +54,53 @@ spec: require.NoError(t, err) require.FileExists(t, outputFile) }) + + 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 kustomizeOpts.Images is not supported when using 'kubectl kustomize'") + }) } diff --git a/runner.go b/runner.go index 4bc5997..aafdd0f 100644 --- a/runner.go +++ b/runner.go @@ -108,6 +108,9 @@ 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" } diff --git a/util_test.go b/util_test.go index f338897..e04fccd 100644 --- a/util_test.go +++ b/util_test.go @@ -2,10 +2,11 @@ package chartify import ( "os" - "os/exec" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" ) func TestCreateFlagChain(t *testing.T) { @@ -111,7 +112,7 @@ func TestFindSemVerInfo(t *testing.T) { } func TestKustomizeBin(t *testing.T) { - t.Run("KustomizeBinary is set", func(t *testing.T) { + t.Run("KustomizeBinary option is set", func(t *testing.T) { r := New(KustomizeBin("/custom/kustomize")) got := r.kustomizeBin() want := "/custom/kustomize" @@ -120,25 +121,33 @@ func TestKustomizeBin(t *testing.T) { } }) - t.Run("kustomize binary exists in PATH", func(t *testing.T) { - if _, err := exec.LookPath("kustomize"); err != nil { - t.Skip("kustomize binary not found in PATH") + t.Run("KUSTOMIZE_BIN environment variable", func(t *testing.T) { + if _, ok := os.LookupEnv("KUSTOMIZE_BIN"); ok { + t.Skip("KUSTOMIZE_BIN environment variable is already set") } + os.Setenv("KUSTOMIZE_BIN", "/custom/kustomize") + defer os.Unsetenv("KUSTOMIZE_BIN") r := New() got := r.kustomizeBin() - want := "kustomize" + 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) { - if _, err := exec.LookPath("kubectl"); err != nil { - t.Skip("kubectl binary not found in PATH") - } - if _, err := exec.LookPath("kustomize"); err == nil { - t.Skip("kustomize binary found, cannot test fallback") - } + 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) + r := New() got := r.kustomizeBin() want := "kubectl kustomize" @@ -147,18 +156,43 @@ func TestKustomizeBin(t *testing.T) { } }) - t.Run("KUSTOMIZE_BIN environment variable", func(t *testing.T) { - if _, err := exec.LookPath("kustomize"); err != nil { - t.Skip("kustomize binary not found in PATH") - } - if _, ok := os.LookupEnv("KUSTOMIZE_BIN"); ok { - t.Skip("KUSTOMIZE_BIN environment variable is already set") + 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) + + r := New() + got := r.kustomizeBin() + want := "kustomize" + if got != want { + t.Errorf("kustomizeBin() = %v, want %v", got, want) } - os.Setenv("KUSTOMIZE_BIN", "/custom/kustomize") - defer os.Unsetenv("KUSTOMIZE_BIN") - r := New(KustomizeBin(os.Getenv("KUSTOMIZE_BIN"))) + }) + + 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) + + r := New() got := r.kustomizeBin() - want := "/custom/kustomize" + want := "kustomize" if got != want { t.Errorf("kustomizeBin() = %v, want %v", got, want) } From 16b82354943ddbfafe7006a4d798027b4dd49f61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:18:16 +0000 Subject: [PATCH 3/6] refactor: Rename TestKubectlKustomizeFallback to TestKubectlKustomize to accurately reflect test purpose Agent-Logs-Url: https://github.com/helmfile/chartify/sessions/3fa21b29-e929-497e-b448-a711302394ad Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --- kubectl_kustomize_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/kubectl_kustomize_test.go b/kubectl_kustomize_test.go index efc4ad3..b8103cf 100644 --- a/kubectl_kustomize_test.go +++ b/kubectl_kustomize_test.go @@ -9,8 +9,11 @@ import ( "github.com/stretchr/testify/require" ) -func TestKubectlKustomizeFallback(t *testing.T) { - t.Run("KustomizeBuild with kubectl kustomize", func(t *testing.T) { +// 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) { if _, err := exec.LookPath("kubectl"); err != nil { t.Skip("kubectl binary not found in PATH") } From 57fe4e34d7d7fd67bfdde7c7867e692d53dea862 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:50:47 +0000 Subject: [PATCH 4/6] fix: Address 4 PR reviewer comments on kustomize fallback implementation Agent-Logs-Url: https://github.com/helmfile/chartify/sessions/6499602a-94b2-481c-ac49-a2fc2d015090 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --- kubectl_kustomize_test.go | 30 ++++++++++++++++++++++++++---- patch.go | 3 +++ runner.go | 4 ++-- util_test.go | 39 +++++++++++++++++++++++++++++++++++---- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/kubectl_kustomize_test.go b/kubectl_kustomize_test.go index b8103cf..7590bb8 100644 --- a/kubectl_kustomize_test.go +++ b/kubectl_kustomize_test.go @@ -2,7 +2,6 @@ package chartify import ( "os" - "os/exec" "path/filepath" "testing" @@ -14,9 +13,32 @@ import ( // in TestKustomizeBin. func TestKubectlKustomize(t *testing.T) { t.Run("KustomizeBuild succeeds with kubectl kustomize option", func(t *testing.T) { - if _, err := exec.LookPath("kubectl"); err != nil { - t.Skip("kubectl binary not found in PATH") - } + // Create a stub kubectl that handles the kustomize subcommand, + // so this test is self-contained and not skipped when kubectl is absent. + stubDir := t.TempDir() + stubKubectl := filepath.Join(stubDir, "kubectl") + // The stub writes minimal valid YAML to the -o output file. + stubScript := []byte(`#!/bin/sh +# Stub kubectl for testing kubectl kustomize +if [ "$1" = "kustomize" ]; then + shift + OUTPUT="" + while [ $# -gt 0 ]; do + case "$1" in + -o) OUTPUT="$2"; shift 2;; + *) shift;; + esac + done + if [ -n "$OUTPUT" ]; then + printf 'apiVersion: v1\nkind: List\nitems: []\n' > "$OUTPUT" + fi +fi +`) + require.NoError(t, os.WriteFile(stubKubectl, stubScript, 0755)) + + origPath := os.Getenv("PATH") + defer os.Setenv("PATH", origPath) + os.Setenv("PATH", stubDir+":"+origPath) tmpDir := t.TempDir() srcDir := t.TempDir() diff --git a/patch.go b/patch.go index 1229564..1167039 100644 --- a/patch.go +++ b/patch.go @@ -187,6 +187,9 @@ resources: if !r.isUsingKubectlKustomize() { 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 { diff --git a/runner.go b/runner.go index aafdd0f..8bf0827 100644 --- a/runner.go +++ b/runner.go @@ -163,10 +163,10 @@ func (r *Runner) runBytes(envs map[string]string, dir, cmd string, args ...strin wrappedErr := fmt.Errorf(`%w COMMAND: - %s +%s OUTPUT: - %s`, +%s`, err, indent(c, " "), indent(string(errBytes), " "), diff --git a/util_test.go b/util_test.go index e04fccd..92550fa 100644 --- a/util_test.go +++ b/util_test.go @@ -122,11 +122,15 @@ func TestKustomizeBin(t *testing.T) { }) t.Run("KUSTOMIZE_BIN environment variable", func(t *testing.T) { - if _, ok := os.LookupEnv("KUSTOMIZE_BIN"); ok { - t.Skip("KUSTOMIZE_BIN environment variable is already set") - } + 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") - defer os.Unsetenv("KUSTOMIZE_BIN") r := New() got := r.kustomizeBin() want := "/custom/kustomize" @@ -148,6 +152,15 @@ func TestKustomizeBin(t *testing.T) { 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" @@ -173,6 +186,15 @@ func TestKustomizeBin(t *testing.T) { 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" @@ -190,6 +212,15 @@ func TestKustomizeBin(t *testing.T) { 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" From 9eaf7ceeae150c424548809f858697d61d40fb08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:26:50 +0000 Subject: [PATCH 5/6] fix: Address 7 PR reviewer comments on kubectl kustomize fallback Agent-Logs-Url: https://github.com/helmfile/chartify/sessions/3bcd32ca-c9c5-4129-9c6c-87e122e320db Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --- kubectl_kustomize_test.go | 68 +++++++++++++++++++++++++++++---------- kustomize.go | 44 +++++++++++++------------ patch.go | 10 ++++-- 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/kubectl_kustomize_test.go b/kubectl_kustomize_test.go index 7590bb8..97899b4 100644 --- a/kubectl_kustomize_test.go +++ b/kubectl_kustomize_test.go @@ -8,37 +8,46 @@ import ( "github.com/stretchr/testify/require" ) -// 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 that handles the kustomize subcommand, - // so this test is self-contained and not skipped when kubectl is absent. - stubDir := t.TempDir() - stubKubectl := filepath.Join(stubDir, "kubectl") - // The stub writes minimal valid YAML to the -o output file. - stubScript := []byte(`#!/bin/sh -# Stub kubectl for testing kubectl kustomize +// 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: v1\nkind: List\nitems: []\n' > "$OUTPUT" + printf 'apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: stub\n' > "$OUTPUT" fi fi -`) - require.NoError(t, os.WriteFile(stubKubectl, stubScript, 0755)) +` + +// writeStubKubectl creates a stub kubectl script in dir and returns its path. +func writeStubKubectl(t *testing.T, dir string) string { + t.Helper() + p := filepath.Join(dir, "kubectl") + require.NoError(t, os.WriteFile(p, []byte(stubKubectlScript), 0755)) + return p +} + +// 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+":"+origPath) + os.Setenv("PATH", stubDir+string(os.PathListSeparator)+origPath) tmpDir := t.TempDir() srcDir := t.TempDir() @@ -80,6 +89,31 @@ spec: 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() @@ -126,6 +160,6 @@ spec: _, err := r.KustomizeBuild(srcDir, tmpDir, &KustomizeBuildOpts{ValuesFiles: []string{valuesFile}}) require.Error(t, err) - require.Contains(t, err.Error(), "setting images via kustomizeOpts.Images is not supported when using 'kubectl kustomize'") + 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 eb1c878..9f729a1 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,43 +114,43 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize } if len(kustomizeOpts.Images) > 0 { - if r.isUsingKubectlKustomize() { - return "", fmt.Errorf("setting images via kustomizeOpts.Images is not supported when using 'kubectl kustomize'. Please set images directly in your kustomization.yaml file") + 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 != "" { - if r.isUsingKubectlKustomize() { - return "", fmt.Errorf("setting namePrefix via kustomizeOpts.NamePrefix is not supported when using 'kubectl kustomize'. Please set namePrefix directly in your kustomization.yaml file") + 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, r.kustomizeBin(), "edit", "set", "nameprefix", kustomizeOpts.NamePrefix) + _, err := r.runInDir(tempDir, bin, "edit", "set", "nameprefix", kustomizeOpts.NamePrefix) if err != nil { fmt.Println(err) return "", err } } if kustomizeOpts.NameSuffix != "" { - if r.isUsingKubectlKustomize() { - return "", fmt.Errorf("setting nameSuffix via kustomizeOpts.NameSuffix is not supported when using 'kubectl kustomize'. Please set nameSuffix directly in your kustomization.yaml file") + 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 != "" { - if r.isUsingKubectlKustomize() { - return "", fmt.Errorf("setting namespace via kustomizeOpts.Namespace is not supported when using 'kubectl kustomize'. Please set namespace directly in your kustomization.yaml file") + 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, r.kustomizeBin(), "edit", "set", "namespace", kustomizeOpts.Namespace) + _, err := r.runInDir(tempDir, bin, "edit", "set", "namespace", kustomizeOpts.Namespace) if err != nil { return "", err } @@ -154,18 +158,18 @@ func (r *Runner) KustomizeBuild(srcDir string, tempDir string, opts ...Kustomize outputFile := filepath.Join(tempDir, "templates", "kustomized.yaml") kustomizeArgs := []string{"-o", outputFile} - if !r.isUsingKubectlKustomize() { + 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 } @@ -175,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 } @@ -214,8 +218,8 @@ func (r *Runner) isUsingKubectlKustomize() bool { // 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) { - if r.isUsingKubectlKustomize() { +func (r *Runner) kustomizeEnableAlphaPluginsFlag(usingKubectl bool) (string, error) { + if usingKubectl { return "--enable-alpha-plugins", nil } version, err := r.kustomizeVersion() @@ -232,8 +236,8 @@ 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) { - if r.isUsingKubectlKustomize() { +func (r *Runner) kustomizeLoadRestrictionsNoneFlag(usingKubectl bool) (string, error) { + if usingKubectl { return "--load-restrictor=LoadRestrictionsNone", nil } version, err := r.kustomizeVersion() diff --git a/patch.go b/patch.go index 1167039..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 @@ -185,7 +189,7 @@ resources: kustomizeArgs := []string{"--output", renderedFile} - if !r.isUsingKubectlKustomize() { + if !usingKubectl { kustomizeArgs = append([]string{"build", tempDir}, kustomizeArgs...) } else { // kubectl kustomize does not use the "build" subcommand; pass tempDir as the target directly. @@ -193,14 +197,14 @@ resources: } 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 } From 4bc02339f172d2c92f92248866b2f71c7d3bba59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:06:32 +0000 Subject: [PATCH 6/6] fix: resolve lint errors - drop unused isUsingKubectlKustomize and fix writeStubKubectl return Agent-Logs-Url: https://github.com/helmfile/chartify/sessions/d60019c1-5e03-4997-843e-aeb5ca7d0af6 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --- kubectl_kustomize_test.go | 5 ++--- kustomize.go | 5 ----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/kubectl_kustomize_test.go b/kubectl_kustomize_test.go index 97899b4..fb16b84 100644 --- a/kubectl_kustomize_test.go +++ b/kubectl_kustomize_test.go @@ -28,12 +28,11 @@ if [ "$1" = "kustomize" ]; then fi ` -// writeStubKubectl creates a stub kubectl script in dir and returns its path. -func writeStubKubectl(t *testing.T, dir string) string { +// 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)) - return p } // TestKubectlKustomize tests behavior when kubectl kustomize is explicitly configured diff --git a/kustomize.go b/kustomize.go index 9f729a1..5a92256 100644 --- a/kustomize.go +++ b/kustomize.go @@ -210,11 +210,6 @@ func (r *Runner) kustomizeVersion() (*semver.Version, error) { return version, nil } -// isUsingKubectlKustomize checks if we're using kubectl's built-in kustomize -func (r *Runner) isUsingKubectlKustomize() bool { - return r.kustomizeBin() == "kubectl kustomize" -} - // 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`.