Files
helmfile/pkg/app/run.go
T
yxxheroandClaude ad81e4231e build: update Go to 1.27.1 and modernize the codebase (#2798)
Toolchain and CI:
- Bump go directive from 1.26.8 to 1.27.1 (go.mod)
- Use golang:1.27-alpine builder images in all Dockerfiles
- Bump golangci-lint to v2.13.2 (first release with go1.27 support)
- Add CI gate: `go fix -diff` fails when outdated Go patterns are
  detected (locally: `make check-modernize`)

Note: darwin binaries now require macOS 13 or later.

Lint fixes required by golangci-lint v2.13.2:
- goconst: ignore tests (all 436 findings were test-only; goconst
  got stricter since v2.12 and this option was added for it)
- openai.go: keep deprecated MaxTokens deliberately with a nolint
  rationale (max_tokens is the only form universally supported by
  OpenAI-compatible backends like One-API, LiteLLM, Ollama shim)
- state.go: drop always-nil flags param from appendChartVersionFlags
  (renamed to chartVersionFlags, unparam)

Modernization (go fix ./..., 62 files):
- interface{} -> any, maps.Copy, strings.SplitSeq, range-over-int,
  builtin min/max, slices.Contains/ContainsFunc/Sort, WaitGroup.Go,
  reflect.Type.Fields(), new(expr)
- exit_error.go: strings.Builder + fmt.Fprintf instead of string
  concatenation and WriteString(fmt.Sprintf(...)) (QF1012)
- chart_dependency.go: strings.CutLast for OCI dependency helpers

Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-16 13:29:02 +08:00

317 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 {
if me, ok := errors.AsType[*MultiError](prepareErr); ok {
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
}