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/
|
||||
.idea/
|
||||
helmfile
|
||||
27
README.md
27
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
|
||||
|
|
|
|||
17
main.go
17
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)
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue