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:
Alex Withrow 2018-03-23 10:05:19 -06:00 committed by KUOKA Yusuke
parent e81541d65c
commit 4b08ea9292
6 changed files with 215 additions and 15 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
dist/
.idea/
helmfile

View File

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

17
main.go
View File

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

64
state/release_filters.go Normal file
View File

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

View File

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

View File

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