Repository: kubeless/kubeless
Pull request #1169 (2020-09-08 07:14:10)

Add secrets to runtime configuration
**Issue Ref**: None
 
**Description**: 

Adds support for using secrets via mounted volumes.

Extends the `runtime-images` structure in `kubeless-config` configmap to allow using secrets in the init containers of function deployments, in a similar way as providing environment variables.

**Usage:**

Manifest for `github-token` secret.
```yaml
apiVersion: v1
kind: Secret
metadata:
  name: github-token
type: Opaque
stringData:
  token: foobar

```

Manifest for `kubeless-config` configmap
```yaml
...
data:
  runtime-images: |
    [
        {
            "ID": "go",
            "depName": "go.mod",
            "fileNameSuffix": ".go",
            "versions": [
                {
                    "imagePullSecrets": [{"ImageSecret": "docker-registry"}],
                    "images": [
                        {
                            "command": "docker-entrypoint",
                            "env": {
                                "GITHUB_TOKEN_SECRET_FILE": "/var/run/secrets/kubeless.io/github-token/token",
                                "GOCACHE": "$(KUBELESS_INSTALL_VOLUME)/.cache",
                                "GOPRIVATE": "github.com/acme/*"
                            },
                            "image": "quay.io/acme/kubeless-runtime:go-1.14",
                            "phase": "compilation",
                            "secrets": [{"name": "github-token"}]
                        },
                        {
                            "image": "kubeless/go@sha256:55759228714d7080b3dd858e56530d4e1f539d071906e88d88b454ee3b3c9b16",
                            "phase": "runtime"
                        }
                    ],
                    "name": "go1.14",
                    "version": "1.14"
                }
            ]
        }
    ]
...
```

The above runtime configuration specified custom image for Go 1.14 runtime with a secret `github-token` shared with the container as a volume mounted at `/var/run/secrets/kubeless.io/{{secret.name}}` (`/var/run/secrets/kubeless.io/github-token` in this example). Now the container can read the GitHub token secret from the filesystem and use it to download private Go packages.

**TODOs**:
 - [x] Ready to review
 - [x] Automated Tests
 - [x] Docs



