Files
helmfile/pkg/app/issue_2741_test.go
T
Thomas HanserandClaude Sonnet 5 2cb878d5a0 fix(#2741): prefetch shared remote charts instead of serializing sync (#2743)
* fix(state): prefetch shared remote charts instead of serializing sync/diff (#2741)

PR #2662 fixed a Windows chart-download race (#768) by wrapping the entire
helm upgrade/diff operation in a per-chart+version lock, not just the
download. Since sync/apply/diff never set ForceDownload, releases sharing a
remote chart end up fully serialized even at high --concurrency.

Add ChartPrepareOptions.PrefetchSharedRemoteCharts: PrepareCharts groups
selected releases by chart+version, and force-downloads (once) any chart
used by 2+ releases that also resolve to identical acquisition flags
(--verify/--keyring/--plain-http/--insecure-skip-tls-verify/--devel) and to
a configured repository (or OCI ref) - not a bare \"dir/chart\"-shaped local
path. That materializes release.ChartPath, which lets withChartOperationLock's
existing ChartPath != \"\" guard skip the lock, restoring concurrency without
touching the #768 protection for charts that aren't prefetched.

Also add chartFetchFlags to give forcedDownloadChart's \`helm fetch\` the same
verify/keyring/TLS flags flagsForUpgrade already applies, closing a parity
gap that predates this change (affects lint/unittest/pull too).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Thomas Hanser <gh@toms.place>

* fix(state): exclude verify-enabled charts from shared-chart prefetch, use NUL-delimited flag signature

Copilot review on #2741's PR flagged two issues in the shared-chart prefetch
added there:

1. forcedDownloadChart untars a shared chart into a local directory, but
   flagsForUpgrade unconditionally re-adds --verify for the later `helm
   upgrade` regardless of ChartPath. Helm's VerifyChart only accepts a
   packaged .tgz/provenance pair, not an unpacked directory, so upgrading a
   prefetched chart with verify enabled would fail. Exclude --verify from
   prefetch eligibility entirely rather than trying to suppress the later
   flag - those releases just keep the pre-existing serialized-lock
   behavior, unaffected by this feature.

2. The per-key flag-agreement signature joined flags with a space, which
   isn't injective: a keyring path containing a space and a flag-like token
   could collide with a different keyring plus a real flag, silently
   deduplicating releases with different acquisition settings. Join with
   NUL instead, which can't appear in an OS argument.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Thomas Hanser <gh@toms.place>

* fix(state): apply -chart override before shared-chart grouping

Copilot flagged that PrepareCharts grouped releases by release.Chart
before prepareChartForRelease applied st.OverrideChart (the -chart CLI
flag), so distinct original charts that all resolve to the same
overridden chart were never recognized as shared and missed the
prefetch. Apply the override once upfront, before the grouping loop
reads release.Chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Thomas Hanser <gh@toms.place>

---------

Signed-off-by: Thomas Hanser <gh@toms.place>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 08:57:58 +08:00

138 lines
4.5 KiB
Go

package app
import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"github.com/helmfile/vals"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/exectest"
ffs "github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
)
// prefetchTrackingHelm wraps exectest.Helm to verify the real production
// wiring behind issue #2741 (app.go's PrefetchSharedRemoteCharts:true on
// Diff/Sync/Apply -> state.PrepareCharts -> state.withChartOperationLock):
// it counts Fetch calls and records the chart argument each DiffRelease call
// actually received.
type prefetchTrackingHelm struct {
*exectest.Helm
fetchCount atomic.Int32
mu sync.Mutex
diffedChart map[string]string
}
func (m *prefetchTrackingHelm) Fetch(chart string, flags ...string) error {
untarDir := ""
for i, f := range flags {
if f == "--untardir" && i+1 < len(flags) {
untarDir = flags[i+1]
break
}
}
if untarDir != "" {
chartDir := filepath.Join(untarDir, chart)
_ = os.MkdirAll(chartDir, 0755)
_ = os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte("apiVersion: v2\nname: test\nversion: 1.0.0\n"), 0644)
}
m.fetchCount.Add(1)
return nil
}
func (m *prefetchTrackingHelm) DiffRelease(context helmexec.HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.diffedChart == nil {
m.diffedChart = map[string]string{}
}
m.diffedChart[name] = chart
return nil
}
// TestDiffPrefetchesSharedRemoteChart is the end-to-end regression test for
// issue #2741, exercising the real production wiring rather than a hand
// simulation of it: app.Diff -> run.WithPreparedCharts(...,
// PrefetchSharedRemoteCharts: true) -> state.PrepareCharts ->
// withChartOperationLock -> DiffRelease. Two releases share a chart+version
// from a declared repository; without the fix they'd each hand
// "myrepo/mychart" to helm-diff untouched (safe, but serialized by
// withChartOperationLock). With the fix, the chart is fetched once and both
// releases receive the same materialized local chart path, so
// withChartOperationLock's ChartPath != "" guard lets them run concurrently.
//
// This test uses a real on-disk helmfile.yaml and the real filesystem
// (ffs.DefaultFileSystem()), not the in-memory testhelper.TestFs used by
// sibling tests in this package: the chart-download cache's fast path
// (forcedDownloadChart, see state.go) corroborates a cache hit against disk
// via st.fs.DirectoryExistsAt, and the in-memory TestFs has no way to see
// files a mocked `helm fetch` writes to real disk, which would make every
// call look like a cache miss regardless of this fix.
func TestDiffPrefetchesSharedRemoteChart(t *testing.T) {
tempDir := t.TempDir()
helmfilePath := filepath.Join(tempDir, "helmfile.yaml")
helmfileContent := []byte(`
repositories:
- name: myrepo
url: https://example.com/charts
releases:
- name: release-a
chart: myrepo/mychart
version: 1.0.0
- name: release-b
chart: myrepo/mychart
version: 1.0.0
`)
require.NoError(t, os.WriteFile(helmfilePath, helmfileContent, 0644))
logger := zap.NewExample().Sugar()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
require.NoError(t, err)
helm := &prefetchTrackingHelm{Helm: &exectest.Helm{
ChartsMutex: &sync.Mutex{},
DiffMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}}
app := &App{
OverrideHelmBinary: DefaultHelmBinary,
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
FileOrDir: helmfilePath,
Logger: logger,
fs: ffs.DefaultFileSystem(),
Set: map[string]any{},
helms: map[helmKey]helmexec.Interface{
createHelmKey(DefaultHelmBinary, "default"): helm,
},
valsRuntime: valsRuntime,
}
diffErr := app.Diff(diffConfig{
concurrency: 5,
logger: logger,
})
require.NoError(t, diffErr)
require.Equal(t, int32(1), helm.fetchCount.Load(),
"myrepo/mychart shared by 2 releases must be fetched exactly once")
require.Len(t, helm.diffedChart, 2)
chartA := helm.diffedChart["release-a"]
chartB := helm.diffedChart["release-b"]
require.NotEmpty(t, chartA)
require.NotEqual(t, "myrepo/mychart", chartA, "release-a should receive the materialized local chart path, not the bare chart reference")
require.Equal(t, chartA, chartB, "both releases sharing the chart should receive the identical materialized path")
}