Files
helmfile/pkg/state/issue_1757_test.go
T
Ankit 1341fd8659 fix: skip chartification when chart renders zero resources (#2724)
* fix: skip chartification when chart renders zero resources

When a chart's templates render zero resources (e.g. everything is
gated behind a falsy `{{- if .Values.enabled }}`) and the release has
transformers, jsonPatches, or strategicMergePatches configured,
helmfile crashed with:

  assertion failed: unexpected dir entry "" it must be the abs path
  to the output directory

Root cause: chartify's replace.go runs `helm template --output-dir`
and expects exactly one directory entry under that output dir (the
rendered chart). When helm renders no resources, the output dir is
empty, so chartOutputDir stays "" and chartify's own assertion on it
being an absolute path fails. chartify (v0.28.0) doesn't expose a
typed/sentinel error for this, only the assertion text.

Since there's nothing to chartify when a release has no rendered
resources, treat this specific chartify failure as a no-op: keep
using the chart as-is and let helm template it normally (producing
the same empty output helm would have produced without chartify).
Any other chartify error is still surfaced unchanged.

Verified manually end-to-end with a real helm+kustomize:
- a chart with `enabled: false` + a transformer now runs without
  error instead of crashing
- a chart with `enabled: true` + the same transformer still gets
  transformed correctly (annotations applied), confirming the normal
  chartify path is untouched

Added unit tests for the new error-matching helper in
pkg/state/issue_1757_test.go.

Fixes #1757

Signed-off-by: ankit090701 <ankitanku090701@gmail.com>

* fix: return original chart path from empty-render no-op, not the deps-rewrite temp copy

