package state import ( "errors" "fmt" "io/ioutil" "os" "path/filepath" "strings" "sync" "text/template" "github.com/Masterminds/sprig" "github.com/roboll/helmfile/helmexec" "bytes" "regexp" yaml "gopkg.in/yaml.v2" ) // HelmState structure for the helmfile type HelmState struct { BaseChartPath string Context string `yaml:"context"` DeprecatedReleases []ReleaseSpec `yaml:"charts"` Namespace string `yaml:"namespace"` Repositories []RepositorySpec `yaml:"repositories"` Releases []ReleaseSpec `yaml:"releases"` } // RepositorySpec that defines values for a helm repo type RepositorySpec struct { Name string `yaml:"name"` URL string `yaml:"url"` CertFile string `yaml:"certFile"` KeyFile string `yaml:"keyFile"` } // ReleaseSpec defines the structure of a helm release type ReleaseSpec struct { // Chart is the name of the chart being installed to create this release Chart string `yaml:"chart"` Version string `yaml:"version"` Verify bool `yaml:"verify"` // Name is the name of this release Name string `yaml:"name"` Namespace string `yaml:"namespace"` Labels map[string]string `yaml:"labels"` Values []string `yaml:"values"` Secrets []string `yaml:"secrets"` SetValues []SetValue `yaml:"set"` // The 'env' section is not really necessary any longer, as 'set' would now provide the same functionality EnvValues []SetValue `yaml:"env"` // generatedValues are values that need cleaned up on exit generatedValues []string } // SetValue are the key values to set on a helm release type SetValue struct { Name string `yaml:"name"` Value string `yaml:"value"` } // ReadFromFile loads the helmfile from disk and processes the template func ReadFromFile(file string) (*HelmState, error) { content, err := ioutil.ReadFile(file) if err != nil { return nil, err } tpl, err := stringTemplate().Parse(string(content)) if err != nil { return nil, err } var tplString bytes.Buffer err = tpl.Execute(&tplString, nil) if err != nil { return nil, err } return readFromYaml(tplString.Bytes(), file) } func readFromYaml(content []byte, file string) (*HelmState, error) { var state HelmState state.BaseChartPath, _ = filepath.Abs(filepath.Dir(file)) if err := yaml.UnmarshalStrict(content, &state); err != nil { return nil, err } if len(state.DeprecatedReleases) > 0 { if len(state.Releases) > 0 { return nil, fmt.Errorf("failed to parse %s: you can't specify both `charts` and `releases` sections", file) } state.Releases = state.DeprecatedReleases state.DeprecatedReleases = []ReleaseSpec{} } return &state, nil } func stringTemplate() *template.Template { return template.New("stringTemplate").Funcs(sprig.TxtFuncMap()) } func renderTemplateString(s string) (string, error) { var t, parseErr = stringTemplate().Parse(s) if parseErr != nil { return "", parseErr } var tplString bytes.Buffer var execErr = t.Execute(&tplString, nil) if execErr != nil { return "", execErr } return tplString.String(), nil } func (state *HelmState) applyDefaultsTo(spec *ReleaseSpec) { if state.Namespace != "" { spec.Namespace = state.Namespace } } // SyncRepos will update the given helm releases func (state *HelmState) SyncRepos(helm helmexec.Interface) []error { errs := []error{} for _, repo := range state.Repositories { if err := helm.AddRepo(repo.Name, repo.URL, repo.CertFile, repo.KeyFile); err != nil { errs = append(errs, err) } } if len(errs) != 0 { return errs } if err := helm.UpdateRepo(); err != nil { return []error{err} } return nil } // SyncReleases wrapper for executing helm upgrade on the releases func (state *HelmState) SyncReleases(helm helmexec.Interface, additionalValues []string, workerLimit int) []error { errs := []error{} jobQueue := make(chan *ReleaseSpec) doneQueue := make(chan bool) errQueue := make(chan error) if workerLimit < 1 { workerLimit = len(state.Releases) } for w := 1; w <= workerLimit; w++ { go func() { for release := range jobQueue { state.applyDefaultsTo(release) flags, flagsErr := flagsForRelease(helm, state.BaseChartPath, release) if flagsErr != nil { errQueue <- flagsErr doneQueue <- true continue } haveValueErr := false for _, value := range additionalValues { valfile, err := filepath.Abs(value) if err != nil { errQueue <- err haveValueErr = true } if _, err := os.Stat(valfile); os.IsNotExist(err) { errQueue <- err haveValueErr = true } flags = append(flags, "--values", valfile) } if haveValueErr { doneQueue <- true continue } chart := normalizeChart(state.BaseChartPath, release.Chart) if err := helm.SyncRelease(release.Name, chart, flags...); err != nil { errQueue <- err } doneQueue <- true } }() } go func() { for i := 0; i < len(state.Releases); i++ { jobQueue <- &state.Releases[i] } close(jobQueue) }() for i := 0; i < len(state.Releases); { select { case err := <-errQueue: errs = append(errs, err) case <-doneQueue: i++ } } if len(errs) != 0 { return errs } return nil } // DiffReleases wrapper for executing helm diff on the releases func (state *HelmState) DiffReleases(helm helmexec.Interface, additionalValues []string, workerLimit int) []error { var wgRelease sync.WaitGroup var wgError sync.WaitGroup errs := []error{} jobQueue := make(chan *ReleaseSpec, len(state.Releases)) errQueue := make(chan error) if workerLimit < 1 { workerLimit = len(state.Releases) } wgRelease.Add(len(state.Releases)) for w := 1; w <= workerLimit; w++ { go func() { for release := range jobQueue { errs := []error{} // Plugin command doesn't support explicit namespace release.Namespace = "" flags, err := flagsForRelease(helm, state.BaseChartPath, release) if err != nil { errs = append(errs, err) } for _, value := range additionalValues { valfile, err := filepath.Abs(value) if err != nil { errs = append(errs, err) } if _, err := os.Stat(valfile); os.IsNotExist(err) { errs = append(errs, err) } flags = append(flags, "--values", valfile) } if len(errs) == 0 { if err := helm.DiffRelease(release.Name, normalizeChart(state.BaseChartPath, release.Chart), flags...); err != nil { errs = append(errs, err) } } for _, err := range errs { errQueue <- err } wgRelease.Done() } }() } wgError.Add(1) go func() { for err := range errQueue { errs = append(errs, err) } wgError.Done() }() for i := 0; i < len(state.Releases); i++ { jobQueue <- &state.Releases[i] } close(jobQueue) wgRelease.Wait() close(errQueue) wgError.Wait() if len(errs) != 0 { return errs } return nil } func (state *HelmState) ReleaseStatuses(helm helmexec.Interface, workerLimit int) []error { var errs []error jobQueue := make(chan ReleaseSpec) doneQueue := make(chan bool) errQueue := make(chan error) if workerLimit < 1 { workerLimit = len(state.Releases) } for w := 1; w <= workerLimit; w++ { go func() { for release := range jobQueue { if err := helm.ReleaseStatus(release.Name); err != nil { errQueue <- err } doneQueue <- true } }() } go func() { for _, release := range state.Releases { jobQueue <- release } close(jobQueue) }() for i := 0; i < len(state.Releases); { select { case err := <-errQueue: errs = append(errs, err) case <-doneQueue: i++ } } if len(errs) != 0 { return errs } return nil } // DeleteReleases wrapper for executing helm delete on the releases func (state *HelmState) DeleteReleases(helm helmexec.Interface) []error { var wg sync.WaitGroup errs := []error{} for _, release := range state.Releases { wg.Add(1) go func(wg *sync.WaitGroup, release ReleaseSpec) { if err := helm.DeleteRelease(release.Name); err != nil { errs = append(errs, err) } wg.Done() }(&wg, release) } wg.Wait() if len(errs) != 0 { return errs } return nil } // Clean will remove any generated secrets func (state *HelmState) Clean() []error { errs := []error{} for _, release := range state.Releases { for _, value := range release.generatedValues { err := os.Remove(value) if err != nil { errs = append(errs, err) } } } if len(errs) != 0 { return errs } return nil } // FilterReleases allows for the execution of helm commands against a subset of the releases in the helmfile. func (state *HelmState) FilterReleases(labels []string) error { var filteredReleases []ReleaseSpec releaseSet := map[string]ReleaseSpec{} filters := []ReleaseFilter{} for _, label := range labels { f, err := ParseLabels(label) if err != nil { return err } filters = append(filters, f) } for _, r := range state.Releases { if r.Labels == nil { r.Labels = map[string]string{} } // Let the release name be used as a tag r.Labels["name"] = r.Name for _, f := range filters { if r.Labels == nil { r.Labels = map[string]string{} } if f.Match(r) { releaseSet[r.Name] = r continue } } } for _, r := range releaseSet { filteredReleases = append(filteredReleases, r) } state.Releases = filteredReleases return nil } // UpdateDeps wrapper for updating dependencies on the releases func (state *HelmState) UpdateDeps(helm helmexec.Interface) []error { errs := []error{} for _, release := range state.Releases { if isLocalChart(release.Chart) { if err := helm.UpdateDeps(normalizeChart(state.BaseChartPath, release.Chart)); err != nil { errs = append(errs, err) } } } if len(errs) != 0 { return errs } return nil } // normalizeChart allows for the distinction between a file path reference and repository references. // - Any single (or double character) followed by a `/` will be considered a local file reference and // be constructed relative to the `base path`. // - Everything else is assumed to be an absolute path or an actual / reference. func normalizeChart(basePath, chart string) string { regex, _ := regexp.Compile("^[.]?./") if !regex.MatchString(chart) { return chart } return filepath.Join(basePath, chart) } func isLocalChart(chart string) bool { _, err := os.Stat(chart) return err == nil } func flagsForRelease(helm helmexec.Interface, basePath string, release *ReleaseSpec) ([]string, error) { flags := []string{} if release.Version != "" { flags = append(flags, "--version", release.Version) } if release.Verify { flags = append(flags, "--verify") } if release.Namespace != "" { flags = append(flags, "--namespace", release.Namespace) } for _, value := range release.Values { path := filepath.Join(basePath, value) if _, err := os.Stat(path); os.IsNotExist(err) { return nil, err } flags = append(flags, "--values", path) } for _, value := range release.Secrets { path := filepath.Join(basePath, value) if _, err := os.Stat(path); os.IsNotExist(err) { return nil, err } valfile, err := helm.DecryptSecret(path) if err != nil { return nil, err } release.generatedValues = append(release.generatedValues, valfile) flags = append(flags, "--values", valfile) } if len(release.SetValues) > 0 { val := []string{} for _, set := range release.SetValues { val = append(val, fmt.Sprintf("%s=%s", set.Name, set.Value)) } flags = append(flags, "--set", strings.Join(val, ",")) } /*********** * START 'env' section for backwards compatibility ***********/ // The 'env' section is not really necessary any longer, as 'set' would now provide the same functionality if len(release.EnvValues) > 0 { val := []string{} envValErrs := []string{} for _, set := range release.EnvValues { value, isSet := os.LookupEnv(set.Value) if isSet { val = append(val, fmt.Sprintf("%s=%s", set.Name, value)) } else { errMsg := fmt.Sprintf("\t%s", set.Value) envValErrs = append(envValErrs, errMsg) } } if len(envValErrs) != 0 { joinedEnvVals := strings.Join(envValErrs, "\n") errMsg := fmt.Sprintf("Environment Variables not found. Please make sure they are set and try again:\n%s", joinedEnvVals) return nil, errors.New(errMsg) } flags = append(flags, "--set", strings.Join(val, ",")) } /************** * END 'env' section for backwards compatibility **************/ return flags, nil }