mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 17:31:00 +02:00
* 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>
138 lines
4.5 KiB
Go
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")
|
|
}
|