Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions pkg/builder/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package builder
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
Expand All @@ -45,6 +46,7 @@ type WebhookBuilder struct {
customPath string
customValidatorCustomPath string
customDefaulterCustomPath string
converter []conversion.Converter
gvk schema.GroupVersionKind
mgr manager.Manager
config *rest.Config
Expand Down Expand Up @@ -86,6 +88,12 @@ func (blder *WebhookBuilder) WithValidator(validator admission.CustomValidator)
return blder
}

// WithConverters .
func (blder *WebhookBuilder) WithConverters(converter ...conversion.Converter) *WebhookBuilder {
blder.converter = converter
return blder
}

// WithLogConstructor overrides the webhook's LogConstructor.
func (blder *WebhookBuilder) WithLogConstructor(logConstructor func(base logr.Logger, req *admission.Request) logr.Logger) *WebhookBuilder {
blder.logConstructor = logConstructor
Expand Down Expand Up @@ -287,18 +295,26 @@ func (blder *WebhookBuilder) getValidatingWebhook() *admission.Webhook {
}

func (blder *WebhookBuilder) registerConversionWebhook() error {
ok, err := conversion.IsConvertible(blder.mgr.GetScheme(), blder.apiType)
if err != nil {
log.Error(err, "conversion check failed", "GVK", blder.gvk)
return err
}
if ok {
if !blder.isAlreadyHandled("/convert") {
blder.mgr.GetWebhookServer().Register("/convert", conversion.NewWebhookHandler(blder.mgr.GetScheme()))
if len(blder.converter) > 0 {
if err := blder.mgr.GetConverterRegistry().Register(blder.gvk, blder.converter...); err != nil {
return fmt.Errorf("failed to register converter for %s: %w", blder.gvk.Kind, err)
}
} else {
ok, err := conversion.IsConvertible(blder.mgr.GetScheme(), blder.apiType)
if err != nil {
log.Error(err, "conversion check failed", "GVK", blder.gvk)
return err
}
if !ok {
return nil
}
log.Info("Conversion webhook enabled", "GVK", blder.gvk)
}

if !blder.isAlreadyHandled("/convert") {
blder.mgr.GetWebhookServer().Register("/convert", conversion.NewWebhookHandler(blder.mgr.GetScheme(), blder.mgr.GetConverterRegistry()))
}
log.Info("Conversion webhook enabled", "GVK", blder.gvk)

return nil
}

Expand Down
7 changes: 7 additions & 0 deletions pkg/manager/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"k8s.io/client-go/tools/leaderelection"
"k8s.io/client-go/tools/leaderelection/resourcelock"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/webhook/conversion"

"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand Down Expand Up @@ -129,6 +130,8 @@ type controllerManager struct {
// webhookServer if unset, and Add() it to controllerManager.
webhookServerOnce sync.Once

conversionRegistry conversion.Registry

// leaderElectionID is the name of the resource that leader election
// will use for holding the leader lock.
leaderElectionID string
Expand Down Expand Up @@ -279,6 +282,10 @@ func (cm *controllerManager) GetWebhookServer() webhook.Server {
return cm.webhookServer
}

func (cm *controllerManager) GetConverterRegistry() conversion.Registry {
return cm.conversionRegistry
}

func (cm *controllerManager) GetLogger() logr.Logger {
return cm.logger
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/manager/internal/integration/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ type ConversionWebhook struct {
}

func createConversionWebhook(mgr manager.Manager) *ConversionWebhook {
conversionHandler := conversion.NewWebhookHandler(mgr.GetScheme())
conversionHandler := conversion.NewWebhookHandler(mgr.GetScheme(), conversion.NewRegistry(mgr.GetScheme()))
httpClient := http.Client{
// Setting a timeout to not get stuck when calling the readiness probe.
Timeout: 5 * time.Second,
Expand Down
5 changes: 5 additions & 0 deletions pkg/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"k8s.io/client-go/tools/record"
"k8s.io/utils/ptr"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook/conversion"

"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand Down Expand Up @@ -95,6 +96,9 @@ type Manager interface {

// GetControllerOptions returns controller global configuration options.
GetControllerOptions() config.Controller

// GetConverterRegistry FIXME.
GetConverterRegistry() conversion.Registry
}

// Options are the arguments for creating a new Manager.
Expand Down Expand Up @@ -445,6 +449,7 @@ func New(config *rest.Config, options Options) (Manager, error) {
logger: options.Logger,
elected: make(chan struct{}),
webhookServer: options.WebhookServer,
conversionRegistry: conversion.NewRegistry(cluster.GetScheme()),
leaderElectionID: options.LeaderElectionID,
leaseDuration: *options.LeaseDuration,
renewDeadline: *options.RenewDeadline,
Expand Down
134 changes: 130 additions & 4 deletions pkg/webhook/conversion/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
"sigs.k8s.io/controller-runtime/pkg/conversion"
logf "sigs.k8s.io/controller-runtime/pkg/log"
conversionmetrics "sigs.k8s.io/controller-runtime/pkg/webhook/conversion/metrics"
Expand All @@ -43,14 +45,110 @@ var (
log = logf.Log.WithName("conversion-webhook")
)

func NewWebhookHandler(scheme *runtime.Scheme) http.Handler {
return &webhook{scheme: scheme, decoder: NewDecoder(scheme)}
type Registry struct {
scheme *runtime.Scheme
convertersByHubGK map[schema.GroupKind]convertersForHub
}

func NewRegistry(scheme *runtime.Scheme) Registry {
return Registry{
scheme: scheme,
convertersByHubGK: map[schema.GroupKind]convertersForHub{},
}
}

type convertersForHub struct {
hubGVK schema.GroupVersionKind
convertersBySpokeGVK map[schema.GroupVersionKind]Converter
}

func (r Registry) Register(hubGVK schema.GroupVersionKind, converters ...Converter) error {
if _, ok := r.convertersByHubGK[hubGVK.GroupKind()]; ok {
return fmt.Errorf("converter already registered for %s", hubGVK.GroupKind())
}

// TODO: validate against schema that all converters have been registred for a type (similar to previous validation)

r.convertersByHubGK[hubGVK.GroupKind()] = convertersForHub{
hubGVK: hubGVK,
convertersBySpokeGVK: map[schema.GroupVersionKind]Converter{},
}
for _, converter := range converters {
converterHubGVK, err := apiutil.GVKForObject(converter.GetHub(), r.scheme)
if err != nil {
return err
}
if hubGVK != converterHubGVK {
return fmt.Errorf("converter GVK does not match builder gvk: FIXME")
}
converterSpokeGVK, err := apiutil.GVKForObject(converter.GetSpoke(), r.scheme)
if err != nil {
return err
}
if hubGVK.GroupKind() != converterSpokeGVK.GroupKind() {
return fmt.Errorf("converter GVK does not match builder gvk: FIXME")
}
r.convertersByHubGK[hubGVK.GroupKind()].convertersBySpokeGVK[converterSpokeGVK] = converter
}

return nil
}

type Converter interface {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this should be how it works internally, why do we need to have the concept of hub and spoke visible in the external interface and the webhook be aware of it? All it really wants is a convert(from, to runtime.Object) error.

Also without really having context on our current conversion machinery, why don't we use the scheme, it allows to register and call conversion funcs (which internally can be built on a hub and spoke system, but that is nothing the scheme or anything else that wants to convert really cares about)

Copy link
Member Author

@sbueringer sbueringer Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this should be how it works internally, why do we need to have the concept of hub and spoke visible in the external interface and the webhook be aware of it? All it really wants is a convert(from, to runtime.Object) error.

We could also say just take a convert(from, to runtime.Object) func, but then we have no validation on our side and users have to implement the logic in our convertObject func on there side. I would prefer if users still only have to implement ~ the ConvertTo/ConvertFrom funcs for all their hub-spoke combinations and we take care of the rest.

Also without really having context on our current conversion machinery, why don't we use the scheme, it allows to register and call conversion funcs (which internally can be built on a hub and spoke system, but that is nothing the scheme or anything else that wants to convert really cares about)

Today conversions with CR work the following way:

I would prefer if we could avoid mixing these two layers by putting all of these funcs into the scheme.
I also just realized that we should start passing context.Context into the ConvertTo/ConvertFrom funcs, that would not be possible with the scheme (it only takes type ConversionFunc func(a, b interface{}, scope Scope) error funcs)

If I understand correctly if we would want to delegate the conversion entirely to the scheme we would have to register conversion funcs for all combinations in the scheme, e.g.

  • v1beta2 <=> v1beta1, v1beta2 <=> v1alpha4, v1beta2 <=> v1alpha3
  • v1beta1 <=> v1alpha4, v1beta1 <=> v1alpha3
  • v1alpha4 <=> v1alpha3

Instead of just:

  • v1beta2 <=> v1beta1, v1beta2 <=> v1alpha4, v1beta2 <=> v1alpha3

I believe that's why the hub-spoke model was implemented as it is today. I would really prefer if users writing conversion code only have to implement the hub-spoke conversions and not conversion funcs for all combinations.

GetHub() client.Object
GetSpoke() client.Object
ConvertHubToSpoke(hub, spoke runtime.Object) error
ConvertSpokeToHub(hub, spoke runtime.Object) error
}

func NewConverter[hubObject, spokeObject client.Object](
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This confuses me. Shouldn't this take a slice of ConvertToHub(spoke runtime.Object)(hub runtime.object, _ error)? Right now, this can only be used if there are exactly to versions (and in that context, the concept of hub and spoke doesn't really make too much sense)

Copy link
Member Author

@sbueringer sbueringer Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some general context. Current state of conversion:

  • Hub has to be implemented on the hup API type
  • ConvertTo / ConvertFrom has to be implemented on all spoke API types (there's validation for that in CR)
  • So overall the following is needed (example from CAPI):
    • v1beta2 API package:
      • func (*Cluster) Hub() {}
    • v1alpha3 / v1alpha4 / v1beta1 API packages:
      • func (src *Cluster) ConvertTo(dstRaw conversion.Hub) error
      • func (dst *Cluster) ConvertFrom(srcRaw conversion.Hub) error

My main goals are:

  • Be able to implement ConvertTo/ConvertFrom outside of API packages
  • Of course accordingly ConvertTo/ConvertFrom can't be methods anymore
  • Because they are not methods anymore I need a new way to register the funcs (but I still want to be able to verify that all necessary conversions have been provided)
  • Make the conversion funcs more type-safe:
    • Today:
      • func (src *Cluster) ConvertTo(dstRaw conversion.Hub) error
      • func (dst *Cluster) ConvertFrom(srcRaw conversion.Hub) error
    • With this PR: (func names don't matter, only the parameter)
      • func ConvertClusterV1Beta1ToHub(src *clusterv1beta1.Cluster, dst *clusterv1.Cluster) error
      • func ConvertClusterHubToV1Beta1(src *clusterv1.Cluster, dst *clusterv1beta1.Cluster) error
  • Minimal migration effort for folks that already implemented ConvertTo/ConvertFrom methods and want to move to the new model

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think generics are a nice way to make the conversion funcs more type safe (similar to how this works with source.Kind, e.g.: https://github.com/kubernetes-sigs/cluster-api/blob/a9fbe115c8adf0de2c6a27f4f436ccdf657aa884/internal/controllers/clusterresourceset/clusterresourceset_controller.go#L100)

The webhook builder itself cannot become generic with type parameters for the hub type and an arbitrary amount of spoke types. So I thought I'll use an additioanl type that takes care of the generics and then implements an interface that allows to pass it into the builder and use it later.

A concrete example how this can be used based on Cluster API:

	return ctrl.NewWebhookManagedBy(mgr).
		For(&clusterv1.Cluster{}).
		WithDefaulter(webhook).
		WithValidator(webhook).
		WithConverters(
			conversion.NewConverter(&clusterv1.Cluster{}, &clusterv1beta1.Cluster{}, ConvertClusterHubToV1Beta1, ConvertClusterV1Beta1ToHub),
			conversion.NewConverter(&clusterv1.Cluster{}, &clusterv1alpha4.Cluster{}, ConvertClusterHubToV1Alpha4, ConvertClusterV1Alpha4ToHub),
			conversion.NewConverter(&clusterv1.Cluster{}, &clusterv1alpha3.Cluster{}, ConvertClusterHubToV1Alpha3, ConvertClusterV1Alpha3ToHub),
		).
		Complete()
  • The generic NewConverter func provides a conversion between the hub and one spoke type
  • The hubObject and spokeObject parameter ensure:
    • WithConverters can validate that all necessary conversions have been provided (not fully implemented yet in the PR, but it can check hub and spoke types against the type of the webhook + all GK's registered in the scheme)
    • that the passed in conversion functions have the right type parameters

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this take a slice of ConvertToHub(spoke runtime.Object)(hub runtime.object, _ error)?

It could, but it would be less type-safe (side-note: I would prefer to keep hub an input parameter, so that CR stays responible for creating an instance of the hub type).

Right now, this can only be used if there are exactly two versions (and in that context, the concept of hub and spoke doesn't really make too much sense)

No, see example one comment above. NewConverter would be just for one hub-spoke conversion. Users have to pass in all the necessary conversions (similar to how they previously had to implement ConvertTo/ConvertFrom on all spoke types)

hub hubObject,
spoke spokeObject,
convertHubToSpokeFunc func(src hubObject, dst spokeObject) error,
convertSpokeToHubFunc func(src spokeObject, dst hubObject) error,
Comment on lines +107 to +108
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO for myself: we should start passing in ctx here (e.g. for logging)

) Converter {
return &converter[hubObject, spokeObject]{
hub: hub,
spoke: spoke,
convertSpokeToHubFunc: convertSpokeToHubFunc,
convertHubToSpokeFunc: convertHubToSpokeFunc,
}
}

var _ Converter = converter[client.Object, client.Object]{}

type converter[hubObject, spokeObject client.Object] struct {
hub hubObject
spoke spokeObject
convertHubToSpokeFunc func(src hubObject, dst spokeObject) error
convertSpokeToHubFunc func(src spokeObject, dst hubObject) error
}

func (c converter[hubObject, spokeObject]) GetHub() client.Object {
return c.hub
}

func (c converter[hubObject, spokeObject]) GetSpoke() client.Object {
return c.spoke
}

func (c converter[hubObject, spokeObject]) ConvertHubToSpoke(hub, spoke runtime.Object) error {
return c.convertHubToSpokeFunc(hub.(hubObject), spoke.(spokeObject))
}

func (c converter[hubObject, spokeObject]) ConvertSpokeToHub(hub, spoke runtime.Object) error {
return c.convertSpokeToHubFunc(spoke.(spokeObject), hub.(hubObject))
}

func NewWebhookHandler(scheme *runtime.Scheme, registry Registry) http.Handler {
return &webhook{scheme: scheme, decoder: NewDecoder(scheme), registry: registry}
}

// webhook implements a CRD conversion webhook HTTP handler.
type webhook struct {
scheme *runtime.Scheme
decoder *Decoder
scheme *runtime.Scheme
decoder *Decoder
registry Registry
}

// ensure Webhook implements http.Handler
Expand Down Expand Up @@ -149,6 +247,34 @@ func (wh *webhook) convertObject(src, dst runtime.Object) error {
return fmt.Errorf("conversion is not allowed between same type %T", src)
}

if converters, ok := wh.registry.convertersByHubGK[srcGVK.GroupKind()]; ok {
srcIsHub := converters.hubGVK == srcGVK
dstIsHub := converters.hubGVK == dstGVK
_, srcIsConvertible := converters.convertersBySpokeGVK[srcGVK]
_, dstIsConvertible := converters.convertersBySpokeGVK[dstGVK]

switch {
case srcIsHub && dstIsConvertible:
return converters.convertersBySpokeGVK[dstGVK].ConvertHubToSpoke(src, dst)
case dstIsHub && srcIsConvertible:
return converters.convertersBySpokeGVK[srcGVK].ConvertSpokeToHub(src, dst)
case srcIsConvertible && dstIsConvertible:
hubGVK := converters.hubGVK
hub, err := wh.scheme.New(hubGVK)
if err != nil {
return fmt.Errorf("failed to allocate an instance for gvk %v: %w", hubGVK, err)
}
if err := converters.convertersBySpokeGVK[srcGVK].ConvertSpokeToHub(src, hub); err != nil {
return fmt.Errorf("%T failed to convert to hub version %T : %w", src, hub, err)
}
if err := converters.convertersBySpokeGVK[dstGVK].ConvertHubToSpoke(hub, dst); err != nil {
return fmt.Errorf("%T failed to convert from hub version %T : %w", dst, hub, err)
}
default:
return fmt.Errorf("%T is not convertible to %T", src, dst)
}
}

srcIsHub, dstIsHub := isHub(src), isHub(dst)
srcIsConvertible, dstIsConvertible := isConvertible(src), isConvertible(dst)

Expand Down
2 changes: 1 addition & 1 deletion pkg/webhook/conversion/conversion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ var _ = Describe("Conversion Webhook", func() {
Expect(jobsv3.AddToScheme(scheme)).To(Succeed())

decoder = conversion.NewDecoder(scheme)
wh = conversion.NewWebhookHandler(scheme)
wh = conversion.NewWebhookHandler(scheme, conversion.NewRegistry(scheme))
})

doRequest := func(convReq *apix.ConversionReview) *apix.ConversionReview {
Expand Down
Loading