Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions kubectl_kustomize_test.go
Original file line number Diff line number Diff line change
@@ -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 <dir> [-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'")
})
}
46 changes: 36 additions & 10 deletions kustomize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
Comment on lines 116 to +119
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message leaks the internal struct/field name kustomizeOpts.Images, which isn’t meaningful to callers. Consider rewording to reference the user-facing input (e.g., images configured via values files / KustomizeBuildOpts) and what to do instead when using kubectl kustomize.

Copilot uses AI. Check for mistakes.
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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
19 changes: 15 additions & 4 deletions patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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...)
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using the kubectl-based kustomize path, this command invocation never specifies the target directory and also uses r.run (dir=""). That means it will run kubectl kustomize against the process working directory rather than tempDir, producing wrong output or failing. Pass tempDir as the kustomize target (or run in tempDir) for the kubectl case as well, similar to how the non-kubectl path includes build tempDir.

Suggested change
kustomizeArgs = append([]string{"build", tempDir}, kustomizeArgs...)
kustomizeArgs = append([]string{"build", tempDir}, kustomizeArgs...)
} else {
// When using kubectl kustomize, explicitly pass tempDir as the target directory.
kustomizeArgs = append([]string{tempDir}, kustomizeArgs...)

Copilot uses AI. Check for mistakes.
} else {
// kubectl kustomize does not use the "build" subcommand; pass tempDir as the target directly.
kustomizeArgs = append([]string{tempDir}, kustomizeArgs...)
}
Comment on lines +190 to +197
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new branch changes the argument shape for the kustomize invocation when kubectl kustomize is used. There aren’t tests covering the Patch() execution path, so regressions in argument ordering/flags for the kubectl case could slip through. Consider adding a unit test that stubs Runner.RunCommand and asserts the exact binary/args used for both kustomize and kubectl kustomize.

Copilot uses AI. Check for mistakes.

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
}
Expand Down
11 changes: 10 additions & 1 deletion runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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...)

Expand Down
Loading