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>
This commit is contained in:
Thomas Hanser
2026-08-15 08:57:58 +08:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 228c8fe24f
commit 2cb878d5a0
5 changed files with 827 additions and 41 deletions
+117 -8
View File
@@ -15,6 +15,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"slices"
"sort"
"strconv"
"strings"
@@ -1526,11 +1527,15 @@ func filterReleasesForBuild(releases []ReleaseSpec) []ReleaseSpec {
type ChartPrepareOptions struct {
ForceDownload bool
SkipRepos bool
SkipDeps bool
SkipRefresh bool
SkipResolve bool
SkipCleanup bool
// PrefetchSharedRemoteCharts forces a single materialized download for any remote chart
// (chart+version) used by more than one selected release, so those releases stop being
// serialized by withChartOperationLock during sync/diff. See issue #2741.
PrefetchSharedRemoteCharts bool
SkipRepos bool
SkipDeps bool
SkipRefresh bool
SkipResolve bool
SkipCleanup bool
// SkipSchemaValidation configures chartify to pass --skip-schema-validation to helm-template run by it.
SkipSchemaValidation bool
// Validate configures chartify to pass --validate to helm-template run by it.
@@ -2043,6 +2048,25 @@ func (st *HelmState) processLocalChart(normalizedChart, dir string, release *Rel
return chartPath, nil
}
// chartFetchFlags builds the `helm fetch` flags for a remote chart download, mirroring
// the flags flagsForUpgrade applies for chart acquisition (version, verify, keyring,
// TLS/plain-http) so a prefetched chart behaves identically to one helm would have
// downloaded itself during `helm upgrade`. See issue #2741.
func (st *HelmState) chartFetchFlags(release *ReleaseSpec) []string {
var flags []string
flags = st.appendChartVersionFlags(flags, release)
// non-OCI chart should be verified here, matching flagsForUpgrade.
if !st.IsOCIChart(release.Chart) {
flags = st.appendVerifyFlags(flags, release)
flags = st.appendKeyringFlags(flags, release)
}
flags = st.appendChartDownloadFlags(flags, release)
return flags
}
// forcedDownloadChart handles forced chart downloads.
// Locks are acquired during download and released immediately after.
// A per-chart+version mutex serializes downloads within the process so that
@@ -2100,8 +2124,7 @@ func (st *HelmState) forcedDownloadChart(chartName, dir string, release *Release
}
// Download the chart
var fetchFlags []string
fetchFlags = st.appendChartVersionFlags(fetchFlags, release)
fetchFlags := st.chartFetchFlags(release)
fetchFlags = append(fetchFlags, "--untar", "--untardir", chartPath)
if err := helm.Fetch(chartName, fetchFlags...); err != nil {
lockResult.Release(st.logger)
@@ -2258,10 +2281,78 @@ func (st *HelmState) PrepareCharts(helm helmexec.Interface, dir string, concurre
releases = filterReleasesForBuild(releases)
}
// Apply -chart override here, before the shared-chart grouping below reads
// release.Chart. It was previously applied later inside prepareChartForRelease,
// which meant grouping ran on each release's original chart and never noticed
// that all of them resolve to the same overridden chart.
if st.OverrideChart != "" {
for i := range releases {
releases[i].Chart = st.OverrideChart
}
}
// Initialize the chartify temp dir tracker before concurrent workers start,
// so that all workers share a single tracker instance. See issue #1799.
st.chartifyTempDirs = &chartifyTempDirTracker{}
// Issue #2741: releases sharing a remote chart+version are otherwise serialized by
// withChartOperationLock for the whole sync/diff, not just the download. Force a
// single materialized download per shared chart key here so release.ChartPath gets
// populated and withChartOperationLock's ChartPath != "" guard skips the lock for them.
// Charts unique to one release are left alone to preserve current behavior.
sharedChartKeys := map[ChartCacheKey]bool{}
if opts.PrefetchSharedRemoteCharts {
type prefetchStats struct {
count int
flagSigs map[string]bool
sampleFlags []string
}
stats := map[ChartCacheKey]*prefetchStats{}
for i := range releases {
release := &releases[i]
key := st.getChartCacheKey(release)
s, ok := stats[key]
if !ok {
s = &prefetchStats{flagSigs: map[string]bool{}}
stats[key] = s
}
s.count++
flags := st.chartFetchFlags(release)
// NUL can't appear in an OS argument, so this join is injective —
// unlike a space-joined signature, it can't collide two different
// flag slices (e.g. a keyring path containing a space and a
// flag-like token) into the same string.
s.flagSigs[strings.Join(flags, "\x00")] = true
s.sampleFlags = flags
}
for key, s := range stats {
// The download cache (checkChartCache/addToChartCache) is keyed by
// chart+version alone, not by acquisition flags. If releases sharing a
// chart+version disagree on --verify/--keyring/--plain-http/
// --insecure-skip-tls-verify/--devel, whichever release's worker wins the
// download race would silently decide those settings for the others. Only
// prefetch when every release sharing this key resolves to identical flags;
// otherwise fall back to today's per-release fetch (still correct, just not
// deduplicated).
//
// Only chart strings that resolve to a configured repository (or an OCI
// reference) are unambiguously remote. A bare "dir/chart"-shaped string with
// no matching `repositories:` entry is conventionally a local chart path
// (e.g. "charts/frontend") and must be left to the existing local-directory
// resolution, not force-fetched as if it were a registry chart.
//
// --verify is also disqualifying: forcedDownloadChart untars the chart, and
// flagsForUpgrade later re-adds --verify for the upgrade itself (it can't
// tell the chart was already verified at fetch time). Helm's VerifyChart only
// accepts a packaged .tgz/provenance pair, not an unpacked directory, so
// upgrading a prefetched chart with verify enabled would fail. Leave verified
// charts on the existing serialized remote-chart path instead.
if s.count > 1 && len(s.flagSigs) == 1 && !slices.Contains(s.sampleFlags, "--verify") && st.isPrefetchEligibleChart(key.Chart) {
sharedChartKeys[key] = true
}
}
}
var prepareChartInfoMutex sync.Mutex
prepareChartInfo := make(map[PrepareChartKey]string, len(releases))
@@ -2284,7 +2375,11 @@ func (st *HelmState) PrepareCharts(helm helmexec.Interface, dir string, concurre
},
func(workerIndex int) {
for release := range jobQueue {
result := st.prepareChartForRelease(release, helm, dir, helmfileCommand, opts, workerIndex)
releaseOpts := opts
if sharedChartKeys[st.getChartCacheKey(release)] {
releaseOpts.ForceDownload = true
}
result := st.prepareChartForRelease(release, helm, dir, helmfileCommand, releaseOpts, workerIndex)
results <- result
}
},
@@ -6096,6 +6191,20 @@ func (st *HelmState) IsOCIChart(chart string) bool {
return repo.OCI
}
// isPrefetchEligibleChart returns true if chart unambiguously refers to a
// remote source: an OCI reference, or a "repo/chart" string whose repo prefix
// matches a configured `repositories:` entry. Used by PrepareCharts to decide
// which shared chart keys are safe to force-download (see issue #2741) -
// local chart paths conventionally shaped like "dir/chart" (e.g.
// "charts/frontend") must not be mistaken for registry references.
func (st *HelmState) isPrefetchEligibleChart(chart string) bool {
if strings.HasPrefix(chart, "oci://") {
return true
}
repo, _ := st.GetRepositoryAndNameFromChartName(chart)
return repo != nil
}
// NeedsRepoUpdate returns true if there are any repositories that require `helm repo update`.
// OCI repositories don't need `helm repo update` as they use `helm registry login` instead.
func (st *HelmState) NeedsRepoUpdate() bool {