Files
helmfile/pkg/state/helmx.go
T
yxxhero 81ab674017 fix: normalize dependency chart path before DirectoryExistsAt check (#2598)
When helmfile.d contains multiple release files and one release has a
local chart dependency (e.g. chart: ../chart), the dependency path was
passed to DirectoryExistsAt without normalizing against basePath. This
caused the path to be resolved against CWD instead of the helmfile
directory, so the local chart was not detected and helmfile tried to
resolve it as a remote repo, failing with:
  'failed reading adhoc dependencies: no helm list entry found for repository'

Fixes #2596

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-05-20 08:44:14 +08:00

728 lines
21 KiB
Go

package state
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"github.com/helmfile/chartify"
"helm.sh/helm/v4/pkg/storage/driver"
"github.com/helmfile/helmfile/pkg/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
}
func (st *HelmState) getTrackMode(release *ReleaseSpec, ops *SyncOpts) string {
trackMode := release.TrackMode
if trackMode == "" && ops != nil && ops.TrackMode != "" {
trackMode = ops.TrackMode
}
if trackMode == "" {
trackMode = st.HelmDefaults.TrackMode
}
if trackMode == "" {
trackMode = string(kubedog.TrackModeHelm)
}
return trackMode
}
func (st *HelmState) appendWaitFlags(flags []string, helm helmexec.Interface, release *ReleaseSpec, ops *SyncOpts) []string {
if st.shouldUseKubedog(release, ops) {
return flags
}
shouldWait := false
switch {
case release.Wait != nil && *release.Wait:
shouldWait = true
case ops != nil && ops.Wait:
shouldWait = true
case release.Wait == nil && st.HelmDefaults.Wait:
shouldWait = true
}
if shouldWait {
trackMode := st.getTrackMode(release, ops)
if trackMode == string(kubedog.TrackModeHelmLegacy) {
if helm != nil && helm.IsHelm4() {
flags = append(flags, "--wait=legacy")
} else {
if st.logger != nil {
st.logger.Warnf("trackMode 'helm-legacy' requires Helm v4, falling back to regular --wait for release %s", release.Name)
}
flags = append(flags, "--wait")
}
} else {
flags = append(flags, "--wait")
}
}
return flags
}
// append post-renderer flags to helm flags
func (st *HelmState) appendCascadeFlags(flags []string, helm helmexec.Interface, release *ReleaseSpec, cascade string) []string {
// see https://github.com/helm/helm/releases/tag/v3.12.1
if !helm.IsVersionAtLeast("3.12.1") {
return flags
}
switch {
// postRenderer arg comes from cmd flag.
case release.Cascade != nil && *release.Cascade != "":
flags = append(flags, "--cascade", *release.Cascade)
case cascade != "":
flags = append(flags, "--cascade", cascade)
case st.HelmDefaults.Cascade != nil && *st.HelmDefaults.Cascade != "":
flags = append(flags, "--cascade", *st.HelmDefaults.Cascade)
}
return flags
}
// append hide-notes flags to helm flags
func (st *HelmState) appendHideNotesFlags(flags []string, helm helmexec.Interface, ops *SyncOpts) []string {
if ops == nil {
return flags
}
// see https://github.com/helm/helm/releases/tag/v3.16.0
if !helm.IsVersionAtLeast("3.16.0") {
return flags
}
switch {
case ops.HideNotes:
flags = append(flags, "--hide-notes")
}
return flags
}
// append take-ownership flags to helm flags
func (st *HelmState) appendTakeOwnershipFlagsForUpgrade(flags []string, helm helmexec.Interface, release *ReleaseSpec, takeOwnership bool) []string {
// see https://github.com/helm/helm/releases/tag/v3.17.0
if !helm.IsVersionAtLeast("3.17.0") {
return flags
}
switch {
case release.TakeOwnership != nil && *release.TakeOwnership:
flags = append(flags, "--take-ownership")
case takeOwnership:
flags = append(flags, "--take-ownership")
case st.HelmDefaults.TakeOwnership != nil && *st.HelmDefaults.TakeOwnership:
flags = append(flags, "--take-ownership")
}
return flags
}
// append show-only flags to helm flags
func (st *HelmState) appendShowOnlyFlags(flags []string, showOnly []string) []string {
showOnlyFlags := []string{}
if len(showOnly) != 0 {
showOnlyFlags = showOnly
}
for _, arg := range showOnlyFlags {
if arg != "" {
flags = append(flags, "--show-only", arg)
}
}
return flags
}
type Chartify struct {
Opts *chartify.ChartifyOpts
Clean func()
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)
}
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
}
c.Opts.AdhocChartDependencies = append(c.Opts.AdhocChartDependencies, chartify.ChartDependency{
Alias: d.Alias,
Chart: chart,
Version: d.Version,
})
shouldRun = true
}
jsonPatches := release.JSONPatches
if len(jsonPatches) > 0 {
generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, jsonPatches)
if err != nil {
return nil, clean, err
}
filesNeedCleaning = append(filesNeedCleaning, generatedFiles...)
c.Opts.JsonPatches = append(c.Opts.JsonPatches, generatedFiles...)
shouldRun = true
}
strategicMergePatches := release.StrategicMergePatches
if len(strategicMergePatches) > 0 {
generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, strategicMergePatches)
if err != nil {
return nil, clean, err
}
c.Opts.StrategicMergePatches = append(c.Opts.StrategicMergePatches, generatedFiles...)
filesNeedCleaning = append(filesNeedCleaning, generatedFiles...)
shouldRun = true
}
transformers := release.Transformers
if len(transformers) > 0 {
generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, transformers)
if err != nil {
return nil, clean, err
}
c.Opts.Transformers = append(c.Opts.Transformers, generatedFiles...)
filesNeedCleaning = append(filesNeedCleaning, generatedFiles...)
shouldRun = true
}
if release.ForceNamespace != "" {
c.Opts.OverrideNamespace = release.ForceNamespace
shouldRun = true
}
if shouldRun {
st.logger.Debugf("Chartify process for %s", dir)
generatedFiles, err := st.generateValuesFiles(helm, release, workerIndex)
if err != nil {
return nil, clean, err
}
filesNeedCleaning = append(filesNeedCleaning, generatedFiles...)
c.Opts.ValuesFiles = generatedFiles
setFlags, err := st.setFlags(release.SetValues)
if err != nil {
return nil, clean, fmt.Errorf("rendering set value entry for release %s: %v", release.Name, err)
}
c.Opts.SetFlags = setFlags
c.Opts.TemplateData = st.newReleaseTemplateData(release)
c.Opts.TemplateFuncs = st.newReleaseTemplateFuncMap(dir)
return c, clean, nil
}
return nil, clean, nil
}
func (st *HelmState) trackWithKubedog(ctx context.Context, release *ReleaseSpec, helm helmexec.Interface, ops *SyncOpts) error {
timeout := 5 * time.Minute
if release.TrackTimeout != nil && *release.TrackTimeout > 0 {
timeout = time.Duration(*release.TrackTimeout) * time.Second
} else if ops != nil && ops.TrackTimeout > 0 {
timeout = time.Duration(ops.TrackTimeout) * time.Second
}
trackLogs := release.TrackLogs != nil && *release.TrackLogs
if release.TrackLogs == nil && ops != nil {
trackLogs = ops.TrackLogs
}
filterConfig := &resource.FilterConfig{
TrackKinds: release.TrackKinds,
SkipKinds: release.SkipKinds,
TrackResources: convertTrackResources(release.TrackResources),
}
kubeContext := st.getKubeContext(release)
trackOpts := kubedog.NewTrackOptions().
WithTimeout(timeout).
WithLogs(trackLogs).
WithFilterConfig(filterConfig)
tracker, err := kubedog.NewTracker(&kubedog.TrackerConfig{
Logger: st.logger,
Namespace: release.Namespace,
KubeContext: kubeContext,
Kubeconfig: st.kubeconfig,
TrackOptions: trackOpts,
KubedogQPS: release.KubedogQPS,
KubedogBurst: release.KubedogBurst,
})
if err != nil {
return fmt.Errorf("failed to create kubedog tracker: %w", err)
}
resources, err := st.getReleaseResources(ctx, release, helm)
if err != nil {
return fmt.Errorf("failed to get release resources: %w", err)
}
if len(resources) == 0 {
st.logger.Infof("No trackable resources found for release %s", release.Name)
return nil
}
st.logger.Infof("Tracking %d resources from release %s with kubedog", len(resources), release.Name)
if err := tracker.TrackResources(ctx, resources); err != nil {
return fmt.Errorf("kubedog tracking failed for release %s: %w", release.Name, err)
}
return nil
}
func (st *HelmState) getReleaseResources(_ context.Context, release *ReleaseSpec, helm helmexec.Interface) ([]*resource.Resource, error) {
st.logger.Debugf("Getting resources for release %s", release.Name)
manifest, namespace, err := st.getReleaseManifest(release, helm)
if err != nil {
return nil, fmt.Errorf("failed to get release manifest: %w", err)
}
if len(manifest) == 0 {
st.logger.Infof("No manifest found for release %s", release.Name)
return nil, nil
}
defaultNs := namespace
if defaultNs == "" {
defaultNs = "default"
}
resources, err := resource.ParseManifest(manifest, defaultNs, st.logger)
if err != nil {
return nil, fmt.Errorf("failed to parse release resources from manifest: %w", err)
}
if len(resources) == 0 {
st.logger.Infof("No resources found in manifest for release %s", release.Name)
return nil, nil
}
st.logger.Infof("Found %d resources in manifest for release %s", len(resources), release.Name)
result := make([]*resource.Resource, len(resources))
for i := range resources {
result[i] = &resources[i]
}
return result, nil
}
func (st *HelmState) getReleaseManifest(release *ReleaseSpec, helm helmexec.Interface) ([]byte, string, error) {
var tempDir string
var err error
if st.tempDir != nil {
tempDir, err = st.tempDir("", "helmfile-template-")
} else {
tempDir, err = os.MkdirTemp("", "helmfile-template-")
}
if err != nil {
return nil, "", fmt.Errorf("failed to create temp directory: %w", err)
}
defer func() {
if err := os.RemoveAll(tempDir); err != nil {
st.logger.Warnf("Failed to remove temp directory %s: %v", tempDir, err)
}
}()
releaseCopy := *release
st.ApplyOverrides(&releaseCopy)
flags, files, err := st.flagsForTemplate(helm, &releaseCopy, 0, &TemplateOpts{})
if err != nil {
return nil, "", fmt.Errorf("failed to generate template flags: %w", err)
}
defer st.removeFiles(files)
flags = append(flags, "--output-dir", tempDir)
if err := helm.TemplateRelease(releaseCopy.Name, releaseCopy.ChartPathOrName(), flags...); err != nil {
return nil, "", fmt.Errorf("failed to run helm template: %w", err)
}
var manifest []byte
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(info.Name(), ".yaml") && !strings.HasSuffix(info.Name(), ".yml") {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", path, err)
}
if len(manifest) > 0 {
manifest = append(manifest, []byte("\n---\n")...)
}
manifest = append(manifest, content...)
return nil
})
if err != nil {
return nil, "", fmt.Errorf("failed to walk template output directory: %w", err)
}
return manifest, releaseCopy.Namespace, nil
}
func convertTrackResources(resources []TrackResourceSpec) []resource.Resource {
if len(resources) == 0 {
return nil
}
result := make([]resource.Resource, len(resources))
for i, r := range resources {
result[i] = resource.Resource{
Kind: r.Kind,
Name: r.Name,
Namespace: r.Namespace,
}
}
return result
}