feat: Dependency locking (#593)

In order to maintain predictable deployments, as developer I want to generate and use "lock files" for all chart versions retrieved from a helmfile.

This change solves it by (1)enhancing `helmfile deps` to generate a lock file containing all the direct chart dependencies of each helmfile state file and
(2)making other helmfile sub-commands reads the lock file and merge the locked version numbers to the helmfile state file being processed.

The lock file is named after the helmfile state file being locked, so that you can have multiple set of the helmfile state file and the lock file pairs in a directory.

When `helmfile deps` are not explicitly run before commands like `sync`, all the helmfile behavior should remain as before.

Let's say you have `helmfile.1.yaml`:

```
repositories:
- name: stable
  url: https://kubernetes-charts.storage.googleapis.com

releases:
- name: envoy
  chart: stable/envoy
- name: envoy2
  chart: stable/envoy
```

`helmfile deps` generates `helmfile.1.lock` that looks like:

```
dependencies:
- name: envoy
  repository: https://kubernetes-charts.storage.googleapis.com
  version: 1.5.0
digest: sha256:e43b05c8528ea8ef1560f4980a519719ad2a634658abde0a98daefdb83a104e9
generated: 2019-05-14T16:45:37.78205+09:00
```

Under the hood, `helmfile deps` creates a temporary local helm chart with a dummy `Chart.yaml` and `requirements.yaml` deduced from the `helmfile.yaml` content, then runs `helm dependency update` to produce od update the corresponding `requirements.lock` file.

`helmfile` then renames it to match the name of the targeted helmfile state file and moves it,  so that it becomes adjacent to each `helmfile.yaml`.

Other `helmfile` commands like `sync`, `diiff`, `apply`, `lint` read chart version numbers from the lock file.

Resolves #483
This commit is contained in:
KUOKA Yusuke 2019-05-15 09:39:12 +09:00 committed by GitHub
parent 9eec72b318
commit c9a43ad9cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 512 additions and 15 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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
}

11
main.go
View File

@ -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
}

365
state/chart_dependency.go Normal file
View File

@ -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 `<basename>.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
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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"