Repository: kubernetes-sigs/controller-runtime

Issue:
Consider making pkg/conversion a Go sub-module
Please consider making pkg/conversion a Go sub-module within this project. That way folks that need to implement conversion webhooks in their API modules can do so without pulling in the entire Controller-Runtime dependency graph. Thanks!

Hints from issue comments:
@sbueringer: I think we should find a way to implement conversion outside of API packages (just like mutating & validating webhooks today)@sbueringer: Hm wondering if there is a pretty easy way to make this work

Currently we have the following interfaces

```go
type Convertible interface {
	runtime.Object
	ConvertTo(dst Hub) error
	ConvertFrom(src Hub) error
}

type Hub interface {
	runtime.Object
	Hub()
}
```

I think the only reason why pkg/conversion needs to be imported is because otherwise it's not possible to implement the ConvertTo / ConvertFrom functions.

If we would additionally support implementing the following interface, no import to CR would be needed at all?

```go
type Convertible interface { 
	runtime.Object
	ConvertTo(dst runtime.Object) error
	ConvertFrom(src runtime.Object) error
}
```

An alternative: I don't know if there is a way to define an interface with generics so that src & dst in the functions only have to be types that have the `Hub` method.@akutz: > Hm wondering if there is a pretty easy way to make this work
> 
> Currently we have the following interfaces
> 
> type Convertible interface {
> 	runtime.Object
> 	ConvertTo(dst Hub) error
> 	ConvertFrom(src Hub) error
> }
> 
> type Hub interface {
> 	runtime.Object
> 	Hub()
> }
> I think the only reason why pkg/conversion needs to be imported is because otherwise it's not possible to implement the ConvertTo / ConvertFrom functions.
> 
> If we would additionally support implementing the following interface, no import to CR would be needed at all?
> 
> type Convertible interface { 
> 	runtime.Object
> 	ConvertTo(dst runtime.Object) error
> 	ConvertFrom(src runtime.Object) error
> }
> An alternative: I don't know if there is a way to define an interface with generics so that src & dst in the functions only have to be types that have the `Hub` method.

So yes, the issue is the use of `dst Hub` and `src Hub` in the function signatures. And yes, generics _would_ help, but that would also mean _everything_ in controller-runtime related to those interfaces would need to use type parameters as well.

I've already thought a lot about this, and the _simplest_ solution is to make that package a separate Go module. There's nothing else in it, and it would be trivial to do. The only other solution that would approach the simplicity of this would be to do the following:

```golang
type Convertible interface {
	runtime.Object
	ConvertTo(dst runtime.Object) error
	ConvertFrom(src runtime.Object) error
}

type Hub interface {
	runtime.Object
	Hub()
}
```

This way people could just copy the interfaces locally and the actual implementation of code that expects a `Convertible` object could validate the object implements `Hub()` or returns an error.

@akutz: > I think we should find a way to implement conversion outside of API packages (just like mutating & validating webhooks today)

Yes, 1000 times yes, but what I'm recommending is far easier and does not add any real debt. In fact, my second recommendation is even simpler.@akutz: By the way, this is what it would look like to make this work with generics:

