Skip to content
Merged
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
39 changes: 30 additions & 9 deletions pkg/builder/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions pkg/manager/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
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(), mgr.GetConverterRegistry())
httpClient := http.Client{
// Setting a timeout to not get stuck when calling the readiness probe.
Timeout: 5 * time.Second,
Expand Down
6 changes: 6 additions & 0 deletions pkg/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 11 additions & 6 deletions pkg/webhook/conversion/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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()

Expand All @@ -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)

Expand Down
173 changes: 173 additions & 0 deletions pkg/webhook/conversion/conversion_hubspoke.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading