Skip to content

Commit 83846f5

Browse files
committed
Simple helper for unmanaged webhook server
1 parent 197751d commit 83846f5

File tree

4 files changed

+272
-0
lines changed

4 files changed

+272
-0
lines changed

pkg/webhook/server.go

+63
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import (
3131

3232
"github.com/prometheus/client_golang/prometheus"
3333
"github.com/prometheus/client_golang/prometheus/promhttp"
34+
"k8s.io/apimachinery/pkg/runtime"
35+
"k8s.io/client-go/kubernetes/scheme"
3436
"sigs.k8s.io/controller-runtime/pkg/certwatcher"
3537
"sigs.k8s.io/controller-runtime/pkg/runtime/inject"
3638
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics"
@@ -105,6 +107,67 @@ func (s *Server) setDefaults() {
105107
}
106108
}
107109

110+
// Options are the subset of fields on the controller that can be
111+
// configured when running an unmanaged webhook server (i.e. webhook.NewUnmanaged())
112+
type Options struct {
113+
// Host is the address that the server will listen on.
114+
// Defaults to "" - all addresses.
115+
Host string
116+
117+
// Port is the port number that the server will serve.
118+
// It will be defaulted to 9443 if unspecified.
119+
Port int
120+
121+
// CertDir is the directory that contains the server key and certificate. The
122+
// server key and certificate.
123+
CertDir string
124+
125+
// CertName is the server certificate name. Defaults to tls.crt.
126+
CertName string
127+
128+
// KeyName is the server key name. Defaults to tls.key.
129+
KeyName string
130+
131+
// ClientCAName is the CA certificate name which server used to verify remote(client)'s certificate.
132+
// Defaults to "", which means server does not verify client's certificate.
133+
ClientCAName string
134+
135+
// WebhookMux is the multiplexer that handles different webhooks.
136+
WebhookMux *http.ServeMux
137+
138+
// Scheme is the scheme used to resolve runtime.Objects to GroupVersionKinds / Resources
139+
// Defaults to the kubernetes/client-go scheme.Scheme, but it's almost always better
140+
// idea to pass your own scheme in. See the documentation in pkg/scheme for more information.
141+
Scheme *runtime.Scheme
142+
}
143+
144+
// NewUnmanaged provides a webhook server that can be ran without
145+
// a controller manager.
146+
func NewUnmanaged(options Options) (*Server, error) {
147+
server := &Server{
148+
Host: options.Host,
149+
Port: options.Port,
150+
CertDir: options.CertDir,
151+
CertName: options.CertName,
152+
KeyName: options.KeyName,
153+
WebhookMux: options.WebhookMux,
154+
}
155+
server.setDefaults()
156+
// Use the Kubernetes client-go scheme if none is specified
157+
if options.Scheme == nil {
158+
options.Scheme = scheme.Scheme
159+
}
160+
161+
// TODO: can we do this without dep injection?
162+
server.InjectFunc(func(i interface{}) error {
163+
if _, err := inject.SchemeInto(options.Scheme, i); err != nil {
164+
return err
165+
}
166+
return nil
167+
})
168+
return server, nil
169+
}
170+
108171
// NeedLeaderElection implements the LeaderElectionRunnable interface, which indicates
109172
// the webhook server doesn't need leader election.
110173
func (*Server) NeedLeaderElection() bool {

pkg/webhook/server_test.go

+28
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,34 @@ var _ = Describe("Webhook Server", func() {
174174
Expect(handler.injectedField).To(BeTrue())
175175
})
176176
})
177+
178+
Context("when using an unmanaged webhook server", func() {
179+
It("should serve a webhook on the requested path", func() {
180+
opts := webhook.Options{
181+
Host: servingOpts.LocalServingHost,
182+
Port: servingOpts.LocalServingPort,
183+
CertDir: servingOpts.LocalServingCertDir,
184+
}
185+
var err error
186+
// overwrite the server so that startServer() starts it
187+
server, err = webhook.NewUnmanaged(opts)
188+
189+
Expect(err).NotTo(HaveOccurred())
190+
server.Register("/somepath", &testHandler{})
191+
doneCh := startServer()
192+
193+
Eventually(func() ([]byte, error) {
194+
resp, err := client.Get(fmt.Sprintf("https://%s/somepath", testHostPort))
195+
Expect(err).NotTo(HaveOccurred())
196+
defer resp.Body.Close()
197+
return ioutil.ReadAll(resp.Body)
198+
}).Should(Equal([]byte("gadzooks!")))
199+
200+
ctxCancel()
201+
Eventually(doneCh, "4s").Should(BeClosed())
202+
})
203+
204+
})
177205
})
178206

