From 8550b261adddd4f715efa9858c1809e807cfba85 Mon Sep 17 00:00:00 2001 From: kannan Date: Fri, 27 Mar 2026 09:34:56 +0530 Subject: [PATCH 1/7] Add optional support to create secret and shell for additional support --- README.md | 62 +++++++ plugin.go | 157 ++++++++++++++++-- plugin_test.go | 58 +++++++ test-clusters/cluster1-lab.yaml | 8 + types/types.go | 44 +++-- .../sample_config_shell_and_secrets.yaml | 16 ++ utils/utils_test.go | 15 ++ 7 files changed, 328 insertions(+), 32 deletions(-) create mode 100644 utils/tests/sample_config_shell_and_secrets.yaml diff --git a/README.md b/README.md index 41b6dd8..010d05c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,68 @@ releases: - sample-server-lab ``` +### Post-deploy shell commands (Optional feature) + +Run arbitrary shell commands after a release is deployed. Useful for one-off `kubectl` tasks or any other post-deploy automation that isn't covered by Helm or kubectl manifests. + +* Commands are skipped automatically on `--dry-run` and `--diff-run`. +* Each command is executed with `sh -c`, so shell features like pipes and env variable expansion work as expected. + +```yaml +name: cluster1-lab +releases: + - name: sample-server + namespace: kube-system + version: 3.9.0 + chartPath: stable/sample-server + shell: + - kubectl annotate namespace kube-system example.com/managed-by=impeller --overwrite + - echo "Deployment of sample-server complete" +``` + +### Kubernetes secret management (Optional feature) + +Declare secrets inline in the cluster config. Impeller will create or update each secret using `kubectl apply` after the release is deployed. + +* Secrets are skipped automatically on `--dry-run` and `--diff-run`. +* `namespace` on each secret is optional; when omitted the release `namespace` is used. +* Data values are treated as literal strings. If a value matches the name of a set environment variable, the environment variable's value is used instead — this keeps sensitive values out of source control. + +```yaml +name: cluster1-lab +releases: + - name: sample-server + namespace: kube-system + version: 3.9.0 + chartPath: stable/sample-server + secrets: + - name: app-credentials + namespace: kube-system # optional; defaults to release namespace + data: + username: myuser # literal value + password: MY_PASSWORD # resolved from $MY_PASSWORD env var if set +``` + +Full example combining both features: + +```yaml +name: cluster1-lab +releases: + - name: sample-server + namespace: kube-system + version: 3.9.0 + chartPath: stable/sample-server + waitforDeployment: + - sample-server + shell: + - kubectl label namespace kube-system team=platform --overwrite + secrets: + - name: app-credentials + data: + username: myuser + password: APP_PASSWORD # resolved from $APP_PASSWORD env var +``` + ### Other features * Use it as a [Drone](https://drone.io/) plugin for CI/CD. * Read secrets from environment variables. diff --git a/plugin.go b/plugin.go index 223c3df..3aa04cc 100644 --- a/plugin.go +++ b/plugin.go @@ -16,6 +16,7 @@ import ( "github.com/target/impeller/utils" "github.com/target/impeller/utils/commandbuilder" "github.com/target/impeller/utils/report" + "gopkg.in/yaml.v2" ) const ( @@ -172,21 +173,31 @@ func (p *Plugin) installAddon(release *types.Release) error { default: err = p.installAddonViaHelm(release) } - + if err != nil { return err } - + // Wait for resources to be ready if err := p.waitForResources(release); err != nil { return err } - + // Apply additional kubectl files after resources are ready if err := p.applyKubectlFiles(release); err != nil { return err } - + + // Create/update configured secrets after core resources are ready. + if err := p.applySecrets(release); err != nil { + return err + } + + // Run optional post-deploy shell commands. + if err := p.runShellCommands(release); err != nil { + return err + } + return nil } @@ -481,14 +492,14 @@ func (p *Plugin) applyKubectlFiles(release *types.Release) error { // Apply each file for _, file := range filesToApply { log.Printf("Applying kubectl file: %s", file) - + cb := commandbuilder.CommandBuilder{Name: constants.KubectlBin} cb.Add(commandbuilder.Arg{Type: commandbuilder.ArgTypeRaw, Value: "apply"}) cb.Add(commandbuilder.Arg{Type: commandbuilder.ArgTypeLongParam, Name: "filename", Value: file}) - + // Don't force namespace - let the manifest define its own namespace // This allows resources to be created in their specified namespaces - + if p.KubeContext != "" { cb.Add(commandbuilder.Arg{Type: commandbuilder.ArgTypeLongParam, Name: "context", Value: p.KubeContext}) } @@ -496,7 +507,7 @@ func (p *Plugin) applyKubectlFiles(release *types.Release) error { if err := cb.Run(); err != nil { return fmt.Errorf("error applying kubectl file \"%s\": %v", file, err) } - + log.Printf("Successfully applied kubectl file: %s", file) } } @@ -508,7 +519,7 @@ func (p *Plugin) applyKubectlFiles(release *types.Release) error { // Excludes kustomization.yaml and Kustomization.yaml files func (p *Plugin) getYAMLFilesFromDir(dirPath string) ([]string, error) { var yamlFiles []string - + files, err := ioutil.ReadDir(dirPath) if err != nil { return nil, err @@ -518,16 +529,16 @@ func (p *Plugin) getYAMLFilesFromDir(dirPath string) ([]string, error) { if file.IsDir() { continue } - + fileName := file.Name() - + // Skip kustomization files - if fileName == "kustomization.yaml" || fileName == "Kustomization.yaml" || - fileName == "kustomization.yml" || fileName == "Kustomization.yml" { + if fileName == "kustomization.yaml" || fileName == "Kustomization.yaml" || + fileName == "kustomization.yml" || fileName == "Kustomization.yml" { log.Printf("Skipping kustomization file: %s", fileName) continue } - + if strings.HasSuffix(fileName, ".yaml") || strings.HasSuffix(fileName, ".yml") { fullPath := dirPath + "/" + fileName yamlFiles = append(yamlFiles, fullPath) @@ -543,6 +554,124 @@ func (p *Plugin) getYAMLFilesFromDir(dirPath string) ([]string, error) { return yamlFiles, nil } +func (p *Plugin) applySecrets(release *types.Release) error { + if p.Dryrun || p.Diffrun || len(release.Secrets) == 0 { + return nil + } + + for _, secret := range release.Secrets { + if secret.Name == "" { + return fmt.Errorf("secret name cannot be empty for release %s", release.Name) + } + if len(secret.Data) == 0 { + return fmt.Errorf("secret %s has no data for release %s", secret.Name, release.Name) + } + + namespace := secret.Namespace + if namespace == "" { + namespace = release.Namespace + } + + secretData := map[string]string{} + secretStringData := map[string]string{} + + for key, rawValue := range secret.Data { + value := rawValue + if envValue, isSet := os.LookupEnv(rawValue); isSet { + value = envValue + } + + if isBase64Encoded(value) { + secretData[key] = strings.TrimSpace(value) + } else { + secretStringData[key] = value + } + } + + metadata := map[string]string{"name": secret.Name} + if namespace != "" { + metadata["namespace"] = namespace + } + + secretManifestMap := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": metadata, + "type": "Opaque", + } + + if len(secretData) > 0 { + secretManifestMap["data"] = secretData + } + if len(secretStringData) > 0 { + secretManifestMap["stringData"] = secretStringData + } + + secretManifest, err := yaml.Marshal(secretManifestMap) + if err != nil { + return fmt.Errorf("error preparing secret %q manifest: %v", secret.Name, err) + } + + applyArgs := []string{"apply", "--filename", "-"} + if p.KubeContext != "" { + applyArgs = append(applyArgs, "--context", p.KubeContext) + } + + applyCmd := exec.Command(constants.KubectlBin, applyArgs...) + applyCmd.Stdin = strings.NewReader(string(secretManifest)) + applyCmd.Stdout = os.Stdout + applyCmd.Stderr = os.Stderr + + if err := applyCmd.Run(); err != nil { + return fmt.Errorf("error applying secret %q: %v", secret.Name, err) + } + + log.Printf("Applied secret: %s", secret.Name) + } + + return nil +} + +func isBase64Encoded(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return false + } + + if decoded, err := base64.StdEncoding.DecodeString(trimmed); err == nil { + if base64.StdEncoding.EncodeToString(decoded) == trimmed { + return true + } + } + + if decoded, err := base64.RawStdEncoding.DecodeString(trimmed); err == nil { + if base64.RawStdEncoding.EncodeToString(decoded) == trimmed { + return true + } + } + + return false +} + +func (p *Plugin) runShellCommands(release *types.Release) error { + if p.Dryrun || p.Diffrun || len(release.Shell) == 0 { + return nil + } + + for _, cmdStr := range release.Shell { + if strings.TrimSpace(cmdStr) == "" { + continue + } + + cmd := exec.Command("sh", "-c", cmdStr) + if err := utils.Run(cmd, false); err != nil { + return fmt.Errorf("error running shell command %q: %v", cmdStr, err) + } + } + + return nil +} + func (p *Plugin) fetchChart(release *types.Release) error { cb := commandbuilder.CommandBuilder{Name: constants.HelmBin} cb.Add(commandbuilder.Arg{Type: commandbuilder.ArgTypeRaw, Value: "fetch"}) diff --git a/plugin_test.go b/plugin_test.go index 79de270..f84e589 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -151,3 +151,61 @@ func TestWaitForResourcesSkipsOnDiffrun(t *testing.T) { err := p.waitForResources(release) require.NoError(t, err) } + +func TestApplySecretsSkipsOnDryrun(t *testing.T) { + p := &Plugin{Dryrun: true} + release := &types.Release{ + Secrets: []types.Secret{{ + Name: "my-secret", + Data: map[string]string{"key": "value"}, + }}, + } + + err := p.applySecrets(release) + require.NoError(t, err) +} + +func TestApplySecretsSkipsOnDiffrun(t *testing.T) { + p := &Plugin{Diffrun: true} + release := &types.Release{ + Secrets: []types.Secret{{ + Name: "my-secret", + Data: map[string]string{"key": "value"}, + }}, + } + + err := p.applySecrets(release) + require.NoError(t, err) +} + +func TestRunShellCommandsSkipsOnDryrun(t *testing.T) { + p := &Plugin{Dryrun: true} + release := &types.Release{Shell: []string{"echo hello"}} + + err := p.runShellCommands(release) + require.NoError(t, err) +} + +func TestRunShellCommandsSkipsOnDiffrun(t *testing.T) { + p := &Plugin{Diffrun: true} + release := &types.Release{Shell: []string{"echo hello"}} + + err := p.runShellCommands(release) + require.NoError(t, err) +} + +func TestApplySecretsValidation(t *testing.T) { + p := &Plugin{} + release := &types.Release{Name: "test-release", Secrets: []types.Secret{{}}} + + err := p.applySecrets(release) + require.Error(t, err) + assert.Contains(t, err.Error(), "secret name cannot be empty") +} + +func TestIsBase64Encoded(t *testing.T) { + assert.True(t, isBase64Encoded("aGVsbG8=")) + assert.True(t, isBase64Encoded("aGVsbG8")) + assert.False(t, isBase64Encoded("hello")) + assert.False(t, isBase64Encoded("")) +} diff --git a/test-clusters/cluster1-lab.yaml b/test-clusters/cluster1-lab.yaml index 0290b03..7a20d58 100644 --- a/test-clusters/cluster1-lab.yaml +++ b/test-clusters/cluster1-lab.yaml @@ -11,6 +11,14 @@ releases: kubectlFiles: - sample-server.yaml - sample-server + shell: + - kubectl create secrets hello-world --from-literal=hello=world -n kube-system + secrets: + - name: hello-world + namespace: kube-system + data: + key1: MYSECRET + key2: MYSECRET2 - name: sample-ingress namespace: kube-system version: 3.9.3 diff --git a/types/types.go b/types/types.go index 61697c8..864bb54 100644 --- a/types/types.go +++ b/types/types.go @@ -23,20 +23,28 @@ type HelmRepo struct { } type Release struct { - Name string `yaml:"name"` - DeploymentMethod string `yaml:"deploymentMethod,omitempty"` - Version string `yaml:"version"` - ChartPath string `yaml:"chartPath"` - ChartsSource string `yaml:"chartsSource"` - History uint `yaml:history` - Overrides []Override `yaml:"overrides,omitempty"` - Namespace string `yaml:"namespace,omitempty"` - ValueFiles []string `yaml:"valueFiles,omitempty"` - WaitforDeployment []string `yaml:"waitforDeployment,omitempty"` - WaitforDaemonSet []string `yaml:"waitforDaemonSet,omitempty"` - WaitforStatefulSet []string `yaml:"waitforStatefulSet,omitempty"` - KubectlFiles []string `yaml:"kubectlFiles,omitempty"` - Force bool `yaml:"force,omitempty"` + Name string `yaml:"name"` + DeploymentMethod string `yaml:"deploymentMethod,omitempty"` + Version string `yaml:"version"` + ChartPath string `yaml:"chartPath"` + ChartsSource string `yaml:"chartsSource"` + History uint `yaml:history` + Overrides []Override `yaml:"overrides,omitempty"` + Namespace string `yaml:"namespace,omitempty"` + ValueFiles []string `yaml:"valueFiles,omitempty"` + WaitforDeployment []string `yaml:"waitforDeployment,omitempty"` + WaitforDaemonSet []string `yaml:"waitforDaemonSet,omitempty"` + WaitforStatefulSet []string `yaml:"waitforStatefulSet,omitempty"` + KubectlFiles []string `yaml:"kubectlFiles,omitempty"` + Shell []string `yaml:"shell,omitempty"` + Secrets []Secret `yaml:"secrets,omitempty"` + Force bool `yaml:"force,omitempty"` +} + +type Secret struct { + Name string `yaml:"name"` + Namespace string `yaml:"namespace,omitempty"` + Data map[string]string `yaml:"data,omitempty"` } type Override struct { @@ -44,10 +52,10 @@ type Override struct { Target string `yaml:"target"` } -/// BuildArg creates a commandbuilder.Arg for either a `--set` or -/// `--set-file` argument. If the value is provided directly or as an -/// environment variable, `--set` will be used. If a file is provided, -/// then `--set-file` will be used. +// BuildArg creates a commandbuilder.Arg for either a `--set` or +// `--set-file` argument. If the value is provided directly or as an +// environment variable, `--set` will be used. If a file is provided, +// then `--set-file` will be used. func (o Override) BuildArg() (*commandbuilder.Arg, error) { return o.Value.BuildArg(o.Target) } diff --git a/utils/tests/sample_config_shell_and_secrets.yaml b/utils/tests/sample_config_shell_and_secrets.yaml new file mode 100644 index 0000000..5431dff --- /dev/null +++ b/utils/tests/sample_config_shell_and_secrets.yaml @@ -0,0 +1,16 @@ +name: unittest-cluster +helm: + skipSetupKubeConfig: true + skipSetupHelmRepo: true +releases: + - name: release-with-optional-features + namespace: kube-system + version: 1.0.0 + chartPath: stable/sample + shell: + - echo ready + secrets: + - name: app-secret + data: + username: USERNAME_ENV + password: plain-password diff --git a/utils/utils_test.go b/utils/utils_test.go index 241e2ce..081cdf2 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -39,3 +39,18 @@ func TestReadConfigWithChartOverrides(t *testing.T) { require.Nil(t, err) assert.Equal(t, "12.12.12.0/24 12.0.0.0/4 cluster.local", overrideValue) } + +func TestReadConfigWithShellAndSecrets(t *testing.T) { + config, err := ReadClusterConfig("./tests/sample_config_shell_and_secrets.yaml") + require.Nil(t, err) + require.Len(t, config.Releases, 1) + + release := config.Releases[0] + require.Len(t, release.Shell, 1) + assert.Equal(t, "echo ready", release.Shell[0]) + + require.Len(t, release.Secrets, 1) + assert.Equal(t, "app-secret", release.Secrets[0].Name) + assert.Equal(t, "USERNAME_ENV", release.Secrets[0].Data["username"]) + assert.Equal(t, "plain-password", release.Secrets[0].Data["password"]) +} From 7d030f812f5124c0011e17de9c645fc1dba78529 Mon Sep 17 00:00:00 2001 From: kannan Date: Fri, 27 Mar 2026 18:16:07 +0530 Subject: [PATCH 2/7] Remove shell and accept only Environment variable for secrets --- README.md | 13 +++--- plugin.go | 40 ++++++------------- plugin_test.go | 31 +++++++------- types/types.go | 7 ++-- .../sample_config_shell_and_secrets.yaml | 4 +- utils/utils_test.go | 7 +--- 6 files changed, 40 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 010d05c..c6c1fc7 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ Declare secrets inline in the cluster config. Impeller will create or update eac * Secrets are skipped automatically on `--dry-run` and `--diff-run`. * `namespace` on each secret is optional; when omitted the release `namespace` is used. -* Data values are treated as literal strings. If a value matches the name of a set environment variable, the environment variable's value is used instead — this keeps sensitive values out of source control. +* Data values must be environment variable names. Impeller resolves each key from that environment variable and fails if any variable is missing or empty. +* If an environment value is already base64-encoded, it is preserved as-is; otherwise Kubernetes encodes it from `stringData`. ```yaml name: cluster1-lab @@ -86,11 +87,11 @@ releases: - name: app-credentials namespace: kube-system # optional; defaults to release namespace data: - username: myuser # literal value - password: MY_PASSWORD # resolved from $MY_PASSWORD env var if set + username: APP_USERNAME # read from $APP_USERNAME + password: MY_PASSWORD # read from $MY_PASSWORD ``` -Full example combining both features: +Example: ```yaml name: cluster1-lab @@ -106,8 +107,8 @@ releases: secrets: - name: app-credentials data: - username: myuser - password: APP_PASSWORD # resolved from $APP_PASSWORD env var + username: APP_USERNAME + password: APP_PASSWORD # read from $APP_PASSWORD ``` ### Other features diff --git a/plugin.go b/plugin.go index 3aa04cc..44f2b4a 100644 --- a/plugin.go +++ b/plugin.go @@ -193,11 +193,6 @@ func (p *Plugin) installAddon(release *types.Release) error { return err } - // Run optional post-deploy shell commands. - if err := p.runShellCommands(release); err != nil { - return err - } - return nil } @@ -575,10 +570,18 @@ func (p *Plugin) applySecrets(release *types.Release) error { secretData := map[string]string{} secretStringData := map[string]string{} - for key, rawValue := range secret.Data { - value := rawValue - if envValue, isSet := os.LookupEnv(rawValue); isSet { - value = envValue + for key, envVarName := range secret.Data { + envVarName = strings.TrimSpace(envVarName) + if envVarName == "" { + return fmt.Errorf("secret %q key %q has empty environment variable name", secret.Name, key) + } + + value, isSet := os.LookupEnv(envVarName) + if !isSet { + return fmt.Errorf("secret %q key %q requires environment variable %q to be set", secret.Name, key, envVarName) + } + if strings.TrimSpace(value) == "" { + return fmt.Errorf("secret %q key %q resolved empty value from environment variable %q", secret.Name, key, envVarName) } if isBase64Encoded(value) { @@ -653,25 +656,6 @@ func isBase64Encoded(value string) bool { return false } -func (p *Plugin) runShellCommands(release *types.Release) error { - if p.Dryrun || p.Diffrun || len(release.Shell) == 0 { - return nil - } - - for _, cmdStr := range release.Shell { - if strings.TrimSpace(cmdStr) == "" { - continue - } - - cmd := exec.Command("sh", "-c", cmdStr) - if err := utils.Run(cmd, false); err != nil { - return fmt.Errorf("error running shell command %q: %v", cmdStr, err) - } - } - - return nil -} - func (p *Plugin) fetchChart(release *types.Release) error { cb := commandbuilder.CommandBuilder{Name: constants.HelmBin} cb.Add(commandbuilder.Arg{Type: commandbuilder.ArgTypeRaw, Value: "fetch"}) diff --git a/plugin_test.go b/plugin_test.go index f84e589..6b6dc0e 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -178,22 +178,6 @@ func TestApplySecretsSkipsOnDiffrun(t *testing.T) { require.NoError(t, err) } -func TestRunShellCommandsSkipsOnDryrun(t *testing.T) { - p := &Plugin{Dryrun: true} - release := &types.Release{Shell: []string{"echo hello"}} - - err := p.runShellCommands(release) - require.NoError(t, err) -} - -func TestRunShellCommandsSkipsOnDiffrun(t *testing.T) { - p := &Plugin{Diffrun: true} - release := &types.Release{Shell: []string{"echo hello"}} - - err := p.runShellCommands(release) - require.NoError(t, err) -} - func TestApplySecretsValidation(t *testing.T) { p := &Plugin{} release := &types.Release{Name: "test-release", Secrets: []types.Secret{{}}} @@ -203,6 +187,21 @@ func TestApplySecretsValidation(t *testing.T) { assert.Contains(t, err.Error(), "secret name cannot be empty") } +func TestApplySecretsRequiresEnvironmentVariables(t *testing.T) { + p := &Plugin{} + release := &types.Release{ + Name: "test-release", + Secrets: []types.Secret{{ + Name: "my-secret", + Data: map[string]string{"password": "MISSING_ENV_VAR_FOR_TEST"}, + }}, + } + + err := p.applySecrets(release) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires environment variable") +} + func TestIsBase64Encoded(t *testing.T) { assert.True(t, isBase64Encoded("aGVsbG8=")) assert.True(t, isBase64Encoded("aGVsbG8")) diff --git a/types/types.go b/types/types.go index 864bb54..b4ea011 100644 --- a/types/types.go +++ b/types/types.go @@ -35,10 +35,9 @@ type Release struct { WaitforDeployment []string `yaml:"waitforDeployment,omitempty"` WaitforDaemonSet []string `yaml:"waitforDaemonSet,omitempty"` WaitforStatefulSet []string `yaml:"waitforStatefulSet,omitempty"` - KubectlFiles []string `yaml:"kubectlFiles,omitempty"` - Shell []string `yaml:"shell,omitempty"` - Secrets []Secret `yaml:"secrets,omitempty"` - Force bool `yaml:"force,omitempty"` + KubectlFiles []string `yaml:"kubectlFiles,omitempty"` + Secrets []Secret `yaml:"secrets,omitempty"` + Force bool `yaml:"force,omitempty"` } type Secret struct { diff --git a/utils/tests/sample_config_shell_and_secrets.yaml b/utils/tests/sample_config_shell_and_secrets.yaml index 5431dff..2fd7cc2 100644 --- a/utils/tests/sample_config_shell_and_secrets.yaml +++ b/utils/tests/sample_config_shell_and_secrets.yaml @@ -7,10 +7,8 @@ releases: namespace: kube-system version: 1.0.0 chartPath: stable/sample - shell: - - echo ready secrets: - name: app-secret data: username: USERNAME_ENV - password: plain-password + password: PASSWORD_ENV diff --git a/utils/utils_test.go b/utils/utils_test.go index 081cdf2..0b262e8 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -40,17 +40,14 @@ func TestReadConfigWithChartOverrides(t *testing.T) { assert.Equal(t, "12.12.12.0/24 12.0.0.0/4 cluster.local", overrideValue) } -func TestReadConfigWithShellAndSecrets(t *testing.T) { +func TestReadConfigWithSecrets(t *testing.T) { config, err := ReadClusterConfig("./tests/sample_config_shell_and_secrets.yaml") require.Nil(t, err) require.Len(t, config.Releases, 1) release := config.Releases[0] - require.Len(t, release.Shell, 1) - assert.Equal(t, "echo ready", release.Shell[0]) - require.Len(t, release.Secrets, 1) assert.Equal(t, "app-secret", release.Secrets[0].Name) assert.Equal(t, "USERNAME_ENV", release.Secrets[0].Data["username"]) - assert.Equal(t, "plain-password", release.Secrets[0].Data["password"]) + assert.Equal(t, "PASSWORD_ENV", release.Secrets[0].Data["password"]) } From 376836dbad11326e74e138de46a90b6da077b602 Mon Sep 17 00:00:00 2001 From: kannan Date: Fri, 27 Mar 2026 18:30:03 +0530 Subject: [PATCH 3/7] Remove shell and accept only Environment variable for secrets --- test-clusters/cluster1-lab.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/test-clusters/cluster1-lab.yaml b/test-clusters/cluster1-lab.yaml index 7a20d58..40c7638 100644 --- a/test-clusters/cluster1-lab.yaml +++ b/test-clusters/cluster1-lab.yaml @@ -11,8 +11,6 @@ releases: kubectlFiles: - sample-server.yaml - sample-server - shell: - - kubectl create secrets hello-world --from-literal=hello=world -n kube-system secrets: - name: hello-world namespace: kube-system From 81ab174612984fb71a7b2a99f102ab75a65f04bb Mon Sep 17 00:00:00 2001 From: kannan Date: Fri, 27 Mar 2026 18:35:34 +0530 Subject: [PATCH 4/7] Remove shell docs from ReadME --- README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/README.md b/README.md index c6c1fc7..6e30678 100644 --- a/README.md +++ b/README.md @@ -48,25 +48,6 @@ releases: - sample-server-lab ``` -### Post-deploy shell commands (Optional feature) - -Run arbitrary shell commands after a release is deployed. Useful for one-off `kubectl` tasks or any other post-deploy automation that isn't covered by Helm or kubectl manifests. - -* Commands are skipped automatically on `--dry-run` and `--diff-run`. -* Each command is executed with `sh -c`, so shell features like pipes and env variable expansion work as expected. - -```yaml -name: cluster1-lab -releases: - - name: sample-server - namespace: kube-system - version: 3.9.0 - chartPath: stable/sample-server - shell: - - kubectl annotate namespace kube-system example.com/managed-by=impeller --overwrite - - echo "Deployment of sample-server complete" -``` - ### Kubernetes secret management (Optional feature) Declare secrets inline in the cluster config. Impeller will create or update each secret using `kubectl apply` after the release is deployed. @@ -102,8 +83,6 @@ releases: chartPath: stable/sample-server waitforDeployment: - sample-server - shell: - - kubectl label namespace kube-system team=platform --overwrite secrets: - name: app-credentials data: From 9dfe7a7ba3173e168c08281b288b116f199da469 Mon Sep 17 00:00:00 2001 From: kannan Date: Fri, 27 Mar 2026 18:41:34 +0530 Subject: [PATCH 5/7] Test case for plain test secret --- plugin_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/plugin_test.go b/plugin_test.go index 6b6dc0e..5efd94a 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -202,6 +202,22 @@ func TestApplySecretsRequiresEnvironmentVariables(t *testing.T) { assert.Contains(t, err.Error(), "requires environment variable") } +func TestApplySecretsRejectsPlainTextSecretValue(t *testing.T) { + p := &Plugin{} + release := &types.Release{ + Name: "test-release", + Secrets: []types.Secret{{ + Name: "my-secret", + Data: map[string]string{"password": "my-plain-text-password"}, + }}, + } + + err := p.applySecrets(release) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires environment variable") + assert.Contains(t, err.Error(), "my-plain-text-password") +} + func TestIsBase64Encoded(t *testing.T) { assert.True(t, isBase64Encoded("aGVsbG8=")) assert.True(t, isBase64Encoded("aGVsbG8")) From 7e56523cffd3cdae37f4bbcba4a851a414fc9061 Mon Sep 17 00:00:00 2001 From: kannan Date: Fri, 27 Mar 2026 19:23:37 +0530 Subject: [PATCH 6/7] Add _ENV postfix to ENV String --- README.md | 22 ++-------------------- plugin.go | 3 +++ plugin_test.go | 28 ++++++++++++++++++++++------ test-clusters/cluster1-lab.yaml | 4 ++-- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 6e30678..ea5c63d 100644 --- a/README.md +++ b/README.md @@ -68,26 +68,8 @@ releases: - name: app-credentials namespace: kube-system # optional; defaults to release namespace data: - username: APP_USERNAME # read from $APP_USERNAME - password: MY_PASSWORD # read from $MY_PASSWORD -``` - -Example: - -```yaml -name: cluster1-lab -releases: - - name: sample-server - namespace: kube-system - version: 3.9.0 - chartPath: stable/sample-server - waitforDeployment: - - sample-server - secrets: - - name: app-credentials - data: - username: APP_USERNAME - password: APP_PASSWORD # read from $APP_PASSWORD + username: APP_USERNAME_ENV # read from $APP_USERNAME_ENV + password: MY_PASSWORD_ENV # read from $MY_PASSWORD_ENV ``` ### Other features diff --git a/plugin.go b/plugin.go index 44f2b4a..dc58d08 100644 --- a/plugin.go +++ b/plugin.go @@ -575,6 +575,9 @@ func (p *Plugin) applySecrets(release *types.Release) error { if envVarName == "" { return fmt.Errorf("secret %q key %q has empty environment variable name", secret.Name, key) } + if !strings.HasSuffix(envVarName, "_ENV") { + return fmt.Errorf("secret %q key %q: value %q must be an environment variable name ending with _ENV", secret.Name, key, envVarName) + } value, isSet := os.LookupEnv(envVarName) if !isSet { diff --git a/plugin_test.go b/plugin_test.go index 5efd94a..a142370 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -157,7 +157,7 @@ func TestApplySecretsSkipsOnDryrun(t *testing.T) { release := &types.Release{ Secrets: []types.Secret{{ Name: "my-secret", - Data: map[string]string{"key": "value"}, + Data: map[string]string{"key": "MY_VALUE_ENV"}, }}, } @@ -170,7 +170,7 @@ func TestApplySecretsSkipsOnDiffrun(t *testing.T) { release := &types.Release{ Secrets: []types.Secret{{ Name: "my-secret", - Data: map[string]string{"key": "value"}, + Data: map[string]string{"key": "MY_VALUE_ENV"}, }}, } @@ -193,7 +193,7 @@ func TestApplySecretsRequiresEnvironmentVariables(t *testing.T) { Name: "test-release", Secrets: []types.Secret{{ Name: "my-secret", - Data: map[string]string{"password": "MISSING_ENV_VAR_FOR_TEST"}, + Data: map[string]string{"password": "MISSING_ENV_VAR_ENV"}, }}, } @@ -202,7 +202,7 @@ func TestApplySecretsRequiresEnvironmentVariables(t *testing.T) { assert.Contains(t, err.Error(), "requires environment variable") } -func TestApplySecretsRejectsPlainTextSecretValue(t *testing.T) { +func TestApplySecretsRejectsValueWithoutENVSuffix(t *testing.T) { p := &Plugin{} release := &types.Release{ Name: "test-release", @@ -214,8 +214,24 @@ func TestApplySecretsRejectsPlainTextSecretValue(t *testing.T) { err := p.applySecrets(release) require.Error(t, err) - assert.Contains(t, err.Error(), "requires environment variable") - assert.Contains(t, err.Error(), "my-plain-text-password") + assert.Contains(t, err.Error(), "must be an environment variable name ending with _ENV") +} + +func TestApplySecretsRejectsValueWithoutENVSuffixVariants(t *testing.T) { + invalidValues := []string{"MY_SECRET", "mysecret", "SECRET_VAR", "VALUE_ENVX", "plain text"} + for _, v := range invalidValues { + p := &Plugin{} + release := &types.Release{ + Name: "test-release", + Secrets: []types.Secret{{ + Name: "my-secret", + Data: map[string]string{"key": v}, + }}, + } + err := p.applySecrets(release) + require.Errorf(t, err, "expected error for value %q", v) + assert.Contains(t, err.Error(), "must be an environment variable name ending with _ENV") + } } func TestIsBase64Encoded(t *testing.T) { diff --git a/test-clusters/cluster1-lab.yaml b/test-clusters/cluster1-lab.yaml index 40c7638..7a0f36f 100644 --- a/test-clusters/cluster1-lab.yaml +++ b/test-clusters/cluster1-lab.yaml @@ -15,8 +15,8 @@ releases: - name: hello-world namespace: kube-system data: - key1: MYSECRET - key2: MYSECRET2 + key1: MYSECRET_ENV + key2: MYSECRET2_ENV - name: sample-ingress namespace: kube-system version: 3.9.3 From 669dc9c3f319c36f360aee0c93782d033c217fb0 Mon Sep 17 00:00:00 2001 From: Kannan Thiruneelakandan Date: Fri, 27 Mar 2026 19:53:37 +0530 Subject: [PATCH 7/7] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ea5c63d..6ff9bf2 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ Declare secrets inline in the cluster config. Impeller will create or update eac * Secrets are skipped automatically on `--dry-run` and `--diff-run`. * `namespace` on each secret is optional; when omitted the release `namespace` is used. * Data values must be environment variable names. Impeller resolves each key from that environment variable and fails if any variable is missing or empty. -* If an environment value is already base64-encoded, it is preserved as-is; otherwise Kubernetes encodes it from `stringData`. +* If an environment value is already base64-encoded, it is preserved as-is; otherwise Kubernetes encodes it from `stringData` +* **Note:** The environment variable should be suffixed with`_ENV` and plain text is not accepted. ```yaml name: cluster1-lab