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
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ GOLANGCI_LINT_VERSION := 2.1.6
GORELEASER_VERSION := 2.13.0
GOTESTSUM_VERSION := 1.8.1
HELM_VERSION := 3.18.6
KCP_VERSION := 0.29.0
# unreleased kcp version with vw code for schemas
KCP_VERSION := 301a8f749e7b99a0c81f43b37aa5b5e5ff0fc0b4
KUBE_APPLYCONFIGURATION_GEN_VERSION := v0.32.0
KUBE_CLIENT_GEN_VERSION := v0.32.0
KUBE_INFORMER_GEN_VERSION := v0.32.0
Expand Down Expand Up @@ -156,7 +157,11 @@ install-boilerplate:

.PHONY: install-kcp
install-kcp:
@hack/uget.sh https://github.com/kcp-dev/kcp/releases/download/v{VERSION}/kcp_{VERSION}_{GOOS}_{GOARCH}.tar.gz kcp $(KCP_VERSION)
@if echo "$(KCP_VERSION)" | grep -qE '^v?[0-9]+\.[0-9]+'; then \
hack/uget.sh https://github.com/kcp-dev/kcp/releases/download/v{VERSION}/kcp_{VERSION}_{GOOS}_{GOARCH}.tar.gz kcp $(KCP_VERSION); \
else \
GOBIN=$(abspath $(UGET_DIRECTORY)) hack/go-install.sh github.com/kcp-dev/kcp/cmd/kcp kcp $(KCP_VERSION); \
fi

GORELEASER = $(UGET_DIRECTORY)/goreleaser-$(GORELEASER_VERSION)

Expand Down
180 changes: 180 additions & 0 deletions backend/provider/kcp/controllers/apibindingtemplate/controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
Copyright 2026 The Kube Bind 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 apibindingtemplate contains a kcp-specific controller that watches
// APIBindings in provider/backend workspaces and automatically creates or
// updates APIServiceExportTemplates based on the APIResourceSchemas exposed by
// each bound APIExport.
package apibindingtemplate

import (
"context"
"fmt"
"strings"

apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/cluster"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"

"github.com/kube-bind/kube-bind/backend/provider/kcp/controllers/shared"
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
)

const controllerName = "kube-bind-kcp-apibinding-template"

// APIBindingTemplateReconciler watches APIBindings and ensures an
// APIServiceExportTemplate exists for every bound APIExport.
type APIBindingTemplateReconciler struct {
manager mcmanager.Manager
opts controller.TypedOptions[mcreconcile.Request]
ignorePrefixes []string
scheme *runtime.Scheme
vwCache *shared.VWClientCache
}

// New returns a new APIBindingTemplateReconciler.
func New(
ctx context.Context,
mgr mcmanager.Manager,
opts controller.TypedOptions[mcreconcile.Request],
ignorePrefixes []string,
baseConfig *rest.Config,
scheme *runtime.Scheme,
) (*APIBindingTemplateReconciler, error) {
r := &APIBindingTemplateReconciler{
manager: mgr,
opts: opts,
ignorePrefixes: ignorePrefixes,
scheme: scheme,
vwCache: shared.NewVWClientCache(baseConfig, scheme),
}

return r, nil
}

// shouldIgnore returns true if the APIBinding name matches any of the
// configured ignore prefixes.
func (r *APIBindingTemplateReconciler) shouldIgnore(name string) bool {
for _, prefix := range r.ignorePrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}

// Reconcile implements reconcile.Reconciler for multicluster-runtime.
func (r *APIBindingTemplateReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)

if r.shouldIgnore(req.Name) {
logger.V(4).Info("Ignoring APIBinding matching ignore prefix", "name", req.Name)
return ctrl.Result{}, nil
}

logger.Info("Reconciling APIBinding", "request", req)

cl, err := r.manager.GetCluster(ctx, req.ClusterName)
if err != nil {
return ctrl.Result{}, fmt.Errorf("failed to get client for cluster %q: %w", req.ClusterName, err)
}

c := cl.GetClient()
clusterConfig := cl.GetConfig()

binding := &apisv1alpha2.APIBinding{}
if err := c.Get(ctx, req.NamespacedName, binding); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("failed to get APIBinding %q: %w", req.Name, err)
}

// Build the schema getter with VW fallback using the shared cache.
getSchema := shared.SchemaGetterWithFallback(c, clusterConfig, r.vwCache)

rec := reconciler{
client: c,
scheme: r.scheme,
getAPIResourceSchema: getSchema,
}

if err := rec.reconcile(ctx, binding); err != nil {
logger.Error(err, "Failed to reconcile APIBinding", "name", req.Name)
return ctrl.Result{}, err
}

return ctrl.Result{}, nil
}

// getTemplateMapper returns a mapper that enqueues the owning APIBinding when
// an APIServiceExportTemplate changes.
//
// This function has the signature func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler
// because multicluster-runtime's mcbuilder.Watches accepts a "per-cluster event handler factory"
// rather than a plain handler — it calls this factory for each cluster that is engaged.
func getTemplateMapper(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request {
annotations := obj.GetAnnotations()
if annotations == nil {
return nil
}
ownerName, ok := annotations[shared.AnnotationOwnerBinding]
if !ok {
return nil
}

c := cl.GetClient()
var binding apisv1alpha2.APIBinding
if err := c.Get(ctx, client.ObjectKey{Name: ownerName}, &binding); err != nil {
return nil
}

return []mcreconcile.Request{
{
Request: reconcile.Request{
NamespacedName: types.NamespacedName{Name: ownerName},
},
ClusterName: clusterName,
},
}
})
}

// SetupWithManager registers the controller with the multicluster-runtime Manager.
func (r *APIBindingTemplateReconciler) SetupWithManager(mgr mcmanager.Manager) error {
return mcbuilder.ControllerManagedBy(mgr).
For(&apisv1alpha2.APIBinding{}).
Watches(
&kubebindv1alpha2.APIServiceExportTemplate{},
getTemplateMapper,
).
WithOptions(r.opts).
Named(controllerName).
Complete(r)
}
Loading
Loading