diff --git a/pkg/webhook/server.go b/pkg/webhook/server.go index 721df490a0..bc7e19a9a3 100644 --- a/pkg/webhook/server.go +++ b/pkg/webhook/server.go @@ -31,6 +31,8 @@ import ( "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/runtime/inject" "sigs.k8s.io/controller-runtime/pkg/webhook/internal/certwatcher" "sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics" @@ -105,6 +107,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) { + server := &Server{ + Host: options.Host, + Port: options.Port, + CertDir: options.CertDir, + CertName: options.CertName, + KeyName: options.KeyName, + WebhookMux: options.WebhookMux, + } + server.setDefaults() + // 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 { diff --git a/pkg/webhook/server_test.go b/pkg/webhook/server_test.go index eccc438ef4..75801b0a25 100644 --- a/pkg/webhook/server_test.go +++ b/pkg/webhook/server_test.go @@ -174,6 +174,34 @@ var _ = Describe("Webhook Server", func() { Expect(handler.injectedField).To(BeTrue()) }) }) + + Context("when using an unmanaged webhook server", func() { + 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 { diff --git a/pkg/webhook/webhook_integration_test.go b/pkg/webhook/webhook_integration_test.go new file mode 100644 index 0000000000..0aa5207eb1 --- /dev/null +++ b/pkg/webhook/webhook_integration_test.go @@ -0,0 +1,113 @@ +package webhook_test + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var _ = Describe("Webhook", func() { + var c client.Client + var obj *appsv1.Deployment + BeforeEach(func() { + Expect(cfg).NotTo(BeNil()) + var err error + c, err = client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + + obj = &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apps/v1", + Kind: "Deployment", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "default", + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + } + }) + Context("when running a webhook server with a manager", func() { + It("should reject create request for webhook that rejects all requests", func(done Done) { + m, err := manager.New(cfg, manager.Options{ + Port: testenv.WebhookInstallOptions.LocalServingPort, + Host: testenv.WebhookInstallOptions.LocalServingHost, + CertDir: testenv.WebhookInstallOptions.LocalServingCertDir, + }) // we need manager here just to leverage manager.SetFields + Expect(err).NotTo(HaveOccurred()) + server := m.GetWebhookServer() + server.Register("/failing", &webhook.Admission{Handler: &rejectingValidator{}}) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + _ = server.Start(ctx) + }() + + Eventually(func() bool { + err = c.Create(context.TODO(), obj) + return errors.ReasonForError(err) == metav1.StatusReason("Always denied") + }, 1*time.Second).Should(BeTrue()) + + cancel() + close(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) { + opts := webhook.Options{ + Port: testenv.WebhookInstallOptions.LocalServingPort, + Host: testenv.WebhookInstallOptions.LocalServingHost, + CertDir: testenv.WebhookInstallOptions.LocalServingCertDir, + } + server, err := webhook.NewUnmanaged(opts) + Expect(err).NotTo(HaveOccurred()) + server.Register("/failing", &webhook.Admission{Handler: &rejectingValidator{}}) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + _ = server.Start(ctx) + }() + + Eventually(func() bool { + err = c.Create(context.TODO(), obj) + return errors.ReasonForError(err) == metav1.StatusReason("Always denied") + }, 1*time.Second).Should(BeTrue()) + + cancel() + close(done) + }) + }) +}) + +type rejectingValidator struct { +} + +func (v *rejectingValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + return admission.Denied(fmt.Sprint("Always denied")) +} diff --git a/pkg/webhook/webhook_suite_test.go b/pkg/webhook/webhook_suite_test.go index d921493f0e..fb2b02f195 100644 --- a/pkg/webhook/webhook_suite_test.go +++ b/pkg/webhook/webhook_suite_test.go @@ -17,11 +17,17 @@ limitations under the License. package webhook_test import ( + "fmt" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + admissionv1 "k8s.io/api/admissionregistration/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/envtest/printer" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -33,8 +39,70 @@ func TestSource(t *testing.T) { RunSpecsWithDefaultAndCustomReporters(t, suiteName, []Reporter{printer.NewlineReporter{}, printer.NewProwReporter(suiteName)}) } +var testenv *envtest.Environment +var cfg *rest.Config + var _ = BeforeSuite(func(done Done) { logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + testenv = &envtest.Environment{} + // we're initializing webhook here and not in webhook.go to also test the envtest install code via WebhookOptions + initializeWebhookInEnvironment() + var err error + cfg, err = testenv.Start() + Expect(err).NotTo(HaveOccurred()) close(done) }, 60) + +var _ = AfterSuite(func() { + fmt.Println("stopping?") + Expect(testenv.Stop()).To(Succeed()) +}, 60) + +func initializeWebhookInEnvironment() { + namespacedScopeV1 := admissionv1.NamespacedScope + failedTypeV1 := admissionv1.Fail + equivalentTypeV1 := admissionv1.Equivalent + noSideEffectsV1 := admissionv1.SideEffectClassNone + webhookPathV1 := "/failing" + + testenv.WebhookInstallOptions = envtest.WebhookInstallOptions{ + ValidatingWebhooks: []client.Object{ + &admissionv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deployment-validation-webhook-config", + }, + TypeMeta: metav1.TypeMeta{ + Kind: "ValidatingWebhookConfiguration", + APIVersion: "admissionregistration.k8s.io/v1beta1", + }, + Webhooks: []admissionv1.ValidatingWebhook{ + { + Name: "deployment-validation.kubebuilder.io", + Rules: []admissionv1.RuleWithOperations{ + { + Operations: []admissionv1.OperationType{"CREATE", "UPDATE"}, + Rule: admissionv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"v1"}, + Resources: []string{"deployments"}, + Scope: &namespacedScopeV1, + }, + }, + }, + FailurePolicy: &failedTypeV1, + MatchPolicy: &equivalentTypeV1, + SideEffects: &noSideEffectsV1, + ClientConfig: admissionv1.WebhookClientConfig{ + Service: &admissionv1.ServiceReference{ + Name: "deployment-validation-service", + Namespace: "default", + Path: &webhookPathV1, + }, + }, + }, + }, + }, + }, + } +}