Skip to content

Commit 88234a8

Browse files
authored
Merge pull request kubernetes-sigs#1900 from isitinschi/webhooks-recover-panics
✨ webhook: add an option to recover from panics in handler
2 parents b93b5f9 + 037bde6 commit 88234a8

File tree

4 files changed

+239
-7
lines changed

4 files changed

+239
-7
lines changed

pkg/builder/webhook.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type WebhookBuilder struct {
3939
gvk schema.GroupVersionKind
4040
mgr manager.Manager
4141
config *rest.Config
42+
recoverPanic bool
4243
}
4344

4445
// WebhookManagedBy allows inform its manager.Manager.
@@ -68,6 +69,12 @@ func (blder *WebhookBuilder) WithValidator(validator admission.CustomValidator)
6869
return blder
6970
}
7071

72+
// RecoverPanic indicates whether the panic caused by webhook should be recovered.
73+
func (blder *WebhookBuilder) RecoverPanic() *WebhookBuilder {
74+
blder.recoverPanic = true
75+
return blder
76+
}
77+
7178
// Complete builds the webhook.
7279
func (blder *WebhookBuilder) Complete() error {
7380
// Set the Config
@@ -124,10 +131,10 @@ func (blder *WebhookBuilder) registerDefaultingWebhook() {
124131

125132
func (blder *WebhookBuilder) getDefaultingWebhook() *admission.Webhook {
126133
if defaulter := blder.withDefaulter; defaulter != nil {
127-
return admission.WithCustomDefaulter(blder.apiType, defaulter)
134+
return admission.WithCustomDefaulter(blder.apiType, defaulter).WithRecoverPanic(blder.recoverPanic)
128135
}
129136
if defaulter, ok := blder.apiType.(admission.Defaulter); ok {
130-
return admission.DefaultingWebhookFor(defaulter)
137+
return admission.DefaultingWebhookFor(defaulter).WithRecoverPanic(blder.recoverPanic)
131138
}
132139
log.Info(
133140
"skip registering a mutating webhook, object does not implement admission.Defaulter or WithDefaulter wasn't called",
@@ -153,10 +160,10 @@ func (blder *WebhookBuilder) registerValidatingWebhook() {
153160

154161
func (blder *WebhookBuilder) getValidatingWebhook() *admission.Webhook {
155162
if validator := blder.withValidator; validator != nil {
156-
return admission.WithCustomValidator(blder.apiType, validator)
163+
return admission.WithCustomValidator(blder.apiType, validator).WithRecoverPanic(blder.recoverPanic)
157164
}
158165
if validator, ok := blder.apiType.(admission.Validator); ok {
159-
return admission.ValidatingWebhookFor(validator)
166+
return admission.ValidatingWebhookFor(validator).WithRecoverPanic(blder.recoverPanic)
160167
}
161168
log.Info(
162169
"skip registering a validating webhook, object does not implement admission.Validator or WithValidator wasn't called",

pkg/builder/webhook_test.go

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,72 @@ func runTests(admissionReviewVersion string) {
134134
ExpectWithOffset(1, w.Code).To(Equal(http.StatusNotFound))
135135
})
136136

137+
It("should scaffold a defaulting webhook which recovers from panics", func() {
138+
By("creating a controller manager")
139+
m, err := manager.New(cfg, manager.Options{})
140+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
141+
142+
By("registering the type in the Scheme")
143+
builder := scheme.Builder{GroupVersion: testDefaulterGVK.GroupVersion()}
144+
builder.Register(&TestDefaulter{}, &TestDefaulterList{})
145+
err = builder.AddToScheme(m.GetScheme())
146+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
147+
148+
err = WebhookManagedBy(m).
149+
For(&TestDefaulter{Panic: true}).
150+
RecoverPanic().
151+
Complete()
152+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
153+
svr := m.GetWebhookServer()
154+
ExpectWithOffset(1, svr).NotTo(BeNil())
155+
156+
reader := strings.NewReader(`{
157+
"kind":"AdmissionReview",
158+
"apiVersion":"admission.k8s.io/` + admissionReviewVersion + `",
159+
"request":{
160+
"uid":"07e52e8d-4513-11e9-a716-42010a800270",
161+
"kind":{
162+
"group":"",
163+
"version":"v1",
164+
"kind":"TestDefaulter"
165+
},
166+
"resource":{
167+
"group":"",
168+
"version":"v1",
169+
"resource":"testdefaulter"
170+
},
171+
"namespace":"default",
172+
"operation":"CREATE",
173+
"object":{
174+
"replica":1,
175+
"panic":true
176+
},
177+
"oldObject":null
178+
}
179+
}`)
180+
181+
ctx, cancel := context.WithCancel(context.Background())
182+
cancel()
183+
// TODO: we may want to improve it to make it be able to inject dependencies,
184+
// but not always try to load certs and return not found error.
185+
err = svr.Start(ctx)
186+
if err != nil && !os.IsNotExist(err) {
187+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
188+
}
189+
190+
By("sending a request to a mutating webhook path")
191+
path := generateMutatePath(testDefaulterGVK)
192+
req := httptest.NewRequest("POST", "http://svc-name.svc-ns.svc"+path, reader)
193+
req.Header.Add("Content-Type", "application/json")
194+
w := httptest.NewRecorder()
195+
svr.WebhookMux.ServeHTTP(w, req)
196+
ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK))
197+
By("sanity checking the response contains reasonable fields")
198+
ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`))
199+
ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":500`))
200+
ExpectWithOffset(1, w.Body).To(ContainSubstring(`"message":"panic: injected panic [recovered]`))
201+
})
202+
137203
It("should scaffold a defaulting webhook with a custom defaulter", func() {
138204
By("creating a controller manager")
139205
m, err := manager.New(cfg, manager.Options{})
@@ -284,6 +350,73 @@ func runTests(admissionReviewVersion string) {
284350
ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":403`))
285351
})
286352

353+
It("should scaffold a validating webhook which recovers from panics", func() {
354+
By("creating a controller manager")
355+
m, err := manager.New(cfg, manager.Options{})
356+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
357+
358+
By("registering the type in the Scheme")
359+
builder := scheme.Builder{GroupVersion: testValidatorGVK.GroupVersion()}
360+
builder.Register(&TestValidator{}, &TestValidatorList{})
361+
err = builder.AddToScheme(m.GetScheme())
362+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
363+
364+
err = WebhookManagedBy(m).
365+
For(&TestValidator{Panic: true}).
366+
RecoverPanic().
367+
Complete()
368+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
369+
svr := m.GetWebhookServer()
370+
ExpectWithOffset(1, svr).NotTo(BeNil())
371+
372+
reader := strings.NewReader(`{
373+
"kind":"AdmissionReview",
374+
"apiVersion":"admission.k8s.io/` + admissionReviewVersion + `",
375+
"request":{
376+
"uid":"07e52e8d-4513-11e9-a716-42010a800270",
377+
"kind":{
378+
"group":"",
379+
"version":"v1",
380+
"kind":"TestValidator"
381+
},
382+
"resource":{
383+
"group":"",
384+
"version":"v1",
385+
"resource":"testvalidator"
386+
},
387+
"namespace":"default",
388+
"operation":"CREATE",
389+
"object":{
390+
"replica":2,
391+
"panic":true
392+
}
393+
}
394+
}`)
395+
396+
ctx, cancel := context.WithCancel(context.Background())
397+
cancel()
398+
// TODO: we may want to improve it to make it be able to inject dependencies,
399+
// but not always try to load certs and return not found error.
400+
err = svr.Start(ctx)
401+
if err != nil && !os.IsNotExist(err) {
402+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
403+
}
404+
405+
By("sending a request to a validating webhook path")
406+
path := generateValidatePath(testValidatorGVK)
407+
_, err = reader.Seek(0, 0)
408+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
409+
req := httptest.NewRequest("POST", "http://svc-name.svc-ns.svc"+path, reader)
410+
req.Header.Add("Content-Type", "application/json")
411+
w := httptest.NewRecorder()
412+
svr.WebhookMux.ServeHTTP(w, req)
413+
ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK))
414+
By("sanity checking the response contains reasonable field")
415+
ExpectWithOffset(1, w.Body).To(ContainSubstring(`"allowed":false`))
416+
ExpectWithOffset(1, w.Body).To(ContainSubstring(`"code":500`))
417+
ExpectWithOffset(1, w.Body).To(ContainSubstring(`"message":"panic: injected panic [recovered]`))
418+
})
419+
287420
It("should scaffold a validating webhook with a custom validator", func() {
288421
By("creating a controller manager")
289422
m, err := manager.New(cfg, manager.Options{})
@@ -542,7 +675,8 @@ var _ runtime.Object = &TestDefaulter{}
542675
const testDefaulterKind = "TestDefaulter"
543676

544677
type TestDefaulter struct {
545-
Replica int `json:"replica,omitempty"`
678+
Replica int `json:"replica,omitempty"`
679+
Panic bool `json:"panic,omitempty"`
546680
}
547681

548682
var testDefaulterGVK = schema.GroupVersionKind{Group: "foo.test.org", Version: "v1", Kind: testDefaulterKind}
@@ -568,6 +702,9 @@ func (*TestDefaulterList) GetObjectKind() schema.ObjectKind { return nil }
568702
func (*TestDefaulterList) DeepCopyObject() runtime.Object { return nil }
569703

570704
func (d *TestDefaulter) Default() {
705+
if d.Panic {
706+
panic("injected panic")
707+
}
571708
if d.Replica < 2 {
572709
d.Replica = 2
573710
}
@@ -579,7 +716,8 @@ var _ runtime.Object = &TestValidator{}
579716
const testValidatorKind = "TestValidator"
580717

581718
type TestValidator struct {
582-
Replica int `json:"replica,omitempty"`
719+
Replica int `json:"replica,omitempty"`
720+
Panic bool `json:"panic,omitempty"`
583721
}
584722

585723
var testValidatorGVK = schema.GroupVersionKind{Group: "foo.test.org", Version: "v1", Kind: testValidatorKind}
@@ -607,13 +745,19 @@ func (*TestValidatorList) DeepCopyObject() runtime.Object { return nil }
607745
var _ admission.Validator = &TestValidator{}
608746

609747
func (v *TestValidator) ValidateCreate() error {
748+
if v.Panic {
749+
panic("injected panic")
750+
}
610751
if v.Replica < 0 {
611752
return errors.New("number of replica should be greater than or equal to 0")
612753
}
613754
return nil
614755
}
615756

616757
func (v *TestValidator) ValidateUpdate(old runtime.Object) error {
758+
if v.Panic {
759+
panic("injected panic")
760+
}
617761
if v.Replica < 0 {
618762
return errors.New("number of replica should be greater than or equal to 0")
619763
}
@@ -626,6 +770,9 @@ func (v *TestValidator) ValidateUpdate(old runtime.Object) error {
626770
}
627771

628772
func (v *TestValidator) ValidateDelete() error {
773+
if v.Panic {
774+
panic("injected panic")
775+
}
629776
if v.Replica > 0 {
630777
return errors.New("number of replica should be less than or equal to 0 to delete")
631778
}

pkg/webhook/admission/webhook.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package admission
1919
import (
2020
"context"
2121
"errors"
22+
"fmt"
2223
"net/http"
2324

2425
"github.com/go-logr/logr"
@@ -27,6 +28,7 @@ import (
2728
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2829
"k8s.io/apimachinery/pkg/runtime"
2930
"k8s.io/apimachinery/pkg/util/json"
31+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
3032
"k8s.io/client-go/kubernetes/scheme"
3133

3234
logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
@@ -121,6 +123,9 @@ type Webhook struct {
121123
// and potentially patches to apply to the handler.
122124
Handler Handler
123125

126+
// RecoverPanic indicates whether the panic caused by webhook should be recovered.
127+
RecoverPanic bool
128+
124129
// WithContextFunc will allow you to take the http.Request.Context() and
125130
// add any additional information such as passing the request path or
126131
// headers thus allowing you to read them from within the handler
@@ -138,11 +143,29 @@ func (wh *Webhook) InjectLogger(l logr.Logger) error {
138143
return nil
139144
}
140145

146+
// WithRecoverPanic takes a bool flag which indicates whether the panic caused by webhook should be recovered.
147+
func (wh *Webhook) WithRecoverPanic(recoverPanic bool) *Webhook {
148+
wh.RecoverPanic = recoverPanic
149+
return wh
150+
}
151+
141152
// Handle processes AdmissionRequest.
142153
// If the webhook is mutating type, it delegates the AdmissionRequest to each handler and merge the patches.
143154
// If the webhook is validating type, it delegates the AdmissionRequest to each handler and
144155
// deny the request if anyone denies.
145-
func (wh *Webhook) Handle(ctx context.Context, req Request) Response {
156+
func (wh *Webhook) Handle(ctx context.Context, req Request) (response Response) {
157+
if wh.RecoverPanic {
158+
defer func() {
159+
if r := recover(); r != nil {
160+
for _, fn := range utilruntime.PanicHandlers {
161+
fn(r)
162+
}
163+
response = Errored(http.StatusInternalServerError, fmt.Errorf("panic: %v [recovered]", r))
164+
return
165+
}
166+
}()
167+
}
168+
146169
resp := wh.Handler.Handle(ctx, req)
147170
if err := resp.Complete(req); err != nil {
148171
wh.log.Error(err, "unable to encode response")

pkg/webhook/admission/webhook_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,61 @@ var _ = Describe("Admission Webhooks", func() {
192192
Expect(handler.dep.decoder).NotTo(BeNil())
193193
})
194194
})
195+
196+
Describe("panic recovery", func() {
197+
It("should recover panic if RecoverPanic is true", func() {
198+
panicHandler := func() *Webhook {
199+
handler := &fakeHandler{
200+
fn: func(ctx context.Context, req Request) Response {
201+
panic("injected panic")
202+
},
203+
}
204+
webhook := &Webhook{
205+
Handler: handler,
206+
RecoverPanic: true,
207+
log: logf.RuntimeLog.WithName("webhook"),
208+
}
209+
210+
return webhook
211+
}
212+
213+
By("setting up a webhook with a panicking handler")
214+
webhook := panicHandler()
215+
216+
By("invoking the webhook")
217+
resp := webhook.Handle(context.Background(), Request{})
218+
219+
By("checking that it errored the request")
220+
Expect(resp.Allowed).To(BeFalse())
221+
Expect(resp.Result.Code).To(Equal(int32(http.StatusInternalServerError)))
222+
Expect(resp.Result.Message).To(Equal("panic: injected panic [recovered]"))
223+
})
224+
225+
It("should not recover panic if RecoverPanic is false by default", func() {
226+
panicHandler := func() *Webhook {
227+
handler := &fakeHandler{
228+
fn: func(ctx context.Context, req Request) Response {
229+
panic("injected panic")
230+
},
231+
}
232+
webhook := &Webhook{
233+
Handler: handler,
234+
log: logf.RuntimeLog.WithName("webhook"),
235+
}
236+
237+
return webhook
238+
}
239+
240+
By("setting up a webhook with a panicking handler")
241+
defer func() {
242+
Expect(recover()).ShouldNot(BeNil())
243+
}()
244+
webhook := panicHandler()
245+
246+
By("invoking the webhook")
247+
webhook.Handle(context.Background(), Request{})
248+
})
249+
})
195250
})
196251

197252
var _ = Describe("Should be able to write/read admission.Request to/from context", func() {

0 commit comments

Comments
 (0)