Files
helmfile/pkg/state/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

455 lines
15 KiB
Go

package state
import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/exectest"
"github.com/helmfile/helmfile/pkg/helmexec"
)
const sharedChartHelmfile = `
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
- name: release-c
chart: myrepo/mychart
version: 1.0.0
- name: release-d
chart: myrepo/mychart
version: 1.0.0
- name: release-e
chart: myrepo/mychart
version: 1.0.0
`
const mixedVerifyFlagsHelmfile = `
repositories:
- name: myrepo
url: https://example.com/charts
releases:
- name: release-a
chart: myrepo/mychart
version: 1.0.0
verify: true
- name: release-b
chart: myrepo/mychart
version: 1.0.0
verify: false
`
const uniformVerifyFlagsHelmfile = `
repositories:
- name: myrepo
url: https://example.com/charts
releases:
- name: release-a
chart: myrepo/mychart
version: 1.0.0
verify: true
- name: release-b
chart: myrepo/mychart
version: 1.0.0
verify: true
`
const localStyleChartNoRepoHelmfile = `
releases:
- name: frontend-v2
chart: charts/frontend
- name: frontend-v3
chart: charts/frontend
`
const uniqueChartsHelmfile = `
repositories:
- name: myrepo
url: https://example.com/charts
releases:
- name: release-a
chart: myrepo/chart-a
version: 1.0.0
- name: release-b
chart: myrepo/chart-b
version: 1.0.0
- name: release-c
chart: myrepo/chart-c
version: 1.0.0
`
// TestPrepareChartsPrefetchesSharedRemoteChart verifies that when
// PrefetchSharedRemoteCharts is set, releases sharing a remote chart+version
// trigger exactly one `helm fetch`, and all of them are handed a materialized
// local chart path. This is the fix for issue #2741.
func TestPrepareChartsPrefetchesSharedRemoteChart(t *testing.T) {
resetChartCacheForTest()
logger := zap.NewExample().Sugar()
st, err := createFromYaml([]byte(sharedChartHelmfile), "example/path/to/helmfile.yaml", DefaultEnv, logger)
require.NoError(t, err)
tempDir := t.TempDir()
mockHelm := &mockFetchHelm{Helm: &exectest.Helm{Helm3: true, ChartsMutex: &sync.Mutex{}}}
opts := ChartPrepareOptions{
SkipResolve: true,
PrefetchSharedRemoteCharts: true,
Concurrency: 5,
OutputDirTemplate: "{{ .OutputDir }}/{{ .Release.Name }}",
}
releaseToChart, errs := st.PrepareCharts(mockHelm, tempDir, 5, "sync", opts)
require.Empty(t, errs, "PrepareCharts should not return errors")
assert.Equal(t, int32(1), mockHelm.fetchCount.Load(),
"helm.Fetch must be called exactly once for 5 releases sharing the same chart+version")
require.Len(t, releaseToChart, 5)
var paths []string
for _, name := range []string{"release-a", "release-b", "release-c", "release-d", "release-e"} {
path, ok := releaseToChart[PrepareChartKey{Name: name}]
require.True(t, ok, "release %s should have a prepared chart", name)
assert.NotEqual(t, "myrepo/mychart", path, "release %s should have a materialized local chart path", name)
paths = append(paths, path)
}
for _, p := range paths[1:] {
assert.Equal(t, paths[0], p, "all releases sharing the chart should be prepared to the same local path")
}
}
// TestPrepareChartsSkipsPrefetchForUniqueCharts verifies that
// PrefetchSharedRemoteCharts leaves charts used by only one release
// untouched: no forced download, no local ChartPath materialized. Preserves
// current behavior for the common single-release-per-chart case.
func TestPrepareChartsSkipsPrefetchForUniqueCharts(t *testing.T) {
resetChartCacheForTest()
logger := zap.NewExample().Sugar()
st, err := createFromYaml([]byte(uniqueChartsHelmfile), "example/path/to/helmfile.yaml", DefaultEnv, logger)
require.NoError(t, err)
tempDir := t.TempDir()
mockHelm := &mockFetchHelm{Helm: &exectest.Helm{Helm3: true, ChartsMutex: &sync.Mutex{}}}
opts := ChartPrepareOptions{
SkipResolve: true,
PrefetchSharedRemoteCharts: true,
Concurrency: 3,
OutputDirTemplate: "{{ .OutputDir }}/{{ .Release.Name }}",
}
releaseToChart, errs := st.PrepareCharts(mockHelm, tempDir, 3, "sync", opts)
require.Empty(t, errs, "PrepareCharts should not return errors")
assert.Equal(t, int32(0), mockHelm.fetchCount.Load(),
"helm.Fetch must not be called for charts used by only one release")
for name, chart := range map[string]string{
"release-a": "myrepo/chart-a",
"release-b": "myrepo/chart-b",
"release-c": "myrepo/chart-c",
} {
path, ok := releaseToChart[PrepareChartKey{Name: name}]
require.True(t, ok)
assert.Equal(t, chart, path, "unique chart should be handed to helm unmodified, not materialized locally")
}
}
// TestPrepareChartsSkipsPrefetchWhenFetchFlagsDiffer verifies that releases
// sharing a chart+version are NOT prefetched if they resolve to different
// chart acquisition flags (verify/keyring/plain-http/insecure-skip-tls-verify).
// The download cache is keyed by chart+version alone, so deduplicating a
// fetch across releases with different --verify settings would let whichever
// release's worker wins the race silently decide verification for the other.
func TestPrepareChartsSkipsPrefetchWhenFetchFlagsDiffer(t *testing.T) {
resetChartCacheForTest()
logger := zap.NewExample().Sugar()
st, err := createFromYaml([]byte(mixedVerifyFlagsHelmfile), "example/path/to/helmfile.yaml", DefaultEnv, logger)
require.NoError(t, err)
tempDir := t.TempDir()
mockHelm := &mockFetchHelm{Helm: &exectest.Helm{Helm3: true, ChartsMutex: &sync.Mutex{}}}
opts := ChartPrepareOptions{
SkipResolve: true,
PrefetchSharedRemoteCharts: true,
Concurrency: 2,
OutputDirTemplate: "{{ .OutputDir }}/{{ .Release.Name }}",
}
releaseToChart, errs := st.PrepareCharts(mockHelm, tempDir, 2, "sync", opts)
require.Empty(t, errs)
assert.Equal(t, int32(0), mockHelm.fetchCount.Load(),
"releases with differing verify/keyring/TLS flags must not be deduplicated into a single fetch")
for _, name := range []string{"release-a", "release-b"} {
path, ok := releaseToChart[PrepareChartKey{Name: name}]
require.True(t, ok)
assert.Equal(t, "myrepo/mychart", path, "chart should be handed to helm unmodified when flags differ across releases")
}
}
// TestPrepareChartsSkipsPrefetchWhenVerifyEnabled verifies that releases
// sharing a chart+version are NOT prefetched when verify is enabled, even
// when every release agrees on identical flags. forcedDownloadChart untars
// the chart into a local directory, but flagsForUpgrade unconditionally
// re-adds --verify for the later `helm upgrade` regardless of ChartPath —
// and Helm's VerifyChart only accepts a packaged .tgz/provenance pair, not
// an unpacked directory, so upgrading a prefetched+verified chart would fail
// with a real helm binary. Confirms both that prefetch is skipped and that
// the resulting flagsForUpgrade output for the affected release still
// carries --verify unchanged, i.e. behaves exactly as it did before this
// feature existed.
func TestPrepareChartsSkipsPrefetchWhenVerifyEnabled(t *testing.T) {
resetChartCacheForTest()
logger := zap.NewExample().Sugar()
st, err := createFromYaml([]byte(uniformVerifyFlagsHelmfile), "example/path/to/helmfile.yaml", DefaultEnv, logger)
require.NoError(t, err)
tempDir := t.TempDir()
mockHelm := &mockFetchHelm{Helm: &exectest.Helm{Helm3: true, ChartsMutex: &sync.Mutex{}}}
opts := ChartPrepareOptions{
SkipResolve: true,
PrefetchSharedRemoteCharts: true,
Concurrency: 2,
OutputDirTemplate: "{{ .OutputDir }}/{{ .Release.Name }}",
}
releaseToChart, errs := st.PrepareCharts(mockHelm, tempDir, 2, "sync", opts)
require.Empty(t, errs)
assert.Equal(t, int32(0), mockHelm.fetchCount.Load(),
"verify-enabled releases must not be prefetched, even with identical flags across the group")
var release *ReleaseSpec
for i := range st.Releases {
if st.Releases[i].Name == "release-a" {
release = &st.Releases[i]
}
}
require.NotNil(t, release)
path, ok := releaseToChart[PrepareChartKey{Name: "release-a"}]
require.True(t, ok)
assert.Equal(t, "myrepo/mychart", path, "chart should be handed to helm unmodified, not materialized locally")
assert.Empty(t, release.ChartPath, "ChartPath must stay unset so withChartOperationLock keeps serializing this chart")
// flagsForUpgrade's own --verify emission is unconditional and untouched by
// this feature (see state.go:4052-4055) — it's driven purely by
// release.Verify/repo.Verify/HelmDefaults.Verify, not by ChartPath. Because
// prefetch was skipped above, that pre-existing logic is exactly what runs
// for this release, exactly as it did before this feature existed.
flags := st.appendVerifyFlags(nil, release)
assert.Contains(t, flags, "--verify", "verify must still reach the upgrade command exactly as before this feature existed")
}
// TestPrepareChartsSkipsPrefetchForUnknownRepoChart verifies that a
// "dir/chart"-shaped chart string with no matching `repositories:` entry
// (the conventional way to reference a local chart, e.g. "charts/frontend")
// is never force-downloaded even when shared by multiple releases. Locks in
// the fix for the regression this caused in pkg/app's diff/sync fixtures,
// where such releases must be handed unmodified to helm/diff, not routed
// through forcedDownloadChart.
func TestPrepareChartsSkipsPrefetchForUnknownRepoChart(t *testing.T) {
resetChartCacheForTest()
logger := zap.NewExample().Sugar()
st, err := createFromYaml([]byte(localStyleChartNoRepoHelmfile), "example/path/to/helmfile.yaml", DefaultEnv, logger)
require.NoError(t, err)
tempDir := t.TempDir()
mockHelm := &mockFetchHelm{Helm: &exectest.Helm{Helm3: true, ChartsMutex: &sync.Mutex{}}}
opts := ChartPrepareOptions{
SkipResolve: true,
PrefetchSharedRemoteCharts: true,
Concurrency: 2,
OutputDirTemplate: "{{ .OutputDir }}/{{ .Release.Name }}",
}
releaseToChart, errs := st.PrepareCharts(mockHelm, tempDir, 2, "diff", opts)
require.Empty(t, errs)
assert.Equal(t, int32(0), mockHelm.fetchCount.Load(),
"a chart string with no matching repository must not be treated as a remote chart")
for _, name := range []string{"frontend-v2", "frontend-v3"} {
path, ok := releaseToChart[PrepareChartKey{Name: name}]
require.True(t, ok)
assert.Equal(t, "charts/frontend", path)
}
}
// concurrencyTrackingHelm wraps exectest.Helm to record the maximum number of
// concurrent SyncRelease/DiffRelease calls observed.
type concurrencyTrackingHelm struct {
*exectest.Helm
syncConcurrent atomic.Int32
syncMax atomic.Int32
diffConcurrent atomic.Int32
diffMax atomic.Int32
}
func bumpMax(cur *atomic.Int32, max *atomic.Int32) {
c := cur.Add(1)
for {
old := max.Load()
if c <= old || max.CompareAndSwap(old, c) {
break
}
}
time.Sleep(20 * time.Millisecond)
cur.Add(-1)
}
func (m *concurrencyTrackingHelm) SyncRelease(context helmexec.HelmContext, name, chart, namespace string, flags ...string) error {
bumpMax(&m.syncConcurrent, &m.syncMax)
return nil
}
func (m *concurrencyTrackingHelm) DiffRelease(context helmexec.HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error {
bumpMax(&m.diffConcurrent, &m.diffMax)
return nil
}
// TestPrefetchedSharedChartAllowsConcurrentSyncRelease is the end-to-end
// regression test for issue #2741: it runs PrepareCharts with
// PrefetchSharedRemoteCharts on 5 releases sharing a remote chart, applies the
// resulting chart paths to each release (mirroring what
// app.Run.WithPreparedCharts does), then drives them through
// withChartOperationLock + SyncRelease exactly like the production sync path
// (see state.go's SyncRelease call sites). Without the prefetch, this would
// serialize to max concurrency 1 (see TestWithChartOperationLockSerializesSameChart);
// with it, all 5 should run concurrently.
func TestPrefetchedSharedChartAllowsConcurrentSyncRelease(t *testing.T) {
resetChartCacheForTest()
logger := zap.NewExample().Sugar()
st, err := createFromYaml([]byte(sharedChartHelmfile), "example/path/to/helmfile.yaml", DefaultEnv, logger)
require.NoError(t, err)
tempDir := t.TempDir()
fetchHelm := &mockFetchHelm{Helm: &exectest.Helm{Helm3: true, ChartsMutex: &sync.Mutex{}}}
opts := ChartPrepareOptions{
SkipResolve: true,
PrefetchSharedRemoteCharts: true,
Concurrency: 5,
OutputDirTemplate: "{{ .OutputDir }}/{{ .Release.Name }}",
}
releaseToChart, errs := st.PrepareCharts(fetchHelm, tempDir, 5, "sync", opts)
require.Empty(t, errs)
require.Equal(t, int32(1), fetchHelm.fetchCount.Load())
// Mirror app.Run.WithPreparedCharts: only set ChartPath when the prepared
// chart differs from the original chart reference.
for i := range st.Releases {
rel := &st.Releases[i]
if chart, ok := releaseToChart[PrepareChartKey{Name: rel.Name}]; ok && chart != rel.Chart {
rel.ChartPath = chart
}
}
trackingHelm := &concurrencyTrackingHelm{Helm: &exectest.Helm{}}
var wg sync.WaitGroup
for i := range st.Releases {
rel := &st.Releases[i]
require.NotEmpty(t, rel.ChartPath, "release %s should have a materialized ChartPath after prefetch", rel.Name)
wg.Add(1)
go func(release *ReleaseSpec) {
defer wg.Done()
_ = st.withChartOperationLock(release, release.Chart, func() error {
return trackingHelm.SyncRelease(helmexec.HelmContext{}, release.Name, release.ChartPath, release.Namespace)
})
}(rel)
}
wg.Wait()
assert.Equal(t, int32(5), trackingHelm.syncMax.Load(),
"prefetched shared-chart releases must run SyncRelease concurrently, not serialized")
}
// TestPrefetchedSharedChartAllowsConcurrentDiffRelease is the DiffRelease
// analog of TestPrefetchedSharedChartAllowsConcurrentSyncRelease, mirroring
// the withChartOperationLock(release, chartPath, DiffRelease) call site in
// prepareDiffReleases.
func TestPrefetchedSharedChartAllowsConcurrentDiffRelease(t *testing.T) {
resetChartCacheForTest()
logger := zap.NewExample().Sugar()
st, err := createFromYaml([]byte(sharedChartHelmfile), "example/path/to/helmfile.yaml", DefaultEnv, logger)
require.NoError(t, err)
tempDir := t.TempDir()
fetchHelm := &mockFetchHelm{Helm: &exectest.Helm{Helm3: true, ChartsMutex: &sync.Mutex{}}}
opts := ChartPrepareOptions{
SkipResolve: true,
PrefetchSharedRemoteCharts: true,
Concurrency: 5,
OutputDirTemplate: "{{ .OutputDir }}/{{ .Release.Name }}",
}
releaseToChart, errs := st.PrepareCharts(fetchHelm, tempDir, 5, "diff", opts)
require.Empty(t, errs)
require.Equal(t, int32(1), fetchHelm.fetchCount.Load())
for i := range st.Releases {
rel := &st.Releases[i]
if chart, ok := releaseToChart[PrepareChartKey{Name: rel.Name}]; ok && chart != rel.Chart {
rel.ChartPath = chart
}
}
trackingHelm := &concurrencyTrackingHelm{Helm: &exectest.Helm{}}
var wg sync.WaitGroup
for i := range st.Releases {
rel := &st.Releases[i]
require.NotEmpty(t, rel.ChartPath)
wg.Add(1)
go func(release *ReleaseSpec) {
defer wg.Done()
_ = st.withChartOperationLock(release, release.ChartPath, func() error {
return trackingHelm.DiffRelease(helmexec.HelmContext{}, release.Name, release.ChartPath, release.Namespace, false)
})
}(rel)
}
wg.Wait()
assert.Equal(t, int32(5), trackingHelm.diffMax.Load(),
"prefetched shared-chart releases must run DiffRelease concurrently, not serialized")
}