appconfigmgrv2/api/webhooks/builtins/pod_webhook.go (502 lines of code) (raw):
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Copyright 2019 Google LLC. This software is provided as-is,
// without warranty or representation for any use or purpose.
//
package builtins
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
appconfig "github.com/GoogleCloudPlatform/anthos-appconfig/appconfigmgrv2/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
k8sapierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)
const (
VAULT_CONFIGMAP_NAME = "vault"
VAULT_CA_SECRET_NAME = "vault-ca"
TODO_FIND_NAMESPACE = "appconfigmgrv2-system"
)
var (
log = ctrl.Log.WithName("webhooks-builtins-pod")
localMgr ctrl.Manager
)
func SetupWebHook(mgr ctrl.Manager) {
// Setup webhooks
// entryLog.Info("setting up webhook server")
hookServer := mgr.GetWebhookServer()
localMgr = mgr
// entryLog.Info("registering webhooks to the webhook server")
hookServer.Register("/mutate-v1-pod", &webhook.Admission{Handler: &podAnnotator{}})
hookServer.Register("/validate-v1-pod", &webhook.Admission{Handler: &podValidator{}})
}
// +kubebuilder:webhook:path=/mutate-v1-pod,mutating=true,failurePolicy=fail,groups="",resources=pods,verbs=create;update,versions=v1,name=upod.appconfigmgr.cft.dev
// podAnnotator annotates Pods
type podAnnotator struct {
client client.Client
decoder *admission.Decoder
}
//func getJSONKey(client client.Client, ns string, secret string) {
// client.
//}
func kubeSecretFromTemplate(ns string, name string, mapKey string, value string) *corev1.Secret {
return kubeSecretFromTemplateBytes(ns, name, mapKey, []byte(value))
}
func kubeSecretFromTemplateBytes(ns string, name string, mapKey string, value []byte) *corev1.Secret {
return &corev1.Secret{
Type: corev1.SecretTypeOpaque,
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
},
Data: map[string][]byte{
mapKey: []byte(value),
},
}
}
func kubeSecretReponse(ns string, name string) *corev1.Secret {
return &corev1.Secret{
Type: corev1.SecretTypeOpaque,
}
}
func getSecretName(ns string) string {
log.Info("getSecretName:Start:" + "demo-" + ns + "-key")
return "demo-" + ns + "-key"
}
func updateSecretsVolume(pod *corev1.Pod, secretName string) {
log.V(1).Info("updateSecretsVolume", "secretName", secretName)
found := false
index := -1
for i, element := range pod.Spec.Volumes {
if element.Name == "google-auth-token" {
log.V(1).Info("updateSecretsVolume:volumeFound", "element.Name", element.Name)
found = true
index = i
}
}
element := &corev1.Volume{
Name: "google-auth-token",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: secretName,
},
},
}
if !found {
index = len(pod.Spec.Volumes)
pod.Spec.Volumes = append(pod.Spec.Volumes, *element)
} else {
pod.Spec.Volumes[index] = *element
}
}
func updateContainers(pod *corev1.Pod, appName string, mountName string, mountPath string, envName string) {
log.Info("updateContainers",
"appName", appName,
"mountName", mountName,
"mountPath", mountPath,
)
for index, element := range pod.Spec.Containers {
if strings.HasPrefix(appName, element.Name) {
log.Info("updateContainers:found",
"appName", element.Name,
"mountName", mountName,
"mountPath", mountPath)
updateContainerMounts(&element, element.Name, mountName, mountPath)
updateContainerEnv(&element, element.Name, envName, mountPath+"/key.json")
pod.Spec.Containers[index] = element
return
}
}
//TODO - Decide how to fail or just warning
log.Info("updateContainers:containerNotFound", "appName", appName,
"mountName", mountName,
"mountPath", mountPath)
}
func updateContainerMounts(container *corev1.Container, containerName string, mountName string, mountPath string) {
log.V(1).Info("updateContainerMounts",
"containerName", containerName,
"mountName", mountName,
"mountPath", mountPath,
)
found := false
index := -1
for i, element := range container.VolumeMounts {
if element.Name == mountName {
found = true
index = i
log.V(1).Info("updateContainerMounts:found",
"containerName", containerName,
"mountName", mountName,
"mountPath", mountPath,
)
}
}
element := &corev1.VolumeMount{
Name: mountName,
MountPath: mountPath,
}
if !found {
// Append The Mount
log.V(1).Info("updateContainerMounts:addMount",
"containerName", containerName,
"mountName", mountName,
"mountPath", mountPath,
)
index = len(container.VolumeMounts)
container.VolumeMounts = append(container.VolumeMounts, *element)
} else {
container.VolumeMounts[index] = *element
}
log.V(1).Info("updateContainerMounts:exit",
"containerInfo", container,
)
}
func updateContainerEnv(container *corev1.Container, containerName string, envName string, mountPath string) {
log.V(1).Info("updateContainerEnv",
"containerName", containerName,
"envName", envName,
"mountPath", mountPath,
)
found := false
index := -1
for i, element := range container.Env {
if element.Name == envName {
found = true
index = i
log.V(1).Info("updateContainerEnv:found",
"containerName", containerName,
"envName", envName,
"mountPath", mountPath,
)
}
}
element := &corev1.EnvVar{
Name: envName,
Value: mountPath,
}
if !found {
// Append The Mount
log.V(1).Info("updateContainerEnv:addMount",
"containerName", containerName,
"envName", envName,
"mountPath", mountPath,
)
index = len(container.Env)
container.Env = append(container.Env, *element)
} else {
container.Env[index] = *element
}
log.V(1).Info("updateContainerEnv:exit",
"containerInfo", container,
)
}
func (a *podAnnotator) handleGCPSecretIfNeeded(ctx context.Context, pod *corev1.Pod, app *appconfig.AppEnvConfigTemplateV2) error {
log.Info("podAnnotator:handleGCPSecretIfNeeded")
switch {
case app.Spec.Auth == nil, app.Spec.Auth.GCPAccess == nil:
return nil
case app.Spec.Auth.GCPAccess.AccessType == "vault":
return a.handleGCPVault(ctx, pod, app)
case app.Spec.Auth.GCPAccess.AccessType == "secret":
return a.handleGCPSecret(ctx, pod, app)
default:
log.Error(fmt.Errorf("invalid GCPAccess value"), "\"%s\"", app.Spec.Auth.GCPAccess.AccessType)
return nil
}
}
func (a *podAnnotator) handleGCPVault(ctx context.Context, pod *corev1.Pod, app *appconfig.AppEnvConfigTemplateV2) error {
log.Info("podAnnotator:handleGCPVault")
var (
caVolName = VAULT_CA_SECRET_NAME + "-vol"
gcpVolName = "google-auth-token"
vaultInfo = app.Spec.Auth.GCPAccess.VaultInfo
)
log.Info("handleGCPVault:loadConfig")
// read vaultInfo from AppEnvConfigTemplateV2 spec
if vaultInfo == nil {
return fmt.Errorf("vaultInfo not configured")
}
if vaultInfo.ServiceAccount == "" {
return fmt.Errorf("vaultInfo missing serviceAccount field")
}
if vaultInfo.ServiceAccount != pod.Spec.ServiceAccountName {
return fmt.Errorf("pod serviceAccountName does not equal vaultInfo serviceAccount field")
}
// TODO - This did not work so we introduced a check and this might be outside vault
//pod.Spec.ServiceAccountName = vaultInfo.ServiceAccount
if vaultInfo.Path == "" {
return fmt.Errorf("vaultInfo missing gcpPath field")
}
// construct image name and tag from env
imageBuild := os.Getenv("CONTROLLER_BUILD")
if imageBuild == "" {
imageBuild = "latest"
}
imageRegistry := os.Getenv("CONTROLLER_REGISTRY")
if imageRegistry == "" {
imageRegistry = "gcr.io/anthos-appconfig"
}
image := fmt.Sprintf("%s/vault-api-helper:%s", imageRegistry, imageBuild)
// get vault configMap, validate
log.Info("handleGCPVault:loadConfig", "ConfigMap", VAULT_CONFIGMAP_NAME)
config, err := getConfigMap(ctx, VAULT_CONFIGMAP_NAME, TODO_FIND_NAMESPACE)
if err != nil {
return err
}
if config.Data["vault-addr"] == "" {
return fmt.Errorf("ConfigMap missing vault-addr")
}
if config.Data["vault-cluster-path"] == "" {
return fmt.Errorf("ConfigMap missing vault-cluster-path")
}
if config.Data["gcp-vault-path"] == "" {
return fmt.Errorf("ConfigMap missing gcp-vault-path")
}
//// get provided serviceAccount JWT token
//log.Info("handleGCPVault:loadConfig", "ServiceAccount", vaultInfo.ServiceAccount)
//ksaToken, err := svcAcctJWT(ctx, vaultInfo.ServiceAccount, app.Namespace)
//if err != nil {
// return err
//}
//log.Info("handleGCPVault:loadConfig", "Token", len(ksaToken))
//
//VAULT_ADDITIONAL_SECRET := "vault-helper-info"
//secretDataMap := &map[string]string{
// "ksa.token": ksaToken,
//}
//createSecret(context.TODO(), VAULT_ADDITIONAL_SECRET, app.Namespace, secretDataMap)
// copy vault CA cert into app namespace
VAULT_CA_SECRET_NAME := "vault-ca"
// add vault CA cert secret to pod volumes
log.Info("handleGCPVault:applyConfig", "Volume", VAULT_CA_SECRET_NAME)
injectVolume(pod, corev1.Volume{
Name: caVolName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: VAULT_CA_SECRET_NAME,
},
},
})
var envVar = []corev1.EnvVar{
{
Name: "MY_POD_NAMESPACE",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
APIVersion: "v1",
FieldPath: "metadata.namespace",
},
},
},
{
Name: "MY_POD_SERVICE_ACCOUNT",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
APIVersion: "v1",
FieldPath: "spec.serviceAccountName",
},
},
},
{
Name: "INIT_GCP_KEYPATH",
Value: fmt.Sprintf("%s/key/%s", config.Data["gcp-vault-path"], vaultInfo.Roleset),
},
{
Name: "INIT_K8S_KEYPATH",
Value: fmt.Sprintf("%s", config.Data["vault-cluster-path"]),
},
{
Name: "INIT_K8S_ROLE",
Value: fmt.Sprintf("%s", vaultInfo.Roleset),
},
{
Name: "VAULT_ADDR",
Value: config.Data["vault-addr"],
},
{
Name: "VAULT_CAPATH",
Value: "/var/run/secrets/vault/ca.pem",
},
{
Name: "GOOGLE_APPLICATION_CREDENTIALS",
Value: "/var/run/secrets/google/token/key.json",
},
{
Name: "INIT_K8S_TOKEN_KEYPATH",
Value: "/var/run/secrets/kubernetes.io/serviceaccount/token",
},
}
log.Info("handleGCPVault:applyConfig", "getVolumeMountForToken", gcpVolName)
serviceAccountVolumeMount := getVolumeMountsInExistingContainers(pod)
if serviceAccountVolumeMount == nil {
panic(errors.New("Failed to find serviceAccountVolumeMount"))
}
log.Info("handleGCPVault:injectInitContainer", "Container", "vault-gcp-auth")
// inject vault-gcp init container
injectInitContainer(pod, corev1.Container{
Name: "vault-gcp-auth",
Image: image,
ImagePullPolicy: corev1.PullAlways,
Env: envVar,
VolumeMounts: []corev1.VolumeMount{
{
Name: caVolName,
MountPath: "/var/run/secrets/vault",
},
{
Name: gcpVolName,
MountPath: "/var/run/secrets/google/token",
},
*serviceAccountVolumeMount,
},
})
// inject vault-gcp cycle container
injectContainer(pod, corev1.Container{
Name: "vault-gcp-cycle",
Image: image,
ImagePullPolicy: corev1.PullAlways,
Command: []string{"./app", "--mode", "GCP-RECYCLE"},
Env: envVar,
VolumeMounts: []corev1.VolumeMount{
{
Name: caVolName,
MountPath: "/var/run/secrets/vault",
},
{
Name: gcpVolName,
MountPath: "/var/run/secrets/google/token",
},
*serviceAccountVolumeMount,
},
})
// add GCP token volume to pod
log.Info("handleGCPVault:applyConfig", "Volume", gcpVolName)
injectVolume(pod, corev1.Volume{
Name: gcpVolName,
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{
Medium: corev1.StorageMediumMemory,
},
},
})
// inject volume mount for all pod containers
log.Info("handleGCPVault:applyConfig", "VolumeMount", gcpVolName)
injectVolumeMount(pod, corev1.VolumeMount{
Name: gcpVolName,
ReadOnly: false,
MountPath: "/var/run/secrets/google/token",
})
// inject app credential env var for all pod containers
log.Info("handleGCPVault:applyConfig", "EnvVar", "GOOGLE_APPLICATION_CREDENTIALS")
injectEnvVar(pod, corev1.EnvVar{
Name: "GOOGLE_APPLICATION_CREDENTIALS",
Value: "/var/run/secrets/google/token/key.json",
})
return nil
}
func (a *podAnnotator) handleGCPSecret(ctx context.Context, pod *corev1.Pod, app *appconfig.AppEnvConfigTemplateV2) error {
log.Info("podAnnotator:handleGCPSecret")
secretName := app.Spec.Auth.GCPAccess.SecretInfo.Name
secretNamespace := TODO_FIND_NAMESPACE
secret := &corev1.Secret{}
cl := localMgr.GetClient()
err := cl.Get(ctx, types.NamespacedName{Name: secretName, Namespace: secretNamespace}, secret)
if err != nil {
log.Error(err, "Get Google Key from Secret to generate token")
return errors.New("Secret Not Found")
// Try Create
//err = cl.Create(ctx, kubeSecretFromTemplate(req.Namespace, "google-cloud-key"))
//if err != nil {
// log.Error(err, "Secret:Create")
// return admission.Errored(http.StatusBadRequest, err)
//}
}
log.Info("HandleUpdate:Secret", "secret", secret.Name)
token := string(secret.Data["key.json"])
appSecret := &corev1.Secret{}
err = cl.Get(ctx, types.NamespacedName{Name: "google-cloud-token", Namespace: app.Namespace}, appSecret)
if err != nil {
// avoid using ! in compound statement due to readability
if k8sapierrors.IsNotFound(err) {
err = cl.Create(ctx, kubeSecretFromTemplate(app.Namespace, "google-cloud-token", "key.json", token))
if err != nil {
return err
}
} else {
return err
}
} else {
appSecret.Data["key.json"] = []byte(token)
err = cl.Update(ctx, appSecret)
if err != nil {
return err
}
}
log.Info("HandleUpdate:Volume Mounts", "secret", "google-cloud-token")
updateSecretsVolume(pod, "google-cloud-token")
log.Info("HandleUpdate:Containers", "pod.Labels", pod.GetLabels())
if len(pod.GetLabels()["app"]) > 0 {
log.Info("HandleUpdate:Containers:app", "pod.Labels.app", pod.GetLabels()["app"])
updateContainers(pod, pod.GetLabels()["app"], "google-auth-token",
"/var/run/secrets/google/token", "GOOGLE_APPLICATION_CREDENTIALS")
}
return nil
}
func getApplicationName(pod *corev1.Pod) (string, error) {
if pod.Annotations == nil {
return "", errors.New("Annotation not found, empty annotations")
}
if val, ok := pod.Annotations["appconfigmgr.cft.dev/application"]; ok {
return val, nil
}
return "", errors.New("Annotation not found, empty annotations")
}
// podAnnotator adds an annotation to every incoming pods.
func (a *podAnnotator) Handle(ctx context.Context, req admission.Request) admission.Response {
pod := &corev1.Pod{}
log.Info("HandleUpdate:Start", req.Name, req.Namespace)
err := a.decoder.Decode(req, pod)
if err != nil {
return admission.Errored(http.StatusBadRequest, err)
}
app := &appconfig.AppEnvConfigTemplateV2{}
applicationName, err := getApplicationName(pod)
if err != nil {
log.Error(err, "Application annotation not found")
}
log.Info("HandleUpdate:applicationName", "applicationName", applicationName,
"req.Namespace", req.Namespace, "req.Operation", req.Operation)
err = localMgr.GetClient().Get(ctx, types.NamespacedName{Name: applicationName, Namespace: req.Namespace}, app)
if err != nil {
log.Error(err, "Application Does not Exist - working to see why it is not in scheme, hardcoded app to pubsub")
//return admission.Errored(http.StatusBadRequest, err)
}
if req.Operation == "CREATE" {
if err := a.handleGCPSecretIfNeeded(ctx, pod, app); err != nil {
log.Error(err, "Application GCP Secret could not be handled see error")
return admission.Errored(http.StatusBadRequest, err)
}
if err := a.handleServiceAccount(ctx, pod, app); err != nil {
log.Error(err, "Handling service account")
return admission.Errored(http.StatusBadRequest, err)
}
}
if pod.Annotations == nil {
pod.Annotations = map[string]string{}
}
pod.Annotations["example-mutating-admission-webhook"] = "foo"
marshaledPod, err := json.Marshal(pod)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
return admission.PatchResponseFromRaw(req.Object.Raw, marshaledPod)
}
// podAnnotator implements inject.Client.
// A client will be automatically injected.
// InjectClient injects the client.
func (a *podAnnotator) InjectClient(c client.Client) error {
a.client = c
return nil
}
// podAnnotator implements admission.DecoderInjector.
// A decoder will be automatically injected.
// InjectDecoder injects the decoder.
func (a *podAnnotator) InjectDecoder(d *admission.Decoder) error {
a.decoder = d
return nil
}
// handleServiceAccount sets the pod's service account if one is specified for the
// corresponding service.
func (a *podAnnotator) handleServiceAccount(ctx context.Context, pod *corev1.Pod, appcfg *appconfig.AppEnvConfigTemplateV2) error {
// Find the service spec that matches the pod.
app := pod.GetLabels()["app"]
for _, s := range appcfg.Spec.Services {
if app != s.DeploymentApp {
continue
}
if s.ServiceAccount == "" {
return nil
}
pod.Spec.ServiceAccountName = s.ServiceAccount
}
return nil
}
// +kubebuilder:webhook:path=/validate-v1-pod,mutating=false,failurePolicy=fail,groups="",resources=pods,verbs=create;update,versions=v1,name=vpod.appconfigmgr.cft.dev
// podValidator validates Pods
type podValidator struct {
client client.Client
decoder *admission.Decoder
}
// podValidator admits a pod iff a specific annotation exists.
func (v *podValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
pod := &corev1.Pod{}
err := v.decoder.Decode(req, pod)
if err != nil {
return admission.Errored(http.StatusBadRequest, err)
}
key := "example-mutating-admission-webhook"
anno, found := pod.Annotations[key]
if !found {
return admission.Denied(fmt.Sprintf("missing annotation %s", key))
}
if anno != "foo" {
return admission.Denied(fmt.Sprintf("annotation %s did not have value %q", key, "foo"))
}
return admission.Allowed("")
}
// podValidator implements inject.Client.
// A client will be automatically injected.
// InjectClient injects the client.
func (v *podValidator) InjectClient(c client.Client) error {
v.client = c
return nil
}
// podValidator implements admission.DecoderInjector.
// A decoder will be automatically injected.
// InjectDecoder injects the decoder.
func (v *podValidator) InjectDecoder(d *admission.Decoder) error {
v.decoder = d
return nil
}