From 4b08ea9292857a609727222481889d45f89d5c4b Mon Sep 17 00:00:00 2001 From: Alex Withrow Date: Fri, 23 Mar 2018 10:05:19 -0600 Subject: [PATCH] Allow running helmfile against a subset of releases (#30) This adds `releases[].labels` in which you can set arbitrary number of key-value pairs, so that commands like `helmfile sync --selector key=value` can be used to run the helmfile subcommand against a subnet of declared releases. `labels` and `selector` are named as such on purpose of being consistent with terminology of Kubernetes and other tools in the K8S ecosystem, including kubectl, stern, helm, and so on. Resolves #8 --- .gitignore | 1 + README.md | 27 +++++++++++++--- main.go | 17 +++++++++- state/release_filters.go | 64 ++++++++++++++++++++++++++++++++++++++ state/state.go | 54 ++++++++++++++++++++++++++------ state/state_test.go | 67 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 state/release_filters.go diff --git a/.gitignore b/.gitignore index 5cb6357e..3e42cef0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist/ .idea/ +helmfile \ No newline at end of file diff --git a/README.md b/README.md index 3eee9edc..68f18e70 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ releases: # Published chart example - name: vault # name of this release namespace: vault # target namespace + labels: # Arbitrary key value pairs for filtering releases + foo: bar chart: roboll/vault-secret-manager # the chart being installed to create this release, referenced by `repository/chart` syntax values: [ vault.yaml ] # value files (--values) secrets: @@ -102,11 +104,16 @@ COMMANDS: delete delete charts from state file (helm delete) GLOBAL OPTIONS: - --file FILE, -f FILE load config from FILE (default: "helmfile.yaml") - --quiet, -q silence output - --kube-context value Set kubectl context. Uses current context by default - --help, -h show help - --version, -v print the version + --file FILE, -f FILE load config from FILE (default: "helmfile.yaml") + --quiet, -q silence output + --namespace value, -n value Set namespace. Uses the namespace set in the context by default + --selector,l value Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar. + A release must match all labels in a group in order to be used. Multiple groups can be specified at once. + --selector tier=frontend,tier!=proxy --selector tier=backend. Will match all frontend, non-proxy releases AND all backend releases. + The name of a release can be used as a label. --selector name=myrelease + --kube-context value Set kubectl context. Uses current context by default + --help, -h show help + --version, -v print the version ``` ### sync @@ -142,3 +149,13 @@ A few rules to clear up this ambiguity: - Relative paths referenced on the command line are relative to the current working directory the user is in For additional context, take a look at [paths examples](PATHS.md) +## Labels Overview +A selector can be used to only target a subset of releases when running helmfile. This is useful for large helmfiles with releases that are logically grouped together. + +Labels are simple key value pairs that are an optional field of the release spec. When selecting by label, the search can be inverted. `tier!=backend` would match all releases that do NOT have the `tier: backend` label. `tier=fronted` would only match releases with the `tier: frontend` label. + +Multiple labels can be specified using `,` as a separator. A release must match all selectors in order to be selected for the final helm command. + +The `selector` parameter can be specified multiple times. Each parameter is resolved independently so a release that matches any parameter will be used. + +`--selector tier=frontend --selector tier=backend` will select all the charts diff --git a/main.go b/main.go index a3c64815..102ee4e5 100644 --- a/main.go +++ b/main.go @@ -45,6 +45,13 @@ func main() { Name: "namespace, n", Usage: "Set namespace. Uses the namespace set in the context by default", }, + cli.StringSliceFlag{ + Name: "selector, l", + Usage: `Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar. + A release must match all labels in a group in order to be used. Multiple groups can be specified at once. + --selector tier=frontend,tier!=proxy --selector tier=backend. Will match all frontend, non-proxy releases AND all backend releases. + The name of a release can be used as a label. --selector name=myrelease`, + }, } app.Commands = []cli.Command{ @@ -215,6 +222,7 @@ func before(c *cli.Context) (*state.HelmState, helmexec.Interface, error) { quiet := c.GlobalBool("quiet") kubeContext := c.GlobalString("kube-context") namespace := c.GlobalString("namespace") + labels := c.GlobalStringSlice("selector") st, err := state.ReadFromFile(file) if err != nil && strings.Contains(err.Error(), fmt.Sprintf("open %s:", DefaultHelmfile)) { @@ -239,6 +247,13 @@ func before(c *cli.Context) (*state.HelmState, helmexec.Interface, error) { } st.Namespace = namespace } + if len(labels) > 0 { + err = st.FilterReleases(labels) + if err != nil { + log.Print(err) + os.Exit(1) + } + } var writer io.Writer if !quiet { writer = os.Stdout @@ -249,7 +264,7 @@ func before(c *cli.Context) (*state.HelmState, helmexec.Interface, error) { go func() { sig := <-sigs - errs := []error{fmt.Errorf("Recived [%s] to shutdown ", sig)} + errs := []error{fmt.Errorf("Received [%s] to shutdown ", sig)} clean(st, errs) }() diff --git a/state/release_filters.go b/state/release_filters.go new file mode 100644 index 00000000..0709c35b --- /dev/null +++ b/state/release_filters.go @@ -0,0 +1,64 @@ +package state + +import ( + "fmt" + "regexp" + "strings" +) + +// ReleaseFilter is used to determine if a given release should be used during helmfile execution +type ReleaseFilter interface { + // Match returns true if the ReleaseSpec matches the Filter + Match(r ReleaseSpec) bool +} + +// LabelFilter matches a release with the given positive lables. Negative labels +// invert the match for cases such as tier!=backend +type LabelFilter struct { + positiveLabels map[string]string + negativeLabels map[string]string +} + +// Match will match a release that has the same labels as the filter +func (l LabelFilter) Match(r ReleaseSpec) bool { + if len(l.positiveLabels) > 0 { + for k, v := range l.positiveLabels { + if rVal, ok := r.Labels[k]; !ok { + return false + } else if rVal != v { + return false + } + } + } + if len(l.negativeLabels) > 0 { + for k, v := range l.negativeLabels { + if rVal, ok := r.Labels[k]; !ok { + return true + } else if rVal == v { + return false + } + } + } + return true +} + +// ParseLabels takes a label in the form foo=bar,baz!=bat and returns a LabelFilter that will match the labels +func ParseLabels(l string) (LabelFilter, error) { + lf := LabelFilter{} + lf.positiveLabels = map[string]string{} + lf.negativeLabels = map[string]string{} + var err error + labels := strings.Split(l, ",") + for _, label := range labels { + if match, _ := regexp.MatchString("^[a-zA-Z0-9_-]+!=[a-zA-Z0-9_-]+$", label); match == true { // k!=v case + kv := strings.Split(label, "!=") + lf.negativeLabels[kv[0]] = kv[1] + } else if match, _ := regexp.MatchString("^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$", label); match == true { // k=v case + kv := strings.Split(label, "=") + lf.positiveLabels[kv[0]] = kv[1] + } else { // malformed case + err = fmt.Errorf("Malformed label: %s. Expected label in form k=v or k!=v", label) + } + } + return lf, err +} diff --git a/state/state.go b/state/state.go index e7900f18..2aea6181 100644 --- a/state/state.go +++ b/state/state.go @@ -42,11 +42,12 @@ type ReleaseSpec struct { Verify bool `yaml:"verify"` // Name is the name of this release - Name string `yaml:"name"` - Namespace string `yaml:"namespace"` - Values []string `yaml:"values"` - Secrets []string `yaml:"secrets"` - SetValues []SetValue `yaml:"set"` + 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"` @@ -148,7 +149,7 @@ func (state *HelmState) SyncRepos(helm helmexec.Interface) []error { return nil } -func (state *HelmState) SyncReleases(helm helmexec.Interface, additonalValues []string, workerLimit int) []error { +func (state *HelmState) SyncReleases(helm helmexec.Interface, additionalValues []string, workerLimit int) []error { errs := []error{} jobQueue := make(chan ReleaseSpec) doneQueue := make(chan bool) @@ -169,7 +170,7 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additonalValues [] } haveValueErr := false - for _, value := range additonalValues { + for _, value := range additionalValues { valfile, err := filepath.Abs(value) if err != nil { errQueue <- err @@ -214,7 +215,7 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additonalValues [] return nil } -func (state *HelmState) DiffReleases(helm helmexec.Interface, additonalValues []string) []error { +func (state *HelmState) DiffReleases(helm helmexec.Interface, additionalValues []string) []error { var wg sync.WaitGroup errs := []error{} @@ -229,7 +230,7 @@ func (state *HelmState) DiffReleases(helm helmexec.Interface, additonalValues [] errs = append(errs, flagsErr) } - for _, value := range additonalValues { + for _, value := range additionalValues { valfile, err := filepath.Abs(value) if err != nil { errs = append(errs, err) @@ -295,6 +296,41 @@ func (state *HelmState) Clean() []error { 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 +} + // 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`. diff --git a/state/state_test.go b/state/state_test.go index bed68603..5238a52d 100644 --- a/state/state_test.go +++ b/state/state_test.go @@ -62,6 +62,73 @@ releases: } } +func TestReadFromYaml_FilterReleasesOnLabels(t *testing.T) { + yamlFile := "example/path/to/yaml/file" + yamlContent := []byte(`releases: +- name: myrelease1 + chart: mychart1 + labels: + tier: frontend + foo: bar +- name: myrelease2 + chart: mychart2 + labels: + tier: frontend +- name: myrelease3 + chart: mychart3 + labels: + tier: backend +`) + cases := []struct { + filter LabelFilter + results []bool + }{ + {LabelFilter{positiveLabels: map[string]string{"tier": "frontend"}}, + []bool{true, true, false}}, + {LabelFilter{positiveLabels: map[string]string{"tier": "frontend", "foo": "bar"}}, + []bool{true, false, false}}, + {LabelFilter{negativeLabels: map[string]string{"tier": "frontend"}}, + []bool{false, false, true}}, + {LabelFilter{positiveLabels: map[string]string{"tier": "frontend"}, negativeLabels: map[string]string{"foo": "bar"}}, + []bool{false, true, false}}, + } + state, err := readFromYaml(yamlContent, yamlFile) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + for idx, c := range cases { + for idx2, expected := range c.results { + if f := c.filter.Match(state.Releases[idx2]); f != expected { + t.Errorf("[case: %d][outcome: %d] Unexpected outcome wanted %t, got %t", idx, idx2, expected, f) + } + } + } +} + +func TestLabelParsing(t *testing.T) { + cases := []struct { + labelString string + expectedFilter LabelFilter + errorExected bool + }{ + {"foo=bar", LabelFilter{positiveLabels: map[string]string{"foo": "bar"}, negativeLabels: map[string]string{}}, false}, + {"foo!=bar", LabelFilter{positiveLabels: map[string]string{}, negativeLabels: map[string]string{"foo": "bar"}}, false}, + {"foo!=bar,baz=bat", LabelFilter{positiveLabels: map[string]string{"baz": "bat"}, negativeLabels: map[string]string{"foo": "bar"}}, false}, + {"foo", LabelFilter{positiveLabels: map[string]string{}, negativeLabels: map[string]string{}}, true}, + {"foo!=bar=baz", LabelFilter{positiveLabels: map[string]string{}, negativeLabels: map[string]string{}}, true}, + {"=bar", LabelFilter{positiveLabels: map[string]string{}, negativeLabels: map[string]string{}}, true}, + } + for idx, c := range cases { + filter, err := ParseLabels(c.labelString) + if err != nil && !c.errorExected { + t.Errorf("[%d] Didn't expect an error parsing labels: %s", idx, err) + } else if err == nil && c.errorExected { + t.Errorf("[%d] Expected %s to result in an error but got none", idx, c.labelString) + } else if !reflect.DeepEqual(filter, c.expectedFilter) { + t.Errorf("[%d] parsed label did not result in expected filter: %v", idx, filter) + } + } +} func TestHelmState_applyDefaultsTo(t *testing.T) { type fields struct { BaseChartPath string