From bd6474c344b4047fd828922a5c5968f162ef20ad Mon Sep 17 00:00:00 2001 From: yxxhero Date: Sat, 24 Jan 2026 14:00:27 +0800 Subject: [PATCH 1/2] 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/2] 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) }