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-24 01:05:19 +09:00
committed by KUOKA Yusuke
parent e81541d65c
commit 4b08ea9292
6 changed files with 215 additions and 15 deletions
+64
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
}
+45 -9
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`.
+67
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