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
|
||||||
27
README.md
27
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:
|
||||||
|
|
@ -102,11 +104,16 @@ COMMANDS:
|
||||||
delete delete charts from state file (helm delete)
|
delete delete charts from state file (helm delete)
|
||||||
|
|
||||||
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
|
||||||
--kube-context value Set kubectl context. Uses current context by default
|
--namespace value, -n value Set namespace. Uses the namespace set in the context by default
|
||||||
--help, -h show help
|
--selector,l value Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar.
|
||||||
--version, -v print the version
|
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
|
### 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
|
- 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
|
||||||
|
}
|
||||||
|
|
@ -42,11 +42,12 @@ type ReleaseSpec struct {
|
||||||
Verify bool `yaml:"verify"`
|
Verify bool `yaml:"verify"`
|
||||||
|
|
||||||
// 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"`
|
||||||
Values []string `yaml:"values"`
|
Labels map[string]string `yaml:"labels"`
|
||||||
Secrets []string `yaml:"secrets"`
|
Values []string `yaml:"values"`
|
||||||
SetValues []SetValue `yaml:"set"`
|
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
|
// The 'env' section is not really necessary any longer, as 'set' would now provide the same functionality
|
||||||
EnvValues []SetValue `yaml:"env"`
|
EnvValues []SetValue `yaml:"env"`
|
||||||
|
|
@ -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