mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 14:22:56 +02:00
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>
317 lines
10 KiB
Go
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
|
|
}
|