diff --git a/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_controller.go b/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_controller.go index bc7a4fa2772..133c691dd29 100644 --- a/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_controller.go +++ b/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_controller.go @@ -60,6 +60,7 @@ const ( func NewDefaultAPIBindingController( kcpClusterClient kcpclientset.ClusterInterface, logicalClusterInformer corev1alpha1informers.LogicalClusterClusterInformer, + globalLogicalClusterInformer corev1alpha1informers.LogicalClusterClusterInformer, workspaceTypeInformer, globalWorkspaceTypeInformer tenancyv1alpha1informers.WorkspaceTypeClusterInformer, apiBindingsInformer apisv1alpha2informers.APIBindingClusterInformer, apiExportsInformer, globalAPIExportsInformer apisv1alpha2informers.APIExportClusterInformer, @@ -76,6 +77,22 @@ func NewDefaultAPIBindingController( return logicalClusterInformer.Lister().Cluster(clusterName).Get(corev1alpha1.LogicalClusterName) }, + getLogicalClusterByPath: func(path logicalcluster.Path) (*corev1alpha1.LogicalCluster, error) { + clusters, err := indexers.ByIndexWithFallback[*corev1alpha1.LogicalCluster]( + logicalClusterInformer.Informer().GetIndexer(), + globalLogicalClusterInformer.Informer().GetIndexer(), + indexers.ByLogicalClusterPath, + path.String(), + ) + if err != nil { + return nil, err + } + if len(clusters) == 0 { + return nil, apierrors.NewNotFound(corev1alpha1.Resource("logicalclusters"), path.String()) + } + return clusters[0], nil + }, + listLogicalClusters: func() ([]*corev1alpha1.LogicalCluster, error) { return logicalClusterInformer.Lister().List(labels.Everything()) }, @@ -87,6 +104,17 @@ func NewDefaultAPIBindingController( listAPIBindings: func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error) { return apiBindingsInformer.Lister().Cluster(clusterName).List(labels.Everything()) }, + listAPIBindingsByPath: func(ctx context.Context, clusterPath logicalcluster.Path) ([]*apisv1alpha2.APIBinding, error) { + bindingList, err := kcpClusterClient.Cluster(clusterPath).ApisV1alpha2().APIBindings().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + result := make([]*apisv1alpha2.APIBinding, len(bindingList.Items)) + for i := range bindingList.Items { + result[i] = &bindingList.Items[i] + } + return result, nil + }, getAPIBinding: func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIBinding, error) { return apiBindingsInformer.Lister().Cluster(clusterName).Get(name) }, @@ -134,14 +162,16 @@ type logicalClusterResource = committer.Resource[*corev1alpha1.LogicalClusterSpe type DefaultAPIBindingController struct { queue workqueue.TypedRateLimitingInterface[string] - getLogicalCluster func(clusterName logicalcluster.Name) (*corev1alpha1.LogicalCluster, error) - getWorkspaceType func(clusterName logicalcluster.Path, name string) (*tenancyv1alpha1.WorkspaceType, error) - listLogicalClusters func() ([]*corev1alpha1.LogicalCluster, error) + getLogicalCluster func(clusterName logicalcluster.Name) (*corev1alpha1.LogicalCluster, error) + getLogicalClusterByPath func(path logicalcluster.Path) (*corev1alpha1.LogicalCluster, error) + getWorkspaceType func(clusterName logicalcluster.Path, name string) (*tenancyv1alpha1.WorkspaceType, error) + listLogicalClusters func() ([]*corev1alpha1.LogicalCluster, error) - listAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error) - getAPIBinding func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIBinding, error) - createAPIBinding func(ctx context.Context, clusterName logicalcluster.Path, binding *apisv1alpha2.APIBinding) (*apisv1alpha2.APIBinding, error) - getAPIExport func(clusterName logicalcluster.Path, name string) (*apisv1alpha2.APIExport, error) + listAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error) + listAPIBindingsByPath func(ctx context.Context, clusterPath logicalcluster.Path) ([]*apisv1alpha2.APIBinding, error) + getAPIBinding func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIBinding, error) + createAPIBinding func(ctx context.Context, clusterName logicalcluster.Path, binding *apisv1alpha2.APIBinding) (*apisv1alpha2.APIBinding, error) + getAPIExport func(clusterName logicalcluster.Path, name string) (*apisv1alpha2.APIExport, error) commitApiBinding func(ctx context.Context, old, new *apiBindingResource) error commitLogicalCluster func(ctx context.Context, old, new *logicalClusterResource) error @@ -302,7 +332,13 @@ func (c *DefaultAPIBindingController) process(ctx context.Context, key string) e } // InstallIndexers adds the additional indexers that this controller requires to the informers. -func InstallIndexers(apiExportInformer, globalApiExportInformer apisv1alpha2informers.APIExportClusterInformer) { +func InstallIndexers(logicalClusterInformer, globalLogicalClusterInformer corev1alpha1informers.LogicalClusterClusterInformer, apiExportInformer, globalApiExportInformer apisv1alpha2informers.APIExportClusterInformer) { + indexers.AddIfNotPresentOrDie(logicalClusterInformer.Informer().GetIndexer(), cache.Indexers{ + indexers.ByLogicalClusterPath: indexers.IndexByLogicalClusterPath, + }) + indexers.AddIfNotPresentOrDie(globalLogicalClusterInformer.Informer().GetIndexer(), cache.Indexers{ + indexers.ByLogicalClusterPath: indexers.IndexByLogicalClusterPath, + }) indexers.AddIfNotPresentOrDie(apiExportInformer.Informer().GetIndexer(), cache.Indexers{ indexers.ByLogicalClusterPathAndName: indexers.IndexByLogicalClusterPathAndName, }) diff --git a/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_reconcile.go b/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_reconcile.go index 1512072a888..6a6d101a08b 100644 --- a/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_reconcile.go +++ b/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_reconcile.go @@ -30,6 +30,7 @@ import ( "github.com/kcp-dev/logicalcluster/v3" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" conditionsv1alpha1 "github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" @@ -39,6 +40,70 @@ import ( "github.com/kcp-dev/kcp/pkg/reconciler/tenancy/initialization" ) +func (c *DefaultAPIBindingController) findSelectorInWorkspace(ctx context.Context, workspacePath logicalcluster.Path, exportRef tenancyv1alpha1.APIExportReference, exportClaim apisv1alpha2.PermissionClaim, logger klog.Logger) *apisv1alpha2.PermissionClaimSelector { + if workspacePath.Empty() { + return nil + } + + workspaceBindings, err := c.listAPIBindingsByPath(ctx, workspacePath) + if err != nil { + logger.V(4).Info("error listing workspace APIBindings by path", "error", err, "path", workspacePath) + return nil + } + + exportRefPath := logicalcluster.NewPath(exportRef.Path) + if exportRefPath.Empty() { + exportRefPath = workspacePath + } + + var matchingBindings []*apisv1alpha2.APIBinding + for _, binding := range workspaceBindings { + if binding.Spec.Reference.Export == nil { + continue + } + + bindingExportPath := logicalcluster.NewPath(binding.Spec.Reference.Export.Path) + if bindingExportPath.Empty() { + bindingExportPath = workspacePath + } + + if binding.Spec.Reference.Export.Name == exportRef.Export && + bindingExportPath.String() == exportRefPath.String() { + matchingBindings = append(matchingBindings, binding) + } + } + + if len(matchingBindings) == 0 { + return nil + } + + var matchedSelector *apisv1alpha2.PermissionClaimSelector + for _, binding := range matchingBindings { + for _, claim := range binding.Spec.PermissionClaims { + if claim.Group == exportClaim.Group && + claim.Resource == exportClaim.Resource && + claim.IdentityHash == exportClaim.IdentityHash && + claim.State == apisv1alpha2.ClaimAccepted { + if !claim.Selector.MatchAll { + logger.V(4).Info("found matching selector in workspace binding", "workspacePath", workspacePath, "selector", claim.Selector) + return &claim.Selector + } + + if matchedSelector == nil { + matchedSelector = &claim.Selector + } + } + } + } + + if matchedSelector != nil { + logger.V(4).Info("found matching selector in workspace binding", "workspacePath", workspacePath, "selector", matchedSelector) + return matchedSelector + } + + return nil +} + func (c *DefaultAPIBindingController) reconcile(ctx context.Context, logicalCluster *corev1alpha1.LogicalCluster) error { logger := klog.FromContext(ctx) @@ -132,13 +197,26 @@ func (c *DefaultAPIBindingController) reconcile(ctx context.Context, logicalClus } for _, exportClaim := range apiExport.Spec.PermissionClaims { - // For now we automatically accept DefaultAPIBindings + var selector apisv1alpha2.PermissionClaimSelector + selector = apisv1alpha2.PermissionClaimSelector{ + MatchAll: true, + } + + var parentPath logicalcluster.Path + if annPath, found := logicalCluster.Annotations[core.LogicalClusterPathAnnotationKey]; found { + currentPath := logicalcluster.NewPath(annPath) + parentPath, _ = currentPath.Parent() + } + + if parentSelector := c.findSelectorInWorkspace(ctx, parentPath, exportRef, exportClaim, logger); parentSelector != nil { + selector = *parentSelector + logger.V(3).Info("inheriting selector from parent workspace binding", "parentPath", parentPath, "selector", selector) + } + acceptedClaim := apisv1alpha2.AcceptablePermissionClaim{ ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ PermissionClaim: exportClaim, - Selector: apisv1alpha2.PermissionClaimSelector{ - MatchAll: true, - }, + Selector: selector, }, State: apisv1alpha2.ClaimAccepted, } diff --git a/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_reconcile_test.go b/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_reconcile_test.go new file mode 100644 index 00000000000..b3f2b42e15c --- /dev/null +++ b/pkg/reconciler/tenancy/defaultapibindinglifecycle/default_apibinding_lifecycle_reconcile_test.go @@ -0,0 +1,290 @@ +/* +Copyright 2026 The KCP 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 defaultapibindinglifecycle + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + + "github.com/kcp-dev/logicalcluster/v3" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" +) + +func TestFindSelectorInWorkspace(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + workspacePath logicalcluster.Path + exportRef tenancyv1alpha1.APIExportReference + exportClaim apisv1alpha2.PermissionClaim + workspaceBindings []*apisv1alpha2.APIBinding + listBindingsError error + expectedSelector *apisv1alpha2.PermissionClaimSelector + expectedFound bool + }{ + "returns matchLabels selector when parent workspace has matching APIBinding with label selector": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + workspaceBindings: []*apisv1alpha2.APIBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: "root:export-ws", + Name: "test.export", + }, + }, + PermissionClaims: []apisv1alpha2.AcceptablePermissionClaim{ + { + ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ + PermissionClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + }, + Selector: apisv1alpha2.PermissionClaimSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "platform-mesh.io/enabled": "true", + }, + }, + }, + }, + State: apisv1alpha2.ClaimAccepted, + }, + }, + }, + }, + }, + expectedSelector: &apisv1alpha2.PermissionClaimSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "platform-mesh.io/enabled": "true", + }, + }, + }, + expectedFound: true, + }, + "returns MatchAll selector when parent workspace only has MatchAll selector": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + workspaceBindings: []*apisv1alpha2.APIBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: "root:export-ws", + Name: "test.export", + }, + }, + PermissionClaims: []apisv1alpha2.AcceptablePermissionClaim{ + { + ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ + PermissionClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + }, + Selector: apisv1alpha2.PermissionClaimSelector{ + MatchAll: true, + }, + }, + State: apisv1alpha2.ClaimAccepted, + }, + }, + }, + }, + }, + expectedSelector: &apisv1alpha2.PermissionClaimSelector{ + MatchAll: true, + }, + expectedFound: true, + }, + "returns nil where no APIBinding matches the export reference": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + workspaceBindings: []*apisv1alpha2.APIBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "other-binding", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: "root:other-ws", + Name: "other.export", + }, + }, + }, + }, + }, + expectedFound: false, + }, + "returns nil when permission claim is rejected": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + workspaceBindings: []*apisv1alpha2.APIBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: "root:export-ws", + Name: "test.export", + }, + }, + PermissionClaims: []apisv1alpha2.AcceptablePermissionClaim{ + { + ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ + PermissionClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + }, + Selector: apisv1alpha2.PermissionClaimSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "platform-mesh.io/enabled": "true", + }, + }, + }, + }, + State: apisv1alpha2.ClaimRejected, + }, + }, + }, + }, + }, + expectedFound: false, + }, + "returns nil when listAPIBindings fails for workspace": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + listBindingsError: fmt.Errorf("workspace not found"), + expectedFound: false, + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + c := &DefaultAPIBindingController{ + listAPIBindingsByPath: func(ctx context.Context, path logicalcluster.Path) ([]*apisv1alpha2.APIBinding, error) { + if tc.listBindingsError != nil { + return nil, tc.listBindingsError + } + if path == tc.workspacePath { + return tc.workspaceBindings, nil + } + return nil, nil + }, + } + + ctx := klog.NewContext(context.Background(), klog.Background()) + logger := klog.FromContext(ctx) + + result := c.findSelectorInWorkspace(ctx, tc.workspacePath, tc.exportRef, tc.exportClaim, logger) + + if !tc.expectedFound { + require.Nil(t, result, "expected no selector to be found") + return + } + + require.NotNil(t, result, "expected to find a selector") + if tc.expectedSelector != nil { + require.Equal(t, tc.expectedSelector.MatchAll, result.MatchAll, "MatchAll should match") + require.Equal(t, tc.expectedSelector.LabelSelector.MatchLabels, result.LabelSelector.MatchLabels, "MatchLabels should match") + require.Equal(t, tc.expectedSelector.LabelSelector.MatchExpressions, result.LabelSelector.MatchExpressions, "MatchExpressions should match") + } + }) + } +} diff --git a/pkg/reconciler/tenancy/initialization/apibinder_initializer_controller.go b/pkg/reconciler/tenancy/initialization/apibinder_initializer_controller.go index a09c6ff27d3..f3e14d2b766 100644 --- a/pkg/reconciler/tenancy/initialization/apibinder_initializer_controller.go +++ b/pkg/reconciler/tenancy/initialization/apibinder_initializer_controller.go @@ -59,7 +59,10 @@ const ( // in new Workspaces. func NewAPIBinder( kcpClusterClient kcpclientset.ClusterInterface, + globalKcpClusterClient kcpclientset.ClusterInterface, logicalClusterInformer corev1alpha1informers.LogicalClusterClusterInformer, + logicalClustersInformer corev1alpha1informers.LogicalClusterClusterInformer, + globalLogicalClustersInformer corev1alpha1informers.LogicalClusterClusterInformer, workspaceTypeInformer, globalWorkspaceTypeInformer tenancyv1alpha1informers.WorkspaceTypeClusterInformer, apiBindingsInformer apisv1alpha2informers.APIBindingClusterInformer, apiExportsInformer, globalAPIExportsInformer apisv1alpha2informers.APIExportClusterInformer, @@ -75,16 +78,41 @@ func NewAPIBinder( getLogicalCluster: func(clusterName logicalcluster.Name) (*corev1alpha1.LogicalCluster, error) { return logicalClusterInformer.Lister().Cluster(clusterName).Get(corev1alpha1.LogicalClusterName) }, + getLogicalClusterByPath: func(path logicalcluster.Path) (*corev1alpha1.LogicalCluster, error) { + clusters, err := indexers.ByIndexWithFallback[*corev1alpha1.LogicalCluster]( + logicalClustersInformer.Informer().GetIndexer(), + globalLogicalClustersInformer.Informer().GetIndexer(), + indexers.ByLogicalClusterPath, + path.String(), + ) + if err != nil { + return nil, err + } + if len(clusters) == 0 { + return nil, apierrors.NewNotFound(corev1alpha1.Resource("logicalclusters"), path.String()) + } + return clusters[0], nil + }, getWorkspaceType: func(path logicalcluster.Path, name string) (*tenancyv1alpha1.WorkspaceType, error) { return indexers.ByPathAndNameWithFallback[*tenancyv1alpha1.WorkspaceType](tenancyv1alpha1.Resource("workspacetypes"), workspaceTypeInformer.Informer().GetIndexer(), globalWorkspaceTypeInformer.Informer().GetIndexer(), path, name) }, listLogicalClusters: func() ([]*corev1alpha1.LogicalCluster, error) { return logicalClusterInformer.Lister().List(labels.Everything()) }, - listAPIBindings: func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error) { return apiBindingsInformer.Lister().Cluster(clusterName).List(labels.Everything()) }, + listAPIBindingsByPath: func(ctx context.Context, clusterPath logicalcluster.Path) ([]*apisv1alpha2.APIBinding, error) { + bindingList, err := globalKcpClusterClient.Cluster(clusterPath).ApisV1alpha2().APIBindings().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + result := make([]*apisv1alpha2.APIBinding, len(bindingList.Items)) + for i := range bindingList.Items { + result[i] = &bindingList.Items[i] + } + return result, nil + }, getAPIBinding: func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIBinding, error) { return apiBindingsInformer.Lister().Cluster(clusterName).Get(name) }, @@ -149,13 +177,15 @@ type logicalClusterResource = committer.Resource[*corev1alpha1.LogicalClusterSpe type APIBinder struct { queue workqueue.TypedRateLimitingInterface[string] - getLogicalCluster func(clusterName logicalcluster.Name) (*corev1alpha1.LogicalCluster, error) - getWorkspaceType func(clusterName logicalcluster.Path, name string) (*tenancyv1alpha1.WorkspaceType, error) - listLogicalClusters func() ([]*corev1alpha1.LogicalCluster, error) + getLogicalCluster func(clusterName logicalcluster.Name) (*corev1alpha1.LogicalCluster, error) + getLogicalClusterByPath func(path logicalcluster.Path) (*corev1alpha1.LogicalCluster, error) + getWorkspaceType func(clusterName logicalcluster.Path, name string) (*tenancyv1alpha1.WorkspaceType, error) + listLogicalClusters func() ([]*corev1alpha1.LogicalCluster, error) - listAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error) - getAPIBinding func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIBinding, error) - createAPIBinding func(ctx context.Context, clusterName logicalcluster.Path, binding *apisv1alpha2.APIBinding) (*apisv1alpha2.APIBinding, error) + listAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error) + listAPIBindingsByPath func(ctx context.Context, clusterPath logicalcluster.Path) ([]*apisv1alpha2.APIBinding, error) + getAPIBinding func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIBinding, error) + createAPIBinding func(ctx context.Context, clusterName logicalcluster.Path, binding *apisv1alpha2.APIBinding) (*apisv1alpha2.APIBinding, error) getAPIExport func(clusterName logicalcluster.Path, name string) (*apisv1alpha2.APIExport, error) @@ -321,7 +351,13 @@ func (b *APIBinder) process(ctx context.Context, key string) error { } // InstallIndexers adds the additional indexers that this controller requires to the informers. -func InstallIndexers(workspaceTypeInformer, globalWorkspaceTypeInformer tenancyv1alpha1informers.WorkspaceTypeClusterInformer) { +func InstallIndexers(logicalClusterInformer, globalLogicalClusterInformer corev1alpha1informers.LogicalClusterClusterInformer, workspaceTypeInformer, globalWorkspaceTypeInformer tenancyv1alpha1informers.WorkspaceTypeClusterInformer) { + indexers.AddIfNotPresentOrDie(logicalClusterInformer.Informer().GetIndexer(), cache.Indexers{ + indexers.ByLogicalClusterPath: indexers.IndexByLogicalClusterPath, + }) + indexers.AddIfNotPresentOrDie(globalLogicalClusterInformer.Informer().GetIndexer(), cache.Indexers{ + indexers.ByLogicalClusterPath: indexers.IndexByLogicalClusterPath, + }) indexers.AddIfNotPresentOrDie(workspaceTypeInformer.Informer().GetIndexer(), cache.Indexers{ indexers.ByLogicalClusterPathAndName: indexers.IndexByLogicalClusterPathAndName, }) diff --git a/pkg/reconciler/tenancy/initialization/apibinder_initializer_reconcile.go b/pkg/reconciler/tenancy/initialization/apibinder_initializer_reconcile.go index 8bd50419f61..b14a2b9f83e 100644 --- a/pkg/reconciler/tenancy/initialization/apibinder_initializer_reconcile.go +++ b/pkg/reconciler/tenancy/initialization/apibinder_initializer_reconcile.go @@ -32,6 +32,7 @@ import ( "github.com/kcp-dev/logicalcluster/v3" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" "github.com/kcp-dev/sdk/apis/tenancy/initialization" tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" @@ -163,12 +164,26 @@ func (b *APIBinder) reconcile(ctx context.Context, logicalCluster *corev1alpha1. } for _, exportClaim := range apiExport.Spec.PermissionClaims { + var selector apisv1alpha2.PermissionClaimSelector + selector = apisv1alpha2.PermissionClaimSelector{ + MatchAll: true, + } + + var parentPath logicalcluster.Path + if annPath, found := logicalCluster.Annotations[core.LogicalClusterPathAnnotationKey]; found { + currentPath := logicalcluster.NewPath(annPath) + parentPath, _ = currentPath.Parent() + } + + if parentSelector := b.findSelectorInWorkspace(ctx, parentPath, exportRef, exportClaim); parentSelector != nil { + selector = *parentSelector + logger.V(3).Info("inheriting selector from parent workspace binding", "parentPath", parentPath, "selector", selector) + } + acceptedClaim := apisv1alpha2.AcceptablePermissionClaim{ ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ PermissionClaim: exportClaim, - Selector: apisv1alpha2.PermissionClaimSelector{ - MatchAll: true, - }, + Selector: selector, }, State: apisv1alpha2.ClaimAccepted, } @@ -252,6 +267,72 @@ func (b *APIBinder) reconcile(ctx context.Context, logicalCluster *corev1alpha1. return nil } +func (b *APIBinder) findSelectorInWorkspace(ctx context.Context, workspacePath logicalcluster.Path, exportRef tenancyv1alpha1.APIExportReference, exportClaim apisv1alpha2.PermissionClaim) *apisv1alpha2.PermissionClaimSelector { + logger := klog.FromContext(ctx) + + if workspacePath.Empty() { + return nil + } + + workspaceBindings, err := b.listAPIBindingsByPath(ctx, workspacePath) + if err != nil { + logger.V(4).Info("error listing workspace APIBindings by path", "error", err, "path", workspacePath) + return nil + } + + exportRefPath := logicalcluster.NewPath(exportRef.Path) + if exportRefPath.Empty() { + exportRefPath = workspacePath + } + + var matchingBindings []*apisv1alpha2.APIBinding + for _, binding := range workspaceBindings { + if binding.Spec.Reference.Export == nil { + continue + } + + bindingExportPath := logicalcluster.NewPath(binding.Spec.Reference.Export.Path) + if bindingExportPath.Empty() { + bindingExportPath = workspacePath + } + + if binding.Spec.Reference.Export.Name == exportRef.Export && + bindingExportPath.String() == exportRefPath.String() { + matchingBindings = append(matchingBindings, binding) + } + } + + if len(matchingBindings) == 0 { + return nil + } + + var matchedSeclector *apisv1alpha2.PermissionClaimSelector + for _, binding := range matchingBindings { + for _, claim := range binding.Spec.PermissionClaims { + if claim.Group == exportClaim.Group && + claim.Resource == exportClaim.Resource && + claim.IdentityHash == exportClaim.IdentityHash && + claim.State == apisv1alpha2.ClaimAccepted { + if !claim.Selector.MatchAll { + logger.V(4).Info("found matching selector in workspace binding", "workspacePath", workspacePath, "selector", claim.Selector) + return &claim.Selector + } + + if matchedSeclector == nil { + matchedSeclector = &claim.Selector + } + } + } + } + + if matchedSeclector != nil { + logger.V(4).Info("found matching selector in workspace binding", "workspacePath", workspacePath, "selector", matchedSeclector) + return matchedSeclector + } + + return nil +} + // maxExportNamePrefixLength is the maximum allowed length for the export name portion of the generated API binding // name. Subtrace 1 for the dash ("-") that separates the export name prefix from the hash suffix, and 5 for the // hash length. diff --git a/pkg/reconciler/tenancy/initialization/apibinder_initializer_reconcile_test.go b/pkg/reconciler/tenancy/initialization/apibinder_initializer_reconcile_test.go index da7f2dd04d1..a32ed0c5888 100644 --- a/pkg/reconciler/tenancy/initialization/apibinder_initializer_reconcile_test.go +++ b/pkg/reconciler/tenancy/initialization/apibinder_initializer_reconcile_test.go @@ -17,13 +17,20 @@ limitations under the License. package initialization import ( + "context" + "fmt" "regexp" "strings" "testing" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + "github.com/kcp-dev/logicalcluster/v3" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" ) func TestGenerateAPIBindingName(t *testing.T) { @@ -83,3 +90,260 @@ func TestGenerateAPIBindingNameWithMultipleSimilarLongNames(t *testing.T) { require.Len(t, generated2, 253) require.NotEqual(t, generated1, generated2, "expected different generated names") } + +func TestFindSelectorInWorkspace(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + workspacePath logicalcluster.Path + exportRef tenancyv1alpha1.APIExportReference + exportClaim apisv1alpha2.PermissionClaim + workspaceBindings []*apisv1alpha2.APIBinding + listBindingsError error + expectedSelector *apisv1alpha2.PermissionClaimSelector + expectedFound bool + }{ + "returns matchLabels selector when workspace has matching APIBinding with label selector": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + workspaceBindings: []*apisv1alpha2.APIBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: "root:export-ws", + Name: "test.export", + }, + }, + PermissionClaims: []apisv1alpha2.AcceptablePermissionClaim{ + { + ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ + PermissionClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + }, + Selector: apisv1alpha2.PermissionClaimSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "platform-mesh.io/enabled": "true", + }, + }, + }, + }, + State: apisv1alpha2.ClaimAccepted, + }, + }, + }, + }, + }, + expectedSelector: &apisv1alpha2.PermissionClaimSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "platform-mesh.io/enabled": "true", + }, + }, + }, + expectedFound: true, + }, + "returns MatchAll selector when workspace only has MatchAll selector": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + workspaceBindings: []*apisv1alpha2.APIBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: "root:export-ws", + Name: "test.export", + }, + }, + PermissionClaims: []apisv1alpha2.AcceptablePermissionClaim{ + { + ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ + PermissionClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + }, + Selector: apisv1alpha2.PermissionClaimSelector{ + MatchAll: true, + }, + }, + State: apisv1alpha2.ClaimAccepted, + }, + }, + }, + }, + }, + expectedSelector: &apisv1alpha2.PermissionClaimSelector{ + MatchAll: true, + }, + expectedFound: true, + }, + "returns nil when no APIBinding matches the export reference": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + workspaceBindings: []*apisv1alpha2.APIBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "other-binding", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: "root:other-ws", + Name: "other.export", + }, + }, + }, + }, + }, + expectedFound: false, + }, + "returns nil when permission claim is rejected": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + workspaceBindings: []*apisv1alpha2.APIBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: "root:export-ws", + Name: "test.export", + }, + }, + PermissionClaims: []apisv1alpha2.AcceptablePermissionClaim{ + { + ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ + PermissionClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + }, + Selector: apisv1alpha2.PermissionClaimSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "platform-mesh.io/enabled": "true", + }, + }, + }, + }, + State: apisv1alpha2.ClaimRejected, + }, + }, + }, + }, + }, + expectedFound: false, + }, + "returns nil when listAPIBindings fails for workspace": { + workspacePath: logicalcluster.NewPath("root:parent"), + exportRef: tenancyv1alpha1.APIExportReference{ + Path: "root:export-ws", + Export: "test.export", + }, + exportClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + IdentityHash: "hash123", + Verbs: []string{"get", "list"}, + }, + listBindingsError: fmt.Errorf("workspace not found"), + expectedFound: false, + }, + } + + for testName, tc := range tests { + t.Run(testName, func(t *testing.T) { + b := &APIBinder{ + listAPIBindingsByPath: func(ctx context.Context, path logicalcluster.Path) ([]*apisv1alpha2.APIBinding, error) { + if tc.listBindingsError != nil { + return nil, tc.listBindingsError + } + if path == tc.workspacePath { + return tc.workspaceBindings, nil + } + return nil, nil + }, + } + + ctx := klog.NewContext(context.Background(), klog.Background()) + + result := b.findSelectorInWorkspace(ctx, tc.workspacePath, tc.exportRef, tc.exportClaim) + + if !tc.expectedFound { + require.Nil(t, result, "expected no selector to be found") + return + } + + require.NotNil(t, result, "expected to find a selector") + if tc.expectedSelector != nil { + require.Equal(t, tc.expectedSelector.MatchAll, result.MatchAll, "MatchAll should match") + require.Equal(t, tc.expectedSelector.LabelSelector.MatchLabels, result.LabelSelector.MatchLabels, "MatchLabels should match") + require.Equal(t, tc.expectedSelector.LabelSelector.MatchExpressions, result.LabelSelector.MatchExpressions, "MatchExpressions should match") + } + }) + } +} diff --git a/pkg/server/controllers.go b/pkg/server/controllers.go index d1f9fbd6db1..bb7e57475da 100644 --- a/pkg/server/controllers.go +++ b/pkg/server/controllers.go @@ -931,6 +931,7 @@ func (s *Server) installDefaultAPIBindingController(ctx context.Context, config c, err := defaultapibindinglifecycle.NewDefaultAPIBindingController( kcpClusterClient, s.KcpSharedInformerFactory.Core().V1alpha1().LogicalClusters(), + s.CacheKcpSharedInformerFactory.Core().V1alpha1().LogicalClusters(), s.KcpSharedInformerFactory.Tenancy().V1alpha1().WorkspaceTypes(), s.CacheKcpSharedInformerFactory.Tenancy().V1alpha1().WorkspaceTypes(), s.KcpSharedInformerFactory.Apis().V1alpha2().APIBindings(), @@ -963,6 +964,13 @@ func (s *Server) installAPIBinderController(ctx context.Context, config *rest.Co // Client used to create APIBindings within the initializing workspace config = rest.CopyConfig(config) config = rest.AddUserAgent(config, initialization.ControllerName) + + // globalKcpClusterClient uses the unmodified config (before vw URL is appended) so it + // can route to any workspace cross-shard for parent workspace APIBinding lookups. + globalKcpClusterClient, err := kcpclientset.NewForConfig(config) + if err != nil { + return err + } config.Host += initializingworkspacesbuilder.URLFor(tenancyv1alpha1.WorkspaceAPIBindingsInitializer) if !s.Options.Virtual.Enabled && s.Options.Extra.ShardVirtualWorkspaceURL != "" { @@ -1003,7 +1011,10 @@ func (s *Server) installAPIBinderController(ctx context.Context, config *rest.Co c, err := initialization.NewAPIBinder( initializingWorkspacesKcpClusterClient, + globalKcpClusterClient, initializingWorkspacesKcpInformers.Core().V1alpha1().LogicalClusters(), + s.KcpSharedInformerFactory.Core().V1alpha1().LogicalClusters(), + s.CacheKcpSharedInformerFactory.Core().V1alpha1().LogicalClusters(), s.KcpSharedInformerFactory.Tenancy().V1alpha1().WorkspaceTypes(), s.CacheKcpSharedInformerFactory.Tenancy().V1alpha1().WorkspaceTypes(), s.KcpSharedInformerFactory.Apis().V1alpha2().APIBindings(), @@ -1908,12 +1919,16 @@ func (s *Server) addIndexersToInformers(_ context.Context) map[schema.GroupVersi s.KcpSharedInformerFactory.Apis().V1alpha2().APIBindings(), ) initialization.InstallIndexers( + s.KcpSharedInformerFactory.Core().V1alpha1().LogicalClusters(), + s.CacheKcpSharedInformerFactory.Core().V1alpha1().LogicalClusters(), s.KcpSharedInformerFactory.Tenancy().V1alpha1().WorkspaceTypes(), s.CacheKcpSharedInformerFactory.Tenancy().V1alpha1().WorkspaceTypes()) crdcleanup.InstallIndexers( s.KcpSharedInformerFactory.Apis().V1alpha2().APIBindings(), ) defaultapibindinglifecycle.InstallIndexers( + s.KcpSharedInformerFactory.Core().V1alpha1().LogicalClusters(), + s.CacheKcpSharedInformerFactory.Core().V1alpha1().LogicalClusters(), s.KcpSharedInformerFactory.Apis().V1alpha2().APIExports(), s.CacheKcpSharedInformerFactory.Apis().V1alpha2().APIExports(), ) diff --git a/test/e2e/reconciler/workspace/apibinding_selector_inheritance_test.go b/test/e2e/reconciler/workspace/apibinding_selector_inheritance_test.go new file mode 100644 index 00000000000..0d7cb059492 --- /dev/null +++ b/test/e2e/reconciler/workspace/apibinding_selector_inheritance_test.go @@ -0,0 +1,241 @@ +/* +Copyright 2026 The KCP 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 workspace + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/restmapper" + + kcpdynamic "github.com/kcp-dev/client-go/dynamic" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/core" + tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" + kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" + kcptesting "github.com/kcp-dev/sdk/testing" + kcptestinghelpers "github.com/kcp-dev/sdk/testing/helpers" + + "github.com/kcp-dev/kcp/config/helpers" + "github.com/kcp-dev/kcp/test/e2e/framework" +) + +func TestAPIBindingSelectorInheritance(t *testing.T) { + t.Parallel() + framework.Suite(t, "control-plane") + + server := kcptesting.SharedKcpServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + cfg := server.BaseConfig(t) + kcpClusterClient, err := kcpclientset.NewForConfig(cfg) + require.NoError(t, err, "failed to construct kcp cluster client for server") + + dynamicClusterClient, err := kcpdynamic.NewForConfig(cfg) + require.NoError(t, err, "failed to construct dynamic cluster client for server") + + serviceProviderClient, err := kcpclientset.NewForConfig(cfg) + require.NoError(t, err) + + t.Logf("Create WorkspaceType in root workspace with DefaultAPIBinding") + workspaceType := &tenancyv1alpha1.WorkspaceType{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-consumer-type", + }, + Spec: tenancyv1alpha1.WorkspaceTypeSpec{ + Extend: tenancyv1alpha1.WorkspaceTypeExtension{ + With: []tenancyv1alpha1.WorkspaceTypeReference{ + { + Path: core.RootCluster.Path().String(), + Name: "universal", + }, + }, + }, + DefaultAPIBindings: []tenancyv1alpha1.APIExportReference{ + { + Path: "", + Export: "today-cowboys", + }, + }, + }, + } + workspaceType, err = kcpClusterClient.Cluster(core.RootCluster.Path()).TenancyV1alpha1().WorkspaceTypes().Create(ctx, workspaceType, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create workspace type") + + orgsPath, _ := kcptesting.NewWorkspaceFixture(t, server, core.RootCluster.Path(), kcptesting.WithType(core.RootCluster.Path(), "organization"), kcptesting.WithName("orgs")) + platformPath, _ := kcptesting.NewWorkspaceFixture(t, server, core.RootCluster.Path(), kcptesting.WithName("platform")) + + t.Logf("Install cowboys APIResourceSchema into platform workspace %q", platformPath) + mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(serviceProviderClient.Cluster(platformPath).Discovery())) + err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(platformPath), mapper, nil, "apiresourceschema_cowboys.yaml", testFiles) + require.NoError(t, err) + + err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(platformPath), mapper, nil, "clusterrole_cowboys.yaml", testFiles) + require.NoError(t, err) + + err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(platformPath), mapper, nil, "clusterrolebinding_cowboys.yaml", testFiles) + require.NoError(t, err) + + t.Logf("Create an APIExport with permission claims") + apiExport := &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "today-cowboys", + }, + Spec: apisv1alpha2.APIExportSpec{ + Resources: []apisv1alpha2.ResourceSchema{ + { + Group: "wildwest.dev", + Name: "cowboys", + Schema: "today.cowboys.wildwest.dev", + Storage: apisv1alpha2.ResourceSchemaStorage{ + CRD: &apisv1alpha2.ResourceSchemaStorageCRD{}, + }, + }, + }, + PermissionClaims: []apisv1alpha2.PermissionClaim{ + { + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + Verbs: []string{"get", "list"}, + }, + }, + }, + } + apiExport, err = kcpClusterClient.Cluster(platformPath).ApisV1alpha2().APIExports().Create(ctx, apiExport, metav1.CreateOptions{}) + require.NoError(t, err) + + kcptestinghelpers.EventuallyCondition(t, func() (conditions.Getter, error) { + return kcpClusterClient.Cluster(platformPath).ApisV1alpha2().APIExports().Get(ctx, apiExport.Name, metav1.GetOptions{}) + }, kcptestinghelpers.Is(apisv1alpha2.APIExportIdentityValid), "could not wait for APIExport to be valid with identity hash") + + apiExport, err = kcpClusterClient.Cluster(platformPath).ApisV1alpha2().APIExports().Get(ctx, apiExport.Name, metav1.GetOptions{}) + require.NoError(t, err) + identityHash := apiExport.Status.IdentityHash + require.NotEmpty(t, identityHash, "APIExport should have identity hash") + + t.Logf("Create APIBinding in orgs workspace %q with label selector", orgsPath) + orgsBinding := &apisv1alpha2.APIBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "today-cowboys", + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: platformPath.String(), + Name: apiExport.Name, + }, + }, + PermissionClaims: []apisv1alpha2.AcceptablePermissionClaim{ + { + ScopedPermissionClaim: apisv1alpha2.ScopedPermissionClaim{ + PermissionClaim: apisv1alpha2.PermissionClaim{ + GroupResource: apisv1alpha2.GroupResource{ + Group: "", + Resource: "configmaps", + }, + Verbs: []string{"get", "list"}, + }, + Selector: apisv1alpha2.PermissionClaimSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "platform-mesh.io/enabled": "true", + }, + }, + }, + }, + State: apisv1alpha2.ClaimAccepted, + }, + }, + }, + } + + kcptestinghelpers.Eventually(t, func() (bool, string) { + _, err := kcpClusterClient.Cluster(orgsPath).ApisV1alpha2().APIBindings().Create(ctx, orgsBinding, metav1.CreateOptions{}) + return err == nil, fmt.Sprintf("Error creating orgs APIBinding: %v", err) + }, wait.ForeverTestTimeout, time.Millisecond*100, "failed to create orgs APIBinding") + + kcptestinghelpers.EventuallyCondition(t, func() (conditions.Getter, error) { + return kcpClusterClient.Cluster(orgsPath).ApisV1alpha2().APIBindings().Get(ctx, orgsBinding.Name, metav1.GetOptions{}) + }, kcptestinghelpers.Is(apisv1alpha2.InitialBindingCompleted), "orgs APIBinding should be completed") + + kcptestinghelpers.Eventually(t, func() (bool, string) { + wt, err := kcpClusterClient.Cluster(core.RootCluster.Path()).TenancyV1alpha1().WorkspaceTypes().Get(ctx, workspaceType.Name, metav1.GetOptions{}) + if err != nil { + return false, fmt.Sprintf("failed to get workspace type: %v", err) + } + wt.Spec.DefaultAPIBindings[0].Path = platformPath.String() + _, err = kcpClusterClient.Cluster(core.RootCluster.Path()).TenancyV1alpha1().WorkspaceTypes().Update(ctx, wt, metav1.UpdateOptions{}) + if err != nil { + return false, fmt.Sprintf("failed to update workspace type: %v", err) + } + return true, "" + }, wait.ForeverTestTimeout, time.Millisecond*100, "failed to update workspace type with platform path") + + t.Logf("Create child workspace in orgs with WorkspaceType - APIBinding should inherit selector from orgs") + childPath, _ := kcptesting.NewWorkspaceFixture(t, server, orgsPath, kcptesting.WithType(core.RootCluster.Path(), tenancyv1alpha1.WorkspaceTypeName(workspaceType.Name)), kcptesting.WithName("child")) + + t.Logf("Verify that child workspace APIBinding inherited selector from parent") + kcptestinghelpers.Eventually(t, func() (bool, string) { + bindings, err := kcpClusterClient.Cluster(childPath).ApisV1alpha2().APIBindings().List(ctx, metav1.ListOptions{}) + if err != nil { + return false, fmt.Sprintf("failed to list APIBindings: %v", err) + } + + for _, binding := range bindings.Items { + if binding.Spec.Reference.Export == nil { + continue + } + if binding.Spec.Reference.Export.Name == apiExport.Name && + binding.Spec.Reference.Export.Path == platformPath.String() { + for _, claim := range binding.Spec.PermissionClaims { + if claim.Group == "" && claim.Resource == "configmaps" && + claim.State == apisv1alpha2.ClaimAccepted { + if claim.Selector.MatchAll { + return false, fmt.Sprintf("APIBinding has MatchAll selector instead of inherited label selector. Full binding: %+v", binding) + } + if claim.Selector.LabelSelector.MatchLabels == nil { + return false, fmt.Sprintf("APIBinding should have MatchLabels selector inherited from parent. Selector: %+v", claim.Selector) + } + expectedLabel := "platform-mesh.io/enabled" + expectedValue := "true" + if claim.Selector.LabelSelector.MatchLabels[expectedLabel] != expectedValue { + return false, fmt.Sprintf("APIBinding should have inherited selector with label %s=%s, got %v. Full selector: %+v", + expectedLabel, expectedValue, claim.Selector.LabelSelector.MatchLabels, claim.Selector) + } + return true, "APIBinding correctly inherited selector from parent" + } + } + return false, fmt.Sprintf("APIBinding found but permission claim not found or not accepted. Claims: %+v", binding.Spec.PermissionClaims) + } + } + return false, fmt.Sprintf("APIBinding not found yet. Found bindings: %d", len(bindings.Items)) + }, wait.ForeverTestTimeout, time.Second*2, "failed to verify selector inheritance") + + t.Logf("Successfully verified that child workspace APIBinding inherited selector from orgs workspace") +}