Getting Started

Admission Controllers In Kubernetes

We already know, Control Planes in Kubernetes exposes API of various Group, Version and Kinds [GVK]. An admission control plug-in works as a webhook in Kubernetes that intercept API requests before they pass to the API server and can prohibit or modify them which can be targeted to specific GVKs.


Introduction

Each admission control plug-in is run in sequence before a request is accepted into the cluster. If any of the plug-ins in the sequence reject the request, the entire request is rejected immediately and an error is returned to the end user. Each request must be authenticated and authorized before it can be accepted by the API Server and all communication must happen on port 443 or HTTPS.

Several admission controllers are enabled by default because most normal Kubernetes operations rely upon them. Most of these controllers comprise some of the Kubernetes source trees and are compiled as plugins.

Kubernetes supports two types of admission controllers,

  1. Mutating Admission Controller
  2. Validation Admission Controller

Mutating Admission Controller reads the incoming request into a valid schema and can perform ADD or Delete or Modify on the request. The validation Admission Controller reads the incoming request into a valid schema and approves or rejects the request based on predefined checks.

Why do you need them?

Let’s start with a use case, Suppose you have a deployment which uses a Specific Version/Tag of an Image and you don’t want to use the default Latest image tag which could lead to backward compatibility issues. Here you can write a Validating admission controller that adds this check on your deployment that if the image tag is the Latest, the deployment would be rejected.

Many advanced features in Kubernetes require an admission control plug-in to be enabled to properly support the feature. As a result, a Kubernetes API server that is not properly configured with the right set of admission control plug-ins is an incomplete server and will not support all the features you expect.

How to build one?

Now that we have a basic understanding of the controllers, Let’s write a simple Validation Controller that checks if the deployment has Nodeselector/Node-affinity or not.

We would be using Client-Go for Kubernetes and the k8s.io/apiserver/pkg/server package that behaves like the API server itself. This server would be the one handling every incoming request and returning valid responses.

But before building a controller, we know the API server only accepts requests on port 443, we need to create tls certs for the same, run this command in your terminal,

openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt --days 3650 --nodes --subj "/CN=resourceguard.kube-system.svc" --addext "subjectAltName = DNS:resourceguard.kube-system.svc"

to use these certs, create a secret out of it,

kubectl create secret generic certs --from-file tls.crt --from-file tls.key --dry-run=client -oyaml> secrets.yaml

To create a basic server add following code,

import (
    "net/http"
    "os"
    "time"

    "github.com/spf13/pflag"
    "k8s.io/apiserver/pkg/server"
    "k8s.io/apiserver/pkg/server/options"
    "k8s.io/component-base/cli/globalflag"
)

// Options Setting Up the HTTPS server For Request and Response
type Options struct {
    SecureServingOptions options.SecureServingOptions
}

// AddFlagSet Adding Flag Support
func (o *Options) AddFlagSet(fs *pflag.FlagSet) {
    o.SecureServingOptions.AddFlags(fs)
}

type Config struct {
    SecureServingInfo *server.SecureServingInfo
}

const (
    valkontroller = "val-kontroller"
)

func (o *Options) Config() *Config {
    if err := o.SecureServingOptions.MaybeDefaultWithSelfSignedCerts("0.0.0.0", nil, nil); err != nil {
        panic(err)
    }

    c := Config{}
    if err := o.SecureServingOptions.ApplyTo(&c.SecureServingInfo); err != nil {
        panic(err)
    }
    return &c
}

func DefaultServerOptions() *Options {
    NewOption := &Options{
        SecureServingOptions: *options.NewSecureServingOptions(),
    }
    NewOption.SecureServingOptions.BindPort = 8443
    NewOption.SecureServingOptions.ServerCert.PairName = valkontroller
    return NewOption
}

// Init Starting HTTPS Server
func Init() {
    option := DefaultServerOptions()
    fs := pflag.NewFlagSet(valkontroller, pflag.ExitOnError)
    globalflag.AddGlobalFlags(fs, valkontroller)
    option.AddFlagSet(fs)
    sentrylog()
    if err := fs.Parse(os.Args); err != nil {
        panic(err)
    }
    c := option.Config()

    mux := http.NewServeMux()
    mux.Handle("/validate", http.HandlerFunc(ValidatePod))
    // This Channel will Run Until Gets SIGTERM or SIGINT
    stopCh := server.SetupSignalHandler()
    ch, err := c.SecureServingInfo.Serve(mux, 60*time.Second, stopCh)
    if err != nil {
        sentry.CaptureException(err)
        panic(err)
    } else {
        <-ch
    }
}

On Path /validate a validation function would run, where the logic is built,

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"

    admissionv1 "k8s.io/api/admission/v1beta1"
    appsv1 "k8s.io/api/apps/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/serializer"
)

var (
    scheme = runtime.NewScheme()
    codecs = serializer.NewCodecFactory(scheme)
    logger = log.New(os.Stdout, "info: ", log.LstdFlags)
)

