feat(#344): add sub helmfiles explicit selectors (#567)

Fixes #344 by allowing explicit selectors to be specified for composed helmfiles using the following structure

```yaml
helmfiles:
- path: helmfile.d/a*.yaml
  selectors:
  - name=prometheus      
  - name!=zipkin      
- helmfile.d/b*.yaml
- path: helmfile.d/c*.yaml
  selectors: {}
```

2 modes here : 
* legacy mode when no the env var HELMFILE_EXPERIMENTAL is not set to true
  * no selector : inherit from the command line.
  * selector:  is specified then it is used (an emty means no inheritance from command line and take everything).
* experimental when the env var HELMFILE_EXPERIMENTAL=true
  * no selector : nothing is inherited from the command line so use all releases.
  * selector:  is specified then it is used (an emty means no inheritance from command line and take everything).
This commit is contained in:
sgandon
2019-05-05 13:38:52 +09:00
committed by KUOKA Yusuke
parent d2353b5b70
commit 4581e004b8
11 changed files with 569 additions and 28 deletions
+111
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"reflect"
"testing"
. "gotest.tools/assert"
)
func TestReadFromYaml(t *testing.T) {
@@ -247,3 +249,112 @@ func TestReadFromYaml_FilterNegatives(t *testing.T) {
}
}
}
func TestReadFromYaml_Helmfiles_Selectors(t *testing.T) {
tests := []struct {
path string
content []byte
wantErr bool
helmfiles []SubHelmfileSpec
}{
{
path: "working/selector",
content: []byte(`helmfiles:
- simple/helmfile.yaml
- simple/helmfile/with/semicolon.yaml:
- two/selectors.yaml:
selectors:
- name=foo
- name=bar
- empty/selector.yaml:
selectors: {}
- inherits/selector.yaml:
selectors: inherits
- path: path/prefix/selector.yaml
selectors:
- name=zorba
- path: path/prefix/empty/selector.yaml
selectors: {}
- path: path/prefix/inherits/selector.yaml
selectors: inherits
`),
wantErr: false,
helmfiles: []SubHelmfileSpec{{Path: "simple/helmfile.yaml"},
{Path: "simple/helmfile/with/semicolon.yaml", Selectors: nil, Inherits: false},
{Path: "two/selectors.yaml", Selectors: []string{"name=foo", "name=bar"}, Inherits: false},
{Path: "empty/selector.yaml", Selectors: []string{}, Inherits: false},
{Path: "inherits/selector.yaml", Selectors: nil, Inherits: true},
{Path: "path/prefix/selector.yaml", Selectors: []string{"name=zorba"}, Inherits: false},
{Path: "path/prefix/empty/selector.yaml", Selectors: []string{}, Inherits: false},
{Path: "path/prefix/inherits/selector.yaml", Selectors: nil, Inherits: true},
},
},
{
path: "failing1/selector",
content: []byte(`helmfiles:
- failing1/helmfile.yaml: foo
`),
wantErr: true,
},
{
path: "failing2/selector",
content: []byte(`helmfiles:
- failing2/helmfile.yaml:
wrongkey:
`),
wantErr: true,
},
{
path: "failing3/selector",
content: []byte(`helmfiles:
- failing3/helmfile.yaml:
selectors: foo
`),
wantErr: true,
},
{
path: "failing4/selector",
content: []byte(`helmfiles:
- failing4/helmfile.yaml:
selectors:
`),
wantErr: true,
},
{
path: "failing4/selector",
content: []byte(`helmfiles:
- failing4/helmfile.yaml:
selectors:
- colon: not-authorized
`),
wantErr: true,
},
{
path: "failing5/selector",
content: []byte(`helmfiles:
- selectors:
- colon: not-authorized
`),
wantErr: true,
},
{
path: "failing6/selector",
content: []byte(`helmfiles:
- selectors:
- whatever
`),
wantErr: true,
},
}
for _, test := range tests {
st, err := createFromYaml(test.content, test.path, DefaultEnv, logger)
if err != nil {
if test.wantErr {
continue
} else {
t.Error("unexpected error:", err)
}
}
DeepEqual(t, st.Helmfiles, test.helmfiles)
}
}
+120 -11
View File
@@ -18,13 +18,14 @@ import (
"os/exec"
"syscall"
"net/url"
"github.com/roboll/helmfile/environment"
"github.com/roboll/helmfile/event"
"github.com/roboll/helmfile/tmpl"
"github.com/tatsushid/go-prettytable"
"go.uber.org/zap"
"gopkg.in/yaml.v2"
"net/url"
)
// HelmState structure for the helmfile
@@ -32,13 +33,14 @@ type HelmState struct {
basePath string
Environments map[string]EnvironmentSpec
FilePath string
HelmDefaults HelmSpec `yaml:"helmDefaults"`
Helmfiles []string `yaml:"helmfiles"`
DeprecatedContext string `yaml:"context"`
DeprecatedReleases []ReleaseSpec `yaml:"charts"`
Namespace string `yaml:"namespace"`
Repositories []RepositorySpec `yaml:"repositories"`
Releases []ReleaseSpec `yaml:"releases"`
HelmDefaults HelmSpec `yaml:"helmDefaults"`
Helmfiles []SubHelmfileSpec `yaml:"helmfiles"`
DeprecatedContext string `yaml:"context"`
DeprecatedReleases []ReleaseSpec `yaml:"charts"`
Namespace string `yaml:"namespace"`
Repositories []RepositorySpec `yaml:"repositories"`
Releases []ReleaseSpec `yaml:"releases"`
Selectors []string
Templates map[string]TemplateSpec `yaml:"templates"`
@@ -54,6 +56,15 @@ type HelmState struct {
runner helmexec.Runner
}
// SubHelmfileSpec defines the subhelmfile path and options
type SubHelmfileSpec struct {
Path string //path or glob pattern for the sub helmfiles
Selectors []string //chosen selectors for the sub helmfiles
Inherits bool //do the sub helmfiles inherits from parent selectors
}
const InheritsYamlValue = "inherits"
// HelmSpec to defines helmDefault values
type HelmSpec struct {
KubeContext string `yaml:"kubeContext"`
@@ -866,11 +877,11 @@ func (st *HelmState) Clean() []error {
}
// FilterReleases allows for the execution of helm commands against a subset of the releases in the helmfile.
func (st *HelmState) FilterReleases(labels []string) error {
func (st *HelmState) FilterReleases() error {
var filteredReleases []ReleaseSpec
releaseSet := map[string][]ReleaseSpec{}
filters := []ReleaseFilter{}
for _, label := range labels {
for _, label := range st.Selectors {
f, err := ParseLabels(label)
if err != nil {
return err
@@ -902,7 +913,7 @@ func (st *HelmState) FilterReleases(labels []string) error {
}
st.Releases = filteredReleases
numFound := len(filteredReleases)
st.logger.Debugf("%d release(s) matching %s found in %s\n", numFound, strings.Join(labels, ","), st.FilePath)
st.logger.Debugf("%d release(s) matching %s found in %s\n", numFound, strings.Join(st.Selectors, ","), st.FilePath)
return nil
}
@@ -1387,3 +1398,101 @@ func escape(value string) string {
intermediate = strings.Replace(intermediate, "}", "\\}", -1)
return strings.Replace(intermediate, ",", "\\,", -1)
}
//UnmarshalYAML will unmarshal the helmfile yaml section and fill the SubHelmfileSpec structure
//this is required to keep allowing string scalar for defining helmfile (maybe)
func (hf *SubHelmfileSpec) UnmarshalYAML(unmarshal func(interface{}) error) error {
var tmp interface{}
if err := unmarshal(&tmp); err != nil {
return err
}
switch i := tmp.(type) {
case string: // single path definition without sub items
hf.Path = i
case map[interface{}]interface{}: // helmfile path with sub section
for k, v := range i {
switch key := k.(type) {
case string:
//get the path
if key == "path" {
hf.Path = v.(string)
} else if key == "selectors" {
if err := extractSelectorContent(hf, v); err != nil {
return err
}
} else {
hf.Path = key
//get the selectors if something is specified
if v != nil { //we have a path, now compute the selector
if err := extractSelector(hf, v); err != nil {
return err
}
} //else it is a path with ending semi-colon without anything else below and it is fine
}
default:
return fmt.Errorf("Expecting a \"string\" scalar for the helmfile collection but got: %v", key)
}
}
}
//since we cannot make sur the "console" string can be red after the "path" we must check we don't have
//a SubHelmfileSpec with only selector and no path
if hf.Selectors != nil && hf.Path == "" {
return fmt.Errorf("found 'selectors' definition without path: %v", hf.Selectors)
}
return nil
}
//extractSelector this will extract the selectors: from the helmfile section
//this has been developed to only expect selectors: under the helmfiles for now.
func extractSelector(hf *SubHelmfileSpec, value interface{}) error {
switch value := value.(type) {
case map[interface{}]interface{}:
for k, v := range value {
switch key := k.(type) {
case string:
if key == "selectors" {
return extractSelectorContent(hf, v)
} else { //not 'selectors' so error
return fmt.Errorf("Expecting a \"selectors\" mapping but got: %v", key)
}
default: //we where expecting a selector but go something else
return fmt.Errorf("Expecting a \"selectors\" mapping for string [-%v] but got: %v of type %T", hf.Path, key, key)
}
}
default:
return fmt.Errorf("Expecting a \"selectors\" mapping for string [-%v] but got: %v of type %T", hf.Path, value, value)
}
return nil
}
func extractSelectorContent(hf *SubHelmfileSpec, value interface{}) error {
switch selectors := value.(type) {
case string: //check the string is `inherits` else error
if selectors == InheritsYamlValue {
hf.Inherits = true
} else {
return fmt.Errorf("Expecting a list of string, an empty {} or '%v' but got: %v", InheritsYamlValue, selectors)
}
case []interface{}: //expect an array if strings
for _, sel := range selectors {
switch selValue := sel.(type) {
case string:
hf.Selectors = append(hf.Selectors, selValue)
default:
return fmt.Errorf("Expecting a string, but got: %v", selValue)
}
}
case map[interface{}]interface{}:
if len(selectors) == 0 {
hf.Selectors = make([]string, 0) //allocate and non nil empty array
} else { //unexpected unempty map so error
return fmt.Errorf("unexpected unempty map in selector [-%v] but got: %v", hf.Path, selectors)
}
default:
return fmt.Errorf("Expecting list of strings or and empty {} mapping [-%v] but got: [%v] of type [%T] ", hf.Path, selectors, selectors)
}
return nil
}
+2 -1
View File
@@ -1636,7 +1636,8 @@ func TestHelmState_NoReleaseMatched(t *testing.T) {
Releases: releases,
logger: logger,
}
errs := state.FilterReleases([]string{tt.labels})
state.Selectors = []string{tt.labels}
errs := state.FilterReleases()
if (errs != nil) != tt.wantErr {
t.Errorf("ReleaseStatuses() for %s error = %v, wantErr %v", tt.name, errs, tt.wantErr)
return