mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 08:15:58 +02:00
* feat: allow helmfile to continue on failed releases Co-authored-by: Peter Honeder <peter.honeder@unwired.at> Signed-off-by: Niklas Ott <niklas.ott@unwired.at> * fix: skip failed-prep releases, complete flag wiring, add tests and docs (#64) Review follow-ups for --allow-failed-releases (#2616): - Track per-release chart preparation failures in PrepareCharts (returned as a map keyed by release) and remove those releases from the state in Run.WithPreparedCharts when --allow-failed-releases is set, so a failed release is never executed against its original, un-prepared chart reference (which could either fail again with a duplicate error or, for charts requiring chartify, bypass patches/dependency modifications and produce an unintended result). All failures are still reported at the end via the aggregated MultiError. - Complete the release identity on error results from prepareChartForRelease so failures are attributed to the correct release. - With --allow-failed-releases, continue building dependencies of the remaining charts when 'helm dep build' fails for one release, and skip the affected releases during execution. - Wire --allow-failed-releases into 'helmfile unittest' and 'helmfile status'; remove the dead flag wiring for write-values and list (both never prepare charts, see commandsSkipChartPrep). - Simplify control flow (guard clauses, errors.As, drop dead code and redundant else branches). - Add end-to-end coverage in pkg/app/issue_2616_test.go and extend the state-level tests; document the flag in docs/cli.md and CHANGELOG.md. Signed-off-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --------- Signed-off-by: Niklas Ott <niklas.ott@unwired.at> Signed-off-by: yxxhero <11087727+yxxhero@users.noreply.github.com> Co-authored-by: Peter Honeder <peter.honeder@unwired.at> Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>
455 lines
15 KiB
Go
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")
|
|
}
|