diff --git a/README.md b/README.md index 990671dc..0f19443c 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,20 @@ dependencies of any referenced local charts. For Helm 2.9+ you can use a username and password to authenticate to a remote repository. +### deps + +The `helmfile deps` sub-command locks your helmfile state and local charts dependencies. + +It basically runs `helm dependency update` on your helmfile state file and all the referenced local charts, so that you get a "lock" file per each helmfile state or local chart. + +All the other `helmfile` sub-commands like `sync` use chart versions recorded in the lock files, so that e.g. untested chart versions won't suddenly get deployed to the production environment. + +For example, the lock file for a helmfile state file named `helmfile.1.yaml` will be `helmfile.1.lock`. The lock file for a local chart would be `requirements.lock`, which is the same as `helm`. + +It is recommended to version-control all the lock files, so that they can be used in the production deployment pipeline for extra reproducibility. + +To bring in chart updates systematically, it would also be a good idea to run `helmfile deps` regularly, test it, and then update the lock files in the version-control system. + ### diff The `helmfile diff` sub-command executes the [helm-diff](https://github.com/databus23/helm-diff) plugin across all of @@ -292,6 +306,7 @@ The `helmfile delete` sub-command deletes all the releases defined in the manife Note that `delete` doesn't purge releases. So `helmfile delete && helmfile sync` results in sync failed due to that releases names are not deleted but preserved for future references. If you really want to remove releases for reuse, add `--purge` flag to run it like `helmfile delete --purge`. + ### secrets The `secrets` parameter in a `helmfile.yaml` causes the [helm-secrets](https://github.com/futuresimple/helm-secrets) plugin to be executed to decrypt the file. diff --git a/cmd/deps.go b/cmd/deps.go index 5e8f1455..6aac8427 100644 --- a/cmd/deps.go +++ b/cmd/deps.go @@ -8,7 +8,7 @@ import ( "github.com/urfave/cli" ) -func Deps(a *app.App) cli.Command { +func Deps() cli.Command { return cli.Command{ Name: "deps", Usage: "update charts based on the contents of requirements.yaml", diff --git a/helmexec/helmexec.go b/helmexec/helmexec.go index 02949a10..881be7f8 100644 --- a/helmexec/helmexec.go +++ b/helmexec/helmexec.go @@ -20,3 +20,7 @@ type Interface interface { List(context HelmContext, filter string, flags ...string) (string, error) DecryptSecret(context HelmContext, name string, flags ...string) (string, error) } + +type DependencyUpdater interface { + UpdateDeps(chart string) error +} diff --git a/main.go b/main.go index 2e136d21..688938c0 100644 --- a/main.go +++ b/main.go @@ -90,6 +90,7 @@ func main() { cliApp.Before = configureLogging cliApp.Commands = []cli.Command{ + cmd.Deps(), { Name: "repos", Usage: "sync repositories from state file (helm repo add && helm repo update)", @@ -184,7 +185,7 @@ func main() { return errs } } - if errs := state.PrepareRelease(helm, "diff"); errs != nil && len(errs) > 0 { + if errs := state.PrepareReleases(helm, "diff"); errs != nil && len(errs) > 0 { return errs } @@ -226,7 +227,7 @@ func main() { return errs } } - if errs := state.PrepareRelease(helm, "template"); errs != nil && len(errs) > 0 { + if errs := state.PrepareReleases(helm, "template"); errs != nil && len(errs) > 0 { return errs } return executeTemplateCommand(c, state, helm) @@ -260,7 +261,7 @@ func main() { if errs := ctx.SyncReposOnce(state, helm); errs != nil && len(errs) > 0 { return errs } - if errs := state.PrepareRelease(helm, "lint"); errs != nil && len(errs) > 0 { + if errs := state.PrepareReleases(helm, "lint"); errs != nil && len(errs) > 0 { return errs } return state.LintReleases(helm, values, args, workers) @@ -301,7 +302,7 @@ func main() { return errs } } - if errs := st.PrepareRelease(helm, "sync"); errs != nil && len(errs) > 0 { + if errs := st.PrepareReleases(helm, "sync"); errs != nil && len(errs) > 0 { return errs } return executeSyncCommand(c, &affectedReleases, st, helm) @@ -348,7 +349,7 @@ func main() { return errs } } - if errs := st.PrepareRelease(helm, "apply"); errs != nil && len(errs) > 0 { + if errs := st.PrepareReleases(helm, "apply"); errs != nil && len(errs) > 0 { return errs } diff --git a/state/chart_dependency.go b/state/chart_dependency.go new file mode 100644 index 00000000..8a2989ff --- /dev/null +++ b/state/chart_dependency.go @@ -0,0 +1,365 @@ +package state + +import ( + "fmt" + "github.com/roboll/helmfile/helmexec" + "go.uber.org/zap" + "gopkg.in/yaml.v2" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +type ChartMeta struct { + Name string `yaml:"name"` +} + +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"` +} + +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"` +} + +// StatePackage is for packaging your helmfile state file along with its dependencies. +// The only type of dependency currently supported is `chart`. +// It is transient and generated on demand while resolving dependencies, and automatically removed afterwards. +type StatePackage struct { + // name is the name of the package. + // Usually this is the "basename" of the helmfile state file, e.g. `helmfile.2` when the state file is named `helmfile.2.yaml`, `helmfille.2.gotmpl`, or `helmfile.2.yaml.gotmpl`. + name string + + chartDependencies map[string]unresolvedChartDependency +} + +type UnresolvedDependencies struct { + deps map[string]unresolvedChartDependency +} + +type ChartRequirements struct { + UnresolvedDependencies []unresolvedChartDependency `yaml:"dependencies"` +} + +type ChartLockedRequirements struct { + ResolvedDependencies []ResolvedChartDependency `yaml:"dependencies"` +} + +func (d *UnresolvedDependencies) Add(chart, url, versionConstraint string) error { + dep := unresolvedChartDependency{ + ChartName: chart, + Repository: url, + VersionConstraint: versionConstraint, + } + return d.add(dep) +} + +func (d *UnresolvedDependencies) add(dep unresolvedChartDependency) error { + existing, exists := d.deps[dep.ChartName] + if exists && (existing.Repository != dep.Repository || existing.VersionConstraint != dep.VersionConstraint) { + return fmt.Errorf("duplicate chart dependency \"%s\". you can't have two or more charts with the same name but with different urls or versions: existing=%v, new=%v", dep.ChartName, existing, dep) + } + d.deps[dep.ChartName] = dep + return nil +} + +func (d *UnresolvedDependencies) ToChartRequirements() *ChartRequirements { + deps := []unresolvedChartDependency{} + + for _, d := range d.deps { + if d.VersionConstraint == "" { + d.VersionConstraint = "*" + } + deps = append(deps, d) + } + + return &ChartRequirements{UnresolvedDependencies: deps} +} + +type ResolvedDependencies struct { + deps map[string]ResolvedChartDependency +} + +func (d *ResolvedDependencies) add(dep ResolvedChartDependency) error { + _, exists := d.deps[dep.ChartName] + if exists { + return fmt.Errorf("duplicate chart dependency \"%s\"", dep.ChartName) + } + d.deps[dep.ChartName] = dep + return nil +} + +func (d *ResolvedDependencies) Get(chart string) (string, error) { + dep, exists := d.deps[chart] + if !exists { + return "", fmt.Errorf("no resolved dependency found for \"%s\"", chart) + } + return dep.Version, nil +} + +func resolveRemoteChart(repoAndChart string) (string, string, bool) { + parts := strings.Split(repoAndChart, "/") + if isLocalChart(repoAndChart) { + return "", "", false + } + if len(parts) != 2 { + panic(fmt.Sprintf("unsupported format of chart name: %s", repoAndChart)) + } + + repo := parts[0] + chart := parts[1] + + return repo, chart, true +} + +func (st *HelmState) mergeLockedDependencies() (*HelmState, error) { + filename, unresolved, err := getUnresolvedDependenciess(st) + if err != nil { + return nil, err + } + + if len(unresolved.deps) == 0 { + return st, nil + } + + depMan := NewChartDependencyManager(filename, st.logger) + + 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) + 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, err := getUnresolvedDependenciess(st) + if err != nil { + return nil, err + } + + if len(unresolved.deps) == 0 { + return st, nil + } + + d, err := tempDir("", "") + if err != nil { + return nil, fmt.Errorf("unable to create dir: %v", err) + } + defer os.RemoveAll(d) + + return updateDependencies(st, shell, unresolved, filename, d) +} + +func getUnresolvedDependenciess(st *HelmState) (string, *UnresolvedDependencies, error) { + repoToURL := map[string]string{} + + for _, r := range st.Repositories { + repoToURL[r.Name] = r.URL + } + + unresolved := &UnresolvedDependencies{deps: map[string]unresolvedChartDependency{}} + //if err := unresolved.Add("stable/envoy", "https://kubernetes-charts.storage.googleapis.com", ""); err != nil { + // panic(err) + //} + + for _, r := range st.Releases { + repo, chart, ok := resolveRemoteChart(r.Chart) + if !ok { + continue + } + + url, 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 + } + + if err := unresolved.Add(chart, url, r.Version); err != nil { + return "", nil, err + } + } + + filename := filepath.Base(st.FilePath) + filename = strings.TrimSuffix(filename, ".gotmpl") + filename = strings.TrimSuffix(filename, ".yaml") + filename = strings.TrimSuffix(filename, ".yml") + + return filename, unresolved, nil +} + +func updateDependencies(st *HelmState, shell helmexec.DependencyUpdater, unresolved *UnresolvedDependencies, filename, wd string) (*HelmState, error) { + depMan := NewChartDependencyManager(filename, st.logger) + + _, err := depMan.Update(shell, wd, unresolved) + if err != nil { + return nil, fmt.Errorf("unable to resolve %d deps: %v", len(unresolved.deps), err) + } + + return resolveDependencies(st, depMan, unresolved) +} + +type chartDependencyManager struct { + Name string + + logger *zap.SugaredLogger + + readFile func(string) ([]byte, error) + writeFile func(string, []byte, os.FileMode) error +} + +func NewChartDependencyManager(name string, logger *zap.SugaredLogger) *chartDependencyManager { + return &chartDependencyManager{ + Name: name, + readFile: ioutil.ReadFile, + writeFile: ioutil.WriteFile, + logger: logger, + } +} + +func (m *chartDependencyManager) lockFileName() string { + return fmt.Sprintf("%s.lock", m.Name) +} + +func (m *chartDependencyManager) Update(shell helmexec.DependencyUpdater, wd string, unresolved *UnresolvedDependencies) (*ResolvedDependencies, error) { + // Generate `Chart.yaml` of the temporary local chart + if err := m.writeBytes(filepath.Join(wd, "Chart.yaml"), []byte(fmt.Sprintf("name: %s\n", m.Name))); err != nil { + return nil, err + } + + // 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, "requirements.yaml"), reqsContent); err != nil { + return nil, err + } + + // Generate `requirements.lock` of the temporary local chart by coping `.lock` + lockFile := m.lockFileName() + + lockFileContent, err := m.readBytes(lockFile) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + if lockFileContent != nil { + if err := m.writeBytes(filepath.Join(wd, "requirements.lock"), lockFileContent); 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, "requirements.lock")) + if err != nil { + return nil, err + } + + // Commit the lock file if and only if everything looks ok + if err := m.writeBytes(lockFile, 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, true, nil + } + return nil, false, err + } + + // Load resolved dependencies into memory + lockedReqs := &ChartLockedRequirements{} + if err := yaml.Unmarshal(updatedLockFileContent, lockedReqs); err != nil { + return nil, false, err + } + + 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 +} diff --git a/state/state.go b/state/state.go index 3e21d371..80ca3e66 100644 --- a/state/state.go +++ b/state/state.go @@ -51,6 +51,7 @@ type HelmState struct { removeFile func(string) error fileExists func(string) (bool, error) + tempDir func(string, string) (string, error) runner helmexec.Runner } @@ -902,7 +903,7 @@ func (st *HelmState) FilterReleases() error { return nil } -func (st *HelmState) PrepareRelease(helm helmexec.Interface, helmfileCommand string) []error { +func (st *HelmState) PrepareReleases(helm helmexec.Interface, helmfileCommand string) []error { errs := []error{} for _, release := range st.Releases { @@ -914,6 +915,14 @@ func (st *HelmState) PrepareRelease(helm helmexec.Interface, helmfileCommand str if len(errs) != 0 { return errs } + + updated, err := st.ResolveDeps() + if err != nil { + return []error{err} + } + + *st = *updated + return nil } @@ -946,6 +955,11 @@ func (st *HelmState) triggerReleaseEvent(evt string, r *ReleaseSpec, helmfileCmd return bus.Trigger(evt, data) } +// ResolveDeps returns a copy of this helmfile state with the concrete chart version numbers filled in for remote chart dependencies +func (st *HelmState) ResolveDeps() (*HelmState, error) { + return st.mergeLockedDependencies() +} + // UpdateDeps wrapper for updating dependencies on the releases func (st *HelmState) UpdateDeps(helm helmexec.Interface) []error { errs := []error{} @@ -957,6 +971,18 @@ func (st *HelmState) UpdateDeps(helm helmexec.Interface) []error { } } } + + if len(errs) == 0 { + tempDir := st.tempDir + if tempDir == nil { + tempDir = ioutil.TempDir + } + _, err := st.updateDependenciesInTempDir(helm, tempDir) + if err != nil { + errs = append(errs, fmt.Errorf("unable to update deps: %v", err)) + } + } + if len(errs) != 0 { return errs } @@ -1008,7 +1034,12 @@ func normalizeChart(basePath, chart string) string { func isLocalChart(chart string) bool { regex, _ := regexp.Compile("^[.]?./") - return regex.MatchString(chart) + matched := regex.MatchString(chart) + if matched { + return true + } + + return chart == "" || chart[0] == '/' || len(strings.Split(chart, "/")) != 2 } func pathExists(chart string) bool { diff --git a/state/state_test.go b/state/state_test.go index 4675c980..0a2b5cf8 100644 --- a/state/state_test.go +++ b/state/state_test.go @@ -1,7 +1,9 @@ package state import ( + "io/ioutil" "os" + "path/filepath" "reflect" "testing" @@ -551,7 +553,7 @@ func Test_isLocalChart(t *testing.T) { args: args{ chart: "", }, - want: false, + want: true, }, { name: "parent local path", @@ -567,11 +569,25 @@ func Test_isLocalChart(t *testing.T) { }, want: true, }, + { + name: "absolute path", + args: args{ + chart: "/foo/bar/baz", + }, + want: true, + }, + { + name: "local chart in 3-level deep dir", + args: args{ + chart: "foo/bar/baz", + }, + want: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isLocalChart(tt.args.chart); got != tt.want { - t.Errorf("pathExists() = %v, want %v", got, tt.want) + t.Errorf("%s(\"%s\") isLocalChart(): got %v, want %v", tt.name, tt.args.chart, got, tt.want) } }) } @@ -643,6 +659,8 @@ type mockHelmExec struct { deleted []mockRelease lists map[listKey]string diffed []mockRelease + + updateDepsCallbacks map[string]func(string) error } type mockRelease struct { @@ -658,9 +676,18 @@ type mockAffected struct { func (helm *mockHelmExec) UpdateDeps(chart string) error { if strings.Contains(chart, "error") { - return errors.New("error") + return fmt.Errorf("simulated UpdateDeps failure for chart: %s", chart) } helm.charts = append(helm.charts, chart) + + if helm.updateDepsCallbacks != nil { + callback, exists := helm.updateDepsCallbacks[chart] + if exists { + if err := callback(chart); err != nil { + return err + } + } + } return nil } @@ -1394,8 +1421,36 @@ func TestHelmState_DiffReleasesCleanup(t *testing.T) { } func TestHelmState_UpdateDeps(t *testing.T) { + helm := &mockHelmExec{ + updateDepsCallbacks: map[string]func(string) error{}, + } + + var generatedDir string + tempDir := func(dir, prefix string) (string, error) { + var err error + generatedDir, err = ioutil.TempDir(dir, prefix) + if err != nil { + return "", err + } + helm.updateDepsCallbacks[generatedDir] = func(chart string) error { + content := []byte(`dependencies: +- name: envoy + repository: https://kubernetes-charts.storage.googleapis.com + version: 1.5.0 +digest: sha256:e43b05c8528ea8ef1560f4980a519719ad2a634658abde0a98daefdb83a104e9 +generated: 2019-05-14T11:29:35.144399+09:00 +`) + filename := filepath.Join(generatedDir, "requirements.lock") + logger.Debugf("test: writing %s: %s", filename, content) + return ioutil.WriteFile(filename, content, 0644) + } + return generatedDir, nil + } + + logger := helmexec.NewLogger(os.Stderr, "debug") state := &HelmState{ basePath: "/src", + FilePath: "/src/helmfile.yaml", Releases: []ReleaseSpec{ { Chart: "./..", @@ -1413,19 +1468,35 @@ func TestHelmState_UpdateDeps(t *testing.T) { Chart: "published/deeper", }, { - Chart: ".error", + Chart: "stable/envoy", }, }, + Repositories: []RepositorySpec{ + { + Name: "stable", + URL: "https://kubernetes-charts.storage.googleapis.com", + }, + }, + tempDir: tempDir, + logger: logger, } - want := []string{"/", "/examples", "/helmfile"} - helm := &mockHelmExec{} errs := state.UpdateDeps(helm) + want := []string{"/", "/examples", "/helmfile", "/src/published", generatedDir} if !reflect.DeepEqual(helm.charts, want) { t.Errorf("HelmState.UpdateDeps() = %v, want %v", helm.charts, want) } if len(errs) != 0 { - t.Errorf("HelmState.UpdateDeps() - no errors, but got: %v", len(errs)) + t.Errorf("HelmState.UpdateDeps() - no errors, but got %d: %v", len(errs), errs) + } + + resolved, err := state.ResolveDeps() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if resolved.Releases[5].Version != "1.5.0" { + t.Errorf("unexpected version number: expected=1.5.0, got=%s", resolved.Releases[5].Version) } } diff --git a/test/integration/run.sh b/test/integration/run.sh index c925b591..276c55a1 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -76,6 +76,16 @@ ${helmfile} -f ${dir}/happypath.yaml apply code=$? [ ${code} -eq 0 ] || fail "unexpected exit code returned by helmfile apply: ${code}" +info "Locking dependencies" +${helmfile} -f ${dir}/happypath.yaml deps +code=$? +[ ${code} -eq 0 ] || fail "unexpected exit code returned by helmfile deps: ${code}" + +info "Applying ${dir}/happypath.yaml with locked dependencies" +${helmfile} -f ${dir}/happypath.yaml apply +code=$? +[ ${code} -eq 0 ] || fail "unexpected exit code returned by helmfile apply: ${code}" + info "Deleting release" ${helmfile} -f ${dir}/happypath.yaml delete ${helm} status --namespace=${test_ns} httpbin &> /dev/null && fail "release should not exist anymore after a delete"