Skip to content

🐛 pkg/webhook/admission: set GVK on AdmissionReview responses #1293

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions pkg/webhook/admission/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
v1 "k8s.io/api/admission/v1"
"k8s.io/api/admission/v1beta1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
)
Expand Down Expand Up @@ -73,14 +74,17 @@ func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {

// Both v1 and v1beta1 AdmissionReview types are exactly the same, so the v1beta1 type can
// be decoded into the v1 type. However the runtime codec's decoder guesses which type to
// decode into by type name if an Object's TypeMeta isn't set. By setting TypeMeta of an`
// decode into by type name if an Object's TypeMeta isn't set. By setting TypeMeta of an
// unregistered type to the v1 GVK, the decoder will coerce a v1beta1 AdmissionReview to v1.
// The actual AdmissionReview GVK will be used to write a typed response in case the
// webhook config permits multiple versions, otherwise this response will fail.
req := Request{}
ar := unversionedAdmissionReview{}
// avoid an extra copy
ar.Request = &req.AdmissionRequest
ar.SetGroupVersionKind(v1.SchemeGroupVersion.WithKind("AdmissionReview"))
if _, _, err := admissionCodecs.UniversalDeserializer().Decode(body, nil, &ar); err != nil {
_, actualAdmRevGVK, err := admissionCodecs.UniversalDeserializer().Decode(body, nil, &ar)
if err != nil {
wh.log.Error(err, "unable to decode the request")
reviewResponse = Errored(http.StatusBadRequest, err)
wh.writeResponse(w, reviewResponse)
Expand All @@ -90,20 +94,39 @@ func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {

// TODO: add panic-recovery for Handle
reviewResponse = wh.Handle(r.Context(), req)
wh.writeResponse(w, reviewResponse)
wh.writeResponseTyped(w, reviewResponse, actualAdmRevGVK)
}

// writeResponse writes response to w generically, i.e. without encoding GVK information.
func (wh *Webhook) writeResponse(w io.Writer, response Response) {
encoder := json.NewEncoder(w)
responseAdmissionReview := v1.AdmissionReview{
wh.writeAdmissionResponse(w, v1.AdmissionReview{Response: &response.AdmissionResponse})
}

// writeResponseTyped writes response to w with GVK set to admRevGVK, which is necessary
// if multiple AdmissionReview versions are permitted by the webhook.
func (wh *Webhook) writeResponseTyped(w io.Writer, response Response, admRevGVK *schema.GroupVersionKind) {
ar := v1.AdmissionReview{
Response: &response.AdmissionResponse,
}
err := encoder.Encode(responseAdmissionReview)
// Default to a v1 AdmissionReview, otherwise the API server may not recognize the request
// if multiple AdmissionReview versions are permitted by the webhook config.
// TODO(estroz): this should be configurable since older API servers won't know about v1.
if admRevGVK == nil || *admRevGVK == (schema.GroupVersionKind{}) {
ar.SetGroupVersionKind(v1.SchemeGroupVersion.WithKind("AdmissionReview"))
} else {
ar.SetGroupVersionKind(*admRevGVK)
}
wh.writeAdmissionResponse(w, ar)
}

// writeAdmissionResponse writes ar to w.
func (wh *Webhook) writeAdmissionResponse(w io.Writer, ar v1.AdmissionReview) {
err := json.NewEncoder(w).Encode(ar)
if err != nil {
wh.log.Error(err, "unable to encode the response")
wh.writeResponse(w, Errored(http.StatusInternalServerError, err))
} else {
res := responseAdmissionReview.Response
res := ar.Response
if log := wh.log; log.V(1).Enabled() {
if res.Result != nil {
log = log.WithValues("code", res.Result.Code, "reason", res.Result.Reason)
Expand Down
70 changes: 54 additions & 16 deletions pkg/webhook/admission/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import (

var _ = Describe("Admission Webhooks", func() {

const (
gvkJSONv1 = `"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1"`
gvkJSONv1beta1 = `"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1beta1"`
)

Describe("HTTP Handler", func() {
var respRecorder *httptest.ResponseRecorder
webhook := &Webhook{
Expand All @@ -51,10 +56,10 @@ var _ = Describe("Admission Webhooks", func() {
It("should return bad-request when given an empty body", func() {
req := &http.Request{Body: nil}

expected := []byte(`{"response":{"uid":"","allowed":false,"status":{"metadata":{},"message":"request body is empty","code":400}}}
`)
expected := `{"response":{"uid":"","allowed":false,"status":{"metadata":{},"message":"request body is empty","code":400}}}
`
webhook.ServeHTTP(respRecorder, req)
Expect(respRecorder.Body.Bytes()).To(Equal(expected))
Expect(respRecorder.Body.String()).To(Equal(expected))
})

It("should return bad-request when given the wrong content-type", func() {
Expand All @@ -63,10 +68,11 @@ var _ = Describe("Admission Webhooks", func() {
Body: nopCloser{Reader: bytes.NewBuffer(nil)},
}

expected := []byte(`{"response":{"uid":"","allowed":false,"status":{"metadata":{},"message":"contentType=application/foo, expected application/json","code":400}}}
`)
expected :=
`{"response":{"uid":"","allowed":false,"status":{"metadata":{},"message":"contentType=application/foo, expected application/json","code":400}}}
`
webhook.ServeHTTP(respRecorder, req)
Expect(respRecorder.Body.Bytes()).To(Equal(expected))
Expect(respRecorder.Body.String()).To(Equal(expected))
})

It("should return bad-request when given an undecodable body", func() {
Expand All @@ -75,14 +81,14 @@ var _ = Describe("Admission Webhooks", func() {
Body: nopCloser{Reader: bytes.NewBufferString("{")},
}

expected := []byte(
expected :=
`{"response":{"uid":"","allowed":false,"status":{"metadata":{},"message":"couldn't get version/kind; json parse error: unexpected end of JSON input","code":400}}}
`)
`
webhook.ServeHTTP(respRecorder, req)
Expect(respRecorder.Body.Bytes()).To(Equal(expected))
Expect(respRecorder.Body.String()).To(Equal(expected))
})

It("should return the response given by the handler", func() {
It("should return the response given by the handler with version defaulted to v1", func() {
req := &http.Request{
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: nopCloser{Reader: bytes.NewBufferString(`{"request":{}}`)},
Expand All @@ -92,10 +98,42 @@ var _ = Describe("Admission Webhooks", func() {
log: logf.RuntimeLog.WithName("webhook"),
}

expected := []byte(`{"response":{"uid":"","allowed":true,"status":{"metadata":{},"code":200}}}
`)
expected := fmt.Sprintf(`{%s,"response":{"uid":"","allowed":true,"status":{"metadata":{},"code":200}}}
`, gvkJSONv1)
webhook.ServeHTTP(respRecorder, req)
Expect(respRecorder.Body.String()).To(Equal(expected))
})

It("should return the v1 response given by the handler", func() {
req := &http.Request{
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: nopCloser{Reader: bytes.NewBufferString(fmt.Sprintf(`{%s,"request":{}}`, gvkJSONv1))},
}
webhook := &Webhook{
Handler: &fakeHandler{},
log: logf.RuntimeLog.WithName("webhook"),
}

expected := fmt.Sprintf(`{%s,"response":{"uid":"","allowed":true,"status":{"metadata":{},"code":200}}}
`, gvkJSONv1)
webhook.ServeHTTP(respRecorder, req)
Expect(respRecorder.Body.String()).To(Equal(expected))
})

It("should return the v1beta1 response given by the handler", func() {
req := &http.Request{
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: nopCloser{Reader: bytes.NewBufferString(fmt.Sprintf(`{%s,"request":{}}`, gvkJSONv1beta1))},
}
webhook := &Webhook{
Handler: &fakeHandler{},
log: logf.RuntimeLog.WithName("webhook"),
}

expected := fmt.Sprintf(`{%s,"response":{"uid":"","allowed":true,"status":{"metadata":{},"code":200}}}
`, gvkJSONv1beta1)
webhook.ServeHTTP(respRecorder, req)
Expect(respRecorder.Body.Bytes()).To(Equal(expected))
Expect(respRecorder.Body.String()).To(Equal(expected))
})

It("should present the Context from the HTTP request, if any", func() {
Expand All @@ -116,13 +154,13 @@ var _ = Describe("Admission Webhooks", func() {
log: logf.RuntimeLog.WithName("webhook"),
}

expected := []byte(fmt.Sprintf(`{"response":{"uid":"","allowed":true,"status":{"metadata":{},"reason":%q,"code":200}}}
`, value))
expected := fmt.Sprintf(`{%s,"response":{"uid":"","allowed":true,"status":{"metadata":{},"reason":%q,"code":200}}}
`, gvkJSONv1, value)

ctx, cancel := context.WithCancel(context.WithValue(context.Background(), key, value))
cancel()
webhook.ServeHTTP(respRecorder, req.WithContext(ctx))
Expect(respRecorder.Body.Bytes()).To(Equal(expected))
Expect(respRecorder.Body.String()).To(Equal(expected))
})
})
})
Expand Down