Files
helmfile/pkg/state/helmx.go
T
yxxhero ec7302c9dc fix: enable kubedog tracking with atomic/rollback-on-failure helm flags
When using atomic: true with trackMode: kubedog, the --atomic flag was
passed to Helm which caused Helm to wait for resources and rollback
internally before helmfile could start kubedog tracking. This prevented
users from seeing deployment logs and errors.

Changes:
- Add RollbackRelease method to helmexec interface and implementation
- Add appendAtomicFlags function that skips --atomic when kubedog is enabled
- Add handleKubedogFailure function to manually rollback on tracking failure
- Update flagsForUpgrade to use new atomic flag handling
- Add tests for appendAtomicFlags behavior

Now when atomic: true and trackMode: kubedog are both enabled:
1. Helm upgrade runs without --atomic (no internal wait)
2. kubedog tracks resources immediately (users can see logs)
3. If kubedog tracking fails, helmfile manually executes rollback
4. Atomic semantics are preserved

Fixes: #2448
Signed-off-by: yxxhero <aiopsclub@163.com>
2026-03-25 08:37:26 +08:00

677 lines
19 KiB
Go

package state
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"github.com/helmfile/chartify"
"helm.sh/helm/v4/pkg/storage/driver"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/kubedog"
"github.com/helmfile/helmfile/pkg/remote"
"github.com/helmfile/helmfile/pkg/resource"
)
type Dependency struct {
Chart string `yaml:"chart"`
Version string `yaml:"version"`
Alias string `yaml:"alias"`
}
func (st *HelmState) appendHelmXFlags(flags []string, release *ReleaseSpec) []string {
for _, adopt := range release.Adopt {
flags = append(flags, "--adopt", adopt)
}
return flags
}
func formatLabels(labels map[string]string) string {
var labelsList, keys []string
for k := range labels {
if k == "" || slices.Contains(driver.GetSystemLabels(), k) {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 {
return ""
}
for _, k := range keys {
val := labels[k]
labelsList = append(labelsList, fmt.Sprintf("%s=%s", k, val))
}
return strings.Join(labelsList, ",")
}
// append labels flags to helm flags, starting from helm v3.13.0
func (st *HelmState) appendLabelsFlags(flags []string, helm helmexec.Interface, release *ReleaseSpec, syncReleaseLabels bool) []string {
if !helm.IsVersionAtLeast("3.13.0") {
return flags
}
isSyncReleaseLabels := false
switch {
// Check if SyncReleaseLabels is true in the release spec.
case release.SyncReleaseLabels != nil && *release.SyncReleaseLabels:
isSyncReleaseLabels = true
// Check if syncReleaseLabels argument is true.
case syncReleaseLabels:
isSyncReleaseLabels = true
// Check if SyncReleaseLabels is true in HelmDefaults.
case st.HelmDefaults.SyncReleaseLabels != nil && *st.HelmDefaults.SyncReleaseLabels:
isSyncReleaseLabels = true
}
if isSyncReleaseLabels {
labels := formatLabels(release.Labels)
if labels != "" {
flags = append(flags, "--labels", labels)
}
}
return flags
}
// append post-renderer flags to helm flags
func (st *HelmState) appendPostRenderFlags(flags []string, release *ReleaseSpec, postRenderer string, helm helmexec.Interface) []string {
var rendererPath string
switch {
// postRenderer arg comes from cmd flag.
case release.PostRenderer != nil && *release.PostRenderer != "":
rendererPath = *release.PostRenderer
case postRenderer != "":
rendererPath = postRenderer
case st.HelmDefaults.PostRenderer != nil && *st.HelmDefaults.PostRenderer != "":
rendererPath = *st.HelmDefaults.PostRenderer
}
if rendererPath != "" {
// For Helm 4, convert the bash script path to a plugin name
if helm != nil && !helm.IsHelm3() {
// Check if this is a bash script path that needs conversion
if strings.HasSuffix(rendererPath, ".bash") || strings.HasSuffix(rendererPath, ".sh") {
// Extract the base name (e.g., "add-cm1" from "../../postrenderers/add-cm1.bash")
baseName := filepath.Base(rendererPath)
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
// For Helm 4, use just the plugin name
// From: ../../postrenderers/add-cm1.bash
// To: add-cm1 (assuming the plugin is installed with this name)
rendererPath = baseName
}
}
flags = append(flags, "--post-renderer", rendererPath)
}
return flags
}
// append post-renderer-args flags to helm flags
func (st *HelmState) appendPostRenderArgsFlags(flags []string, release *ReleaseSpec, postRendererArgs []string) []string {
postRendererArgsFlags := []string{}
switch {
case len(release.PostRendererArgs) != 0:
postRendererArgsFlags = release.PostRendererArgs
case len(postRendererArgs) != 0:
postRendererArgsFlags = postRendererArgs
case len(st.HelmDefaults.PostRendererArgs) != 0:
postRendererArgsFlags = st.HelmDefaults.PostRendererArgs
}
for _, arg := range postRendererArgsFlags {
if arg != "" {
flags = append(flags, "--post-renderer-args", arg)
}
}
return flags
}
// append skip-schema-validation flags to helm flags
func (st *HelmState) appendSkipSchemaValidationFlags(flags []string, release *ReleaseSpec, skipSchemaValidation bool) []string {
switch {
// Check if SkipSchemaValidation is true in the release spec.
case release.SkipSchemaValidation != nil && *release.SkipSchemaValidation:
flags = append(flags, "--skip-schema-validation")
// Check if skipSchemaValidation argument is true.
case skipSchemaValidation:
flags = append(flags, "--skip-schema-validation")
// Check if SkipSchemaValidation is true in HelmDefaults.
case st.HelmDefaults.SkipSchemaValidation != nil && *st.HelmDefaults.SkipSchemaValidation:
flags = append(flags, "--skip-schema-validation")
}
return flags
}
// append suppress-output-line-regex flags to helm diff flags
func (st *HelmState) appendSuppressOutputLineRegexFlags(flags []string, release *ReleaseSpec, suppressOutputLineRegex []string) []string {
suppressOutputLineRegexFlags := []string{}
switch {
case len(release.SuppressOutputLineRegex) != 0:
suppressOutputLineRegexFlags = release.SuppressOutputLineRegex
case len(suppressOutputLineRegex) != 0:
suppressOutputLineRegexFlags = suppressOutputLineRegex
case len(st.HelmDefaults.SuppressOutputLineRegex) != 0:
suppressOutputLineRegexFlags = st.HelmDefaults.SuppressOutputLineRegex
}
for _, arg := range suppressOutputLineRegexFlags {
if arg != "" {
flags = append(flags, "--suppress-output-line-regex", arg)
}
}
return flags
}
func (st *HelmState) appendWaitForJobsFlags(flags []string, release *ReleaseSpec, ops *SyncOpts) []string {
if st.shouldUseKubedog(release, ops) {
return flags
}
switch {
case release.WaitForJobs != nil && *release.WaitForJobs:
flags = append(flags, "--wait-for-jobs")
case ops != nil && ops.WaitForJobs:
flags = append(flags, "--wait-for-jobs")
case release.WaitForJobs == nil && st.HelmDefaults.WaitForJobs:
flags = append(flags, "--wait-for-jobs")
}
return flags
}
func (st *HelmState) shouldUseKubedog(release *ReleaseSpec, ops *SyncOpts) bool {
return st.getTrackMode(release, ops) == string(kubedog.TrackModeKubedog)
}
func (st *HelmState) getTrackMode(release *ReleaseSpec, ops *SyncOpts) string {
trackMode := release.TrackMode
if trackMode == "" && ops != nil && ops.TrackMode != "" {
trackMode = ops.TrackMode
}
if trackMode == "" {
trackMode = st.HelmDefaults.TrackMode
}
if trackMode == "" {
trackMode = string(kubedog.TrackModeHelm)
}
return trackMode
}
func (st *HelmState) appendWaitFlags(flags []string, helm helmexec.Interface, release *ReleaseSpec, ops *SyncOpts) []string {
if st.shouldUseKubedog(release, ops) {
return flags
}
shouldWait := false
switch {
case release.Wait != nil && *release.Wait:
shouldWait = true
case ops != nil && ops.Wait:
shouldWait = true
case release.Wait == nil && st.HelmDefaults.Wait:
shouldWait = true
}
if shouldWait {
trackMode := st.getTrackMode(release, ops)
if trackMode == string(kubedog.TrackModeHelmLegacy) {
if helm != nil && helm.IsHelm4() {
flags = append(flags, "--wait=legacy")
} else {
if st.logger != nil {
st.logger.Warnf("trackMode 'helm-legacy' requires Helm v4, falling back to regular --wait for release %s", release.Name)
}
flags = append(flags, "--wait")
}
} else {
flags = append(flags, "--wait")
}
}
return flags
}
func (st *HelmState) shouldUseAtomic(release *ReleaseSpec, ops *SyncOpts) bool {
switch {
case release.Atomic != nil:
return *release.Atomic
default:
return st.HelmDefaults.Atomic
}
}
func (st *HelmState) appendAtomicFlags(flags []string, release *ReleaseSpec, ops *SyncOpts) []string {
if st.shouldUseKubedog(release, ops) {
return flags
}
if st.shouldUseAtomic(release, ops) {
flags = append(flags, "--atomic")
}
return flags
}
// append post-renderer flags to helm flags
func (st *HelmState) appendCascadeFlags(flags []string, helm helmexec.Interface, release *ReleaseSpec, cascade string) []string {
// see https://github.com/helm/helm/releases/tag/v3.12.1
if !helm.IsVersionAtLeast("3.12.1") {
return flags
}
switch {
// postRenderer arg comes from cmd flag.
case release.Cascade != nil && *release.Cascade != "":
flags = append(flags, "--cascade", *release.Cascade)
case cascade != "":
flags = append(flags, "--cascade", cascade)
case st.HelmDefaults.Cascade != nil && *st.HelmDefaults.Cascade != "":
flags = append(flags, "--cascade", *st.HelmDefaults.Cascade)
}
return flags
}
// append hide-notes flags to helm flags
func (st *HelmState) appendHideNotesFlags(flags []string, helm helmexec.Interface, ops *SyncOpts) []string {
if ops == nil {
return flags
}
// see https://github.com/helm/helm/releases/tag/v3.16.0
if !helm.IsVersionAtLeast("3.16.0") {
return flags
}
switch {
case ops.HideNotes:
flags = append(flags, "--hide-notes")
}
return flags
}
// append take-ownership flags to helm flags
func (st *HelmState) appendTakeOwnershipFlagsForUpgrade(flags []string, helm helmexec.Interface, release *ReleaseSpec, takeOwnership bool) []string {
// see https://github.com/helm/helm/releases/tag/v3.17.0
if !helm.IsVersionAtLeast("3.17.0") {
return flags
}
switch {
case release.TakeOwnership != nil && *release.TakeOwnership:
flags = append(flags, "--take-ownership")
case takeOwnership:
flags = append(flags, "--take-ownership")
case st.HelmDefaults.TakeOwnership != nil && *st.HelmDefaults.TakeOwnership:
flags = append(flags, "--take-ownership")
}
return flags
}
// append show-only flags to helm flags
func (st *HelmState) appendShowOnlyFlags(flags []string, showOnly []string) []string {
showOnlyFlags := []string{}
if len(showOnly) != 0 {
showOnlyFlags = showOnly
}
for _, arg := range showOnlyFlags {
if arg != "" {
flags = append(flags, "--show-only", arg)
}
}
return flags
}
type Chartify struct {
Opts *chartify.ChartifyOpts
Clean func()
}
func (st *HelmState) downloadChartWithGoGetter(r *ReleaseSpec) (string, error) {
var pathElems []string
if r.Namespace != "" {
pathElems = append(pathElems, r.Namespace)
}
if r.KubeContext != "" {
pathElems = append(pathElems, r.KubeContext)
}
pathElems = append(pathElems, r.Name)
cacheDir := filepath.Join(pathElems...)
return st.goGetterChart(r.Chart, r.Directory, cacheDir, r.ForceGoGetter)
}
func (st *HelmState) goGetterChart(chart, dir, cacheDir string, force bool) (string, error) {
if dir != "" && chart == "" {
chart = dir
}
_, err := remote.Parse(chart)
if err != nil {
if force {
return "", fmt.Errorf("Parsing url from dir failed due to error %q.\nContinuing the process assuming this is a regular Helm chart or a local dir.", err.Error())
}
} else {
r := remote.NewRemote(st.logger, "", st.fs)
fetchedDir, err := r.Fetch(chart, cacheDir)
if err != nil {
return "", fmt.Errorf("fetching %q: %v", chart, err)
}
chart = fetchedDir
}
return chart, nil
}
func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSpec, chart string, workerIndex int) (*Chartify, func(), error) {
c := &Chartify{
Opts: &chartify.ChartifyOpts{
WorkaroundOutputDirIssue: true,
EnableKustomizeAlphaPlugins: true,
ChartVersion: release.Version,
Namespace: release.Namespace,
ID: ReleaseToID(release),
},
}
var filesNeedCleaning []string
clean := func() {
st.removeFiles(filesNeedCleaning)
}
var shouldRun bool
dir := chart
if !filepath.IsAbs(chart) {
dir = filepath.Join(st.basePath, chart)
}
if stat, _ := os.Stat(dir); stat != nil && stat.IsDir() {
if exists, err := st.fs.FileExists(filepath.Join(dir, "Chart.yaml")); err == nil && !exists {
shouldRun = true
}
}
for _, d := range release.Dependencies {
chart := d.Chart
if st.fs.DirectoryExistsAt(chart) {
var err error
// Otherwise helm-dependency-up on the temporary chart generated by chartify ends up errors like:
// Error: directory /tmp/chartify945964195/myapp-57fb4495cf/test/integration/charts/httpbin not found]
// which is due to that the temporary chart is generated outside of the current working directory/basePath,
// and therefore the relative path in `chart` points to somewhere inexistent.
chart, err = filepath.Abs(filepath.Join(st.basePath, chart))
if err != nil {
return nil, clean, err
}
}
c.Opts.AdhocChartDependencies = append(c.Opts.AdhocChartDependencies, chartify.ChartDependency{
Alias: d.Alias,
Chart: chart,
Version: d.Version,
})
shouldRun = true
}
jsonPatches := release.JSONPatches
if len(jsonPatches) > 0 {
generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, jsonPatches)
if err != nil {
return nil, clean, err
}
filesNeedCleaning = append(filesNeedCleaning, generatedFiles...)
c.Opts.JsonPatches = append(c.Opts.JsonPatches, generatedFiles...)
shouldRun = true
}
strategicMergePatches := release.StrategicMergePatches
if len(strategicMergePatches) > 0 {
generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, strategicMergePatches)
if err != nil {
return nil, clean, err
}
c.Opts.StrategicMergePatches = append(c.Opts.StrategicMergePatches, generatedFiles...)
filesNeedCleaning = append(filesNeedCleaning, generatedFiles...)
shouldRun = true
}
transformers := release.Transformers
if len(transformers) > 0 {
generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, transformers)
if err != nil {
return nil, clean, err
}
c.Opts.Transformers = append(c.Opts.Transformers, generatedFiles...)
filesNeedCleaning = append(filesNeedCleaning, generatedFiles...)
shouldRun = true
}
if release.ForceNamespace != "" {
c.Opts.OverrideNamespace = release.ForceNamespace
shouldRun = true
}
if shouldRun {
st.logger.Debugf("Chartify process for %s", dir)
generatedFiles, err := st.generateValuesFiles(helm, release, workerIndex)
if err != nil {
return nil, clean, err
}
filesNeedCleaning = append(filesNeedCleaning, generatedFiles...)
c.Opts.ValuesFiles = generatedFiles
setFlags, err := st.setFlags(release.SetValues)
if err != nil {
return nil, clean, fmt.Errorf("rendering set value entry for release %s: %v", release.Name, err)
}
c.Opts.SetFlags = setFlags
c.Opts.TemplateData = st.newReleaseTemplateData(release)
c.Opts.TemplateFuncs = st.newReleaseTemplateFuncMap(dir)
return c, clean, nil
}
return nil, clean, nil
}
func (st *HelmState) trackWithKubedog(ctx context.Context, release *ReleaseSpec, helm helmexec.Interface, ops *SyncOpts) error {
timeout := 5 * time.Minute
if release.TrackTimeout != nil && *release.TrackTimeout > 0 {
timeout = time.Duration(*release.TrackTimeout) * time.Second
} else if ops != nil && ops.TrackTimeout > 0 {
timeout = time.Duration(ops.TrackTimeout) * time.Second
}
trackLogs := release.TrackLogs != nil && *release.TrackLogs
if release.TrackLogs == nil && ops != nil {
trackLogs = ops.TrackLogs
}
filterConfig := &resource.FilterConfig{
TrackKinds: release.TrackKinds,
SkipKinds: release.SkipKinds,
TrackResources: convertTrackResources(release.TrackResources),
}
kubeContext := st.getKubeContext(release)
trackOpts := kubedog.NewTrackOptions().
WithTimeout(timeout).
WithLogs(trackLogs).
WithFilterConfig(filterConfig)
tracker, err := kubedog.NewTracker(&kubedog.TrackerConfig{
Logger: st.logger,
Namespace: release.Namespace,
KubeContext: kubeContext,
Kubeconfig: st.kubeconfig,
TrackOptions: trackOpts,
KubedogQPS: release.KubedogQPS,
KubedogBurst: release.KubedogBurst,
})
if err != nil {
return fmt.Errorf("failed to create kubedog tracker: %w", err)
}
resources, err := st.getReleaseResources(ctx, release, helm)
if err != nil {
return fmt.Errorf("failed to get release resources: %w", err)
}
if len(resources) == 0 {
st.logger.Infof("No trackable resources found for release %s", release.Name)
return nil
}
st.logger.Infof("Tracking %d resources from release %s with kubedog", len(resources), release.Name)
if err := tracker.TrackResources(ctx, resources); err != nil {
return fmt.Errorf("kubedog tracking failed for release %s: %w", release.Name, err)
}
return nil
}
func (st *HelmState) getReleaseResources(_ context.Context, release *ReleaseSpec, helm helmexec.Interface) ([]*resource.Resource, error) {
st.logger.Debugf("Getting resources for release %s", release.Name)
manifest, namespace, err := st.getReleaseManifest(release, helm)
if err != nil {
return nil, fmt.Errorf("failed to get release manifest: %w", err)
}
if len(manifest) == 0 {
st.logger.Infof("No manifest found for release %s", release.Name)
return nil, nil
}
defaultNs := namespace
if defaultNs == "" {
defaultNs = "default"
}
resources, err := resource.ParseManifest(manifest, defaultNs, st.logger)
if err != nil {
return nil, fmt.Errorf("failed to parse release resources from manifest: %w", err)
}
if len(resources) == 0 {
st.logger.Infof("No resources found in manifest for release %s", release.Name)
return nil, nil
}
st.logger.Infof("Found %d resources in manifest for release %s", len(resources), release.Name)
result := make([]*resource.Resource, len(resources))
for i := range resources {
result[i] = &resources[i]
}
return result, nil
}
func (st *HelmState) getReleaseManifest(release *ReleaseSpec, helm helmexec.Interface) ([]byte, string, error) {
var tempDir string
var err error
if st.tempDir != nil {
tempDir, err = st.tempDir("", "helmfile-template-")
} else {
tempDir, err = os.MkdirTemp("", "helmfile-template-")
}
if err != nil {
return nil, "", fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() {
if err := os.RemoveAll(tempDir); err != nil {
st.logger.Warnf("Failed to remove temp directory %s: %v", tempDir, err)
}
}()
releaseCopy := *release
st.ApplyOverrides(&releaseCopy)
flags, files, err := st.flagsForTemplate(helm, &releaseCopy, 0, &TemplateOpts{})
if err != nil {
return nil, "", fmt.Errorf("failed to generate template flags: %w", err)
}
defer st.removeFiles(files)
flags = append(flags, "--output-dir", tempDir)
if err := helm.TemplateRelease(releaseCopy.Name, releaseCopy.ChartPathOrName(), flags...); err != nil {
return nil, "", fmt.Errorf("failed to run helm template: %w", err)
}
var manifest []byte
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(info.Name(), ".yaml") && !strings.HasSuffix(info.Name(), ".yml") {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", path, err)
}
if len(manifest) > 0 {
manifest = append(manifest, []byte("\n---\n")...)
}
manifest = append(manifest, content...)
return nil
})
if err != nil {
return nil, "", fmt.Errorf("failed to walk template output directory: %w", err)
}
return manifest, releaseCopy.Namespace, nil
}
func convertTrackResources(resources []TrackResourceSpec) []resource.Resource {
if len(resources) == 0 {
return nil
}
result := make([]resource.Resource, len(resources))
for i, r := range resources {
result[i] = resource.Resource{
Kind: r.Kind,
Name: r.Name,
Namespace: r.Namespace,
}
}
return result
}