Skip to content

Commit 08d1969

Browse files
committed
Implement reconciliation of the VWC for object schema validation
1 parent e85ffc6 commit 08d1969

5 files changed

Lines changed: 180 additions & 7 deletions

File tree

pkg/controllers/crdcompatibility/objectvalidation/handle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func compatibilityRequrementFromContext(ctx context.Context) string {
5252
// of the CompatibilityRequirement from it and adds it to the context so it can later
5353
// be read inside the Handle functions.
5454
func compatibilityRequrementIntoContext(ctx context.Context, r *http.Request) context.Context {
55-
compatibilityRequirementName := strings.TrimPrefix(r.URL.Path, webhookPrefix)
55+
compatibilityRequirementName := strings.TrimPrefix(r.URL.Path, WebhookPrefix)
5656
return context.WithValue(ctx, compatibilityRequirementContextKey{}, compatibilityRequirementName)
5757
}
5858

pkg/controllers/crdcompatibility/objectvalidation/handle_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import (
4040

4141
// createValidatingWebhookConfig creates a ValidatingWebhookConfiguration for end-to-end testing
4242
func createValidatingWebhookConfig(crd *apiextensionsv1.CustomResourceDefinition, compatibilityRequirement *apiextensionsv1alpha1.CompatibilityRequirement) *admissionv1.ValidatingWebhookConfiguration {
43-
webhookPath := fmt.Sprintf("%s%s", webhookPrefix, compatibilityRequirement.Name)
43+
webhookPath := fmt.Sprintf("%s%s", WebhookPrefix, compatibilityRequirement.Name)
4444

4545
// Get webhook server configuration from test environment
4646
hostPort := fmt.Sprintf("%s:%d", testEnv.WebhookInstallOptions.LocalServingHost, testEnv.WebhookInstallOptions.LocalServingPort)
@@ -479,7 +479,7 @@ var _ = Describe("End-to-End Admission Webhook Integration", Ordered, ContinueOn
479479
// This test verifies the webhook URL path processing works correctly
480480
// The webhook is configured with path patterns that include the CompatibilityRequirement name
481481

482-
testPath := fmt.Sprintf("%s%s", webhookPrefix, compatibilityRequirement.Name)
482+
testPath := fmt.Sprintf("%s%s", WebhookPrefix, compatibilityRequirement.Name)
483483
req := &http.Request{}
484484
req.URL = &url.URL{Path: testPath}
485485

pkg/controllers/crdcompatibility/objectvalidation/webhook.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ import (
4444
)
4545

4646
const (
47-
// webhookPrefix is the static path prefix of our object admission endpoint.
47+
// WebhookPrefix is the static path prefix of our object admission endpoint.
4848
// Requests will be sent to a sub-path with the next component of the path
4949
// as a compatibility requirement name.
50-
webhookPrefix = "/compatibility-requirement-object-admission/"
50+
WebhookPrefix = "/compatibility-requirement-object-admission/"
5151
)
5252

5353
var (
@@ -91,7 +91,7 @@ func (v *validator) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts
9191
// Register a webhook on a path with a dynamic component for the compatibility requirement name.
9292
// we will extract this component into the context so that the handler can identify which compatibility
9393
// requirement the request was intended to validate against.
94-
mgr.GetWebhookServer().Register(webhookPrefix+"{CompatibilityRequirement}", &admission.Webhook{
94+
mgr.GetWebhookServer().Register(WebhookPrefix+"{CompatibilityRequirement}", &admission.Webhook{
9595
Handler: v,
9696
WithContextFunc: compatibilityRequrementIntoContext,
9797
})

pkg/controllers/crdcompatibility/reconcile.go

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,18 @@ import (
2323
"slices"
2424

2525
"github.com/go-logr/logr"
26+
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
2627
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2728
apierrors "k8s.io/apimachinery/pkg/api/errors"
29+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2830
"k8s.io/apimachinery/pkg/types"
31+
"k8s.io/utils/ptr"
2932
ctrl "sigs.k8s.io/controller-runtime"
3033
logf "sigs.k8s.io/controller-runtime/pkg/log"
3134
"sigs.k8s.io/yaml"
3235

3336
apiextensionsv1alpha1 "github.com/openshift/api/apiextensions/v1alpha1"
37+
"github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility/objectvalidation"
3438
"github.com/openshift/cluster-capi-operator/pkg/crdchecker"
3539
"github.com/openshift/cluster-capi-operator/pkg/util"
3640
)
@@ -155,6 +159,7 @@ func (r *reconcileState) reconcileCreateOrUpdate(ctx context.Context, obj *apiex
155159
r.parseCompatibilityCRD(obj),
156160
r.fetchCurrentCRD(ctx, logger),
157161
r.checkCompatibilityRequirement(),
162+
r.ensureObjectValidationWebhook(ctx, obj),
158163
)
159164

160165
if err != nil {
@@ -169,9 +174,107 @@ func (r *reconcileState) reconcileDelete(ctx context.Context, obj *apiextensions
169174

170175
logger.Info("Reconciling CompatibilityRequirement deletion")
171176

172-
if err := clearFinalizer(ctx, r.client, obj); err != nil {
177+
err := errors.Join(
178+
clearFinalizer(ctx, r.client, obj),
179+
r.removeObjectValidationWebhook(ctx, obj),
180+
)
181+
182+
if err != nil {
173183
return ctrl.Result{}, err
174184
}
175185

176186
return ctrl.Result{}, nil
177187
}
188+
189+
func (r *reconcileState) ensureObjectValidationWebhook(ctx context.Context, obj *apiextensionsv1alpha1.CompatibilityRequirement) error {
190+
if isObjectValidationWebhookEnabled(obj) {
191+
return nil
192+
}
193+
194+
webhookConfig := validatingWebhookConfigurationFor(obj, r.compatibilityCRD)
195+
if err := r.client.Get(ctx, types.NamespacedName{Name: webhookConfig.Name}, webhookConfig); err != nil {
196+
if apierrors.IsNotFound(err) {
197+
return r.client.Create(ctx, webhookConfig)
198+
}
199+
200+
return err
201+
}
202+
203+
return r.client.Update(ctx, webhookConfig)
204+
}
205+
206+
func (r *reconcileState) removeObjectValidationWebhook(ctx context.Context, obj *apiextensionsv1alpha1.CompatibilityRequirement) error {
207+
webhookConfig := &admissionregistrationv1.ValidatingWebhookConfiguration{
208+
ObjectMeta: metav1.ObjectMeta{
209+
Name: obj.Name,
210+
},
211+
}
212+
213+
if err := r.client.Get(ctx, types.NamespacedName{Name: webhookConfig.Name}, webhookConfig); err != nil {
214+
if apierrors.IsNotFound(err) {
215+
return nil
216+
}
217+
return err
218+
}
219+
220+
return r.client.Delete(ctx, webhookConfig)
221+
}
222+
223+
func isObjectValidationWebhookEnabled(obj *apiextensionsv1alpha1.CompatibilityRequirement) bool {
224+
osv := obj.Spec.ObjectSchemaValidation
225+
return osv.Action == "" && osv.MatchConditions == nil && labelSelectorIsEmpty(osv.NamespaceSelector) && labelSelectorIsEmpty(osv.ObjectSelector)
226+
}
227+
228+
func labelSelectorIsEmpty(ls metav1.LabelSelector) bool {
229+
return len(ls.MatchLabels) == 0 && len(ls.MatchExpressions) == 0
230+
}
231+
232+
func validatingWebhookConfigurationFor(obj *apiextensionsv1alpha1.CompatibilityRequirement, crd *apiextensionsv1.CustomResourceDefinition) *admissionregistrationv1.ValidatingWebhookConfiguration {
233+
return &admissionregistrationv1.ValidatingWebhookConfiguration{
234+
TypeMeta: metav1.TypeMeta{
235+
Kind: "ValidatingWebhookConfiguration",
236+
APIVersion: "admissionregistration.k8s.io/v1",
237+
},
238+
ObjectMeta: metav1.ObjectMeta{
239+
Name: obj.Name,
240+
Annotations: map[string]string{
241+
"service.beta.openshift.io/inject-cabundle": "true",
242+
},
243+
},
244+
Webhooks: []admissionregistrationv1.ValidatingWebhook{
245+
{
246+
AdmissionReviewVersions: []string{"v1"},
247+
ClientConfig: admissionregistrationv1.WebhookClientConfig{
248+
Service: &admissionregistrationv1.ServiceReference{
249+
Name: "compatibility-requirements-controllers-validation-webhook-service",
250+
Namespace: "openshift-compatibility-requirements-operator",
251+
Path: ptr.To(fmt.Sprintf("%s%s", objectvalidation.WebhookPrefix, obj.Name)),
252+
},
253+
},
254+
SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone),
255+
FailurePolicy: ptr.To(admissionregistrationv1.Fail),
256+
MatchPolicy: ptr.To(admissionregistrationv1.Exact),
257+
Name: "compatibilityrequirement.operator.openshift.io",
258+
Rules: []admissionregistrationv1.RuleWithOperations{
259+
{
260+
Rule: admissionregistrationv1.Rule{
261+
APIGroups: []string{crd.Spec.Group},
262+
APIVersions: mapFunc(crd.Spec.Versions, func(version apiextensionsv1.CustomResourceDefinitionVersion) string { return version.Name }),
263+
Resources: []string{crd.Spec.Names.Plural},
264+
Scope: ptr.To(admissionregistrationv1.ScopeType(crd.Spec.Scope)),
265+
},
266+
Operations: []admissionregistrationv1.OperationType{"CREATE", "UPDATE"},
267+
},
268+
},
269+
},
270+
},
271+
}
272+
}
273+
274+
func mapFunc[T any, R any](items []T, transform func(T) R) []R {
275+
result := make([]R, len(items))
276+
for i, item := range items {
277+
result[i] = transform(item)
278+
}
279+
return result
280+
}

pkg/controllers/crdcompatibility/reconcile_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,19 @@ package crdcompatibility
1818

1919
import (
2020
"context"
21+
"fmt"
2122

2223
. "github.com/onsi/ginkgo/v2"
2324
. "github.com/onsi/gomega"
2425

26+
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
2527
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2628
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2729
"sigs.k8s.io/controller-runtime/pkg/client"
2830

2931
apiextensionsv1alpha1 "github.com/openshift/api/apiextensions/v1alpha1"
3032
"github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility/crdvalidation"
33+
"github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility/objectvalidation"
3134
"github.com/openshift/cluster-capi-operator/pkg/test"
3235
)
3336

@@ -216,6 +219,73 @@ var _ = Describe("CompatibilityRequirement", Ordered, ContinueOnFailure, func()
216219

217220
})
218221

222+
Context("When creating a CompatibilityRequirement with configured object schema validation", Ordered, func() {
223+
var webhookConfig *admissionregistrationv1.ValidatingWebhookConfiguration
224+
var requirement *apiextensionsv1alpha1.CompatibilityRequirement
225+
226+
BeforeAll(func(ctx context.Context) {
227+
requirement = test.GenerateTestCompatibilityRequirement(testCRDClean)
228+
requirement.Spec.ObjectSchemaValidation = apiextensionsv1alpha1.ObjectSchemaValidation{
229+
Action: apiextensionsv1alpha1.CRDAdmitActionDeny,
230+
NamespaceSelector: metav1.LabelSelector{
231+
MatchLabels: map[string]string{
232+
"test": "test",
233+
},
234+
},
235+
ObjectSelector: metav1.LabelSelector{
236+
MatchLabels: map[string]string{
237+
"test": "test",
238+
},
239+
},
240+
MatchConditions: []admissionregistrationv1.MatchCondition{
241+
{
242+
Name: "test",
243+
Expression: "true",
244+
},
245+
},
246+
}
247+
248+
createTestObject(ctx, requirement, "CompatibilityRequirement")
249+
250+
webhookConfig = &admissionregistrationv1.ValidatingWebhookConfiguration{
251+
ObjectMeta: metav1.ObjectMeta{
252+
Name: requirement.Name,
253+
},
254+
}
255+
})
256+
257+
It("Should create a validating webhook configuration to implement the compatiblity requirement", func(ctx context.Context) {
258+
Eventually(kWithCtx(ctx).Object(webhookConfig)).Should(SatisfyAll(
259+
HaveField("ObjectMeta.Name", BeEquivalentTo(requirement.Name)),
260+
HaveField("ObjectMeta.Annotations", HaveKey("service.beta.openshift.io/inject-cabundle")),
261+
HaveField("Webhooks", ConsistOf(SatisfyAll(
262+
HaveField("Name", BeEquivalentTo("compatibilityrequirement.operator.openshift.io")),
263+
HaveField("ClientConfig.Service.Name", BeEquivalentTo("compatibility-requirements-controllers-validation-webhook-service")),
264+
HaveField("ClientConfig.Service.Namespace", BeEquivalentTo("openshift-compatibility-requirements-operator")),
265+
HaveField("ClientConfig.Service.Path", HaveValue(BeEquivalentTo(fmt.Sprintf("%s%s", objectvalidation.WebhookPrefix, requirement.Name)))),
266+
HaveField("SideEffects", HaveValue(BeEquivalentTo(admissionregistrationv1.SideEffectClassNone))),
267+
HaveField("FailurePolicy", HaveValue(BeEquivalentTo(admissionregistrationv1.Fail))),
268+
HaveField("MatchPolicy", HaveValue(BeEquivalentTo(admissionregistrationv1.Exact))),
269+
HaveField("Rules", ConsistOf(SatisfyAll(
270+
HaveField("APIGroups", BeEquivalentTo([]string{testCRDClean.Spec.Group})),
271+
HaveField("APIVersions", BeEquivalentTo([]string{testCRDClean.Spec.Versions[0].Name})),
272+
HaveField("Resources", BeEquivalentTo([]string{testCRDClean.Spec.Names.Plural})),
273+
HaveField("Scope", HaveValue(BeEquivalentTo(admissionregistrationv1.ScopeType(testCRDClean.Spec.Scope)))),
274+
HaveField("Operations", ConsistOf(
275+
BeEquivalentTo("CREATE"),
276+
BeEquivalentTo("UPDATE"),
277+
)),
278+
))),
279+
))),
280+
))
281+
})
282+
283+
It("Should delete the validating webhook configuration when the CompatibilityRequirement is deleted", func(ctx context.Context) {
284+
Expect(cl.Delete(ctx, requirement)).To(Succeed())
285+
Eventually(kWithCtx(ctx).Get(webhookConfig)).Should(test.BeK8SNotFound())
286+
})
287+
})
288+
219289
Context("When creating or modifying a CRD", func() {
220290
testIncompatibleCRD := func(ctx context.Context, testCRD *apiextensionsv1.CustomResourceDefinition, requirement *apiextensionsv1alpha1.CompatibilityRequirement, createOrUpdateCRD func(context.Context, client.Object, func()) func() error) {
221291
// Create a working copy of the CRD so we maintain a clean version

0 commit comments

Comments
 (0)