```diff
diff --git a/pkg/builder/webhook.go b/pkg/builder/webhook.go
index c74742d6..bd80cb55 100644
--- a/pkg/builder/webhook.go
+++ b/pkg/builder/webhook.go
@@ -30,13 +30,17 @@ import (
 	"k8s.io/klog/v2"
 
 	"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+	pkgconv "sigs.k8s.io/controller-runtime/pkg/conversion"
 	"sigs.k8s.io/controller-runtime/pkg/manager"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/conversion"
 )
 
 // WebhookBuilder builds a Webhook.
-type WebhookBuilder struct {
+type WebhookBuilder[
+	THub pkgconv.Hub,
+	TConvertible pkgconv.Convertible[THub],
+] struct {
 	apiType             runtime.Object
 	customDefaulter     admission.CustomDefaulter
 	customDefaulterOpts []admission.DefaulterOption
@@ -51,8 +55,15 @@ type WebhookBuilder struct {
 }
 
 // WebhookManagedBy returns a new webhook builder.
-func WebhookManagedBy(m manager.Manager) *WebhookBuilder {
-	return &WebhookBuilder{mgr: m}
+func WebhookManagedBy(m manager.Manager) *WebhookBuilder[pkgconv.Hub, pkgconv.Convertible[pkgconv.Hub]] {
+	return &WebhookBuilder[pkgconv.Hub, pkgconv.Convertible[pkgconv.Hub]]{mgr: m}
+}
+
+func WebhookManagedByTyped[
+	THub pkgconv.Hub,
+	TConvertible pkgconv.Convertible[THub],
+](m manager.Manager) *WebhookBuilder[THub, TConvertible] {
+	return &WebhookBuilder[THub, TConvertible]{mgr: m}
 }
 
 // TODO(droot): update the GoDoc for conversion.
@@ -60,7 +71,7 @@ func WebhookManagedBy(m manager.Manager) *WebhookBuilder {
 // For takes a runtime.Object which should be a CR.
 // If the given object implements the admission.Defaulter interface, a MutatingWebhook will be wired for this type.
 // If the given object implements the admission.Validator interface, a ValidatingWebhook will be wired for this type.
-func (blder *WebhookBuilder) For(apiType runtime.Object) *WebhookBuilder {
+func (blder *WebhookBuilder[THub, TConvertible]) For(apiType runtime.Object) *WebhookBuilder[THub, TConvertible] {
 	if blder.apiType != nil {
 		blder.err = errors.New("For(...) should only be called once, could not assign multiple objects for webhook registration")
 	}
@@ -70,39 +81,39 @@ func (blder *WebhookBuilder) For(apiType runtime.Object) *WebhookBuilder {
 
 // WithDefaulter takes an admission.CustomDefaulter interface, a MutatingWebhook with the provided opts (admission.DefaulterOption)
 // will be wired for this type.
-func (blder *WebhookBuilder) WithDefaulter(defaulter admission.CustomDefaulter, opts ...admission.DefaulterOption) *WebhookBuilder {
+func (blder *WebhookBuilder[THub, TConvertible]) WithDefaulter(defaulter admission.CustomDefaulter, opts ...admission.DefaulterOption) *WebhookBuilder[THub, TConvertible] {
 	blder.customDefaulter = defaulter
 	blder.customDefaulterOpts = opts
 	return blder
 }
 
 // WithValidator takes a admission.CustomValidator interface, a ValidatingWebhook will be wired for this type.
-func (blder *WebhookBuilder) WithValidator(validator admission.CustomValidator) *WebhookBuilder {
+func (blder *WebhookBuilder[THub, TConvertible]) WithValidator(validator admission.CustomValidator) *WebhookBuilder[THub, TConvertible] {
 	blder.customValidator = validator
 	return blder
 }
 
 // WithLogConstructor overrides the webhook's LogConstructor.
-func (blder *WebhookBuilder) WithLogConstructor(logConstructor func(base logr.Logger, req *admission.Request) logr.Logger) *WebhookBuilder {
+func (blder *WebhookBuilder[THub, TConvertible]) WithLogConstructor(logConstructor func(base logr.Logger, req *admission.Request) logr.Logger) *WebhookBuilder[THub, TConvertible] {
 	blder.logConstructor = logConstructor
 	return blder
 }
 
 // RecoverPanic indicates whether panics caused by the webhook should be recovered.
 // Defaults to true.
-func (blder *WebhookBuilder) RecoverPanic(recoverPanic bool) *WebhookBuilder {
+func (blder *WebhookBuilder[THub, TConvertible]) RecoverPanic(recoverPanic bool) *WebhookBuilder[THub, TConvertible] {
 	blder.recoverPanic = &recoverPanic
 	return blder
 }
 
 // WithCustomPath overrides the webhook's default path by the customPath
-func (blder *WebhookBuilder) WithCustomPath(customPath string) *WebhookBuilder {
+func (blder *WebhookBuilder[THub, TConvertible]) WithCustomPath(customPath string) *WebhookBuilder[THub, TConvertible] {
 	blder.customPath = customPath
 	return blder
 }
 
 // Complete builds the webhook.
-func (blder *WebhookBuilder) Complete() error {
+func (blder *WebhookBuilder[THub, TConvertible]) Complete() error {
 	// Set the Config
 	blder.loadRestConfig()
 
@@ -113,13 +124,13 @@ func (blder *WebhookBuilder) Complete() error {
 	return blder.registerWebhooks()
 }
 
-func (blder *WebhookBuilder) loadRestConfig() {
+func (blder *WebhookBuilder[THub, TConvertible]) loadRestConfig() {
 	if blder.config == nil {
 		blder.config = blder.mgr.GetConfig()
 	}
 }
 
-func (blder *WebhookBuilder) setLogConstructor() {
+func (blder *WebhookBuilder[THub, TConvertible]) setLogConstructor() {
 	if blder.logConstructor == nil {
 		blder.logConstructor = func(base logr.Logger, req *admission.Request) logr.Logger {
 			log := base.WithValues(
@@ -139,7 +150,7 @@ func (blder *WebhookBuilder) setLogConstructor() {
 	}
 }
 
-func (blder *WebhookBuilder) registerWebhooks() error {
+func (blder *WebhookBuilder[THub, TConvertible]) registerWebhooks() error {
 	typ, err := blder.getType()
 	if err != nil {
 		return err
@@ -169,7 +180,7 @@ func (blder *WebhookBuilder) registerWebhooks() error {
 }
 
 // registerDefaultingWebhook registers a defaulting webhook if necessary.
-func (blder *WebhookBuilder) registerDefaultingWebhook() error {
+func (blder *WebhookBuilder[THub, TConvertible]) registerDefaultingWebhook() error {
 	mwh := blder.getDefaultingWebhook()
 	if mwh != nil {
 		mwh.LogConstructor = blder.logConstructor
@@ -195,7 +206,7 @@ func (blder *WebhookBuilder) registerDefaultingWebhook() error {
 	return nil
 }
 
-func (blder *WebhookBuilder) getDefaultingWebhook() *admission.Webhook {
+func (blder *WebhookBuilder[THub, TConvertible]) getDefaultingWebhook() *admission.Webhook {
 	if defaulter := blder.customDefaulter; defaulter != nil {
 		w := admission.WithCustomDefaulter(blder.mgr.GetScheme(), blder.apiType, defaulter, blder.customDefaulterOpts...)
 		if blder.recoverPanic != nil {
@@ -207,7 +218,7 @@ func (blder *WebhookBuilder) getDefaultingWebhook() *admission.Webhook {
 }
 
 // registerValidatingWebhook registers a validating webhook if necessary.
-func (blder *WebhookBuilder) registerValidatingWebhook() error {
+func (blder *WebhookBuilder[THub, TConvertible]) registerValidatingWebhook() error {
 	vwh := blder.getValidatingWebhook()
 	if vwh != nil {
 		vwh.LogConstructor = blder.logConstructor
@@ -233,7 +244,7 @@ func (blder *WebhookBuilder) registerValidatingWebhook() error {
 	return nil
 }
 
-func (blder *WebhookBuilder) getValidatingWebhook() *admission.Webhook {
+func (blder *WebhookBuilder[THub, TConvertible]) getValidatingWebhook() *admission.Webhook {
 	if validator := blder.customValidator; validator != nil {
 		w := admission.WithCustomValidator(blder.mgr.GetScheme(), blder.apiType, validator)
 		if blder.recoverPanic != nil {
@@ -244,15 +255,15 @@ func (blder *WebhookBuilder) getValidatingWebhook() *admission.Webhook {
 	return nil
 }
 
-func (blder *WebhookBuilder) registerConversionWebhook() error {
-	ok, err := conversion.IsConvertible(blder.mgr.GetScheme(), blder.apiType)
+func (blder *WebhookBuilder[THub, TConvertible]) registerConversionWebhook() error {
+	ok, err := conversion.IsConvertibleTyped[THub, TConvertible](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()))
+			blder.mgr.GetWebhookServer().Register("/convert", conversion.NewWebhookHandlerTyped[THub, TConvertible](blder.mgr.GetScheme()))
 		}
 		log.Info("Conversion webhook enabled", "GVK", blder.gvk)
 	}
@@ -260,14 +271,14 @@ func (blder *WebhookBuilder) registerConversionWebhook() error {
 	return nil
 }
 
-func (blder *WebhookBuilder) getType() (runtime.Object, error) {
+func (blder *WebhookBuilder[THub, TConvertible]) getType() (runtime.Object, error) {
 	if blder.apiType != nil {
 		return blder.apiType, nil
 	}
 	return nil, errors.New("For() must be called with a valid object")
 }
 
-func (blder *WebhookBuilder) isAlreadyHandled(path string) bool {
+func (blder *WebhookBuilder[THub, TConvertible]) isAlreadyHandled(path string) bool {
 	if blder.mgr.GetWebhookServer().WebhookMux() == nil {
 		return false
 	}
diff --git a/pkg/conversion/conversion.go b/pkg/conversion/conversion.go
index df5973dd..21e2b99f 100644
--- a/pkg/conversion/conversion.go
+++ b/pkg/conversion/conversion.go
@@ -24,10 +24,10 @@ package conversion
 import "k8s.io/apimachinery/pkg/runtime"
 
 // Convertible defines capability of a type to convertible i.e. it can be converted to/from a hub type.
-type Convertible interface {
+type Convertible[THub Hub] interface {
 	runtime.Object
-	ConvertTo(dst runtime.Object) error
-	ConvertFrom(src runtime.Object) error
+	ConvertTo(dst THub) error
+	ConvertFrom(src THub) error
 }
 
 // Hub marks that a given type is the hub type for conversion. This means that
diff --git a/pkg/webhook/conversion/conversion.go b/pkg/webhook/conversion/conversion.go
index 249a364b..9f50ca5e 100644
--- a/pkg/webhook/conversion/conversion.go
+++ b/pkg/webhook/conversion/conversion.go
@@ -40,19 +40,30 @@ var (
 )
 
 func NewWebhookHandler(scheme *runtime.Scheme) http.Handler {
-	return &webhook{scheme: scheme, decoder: NewDecoder(scheme)}
+	return &webhook[conversion.Hub, conversion.Convertible[conversion.Hub]]{scheme: scheme, decoder: NewDecoder(scheme)}
+}
+
+func NewWebhookHandlerTyped[
+	THub conversion.Hub,
+	TConvertible conversion.Convertible[THub],
+](scheme *runtime.Scheme) http.Handler {
+
+	return &webhook[THub, TConvertible]{scheme: scheme, decoder: NewDecoder(scheme)}
 }
 
 // webhook implements a CRD conversion webhook HTTP handler.
-type webhook struct {
+type webhook[
+	THub conversion.Hub,
+	TConvertible conversion.Convertible[THub],
+] struct {
 	scheme  *runtime.Scheme
 	decoder *Decoder
 }
 
 // ensure Webhook implements http.Handler
-var _ http.Handler = &webhook{}
+var _ http.Handler = &webhook[conversion.Hub, conversion.Convertible[conversion.Hub]]{}
 
-func (wh *webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (wh *webhook[THub, TConvertible]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	convertReview := &apix.ConversionReview{}
 	err := json.NewDecoder(r.Body).Decode(convertReview)
 	if err != nil {
@@ -87,7 +98,7 @@ func (wh *webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 }
 
 // handles a version conversion request.
-func (wh *webhook) handleConvertRequest(req *apix.ConversionRequest) (*apix.ConversionResponse, error) {
+func (wh *webhook[THub, TConvertible]) handleConvertRequest(req *apix.ConversionRequest) (*apix.ConversionResponse, error) {
 	if req == nil {
 		return nil, fmt.Errorf("conversion request is nil")
 	}
@@ -120,7 +131,7 @@ func (wh *webhook) handleConvertRequest(req *apix.ConversionRequest) (*apix.Conv
 // convertObject will convert given a src object to dst object.
 // Note(droot): couldn't find a way to reduce the cyclomatic complexity under 10
 // without compromising readability, so disabling gocyclo linter
-func (wh *webhook) convertObject(src, dst runtime.Object) error {
+func (wh *webhook[THub, TConvertible]) convertObject(src, dst runtime.Object) error {
 	srcGVK := src.GetObjectKind().GroupVersionKind()
 	dstGVK := dst.GetObjectKind().GroupVersionKind()
 
@@ -133,28 +144,28 @@ func (wh *webhook) convertObject(src, dst runtime.Object) error {
 	}
 
 	srcIsHub, dstIsHub := isHub(src), isHub(dst)
-	srcIsConvertible, dstIsConvertible := isConvertible(src), isConvertible(dst)
+	srcIsConvertible, dstIsConvertible := isConvertible[THub, TConvertible](src), isConvertible[THub, TConvertible](dst)
 
 	switch {
 	case srcIsHub && dstIsConvertible:
-		return dst.(conversion.Convertible).ConvertFrom(src.(conversion.Hub))
+		return dst.(TConvertible).ConvertFrom(src.(THub))
 	case dstIsHub && srcIsConvertible:
-		return src.(conversion.Convertible).ConvertTo(dst.(conversion.Hub))
+		return src.(TConvertible).ConvertTo(dst.(THub))
 	case srcIsConvertible && dstIsConvertible:
-		return wh.convertViaHub(src.(conversion.Convertible), dst.(conversion.Convertible))
+		return wh.convertViaHub(src.(TConvertible), dst.(TConvertible))
 	default:
 		return fmt.Errorf("%T is not convertible to %T", src, dst)
 	}
 }
 
-func (wh *webhook) convertViaHub(src, dst conversion.Convertible) error {
-	hub, err := wh.getHub(src)
+func (wh *webhook[THub, TConvertible]) convertViaHub(src, dst TConvertible) error {
+	hub, ok, err := wh.getHub(src)
 	if err != nil {
 		return err
 	}
 
-	if hub == nil {
-		return fmt.Errorf("%s does not have any Hub defined", src)
+	if !ok {
+		return fmt.Errorf("%T does not have any Hub defined", src)
 	}
 
 	err = src.ConvertTo(hub)
@@ -171,35 +182,36 @@ func (wh *webhook) convertViaHub(src, dst conversion.Convertible) error {
 }
 
 // getHub returns an instance of the Hub for passed-in object's group/kind.
-func (wh *webhook) getHub(obj runtime.Object) (conversion.Hub, error) {
+func (wh *webhook[THub, TConvertible]) getHub(obj runtime.Object) (THub, bool, error) {
+	var hub THub
+
 	gvks, err := objectGVKs(wh.scheme, obj)
 	if err != nil {
-		return nil, err
+		return hub, false, err
 	}
 	if len(gvks) == 0 {
-		return nil, fmt.Errorf("error retrieving gvks for object : %v", obj)
+		return hub, false, fmt.Errorf("error retrieving gvks for object : %v", obj)
 	}
 
-	var hub conversion.Hub
 	var hubFoundAlready bool
 	for _, gvk := range gvks {
 		instance, err := wh.scheme.New(gvk)
 		if err != nil {
-			return nil, fmt.Errorf("failed to allocate an instance for gvk %v: %w", gvk, err)
+			return hub, false, fmt.Errorf("failed to allocate an instance for gvk %v: %w", gvk, err)
 		}
-		if val, isHub := instance.(conversion.Hub); isHub {
+		if val, isHub := instance.(THub); isHub {
 			if hubFoundAlready {
-				return nil, fmt.Errorf("multiple hub version defined for %T", obj)
+				return hub, false, fmt.Errorf("multiple hub version defined for %T", obj)
 			}
 			hubFoundAlready = true
 			hub = val
 		}
 	}
-	return hub, nil
+	return hub, hubFoundAlready, nil
 }
 
 // allocateDstObject returns an instance for a given GVK.
-func (wh *webhook) allocateDstObject(apiVersion, kind string) (runtime.Object, error) {
+func (wh *webhook[THub, TConvertible]) allocateDstObject(apiVersion, kind string) (runtime.Object, error) {
 	gvk := schema.FromAPIVersionAndKind(apiVersion, kind)
 
 	obj, err := wh.scheme.New(gvk)
@@ -222,6 +234,17 @@ func (wh *webhook) allocateDstObject(apiVersion, kind string) (runtime.Object, e
 // to be convertible, the group-kind needs to have a Hub type defined and all
 // non-hub types must be able to convert to/from Hub.
 func IsConvertible(scheme *runtime.Scheme, obj runtime.Object) (bool, error) {
+	return IsConvertibleTyped[conversion.Hub, conversion.Convertible[conversion.Hub]](scheme, obj)
+}
+
+// IsConvertibleTyped determines if given type is convertible or not. For a type
+// to be convertible, the group-kind needs to have a Hub type defined and all
+// non-hub types must be able to convert to/from Hub.
+func IsConvertibleTyped[
+	THub conversion.Hub,
+	TConvertible conversion.Convertible[THub],
+](scheme *runtime.Scheme, obj runtime.Object) (bool, error) {
+
 	var hubs, spokes, nonSpokes []runtime.Object
 
 	gvks, err := objectGVKs(scheme, obj)
@@ -243,7 +266,7 @@ func IsConvertible(scheme *runtime.Scheme, obj runtime.Object) (bool, error) {
 			continue
 		}
 
-		if !isConvertible(instance) {
+		if !isConvertible[THub, TConvertible](instance) {
 			nonSpokes = append(nonSpokes, instance)
 			continue
 		}
@@ -327,8 +350,11 @@ func isHub(obj runtime.Object) bool {
 }
 
 // isConvertible determines if passed-in object is a convertible.
-func isConvertible(obj runtime.Object) bool {
-	_, yes := obj.(conversion.Convertible)
+func isConvertible[
+	THub conversion.Hub,
+	TConvertible conversion.Convertible[THub],
+](obj runtime.Object) bool {
+	_, yes := obj.(TConvertible)
 	return yes
 }
```@sbueringer: > The only other solution that would approach the simplicity of this would be to do the following:

