Skip to content

Commit 09f7e26

Browse files
MisterMXblut
andcommitted
feat(workspacetype): Add defaultAPIBinding lifecylce
Add a controller to automatically keep defaultAPIBindings defined in a workspacetype up to date in all workspaces that derive from it. Co-authored-by: Hannes Blut <[email protected]> on-behalf-of: @eon-se [email protected] Signed-off-by: Hannes Blut <[email protected]> Signed-off-by: Maximilian Blatt <[email protected]>
1 parent 68596e1 commit 09f7e26

File tree

14 files changed

+935
-9
lines changed

14 files changed

+935
-9
lines changed

config/crds/tenancy.kcp.io_workspacetypes.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ spec:
5656
additionalWorkspaceLabels are a set of labels that will be added to a
5757
Workspace on creation.
5858
type: object
59+
defaultAPIBindingLifecycle:
60+
description: Configure the lifecycle behaviour of defaultAPIBindings.
61+
enum:
62+
- InitializeOnly
63+
- Maintain
64+
type: string
5965
defaultAPIBindings:
6066
description: |-
6167
defaultAPIBindings are the APIs to bind during initialization of workspaces created from this type.

config/root-phase0/apiexport-tenancy.kcp.io.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ spec:
1414
crd: {}
1515
- group: tenancy.kcp.io
1616
name: workspacetypes
17-
schema: v250325-c1864de17.workspacetypes.tenancy.kcp.io
17+
schema: v250603-d4d365c8e.workspacetypes.tenancy.kcp.io
1818
storage:
1919
crd: {}
2020
status: {}

config/root-phase0/apiresourceschema-workspacetypes.tenancy.kcp.io.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apiVersion: apis.kcp.io/v1alpha1
22
kind: APIResourceSchema
33
metadata:
44
creationTimestamp: null
5-
name: v250325-c1864de17.workspacetypes.tenancy.kcp.io
5+
name: v250603-d4d365c8e.workspacetypes.tenancy.kcp.io
66
spec:
77
group: tenancy.kcp.io
88
names:
@@ -54,6 +54,12 @@ spec:
5454
additionalWorkspaceLabels are a set of labels that will be added to a
5555
Workspace on creation.
5656
type: object
57+
defaultAPIBindingLifecycle:
58+
description: Configure the lifecycle behaviour of defaultAPIBindings.
59+
enum:
60+
- InitializeOnly
61+
- Maintain
62+
type: string
5763
defaultAPIBindings:
5864
description: |-
5965
defaultAPIBindings are the APIs to bind during initialization of workspaces created from this type.

