Compare commits

...
Author SHA1 Message Date
yxxhero 042df585b8 fix: silence prebuild dep-build output to restore e2e snapshots
CI failed TestHelmfileTemplateWithBuildCommand (chart_need,
chart_need_enable_live_output, oci_need, postrenderer) on all tested
Helm versions: routing the prebuild through helmexec.BuildDeps emitted a
per-subchart "Building dependency release=<subchart>, chart=..." line
at Info that the snapshot golden files do not expect.

The prebuild is best-effort and runs for every transitive subchart, so it
must stay user-silent (the original os/exec version logged only at Debug).
Run the dep builds on a shallow clone of the helmexec runner whose logger
is raised above Info (LoggerSwapper, same pattern as helmx.go) and with
live output disabled, so neither the "Building dependency" line nor helm
stdout can leak — while still inheriting the runner's command wiring
(binary, flags, context) requested in review. Failures are surfaced at
Debug via st.logger, and the helmexec ExitError message still carries
helm's combined output for isLockOutOfSync.

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-07-02 06:37:50 +08:00
yxxhero 6ccfe559b1 fix: route prebuild dep builds through the helmexec runner
Address Copilot review comments on preBuildTransitiveSubchartDeps:

- execHelmDep shelled out via os/exec, bypassing Helmfile's standard
  command wiring. Thread the helmexec.Interface (already available in
  prepareChartForRelease) through processChartification into the prebuild
  helpers and dispatch via helm.BuildDeps/helm.UpdateDeps. Drop the
  os/exec call and the DefaultHelmBinary re-resolution.
- isLockOutOfSync now inspects the helmexec ExitError message (which
  embeds the combined output) instead of raw []byte output.
- DoesNotRecurseIntoRemoteDeps used a real helm binary, risking network
  I/O to https://charts.example.com. Switch it to a stub helm whose
  BuildDeps returns an error so it fails fast and deterministically, and
  assert it visits only the chart dir (no recursion).

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-07-02 06:37:50 +08:00
yxxhero bfcabf3ead fix: address Copilot review comments on preBuildTransitiveSubchartDeps
- Correct parseLocalFileDeps doc to match behavior (ok=false only when
  Chart.yaml is missing/unreadable/unparseable; may return empty slice).
- Align log prefixes with function names (parseLocalFileDeps,
  runHelmDepBuild, execHelmDep) instead of the stale preBuildSubchartDeps.
- Log execHelmDep failures at Debug (best-effort) instead of Warn.
- Add skipIfNoHelm helper and guard tests that invoke helm.
- Rename NoOpOnRemoteDeps -> DoesNotRecurseIntoRemoteDeps for accuracy.

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-07-02 06:37:50 +08:00
yxxhero 3dda0385e5 refactor: improve readability of preBuildTransitiveSubchartDeps
Extract three focused helpers from the monolithic recursive walker:

- parseLocalFileDeps: reads Chart.yaml, returns local file:// dep dirs.
  Pure data extraction, easy to test independently.
- resolveFileDep: resolves a single file:// URL to an absolute dir path.
  Encapsulates the file:// prefix check + relative-to-absolute logic.
- execHelmDep: thin wrapper around exec.Command that handles logging.
  Eliminates duplicated exec+log patterns between build and update.

Simplify prebuildChartDeps (the recursive worker): it now reads "parse
deps → recurse into each → build this chart" with no inline YAML parsing
or path resolution noise.

