mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 18:48:12 +02:00
fix: helmfile deps broken for OCI charts with underscores in path (#954) For OCI charts with multi-segment paths (e.g., myrepo/path_with_underscores/example), helmfile was putting the full path as the dependency name in the generated Chart.yaml. Helm then reconstructed the OCI reference using this name, and underscores in the path caused issues with helm's OCI reference handling during dependency update. Fix: move the chart path prefix into the repository URL and use only the chart basename as the dependency name, matching Helm's recommended Chart.yaml format for OCI dependencies: # Before (broken): dependencies: - name: path_with_underscores/example repository: oci://harbor.custom.com # After (fixed): dependencies: - name: example repository: oci://harbor.custom.com/path_with_underscores The resulting OCI reference is identical, but the dependency name is now clean. Includes backward-compatibility fallback for old lock files that used the full path as the dependency name. Signed-off-by: yxxhero <aiopsclub@163.com>
508 lines
16 KiB
Go
508 lines
16 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 dedupResolvedDependencies(deps []ResolvedChartDependency) []ResolvedChartDependency {
|
|
seen := map[string]bool{}
|
|
result := make([]ResolvedChartDependency, 0, len(deps))
|
|
for _, dep := range deps {
|
|
key := dep.ChartName + "|" + dep.Repository + "|" + dep.Version
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
result = append(result, dep)
|
|
}
|
|
return result
|
|
}
|
|
|
|
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.
|
|
switch {
|
|
case lockFile != "":
|
|
if st.basePath != "" && !filepath.IsAbs(lockFile) {
|
|
lockFile = filepath.Join(st.basePath, lockFile)
|
|
}
|
|
case st.basePath != "":
|
|
// When no custom lockfile is specified, use the default lockfile name
|
|
// joined with basePath to ensure it's found when not changing CWD.
|
|
lockFile = filepath.Join(st.basePath, filename+".lock")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
repoToSpec := map[string]RepositorySpec{}
|
|
|
|
for _, r := range st.Repositories {
|
|
repoToSpec[r.Name] = r
|
|
}
|
|
|
|
updated := *st
|
|
for i, r := range updated.Releases {
|
|
repo, chart, ok := resolveRemoteChart(r.Chart)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
repoSpec, ok := repoToSpec[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
|
|
}
|
|
|
|
// For OCI charts, the dependency name in the lock file is the chart basename
|
|
// (last path segment), not the full path. Apply the same transformation as
|
|
// getUnresolvedDependenciess to ensure the lookup matches (see issue #954).
|
|
lookupChart := chart
|
|
if repoSpec.OCI {
|
|
lookupChart = ociDependencyChartName(chart)
|
|
}
|
|
|
|
ver, err := resolved.Get(lookupChart, r.Version)
|
|
if err != nil && repoSpec.OCI && lookupChart != chart {
|
|
// Backward compatibility: old lock files may use the full path as name
|
|
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)
|
|
}
|
|
|
|
// ociDependencyChartName returns the last path segment of a chart name, which is
|
|
// used as the dependency name in Chart.yaml for OCI charts. For example:
|
|
//
|
|
// "path_with_underscores/example" → "example"
|
|
// "example" → "example"
|
|
//
|
|
// This avoids issues with slashes, underscores, and other special characters in
|
|
// the chart path when helm processes OCI references during dependency update.
|
|
// See issue #954.
|
|
func ociDependencyChartName(chart string) string {
|
|
if idx := strings.LastIndex(chart, "/"); idx >= 0 {
|
|
return chart[idx+1:]
|
|
}
|
|
return chart
|
|
}
|
|
|
|
// ociDependencyRepoURL builds the repository URL for an OCI chart dependency by
|
|
// combining the base registry URL with any path prefix from the chart name.
|
|
// For example:
|
|
//
|
|
// chart="path_with_underscores/example", baseURL="oci://registry.example.com"
|
|
// → "oci://registry.example.com/path_with_underscores"
|
|
func ociDependencyRepoURL(chart, ociBaseURL string) string {
|
|
if idx := strings.LastIndex(chart, "/"); idx >= 0 {
|
|
return strings.TrimSuffix(ociBaseURL, "/") + "/" + chart[:idx]
|
|
}
|
|
return ociBaseURL
|
|
}
|
|
|
|
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 {
|
|
ociBaseURL := fmt.Sprintf("oci://%s", url)
|
|
// For OCI charts with multi-segment paths (e.g., "path/chart"), put the
|
|
// path prefix into the repository URL and use just the chart basename as
|
|
// the dependency name. This avoids issues with underscores and other
|
|
// special characters in OCI references during helm dependency update.
|
|
url = ociDependencyRepoURL(chart, ociBaseURL)
|
|
chart = ociDependencyChartName(chart)
|
|
}
|
|
|
|
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
|
|
switch {
|
|
case lockFile != "":
|
|
if st.basePath != "" && !filepath.IsAbs(lockFile) {
|
|
lockFile = filepath.Join(st.basePath, lockFile)
|
|
}
|
|
case st.basePath != "":
|
|
lockFile = filepath.Join(st.basePath, filename+".lock")
|
|
}
|
|
|
|
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.ResolvedDependencies = dedupResolvedDependencies(lockedReqs.ResolvedDependencies)
|
|
|
|
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
|
|
}
|