Files
helmfile/pkg/app/app.go
T
Tsukoyachiandyxxhero ca58090af4 feat: add per-release continue-on-error support (#2804)
* feat: add per-release continue-on-error support

Work in progress for discussion #2799.

This introduces the initial release-level configuration needed to
continue processing independent releases after a deployment failure.
The DAG execution and failure propagation behavior are still being
implemented.

Refs #2799

Signed-off-by: Axel Delille <Axel.delille31@gmail.com>

* fix: address review findings for continueOnError

Review fixes for the per-release continueOnError feature:

1. Regenerate values-file ID hashes in pkg/state/temp_test.go — adding
   ContinueOnError to ReleaseSpec shifts generateValuesID hashes, which
   broke TestGenerateID (the test file notes these must be regenerated
   whenever ReleaseSpec changes).

2. Remove the dead skippedErrors variable in withBatches and instead log
   each skipped release with logger.Warnf at decision time, so users see
   why a release never ran instead of only learning from the final error
   list.

3. Aggregate all errors from a state file instead of returning only
   errs[0] in visitStatesWithContext/processStateFileParallel. With
   continueOnError, multiple releases can fail or be skipped in one run;
   reporting only the first error hid the skip errors (and other
   failures), contradicting the feature's contract. Single-error
   rendering is unchanged.

4. Gate tolerated errors on ReleaseErrorCodeFailure so that non-failure
   release errors (e.g. helm-diff's "changes detected" exit code 2 can
   never enable continuation or block dependents.

5. Report skipped-release messages with the dependency's plain release
   name instead of its kubeContext/namespace-qualified needs id
   (e.g. dependency database instead of default/default/database),
   matching the documented message format.

6. Restructure the withBatches helpers into guard-clause style
   (filterBlockedReleases, toleratesBatchErrors) and drop the test-only
   logger nil-guards in favor of a nop logger in tests.

7. Add end-to-end coverage through App.Sync with the exectest fake helm:
   independent releases continue after a tolerated failure, dependents
   are skipped with an explicit error, the exit code stays non-zero, and
   fail-fast remains the default without continueOnError. Also cover the
   non-failure error code case at the withBatches level.

8. Use new(true) instead of a boolPtr helper (CI-enforced check-modernize)
   and document the failure-handling interaction in docs/releases.md.

Signed-off-by: yxxhero <aiopsclub@163.com>
EOF
)
Signed-off-by: yxxhero <aiopsclub@163.com>

---------

Signed-off-by: Axel Delille <Axel.delille31@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-21 07:48:50 +08:00

3041 lines
86 KiB
Go

package app
import (
"bytes"
goContext "context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"text/tabwriter"
"github.com/helmfile/vals"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/argparser"
"github.com/helmfile/helmfile/pkg/cluster"
"github.com/helmfile/helmfile/pkg/envvar"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/plugins"
"github.com/helmfile/helmfile/pkg/remote"
"github.com/helmfile/helmfile/pkg/state"
"github.com/helmfile/helmfile/pkg/telemetry"
)
var CleanWaitGroup sync.WaitGroup
var Cancel goContext.CancelFunc
// App is the main application object.
type App struct {
OverrideKubeContext string
OverrideHelmBinary string
OverrideKustomizeBinary string
EnableLiveOutput bool
StripArgsValuesOnExitError bool
DisableForceUpdate bool
EnforcePluginVerification bool
HelmOCIPlainHTTP bool
RepoRetry int
DisableKubeVersionAutoDetection bool
SequentialHelmfiles bool
Logger *zap.SugaredLogger
Kubeconfig string
Env string
Namespace string
Chart string
Selectors []string
Args string
ValuesFiles []string
Set map[string]any
FileOrDir string
fs *filesystem.FileSystem
remote *remote.Remote
valsRuntime vals.Evaluator
helms map[helmKey]helmexec.Interface
helmsMutex sync.Mutex
ctx goContext.Context
}
type HelmRelease struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Enabled bool `json:"enabled"`
Installed bool `json:"installed"`
Labels string `json:"labels"`
Chart string `json:"chart"`
Version string `json:"version"`
}
func New(conf ConfigProvider) *App {
// telemetry.CommandContext returns context.Background when tracing is
// disabled, so this is behavior-identical to the previous explicit
// Background() while rooting the app context under the command span when
// tracing is on.
ctx := telemetry.CommandContext()
ctx, Cancel = goContext.WithCancel(ctx)
return Init(&App{
OverrideKubeContext: conf.KubeContext(),
OverrideHelmBinary: conf.HelmBinary(),
OverrideKustomizeBinary: conf.KustomizeBinary(),
EnableLiveOutput: conf.EnableLiveOutput(),
StripArgsValuesOnExitError: conf.StripArgsValuesOnExitError(),
DisableForceUpdate: conf.DisableForceUpdate(),
EnforcePluginVerification: conf.EnforcePluginVerification(),
HelmOCIPlainHTTP: conf.HelmOCIPlainHTTP(),
RepoRetry: conf.RepoRetry(),
SequentialHelmfiles: conf.SequentialHelmfiles(),
Logger: conf.Logger(),
Kubeconfig: conf.Kubeconfig(),
Env: conf.Env(),
Namespace: conf.Namespace(),
Chart: conf.Chart(),
Selectors: conf.Selectors(),
Args: conf.Args(),
FileOrDir: conf.FileOrDir(),
ValuesFiles: conf.StateValuesFiles(),
Set: conf.StateValuesSet(),
fs: filesystem.DefaultFileSystem(),
ctx: ctx,
})
}
// WithFileSystem swaps the FileSystem this App reads through while loading state.
func (a *App) WithFileSystem(fs *filesystem.FileSystem) *App {
a.fs = fs
return a
}
func Init(app *App) *App {
var err error
app.valsRuntime, err = plugins.ValsInstance()
if err != nil {
panic(fmt.Sprintf("Failed to initialize vals runtime: %v", err))
}
if app.EnableLiveOutput {
app.Logger.Info("Live output is enabled")
}
return app
}
func (a *App) Init(c InitConfigProvider) error {
runner := &helmexec.ShellRunner{
Logger: a.Logger,
Ctx: a.ctx,
StripArgsValuesOnExitError: a.StripArgsValuesOnExitError,
}
helmfileInit := NewHelmfileInit(a.OverrideHelmBinary, c, a.Logger, runner)
return helmfileInit.Initialize()
}
func (a *App) Deps(c DepsConfigProvider) error {
return a.ForEachState(func(run *Run) (_ bool, errs []error) {
errs = run.Deps(c)
return
}, c.IncludeTransitiveNeeds(), SetFilter(true))
}
func (a *App) Repos(c ReposConfigProvider) error {
return a.ForEachState(func(run *Run) (_ bool, errs []error) {
reposErr := run.Repos(c)
if reposErr != nil {
errs = append(errs, reposErr)
}
return
}, c.IncludeTransitiveNeeds(), SetFilter(true))
}
func (a *App) Diff(c DiffConfigProvider) error {
var allDiffDetectedErrs []error
var affectedAny bool
err := a.ForEachState(func(run *Run) (bool, []error) {
var criticalErrs []error
var msg *string
var matched, affected bool
var errs []error
includeCRDs := !c.SkipCRDs()
prepErr := run.WithPreparedCharts("diff", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
IncludeCRDs: &includeCRDs,
Validate: c.Validate(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeNeeds(),
PrefetchSharedRemoteCharts: true,
}, func() []error {
msg, matched, affected, errs = a.diff(run, c)
return errs
})
if msg != nil {
a.Logger.Info(*msg)
}
if prepErr != nil {
errs = append(errs, prepErr)
}
affectedAny = affectedAny || affected
for i := range errs {
switch e := errs[i].(type) {
case *state.ReleaseError:
switch e.Code {
case 2:
// See https://github.com/roboll/helmfile/issues/874
allDiffDetectedErrs = append(allDiffDetectedErrs, e)
default:
criticalErrs = append(criticalErrs, e)
}
default:
criticalErrs = append(criticalErrs, e)
}
}
return matched, criticalErrs
}, c.IncludeNeeds())
if err != nil {
return err
}
if c.DetailedExitcode() && (len(allDiffDetectedErrs) > 0 || affectedAny) {
// We take the first release error w/ exit status 2 (although all the deferred errs should have exit status 2)
// to just let helmfile itself to exit with 2
// See https://github.com/roboll/helmfile/issues/749
code := 2
e := &Error{
msg: "Identified at least one change",
code: &code,
}
return e
}
return nil
}
func (a *App) Template(c TemplateConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
includeCRDs := c.IncludeCRDs()
// Live output should never be enabled for the "template" subcommand to avoid breaking `helmfile template | kubectl apply -f -`
run.helm.SetEnableLiveOutput(false)
// Reset helm extra args to not pollute BuildDeps() and AddRepo() on subsequent helmfiles
// https://github.com/helmfile/helmfile/issues/1749
run.helm.SetExtraArgs()
prepErr := run.WithPreparedCharts("template", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
IncludeCRDs: &includeCRDs,
SkipCleanup: c.SkipCleanup(),
Validate: c.Validate(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeNeeds(),
Set: c.Set(),
Values: c.Values(),
KubeVersion: c.KubeVersion(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
TemplateArgs: c.TemplateArgs(),
}, func() []error {
ok, errs = a.template(run, c)
return errs
})
if prepErr != nil {
errs = append(errs, prepErr)
}
return
}, c.IncludeNeeds())
}
func (a *App) WriteValues(c WriteValuesConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
prepErr := run.WithPreparedCharts("write-values", state.ChartPrepareOptions{
// Note: "write-values" never prepares charts (see commandsSkipChartPrep
// in run.go), so AllowFailedReleases does not apply here.
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
SkipCleanup: c.SkipCleanup(),
Concurrency: c.Concurrency(),
}, func() []error {
ok, errs = a.writeValues(run, c)
return errs
})
if prepErr != nil {
errs = append(errs, prepErr)
}
return
}, c.IncludeTransitiveNeeds(), SetFilter(true))
}
type MultiError struct {
Errors []error
}
func (e *MultiError) Error() string {
indent := func(text string, indent string) string {
lines := strings.Split(text, "\n")
var buf bytes.Buffer
for _, l := range lines {
buf.WriteString(indent)
buf.WriteString(l)
buf.WriteString("\n")
}
return buf.String()
}
lines := []string{fmt.Sprintf("Failed with %d errors:", len(e.Errors))}
for i, err := range e.Errors {
lines = append(lines, fmt.Sprintf("Error %d:\n\n%v", i+1, indent(err.Error(), " ")))
}
return strings.Join(lines, "\n\n")
}
func (a *App) Lint(c LintConfigProvider) error {
var deferredLintErrors []error
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
var lintErrs []error
// `helm lint` on helm v2 and v3 does not support remote charts, that we need to set `forceDownload=true` here
prepErr := run.WithPreparedCharts("lint", state.ChartPrepareOptions{
ForceDownload: true,
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipCleanup: c.SkipCleanup(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeNeeds(),
}, func() []error {
ok, lintErrs, errs = a.lint(run, c)
return append(errs, lintErrs...)
})
if prepErr != nil {
errs = append(errs, prepErr)
}
if len(lintErrs) > 0 {
deferredLintErrors = append(deferredLintErrors, lintErrs...)
}
return
}, c.IncludeNeeds())
if err != nil {
return err
}
if len(deferredLintErrors) > 0 {
return &MultiError{Errors: deferredLintErrors}
}
return nil
}
func (a *App) Unittest(c UnittestConfigProvider) error {
var deferredUnittestErrors []error
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
var unittestErrs []error
// helm unittest needs local charts, so force download
prepErr := run.WithPreparedCharts("unittest", state.ChartPrepareOptions{
ForceDownload: true,
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipCleanup: c.SkipCleanup(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(),
}, func() []error {
ok, unittestErrs, errs = a.unittest(run, c)
return append(errs, unittestErrs...)
})
if prepErr != nil {
errs = append(errs, prepErr)
}
if len(unittestErrs) > 0 {
deferredUnittestErrors = append(deferredUnittestErrors, unittestErrs...)
}
return
}, c.IncludeNeeds())
if err != nil {
return err
}
if len(deferredUnittestErrors) > 0 {
return &MultiError{Errors: deferredUnittestErrors}
}
return nil
}
func (a *App) Fetch(c FetchConfigProvider) error {
if c.WriteOutput() && c.OutputDir() == "" {
return fmt.Errorf("--output-dir is required when --write-output is set")
}
if c.WriteOutput() {
// Force sequential processing to ensure YAML documents are emitted in order
// without interleaving when multiple helmfile state files are processed.
// Restore the original value when Fetch returns so the App instance is not
// permanently mutated (important for tests and library usage).
prev := a.SequentialHelmfiles
a.SequentialHelmfiles = true
defer func() { a.SequentialHelmfiles = prev }()
}
// processedStateFileCount tracks how many state files have been processed when
// --write-output is set; used to detect multi-file inputs early and return
// a clear error instead of silently producing semantically incorrect YAML.
var processedStateFileCount int
// yamlOutput buffers the generated YAML document so that nothing is written to
// stdout until ForEachState completes successfully. This prevents partial/corrupted
// output reaching stdout when a later state file (or chart download error) causes
// the operation to fail.
var yamlOutput strings.Builder
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
if c.WriteOutput() {
processedStateFileCount++
if processedStateFileCount > 1 {
return false, []error{fmt.Errorf(
"--write-output requires a single helmfile state file, but multiple were found; " +
"use -f to specify a single helmfile instead of a directory or a helmfile with nested helmfiles: entries",
)}
}
// Disable live output to avoid Helm progress/status lines being streamed
// to stdout and corrupting the YAML document emitted by --write-output.
// Restore the original value when this callback returns so the cached helm
// exec instance is not permanently mutated (important for tests and library usage).
run.helm.SetEnableLiveOutput(false)
defer run.helm.SetEnableLiveOutput(a.EnableLiveOutput)
}
prepErr := run.WithPreparedCharts("pull", state.ChartPrepareOptions{
ForceDownload: true,
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipDeps: c.SkipDeps(),
OutputDir: c.OutputDir(),
OutputDirTemplate: c.OutputDirTemplate(),
Concurrency: c.Concurrency(),
}, func() []error {
if c.WriteOutput() {
for i := range run.state.Releases {
rel := &run.state.Releases[i]
if rel.ChartPath != "" {
rel.Chart = rel.ChartPath
rel.ChartPath = ""
}
}
stateYaml, yamlErr := run.state.ToYaml()
if yamlErr != nil {
return []error{yamlErr}
}
sourceFile, pathErr := run.state.FullFilePath()
if pathErr != nil {
return []error{pathErr}
}
fmt.Fprintf(&yamlOutput, "---\n# Source: %s\n\n%s", sourceFile, stateYaml)
}
return nil
})
if prepErr != nil {
errs = append(errs, prepErr)
}
return ok, errs
}, false, SetFilter(true))
if err == nil && c.WriteOutput() {
fmt.Print(yamlOutput.String())
}
return err
}
func (a *App) Sync(c SyncConfigProvider) error {
var any bool
mut := &sync.Mutex{}
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
includeCRDs := !c.SkipCRDs()
prepErr := run.WithPreparedCharts("sync", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
Wait: c.Wait(),
WaitRetries: c.WaitRetries(),
WaitForJobs: c.WaitForJobs(),
IncludeCRDs: &includeCRDs,
IncludeTransitiveNeeds: c.IncludeNeeds(),
Validate: c.Validate(),
Concurrency: c.Concurrency(),
TemplateArgs: c.TemplateArgs(),
PrefetchSharedRemoteCharts: true,
}, func() []error {
matched, updated, es := a.SyncState(run, c)
mut.Lock()
any = any || updated
mut.Unlock()
ok = matched
errs = es
return errs
})
if prepErr != nil {
errs = append(errs, prepErr)
}
return
}, c.IncludeNeeds())
if err != nil {
return err
}
if ec, ok := c.(interface{ DetailedExitcode() bool }); ok && ec.DetailedExitcode() && any {
code := 2
return &Error{msg: "", code: &code}
}
return nil
}
func (a *App) Apply(c ApplyConfigProvider) error {
var any bool
mut := &sync.Mutex{}
var opts []LoadOption
opts = append(opts, SetRetainValuesFiles(c.SkipCleanup()))
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
includeCRDs := !c.SkipCRDs()
prepErr := run.WithPreparedCharts("apply", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
Wait: c.Wait(),
WaitRetries: c.WaitRetries(),
WaitForJobs: c.WaitForJobs(),
IncludeCRDs: &includeCRDs,
SkipCleanup: c.SkipCleanup(),
Validate: c.Validate(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeNeeds(),
TemplateArgs: c.TemplateArgs(),
PrefetchSharedRemoteCharts: true,
}, func() []error {
matched, updated, es := a.apply(run, c)
mut.Lock()
any = any || updated
mut.Unlock()
ok, errs = matched, es
return errs
})
if prepErr != nil {
errs = append(errs, prepErr)
}
return
}, c.IncludeNeeds(), opts...)
if err != nil {
return err
}
if c.DetailedExitcode() && any {
code := 2
return &Error{msg: "", Errors: nil, code: &code}
}
return nil
}
func (a *App) Status(c StatusesConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
err := run.WithPreparedCharts("status", state.ChartPrepareOptions{
SkipRepos: true,
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: true,
Concurrency: c.Concurrency(),
}, func() []error {
ok, errs = a.status(run, c)
return errs
})
if err != nil {
errs = append(errs, err)
}
return
}, false, SetFilter(true))
}
func (a *App) Destroy(c DestroyConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
if !c.SkipCharts() {
err := run.WithPreparedCharts("destroy", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
Concurrency: c.Concurrency(),
DeleteWait: c.DeleteWait(),
DeleteTimeout: c.DeleteTimeout(),
}, func() []error {
ok, errs = a.delete(run, true, c)
return errs
})
if err != nil {
errs = append(errs, err)
}
} else {
ok, errs = a.delete(run, true, c)
}
return
}, false, SetReverse(true))
}
func (a *App) Test(c TestConfigProvider) error {
return a.ForEachState(func(run *Run) (_ bool, errs []error) {
if c.Cleanup() {
a.Logger.Warnf("warn: requested cleanup will not be applied. " +
"To clean up test resources with Helm 3, you have to remove them manually " +
"or set helm.sh/hook-delete-policy\n")
}
err := run.WithPreparedCharts("test", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
Concurrency: c.Concurrency(),
}, func() []error {
errs = a.test(run, c)
return errs
})
if err != nil {
errs = append(errs, err)
}
return
}, false, SetFilter(true))
}
func (a *App) PrintDAGState(c DAGConfigProvider) error {
var err error
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
err = run.WithPreparedCharts("show-dag", state.ChartPrepareOptions{
SkipRepos: true,
SkipDeps: true,
Concurrency: 2,
}, func() []error {
err = a.dag(run)
if err != nil {
errs = append(errs, err)
}
return errs
})
return ok, errs
}, false, SetFilter(true))
}
func (a *App) PrintState(c StateConfigProvider) error {
return a.ForEachState(func(run *Run) (_ bool, errs []error) {
err := run.WithPreparedCharts("build", state.ChartPrepareOptions{
SkipRepos: true,
SkipDeps: true,
Concurrency: 2,
}, func() []error {
if c.EmbedValues() {
for i := range run.state.Releases {
r := run.state.Releases[i]
values, err := run.state.LoadYAMLForEmbedding(&r, r.Values, r.MissingFileHandler, r.ValuesPathPrefix)
if err != nil {
errs = []error{err}
return errs
}
run.state.Releases[i].Values = values
secrets, err := run.state.LoadYAMLForEmbedding(&r, r.Secrets, r.MissingFileHandler, r.ValuesPathPrefix)
if err != nil {
errs = []error{err}
return errs
}
run.state.Releases[i].Secrets = secrets
}
}
stateYaml, err := run.state.ToYaml()
if err != nil {
errs = []error{err}
return errs
}
sourceFile, err := run.state.FullFilePath()
if err != nil {
errs = []error{err}
return errs
}
fmt.Printf("---\n# Source: %s\n\n%+v", sourceFile, stateYaml)
errs = []error{}
return errs
})
if err != nil {
errs = append(errs, err)
}
return false, errs
}, false, SetFilter(true))
}
func (a *App) dag(r *Run) error {
st := r.state
batches, err := st.PlanReleases(state.PlanOptions{SelectedReleases: st.Releases, Reverse: false, SkipNeeds: false, IncludeNeeds: true, IncludeTransitiveNeeds: true})
if err != nil {
return err
}
fmt.Print(printDAG(batches))
return nil
}
func (a *App) ListReleases(c ListConfigProvider) error {
// Collect the per-state results under a mutex. ForEachState may visit
// states concurrently, and a bounded channel that is drained only after the
// visit finishes blocks forever once more states carry releases than the
// buffer can hold (the previous 100-slot channel deadlocked at 101 states).
var (
releasesMu sync.Mutex
releases []*HelmRelease
)
err := a.ForEachState(func(run *Run) (_ bool, errs []error) {
var stateReleases []*HelmRelease
var listErr error
if !c.SkipCharts() {
prepErr := run.WithPreparedCharts("list", state.ChartPrepareOptions{
// Note: "list" never prepares charts (see commandsSkipChartPrep in
// run.go), so AllowFailedReleases does not apply here.
SkipRepos: true,
SkipDeps: true,
Concurrency: 2,
}, func() []error {
rel, err := a.list(run)
if err != nil {
errs = append(errs, err)
return []error{err}
}
stateReleases = rel
return nil
})
if prepErr != nil {
errs = append(errs, prepErr)
}
} else {
stateReleases, listErr = a.list(run)
if listErr != nil {
errs = append(errs, listErr)
}
}
if len(stateReleases) > 0 {
releasesMu.Lock()
releases = append(releases, stateReleases...)
releasesMu.Unlock()
}
return
}, false, SetFilter(true))
if err != nil {
return err
}
// Sort releases to ensure deterministic output order regardless of parallel execution
sort.Slice(releases, func(i, j int) bool {
if releases[i].Namespace != releases[j].Namespace {
return releases[i].Namespace < releases[j].Namespace
}
return releases[i].Name < releases[j].Name
})
if c.Output() == "json" {
err = FormatAsJson(releases)
} else {
err = FormatAsTable(releases)
}
return err
}
func (a *App) list(run *Run) ([]*HelmRelease, error) {
var releases []*HelmRelease
resolvedState, err := run.state.ResolveDeps()
if err != nil {
return nil, fmt.Errorf("unable to resolve dependencies for %s: %w", run.state.FilePath, err)
}
for _, r := range resolvedState.Releases {
labels := ""
if r.Labels == nil {
r.Labels = map[string]string{}
}
var keys []string
for k := range r.Labels {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := r.Labels[k]
labels = fmt.Sprintf("%s,%s:%s", labels, k, v)
}
labels = strings.Trim(labels, ",")
enabled, err := state.ConditionEnabled(r, resolvedState.Values())
if err != nil {
return nil, err
}
releases = append(releases, &HelmRelease{
Name: r.Name,
Namespace: r.Namespace,
Installed: r.Desired(),
Enabled: enabled,
Labels: labels,
Chart: r.Chart,
Version: r.Version,
})
}
return releases, nil
}
func (a *App) within(dir string, do func() error) error {
if dir == "." {
return do()
}
prev, err := a.fs.Getwd()
if err != nil {
return fmt.Errorf("failed getting current working directory: %v", err)
}
absDir, err := a.fs.Abs(dir)
if err != nil {
return err
}
// Skip chdir if we're already in the target directory
if absDir == prev {
return do()
}
a.Logger.Debugf("changing working directory to \"%s\"", absDir)
if err := a.fs.Chdir(absDir); err != nil {
return fmt.Errorf("failed changing working directory to \"%s\": %v", absDir, err)
}
appErr := do()
a.Logger.Debugf("changing working directory back to \"%s\"", prev)
if chdirBackErr := a.fs.Chdir(prev); chdirBackErr != nil {
if appErr != nil {
a.Logger.Warnf("%v", appErr)
}
return fmt.Errorf("failed chaging working directory back to \"%s\": %v", prev, chdirBackErr)
}
return appErr
}
func (a *App) loadDesiredStateFromYaml(file string, opts ...LoadOpts) (*state.HelmState, error) {
return a.loadDesiredStateFromYamlWithBaseDir(file, "", opts...)
}
func (a *App) loadDesiredStateFromYamlWithBaseDir(file string, baseDir string, opts ...LoadOpts) (*state.HelmState, error) {
var op LoadOpts
if len(opts) > 0 {
op = opts[0]
}
// The load span covers remote fetching, rendering, and parsing of one
// state file; render/parse spans attach through the loader's traceCtx.
loadCtx, loadSpan := telemetry.Tracer(telemetry.ScopeHelmfile).Start(a.spanParentCtx(), "helmfile.load",
trace.WithAttributes(attribute.String("helmfile.state_file", file)),
)
defer loadSpan.End()
ld := &desiredStateLoader{
fs: a.fs,
env: a.Env,
namespace: a.Namespace,
chart: a.Chart,
logger: a.Logger,
remote: a.remote,
baseDir: baseDir,
traceCtx: loadCtx,
overrideKubeContext: a.OverrideKubeContext,
overrideHelmBinary: a.OverrideHelmBinary,
overrideKustomizeBinary: a.OverrideKustomizeBinary,
enableLiveOutput: a.EnableLiveOutput,
getHelm: a.getHelm,
valsRuntime: a.valsRuntime,
}
st, err := ld.Load(file, op)
if err != nil {
return nil, err
}
// Per-release spans (pkg/state) parent under the load span.
st.SetTraceContext(loadCtx)
// Kubedog tracking / buffered helm subprocesses cancel with the app.
st.SetCancelContext(a.ctx)
st.SetKubeconfig(a.Kubeconfig)
return st, nil
}
type helmKey struct {
Binary string
Context string
}
func createHelmKey(bin, kubectx string) helmKey {
return helmKey{
Binary: bin,
Context: kubectx,
}
}
// GetHelm returns the global helm exec instance for the specified state that is used for helmfile-wise operation
// like decrypting environment secrets.
//
// This is currently used for running all the helm commands for reconciling releases. But this may change in the future
// once we enable each release to have its own helm binary/version.
func (a *App) getHelm(st *state.HelmState) (helmexec.Interface, error) {
a.helmsMutex.Lock()
defer a.helmsMutex.Unlock()
if a.helms == nil {
a.helms = map[helmKey]helmexec.Interface{}
}
bin := st.DefaultHelmBinary
if bin == "" {
bin = state.DefaultHelmBinary
}
kubeconfig := a.Kubeconfig
kubectx := st.HelmDefaults.KubeContext
key := createHelmKey(bin, kubectx)
if _, ok := a.helms[key]; !ok {
exec, err := helmexec.New(bin, helmexec.HelmExecOptions{
EnableLiveOutput: a.EnableLiveOutput,
DisableForceUpdate: a.DisableForceUpdate,
EnforcePluginVerification: a.EnforcePluginVerification,
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
RepoRetry: a.RepoRetry,
}, a.Logger, kubeconfig, kubectx, &helmexec.ShellRunner{
Logger: a.Logger,
Ctx: a.ctx,
StripArgsValuesOnExitError: a.StripArgsValuesOnExitError,
})
if err != nil {
return nil, err
}
a.helms[key] = exec
}
return a.helms[key], nil
}
// processStateFileParallel processes a single helmfile state file in a goroutine.
// It is used for parallel processing of multiple helmfile.d files.
// Results are communicated via errChan (errors) and matchChan (whether file had matching releases).
func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context, errChan chan error, matchChan chan bool) {
var file string
var dir string
if a.fs.DirectoryExistsAt(relPath) {
file = relPath
dir = relPath
} else {
file = filepath.Base(relPath)
dir = filepath.Dir(relPath)
}
absd, errAbsDir := a.fs.Abs(dir)
if errAbsDir != nil {
errChan <- errAbsDir
return
}
opts := defOpts.DeepCopy()
if opts.CalleePath == "" {
opts.CalleePath = file
}
st, err := a.loadDesiredStateFromYamlWithBaseDir(file, absd, opts)
if err != nil {
switch stateLoadErr := err.(type) {
case *state.StateLoadError:
switch stateLoadErr.Cause.(type) {
case *state.UndefinedEnvError:
return
default:
errChan <- appError(fmt.Sprintf("in %s/%s", dir, file), err)
return
}
default:
errChan <- appError(fmt.Sprintf("in %s/%s", dir, file), err)
return
}
}
if st == nil {
return
}
st.Selectors = opts.Selectors
// Track whether any releases matched across nested helmfiles and converge.
// Aggregate into a single send to matchChan to avoid overfilling the buffer
// (which is sized to len(desiredStateFiles), i.e. one send per goroutine).
anyMatched := false
if len(st.Helmfiles) > 0 && !opts.Reverse {
matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
if err != nil {
errChan <- err
return
}
if matched {
anyMatched = true
}
}
templated, err := st.ExecuteTemplates()
if err != nil {
errChan <- appError(fmt.Sprintf("in %s/%s: failed executing release templates in \"%s\"", dir, file, file), err)
return
}
var errs []error
CleanWaitGroup.Add(1)
var cleanErr error
defer func() {
defer CleanWaitGroup.Done()
cleanErr = context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs)
}()
processed, errs := converge(templated)
if len(errs) > 0 {
errChan <- aggregateErrors(errs)
return
}
if cleanErr != nil {
errChan <- cleanErr
return
}
if processed {
anyMatched = true
}
if opts.Reverse && len(st.Helmfiles) > 0 {
matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
if err != nil {
errChan <- err
return
}
if matched {
anyMatched = true
}
}
if anyMatched {
matchChan <- true
}
}
// processNestedHelmfiles processes sub-helmfiles referenced from a parent state.
// It returns true if any nested helmfile successfully found matching releases,
// which is used to update the caller's noMatchInHelmfiles tracking.
func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, defOpts, opts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) (bool, error) {
anyMatched := false
// Parent repo names are constant across sub-helmfiles; compute once for the
// "did you mean inherits: [repositories]?" footgun warning. These come from
// st.Repositories — the parent's *effective* set, which includes repositories
// brought in via bases: or inherits:, not just those declared inline here.
parentRepoNames := make([]string, 0, len(st.Repositories))
for _, r := range st.Repositories {
parentRepoNames = append(parentRepoNames, r.Name)
}
for i, m := range st.Helmfiles {
if subhelmfileSelectorsConflict(a.Selectors, m, a.Logger) {
a.Logger.Debugf("skipping subhelmfile %q: CLI selectors %v conflict with subhelmfile selectors %v", m.Path, a.Selectors, m.Selectors)
continue
}
optsForNestedState := LoadOpts{
CalleePath: filepath.Join(absd, file),
Environment: m.Environment,
Reverse: defOpts.Reverse,
RetainValuesFiles: defOpts.RetainValuesFiles,
}
if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited {
optsForNestedState.Selectors = opts.Selectors
} else {
optsForNestedState.Selectors = m.Selectors
}
// Carry parent config requested via `inherits:`, and parent repo names
// for the warning, down to the sub-helmfile load path.
optsForNestedState.ParentRepoNames = parentRepoNames
if len(m.Inherits) > 0 {
inherited, err := st.BuildInheritedConfig(m.Inherits)
if err != nil {
return anyMatched, appError(fmt.Sprintf("in .helmfiles[%d]", i), err)
}
optsForNestedState.Inherited = inherited
}
if err := a.visitStatesWithContext(m.Path, optsForNestedState, converge, sharedCtx); err != nil {
switch err.(type) {
case *NoMatchingHelmfileError:
default:
return anyMatched, appError(fmt.Sprintf("in .helmfiles[%d]", i), err)
}
} else {
anyMatched = true
}
}
return anyMatched, nil
}
// subhelmfileSelectorsConflict returns true when the subhelmfile has explicit
// selectors that are provably incompatible with the CLI selectors,
// meaning no release could satisfy both. In that case the subhelmfile can be
// safely skipped without loading or evaluating it.
// Only CLI selectors (not inherited parent selectors) are used for comparison,
// so this optimization is restricted to cases where the user explicitly
// provided selectors via the command line (e.g. -l name=b).
func subhelmfileSelectorsConflict(cliSelectors []string, m state.SubHelmfileSpec, logger *zap.SugaredLogger) bool {
if len(cliSelectors) == 0 || len(m.Selectors) == 0 || m.SelectorsInherited {
return false
}
compatible, err := state.SelectorsAreCompatible(cliSelectors, m.Selectors)
if err != nil {
logger.Debugf("selector compatibility check failed for subhelmfile %q: %v", m.Path, err)
}
return !compatible
}
func (a *App) visitStatesWithContext(fileOrDir string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) error {
noMatchInHelmfiles := true
desiredStateFiles, findErr := a.findDesiredStateFiles(fileOrDir, defOpts)
if findErr != nil {
return appError("", findErr)
}
// Process files in parallel if we have multiple files and parallel mode is enabled
shouldProcessInParallel := len(desiredStateFiles) > 1 && !a.SequentialHelmfiles
if shouldProcessInParallel {
// Parallel processing for multiple files (default behavior)
var wg sync.WaitGroup
errChan := make(chan error, len(desiredStateFiles))
matchChan := make(chan bool, len(desiredStateFiles))
for _, relPath := range desiredStateFiles {
wg.Add(1)
go func(relPath string) {
defer wg.Done()
a.processStateFileParallel(relPath, defOpts, converge, sharedCtx, errChan, matchChan)
}(relPath)
}
wg.Wait()
close(errChan)
close(matchChan)
for err := range errChan {
if err != nil {
return err
}
}
// Check if any files had matching releases
for range matchChan {
noMatchInHelmfiles = false
}
} else {
// Sequential processing for single file or when --sequential-helmfiles is set.
//
// Two strategies for path resolution:
// - Single file: use os.Chdir (via within()) to preserve backward-compatible
// chart path format in output (relative to the helmfile directory).
// - Multiple files with --sequential-helmfiles: use baseDir parameter to avoid
// os.Chdir, which fixes relative env var paths like KUBECONFIG (#2409).
useBaseDir := len(desiredStateFiles) > 1
for _, relPath := range desiredStateFiles {
var file string
var dir string
if a.fs.DirectoryExistsAt(relPath) {
file = relPath
dir = relPath
} else {
file = filepath.Base(relPath)
dir = filepath.Dir(relPath)
}
absd, errAbsDir := a.fs.Abs(dir)
if errAbsDir != nil {
return errAbsDir
}
opts := defOpts.DeepCopy()
if opts.CalleePath == "" {
opts.CalleePath = file
}
processFile := func() (retErr error) {
var st *state.HelmState
var loadErr error
if useBaseDir {
// Multi-file sequential: use absolute baseDir for path resolution
// instead of os.Chdir to avoid breaking relative env var paths.
// Must use absd (absolute dir) to correctly resolve relative values/secrets paths.
st, loadErr = a.loadDesiredStateFromYamlWithBaseDir(file, absd, opts)
} else {
// Single file: CWD is set by within(), load without baseDir
st, loadErr = a.loadDesiredStateFromYaml(file, opts)
}
ctx := context{app: a, st: st, retainValues: defOpts.RetainValuesFiles}
if loadErr != nil {
switch stateLoadErr := loadErr.(type) {
case *state.StateLoadError:
switch stateLoadErr.Cause.(type) {
case *state.UndefinedEnvError:
return nil
default:
return ctx.wrapErrs(loadErr)
}
default:
return ctx.wrapErrs(loadErr)
}
}
if st == nil {
return nil
}
st.Selectors = opts.Selectors
if !opts.Reverse && len(st.Helmfiles) > 0 {
matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
if err != nil {
return err
}
if matched {
noMatchInHelmfiles = false
}
}
templated, tmplErr := st.ExecuteTemplates()
if tmplErr != nil {
return appError(fmt.Sprintf("failed executing release templates in \"%s\"", file), tmplErr)
}
var (
processed bool
errs []error
)
CleanWaitGroup.Add(1)
defer func() {
defer CleanWaitGroup.Done()
cleanErr := context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs)
if retErr == nil {
retErr = cleanErr
} else if cleanErr != nil {
a.Logger.Debugf("Failed to clean up temporary files generated while processing %q: %v", templated.FilePath, cleanErr)
}
}()
processed, errs = converge(templated)
if len(errs) > 0 {
return aggregateErrors(errs)
}
noMatchInHelmfiles = noMatchInHelmfiles && !processed
if opts.Reverse && len(st.Helmfiles) > 0 {
matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
if err != nil {
return err
}
if matched {
noMatchInHelmfiles = false
}
}
return nil
}
var fileErr error
if useBaseDir {
fileErr = processFile()
} else {
fileErr = a.within(absd, processFile)
}
if fileErr != nil {
return appError(fmt.Sprintf("in %s/%s", dir, file), fileErr)
}
}
}
if noMatchInHelmfiles {
return &NoMatchingHelmfileError{selectors: a.Selectors, env: a.Env}
}
return nil
}
type LoadOption func(o *LoadOpts)
var (
SetReverse = func(r bool) func(o *LoadOpts) {
return func(o *LoadOpts) {
o.Reverse = r
}
}
SetRetainValuesFiles = func(r bool) func(o *LoadOpts) {
return func(o *LoadOpts) {
o.RetainValuesFiles = r
}
}
SetFilter = func(f bool) func(o *LoadOpts) {
return func(o *LoadOpts) {
o.Filter = f
}
}
)
// ForEachState iterates over each loaded state file and invokes do.
//
// includeTransitiveNeeds controls whether releases reachable via "needs" are
// un-filtered when SetFilter(true) is used. Despite the name, passing true
// unmarks ALL needs (both direct and transitive) via collectNeedsWithTransitives.
// Callers that support --include-needs should pass c.IncludeNeeds() (which is
// true for both --include-needs and --include-transitive-needs) so that chart
// preparation and early filtering stay consistent. See issue #923.
func (a *App) ForEachState(do func(*Run) (bool, []error), includeTransitiveNeeds bool, o ...LoadOption) error {
ctx := NewContext()
err := a.visitStatesWithSelectorsAndRemoteSupportWithContext(a.FileOrDir, func(st *state.HelmState) (bool, []error) {
helm, err := a.getHelm(st)
if err != nil {
return false, []error{err}
}
run, err := NewRun(st, helm, &ctx)
if err != nil {
return false, []error{err}
}
return do(run)
}, includeTransitiveNeeds, &ctx, o...)
return err
}
func printBatches(batches [][]state.Release) string {
buf := &bytes.Buffer{}
w := new(tabwriter.Writer)
w.Init(buf, 0, 1, 1, ' ', 0)
_, _ = fmt.Fprintln(w, "GROUP\tRELEASES")
for i, batch := range batches {
ids := []string{}
for _, r := range batch {
ids = append(ids, state.ReleaseToID(&r.ReleaseSpec))
}
_, _ = fmt.Fprintf(w, "%d\t%s\n", i+1, strings.Join(ids, ", "))
}
_ = w.Flush()
return buf.String()
}
func printDAG(batches [][]state.Release) string {
buf := &bytes.Buffer{}
w := new(tabwriter.Writer)
w.Init(buf, 0, 1, 1, ' ', 0)
_, _ = fmt.Fprintln(w, "GROUP\tRELEASE\tDEPENDENCIES")
for i, batch := range batches {
for _, r := range batch {
id := state.ReleaseToID(&r.ReleaseSpec)
needs := r.Needs
_, _ = fmt.Fprintf(w, "%d\t%s\t%s\n", i+1, id, strings.Join(needs, ", "))
}
}
_ = w.Flush()
return trimTrailingWhitespace(buf.String())
}
// nolint: unparam
func withDAG(templated *state.HelmState, helm helmexec.Interface, logger *zap.SugaredLogger, opts state.PlanOptions, converge func(*state.HelmState, helmexec.Interface) (bool, []error)) (bool, []error) {
batches, err := templated.PlanReleases(opts)
if err != nil {
return false, []error{err}
}
return withBatches(opts.Purpose, templated, batches, helm, logger, converge)
}
func withBatches(purpose string, templated *state.HelmState, batches [][]state.Release, helm helmexec.Interface, logger *zap.SugaredLogger, converge func(*state.HelmState, helmexec.Interface) (bool, []error)) (bool, []error) {
numBatches := len(batches)
if purpose == "" {
purpose = "processing"
}
logger.Debugf("%s %d groups of releases in this order:\n%s", purpose, numBatches, printBatches(batches))
any := false
var allErrs []error
blockedByID := map[string]blockedRelease{}
for i, batch := range batches {
targets, skippedErrs := filterBlockedReleases(batch, blockedByID, logger)
allErrs = append(allErrs, skippedErrs...)
var releaseIds []string
for _, r := range targets {
releaseIds = append(releaseIds, state.ReleaseToID(&r))
}
logger.Debugf("%s releases in group %d/%d: %s", purpose, i+1, numBatches, strings.Join(releaseIds, ", "))
batchSt := *templated
batchSt.Releases = targets
processed, batchErrs := converge(&batchSt, helm)
allErrs = append(allErrs, batchErrs...)
if toleratesBatchErrors(batchErrs, blockedByID) {
any = any || processed
continue
}
return false, allErrs
}
return any, allErrs
}
// blockedRelease remembers a release that failed or was skipped, so that its
// dependents can be skipped transitively and reported with its plain name
// (rather than the kubeContext/namespace-qualified `needs` id).
type blockedRelease struct {
release *state.ReleaseSpec
err error
}
// filterBlockedReleases partitions a batch into the releases that may still be
// processed and those that must be skipped because one of their `needs`
// dependencies already failed or was skipped. Skipped releases are recorded in
// blockedByID (so that their own dependents are skipped transitively) and
// reported as errors, so that a failed dependency never results in a
// successful command.
func filterBlockedReleases(batch []state.Release, blockedByID map[string]blockedRelease, logger *zap.SugaredLogger) ([]state.ReleaseSpec, []error) {
var targets []state.ReleaseSpec
var skippedErrs []error
for _, marked := range batch {
release := marked.ReleaseSpec
blocked, ok := firstBlockedDependency(&release, blockedByID)
if !ok {
targets = append(targets, release)
continue
}
skippedErr := fmt.Errorf("release %q was skipped because dependency %q failed: %w", release.Name, blocked.release.Name, blocked.err)
skippedErrs = append(skippedErrs, skippedErr)
blockedByID[state.ReleaseToID(&release)] = blockedRelease{release: &release, err: skippedErr}
logger.Warnf("release %q was skipped because dependency %q failed: %v", release.Name, blocked.release.Name, blocked.err)
}
return targets, skippedErrs
}
// toleratesBatchErrors inspects the errors reported while processing a batch.
// Each per-release failure is recorded in blockedByID so that its dependents
// are skipped in subsequent batches. It returns true when every error is a
// release failure whose release opted in to continueOnError, so that
// independent branches of the DAG keep being processed. Any other error (a
// non-release error, or a release failure without continueOnError) is fatal
// and preserves the historical fail-fast behavior.
func toleratesBatchErrors(batchErrs []error, blockedByID map[string]blockedRelease) bool {
for _, err := range batchErrs {
releaseErr := releaseErrorForContinueOnError(err)
if releaseErr == nil {
return false
}
blockedByID[state.ReleaseToID(releaseErr.ReleaseSpec)] = blockedRelease{release: releaseErr.ReleaseSpec, err: err}
if !continueOnErrorEnabled(releaseErr.ReleaseSpec) {
return false
}
}
return true
}
func continueOnErrorEnabled(release *state.ReleaseSpec) bool {
return release != nil && release.ContinueOnError != nil && *release.ContinueOnError
}
func firstBlockedDependency(release *state.ReleaseSpec, blockedByID map[string]blockedRelease) (blockedRelease, bool) {
for _, need := range release.Needs {
if blocked, ok := blockedByID[need]; ok {
return blocked, true
}
}
return blockedRelease{}, false
}
// releaseErrorForContinueOnError returns err as a *state.ReleaseError when it
// is a plain release failure. Non-release errors, and release errors carrying
// a non-failure code (e.g. the diff "changes detected" exit code 2), are not
// tolerable failures and must keep failing fast.
func releaseErrorForContinueOnError(err error) *state.ReleaseError {
var releaseErr *state.ReleaseError
if !errors.As(err, &releaseErr) {
return nil
}
if releaseErr.Code != state.ReleaseErrorCodeFailure {
return nil
}
return releaseErr
}
type Opts struct {
DAGEnabled bool
}
func (a *App) visitStatesWithSelectorsAndRemoteSupportWithContext(fileOrDir string, converge func(*state.HelmState) (bool, []error), includeTransitiveNeeds bool, sharedCtx *Context, opt ...LoadOption) error {
opts := LoadOpts{
Selectors: a.Selectors,
}
for _, o := range opt {
o(&opts)
}
envvals := []any{}
for _, v := range a.ValuesFiles {
envvals = append(envvals, v)
}
if len(envvals) > 0 {
opts.Environment.OverrideValues = envvals
}
if len(a.Set) > 0 {
opts.Environment.OverrideCLISetValues = []any{a.Set}
}
a.remote = remote.NewRemote(a.Logger, "", a.fs)
f := converge
if opts.Filter {
f = func(st *state.HelmState) (bool, []error) {
return processFilteredReleases(st, func(st *state.HelmState) []error {
_, err := converge(st)
return err
},
includeTransitiveNeeds)
}
}
// pre-handles HelmState
fHelmStatsWithOverrides := func(st *state.HelmState) (bool, []error) {
var err error
// override release settings
st.Releases, err = st.GetReleasesWithOverrides()
if err != nil {
return false, []error{err}
}
// override release labels
st.Releases = st.GetReleasesWithLabels()
return f(st)
}
return a.visitStatesWithContext(fileOrDir, opts, fHelmStatsWithOverrides, sharedCtx)
}
func processFilteredReleases(st *state.HelmState, converge func(st *state.HelmState) []error, includeTransitiveNeeds bool) (bool, []error) {
if len(st.Selectors) > 0 {
err := st.FilterReleases(includeTransitiveNeeds)
if err != nil {
return false, []error{err}
}
}
if err := checkDuplicates(st.Releases); err != nil {
return false, []error{err}
}
errs := converge(st)
processed := len(st.Releases) != 0 && len(errs) == 0
return processed, errs
}
func checkDuplicates(releases []state.ReleaseSpec) error {
type Key struct {
Namespace, Name, KubeContext string
}
releaseNameCounts := map[Key]int{}
for _, r := range releases {
namespace := r.Namespace
releaseNameCounts[Key{namespace, r.Name, r.KubeContext}]++
}
for name, c := range releaseNameCounts {
if c > 1 {
var msg string
if name.Namespace != "" {
msg += fmt.Sprintf(" in namespace %q", name.Namespace)
}
if name.KubeContext != "" {
msg += fmt.Sprintf(" in kubecontext %q", name.KubeContext)
}
return fmt.Errorf("duplicate release %q found%s: there were %d releases named \"%s\" matching specified selector", name.Name, msg, c, name.Name)
}
}
return nil
}
func (a *App) Wrap(converge func(*state.HelmState, helmexec.Interface) []error) func(st *state.HelmState, helm helmexec.Interface, includeTransitiveNeeds bool) (bool, []error) {
return func(st *state.HelmState, helm helmexec.Interface, includeTransitiveNeeds bool) (bool, []error) {
return processFilteredReleases(st, func(st *state.HelmState) []error {
return converge(st, helm)
}, includeTransitiveNeeds)
}
}
func (a *App) WrapWithoutSelector(converge func(*state.HelmState, helmexec.Interface) []error) func(st *state.HelmState, helm helmexec.Interface) (bool, []error) {
return func(st *state.HelmState, helm helmexec.Interface) (bool, []error) {
errs := converge(st, helm)
processed := len(st.Releases) != 0 && len(errs) == 0
return processed, errs
}
}
// spanParentCtx returns the context app-layer spans attach to. Tests
// construct App literals without a context, so nil falls back to Background
// (with tracing disabled, span starts are no-ops anyway).
func (a *App) spanParentCtx() goContext.Context {
if a.ctx != nil {
return a.ctx
}
return goContext.Background()
}
func (a *App) findDesiredStateFiles(specifiedPath string, opts LoadOpts) ([]string, error) {
_, span := telemetry.Tracer(telemetry.ScopeHelmfile).Start(a.spanParentCtx(), "helmfile.discover_states",
// specifiedPath is captured before Remote.Locate resolves it; it may
// be a remote reference carrying credentials in userinfo or query.
trace.WithAttributes(attribute.String("helmfile.path", helmexec.RedactedRef(specifiedPath))),
)
defer span.End()
path, err := a.remote.Locate(specifiedPath, "states")
if err != nil {
return nil, fmt.Errorf("locate: %v", err)
}
if specifiedPath != path {
a.Logger.Debugf("fetched remote \"%s\" to local cache \"%s\" and loading the latter...", specifiedPath, path)
}
specifiedPath = path
var helmfileDir string
if specifiedPath != "" {
switch {
case a.fs.FileExistsAt(specifiedPath):
return []string{specifiedPath}, nil
case a.fs.DirectoryExistsAt(specifiedPath):
helmfileDir = specifiedPath
default:
return []string{}, fmt.Errorf("specified state file %s is not found", specifiedPath)
}
} else {
var defaultFile string
DefaultGotmplHelmfile := DefaultHelmfile + ".gotmpl"
if a.fs.FileExistsAt(DefaultHelmfile) && a.fs.FileExistsAt(DefaultGotmplHelmfile) {
return []string{}, fmt.Errorf("both %s and %s.gotmpl exist. Please remove one of them", DefaultHelmfile, DefaultHelmfile)
}
switch {
case a.fs.FileExistsAt(DefaultHelmfile):
defaultFile = DefaultHelmfile
case a.fs.FileExistsAt(DefaultGotmplHelmfile):
defaultFile = DefaultGotmplHelmfile
}
switch {
case a.fs.DirectoryExistsAt(DefaultHelmfileDirectory):
if defaultFile != "" {
return []string{}, fmt.Errorf("configuration conlict error: you can have either %s or %s, but not both", defaultFile, DefaultHelmfileDirectory)
}
helmfileDir = DefaultHelmfileDirectory
case defaultFile != "":
return []string{defaultFile}, nil
default:
return []string{}, fmt.Errorf("no state file found. It must be named %s/*.{yaml,yml,yaml.gotmpl,yml.gotmpl}, %s, or %s, otherwise specified with the --file flag or %s environment variable", DefaultHelmfileDirectory, DefaultHelmfile, DefaultGotmplHelmfile, envvar.FilePath)
}
}
files := []string{}
ymlFiles, err := a.fs.Glob(filepath.Join(helmfileDir, "*.y*ml"))
if err != nil {
return []string{}, err
}
gotmplFiles, err := a.fs.Glob(filepath.Join(helmfileDir, "*.y*ml.gotmpl"))
if err != nil {
return []string{}, err
}
files = append(files, ymlFiles...)
files = append(files, gotmplFiles...)
if opts.Reverse {
sort.Slice(files, func(i, j int) bool {
return files[j] < files[i]
})
} else {
slices.Sort(files)
}
a.Logger.Debugf("found %d helmfile state files in %s: %s", len(ymlFiles)+len(gotmplFiles), helmfileDir, strings.Join(files, ", "))
return files, nil
}
func (a *App) getSelectedReleases(r *Run, includeTransitiveNeeds bool) ([]state.ReleaseSpec, []state.ReleaseSpec, error) {
selected, err := r.state.GetSelectedReleases(includeTransitiveNeeds)
if err != nil {
return nil, nil, err
}
selectedIds := map[string]state.ReleaseSpec{}
selectedCounts := map[string]int{}
for _, r := range selected {
id := state.ReleaseToID(&r)
selectedIds[id] = r
selectedCounts[id]++
if dupCount := selectedCounts[id]; dupCount > 1 {
return nil, nil, fmt.Errorf("found %d duplicate releases with ID %q", dupCount, id)
}
}
allReleases := r.state.Releases
groupsByID := map[string][]*state.ReleaseSpec{}
for _, r := range allReleases {
groupsByID[state.ReleaseToID(&r)] = append(groupsByID[state.ReleaseToID(&r)], &r)
}
var deduplicated []state.ReleaseSpec
dedupedBefore := map[string]struct{}{}
// We iterate over allReleases rather than groupsByID
// to preserve the order of releases
for _, seq := range allReleases {
release := seq
id := state.ReleaseToID(&release)
rs := groupsByID[id]
if len(rs) == 1 {
deduplicated = append(deduplicated, *rs[0])
continue
}
if _, ok := dedupedBefore[id]; ok {
continue
}
// We keep the selected one only when there were two or more duplicate
// releases in the helmfile config.
// Otherwise we can't compute the DAG of releases correctly.
r, deduped := selectedIds[id]
if deduped {
deduplicated = append(deduplicated, r)
dedupedBefore[id] = struct{}{}
}
}
if err := checkDuplicates(deduplicated); err != nil {
return nil, nil, err
}
var extra string
if len(r.state.Selectors) > 0 {
extra = " matching " + strings.Join(r.state.Selectors, ",")
}
a.Logger.Debugf("%d release(s)%s found in %s\n", len(selected), extra, r.state.FilePath)
return selected, deduplicated, nil
}
// GetPlannedAndSelectedReleasesWithNeeds returns the planned releases and the selected releases used for planning.
// The planned releases include dependency releases only when includeNeeds is true and skipNeeds is false.
func (a *App) GetPlannedAndSelectedReleasesWithNeeds(r *Run, skipNeeds bool, includeNeeds bool, includeTransitiveNeeds bool) ([]state.ReleaseSpec, []state.ReleaseSpec, error) {
st := r.state
selectedReleases, selectedAndNeededReleases, err := a.getSelectedReleases(r, includeTransitiveNeeds)
if err != nil {
return nil, nil, err
}
if len(selectedReleases) == 0 {
return nil, nil, nil
}
// This is required when you're trying to deduplicate releases by the selector.
// Without this, `PlanReleases` conflates duplicates and return both in `batches`,
// even if we provided `SelectedReleases: selectedReleases`.
// See https://github.com/roboll/helmfile/issues/1818 for more context.
originalReleases := st.Releases
st.Releases = selectedAndNeededReleases
defer func() {
st.Releases = originalReleases
}()
batches, err := st.PlanReleases(state.PlanOptions{Reverse: false, SelectedReleases: selectedReleases, SkipNeeds: skipNeeds, IncludeNeeds: includeNeeds, IncludeTransitiveNeeds: includeTransitiveNeeds})
if err != nil {
return nil, nil, err
}
var releasesWithNeeds []state.ReleaseSpec
for _, rs := range batches {
for _, r := range rs {
releasesWithNeeds = append(releasesWithNeeds, r.ReleaseSpec)
}
}
return releasesWithNeeds, selectedAndNeededReleases, nil
}
func (a *App) apply(r *Run, c ApplyConfigProvider) (bool, bool, []error) {
st := r.state
helm := r.helm
helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
releasesWithNeeds, selectedAndNeededReleases, err := a.GetPlannedAndSelectedReleasesWithNeeds(r, c.SkipNeeds(), c.IncludeNeeds(), c.IncludeTransitiveNeeds())
if err != nil {
return false, false, []error{err}
}
if len(releasesWithNeeds) == 0 {
return false, false, nil
}
// Do build deps and prepare only on selected releases so that we won't waste time
// on running various helm commands on unnecessary releases
st.Releases = releasesWithNeeds
// helm must be 2.11+ and helm-diff should be provided `--detailed-exitcode` in order for `helmfile apply` to work properly
detailedExitCode := true
detectedKubeVersion := a.detectKubeVersion(st)
diffOpts := &state.DiffOpts{
Color: c.Color(),
NoColor: c.NoColor(),
Context: c.Context(),
Output: c.DiffOutput(),
Set: c.Set(),
SkipCleanup: c.SkipCleanup(),
SkipDiffOnInstall: c.SkipDiffOnInstall(),
SkipDiffValidationOnInstall: c.SkipDiffValidationOnInstall(),
ReuseValues: c.ReuseValues(),
ResetValues: c.ResetValues(),
DiffArgs: c.DiffArgs(),
TemplateArgs: c.TemplateArgs(),
PostRenderer: c.PostRenderer(),
PostRendererArgs: c.PostRendererArgs(),
SkipSchemaValidation: c.SkipSchemaValidation(),
SuppressOutputLineRegex: c.SuppressOutputLineRegex(),
TakeOwnership: c.TakeOwnership(),
ServerSide: c.ServerSide(),
DetectedKubeVersion: detectedKubeVersion,
}
infoMsg, releasesToUpdate, releasesToDelete, diffErrs := r.diff(false, detailedExitCode, c, diffOpts)
if len(diffErrs) > 0 {
return false, false, diffErrs
}
var toDelete []state.ReleaseSpec
for _, r := range releasesToDelete {
toDelete = append(toDelete, r)
}
var toUpdate []state.ReleaseSpec
for _, r := range releasesToUpdate {
toUpdate = append(toUpdate, r)
}
releasesWithNoChange := map[string]state.ReleaseSpec{}
for _, r := range releasesWithNeeds {
release := r
id := state.ReleaseToID(&release)
_, uninstalled := releasesToDelete[id]
_, updated := releasesToUpdate[id]
if !uninstalled && !updated {
releasesWithNoChange[id] = release
}
}
infoMsgStr := ""
if infoMsg != nil {
infoMsgStr = *infoMsg
}
confMsg := fmt.Sprintf(`%s
Do you really want to apply?
Helmfile will apply all your changes, as shown above.
`, infoMsgStr)
interactive := c.Interactive()
if !interactive && infoMsgStr != "" {
a.Logger.Debug(infoMsgStr)
}
var errs []error
// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
st.Releases = selectedAndNeededReleases
if len(releasesToUpdate) == 0 && len(releasesToDelete) == 0 {
return true, false, nil
}
affectedReleases := state.AffectedReleases{}
if !interactive || interactive && r.askForConfirmation(confMsg) {
if _, preapplyErrors := withDAG(st, helm, a.Logger, state.PlanOptions{Purpose: "invoking preapply hooks for", Reverse: true, SelectedReleases: releasesWithNeeds, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
for _, r := range subst.Releases {
release := r
if _, err := st.TriggerPreapplyEvent(&release, "apply"); err != nil {
return []error{err}
}
}
return nil
})); len(preapplyErrors) > 0 {
return true, false, preapplyErrors
}
// We deleted releases by traversing the DAG in reverse order
if len(releasesToDelete) > 0 {
_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{Reverse: true, SelectedReleases: toDelete, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
for _, r := range subst.Releases {
release := r
if r2, ok := releasesToDelete[state.ReleaseToID(&release)]; ok {
rs = append(rs, r2)
}
}
subst.Releases = rs
return subst.DeleteReleasesForSync(&affectedReleases, helm, c.Concurrency(), c.Cascade())
}))
if len(deletionErrs) > 0 {
errs = append(errs, deletionErrs...)
}
}
// We upgrade releases by traversing the DAG
if len(releasesToUpdate) > 0 {
_, updateErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
for _, r := range subst.Releases {
release := r
if r2, ok := releasesToUpdate[state.ReleaseToID(&release)]; ok {
rs = append(rs, r2)
}
}
subst.Releases = rs
syncOpts := &state.SyncOpts{
Set: c.Set(),
SkipCleanup: c.SkipCleanup(),
SkipCRDs: c.SkipCRDs(),
Wait: c.Wait(),
WaitRetries: c.WaitRetries(),
WaitForJobs: c.WaitForJobs(),
Timeout: c.Timeout(),
ReuseValues: c.ReuseValues(),
ResetValues: c.ResetValues(),
PostRenderer: c.PostRenderer(),
PostRendererArgs: c.PostRendererArgs(),
SkipSchemaValidation: c.SkipSchemaValidation(),
SyncArgs: c.SyncArgs(),
HideNotes: c.HideNotes(),
TakeOwnership: c.TakeOwnership(),
ServerSide: c.ServerSide(),
SyncReleaseLabels: c.SyncReleaseLabels(),
TrackMode: c.TrackMode(),
TrackTimeout: c.TrackTimeout(),
TrackLogs: c.TrackLogs(),
TrackFailedLogs: c.TrackFailedLogs(),
HelmStuckGrace: c.HelmStuckGrace(),
TrackFailOnError: c.TrackFailOnError(),
Description: c.Description(),
Color: c.Color(),
NoColor: c.NoColor(),
}
return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), syncOpts)
}))
if len(updateErrs) > 0 {
errs = append(errs, updateErrs...)
}
}
}
affectedReleases.DisplayAffectedReleases(c.Logger(), !c.NoColor())
for id := range releasesWithNoChange {
r := releasesWithNoChange[id]
if _, err := st.TriggerCleanupEvent(&r, "apply"); err != nil {
a.Logger.Warnf("warn: %v\n", err)
}
}
if releasesToDelete == nil && releasesToUpdate == nil {
return true, false, nil
}
return true, true, errs
}
func (a *App) delete(r *Run, purge bool, c DestroyConfigProvider) (bool, []error) {
st := r.state
helm := r.helm
affectedReleases := state.AffectedReleases{}
toSync, _, err := a.getSelectedReleases(r, false)
if err != nil {
return false, []error{err}
}
if len(toSync) == 0 {
return false, nil
}
toDelete, err := st.DetectReleasesToBeDeleted(helm, toSync)
if err != nil {
return false, []error{err}
}
releasesToDelete := map[string]state.ReleaseSpec{}
for _, r := range toDelete {
release := r
id := state.ReleaseToID(&release)
releasesToDelete[id] = release
}
releasesWithNoChange := map[string]state.ReleaseSpec{}
for _, r := range toSync {
release := r
id := state.ReleaseToID(&release)
_, uninstalled := releasesToDelete[id]
if !uninstalled {
releasesWithNoChange[id] = release
}
}
for id := range releasesWithNoChange {
r := releasesWithNoChange[id]
if _, err := st.TriggerCleanupEvent(&r, "delete"); err != nil {
a.Logger.Warnf("warn: %v\n", err)
}
}
names := make([]string, len(toSync))
for i, r := range toSync {
names[i] = fmt.Sprintf(" %s (%s)", r.Name, r.Chart)
}
var errs []error
msg := fmt.Sprintf(`Affected releases are:
%s
Do you really want to delete?
Helmfile will delete all your releases, as shown above.
`, strings.Join(names, "\n"))
interactive := c.Interactive()
if !interactive || interactive && r.askForConfirmation(msg) {
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
if len(releasesToDelete) > 0 {
_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toDelete, Reverse: true, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
return subst.DeleteReleases(&affectedReleases, helm, c.Concurrency(), purge, c.Cascade())
}))
if len(deletionErrs) > 0 {
errs = append(errs, deletionErrs...)
}
}
}
affectedReleases.DisplayAffectedReleases(c.Logger(), !c.NoColor())
return true, errs
}
// detectKubeVersion auto-detects the Kubernetes cluster version if not specified in helmfile.yaml.
// This prevents helm-diff from falling back to v1.20.0 (issue #2275).
// Returns empty string when kubeVersion is already set in helmfile.yaml (not needed),
// when auto-detection is disabled, or if detection fails.
func (a *App) detectKubeVersion(st *state.HelmState) string {
if st.KubeVersion != "" {
return ""
}
// Allow tests to disable auto-detection to avoid connecting to real clusters
if a.DisableKubeVersionAutoDetection {
return ""
}
version, err := cluster.DetectServerVersion(a.Kubeconfig, a.OverrideKubeContext)
if err != nil {
// If detection fails, we silently continue - helm-diff will handle it
return ""
}
return version
}
func (a *App) diff(r *Run, c DiffConfigProvider) (*string, bool, bool, []error) {
var (
infoMsg *string
updated, deleted map[string]state.ReleaseSpec
)
ok, errs := a.withNeeds(r, c, true, func(st *state.HelmState) []error {
helm := r.helm
var errs []error
helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
detectedKubeVersion := a.detectKubeVersion(st)
opts := &state.DiffOpts{
Context: c.Context(),
Output: c.DiffOutput(),
Color: c.Color(),
NoColor: c.NoColor(),
Set: c.Set(),
DiffArgs: c.DiffArgs(),
TemplateArgs: c.TemplateArgs(),
SkipDiffOnInstall: c.SkipDiffOnInstall(),
SkipDiffValidationOnInstall: c.SkipDiffValidationOnInstall(),
ReuseValues: c.ReuseValues(),
ResetValues: c.ResetValues(),
PostRenderer: c.PostRenderer(),
PostRendererArgs: c.PostRendererArgs(),
SkipSchemaValidation: c.SkipSchemaValidation(),
SuppressOutputLineRegex: c.SuppressOutputLineRegex(),
TakeOwnership: c.TakeOwnership(),
ServerSide: c.ServerSide(),
DetectedKubeVersion: detectedKubeVersion,
}
filtered := &Run{
state: st,
helm: helm,
ctx: r.ctx,
Ask: r.Ask,
}
infoMsg, updated, deleted, errs = filtered.diff(true, c.DetailedExitcode(), c, opts)
return errs
})
return infoMsg, ok, len(deleted) > 0 || len(updated) > 0, errs
}
func (a *App) lint(r *Run, c LintConfigProvider) (bool, []error, []error) {
var deferredLintErrs []error
ok, errs := a.withNeeds(r, c, false, func(st *state.HelmState) []error {
helm := r.helm
args := GetArgs(c.Args(), st)
// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `lint`
helm.SetExtraArgs()
if len(args) > 0 {
helm.SetExtraArgs(args...)
}
opts := &state.LintOpts{
Set: c.Set(),
SkipCleanup: c.SkipCleanup(),
}
lintErrs := st.LintReleases(helm, c.Values(), args, c.Concurrency(), opts)
if len(lintErrs) == 1 {
if err, ok := lintErrs[0].(helmexec.ExitError); ok {
if err.Code > 0 {
deferredLintErrs = append(deferredLintErrs, err)
return nil
}
}
}
return lintErrs
})
return ok, deferredLintErrs, errs
}
func (a *App) unittest(r *Run, c UnittestConfigProvider) (bool, []error, []error) {
var deferredUnittestErrs []error
ok, errs := a.withNeeds(r, c, false, func(st *state.HelmState) []error {
helm := r.helm
args := GetArgs(c.Args(), st)
// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `unittest`
helm.SetExtraArgs()
if len(args) > 0 {
helm.SetExtraArgs(args...)
}
opts := &state.UnittestOpts{
Set: c.Set(),
SkipCleanup: c.SkipCleanup(),
FailFast: c.FailFast(),
Color: c.Color(),
DebugPlugin: c.DebugPlugin(),
}
unittestErrs := st.UnittestReleases(helm, c.Values(), args, c.Concurrency(), opts)
if len(unittestErrs) == 1 {
if err, ok := unittestErrs[0].(helmexec.ExitError); ok {
if err.Code > 0 {
deferredUnittestErrs = append(deferredUnittestErrs, err)
return nil
}
}
}
return unittestErrs
})
return ok, deferredUnittestErrs, errs
}
func (a *App) status(r *Run, c StatusesConfigProvider) (bool, []error) {
st := r.state
helm := r.helm
allReleases := st.Releases
selectedReleases, selectedAndNeededReleases, err := a.getSelectedReleases(r, false)
if err != nil {
return false, []error{err}
}
if len(selectedReleases) == 0 {
return false, nil
}
// Do build deps and prepare only on selected releases so that we won't waste time
// on running various helm commands on unnecessary releases
st.Releases = selectedAndNeededReleases
var toStatus []state.ReleaseSpec
for _, r := range selectedReleases {
if r.Desired() {
toStatus = append(toStatus, r)
}
}
var errs []error
// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
st.Releases = allReleases
args := GetArgs(c.Args(), st)
// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `lint`
helm.SetExtraArgs()
if len(args) > 0 {
helm.SetExtraArgs(args...)
}
if len(toStatus) > 0 {
_, templateErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toStatus, Reverse: false, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
return subst.ReleaseStatuses(helm, c.Concurrency())
}))
if len(templateErrs) > 0 {
errs = append(errs, templateErrs...)
}
}
return true, errs
}
func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, bool, []error) {
st := r.state
helm := r.helm
releasesWithNeeds, selectedAndNeededReleases, err := a.GetPlannedAndSelectedReleasesWithNeeds(r, c.SkipNeeds(), c.IncludeNeeds(), c.IncludeTransitiveNeeds())
if err != nil {
return false, false, []error{err}
}
if len(releasesWithNeeds) == 0 {
return false, false, nil
}
// Do build deps and prepare only on selected releases so that we won't waste time
// on running various helm commands on unnecessary releases
st.Releases = releasesWithNeeds
toDelete, err := st.DetectReleasesToBeDeletedForSync(helm, releasesWithNeeds)
if err != nil {
return false, false, []error{err}
}
releasesToDelete := map[string]state.ReleaseSpec{}
for _, r := range toDelete {
release := r
id := state.ReleaseToID(&release)
releasesToDelete[id] = release
}
var toUpdate []state.ReleaseSpec
for _, r := range releasesWithNeeds {
release := r
if _, deleted := releasesToDelete[state.ReleaseToID(&release)]; !deleted {
if r.Desired() {
toUpdate = append(toUpdate, release)
}
// TODO Emit error when the user opted to fail when the needed release is disabled,
// instead of silently ignoring it.
// See https://github.com/roboll/helmfile/issues/1018
}
}
releasesToUpdate := map[string]state.ReleaseSpec{}
for _, r := range toUpdate {
release := r
id := state.ReleaseToID(&release)
releasesToUpdate[id] = release
}
releasesWithNoChange := map[string]state.ReleaseSpec{}
for _, r := range releasesWithNeeds {
release := r
id := state.ReleaseToID(&release)
_, uninstalled := releasesToDelete[id]
_, updated := releasesToUpdate[id]
if !uninstalled && !updated {
releasesWithNoChange[id] = release
}
}
names := []string{}
for _, r := range releasesToUpdate {
names = append(names, fmt.Sprintf(" %s (%s) UPDATED", r.Name, r.Chart))
}
for _, r := range releasesToDelete {
names = append(names, fmt.Sprintf(" %s (%s) DELETED", r.Name, r.Chart))
}
// Make the output deterministic for testing purpose
sort.Strings(names)
interactive := c.Interactive()
var infoMsg string
var errs []error
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
operationsAttempted := false
if interactive {
if diffC, ok := c.(DiffConfigProvider); ok {
detectedKubeVersion := a.detectKubeVersion(st)
diffOpts := &state.DiffOpts{
Context: diffC.Context(),
Output: diffC.DiffOutput(),
Color: diffC.Color(),
NoColor: diffC.NoColor(),
Set: diffC.Set(),
DiffArgs: diffC.DiffArgs(),
TemplateArgs: diffC.TemplateArgs(),
SkipDiffOnInstall: diffC.SkipDiffOnInstall(),
SkipDiffValidationOnInstall: diffC.SkipDiffValidationOnInstall(),
ReuseValues: diffC.ReuseValues(),
ResetValues: diffC.ResetValues(),
PostRenderer: diffC.PostRenderer(),
PostRendererArgs: diffC.PostRendererArgs(),
SkipSchemaValidation: diffC.SkipSchemaValidation(),
SuppressOutputLineRegex: diffC.SuppressOutputLineRegex(),
TakeOwnership: diffC.TakeOwnership(),
ServerSide: diffC.ServerSide(),
DetectedKubeVersion: detectedKubeVersion,
}
infoMsgPtr, _, _, diffErrs := r.diff(false, diffC.DetailedExitcode(), diffC, diffOpts)
if len(diffErrs) > 0 {
return false, false, diffErrs
}
if infoMsgPtr != nil {
infoMsg = *infoMsgPtr
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
}
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
}
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
a.Logger.Debug(infoMsg)
}
confMsg := fmt.Sprintf(`%s
Do you really want to sync?
Helmfile will sync all your releases, as shown above.
`, infoMsg)
// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
st.Releases = selectedAndNeededReleases
affectedReleases := state.AffectedReleases{}
if !interactive || interactive && r.askForConfirmation(confMsg) {
if len(releasesToDelete) > 0 {
operationsAttempted = true
_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{Reverse: true, SelectedReleases: toDelete, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
for _, r := range subst.Releases {
release := r
if r2, ok := releasesToDelete[state.ReleaseToID(&release)]; ok {
rs = append(rs, r2)
}
}
subst.Releases = rs
return subst.DeleteReleasesForSync(&affectedReleases, helm, c.Concurrency(), c.Cascade())
}))
if len(deletionErrs) > 0 {
errs = append(errs, deletionErrs...)
}
}
if len(releasesToUpdate) > 0 {
operationsAttempted = true
_, syncErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
for _, r := range subst.Releases {
release := r
if _, ok := releasesToDelete[state.ReleaseToID(&release)]; !ok {
rs = append(rs, release)
}
}
subst.Releases = rs
syncOpts := &state.SyncOpts{
Set: c.Set(),
SkipCRDs: c.SkipCRDs(),
Wait: c.Wait(),
WaitRetries: c.WaitRetries(),
WaitForJobs: c.WaitForJobs(),
Timeout: c.Timeout(),
ReuseValues: c.ReuseValues(),
ResetValues: c.ResetValues(),
PostRenderer: c.PostRenderer(),
PostRendererArgs: c.PostRendererArgs(),
SyncArgs: c.SyncArgs(),
HideNotes: c.HideNotes(),
TakeOwnership: c.TakeOwnership(),
ServerSide: c.ServerSide(),
SkipSchemaValidation: c.SkipSchemaValidation(),
SyncReleaseLabels: c.SyncReleaseLabels(),
TrackMode: c.TrackMode(),
TrackTimeout: c.TrackTimeout(),
TrackLogs: c.TrackLogs(),
TrackFailedLogs: c.TrackFailedLogs(),
HelmStuckGrace: c.HelmStuckGrace(),
TrackFailOnError: c.TrackFailOnError(),
Description: c.Description(),
Color: c.Color(),
NoColor: c.NoColor(),
}
return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), syncOpts)
}))
if len(syncErrs) > 0 {
errs = append(errs, syncErrs...)
}
}
}
affectedReleases.DisplayAffectedReleases(c.Logger(), !c.NoColor())
for id := range releasesWithNoChange {
r := releasesWithNoChange[id]
if _, err := st.TriggerCleanupEvent(&r, "sync"); err != nil {
a.Logger.Warnf("warn: %v\n", err)
}
}
changesApplied := operationsAttempted && len(errs) == 0
return true, changesApplied, errs
}
func (a *App) template(r *Run, c TemplateConfigProvider) (bool, []error) {
return a.withNeeds(r, c, false, func(st *state.HelmState) []error {
helm := r.helm
args := GetArgs(c.Args(), st)
// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `lint`
helm.SetExtraArgs()
if len(args) > 0 {
helm.SetExtraArgs(args...)
}
opts := &state.TemplateOpts{
Set: c.Set(),
IncludeCRDs: c.IncludeCRDs(),
NoHooks: c.NoHooks(),
OutputDirTemplate: c.OutputDirTemplate(),
SkipCleanup: c.SkipCleanup(),
SkipTests: c.SkipTests(),
PostRenderer: c.PostRenderer(),
PostRendererArgs: c.PostRendererArgs(),
KubeVersion: c.KubeVersion(),
ShowOnly: c.ShowOnly(),
SkipSchemaValidation: c.SkipSchemaValidation(),
TemplateArgs: c.TemplateArgs(),
}
return st.TemplateReleases(helm, c.OutputDir(), c.Values(), args, c.Concurrency(), c.Validate(), opts)
})
}
func (a *App) withNeeds(r *Run, c DAGConfig, includeDisabled bool, f func(*state.HelmState) []error) (bool, []error) {
st := r.state
selectedReleases, deduplicated, err := a.getSelectedReleases(r, false)
if err != nil {
return false, []error{err}
}
if len(selectedReleases) == 0 {
return false, nil
}
// This is required when you're trying to deduplicate releases by the selector.
// Without this, `PlanReleases` conflates duplicates and return both in `batches`,
// even if we provided `SelectedReleases: selectedReleases`.
// See https://github.com/roboll/helmfile/issues/1818 for more context.
st.Releases = deduplicated
includeNeeds := c.IncludeNeeds()
if c.IncludeTransitiveNeeds() {
includeNeeds = true
}
batches, err := st.PlanReleases(state.PlanOptions{
Reverse: false,
SelectedReleases: selectedReleases,
IncludeNeeds: includeNeeds,
IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(),
SkipNeeds: c.SkipNeeds(),
})
if err != nil {
return false, []error{err}
}
var selectedReleasesWithNeeds []state.ReleaseSpec
for _, rs := range batches {
for _, r := range rs {
selectedReleasesWithNeeds = append(selectedReleasesWithNeeds, r.ReleaseSpec)
}
}
var toRender []state.ReleaseSpec
releasesToUninstall := map[string]state.ReleaseSpec{}
for _, r := range selectedReleasesWithNeeds {
release := r
id := state.ReleaseToID(&release)
if !release.Desired() {
releasesToUninstall[id] = release
} else {
toRender = append(toRender, release)
}
}
var rels []state.ReleaseSpec
if len(toRender) > 0 {
// toRender already contains the direct and transitive needs depending on the DAG options.
// That's why we don't pass in `IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()` here.
// Otherwise, in case include-needs=true, it will include the needs of needs, which results in unexpectedly introducing transitive needs,
// even if include-transitive-needs=true is unspecified.
if _, errs := withDAG(st, r.helm, a.Logger, state.PlanOptions{SelectedReleases: toRender, Reverse: false, SkipNeeds: c.SkipNeeds(), IncludeNeeds: includeNeeds}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
rels = append(rels, subst.Releases...)
return nil
})); len(errs) > 0 {
return false, errs
}
}
if c.EnforceNeedsAreInstalled() {
for _, r := range toRender {
visited := make(map[string]bool)
if depErr := st.HasTransitiveDependencyWithInstalledFalse(r, visited); depErr != nil {
return false, []error{fmt.Errorf("Release %s has a transitive dependency %s marked as installed=false", depErr.Release.Name, depErr.Dependency.Name)}
}
}
}
if includeDisabled {
for _, d := range releasesToUninstall {
rels = append(rels, d)
}
}
// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
// and we don't fail on dependenciese on disabled releases.
// In diff, we need to diff on disabled releases to show to-be-uninstalled releases.
// In lint and template, we'd need to run respective helm commands only on enabled releases,
// without failing on disabled releases.
st.Releases = rels
errs := f(st)
return true, errs
}
func (a *App) test(r *Run, c TestConfigProvider) []error {
cleanup := c.Cleanup()
timeout := c.Timeout()
concurrency := c.Concurrency()
st := r.state
toTest, _, err := a.getSelectedReleases(r, false)
if err != nil {
return []error{err}
}
if len(toTest) == 0 {
return nil
}
// Do test only on selected releases, because that's what the user intended
// with conditions and selectors
st.Releases = toTest
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
return st.TestReleases(r.helm, cleanup, timeout, concurrency, state.Logs(c.Logs()))
}
func (a *App) writeValues(r *Run, c WriteValuesConfigProvider) (bool, []error) {
st := r.state
helm := r.helm
toRender, _, err := a.getSelectedReleases(r, false)
if err != nil {
return false, []error{err}
}
if len(toRender) == 0 {
return false, nil
}
// Do build deps and prepare only on selected releases so that we won't waste time
// on running various helm commands on unnecessary releases
st.Releases = toRender
releasesToWrite := map[string]state.ReleaseSpec{}
for _, r := range toRender {
release := r
id := state.ReleaseToID(&release)
if release.Desired() {
releasesToWrite[id] = release
}
}
var errs []error
// Note: We don't calculate the DAG of releases here unlike other helmfile operations,
// because there's no need to do so for just writing values.
// See the first bullet in https://github.com/roboll/helmfile/issues/1460#issuecomment-691863465
if len(releasesToWrite) > 0 {
var rs []state.ReleaseSpec
for _, r := range releasesToWrite {
rs = append(rs, r)
}
st.Releases = rs
opts := &state.WriteValuesOpts{
Set: c.Set(),
OutputFileTemplate: c.OutputFileTemplate(),
SkipCleanup: c.SkipCleanup(),
}
errs = st.WriteReleasesValues(helm, c.Values(), opts)
}
return true, errs
}
// Error is a wrapper around an error that adds context to the error.
type Error struct {
msg string
Errors []error
code *int
}
func (e *Error) Error() string {
var cause string
if e.Errors == nil {
return e.msg
}
if len(e.Errors) == 1 {
if e.Errors[0] == nil {
panic(fmt.Sprintf("[bug] assertion error: unexpected state: e.Errors: %v", e.Errors))
}
cause = e.Errors[0].Error()
} else {
msgs := []string{}
for i, err := range e.Errors {
if err == nil {
continue
}
msgs = append(msgs, fmt.Sprintf("err %d: %v", i, err.Error()))
}
cause = fmt.Sprintf("%d errors:\n%s", len(e.Errors), strings.Join(msgs, "\n"))
}
msg := ""
if e.msg != "" {
msg = fmt.Sprintf("%s: %s", e.msg, cause)
} else {
msg = cause
}
return msg
}
func (e *Error) Code() int {
if e.code != nil {
return *e.code
}
allDiff := false
anyNonZero := false
for _, err := range e.Errors {
switch ee := err.(type) {
case *state.ReleaseError:
if anyNonZero {
allDiff = allDiff && ee.Code == 2
} else {
allDiff = ee.Code == 2
}
case *Error:
if anyNonZero {
allDiff = allDiff && ee.Code() == 2
} else {
allDiff = ee.Code() == 2
}
}
anyNonZero = true
}
if anyNonZero {
if allDiff {
return 2
}
return 1
}
panic(fmt.Sprintf("[bug] assertion error: unexpected state: unable to handle errors: %v", e.Errors))
}
func appError(msg string, err error) *Error {
return &Error{msg: msg, Errors: []error{err}}
}
// aggregateErrors combines multiple errors from processing a state file into a
// single *Error that renders all of them. Rendering a single error is
// identical to returning it directly, so aggregating does not change the
// reported message for the single-error case — but it keeps errors that are
// not the first one visible, which matters when continueOnError lets multiple
// releases fail or be skipped within one run.
func aggregateErrors(errs []error) error {
filtered := make([]error, 0, len(errs))
for _, err := range errs {
if err != nil {
filtered = append(filtered, err)
}
}
if len(filtered) == 0 {
return nil
}
return &Error{Errors: filtered}
}
func (c context) clean(errs []error) error {
if errs == nil {
errs = []error{}
}
if !c.retainValues {
cleanErrs := c.st.Clean()
if cleanErrs != nil {
errs = append(errs, cleanErrs...)
}
}
return c.wrapErrs(errs...)
}
type context struct {
app *App
st *state.HelmState
retainValues bool
}
func (c context) wrapErrs(errs ...error) error {
if len(errs) > 0 {
for _, err := range errs {
switch e := err.(type) {
case *state.ReleaseError:
c.app.Logger.Debugf("err: release \"%s\" in \"%s\" failed: %v", e.Name, c.st.FilePath, e)
default:
c.app.Logger.Debugf("err: %v", e)
}
}
return &Error{Errors: errs}
}
return nil
}
func (a *App) ShowCacheDir(c CacheConfigProvider) error {
fmt.Printf("Cache directory: %s\n", remote.CacheDir())
if !a.fs.DirectoryExistsAt(remote.CacheDir()) {
return nil
}
dirs, err := a.fs.ReadDir(remote.CacheDir())
if err != nil {
return err
}
for _, e := range dirs {
fmt.Printf("- %s\n", e.Name())
}
return nil
}
func (a *App) CleanCacheDir(c CacheConfigProvider) error {
if !a.fs.DirectoryExistsAt(remote.CacheDir()) {
return nil
}
fmt.Printf("Cleaning up cache directory: %s\n", remote.CacheDir())
dirs, err := os.ReadDir(remote.CacheDir())
if err != nil {
return err
}
for _, e := range dirs {
fmt.Printf("- %s\n", e.Name())
err := os.RemoveAll(filepath.Join(remote.CacheDir(), e.Name()))
if err != nil {
return err
}
}
return nil
}
func GetArgs(args string, state *state.HelmState) []string {
baseArgs := []string{}
stateArgs := []string{}
if len(args) > 0 {
baseArgs = argparser.CollectArgs(args)
}
if len(state.HelmDefaults.Args) > 0 {
stateArgs = argparser.CollectArgs(strings.Join(state.HelmDefaults.Args, " "))
}
state.HelmDefaults.Args = append(baseArgs, stateArgs...)
return state.HelmDefaults.Args
}