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 16, 2021
1 parent b125a18 commit 4f05a36
Show file tree
Hide file tree
Showing 5 changed files with 288 additions and 0 deletions.
5 changes: 5 additions & 0 deletions pkg/cluster/cluster.go
Expand Up @@ -204,6 +204,11 @@ func New(config *rest.Config, opts ...Option) (Cluster, error) {
}, nil
}

// NewFakeCluster constructs an empty cluster for testing
func NewFakeCluster() Cluster {
return &cluster{}
}

// setOptionsDefaults set default values for Options fields
func setOptionsDefaults(options Options) Options {
// Use the Kubernetes client-go scheme if none is specified
Expand Down
68 changes: 68 additions & 0 deletions pkg/webhook/server.go
Expand Up @@ -31,6 +31,7 @@ import (

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sigs.k8s.io/controller-runtime/pkg/cluster"
"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 +106,73 @@ 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
}

// NewUnmanaged provides a webhook server that can be ran without
// a controller manager.
func NewUnmanaged(cluster cluster.Cluster, options Options) (*Server, error) {
server := &Server{
Host: options.Host,
WebhookMux: options.WebhookMux,
Port: options.Port,
CertDir: options.CertDir,
CertName: options.CertName,
KeyName: options.KeyName,
}
server.setDefaults()

server.InjectFunc(func(i interface{}) error {
if _, err := inject.ConfigInto(cluster.GetConfig(), i); err != nil {
return err
}
if _, err := inject.SchemeInto(cluster.GetScheme(), i); err != nil {
return err
}

if _, err := inject.ClientInto(cluster.GetClient(), i); err != nil {
return err
}
if _, err := inject.APIReaderInto(cluster.GetAPIReader(), i); err != nil {
return err
}
if _, err := inject.CacheInto(cluster.GetCache(), i); err != nil {
return err
}
if _, err := inject.MapperInto(cluster.GetRESTMapper(), 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
29 changes: 29 additions & 0 deletions pkg/webhook/server_test.go
Expand Up @@ -26,6 +26,7 @@ import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/cluster"
"sigs.k8s.io/controller-runtime/pkg/envtest"
"sigs.k8s.io/controller-runtime/pkg/webhook"
)
Expand Down Expand Up @@ -174,6 +175,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(cluster.NewFakeCluster(), 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
118 changes: 118 additions & 0 deletions pkg/webhook/webhook_integration_test.go
@@ -0,0 +1,118 @@
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/cluster"
"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) {
cluster, err := cluster.New(cfg, func(clusterOptions *cluster.Options) {})
Expect(err).NotTo(HaveOccurred())

opts := webhook.Options{
Port: testenv.WebhookInstallOptions.LocalServingPort,
Host: testenv.WebhookInstallOptions.LocalServingHost,
CertDir: testenv.WebhookInstallOptions.LocalServingCertDir,
}
server, err := webhook.NewUnmanaged(cluster, 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)
fmt.Println("SUCCESS?")
})
})
})

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 4f05a36

Please sign in to comment.