Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
144 changes: 130 additions & 14 deletions plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -481,22 +487,22 @@ 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})
}

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)
}
}
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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"})
Expand Down
89 changes: 89 additions & 0 deletions plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(""))
}
6 changes: 6 additions & 0 deletions test-clusters/cluster1-lab.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading