Files
helmfile/pkg/app/run.go
T
yxxhero dca41e8163 fix: skip failed-prep releases, complete flag wiring, add tests and docs
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>
2026-09-01 09:07:26 +08:00

318 lines
10 KiB
Go

package app
import (
"errors"
"fmt"
"os"
"slices"
"sort"
"strings"
"github.com/fatih/color"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/state"
)
type Run struct {
state *state.HelmState
helm helmexec.Interface
ctx *Context
ReleaseToChart map[state.PrepareChartKey]string
Ask func(string) bool
}
func NewRun(st *state.HelmState, helm helmexec.Interface, ctx *Context) (*Run, error) {
if helm == nil {
return nil, fmt.Errorf("Assertion failed: helmexec.Interface must not be nil")
}
if !helm.IsHelm3() && !helm.IsHelm4() {
return nil, fmt.Errorf("helmfile requires helm 3.x or 4.x")
}
return &Run{state: st, helm: helm, ctx: ctx}, nil
}
func (r *Run) askForConfirmation(msg string) bool {
if r.Ask != nil {
return r.Ask(msg)
}
return AskForConfirmation(msg)
}
// commandsSkipChartPrep lists commands that don't prepare or pull charts.
// These commands skip chart preparation, and when skipRepos is true they also
// skip the OCI-only registry login (since no chart pulls need authentication).
// When skipRepos is false, SyncReposOnce still runs normally for all repos.
var commandsSkipChartPrep = []string{"write-values", "list"}
func (r *Run) prepareChartsIfNeeded(helmfileCommand string, dir string, concurrency int, opts state.ChartPrepareOptions) (map[state.PrepareChartKey]string, map[state.PrepareChartKey]error, error) {
// Skip chart preparation for commands that don't need chart pulls
if slices.Contains(commandsSkipChartPrep, strings.ToLower(helmfileCommand)) {
return nil, nil, nil
}
releaseToChart, failedReleases, errs := r.state.PrepareCharts(r.helm, dir, concurrency, helmfileCommand, opts)
if len(errs) > 0 {
if !opts.AllowFailedReleases {
// abort on first error
return nil, nil, fmt.Errorf("%v", errs)
}
// return partial results, along with the per-release errors for the failed ones
return releaseToChart, failedReleases, &MultiError{Errors: errs}
}
return releaseToChart, failedReleases, nil
}
func (r *Run) WithPreparedCharts(helmfileCommand string, opts state.ChartPrepareOptions, f func() []error) error {
if r.ReleaseToChart != nil {
return fmt.Errorf("Run.WithPreparedCharts can be called only once")
}
// Check both CLI options and helmDefaults for skipping repos (issue #2296)
// Both skipDeps and skipRefresh cause repo sync to be skipped because:
// - skipRefresh explicitly means "don't update repos"
// - skipDeps implies "I have all dependencies locally" which means repo data isn't needed
// This matches the CLI behavior where --skip-deps and --skip-refresh both skip repo operations.
// However, OCI registries need `helm registry login` before chart pulls when
// credentials are configured (issue #1847), so when skipRepos is true we still
// perform OCI-only login — but only for commands that actually pull charts.
needsChartPrep := !slices.Contains(commandsSkipChartPrep, strings.ToLower(helmfileCommand))
skipRepos := opts.SkipRepos || r.state.HelmDefaults.SkipDeps || r.state.HelmDefaults.SkipRefresh
if !skipRepos {
if err := r.ctx.SyncReposOnce(r.state, r.helm); err != nil {
return err
}
} else if needsChartPrep {
if err := r.ctx.SyncReposOnce(r.state, r.helm, state.WithOCIOnly()); err != nil {
return err
}
}
// Create tmp directory and bail immediately if it fails
var dir string
if len(opts.OutputDir) == 0 {
tempDir, err := os.MkdirTemp("", "helmfile*")
if err != nil {
return err
}
defer func() {
_ = os.RemoveAll(tempDir)
}()
dir = tempDir
} else {
dir = opts.OutputDir
fmt.Fprintf(os.Stderr, "Charts will be downloaded to: %s\n", dir)
}
if _, err := r.state.TriggerGlobalPrepareEvent(helmfileCommand); err != nil {
return err
}
// Ensure chartify temp directories are always cleaned up, even when chart
// preparation or helm operations fail. The deferred call runs at function
// exit — after f() and TriggerGlobalCleanupEvent — so chartified charts
// remain available for the entire operation lifecycle. See issue #1799.
defer r.state.CleanupChartifyTempDirs()
releaseToChart, failedReleases, prepareErr := r.prepareChartsIfNeeded(helmfileCommand, dir, opts.Concurrency, opts)
// A prepare error with no per-release attribution (state parsing, dependency
// resolution, ...) is a general failure: always abort, even with
// opts.AllowFailedReleases, since there are no processable partial results.
// Otherwise, abort only when partial results are not allowed; the failed
// releases are skipped below and their errors are reported at the end.
if prepareErr != nil && (len(failedReleases) == 0 || !opts.AllowFailedReleases) {
return prepareErr
}
// Releases whose chart preparation failed are removed from the state, so that
// the operation below never executes them against their original, un-prepared
// chart reference. That would either fail again with a duplicate error or,
// worse, bypass chartify modifications (patches, dependencies) and produce an
// unintended result. Their preparation errors are reported via prepareErr.
releases := r.state.Releases
if len(failedReleases) > 0 {
releases = make([]state.ReleaseSpec, 0, len(r.state.Releases))
}
for i := range r.state.Releases {
rel := &r.state.Releases[i]
key := state.PrepareChartKey{
Name: rel.Name,
Namespace: rel.Namespace,
KubeContext: rel.KubeContext,
}
if _, failed := failedReleases[key]; failed {
continue
}
if chart, ok := releaseToChart[key]; ok && chart != rel.Chart {
// The chart has been downloaded and modified by Helmfile (and chartify under the hood).
// We let the later step use the modified version of the chart, located under the `chart` variable,
// instead of the original chart path.
// This way, the later step can use the modified chart without knowing
// if it has been modified or not.
rel.ChartPath = chart
}
if len(failedReleases) > 0 {
releases = append(releases, *rel)
}
}
r.state.Releases = releases
r.ReleaseToChart = releaseToChart
errs := f()
var firstErr error
for _, e := range errs {
if e != nil {
firstErr = e
break
}
}
_, cleanupErr := r.state.TriggerGlobalCleanupEvent(helmfileCommand, firstErr)
if !opts.AllowFailedReleases {
return cleanupErr
}
// merge the preparation and cleanup errors into a single error output
var merged []error
if prepareErr != nil {
var me *MultiError
if errors.As(prepareErr, &me) {
merged = append(merged, me.Errors...)
} else {
merged = append(merged, prepareErr)
}
}
if cleanupErr != nil {
merged = append(merged, fmt.Errorf("error during global cleanup event: %w", cleanupErr))
}
if len(merged) > 0 {
return &MultiError{Errors: merged}
}
return nil
}
func (r *Run) Deps(c DepsConfigProvider) []error {
// Check both CLI options and helmDefaults for skipping repos (issue #2296)
// Both skipDeps and skipRefresh cause repo sync to be skipped (see WithPreparedCharts for rationale).
// OCI registries need login before chart pulls when credentials are
// configured (issue #1847), so skipRepos=true still performs OCI-only login.
skipRepos := c.SkipRepos() || r.state.HelmDefaults.SkipDeps || r.state.HelmDefaults.SkipRefresh
var repoOpts []state.SyncOption
if skipRepos {
repoOpts = append(repoOpts, state.WithOCIOnly())
}
if err := r.ctx.SyncReposOnce(r.state, r.helm, repoOpts...); err != nil {
return []error{err}
}
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
return r.state.UpdateDeps(r.helm, c.IncludeTransitiveNeeds())
}
func (r *Run) Repos(c ReposConfigProvider) error {
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
return r.ctx.SyncReposOnce(r.state, r.helm)
}
func (r *Run) diff(triggerCleanupEvent bool, detailedExitCode bool, c DiffConfigProvider, diffOpts *state.DiffOpts) (*string, map[string]state.ReleaseSpec, map[string]state.ReleaseSpec, []error) {
st := r.state
helm := r.helm
var changedReleases []state.ReleaseSpec
var deletingReleases []state.ReleaseSpec
var planningErrs []error
// TODO Better way to detect diff on only filtered releases
{
changedReleases, planningErrs = st.DiffReleases(helm, c.Values(), c.Concurrency(), detailedExitCode, c.StripTrailingCR(), c.IncludeTests(), c.Suppress(), c.SuppressSecrets(), c.ShowSecrets(), c.NoHooks(), c.SuppressDiff(), triggerCleanupEvent, diffOpts)
var err error
deletingReleases, err = st.DetectReleasesToBeDeletedForSync(helm, st.Releases)
if err != nil {
planningErrs = append(planningErrs, err)
}
}
fatalErrs := []error{}
for _, e := range planningErrs {
switch err := e.(type) {
case *state.ReleaseError:
if err.Code != 2 {
fatalErrs = append(fatalErrs, e)
}
default:
fatalErrs = append(fatalErrs, e)
}
}
if len(fatalErrs) > 0 {
return nil, nil, nil, fatalErrs
}
releasesToBeDeleted := map[string]state.ReleaseSpec{}
for _, r := range deletingReleases {
release := r
id := state.ReleaseToID(&release)
releasesToBeDeleted[id] = release
}
releasesToBeUpdated := map[string]state.ReleaseSpec{}
for _, r := range changedReleases {
release := r
id := state.ReleaseToID(&release)
// If `helm-diff` detected changes but it is not being `helm delete`ed, we should run `helm upgrade`
if _, ok := releasesToBeDeleted[id]; !ok {
releasesToBeUpdated[id] = release
}
}
// sync only when there are changes
if len(releasesToBeUpdated) == 0 && len(releasesToBeDeleted) == 0 {
var msg *string
if c.DetailedExitcode() {
// TODO better way to get the logger
m := "No affected releases"
msg = &m
}
return msg, nil, nil, nil
}
names := []string{}
for _, r := range releasesToBeUpdated {
names = append(names, fmt.Sprintf(" %s (%s) UPDATED", r.Name, r.Chart))
}
for _, r := range releasesToBeDeleted {
releaseToBeDeleted := fmt.Sprintf(" %s (%s) DELETED", r.Name, r.Chart)
if c.Color() {
releaseToBeDeleted = color.RedString(releaseToBeDeleted)
}
names = append(names, releaseToBeDeleted)
}
// Make the output deterministic for testing purpose
sort.Strings(names)
infoMsg := fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
return &infoMsg, releasesToBeUpdated, releasesToBeDeleted, nil
}
func (r *Run) State() *state.HelmState {
return r.state
}
func (r *Run) Helm() helmexec.Interface {
return r.helm
}