179207
type testHandler struct {
+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package webhook_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
. "github.com/onsi/ginkgo"
9+
. "github.com/onsi/gomega"
10+
appsv1 "k8s.io/api/apps/v1"
11+
corev1 "k8s.io/api/core/v1"
12+
"k8s.io/apimachinery/pkg/api/errors"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"sigs.k8s.io/controller-runtime/pkg/client"
15+
"sigs.k8s.io/controller-runtime/pkg/manager"
16+
"sigs.k8s.io/controller-runtime/pkg/webhook"
17+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
18+
)
19+
20+
var _ = Describe("Webhook", func() {
21+
var c client.Client
22+
var obj *appsv1.Deployment
23+
BeforeEach(func() {
24+
Expect(cfg).NotTo(BeNil())
25+
var err error
26+
c, err = client.New(cfg, client.Options{})
27+
Expect(err).NotTo(HaveOccurred())
28+
29+
obj = &appsv1.Deployment{
30+
TypeMeta: metav1.TypeMeta{
31+
APIVersion: "apps/v1",
32+
Kind: "Deployment",
33+
},
34+
ObjectMeta: metav1.ObjectMeta{
35+
Name: "test-deployment",
36+
Namespace: "default",
37+
},
38+
Spec: appsv1.DeploymentSpec{
39+
Selector: &metav1.LabelSelector{
40+
MatchLabels: map[string]string{"foo": "bar"},
41+
},
42+
Template: corev1.PodTemplateSpec{
43+
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"foo": "bar"}},
44+
Spec: corev1.PodSpec{
45+
Containers: []corev1.Container{
46+
{
47+
Name: "nginx",
48+
Image: "nginx",
49+
},
50+
},
51+
},
52+
},
53+
},
54+
}
55+
})
56+
Context("when running a webhook server with a manager", func() {
57+
It("should reject create request for webhook that rejects all requests", func(done Done) {
58+
m, err := manager.New(cfg, manager.Options{
59+
Port: testenv.WebhookInstallOptions.LocalServingPort,
60+
Host: testenv.WebhookInstallOptions.LocalServingHost,
61+
CertDir: testenv.WebhookInstallOptions.LocalServingCertDir,
62+
}) // we need manager here just to leverage manager.SetFields
63+
Expect(err).NotTo(HaveOccurred())
64+
server := m.GetWebhookServer()
65+
server.Register("/failing", &webhook.Admission{Handler: &rejectingValidator{}})
66+
67+
ctx, cancel := context.WithCancel(context.Background())
68+
go func() {
69+
_ = server.Start(ctx)
70+
}()
71+
72+
Eventually(func() bool {
73+
err = c.Create(context.TODO(), obj)
74+
return errors.ReasonForError(err) == metav1.StatusReason("Always denied")
75+
}, 1*time.Second).Should(BeTrue())
76+
77+
cancel()
78+
close(done)
79+
})
80+
})
81+
Context("when running a webhook server without a manager ", func() {
82+
It("should reject create request for webhook that rejects all requests", func(done Done) {
83+
opts := webhook.Options{
84+
Port: testenv.WebhookInstallOptions.LocalServingPort,
85+
Host: testenv.WebhookInstallOptions.LocalServingHost,
86+
CertDir: testenv.WebhookInstallOptions.LocalServingCertDir,
87+
}
88+
server, err := webhook.NewUnmanaged(opts)
89+
Expect(err).NotTo(HaveOccurred())
90+
server.Register("/failing", &webhook.Admission{Handler: &rejectingValidator{}})
91+
92+
ctx, cancel := context.WithCancel(context.Background())
93+
go func() {
94+
_ = server.Start(ctx)
95+
}()
96+
97+
Eventually(func() bool {
98+
err = c.Create(context.TODO(), obj)
99+
return errors.ReasonForError(err) == metav1.StatusReason("Always denied")
100+
}, 1*time.Second).Should(BeTrue())
101+
102+
cancel()
103+
close(done)
104+
})
105+
})
106+
})
107+
108+
type rejectingValidator struct {
109+
}
110+
111+
func (v *rejectingValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
112+
return admission.Denied(fmt.Sprint("Always denied"))
113+
}