func admissionReviewFromRequest(r *http.Request, deserializer runtime.Decoder) (*admissionv1.AdmissionReview, error) {
    // Validate that the incoming content type is correct.
    if r.Header.Get("Content-Type") != "application/json" {
        return nil, fmt.Errorf("expected application/json content-type")
    }

    // Get the body data, which will be the AdmissionReview
    // content for the request.
    var body []byte
    if r.Body != nil {
        requestData, err := ioutil.ReadAll(r.Body)
        if err != nil {
            return nil, err
        }
        body = requestData
    }

    // Decode the request body into
    admissionReviewRequest := &admissionv1.AdmissionReview{}
    if _, _, err := deserializer.Decode(body, nil, admissionReviewRequest); err != nil {
        return nil, err
    }

    return admissionReviewRequest, nil
}

func ValidatePod(w http.ResponseWriter, r *http.Request) {

    logger.Printf("Validation controller was called")

    deserializer := codecs.UniversalDeserializer()

    // Parse the AdmissionReview from the http request.
    admissionReviewRequest, err := admissionReviewFromRequest(r, deserializer)
    if err != nil {
        msg := fmt.Sprintf("error getting admission review from request: %v", err)
        logger.Printf(msg)
        w.WriteHeader(400)
        w.Write([]byte(msg))
        return
    }
    rawRequest := admissionReviewRequest.Request.Object.Raw
    deployment := appsv1.Deployment{}
    if _, _, err := deserializer.Decode(rawRequest, nil, &deployment); err != nil {
        msg := fmt.Sprintf("error decoding raw pod: %v", err)
        logger.Printf(msg)
        w.WriteHeader(500)
        w.Write([]byte(msg))
        return
    }
    admissionResponse := &admissionv1.AdmissionResponse{}
    admissionResponse.Allowed = true
    podNodeselector := deployment.Spec.Template.Spec.NodeSelector
    // note : Need to add extra check because Affinity is struct and *ref to podspec
    podNodeselectorTerm := deployment.Spec.Template.Spec.Affinity
    if len(podNodeselector) == 0 {
        if podNodeselectorTerm == nil {
            admissionResponse.Allowed = false
            admissionResponse.Result = &metav1.Status{
                Message: "Nodeselector is Missing in the Deployment Spec, NodeSelector is a required field",
            }
            logger.Printf("[Deployment Rejected] ResourceGroup = 'apps/v1 deployments' Namespace = %v Deployment = %v ", admissionReviewRequest.Request.Namespace, admissionReviewRequest.Request.Name)
        }
    }

    var admissionReviewResponse admissionv1.AdmissionReview
    admissionReviewResponse.Response = admissionResponse
    admissionReviewResponse.SetGroupVersionKind(admissionReviewRequest.GroupVersionKind())
    admissionReviewResponse.Response.UID = admissionReviewRequest.Request.UID

    resp, err := json.Marshal(admissionReviewResponse)
    if err != nil {
        msg := fmt.Sprintf("error marshalling response json: %v", err)
        logger.Printf(msg)
        w.WriteHeader(500)
        w.Write([]byte(msg))
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write(resp)
}

Now build the Binary and create docker image,

FROM debian
COPY ./validatingwebhook /validatingwebhook
ENTRYPOINT ["/validatingwebhook","--tls-cert-file=/var/run/webhook/serving-cert/tls.crt", "--tls-private-key-file=/var/run/webhook/serving-cert/tls.key","--v=10"]

Your webhook is now ready to be deployed

apiVersion: v1
kind: Service
metadata:
  namespace: kube-system
  labels:
    app: validatingwebhook
  name: validatingwebhook
spec:
  ports:
    - port: 443
      protocol: TCP
      targetPort: 8443
      name: https
  selector:
    app: validatingwebhook
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: "validatingwebhook.kube-system.svc"
webhooks:
- name: "validatingwebhook.kube-system.svc"
  rules:
  - apiGroups:   ["*"]
    apiVersions: ["v1", "v1beta1"]
    operations:  ["CREATE"]
    resources:   ["deployments"]
  clientConfig:
    service:
      namespace: "kube-system"
      name: "validatingwebhook"
      path: "/validate"
    caBundle: "Cert that you created"
  admissionReviewVersions: ["v1", "v1beta1"]
  sideEffects: None
  timeoutSeconds: 5
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: validatingwebhook
  namespace: kube-system
automountServiceAccountToken: true

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: validatingwebhook
  namespace: kube-system
  labels:
    app: validatingwebhook
spec:
  replicas: 1
  selector:
    matchLabels:
      app: validatingwebhook
  template:
    metadata:
      labels:
        app: validatingwebhook
    spec:
      serviceAccountName: validatingwebhook
      securityContext:
        fsGroup: 65534
      hostNetwork: true
      containers:
        - name: validatingwebhook
          image: validation-kontoller:latest
          imagePullPolicy: Always
          resources:
            limits:
              cpu: 500m
              memory: 200Mi
            requests:
              cpu: 200m
              memory: 200Mi
          ports:
            - containerPort: 8443
          volumeMounts:
            - name: serving-cert
              mountPath: /var/run/webhook/serving-cert
      volumes:
        - name: serving-cert
          secret:
            secretName: certs

Subscribe to Developer Stack

Get the latest posts delivered right to your inbox