I would be fine with that one, either by modifying the existing interface or adding a new one if we don't want to modify the existing one. Taking Hub as an input parameter isn't worth the dependency this introduces to API packages in my opinion.

I"m not a big fan of a separate module as we either have to push additional tags for that one or it won't have proper versions.

@vincepri @alvaroaleman Opinions?

(I'll take a closer look at the generics option when I have some time)@sbueringer: Hm, yeah the generics option has a lot of impact.

I think we could avoid some / half of it if we don't change builder/webhook and require folks to call the following once directly instead:
```go
			blder.mgr.GetWebhookServer().Register("/convert", conversion.NewWebhookHandlerTyped[THub, TConvertible](blder.mgr.GetScheme()))
```

But it's still a lot of changes only to ~ keep the Hub interface in the ConvertTo/ConvertFrom methods.@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle rotten

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues and PRs.

This bot triages issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Reopen this issue with `/reopen`
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/close not-planned

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-ci-robot: @k8s-triage-robot: Closing this issue, marking it as "Not Planned".

<details>

In response to [this](https://github.com/kubernetes-sigs/controller-runtime/issues/3130#issuecomment-3119916232):

>The Kubernetes project currently lacks enough active contributors to adequately respond to all issues and PRs.
>
>This bot triages issues according to the following rules:
>- After 90d of inactivity, `lifecycle/stale` is applied
>- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
>- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed
>
>You can:
>- Reopen this issue with `/reopen`
>- Mark this issue as fresh with `/remove-lifecycle rotten`
>- Offer to help out with [Issue Triage][1]
>
>Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).
>
>/close not-planned
>
>[1]: https://www.kubernetes.dev/docs/guide/issue-triage/


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>@sbueringer: Should be covered by https://github.com/kubernetes-sigs/controller-runtime/pull/3335

/close@k8s-ci-robot: @sbueringer: Closing this issue.

<details>

In response to [this](https://github.com/kubernetes-sigs/controller-runtime/issues/3130#issuecomment-3420717550):

>Should be covered by https://github.com/kubernetes-sigs/controller-runtime/pull/3335
>
>/close


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

Solution patch:
diff --git a/pkg/builder/webhook.go b/pkg/builder/webhook.go
index 6f4726d2..bb5b6deb 100644
--- a/pkg/builder/webhook.go
+++ b/pkg/builder/webhook.go
@@ -45,6 +45,7 @@ type WebhookBuilder struct {
 	customPath                string
 	customValidatorCustomPath string
 	customDefaulterCustomPath string
+	converterConstructor      func(*runtime.Scheme) (conversion.Converter, error)
 	gvk                       schema.GroupVersionKind
 	mgr                       manager.Manager
 	config                    *rest.Config
@@ -86,6 +87,13 @@ func (blder *WebhookBuilder) WithValidator(validator admission.CustomValidator)
 	return blder
 }
 
+// WithConverter takes a func that constructs a converter.Converter.
+// The Converter will then be used by the conversion endpoint for the type passed into For().
+func (blder *WebhookBuilder) WithConverter(converterConstructor func(*runtime.Scheme) (conversion.Converter, error)) *WebhookBuilder {
+	blder.converterConstructor = converterConstructor
+	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
@@ -287,17 +295,30 @@ 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 blder.converterConstructor != nil {
+		converter, err := blder.converterConstructor(blder.mgr.GetScheme())
+		if err != nil {
+			return err
 		}
-		log.Info("Conversion webhook enabled", "GVK", blder.gvk)
+
+		if err := blder.mgr.GetConverterRegistry().RegisterConverter(blder.gvk.GroupKind(), converter); err != nil {
+			return 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
+		}
+	}
+
+	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
 }
diff --git a/pkg/manager/internal.go b/pkg/manager/internal.go
index 4362022b..187d4f56 100644
--- a/pkg/manager/internal.go
+++ b/pkg/manager/internal.go
@@ -36,6 +36,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"
@@ -130,6 +131,9 @@ type controllerManager struct {
 	// webhookServer if unset, and Add() it to controllerManager.
 	webhookServerOnce sync.Once
 
+	// converterRegistry stores conversion.Converter for the conversion endpoint.
+	converterRegistry conversion.Registry
+
 	// leaderElectionID is the name of the resource that leader election
 	// will use for holding the leader lock.
 	leaderElectionID string
@@ -284,6 +288,10 @@ func (cm *controllerManager) GetWebhookServer() webhook.Server {
 	return cm.webhookServer
 }
 
+func (cm *controllerManager) GetConverterRegistry() conversion.Registry {
+	return cm.converterRegistry
+}
+
 func (cm *controllerManager) GetLogger() logr.Logger {
 	return cm.logger
 }
diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go
index 74983ddc..af532ea7 100644
--- a/pkg/manager/manager.go
+++ b/pkg/manager/manager.go
@@ -36,6 +36,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"
@@ -97,6 +98,10 @@ type Manager interface {
 
 	// GetControllerOptions returns controller global configuration options.
 	GetControllerOptions() config.Controller
+
+	// GetConverterRegistry returns the converter registry that is used to store conversion.Converter
+	// for the conversion endpoint.
+	GetConverterRegistry() conversion.Registry
 }
 
 // Options are the arguments for creating a new Manager.
@@ -450,6 +455,7 @@ func New(config *rest.Config, options Options) (Manager, error) {
 		logger:                        options.Logger,
 		elected:                       make(chan struct{}),
 		webhookServer:                 options.WebhookServer,
+		converterRegistry:             conversion.NewRegistry(),
 		leaderElectionID:              options.LeaderElectionID,
 		leaseDuration:                 *options.LeaseDuration,
 		renewDeadline:                 *options.RenewDeadline,
diff --git a/pkg/webhook/conversion/conversion.go b/pkg/webhook/conversion/conversion.go
index a26fa348..3f98fb7b 100644
--- a/pkg/webhook/conversion/conversion.go
+++ b/pkg/webhook/conversion/conversion.go
@@ -43,14 +43,15 @@ var (
 	log = logf.Log.WithName("conversion-webhook")
 )
 
-func NewWebhookHandler(scheme *runtime.Scheme) http.Handler {
-	return &webhook{scheme: scheme, decoder: NewDecoder(scheme)}
+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
@@ -119,7 +120,7 @@ func (wh *webhook) handleConvertRequest(ctx context.Context, req *apix.Conversio
 		if err != nil {
 			return nil, err
 		}
-		err = wh.convertObject(src, dst)
+		err = wh.convertObject(ctx, src, dst)
 		if err != nil {
 			return nil, err
 		}
@@ -137,7 +138,7 @@ func (wh *webhook) handleConvertRequest(ctx context.Context, req *apix.Conversio
 // convertObject will convert given a src object to dst object.
 // Note(droot): couldn't find a way to reduce the cyclomatic complexity under 10
 // without compromising readability, so disabling gocyclo linter
-func (wh *webhook) convertObject(src, dst runtime.Object) error {
+func (wh *webhook) convertObject(ctx context.Context, src, dst runtime.Object) error {
 	srcGVK := src.GetObjectKind().GroupVersionKind()
 	dstGVK := dst.GetObjectKind().GroupVersionKind()
 
@@ -149,6 +150,10 @@ func (wh *webhook) convertObject(src, dst runtime.Object) error {
 		return fmt.Errorf("conversion is not allowed between same type %T", src)
 	}
 
+	if converter, ok := wh.registry.GetConverter(srcGVK.GroupKind()); ok {
+		return converter.ConvertObject(ctx, src, dst)
+	}
+
 	srcIsHub, dstIsHub := isHub(src), isHub(dst)
 	srcIsConvertible, dstIsConvertible := isConvertible(src), isConvertible(dst)
 
diff --git a/pkg/webhook/conversion/conversion_hubspoke.go b/pkg/webhook/conversion/conversion_hubspoke.go
new file mode 100644
index 00000000..b33af92f
--- /dev/null
+++ b/pkg/webhook/conversion/conversion_hubspoke.go
@@ -0,0 +1,173 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+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 conversion
+
+import (
+	"context"
+	"fmt"
+	"slices"
+	"strings"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+)
+
+func NewHubSpokeConverter[hubObject runtime.Object](hub hubObject, spokeConverter ...SpokeConverter[hubObject]) func(scheme *runtime.Scheme) (Converter, error) {
+	return func(scheme *runtime.Scheme) (Converter, error) {
+		hubGVK, err := apiutil.GVKForObject(hub, scheme)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create hub spoke converter: failed to get GroupVersionKind for hub: %w", err)
+		}
+		allGVKs, err := objectGVKs(scheme, hub)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create hub spoke converter for %s: %w", hubGVK.Kind, err)
+		}
+		spokeVersions := sets.New[string]()
+		for _, gvk := range allGVKs {
+			if gvk != hubGVK {
+				spokeVersions.Insert(gvk.Version)
+			}
+		}
+
+		c := &hubSpokeConverter[hubObject]{
+			scheme:              scheme,
+			hubGVK:              hubGVK,
+			spokeConverterByGVK: map[schema.GroupVersionKind]SpokeConverter[hubObject]{},
+		}
+
+		spokeConverterVersions := sets.New[string]()
+		for _, sc := range spokeConverter {
+			spokeGVK, err := apiutil.GVKForObject(sc.GetSpoke(), scheme)
+			if err != nil {
+				return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
+					"failed to get GroupVersionKind for spoke converter: %w",
+					hubGVK.Kind, err)
+			}
+			if hubGVK.GroupKind() != spokeGVK.GroupKind() {
+				return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
+					"spoke converter GroupKind %s does not match hub GroupKind %s",
+					hubGVK.Kind, spokeGVK.GroupKind(), hubGVK.GroupKind())
+			}
+
+			if _, ok := c.spokeConverterByGVK[spokeGVK]; ok {
+				return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
+					"duplicate spoke converter for version %s",
+					hubGVK.Kind, spokeGVK.Version)
+			}
+			c.spokeConverterByGVK[spokeGVK] = sc
+			spokeConverterVersions.Insert(spokeGVK.Version)
+		}
+
+		if !spokeConverterVersions.Equal(spokeVersions) {
+			return nil, fmt.Errorf("failed to create hub spoke converter for %s: "+
+				"expected spoke converter for %s got spoke converter for %s",
+				hubGVK.Kind, sortAndJoin(spokeVersions), sortAndJoin(spokeConverterVersions))
+		}
+
+		return c, nil
+	}
+}
+
+func sortAndJoin(set sets.Set[string]) string {
+	list := set.UnsortedList()
+	slices.Sort(list)
+	return strings.Join(list, ",")
+}
+
+type hubSpokeConverter[hubObject runtime.Object] struct {
+	scheme              *runtime.Scheme
+	hubGVK              schema.GroupVersionKind
+	spokeConverterByGVK map[schema.GroupVersionKind]SpokeConverter[hubObject]
+}
+
+func (c hubSpokeConverter[hubObject]) ConvertObject(ctx context.Context, src, dst runtime.Object) error {
+	srcGVK := src.GetObjectKind().GroupVersionKind()
+	dstGVK := dst.GetObjectKind().GroupVersionKind()
+
+	if srcGVK.GroupKind() != dstGVK.GroupKind() {
+		return fmt.Errorf("src %T and dst %T does not belong to same API Group", src, dst)
+	}
+
+	if srcGVK == dstGVK {
+		return fmt.Errorf("conversion is not allowed between same type %T", src)
+	}
+
+	srcIsHub := c.hubGVK == srcGVK
+	dstIsHub := c.hubGVK == dstGVK
+	_, srcIsConvertible := c.spokeConverterByGVK[srcGVK]
+	_, dstIsConvertible := c.spokeConverterByGVK[dstGVK]
+
+	switch {
+	case srcIsHub && dstIsConvertible:
+		return c.spokeConverterByGVK[dstGVK].ConvertHubToSpoke(ctx, src.(hubObject), dst)
+	case dstIsHub && srcIsConvertible:
+		return c.spokeConverterByGVK[srcGVK].ConvertSpokeToHub(ctx, src, dst.(hubObject))
+	case srcIsConvertible && dstIsConvertible:
+		hub, err := c.scheme.New(c.hubGVK)
+		if err != nil {
+			return fmt.Errorf("failed to allocate an instance for GroupVersionKind %s: %w", c.hubGVK, err)
+		}
+		if err := c.spokeConverterByGVK[srcGVK].ConvertSpokeToHub(ctx, src, hub.(hubObject)); err != nil {
+			return fmt.Errorf("failed to convert spoke %s to hub %s : %w", srcGVK, c.hubGVK, err)
+		}
+		if err := c.spokeConverterByGVK[dstGVK].ConvertHubToSpoke(ctx, hub.(hubObject), dst); err != nil {
+			return fmt.Errorf("failed to convert hub %s to spoke %s : %w", c.hubGVK, dstGVK, err)
+		}
+		return nil
+	default:
+		return fmt.Errorf("failed to convert %s to %s: not convertible", srcGVK, dstGVK)
+	}
+}
+
+type SpokeConverter[hubObject runtime.Object] interface {
+	GetSpoke() runtime.Object
+	ConvertHubToSpoke(ctx context.Context, hub hubObject, spoke runtime.Object) error
+	ConvertSpokeToHub(ctx context.Context, spoke runtime.Object, hub hubObject) error
+}
+
+func NewSpokeConverter[hubObject, spokeObject client.Object](
+	spoke spokeObject,
+	convertHubToSpokeFunc func(ctx context.Context, src hubObject, dst spokeObject) error,
+	convertSpokeToHubFunc func(ctx context.Context, src spokeObject, dst hubObject) error,
+) SpokeConverter[hubObject] {
+	return &spokeConverter[hubObject, spokeObject]{
+		spoke:                 spoke,
+		convertSpokeToHubFunc: convertSpokeToHubFunc,
+		convertHubToSpokeFunc: convertHubToSpokeFunc,
+	}
+}
+
+type spokeConverter[hubObject, spokeObject runtime.Object] struct {
+	spoke                 spokeObject
+	convertHubToSpokeFunc func(ctx context.Context, src hubObject, dst spokeObject) error
+	convertSpokeToHubFunc func(ctx context.Context, src spokeObject, dst hubObject) error
+}
+
+func (c spokeConverter[hubObject, spokeObject]) GetSpoke() runtime.Object {
+	return c.spoke
+}
+
+func (c spokeConverter[hubObject, spokeObject]) ConvertHubToSpoke(ctx context.Context, hub hubObject, spoke runtime.Object) error {
+	return c.convertHubToSpokeFunc(ctx, hub, spoke.(spokeObject))
+}
+
+func (c spokeConverter[hubObject, spokeObject]) ConvertSpokeToHub(ctx context.Context, spoke runtime.Object, hub hubObject) error {
+	return c.convertSpokeToHubFunc(ctx, spoke.(spokeObject), hub)
+}
diff --git a/pkg/webhook/conversion/conversion_registry.go b/pkg/webhook/conversion/conversion_registry.go
new file mode 100644
index 00000000..6e68b5ff
--- /dev/null
+++ b/pkg/webhook/conversion/conversion_registry.go
@@ -0,0 +1,57 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+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 conversion
+
+import (
+	"context"
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+type Converter interface {
+	ConvertObject(ctx context.Context, src, dst runtime.Object) error
+}
+
+type Registry interface {
+	RegisterConverter(gk schema.GroupKind, converter Converter) error
+	GetConverter(gk schema.GroupKind) (Converter, bool)
+}
+
+type registry struct {
+	converterByGK map[schema.GroupKind]Converter
+}
+
+func NewRegistry() Registry {
+	return registry{
+		converterByGK: map[schema.GroupKind]Converter{},
+	}
+}
+func (r registry) RegisterConverter(gk schema.GroupKind, converter Converter) error {
+	if _, ok := r.converterByGK[gk]; ok {
+		return fmt.Errorf("failed to register Converter for GroupKind %s: converter already registered", gk)
+	}
+
+	r.converterByGK[gk] = converter
+	return nil
+}
+
+func (r registry) GetConverter(gk schema.GroupKind) (Converter, bool) {
+	c, ok := r.converterByGK[gk]
+	return c, ok
+}



Test patch:
diff --git a/pkg/manager/internal/integration/manager_test.go b/pkg/manager/internal/integration/manager_test.go
index c83eead3..570c932a 100644
--- a/pkg/manager/internal/integration/manager_test.go
+++ b/pkg/manager/internal/integration/manager_test.go
@@ -262,7 +262,7 @@ type ConversionWebhook struct {
 }
 
 func createConversionWebhook(mgr manager.Manager) *ConversionWebhook {
-	conversionHandler := conversion.NewWebhookHandler(mgr.GetScheme())
+	conversionHandler := conversion.NewWebhookHandler(mgr.GetScheme(), mgr.GetConverterRegistry())
 	httpClient := http.Client{
 		// Setting a timeout to not get stuck when calling the readiness probe.
 		Timeout: 5 * time.Second,
diff --git a/pkg/webhook/conversion/conversion_test.go b/pkg/webhook/conversion/conversion_test.go
index 489689bc..046ab44c 100644
--- a/pkg/webhook/conversion/conversion_test.go
+++ b/pkg/webhook/conversion/conversion_test.go
@@ -18,6 +18,7 @@ package conversion_test
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"io"
 	"net/http"
@@ -25,7 +26,7 @@ import (
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-
+	appsv1 "k8s.io/api/apps/v1"
 	appsv1beta1 "k8s.io/api/apps/v1beta1"
 	apix "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -39,285 +40,370 @@ import (
 	jobsv3 "sigs.k8s.io/controller-runtime/pkg/webhook/conversion/testdata/api/v3"
 )
 
-var _ = Describe("Conversion Webhook", func() {
-
-	var respRecorder *httptest.ResponseRecorder
-	var decoder *conversion.Decoder
-	var scheme *runtime.Scheme
-	var wh http.Handler
-
-	BeforeEach(func() {
-		respRecorder = &httptest.ResponseRecorder{
-			Body: bytes.NewBuffer(nil),
-		}
-
-		scheme = runtime.NewScheme()
-		Expect(kscheme.AddToScheme(scheme)).To(Succeed())
-		Expect(jobsv1.AddToScheme(scheme)).To(Succeed())
-		Expect(jobsv2.AddToScheme(scheme)).To(Succeed())
-		Expect(jobsv3.AddToScheme(scheme)).To(Succeed())
-
-		decoder = conversion.NewDecoder(scheme)
-		wh = conversion.NewWebhookHandler(scheme)
-	})
-
-	doRequest := func(convReq *apix.ConversionReview) *apix.ConversionReview {
-		var payload bytes.Buffer
+var _ = Describe("Conversion with Hub/ConvertTo/ConvertFrom methods", func() {
+	ConversionTest(false)
+})
 
-		Expect(json.NewEncoder(&payload).Encode(convReq)).Should(Succeed())
+var _ = Describe("Conversion with HubSpokeConverter", func() {
+	ConversionTest(true)
+})
 
-		convReview := &apix.ConversionReview{}
-		req := &http.Request{
-			Body: io.NopCloser(bytes.NewReader(payload.Bytes())),
+func ConversionTest(withHubSpokeConverter bool) {
+	Describe("Conversion Webhook", func() {
+		var respRecorder *httptest.ResponseRecorder
+		var decoder *conversion.Decoder
+		var scheme *runtime.Scheme
+		var wh http.Handler
+
+		BeforeEach(func() {
+			respRecorder = &httptest.ResponseRecorder{
+				Body: bytes.NewBuffer(nil),
+			}
+
+			scheme = runtime.NewScheme()
+			Expect(kscheme.AddToScheme(scheme)).To(Succeed())
+			Expect(jobsv1.AddToScheme(scheme)).To(Succeed())
+			Expect(jobsv2.AddToScheme(scheme)).To(Succeed())
+			Expect(jobsv3.AddToScheme(scheme)).To(Succeed())
+
+			decoder = conversion.NewDecoder(scheme)
+			registry := conversion.NewRegistry()
+
+			if withHubSpokeConverter {
+				converter, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{},
+					conversion.NewSpokeConverter(&jobsv1.ExternalJob{}, convertHubToV1, convertV1ToHub),
+					conversion.NewSpokeConverter(&jobsv3.ExternalJob{}, convertHubToV3, convertV3ToHub),
+				)(scheme)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(registry.RegisterConverter(jobsv2.GroupVersion.WithKind("ExternalJob").GroupKind(), converter)).To(Succeed())
+			}
+
+			wh = conversion.NewWebhookHandler(scheme, registry)
+		})
+
+		doRequest := func(convReq *apix.ConversionReview) *apix.ConversionReview {
+			var payload bytes.Buffer
+
+			Expect(json.NewEncoder(&payload).Encode(convReq)).Should(Succeed())
+
+			convReview := &apix.ConversionReview{}
+			req := &http.Request{
+				Body: io.NopCloser(bytes.NewReader(payload.Bytes())),
+			}
+			wh.ServeHTTP(respRecorder, req)
+			Expect(json.NewDecoder(respRecorder.Result().Body).Decode(convReview)).To(Succeed())
+			return convReview
 		}
-		wh.ServeHTTP(respRecorder, req)
-		Expect(json.NewDecoder(respRecorder.Result().Body).Decode(convReview)).To(Succeed())
-		return convReview
-	}
 
-	makeV1Obj := func() *jobsv1.ExternalJob {
-		return &jobsv1.ExternalJob{
-			TypeMeta: metav1.TypeMeta{
-				Kind:       "ExternalJob",
-				APIVersion: "jobs.testprojects.kb.io/v1",
-			},
-			ObjectMeta: metav1.ObjectMeta{
-				Namespace: "default",
-				Name:      "obj-1",
-			},
-			Spec: jobsv1.ExternalJobSpec{
-				RunAt: "every 2 seconds",
-			},
+		makeV1Obj := func() *jobsv1.ExternalJob {
+			return &jobsv1.ExternalJob{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "ExternalJob",
+					APIVersion: "jobs.testprojects.kb.io/v1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "default",
+					Name:      "obj-1",
+				},
+				Spec: jobsv1.ExternalJobSpec{
+					RunAt: "every 2 seconds",
+				},
+			}
 		}
-	}
 
-	makeV2Obj := func() *jobsv2.ExternalJob {
-		return &jobsv2.ExternalJob{
-			TypeMeta: metav1.TypeMeta{
-				Kind:       "ExternalJob",
-				APIVersion: "jobs.testprojects.kb.io/v2",
-			},
-			ObjectMeta: metav1.ObjectMeta{
-				Namespace: "default",
-				Name:      "obj-1",
-			},
-			Spec: jobsv2.ExternalJobSpec{
-				ScheduleAt: "every 2 seconds",
-			},
+		makeV2Obj := func() *jobsv2.ExternalJob {
+			return &jobsv2.ExternalJob{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "ExternalJob",
+					APIVersion: "jobs.testprojects.kb.io/v2",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "default",
+					Name:      "obj-1",
+				},
+				Spec: jobsv2.ExternalJobSpec{
+					ScheduleAt: "every 2 seconds",
+				},
+			}
 		}
-	}
 
-	It("should convert spoke to hub successfully", func() {
+		It("should convert spoke to hub successfully", func() {
 
-		v1Obj := makeV1Obj()
+			v1Obj := makeV1Obj()
 
-		expected := &jobsv2.ExternalJob{
-			TypeMeta: metav1.TypeMeta{
-				Kind:       "ExternalJob",
-				APIVersion: "jobs.testprojects.kb.io/v2",
-			},
-			ObjectMeta: metav1.ObjectMeta{
-				Namespace: "default",
-				Name:      "obj-1",
-			},
-			Spec: jobsv2.ExternalJobSpec{
-				ScheduleAt: "every 2 seconds",
-			},
-		}
-
-		convReq := &apix.ConversionReview{
-			TypeMeta: metav1.TypeMeta{},
-			Request: &apix.ConversionRequest{
-				DesiredAPIVersion: "jobs.testprojects.kb.io/v2",
-				Objects: []runtime.RawExtension{
-					{
-						Object: v1Obj,
+			expected := &jobsv2.ExternalJob{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "ExternalJob",
+					APIVersion: "jobs.testprojects.kb.io/v2",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "default",
+					Name:      "obj-1",
+				},
+				Spec: jobsv2.ExternalJobSpec{
+					ScheduleAt: "every 2 seconds",
+				},
+			}
+
+			convReq := &apix.ConversionReview{
+				TypeMeta: metav1.TypeMeta{},
+				Request: &apix.ConversionRequest{
+					DesiredAPIVersion: "jobs.testprojects.kb.io/v2",
+					Objects: []runtime.RawExtension{
+						{
+							Object: v1Obj,
+						},
 					},
 				},
-			},
-		}
+			}
 
-		convReview := doRequest(convReq)
+			convReview := doRequest(convReq)
 
-		Expect(convReview.Response.ConvertedObjects).To(HaveLen(1))
-		Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess))
-		got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw)
-		Expect(err).NotTo(HaveOccurred())
-		Expect(got).To(Equal(expected))
-	})
+			Expect(convReview.Response.ConvertedObjects).To(HaveLen(1))
+			Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess))
+			got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(got).To(Equal(expected))
+		})
 
-	It("should convert hub to spoke successfully", func() {
+		It("should convert hub to spoke successfully", func() {
 
-		v2Obj := makeV2Obj()
+			v2Obj := makeV2Obj()
 
-		expected := &jobsv1.ExternalJob{
-			TypeMeta: metav1.TypeMeta{
-				Kind:       "ExternalJob",
-				APIVersion: "jobs.testprojects.kb.io/v1",
-			},
-			ObjectMeta: metav1.ObjectMeta{
-				Namespace: "default",
-				Name:      "obj-1",
-			},
-			Spec: jobsv1.ExternalJobSpec{
-				RunAt: "every 2 seconds",
-			},
-		}
-
-		convReq := &apix.ConversionReview{
-			TypeMeta: metav1.TypeMeta{},
-			Request: &apix.ConversionRequest{
-				DesiredAPIVersion: "jobs.testprojects.kb.io/v1",
-				Objects: []runtime.RawExtension{
-					{
-						Object: v2Obj,
+			expected := &jobsv1.ExternalJob{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "ExternalJob",
+					APIVersion: "jobs.testprojects.kb.io/v1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "default",
+					Name:      "obj-1",
+				},
+				Spec: jobsv1.ExternalJobSpec{
+					RunAt: "every 2 seconds",
+				},
+			}
+
+			convReq := &apix.ConversionReview{
+				TypeMeta: metav1.TypeMeta{},
+				Request: &apix.ConversionRequest{
+					DesiredAPIVersion: "jobs.testprojects.kb.io/v1",
+					Objects: []runtime.RawExtension{
+						{
+							Object: v2Obj,
+						},
 					},
 				},
-			},
-		}
+			}
 
-		convReview := doRequest(convReq)
+			convReview := doRequest(convReq)
 
-		Expect(convReview.Response.ConvertedObjects).To(HaveLen(1))
-		Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess))
-		got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw)
-		Expect(err).NotTo(HaveOccurred())
-		Expect(got).To(Equal(expected))
-	})
+			Expect(convReview.Response.ConvertedObjects).To(HaveLen(1))
+			Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess))
+			got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(got).To(Equal(expected))
+		})
 
-	It("should convert spoke to spoke successfully", func() {
+		It("should convert spoke to spoke successfully", func() {
 
-		v1Obj := makeV1Obj()
+			v1Obj := makeV1Obj()
 
-		expected := &jobsv3.ExternalJob{
-			TypeMeta: metav1.TypeMeta{
-				Kind:       "ExternalJob",
-				APIVersion: "jobs.testprojects.kb.io/v3",
-			},
-			ObjectMeta: metav1.ObjectMeta{
-				Namespace: "default",
-				Name:      "obj-1",
-			},
-			Spec: jobsv3.ExternalJobSpec{
-				DeferredAt: "every 2 seconds",
-			},
-		}
-
-		convReq := &apix.ConversionReview{
-			TypeMeta: metav1.TypeMeta{},
-			Request: &apix.ConversionRequest{
-				DesiredAPIVersion: "jobs.testprojects.kb.io/v3",
-				Objects: []runtime.RawExtension{
-					{
-						Object: v1Obj,
+			expected := &jobsv3.ExternalJob{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "ExternalJob",
+					APIVersion: "jobs.testprojects.kb.io/v3",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "default",
+					Name:      "obj-1",
+				},
+				Spec: jobsv3.ExternalJobSpec{
+					DeferredAt: "every 2 seconds",
+				},
+			}
+
+			convReq := &apix.ConversionReview{
+				TypeMeta: metav1.TypeMeta{},
+				Request: &apix.ConversionRequest{
+					DesiredAPIVersion: "jobs.testprojects.kb.io/v3",
+					Objects: []runtime.RawExtension{
+						{
+							Object: v1Obj,
+						},
 					},
 				},
-			},
-		}
+			}
+
+			convReview := doRequest(convReq)
+
+			Expect(convReview.Response.ConvertedObjects).To(HaveLen(1))
+			Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess))
+			got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(got).To(Equal(expected))
+		})
+
+		It("should return error when dest/src objects belong to different API groups", func() {
+			v1Obj := makeV1Obj()
+
+			convReq := &apix.ConversionReview{
+				TypeMeta: metav1.TypeMeta{},
+				Request: &apix.ConversionRequest{
+					// request conversion for different group
+					DesiredAPIVersion: "jobss.example.org/v2",
+					Objects: []runtime.RawExtension{
+						{
+							Object: v1Obj,
+						},
+					},
+				},
+			}
 
-		convReview := doRequest(convReq)
+			convReview := doRequest(convReq)
+			Expect(convReview.Response.Result.Status).To(Equal("Failure"))
+			Expect(convReview.Response.ConvertedObjects).To(BeEmpty())
+		})
 
-		Expect(convReview.Response.ConvertedObjects).To(HaveLen(1))
-		Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusSuccess))
-		got, _, err := decoder.Decode(convReview.Response.ConvertedObjects[0].Raw)
-		Expect(err).NotTo(HaveOccurred())
-		Expect(got).To(Equal(expected))
-	})
+		It("should return error when dest/src objects are of same type", func() {
+
+			v1Obj := makeV1Obj()
 
-	It("should return error when dest/src objects belong to different API groups", func() {
-		v1Obj := makeV1Obj()
-
-		convReq := &apix.ConversionReview{
-			TypeMeta: metav1.TypeMeta{},
-			Request: &apix.ConversionRequest{
-				// request conversion for different group
-				DesiredAPIVersion: "jobss.example.org/v2",
-				Objects: []runtime.RawExtension{
-					{
-						Object: v1Obj,
+			convReq := &apix.ConversionReview{
+				TypeMeta: metav1.TypeMeta{},
+				Request: &apix.ConversionRequest{
+					DesiredAPIVersion: "jobs.testprojects.kb.io/v1",
+					Objects: []runtime.RawExtension{
+						{
+							Object: v1Obj,
+						},
 					},
 				},
-			},
-		}
-
-		convReview := doRequest(convReq)
-		Expect(convReview.Response.Result.Status).To(Equal("Failure"))
-		Expect(convReview.Response.ConvertedObjects).To(BeEmpty())
-	})
+			}
 
-	It("should return error when dest/src objects are of same type", func() {
+			convReview := doRequest(convReq)
+			Expect(convReview.Response.Result.Status).To(Equal("Failure"))
+			Expect(convReview.Response.ConvertedObjects).To(BeEmpty())
+		})
 
-		v1Obj := makeV1Obj()
+		It("should return error when the API group does not have a hub defined", func() {
 
-		convReq := &apix.ConversionReview{
-			TypeMeta: metav1.TypeMeta{},
-			Request: &apix.ConversionRequest{
-				DesiredAPIVersion: "jobs.testprojects.kb.io/v1",
-				Objects: []runtime.RawExtension{
-					{
-						Object: v1Obj,
+			v1Obj := &appsv1beta1.Deployment{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Deployment",
+					APIVersion: "apps/v1beta1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "default",
+					Name:      "obj-1",
+				},
+			}
+
+			convReq := &apix.ConversionReview{
+				TypeMeta: metav1.TypeMeta{},
+				Request: &apix.ConversionRequest{
+					DesiredAPIVersion: "apps/v1",
+					Objects: []runtime.RawExtension{
+						{
+							Object: v1Obj,
+						},
 					},
 				},
-			},
-		}
+			}
+
+			convReview := doRequest(convReq)
+			Expect(convReview.Response.Result.Status).To(Equal("Failure"))
+			Expect(convReview.Response.ConvertedObjects).To(BeEmpty())
+		})
+
+		It("should return error on panic in conversion", func() {
+
+			v1Obj := makeV1Obj()
+			v1Obj.Spec.PanicInConversion = true
+
+			convReq := &apix.ConversionReview{
+				TypeMeta: metav1.TypeMeta{},
+				Request: &apix.ConversionRequest{
+					DesiredAPIVersion: "jobs.testprojects.kb.io/v3",
+					Objects: []runtime.RawExtension{
+						{
+							Object: v1Obj,
+						},
+					},
+				},
+			}
 
-		convReview := doRequest(convReq)
-		Expect(convReview.Response.Result.Status).To(Equal("Failure"))
-		Expect(convReview.Response.ConvertedObjects).To(BeEmpty())
-	})
+			convReview := doRequest(convReq)
 
-	It("should return error when the API group does not have a hub defined", func() {
+			Expect(convReview.Response.ConvertedObjects).To(HaveLen(0))
+			Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusFailure))
+			Expect(convReview.Response.Result.Message).To(Equal("internal error occurred during conversion"))
+		})
+	})
+}
 
-		v1Obj := &appsv1beta1.Deployment{
-			TypeMeta: metav1.TypeMeta{
-				Kind:       "Deployment",
-				APIVersion: "apps/v1beta1",
-			},
-			ObjectMeta: metav1.ObjectMeta{
-				Namespace: "default",
-				Name:      "obj-1",
-			},
-		}
+var _ = Describe("NewHubSpokeConverter", func() {
+	var scheme *runtime.Scheme
 
-		convReq := &apix.ConversionReview{
-			TypeMeta: metav1.TypeMeta{},
-			Request: &apix.ConversionRequest{
-				DesiredAPIVersion: "apps/v1",
-				Objects: []runtime.RawExtension{
-					{
-						Object: v1Obj,
-					},
-				},
-			},
-		}
+	BeforeEach(func() {
+		scheme = runtime.NewScheme()
+		Expect(jobsv1.AddToScheme(scheme)).To(Succeed())
+		Expect(jobsv2.AddToScheme(scheme)).To(Succeed())
+		Expect(jobsv3.AddToScheme(scheme)).To(Succeed())
+	})
 
-		convReview := doRequest(convReq)
-		Expect(convReview.Response.Result.Status).To(Equal("Failure"))
-		Expect(convReview.Response.ConvertedObjects).To(BeEmpty())
+	It("should succeed if all converter are specified", func() {
+		_, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{},
+			conversion.NewSpokeConverter(&jobsv1.ExternalJob{}, convertHubToV1, convertV1ToHub),
+			conversion.NewSpokeConverter(&jobsv3.ExternalJob{}, convertHubToV3, convertV3ToHub),
+		)(scheme)
+		Expect(err).ToNot(HaveOccurred())
 	})
 
-	It("should return error on panic in conversion", func() {
+	It("should return error if hub is not registered in the scheme", func() {
+		_, err := conversion.NewHubSpokeConverter(&appsv1.Deployment{})(scheme)
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(Equal("failed to create hub spoke converter: failed to get GroupVersionKind for hub: no kind is registered for the type v1.Deployment in scheme \"pkg/runtime/scheme.go:111\""))
+	})
 
-		v1Obj := makeV1Obj()
-		v1Obj.Spec.PanicInConversion = true
+	It("should return error if spoke is not registered in the scheme", func() {
+		_, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{},
+			conversion.NewSpokeConverter(&appsv1.Deployment{},
+				func(context.Context, *jobsv2.ExternalJob, *appsv1.Deployment) error { return nil },
+				func(context.Context, *appsv1.Deployment, *jobsv2.ExternalJob) error { return nil }),
+		)(scheme)
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(Equal("failed to create hub spoke converter for ExternalJob: failed to get GroupVersionKind for spoke converter: no kind is registered for the type v1.Deployment in scheme \"pkg/runtime/scheme.go:111\""))
+	})
 
-		convReq := &apix.ConversionReview{
-			TypeMeta: metav1.TypeMeta{},
-			Request: &apix.ConversionRequest{
-				DesiredAPIVersion: "jobs.testprojects.kb.io/v3",
-				Objects: []runtime.RawExtension{
-					{
-						Object: v1Obj,
-					},
-				},
-			},
-		}
+	It("should return error if spoke does not have the same GroupKind as the hub", func() {
+		_ = kscheme.AddToScheme(scheme)
+		_, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{},
+			conversion.NewSpokeConverter(&appsv1.Deployment{},
+				func(context.Context, *jobsv2.ExternalJob, *appsv1.Deployment) error { return nil },
+				func(context.Context, *appsv1.Deployment, *jobsv2.ExternalJob) error { return nil }),
+		)(scheme)
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(Equal("failed to create hub spoke converter for ExternalJob: spoke converter GroupKind Deployment.apps does not match hub GroupKind ExternalJob.jobs.testprojects.kb.io"))
+	})
 
-		convReview := doRequest(convReq)
+	It("should return error if same spoke is specified twice", func() {
+		_, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{},
+			conversion.NewSpokeConverter(&jobsv1.ExternalJob{}, convertHubToV1, convertV1ToHub),
+			conversion.NewSpokeConverter(&jobsv3.ExternalJob{}, convertHubToV3, convertV3ToHub), // duplicate
+			conversion.NewSpokeConverter(&jobsv3.ExternalJob{}, convertHubToV3, convertV3ToHub), // duplicate
+		)(scheme)
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(Equal("failed to create hub spoke converter for ExternalJob: duplicate spoke converter for version v3"))
+	})
 
-		Expect(convReview.Response.ConvertedObjects).To(HaveLen(0))
-		Expect(convReview.Response.Result.Status).To(Equal(metav1.StatusFailure))
-		Expect(convReview.Response.Result.Message).To(Equal("internal error occurred during conversion"))
+	It("should return error if a converter is missing", func() {
+		_, err := conversion.NewHubSpokeConverter(&jobsv2.ExternalJob{},
+			conversion.NewSpokeConverter(&jobsv1.ExternalJob{}, convertHubToV1, convertV1ToHub),
+			// jobsv3.ExternalJob converter is missing
+		)(scheme)
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(Equal("failed to create hub spoke converter for ExternalJob: expected spoke converter for v1,v3 got spoke converter for v1"))
 	})
 })
 
@@ -381,3 +467,19 @@ var _ = Describe("IsConvertible", func() {
 		Expect(ok).ToNot(BeTrue())
 	})
 })
+
+func convertV1ToHub(_ context.Context, src *jobsv1.ExternalJob, dst *jobsv2.ExternalJob) error {
+	return src.ConvertTo(dst)
+}
+
+func convertHubToV1(_ context.Context, src *jobsv2.ExternalJob, dst *jobsv1.ExternalJob) error {
+	return dst.ConvertFrom(src)
+}
+
+func convertV3ToHub(_ context.Context, src *jobsv3.ExternalJob, dst *jobsv2.ExternalJob) error {
+	return src.ConvertTo(dst)
+}
+
+func convertHubToV3(_ context.Context, src *jobsv2.ExternalJob, dst *jobsv3.ExternalJob) error {
+	return dst.ConvertFrom(src)
+}