Reading time: 8 minutes

In my continuous learning about Kubernetes, my interest wandered a bit and moved towards the extensions that it offers.

Got more curiosity in things like operators, custom resources, definitions, webhooks, and other goods that a Kubernetes cluster can be customized with.

After reading and researching for a bit, I’ve decided to try out and build my own validating webhook.

It’s excellent as a pet project, with lots of things to consider until everything fits together and starts working.

Now, what is a webhook or an admission controller for that matter?

An admission controller is a piece of code that intercepts requests to the Kubernetes API server before the persistence of the object, but after the request is authenticated and authorized.

In Kubernetes, there are two types of admission controllers, called the ValidatingAdmissionWebhook and MutatingAdmissionWebhook.

As each of their names implies; one just validates the requests, and the other modifies it if it isn’t up to spec.

Now I won’t be going into details about them. If you want to read more here is a link to the official documentation with a much more extensive explanation.

The great thing with the webhooks is that you can write your own, in whatever language you like, along with custom logic, be it for validation or mutation of requests.

I’ve decided to start with the validating webhook using Python and the Flask framework.

Writing only the webhook logic is one thing but setting it up and getting it to work is another.

To bootstrap the whole thing, in my case it required:

  • Python and the Flask framework
  • Docker
  • SSL cert
  • And a bunch of Kubernetes files(service, deployment, secret…)

All that for one simple webhook?!

Although it seems a bit tedious, I promise it’s quite a fun and rewarding project.

The project files are also available on my GitHub repository here.

Let’s start from the beginning.

Python code

With this basic functionality, I wrote the webhook code in less than fifty lines of Python code. Using Flask makes this super easy.

There are a lot of examples on the internet that you can take inspiration for writing it.

It’ll eventually boil down to two things.

You need to:

  1. Analyze the request and validate or mutate it against a set rule
  2. Send back an HTTP response to the Kubernetes admission controller

Everything else you add to the code is a plus.

This validating webhook will check for a label if it’s present on Deployment creation.

If it isn’t the request is blocked and an error message is displayed.

I’ve added the option to set the required label from an environment variable and also included logging.

Initially, I wanted to go with Django but considered it would be an overkill for this, so I chose to do it with Flask.

It came up really clean and simple:

from flask import Flask, request, jsonify
from os import environ
import logging

webhook = Flask(__name__)

webhook.config['LABEL'] = environ.get('LABEL')

webhook.logger.setLevel(logging.INFO)


if "LABEL" not in environ:
    webhook.logger.error("Required environment variable for label isn't set. Exiting...")
    exit(1)


@webhook.route('/validate', methods=['POST'])
def validating_webhook():
    request_info = request.get_json()
    uid = request_info["request"].get("uid")

    if request_info["request"]["object"]["metadata"]["labels"].get(webhook.config['LABEL']):
        webhook.logger.info(f'Object {request_info["request"]["object"]["kind"]}/{request_info["request"]["object"]["metadata"]["name"]} contains the required \"{webhook.config["LABEL"]}\" label. Allowing the request.')

        return admission_response(True, uid, f"{webhook.config['LABEL']} label exists.")
    else:
        webhook.logger.error(f'Object {request_info["request"]["object"]["kind"]}/{request_info["request"]["object"]["metadata"]["name"]} doesn\'t have the required \"{webhook.config["LABEL"]}\" label. Request rejected!')

        return admission_response(False, uid, f"The label \"{webhook.config['LABEL']}\" isn't set!")


def admission_response(allowed, uid, message):
    return jsonify({"apiVersion": "admission.k8s.io/v1",
                    "kind": "AdmissionReview",
                    "response":
                        {"allowed": allowed,
                         "uid": uid,
                         "status": {"message": message}
                         }
                    })


if __name__ == '__main__':
    webhook.run(host='0.0.0.0', port=5000)

The first thing is setting the environment variable and logging.

The initial if condition checks that the label environment variable isn’t empty, which is required for the webhook to work.

From there, on POST request the code checks if the set label is present in the Deployment’s metadata field. And if the label isn’t present the admission controller will reject the Deployment.

I added the logger function so it will print out a log message depending on the status of the request.

The second function is only to respond to the admission controller. With an HTTP response if allowing the request is true or false.

The Kubernetes docs, also have an example of how the response should look like.

As stated in the documentation, the UID and the allowed fields are mandatory to be present in the AdmissionReview response.

Now one crucial thing… You must supply SSL encryption between the traffic from the webhook and the admission controller!

How you choose to supply the cert and key is up to you.

You can package them in the docker image or inject them separately using Kubernetes secrets. I did the latter.

Docker

The requirements.txt with the libraries can be generated, or found on the repository here.

The Dockerfile is self-explanatory:

FROM ubuntu:20.10

RUN apt-get update -y && apt-get install -y python3-pip python-dev

WORKDIR /app

COPY requirements.txt /app/requirements.txt

RUN pip3 install -r /app/requirements.txt

COPY validate.py /app

COPY wsgi.py /app

CMD gunicorn --certfile=/certs/webhook.crt --keyfile=/certs/webhook.key --bind 0.0.0.0:443 wsgi:webhook

For the image, well, you can choose any base image you want.

The other things are to install Python, Flask, Gunicorn, the libraries, and to copy over the code.

The important bit is the certificate and key.

Gunicorn will look in the /certs path, so they must be available there when mounted on the pod.

Note: If you aren’t familiar, Gunicorn is the application server that will forward the requests to your webhook app via the WSGI protocol.

