Skip to content

Commit

Permalink
Simple helper for unmanaged webhook server
Browse files Browse the repository at this point in the history
  • Loading branch information
kevindelgado committed Mar 17, 2021
1 parent b125a18 commit 5e23ce1
Show file tree
Hide file tree
Showing 4 changed files with 272 additions and 0 deletions.
63 changes: 63 additions & 0 deletions pkg/webhook/server.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions pkg/webhook/server_test.go
Expand Up @@ -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 {
Expand Down
113 changes: 113 additions & 0 deletions 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"))
}
68 changes: 68 additions & 0 deletions pkg/webhook/webhook_suite_test.go
Expand Up @@ -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"
Expand All @@ -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,
},
},
},
},
},
},
}
}

0 comments on commit 5e23ce1

Please sign in to comment.