From 0d8812ca8f8c2a07c7529d580225226be4707f15 Mon Sep 17 00:00:00 2001 From: Craig Dunford Date: Sat, 25 Apr 2020 20:21:13 -0400 Subject: [PATCH] Support for createNamespace - createNamespace is a new attribute that can be added to helmDefaults or an individual release to enforce the creation of a release namespace during sync if the namespace does not exist. This leverages helm's (3.2+) --create-namespace flag for the install/upgrade command. If running helm < 3.2, the createNamespace attribute has no effect. Resolves #891 Resolves #1140 --- README.md | 34 +++++++------ pkg/app/app_test.go | 8 +++ pkg/app/mocks_test.go | 10 ++++ pkg/exectest/helm.go | 17 +++++++ pkg/helmexec/exec.go | 64 +++++++++++++++++++---- pkg/helmexec/exec_test.go | 41 +++++++++++++++ pkg/helmexec/helmexec.go | 9 ++++ pkg/state/state.go | 10 ++++ pkg/state/state_test.go | 103 +++++++++++++++++++++++++++++++++++++- 9 files changed, 270 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8499d3345..1c532c30a 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ repositories: # context: kube-context # this directive is deprecated, please consider using helmDefaults.kubeContext -# Default values to set for args along with dedicated keys that can be set by contributors, cli args take precedence over these. +# Default values to set for args along with dedicated keys that can be set by contributors, cli args take precedence over these. # In other words, unset values results in no flags passed to helm. # See the helm usage (helm SUBCOMMAND -h) for more info on default values when those flags aren't provided. helmDefaults: @@ -91,15 +91,18 @@ helmDefaults: # forces resource update through delete/recreate if needed (default false) force: false # enable TLS for request to Tiller (default false) - tls: true + tls: true # path to TLS CA certificate file (default "$HELM_HOME/ca.pem") tlsCACert: "path/to/ca.pem" # path to TLS certificate file (default "$HELM_HOME/cert.pem") tlsCert: "path/to/cert.pem" # path to TLS key file (default "$HELM_HOME/key.pem") tlsKey: "path/to/key.pem" - # limit the maximum number of revisions saved per release. Use 0 for no limit. (default 10) + # limit the maximum number of revisions saved per release. Use 0 for no limit. (default 10) historyMax: 10 + # when using helm 3.2+, automatically create release namespaces if they do not exist (default true) + createNamespace: true + # The desired states of Helm releases. # @@ -108,6 +111,7 @@ releases: # Published chart example - name: vault # name of this release namespace: vault # target namespace + createNamespace: true # helm 3.2+ automatically create release namespace (default true) 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 @@ -152,21 +156,21 @@ releases: value: {{ .Namespace }} # will attempt to decrypt it using helm-secrets plugin secrets: - - vault_secret.yaml - # Override helmDefaults options for verify, wait, timeout, recreatePods and force. - verify: true - wait: true - timeout: 60 - recreatePods: true - force: false + - vault_secret.yaml + # Override helmDefaults options for verify, wait, timeout, recreatePods and force. + verify: true + wait: true + timeout: 60 + recreatePods: true + force: false # set `false` to uninstall this release on sync. (default true) installed: true # restores previous state in case of failed release (default false) - atomic: true + atomic: true # when true, cleans up any new resources created during a failed release (default false) - cleanupOnFail: false - # name of the tiller namespace (default "") - tillerNamespace: vault + cleanupOnFail: false + # name of the tiller namespace (default "") + tillerNamespace: vault # if true, will use the helm-tiller plugin (default false) tillerless: false # enable TLS for request to Tiller (default false) @@ -280,7 +284,7 @@ bases: # 'helmfile template' renders releases locally without querying an actual cluster, # and in this case `.Capabilities.APIVersions` cannot be populated. # When a chart queries for a specific CRD, this can lead to unexpected results. -# +# # Configure a fixed list of api versions to pass to 'helm template' via the --api-versions flag: apiVersions: - example/v1 diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 66719f946..86582bc38 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -2168,6 +2168,14 @@ func (helm *mockHelmExec) IsHelm3() bool { return false } +func (helm *mockHelmExec) GetVersion() helmexec.Version { + return helmexec.Version{} +} + +func (helm *mockHelmExec) IsVersionAtLeast(major int, minor int) bool { + return false +} + func TestTemplate_SingleStateFile(t *testing.T) { files := map[string]string{ "/path/to/helmfile.yaml": ` diff --git a/pkg/app/mocks_test.go b/pkg/app/mocks_test.go index e0f1411ce..fdd45d4dc 100644 --- a/pkg/app/mocks_test.go +++ b/pkg/app/mocks_test.go @@ -82,3 +82,13 @@ func (helm *noCallHelmExec) IsHelm3() bool { helm.doPanic() return false } + +func (helm *noCallHelmExec) GetVersion() helmexec.Version { + helm.doPanic() + return helmexec.Version{} +} + +func (helm *noCallHelmExec) IsVersionAtLeast(major int, minor int) bool { + helm.doPanic() + return false +} diff --git a/pkg/exectest/helm.go b/pkg/exectest/helm.go index d7dbce7e6..39a36e450 100644 --- a/pkg/exectest/helm.go +++ b/pkg/exectest/helm.go @@ -30,6 +30,7 @@ type Helm struct { Diffed []Release FailOnUnexpectedDiff bool FailOnUnexpectedList bool + Version *helmexec.Version UpdateDepsCallbacks map[string]func(string) error @@ -161,6 +162,22 @@ func (helm *Helm) IsHelm3() bool { return false } +func (helm *Helm) GetVersion() helmexec.Version { + if helm.Version != nil { + return *helm.Version + } + + return helmexec.Version{} +} + +func (helm *Helm) IsVersionAtLeast(major int, minor int) bool { + if helm.Version == nil { + return false + } + + return helm.Version.Major >= major && minor >= helm.Version.Minor +} + func (helm *Helm) sync(m *sync.Mutex, f func()) { if m != nil { m.Lock() diff --git a/pkg/helmexec/exec.go b/pkg/helmexec/exec.go index d11033c0c..1a35a10ae 100644 --- a/pkg/helmexec/exec.go +++ b/pkg/helmexec/exec.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "os" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -21,7 +22,7 @@ type decryptedSecret struct { type execer struct { helmBinary string - isHelm3 bool + version Version runner Runner logger *zap.SugaredLogger kubeContext string @@ -47,25 +48,62 @@ func NewLogger(writer io.Writer, logLevel string) *zap.SugaredLogger { return zap.New(core).Sugar() } -func detectHelm3(helmBinary string, logger *zap.SugaredLogger, runner Runner) bool { - // Support explicit opt-in via environment variable - if os.Getenv("HELMFILE_HELM3") != "" { - return true - } +func getHelmVersion(helmBinary string, logger *zap.SugaredLogger, runner Runner) Version { // Autodetect from `helm verison` bytes, err := runner.Execute(helmBinary, []string{"version", "--client", "--short"}, nil) if err != nil { panic(err) } - return strings.HasPrefix(string(bytes), "v3.") + + if bytes == nil || len(bytes) == 0 { + return Version{} + } + + re := regexp.MustCompile("v(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)") + matches := re.FindStringSubmatch(string(bytes)) + + result := make(map[string]string) + for i, name := range re.SubexpNames() { + result[name] = matches[i] + } + + major, err := strconv.Atoi(result["major"]) + if err != nil { + panic(err) + } + + minor, err := strconv.Atoi(result["minor"]) + if err != nil { + panic(err) + } + + patch, err := strconv.Atoi(result["patch"]) + if err != nil { + panic(err) + } + + // Support explicit helm3 opt-in via environment variable + if os.Getenv("HELMFILE_HELM3") != "" && major < 3 { + return Version{ + Major: 3, + Minor: 0, + Patch: 0, + } + } + + return Version{ + Major: major, + Minor: minor, + Patch: patch, + } } // New for running helm commands func New(helmBinary string, logger *zap.SugaredLogger, kubeContext string, runner Runner) *execer { return &execer{ helmBinary: helmBinary, - isHelm3: detectHelm3(helmBinary, logger, runner), + version: getHelmVersion(helmBinary, logger, runner), logger: logger, kubeContext: kubeContext, runner: runner, @@ -349,5 +387,13 @@ func (helm *execer) write(out []byte) { } func (helm *execer) IsHelm3() bool { - return helm.isHelm3 + return helm.version.Major == 3 +} + +func (helm *execer) GetVersion() Version { + return helm.version +} + +func (helm *execer) IsVersionAtLeast(major int, minor int) bool { + return helm.version.Major >= major && helm.version.Minor >= minor } diff --git a/pkg/helmexec/exec_test.go b/pkg/helmexec/exec_test.go index 7a5ffc7d8..360816b47 100644 --- a/pkg/helmexec/exec_test.go +++ b/pkg/helmexec/exec_test.go @@ -528,4 +528,45 @@ func Test_IsHelm3(t *testing.T) { if !helm.IsHelm3() { t.Error("helmexec.IsHelm3() - Failed to detect Helm 3") } + + os.Setenv("HELMFILE_HELM3", "1") + helm2Runner = mockRunner{output: []byte("Client: v2.16.0+ge13bc94\n")} + helm = New("helm", NewLogger(os.Stdout, "info"), "dev", &helm2Runner) + if !helm.IsHelm3() { + t.Error("helmexec.IsHelm3() - Helm3 not detected when HELMFILE_HELM3 is set") + } + os.Setenv("HELMFILE_HELM3", "") +} + +func Test_GetVersion(t *testing.T) { + helm2Runner := mockRunner{output: []byte("Client: v2.16.1+ge13bc94\n")} + helm := New("helm", NewLogger(os.Stdout, "info"), "dev", &helm2Runner) + ver := helm.GetVersion() + if ver.Major != 2 || ver.Minor != 16 || ver.Patch != 1 { + t.Error(fmt.Sprintf("helmexec.GetVersion - did not detect correct Helm2 version; it was: %+v", ver)) + } + + helm3Runner := mockRunner{output: []byte("v3.2.4+ge29ce2a\n")} + helm = New("helm", NewLogger(os.Stdout, "info"), "dev", &helm3Runner) + ver = helm.GetVersion() + if ver.Major != 3 || ver.Minor != 2 || ver.Patch != 4 { + t.Error(fmt.Sprintf("helmexec.GetVersion - did not detect correct Helm3 version; it was: %+v", ver)) + } } + +func Test_IsVersionAtLeast(t *testing.T) { + helm2Runner := mockRunner{output: []byte("Client: v2.16.1+ge13bc94\n")} + helm := New("helm", NewLogger(os.Stdout, "info"), "dev", &helm2Runner) + if !helm.IsVersionAtLeast(2, 1) { + t.Error("helmexec.IsVersionAtLeast - 2.16.1 not atleast 2.1") + } + + if helm.IsVersionAtLeast(2, 19) { + t.Error("helmexec.IsVersionAtLeast - 2.16.1 is atleast 2.19") + } + + if helm.IsVersionAtLeast(3, 2) { + t.Error("helmexec.IsVersionAtLeast - 2.16.1 is atleast 3.2") + } + +} \ No newline at end of file diff --git a/pkg/helmexec/helmexec.go b/pkg/helmexec/helmexec.go index b17d4fcab..ba482f983 100644 --- a/pkg/helmexec/helmexec.go +++ b/pkg/helmexec/helmexec.go @@ -1,5 +1,12 @@ package helmexec +// Version represents the version of helm +type Version struct { + Major int + Minor int + Patch int +} + // Interface for executing helm commands type Interface interface { SetExtraArgs(args ...string) @@ -20,6 +27,8 @@ type Interface interface { List(context HelmContext, filter string, flags ...string) (string, error) DecryptSecret(context HelmContext, name string, flags ...string) (string, error) IsHelm3() bool + GetVersion() Version + IsVersionAtLeast(major int, minor int) bool } type DependencyUpdater interface { diff --git a/pkg/state/state.go b/pkg/state/state.go index c411d50ed..b5bde67c9 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -114,6 +114,8 @@ type HelmSpec struct { CleanupOnFail bool `yaml:"cleanupOnFail,omitempty"` // HistoryMax, limit the maximum number of revisions saved per release. Use 0 for no limit (default 10) HistoryMax *int `yaml:"historyMax,omitempty"` + // CreateNamespace, when set to true (default), --create-namespace is passed to helm3 on install/upgrade (ignored for helm2) + CreateNamespace *bool `yaml:"createNamespace,omitempty"` TLS bool `yaml:"tls"` TLSCACert string `yaml:"tlsCACert,omitempty"` @@ -158,6 +160,8 @@ type ReleaseSpec struct { HistoryMax *int `yaml:"historyMax,omitempty"` // Condition, when set, evaluate the mapping specified in this string to a boolean which decides whether or not to process the release Condition string `yaml:"condition,omitempty"` + // CreateNamespace, when set to true (default), --create-namespace is passed to helm3 on install (ignored for helm2) + CreateNamespace *bool `yaml:"createNamespace,omitempty"` // MissingFileHandler is set to either "Error" or "Warn". "Error" instructs helmfile to fail when unable to find a values or secrets file. When "Warn", it prints the file and continues. // The default value for MissingFileHandler is "Error". @@ -1635,6 +1639,12 @@ func (st *HelmState) flagsForUpgrade(helm helmexec.Interface, release *ReleaseSp flags = append(flags, "--cleanup-on-fail") } + if helm.IsVersionAtLeast(3, 2) && + (release.CreateNamespace != nil && *release.CreateNamespace || + release.CreateNamespace == nil && (st.HelmDefaults.CreateNamespace == nil || *st.HelmDefaults.CreateNamespace)) { + flags = append(flags, "--create-namespace") + } + flags = st.appendConnectionFlags(flags, release) var err error diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index 91691adf3..35d498a68 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -163,6 +163,7 @@ func TestHelmState_flagsForUpgrade(t *testing.T) { tests := []struct { name string + version *helmexec.Version defaults HelmSpec release *ReleaseSpec want []string @@ -573,6 +574,101 @@ func TestHelmState_flagsForUpgrade(t *testing.T) { "--tls-ca-cert", "ca.pem", }, }, + { + name: "create-namespace-default-helm3.2", + defaults: HelmSpec{ + Verify: false, + }, + version: &helmexec.Version{ + Major: 3, + Minor: 2, + Patch: 0, + }, + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Verify: &disable, + Name: "test-charts", + Namespace: "test-namespace", + }, + want: []string{ + "--version", "0.1", + "--create-namespace", + "--namespace", "test-namespace", + }, + }, + { + name: "create-namespace-disabled-helm3.2", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: &helmexec.Version{ + Major: 3, + Minor: 2, + Patch: 0, + }, + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Verify: &disable, + Name: "test-charts", + Namespace: "test-namespace", + }, + want: []string{ + "--version", "0.1", + "--namespace", "test-namespace", + }, + }, + { + name: "create-namespace-release-override-enabled-helm3.2", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: &helmexec.Version{ + Major: 3, + Minor: 2, + Patch: 0, + }, + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Verify: &disable, + Name: "test-charts", + Namespace: "test-namespace", + CreateNamespace: &enable, + }, + want: []string{ + "--version", "0.1", + "--create-namespace", + "--namespace", "test-namespace", + }, + }, + { + name: "create-namespace-release-override-disabled-helm3.2", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &enable, + }, + version: &helmexec.Version{ + Major: 3, + Minor: 2, + Patch: 0, + }, + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Verify: &disable, + Name: "test-charts", + Namespace: "test-namespace", + CreateNamespace: &disable, + }, + want: []string{ + "--version", "0.1", + "--namespace", "test-namespace", + }, + }, } for i := range tests { tt := tests[i] @@ -584,10 +680,13 @@ func TestHelmState_flagsForUpgrade(t *testing.T) { HelmDefaults: tt.defaults, valsRuntime: valsRuntime, } - helm := helmexec.New("helm", logger, "default", &mockRunner{}) + helm := &exectest.Helm{ + Version: tt.version, + } + args, err := state.flagsForUpgrade(helm, tt.release, 0) if err != nil { - t.Errorf("unexpected error flagsForUpgade: %v", err) + t.Errorf("unexpected error flagsForUpgrade: %v", err) } if !reflect.DeepEqual(args, tt.want) { t.Errorf("flagsForUpgrade returned = %v, want %v", args, tt.want)