Last time I introduced the 2 main parts of a 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.
handler.Handle("/routegroups", admission.Handler())
admission.Handler
is just a go func
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:
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:
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"request": {
// ...
}
}
Therefore, we can just use json.Unmarshal
to decode them:
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 here. You can find other Kubernetes types in the k8s.io/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
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
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.
Util Funcs
I found it useful to have writeResponse
and errorResponse
functions.
Writing Response
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
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 in Skipper, our HTTP reverse proxy.
Thanks to my teammates Arpad & Sandor for helping me contribute a validating admission webhook to Skipper!
Reference
The following is just an abridged version of admission.go
from the validating admission webhook in Skipper.
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>"
}
Top comments (0)