Skip to content
8 changes: 8 additions & 0 deletions cmd/crd-compatibility-checker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility"
crdcompatibilitybindata "github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility/bindata"
"github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility/crdvalidation"
"github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility/objectpruning"
"github.com/openshift/cluster-capi-operator/pkg/controllers/crdcompatibility/objectvalidation"
"github.com/openshift/cluster-capi-operator/pkg/controllers/staticresourceinstaller"
"github.com/openshift/cluster-capi-operator/pkg/util"
Expand Down Expand Up @@ -156,6 +157,13 @@ func main() {
os.Exit(1)
}

objectPruner := objectpruning.NewValidator()
// Setup the objectpruning controller and webhook
if err := objectPruner.SetupWithManager(ctx, mgr); err != nil {
klog.Error(err, "unable to create controller", "controller", "ObjectPruner")
os.Exit(1)
}

if err := mgr.AddHealthzCheck("health", healthz.Ping); err != nil {
klog.Error(err, "unable to set up health check")
os.Exit(1)
Expand Down
32 changes: 32 additions & 0 deletions pkg/controllers/crdcompatibility/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import (
"errors"
"fmt"

admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
ctrl "sigs.k8s.io/controller-runtime"
Expand Down Expand Up @@ -90,6 +92,14 @@ func (r *CompatibilityRequirementReconciler) SetupWithManager(ctx context.Contex
Watches(
&apiextensionsv1.CustomResourceDefinition{},
handler.EnqueueRequestsFromMapFunc(r.findCompatibilityRequirementsForCRD),
).
Watches(
&admissionregistrationv1.ValidatingWebhookConfiguration{},
handler.EnqueueRequestsFromMapFunc(r.findCompatibilityRequirementsForWebhookConfig),
).
Watches(
&admissionregistrationv1.MutatingWebhookConfiguration{},
handler.EnqueueRequestsFromMapFunc(r.findCompatibilityRequirementsForWebhookConfig),
)

for _, opt := range opts {
Expand Down Expand Up @@ -131,3 +141,25 @@ func (r *CompatibilityRequirementReconciler) findCompatibilityRequirementsForCRD

return requests
}

// findCompatibilityRequirementsForWebhookConfig finds a compatibility requirement matching the name of the webhook configuration
// and returns a reconcile request if a matching requirement is found.
func (r *CompatibilityRequirementReconciler) findCompatibilityRequirementsForWebhookConfig(ctx context.Context, obj client.Object) []reconcile.Request {
compatibilityRequirement := &apiextensionsv1alpha1.CompatibilityRequirement{}

if err := r.client.Get(ctx, types.NamespacedName{Name: obj.GetName()}, compatibilityRequirement); err != nil && !apierrors.IsNotFound(err) {
logf.FromContext(ctx).Error(err, "failed to get CompatibilityRequirement for webhook configuration", "webhookConfigName", obj.GetName())

return nil
} else if apierrors.IsNotFound(err) {
return nil
}

return []reconcile.Request{
{
NamespacedName: types.NamespacedName{
Name: obj.GetName(),
},
},
}
}
111 changes: 111 additions & 0 deletions pkg/controllers/crdcompatibility/objectpruning/handle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2026 Red Hat, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package objectpruning

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"

admissionv1 "k8s.io/api/admission/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

type compatibilityRequirementContextKey struct{}

// compatibilityRequrementFromContext extracts the name of the CompatibilityRequirement
// out of the context.
func compatibilityRequrementFromContext(ctx context.Context) string {
v := ctx.Value(compatibilityRequirementContextKey{})

switch v := v.(type) {
case string:
return v
default:
// Not reached.
panic(fmt.Sprintf("unexpected value type for CompatibilityRequirement context key: %T", v))
}
}

// compatibilityRequrementIntoContext takes the request's .URL.Path, extracts the name
// of the CompatibilityRequirement from it and adds it to the context so it can later
// be read inside the Handle functions.
func compatibilityRequrementIntoContext(ctx context.Context, r *http.Request) context.Context {
compatibilityRequirementName := strings.TrimPrefix(r.URL.Path, webhookPrefix)
return context.WithValue(ctx, compatibilityRequirementContextKey{}, compatibilityRequirementName)
}

// Handle handles admission requests.
//
// Note: This function is adapted from sigs.k8s.io/controller-runtime/pkg/webhook/admission/defaulter_custom.go defaulterForType.Handle
// and be compared to that.
func (v *validator) Handle(ctx context.Context, req admission.Request) admission.Response {
if v.decoder == nil {
panic("decoder should never be nil")
}

// Always skip when a DELETE operation received in custom mutation handler.
if req.Operation == admissionv1.Delete {
return admission.Response{AdmissionResponse: admissionv1.AdmissionResponse{
Allowed: true,
Result: &metav1.Status{
Code: http.StatusOK,
},
}}
}

ctx = admission.NewContextWithRequest(ctx, req)
compatibilityRequirementName := compatibilityRequrementFromContext(ctx)

// Get the object in the request
obj := &unstructured.Unstructured{}
if err := v.decoder.Decode(req, obj); err != nil {
return admission.Errored(http.StatusBadRequest, err)
}

if err := v.handleObjectPruning(ctx, compatibilityRequirementName, obj); err != nil {
var apiStatus apierrors.APIStatus
if errors.As(err, &apiStatus) {
return validationResponseFromStatus(false, apiStatus.Status())
}

return admission.Denied(err.Error())
}

// Create the patch
marshalled, err := json.Marshal(obj)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}

handlerResponse := admission.PatchResponseFromRaw(req.Object.Raw, marshalled)

return handlerResponse
}

func validationResponseFromStatus(allowed bool, status metav1.Status) admission.Response {
return admission.Response{
AdmissionResponse: admissionv1.AdmissionResponse{
Allowed: allowed,
Result: &status,
},
}
}
Loading