mirror of
https://github.com/helmfile/helmfile.git
synced 2026-10-03 07:22:01 +02:00
* fix: eliminate os.Chdir in sequential helmfiles to fix relative path resolution The sequential code path used within() → os.Chdir() to change the process-wide working directory when processing helmfile.d files. This broke relative environment variable paths (e.g. KUBECONFIG=kubeconfig.yaml) because they resolved from the wrong directory after chdir. Replace the chdir-based approach with the same baseDir parameter pattern used by the parallel code path, passing explicit directory context through loadDesiredStateFromYamlWithBaseDir() instead of mutating global process state. Closes #2409 Signed-off-by: Aditya Menon <amenon@canarytechnologies.com> * fix: restore within() for single-file sequential to preserve chart path format The previous approach used baseDir for all sequential processing, which changed chart path format in output (e.g. from "../../../../charts/raw" to "test/integration/charts/raw"). This broke integration tests that compare chart paths in expected output. Now the sequential branch uses two strategies: - Single file: use os.Chdir via within() to preserve backward-compatible relative chart paths in output - Multiple files with --sequential-helmfiles: use baseDir parameter to avoid os.Chdir, fixing relative env var paths like KUBECONFIG (#2409) Signed-off-by: Aditya Menon <amenon@canarytechnologies.com> * fix: revert e2e snapshot outputs to match within() behavior The previous commit restored within() for single-file sequential processing, which produces relative chart paths (e.g. ../../charts/raw) and filename-only FilePath. Revert the e2e snapshot expected outputs to match main branch since single-file behavior is now identical. Signed-off-by: Aditya Menon <amenon@canarytechnologies.com> * fix: restructure integration test for multi-file sequential processing - Point -f at helmfile.d/ directly (not parent dir) so findDesiredStateFiles discovers the yaml files - Add second helmfile to trigger baseDir path (len > 1) - Inline environment config to avoid base file relative path issues - Verify both releases appear in output instead of comparing with parallel (which may differ in ordering) Signed-off-by: Aditya Menon <amenon@canarytechnologies.com> * fix: reduce cognitive complexity and improve accuracy of sequential helmfiles Replace inline visitSubHelmfiles closure with calls to the existing processNestedHelmfiles() method, matching the parallel path. This eliminates duplicated nested logic and reduces gocognit complexity below the CI threshold of 110. Also fixes help text and docs to accurately describe that single-file processing still uses within(), and adds kubeContext verification to the integration test. Signed-off-by: Aditya Menon <amenon@canarytechnologies.com> * test: validate kubeContext resolution in sequential helmfiles integration test Restructure the integration test to replicate the exact user scenario from issue #2409: - Multiple files in helmfile.d/ using bases: with relative paths (../bases/) for environments and defaults - Environment values set kubeContext via .Environment.Values - helmDefaults.kubeContext rendered from gotmpl - Local chart references (../../../../charts/raw) from helmfile.d/ - Run diff against the minikube cluster to exercise kubeContext resolution, which would fail with "context does not exist" if os.Chdir() broke relative path resolution - Also verify template output for both releases and relative values file (values/common.yaml) resolution Fix normalizeChart() in util.go to be idempotent — skip re-prefixing when the chart path already starts with basePath. This prevents double-prefixing of local chart paths (e.g. helmfile.d/test/.../raw) when normalizeChart is called multiple times (once during chart preparation and again during diff/sync). Signed-off-by: Aditya Menon <amenon@canarytechnologies.com> --------- Signed-off-by: Aditya Menon <amenon@canarytechnologies.com>
433 lines
13 KiB
Go
433 lines
13 KiB
Go
package state
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/Masterminds/semver/v3"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/helmfile/helmfile/pkg/app/version"
|
|
"github.com/helmfile/helmfile/pkg/helmexec"
|
|
"github.com/helmfile/helmfile/pkg/yaml"
|
|
)
|
|
|
|
type ChartMeta struct {
|
|
Name string `yaml:"name"`
|
|
}
|
|
|
|
// unresolvedChartDependency represents a dependency that is to be resolved.
|
|
//
|
|
// Helmfile generates Helm Chart.yaml containing unresolved dependencies, and runs `helm dependency update` to produce Helm Chart.lock
|
|
// which becomes helmfile.lock, which is then used to resolve the dependencies.
|
|
type unresolvedChartDependency struct {
|
|
// ChartName identifies the dependant chart. In Helmfile, ChartName for `chart: stable/envoy` would be just `envoy`.
|
|
// It can't be collided with other charts referenced in the same helmfile spec.
|
|
// That is, collocating `chart: incubator/foo` and `chart: stable/foo` isn't allowed. Name them differently for a work-around.
|
|
ChartName string `yaml:"name"`
|
|
// Repository contains the URL for the helm chart repository that hosts the chart identified by ChartName
|
|
Repository string `yaml:"repository"`
|
|
// VersionConstraint is the version constraint of the dependent chart. "*" means the latest version.
|
|
VersionConstraint string `yaml:"version"`
|
|
// Alias differentiates multiple dependencies with the same ChartName.
|
|
// Despite its name, and its optional in Helm's Chart.yaml, we use this as a unique identifier for the dependency.
|
|
// So, every dependency have an alias, even if it's not explicitly set in the helmfile.
|
|
Alias string `yaml:"alias"`
|
|
}
|
|
|
|
type ResolvedChartDependency struct {
|
|
// ChartName identifies the dependant chart. In Helmfile, ChartName for `chart: stable/envoy` would be just `envoy`.
|
|
// It can't be collided with other charts referenced in the same helmfile spec.
|
|
// That is, collocating `chart: incubator/foo` and `chart: stable/foo` isn't allowed. Name them differently for a work-around.
|
|
ChartName string `yaml:"name"`
|
|
// Repository contains the URL for the helm chart repository that hosts the chart identified by ChartName
|
|
Repository string `yaml:"repository"`
|
|
// Version is the version number of the dependent chart.
|
|
// In the context of helmfile this can be omitted. When omitted, it is considered `*` which results helm/helmfile fetching the latest version.
|
|
Version string `yaml:"version"`
|
|
}
|
|
|
|
type UnresolvedDependencies struct {
|
|
deps map[string][]unresolvedChartDependency
|
|
}
|
|
|
|
type ChartRequirements struct {
|
|
UnresolvedDependencies []unresolvedChartDependency `yaml:"dependencies"`
|
|
}
|
|
|
|
type ChartLockedRequirements struct {
|
|
Version string `yaml:"version"`
|
|
ResolvedDependencies []ResolvedChartDependency `yaml:"dependencies"`
|
|
Digest string `yaml:"digest"`
|
|
Generated string `yaml:"generated"`
|
|
}
|
|
|
|
func (d *UnresolvedDependencies) Add(chart, url, versionConstraint, alias string) {
|
|
d.deps[chart] = append(d.deps[chart], unresolvedChartDependency{
|
|
ChartName: chart,
|
|
Repository: url,
|
|
VersionConstraint: versionConstraint,
|
|
Alias: alias,
|
|
})
|
|
}
|
|
|
|
func (d *UnresolvedDependencies) ToChartRequirements() *ChartRequirements {
|
|
deps := []unresolvedChartDependency{}
|
|
|
|
for _, ds := range d.deps {
|
|
for _, d := range ds {
|
|
if d.VersionConstraint == "" {
|
|
d.VersionConstraint = "*"
|
|
}
|
|
deps = append(deps, d)
|
|
}
|
|
}
|
|
|
|
return &ChartRequirements{UnresolvedDependencies: deps}
|
|
}
|
|
|
|
type ResolvedDependencies struct {
|
|
deps map[string][]ResolvedChartDependency
|
|
}
|
|
|
|
// nolint: unparam
|
|
func (d *ResolvedDependencies) add(dep ResolvedChartDependency) error {
|
|
deps := d.deps[dep.ChartName]
|
|
if deps == nil {
|
|
deps = []ResolvedChartDependency{dep}
|
|
} else {
|
|
deps = append(deps, dep)
|
|
}
|
|
d.deps[dep.ChartName] = deps
|
|
return nil
|
|
}
|
|
|
|
func (d *ResolvedDependencies) Get(chart, versionConstraint string) (string, error) {
|
|
if versionConstraint == "" {
|
|
versionConstraint = "*"
|
|
}
|
|
|
|
deps, exists := d.deps[chart]
|
|
if exists {
|
|
for _, dep := range deps {
|
|
constraint, err := semver.NewConstraint(versionConstraint)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
version, err := semver.NewVersion(dep.Version)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if constraint.Check(version) {
|
|
return dep.Version, nil
|
|
}
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no resolved dependency found for \"%s\", running \"helmfile deps\" may resolve the issue", chart)
|
|
}
|
|
|
|
func (st *HelmState) mergeLockedDependencies() (*HelmState, error) {
|
|
filename, unresolved := getUnresolvedDependenciess(st)
|
|
|
|
if len(unresolved.deps) == 0 {
|
|
return st, nil
|
|
}
|
|
|
|
lockFile := st.LockFile
|
|
// When basePath is set (e.g. when loaded with baseDir instead of os.Chdir),
|
|
// resolve the lock file path relative to basePath so it can be found
|
|
// without changing the working directory.
|
|
if lockFile != "" && st.basePath != "" && !filepath.IsAbs(lockFile) {
|
|
lockFile = filepath.Join(st.basePath, lockFile)
|
|
}
|
|
|
|
depMan := NewChartDependencyManager(filename, st.logger, lockFile)
|
|
|
|
if st.fs.ReadFile != nil {
|
|
depMan.readFile = st.fs.ReadFile
|
|
}
|
|
|
|
return resolveDependencies(st, depMan, unresolved)
|
|
}
|
|
|
|
func resolveDependencies(st *HelmState, depMan *chartDependencyManager, unresolved *UnresolvedDependencies) (*HelmState, error) {
|
|
resolved, lockfileExists, err := depMan.Resolve(unresolved)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to resolve %d deps: %v", len(unresolved.deps), err)
|
|
}
|
|
if !lockfileExists {
|
|
return st, nil
|
|
}
|
|
|
|
repoToURL := map[string]string{}
|
|
|
|
for _, r := range st.Repositories {
|
|
repoToURL[r.Name] = r.URL
|
|
}
|
|
|
|
updated := *st
|
|
for i, r := range updated.Releases {
|
|
repo, chart, ok := resolveRemoteChart(r.Chart)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
_, ok = repoToURL[repo]
|
|
// Skip this chart from dependency management, as there's no matching `repository` in the helmfile state,
|
|
// which may imply that this is a local chart within a directory, like `charts/myapp`
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
ver, err := resolved.Get(chart, r.Version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updated.Releases[i].Version = ver
|
|
}
|
|
|
|
return &updated, nil
|
|
}
|
|
|
|
func (st *HelmState) updateDependenciesInTempDir(shell helmexec.DependencyUpdater, tempDir func(string, string) (string, error)) (*HelmState, error) {
|
|
filename, unresolved := getUnresolvedDependenciess(st)
|
|
|
|
if len(unresolved.deps) == 0 {
|
|
st.logger.Warnf("There are no repositories defined in your helmfile.yaml.\nThis means helmfile cannot update your dependencies or create a lock file.\nSee https://github.com/roboll/helmfile/issues/878 for more information.")
|
|
return st, nil
|
|
}
|
|
|
|
d, err := tempDir("", "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create dir: %v", err)
|
|
}
|
|
defer func() {
|
|
_ = os.RemoveAll(d)
|
|
}()
|
|
|
|
return updateDependencies(st, shell, unresolved, filename, d)
|
|
}
|
|
|
|
// aliasNameFormat = regexp.MustCompile("^[a-zA-Z0-9_-]+$") from helm code
|
|
func chartDependenciesAlias(namespace, releaseName string) string {
|
|
return fmt.Sprintf("%s-%s", namespace, releaseName)
|
|
}
|
|
|
|
func getUnresolvedDependenciess(st *HelmState) (string, *UnresolvedDependencies) {
|
|
repoToURL := map[string]RepositorySpec{}
|
|
|
|
for _, r := range st.Repositories {
|
|
repoToURL[r.Name] = r
|
|
}
|
|
|
|
unresolved := &UnresolvedDependencies{deps: map[string][]unresolvedChartDependency{}}
|
|
|
|
for _, r := range st.Releases {
|
|
repo, chart, ok := resolveRemoteChart(r.Chart)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
repoSpec, ok := repoToURL[repo]
|
|
// Skip this chart from dependency management, as there's no matching `repository` in the helmfile state,
|
|
// which may imply that this is a local chart within a directory, like `charts/myapp`
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
url := repoSpec.URL
|
|
|
|
if repoSpec.OCI {
|
|
url = fmt.Sprintf("oci://%s", url)
|
|
}
|
|
|
|
unresolved.Add(chart, url, r.Version, chartDependenciesAlias(r.Namespace, r.Name))
|
|
}
|
|
|
|
filename := filepath.Base(st.FilePath)
|
|
filename = strings.TrimSuffix(filename, ".gotmpl")
|
|
filename = strings.TrimSuffix(filename, ".yaml")
|
|
filename = strings.TrimSuffix(filename, ".yml")
|
|
|
|
return filename, unresolved
|
|
}
|
|
|
|
func updateDependencies(st *HelmState, shell helmexec.DependencyUpdater, unresolved *UnresolvedDependencies, filename, wd string) (*HelmState, error) {
|
|
lockFile := st.LockFile
|
|
if lockFile != "" && st.basePath != "" && !filepath.IsAbs(lockFile) {
|
|
lockFile = filepath.Join(st.basePath, lockFile)
|
|
}
|
|
|
|
depMan := NewChartDependencyManager(filename, st.logger, lockFile)
|
|
|
|
_, err := depMan.Update(shell, wd, unresolved)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to update %d deps: %v", len(unresolved.deps), err)
|
|
}
|
|
|
|
return resolveDependencies(st, depMan, unresolved)
|
|
}
|
|
|
|
type chartDependencyManager struct {
|
|
Name string
|
|
|
|
lockFilePath string
|
|
|
|
logger *zap.SugaredLogger
|
|
|
|
readFile func(string) ([]byte, error)
|
|
writeFile func(string, []byte, os.FileMode) error
|
|
}
|
|
|
|
func NewChartDependencyManager(name string, logger *zap.SugaredLogger, lockFilePath string) *chartDependencyManager {
|
|
return &chartDependencyManager{
|
|
Name: name,
|
|
readFile: os.ReadFile,
|
|
writeFile: os.WriteFile,
|
|
logger: logger,
|
|
lockFilePath: lockFilePath,
|
|
}
|
|
}
|
|
|
|
func (m *chartDependencyManager) lockFileName() string {
|
|
if m.lockFilePath != "" {
|
|
return m.lockFilePath
|
|
} else {
|
|
return fmt.Sprintf("%s.lock", m.Name)
|
|
}
|
|
}
|
|
|
|
func (m *chartDependencyManager) Update(shell helmexec.DependencyUpdater, wd string, unresolved *UnresolvedDependencies) (*ResolvedDependencies, error) {
|
|
return m.updateHelm(shell, wd, unresolved)
|
|
}
|
|
|
|
func (m *chartDependencyManager) updateHelm(shell helmexec.DependencyUpdater, wd string, unresolved *UnresolvedDependencies) (*ResolvedDependencies, error) {
|
|
// Generate `Chart.yaml` of the temporary local chart
|
|
// Both Helm 3 and Helm 4 use apiVersion: v2 and Chart.lock
|
|
chartMetaContent := fmt.Sprintf("name: %s\nversion: 1.0.0\napiVersion: v2\n", m.Name)
|
|
|
|
// Generate `requirements.yaml` of the temporary local chart from the helmfile state
|
|
reqsContent, err := yaml.Marshal(unresolved.ToChartRequirements())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := m.writeBytes(filepath.Join(wd, "Chart.yaml"), []byte(chartMetaContent+string(reqsContent))); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return m.doUpdate("Chart.lock", unresolved, shell, wd)
|
|
}
|
|
|
|
func (m *chartDependencyManager) doUpdate(chartLockFile string, unresolved *UnresolvedDependencies, shell helmexec.DependencyUpdater, wd string) (*ResolvedDependencies, error) {
|
|
// Generate `requirements.lock` of the temporary local chart by coping `<basename>.lock`
|
|
lockFilePath := m.lockFileName()
|
|
|
|
originalLockFileContent, err := m.readBytes(lockFilePath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
|
|
if originalLockFileContent != nil {
|
|
if err := m.writeBytes(filepath.Join(wd, chartLockFile), originalLockFileContent); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Update the lock file by running `helm dependency update`
|
|
if err := shell.UpdateDeps(wd); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updatedLockFileContent, err := m.readBytes(filepath.Join(wd, chartLockFile))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sort requirements alphabetically by name.
|
|
lockedReqs := &ChartLockedRequirements{}
|
|
if err := yaml.Unmarshal(updatedLockFileContent, lockedReqs); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sort.Slice(lockedReqs.ResolvedDependencies, func(i, j int) bool {
|
|
return lockedReqs.ResolvedDependencies[i].ChartName < lockedReqs.ResolvedDependencies[j].ChartName
|
|
})
|
|
|
|
lockedReqs.Version = version.Version()
|
|
|
|
updatedLockFileContent, err = yaml.Marshal(lockedReqs)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Commit the lock file if and only if everything looks ok
|
|
if err := m.writeBytes(lockFilePath, updatedLockFileContent); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resolved, _, err := m.Resolve(unresolved)
|
|
return resolved, err
|
|
}
|
|
|
|
func (m *chartDependencyManager) Resolve(unresolved *UnresolvedDependencies) (*ResolvedDependencies, bool, error) {
|
|
updatedLockFileContent, err := m.readBytes(m.lockFileName())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
|
|
// Load resolved dependencies into memory
|
|
lockedReqs := &ChartLockedRequirements{}
|
|
if err := yaml.Unmarshal(updatedLockFileContent, lockedReqs); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
// Make sure go run main.go works and compatible with old lock files.
|
|
if version.Version() != "" && lockedReqs.Version != "" {
|
|
// Check that the locked version, i.e. the helmfile binary version recorded in the lock file,
|
|
// conforms to semver.
|
|
// This is purely for validation purposes.
|
|
_, err := semver.NewVersion(lockedReqs.Version)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
// Note: We no longer validate the version of the lockfile against the version of the helmfile binary.
|
|
// See https://github.com/helmfile/helmfile/issues/1473
|
|
}
|
|
|
|
resolved := &ResolvedDependencies{deps: map[string][]ResolvedChartDependency{}}
|
|
for _, d := range lockedReqs.ResolvedDependencies {
|
|
if err := resolved.add(d); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
|
|
return resolved, true, nil
|
|
}
|
|
|
|
func (m *chartDependencyManager) readBytes(filename string) ([]byte, error) {
|
|
bytes, err := m.readFile(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.logger.Debugf("readBytes: read from %s:\n%s", filename, bytes)
|
|
return bytes, nil
|
|
}
|
|
|
|
func (m *chartDependencyManager) writeBytes(filename string, data []byte) error {
|
|
err := m.writeFile(filename, data, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.logger.Debugf("writeBytes: wrote to %s:\n%s", filename, data)
|
|
return nil
|
|
}
|