Skip to content

Commit

Permalink
Merge pull request #2 from spreadshirt/filesbased-secret-mounting
Browse files Browse the repository at this point in the history
Changed from env based to file based secret as source
  • Loading branch information
lynx-coding authored Nov 6, 2023
2 parents 3c68816 + 3fdd03c commit 3279bc3
Show file tree
Hide file tree
Showing 13 changed files with 230 additions and 86 deletions.
70 changes: 49 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,30 @@ We assume that applications configuration (files) are stored in [Configmaps](htt
All secrets needed by the application are also mounted to the init container, allowing the [templating](https://pkg.go.dev/text/template) mechanism to inject secrets into application configuration. The rendered templates are available to application containers using shared volumes between init containers and regular containers inside one Pod.

## Development
The example inside `examples/simple` can be used for development when operating locally.
The example inside `examples/simple` can be used for development when operating locally. Example docker command for local use is:
`docker run -v $(pwd)/tests/secrets:/var/run/secrets/spreadgroup.com/multi-secret/secrets -v $(pwd)/examples/simple/templates:/var/run/secrets/spreadgroup.com/multi-secret/templates -v $(pwd)/examples/rendered:/var/run/secrets/spreadgroup.com/multi-secret/rendered docker.io/library/k8s-multi-secret-to-file:local`
Make sure the directory mounted to `/var/run/secrets/spreadgroup.com/multi-secret/rendered` already exists.
### CLI parameters
| Parameter | Default value | Description |
|-------------------------|:-------------------------------------------------------:|--------------------------------------------------------------------------------------------------------------------------------------|
| continue-on-missing-key | false | Templating of configfiles fails if a (secret) key is missing. This flag allows to continue on missing keys. |
| left-delimiter | {{ | Left delimiter for internal templating. Change if this delimiter conflicts with your config format. |
| right-delimiter | }} | Right delimiter for internal templating. Change if this delimiter conflicts with your config format. |
| secret-path | /var/run/secrets/spreadgroup.com/multi-secret/secrets | Path were secrets are read from. Can be changed for local development or testing. Should not matter when using the container. |
| target-base-dir | /var/run/secrets/spreadgroup.com/multi-secret/rendered | Path were rendered files are stored. Can be changed for local development or testing. Should not matter when using the container. |
| template-base-dir | /var/run/secrets/spreadgroup.com/multi-secret/templates | Path were template files are read from. Can be changed for local development or testing. Should not matter when using the container. |

## Setup
A working example can be found in `examples/k8s`. The files inside manifests directory can be deployed to a Kubernetes cluster.

1. wrap a configfile inside a configmap, and use `{{ .secretKey }}` as placeholder
1. wrap a configfile inside a configmap, and use `{{ index .Secrets "<secret name>" "<secret key>" }}` as placeholder. (using the `index` function here to allow special chars in secret names and keys)

```yaml
...
data:
secret-config: |-
key1={{ .secret1 }}
key2={{ .secret2 }}
key1={{ index .Secrets "apache-demo" "secret1" }}
key2={{ index .Secrets "apache-demo" "secret2" }}
...
```

Expand All @@ -46,21 +57,24 @@ A working example can be found in `examples/k8s`. The files inside manifests dir
...
```

4. provide secrets as environment variables to the init container. Envs must be prefixed (default: `SECRET_`)

4. provide secrets as files to the init container. `/var/run/secrets/spreadgroup.com/multi-secret/secrets` is the default secret path inside the init-container. For each Secret, configure the volumes:
```yaml
...
volumes:
- name: apache-demo
secret:
defaultMode: 420
secretName: apache-demo
...
```

and the volumeMounts:
```yaml
...
- env:
- name: SECRET_secret1
valueFrom:
secretKeyRef:
name: apache-demo
key: secret1
- name: SECRET_secret2
valueFrom:
secretKeyRef:
name: apache-demo
key: secret2
volumeMounts:
- mountPath: /var/run/secrets/spreadgroup.com/multi-secret/secrets/apache-demo
name: apache-demo
readOnly: true
...
```

Expand All @@ -82,15 +96,15 @@ A working example can be found in `examples/k8s`. The files inside manifests dir
```yaml
...
volumeMounts:
- mountPath: /etc/rendered
- mountPath: /var/run/secrets/spreadgroup.com/multi-secret/rendered
name: init-share
- mountPath: /etc/templates/path/to/secret/config
- mountPath: /var/run/secrets/spreadgroup.com/multi-secret/templates/path/to/secret/config
name: configmap
subPath: secret-config
...
```

`/etc/templates` and `/etc/rendered` are the default paths for templates and the results, this can be configured, if necessary
`/var/run/secrets/spreadgroup.com/multi-secret/templates` and `/var/run/secrets/spreadgroup.com/multi-secret/rendered` are the default paths for templates and the results, this can be configured, if necessary

7. mount the rendered config file to the application container

Expand All @@ -109,4 +123,18 @@ A working example can be found in `examples/k8s`. The files inside manifests dir
$ kubectl exec apache-demo-8479b98dd4-82jnn -c apache -- cat /path/to/secret/config
key1=valueFromSecret
key2=2ndValueFromSecret
```
```

## Advanced features
### getValueByFirstMatchingKey
This extension allows you to use dynamic keys in your secrets, which could happen if you use an external Secret operator for example.

When is this useful? E.g. if you use keys inside Secrets to represent some kind of hierarchical configuration:
```yaml
value: my-value
value_aws: aws-specific-value
value_azure: azure-specific-value
```

Use the following line to access it: `value={{ getValueByFirstMatchingKey (index .Secrets "secretValues") "value_aws" "value" }}`
This will return the value for the first matching key in `.Secrets.secretValues`, `aws-specific-value` but would fall back to `my-value` in case it does not find a value for key `value_aws`.
4 changes: 2 additions & 2 deletions examples/k8s/configmap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ metadata:
name: apache-demo-cfg
data:
secret-config: |-
key1={{ .secret1 }}
key2={{ .secret2 }}
key1={{ getValueByFirstMatchingKey (index .Secrets "apache-demo") "secret1" }}
key2={{ index .Secrets "apache-demo" "secret2" }}
26 changes: 11 additions & 15 deletions examples/k8s/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,18 @@ spec:
app: apache-demo
spec:
initContainers:
- env:
- name: SECRET_secret1
valueFrom:
secretKeyRef:
name: apache-demo
key: secret1
- name: SECRET_secret2
valueFrom:
secretKeyRef:
name: apache-demo
key: secret2
image: ghcr.io/spreadshirt/k8s-multi-secret-to-file:latest
- image: ghcr.io/spreadshirt/k8s-multi-secret-to-file:latest
imagePullPolicy: Always
name: secret-init
volumeMounts:
- mountPath: /etc/rendered
- mountPath: /var/run/secrets/spreadgroup.com/multi-secret/rendered
name: init-share
- mountPath: /etc/templates/path/to/secret/config
- mountPath: /var/run/secrets/spreadgroup.com/multi-secret/templates/path/to/secret/config
name: configmap
subPath: secret-config
- mountPath: /var/run/secrets/spreadgroup.com/multi-secret/secrets/apache-demo
name: apache-demo
readOnly: true
containers:
- name: apache
image: php:8-apache
Expand All @@ -48,4 +40,8 @@ spec:
name: init-share
- configMap:
name: apache-demo-cfg
name: configmap
name: configmap
- name: apache-demo
secret:
defaultMode: 420
secretName: apache-demo
2 changes: 2 additions & 0 deletions examples/rendered/etc/config
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test1=testvalue1
test2=testvalue2
4 changes: 2 additions & 2 deletions examples/simple/templates/etc/config
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
test1={{ .TEST1 }}
test2={{ .TEST2 }}
test1={{ getValueByFirstMatchingKey (index .Secrets "example") "notExistingKey" "TEST1" }}
test2={{ .Secrets.example.TEST2 }}
88 changes: 64 additions & 24 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,29 @@ import (
)

const (
LeftLimiter = "{{"
RightLimiter = "}}"
SecretPrefix = "SECRET_"
TargetBasePath = "/etc/rendered"
TemplateBasePath = "/etc/templates"
LeftDelimiter = "{{"
RightDelimiter = "}}"
SecretPath = "/var/run/secrets/spreadgroup.com/multi-secret/secrets"
TargetBasePath = "/var/run/secrets/spreadgroup.com/multi-secret/rendered"
TemplateBasePath = "/var/run/secrets/spreadgroup.com/multi-secret/templates"
)

func main() {

// definition of cli interface
continueOnMissingKey := flag.Bool("continue-on-missing-key", false, "enable to not stop when hitting missing keys during templating")
leftLimiter := flag.String("left-limiter", LeftLimiter, "left limiter for internal go templating")
rightLimiter := flag.String("right-limiter", RightLimiter, "right limiter for internal go templating")
secretEnvPrefix := flag.String("secret-env-prefix", SecretPrefix, "prefix for the environment variables containing secrets")
leftDelimiter := flag.String("left-delimiter", LeftDelimiter, "left delimiter for internal go templating")
rightDelimiter := flag.String("right-delimiter", RightDelimiter, "right delimiter for internal go templating")
secretPath := flag.String("secret-path", SecretPath, "absolute path to directory where secrets are mounted")
targetBasePath := flag.String("target-base-dir", TargetBasePath, "absolute path to directory containing rendered template files")
templateBasePath := flag.String("template-base-dir", TemplateBasePath, "absolute path to directory containing template files")
flag.Parse()

// retrieve secrets
secrets := getSecretsFromEnv(*secretEnvPrefix)
secrets, err := getSecretsFromFiles(*secretPath)
if err != nil {
log.Panicf("failed to get secrets from files: %s", err)
}

// detect templates
templatePaths, err := getAllTemplateFilePaths(*templateBasePath)
Expand All @@ -41,19 +44,22 @@ func main() {
}

// parse every template file separately
err = parseTemplates(templatePaths, *leftLimiter, *rightLimiter, *continueOnMissingKey, *targetBasePath, *templateBasePath, secrets)
err = renderSecretsIntoTemplates(templatePaths, *leftDelimiter, *rightDelimiter, *continueOnMissingKey, *targetBasePath, *templateBasePath, secrets)
if err != nil {
log.Panicf("failed to parse template: %s", err)
}
}

func parseTemplates(templatePaths []string, leftLimiter string, rightLimiter string, continueOnMissingKey bool, targetBasePath string, templateBasePath string, secrets map[string]string) error {
func renderSecretsIntoTemplates(templatePaths []string, leftDelimiter string, rightDelimiter string, continueOnMissingKey bool, targetBasePath string, templateBasePath string, secrets map[string]map[string]string) error {
funcMap := template.FuncMap{
"getValueByFirstMatchingKey": getValueByFirstMatchingKey,
}
for _, templatePath := range templatePaths {
t, err := template.ParseFiles(templatePath)
t, err := template.New(path.Base(templatePath)).Funcs(funcMap).ParseFiles(templatePath)
if err != nil {
return fmt.Errorf("failed to parse template files(%q): %w", templatePath, err)
}
t.Delims(leftLimiter, rightLimiter)
t.Delims(leftDelimiter, rightDelimiter)
if !continueOnMissingKey {
t.Option("missingkey=error")
}
Expand All @@ -64,8 +70,15 @@ func parseTemplates(templatePaths []string, leftLimiter string, rightLimiter str
if err != nil {
return fmt.Errorf("failed to create target dir for %q: %w", templatePath, err)
}
targetFile, _ := os.Create(targetPath)
err = t.Execute(targetFile, secrets)
targetFile, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("failed to create target file at %q: %w", targetPath, err)
}
err = t.Funcs(funcMap).Execute(targetFile, struct {
Secrets map[string]map[string]string
}{
Secrets: secrets,
})
if err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
Expand All @@ -87,17 +100,34 @@ func getAllTemplateFilePaths(templateWalkDir string) (templateFilePaths []string
return templateFilePaths, err
}

func getSecretsFromEnv(prefix string) map[string]string {
var secrets = make(map[string]string)
for _, envVar := range os.Environ() {
if strings.HasPrefix(envVar, prefix) {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) == 2 {
secrets[strings.TrimPrefix(parts[0], prefix)] = parts[1]
}
func getSecretsFromFiles(secretsPath string) (map[string]map[string]string, error) {
secrets := make(map[string]map[string]string)
err := filepath.WalkDir(secretsPath, func(filePath string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasPrefix(d.Name(), ".") || d.IsDir() {
return nil
}

secret, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read secret from file %q: %w", filePath, err)
}
keyName := path.Base(filePath)
secretName := path.Base(path.Dir(filePath))
_, ok := secrets[secretName]
if !ok {
secrets[secretName] = make(map[string]string)
}
secrets[secretName][keyName] = string(secret)

return nil
})
if err != nil {
return secrets, fmt.Errorf("failed to get secrets from files: %w", err)
}
return secrets
return secrets, nil
}

func isDirectory(path string) bool {
Expand All @@ -119,3 +149,13 @@ func mkDirIfNotExists(path string) error {

return nil
}

func getValueByFirstMatchingKey(stringMap map[string]string, keys ...string) (string, error) {
for _, key := range keys {
val, ok := stringMap[key]
if ok {
return val, nil
}
}
return "", fmt.Errorf("no matching key found in secret")
}
Loading

0 comments on commit 3279bc3

Please sign in to comment.