Deployment files

Deploying the webhook requires four pieces for the puzzle to be complete.

First is the webhook deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: validation-webhook
  labels:
    app: validate
spec:
  replicas: 1
  selector:
    matchLabels:
      app: validate
  template:
    metadata:
      labels:
        app: validate
    spec:
      containers:
      - name: webhook
        image: kmitevski/webhook:gunicorn
        ports:
        - containerPort: 443
        env:
        - name: LABEL
          value: development
        volumeMounts:
        - name: certs-volume
          readOnly: true
          mountPath: "/certs"
        imagePullPolicy: Always
      volumes:
      - name: certs-volume
        secret:
          secretName: admission-tls

The service on which the webhook will be available:

apiVersion: v1
kind: Service
metadata:
  name: validate
spec:
  selector:
    app: validate
  ports:
  - port: 443

The secret manifest containing the certificate and key:

apiVersion: v1
kind: Secret
metadata:
  name: admission-tls
type: Opaque
data:
  webhook.crt: YOUR ENCODED BASE64 CERT
  webhook.key: YOUR ENCODED BASE64 KEY

Finally the Validating Webhook configuration file:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: validating-webhook
webhooks:
  - name: validate.default.svc
    failurePolicy: Fail
    sideEffects: None
    admissionReviewVersions: ["v1","v1beta1"]
    rules:
      - apiGroups: ["apps", ""]
        resources:
          - "deployments"
        apiVersions:
          - "*"
        operations:
          - CREATE
    clientConfig:
      service:
        name: validate
        namespace: default
        path: /validate/
      caBundle: YOUR ENCODED BASE64 CERT

Whew… Nearly there.

Compiling it all together

To get it all together and for the webhook to work, there is somewhat an order in which you should deploy things.

I started with generating the certificate, which you will need to supply to Gunicorn, and the webhook configuration.

I had a lot of trial and error to do until the whole thing fit in place and started to work.

Let’s just say that generating a certificate a hundred times until it worked wasn’t quite fun.

Generating certificate

To generate the cert, the openssl tool is used:

openssl req -x509 -sha256 -newkey rsa:2048 -keyout webhook.key -out webhook.crt -days 1024 -nodes -addext "subjectAltName = DNS.1:validate.default.svc"

Now here is the catch, SAN – subjectAltName that has a DNS record must be included with the cert!

You must match that DNS to your service name and namespace. The notation is service_name.namespace.svc.

Failing to do so may leave you with a bitter feel and desire to just rm -rf * the whole thing.

I was close to this multiple times!

Everything will run, but when you decide to test the webhook this little annoying message will pop up:

open ssl x509: certificate relies on legacy Common Name field, use SANs or temporarily enable Common Name matching with GODEBUG=x509ignoreCN=0

To have it in mind, you will need to reference the name of the service in multiple places.

Make sure it stays the same, otherwise changing it later will require changes in the certificate and the webhook config.

Creating the Kubernetes secret

The Kubernetes Secret will need to include the certificate and key, encoded in base64 format.

This is easy to do on Linux with:

cat webhook.key | base64 | tr -d '\n'
#LS0tLS1CRUdJTiBQUklWQVRFI....
cat webhook.crt | base64 | tr -d '\n'
#LS0tLS1CRUdJTiBDRVJUSUZJQ0FUR

Copy the webhook.crt output and paste it to the secret and the webhook config manifests.

Copy the webhook.key as well, although the key only needs to be included in the secret.

Just be careful when copying so you don’t include your username from the prompt :).

The mount point in the Pod spec is set on /certs.

You can change it, but make sure to create a new image with the change done in the Gunicorn cert and key paths.

Creating the Image

The folder and file structure are like this:

tree .
.
|-- Dockerfile
|-- kubernetes-manifests
|   |-- label.yaml
|   |-- webhook-config.yaml
|   |-- webhook-deploy.yaml
|   |-- webhook-secret.yaml
|   `-- webhook-service.yaml
|-- requirements.txt
|-- validate.py
|-- webhook.crt
|-- webhook.key
`-- wsgi.py

I built the Docker image from the root folder. Where the Dockerfile is located, along with the Python files and certificates.

The wsgi.py is just a helper file for Gunicorn to run the webhook app.

If you intend to use your own image, you’ll need to build, tag, and push it to a repository using the following commands as examples:

docker build -t webhook:gunicorn -f Dockerfile .
docker tag webhook:gunicorn kmitevski/webhook
docker push kmitevski/webhook:gunicorn

Bootstrapping the app

  1. Create the certificate
  2. Create the Docker image
  3. Apply the Secret manifest to the cluster
  4. Apply the Deployment, Service, and the Webhook configuration manifest
  5. Test the webhook

Testing

For testing, you can try and create a simple Deployment with an Nginx image.

This is easily tested using the imperative approach:

$ kubectl create deploy nginx --image=nginx
error: failed to create deployment: admission webhook "validate.default.svc" denied the request: The label "development" isn't set!

And if you check in the webhook pod logs:

ERROR in validate: Object Deployment/nginx doesn't have the required "development" label. Request rejected!

Now try and deploy the label.yaml manifest.

That one contains the development label, and with it, creating the Deployment will be allowed.

$ kubectl apply -f label.yaml
deployment.apps/nginx created
INFO in validate: Object Deployment/nginx contains the required "development" label. Allowing the request.

Success!! The validating webhook works!

Next up on the list is building a mutating webhook, I’ll see how that one works out :).