Skip to content

Commit

Permalink
feat(k8s): update secret API reference
Browse files Browse the repository at this point in the history
Updates the reference to allow other Kubernetes objects
like ConfigMaps, which can become a valid use case.

Also adds unit tests.

Signed-off-by: Bernardo Salazar <[email protected]>
  • Loading branch information
bersalazar committed Dec 13, 2023
1 parent 4616e3d commit c0f7f64
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 34 deletions.
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,9 +726,9 @@ Examples:
### Kubernetes secrets
Fetch values from Kubernetes secrets:
Fetch value from a Kubernetes secret:
- `ref+k8s://NAMESPACE/SECRET_NAME/KEY[?kubeConfigPath=<path_to_kubeconfig>&kubeContext=<kubernetes context name>]`
- `ref+k8s://API_VERSION/KIND/NAMESPACE/NAME/KEY[?kubeConfigPath=<path_to_kubeconfig>&kubeContext=<kubernetes context name>]`
Authentication to the Kubernetes cluster is done by referencing the local kubeconfig file.
The path to the kubeconfig can be specified as a URI parameter, read from the `KUBECONFIG` environment variable or the provider will attempt to read `$HOME/.kube/config`.
Expand All @@ -740,10 +740,12 @@ Environment variables:
Examples:
- `ref+k8s://mynamespace/mysecret/foo`
- `ref+k8s://mynamespace/mysecret/bar?kubeConfigPath=/home/user/kubeconfig`
- `secretref+k8s://mynamespace/secrets/baz`
- `secretref+k8s://mynamespace/secrets/baz?kubeContext=minikube`
- `ref+k8s://v1/Secret/mynamespace/mysecret/foo`
- `ref+k8s://v1/Secret/mynamespace/mysecret/bar?kubeConfigPath=/home/user/kubeconfig`
- `secretref+k8s://v1/Secret/mynamespace/secrets/baz`
- `secretref+k8s://v1/Secret/mynamespace/secrets/baz?kubeContext=minikube`
> NOTE: This provider only supports kind "Secret" in apiVersion "v1" at this time.
## Advanced Usages
Expand Down
50 changes: 31 additions & 19 deletions pkg/providers/k8s/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func New(l *log.Logger, cfg api.StaticConfig) (*provider, error) {

kubeConfig, err := getKubeConfig(cfg)
if err != nil {
fmt.Printf("Unable to get a valid Kubeconfig path: %s\n", err)
p.log.Debugf("Unable to get a valid Kubeconfig path: %s\n", err)
return nil, err
}

Expand All @@ -42,14 +42,14 @@ func getKubeConfig(cfg api.StaticConfig) (string, error) {
// Use kubeConfigPath from URI parameters if specified
if cfg.String("kubeConfigPath") != "" {
if _, err := os.Stat(cfg.String("kubeConfigPath")); err != nil {
return cfg.String("kubeConfigPath"), fmt.Errorf("kubeConfigPath URI parameter is set but path %s does not exist.", cfg.String("kubeConfigPath"))
return "", fmt.Errorf("kubeConfigPath URI parameter is set but path %s does not exist.", cfg.String("kubeConfigPath"))
}
}

// Use path in KUBECONFIG environment variable if set
if envPath := os.Getenv("KUBECONFIG"); envPath != "" {
if _, err := os.Stat(envPath); err != nil {
return envPath, fmt.Errorf("KUBECONFIG environment variable is set but path %s does not exist.", envPath)
return "", fmt.Errorf("KUBECONFIG environment variable is set but path %s does not exist.", envPath)
}
}

Expand All @@ -71,32 +71,44 @@ func (p *provider) GetString(path string) (string, error) {
separator := "/"
splits := strings.Split(path, separator)

if len(splits) != 3 {
return "", fmt.Errorf("Invalid path %s. Path must be in the format <namespace>/<secret>/<key>", path)
if len(splits) != 5 {
return "", fmt.Errorf("Invalid path %s. Path must be in the format <apiVersion>/<kind>/<namespace>/<name>/<key>", path)
}

namespace := splits[0]
secretName := splits[1]
key := splits[2]
apiVersion := splits[0]
kind := splits[1]
namespace := splits[2]
name := splits[3]
key := splits[4]

secretData, err := getSecret(namespace, secretName, p.KubeConfigPath, p.KubeContext, context.Background())
if apiVersion != "v1" {
return "", fmt.Errorf("Invalid apiVersion %s. Only apiVersion v1 is supported at this time.", apiVersion)
}
if kind != "Secret" {
return "", fmt.Errorf("Invalid kind %s. Only kind Secret is supported at this time.", kind)
}

//TODO:
// At this time, only Secret kind with v1 apiVersion version is supported.
// getObject() should be extended to support both ConfigMap and Secrets kind in other apiVersions.
objectData, err := getObject(namespace, name, p.KubeConfigPath, p.KubeContext, context.Background())
if err != nil {
return "", fmt.Errorf("Unable to get secret %s/%s: %s", namespace, secretName, err)
return "", fmt.Errorf("Unable to get %s %s/%s: %s", kind, namespace, name, err)
}

secret, exists := secretData[key]
object, exists := objectData[key]
if !exists {
return "", fmt.Errorf("Key %s does not exist in %s/%s", key, namespace, secretName)
return "", fmt.Errorf("Key %s does not exist in %s/%s", key, namespace, name)
}

// Print success message with kubeContext if provided
message := fmt.Sprintf("vals-k8s: Retrieved secret %s/%s/%s", namespace, secretName, key)
message := fmt.Sprintf("vals-k8s: Retrieved %s: %s/%s/%s", kind, namespace, name, key)
if p.KubeContext != "" {
message += fmt.Sprintf(" (KubeContext: %s)", p.KubeContext)
}
p.log.Debugf(message)

return string(secret), nil
return string(object), nil
}

func (p *provider) GetStringMap(path string) (map[string]interface{}, error) {
Expand All @@ -121,8 +133,8 @@ func buildConfigWithContextFromFlags(context string, kubeconfigPath string) (*re
}).ClientConfig()
}

// Fetch the secret from the Kubernetes cluster
func getSecret(namespace string, secretName string, kubeConfigPath string, kubeContext string, ctx context.Context) (map[string][]byte, error) {
// Fetch the object from the Kubernetes cluster
func getObject(namespace string, name string, kubeConfigPath string, kubeContext string, ctx context.Context) (map[string][]byte, error) {
if kubeContext == "" {
fmt.Printf("vals-k8s: kubeContext was not provided. Using current context.\n")
}
Expand All @@ -138,10 +150,10 @@ func getSecret(namespace string, secretName string, kubeConfigPath string, kubeC
return nil, fmt.Errorf("Unable to create the Kubernetes client: %s", err)
}

secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
object, err := clientset.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("Unable to get the secret from Kubernetes: %s", err)
return nil, fmt.Errorf("Unable to get the object from Kubernetes: %s", err)
}

return secret.Data, nil
return object.Data, nil
}
209 changes: 209 additions & 0 deletions pkg/providers/k8s/k8s_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package k8s

import (
"context"
"fmt"
"os"
"testing"

"github.com/google/go-cmp/cmp"

"github.com/helmfile/vals/pkg/config"
"github.com/helmfile/vals/pkg/log"
)

// Setup:
// create a local Kubernetes cluster using minikube:
// minikube start
// create a namespace:
// kubectl create namespace test-namespace
// create a secret:
// kubectl create secret generic mysecret -n test-namespace --from-literal=key=p4ssw0rd

func Test_getObject(t *testing.T) {
homeDir, _ := os.UserHomeDir()
testcases := []struct {
namespace string
name string
kubeConfigPath string
want map[string][]uint8
wantErr string
}{
{
namespace: "test-namespace",
name: "mysecret",
kubeConfigPath: fmt.Sprintf("%s/.kube/config", homeDir),
want: map[string][]uint8{"key": []uint8("p4ssw0rd")},
wantErr: "",
},
// kubeConfigPath does not exist
{
namespace: "test-namespace",
name: "mysecret",
kubeConfigPath: "/tmp/does-not-exist",
want: nil,
wantErr: "Unable to build Kubeconfig from vals configuration: stat /tmp/does-not-exist: no such file or directory",
},
// namespace does not exist
{
namespace: "non-existent-namespace",
name: "mysecret",
kubeConfigPath: fmt.Sprintf("%s/.kube/config", homeDir),
want: nil,
wantErr: "Unable to get the object from Kubernetes: secrets \"mysecret\" not found",
},
// secret does not exist
{
namespace: "test-namespace",
name: "non-existent-secret",
kubeConfigPath: fmt.Sprintf("%s/.kube/config", homeDir),
want: nil,
wantErr: "Unable to get the object from Kubernetes: secrets \"non-existent-secret\" not found",
},
}

for i := range testcases {
tc := testcases[i]
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
got, err := getObject(tc.namespace, tc.name, tc.kubeConfigPath, "", context.Background())
if err != nil {
if err.Error() != tc.wantErr {
t.Fatalf("unexpected error: want %q, got %q", tc.wantErr, err.Error())
}
} else {
if tc.wantErr != "" {
t.Fatalf("expected error did not occur: want %q, got none", tc.wantErr)
}
}

if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("unexpected result: -(want), +(got)\n%s", diff)
}
})
}
}

