helmfile/pkg/state/chart_dependency.go

419 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
}
depMan := NewChartDependencyManager(filename, st.logger, st.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) {
depMan := NewChartDependencyManager(filename, st.logger, st.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.updateHelm3(shell, wd, unresolved)
}
func (m *chartDependencyManager) updateHelm3(shell helmexec.DependencyUpdater, wd string, unresolved *UnresolvedDependencies) (*ResolvedDependencies, error) {
// Generate `Chart.yaml` of the temporary local chart
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
}