pkg/openapi/zz_generated.openapi.go

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/*
2+
Copyright 2025 The KCP Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package defaultapibindinglifecycle
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"time"
23+
24+
"github.com/go-logr/logr"
25+
26+
apierrors "k8s.io/apimachinery/pkg/api/errors"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"k8s.io/apimachinery/pkg/labels"
29+
utilerrors "k8s.io/apimachinery/pkg/util/errors"
30+
"k8s.io/apimachinery/pkg/util/runtime"
31+
"k8s.io/apimachinery/pkg/util/wait"
32+
"k8s.io/client-go/tools/cache"
33+
"k8s.io/client-go/util/workqueue"
34+
"k8s.io/klog/v2"
35+
36+
kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache"
37+
"github.com/kcp-dev/logicalcluster/v3"
38+
39+
admission "github.com/kcp-dev/kcp/pkg/admission/workspacetypeexists"
40+
"github.com/kcp-dev/kcp/pkg/indexers"
41+
"github.com/kcp-dev/kcp/pkg/logging"
42+
"github.com/kcp-dev/kcp/pkg/reconciler/committer"
43+
apisv1alpha2 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha2"
44+
corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1"
45+
tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1"
46+
kcpclientset "github.com/kcp-dev/kcp/sdk/client/clientset/versioned/cluster"
47+
apisv1alpha2client "github.com/kcp-dev/kcp/sdk/client/clientset/versioned/typed/apis/v1alpha2"
48+
corev1alpha1client "github.com/kcp-dev/kcp/sdk/client/clientset/versioned/typed/core/v1alpha1"
49+
apisv1alpha2informers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions/apis/v1alpha2"
50+
corev1alpha1informers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions/core/v1alpha1"
51+
tenancyv1alpha1informers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions/tenancy/v1alpha1"
52+
)
53+
54+
const (
55+
ControllerName = "kcp-default-apibinding-controller"
56+
)
57+
58+
// NewDefaultAPIBindingController returns a new controller which instantiates APIBindings and waits for them to be fully bound
59+
// in new Workspaces.
60+
func NewDefaultAPIBindingController(
61+
kcpClusterClient kcpclientset.ClusterInterface,
62+
logicalClusterInformer corev1alpha1informers.LogicalClusterClusterInformer,
63+
workspaceTypeInformer, globalWorkspaceTypeInformer tenancyv1alpha1informers.WorkspaceTypeClusterInformer,
64+
apiBindingsInformer apisv1alpha2informers.APIBindingClusterInformer,
65+
apiExportsInformer, globalAPIExportsInformer apisv1alpha2informers.APIExportClusterInformer,
66+
) (*DefaultAPIBindingController, error) {
67+
c := &DefaultAPIBindingController{
68+
queue: workqueue.NewTypedRateLimitingQueueWithConfig(
69+
workqueue.DefaultTypedControllerRateLimiter[string](),
70+
workqueue.TypedRateLimitingQueueConfig[string]{
71+
Name: ControllerName,
72+
},
73+
),
74+
75+
getLogicalCluster: func(clusterName logicalcluster.Name) (*corev1alpha1.LogicalCluster, error) {
76+
return logicalClusterInformer.Lister().Cluster(clusterName).Get(corev1alpha1.LogicalClusterName)
77+
},
78+
79+
listLogicalClusters: func() ([]*corev1alpha1.LogicalCluster, error) {
80+
return logicalClusterInformer.Lister().List(labels.Everything())
81+
},
82+
83+
getWorkspaceType: func(path logicalcluster.Path, name string) (*tenancyv1alpha1.WorkspaceType, error) {
84+
return indexers.ByPathAndNameWithFallback[*tenancyv1alpha1.WorkspaceType](tenancyv1alpha1.Resource("workspacetypes"), workspaceTypeInformer.Informer().GetIndexer(), globalWorkspaceTypeInformer.Informer().GetIndexer(), path, name)
85+
},
86+
87+
listAPIBindings: func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error) {
88+
return apiBindingsInformer.Lister().Cluster(clusterName).List(labels.Everything())
89+
},
90+
getAPIBinding: func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIBinding, error) {
91+
return apiBindingsInformer.Lister().Cluster(clusterName).Get(name)
92+
},
93+
createAPIBinding: func(ctx context.Context, clusterName logicalcluster.Path, binding *apisv1alpha2.APIBinding) (*apisv1alpha2.APIBinding, error) {
94+
return kcpClusterClient.Cluster(clusterName).ApisV1alpha2().APIBindings().Create(ctx, binding, metav1.CreateOptions{})
95+
},
96+
97+
getAPIExport: func(path logicalcluster.Path, name string) (*apisv1alpha2.APIExport, error) {
98+
return indexers.ByPathAndNameWithFallback[*apisv1alpha2.APIExport](apisv1alpha2.Resource("apiexports"), apiExportsInformer.Informer().GetIndexer(), globalAPIExportsInformer.Informer().GetIndexer(), path, name)
99+
},
100+
101+
commitApiBinding: committer.NewCommitter[*apisv1alpha2.APIBinding, apisv1alpha2client.APIBindingInterface, *apisv1alpha2.APIBindingSpec, *apisv1alpha2.APIBindingStatus](kcpClusterClient.ApisV1alpha2().APIBindings()),
102+
commitLogicalCluster: committer.NewCommitter[*corev1alpha1.LogicalCluster, corev1alpha1client.LogicalClusterInterface, *corev1alpha1.LogicalClusterSpec, *corev1alpha1.LogicalClusterStatus](kcpClusterClient.CoreV1alpha1().LogicalClusters()),
103+
}
104+
105+
c.transitiveTypeResolver = admission.NewTransitiveTypeResolver(c.getWorkspaceType)
106+
107+
logger := logging.WithReconciler(klog.Background(), ControllerName)
108+
109+
// needed to reconcile if providers change defaultAPIBindings on their WorkspaceTypes
110+
_, _ = workspaceTypeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
111+
UpdateFunc: func(_, obj interface{}) { c.enqueueWorkspaceTypes(obj, logger) },
112+
})
113+
_, _ = globalWorkspaceTypeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
114+
UpdateFunc: func(_, obj interface{}) { c.enqueueWorkspaceTypes(obj, logger) },
115+
})
116+
117+
// needed to reconcile when new published resources or claims are added to api exports
118+
_, _ = apiExportsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
119+
UpdateFunc: func(_, obj interface{}) { c.enqueueAPIExport(obj, logger) },
120+
})
121+
_, _ = globalAPIExportsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
122+
AddFunc: func(obj interface{}) { c.enqueueAPIExport(obj, logger) },
123+
UpdateFunc: func(_, obj interface{}) { c.enqueueAPIExport(obj, logger) },
124+
})
125+
126+
return c, nil
127+
}
128+
129+
type apiBindingResource = committer.Resource[*apisv1alpha2.APIBindingSpec, *apisv1alpha2.APIBindingStatus]
130+
type logicalClusterResource = committer.Resource[*corev1alpha1.LogicalClusterSpec, *corev1alpha1.LogicalClusterStatus]
131+
132+
// DefaultAPIBindingController is a controller which instantiates APIBindings and waits for them to be fully bound
133+
// in new Workspaces.
134+
type DefaultAPIBindingController struct {
135+
queue workqueue.TypedRateLimitingInterface[string]
136+
137+
getLogicalCluster func(clusterName logicalcluster.Name) (*corev1alpha1.LogicalCluster, error)
138+
getWorkspaceType func(clusterName logicalcluster.Path, name string) (*tenancyv1alpha1.WorkspaceType, error)
139+
listLogicalClusters func() ([]*corev1alpha1.LogicalCluster, error)
140+
141+
listAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error)
142+
getAPIBinding func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIBinding, error)
143+
createAPIBinding func(ctx context.Context, clusterName logicalcluster.Path, binding *apisv1alpha2.APIBinding) (*apisv1alpha2.APIBinding, error)
144+
getAPIExport func(clusterName logicalcluster.Path, name string) (*apisv1alpha2.APIExport, error)
145+
146+
commitApiBinding func(ctx context.Context, old, new *apiBindingResource) error
147+
commitLogicalCluster func(ctx context.Context, old, new *logicalClusterResource) error
148+
149+
transitiveTypeResolver transitiveTypeResolver
150+
}
151+
152+
type transitiveTypeResolver interface {
153+
Resolve(t *tenancyv1alpha1.WorkspaceType) ([]*tenancyv1alpha1.WorkspaceType, error)
154+
}
155+
156+
func (c *DefaultAPIBindingController) enqueueLogicalCluster(obj interface{}, logger logr.Logger, logSuffix string) {
157+
key, err := kcpcache.DeletionHandlingMetaClusterNamespaceKeyFunc(obj)
158+
if err != nil {
159+
runtime.HandleError(err)
160+
return
161+
}
162+
163+
logging.WithQueueKey(logger, key).V(4).Info(fmt.Sprintf("queueing LogicalCluster%s", logSuffix))
164+
c.queue.Add(key)
165+
}
166+
167+
func (c *DefaultAPIBindingController) enqueueWorkspaceTypes(obj interface{}, logger logr.Logger) {
168+
wt, ok := obj.(*tenancyv1alpha1.WorkspaceType)
169+
if !ok {
170+
runtime.HandleError(fmt.Errorf("obj is supposed to be a WorkspaceType, but is %T", obj))
171+
return
172+
}
173+
174+
if len(wt.Spec.DefaultAPIBindings) == 0 {
175+
return
176+
}
177+
178+
list, err := c.listLogicalClusters()
179+
if err != nil {
180+
runtime.HandleError(fmt.Errorf("error listing workspaces: %w", err))
181+
}
182+
183+
for _, ws := range list {
184+
logger := logging.WithObject(logger, ws)
185+
c.enqueueLogicalCluster(ws, logger, " because of WorkspaceType")
186+
}
187+
}
188+
189+
func (c *DefaultAPIBindingController) enqueueAPIExport(obj interface{}, logger logr.Logger) {
190+
apiExport, ok := obj.(*apisv1alpha2.APIExport)
191+
if !ok {
192+
runtime.HandleError(fmt.Errorf("obj is supposed to be an APIExport, but is %T", obj))
193+
return
194+
}
195+
196+
if len(apiExport.Spec.Resources) == 0 && len(apiExport.Spec.PermissionClaims) == 0 {
197+
return
198+
}
199+
200+
list, err := c.listLogicalClusters()
201+
if err != nil {
202+
runtime.HandleError(fmt.Errorf("error listing logical clusters: %w", err))
203+
}
204+
205+
// NOTE: Changing an APIExport will trigger an update for every cluster
206+
// regardless if it binds to it or not.
207+
for _, ws := range list {
208+
logger := logging.WithObject(logger, ws)
209+
c.enqueueLogicalCluster(ws, logger, " because of referenced APIExport")
210+
}
211+
}
212+
213+
func (c *DefaultAPIBindingController) startWorker(ctx context.Context) {
214+
for c.processNextWorkItem(ctx) {
215+
}
216+
}
217+
218+
func (c *DefaultAPIBindingController) Start(ctx context.Context, numThreads int) {
219+
defer runtime.HandleCrash()
220+
defer c.queue.ShutDown()
221+
logger := logging.WithReconciler(klog.FromContext(ctx), ControllerName)
222+
ctx = klog.NewContext(ctx, logger)
223+
224+
logger.Info("Starting controller")
225+
defer logger.Info("Shutting down controller")
226+
227+
for i := 0; i < numThreads; i++ {
228+
go wait.UntilWithContext(ctx, c.startWorker, time.Second)
229+
}
230+
<-ctx.Done()
231+
}
232+
233+
func (c *DefaultAPIBindingController) ShutDown() {
234+
c.queue.ShutDown()
235+
}
236+
237+
func (c *DefaultAPIBindingController) processNextWorkItem(ctx context.Context) bool {
238+
// Wait until there is a new item in the working queue
239+
k, quit := c.queue.Get()
240+
if quit {
241+
return false
242+
}
243+
key := k
244+
245+
logger := logging.WithQueueKey(klog.FromContext(ctx), key)
246+
ctx = klog.NewContext(ctx, logger)
247+
logger.V(4).Info("processing key")
248+
249+
// No matter what, tell the queue we're done with this key, to unblock
250+
// other workers.
251+
defer c.queue.Done(key)
252+
253+
if err := c.process(ctx, key); err != nil {
254+
runtime.HandleError(fmt.Errorf("%s: failed to sync %q, err: %w", ControllerName, key, err))
255+
c.queue.AddRateLimited(key)
256+
return true
257+
}
258+
259+
c.queue.Forget(key)
260+
return true
261+
}
262+
263+
func (c *DefaultAPIBindingController) process(ctx context.Context, key string) error {
264+
logger := klog.FromContext(ctx)
265+
logger.V(3).Info("processing item", "key", key)
266+
267+
clusterName, _, _, err := kcpcache.SplitMetaClusterNamespaceKey(key)
268+
if err != nil {
269+
logger.Error(err, "unable to decode key")
270+
return nil
271+
}
272+
273+
logicalCluster, err := c.getLogicalCluster(clusterName)
274+
if err != nil {
275+
if !apierrors.IsNotFound(err) {
276+
logger.Error(err, "failed to get LogicalCluster from lister", "cluster", clusterName)
277+
}
278+
279+
return nil // nothing we can do here
280+
}
281+
282+
old := logicalCluster
283+
logicalCluster = logicalCluster.DeepCopy()
284+
285+
logger = logging.WithObject(logger, logicalCluster)
286+
ctx = klog.NewContext(ctx, logger)
287+
288+
var errs []error
289+
err = c.reconcile(ctx, logicalCluster)
290+
if err != nil {
291+
errs = append(errs, err)
292+
}
293+
294+
// If the object being reconciled changed as a result, update it.
295+
oldResource := &logicalClusterResource{ObjectMeta: old.ObjectMeta, Spec: &old.Spec, Status: &old.Status}
296+
newResource := &logicalClusterResource{ObjectMeta: logicalCluster.ObjectMeta, Spec: &logicalCluster.Spec, Status: &logicalCluster.Status}
297+
if err := c.commitLogicalCluster(ctx, oldResource, newResource); err != nil {
298+
errs = append(errs, err)
299+
}
300+
301+
return utilerrors.NewAggregate(errs)
302+
}
303+
304+
// InstallIndexers adds the additional indexers that this controller requires to the informers.
305+
func InstallIndexers(apiExportInformer, globalApiExportInformer apisv1alpha2informers.APIExportClusterInformer) {
306+
indexers.AddIfNotPresentOrDie(apiExportInformer.Informer().GetIndexer(), cache.Indexers{
307+
indexers.ByLogicalClusterPathAndName: indexers.IndexByLogicalClusterPathAndName,
308+
})
309+
indexers.AddIfNotPresentOrDie(globalApiExportInformer.Informer().GetIndexer(), cache.Indexers{
310+
indexers.ByLogicalClusterPathAndName: indexers.IndexByLogicalClusterPathAndName,
311+
})
312+
}

0 commit comments

Comments
 (0)