Skip to content

Commit

Permalink
Refactoring to add templates (#55)
Browse files Browse the repository at this point in the history
* Refactoring to add templates

* Update helm chart and version

* Clean up and update docs
  • Loading branch information
digiserg authored Jan 30, 2023
1 parent 8f7f606 commit 5fb3274
Show file tree
Hide file tree
Showing 11 changed files with 155 additions and 84 deletions.
55 changes: 29 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,35 @@ The default encoding is `text` but you can change it to `base64` per secret refe
You may also use GoLang templates to format a secret. You can inject as variables any of the keys referenced in the `data` section to format, for example, a configuration file.
The [sprig](https://github.com/Masterminds/sprig/blob/master/docs/index.md) functions are supported.

## Vault database credentials

---
> **_NOTE:_** Vault >= 1.10 is required for this feature to work
---

A great feature in HashiCorp Vault is the generate [database credentials](https://developer.hashicorp.com/vault/docs/secrets/databases) dynamically.
The missing part is you need these credentials in Kubernertes where your applications are. This is why we have added a new resource definition to do just that:

```yaml
apiVersion: digitalis.io/v1beta1
kind: DbSecret
metadata:
name: cassandra
spec:
renew: true # this is the default, otherwise a new credential will be generated every time
vault:
role: readonly
mount: cass000
template: # optional: change the secret format
CASSANDRA_USERNAME: "{{ .username }}"
CASSANDRA_PASSWORD: "{{ .password }}"
rollout: # optional: run a `rollout` to make the pods use new credentials
- kind: Deployment
name: cassandra-client
- kind: StatefulSet
name: cassandra-client-other
```
## Advance config: password rotation
If you're running a database you may want to keep the secrets in sync between your secrets store, Kubernetes and the database. This can be handy for password rotation to ensure the clients don't use the same password all the time. Please be aware your client *must* suppport re-reading the secret and reconnecting whenever it is updated.
Expand Down Expand Up @@ -193,32 +222,6 @@ spec:
- https://my-other-elastic:9200 # provide full URL instead
```
## Vault database credentials
---
> **_NOTE:_** Vault >= 1.10 is required for this feature to work
---
A great feature in HashiCorp Vault is the generate [database credentials](https://developer.hashicorp.com/vault/docs/secrets/databases) dynamically.
The missing part is you need these credentials in Kubernertes where your applications are. This is why we have added a new resource definition to do just that:
```yaml
apiVersion: digitalis.io/v1beta1
kind: DbSecret
metadata:
name: cassandra
spec:
renew: true # this is the default, otherwise a new credential will be generated every time
vault:
role: readonly
mount: cass000
rollout: # optional: run a `rollout` to make the pods use new credentials
- kind: Deployment
name: cassandra-client
- kind: StatefulSet
name: cassandra-client-other
```
## Options
The following options are available. See the [helm chart documentation](charts/vals-operator/README.md) for more information on adding them to your deployment configuration.
Expand Down
2 changes: 1 addition & 1 deletion apis/digitalis.io/v1beta1/dbsecret_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type DbSecretSpec struct {
// Name can override the secret name, defaults to manifests.name
SecretName string `json:"secretName,omitempty"`
Vault DbVaultConfig `json:"vault"`
Secret map[string]string `json:"secret,omitempty"`
Template map[string]string `json:"template,omitempty"`
Renew bool `json:"renew,omitempty"`
Rollout []DbRolloutTarget `json:"rollout,omitempty"`
}
Expand Down
4 changes: 2 additions & 2 deletions apis/digitalis.io/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions charts/vals-operator/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ kubeVersion: ">= 1.19.0-0"
type: application

# Chart version
version: 0.6.4
version: 0.7.0
# Latest container tag
appVersion: "0.6.4"
appVersion: "0.7.0"

maintainers:
- email: [email protected]
Expand Down
8 changes: 4 additions & 4 deletions charts/vals-operator/crds/dbsecrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ spec:
- name
type: object
type: array
secret:
additionalProperties:
type: string
type: object
secretName:
description: Name can override the secret name, defaults to manifests.name
type: string
template:
additionalProperties:
type: string
type: object
vault:
properties:
mount:
Expand Down
8 changes: 4 additions & 4 deletions config/crd/bases/digitalis.io_dbsecrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ spec:
- name
type: object
type: array
secret:
additionalProperties:
type: string
type: object
secretName:
description: Name can override the secret name, defaults to manifests.name
type: string
template:
additionalProperties:
type: string
type: object
vault:
properties:
mount:
Expand Down
1 change: 1 addition & 0 deletions controllers/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const (
lastUpdatedAnnotation = "vals-operator.digitalis.io/last-updated"
recordingEnabledAnnotation = "vals-operator.digitalis.io/record"
forceCreateAnnotation = "vals-operator.digitalis.io/force"
templateHash = "vals-operator.digitalis.io/hash"
managedByLabel = "app.kubernetes.io/managed-by"
k8sSecretPrefix = "ref+k8s://"
)
83 changes: 55 additions & 28 deletions controllers/dbsecret_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ limitations under the License.
package controllers

import (
"bytes"
"context"
"fmt"
"strconv"
"strings"
"sync"
"text/template"
"time"

sprig "github.com/Masterminds/sprig/v3"
"github.com/go-logr/logr"
v1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -159,15 +162,20 @@ func (r *DbSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
canRenew = false
}

oldSecretData := currentSecret.Data
newSecretData := dbSecret.Spec.Secret
if !utils.SecretStringByteMatch(newSecretData, oldSecretData) {
if r.recordingEnabled(&dbSecret) {
r.Recorder.Event(&dbSecret, corev1.EventTypeNormal, "Update", "DbSecret has changed, updating Kubernetes Secret")
/* If the new secret doesn't have a template anymore, make sure it's deleted from the secret */
if len(dbSecret.Spec.Template) == 0 {
for k, _ := range currentSecret.Data {
if k != "username" && k != "password" {
delete(currentSecret.Data, k)
}
}
}

newHash := utils.CreateFakeHash(dbSecret.Spec.Template)
if newHash != "" && currentSecret.Annotations[templateHash] != "" {
if newHash != currentSecret.Annotations[templateHash] {
shouldUpdate = true
}
r.Log.Info("DbSecret has changed, updating Kubernetes Secret", "name", dbSecret.Name, "namespace", dbSecret.Namespace)
shouldUpdate = true
canRenew = false
}

if !shouldUpdate {
Expand Down Expand Up @@ -300,27 +308,16 @@ func (r *DbSecretReconciler) upsertSecret(sDef *digitalisiov1beta1.DbSecret, cre
secret = &corev1.Secret{}
}

usernameKey := "username"
passwordKey := "password"
if sDef.Spec.Secret["username"] != "" {
usernameKey = sDef.Spec.Secret["username"]
}
if sDef.Spec.Secret["password"] != "" {
passwordKey = sDef.Spec.Secret["password"]
}
// if I use StringData I can avoid base64encoding the data
data := make(map[string]string)
data[usernameKey] = creds.Username
data[passwordKey] = creds.Password
dataStr := make(map[string]string)
dataStr["username"] = creds.Username
dataStr["password"] = creds.Password
data := r.renderTemplate(sDef, dataStr)

/* Any other values are literals to add to the secret */
for k, v := range sDef.Spec.Secret {
if k != "username" && k != "password" {
data[k] = v
}
if len(data) < 1 {
secret.StringData = dataStr
} else {
secret.Data = data
}
secret.StringData = data
secret.Data = nil

secret.Name = secretName
secret.Namespace = sDef.Namespace
Expand All @@ -343,7 +340,8 @@ func (r *DbSecretReconciler) upsertSecret(sDef *digitalisiov1beta1.DbSecret, cre
secret.ObjectMeta.Annotations[leaseDurationLabel] = fmt.Sprintf("%d", creds.LeaseDuration)
secret.ObjectMeta.Annotations[lastUpdatedAnnotation] = time.Now().UTC().Format(timeLayout)
secret.ObjectMeta.Annotations[expiresOnLabel] = fmt.Sprintf("%d", time.Now().Unix()+int64(creds.LeaseDuration))

/* Hash to check for changes later on */
secret.ObjectMeta.Annotations[templateHash] = utils.CreateFakeHash(sDef.Spec.Template)
delete(secret.ObjectMeta.Annotations, forceCreateAnnotation)

if err = controllerutil.SetControllerReference(sDef, secret, r.Scheme); err != nil {
Expand Down Expand Up @@ -503,3 +501,32 @@ func (r *DbSecretReconciler) getSecretName(sDef *digitalisiov1beta1.DbSecret) st
}
return secretName
}

func (r *DbSecretReconciler) renderTemplate(sDef *digitalisiov1beta1.DbSecret, dataStr map[string]string) map[string][]byte {
data := make(map[string][]byte)

/* Render any template given */
for k, v := range sDef.Spec.Template {
b := bytes.NewBuffer(nil)
t, err := template.New(k).Funcs(sprig.FuncMap()).Parse(v)
if err != nil {
r.Log.Error(err, "Cannot parse template")
if r.recordingEnabled(sDef) {
msg := fmt.Sprintf("Template could not be parsed: %v", err)
r.Recorder.Event(sDef, corev1.EventTypeNormal, "Failed", msg)
}
return data
}
if err := t.Execute(b, &dataStr); err != nil {
r.Log.Error(err, "Cannot render template")
if r.recordingEnabled(sDef) {
msg := fmt.Sprintf("Template could not be rendered: %v", err)
r.Recorder.Event(sDef, corev1.EventTypeNormal, "Failed", msg)
}
return data
}

data[k] = b.Bytes()
}
return data
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ require (
sigs.k8s.io/controller-runtime v0.14.1
)

require github.com/Masterminds/sprig v2.22.0+incompatible

require (
cloud.google.com/go v0.107.0 // indirect
cloud.google.com/go/compute v1.14.0 // indirect
Expand All @@ -51,6 +53,7 @@ require (
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v0.7.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.2.0 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 // indirect
github.com/a8m/envsubst v1.3.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,12 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
Expand Down
67 changes: 50 additions & 17 deletions utils/utils.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
package utils

import (
"bytes"
"crypto/md5"
"encoding/hex"
"fmt"
"regexp"
"sort"
"text/template"

"github.com/Masterminds/sprig"
)

// StringMapsMatch returns true if the provided maps contain the same keys and values, otherwise false
Expand Down Expand Up @@ -59,23 +67,6 @@ func ByteMapsMatch(m1, m2 map[string][]byte) bool {
return true
}

// SecretStringByteMatch returns true if map[string]string and map[string][]byte have the same contents
func SecretStringByteMatch(s map[string]string, b map[string][]byte) bool {
passwordKey := s["password"]
usernameKey := s["username"]
if len(s) != len(b) {
return false
}
for key, value1 := range s {
if key != "username" && key != "password" && key != usernameKey && key != passwordKey {
if value2, ok := b[key]; !ok || string(value2) != value1 {
return false
}
}
}
return true
}

func MergeMap(dst map[string]string, src map[string]string) {
for k, v := range src {
dst[k] = v
Expand Down Expand Up @@ -130,3 +121,45 @@ func K8sSecretFound(m map[string]string) bool {
}
return true
}

func SecretHashString(m map[string]string) string {
var str string
for _, k := range SortedKeysMapString(m) {
str = fmt.Sprintf("%s%s%s", str, k, m[k])
}
hasher := md5.New()
hasher.Write([]byte(str))
return hex.EncodeToString(hasher.Sum(nil))
}

func CreateFakeHash(m map[string]string) string {
data := make(map[string]string)
dataStr := make(map[string]string)
dataStr["username"] = "fake"
dataStr["password"] = "fake"

/* Render any template given with fake username and password */
for k, v := range m {
b := bytes.NewBuffer(nil)
t, err := template.New(k).Funcs(sprig.FuncMap()).Parse(v)
if err != nil {
return ""
}
if err := t.Execute(b, &dataStr); err != nil {
return ""
}

data[k] = string(b.Bytes())
}

return SecretHashString(data)
}

func SortedKeysMapString(m map[string]string) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

0 comments on commit 5fb3274

Please sign in to comment.