Net: -106/+83 lines, same behavior, all tests pass.

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-07-02 06:37:50 +08:00
yxxhero d3cfe04d47 fix: address review issues in preBuildTransitiveSubchartDeps
- Add per-chartDir mutex via getNamedRWMutex to prevent concurrent
  releases referencing the same local chart from racing on helm dep
  build writes (same pattern as forcedDownloadChart for #768).
- Log non-lock errors at Warnf instead of Debugf so users can diagnose
  why strategicMergePatches fail with "no resource matches".
- Document why --skip-refresh is kept (file://-only deps are the common
  case; chartify runs dep build without --skip-refresh afterwards and
  catches stale repos for mixed-dep subcharts).
- Use strings.HasPrefix instead of manual slicing in test helper.

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-07-02 06:37:50 +08:00
yxxhero 08504ad6f6 fix: pre-build transitive subchart deps for envelope charts (#851)
Helm's `dependency build` on the parent chart fetches each file:// subchart
and packages it as .tgz, but does NOT recursively build the subchart's own
dependencies first. For envelope charts whose subcharts declare their own
local file:// dependencies, the resulting .tgz lacks the nested subchart's
resources. chartify's `helm template` then silently drops those resources,
and strategicMergePatches/jsonPatches targeting them fail with
"no resource matches".

Fix: add `preBuildTransitiveSubchartDeps`, called from processChartification
before chartify runs (gated on !skipDeps). It walks the transitive file://
dependency tree depth-first and runs `helm dependency build` bottom-up on each
subchart source directory, so each subchart's deps are pre-built before the
parent packages them. The function is best-effort: errors are logged at debug
level and do not abort the operation, matching helmfile's existing behavior
of tolerating already-built deps.

Verified with a reproduction case that fails before the fix
(`Error: no resource matches strategic merge patch ".../nested-sm.[noNs]"`)
and passes after it.

Resolves #851

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-07-02 06:37:50 +08:00
14 changed files with 645 additions and 2 deletions
+18
View File
@@ -285,6 +285,24 @@ There's also `releases[].jsonPatches` that works similarly to `strategicMergePat
Please also see [test/advanced/helmfile.yaml](https://github.com/helmfile/helmfile/tree/master/test/advanced/helmfile.yaml) for an example of patching support and more.
#### Patching envelope charts (charts whose subcharts have local dependencies)
`strategicMergePatches`, `jsonPatches`, and `transformers` all work on resources defined
anywhere in the chart, including resources rendered by transitive subcharts (a subchart's
own dependencies).
For local envelope charts whose subcharts declare their own `file://` dependencies, Helmfile
runs `helm dependency build` bottom-up across the transitive dependency tree before chartify
runs. Without this, helm's `dependency build` on the parent chart would fetch each file://
subchart and package it as a `.tgz` **without** recursively building the subchart's own deps
first. The resulting `.tgz` would lack the nested subchart's resources, chartify's
`helm template` would silently drop them, and patches targeting those resources would fail
with `no resource matches`.
See [issue #851](https://github.com/helmfile/helmfile/issues/851).
To opt out of dependency building (e.g. for read-only `template` invocations), use
`--skip-deps` or set `helmDefaults.skipDeps: true` / `releases[].skipDeps: true`.
### `transformers`
You can set `transformers` to apply [Kustomize's transformers](https://github.com/kubernetes-sigs/kustomize/blob/master/examples/configureBuiltinPlugin.md#configuring-the-builtin-plugins-instead).
+290
View File
@@ -0,0 +1,290 @@
package state
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
)
// skipIfNoHelm skips the calling test when the helm binary is not available on
// PATH. Tests that exercise runHelmDepBuild through a real helmexec need helm
// installed to produce meaningful results.
func skipIfNoHelm(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("helm"); err != nil {
t.Skipf("skipping: helm binary not found in PATH (%v)", err)
}
}
// newRealHelmExec builds a helmexec.Interface backed by the real helm binary on
// PATH, for tests that assert actual `helm dependency build` side effects.
func newRealHelmExec(t *testing.T) helmexec.Interface {
t.Helper()
logger := zap.NewNop().Sugar()
runner := &helmexec.ShellRunner{Ctx: context.Background(), Logger: logger}
helm, err := helmexec.New("helm", helmexec.HelmExecOptions{}, logger, "", "", runner)
if err != nil {
t.Fatalf("helmexec.New: %v", err)
}
return helm
}
// stubHelm is a minimal helmexec.Interface for prebuild tests that should NOT
// invoke a real helm binary (avoids network I/O and the helm dependency). It
// records BuildDeps/UpdateDeps calls so tests can assert which charts were
// visited. Only BuildDeps/UpdateDeps are exercised by the prebuild path, so the
// embedded nil Interface satisfies the rest of the contract without being
// dereferenced.
type stubHelm struct {
helmexec.Interface
buildErr error
builds []string
updates []string
}
func (s *stubHelm) BuildDeps(name, chart string, flags ...string) error {
s.builds = append(s.builds, chart)
return s.buildErr
}
func (s *stubHelm) UpdateDeps(chart string) error {
s.updates = append(s.updates, chart)
return s.buildErr
}
// writeChart creates a chart directory with Chart.yaml and the given templates.
// deps maps dependency name → repository URL (skipped if empty).
func writeChart(t *testing.T, dir, name string, deps map[string]string, templates map[string]string) {
t.Helper()
chartYaml := "apiVersion: v2\nname: " + name + "\nversion: 0.1.0\n"
if len(deps) > 0 {
chartYaml += "dependencies:\n"
for depName, repo := range deps {
chartYaml += " - name: " + depName + "\n repository: " + repo + "\n version: 0.1.0\n"
}
}
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("write Chart.yaml in %s: %v", dir, err)
}
if len(templates) > 0 {
tmplDir := filepath.Join(dir, "templates")
if err := os.MkdirAll(tmplDir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", tmplDir, err)
}
for filename, content := range templates {
if err := os.WriteFile(filepath.Join(tmplDir, filename), []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", filename, err)
}
}
}
}
// TestPreBuildTransitiveSubchartDeps_VisitsAllTransitiveDeps verifies that
// preBuildTransitiveSubchartDeps walks the full transitive file:// dependency
// tree by checking that helm dep build runs on every chart dir.
//
// We assert this by checking that sub2/charts/nested-* exists after the call —
// that's the exact artifact that helm's non-recursive dep build fails to
// produce for envelope charts (issue #851).
func TestPreBuildTransitiveSubchartDeps_VisitsAllTransitiveDeps(t *testing.T) {
if testing.Short() {
t.Skip("skipping in short mode (requires helm)")
}
skipIfNoHelm(t)
rootDir, err := os.MkdirTemp("", "helmfile-prebuild-")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer os.RemoveAll(rootDir)
// envelope → sub1, sub2
// sub2 → nested
writeChart(t, filepath.Join(rootDir, "envelope"), "envelope",
map[string]string{"sub1": "file://../sub1", "sub2": "file://../sub2"},
map[string]string{"cm.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: parent-cm\n"},
)
writeChart(t, filepath.Join(rootDir, "sub1"), "sub1", nil,
map[string]string{"sm.yaml": "apiVersion: monitoring.coreos.com/v1\nkind: ServiceMonitor\nmetadata:\n name: sub1-sm\n"},
)
writeChart(t, filepath.Join(rootDir, "sub2"), "sub2",
map[string]string{"nested": "file://../nested"},
map[string]string{"sm.yaml": "apiVersion: monitoring.coreos.com/v1\nkind: ServiceMonitor\nmetadata:\n name: sub2-sm\n"},
)
writeChart(t, filepath.Join(rootDir, "nested"), "nested", nil,
map[string]string{"sm.yaml": "apiVersion: monitoring.coreos.com/v1\nkind: ServiceMonitor\nmetadata:\n name: nested-sm\n"},
)
st := &HelmState{
logger: zap.NewNop().Sugar(),
fs: filesystem.DefaultFileSystem(),
ReleaseSetSpec: ReleaseSetSpec{
DefaultHelmBinary: "helm",
},
}
st.preBuildTransitiveSubchartDeps(filepath.Join(rootDir, "envelope"), newRealHelmExec(t))
// The critical assertion: sub2/charts/ should now contain nested (as
// .tgz or unpacked). Without the fix, helm dep build on the parent chart
// produces sub2.tgz WITHOUT nested, and the nested ServiceMonitor is
// silently dropped from the chartify output.
sub2Charts := filepath.Join(rootDir, "sub2", "charts")
entries, err := os.ReadDir(sub2Charts)
if err != nil {
t.Fatalf("sub2/charts should exist after preBuildTransitiveSubchartDeps ran helm dep build on sub2: %v", err)
}
foundNested := false
for _, e := range entries {
name := e.Name()
if name == "nested" || hasNestedTgzPrefix(name) {
foundNested = true
break
}
}
if !foundNested {
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
t.Fatalf("sub2/charts should contain nested after pre-build; got entries: %v", names)
}
// envelope/charts should contain sub1 and sub2.
envCharts := filepath.Join(rootDir, "envelope", "charts")
if entries, err := os.ReadDir(envCharts); err != nil {
t.Fatalf("envelope/charts should exist after pre-build: %v", err)
} else if len(entries) == 0 {
t.Fatalf("envelope/charts should contain sub1 and sub2 after pre-build")
}
}
func hasNestedTgzPrefix(name string) bool {
return strings.HasPrefix(name, "nested-")
}
// TestPreBuildTransitiveSubchartDeps_HandlesMissingChartYaml verifies the
// function is a no-op (no panic) when the chart dir has no Chart.yaml. A stub
// helm is used since the missing Chart.yaml short-circuits before any helm call.
func TestPreBuildTransitiveSubchartDeps_HandlesMissingChartYaml(t *testing.T) {
rootDir, err := os.MkdirTemp("", "helmfile-prebuild-noyaml-")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer os.RemoveAll(rootDir)
st := &HelmState{
logger: zap.NewNop().Sugar(),
fs: filesystem.DefaultFileSystem(),
ReleaseSetSpec: ReleaseSetSpec{
DefaultHelmBinary: "helm",
},
}
// Should not panic.
st.preBuildTransitiveSubchartDeps(rootDir, &stubHelm{})
}
// TestPreBuildTransitiveSubchartDeps_HandlesCircularDeps verifies the function
// terminates when the file:// dependency graph has a cycle.
func TestPreBuildTransitiveSubchartDeps_HandlesCircularDeps(t *testing.T) {
if testing.Short() {
t.Skip("skipping in short mode (requires helm)")
}
skipIfNoHelm(t)
rootDir, err := os.MkdirTemp("", "helmfile-prebuild-cycle-")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer os.RemoveAll(rootDir)
// a → b → a (cycle). Use absolute file:// paths so each can resolve the other.
aDir := filepath.Join(rootDir, "a")
bDir := filepath.Join(rootDir, "b")
writeChart(t, aDir, "a", map[string]string{"b": "file://" + bDir}, nil)
writeChart(t, bDir, "b", map[string]string{"a": "file://" + aDir}, nil)
st := &HelmState{
logger: zap.NewNop().Sugar(),
fs: filesystem.DefaultFileSystem(),
ReleaseSetSpec: ReleaseSetSpec{
DefaultHelmBinary: "helm",
},
}
// Should terminate, not infinite-loop.
done := make(chan struct{})
go func() {
defer close(done)
st.preBuildTransitiveSubchartDeps(aDir, newRealHelmExec(t))
}()
select {
case <-done:
// success — terminated
case <-time.After(30 * time.Second):
t.Fatal("preBuildTransitiveSubchartDeps did not terminate on circular deps")
}
}
// TestPreBuildTransitiveSubchartDeps_DoesNotRecurseIntoRemoteDeps verifies the
// function does not recurse into dependencies declared with non-file://
// repositories (https://, oci://). It still runs helm dep build on the chart dir
// itself, but does not follow the remote dependency.
//
// A stub helm whose BuildDeps returns an error is used so the test fails fast
// and deterministically without performing any network I/O (a real helm would
// try to fetch https://charts.example.com).
func TestPreBuildTransitiveSubchartDeps_DoesNotRecurseIntoRemoteDeps(t *testing.T) {
rootDir, err := os.MkdirTemp("", "helmfile-prebuild-remote-")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer os.RemoveAll(rootDir)
// Chart with only a remote dep. preBuildTransitiveSubchartDeps should not
// recurse into anything (no file:// deps to follow).
chartDir := filepath.Join(rootDir, "chart")
writeChart(t, chartDir, "chart",
map[string]string{"remote": "https://charts.example.com"},
nil,
)
st := &HelmState{
logger: zap.NewNop().Sugar(),
fs: filesystem.DefaultFileSystem(),
ReleaseSetSpec: ReleaseSetSpec{
DefaultHelmBinary: "helm",
},
}
helm := &stubHelm{buildErr: errors.New("stub: dependency build unavailable")}
st.preBuildTransitiveSubchartDeps(chartDir, helm)
// BuildDeps must be called exactly once, on the chart dir itself — proving
// the function did not recurse into the remote dep.
if len(helm.builds) != 1 {
t.Fatalf("expected exactly one BuildDeps call (chart dir, no recursion into remote), got %v", helm.builds)
}
if got, want := filepath.Base(helm.builds[0]), "chart"; got != want {
t.Fatalf("BuildDeps called on %q, want chart dir %q", helm.builds[0], want)
}
// The stub error is not a lock-out-of-sync signal, so there must be no
// fallback to UpdateDeps.
if len(helm.updates) != 0 {
t.Fatalf("expected no UpdateDeps fallback, got %v", helm.updates)
}
}
+159 -2
View File
@@ -1833,11 +1833,168 @@ func (st *HelmState) rewriteChartDependencies(chartPath string) (string, func(),
return tempDir, cleanup, nil
}
// preBuildTransitiveSubchartDeps walks the transitive tree of local file://
// dependencies declared in chartPath/Chart.yaml and runs `helm dependency build`
// on each chart's source directory bottom-up (dependencies before dependents).
//
// This fixes envelope charts whose subcharts declare their own file:// deps:
// helm's `dependency build` on the parent packages each subchart as .tgz without
// recursively building the subchart's own deps, so the nested subchart's
// resources are silently absent and patches targeting them fail with
// "no resource matches". See https://github.com/helmfile/helmfile/issues/851.
//
// Best-effort: errors are logged, not returned, so a single broken subchart
// doesn't abort the entire release.
func (st *HelmState) preBuildTransitiveSubchartDeps(chartPath string, helm helmexec.Interface) {
st.prebuildChartDeps(chartPath, make(map[string]bool), helm)
}
// prebuildChartDeps is the recursive worker. It visits each file:// dependency
// depth-first, then runs helm dependency build on chartDir itself.
func (st *HelmState) prebuildChartDeps(chartDir string, visited map[string]bool, helm helmexec.Interface) {
absChartDir, err := filepath.Abs(chartDir)
if err != nil || visited[absChartDir] {
return
}
visited[absChartDir] = true
deps, ok := st.parseLocalFileDeps(absChartDir)
if !ok {
return
}
for _, depDir := range deps {
st.prebuildChartDeps(depDir, visited, helm)
}
st.runHelmDepBuild(absChartDir, helm)
}
// parseLocalFileDeps reads chartDir/Chart.yaml and returns the absolute paths
// of all local file:// dependencies that exist as directories. Returns ok=false
// only when Chart.yaml is missing, unreadable, or unparseable. When ok=true the
// returned slice may be empty (e.g. no file:// deps present).
func (st *HelmState) parseLocalFileDeps(chartDir string) (depDirs []string, ok bool) {
data, err := st.fs.ReadFile(filepath.Join(chartDir, "Chart.yaml"))
if err != nil {
return nil, false
}
type chartDep struct {
Repository string `yaml:"repository"`
}
type chartMeta struct {
Dependencies []chartDep `yaml:"dependencies,omitempty"`
}
var meta chartMeta
if err := yaml.Unmarshal(data, &meta); err != nil {
st.logger.Debugf("parseLocalFileDeps: failed to parse %s/Chart.yaml: %v", chartDir, err)
return nil, false
}
for _, dep := range meta.Dependencies {
dir, found := st.resolveFileDep(dep.Repository, chartDir)
if found {
depDirs = append(depDirs, dir)
}
}
return depDirs, true
}
// resolveFileDep resolves a file:// repository URL to an absolute directory path.
// Returns (dir, true) if the dependency is a local file:// URL pointing to an
// existing directory; ("", false) otherwise.
func (st *HelmState) resolveFileDep(repo, parentDir string) (string, bool) {
const fileScheme = "file://"
if !strings.HasPrefix(repo, fileScheme) {
return "", false
}
path := strings.TrimPrefix(repo, fileScheme)
if !filepath.IsAbs(path) {
path = filepath.Join(parentDir, path)
}
if !st.fs.DirectoryExistsAt(path) {
return "", false
}
return path, true
}
// runHelmDepBuild runs `helm dependency build` on chartDir with a per-dir lock
// to prevent concurrent releases from corrupting charts/*.tgz (same pattern as
// forcedDownloadChart for #768). It dispatches through the shared helmexec
// runner so the command inherits Helmfile's standard wiring (binary resolution,
// flag handling, context cancellation) instead of shelling out directly. Falls
// back to `helm dependency update` when Chart.lock is missing or stale.
//
// The prebuild is best-effort and runs for every transitive subchart, so it must
// stay user-silent: helmexec.BuildDeps emits "Building dependency release=..."
// at Info, which would clutter output for envelope charts with many subcharts.
// We therefore run on a shallow clone whose logger is raised above Info
// (LoggerSwapper, same pattern as helmx.go). Command wiring is shared via the
// clone; failures are surfaced at Debug through st.logger, and the helmexec
// ExitError message embeds helm's combined output for diagnostics.
//
// --skip-refresh avoids a slow `helm repo update` per subchart; file:// deps
// (the envelope-chart use case) don't need the repo cache. For mixed file:// +
// https:// deps, chartify's own helm dep build (without --skip-refresh) runs
// afterwards as a safety net.
func (st *HelmState) runHelmDepBuild(chartDir string, helm helmexec.Interface) {
mu := st.getNamedRWMutex("prebuild-deps:" + chartDir)
mu.Lock()
defer mu.Unlock()
// Clone the runner with a quiet logger so the per-subchart
// "Building dependency release=..." Info line and helm stdout do not leak
// into user output. Live output is also disabled on the clone so helm stdout
// can't bypass the logger in --enable-live-output mode. The clone shares the
// runner and its options are a value copy, so this does not mutate the
// original execer. Mocks that don't implement LoggerSwapper fall back to the
// original interface (they don't emit that line anyway).
quietHelm := helm
if swapper, ok := helm.(helmexec.LoggerSwapper); ok {
quietHelm = swapper.WithLogger(helmexec.NewLogger(io.Discard, "warn"))
quietHelm.SetEnableLiveOutput(false)
}
// Try `dependency build` first (honors Chart.lock). Fall back to `update`
// (re-resolves from Chart.yaml) when the lock is missing or stale. BuildDeps
// returns a helmexec.ExitError whose message embeds the combined output, so
// isLockOutOfSync can inspect err.Error() without a separate os/exec call.
if err := quietHelm.BuildDeps(filepath.Base(chartDir), chartDir, "--skip-refresh"); err != nil {
if isLockOutOfSync(err.Error()) {
st.logger.Debugf("runHelmDepBuild: falling back to `dependency update` for %s", chartDir)
if err := quietHelm.UpdateDeps(chartDir); err != nil {
st.logger.Debugf("runHelmDepBuild: helm dependency update failed for %s: %v", chartDir, err)
}
} else {
st.logger.Debugf("runHelmDepBuild: helm dependency build failed for %s: %v", chartDir, err)
}
}
}
// isLockOutOfSync returns true when helm's error/output indicates Chart.lock is
// missing or stale — the signal to fall back from `build` to `update`.
func isLockOutOfSync(msg string) bool {
return strings.Contains(msg, "out of sync") ||
strings.Contains(msg, "lock file is out of date") ||
strings.Contains(msg, "no lock file") ||
strings.Contains(msg, "Chart.lock not found")
}
// Otherwise, if a chart is not a helm chart, it will call "chartify" to turn it into a chart.
//
// If exists, it will also patch resources by json patches, strategic-merge patches, and injectors.
// processChartification handles the chartification process
func (st *HelmState) processChartification(chartification *Chartify, release *ReleaseSpec, chartPath string, opts ChartPrepareOptions, skipDeps bool, helmfileCommand string) (string, bool, error) {
func (st *HelmState) processChartification(chartification *Chartify, release *ReleaseSpec, chartPath string, helm helmexec.Interface, opts ChartPrepareOptions, skipDeps bool, helmfileCommand string) (string, bool, error) {
// Pre-build transitive local file:// subchart dependencies before chartify runs.
// helm's `dependency build` on the parent chart fetches each file:// subchart and
// packages it as .tgz, but does NOT recursively build the subchart's own deps.
// Without this pre-build step, envelope charts whose subcharts have their own
// local file:// dependencies silently drop the nested subcharts' resources, and
// strategicMergePatches/jsonPatches targeting those resources fail with
// "no resource matches". See https://github.com/helmfile/helmfile/issues/851.
if !skipDeps && st.fs.DirectoryExistsAt(chartPath) {
st.preBuildTransitiveSubchartDeps(chartPath, helm)
}
// Rewrite relative file:// dependencies in Chart.yaml to absolute paths before chartify processes them
// This prevents errors like "Error: directory /tmp/chartify.../argocd-application not found"
// when Chart.yaml contains dependencies like "file://../argocd-application"
@@ -2112,7 +2269,7 @@ func (st *HelmState) prepareChartForRelease(release *ReleaseSpec, helm helmexec.
if isLocal {
chartPath = normalizeChart(st.basePath, chartPath)
}
chartPath, buildDeps, err = st.processChartification(chartification, release, chartPath, opts, skipDeps, helmfileCommand)
chartPath, buildDeps, err = st.processChartification(chartification, release, chartPath, helm, opts, skipDeps, helmfileCommand)
if err != nil {
return &chartPrepareResult{err: err}
}
+1
View File
@@ -132,6 +132,7 @@ ${kubectl} create namespace ${test_ns} || fail "Could not create namespace ${tes
. ${dir}/test-cases/issue-2247.sh
. ${dir}/test-cases/issue-2097.sh
. ${dir}/test-cases/issue-2291.sh
. ${dir}/test-cases/issue-851.sh
. ${dir}/test-cases/oci-parallel-pull.sh
. ${dir}/test-cases/issue-2297-local-chart-transformers.sh
. ${dir}/test-cases/issue-2309-kube-context-template.sh
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Test for issue #851: Strategic merge with envelope charts does not work
# Issue: https://github.com/helmfile/helmfile/issues/851
#
# Problem: When an envelope chart's subchart declares its own local file://
# dependency, helm's `dependency build` on the parent chart fetches the subchart
# and packages it as .tgz, but does NOT recursively build the subchart's own
# deps first. The resulting .tgz lacks the nested subchart's resources. chartify's
# helm template silently drops those resources, and strategicMergePatches/jsonPatches
# targeting them fail with "no resource matches".
#
# Fix: preBuildTransitiveSubchartDeps walks the transitive file:// dep tree and
# runs `helm dependency build` bottom-up on each subchart source dir before
# chartify runs, so every subchart .tgz includes its own nested deps.
issue_851_input_dir="${cases_dir}/issue-851/input"
# Copy the chart tree to a temp dir so the test doesn't leave generated
# Chart.lock / charts/*.tgz files in the repo. preBuildTransitiveSubchartDeps
# runs `helm dependency build` on subchart source directories, which creates
# those artifacts. The test must not pollute the source tree.
issue_851_tmp_dir=$(mktemp -d)
cp -r "${issue_851_input_dir}/." "${issue_851_tmp_dir}/"
cleanup_issue_851() {
rm -rf "${issue_851_tmp_dir}"
}
trap cleanup_issue_851 EXIT
test_start "issue-851: strategic merge patches work with envelope charts"
info "Step 1: Templating envelope chart whose subchart has its own file:// dep"
${helmfile} -f "${issue_851_tmp_dir}/helmfile.yaml" template > "${issue_851_tmp_dir}/templated.yaml" 2>&1
code=$?
if [ $code -ne 0 ]; then
cat "${issue_851_tmp_dir}/templated.yaml"
fail "helmfile template failed — envelope chart with nested subchart patches should succeed"
fi
info "✓ helmfile template succeeded"
# All three ServiceMonitors must be present, including the one defined in sub2's
# OWN dependency (nested). Without the fix, nested-sm is silently dropped.
for expected_name in sub1-sm sub2-sm nested-sm; do
if ! grep -q "name: ${expected_name}" "${issue_851_tmp_dir}/templated.yaml"; then
cat "${issue_851_tmp_dir}/templated.yaml"
fail "Expected ServiceMonitor ${expected_name} not found (envelope chart nested subchart resources dropped)"
fi
info "✓ ServiceMonitor ${expected_name} present"
done
# Each ServiceMonitor must reflect its strategicMergePatch.
for expected_port in metrics-sub1 metrics-sub2 metrics-nested; do
if ! grep -q "port: ${expected_port}" "${issue_851_tmp_dir}/templated.yaml"; then
cat "${issue_851_tmp_dir}/templated.yaml"
fail "Patch targeting ${expected_port} was not applied (issue 851 regression)"
fi
done
info "✓ all strategicMergePatches applied (sub1-sm, sub2-sm, nested-sm)"
# The critical assertion: nested-sm's patch applied. Without the fix, the nested
# subchart's resources are missing and this patch fails with "no resource matches".
if ! grep -q "port: metrics-nested" "${issue_851_tmp_dir}/templated.yaml"; then
cat "${issue_851_tmp_dir}/templated.yaml"
fail "Patch for nested-sm was not applied — envelope chart regression (issue 851)"
fi
info "✓ nested-sm patch applied (issue 851 fixed)"
cleanup_issue_851
trap - EXIT
test_pass "issue-851: strategic merge patches work with envelope charts"
@@ -0,0 +1,11 @@
apiVersion: v2
name: envelope
version: 0.1.0
description: Envelope chart whose subchart has its own local file:// dep (issue 851)
dependencies:
- name: sub1
version: 0.1.0
repository: file://../sub1
- name: sub2
version: 0.1.0
repository: file://../sub2
@@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: parent-cm
data:
key: parent-value
@@ -0,0 +1,46 @@
# Test for issue #851: Strategic merge with envelope charts does not work
# https://github.com/helmfile/helmfile/issues/851
#
# Problem: When an envelope chart's subchart declares its own local file://
# dependency, helm's `dependency build` on the parent chart fetches the subchart
# and packages it as .tgz, but does NOT recursively build the subchart's own deps
# first. The resulting .tgz lacks the nested subchart's resources. When chartify
# runs helm template, resources from the nested subchart are silently absent,
# and strategicMergePatches targeting them fail with "no resource matches".
#
# Fix: preBuildTransitiveSubchartDeps walks the transitive file:// dep tree and
# runs `helm dependency build` bottom-up on each subchart source dir before
# chartify runs, so every subchart .tgz includes its own nested deps.
releases:
- name: envelope
namespace: test
chart: ./envelope
strategicMergePatches:
- apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: sub1-sm
spec:
endpoints:
- port: metrics-sub1
interval: 10s
- apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: sub2-sm
spec:
endpoints:
- port: metrics-sub2
interval: 15s
# The critical patch: targets a resource defined in sub2's OWN dependency
# (nested). Without the fix, this resource is dropped from chartify's
# patching set and the patch fails with "no resource matches".
- apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: nested-sm
spec:
endpoints:
- port: metrics-nested
interval: 20s
@@ -0,0 +1,4 @@
apiVersion: v2
name: nested
version: 0.1.0
description: Deepest nested subchart — its resources are silently dropped without the fix
@@ -0,0 +1,8 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: nested-sm
spec:
endpoints:
- port: web
interval: 30s
@@ -0,0 +1,4 @@
apiVersion: v2
name: sub1
version: 0.1.0
description: Leaf subchart (no deps)
@@ -0,0 +1,8 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: sub1-sm
spec:
endpoints:
- port: web
interval: 30s
@@ -0,0 +1,8 @@
apiVersion: v2
name: sub2
version: 0.1.0
description: Subchart with its own local file:// dep (the envelope chart scenario)
dependencies:
- name: nested
version: 0.1.0
repository: file://../nested
@@ -0,0 +1,8 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: sub2-sm
spec:
endpoints:
- port: web
interval: 30s