func Test_getKubeConfig(t *testing.T) {
homeDir, _ := os.UserHomeDir()
testcases := []struct {
config config.MapConfig
want string
wantErr string
}{
{
config: config.MapConfig{
M: map[string]interface{}{
"kubeConfigPath": fmt.Sprintf("%s/.kube/config", homeDir),
},
},
want: fmt.Sprintf("%s/.kube/config", homeDir),
wantErr: "",
},
// kubeConfigPath does not exist
{
config: config.MapConfig{
M: map[string]interface{}{"kubeConfigPath": "/tmp/does-not-exist"},
},
want: "",
wantErr: "kubeConfigPath URI parameter is set but path /tmp/does-not-exist does not exist.",
},
}

for i := range testcases {
tc := testcases[i]
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
got, err := getKubeConfig(tc.config)
if err != nil {
if err.Error() != tc.wantErr {
t.Fatalf("unexpected error: want %q, got %q", tc.wantErr, err.Error())
}
} else {
if tc.wantErr != "" {
t.Fatalf("expected error did not occur: want %q, got none", tc.wantErr)
}
}

if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("unexpected result: -(want), +(got)\n%s", diff)
}
})
}

Check failure on line 131 in pkg/providers/k8s/k8s_test.go

View workflow job for this annotation

GitHub Actions / Lint

unnecessary trailing newline (whitespace)
}
func Test_getKubeContext(t *testing.T) {
testcases := []struct {
config config.MapConfig
want string
}{
{
config: config.MapConfig{
M: map[string]interface{}{
"kubeContext": "minikube",
},
},
want: "minikube",
},
// kubeContext is not specified, should return empty
{
config: config.MapConfig{
M: map[string]interface{}{"kubeConfigPath": ""},
},
want: "",
},
}

for i := range testcases {
tc := testcases[i]
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
got := getKubeContext(tc.config)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("unexpected result: -(want), +(got)\n%s", diff)
}
})
}
}

func Test_GetString(t *testing.T) {
logger := log.New(log.Config{Output: os.Stderr})
tests := []struct {
path string
want string
wantErr string
}{
{
path: "v1/Secret/test-namespace/mysecret/key",
want: "p4ssw0rd",
wantErr: "",
},
{
path: "v1/Secret/test-namespace/mysecret/non-existent-key",
want: "",
wantErr: "Key non-existent-key does not exist in test-namespace/mysecret",
},
}
for _, tc := range tests {
t.Run(tc.path, func(t *testing.T) {
// Create provider with mock
homeDir, _ := os.UserHomeDir()
conf := map[string]interface{}{}
conf["kubeConfigPath"] = fmt.Sprintf("%s/.kube/config", homeDir)
conf["kubeContext"] = "minikube"
p, _ := New(logger, config.MapConfig{M: conf})

got, err := p.GetString(tc.path)
if err != nil {
if err.Error() != tc.wantErr {
t.Fatalf("unexpected error: want %q, got %q", tc.wantErr, err.Error())
}
} else {
if tc.wantErr != "" {
t.Fatalf("expected error did not occur: want %q, got none", tc.wantErr)
}
}

if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("unexpected result: -(want), +(got)\n%s", diff)
}
})
}
}
Loading

0 comments on commit c0f7f64

Please sign in to comment.