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
This commit is contained in:
		
							parent
							
								
									e81541d65c
								
							
						
					
					
						commit
						4b08ea9292
					
				|  | @ -1,2 +1,3 @@ | ||||||
| dist/ | dist/ | ||||||
| .idea/ | .idea/ | ||||||
|  | helmfile | ||||||
							
								
								
									
										17
									
								
								README.md
								
								
								
								
							
							
						
						
									
										17
									
								
								README.md
								
								
								
								
							|  | @ -31,6 +31,8 @@ releases: | ||||||
|   # Published chart example |   # Published chart example | ||||||
|   - name: vault                            # name of this release |   - name: vault                            # name of this release | ||||||
|     namespace: vault                       # target namespace |     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 |     chart: roboll/vault-secret-manager     # the chart being installed to create this release, referenced by `repository/chart` syntax | ||||||
|     values: [ vault.yaml ]                 # value files (--values) |     values: [ vault.yaml ]                 # value files (--values) | ||||||
|     secrets: |     secrets: | ||||||
|  | @ -104,6 +106,11 @@ COMMANDS: | ||||||
| GLOBAL OPTIONS: | GLOBAL OPTIONS: | ||||||
|    --file FILE, -f FILE         load config from FILE (default: "helmfile.yaml") |    --file FILE, -f FILE         load config from FILE (default: "helmfile.yaml") | ||||||
|    --quiet, -q                  silence output |    --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 |    --kube-context value         Set kubectl context. Uses current context by default | ||||||
|    --help, -h                   show help |    --help, -h                   show help | ||||||
|    --version, -v                print the version |    --version, -v                print the version | ||||||
|  | @ -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 | - 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) | 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 | ||||||
|  |  | ||||||
							
								
								
									
										17
									
								
								main.go
								
								
								
								
							
							
						
						
									
										17
									
								
								main.go
								
								
								
								
							|  | @ -45,6 +45,13 @@ func main() { | ||||||
| 			Name:  "namespace, n", | 			Name:  "namespace, n", | ||||||
| 			Usage: "Set namespace. Uses the namespace set in the context by default", | 			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{ | 	app.Commands = []cli.Command{ | ||||||
|  | @ -215,6 +222,7 @@ func before(c *cli.Context) (*state.HelmState, helmexec.Interface, error) { | ||||||
| 	quiet := c.GlobalBool("quiet") | 	quiet := c.GlobalBool("quiet") | ||||||
| 	kubeContext := c.GlobalString("kube-context") | 	kubeContext := c.GlobalString("kube-context") | ||||||
| 	namespace := c.GlobalString("namespace") | 	namespace := c.GlobalString("namespace") | ||||||
|  | 	labels := c.GlobalStringSlice("selector") | ||||||
| 
 | 
 | ||||||
| 	st, err := state.ReadFromFile(file) | 	st, err := state.ReadFromFile(file) | ||||||
| 	if err != nil && strings.Contains(err.Error(), fmt.Sprintf("open %s:", DefaultHelmfile)) { | 	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 | 		st.Namespace = namespace | ||||||
| 	} | 	} | ||||||
|  | 	if len(labels) > 0 { | ||||||
|  | 		err = st.FilterReleases(labels) | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.Print(err) | ||||||
|  | 			os.Exit(1) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
| 	var writer io.Writer | 	var writer io.Writer | ||||||
| 	if !quiet { | 	if !quiet { | ||||||
| 		writer = os.Stdout | 		writer = os.Stdout | ||||||
|  | @ -249,7 +264,7 @@ func before(c *cli.Context) (*state.HelmState, helmexec.Interface, error) { | ||||||
| 	go func() { | 	go func() { | ||||||
| 		sig := <-sigs | 		sig := <-sigs | ||||||
| 
 | 
 | ||||||
| 		errs := []error{fmt.Errorf("Recived [%s] to shutdown ", sig)} | 		errs := []error{fmt.Errorf("Received [%s] to shutdown ", sig)} | ||||||
| 		clean(st, errs) | 		clean(st, errs) | ||||||
| 	}() | 	}() | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  | @ -44,6 +44,7 @@ type ReleaseSpec struct { | ||||||
| 	// Name is the name of this release
 | 	// Name is the name of this release
 | ||||||
| 	Name      string            `yaml:"name"` | 	Name      string            `yaml:"name"` | ||||||
| 	Namespace string            `yaml:"namespace"` | 	Namespace string            `yaml:"namespace"` | ||||||
|  | 	Labels    map[string]string `yaml:"labels"` | ||||||
| 	Values    []string          `yaml:"values"` | 	Values    []string          `yaml:"values"` | ||||||
| 	Secrets   []string          `yaml:"secrets"` | 	Secrets   []string          `yaml:"secrets"` | ||||||
| 	SetValues []SetValue        `yaml:"set"` | 	SetValues []SetValue        `yaml:"set"` | ||||||
|  | @ -148,7 +149,7 @@ func (state *HelmState) SyncRepos(helm helmexec.Interface) []error { | ||||||
| 	return nil | 	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{} | 	errs := []error{} | ||||||
| 	jobQueue := make(chan ReleaseSpec) | 	jobQueue := make(chan ReleaseSpec) | ||||||
| 	doneQueue := make(chan bool) | 	doneQueue := make(chan bool) | ||||||
|  | @ -169,7 +170,7 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additonalValues [] | ||||||
| 				} | 				} | ||||||
| 
 | 
 | ||||||
| 				haveValueErr := false | 				haveValueErr := false | ||||||
| 				for _, value := range additonalValues { | 				for _, value := range additionalValues { | ||||||
| 					valfile, err := filepath.Abs(value) | 					valfile, err := filepath.Abs(value) | ||||||
| 					if err != nil { | 					if err != nil { | ||||||
| 						errQueue <- err | 						errQueue <- err | ||||||
|  | @ -214,7 +215,7 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additonalValues [] | ||||||
| 	return nil | 	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 | 	var wg sync.WaitGroup | ||||||
| 	errs := []error{} | 	errs := []error{} | ||||||
| 
 | 
 | ||||||
|  | @ -229,7 +230,7 @@ func (state *HelmState) DiffReleases(helm helmexec.Interface, additonalValues [] | ||||||
| 				errs = append(errs, flagsErr) | 				errs = append(errs, flagsErr) | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			for _, value := range additonalValues { | 			for _, value := range additionalValues { | ||||||
| 				valfile, err := filepath.Abs(value) | 				valfile, err := filepath.Abs(value) | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| 					errs = append(errs, err) | 					errs = append(errs, err) | ||||||
|  | @ -295,6 +296,41 @@ func (state *HelmState) Clean() []error { | ||||||
| 	return nil | 	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.
 | // 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
 | // - Any single (or double character) followed by a `/` will be considered a local file reference and
 | ||||||
| // 	 be constructed relative to the `base path`.
 | // 	 be constructed relative to the `base path`.
 | ||||||
|  |  | ||||||
|  | @ -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) { | func TestHelmState_applyDefaultsTo(t *testing.T) { | ||||||
| 	type fields struct { | 	type fields struct { | ||||||
| 		BaseChartPath      string | 		BaseChartPath      string | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue