Skip to content

Commit d5d2551

Browse files
authored
Merge pull request #1429 from kevindelgado/standalone-webhooks
✨ Simple helper for unmanaged webhook server
2 parents 1c186f5 + 5a5106d commit d5d2551

File tree

7 files changed

+444
-28
lines changed

7 files changed

+444
-28
lines changed

pkg/webhook/admission/webhook.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ import (
2727
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2828
"k8s.io/apimachinery/pkg/runtime"
2929
"k8s.io/apimachinery/pkg/util/json"
30+
"k8s.io/client-go/kubernetes/scheme"
3031

32+
logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
3133
"sigs.k8s.io/controller-runtime/pkg/runtime/inject"
34+
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics"
3235
)
3336

3437
var (
@@ -110,6 +113,9 @@ func (f HandlerFunc) Handle(ctx context.Context, req Request) Response {
110113
}
111114

112115
// Webhook represents each individual webhook.
116+
//
117+
// It must be registered with a webhook.Server or
118+
// populated by StandaloneWebhook to be ran on an arbitrary HTTP server.
113119
type Webhook struct {
114120
// Handler actually processes an admission request returning whether it was allowed or denied,
115121
// and potentially patches to apply to the handler.
@@ -203,3 +209,45 @@ func (w *Webhook) InjectFunc(f inject.Func) error {
203209

204210
return setFields(w.Handler)
205211
}
212+
213+
// StandaloneOptions let you configure a StandaloneWebhook.
214+
type StandaloneOptions struct {
215+
// Scheme is the scheme used to resolve runtime.Objects to GroupVersionKinds / Resources
216+
// Defaults to the kubernetes/client-go scheme.Scheme, but it's almost always better
217+
// idea to pass your own scheme in. See the documentation in pkg/scheme for more information.
218+
Scheme *runtime.Scheme
219+
// Logger to be used by the webhook.
220+
// If none is set, it defaults to log.Log global logger.
221+
Logger logr.Logger
222+
// MetricsPath is used for labelling prometheus metrics
223+
// by the path is served on.
224+
// If none is set, prometheus metrics will not be generated.
225+
MetricsPath string
226+
}
227+
228+
// StandaloneWebhook prepares a webhook for use without a webhook.Server,
229+
// passing in the information normally populated by webhook.Server
230+
// and instrumenting the webhook with metrics.
231+
//
232+
// Use this to attach your webhook to an arbitrary HTTP server or mux.
233+
func StandaloneWebhook(hook *Webhook, opts StandaloneOptions) (http.Handler, error) {
234+
if opts.Scheme == nil {
235+
opts.Scheme = scheme.Scheme
236+
}
237+
238+
var err error
239+
hook.decoder, err = NewDecoder(opts.Scheme)
240+
if err != nil {
241+
return nil, err
242+
}
243+
244+
if opts.Logger == nil {
245+
opts.Logger = logf.RuntimeLog.WithName("webhook")
246+
}
247+
hook.log = opts.Logger
248+
249+
if opts.MetricsPath == "" {
250+
return hook, nil
251+
}
252+
return metrics.InstrumentedHook(opts.MetricsPath, hook), nil
253+
}

pkg/webhook/example_test.go

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,24 @@ package webhook_test
1818

1919
import (
2020
"context"
21+
"net/http"
2122

23+
"k8s.io/client-go/kubernetes/scheme"
2224
ctrl "sigs.k8s.io/controller-runtime"
25+
logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
26+
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
2327
. "sigs.k8s.io/controller-runtime/pkg/webhook"
2428
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
2529
)
2630

27-
func Example() {
28-
// Build webhooks
31+
var (
32+
// Build webhooks used for the various server
33+
// configuration options
34+
//
2935
// These handlers could be also be implementations
3036
// of the AdmissionHandler interface for more complex
3137
// implementations.
32-
mutatingHook := &Admission{
38+
mutatingHook = &Admission{
3339
Handler: admission.HandlerFunc(func(ctx context.Context, req AdmissionRequest) AdmissionResponse {
3440
return Patched("some changes",
3541
JSONPatchOp{Operation: "add", Path: "/metadata/annotations/access", Value: "granted"},
@@ -38,12 +44,16 @@ func Example() {
3844
}),
3945
}
4046

41-
validatingHook := &Admission{
47+
validatingHook = &Admission{
4248
Handler: admission.HandlerFunc(func(ctx context.Context, req AdmissionRequest) AdmissionResponse {
4349
return Denied("none shall pass!")
4450
}),
4551
}
52+
)
4653

54+
// This example registers a webhooks to a webhook server
55+
// that gets ran by a controller manager.
56+
func Example() {
4757
// Create a manager
4858
// Note: GetConfigOrDie will os.Exit(1) w/o any message if no kube-config can be found
4959
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})
@@ -70,3 +80,72 @@ func Example() {
7080
panic(err)
7181
}
7282
}
83+
84+
// This example creates a webhook server that can be
85+
// ran without a controller manager.
86+
func ExampleServer_StartStandalone() {
87+
// Create a webhook server
88+
hookServer := &Server{
89+
Port: 8443,
90+
}
91+
92+
// Register the webhooks in the server.
93+
hookServer.Register("/mutating", mutatingHook)
94+
hookServer.Register("/validating", validatingHook)
95+
96+
// Start the server without a manger
97+
err := hookServer.StartStandalone(signals.SetupSignalHandler(), scheme.Scheme)
98+
if err != nil {
99+
// handle error
100+
panic(err)
101+
}
102+
}
103+
104+
// This example creates a standalone webhook handler
105+
// and runs it on a vanilla go HTTP server to demonstrate
106+
// how you could run a webhook on an existing server
107+
// without a controller manager.
108+
func ExampleStandaloneWebhook() {
109+
// Assume you have an existing HTTP server at your disposal
110+
// configured as desired (e.g. with TLS).
111+
// For this example just create a basic http.ServeMux
112+
mux := http.NewServeMux()
113+
port := ":8000"
114+
115+
// Create the standalone HTTP handlers from our webhooks
116+
mutatingHookHandler, err := admission.StandaloneWebhook(mutatingHook, admission.StandaloneOptions{
117+
Scheme: scheme.Scheme,
118+
// Logger let's you optionally pass
119+
// a custom logger (defaults to log.Log global Logger)
120+
Logger: logf.RuntimeLog.WithName("mutating-webhook"),
121+
// MetricsPath let's you optionally
122+
// provide the path it will be served on
123+
// to be used for labelling prometheus metrics
124+
// If none is set, prometheus metrics will not be generated.
125+
MetricsPath: "/mutating",
126+
})
127+
if err != nil {
128+
// handle error
129+
panic(err)
130+
}
131+
132+
validatingHookHandler, err := admission.StandaloneWebhook(validatingHook, admission.StandaloneOptions{
133+
Scheme: scheme.Scheme,
134+
Logger: logf.RuntimeLog.WithName("validating-webhook"),
135+
MetricsPath: "/validating",
136+
})
137+
if err != nil {
138+
// handle error
139+
panic(err)
140+
}
141+
142+
// Register the webhook handlers to your server
143+
mux.Handle("/mutating", mutatingHookHandler)
144+
mux.Handle("/validating", validatingHookHandler)
145+
146+
// Run your handler
147+
if err := http.ListenAndServe(port, mux); err != nil {
148+
panic(err)
149+
}
150+
151+
}

pkg/webhook/internal/metrics/metrics.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ limitations under the License.
1717
package metrics
1818

1919
import (
20+
"net/http"
21+
2022
"github.com/prometheus/client_golang/prometheus"
23+
"github.com/prometheus/client_golang/prometheus/promhttp"
2124

2225
"sigs.k8s.io/controller-runtime/pkg/metrics"
2326
)
@@ -59,3 +62,24 @@ var (
5962
func init() {
6063
metrics.Registry.MustRegister(RequestLatency, RequestTotal, RequestInFlight)
6164
}
65+
66+
// InstrumentedHook adds some instrumentation on top of the given webhook.
67+
func InstrumentedHook(path string, hookRaw http.Handler) http.Handler {
68+
lbl := prometheus.Labels{"webhook": path}
69+
70+
lat := RequestLatency.MustCurryWith(lbl)
71+
cnt := RequestTotal.MustCurryWith(lbl)
72+
gge := RequestInFlight.With(lbl)
73+
74+
// Initialize the most likely HTTP status codes.
75+
cnt.WithLabelValues("200")
76+
cnt.WithLabelValues("500")
77+
78+
return promhttp.InstrumentHandlerDuration(
79+
lat,
80+
promhttp.InstrumentHandlerCounter(
81+
cnt,
82+
promhttp.InstrumentHandlerInFlight(gge, hookRaw),
83+
),
84+
)
85+
}

pkg/webhook/server.go

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ import (
2929
"strconv"
3030
"sync"
3131

32-
"github.com/prometheus/client_golang/prometheus"
33-
"github.com/prometheus/client_golang/prometheus/promhttp"
32+
"k8s.io/apimachinery/pkg/runtime"
33+
kscheme "k8s.io/client-go/kubernetes/scheme"
3434
"sigs.k8s.io/controller-runtime/pkg/certwatcher"
3535
"sigs.k8s.io/controller-runtime/pkg/runtime/inject"
3636
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics"
@@ -124,7 +124,7 @@ func (s *Server) Register(path string, hook http.Handler) {
124124
}
125125
// TODO(directxman12): call setfields if we've already started the server
126126
s.webhooks[path] = hook
127-
s.WebhookMux.Handle(path, instrumentedHook(path, hook))
127+
s.WebhookMux.Handle(path, metrics.InstrumentedHook(path, hook))
128128

129129
regLog := log.WithValues("path", path)
130130
regLog.Info("registering webhook")
@@ -149,25 +149,24 @@ func (s *Server) Register(path string, hook http.Handler) {
149149
}
150150
}
151151

152-
// instrumentedHook adds some instrumentation on top of the given webhook.
153-
func instrumentedHook(path string, hookRaw http.Handler) http.Handler {
154-
lbl := prometheus.Labels{"webhook": path}
155-
156-
lat := metrics.RequestLatency.MustCurryWith(lbl)
157-
cnt := metrics.RequestTotal.MustCurryWith(lbl)
158-
gge := metrics.RequestInFlight.With(lbl)
159-
160-
// Initialize the most likely HTTP status codes.
161-
cnt.WithLabelValues("200")
162-
cnt.WithLabelValues("500")
163-
164-
return promhttp.InstrumentHandlerDuration(
165-
lat,
166-
promhttp.InstrumentHandlerCounter(
167-
cnt,
168-
promhttp.InstrumentHandlerInFlight(gge, hookRaw),
169-
),
170-
)
152+
// StartStandalone runs a webhook server without
153+
// a controller manager.
154+
func (s *Server) StartStandalone(ctx context.Context, scheme *runtime.Scheme) error {
155+
// Use the Kubernetes client-go scheme if none is specified
156+
if scheme == nil {
157+
scheme = kscheme.Scheme
158+
}
159+
160+
if err := s.InjectFunc(func(i interface{}) error {
161+
if _, err := inject.SchemeInto(scheme, i); err != nil {
162+
return err
163+
}
164+
return nil
165+
}); err != nil {
166+
return err
167+
}
168+
169+
return s.Start(ctx)
171170
}
172171

173172
// Start runs the server.

pkg/webhook/server_test.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525

2626
. "github.com/onsi/ginkgo"
2727
. "github.com/onsi/gomega"
28+
"k8s.io/client-go/kubernetes/scheme"
2829
"k8s.io/client-go/rest"
2930
"sigs.k8s.io/controller-runtime/pkg/envtest"
3031
"sigs.k8s.io/controller-runtime/pkg/webhook"
@@ -68,12 +69,12 @@ var _ = Describe("Webhook Server", func() {
6869
Expect(servingOpts.Cleanup()).To(Succeed())
6970
})
7071

71-
startServer := func() (done <-chan struct{}) {
72+
genericStartServer := func(f func(ctx context.Context)) (done <-chan struct{}) {
7273
doneCh := make(chan struct{})
7374
go func() {
7475
defer GinkgoRecover()
7576
defer close(doneCh)
76-
Expect(server.Start(ctx)).To(Succeed())
77+
f(ctx)
7778
}()
7879
// wait till we can ping the server to start the test
7980
Eventually(func() error {
@@ -93,6 +94,12 @@ var _ = Describe("Webhook Server", func() {
9394
return doneCh
9495
}
9596

97+
startServer := func() (done <-chan struct{}) {
98+
return genericStartServer(func(ctx context.Context) {
99+
Expect(server.Start(ctx)).To(Succeed())
100+
})
101+
}
102+
96103
// TODO(directxman12): figure out a good way to test all the serving setup
97104
// with httptest.Server to get all the niceness from that.
98105

@@ -174,6 +181,28 @@ var _ = Describe("Webhook Server", func() {
174181
Expect(handler.injectedField).To(BeTrue())
175182
})
176183
})
184+
185+
It("should serve be able to serve in unmanaged mode", func() {
186+
server = &webhook.Server{
187+
Host: servingOpts.LocalServingHost,
188+
Port: servingOpts.LocalServingPort,
189+
CertDir: servingOpts.LocalServingCertDir,
190+
}
191+
server.Register("/somepath", &testHandler{})
192+
doneCh := genericStartServer(func(ctx context.Context) {
193+
Expect(server.StartStandalone(ctx, scheme.Scheme))
194+
})
195+
196+
Eventually(func() ([]byte, error) {
197+
resp, err := client.Get(fmt.Sprintf("https://%s/somepath", testHostPort))
198+
Expect(err).NotTo(HaveOccurred())
199+
defer resp.Body.Close()
200+
return ioutil.ReadAll(resp.Body)
201+
}).Should(Equal([]byte("gadzooks!")))
202+
203+
ctxCancel()
204+
Eventually(doneCh, "4s").Should(BeClosed())
205+
})
177206
})
178207

179208
type testHandler struct {

0 commit comments

Comments
 (0)