1
13
14 package envtest
15
16 import (
17 "context"
18 "fmt"
19 "net"
20 "os"
21 "path/filepath"
22 "time"
23
24 admissionv1 "k8s.io/api/admissionregistration/v1"
25 apierrors "k8s.io/apimachinery/pkg/api/errors"
26 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
28 "k8s.io/apimachinery/pkg/runtime/schema"
29 "k8s.io/apimachinery/pkg/util/sets"
30 "k8s.io/apimachinery/pkg/util/wait"
31 "k8s.io/client-go/kubernetes/scheme"
32 "k8s.io/client-go/rest"
33 "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
34 "sigs.k8s.io/yaml"
35
36 "sigs.k8s.io/controller-runtime/pkg/client"
37 "sigs.k8s.io/controller-runtime/pkg/internal/testing/addr"
38 "sigs.k8s.io/controller-runtime/pkg/internal/testing/certs"
39 )
40
41
42 type WebhookInstallOptions struct {
43
44 Paths []string
45
46
47 MutatingWebhooks []*admissionv1.MutatingWebhookConfiguration
48
49
50 ValidatingWebhooks []*admissionv1.ValidatingWebhookConfiguration
51
52
53
54
55 IgnoreSchemeConvertible bool
56
57
58 IgnoreErrorIfPathMissing bool
59
60
61
62 LocalServingHost string
63
64
65
66 LocalServingPort int
67
68
69
70 LocalServingCertDir string
71
72
73 LocalServingCAData []byte
74
75
76 LocalServingHostExternalName string
77
78
79 MaxTime time.Duration
80
81
82 PollInterval time.Duration
83 }
84
85
86
87
88 func (o *WebhookInstallOptions) ModifyWebhookDefinitions() error {
89 caData := o.LocalServingCAData
90
91
92 hostPort, err := o.generateHostPort()
93 if err != nil {
94 return err
95 }
96
97 for i := range o.MutatingWebhooks {
98 for j := range o.MutatingWebhooks[i].Webhooks {
99 updateClientConfig(&o.MutatingWebhooks[i].Webhooks[j].ClientConfig, hostPort, caData)
100 }
101 }
102
103 for i := range o.ValidatingWebhooks {
104 for j := range o.ValidatingWebhooks[i].Webhooks {
105 updateClientConfig(&o.ValidatingWebhooks[i].Webhooks[j].ClientConfig, hostPort, caData)
106 }
107 }
108 return nil
109 }
110
111 func updateClientConfig(cc *admissionv1.WebhookClientConfig, hostPort string, caData []byte) {
112 cc.CABundle = caData
113 if cc.Service != nil && cc.Service.Path != nil {
114 url := fmt.Sprintf("https://%s/%s", hostPort, *cc.Service.Path)
115 cc.URL = &url
116 cc.Service = nil
117 }
118 }
119
120 func (o *WebhookInstallOptions) generateHostPort() (string, error) {
121 if o.LocalServingPort == 0 {
122 port, host, err := addr.Suggest(o.LocalServingHost)
123 if err != nil {
124 return "", fmt.Errorf("unable to grab random port for serving webhooks on: %w", err)
125 }
126 o.LocalServingPort = port
127 o.LocalServingHost = host
128 }
129 host := o.LocalServingHostExternalName
130 if host == "" {
131 host = o.LocalServingHost
132 }
133 return net.JoinHostPort(host, fmt.Sprintf("%d", o.LocalServingPort)), nil
134 }
135
136
137
138
139
140
141 func (o *WebhookInstallOptions) PrepWithoutInstalling() error {
142 if err := o.setupCA(); err != nil {
143 return err
144 }
145
146 if err := parseWebhook(o); err != nil {
147 return err
148 }
149
150 return o.ModifyWebhookDefinitions()
151 }
152
153
154 func (o *WebhookInstallOptions) Install(config *rest.Config) error {
155 defaultWebhookOptions(o)
156
157 if len(o.LocalServingCAData) == 0 {
158 if err := o.PrepWithoutInstalling(); err != nil {
159 return err
160 }
161 }
162
163 if err := createWebhooks(config, o.MutatingWebhooks, o.ValidatingWebhooks); err != nil {
164 return err
165 }
166
167 return WaitForWebhooks(config, o.MutatingWebhooks, o.ValidatingWebhooks, *o)
168 }
169
170
171 func (o *WebhookInstallOptions) Cleanup() error {
172 if o.LocalServingCertDir != "" {
173 return os.RemoveAll(o.LocalServingCertDir)
174 }
175 return nil
176 }
177
178
179 func defaultWebhookOptions(o *WebhookInstallOptions) {
180 if o.MaxTime == 0 {
181 o.MaxTime = defaultMaxWait
182 }
183 if o.PollInterval == 0 {
184 o.PollInterval = defaultPollInterval
185 }
186 }
187
188
189 func WaitForWebhooks(config *rest.Config,
190 mutatingWebhooks []*admissionv1.MutatingWebhookConfiguration,
191 validatingWebhooks []*admissionv1.ValidatingWebhookConfiguration,
192 options WebhookInstallOptions,
193 ) error {
194 waitingFor := map[schema.GroupVersionKind]*sets.Set[string]{}
195
196 for _, hook := range mutatingWebhooks {
197 h := hook
198 gvk, err := apiutil.GVKForObject(h, scheme.Scheme)
199 if err != nil {
200 return fmt.Errorf("unable to get gvk for MutatingWebhookConfiguration %s: %w", hook.GetName(), err)
201 }
202
203 if _, ok := waitingFor[gvk]; !ok {
204 waitingFor[gvk] = &sets.Set[string]{}
205 }
206 waitingFor[gvk].Insert(h.GetName())
207 }
208
209 for _, hook := range validatingWebhooks {
210 h := hook
211 gvk, err := apiutil.GVKForObject(h, scheme.Scheme)
212 if err != nil {
213 return fmt.Errorf("unable to get gvk for ValidatingWebhookConfiguration %s: %w", hook.GetName(), err)
214 }
215
216 if _, ok := waitingFor[gvk]; !ok {
217 waitingFor[gvk] = &sets.Set[string]{}
218 }
219 waitingFor[gvk].Insert(hook.GetName())
220 }
221
222
223 p := &webhookPoller{config: config, waitingFor: waitingFor}
224 return wait.PollUntilContextTimeout(context.TODO(), options.PollInterval, options.MaxTime, true, p.poll)
225 }
226
227
228 type webhookPoller struct {
229
230 config *rest.Config
231
232
233 waitingFor map[schema.GroupVersionKind]*sets.Set[string]
234 }
235
236
237 func (p *webhookPoller) poll(ctx context.Context) (done bool, err error) {
238
239 c, err := client.New(p.config, client.Options{})
240 if err != nil {
241 return false, err
242 }
243
244 allFound := true
245 for gvk, names := range p.waitingFor {
246 if names.Len() == 0 {
247 delete(p.waitingFor, gvk)
248 continue
249 }
250 for _, name := range names.UnsortedList() {
251 obj := &unstructured.Unstructured{}
252 obj.SetGroupVersionKind(gvk)
253 err := c.Get(context.Background(), client.ObjectKey{
254 Namespace: "",
255 Name: name,
256 }, obj)
257
258 if err == nil {
259 names.Delete(name)
260 }
261
262 if apierrors.IsNotFound(err) {
263 allFound = false
264 }
265 if err != nil {
266 return false, err
267 }
268 }
269 }
270 return allFound, nil
271 }
272
273
274 func (o *WebhookInstallOptions) setupCA() error {
275 hookCA, err := certs.NewTinyCA()
276 if err != nil {
277 return fmt.Errorf("unable to set up webhook CA: %w", err)
278 }
279
280 names := []string{"localhost", o.LocalServingHost, o.LocalServingHostExternalName}
281 hookCert, err := hookCA.NewServingCert(names...)
282 if err != nil {
283 return fmt.Errorf("unable to set up webhook serving certs: %w", err)
284 }
285
286 localServingCertsDir, err := os.MkdirTemp("", "envtest-serving-certs-")
287 o.LocalServingCertDir = localServingCertsDir
288 if err != nil {
289 return fmt.Errorf("unable to create directory for webhook serving certs: %w", err)
290 }
291
292 certData, keyData, err := hookCert.AsBytes()
293 if err != nil {
294 return fmt.Errorf("unable to marshal webhook serving certs: %w", err)
295 }
296
297 if err := os.WriteFile(filepath.Join(localServingCertsDir, "tls.crt"), certData, 0640); err != nil {
298 return fmt.Errorf("unable to write webhook serving cert to disk: %w", err)
299 }
300 if err := os.WriteFile(filepath.Join(localServingCertsDir, "tls.key"), keyData, 0640); err != nil {
301 return fmt.Errorf("unable to write webhook serving key to disk: %w", err)
302 }
303
304 o.LocalServingCAData = certData
305 return err
306 }
307
308 func createWebhooks(config *rest.Config, mutHooks []*admissionv1.MutatingWebhookConfiguration, valHooks []*admissionv1.ValidatingWebhookConfiguration) error {
309 cs, err := client.New(config, client.Options{})
310 if err != nil {
311 return err
312 }
313
314
315 for _, hook := range mutHooks {
316 hook := hook
317 log.V(1).Info("installing mutating webhook", "webhook", hook.GetName())
318 if err := ensureCreated(cs, hook); err != nil {
319 return err
320 }
321 }
322 for _, hook := range valHooks {
323 hook := hook
324 log.V(1).Info("installing validating webhook", "webhook", hook.GetName())
325 if err := ensureCreated(cs, hook); err != nil {
326 return err
327 }
328 }
329 return nil
330 }
331
332
333 func ensureCreated(cs client.Client, obj client.Object) error {
334 existing := obj.DeepCopyObject().(client.Object)
335 err := cs.Get(context.Background(), client.ObjectKey{Name: obj.GetName()}, existing)
336 switch {
337 case apierrors.IsNotFound(err):
338 if err := cs.Create(context.Background(), obj); err != nil {
339 return err
340 }
341 case err != nil:
342 return err
343 default:
344 log.V(1).Info("Webhook configuration already exists, updating", "webhook", obj.GetName())
345 obj.SetResourceVersion(existing.GetResourceVersion())
346 if err := cs.Update(context.Background(), obj); err != nil {
347 return err
348 }
349 }
350 return nil
351 }
352
353
354 func parseWebhook(options *WebhookInstallOptions) error {
355 if len(options.Paths) > 0 {
356 for _, path := range options.Paths {
357 _, err := os.Stat(path)
358 if options.IgnoreErrorIfPathMissing && os.IsNotExist(err) {
359 continue
360 }
361 if !options.IgnoreErrorIfPathMissing && os.IsNotExist(err) {
362 return err
363 }
364 mutHooks, valHooks, err := readWebhooks(path)
365 if err != nil {
366 return err
367 }
368 options.MutatingWebhooks = append(options.MutatingWebhooks, mutHooks...)
369 options.ValidatingWebhooks = append(options.ValidatingWebhooks, valHooks...)
370 }
371 }
372 return nil
373 }
374
375
376
377 func readWebhooks(path string) ([]*admissionv1.MutatingWebhookConfiguration, []*admissionv1.ValidatingWebhookConfiguration, error) {
378
379 var files []string
380 var err error
381 log.V(1).Info("reading Webhooks from path", "path", path)
382 info, err := os.Stat(path)
383 if err != nil {
384 return nil, nil, err
385 }
386 if !info.IsDir() {
387 path, files = filepath.Dir(path), []string{info.Name()}
388 } else {
389 entries, err := os.ReadDir(path)
390 if err != nil {
391 return nil, nil, err
392 }
393 for _, e := range entries {
394 files = append(files, e.Name())
395 }
396 }
397
398
399 resourceExtensions := sets.NewString(".json", ".yaml", ".yml")
400
401 var mutHooks []*admissionv1.MutatingWebhookConfiguration
402 var valHooks []*admissionv1.ValidatingWebhookConfiguration
403 for _, file := range files {
404
405 if !resourceExtensions.Has(filepath.Ext(file)) {
406 continue
407 }
408
409
410 docs, err := readDocuments(filepath.Join(path, file))
411 if err != nil {
412 return nil, nil, err
413 }
414
415 for _, doc := range docs {
416 var generic metav1.PartialObjectMetadata
417 if err = yaml.Unmarshal(doc, &generic); err != nil {
418 return nil, nil, err
419 }
420
421 const (
422 admissionregv1 = "admissionregistration.k8s.io/v1"
423 )
424 switch {
425 case generic.Kind == "MutatingWebhookConfiguration":
426 if generic.APIVersion != admissionregv1 {
427 return nil, nil, fmt.Errorf("only v1 is supported right now for MutatingWebhookConfiguration (name: %s)", generic.Name)
428 }
429 hook := &admissionv1.MutatingWebhookConfiguration{}
430 if err := yaml.Unmarshal(doc, hook); err != nil {
431 return nil, nil, err
432 }
433 mutHooks = append(mutHooks, hook)
434 case generic.Kind == "ValidatingWebhookConfiguration":
435 if generic.APIVersion != admissionregv1 {
436 return nil, nil, fmt.Errorf("only v1 is supported right now for ValidatingWebhookConfiguration (name: %s)", generic.Name)
437 }
438 hook := &admissionv1.ValidatingWebhookConfiguration{}
439 if err := yaml.Unmarshal(doc, hook); err != nil {
440 return nil, nil, err
441 }
442 valHooks = append(valHooks, hook)
443 default:
444 continue
445 }
446 }
447
448 log.V(1).Info("read webhooks from file", "file", file)
449 }
450 return mutHooks, valHooks, nil
451 }
452
View as plain text