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
✨ Helper for unmanaged webhook server #1429
Changes from 2 commits
83846f5
2c24442
12c19f7
9a73ff4
4dc10f9
5a5106d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,13 +22,18 @@ import ( | |
"net/http" | ||
|
||
"github.com/go-logr/logr" | ||
"github.com/prometheus/client_golang/prometheus" | ||
"github.com/prometheus/client_golang/prometheus/promhttp" | ||
jsonpatch "gomodules.xyz/jsonpatch/v2" | ||
admissionv1 "k8s.io/api/admission/v1" | ||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
"k8s.io/apimachinery/pkg/runtime" | ||
"k8s.io/apimachinery/pkg/util/json" | ||
"k8s.io/client-go/kubernetes/scheme" | ||
|
||
logf "sigs.k8s.io/controller-runtime/pkg/internal/log" | ||
"sigs.k8s.io/controller-runtime/pkg/runtime/inject" | ||
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics" | ||
) | ||
|
||
var ( | ||
|
@@ -203,3 +208,62 @@ func (w *Webhook) InjectFunc(f inject.Func) error { | |
|
||
return setFields(w.Handler) | ||
} | ||
|
||
// InstrumentedHook adds some instrumentation on top of the given webhook. | ||
func InstrumentedHook(path string, hookRaw http.Handler) http.Handler { | ||
lbl := prometheus.Labels{"webhook": path} | ||
|
||
lat := metrics.RequestLatency.MustCurryWith(lbl) | ||
cnt := metrics.RequestTotal.MustCurryWith(lbl) | ||
gge := metrics.RequestInFlight.With(lbl) | ||
|
||
// Initialize the most likely HTTP status codes. | ||
cnt.WithLabelValues("200") | ||
cnt.WithLabelValues("500") | ||
|
||
return promhttp.InstrumentHandlerDuration( | ||
lat, | ||
promhttp.InstrumentHandlerCounter( | ||
cnt, | ||
promhttp.InstrumentHandlerInFlight(gge, hookRaw), | ||
), | ||
) | ||
} | ||
|
||
// StandaloneOptions let you configure a StandaloneWebhook. | ||
type StandaloneOptions struct { | ||
// Scheme is the scheme used to resolve runtime.Objects to GroupVersionKinds / Resources | ||
// Defaults to the kubernetes/client-go scheme.Scheme, but it's almost always better | ||
// idea to pass your own scheme in. See the documentation in pkg/scheme for more information. | ||
Scheme *runtime.Scheme | ||
// Logger to be used by the webhook. | ||
// If none is set, it defaults to log.Log global logger. | ||
Logger logr.Logger | ||
// Path the webhook will be served at. | ||
// Used for labelling prometheus metrics. | ||
Path string | ||
} | ||
|
||
// StandaloneWebhook transforms a Webhook that needs to be registered | ||
// on a webhook.Server into one that can be ran on any arbitrary mux. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This documentation is technically correct but slightly unclear. I'd
// StandaloneWebhook prepares a webhook for use without a webhook.Server, passing in the information normally
// populated by webhook.Server and instrumenting the webhook with metrics.
//
// Use this to attach your webhook to an arbitrary HTTP server or mux. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sounds good |
||
func StandaloneWebhook(hook *Webhook, opts StandaloneOptions) (http.Handler, error) { | ||
if opts.Scheme == nil { | ||
opts.Scheme = scheme.Scheme | ||
} | ||
|
||
var err error | ||
hook.decoder, err = NewDecoder(opts.Scheme) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kevindelgado what actually injects this into the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤦 It doesn't... because I'm dumb See #1490 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fyi, fixed now @stevekuznetsov |
||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
if opts.Logger == nil { | ||
opts.Logger = logf.RuntimeLog.WithName("webhook") | ||
} | ||
hook.log = opts.Logger | ||
|
||
if opts.Path == "" { | ||
return hook, nil | ||
} | ||
return InstrumentedHook(opts.Path, hook), nil | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this instrument since we don't actually know the path? I almost feel like it should just be up to the caller to do this, or the caller's webhook server, but I'm open to opinions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My thinking was that there is no reason why the caller can't leave out the path and call |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -29,11 +29,11 @@ import ( | |
"strconv" | ||
"sync" | ||
|
||
"github.com/prometheus/client_golang/prometheus" | ||
"github.com/prometheus/client_golang/prometheus/promhttp" | ||
"k8s.io/apimachinery/pkg/runtime" | ||
"k8s.io/client-go/kubernetes/scheme" | ||
"sigs.k8s.io/controller-runtime/pkg/certwatcher" | ||
"sigs.k8s.io/controller-runtime/pkg/runtime/inject" | ||
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics" | ||
"sigs.k8s.io/controller-runtime/pkg/webhook/admission" | ||
) | ||
|
||
// DefaultPort is the default port that the webhook server serves. | ||
|
@@ -105,6 +105,67 @@ func (s *Server) setDefaults() { | |
} | ||
} | ||
|
||
// Options are the subset of fields on the controller that can be | ||
// configured when running an unmanaged webhook server (i.e. webhook.NewUnmanaged()) | ||
type Options struct { | ||
// Host is the address that the server will listen on. | ||
// Defaults to "" - all addresses. | ||
Host string | ||
|
||
// Port is the port number that the server will serve. | ||
// It will be defaulted to 9443 if unspecified. | ||
Port int | ||
|
||
// CertDir is the directory that contains the server key and certificate. The | ||
// server key and certificate. | ||
CertDir string | ||
|
||
// CertName is the server certificate name. Defaults to tls.crt. | ||
CertName string | ||
|
||
// KeyName is the server key name. Defaults to tls.key. | ||
KeyName string | ||
|
||
// ClientCAName is the CA certificate name which server used to verify remote(client)'s certificate. | ||
// Defaults to "", which means server does not verify client's certificate. | ||
ClientCAName string | ||
|
||
// WebhookMux is the multiplexer that handles different webhooks. | ||
WebhookMux *http.ServeMux | ||
|
||
// Scheme is the scheme used to resolve runtime.Objects to GroupVersionKinds / Resources | ||
// Defaults to the kubernetes/client-go scheme.Scheme, but it's almost always better | ||
// idea to pass your own scheme in. See the documentation in pkg/scheme for more information. | ||
Scheme *runtime.Scheme | ||
} | ||
|
||
// NewUnmanaged provides a webhook server that can be ran without | ||
// a controller manager. | ||
func NewUnmanaged(options Options) (*Server, error) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: rename to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. actually, since server is designed to be initialized from a struct, I think we could even switch this around and have a method called just So something like func Standalone(server *Server) error {
// ...
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or even func (s *Server) Standalone() error { /* ... */ } There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm, thinking about this more, It still needs the scheme though so I don't think Thus I could propose something like this:
but if all it is doing is injecting the scheme, why don't we just name it If that is the case, then I can think of two other options:
Alternatively, we could just get rid of the whole standalone concept, because it's clear to me that the |
||
server := &Server{ | ||
Host: options.Host, | ||
Port: options.Port, | ||
CertDir: options.CertDir, | ||
CertName: options.CertName, | ||
KeyName: options.KeyName, | ||
WebhookMux: options.WebhookMux, | ||
} | ||
server.setDefaults() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this must be called under the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm dumb and forgot that it gets called on |
||
// Use the Kubernetes client-go scheme if none is specified | ||
if options.Scheme == nil { | ||
options.Scheme = scheme.Scheme | ||
} | ||
|
||
// TODO: can we do this without dep injection? | ||
server.InjectFunc(func(i interface{}) error { | ||
if _, err := inject.SchemeInto(options.Scheme, i); err != nil { | ||
return err | ||
} | ||
return nil | ||
}) | ||
return server, nil | ||
} | ||
|
||
// NeedLeaderElection implements the LeaderElectionRunnable interface, which indicates | ||
// the webhook server doesn't need leader election. | ||
func (*Server) NeedLeaderElection() bool { | ||
|
@@ -124,7 +185,7 @@ func (s *Server) Register(path string, hook http.Handler) { | |
} | ||
// TODO(directxman12): call setfields if we've already started the server | ||
s.webhooks[path] = hook | ||
s.WebhookMux.Handle(path, instrumentedHook(path, hook)) | ||
s.WebhookMux.Handle(path, admission.InstrumentedHook(path, hook)) | ||
|
||
regLog := log.WithValues("path", path) | ||
regLog.Info("registering webhook") | ||
|
@@ -149,27 +210,6 @@ func (s *Server) Register(path string, hook http.Handler) { | |
} | ||
} | ||
|
||
// instrumentedHook adds some instrumentation on top of the given webhook. | ||
func instrumentedHook(path string, hookRaw http.Handler) http.Handler { | ||
lbl := prometheus.Labels{"webhook": path} | ||
|
||
lat := metrics.RequestLatency.MustCurryWith(lbl) | ||
cnt := metrics.RequestTotal.MustCurryWith(lbl) | ||
gge := metrics.RequestInFlight.With(lbl) | ||
|
||
// Initialize the most likely HTTP status codes. | ||
cnt.WithLabelValues("200") | ||
cnt.WithLabelValues("500") | ||
|
||
return promhttp.InstrumentHandlerDuration( | ||
lat, | ||
promhttp.InstrumentHandlerCounter( | ||
cnt, | ||
promhttp.InstrumentHandlerInFlight(gge, hookRaw), | ||
), | ||
) | ||
} | ||
|
||
// Start runs the server. | ||
// It will install the webhook related resources depend on the server configuration. | ||
func (s *Server) Start(ctx context.Context) error { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -174,6 +174,34 @@ var _ = Describe("Webhook Server", func() { | |
Expect(handler.injectedField).To(BeTrue()) | ||
}) | ||
}) | ||
|
||
Context("when using an unmanaged webhook server", func() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I'd just write this as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
It("should serve a webhook on the requested path", func() { | ||
opts := webhook.Options{ | ||
Host: servingOpts.LocalServingHost, | ||
Port: servingOpts.LocalServingPort, | ||
CertDir: servingOpts.LocalServingCertDir, | ||
} | ||
var err error | ||
// overwrite the server so that startServer() starts it | ||
server, err = webhook.NewUnmanaged(opts) | ||
|
||
Expect(err).NotTo(HaveOccurred()) | ||
server.Register("/somepath", &testHandler{}) | ||
doneCh := startServer() | ||
|
||
Eventually(func() ([]byte, error) { | ||
resp, err := client.Get(fmt.Sprintf("https://%s/somepath", testHostPort)) | ||
Expect(err).NotTo(HaveOccurred()) | ||
defer resp.Body.Close() | ||
return ioutil.ReadAll(resp.Body) | ||
}).Should(Equal([]byte("gadzooks!"))) | ||
|
||
ctxCancel() | ||
Eventually(doneCh, "4s").Should(BeClosed()) | ||
}) | ||
|
||
}) | ||
}) | ||
|
||
type testHandler struct { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I moved
InstrumentedHook
from server.go here intoadmission/webhook.go
because I'm using it inStandaloneWebhook
but also because it seemed more appropriate because its wrapping instrumentation around the handler rather than the server stuff. Let me know if you think somewhere else is more appropriate.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this applies to all webhooks, not just admission ones, so it belongs in
pkg/webhook
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
or you could put it in a subpackage to avoid import loops, if necessary, like
pkg/webhook/internal/metrics
or somethingThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved