Skip to content
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

Merged
merged 6 commits into from Apr 21, 2021

Conversation

kevindelgado
Copy link
Contributor

@kevindelgado kevindelgado commented Mar 16, 2021

This adds helper functions to setup webhooks server without a manager. It adds

  1. webhook.Unmanaged that creates a webhook.Server without a manager / cluster connection information
  2. admission.StandalonWebhook that creates an http.Handler that can be run on any arbitrary Go HTTP server (http.SeverMux) rather than a webhook.Server)

Fixes #1255

@kevindelgado
Copy link
Contributor Author

It’s worth noting that dependency injection is in the process of being deprecated.

I plan to follow this up with an issue/attempt at doing this without dependency injection. This is more involved though (and probably touches a lot of how the webhook code is organized) and it doesn’t seem like we should prevent publishing a simple helper for unmanaged webhook servers until dependency injection is sorted out.

@k8s-ci-robot k8s-ci-robot added the cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. label Mar 16, 2021
@k8s-ci-robot
Copy link
Contributor

Hi @kevindelgado. Thanks for your PR.

I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes/test-infra repository.

@k8s-ci-robot k8s-ci-robot added needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Mar 16, 2021
@kevindelgado
Copy link
Contributor Author

/assign @DirectXMan12

Copy link
Contributor Author

@kevindelgado kevindelgado left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some questions inline PTAL @DirectXMan12


// NewUnmanaged provides a webhook server that can be ran without
// a controller manager.
func NewUnmanaged(cluster cluster.Cluster, options Options) (*Server, error) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just pass a server directly instead of creating this new Options type? It’s weird because Server is publicly exposed whereas in the other types it’s usually an internal type.

I think eventually we probably want to more closely align with how other packages handle options (and default the options instead of the server itself), right? Not sure what's best for right now though.

})
})
Context("when running a webhook server without a manager ", func() {
It("should reject create request for webhook that rejects all requests", func(done Done) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a more canonical way of running the same assertions across different contexts (instead of duplicating everything after the server creation in the It() statements)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either JustBeforeEach and BeforeEach, or use a helper method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, now that I've changed added StartStandalone the tests feel just different enough to not make it worth using a helper or forcing deduplication. The tests just seem more readable as is, but let me know if you think there's anything you'd want to factor out.

Copy link
Contributor Author

@kevindelgado kevindelgado left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added StandaloneWebhook helper for attach c-r webhooks to an existing arbitrary http.ServeMux (part two of #1255 (comment)). PTAL @DirectXMan12

@@ -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 {
Copy link
Contributor Author

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 into admission/webhook.go because I'm using it in StandaloneWebhook 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.

Copy link
Contributor

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.

Copy link
Contributor

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 something

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved

})
})
Context("when running a standalone webhook", func() {
It("should reject create request for webhook that rejects all requests", func(done Done) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only test I have for StandaloneWebhook. I tried to pull the minimal setup I could out of server.go#Start to mimic what an arbitrary Go HTTP server could look like. Let me know if you had something else in mind for testing it.

@rohitagarwal003
Copy link

Can you also add some docs on who to use this? It's hard to discover these without these being in the docs.

@k8s-ci-robot k8s-ci-robot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Mar 22, 2021
@k8s-ci-robot k8s-ci-robot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Mar 22, 2021
Copy link
Contributor

@DirectXMan12 DirectXMan12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also probably have a couple godoc examples (see example_test.go files) and a bit more verbose docs on when you might want to use these.

@@ -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 {
Copy link
Contributor

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.

@@ -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 {
Copy link
Contributor

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 something

}

// StandaloneWebhook transforms a Webhook that needs to be registered
// on a webhook.Server into one that can be ran on any arbitrary mux.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documentation is technically correct but slightly unclear. I'd

  1. Update the documentation on Webhook to mention that it needs to be registered with a webhook.Server or populated by StandaloneWebhook
  2. maybe reword to something like
// 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good

if opts.Path == "" {
return hook, nil
}
return InstrumentedHook(opts.Path, hook), nil
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 InstrumentedHook themselves, but if they do supply a path in the options, it would be nice to do it for them?


// NewUnmanaged provides a webhook server that can be ran without
// a controller manager.
func NewUnmanaged(options Options) (*Server, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: rename to NewStandaloneServer or something?

Copy link
Contributor

Choose a reason for hiding this comment

The 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 Standalone that took an existing server instance. That way we don't need an extra options struct, etc.

So something like

func Standalone(server *Server) error {
  // ...
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or even

func (s *Server) Standalone() error { /* ... */ }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, thinking about this more, Standalone() does not really do anything other than inject the scheme.

It still needs the scheme though so I don't think Standalone() with no arguments will work unless we add a Scheme field to the Server struct.

Thus I could propose something like this:

func (s *Server) Standalone(scheme *runtime.Scheme) error {
        // Use the Kubernetes client-go scheme if none is specified
        if scheme == nil {
                scheme = kscheme.Scheme
        }
        return nil

        return s.InjectFunc(func(i interface{}) error {
                if _, err := inject.SchemeInto(scheme, i); err != nil {
                        return err
                }
                return nil
        })
}

but if all it is doing is injecting the scheme, why don't we just name it InjectScheme since that's what it's doing? Is it because it's not obvious that to get a standalone server all you need to do is initialize a server struct and call InjectScheme on it?

If that is the case, then I can think of two other options:

  1. Add scheme field to the Server struct and inject it upon calling Start. My concern here is that this would override the scheme that is set by the manager. (we could do something like only inject it in Start if Server.Scheme is not nil, but that seems like a footgun).
  2. Instead of Standalone() modifying the server and requiring the caller to then call Start on it, we just make the call be StartStandalone(ctx, scheme) which injects the scheme and calls start for when you have a Server, but want to run it without a manager. I like this the best, but let me know what you think

Alternatively, we could just get rid of the whole standalone concept, because it's clear to me that the StandaloneWebhook is the more interesting use case and so we just tell people, if you want to run a webhook without a manager, you can either bring your own server/mux or if you really want use our server, just inject the

KeyName: options.KeyName,
WebhookMux: options.WebhookMux,
}
server.setDefaults()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this must be called under the defaultingOnce. That aside, why are you calling this here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm dumb and forgot that it gets called on Start(). Removed

@@ -174,6 +174,34 @@ var _ = Describe("Webhook Server", func() {
Expect(handler.injectedField).To(BeTrue())
})
})

Context("when using an unmanaged webhook server", func() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd just write this as it should be able to serve in unmanaged mode (no need for the context, IMO)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

})
})
Context("when running a webhook server without a manager ", func() {
It("should reject create request for webhook that rejects all requests", func(done Done) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either JustBeforeEach and BeforeEach, or use a helper method.

Copy link
Contributor Author

@kevindelgado kevindelgado left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed all feedback and added examples to example_test.go. Let me know if you think there's anywhere else I should document how/why to use this.

PTAL @DirectXMan12

@@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved

if opts.Path == "" {
return hook, nil
}
return InstrumentedHook(opts.Path, hook), nil
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 InstrumentedHook themselves, but if they do supply a path in the options, it would be nice to do it for them?


// NewUnmanaged provides a webhook server that can be ran without
// a controller manager.
func NewUnmanaged(options Options) (*Server, error) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, thinking about this more, Standalone() does not really do anything other than inject the scheme.

It still needs the scheme though so I don't think Standalone() with no arguments will work unless we add a Scheme field to the Server struct.

Thus I could propose something like this:

func (s *Server) Standalone(scheme *runtime.Scheme) error {
        // Use the Kubernetes client-go scheme if none is specified
        if scheme == nil {
                scheme = kscheme.Scheme
        }
        return nil

        return s.InjectFunc(func(i interface{}) error {
                if _, err := inject.SchemeInto(scheme, i); err != nil {
                        return err
                }
                return nil
        })
}

but if all it is doing is injecting the scheme, why don't we just name it InjectScheme since that's what it's doing? Is it because it's not obvious that to get a standalone server all you need to do is initialize a server struct and call InjectScheme on it?

If that is the case, then I can think of two other options:

  1. Add scheme field to the Server struct and inject it upon calling Start. My concern here is that this would override the scheme that is set by the manager. (we could do something like only inject it in Start if Server.Scheme is not nil, but that seems like a footgun).
  2. Instead of Standalone() modifying the server and requiring the caller to then call Start on it, we just make the call be StartStandalone(ctx, scheme) which injects the scheme and calls start for when you have a Server, but want to run it without a manager. I like this the best, but let me know what you think

Alternatively, we could just get rid of the whole standalone concept, because it's clear to me that the StandaloneWebhook is the more interesting use case and so we just tell people, if you want to run a webhook without a manager, you can either bring your own server/mux or if you really want use our server, just inject the

KeyName: options.KeyName,
WebhookMux: options.WebhookMux,
}
server.setDefaults()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm dumb and forgot that it gets called on Start(). Removed

@@ -174,6 +174,34 @@ var _ = Describe("Webhook Server", func() {
Expect(handler.injectedField).To(BeTrue())
})
})

Context("when using an unmanaged webhook server", func() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

})
})
Context("when running a webhook server without a manager ", func() {
It("should reject create request for webhook that rejects all requests", func(done Done) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, now that I've changed added StartStandalone the tests feel just different enough to not make it worth using a helper or forcing deduplication. The tests just seem more readable as is, but let me know if you think there's anything you'd want to factor out.

}

// StandaloneWebhook transforms a Webhook that needs to be registered
// on a webhook.Server into one that can be ran on any arbitrary mux.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good

@stevekuznetsov
Copy link
Contributor

@kevindelgado 👋

Copy link
Contributor

@DirectXMan12 DirectXMan12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm
/approve

Expect(err).NotTo(HaveOccurred())
http.Handle("/failing", hook)

// run the http server
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the future, use By("running the http server") instead of just comments like this. No need to fix this now though

@k8s-ci-robot k8s-ci-robot added the lgtm "Looks good to me", indicates that a PR is ready to be merged. label Apr 21, 2021
@k8s-ci-robot
Copy link
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: DirectXMan12, kevindelgado

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@k8s-ci-robot k8s-ci-robot added the approved Indicates a PR has been approved by an approver from all required OWNERS files. label Apr 21, 2021
@k8s-ci-robot k8s-ci-robot merged commit d5d2551 into kubernetes-sigs:master Apr 21, 2021
@k8s-ci-robot k8s-ci-robot added this to the v0.9.x milestone Apr 21, 2021
}

var err error
hook.decoder, err = NewDecoder(opts.Scheme)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevindelgado what actually injects this into the hook.Handler?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦 It doesn't... because I'm dumb

See #1490

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi, fixed now @stevekuznetsov

hookServer.Register("/validating", validatingHook)

// Start the server without a manger
err := hookServer.StartStandalone(signals.SetupSignalHandler(), scheme.Scheme)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevindelgado one more thing - maybe we could write an e2e for examples to help catch these, but it looks like the cert and key are not optional on this server? When using code similar to this, I get:

time="2021-04-23T20:34:54Z" level=error msg="Failed to serve admission webhooks." error="open /tmp/k8s-webhook-server/serving-certs/tls.crt: no such file or directory"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's intentional -- same with the non-standalone server. More broadly, the main point of the webhook server is to help with managing TLS setup properly. If you don't want TLS, use StandaloneWebhook.

We could certainly document that better, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevindelgado kevindelgado changed the title ✨ Simple helper for unmanaged webhook server ✨ Helper for unmanaged webhook server Aug 16, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
approved Indicates a PR has been approved by an approver from all required OWNERS files. cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. lgtm "Looks good to me", indicates that a PR is ready to be merged. needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

It should be a bit easier to setup webhook handlers without a manger
5 participants