mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 13:44:57 +02:00
* fix: support go-getter URLs in ad-hoc dependencies to fix #821 Ad-hoc release dependencies (release.dependencies[].chart) that used go-getter URLs like "git::https://host/repo.git@path?ref=tag" were passed to chartify as-is. chartify then tried to resolve them via `helm repo list`, which fails with "no helm list entry found for repository \"git::https:\". please `helm repo add` it!". The primary chart already fetched such URLs via downloadChartWithGoGetter, but the ad-hoc dependency path in PrepareChartify only handled local directories and OCI rewrites, so go-getter URLs fell through. This adds a branch that detects remote go-getter URLs (remote.IsRemote) and fetches them to a local cache directory via a new downloadAdhocDepChartWithGoGetter helper, mirroring the primary-chart fetch path. chartify then sees a local chart and takes its file:// branch. Fixes #821 Signed-off-by: yxxhero <aiopsclub@163.com> * test: add integration test for go-getter ad-hoc dependencies (#821) Adds an integration test that commits a chart to a throwaway local git repo and references it via a "git::file://..." go-getter URL as a release ad-hoc dependency, then asserts `helmfile template` renders both the main chart and the fetched dependency. Using file:// (rather than https://) keeps the test deterministic and network-free while exercising the exact fix path (remote.IsRemote + downloadAdhocDepChartWithGoGetter in PrepareChartify). Verified to fail on the unfixed code with the original "no helm list entry found for repository \"git::file:\"" error and pass on the fixed code. Signed-off-by: yxxhero <aiopsclub@163.com> * test: surface helmfile error in issue #821 integration test The integration runner (run.sh) enables `set -e`, so a non-zero helmfile exit aborted the script before the captured output could be printed, hiding the real failure in CI. Disable `set -e` around the helmfile invocation and report the exit code plus full output on failure. Signed-off-by: yxxhero <aiopsclub@163.com> * test: fix issue-821 integration test chart path and errexit handling Two issues caused the integration test to fail in CI (while passing in my local smoke test, which used an absolute path): 1. The main chart path was relative to the integration CWD, but the helmfile.yaml is generated in a temp directory and helmfile resolves `chart:` relative to that directory (its basePath). The relative path was interpreted as a named-repo chart and failed instantly with `Error: repo test not found`. Resolve the case dir to an absolute path with `$(cd ... && pwd)`. 2. run.sh runs under `set -e`, so the unguarded helmfile invocation exited the whole script on failure before the captured output could be printed, hiding the real error. Capture the exit code via `cmd || rc=$?` so failures are reported. Signed-off-by: yxxhero <aiopsclub@163.com> --------- Signed-off-by: yxxhero <aiopsclub@163.com>
1289 lines
44 KiB
Go
1289 lines
44 KiB
Go
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.<rev>",
|
|
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
|
|
}
|