Files
helmfile/pkg/state/partial_chart_download_test.go
Johannes Schmidtandyxxhero a219b5dbd2 fix: remove partially extracted chart when helm pull fails (#2805)
extracts in place, so a failed fetch can leave a partial chart in the cache. `forcedDownloadChart` returns the error but never removes the partial directory, and `findChartDirectory` accepts any directory containing a Chart.yaml, so the next run adopts the leftover as a valid cache entry and renders zero manifests without error. Remove the partial download while still holding the exclusive chart lock so the failure stays a failure.

Co-authored-by: yxxhero <aiopsclub@163.com>
Signed-off-by: Johannes Schmidt <jschmidt@canarytechnologies.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
2026-09-23 13:14:13 +08:00

81 lines
2.4 KiB
Go

package state
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/exectest"
"github.com/helmfile/helmfile/pkg/filesystem"
)
// partialFetchHelm simulates `helm pull --untar` dying part-way through the
// extraction: Chart.yaml (the first entry in a chart archive) lands on disk,
// nothing after it does, and the fetch reports an error.
type partialFetchHelm struct {
*exectest.Helm
}
func (h *partialFetchHelm) Fetch(chart string, flags ...string) error {
var untarDir string
for i, f := range flags {
if f == "--untardir" && i+1 < len(flags) {
untarDir = flags[i+1]
break
}
}
if untarDir != "" {
chartDir := filepath.Join(untarDir, "mychart")
if err := os.MkdirAll(chartDir, 0755); err != nil {
return err
}
partial := "apiVersion: v2\nname: mychart\nversion: 1.0.0\n"
if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(partial), 0644); err != nil {
return err
}
}
return errors.New("fetch failed")
}
// A failed fetch must not leave a partially extracted chart in the cache.
//
// Chart.yaml is the first entry in a chart archive, so an interrupted extraction
// typically leaves a complete Chart.yaml and nothing else. findChartDirectory
// accepts any directory containing a Chart.yaml, so such leftovers are adopted as
// a valid cache entry by the next run -- which then renders zero manifests and
// exits 0. Under --skip-refresh, or anywhere below the shared cache dir where
// isSharedCachePath suppresses refresh, that state is permanent.
func TestForcedDownloadChart_RemovesPartialDownloadOnFetchError(t *testing.T) {
cacheRoot := t.TempDir()
st := &HelmState{
fs: filesystem.DefaultFileSystem(),
logger: zap.NewNop().Sugar(),
Releases: []ReleaseSpec{{Name: "app", Chart: "myrepo/mychart"}},
}
release := &st.Releases[0]
helm := &partialFetchHelm{Helm: &exectest.Helm{}}
_, err := st.forcedDownloadChart("myrepo/mychart", cacheRoot, release, helm, ChartPrepareOptions{
SkipRefresh: true,
})
require.Error(t, err, "a failed fetch must surface as an error")
entries, readErr := os.ReadDir(cacheRoot)
require.NoError(t, readErr)
for _, e := range entries {
if !e.IsDir() {
continue
}
leftover := filepath.Join(cacheRoot, e.Name())
_, findErr := findChartDirectory(leftover)
require.Error(t, findErr,
"partial download at %s was left behind and would be reused as a valid chart", leftover)
}
}