Patch:
diff --git a/docs/function-controller-configuration.md b/docs/function-controller-configuration.md
index d2d536d2..94d8526d 100644
--- a/docs/function-controller-configuration.md
+++ b/docs/function-controller-configuration.md
@@ -227,14 +227,16 @@ The same process should be followed for any trigger controller installed (Kafka,
 
 ## Using custom images
 
-It is possible to configure the different images that Kubeless uses for deploy and execute functions. In this ConfigMap you can configure:
+It is possible to configure the different images that Kubeless uses to deploy and execute functions. In this ConfigMap you can configure:
 
- - Different or additional runtimes. For doing so it is possible to modify/add a runtime in the field `runtimeImages`. Runtimes are categorized by major version. See the guide for [implementing a new runtime](/docs/implementing-new-runtime) for more information. Each major version has:
+ - Different or additional runtimes. For doing so it is possible to modify/add a runtime in the field `runtime-images`. Runtimes are categorized by major version. See the guide for [implementing a new runtime](/docs/implementing-new-runtime) for more information. Each major version has:
   - Name: Unique ID of the runtime. It should contain the runtime name and version.
   - Version: Major and minor version of the runtime.
   - Runtime Image: Image used to execute the function.
   - Init Image: Image used for installing the function and/or dependencies.
   - (Optional) Image Pull Secrets: Secret required to pull the image in case the repository is private.
+  - (Optional) Environment variables.
+  - (Optional) Secrets: Shared with the container as volumes mounted at `/var/run/secrets/kubeless.io/`.
  - The image used to populate the base image with the function. This is called `provision-image`. This image should have at least `unzip`, `GNU tar`, `gzip`, `bzip2`, `xz` and `curl`. It is also possible to specify `provision-image-secret` to specify a secret to pull that image from a private registry.
  - The image used to build function images. This is called `builder-image`. This image is optional since its usage can be disabled with the property `enable-build-step`. A Dockerfile to build this image can be found [here](https://github.com/kubeless/kubeless/tree/master/docker/function-image-builder). It is also possible to specify `builder-image-secret` to specify a secret to pull that image from a private registry.
  
diff --git a/pkg/langruntime/langruntime.go b/pkg/langruntime/langruntime.go
index fc8039cb..ea624003 100644
--- a/pkg/langruntime/langruntime.go
+++ b/pkg/langruntime/langruntime.go
@@ -7,10 +7,10 @@ import (
 	"regexp"
 	"strings"
 
-	yaml "github.com/ghodss/yaml"
+	"github.com/ghodss/yaml"
 	"github.com/sirupsen/logrus"
 	appsv1 "k8s.io/api/apps/v1"
-	v1 "k8s.io/api/core/v1"
+	"k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/util/intstr"
 )
 
@@ -35,6 +35,12 @@ type Image struct {
 	Image   string            `yaml:"image"`
 	Command string            `yaml:"command,omitempty"`
 	Env     map[string]string `yaml:"env,omitempty"`
+	Secrets []Secret          `yaml:"secrets,omitempty"`
+}
+
+// Secret is a reference to a secret.
+type Secret struct {
+	Name string `yaml:"name,omitempty"`
 }
 
 // RuntimeVersion is a struct with all the info about the images and secrets
@@ -231,6 +237,33 @@ func (l *Langruntimes) GetImageSecrets(runtime string) ([]v1.LocalObjectReferenc
 	return lors, nil
 }
 
+// GetInitContainerSecrets gets the secrets of the init container with name
+func (l *Langruntimes) GetInitContainerSecrets(runtime, name string) ([]v1.LocalObjectReference, error) {
+	runtimeInf, err := l.findRuntimeVersion(runtime)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(runtimeInf.Images) == 0 {
+		return nil, nil
+	}
+
+	var secrets []Secret
+	phase := name2phase(name)
+	for _, i := range runtimeInf.Images {
+		if i.Phase == phase {
+			secrets = append(secrets, i.Secrets...)
+			break
+		}
+	}
+	var refs []v1.LocalObjectReference
+	for _, s := range secrets {
+		refs = append(refs, v1.LocalObjectReference{Name: s.Name})
+	}
+
+	return refs, nil
+}
+
 func appendToCommand(orig string, command ...string) string {
 	if len(orig) > 0 {
 		return fmt.Sprintf("%s && %s", orig, strings.Join(command, " && "))
@@ -347,3 +380,14 @@ func (l *Langruntimes) GetCompilationContainer(runtime, funcName string, env []v
 		Resources:       resources,
 	}, nil
 }
+
+// name2phase returns the phase of an init container
+func name2phase(name string) string {
+	switch name {
+	case "compile":
+		return PhaseCompilation
+	case "install":
+		return PhaseInstallation
+	}
+	return name
+}
diff --git a/pkg/utils/k8sutil.go b/pkg/utils/k8sutil.go
index 06db12c0..f9214d00 100644
--- a/pkg/utils/k8sutil.go
+++ b/pkg/utils/k8sutil.go
@@ -163,7 +163,7 @@ func GetKubelessClientOutCluster() (versioned.Interface, error) {
 	return kubelessClient, nil
 }
 
-//GetDefaultNamespace returns the namespace set in current cluster context
+// GetDefaultNamespace returns the namespace set in current cluster context
 func GetDefaultNamespace() string {
 	rules := clientcmd.NewDefaultClientConfigLoadingRules()
 	rules.DefaultClientConfig = &clientcmd.DefaultClientConfig
diff --git a/pkg/utils/kubelessutil.go b/pkg/utils/kubelessutil.go
index 903712a3..b049fa22 100644
--- a/pkg/utils/kubelessutil.go
+++ b/pkg/utils/kubelessutil.go
@@ -21,6 +21,7 @@ import (
 	"encoding/base64"
 	"encoding/hex"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -28,6 +29,7 @@ import (
 	"net/url"
 	"os"
 	"path"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"unicode/utf8"
@@ -49,6 +51,9 @@ import (
 	"k8s.io/client-go/rest"
 )
 
+// secretsMountPath is the file system path where volumes populated with secrets are mounted.
+const secretsMountPath = "/var/run/secrets/kubeless.io"
+
 // GetFunctionPort returns the port for a function service
 func GetFunctionPort(clientset kubernetes.Interface, namespace, functionName string) (string, error) {
 	svc, err := clientset.CoreV1().Services(namespace).Get(functionName, metav1.GetOptions{})
@@ -384,7 +389,7 @@ func populatePodSpec(funcObj *kubelessApi.Function, lr *langruntime.Langruntimes
 		result.InitContainers = []v1.Container{provisionContainer}
 	}
 
-	// Add the imagesecrets if present to pull images from private docker registry
+	// add the image secrets if present to pull images from private docker registry
 	if funcObj.Spec.Runtime != "" {
 		imageSecrets, err := lr.GetImageSecrets(funcObj.Spec.Runtime)
 		if err != nil {
@@ -430,13 +435,51 @@ func populatePodSpec(funcObj *kubelessApi.Function, lr *langruntime.Langruntimes
 			*compContainer,
 		)
 	}
+
+	// mount volumes with init container secrets specified in runtime configuration
+	lr.ReadConfigMap()
+	for i := 0; i < len(result.InitContainers); i++ {
+		secrets, err := lr.GetInitContainerSecrets(funcObj.Spec.Runtime, result.InitContainers[i].Name)
+		if err != nil {
+			return fmt.Errorf("Unable to fetch init container secrets for runtime %s at phase %s: %v", funcObj.Spec.Runtime, result.InitContainers[i].Name, err)
+		}
+		for _, secret := range secrets {
+			// add volume if not available in the pod spec already
+			var found bool
+			for _, vol := range result.Volumes {
+				if vol.Name == secret.Name && (vol.Secret == nil || vol.Secret.SecretName != secret.Name) {
+					return fmt.Errorf("Unable to add volume for secret %s, volume already defined %#v", secret.Name, vol)
+				}
+				if vol.Name == secret.Name && vol.Secret != nil && vol.Secret.SecretName == secret.Name {
+					found = true
+					break
+				}
+			}
+			if !found {
+				result.Volumes = append(result.Volumes, v1.Volume{
+					Name: secret.Name,
+					VolumeSource: v1.VolumeSource{
+						Secret: &v1.SecretVolumeSource{SecretName: secret.Name},
+					},
+				})
+			}
+
+			// add volume mount to the init container
+			result.InitContainers[i].VolumeMounts = append(result.InitContainers[i].VolumeMounts, v1.VolumeMount{
+				Name:      secret.Name,
+				ReadOnly:  true,
+				MountPath: filepath.Join(secretsMountPath, secret.Name),
+			})
+		}
+	}
+
 	return nil
 }
 
 // EnsureFuncImage creates a Job to build a function image
 func EnsureFuncImage(client kubernetes.Interface, funcObj *kubelessApi.Function, lr *langruntime.Langruntimes, or []metav1.OwnerReference, imageName, tag, builderImage, registryHost, dockerSecretName, provisionImage string, registryTLSEnabled bool, imagePullSecrets []v1.LocalObjectReference) error {
 	if len(tag) < 64 {
-		return fmt.Errorf("Expecting sha256 as image tag")
+		return errors.New("Expecting sha256 as image tag")
 	}
 	jobName := fmt.Sprintf("build-%s-%s", funcObj.ObjectMeta.Name, tag[0:10])
 	_, err := client.BatchV1().Jobs(funcObj.ObjectMeta.Namespace).Get(jobName, metav1.GetOptions{})
@@ -578,7 +621,7 @@ func EnsureFuncDeployment(client kubernetes.Interface, funcObj *kubelessApi.Func
 	}
 	maxUnavailable := intstr.FromInt(0)
 
-	//add deployment and copy all func's Spec.Deployment to the deployment
+	// add deployment and copy all func's Spec.Deployment to the deployment
 	dpm := funcObj.Spec.Deployment.DeepCopy()
 	dpm.OwnerReferences = or
 	dpm.ObjectMeta.Name = funcObj.ObjectMeta.Name
@@ -592,7 +635,7 @@ func EnsureFuncDeployment(client kubernetes.Interface, funcObj *kubelessApi.Func
 		},
 	}
 
-	//append data to dpm deployment
+	// append data to dpm deployment
 	dpm.Labels = addDefaultLabel(mergeMap(dpm.Labels, funcObj.Labels))
 	dpm.Spec.Template.Labels = mergeMap(dpm.Spec.Template.Labels, funcObj.Labels)
 	dpm.Annotations = mergeMap(dpm.Annotations, funcObj.Annotations)
@@ -609,7 +652,7 @@ func EnsureFuncDeployment(client kubernetes.Interface, funcObj *kubelessApi.Func
 		if err != nil {
 			return err
 		}
-		//only resolve the image name and build the function if it has not been built already
+		// only resolve the image name and build the function if it has not been built already
 		if dpm.Spec.Template.Spec.Containers[0].Image == "" && prebuiltRuntimeImage == "" {
 			err := populatePodSpec(funcObj, lr, &dpm.Spec.Template.Spec, runtimeVolumeMount, provisionImage, imagePullSecrets)
 			if err != nil {


Test patch:
diff --git a/pkg/langruntime/langruntimetestutils.go b/pkg/langruntime/langruntimetestutils.go
index ff0d104a..d5fa0de6 100644
--- a/pkg/langruntime/langruntimetestutils.go
+++ b/pkg/langruntime/langruntimetestutils.go
@@ -12,45 +12,40 @@ import (
 func AddFakeConfig(clientset *fake.Clientset) {
 
 	runtimeImages := `[
-		{
-      "ID": "python",
-      "compiled": false,
-      "depName": "requirements.txt",
-			"fileNameSuffix": ".py",
-      "livenessProbeInfo": {
-        "exec": {
-          "command": [
-            "curl",
-            "-f",
-            "http://localhost:8080/healthz"
-          ]
-        },
-        "initialDelaySeconds": 5,
-        "periodseconds": 10
+  {
+    "ID": "python",
+    "compiled": false,
+    "depName": "requirements.txt",
+    "fileNameSuffix": ".py",
+    "livenessProbeInfo": {
+      "exec": {
+        "command": ["curl", "-f", "http://localhost:8080/healthz"]
       },
-      "versions": [
-         {
-            "images": [
-               {
-                  "command": "foo",
-                  "image": "python:2.7",
-                  "phase": "installation"
-               },
-               {
-                  "image": "bar",
-									"phase": "runtime",
-									"env": {
-										"PYTHONPATH": "/kubeless/lib/python2.7/site-packages:/kubeless"
-									}
-               }
-            ],
-            "name": "python27",
-						"version": "2.7",
-						"imagePullSecrets": [{"ImageSecret": "p1"}, {"ImageSecret": "p2"}]
-        },
-	  	]
-		}
-	]`
+      "initialDelaySeconds": 5,
+      "periodseconds": 10
+    },
+    "versions": [
+      {
+        "images": [
+          {
+            "command": "foo",
+            "image": "python:2.7",
+            "phase": "installation",
+            "secrets": [{"name": "my-secret"}]            
+          },
+          {
+            "image": "bar",
+            "phase": "runtime",
+            "env": {"PYTHONPATH": "/kubeless/lib/python2.7/site-packages:/kubeless"}
+          }
+        ],
+        "name": "python27",
+        "version": "2.7",
+        "imagePullSecrets": [{"ImageSecret": "p1"}, {"ImageSecret": "p2"}]
+      }
+    ]
+  }
+]`
 	cm := v1.ConfigMap{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      "kubeless-config",
diff --git a/pkg/utils/kubelessutil_test.go b/pkg/utils/kubelessutil_test.go
index 5cfb1eb9..c25b4aac 100644
--- a/pkg/utils/kubelessutil_test.go
+++ b/pkg/utils/kubelessutil_test.go
@@ -448,6 +448,26 @@ func TestEnsureImage(t *testing.T) {
 	if reflect.DeepEqual(jobs.Items[0].Spec.Template.Spec.ImagePullSecrets, pullSecrets) {
 		t.Error("Missing ImagePullSecrets")
 	}
+
+	// ensure my-secret is mounted as /var/run/secrets/kubeless.io/my-secret to install container
+	var container v1.Container
+	for _, c := range jobs.Items[0].Spec.Template.Spec.InitContainers {
+		if c.Name == "install" {
+			container = c
+		}
+	}
+	if len(container.Name) == 0 {
+		t.Fatalf("Cannot find init container %q", "install")
+	}
+	var found bool
+	for _, v := range container.VolumeMounts {
+		if v.MountPath == "/var/run/secrets/kubeless.io/my-secret" {
+			found = true
+		}
+	}
+	if !found {
+		t.Fatalf("Cannot find volume mount /var/run/secrets/kubeless.io/my-secret")
+	}
 }
 
 func getDefaultFunc(name, ns string) *kubelessApi.Function {
@@ -683,6 +703,25 @@ func TestEnsureDeployment(t *testing.T) {
 		t.Errorf("Resources must be set for init container")
 	}
 
+	// ensure my-secret is mounted as /var/run/secrets/kubeless.io/my-secret to install container
+	var container v1.Container
+	for _, c := range dpm.Spec.Template.Spec.InitContainers {
+		if c.Name == "install" {
+			container = c
+		}
+	}
+	if len(container.Name) == 0 {
+		t.Fatalf("Cannot find init container %q", "install")
+	}
+	var found bool
+	for _, v := range container.VolumeMounts {
+		if v.MountPath == "/var/run/secrets/kubeless.io/my-secret" {
+			found = true
+		}
+	}
+	if !found {
+		t.Fatalf("Cannot find volume mount /var/run/secrets/kubeless.io/my-secret")
+	}
 }
 
 func TestEnsureDeploymentWithoutFuncNorHandler(t *testing.T) {