Review feedback on the original fix (#2724) found a real bug: the
empty-render no-op path returned chartPath after it may have already
been reassigned to the temp copy created by rewriteChartDependencies
(for charts with relative file:// deps). That temp dir is removed by
a deferred cleanupTempChart() as soon as processChartification
returns, so the caller was handed a path to a directory that no
longer existed, breaking any subsequent helm command with "chart not
found" - narrow (only local charts with relative file:// deps that
also render zero resources) but real and reproducible.

Capture originalChartPath before the rewrite and return that instead.
Also flatten the nested `if err != nil { if isChartifyEmptyRenderOutputError...`
into two sequential checks per review, and link the upstream tracking
issue (helmfile/chartify#206, opened by a maintainer during review) in
the error-matching constant's doc comment.

Added TestProcessChartification_EmptyRenderReturnsSurvivingPath, an
end-to-end test exercising the real processChartification ->
chartify.Chartify wiring (not just the isChartifyEmptyRenderOutputError
helper) with a chart that has a relative file:// dependency and renders
zero resources - the exact conditions that trigger the bug. Verified
passing against real helm+kustomize in a Linux container; skipped on
Windows due to an unrelated, pre-existing Windows path-handling issue
in chartify's dependency resolution (a drive letter embedded in a
file:// URL gets mis-joined), independent of the code path under test.

Signed-off-by: ankit090701 <ankitanku090701@gmail.com>

* fix: narrow chartify empty-render error match to avoid false positives

Per Copilot's automated review on this PR: the previous substring,
"it must be the abs path to the output directory", matches chartify's
assertion regardless of what chartOutputDir actually is. In the real
empty-render case chartOutputDir is "" (formatted via %q as `""`), but
the same assertion (chartify replace.go:151) would also fire if
chartOutputDir were ever a non-empty-but-still-relative path - a
different, genuine bug that should be surfaced as an error, not
silently treated as an empty-render no-op.

Narrow the match to include the `unexpected dir entry ""` prefix, so
it can only match the exact empty-string case. Verified against the
actual chartify v0.28.0 source (fmt.Errorf with %q on chartOutputDir)
that this is precisely what the empty-render case produces.

Added a test case asserting the same assertion text with a non-empty
dir entry is correctly NOT treated as the empty-render case. Re-ran
the full test suite (including the real helm+kustomize end-to-end
integration test) in a Linux container to confirm the narrower match
still catches the actual reported bug.

Signed-off-by: ankit090701 <ankitanku090701@gmail.com>

---------

Signed-off-by: ankit090701 <ankitanku090701@gmail.com>
2026-08-02 07:28:57 +08:00

201 lines
7.6 KiB
Go

package state
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/helmfile/chartify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/filesystem"
)
// TestIsChartifyEmptyRenderOutputError verifies that isChartifyEmptyRenderOutputError
// only matches chartify's specific "empty rendered output dir" assertion failure, not
// other, unrelated chartify errors. This is a regression test for issue #1757: a chart
// that renders zero resources (e.g. everything gated behind a falsy `if`) combined with
// transformers/jsonPatches/strategicMergePatches used to crash helmfile with
//
// assertion failed: unexpected dir entry "" it must be the abs path to the output directory
//
// because chartify expects exactly one rendered output directory and found none.
func TestIsChartifyEmptyRenderOutputError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "nil error",
err: nil,
expected: false,
},
{
name: "chartify empty render assertion error",
err: errors.New(`assertion failed: unexpected dir entry "" it must be the abs path to the output directory`),
expected: true,
},
{
// Same assertion, but chartOutputDir is a non-empty (merely
// relative) path rather than "" - a different, hypothetical
// chartify bug that happens to trip the same final assertion.
// This must NOT be treated as the empty-render no-op case:
// per review feedback (https://github.com/helmfile/helmfile/pull/2724),
// matching only on the trailing "...it must be the abs path to
// the output directory" phrase (without requiring the `""`
// empty-string dir entry) would have incorrectly matched this
// too, silently masking a genuinely different failure.
name: "same assertion with a non-empty dir entry is not the empty-render case",
err: errors.New(`assertion failed: unexpected dir entry "relative/path" it must be the abs path to the output directory`),
expected: false,
},
{
name: "unrelated chartify error",
err: errors.New("exec: \"kustomize\": executable file not found in %PATH%"),
expected: false,
},
{
name: "unrelated multiple-dir-entries assertion",
err: errors.New(`assertion failed: there should be only one dir entry under the helm output dir /tmp/foo`),
expected: false,
},
{
name: "helm template failure",
err: errors.New("Error: template: mychart/templates/broken.yaml:3:5: executing..."),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, isChartifyEmptyRenderOutputError(tt.err))
})
}
}
// TestProcessChartification_EmptyRenderReturnsSurvivingPath is an end-to-end
// regression test for a bug found in review of the #1757 fix: the empty-render
// no-op path must return the chart's original path, not the temp copy that
// rewriteChartDependencies creates when the chart has relative file:// deps -
// that temp dir is removed by a deferred cleanup as soon as processChartification
// returns, so returning it would hand the caller a path to a directory that no
// longer exists on disk.
//
// This exercises the real processChartification -> chartify.Chartify wiring
// (unlike TestIsChartifyEmptyRenderOutputError above, which only tests the pure
// string-matching helper), so it needs real helm and kustomize binaries on PATH
// and is skipped if either is missing. Verified to pass on Linux; skipped on
// Windows because the file:// dependency URL this test needs (to make
// rewriteChartDependencies actually produce a temp copy) hits an unrelated,
// pre-existing Windows path-handling issue in chartify/helm's dependency
// resolution (a Windows drive letter embedded in a file:// URL gets
// mis-joined onto another path), independent of the fix under test here.
func TestProcessChartification_EmptyRenderReturnsSurvivingPath(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping on Windows: unrelated file:// dependency URL / drive letter handling issue in chartify's helm dependency resolution, not the code path under test; passes on Linux (CI)")
}
if _, err := exec.LookPath("helm"); err != nil {
t.Skip("helm not found on PATH, skipping")
}
if _, err := exec.LookPath("kustomize"); err != nil {
t.Skip("kustomize not found on PATH, skipping")
}
tempDir := t.TempDir()
// A sibling chart referenced via a relative file:// dependency, so that
// processChartification takes the rewriteChartDependencies path (line
// `if st.fs.DirectoryExistsAt(chartPath) { ... }`) and reassigns its local
// chartPath variable to a temp copy before ever calling chartify.
depChartDir := filepath.Join(tempDir, "dep-chart")
require.NoError(t, os.MkdirAll(depChartDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(depChartDir, "Chart.yaml"), []byte(`
apiVersion: v2
name: dep-chart
version: 0.1.0
`), 0644))
chartDir := filepath.Join(tempDir, "chart")
require.NoError(t, os.MkdirAll(filepath.Join(chartDir, "templates"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(`
apiVersion: v2
name: emptychart
version: 0.1.0
dependencies:
- name: dep-chart
version: 0.1.0
repository: "file://../dep-chart"
`), 0644))
require.NoError(t, os.WriteFile(filepath.Join(chartDir, "values.yaml"), []byte("enabled: false\n"), 0644))
// Every template is gated behind a falsy condition, so helm renders zero
// resources - the exact condition that triggers chartify's empty-output
// assertion.
require.NoError(t, os.WriteFile(filepath.Join(chartDir, "templates", "deployment.yaml"), []byte(`
{{- if .Values.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: test
{{- end }}
`), 0644))
// A transformer is required for chartify to be invoked at all (see the
// call site in state.go: chartification is only non-nil, and processChartification
// only gets called, when transformers/jsonPatches/strategicMergePatches are set).
transformerPath := filepath.Join(tempDir, "transformer.yaml")
require.NoError(t, os.WriteFile(transformerPath, []byte(`
apiVersion: builtin
kind: AnnotationsTransformer
metadata:
name: notImportantHere
annotations:
area: "51"
fieldSpecs:
- path: metadata/annotations
create: true
`), 0644))
st := &HelmState{
logger: zap.NewNop().Sugar(),
fs: filesystem.DefaultFileSystem(),
ReleaseSetSpec: ReleaseSetSpec{
DefaultHelmBinary: "helm",
DefaultKustomizeBinary: "kustomize",
},
}
chartification := &Chartify{
Opts: &chartify.ChartifyOpts{
Transformers: []string{transformerPath},
},
}
release := &ReleaseSpec{}
release.Name = "empty-release"
resultPath, buildDeps, err := st.processChartification(
chartification, release, chartDir, ChartPrepareOptions{}, false, "template",
)
require.NoError(t, err)
assert.True(t, buildDeps, "buildDeps should be true (!skipDeps) for the no-op path")
// The returned path must still exist: it must be the original chart
// directory, not the deps-rewritten temp copy that gets deleted by
// rewriteChartDependencies' deferred cleanup on return.
info, statErr := os.Stat(resultPath)
require.NoError(t, statErr, "returned chart path %q must still exist after processChartification returns", resultPath)
assert.True(t, info.IsDir())
// It should specifically be the chart's own directory (or an
// equally-valid, not-yet-cleaned-up path to the same chart), not some
// other temp directory. Chart.yaml must be readable from it.
_, err = os.Stat(filepath.Join(resultPath, "Chart.yaml"))
assert.NoError(t, err, "Chart.yaml should be reachable from the returned path %q", resultPath)
}