diff --git a/README.md b/README.md index 41b6dd8..6ff9bf2 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,31 @@ releases: - sample-server-lab ``` +### 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 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` +* **Note:** The environment variable should be suffixed with`_ENV` and plain text is not accepted. + +```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: APP_USERNAME_ENV # read from $APP_USERNAME_ENV + password: MY_PASSWORD_ENV # read from $MY_PASSWORD_ENV +``` + ### 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..dc58d08 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,26 @@ 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 + } + return nil } @@ -481,14 +487,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 +502,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 +514,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 +524,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 +549,116 @@ 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, 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) + } + 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 { + 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) { + 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) 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..a142370 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -151,3 +151,92 @@ 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": "MY_VALUE_ENV"}, + }}, + } + + 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": "MY_VALUE_ENV"}, + }}, + } + + err := p.applySecrets(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 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_ENV"}, + }}, + } + + err := p.applySecrets(release) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires environment variable") +} + +func TestApplySecretsRejectsValueWithoutENVSuffix(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(), "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) { + 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..7a0f36f 100644 --- a/test-clusters/cluster1-lab.yaml +++ b/test-clusters/cluster1-lab.yaml @@ -11,6 +11,12 @@ releases: kubectlFiles: - sample-server.yaml - sample-server + secrets: + - name: hello-world + namespace: kube-system + data: + key1: MYSECRET_ENV + key2: MYSECRET2_ENV - name: sample-ingress namespace: kube-system version: 3.9.3 diff --git a/types/types.go b/types/types.go index 61697c8..b4ea011 100644 --- a/types/types.go +++ b/types/types.go @@ -23,20 +23,27 @@ 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"` + 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 +51,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..2fd7cc2 --- /dev/null +++ b/utils/tests/sample_config_shell_and_secrets.yaml @@ -0,0 +1,14 @@ +name: unittest-cluster +helm: + skipSetupKubeConfig: true + skipSetupHelmRepo: true +releases: + - name: release-with-optional-features + namespace: kube-system + version: 1.0.0 + chartPath: stable/sample + secrets: + - name: app-secret + data: + username: USERNAME_ENV + password: PASSWORD_ENV diff --git a/utils/utils_test.go b/utils/utils_test.go index 241e2ce..0b262e8 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -39,3 +39,15 @@ 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 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.Secrets, 1) + assert.Equal(t, "app-secret", release.Secrets[0].Name) + assert.Equal(t, "USERNAME_ENV", release.Secrets[0].Data["username"]) + assert.Equal(t, "PASSWORD_ENV", release.Secrets[0].Data["password"]) +}