Parsing Admission Requests in a Validating Admission Webhook
Tags: kubernetes, go
Published: 2021-01-17 16:27:09

Last time I introduced [the 2 main parts of a Validating Admission Webhook](https://www.learningkubeonaws.com/blog/the-2-main-parts-of-a-kubernetes-validating-admission-webhook). The Webserver and the `ValidatingAdmissionWebhookConfiguration`. 

This time I'll expand a bit on the Webserver. Specifically, parsing Admission Requests in a Validating Admission Webhook.

#### The HTTP Handler

The webserver just needs a `POST` endpoint, where the API Server can post Admission Requests to.

```go
handler.Handle("/routegroups", admission.Handler())
```

`admission.Handler` is just a go `func`

```go
func Handler() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
	    // ...
	}
}
```

Having a function that *returns* a `http.HandlerFunc` is helpful because this way I can also parameterize `admission.Handler` for each resource I want to validate e.g:


```go
handler.Handle("/deployments", admission.Handler("deployments"))
```

More on this in a future blog post.



### Parsing an Admission Request

Admission requests are just HTTP `Post` requests with a `JSON` body of the form:

```json
{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "request": {
    // ...
  }
}
```

Therefore, we can just use `json.Unmarshal` to decode them:

```go
import (
    "encoding/json"
	admissionsv1 "k8s.io/api/admission/v1"
)
// ...
review := admissionsv1.AdmissionReview{}
err = json.Unmarshal(body, &review)
if err != nil {
	log.Errorf("Failed to parse request: %v", err)
	w.WriteHeader(http.StatusBadRequest)
	return
}
```

Note how I am just using the upstream `AdmissionReview` type from [k8s.io/api/admission/v1](https://github.com/kubernetes/api/tree/master/admission/v1) here. You can find other Kubernetes types in the [k8s.io/api](https://github.com/kubernetes/api) repo as well.

### Responding to an Admission Request i.e encoding `Admission.Response`

Encoding is just a `json.Marshal` as well.

```
import (
// ...
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

	resp, err := json.Marshal(admissionsv1.AdmissionReview{
		TypeMeta: metav1.TypeMeta{
			Kind:       "AdmissionReview",
			APIVersion: "admission.k8s.io/v1",
		},
		Response: response,
	})
```

There are a couple important things to keep in mind here.

**The response `UID` must match the request**

```go
response = &admissionsv1.AdmissionResponse{
	UID:     &review.UID,
// ...
}
```
Otherwise, the API Server won't know which admission request my response belongs to.

**The `TypeMeta` fields must be present in the response**


```go
admissionsv1.AdmissionReview{
		TypeMeta: metav1.TypeMeta{
			Kind:       "AdmissionReview",
			APIVersion: "admission.k8s.io/v1",
		},
		Response: response,
	})
```

I learnt this the hard way. If the `Kind` and `APIVersion` aren't specified for `v1` resources i.e `v1.AdmissionReview`, [the Admission requests will just fail](https://github.com/kubernetes/kubernetes/blob/518b826b1dcc4852226300ae08110cba03a078c9/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/request/admissionreview.go#L58).


#### Util Funcs

I found it useful to have `writeResponse` and `errorResponse` functions.

**Writing Response**

```go
import (
// ...
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func writeResponse(
    writer http.ResponseWriter, 
    response *admissionsv1.AdmissionResponse,
    ) {
	resp, err := json.Marshal(admissionsv1.AdmissionReview{
		TypeMeta: metav1.TypeMeta{
			Kind:       "AdmissionReview",
			APIVersion: "admission.k8s.io/v1",
		},
		Response: response,
	})
	if err != nil {
		log.Errorf("failed to serialize response: %v", err)
		writer.WriteHeader(http.StatusInternalServerError)
		return
	}
	if _, err := writer.Write(resp); err != nil {
		log.Errorf("failed to write response: %v", err)
	}
}
```

**Writing errors**

```go
import (
// ...
    "k8s.io/apimachinery/pkg/types"
)

func errorResponse(uid types.UID, err error) *admissionsv1.AdmissionResponse {
	return &admissionsv1.AdmissionResponse{
		Allowed: false,
		UID:     uid,
		Result: &metav1.Status{
			Message: err.Error(),
		},
	}
}
```

When I started writing a validating admission webhook for the first 
time, I was very confused about the various moving parts. Hopefully this post shows that in essence, a validating admission webhook is just a go webserver.


### Further Resources

You can find an abridged version of `admission.Handler` under "Reference". For a complete example of a validating admission webhook being used in production, checkout the [validating admission webhook](https://github.com/zalando/skipper/tree/v0.11.175/cmd/webhook) in [Skipper](https://github.com/zalando/skipper), our HTTP reverse proxy.

- [`admission.Handler`](https://github.com/zalando/skipper/blob/v0.11.175/cmd/webhook/admission/admission.go#L109)
- [`writeResponse`](https://github.com/zalando/skipper/blob/v0.11.175/cmd/webhook/admission/admission.go#L179)
- [`errorResponse`](https://github.com/zalando/skipper/blob/v0.11.175/cmd/webhook/admission/admission.go#L197)

> Thanks to my teammates [Arpad](https://twitter.com/arpad_ryszka) & [Sandor](https://twitter.com/sszuecs) for helping me contribute a validating admission webhook to Skipper! 

### Reference 

The following is just an abridged version of [`admission.go`](https://github.com/zalando/skipper/blob/v0.11.175/cmd/webhook/admission/admission.go) from the [validating admission webhook](https://github.com/zalando/skipper/tree/v0.11.175/cmd/webhook) in [Skipper](https://github.com/zalando/skipper).

```go
package admission

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"time"

	log "github.com/sirupsen/logrus"
	"github.com/zalando/skipper/dataclients/kubernetes/definitions"
	admissionsv1 "k8s.io/api/admission/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"
)

func Handler(admitter Admitter) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		admitterName := admitter.Name()

		if r.Method != "POST" || r.Header.Get("Content-Type") != "application/json" {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		body, err := ioutil.ReadAll(r.Body)
		if err != nil {
			log.Errorf("Failed to read request: %v", err)
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		log.Debugln("request received", r.Method, r.URL.Path, r.Header, string(body))

		review := admissionsv1.AdmissionReview{}
		err = json.Unmarshal(body, &review)
		if err != nil {
			log.Errorf("Failed to parse request: %v", err)
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		operationInfo := fmt.Sprintf(
			"%s %s %s/%s",
			review.Request.Operation,
			review.Request.Kind,
			review.Request.Namespace,
			extractName(review.Request),
		)

		gvr := review.Request.Resource
		group := gvr.Group
		if group == "" {
			group = "zalando.org"
		}

		start := time.Now()
		defer admissionDuration.With(labelValues).
			Observe(float64(time.Since(start)) / float64(time.Second))

		admResp, err := admitter.Admit(review.Request)
		if err != nil {
			log.Errorf("Rejected %s: %v", operationInfo, err)
			writeResponse(w, errorResponse(review.Request.UID, err))
			return
		}

		log.Debugf("Allowed %s", operationInfo)
		writeResponse(w, admResp)
	}
}

func writeResponse(writer http.ResponseWriter, response *admissionsv1.AdmissionResponse) {
	resp, err := json.Marshal(admissionsv1.AdmissionReview{
		TypeMeta: metav1.TypeMeta{
			Kind:       "AdmissionReview",
			APIVersion: "admission.k8s.io/v1",
		},
		Response: response,
	})
	if err != nil {
		log.Errorf("failed to serialize response: %v", err)
		writer.WriteHeader(http.StatusInternalServerError)
		return
	}
	if _, err := writer.Write(resp); err != nil {
		log.Errorf("failed to write response: %v", err)
	}
}

func errorResponse(uid types.UID, err error) *admissionsv1.AdmissionResponse {
	return &admissionsv1.AdmissionResponse{
		Allowed: false,
		UID:     uid,
		Result: &metav1.Status{
			Message: err.Error(),
		},
	}
}

func extractName(request *admissionsv1.AdmissionRequest) string {
	if request.Name != "" {
		return request.Name
	}

	obj := metav1.PartialObjectMetadata{}
	if err := json.Unmarshal(request.Object.Raw, &obj); err != nil {
		log.Warnf("failed to parse object: %v", err)
		return "<unknown>"
	}

	if obj.Name != "" {
		return obj.Name
	}
	if obj.GenerateName != "" {
		return obj.GenerateName + "<generated>"
	}
	return "<unknown>"
}
```