Skip to content

Commit 4f05a36

Browse files
committed
Simple helper for unmanaged webhook server
1 parent b125a18 commit 4f05a36

File tree

5 files changed

+288
-0
lines changed

5 files changed

+288
-0
lines changed

pkg/cluster/cluster.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ func New(config *rest.Config, opts ...Option) (Cluster, error) {
204204
}, nil
205205
}
206206

207+
// NewFakeCluster constructs an empty cluster for testing
208+
func NewFakeCluster() Cluster {
209+
return &cluster{}
210+
}
211+
207212
// setOptionsDefaults set default values for Options fields
208213
func setOptionsDefaults(options Options) Options {
209214
// Use the Kubernetes client-go scheme if none is specified

pkg/webhook/server.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131

3232
"github.com/prometheus/client_golang/prometheus"
3333
"github.com/prometheus/client_golang/prometheus/promhttp"
34+
"sigs.k8s.io/controller-runtime/pkg/cluster"
3435
"sigs.k8s.io/controller-runtime/pkg/runtime/inject"
3536
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/certwatcher"
3637
"sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics"
@@ -105,6 +106,73 @@ func (s *Server) setDefaults() {
105106
}
106107
}
107108

109+
// Options are the subset of fields on the controller that can be
110+
// configured when running an unmanaged webhook server (i.e. webhook.NewUnmanaged())
111+
type Options struct {
112+
// Host is the address that the server will listen on.
113+
// Defaults to "" - all addresses.
114+
Host string
115+
116+
// Port is the port number that the server will serve.
117+
// It will be defaulted to 9443 if unspecified.
118+
Port int
119+
120+
// CertDir is the directory that contains the server key and certificate. The
121+
// server key and certificate.
122+
CertDir string
123+
124+
// CertName is the server certificate name. Defaults to tls.crt.
125+
CertName string
126+
127+
// KeyName is the server key name. Defaults to tls.key.
128+
KeyName string
129+
130+
// ClientCAName is the CA certificate name which server used to verify remote(client)'s certificate.
131+
// Defaults to "", which means server does not verify client's certificate.
132+
ClientCAName string
133+
134+
// WebhookMux is the multiplexer that handles different webhooks.
135+
WebhookMux *http.ServeMux
136+
}
137+
138+
// NewUnmanaged provides a webhook server that can be ran without
139+
// a controller manager.
140+
func NewUnmanaged(cluster cluster.Cluster, options Options) (*Server, error) {
141+
server := &Server{
142+
Host: options.Host,
143+
WebhookMux: options.WebhookMux,
144+
Port: options.Port,
145+
CertDir: options.CertDir,
146+
CertName: options.CertName,
147+
KeyName: options.KeyName,
148+
}
149+
server.setDefaults()
150+
151+
server.InjectFunc(func(i interface{}) error {
152+
if _, err := inject.ConfigInto(cluster.GetConfig(), i); err != nil {
153+
return err
154+
}
155+
if _, err := inject.SchemeInto(cluster.GetScheme(), i); err != nil {
156+
return err
157+
}
158+
159+
if _, err := inject.ClientInto(cluster.GetClient(), i); err != nil {
160+
return err
161+
}
162+
if _, err := inject.APIReaderInto(cluster.GetAPIReader(), i); err != nil {
163+
return err
164+
}
165+
if _, err := inject.CacheInto(cluster.GetCache(), i); err != nil {
166+
return err
167+
}
168+
if _, err := inject.MapperInto(cluster.GetRESTMapper(), i); err != nil {
169+
return err
170+
}
171+
return nil
172+
})
173+
return server, nil
174+
}
175+
108176
// NeedLeaderElection implements the LeaderElectionRunnable interface, which indicates
109177
// the webhook server doesn't need leader election.
110178
func (*Server) NeedLeaderElection() bool {

pkg/webhook/server_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
. "github.com/onsi/ginkgo"
2727
. "github.com/onsi/gomega"
2828
"k8s.io/client-go/rest"
29+
"sigs.k8s.io/controller-runtime/pkg/cluster"
2930
"sigs.k8s.io/controller-runtime/pkg/envtest"
3031
"sigs.k8s.io/controller-runtime/pkg/webhook"
3132
)
@@ -174,6 +175,34 @@ var _ = Describe("Webhook Server", func() {
174175
Expect(handler.injectedField).To(BeTrue())
175176
})
176177
})
178+
179+
Context("when using an unmanaged webhook server", func() {
180+
It("should serve a webhook on the requested path", func() {
181+
opts := webhook.Options{
182+
Host: servingOpts.LocalServingHost,
183+
Port: servingOpts.LocalServingPort,
184+
CertDir: servingOpts.LocalServingCertDir,
185+
}
186+
var err error
187+
// overwrite the server so that startServer() starts it
188+
server, err = webhook.NewUnmanaged(cluster.NewFakeCluster(), opts)
189+
190+
Expect(err).NotTo(HaveOccurred())
191+
server.Register("/somepath", &testHandler{})
192+
doneCh := startServer()
193+
194+
Eventually(func() ([]byte, error) {
195+
resp, err := client.Get(fmt.Sprintf("https://%s/somepath", testHostPort))
196+
Expect(err).NotTo(HaveOccurred())
197+
defer resp.Body.Close()
198+
return ioutil.ReadAll(resp.Body)
199+
}).Should(Equal([]byte("gadzooks!")))
200+
201+
ctxCancel()
202+
Eventually(doneCh, "4s").Should(BeClosed())
203+
})
204+
205+
})
177206
})
178207

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

pkg/webhook/webhook_suite_test.go

Lines changed: 68 additions & 0 deletions
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)