pkg/webhook/webhook_suite_test.go

+68
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@ limitations under the License.
1717
package webhook_test
1818

1919
import (
20+
"fmt"
2021
"testing"
2122

2223
. "github.com/onsi/ginkgo"
2324
. "github.com/onsi/gomega"
25+
admissionv1 "k8s.io/api/admissionregistration/v1"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/client-go/rest"
2428

29+
"sigs.k8s.io/controller-runtime/pkg/client"
30+
"sigs.k8s.io/controller-runtime/pkg/envtest"
2531
"sigs.k8s.io/controller-runtime/pkg/envtest/printer"
2632
logf "sigs.k8s.io/controller-runtime/pkg/log"
2733
"sigs.k8s.io/controller-runtime/pkg/log/zap"
@@ -33,8 +39,70 @@ func TestSource(t *testing.T) {
3339
RunSpecsWithDefaultAndCustomReporters(t, suiteName, []Reporter{printer.NewlineReporter{}, printer.NewProwReporter(suiteName)})
3440
}
3541

42+
var testenv *envtest.Environment
43+
var cfg *rest.Config
44+
3645
var _ = BeforeSuite(func(done Done) {
3746
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
3847

48+
testenv = &envtest.Environment{}
49+
// we're initializing webhook here and not in webhook.go to also test the envtest install code via WebhookOptions
50+
initializeWebhookInEnvironment()
51+
var err error
52+
cfg, err = testenv.Start()
53+
Expect(err).NotTo(HaveOccurred())
3954
close(done)
4055
}, 60)
56+
57+
var _ = AfterSuite(func() {
58+
fmt.Println("stopping?")
59+
Expect(testenv.Stop()).To(Succeed())
60+
}, 60)
61+
62+
func initializeWebhookInEnvironment() {
63+
namespacedScopeV1 := admissionv1.NamespacedScope
64+
failedTypeV1 := admissionv1.Fail
65+
equivalentTypeV1 := admissionv1.Equivalent
66+
noSideEffectsV1 := admissionv1.SideEffectClassNone
67+
webhookPathV1 := "/failing"
68+
69+
testenv.WebhookInstallOptions = envtest.WebhookInstallOptions{
70+
ValidatingWebhooks: []client.Object{
71+
&admissionv1.ValidatingWebhookConfiguration{
72+
ObjectMeta: metav1.ObjectMeta{
73+
Name: "deployment-validation-webhook-config",
74+
},
75+
TypeMeta: metav1.TypeMeta{
76+
Kind: "ValidatingWebhookConfiguration",
77+
APIVersion: "admissionregistration.k8s.io/v1beta1",
78+
},
79+
Webhooks: []admissionv1.ValidatingWebhook{
80+
{
81+
Name: "deployment-validation.kubebuilder.io",
82+
Rules: []admissionv1.RuleWithOperations{
83+
{
84+
Operations: []admissionv1.OperationType{"CREATE", "UPDATE"},
85+
Rule: admissionv1.Rule{
86+
APIGroups: []string{"apps"},
87+
APIVersions: []string{"v1"},
88+
Resources: []string{"deployments"},
89+
Scope: &namespacedScopeV1,
90+
},
91+
},
92+
},
93+
FailurePolicy: &failedTypeV1,
94+
MatchPolicy: &equivalentTypeV1,
95+
SideEffects: &noSideEffectsV1,
96+
ClientConfig: admissionv1.WebhookClientConfig{
97+
Service: &admissionv1.ServiceReference{
98+
Name: "deployment-validation-service",
99+
Namespace: "default",
100+
Path: &webhookPathV1,
101+
},
102+
},
103+
},
104+
},
105+
},
106+
},
107+
}
108+
}

0 commit comments

Comments
 (0)