• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kubevirt / hyperconverged-cluster-operator / 20751599122

06 Jan 2026 02:37PM UTC coverage: 82.44% (-0.5%) from 82.989%
20751599122

push

github

web-flow
[release 1.13] Add ValidatingAdmissionPolicy to validate the HyperConverged namespace (#3950)

* Add the new admission policy controller

The current implementation of preventing the creation of the
HyperConverged CR in non-allowed namespace, is not working in Openshift,
where becasue of a race condition, the webhook's namespace selector is
removed by OLM.

This commit adds a new controller, to create and reconcile a
ValidatingAdmissionPolicy and the related
ValidatingAdmissionPolicyBinding, to perform the same validation.

The reason we're doing it in a new controller, is because we need the
ValidatingAdmissionPolicy to be set, even if the HyperConverged CR is
not deployed, while our main controller only reconciles resources if
the HyperConverged CR is deployed.



* Register the admission policy controller on boot



* Remove the current validation

Remove the existing validation of the HyperConverged CR namespace from
the validation webhook, as it is now done by the policy, created by the
admission policy controller.



* Don't remove the namespace selector from the validation wh

OLM adds a namespace selection on the validation webhook CR, causing the
namespace validation to be not relevant.

The webhook setup logic removes this selector, but actually this is
reconciled by OLM, and eventually, user can still create the
HyperConverged CR in any namespace.

The issue is now handled by a ValidationgAdmissionPolicy, and so we
don't need this logic anymore, and so this commit removes it.



---------

Signed-off-by: Nahshon Unna Tsameret <nahsh.ut@gmail.com>

138 of 209 new or added lines in 2 files covered. (66.03%)

3 existing lines in 1 file now uncovered.

5709 of 6925 relevant lines covered (82.44%)

0.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.67
/pkg/webhooks/validator/validator.go
1
package validator
2

3
import (
4
        "context"
5
        "errors"
6
        "fmt"
7
        "net/http"
8
        "reflect"
9
        "strings"
10
        "time"
11

12
        "github.com/go-logr/logr"
13
        openshiftconfigv1 "github.com/openshift/api/config/v1"
14
        "github.com/samber/lo"
15
        xsync "golang.org/x/sync/errgroup"
16
        admissionv1 "k8s.io/api/admission/v1"
17
        apierrors "k8s.io/apimachinery/pkg/api/errors"
18
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19
        "k8s.io/utils/strings/slices"
20
        "sigs.k8s.io/controller-runtime/pkg/client"
21
        "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
22

23
        networkaddonsv1 "github.com/kubevirt/cluster-network-addons-operator/pkg/apis/networkaddonsoperator/v1"
24
        kubevirtcorev1 "kubevirt.io/api/core/v1"
25
        cdiv1beta1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1"
26
        sspv1beta2 "kubevirt.io/ssp-operator/api/v1beta2"
27

28
        "github.com/kubevirt/hyperconverged-cluster-operator/api/v1beta1"
29
        "github.com/kubevirt/hyperconverged-cluster-operator/controllers/operands"
30
        hcoutil "github.com/kubevirt/hyperconverged-cluster-operator/pkg/util"
31
)
32

33
const (
34
        updateDryRunTimeOut = time.Second * 3
35
)
36

37
type WebhookHandler struct {
38
        logger      logr.Logger
39
        cli         client.Client
40
        namespace   string
41
        isOpenshift bool
42
        decoder     admission.Decoder
43
}
44

45
var hcoTLSConfigCache *openshiftconfigv1.TLSSecurityProfile
46

47
func NewWebhookHandler(logger logr.Logger, cli client.Client, decoder admission.Decoder, namespace string, isOpenshift bool, hcoTLSSecurityProfile *openshiftconfigv1.TLSSecurityProfile) *WebhookHandler {
1✔
48
        hcoTLSConfigCache = hcoTLSSecurityProfile
1✔
49
        return &WebhookHandler{
1✔
50
                logger:      logger,
1✔
51
                cli:         cli,
1✔
52
                namespace:   namespace,
1✔
53
                isOpenshift: isOpenshift,
1✔
54
                decoder:     decoder,
1✔
55
        }
1✔
56
}
1✔
57

58
func (wh *WebhookHandler) Handle(ctx context.Context, req admission.Request) admission.Response {
1✔
59

1✔
60
        ctx = admission.NewContextWithRequest(ctx, req)
1✔
61

1✔
62
        // Get the object in the request
1✔
63
        obj := &v1beta1.HyperConverged{}
1✔
64

1✔
65
        dryRun := req.DryRun != nil && *req.DryRun
1✔
66

1✔
67
        var err error
1✔
68
        switch req.Operation {
1✔
69
        case admissionv1.Create:
1✔
70
                if err := wh.decoder.Decode(req, obj); err != nil {
2✔
71
                        return admission.Errored(http.StatusBadRequest, err)
1✔
72
                }
1✔
73

74
                err = wh.ValidateCreate(ctx, dryRun, obj)
1✔
75
        case admissionv1.Update:
1✔
76
                oldObj := &v1beta1.HyperConverged{}
1✔
77
                if err := wh.decoder.DecodeRaw(req.Object, obj); err != nil {
2✔
78
                        return admission.Errored(http.StatusBadRequest, err)
1✔
79
                }
1✔
80
                if err := wh.decoder.DecodeRaw(req.OldObject, oldObj); err != nil {
2✔
81
                        return admission.Errored(http.StatusBadRequest, err)
1✔
82
                }
1✔
83

84
                err = wh.ValidateUpdate(ctx, dryRun, obj, oldObj)
1✔
85
        case admissionv1.Delete:
1✔
86
                // In reference to PR: https://github.com/kubernetes/kubernetes/pull/76346
1✔
87
                // OldObject contains the object being deleted
1✔
88
                if err := wh.decoder.DecodeRaw(req.OldObject, obj); err != nil {
2✔
89
                        return admission.Errored(http.StatusBadRequest, err)
1✔
90
                }
1✔
91

92
                err = wh.ValidateDelete(ctx, dryRun, obj)
1✔
93
        default:
1✔
94
                return admission.Errored(http.StatusBadRequest, fmt.Errorf("unknown operation request %q", req.Operation))
1✔
95
        }
96

97
        // Check the error message first.
98
        if err != nil {
1✔
UNCOV
99
                var apiStatus apierrors.APIStatus
×
UNCOV
100
                if errors.As(err, &apiStatus) {
×
101
                        return validationResponseFromStatus(false, apiStatus.Status())
×
102
                }
×
UNCOV
103
                return admission.Denied(err.Error())
×
104
        }
105

106
        // Return allowed if everything succeeded.
107
        return admission.Allowed("")
1✔
108
}
109

110
func (wh *WebhookHandler) ValidateCreate(_ context.Context, dryrun bool, hc *v1beta1.HyperConverged) error {
1✔
111
        wh.logger.Info("Validating create", "name", hc.Name, "namespace:", hc.Namespace)
1✔
112

1✔
113
        if err := wh.validateCertConfig(hc); err != nil {
1✔
114
                return err
×
115
        }
×
116

117
        if err := wh.validateDataImportCronTemplates(hc); err != nil {
2✔
118
                return err
1✔
119
        }
1✔
120

121
        if err := wh.validateTLSSecurityProfiles(hc); err != nil {
2✔
122
                return err
1✔
123
        }
1✔
124

125
        if err := wh.validateMediatedDeviceTypes(hc); err != nil {
2✔
126
                return err
1✔
127
        }
1✔
128

129
        if _, err := operands.NewKubeVirt(hc); err != nil {
2✔
130
                return err
1✔
131
        }
1✔
132

133
        if _, err := operands.NewCDI(hc); err != nil {
2✔
134
                return err
1✔
135
        }
1✔
136

137
        if _, err := operands.NewNetworkAddons(hc); err != nil {
2✔
138
                return err
1✔
139
        }
1✔
140

141
        if _, _, err := operands.NewSSP(hc); err != nil {
2✔
142
                return err
1✔
143
        }
1✔
144

145
        if !dryrun {
2✔
146
                hcoTLSConfigCache = hc.Spec.TLSSecurityProfile
1✔
147
        }
1✔
148

149
        return nil
1✔
150
}
151

152
func (wh *WebhookHandler) getOperands(requested *v1beta1.HyperConverged) (*kubevirtcorev1.KubeVirt, *cdiv1beta1.CDI, *networkaddonsv1.NetworkAddonsConfig, error) {
1✔
153
        if err := wh.validateCertConfig(requested); err != nil {
2✔
154
                return nil, nil, nil, err
1✔
155
        }
1✔
156

157
        kv, err := operands.NewKubeVirt(requested)
1✔
158
        if err != nil {
2✔
159
                return nil, nil, nil, err
1✔
160
        }
1✔
161

162
        cdi, err := operands.NewCDI(requested)
1✔
163
        if err != nil {
2✔
164
                return nil, nil, nil, err
1✔
165
        }
1✔
166

167
        cna, err := operands.NewNetworkAddons(requested)
1✔
168
        if err != nil {
2✔
169
                return nil, nil, nil, err
1✔
170
        }
1✔
171

172
        return kv, cdi, cna, nil
1✔
173
}
174

175
// ValidateUpdate is the ValidateUpdate webhook implementation. It calls all the resources in parallel, to dry-run the
176
// upgrade.
177
func (wh *WebhookHandler) ValidateUpdate(ctx context.Context, dryrun bool, requested *v1beta1.HyperConverged, exists *v1beta1.HyperConverged) error {
1✔
178
        wh.logger.Info("Validating update", "name", requested.Name)
1✔
179

1✔
180
        if err := wh.validateDataImportCronTemplates(requested); err != nil {
1✔
181
                return err
×
182
        }
×
183

184
        if err := wh.validateTLSSecurityProfiles(requested); err != nil {
2✔
185
                return err
1✔
186
        }
1✔
187

188
        if err := wh.validateMediatedDeviceTypes(requested); err != nil {
2✔
189
                return err
1✔
190
        }
1✔
191

192
        // If no change is detected in the spec nor the annotations - nothing to validate
193
        if reflect.DeepEqual(exists.Spec, requested.Spec) &&
1✔
194
                reflect.DeepEqual(exists.Annotations, requested.Annotations) {
2✔
195
                return nil
1✔
196
        }
1✔
197

198
        kv, cdi, cna, err := wh.getOperands(requested)
1✔
199
        if err != nil {
2✔
200
                return err
1✔
201
        }
1✔
202

203
        toCtx, cancel := context.WithTimeout(ctx, updateDryRunTimeOut)
1✔
204
        defer cancel()
1✔
205

1✔
206
        eg, egCtx := xsync.WithContext(toCtx)
1✔
207
        opts := &client.UpdateOptions{DryRun: []string{metav1.DryRunAll}}
1✔
208

1✔
209
        resources := []client.Object{
1✔
210
                kv,
1✔
211
                cdi,
1✔
212
                cna,
1✔
213
        }
1✔
214

1✔
215
        if wh.isOpenshift {
2✔
216
                ssp, _, err := operands.NewSSP(requested)
1✔
217
                if err != nil {
2✔
218
                        return err
1✔
219
                }
1✔
220
                resources = append(resources, ssp)
1✔
221
        }
222

223
        for _, obj := range resources {
2✔
224
                func(o client.Object) {
2✔
225
                        eg.Go(func() error {
2✔
226
                                return wh.updateOperatorCr(egCtx, requested, o, opts)
1✔
227
                        })
1✔
228
                }(obj)
229
        }
230

231
        err = eg.Wait()
1✔
232
        if err != nil {
2✔
233
                return err
1✔
234
        }
1✔
235

236
        if !dryrun {
2✔
237
                hcoTLSConfigCache = requested.Spec.TLSSecurityProfile
1✔
238
        }
1✔
239

240
        return nil
1✔
241
}
242

243
func (wh *WebhookHandler) updateOperatorCr(ctx context.Context, hc *v1beta1.HyperConverged, exists client.Object, opts *client.UpdateOptions) error {
1✔
244
        err := hcoutil.GetRuntimeObject(ctx, wh.cli, exists)
1✔
245
        if err != nil {
2✔
246
                wh.logger.Error(err, "failed to get object from kubernetes", "kind", exists.GetObjectKind())
1✔
247
                return err
1✔
248
        }
1✔
249

250
        switch existing := exists.(type) {
1✔
251
        case *kubevirtcorev1.KubeVirt:
1✔
252
                required, err := operands.NewKubeVirt(hc)
1✔
253
                if err != nil {
1✔
254
                        return err
×
255
                }
×
256
                required.Spec.DeepCopyInto(&existing.Spec)
1✔
257

258
        case *cdiv1beta1.CDI:
1✔
259
                required, err := operands.NewCDI(hc)
1✔
260
                if err != nil {
1✔
261
                        return err
×
262
                }
×
263
                required.Spec.DeepCopyInto(&existing.Spec)
1✔
264

265
        case *networkaddonsv1.NetworkAddonsConfig:
1✔
266
                required, err := operands.NewNetworkAddons(hc)
1✔
267
                if err != nil {
1✔
268
                        return err
×
269
                }
×
270
                required.Spec.DeepCopyInto(&existing.Spec)
1✔
271

272
        case *sspv1beta2.SSP:
1✔
273
                required, _, err := operands.NewSSP(hc)
1✔
274
                if err != nil {
1✔
275
                        return err
×
276
                }
×
277
                required.Spec.DeepCopyInto(&existing.Spec)
1✔
278
        }
279

280
        if err = wh.cli.Update(ctx, exists, opts); err != nil {
2✔
281
                wh.logger.Error(err, "failed to dry-run update the object", "kind", exists.GetObjectKind())
1✔
282
                return err
1✔
283
        }
1✔
284

285
        wh.logger.Info("dry-run update the object passed", "kind", exists.GetObjectKind())
1✔
286
        return nil
1✔
287
}
288

289
func (wh *WebhookHandler) ValidateDelete(ctx context.Context, dryrun bool, hc *v1beta1.HyperConverged) error {
1✔
290
        wh.logger.Info("Validating delete", "name", hc.Name, "namespace", hc.Namespace)
1✔
291

1✔
292
        kv := operands.NewKubeVirtWithNameOnly(hc)
1✔
293
        cdi := operands.NewCDIWithNameOnly(hc)
1✔
294

1✔
295
        for _, obj := range []client.Object{
1✔
296
                kv,
1✔
297
                cdi,
1✔
298
        } {
2✔
299
                _, err := hcoutil.EnsureDeleted(ctx, wh.cli, obj, hc.Name, wh.logger, true, false, true)
1✔
300
                if err != nil {
2✔
301
                        wh.logger.Error(err, "Delete validation failed", "GVK", obj.GetObjectKind().GroupVersionKind())
1✔
302
                        return err
1✔
303
                }
1✔
304
        }
305
        if !dryrun {
2✔
306
                hcoTLSConfigCache = nil
1✔
307
        }
1✔
308
        return nil
1✔
309
}
310

311
func (wh *WebhookHandler) validateCertConfig(hc *v1beta1.HyperConverged) error {
1✔
312
        minimalDuration := metav1.Duration{Duration: 10 * time.Minute}
1✔
313

1✔
314
        ccValues := make(map[string]time.Duration)
1✔
315
        ccValues["spec.certConfig.ca.duration"] = hc.Spec.CertConfig.CA.Duration.Duration
1✔
316
        ccValues["spec.certConfig.ca.renewBefore"] = hc.Spec.CertConfig.CA.RenewBefore.Duration
1✔
317
        ccValues["spec.certConfig.server.duration"] = hc.Spec.CertConfig.Server.Duration.Duration
1✔
318
        ccValues["spec.certConfig.server.renewBefore"] = hc.Spec.CertConfig.Server.RenewBefore.Duration
1✔
319

1✔
320
        for key, value := range ccValues {
2✔
321
                if value < minimalDuration.Duration {
2✔
322
                        return fmt.Errorf("%v: value is too small", key)
1✔
323
                }
1✔
324
        }
325

326
        if hc.Spec.CertConfig.CA.Duration.Duration < hc.Spec.CertConfig.CA.RenewBefore.Duration {
2✔
327
                return errors.New("spec.certConfig.ca: duration is smaller than renewBefore")
1✔
328
        }
1✔
329

330
        if hc.Spec.CertConfig.Server.Duration.Duration < hc.Spec.CertConfig.Server.RenewBefore.Duration {
2✔
331
                return errors.New("spec.certConfig.server: duration is smaller than renewBefore")
1✔
332
        }
1✔
333

334
        if hc.Spec.CertConfig.CA.Duration.Duration < hc.Spec.CertConfig.Server.Duration.Duration {
2✔
335
                return errors.New("spec.certConfig: ca.duration is smaller than server.duration")
1✔
336
        }
1✔
337

338
        return nil
1✔
339
}
340

341
func (wh *WebhookHandler) validateDataImportCronTemplates(hc *v1beta1.HyperConverged) error {
1✔
342

1✔
343
        for _, dict := range hc.Spec.DataImportCronTemplates {
2✔
344
                val, ok := dict.Annotations[hcoutil.DataImportCronEnabledAnnotation]
1✔
345
                val = strings.ToLower(val)
1✔
346
                if ok && !(val == "false" || val == "true") {
2✔
347
                        return fmt.Errorf(`the %s annotation of a dataImportCronTemplate must be either "true" or "false"`, hcoutil.DataImportCronEnabledAnnotation)
1✔
348
                }
1✔
349

350
                enabled := !ok || val == "true"
1✔
351

1✔
352
                if enabled && dict.Spec == nil {
2✔
353
                        return fmt.Errorf("dataImportCronTemplate spec is empty for an enabled DataImportCronTemplate")
1✔
354
                }
1✔
355
        }
356

357
        return nil
1✔
358
}
359

360
func (wh *WebhookHandler) validateTLSSecurityProfiles(hc *v1beta1.HyperConverged) error {
1✔
361
        tlsSP := hc.Spec.TLSSecurityProfile
1✔
362

1✔
363
        if tlsSP == nil || tlsSP.Custom == nil {
2✔
364
                return nil
1✔
365
        }
1✔
366

367
        if !isValidTLSProtocolVersion(tlsSP.Custom.MinTLSVersion) {
2✔
368
                return fmt.Errorf("invalid value for spec.tlsSecurityProfile.custom.minTLSVersion")
1✔
369
        }
1✔
370

371
        if tlsSP.Custom.MinTLSVersion < openshiftconfigv1.VersionTLS13 && !hasRequiredHTTP2Ciphers(tlsSP.Custom.Ciphers) {
2✔
372
                return fmt.Errorf("http2: TLSConfig.CipherSuites is missing an HTTP/2-required AES_128_GCM_SHA256 cipher (need at least one of ECDHE-RSA-AES128-GCM-SHA256 or ECDHE-ECDSA-AES128-GCM-SHA256)")
1✔
373
        } else if tlsSP.Custom.MinTLSVersion == openshiftconfigv1.VersionTLS13 && len(tlsSP.Custom.Ciphers) > 0 {
3✔
374
                return fmt.Errorf("custom ciphers cannot be selected when minTLSVersion is VersionTLS13")
1✔
375
        }
1✔
376

377
        return nil
1✔
378
}
379

380
func (wh *WebhookHandler) validateMediatedDeviceTypes(hc *v1beta1.HyperConverged) error {
1✔
381
        mdc := hc.Spec.MediatedDevicesConfiguration
1✔
382
        if mdc != nil {
2✔
383
                if len(mdc.MediatedDevicesTypes) > 0 && len(mdc.MediatedDeviceTypes) > 0 && !slices.Equal(mdc.MediatedDevicesTypes, mdc.MediatedDeviceTypes) { //nolint SA1019
2✔
384
                        return fmt.Errorf("mediatedDevicesTypes is deprecated, please use mediatedDeviceTypes instead")
1✔
385
                }
1✔
386
                for _, nmdc := range mdc.NodeMediatedDeviceTypes {
2✔
387
                        if len(nmdc.MediatedDevicesTypes) > 0 && len(nmdc.MediatedDeviceTypes) > 0 && !slices.Equal(nmdc.MediatedDevicesTypes, nmdc.MediatedDeviceTypes) { //nolint SA1019
2✔
388
                                return fmt.Errorf("mediatedDevicesTypes is deprecated, please use mediatedDeviceTypes instead")
1✔
389
                        }
1✔
390
                }
391
        }
392
        return nil
1✔
393
}
394

395
func hasRequiredHTTP2Ciphers(ciphers []string) bool {
1✔
396
        var requiredHTTP2Ciphers = []string{
1✔
397
                "ECDHE-RSA-AES128-GCM-SHA256",
1✔
398
                "ECDHE-ECDSA-AES128-GCM-SHA256",
1✔
399
        }
1✔
400

1✔
401
        // lo.Some returns true if at least 1 element of a subset is contained into a collection
1✔
402
        return lo.Some[string](requiredHTTP2Ciphers, ciphers)
1✔
403
}
1✔
404

405
// validationResponseFromStatus returns a response for admitting a request with provided Status object.
406
func validationResponseFromStatus(allowed bool, status metav1.Status) admission.Response {
×
407
        resp := admission.Response{
×
408
                AdmissionResponse: admissionv1.AdmissionResponse{
×
409
                        Allowed: allowed,
×
410
                        Result:  &status,
×
411
                },
×
412
        }
×
413
        return resp
×
414
}
×
415

416
func SelectCipherSuitesAndMinTLSVersion() ([]string, openshiftconfigv1.TLSProtocolVersion) {
1✔
417
        ci := hcoutil.GetClusterInfo()
1✔
418
        profile := ci.GetTLSSecurityProfile(hcoTLSConfigCache)
1✔
419

1✔
420
        if profile.Custom != nil {
1✔
421
                return profile.Custom.TLSProfileSpec.Ciphers, profile.Custom.TLSProfileSpec.MinTLSVersion
×
422
        }
×
423

424
        return openshiftconfigv1.TLSProfiles[profile.Type].Ciphers, openshiftconfigv1.TLSProfiles[profile.Type].MinTLSVersion
1✔
425
}
426

427
func isValidTLSProtocolVersion(pv openshiftconfigv1.TLSProtocolVersion) bool {
1✔
428
        switch pv {
1✔
429
        case
430
                openshiftconfigv1.VersionTLS10,
431
                openshiftconfigv1.VersionTLS11,
432
                openshiftconfigv1.VersionTLS12,
433
                openshiftconfigv1.VersionTLS13:
1✔
434
                return true
1✔
435
        }
436
        return false
1✔
437
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc