package state import ( "bytes" "context" "fmt" "os" "path/filepath" "slices" "sort" "strings" "sync" "sync/atomic" "time" "github.com/helmfile/chartify" "go.uber.org/zap" "helm.sh/helm/v4/pkg/storage/driver" "github.com/helmfile/helmfile/pkg/filesystem" "github.com/helmfile/helmfile/pkg/helmexec" "github.com/helmfile/helmfile/pkg/kubedog" "github.com/helmfile/helmfile/pkg/remote" "github.com/helmfile/helmfile/pkg/resource" "github.com/helmfile/helmfile/pkg/tmpl" ) 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, error) { postRendererArgsFlags := []string{} switch { case len(release.PostRendererArgs) != 0: postRendererArgsFlags = release.PostRendererArgs case len(postRendererArgs) != 0: postRendererArgsFlags = postRendererArgs case len(st.HelmDefaults.PostRendererArgs) != 0: rendered, err := st.renderPostRendererArgs(release, st.HelmDefaults.PostRendererArgs) if err != nil { return nil, err } postRendererArgsFlags = rendered } for _, arg := range postRendererArgsFlags { if arg != "" { flags = append(flags, "--post-renderer-args="+arg) } } return flags, nil } func (st *HelmState) renderPostRendererArgs(release *ReleaseSpec, args []string) ([]string, error) { vals := st.RenderedValues if vals == nil { vals = make(map[string]any) } fs := st.fs if fs == nil { fs = filesystem.DefaultFileSystem() } tmplData := st.createReleaseTemplateData(release, vals) renderer := tmpl.NewFileRenderer(fs, st.basePath, tmplData) result := make([]string, 0, len(args)) for _, arg := range args { rendered, err := renderer.RenderTemplateContentToString([]byte(arg)) if err != nil { return nil, fmt.Errorf("failed rendering postRendererArg %q for release %q: %w", arg, release.Name, err) } result = append(result, rendered) } return result, nil } // append skip-schema-validation flags to helm flags func (st *HelmState) appendSkipSchemaValidationFlags(flags []string, release *ReleaseSpec, skipSchemaValidation bool) []string { if st.shouldSkipSchemaValidation(release, skipSchemaValidation) { flags = append(flags, "--skip-schema-validation") } return flags } func (st *HelmState) shouldSkipSchemaValidation(release *ReleaseSpec, skipSchemaValidation bool) bool { switch { // Check if SkipSchemaValidation is true in the release spec. case release.SkipSchemaValidation != nil && *release.SkipSchemaValidation: return true // Check if skipSchemaValidation argument is true. case skipSchemaValidation: return true // Check if SkipSchemaValidation is true in HelmDefaults. case st.HelmDefaults.SkipSchemaValidation != nil && *st.HelmDefaults.SkipSchemaValidation: return true default: return false } } // 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) shouldFailOnTrackError(release *ReleaseSpec, ops *SyncOpts) bool { if release.TrackFailOnError != nil { return *release.TrackFailOnError } if ops != nil { return ops.TrackFailOnError } return false } // trackReleaseIfEnabled performs kubedog tracking for a release if trackMode is "kubedog". // It returns a ReleaseError if tracking fails and shouldFailOnTrackError is true. // The caller is responsible for mutating affectedReleases when needed. func (st *HelmState) trackReleaseIfEnabled(ctx context.Context, release *ReleaseSpec, helm helmexec.Interface, opts *SyncOpts) *ReleaseError { if !st.shouldUseKubedog(release, opts) { return nil } if trackErr := st.trackWithKubedog(ctx, release, helm, opts); trackErr != nil { st.logger.Warnf("kubedog tracking failed for release %s: %v", release.Name, trackErr) if st.shouldFailOnTrackError(release, opts) { return newReleaseFailedError(release, trackErr) } } return nil } // kubedogTrackingHandle bundles the closures the caller needs to coordinate // parallel kubedog tracking with helm execution. See // startBackgroundKubedogTracking for the lifecycle. type kubedogTrackingHandle struct { // Helm is the helm.Interface the caller MUST use for SyncRelease and any // follow-up commands (e.g. listReleases) while tracking is running. It's // a logger-scoped clone of the original helm that captures all its output // into the per-release buffer so it doesn't interleave with kubedog // progress on stdout. Helm helmexec.Interface // Wait blocks until the tracker exits and returns the resulting error // (already shaped by trackFailOnError policy). Wait func() *ReleaseError // Cancel cancels the tracker; call when helm itself fails. Cancel func() // NotifyHelmDone signals to in-flight freshness gates that helm finished // so unchanged resources can stop waiting for a generation bump. NotifyHelmDone func() // FlushBufferedHelmOutput emits the captured helm output (upgrades, list, // etc.) through the real logger in a single block. Safe to call multiple // times — second call is a no-op. FlushBufferedHelmOutput func() // WasHelmKilled reports whether the safety-valve helm-killer fired during // tracking. When true, the caller MUST treat any error returned by helm // SyncRelease as success — helm was deliberately interrupted because the // cluster confirmed convergence and helm wedged on its hook waiter. WasHelmKilled func() bool } // startBackgroundKubedogTracking templates the release upfront and starts a // kubedog tracker in a goroutine so it runs in parallel with helm. It returns // a handle whose Helm field MUST be used for the helm calls during the // tracking window — that helm clone buffers output for clean ordering. // // When started is false the caller must fall back to the sequential // trackReleaseIfEnabled path (e.g. templating failed before helm ran, so we // retry after helm finishes to preserve the previous behavior). // buildReleaseTracker resolves the per-release tracking options (timeout, // log capture, filter config, color) and constructs the kubedog tracker. // Extracted from startBackgroundKubedogTracking to keep that function within // the statement-count limit. func (st *HelmState) buildReleaseTracker(release *ReleaseSpec, opts *SyncOpts, useColor bool) (*kubedog.Tracker, *kubedog.TrackOptions, error) { timeout := 5 * time.Minute if release.TrackTimeout != nil && *release.TrackTimeout > 0 { timeout = time.Duration(*release.TrackTimeout) * time.Second } else if opts != nil && opts.TrackTimeout > 0 { timeout = time.Duration(opts.TrackTimeout) * time.Second } trackLogs := release.TrackLogs != nil && *release.TrackLogs if release.TrackLogs == nil && opts != nil { trackLogs = opts.TrackLogs } trackFailedLogs := release.TrackFailedLogs != nil && *release.TrackFailedLogs if release.TrackFailedLogs == nil && opts != nil { trackFailedLogs = opts.TrackFailedLogs } filterConfig := &resource.FilterConfig{ TrackKinds: release.TrackKinds, SkipKinds: release.SkipKinds, TrackResources: convertTrackResources(release.TrackResources), } trackOpts := kubedog.NewTrackOptions(). WithTimeout(timeout). WithLogs(trackLogs). WithFailedLogsOnly(trackFailedLogs). WithFilterConfig(filterConfig). WithColor(useColor) tracker, err := kubedog.NewTracker(&kubedog.TrackerConfig{ Logger: st.logger, Namespace: release.Namespace, KubeContext: st.getKubeContext(release), Kubeconfig: st.kubeconfig, ReleaseName: release.Name, TrackOptions: trackOpts, KubedogQPS: release.KubedogQPS, KubedogBurst: release.KubedogBurst, }) if err != nil { return nil, nil, err } return tracker, trackOpts, nil } // bufferHelmOutput swaps the helm logger for one writing into an in-memory // buffer (so helm output can be replayed as a single block after tracking), // and derives a release-scoped context whose cancellation drives the existing // ShellRunner SIGINT path. The release-scoped context is what lets the // helm-stuck safety valve interrupt a wedged helm subprocess. func (st *HelmState) bufferHelmOutput(helm helmexec.Interface, ctx context.Context, releaseName string) (bufHelm helmexec.Interface, buf *bytes.Buffer, releaseCancel context.CancelFunc) { buf = &bytes.Buffer{} bufHelm = helm if swapper, ok := helm.(helmexec.LoggerSwapper); ok { bufHelm = swapper.WithLogger(helmexec.NewLogger(buf, "info")) } else { st.logger.Debugf("kubedog: helm implementation does not support logger swap; output will interleave for release %s", releaseName) } releaseCtx, releaseCancel := context.WithCancel(ctx) if swapper, ok := bufHelm.(helmexec.ContextSwapper); ok { bufHelm = swapper.WithContext(releaseCtx) } return } func (st *HelmState) startBackgroundKubedogTracking( ctx context.Context, release *ReleaseSpec, helm helmexec.Interface, opts *SyncOpts, ) (h *kubedogTrackingHandle, started bool) { noop := &kubedogTrackingHandle{ Helm: helm, Wait: func() *ReleaseError { return nil }, Cancel: func() {}, NotifyHelmDone: func() {}, FlushBufferedHelmOutput: func() {}, WasHelmKilled: func() bool { return false }, } if !st.shouldUseKubedog(release, opts) { return noop, false } useColor := false if opts != nil { useColor = opts.Color && !opts.NoColor } // Capture the per-release preamble (header + helm template "Templating // release=" / "wrote ..." lines + "Found N resources" line) into one // buffer and emit it via st.logger as a single atomic entry. Without // this, parallel releases interleave their template output and it // becomes very hard to follow which `wrote ...` line belongs to which // chart. Helm output during install/upgrade is buffered separately // (helmOutputBuf below) and flushed after tracking finishes. preambleBuf := &bytes.Buffer{} preambleLogger := helmexec.NewLogger(preambleBuf, "info") preambleLogger.Infof("\n%s", kubedog.HeaderDividerStyled(fmt.Sprintf("Release '%s'", release.Name), useColor)) tmplHelm := helm if swapper, ok := helm.(helmexec.LoggerSwapper); ok { tmplHelm = swapper.WithLogger(preambleLogger) } flushPreamble := func() { if preambleBuf.Len() == 0 { return } st.logger.Infof("%s", strings.TrimRight(preambleBuf.String(), "\n")) preambleBuf.Reset() } resources, err := st.getReleaseResources(ctx, release, tmplHelm, preambleLogger) if err != nil { flushPreamble() st.logger.Warnf("kubedog: failed to template release %s for parallel tracking, falling back to post-helm tracking: %v", release.Name, err) return noop, false } if len(resources) == 0 { flushPreamble() st.logger.Infof("kubedog: no trackable resources templated for release %s", release.Name) // Emit the same "Helm output for release 'X'" divider tracked // releases get at flush time, so a hook-only release (which has // nothing for kubedog to track) still gets the visual delimiter. // Helm output flows live after this line — there's no buffering // because we have nothing to interleave with. st.logger.Infof("\n%s", kubedog.HeaderDividerStyled(fmt.Sprintf("Helm output for release '%s'", release.Name), useColor)) return noop, true } tracker, trackOpts, err := st.buildReleaseTracker(release, opts, useColor) if err != nil { st.logger.Warnf("kubedog: failed to initialize tracker for release %s, falling back to post-helm tracking: %v", release.Name, err) return noop, false } // Compute the kubedog filter + breakdown summary BEFORE launching the // tracker goroutine so we can fold the "Tracking N..." and "Tracking // breakdown: ..." lines into the same atomic preamble flush as the // header and template output. Doing it any later means those two lines // race with other releases' progress ticks. PreviewBreakdown is pure — // no logging, no API calls. _, breakdown := tracker.PreviewBreakdown(resources) preambleLogger.Infof("Tracking %d resources from release %s with kubedog (in parallel with helm)", len(resources), release.Name) if breakdown != "" { preambleLogger.Infof("Tracking breakdown: %s", breakdown) } flushPreamble() // Snapshot UID + generation BEFORE handing off to helm so each tracker // goroutine can wait until the resource actually changes. Without this, // kubedog observes the pre-upgrade "ready" state and exits immediately. trackOpts.WithBaselines(tracker.CaptureBaselines(ctx, resources)) // Buffer helm output so it doesn't interleave with kubedog progress on // stdout. We swap the helm logger out for one that writes into the buffer; // after tracking finishes we replay the buffer through the real logger as // a single block. bufferHelmOutput also derives a release-scoped context // so the helm-stuck safety valve can SIGINT helm directly. bufHelm, helmOutputBuf, releaseCancel := st.bufferHelmOutput(helm, ctx, release.Name) trackCtx, trackCancel := context.WithCancel(ctx) resultCh := make(chan error, 1) go func() { resultCh <- tracker.TrackResources(trackCtx, resources) }() // Two related safety valves run alongside the tracker. Both verify cluster // state via the live API rather than trusting kubedog's resource graph. // // 1. Tracker-race safety valve (always runs): once helm signals success, // wait a grace period and then poll. If the cluster confirms every // tracked resource converged but the dyntracker is still wedged // (kubedog race where a fast Job completion never flips ResourceStatus // to Ready), cancel the tracker so wait() can return success instead // of blocking until --track-timeout. // // 2. Helm-stuck killer (opt-in via helmStuckGrace): poll alongside helm. // If the cluster stays converged for helmStuckGrace while helm is // still running, send SIGINT to helm — that's the helm v4 hook waiter // wedge (statuswait.go:195 or legacy wait.go:263), recoverable only by // interrupting the helm subprocess. helmDoneCh := make(chan struct{}) var helmDoneOnce sync.Once var safetyValveTriggered atomic.Bool var helmKilledByUs atomic.Bool const ( safetyValveGrace = 60 * time.Second safetyValveCheck = 10 * time.Second safetyValveTrackerWindup = 30 * time.Second ) // safetyFiredCh is closed by either safety valve when it triggers, so // wait() can break out of its resultCh receive without depending on // dyntracker actually unwinding. Background: in production we hit an // upstream-kubedog bug where one of N parallel tracker goroutines did // not respect ctx.Done(), so TrackResources blocked on trackerWg.Wait() // forever and never sent on resultCh. With only `trackErr := <-resultCh` // in wait(), helmfile hung indefinitely even though the safety valve // had already confirmed cluster convergence. safetyFiredCh := make(chan struct{}) var safetyFiredOnce sync.Once signalSafetyFired := func() { safetyFiredOnce.Do(func() { close(safetyFiredCh) }) } // scheduleResultChBackstop runs after a safety valve fires to guarantee // resultCh is unblocked even if the dyntracker goroutines are wedged. // resultCh has capacity 1: if the tracker has already (or later) sent // its real result, our non-blocking send drops harmlessly. If the // tracker is hung, our nil unblocks wait(). scheduleResultChBackstop := func() { go func() { time.Sleep(safetyValveTrackerWindup) select { case resultCh <- nil: st.logger.Warnf("kubedog: tracker for release %s did not return %s after safety valve fired; pushing nil to unblock wait() (dyntracker likely wedged)", release.Name, safetyValveTrackerWindup) default: } }() } helmStuckGrace := st.getHelmStuckGrace(release, opts) go func() { select { case <-helmDoneCh: case <-trackCtx.Done(): return } select { case <-time.After(safetyValveGrace): case <-trackCtx.Done(): return } ticker := time.NewTicker(safetyValveCheck) defer ticker.Stop() for { if tracker.VerifyAllConverged(trackCtx, resources) { st.logger.Infof("kubedog: cluster confirms all tracked resources converged for release %s; canceling tracker (worked around dyntracker race)", release.Name) safetyValveTriggered.Store(true) trackCancel() signalSafetyFired() scheduleResultChBackstop() return } select { case <-trackCtx.Done(): return case <-ticker.C: } } }() if helmStuckGrace > 0 { go func() { ticker := time.NewTicker(safetyValveCheck) defer ticker.Stop() var firstConvergedAt time.Time for { select { case <-trackCtx.Done(): return case <-helmDoneCh: return // helm finished on its own; killer not needed case <-ticker.C: } if !tracker.VerifyAllConverged(trackCtx, resources) { firstConvergedAt = time.Time{} continue } if firstConvergedAt.IsZero() { firstConvergedAt = time.Now() continue } if time.Since(firstConvergedAt) < helmStuckGrace { continue } // Cluster has been converged for >= helmStuckGrace while helm // is still running. Treat as the helm v4 hook waiter wedge // and send SIGINT via the release-scoped context. Style the // whole block in bold+yellow so it stands out from the regular // info chatter in CI logs. block := fmt.Sprintf("%s\nCluster has confirmed convergence for %s but helm subprocess is still running.\nSending SIGINT to recover from helm v4 hook waiter wedge.\nRelease secret may need manual cleanup: kubectl -n %s delete secret sh.helm.release.v1.%s.", kubedog.HeaderDivider(fmt.Sprintf("WARNING: Release '%s' — helm-killer fired", release.Name)), time.Since(firstConvergedAt).Round(time.Second), release.Namespace, release.Name) st.logger.Warnf("\n%s", kubedog.StyleWarning(block, useColor)) helmKilledByUs.Store(true) releaseCancel() signalSafetyFired() scheduleResultChBackstop() return } }() } var flushOnce sync.Once flush := func() { flushOnce.Do(func() { payload := strings.TrimSpace(helmOutputBuf.String()) if payload == "" { return } header := kubedog.HeaderDividerStyled(fmt.Sprintf("Helm output for release '%s'", release.Name), useColor) st.logger.Infof("\n%s\n%s", header, payload) }) } canceled := false wait := func() *ReleaseError { // Race resultCh against three other paths so a wedged dyntracker // goroutine that won't return on ctx.Done() cannot pin helmfile // forever. In production we hit exactly this: one of N parallel // tracker goroutines ignored cancellation, trackerWg.Wait() never // completed, resultCh never received, and helmfile hung for 15 // hours despite the safety valve having confirmed cluster // convergence minutes earlier. var trackErr error hardTimeout := st.getReleaseHardTimeout(release, opts) hardTimer := time.NewTimer(hardTimeout) defer hardTimer.Stop() select { case trackErr = <-resultCh: // Normal path — tracker returned (success, error, or nil // pushed by a safety-valve backstop). case <-safetyFiredCh: // A safety valve fired and already confirmed cluster // convergence via the live API. Give dyntracker a brief // grace to wind down on its own; otherwise proceed without // waiting (cluster is healthy regardless). select { case trackErr = <-resultCh: case <-time.After(safetyValveTrackerWindup): st.logger.Warnf("kubedog: tracker for release %s did not return %s after safety valve fired; proceeding (cluster confirmed converged)", release.Name, safetyValveTrackerWindup) } case <-hardTimer.C: // Absolute ceiling: neither dyntracker nor any safety valve // returned within the release timeout. We have no // confirmation of cluster state, so treat as failure rather // than silent success. This is the seatbelt for "everything // else failed" — the user's mental model is that a release // should not exceed its own timeout. st.logger.Warnf("kubedog: tracker for release %s did not return within release timeout %s; treating as failure", release.Name, hardTimeout) trackCancel() releaseCancel() flush() return newReleaseFailedError(release, fmt.Errorf("kubedog tracker did not return within release timeout %s", hardTimeout)) } trackCancel() releaseCancel() flush() if canceled { // Helm failed and we canceled the tracker; treat as no-op. return nil } if safetyValveTriggered.Load() || helmKilledByUs.Load() { // We deliberately canceled the tracker after verifying via the // live API that everything was healthy. The resulting trackErr // (typically context.Canceled) is not a real failure. return nil } if trackErr != nil { st.logger.Warnf("kubedog tracking failed for release %s: %v", release.Name, trackErr) if st.shouldFailOnTrackError(release, opts) { return newReleaseFailedError(release, trackErr) } } return nil } cancel := func() { canceled = true trackCancel() releaseCancel() } notifyHelmDone := func() { tracker.MarkUpstreamCompleted() helmDoneOnce.Do(func() { close(helmDoneCh) }) } wasHelmKilled := func() bool { return helmKilledByUs.Load() } return &kubedogTrackingHandle{ Helm: bufHelm, Wait: wait, Cancel: cancel, NotifyHelmDone: notifyHelmDone, FlushBufferedHelmOutput: flush, WasHelmKilled: wasHelmKilled, }, true } // getReleaseHardTimeout returns the absolute ceiling on how long wait() // should block. Matches the user's mental model of "a release should not // exceed its own timeout" — the same timeout we pass to helm via --timeout. // Priority mirrors timeoutFlags(): ops.Timeout > release.Timeout > // HelmDefaults.Timeout. Falls back to track-timeout when no helm timeout // is configured, and finally to 10 minutes as a safe hard default. func (st *HelmState) getReleaseHardTimeout(release *ReleaseSpec, ops *SyncOpts) time.Duration { if ops != nil && ops.Timeout > 0 { return time.Duration(ops.Timeout) * time.Second } if release.Timeout != nil && *release.Timeout > 0 { return time.Duration(*release.Timeout) * time.Second } if st.HelmDefaults.Timeout > 0 { return time.Duration(st.HelmDefaults.Timeout) * time.Second } if release.TrackTimeout != nil && *release.TrackTimeout > 0 { return time.Duration(*release.TrackTimeout) * time.Second } if ops != nil && ops.TrackTimeout > 0 { return time.Duration(ops.TrackTimeout) * time.Second } return 10 * time.Minute } // getHelmStuckGrace returns the configured helm-stuck grace period — how long // the cluster must be confirmed converged via the live API while helm is // still running before we send SIGINT to helm. Zero disables the killer. // Lookup order: per-release HelmStuckGrace, SyncOpts.HelmStuckGrace, // HelmDefaults.HelmStuckGrace. func (st *HelmState) getHelmStuckGrace(release *ReleaseSpec, ops *SyncOpts) time.Duration { if release.HelmStuckGrace != nil && *release.HelmStuckGrace > 0 { return time.Duration(*release.HelmStuckGrace) * time.Second } if ops != nil && ops.HelmStuckGrace > 0 { return time.Duration(ops.HelmStuckGrace) * time.Second } if st.HelmDefaults.HelmStuckGrace > 0 { return time.Duration(st.HelmDefaults.HelmStuckGrace) * time.Second } return 0 } 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 { 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 } // 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 } // validServerSideValues are the allowed values for the helm 4 --server-side flag. var validServerSideValues = map[string]struct{}{"true": {}, "false": {}, "auto": {}} // resolveServerSideValue resolves the server-side value following precedence: // release-level > CLI flag > helmDefaults. Returns empty string if no value is configured. func (st *HelmState) resolveServerSideValue(release *ReleaseSpec, serverSide string) string { switch { case release.ServerSide != nil && *release.ServerSide != "": return *release.ServerSide case serverSide != "": return serverSide case st.HelmDefaults.ServerSide != nil && *st.HelmDefaults.ServerSide != "": return *st.HelmDefaults.ServerSide default: return "" } } // appendServerSideFlagsForUpgrade appends the helm 4 --server-side flag when appropriate. // Precedence: release-level > CLI flag > helmDefaults. func (st *HelmState) appendServerSideFlagsForUpgrade(flags []string, helm helmexec.Interface, release *ReleaseSpec, serverSide string) ([]string, error) { if !helm.IsHelm4() { // --server-side with a string value is helm 4 only. Guard against misconfiguration // when the user is running helm 3. if release.ServerSide != nil && *release.ServerSide != "" { return nil, fmt.Errorf("serverSide requires Helm 4 or greater (set via releases[].serverSide)") } if st.HelmDefaults.ServerSide != nil && *st.HelmDefaults.ServerSide != "" { return nil, fmt.Errorf("serverSide requires Helm 4 or greater (set via helmDefaults.serverSide)") } return flags, nil } value := st.resolveServerSideValue(release, serverSide) if value == "" { return flags, nil } if _, ok := validServerSideValues[value]; !ok { return nil, fmt.Errorf("invalid serverSide value %q: must be \"true\", \"false\", or \"auto\"", value) } flags = append(flags, "--server-side", value) return flags, nil } // 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() NeedsChartifyForLocalDir bool } 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) } // downloadAdhocDepChartWithGoGetter fetches a go-getter URL referenced by an // ad-hoc release dependency (release.dependencies[].chart) to a local cache // directory and returns the local path. // // Ad-hoc dependencies were previously passed to chartify as-is. chartify then // tried to resolve non-local, non-OCI values via `helm repo list`, which fails // for go-getter URLs like "git::https://host/repo.git@path?ref=tag" with // "no helm list entry found for repository". This mirrors the primary-chart // fetch path (downloadChartWithGoGetter) so go-getter URLs work uniformly. // See issue #821. func (st *HelmState) downloadAdhocDepChartWithGoGetter(release *ReleaseSpec, chart string) (string, error) { var pathElems []string if release.Namespace != "" { pathElems = append(pathElems, release.Namespace) } if release.KubeContext != "" { pathElems = append(pathElems, release.KubeContext) } pathElems = append(pathElems, release.Name, "deps") cacheDir := filepath.Join(pathElems...) return st.goGetterChart(chart, "", cacheDir, release.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 c.NeedsChartifyForLocalDir = true } } for _, d := range release.Dependencies { chart := d.Chart normalizedChart := normalizeChart(st.basePath, chart) if st.fs.DirectoryExistsAt(normalizedChart) { 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 } } else if rewritten, ok := st.resolveOCIAdhocDepChart(d.Chart); ok { st.logger.Debugf("ad-hoc dependency %q rewritten to %q (matched OCI repo entry)", d.Chart, rewritten) chart = rewritten } else if remote.IsRemote(chart) { // Ad-hoc dependency uses a go-getter URL (e.g. // "git::https://host/repo.git@path?ref=tag"). Fetch it to a local // cache directory so chartify treats it as a local chart instead of // trying to resolve it via `helm repo list`, which fails with // "no helm list entry found for repository" for go-getter URLs. // The primary chart already does this via downloadChartWithGoGetter; // this mirrors that path for ad-hoc deps. See issue #821. fetched, err := st.downloadAdhocDepChartWithGoGetter(release, chart) if err != nil { return nil, clean, fmt.Errorf("ad-hoc dependency %q: %w", d.Chart, err) } st.logger.Debugf("ad-hoc dependency %q fetched to %q via go-getter", d.Chart, fetched) chart = fetched } c.Opts.AdhocChartDependencies = append(c.Opts.AdhocChartDependencies, chartify.ChartDependency{ Alias: d.Alias, Chart: chart, Version: d.Version, }) shouldRun = true } // patchTemplateData is computed lazily on first use: only when an actual string patch/transformer // file path is rendered. Inline-map entries don't require template data at all, so we avoid // unnecessary I/O for releases whose patches are all inline maps or that have no string entries. var ( cachedPatchTemplateData releaseTemplateData cachedPatchTemplateDataErr error cachedPatchTemplateDataSet bool ) getPatchTemplateData := func() (releaseTemplateData, error) { if !cachedPatchTemplateDataSet { cachedPatchTemplateDataSet = true cachedPatchTemplateData, cachedPatchTemplateDataErr = st.mergedReleaseTemplateData(release) if cachedPatchTemplateDataErr != nil { cachedPatchTemplateDataErr = fmt.Errorf("failed to compute merged release values for patch rendering: %w", cachedPatchTemplateDataErr) } } return cachedPatchTemplateData, cachedPatchTemplateDataErr } jsonPatches := release.JSONPatches if len(jsonPatches) > 0 { generatedFiles, err := st.generateTemporaryReleaseValuesFilesWithData(release, jsonPatches, getPatchTemplateData) 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.generateTemporaryReleaseValuesFilesWithData(release, strategicMergePatches, getPatchTemplateData) 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.generateTemporaryReleaseValuesFilesWithData(release, transformers, getPatchTemplateData) 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 { tracker, _, err := st.buildReleaseTracker(release, ops, false) if err != nil { return fmt.Errorf("failed to create kubedog tracker: %w", err) } resources, err := st.getReleaseResources(ctx, release, helm, nil) 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 _, breakdown := tracker.PreviewBreakdown(resources); breakdown != "" { st.logger.Infof("Tracking breakdown: %s", breakdown) } if err := tracker.TrackResources(ctx, resources); err != nil { return fmt.Errorf("kubedog tracking failed for release %s: %w", release.Name, err) } return nil } // getReleaseResources templates a release and returns its parsed resources. // outLogger is the logger to use for the user-visible "Found N resources" // (and No-manifest / No-resources) messages — pass a buffered logger when // the caller wants to flush all per-release preamble output as one atomic // block, otherwise pass nil to use st.logger directly. func (st *HelmState) getReleaseResources(_ context.Context, release *ReleaseSpec, helm helmexec.Interface, outLogger *zap.SugaredLogger) ([]*resource.Resource, error) { if outLogger == nil { outLogger = st.logger } 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 { outLogger.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 { outLogger.Infof("No resources found in manifest for release %s", release.Name) return nil, nil } outLogger.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 }