diff --git a/pkg/server/proxy/lookup.go b/pkg/server/proxy/lookup.go index dc0e3f96466..8ab2ae176d4 100644 --- a/pkg/server/proxy/lookup.go +++ b/pkg/server/proxy/lookup.go @@ -37,12 +37,6 @@ import ( kcpauthorization "github.com/kcp-dev/kcp/pkg/authorization" "github.com/kcp-dev/kcp/pkg/index" "github.com/kcp-dev/kcp/pkg/server/proxy/types" - "github.com/kcp-dev/kcp/pkg/virtual/admin" - "github.com/kcp-dev/kcp/pkg/virtual/apiresourceschema" - "github.com/kcp-dev/kcp/pkg/virtual/initializingworkspaces" - "github.com/kcp-dev/kcp/pkg/virtual/migratingworkspaces" - "github.com/kcp-dev/kcp/pkg/virtual/replication" - "github.com/kcp-dev/kcp/pkg/virtual/terminatingworkspaces" ) func WithClusterResolver(delegate http.Handler, mappings []types.PathMapping, idx index.Index) http.Handler { @@ -195,48 +189,9 @@ func newMappingHandler(delegate http.Handler, idx index.Index) http.HandlerFunc } } -// apiExportVirtualWorkspaceName is the apiexport virtual workspace's name, -// builder.VirtualWorkspaceName in pkg/virtual/apiexport/builder, a package too -// heavy to import here. A test keeps the two equal. -const apiExportVirtualWorkspaceName = "apiexport" - -// virtualWorkspaceClusterOffsets is, for each built-in virtual workspace, the -// number of path segments its URLs carry between /services// and the -// clusters/ segment, as its shard-side path resolver parses them: -// -// /services/apiexport///clusters//... -// /services/replication///clusters//... -// /services/apiresourceschema//clusters//... -// /services/initializingworkspaces//clusters//... -// /services/terminatingworkspaces//clusters//... -// /services/admin/clusters//... -// /services/migratingworkspaces/clusters//... -var virtualWorkspaceClusterOffsets = map[string]int{ - apiExportVirtualWorkspaceName: 2, - replication.VirtualWorkspaceName: 2, - apiresourceschema.VirtualWorkspaceName: 1, - initializingworkspaces.VirtualWorkspaceName: 1, - terminatingworkspaces.VirtualWorkspaceName: 1, - admin.VirtualWorkspaceName: 0, - migratingworkspaces.VirtualWorkspaceName: 0, -} - // extractClusterFromPath extracts the cluster name from URLs containing /clusters// // For example: /services/foo/clusters/root:orgs:bob/apis/... -> root:orgs:bob. -// -// For a built-in virtual workspace the cluster is read where that workspace's -// URL layout puts it, so a segment before it named "clusters" — an APIExport -// or a CachedResource of that name — is not taken for the cluster. Other -// virtual workspaces' layouts are unknown here, and their cluster is the -// segment after the first /clusters/. func extractClusterFromPath(path string) string { - if rest, ok := strings.CutPrefix(path, "/services/"); ok { - name, rest, _ := strings.Cut(rest, "/") - if offset, known := virtualWorkspaceClusterOffsets[name]; known { - return clusterAtOffset(rest, offset) - } - } - const clustersPrefix = "/clusters/" idx := strings.Index(path, clustersPrefix) if idx == -1 { @@ -254,18 +209,6 @@ func extractClusterFromPath(path string) string { return rest[:endIdx] } -// clusterAtOffset returns the cluster of a virtual workspace path, given the -// path after /services// and the number of segments before its -// clusters/ segment. A path without clusters/ at that -// position names no cluster. -func clusterAtOffset(path string, offset int) string { - segments := strings.SplitN(path, "/", offset+3) - if len(segments) < offset+2 || segments[offset] != "clusters" { - return "" - } - return segments[offset+1] -} - func resolveClusterName(w http.ResponseWriter, req *http.Request, idx index.Index, clusterName string) (*http.Request, *index.Result) { ctx := req.Context() logger := klog.FromContext(ctx) diff --git a/pkg/server/proxy/lookup_test.go b/pkg/server/proxy/lookup_test.go index 4c4c458ed2b..eb0fbff662a 100644 --- a/pkg/server/proxy/lookup_test.go +++ b/pkg/server/proxy/lookup_test.go @@ -26,7 +26,6 @@ import ( "github.com/kcp-dev/logicalcluster/v3" "github.com/kcp-dev/kcp/pkg/index/indextest" - apiexportbuilder "github.com/kcp-dev/kcp/pkg/virtual/apiexport/builder" ) func TestClusterResolveHandlerMigration(t *testing.T) { @@ -203,91 +202,6 @@ func TestExtractClusterFromPath(t *testing.T) { path: "/services/foo/clusters/root/apis/v1", expected: "root", }, - { - name: "replication VW, cached resource named clusters", - path: "/services/replication/1oget0q1249b2vcy/clusters/clusters/385doly4poks8a45/apis/wildwest.dev/v1alpha1/clusters", - expected: "385doly4poks8a45", - }, - { - name: "replication VW, cached resource named clusters, at the cluster's root", - path: "/services/replication/1oget0q1249b2vcy/clusters/clusters/385doly4poks8a45", - expected: "385doly4poks8a45", - }, - { - name: "replication VW, cached resource named clusters, non-API endpoint", - path: "/services/replication/1oget0q1249b2vcy/clusters/clusters/385doly4poks8a45/healthz", - expected: "385doly4poks8a45", - }, - { - name: "apiexport VW, export named clusters", - path: "/services/apiexport/root:providers/clusters/clusters/root:orgs:bob/api/v1/namespaces", - expected: "root:orgs:bob", - }, - { - name: "apiexport VW, export named clusters, wildcard cluster", - path: "/services/apiexport/root:providers/clusters/clusters/*/apis/wildwest.dev/v1alpha1/clusters", - expected: "*", - }, - { - name: "apiexport VW, export named clusters, trailing slash after the cluster", - path: "/services/apiexport/root:providers/clusters/clusters/root:orgs:bob/", - expected: "root:orgs:bob", - }, - { - name: "apiexport VW, export and its workspace both named clusters", - path: "/services/apiexport/clusters/clusters/clusters/root:orgs:bob/apis/wildwest.dev/v1alpha1/clusters", - expected: "root:orgs:bob", - }, - { - name: "apiexport VW, a later clusters segment does not move the cluster", - path: "/services/apiexport/root:providers/export/clusters/root:orgs:a/x/clusters/root:orgs:b/apis/v1", - expected: "root:orgs:a", - }, - { - name: "apiexport VW without a clusters segment where its layout puts one", - path: "/services/apiexport/root:providers/export/notclusters/root:orgs:bob/apis/v1", - expected: "", - }, - { - name: "apiexport VW cut short before its cluster", - path: "/services/apiexport/root:providers/export/clusters", - expected: "", - }, - { - name: "initializingworkspaces VW", - path: "/services/initializingworkspaces/root:org:custom/clusters/2x7k9fqp1abcd/apis/core.kcp.io/v1alpha1/logicalclusters", - expected: "2x7k9fqp1abcd", - }, - { - name: "terminatingworkspaces VW", - path: "/services/terminatingworkspaces/root:org:custom/clusters/2x7k9fqp1abcd/apis/core.kcp.io/v1alpha1/logicalclusters", - expected: "2x7k9fqp1abcd", - }, - { - name: "apiresourceschema VW", - path: "/services/apiresourceschema/root:consumer/clusters/*/apis/apis.kcp.io/v1alpha1/apiresourceschemas", - expected: "*", - }, - { - name: "admin VW", - path: "/services/admin/clusters/root:orgs:bob/apis/core.kcp.io/v1alpha1/logicalclusters", - expected: "root:orgs:bob", - }, - { - name: "migratingworkspaces VW", - path: "/services/migratingworkspaces/clusters/2x7k9fqp1abcd/apis/v1", - expected: "2x7k9fqp1abcd", - }, - { - name: "unknown VW, resource named clusters after the cluster", - path: "/services/foo/clusters/root:orgs:bob/apis/wildwest.dev/v1alpha1/clusters/cluster-a", - expected: "root:orgs:bob", - }, - { - name: "unknown VW keeps the first segment after /clusters/, even an empty one", - path: "/services/foo/clusters//clusters/root:orgs:bob/apis/v1", - expected: "", - }, } for _, tt := range tests { @@ -300,10 +214,3 @@ func TestExtractClusterFromPath(t *testing.T) { }) } } - -func TestAPIExportVirtualWorkspaceName(t *testing.T) { - t.Parallel() - if apiExportVirtualWorkspaceName != apiexportbuilder.VirtualWorkspaceName { - t.Errorf("apiExportVirtualWorkspaceName = %q, want the apiexport virtual workspace's name %q", apiExportVirtualWorkspaceName, apiexportbuilder.VirtualWorkspaceName) - } -} diff --git a/pkg/virtual/admin/builder/build.go b/pkg/virtual/admin/builder/build.go index e61428b2681..dd00a3cea9c 100644 --- a/pkg/virtual/admin/builder/build.go +++ b/pkg/virtual/admin/builder/build.go @@ -19,14 +19,13 @@ package builder import ( "context" "errors" - "strings" + "path" "sync" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" genericapiserver "k8s.io/apiserver/pkg/server" kcpdynamic "github.com/kcp-dev/client-go/dynamic" - "github.com/kcp-dev/logicalcluster/v3" adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" @@ -46,6 +45,16 @@ import ( // VW serves one fixed API set regardless of the caller. const apiDomainKey = dynamiccontext.APIDomainKey(admin.VirtualWorkspaceName) +// pathPatterns returns the mux patterns of the virtual workspace: +// rootPathPrefix itself and, for cluster-aware clients, below an optional +// clusters/ segment. The views are identical for every value. +func pathPatterns(rootPathPrefix string) []string { + return []string{ + path.Clean(rootPathPrefix), + path.Join(rootPathPrefix, "clusters/{cluster}"), + } +} + // BuildVirtualWorkspace builds the Admin workspace virtual workspace. It // serves two cache-backed, installation-wide views: every shard's Shard // object, writable for the allow-listed operational fields, and the @@ -65,22 +74,11 @@ func BuildVirtualWorkspace( identities *apiidentity.Projector, identitiesSynced func() bool, ) ([]rootapiserver.NamedVirtualWorkspace, error) { - if !strings.HasSuffix(rootPathPrefix, "/") { - rootPathPrefix += "/" - } readyCh := make(chan struct{}) vw := &virtualworkspacesdynamic.DynamicVirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, ctx context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, prefixToStrip, ok := digestURL(urlPath, rootPathPrefix) - if !ok { - return false, "", ctx - } - completedContext = genericapirequest.WithCluster(ctx, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomainKey) - return true, prefixToStrip, completedContext - }), - Authorizer: newAuthorizer(), + RootPathResolver: framework.RootPathResolverFunc(resolveRootPath), + Authorizer: newAuthorizer(), ReadyChecker: framework.ReadyFunc(func() error { select { case <-readyCh: @@ -109,46 +107,18 @@ func BuildVirtualWorkspace( } return []rootapiserver.NamedVirtualWorkspace{ - {Name: admin.VirtualWorkspaceName, VirtualWorkspace: vw}, + {Name: admin.VirtualWorkspaceName, Patterns: pathPatterns(rootPathPrefix), VirtualWorkspace: vw}, }, nil } -// digestURL accepts /services/admin and everything below it. An optional -// /clusters/ segment (added by cluster-aware clients) is parsed -// and stripped; the views are identical for every value. -func digestURL(urlPath, rootPathPrefix string) (cluster genericapirequest.Cluster, logicalPath string, accepted bool) { - if !strings.HasPrefix(urlPath, rootPathPrefix) { - return genericapirequest.Cluster{}, "", false +// resolveRootPath presents the cluster-less aggregate view under the root +// cluster name, so cluster-scoped machinery has a stable, valid cluster, +// unless the request names a cluster. +func resolveRootPath(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + if match.Value(framework.ClusterWildcard) == "" { + ctx = genericapirequest.WithCluster(ctx, genericapirequest.Cluster{Name: core.RootCluster}) } - - prefix := strings.TrimSuffix(rootPathPrefix, "/") - realPath := strings.TrimPrefix(urlPath, prefix) - if realPath != "" && !strings.HasPrefix(realPath, "/") { - return genericapirequest.Cluster{}, "", false - } - - // the aggregate view is cluster-less; present it under the root cluster - // name so cluster-scoped machinery has a stable, valid cluster. - cluster = genericapirequest.Cluster{Name: core.RootCluster} - - if withoutClustersPrefix, ok := strings.CutPrefix(realPath, "/clusters/"); ok { - parts := strings.SplitN(withoutClustersPrefix, "/", 2) - path := logicalcluster.NewPath(parts[0]) - if path == logicalcluster.Wildcard { - cluster = genericapirequest.Cluster{Wildcard: true} - } else if name, ok := path.Name(); ok { - cluster = genericapirequest.Cluster{Name: name} - } else { - return genericapirequest.Cluster{}, "", false - } - realPath = "/" - if len(parts) > 1 { - realPath += parts[1] - } - return cluster, strings.TrimSuffix(urlPath, realPath), true - } - - return cluster, prefix, true + return true, match.PrefixToStrip(), dynamiccontext.WithAPIDomainKey(ctx, apiDomainKey) } // adminAPIDefinitionSetProvider serves the fixed API set of this virtual diff --git a/pkg/virtual/admin/builder/build_test.go b/pkg/virtual/admin/builder/build_test.go index 88296298268..e925c23aec1 100644 --- a/pkg/virtual/admin/builder/build_test.go +++ b/pkg/virtual/admin/builder/build_test.go @@ -30,49 +30,74 @@ import ( "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/watch" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "github.com/kcp-dev/logicalcluster/v3" adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" 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/virtual-workspace-framework/framework" + dynamiccontext "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/context" "github.com/kcp-dev/virtual-workspace-framework/pkg/forwardingregistry" "github.com/kcp-dev/kcp/pkg/apiidentity" ) -func TestDigestURL(t *testing.T) { +func TestPathPatterns(t *testing.T) { t.Parallel() - const prefix = "/services/admin" - tests := []struct { - urlPath string - accepted bool - wildcard bool - clusterName string + require.Equal(t, + []string{"/services/admin", "/services/admin/clusters/{cluster}"}, + pathPatterns("/services/admin")) +} + +func TestResolveRootPath(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + path string + cluster string + trail string + wantCluster *genericapirequest.Cluster prefixToTrim string }{ - {urlPath: "/services/admin", accepted: true, clusterName: "root", prefixToTrim: "/services/admin"}, - {urlPath: "/services/admin/apis/core.kcp.io/v1alpha1/shards", accepted: true, clusterName: "root", prefixToTrim: "/services/admin"}, - {urlPath: "/services/admin/apis/admin.kcp.io/v1alpha1/apiidentities", accepted: true, clusterName: "root", prefixToTrim: "/services/admin"}, - {urlPath: "/services/admin/clusters/*/apis/core.kcp.io/v1alpha1/shards", accepted: true, wildcard: true, prefixToTrim: "/services/admin/clusters/*"}, - {urlPath: "/services/admin/clusters/myws/apis/core.kcp.io/v1alpha1/shards", accepted: true, clusterName: "myws", prefixToTrim: "/services/admin/clusters/myws"}, - {urlPath: "/services/adminfoo/apis", accepted: false}, - {urlPath: "/services/other", accepted: false}, + "plain": { + path: "/services/admin/apis/core.kcp.io/v1alpha1/shards", + trail: "apis/core.kcp.io/v1alpha1/shards", + wantCluster: &genericapirequest.Cluster{Name: core.RootCluster}, + prefixToTrim: "/services/admin", + }, + "bare prefix": { + path: "/services/admin", + wantCluster: &genericapirequest.Cluster{Name: core.RootCluster}, + prefixToTrim: "/services/admin", + }, + "bare prefix, trailing slash": { + path: "/services/admin/", + wantCluster: &genericapirequest.Cluster{Name: core.RootCluster}, + prefixToTrim: "/services/admin", + }, + "cluster aware": { + path: "/services/admin/clusters/myws/apis/core.kcp.io/v1alpha1/shards", + cluster: "myws", + trail: "apis/core.kcp.io/v1alpha1/shards", + prefixToTrim: "/services/admin/clusters/myws", + }, } - for _, tc := range tests { - t.Run(tc.urlPath, func(t *testing.T) { + for name, tc := range tests { + t.Run(name, func(t *testing.T) { t.Parallel() - cluster, prefixToTrim, accepted := digestURL(tc.urlPath, prefix) - require.Equal(t, tc.accepted, accepted, "accepted") - if !tc.accepted { - return - } - require.Equal(t, tc.wildcard, cluster.Wildcard, "wildcard") - if !tc.wildcard { - require.Equal(t, tc.clusterName, cluster.Name.String(), "cluster") - } - require.Equal(t, tc.prefixToTrim, prefixToTrim, "prefixToTrim") + + match := framework.NewPathMatch(tc.path, tc.trail, map[string]string{framework.ClusterWildcard: tc.cluster}) + + accepted, prefixToTrim, ctx := resolveRootPath(t.Context(), match) + + require.True(t, accepted) + require.Equal(t, tc.prefixToTrim, prefixToTrim) + require.Equal(t, tc.wantCluster, genericapirequest.ClusterFrom(ctx)) + require.Equal(t, apiDomainKey, dynamiccontext.APIDomainKeyFrom(ctx)) }) } } diff --git a/pkg/virtual/apiexport/builder/build.go b/pkg/virtual/apiexport/builder/build.go index e4b143552a3..e5794f88b1d 100644 --- a/pkg/virtual/apiexport/builder/build.go +++ b/pkg/virtual/apiexport/builder/build.go @@ -21,8 +21,8 @@ import ( "encoding/json" "errors" "fmt" + "path" "slices" - "strings" "k8s.io/apimachinery/pkg/labels" "k8s.io/apiserver/pkg/authentication/serviceaccount" @@ -69,6 +69,16 @@ const ( OriginalGroupsAnnotationKey = "experimental.authorization.kcp.io/original-groups" ) +const ( + exportClusterWildcard = "exportCluster" + exportNameWildcard = "exportName" +) + +// pathPattern returns the mux pattern of the virtual workspace below rootPathPrefix. +func pathPattern(rootPathPrefix string) string { + return path.Join(rootPathPrefix, "{exportCluster}/{exportName}/clusters/{cluster}") +} + func BuildVirtualWorkspace( rootPathPrefix string, cfg *rest.Config, @@ -76,25 +86,12 @@ func BuildVirtualWorkspace( kcpClusterClient kcpclientset.ClusterInterface, cachedKcpInformers, kcpInformers kcpinformers.SharedInformerFactory, ) ([]rootapiserver.NamedVirtualWorkspace, error) { - if !strings.HasSuffix(rootPathPrefix, "/") { - rootPathPrefix += "/" - } - readyCh := make(chan struct{}) apiExportAdmission := aeadmission.NewSelectorAdmission(kcpInformers.Apis().V1alpha2().APIBindings(), kubeClusterClient) boundOrClaimedWorkspaceContent := &virtualdynamic.DynamicVirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, ctx context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, apiDomain, prefixToStrip, ok := digestUrl(urlPath, rootPathPrefix) - if !ok { - return false, "", ctx - } - - completedContext = genericapirequest.WithCluster(ctx, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomain) - return true, prefixToStrip, completedContext - }), + RootPathResolver: framework.RootPathResolverFunc(resolveRootPath), ReadyChecker: framework.ReadyFunc(func() error { select { @@ -228,73 +225,14 @@ func BuildVirtualWorkspace( } return []rootapiserver.NamedVirtualWorkspace{ - {Name: VirtualWorkspaceName, VirtualWorkspace: boundOrClaimedWorkspaceContent}, + {Name: VirtualWorkspaceName, Patterns: []string{pathPattern(rootPathPrefix)}, VirtualWorkspace: boundOrClaimedWorkspaceContent}, }, nil } -func digestUrl(urlPath, rootPathPrefix string) ( - cluster genericapirequest.Cluster, - domainKey dynamiccontext.APIDomainKey, - logicalPath string, - accepted bool, -) { - if !strings.HasPrefix(urlPath, rootPathPrefix) { - return genericapirequest.Cluster{}, "", "", false - } - - // Incoming requests to this virtual workspace will look like: - // /services/apiexport/root:org:ws//clusters/*/api/v1/configmaps - // └────────────────────────┐ - // Where the withoutRootPathPrefix starts here: ┘ - withoutRootPathPrefix := strings.TrimPrefix(urlPath, rootPathPrefix) - - parts := strings.SplitN(withoutRootPathPrefix, "/", 3) - if len(parts) < 3 { - return genericapirequest.Cluster{}, "", "", false - } - - apiExportClusterName, apiExportName := parts[0], parts[1] - if apiExportClusterName == "" { - return genericapirequest.Cluster{}, "", "", false - } - if apiExportName == "" { - return genericapirequest.Cluster{}, "", "", false - } - - realPath := "/" - if len(parts) > 2 { - realPath += parts[2] - } - - // /services/apiexport/root:org:ws//clusters/*/api/v1/configmaps - // ┌────────────────────────────┘ - // We are now here: ───┘ - // Now, we parse out the logical cluster. - if !strings.HasPrefix(realPath, "/clusters/") { - return genericapirequest.Cluster{}, "", "", false - } - - withoutClustersPrefix := strings.TrimPrefix(realPath, "/clusters/") - parts = strings.SplitN(withoutClustersPrefix, "/", 2) - path := logicalcluster.NewPath(parts[0]) - realPath = "/" - if len(parts) > 1 { - realPath += parts[1] - } - - cluster = genericapirequest.Cluster{} - if path == logicalcluster.Wildcard { - cluster.Wildcard = true - } else { - var ok bool - cluster.Name, ok = path.Name() - if !ok { - return genericapirequest.Cluster{}, "", "", false - } - } - - key := fmt.Sprintf("%s/%s", apiExportClusterName, apiExportName) - return cluster, dynamiccontext.APIDomainKey(key), strings.TrimSuffix(urlPath, realPath), true +// resolveRootPath keys the API domain by /. +func resolveRootPath(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + key := dynamiccontext.APIDomainKey(match.Value(exportClusterWildcard) + "/" + match.Value(exportNameWildcard)) + return true, match.PrefixToStrip(), dynamiccontext.WithAPIDomainKey(ctx, key) } func newAuthorizer(kubeClusterClient, deepSARClient kcpkubernetesclientset.ClusterInterface, cachedKcpInformers, kcpInformers kcpinformers.SharedInformerFactory) authorizer.Authorizer { diff --git a/pkg/virtual/apiresourceschema/builder/build.go b/pkg/virtual/apiresourceschema/builder/build.go index 194a9d8cedf..50a0a0d1f7c 100644 --- a/pkg/virtual/apiresourceschema/builder/build.go +++ b/pkg/virtual/apiresourceschema/builder/build.go @@ -20,7 +20,7 @@ import ( "context" "errors" "fmt" - "strings" + "path" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" @@ -54,6 +54,18 @@ const ( controllerName = "apiresourceschema-virtual-workspace" ) +const consumerClusterWildcard = "consumerCluster" + +// pathPatterns returns the mux patterns of the virtual workspace below rootPathPrefix. +// The consumer cluster determines which APIBindings to look up. +// The clusters/ segment is for compatibility with cluster-aware clients. +func pathPatterns(rootPathPrefix string) []string { + return []string{ + path.Join(rootPathPrefix, "{consumerCluster}"), + path.Join(rootPathPrefix, "{consumerCluster}/clusters/{cluster}"), + } +} + // BuildVirtualWorkspace builds the apiresourceschema virtual workspace. // URL pattern: /services/apiresourceschema//clusters/*/apis/apis.kcp.io/v1alpha1/apiresourceschemas. func BuildVirtualWorkspace( @@ -61,27 +73,12 @@ func BuildVirtualWorkspace( kubeClusterClient kcpkubernetesclientset.ClusterInterface, cachedKcpInformers, wildcardKcpInformers kcpinformers.SharedInformerFactory, ) ([]rootapiserver.NamedVirtualWorkspace, error) { - if !strings.HasSuffix(rootPathPrefix, "/") { - rootPathPrefix += "/" - } - readyCh := make(chan struct{}) klog.Background().Info("Building apiresourceschema virtual workspace", "rootPathPrefix", rootPathPrefix) vw := &virtualworkspacesdynamic.DynamicVirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, ctx context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, prefixToStrip, ok := digestURL(urlPath, rootPathPrefix) - klog.Background().V(6).Info("apiresourceschema VW RootPathResolver", "urlPath", urlPath, "rootPathPrefix", rootPathPrefix, "accepted", ok) - if !ok { - return false, "", ctx - } - - completedContext = genericapirequest.WithCluster(ctx, cluster) - // Use the cluster name as the API domain key for caching - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, dynamiccontext.APIDomainKey(cluster.Name.String())) - return true, prefixToStrip, completedContext - }), + RootPathResolver: framework.RootPathResolverFunc(resolveRootPath), ReadyChecker: framework.ReadyFunc(func() error { select { @@ -125,78 +122,30 @@ func BuildVirtualWorkspace( } return []rootapiserver.NamedVirtualWorkspace{ - {Name: apiresourceschema.VirtualWorkspaceName, VirtualWorkspace: vw}, + {Name: apiresourceschema.VirtualWorkspaceName, Patterns: pathPatterns(rootPathPrefix), VirtualWorkspace: vw}, }, nil } -// digestURL parses the URL to extract the consumer cluster. -// URL pattern: /services/apiresourceschema//clusters/*/apis/... -// The consumer-cluster determines which APIBindings to look up. -// The /clusters/* part is for compatibility with cluster-aware clients but is ignored -// since this VW always serves schemas from the consumer cluster's bindings. -func digestURL(urlPath, rootPathPrefix string) ( - cluster genericapirequest.Cluster, - logicalPath string, - accepted bool, -) { - if !strings.HasPrefix(urlPath, rootPathPrefix) { - return genericapirequest.Cluster{}, "", false - } - - // Incoming requests to this virtual workspace will look like: - // /services/apiresourceschema//clusters/*/apis/apis.kcp.io/v1alpha1/apiresourceschemas - // └────────────────┐ - // Where the withoutRootPathPrefix starts here: ┘ - withoutRootPathPrefix := strings.TrimPrefix(urlPath, rootPathPrefix) - - parts := strings.SplitN(withoutRootPathPrefix, "/", 2) - if len(parts) < 1 || parts[0] == "" { - return genericapirequest.Cluster{}, "", false +// resolveRootPath serves the consumer cluster's bindings. +// The cluster segment, if present, must be the wildcard or the consumer +// cluster, so a client cannot name one cluster and be routed to another. +func resolveRootPath(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + consumerPath := logicalcluster.NewPath(match.Value(consumerClusterWildcard)) + if consumerPath == logicalcluster.Wildcard { + return false, "", ctx } - - consumerClusterName := parts[0] - clusterPath := logicalcluster.NewPath(consumerClusterName) - - // Wildcard is not supported for the consumer cluster - we need a specific cluster - if clusterPath == logicalcluster.Wildcard { - return genericapirequest.Cluster{}, "", false - } - - clusterName, ok := clusterPath.Name() + consumer, ok := consumerPath.Name() if !ok { - return genericapirequest.Cluster{}, "", false - } - - cluster = genericapirequest.Cluster{ - Name: clusterName, + return false, "", ctx } - realPath := "/" - if len(parts) > 1 { - realPath = "/" + parts[1] - } - - // Handle /clusters//... pattern that clients add - // We need to strip this part to get to the API path - if strings.HasPrefix(realPath, "/clusters/") { - withoutClustersPrefix := strings.TrimPrefix(realPath, "/clusters/") - clusterParts := strings.SplitN(withoutClustersPrefix, "/", 2) - - // Enforce that the cluster path in /clusters// matches the consumer cluster - // from the URL prefix, or is a wildcard. This prevents confusion where a client - // specifies one cluster but gets routed to another. - requestedCluster := clusterParts[0] - if requestedCluster != "*" && requestedCluster != consumerClusterName { - return genericapirequest.Cluster{}, "", false - } - - realPath = "/" - if len(clusterParts) > 1 { - realPath += clusterParts[1] - } + if cluster := match.Value(framework.ClusterWildcard); cluster != "" && cluster != "*" && cluster != consumer.String() { + return false, "", ctx } - return cluster, strings.TrimSuffix(urlPath, realPath), true + ctx = genericapirequest.WithCluster(ctx, genericapirequest.Cluster{Name: consumer}) + // Use the cluster name as the API domain key for caching + return true, match.PrefixToStrip(), dynamiccontext.WithAPIDomainKey(ctx, dynamiccontext.APIDomainKey(consumer.String())) } // apiDefinitionSetProvider provides APIDefinitionSets for the virtual workspace. diff --git a/pkg/virtual/apiresourceschema/builder/build_test.go b/pkg/virtual/apiresourceschema/builder/build_test.go new file mode 100644 index 00000000000..a0db0cd7c1a --- /dev/null +++ b/pkg/virtual/apiresourceschema/builder/build_test.go @@ -0,0 +1,81 @@ +/* +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 builder + +import ( + "testing" + + "github.com/stretchr/testify/require" + + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + + "github.com/kcp-dev/virtual-workspace-framework/framework" + dynamiccontext "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/context" +) + +func TestPathPatterns(t *testing.T) { + t.Parallel() + + require.Equal(t, + []string{ + "/services/apiresourceschema/{consumerCluster}", + "/services/apiresourceschema/{consumerCluster}/clusters/{cluster}", + }, + pathPatterns("/services/apiresourceschema/")) +} + +func TestResolveRootPath(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + consumer string + cluster string + accepted bool + prefixToTrim string + }{ + "consumer only": {consumer: "abc", accepted: true, prefixToTrim: "/services/apiresourceschema/abc"}, + "wildcard cluster": {consumer: "abc", cluster: "*", accepted: true, prefixToTrim: "/services/apiresourceschema/abc/clusters/*"}, + "consumer as cluster": {consumer: "abc", cluster: "abc", accepted: true, prefixToTrim: "/services/apiresourceschema/abc/clusters/abc"}, + "other cluster": {consumer: "abc", cluster: "other"}, + "wildcard consumer": {consumer: "*"}, + "workspace path consumer": {consumer: "root:org"}, + "workspace path, wildcard": {consumer: "root:org", cluster: "*"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + path := "/services/apiresourceschema/" + tc.consumer + if tc.cluster != "" { + path += "/clusters/" + tc.cluster + } + values := map[string]string{consumerClusterWildcard: tc.consumer, framework.ClusterWildcard: tc.cluster} + match := framework.NewPathMatch(path+"/apis/apis.kcp.io/v1alpha1/apiresourceschemas", "apis/apis.kcp.io/v1alpha1/apiresourceschemas", values) + + accepted, prefixToTrim, ctx := resolveRootPath(t.Context(), match) + + require.Equal(t, tc.accepted, accepted) + if !tc.accepted { + return + } + require.Equal(t, tc.prefixToTrim, prefixToTrim) + require.Equal(t, &genericapirequest.Cluster{Name: "abc"}, genericapirequest.ClusterFrom(ctx)) + require.Equal(t, dynamiccontext.APIDomainKey("abc"), dynamiccontext.APIDomainKeyFrom(ctx)) + }) + } +} diff --git a/pkg/virtual/initializingworkspaces/builder/build.go b/pkg/virtual/initializingworkspaces/builder/build.go index efbdb73bc38..30757cc05ef 100644 --- a/pkg/virtual/initializingworkspaces/builder/build.go +++ b/pkg/virtual/initializingworkspaces/builder/build.go @@ -68,6 +68,13 @@ const ( workspaceContentName = initializingworkspaces.VirtualWorkspaceName + "-workspace-content" ) +const initializerWildcard = "initializer" + +// pathPattern returns the mux pattern shared by the virtual workspaces below rootPathPrefix. +func pathPattern(rootPathPrefix string) string { + return path.Join(rootPathPrefix, "{initializer}/clusters/{cluster}") +} + // BuildVirtualWorkspace builds the initializing-workspaces virtual workspaces. // // externalLogicalClusterAdminClient is used for SubjectAccessReview calls @@ -83,10 +90,6 @@ func BuildVirtualWorkspace( kubeClusterClient, externalLogicalClusterAdminClient kcpkubernetesclientset.ClusterInterface, wildcardKcpInformers, cachedKcpInformers kcpinformers.SharedInformerFactory, ) ([]rootapiserver.NamedVirtualWorkspace, error) { - if !strings.HasSuffix(rootPathPrefix, "/") { - rootPathPrefix += "/" - } - sarKubeClusterClient := externalLogicalClusterAdminClient if sarKubeClusterClient == nil { sarKubeClusterClient = kubeClusterClient @@ -123,22 +126,8 @@ func BuildVirtualWorkspace( Options: delegated.Options{DenyCacheTTL: 5 * time.Second}, }) wildcardLogicalClusters := &virtualworkspacesdynamic.DynamicVirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, requestContext context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, apiDomain, prefixToStrip, ok := digestUrl(urlPath, rootPathPrefix) - if !ok { - return false, "", requestContext - } - - if !cluster.Wildcard { - // this virtual workspace requires that a wildcard be provided - return false, "", requestContext - } - - completedContext = genericapirequest.WithCluster(requestContext, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomain) - return true, prefixToStrip, completedContext - }), - Authorizer: cachingAuthorizer, + RootPathResolver: framework.RootPathResolverFunc(resolveWildcardLogicalClusters), + Authorizer: cachingAuthorizer, ReadyChecker: framework.ReadyFunc(func() error { return nil }), @@ -154,27 +143,8 @@ func BuildVirtualWorkspace( } logicalClusters := &virtualworkspacesdynamic.DynamicVirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, ctx context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, apiDomain, prefixToStrip, ok := digestUrl(urlPath, rootPathPrefix) - if !ok { - return false, "", ctx - } - - if cluster.Wildcard { - // this virtual workspace requires that a specific cluster be provided - return false, "", ctx - } - - // this delegating server only works for logicalclusters.core.kcp.io - if resourceURL := strings.TrimPrefix(urlPath, prefixToStrip); !isLogicalClusterRequest(resourceURL) { - return false, "", ctx - } - - completedContext = genericapirequest.WithCluster(ctx, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomain) - return true, prefixToStrip, completedContext - }), - Authorizer: cachingAuthorizer, + RootPathResolver: framework.RootPathResolverFunc(resolveLogicalClusters), + Authorizer: cachingAuthorizer, ReadyChecker: framework.ReadyFunc(func() error { return nil }), @@ -191,31 +161,8 @@ func BuildVirtualWorkspace( workspaceContentReadyCh := make(chan struct{}) workspaceContent := &handler.VirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, context context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, apiDomain, prefixToStrip, ok := digestUrl(urlPath, rootPathPrefix) - if !ok { - return false, "", context - } - - if cluster.Wildcard { - // this virtual workspace requires that a specific cluster be provided - return false, "", context - } - - // this proxying server does not handle requests for logicalcluster.core.kcp.io - if resourceURL := strings.TrimPrefix(urlPath, prefixToStrip); isLogicalClusterRequest(resourceURL) { - return false, "", context - } - - // in this case since we're proxying and not consuming this request we *do not* want to strip - // the cluster prefix - prefixToStrip = strings.TrimSuffix(prefixToStrip, cluster.Name.Path().RequestPath()) - - completedContext = genericapirequest.WithCluster(context, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomain) - return true, prefixToStrip, completedContext - }), - Authorizer: cachingAuthorizer, + RootPathResolver: framework.RootPathResolverFunc(resolveWorkspaceContent), + Authorizer: cachingAuthorizer, ReadyChecker: framework.ReadyFunc(func() error { select { case <-workspaceContentReadyCh: @@ -366,78 +313,61 @@ func BuildVirtualWorkspace( }), } + patterns := []string{pathPattern(rootPathPrefix)} return []rootapiserver.NamedVirtualWorkspace{ - {Name: wildcardLogicalClustersName, VirtualWorkspace: wildcardLogicalClusters}, - {Name: logicalClustersName, VirtualWorkspace: logicalClusters}, - {Name: workspaceContentName, VirtualWorkspace: workspaceContent}, + {Name: wildcardLogicalClustersName, Patterns: patterns, VirtualWorkspace: wildcardLogicalClusters}, + {Name: logicalClustersName, Patterns: patterns, VirtualWorkspace: logicalClusters}, + {Name: workspaceContentName, Patterns: patterns, VirtualWorkspace: workspaceContent}, }, nil } -var resolver = requestinfo.NewFactory() +// withInitializerDomain keys the API domain by the initializer. +func withInitializerDomain(ctx context.Context, match framework.PathMatch) context.Context { + return dynamiccontext.WithAPIDomainKey(ctx, dynamiccontext.APIDomainKey(match.Value(initializerWildcard))) +} -func isLogicalClusterRequest(path string) bool { - info, err := resolver.NewRequestInfo(&http.Request{URL: &url.URL{Path: path}}) - if err != nil { - return false +// resolveWildcardLogicalClusters accepts wildcard requests only. +func resolveWildcardLogicalClusters(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + if cluster := genericapirequest.ClusterFrom(ctx); cluster == nil || !cluster.Wildcard { + return false, "", ctx } - return info.IsResourceRequest && info.APIGroup == corev1alpha1.SchemeGroupVersion.Group && info.Resource == "logicalclusters" + return true, match.PrefixToStrip(), withInitializerDomain(ctx, match) } -func digestUrl(urlPath, rootPathPrefix string) ( - cluster genericapirequest.Cluster, - key dynamiccontext.APIDomainKey, - logicalPath string, - accepted bool, -) { - if !strings.HasPrefix(urlPath, rootPathPrefix) { - return genericapirequest.Cluster{}, dynamiccontext.APIDomainKey(""), "", false +// resolveLogicalClusters accepts logicalclusters.core.kcp.io requests for a specific cluster. +func resolveLogicalClusters(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + if cluster := genericapirequest.ClusterFrom(ctx); cluster == nil || cluster.Wildcard { + return false, "", ctx } - withoutRootPathPrefix := strings.TrimPrefix(urlPath, rootPathPrefix) - - // Incoming requests to this virtual workspace will look like: - // /services/initializingworkspaces//clusters//apis/workload.kcp.io/v1alpha1/synctargets - // └───────────┐ - // Where the withoutRootPathPrefix starts here: ┘ - parts := strings.SplitN(withoutRootPathPrefix, "/", 2) - if len(parts) < 2 { - return genericapirequest.Cluster{}, "", "", false + resourceURL := "/" + match.Trail + if !isLogicalClusterRequest(resourceURL) { + return false, "", ctx } + return true, match.PrefixToStrip(), withInitializerDomain(ctx, match) +} - initializerName := parts[0] - if initializerName == "" { - return genericapirequest.Cluster{}, "", "", false +// resolveWorkspaceContent accepts requests for a specific cluster except logicalclusters.core.kcp.io. +// The cluster segment is kept since the request is proxied, not consumed. +func resolveWorkspaceContent(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + cluster := genericapirequest.ClusterFrom(ctx) + if cluster == nil || cluster.Wildcard { + return false, "", ctx } - - realPath := "/" + parts[1] - - // /services/initializingworkspaces//clusters//apis/workload.kcp.io/v1alpha1/synctargets - // ┌─────────────────────────────┘ - // We are now here: ┘ - // Now, we parse out the logical cluster. - if !strings.HasPrefix(realPath, "/clusters/") { - return genericapirequest.Cluster{}, "", "", false // don't accept + if isLogicalClusterRequest("/" + match.Trail) { + return false, "", ctx } + prefixToStrip = strings.TrimSuffix(match.PrefixToStrip(), cluster.Name.Path().RequestPath()) + return true, prefixToStrip, withInitializerDomain(ctx, match) +} - withoutClustersPrefix := strings.TrimPrefix(realPath, "/clusters/") - parts = strings.SplitN(withoutClustersPrefix, "/", 2) - logicalclusterPath := logicalcluster.NewPath(parts[0]) - realPath = "/" - if len(parts) > 1 { - realPath += parts[1] - } +var resolver = requestinfo.NewFactory() - cluster = genericapirequest.Cluster{} - if logicalclusterPath == logicalcluster.Wildcard { - cluster.Wildcard = true - } else { - var ok bool - cluster.Name, ok = logicalclusterPath.Name() - if !ok { - return genericapirequest.Cluster{}, "", "", false - } +func isLogicalClusterRequest(path string) bool { + info, err := resolver.NewRequestInfo(&http.Request{URL: &url.URL{Path: path}}) + if err != nil { + return false } - - return cluster, dynamiccontext.APIDomainKey(initializerName), strings.TrimSuffix(urlPath, realPath), true + return info.IsResourceRequest && info.APIGroup == corev1alpha1.SchemeGroupVersion.Group && info.Resource == "logicalclusters" } // resolveInitializerWorkspaceType resolves the WorkspaceType referenced by an diff --git a/pkg/virtual/initializingworkspaces/builder/build_test.go b/pkg/virtual/initializingworkspaces/builder/build_test.go index 4298d3470cd..181fec97a9b 100644 --- a/pkg/virtual/initializingworkspaces/builder/build_test.go +++ b/pkg/virtual/initializingworkspaces/builder/build_test.go @@ -17,6 +17,7 @@ limitations under the License. package builder import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -36,73 +37,110 @@ import ( tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" kcpinformers "github.com/kcp-dev/sdk/client/informers/externalversions" "github.com/kcp-dev/virtual-workspace-framework/framework" - "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/context" + dynamiccontext "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/context" "github.com/kcp-dev/kcp/pkg/indexers" ) -func TestDigestUrl(t *testing.T) { +func TestPathPattern(t *testing.T) { t.Parallel() - rootPathPrefix := "/services/initializingworkspaces/" - testCases := []struct { - urlPath string - expectedAccept bool - expectedCluster request.Cluster - expectedKey context.APIDomainKey - expectedLogicalPath string + + require.Equal(t, + "/services/initializingworkspaces/{initializer}/clusters/{cluster}", + pathPattern("/services/initializingworkspaces/")) +} + +func TestResolveRootPath(t *testing.T) { + t.Parallel() + + const ( + initializerName = "test-initializer" + clusterName = "abc" + ) + + // registration order, the first accepting resolver wins + resolvers := []struct { + name string + resolve framework.RootPathResolverFunc }{ - { - urlPath: "/services/initializingworkspaces/test-initializer/clusters/test-cluster/apis/workload.kcp.io/v1alpha1/synctargets", - expectedAccept: true, - expectedCluster: request.Cluster{Name: "test-cluster", Wildcard: false, PartialMetadataRequest: false}, - expectedKey: "test-initializer", - expectedLogicalPath: "/services/initializingworkspaces/test-initializer/clusters/test-cluster", + {"wildcardLogicalClusters", resolveWildcardLogicalClusters}, + {"logicalClusters", resolveLogicalClusters}, + {"workspaceContent", resolveWorkspaceContent}, + } + + tests := map[string]struct { + cluster request.Cluster + trail string + wantResolver string + prefixToTrim string + }{ + "wildcard logicalclusters": { + cluster: request.Cluster{Wildcard: true}, + trail: "apis/core.kcp.io/v1alpha1/logicalclusters", + wantResolver: "wildcardLogicalClusters", + prefixToTrim: "/services/initializingworkspaces/test-initializer/clusters/*", }, - { - urlPath: "/services/initializingworkspaces/test-initializer/clusters/*/apis/workload.kcp.io/v1alpha1/synctargets", - expectedAccept: true, - expectedCluster: request.Cluster{Name: "", Wildcard: true, PartialMetadataRequest: false}, - expectedKey: "test-initializer", - expectedLogicalPath: "/services/initializingworkspaces/test-initializer/clusters/*", + "wildcard other resource": { + cluster: request.Cluster{Wildcard: true}, + trail: "api/v1/configmaps", + wantResolver: "wildcardLogicalClusters", + prefixToTrim: "/services/initializingworkspaces/test-initializer/clusters/*", }, - { - urlPath: "/services/initializingworkspaces/test-initializer/clusters/", - expectedAccept: true, - expectedCluster: request.Cluster{Name: "", Wildcard: false, PartialMetadataRequest: false}, - expectedKey: "test-initializer", - expectedLogicalPath: "/services/initializingworkspaces/test-initializer/clusters", + "cluster logicalclusters": { + cluster: request.Cluster{Name: clusterName}, + trail: "apis/core.kcp.io/v1alpha1/logicalclusters/cluster", + wantResolver: "logicalClusters", + prefixToTrim: "/services/initializingworkspaces/test-initializer/clusters/abc", }, - { - urlPath: "/services/initializingworkspaces/test-initializer/clusters/*", - expectedAccept: true, - expectedCluster: request.Cluster{Name: "", Wildcard: true, PartialMetadataRequest: false}, - expectedKey: "test-initializer", - expectedLogicalPath: "/services/initializingworkspaces/test-initializer/clusters/*", + "cluster other resource keeps cluster segment": { + cluster: request.Cluster{Name: clusterName}, + trail: "api/v1/configmaps", + wantResolver: "workspaceContent", + prefixToTrim: "/services/initializingworkspaces/test-initializer", }, - { - urlPath: "/services/initializingworkspaces/test-initializer/", - expectedAccept: false, - expectedCluster: request.Cluster{Name: "", Wildcard: false, PartialMetadataRequest: false}, - expectedKey: "", - expectedLogicalPath: "", + "cluster workspace root keeps cluster segment": { + cluster: request.Cluster{Name: clusterName}, + trail: "", + wantResolver: "workspaceContent", + prefixToTrim: "/services/initializingworkspaces/test-initializer", }, - { - urlPath: "/services/initializingworkspaces/", - expectedAccept: false, - expectedCluster: request.Cluster{Name: "", Wildcard: false, PartialMetadataRequest: false}, - expectedKey: "", - expectedLogicalPath: "", + "no cluster": { + trail: "api/v1/configmaps", }, } - for _, tc := range testCases { - t.Run(tc.urlPath, func(t *testing.T) { + for name, tc := range tests { + t.Run(name, func(t *testing.T) { t.Parallel() - cluster, key, logicalPath, accepted := digestUrl(tc.urlPath, rootPathPrefix) - require.Equal(t, tc.expectedAccept, accepted, "Accepted should match expected value") - require.Equal(t, tc.expectedCluster, cluster, "Cluster should match expected value") - require.Equal(t, tc.expectedKey, key, "Key should match expected value") - require.Equal(t, tc.expectedLogicalPath, logicalPath, "LogicalPath should match expected value") + + segment := tc.cluster.Name.String() + if tc.cluster.Wildcard { + segment = "*" + } + ctx := t.Context() + if segment != "" { + ctx = request.WithCluster(ctx, tc.cluster) + } + values := map[string]string{initializerWildcard: initializerName, framework.ClusterWildcard: segment} + // trailing slash when trail is empty + match := framework.NewPathMatch("/services/initializingworkspaces/test-initializer/clusters/"+segment+"/"+tc.trail, tc.trail, values) + + var gotResolver, gotPrefix string + var gotCtx context.Context + for _, r := range resolvers { + accepted, prefixToTrim, ctx := r.resolve(ctx, match) + if accepted { + gotResolver, gotPrefix, gotCtx = r.name, prefixToTrim, ctx + break + } + } + + require.Equal(t, tc.wantResolver, gotResolver) + if tc.wantResolver == "" { + return + } + require.Equal(t, tc.prefixToTrim, gotPrefix) + require.Equal(t, dynamiccontext.APIDomainKey(initializerName), dynamiccontext.APIDomainKeyFrom(gotCtx)) }) } } diff --git a/pkg/virtual/migratingworkspaces/builder/build.go b/pkg/virtual/migratingworkspaces/builder/build.go index 0a76ebd6c46..08ed17eafda 100644 --- a/pkg/virtual/migratingworkspaces/builder/build.go +++ b/pkg/virtual/migratingworkspaces/builder/build.go @@ -21,8 +21,8 @@ import ( "net/http" "net/http/httputil" "net/url" + "path" "slices" - "strings" "k8s.io/apiserver/pkg/authorization/authorizer" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" @@ -31,7 +31,6 @@ import ( transport "k8s.io/client-go/transport" "k8s.io/klog/v2" - "github.com/kcp-dev/logicalcluster/v3" "github.com/kcp-dev/virtual-workspace-framework/framework" "github.com/kcp-dev/virtual-workspace-framework/pkg/handler" "github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver" @@ -41,31 +40,20 @@ import ( "github.com/kcp-dev/kcp/pkg/virtual/migratingworkspaces" ) +// pathPattern returns the mux pattern of the virtual workspace below rootPathPrefix. +func pathPattern(rootPathPrefix string) string { + return path.Join(rootPathPrefix, "clusters/{cluster}") +} + func BuildVirtualWorkspace( cfg *rest.Config, rootPathPrefix string, ) ([]rootapiserver.NamedVirtualWorkspace, error) { - if !strings.HasSuffix(rootPathPrefix, "/") { - rootPathPrefix += "/" - } - // Build clients that talk directly to the local shard. localCfg := rest.CopyConfig(cfg) vw := &handler.VirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, requestContext context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, prefixToStrip, ok := digestURL(urlPath, rootPathPrefix) - if !ok { - return false, "", requestContext - } - - if cluster.Wildcard { - return false, "", requestContext - } - - completedContext = genericapirequest.WithCluster(requestContext, cluster) - return true, prefixToStrip, completedContext - }), + RootPathResolver: framework.RootPathResolverFunc(resolveRootPath), Authorizer: authorizer.AuthorizerFunc(func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { if a.GetUser() == nil { return authorizer.DecisionDeny, "no user info", nil @@ -84,10 +72,22 @@ func BuildVirtualWorkspace( } return []rootapiserver.NamedVirtualWorkspace{ - {Name: migratingworkspaces.VirtualWorkspaceName, VirtualWorkspace: vw}, + { + Name: migratingworkspaces.VirtualWorkspaceName, + Patterns: []string{pathPattern(rootPathPrefix)}, + VirtualWorkspace: vw, + }, }, nil } +// resolveRootPath accepts requests for a specific cluster only. +func resolveRootPath(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + if cluster := genericapirequest.ClusterFrom(ctx); cluster == nil || cluster.Wildcard { + return false, "", ctx + } + return true, match.PrefixToStrip(), ctx +} + // migratingHandler is a narrow reverse proxy that forwards the // LogicalClusterDump endpoint to the local shard. Anything else under the // VW prefix is rejected. @@ -147,45 +147,3 @@ func (h *migratingHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { logger.V(2).Info("migrating VW proxying dump", "cluster", cluster.Name, "url", outReq.URL.String()) h.proxy.ServeHTTP(w, outReq) } - -func digestURL(urlPath, rootPathPrefix string) ( - cluster genericapirequest.Cluster, - logicalPath string, - accepted bool, -) { - if !strings.HasPrefix(urlPath, rootPathPrefix) { - return genericapirequest.Cluster{}, "", false - } - withoutRootPathPrefix := strings.TrimPrefix(urlPath, rootPathPrefix) - - // Incoming requests look like: - // /services/migratingworkspaces/clusters//apis/... - // └─── withoutRootPathPrefix - if !strings.HasPrefix(withoutRootPathPrefix, "clusters/") { - return genericapirequest.Cluster{}, "", false - } - - withoutClustersPrefix := strings.TrimPrefix(withoutRootPathPrefix, "clusters/") - parts := strings.SplitN(withoutClustersPrefix, "/", 2) - clusterPath := logicalcluster.NewPath(parts[0]) - - realPath := "/" - if len(parts) > 1 { - realPath += parts[1] - } - - cluster = genericapirequest.Cluster{} - if clusterPath == logicalcluster.Wildcard { - cluster.Wildcard = true - } else { - var ok bool - cluster.Name, ok = clusterPath.Name() - if !ok { - return genericapirequest.Cluster{}, "", false - } - } - - // Strip the entire prefix including the cluster path, leaving just the - // kube API path (e.g. /api/v1/configmaps). - return cluster, strings.TrimSuffix(urlPath, realPath), true -} diff --git a/pkg/virtual/replication/builder/build.go b/pkg/virtual/replication/builder/build.go index e7460872541..f982942f712 100644 --- a/pkg/virtual/replication/builder/build.go +++ b/pkg/virtual/replication/builder/build.go @@ -19,11 +19,9 @@ package builder import ( "context" "errors" - "fmt" - "strings" + "path" "k8s.io/apiserver/pkg/authorization/authorizer" - genericapirequest "k8s.io/apiserver/pkg/endpoints/request" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" @@ -31,7 +29,6 @@ import ( kcpdynamic "github.com/kcp-dev/client-go/dynamic" kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" - "github.com/kcp-dev/logicalcluster/v3" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" @@ -49,6 +46,22 @@ import ( "github.com/kcp-dev/kcp/pkg/virtual/replication/controllers/apireconciler" ) +const ( + // sliceClusterWildcard is the cluster containing the ClusterCachedResourceEndpointSlice. + sliceClusterWildcard = "sliceCluster" + // sliceNameWildcard is the ClusterCachedResourceEndpointSlice name. + sliceNameWildcard = "sliceName" +) + +// pathPattern returns the mux pattern of the virtual workspace below rootPathPrefix. +func pathPattern(rootPathPrefix string) string { + // /services/replication///clusters//... + // `slice cluster` is the provider cluster, containing the *EndpointSlice. + // `slice name` is the name of the *EndpointSlice. + // `cluster` is the consumer cluster. + return path.Join(rootPathPrefix, "{sliceCluster}/{sliceName}/clusters/{cluster}") +} + func BuildVirtualWorkspace( cfg *rest.Config, rootPathPrefix string, @@ -58,24 +71,11 @@ func BuildVirtualWorkspace( localKcpInformers kcpinformers.SharedInformerFactory, globalKcpInformers kcpinformers.SharedInformerFactory, ) ([]rootapiserver.NamedVirtualWorkspace, error) { - if !strings.HasSuffix(rootPathPrefix, "/") { - rootPathPrefix += "/" - } - readyCh := make(chan struct{}) clusterCachedResourceContent := &virtualworkspacesdynamic.DynamicVirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, ctx context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, apiDomain, prefixToStrip, ok := digestURL(urlPath, rootPathPrefix) - if !ok { - return false, "", ctx - } - - completedContext = genericapirequest.WithCluster(ctx, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomain) - return true, prefixToStrip, completedContext - }), - Authorizer: newAuthorizer(kubeClusterClient, localKcpInformers, globalKcpInformers), + RootPathResolver: framework.RootPathResolverFunc(resolveRootPath), + Authorizer: newAuthorizer(kubeClusterClient, localKcpInformers, globalKcpInformers), ReadyChecker: framework.ReadyFunc(func() error { select { case <-readyCh: @@ -186,73 +186,14 @@ func BuildVirtualWorkspace( } return []rootapiserver.NamedVirtualWorkspace{ - {Name: replication.VirtualWorkspaceName, VirtualWorkspace: clusterCachedResourceContent}, + {Name: replication.VirtualWorkspaceName, Patterns: []string{pathPattern(rootPathPrefix)}, VirtualWorkspace: clusterCachedResourceContent}, }, nil } -func digestURL(urlPath, rootPathPrefix string) ( - cluster genericapirequest.Cluster, - domainKey dynamiccontext.APIDomainKey, - logicalPath string, - accepted bool, -) { - if !strings.HasPrefix(urlPath, rootPathPrefix) { - return genericapirequest.Cluster{}, "", "", false - } - - // Incoming requests to this virtual workspace will look like: - // /services/apiexport/root:org:ws//clusters/*/api/v1/configmaps - // └────────────────────────┐ - // Where the withoutRootPathPrefix starts here: ┘ - withoutRootPathPrefix := strings.TrimPrefix(urlPath, rootPathPrefix) - - parts := strings.SplitN(withoutRootPathPrefix, "/", 3) - if len(parts) < 3 { - return genericapirequest.Cluster{}, "", "", false - } - - endpointSliceClusterName, endpointSliceName := parts[0], parts[1] - if endpointSliceClusterName == "" { - return genericapirequest.Cluster{}, "", "", false - } - if endpointSliceName == "" { - return genericapirequest.Cluster{}, "", "", false - } - - realPath := "/" - if len(parts) > 2 { - realPath += parts[2] - } - - // /services/apiexport/root:org:ws//clusters/*/api/v1/configmaps - // ┌──────────────────────────────────────────────┘ - // We are now here: ───┘ - // Now, we parse out the logical cluster. - if !strings.HasPrefix(realPath, "/clusters/") { - return genericapirequest.Cluster{}, "", "", false - } - - withoutClustersPrefix := strings.TrimPrefix(realPath, "/clusters/") - parts = strings.SplitN(withoutClustersPrefix, "/", 2) - path := logicalcluster.NewPath(parts[0]) - realPath = "/" - if len(parts) > 1 { - realPath += parts[1] - } - - cluster = genericapirequest.Cluster{} - if path == logicalcluster.Wildcard { - cluster.Wildcard = true - } else { - var ok bool - cluster.Name, ok = path.Name() - if !ok { - return genericapirequest.Cluster{}, "", "", false - } - } - - key := fmt.Sprintf("%s/%s", endpointSliceClusterName, endpointSliceName) - return cluster, dynamiccontext.APIDomainKey(key), strings.TrimSuffix(urlPath, realPath), true +// resolveRootPath keys the API domain by /. +func resolveRootPath(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + key := dynamiccontext.APIDomainKey(match.Value(sliceClusterWildcard) + "/" + match.Value(sliceNameWildcard)) + return true, match.PrefixToStrip(), dynamiccontext.WithAPIDomainKey(ctx, key) } func newAuthorizer( diff --git a/pkg/virtual/replication/builder/build_test.go b/pkg/virtual/replication/builder/build_test.go index 333393088b3..6dfd77a83da 100644 --- a/pkg/virtual/replication/builder/build_test.go +++ b/pkg/virtual/replication/builder/build_test.go @@ -21,59 +21,32 @@ import ( "github.com/stretchr/testify/require" - genericapirequest "k8s.io/apiserver/pkg/endpoints/request" - - "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/context" + "github.com/kcp-dev/virtual-workspace-framework/framework" + dynamiccontext "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/context" ) -func TestDigestUrl(t *testing.T) { +func TestPathPattern(t *testing.T) { t.Parallel() - rootPathPrefix := "/services/replication/" - testCases := []struct { - urlPath string - expectedAccept bool - expectedCluster genericapirequest.Cluster - expectedKey context.APIDomainKey - expectedLogicalPath string - }{ - { - urlPath: "/services/replication/my-cluster/my-clustercachedresource/clusters/my-cluster/apis", - expectedAccept: true, - expectedKey: "my-cluster/my-clustercachedresource", - expectedCluster: genericapirequest.Cluster{Name: "my-cluster", Wildcard: false}, - expectedLogicalPath: "/services/replication/my-cluster/my-clustercachedresource/clusters/my-cluster", - }, - { - urlPath: "/services/replication/my-cluster/my-clustercachedresource/clusters/my-cluster", - expectedAccept: true, - expectedKey: "my-cluster/my-clustercachedresource", - expectedCluster: genericapirequest.Cluster{Name: "my-cluster", Wildcard: false}, - expectedLogicalPath: "/services/replication/my-cluster/my-clustercachedresource/clusters/my-cluster", - }, - { - urlPath: "/services/replication/my-cluster/my-clustercachedresource/clusters/other-cluster", - expectedAccept: true, - expectedKey: "my-cluster/my-clustercachedresource", - expectedCluster: genericapirequest.Cluster{Name: "other-cluster", Wildcard: false}, - expectedLogicalPath: "/services/replication/my-cluster/my-clustercachedresource/clusters/other-cluster", - }, - { - urlPath: "/services/replication/my-cluster/my-clustercachedresource/clusters", - expectedAccept: false, - expectedKey: "", - expectedCluster: genericapirequest.Cluster{}, - expectedLogicalPath: "", - }, - } - for _, tc := range testCases { - t.Run(tc.urlPath, func(t *testing.T) { - t.Parallel() - clusterName, key, logicalPath, accepted := digestURL(tc.urlPath, rootPathPrefix) - require.Equal(t, tc.expectedAccept, accepted, "Accepted should match expected value") - require.Equal(t, tc.expectedKey, key, "Key should match expected value") - require.Equal(t, tc.expectedCluster, clusterName, "cluster name should match expected value") - require.Equal(t, tc.expectedLogicalPath, logicalPath, "LogicalPath should match expected value") - }) + require.Equal(t, + "/services/replication/{sliceCluster}/{sliceName}/clusters/{cluster}", + pathPattern("/services/replication/")) +} + +func TestResolveRootPath(t *testing.T) { + t.Parallel() + + // /services/replication///clusters//... + values := map[string]string{ + sliceClusterWildcard: "slice-cluster", + sliceNameWildcard: "slice-name", + framework.ClusterWildcard: "target-cluster", } + match := framework.NewPathMatch("/services/replication/slice-cluster/slice-name/clusters/target-cluster/apis", "apis", values) + + accepted, prefixToStrip, ctx := resolveRootPath(t.Context(), match) + + require.True(t, accepted) + require.Equal(t, "/services/replication/slice-cluster/slice-name/clusters/target-cluster", prefixToStrip) + require.Equal(t, dynamiccontext.APIDomainKey("slice-cluster/slice-name"), dynamiccontext.APIDomainKeyFrom(ctx)) } diff --git a/pkg/virtual/terminatingworkspaces/builder/build.go b/pkg/virtual/terminatingworkspaces/builder/build.go index 77e1225810d..a402dff580b 100644 --- a/pkg/virtual/terminatingworkspaces/builder/build.go +++ b/pkg/virtual/terminatingworkspaces/builder/build.go @@ -68,6 +68,13 @@ const ( workspaceContentName = terminatingworkspaces.VirtualWorkspaceName + "-workspace-content" ) +const terminatorWildcard = "terminator" + +// pathPattern returns the mux pattern shared by the virtual workspaces below rootPathPrefix. +func pathPattern(rootPathPrefix string) string { + return path.Join(rootPathPrefix, "{terminator}/clusters/{cluster}") +} + // BuildVirtualWorkspace builds the terminating-workspaces virtual workspaces. // // externalLogicalClusterAdminClient is used for SubjectAccessReview calls @@ -83,10 +90,6 @@ func BuildVirtualWorkspace( kubeClusterClient, externalLogicalClusterAdminClient kcpkubernetesclientset.ClusterInterface, wildcardKcpInformers, cachedKcpInformers kcpinformers.SharedInformerFactory, ) ([]rootapiserver.NamedVirtualWorkspace, error) { - if !strings.HasSuffix(rootPathPrefix, "/") { - rootPathPrefix += "/" - } - sarKubeClusterClient := externalLogicalClusterAdminClient if sarKubeClusterClient == nil { sarKubeClusterClient = kubeClusterClient @@ -114,22 +117,8 @@ func BuildVirtualWorkspace( Options: delegated.Options{DenyCacheTTL: 5 * time.Second}, }) wildcardLogicalClusters := &virtualworkspacesdynamic.DynamicVirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, requestContext context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, apiDomain, prefixToStrip, ok := digestUrl(urlPath, rootPathPrefix) - if !ok { - return false, "", requestContext - } - - if !cluster.Wildcard { - // this virtual workspace requires that a wildcard be provided - return false, "", requestContext - } - - completedContext = genericapirequest.WithCluster(requestContext, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomain) - return true, prefixToStrip, completedContext - }), - Authorizer: cachingAuthorizer, + RootPathResolver: framework.RootPathResolverFunc(resolveWildcardLogicalClusters), + Authorizer: cachingAuthorizer, ReadyChecker: framework.ReadyFunc(func() error { return nil }), @@ -145,28 +134,8 @@ func BuildVirtualWorkspace( } logicalClusters := &virtualworkspacesdynamic.DynamicVirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, requestContext context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, apiDomain, prefixToStrip, ok := digestUrl(urlPath, rootPathPrefix) - if !ok { - return false, "", requestContext - } - - if cluster.Wildcard { - // this virtual workspace requires that a specific cluster be provided - return false, "", requestContext - } - - // this delegating server only works for logicalclusters.core.kcp.io and discovery calls - resourceURL := strings.TrimPrefix(urlPath, prefixToStrip) - if !isLogicalClusterRequest(resourceURL) && !isDiscoveryRequest(resourceURL) { - return false, "", requestContext - } - - completedContext = genericapirequest.WithCluster(requestContext, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomain) - return true, prefixToStrip, completedContext - }), - Authorizer: cachingAuthorizer, + RootPathResolver: framework.RootPathResolverFunc(resolveLogicalClusters), + Authorizer: cachingAuthorizer, ReadyChecker: framework.ReadyFunc(func() error { return nil }), @@ -183,32 +152,8 @@ func BuildVirtualWorkspace( workspaceContentReadyCh := make(chan struct{}) workspaceContent := &handler.VirtualWorkspace{ - RootPathResolver: framework.RootPathResolverFunc(func(urlPath string, requestContext context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - cluster, apiDomain, prefixToStrip, ok := digestUrl(urlPath, rootPathPrefix) - if !ok { - return false, "", requestContext - } - - if cluster.Wildcard { - // content access requires a specific cluster - return false, "", requestContext - } - - // this proxying server does not handle requests for logicalclusters.core.kcp.io - resourceURL := strings.TrimPrefix(urlPath, prefixToStrip) - if isLogicalClusterRequest(resourceURL) { - return false, "", requestContext - } - - // in this case since we're proxying and not consuming this request we *do not* want to strip - // the cluster prefix - prefixToStrip = strings.TrimSuffix(prefixToStrip, cluster.Name.Path().RequestPath()) - - completedContext = genericapirequest.WithCluster(requestContext, cluster) - completedContext = dynamiccontext.WithAPIDomainKey(completedContext, apiDomain) - return true, prefixToStrip, completedContext - }), - Authorizer: cachingAuthorizer, + RootPathResolver: framework.RootPathResolverFunc(resolveWorkspaceContent), + Authorizer: cachingAuthorizer, ReadyChecker: framework.ReadyFunc(func() error { select { case <-workspaceContentReadyCh: @@ -352,13 +297,55 @@ func BuildVirtualWorkspace( }), } + patterns := []string{pathPattern(rootPathPrefix)} return []rootapiserver.NamedVirtualWorkspace{ - {Name: wildcardLogicalClustersName, VirtualWorkspace: wildcardLogicalClusters}, - {Name: logicalClustersName, VirtualWorkspace: logicalClusters}, - {Name: workspaceContentName, VirtualWorkspace: workspaceContent}, + {Name: wildcardLogicalClustersName, Patterns: patterns, VirtualWorkspace: wildcardLogicalClusters}, + {Name: logicalClustersName, Patterns: patterns, VirtualWorkspace: logicalClusters}, + {Name: workspaceContentName, Patterns: patterns, VirtualWorkspace: workspaceContent}, }, nil } +// withTerminatorDomain keys the API domain by the terminator. +func withTerminatorDomain(ctx context.Context, match framework.PathMatch) context.Context { + return dynamiccontext.WithAPIDomainKey(ctx, dynamiccontext.APIDomainKey(match.Value(terminatorWildcard))) +} + +// resolveWildcardLogicalClusters accepts wildcard requests only. +func resolveWildcardLogicalClusters(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + if cluster := genericapirequest.ClusterFrom(ctx); cluster == nil || !cluster.Wildcard { + return false, "", ctx + } + return true, match.PrefixToStrip(), withTerminatorDomain(ctx, match) +} + +// resolveLogicalClusters accepts logicalclusters.core.kcp.io and discovery +// requests for a specific cluster. +func resolveLogicalClusters(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + if cluster := genericapirequest.ClusterFrom(ctx); cluster == nil || cluster.Wildcard { + return false, "", ctx + } + resourceURL := "/" + match.Trail + if !isLogicalClusterRequest(resourceURL) && !isDiscoveryRequest(resourceURL) { + return false, "", ctx + } + return true, match.PrefixToStrip(), withTerminatorDomain(ctx, match) +} + +// resolveWorkspaceContent accepts requests for a specific cluster +// except logicalclusters.core.kcp.io. +// The cluster segment is kept since the request is proxied, not consumed. +func resolveWorkspaceContent(ctx context.Context, match framework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + cluster := genericapirequest.ClusterFrom(ctx) + if cluster == nil || cluster.Wildcard { + return false, "", ctx + } + if isLogicalClusterRequest("/" + match.Trail) { + return false, "", ctx + } + prefixToStrip = strings.TrimSuffix(match.PrefixToStrip(), cluster.Name.Path().RequestPath()) + return true, prefixToStrip, withTerminatorDomain(ctx, match) +} + // resolveTerminatorWorkspaceType resolves the WorkspaceType referenced by a // terminator. It returns: // @@ -398,63 +385,6 @@ func isLogicalClusterRequest(path string) bool { return info.IsResourceRequest && info.APIGroup == corev1alpha1.SchemeGroupVersion.Group && info.Resource == "logicalclusters" } -func digestUrl(urlPath, rootPathPrefix string) ( - cluster genericapirequest.Cluster, - key dynamiccontext.APIDomainKey, - logicalPath string, - accepted bool, -) { - if !strings.HasPrefix(urlPath, rootPathPrefix) { - return genericapirequest.Cluster{}, dynamiccontext.APIDomainKey(""), "", false - } - withoutRootPathPrefix := strings.TrimPrefix(urlPath, rootPathPrefix) - - // Incoming requests to this virtual workspace will look like: - // /services/terminatingworkspace//clusters//apis/core.kcp.io/v1alpha1/logicalclusters - // └───────────┐ - // Where the withoutRootPathPrefix starts here: ┘ - parts := strings.SplitN(withoutRootPathPrefix, "/", 2) - if len(parts) < 2 { - return genericapirequest.Cluster{}, "", "", false - } - - terminatorName := parts[0] - if terminatorName == "" { - return genericapirequest.Cluster{}, "", "", false - } - - realPath := "/" + parts[1] - - // /services/terminatingworkspace//clusters//apis/core.kcp.io/v1alpha1/logicalclusters - // ┌─────────────────────────────┘ - // We are now here: ┘ - // Now, we parse out the logical cluster. - if !strings.HasPrefix(realPath, "/clusters/") { - return genericapirequest.Cluster{}, "", "", false // don't accept - } - - withoutClustersPrefix := strings.TrimPrefix(realPath, "/clusters/") - parts = strings.SplitN(withoutClustersPrefix, "/", 2) - logicalclusterPath := logicalcluster.NewPath(parts[0]) - realPath = "/" - if len(parts) > 1 { - realPath += parts[1] - } - - cluster = genericapirequest.Cluster{} - if logicalclusterPath == logicalcluster.Wildcard { - cluster.Wildcard = true - } else { - var ok bool - cluster.Name, ok = logicalclusterPath.Name() - if !ok { - return genericapirequest.Cluster{}, "", "", false - } - } - - return cluster, dynamiccontext.APIDomainKey(terminatorName), strings.TrimSuffix(urlPath, realPath), true -} - type singleResourceAPIDefinitionSetProvider struct { config genericapiserver.CompletedConfig dynamicClusterClient kcpdynamic.ClusterInterface diff --git a/pkg/virtual/terminatingworkspaces/builder/build_test.go b/pkg/virtual/terminatingworkspaces/builder/build_test.go index 872ff9bc7d5..7be86e8263a 100644 --- a/pkg/virtual/terminatingworkspaces/builder/build_test.go +++ b/pkg/virtual/terminatingworkspaces/builder/build_test.go @@ -17,6 +17,7 @@ limitations under the License. package builder import ( + "context" "fmt" "testing" @@ -24,15 +25,127 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/client-go/tools/cache" kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" + "github.com/kcp-dev/virtual-workspace-framework/framework" + dynamiccontext "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/context" "github.com/kcp-dev/kcp/pkg/indexers" ) +func TestPathPattern(t *testing.T) { + t.Parallel() + + require.Equal(t, + "/services/terminatingworkspaces/{terminator}/clusters/{cluster}", + pathPattern("/services/terminatingworkspaces/")) +} + +func TestResolveRootPath(t *testing.T) { + t.Parallel() + + const ( + terminatorName = "test-terminator" + clusterName = "abc" + ) + + // registration order, the first accepting resolver wins + resolvers := []struct { + name string + resolve framework.RootPathResolverFunc + }{ + {"wildcardLogicalClusters", resolveWildcardLogicalClusters}, + {"logicalClusters", resolveLogicalClusters}, + {"workspaceContent", resolveWorkspaceContent}, + } + + tests := map[string]struct { + cluster request.Cluster + trail string + wantResolver string + prefixToTrim string + }{ + "wildcard logicalclusters": { + cluster: request.Cluster{Wildcard: true}, + trail: "apis/core.kcp.io/v1alpha1/logicalclusters", + wantResolver: "wildcardLogicalClusters", + prefixToTrim: "/services/terminatingworkspaces/test-terminator/clusters/*", + }, + "wildcard other resource": { + cluster: request.Cluster{Wildcard: true}, + trail: "api/v1/configmaps", + wantResolver: "wildcardLogicalClusters", + prefixToTrim: "/services/terminatingworkspaces/test-terminator/clusters/*", + }, + "cluster logicalclusters": { + cluster: request.Cluster{Name: clusterName}, + trail: "apis/core.kcp.io/v1alpha1/logicalclusters/cluster", + wantResolver: "logicalClusters", + prefixToTrim: "/services/terminatingworkspaces/test-terminator/clusters/abc", + }, + "cluster discovery": { + cluster: request.Cluster{Name: clusterName}, + trail: "apis", + wantResolver: "logicalClusters", + prefixToTrim: "/services/terminatingworkspaces/test-terminator/clusters/abc", + }, + "cluster other resource keeps cluster segment": { + cluster: request.Cluster{Name: clusterName}, + trail: "api/v1/configmaps", + wantResolver: "workspaceContent", + prefixToTrim: "/services/terminatingworkspaces/test-terminator", + }, + "cluster workspace root keeps cluster segment": { + cluster: request.Cluster{Name: clusterName}, + trail: "", + wantResolver: "workspaceContent", + prefixToTrim: "/services/terminatingworkspaces/test-terminator", + }, + "no cluster": { + trail: "api/v1/configmaps", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + segment := tc.cluster.Name.String() + if tc.cluster.Wildcard { + segment = "*" + } + ctx := t.Context() + if segment != "" { + ctx = request.WithCluster(ctx, tc.cluster) + } + values := map[string]string{terminatorWildcard: terminatorName, framework.ClusterWildcard: segment} + // trailing slash when trail is empty + match := framework.NewPathMatch("/services/terminatingworkspaces/test-terminator/clusters/"+segment+"/"+tc.trail, tc.trail, values) + + var gotResolver, gotPrefix string + var gotCtx context.Context + for _, r := range resolvers { + accepted, prefixToTrim, ctx := r.resolve(ctx, match) + if accepted { + gotResolver, gotPrefix, gotCtx = r.name, prefixToTrim, ctx + break + } + } + + require.Equal(t, tc.wantResolver, gotResolver) + if tc.wantResolver == "" { + return + } + require.Equal(t, tc.prefixToTrim, gotPrefix) + require.Equal(t, dynamiccontext.APIDomainKey(terminatorName), dynamiccontext.APIDomainKeyFrom(gotCtx)) + }) + } +} + func TestResolveTerminatorWorkspaceType(t *testing.T) { t.Parallel() wst := func(cluster, name string) *tenancyv1alpha1.WorkspaceType { diff --git a/staging/src/github.com/kcp-dev/virtual-workspace-framework/architecture.md b/staging/src/github.com/kcp-dev/virtual-workspace-framework/architecture.md index 7dc098533a1..8ebec43c12e 100644 --- a/staging/src/github.com/kcp-dev/virtual-workspace-framework/architecture.md +++ b/staging/src/github.com/kcp-dev/virtual-workspace-framework/architecture.md @@ -1,7 +1,7 @@ # Virtual Workspace Apiserver Architecture The virtual workspace apiserver is based on k8s.io/apiserver and bundles multiple -delegated apiservers into a single apiserver. +delegated apiservers into a single apiserver. Usual k8s.io/apiserver delegation would mean a common filter chain and hence @@ -23,11 +23,43 @@ i.e. 5. independent discovery endpoints /apis. On the other hand, we want to keep using k8s.io/apiserver. To make this work with -the upper requirements, the handler chain will +the upper requirements, the handler chain will + + a) route the request to a virtual workspace by its path pattern + b) inject the virtual workspace into the context + c) rewrite the request path to remove the virtual workspace prefix. + +## Path patterns + +Every virtual workspace is registered with one or more http.ServeMux patterns +(`rootapiserver.NamedVirtualWorkspace.Patterns`), e.g. + + /services/apiexport/{exportCluster}/{exportName}/clusters/{cluster} + +The root apiserver registers each pattern twice, as is and with a trailing +`/{trail...}` wildcard, so the pattern matches with and without a kube-like +API path after it. Precedence between patterns is the ServeMux one: the +most specific pattern wins regardless of registration order, conflicting +patterns panic at startup. Requests with a non-canonical path (`//`, `/./`), +which the ServeMux would redirect, bypass the patterns and reach the +delegate untouched. + +The `{cluster}` wildcard (`framework.ClusterWildcard`) is resolved by the root +apiserver - `*` becomes the wildcard cluster, a valid logical cluster name is set +on the request context, anything else leaves the request unclaimed. Patterns +without `{cluster}` serve cluster-less requests. + +The virtual workspace's `RootPathResolver` then receives the context and a +`framework.PathMatch`: the request path, the matched wildcards and the trail. +It accepts or rejects the request, returns the prefix to strip (usually +`match.PrefixToStrip()`, the path up to the trail) and extends the context, +e.g. with the API domain key derived from the wildcards. + +Virtual workspaces may share a pattern. Only then are they tried in +registration order, the first accepting one claims the request. This lets e.g. the +initializingworkspaces virtual workspaces split one URL layout by wildcard vs. +specific cluster and by requested resource. - a) inject the virtual workspace into the context - b) rewrite the request path to remove the virtual workspace prefix. - The delegated apiserver will wrap the s.Handler's director (which usually decides which requests to pass to gorestful) and check for the virtual workspace name in the context. If it matches, the request is forwarded to the original director, and diff --git a/staging/src/github.com/kcp-dev/virtual-workspace-framework/framework/types.go b/staging/src/github.com/kcp-dev/virtual-workspace-framework/framework/types.go index 1a948579eef..9cb5ef19758 100644 --- a/staging/src/github.com/kcp-dev/virtual-workspace-framework/framework/types.go +++ b/staging/src/github.com/kcp-dev/virtual-workspace-framework/framework/types.go @@ -18,6 +18,8 @@ package framework import ( "context" + "net/http" + "strings" "k8s.io/apiserver/pkg/authorization/authorizer" genericapiserver "k8s.io/apiserver/pkg/server" @@ -25,30 +27,110 @@ import ( "github.com/kcp-dev/virtual-workspace-framework/pkg/admission" ) -// RootPathResolverFunc is the type of a function that, based on the URL path of a request, -// returns whether the request should be accepted and served by a given VirtuaWorkspace. -// When it returns `true`, it will also set the VirtualWorkspace name in the request context, -// as well as the prefix that should be removed from the request URL Path before forwarding -// the request to the given VirtualWorkspace delegated APIServer. -type RootPathResolverFunc func(urlPath string, context context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) +const ( + // ClusterWildcard names the http.ServeMux wildcard of a virtual workspace path pattern that holds the logical cluster. + ClusterWildcard = "cluster" + // TrailWildcard names the http.ServeMux wildcard holding the kube-like API path after the virtual workspace path pattern. + TrailWildcard = "trail" +) + +// PathMatch is a parsed request path. +// +// Virtual workspaces registers in the framework with [http.ServeMux] patterns like this: +// +// /services/apiexport/{exportCluster}/{exportName}/clusters/{cluster} +// +// The framework registers each pattern once by itself and once with a trail wildcard. +// +// A request with this path and the above pattern: +// +// /services/apiexport/root:providers/widgets/clusters/root:org/apis/example.io/v1/widgets +// +// Is parsed into a PathMatch like this: +// +// Path: "/services/apiexport/root:providers/widgets/clusters/root:org/apis/example.io/v1/widgets" +// Trail: "apis/example.io/v1/widgets" +// Value("exportCluster"): "root:providers" +// Value("exportName"): "widgets" +// Value(ClusterWildcard): "root:org" +// PrefixToStrip(): "/services/apiexport/root:providers/widgets/clusters/root:org" +// +// For tests the [NewPathMatch] function can be used to build a valid PathMatch. +type PathMatch struct { + // Path is the full request path. + Path string + // Trail is the kube-like API path after the pattern, "" if none. + Trail string + + value func(name string) string +} -func (r RootPathResolverFunc) ResolveRootPath(urlPath string, context context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { - return r(urlPath, context) +// NewPathMatch returns a PathMatch with the given wildcard values. +func NewPathMatch(path, trail string, values map[string]string) PathMatch { + return PathMatch{ + Path: path, + Trail: trail, + value: func(name string) string { + return values[name] + }, + } +} + +// PathMatchFromRequest returns the PathMatch of a request routed by a +// http.ServeMux pattern with a trailing TrailWildcard. +func PathMatchFromRequest(req *http.Request) PathMatch { + return PathMatch{ + Path: req.URL.Path, + Trail: req.PathValue(TrailWildcard), + value: req.PathValue, + } +} + +// Value returns the segment matched by the named wildcard of the pattern, +// "" if there is none. +func (m PathMatch) Value(name string) string { + if m.value == nil { + return "" + } + return m.value(name) +} + +// PrefixToStrip returns the path up to the trail, without the separator, +// the default prefix to strip before delegating to the virtual workspace. +// A path ending in the separator keeps it as the remaining path. +func (m PathMatch) PrefixToStrip() string { + if m.Trail == "" { + return strings.TrimSuffix(m.Path, "/") + } + return strings.TrimSuffix(m.Path, "/"+m.Trail) +} + +// RootPathResolverFunc adapts a function to RootPathResolver. +type RootPathResolverFunc func(ctx context.Context, match PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) + +func (r RootPathResolverFunc) ResolveRootPath(ctx context.Context, match PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { + return r(ctx, match) } var _ RootPathResolver = RootPathResolverFunc(nil) type RootPathResolver interface { - // ResolveRootPath returns whether the request should be accepted and served by a given VirtualWorkspace. If accepted, - // the prefixToStrip is the prefix that is in-front of the kube-like API surface (including `/clusters/`). E.g. for + // ResolveRootPath returns whether the request should be accepted and served by a given VirtualWorkspace. + // + // match holds the wildcards of the virtual workspace's path pattern; + // the logical cluster named by ClusterWildcard is already set on ctx. + // + // If accepted, prefixToStrip is the prefix in front of the kube-like API + // surface (including `/clusters/`), usually match.PrefixToStrip(). + // E.g. for // // /services/initializingworkspaces//clusters//apis/workload.kcp.io/v1alpha1/synctargets // // the prefixToStrip is `/services/initializingworkspaces/`. // - // Depending on virtual workspace type, the returned context holds e.g. the APIDomainKey, the logical cluster or - // other values like scope, if that is part of the URL path. - ResolveRootPath(urlPath string, context context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) + // Depending on virtual workspace type, the returned context holds e.g. the APIDomainKey + // or other values like scope, if that is part of the URL path. + ResolveRootPath(ctx context.Context, match PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) } // ReadyFunc is the type of readiness check functions exposed by types diff --git a/staging/src/github.com/kcp-dev/virtual-workspace-framework/framework/types_test.go b/staging/src/github.com/kcp-dev/virtual-workspace-framework/framework/types_test.go new file mode 100644 index 00000000000..408fb6b21f5 --- /dev/null +++ b/staging/src/github.com/kcp-dev/virtual-workspace-framework/framework/types_test.go @@ -0,0 +1,55 @@ +/* +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 framework + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPathMatchPrefixToStrip(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + path string + trail string + want string + }{ + "trail": {path: "/services/admin/apis/v1", trail: "apis/v1", want: "/services/admin"}, + "trail with trailing slash": {path: "/services/admin/apis/v1/", trail: "apis/v1/", want: "/services/admin"}, + "no trail": {path: "/services/admin", want: "/services/admin"}, + "no trail, trailing slash": {path: "/services/admin/", want: "/services/admin"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, PathMatch{Path: tc.path, Trail: tc.trail}.PrefixToStrip()) + }) + } +} + +func TestPathMatchValue(t *testing.T) { + t.Parallel() + + require.Empty(t, PathMatch{Path: "/x"}.Value("a")) + + m := NewPathMatch("/x/1", "", map[string]string{"a": "1"}) + require.Equal(t, "1", m.Value("a")) + require.Empty(t, m.Value("b")) +} diff --git a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/doc.go b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/doc.go index b3352ee9d09..e9da66d26ae 100644 --- a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/doc.go +++ b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/doc.go @@ -22,14 +22,14 @@ limitations under the License. // // APIs served by the virtual workspace are partitioned in API domains. Each API domain // is defined by an api domain key and will expose a set of APIs -// that will be served at a dedicated URL path. API domain key is generally extracted from the URL sub-path -// where related APIs will be exposed. +// that will be served at a dedicated URL path. API domain key is generally extracted from the +// wildcards of the path pattern the virtual workspace is registered with. // // APIs are served by an apiserver.DynamicAPIServer which is setup in the virtual workspace Register() method. // // # Typical operation // -// The RootPathResolver() method would usually interpret the URL sub-path, extract the API domain key from it, +// The RootPathResolver() method would usually extract the API domain key from the matched path pattern wildcards, // and pass the request to the apiserver.DynamicAPIServer, along with the API domain key, if the API domain key contains APIs. // // The BootstrapAPISetManagement() method would typically create and initialize an apidefinition.APIDefinitionSetGetter, returned as a result, diff --git a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/example_test.go b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/example_test.go index 5915c1aae40..4bce3460867 100644 --- a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/example_test.go +++ b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/example_test.go @@ -37,21 +37,22 @@ func Example() { readyCh := make(chan struct{}) var _ = dynamic.DynamicVirtualWorkspace{ - RootPathResolver: virtualframework.RootPathResolverFunc(func(urlPath string, requestContext context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) { + RootPathResolver: virtualframework.RootPathResolverFunc(func(ctx context.Context, match virtualframework.PathMatch) (accepted bool, prefixToStrip string, completedContext context.Context) { if someAPIDefinitionSetGetter == nil { // If the APIDefinitionSetGetter is not initialized, don't accept the request return } + // Extract the API domain key from the wildcards of the path pattern + // the virtual workspace was registered with, e.g. match.Value("domain"). var apiDomainKey dynamiccontext.APIDomainKey - // Resolve the request root path and extract the API domain key from it - - // If the root path doesn't start by the right prefix or doesn't contain the API domain key, + // If the wildcards don't yield an API domain key, // just don't accept the request and return. // Add the apiDomainKey to the request context before passing the request to the virtual workspace APIServer - completedContext = dynamiccontext.WithAPIDomainKey(requestContext, apiDomainKey) + completedContext = dynamiccontext.WithAPIDomainKey(ctx, apiDomainKey) + prefixToStrip = match.PrefixToStrip() accepted = true return }), diff --git a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/config.go b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/config.go index c8974f80469..83e47818375 100644 --- a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/config.go +++ b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/config.go @@ -25,8 +25,18 @@ import ( "github.com/kcp-dev/virtual-workspace-framework/framework" ) +// NamedVirtualWorkspace is a virtual workspace served at a path pattern. type NamedVirtualWorkspace struct { Name string + // Patterns are the http.ServeMux patterns of the paths the virtual + // workspace serves, e.g. + // /services/apiexport/{exportCluster}/{exportName}/clusters/{cluster}. + // At least one is required. A pattern must not end in a wildcard + // like {trail...}: the framework registers each pattern as is and with + // a trailing /{trail...} for the kube-like API path. + // The framework.ClusterWildcard, if present, sets the logical cluster. + // Virtual workspaces sharing a pattern are tried in order. + Patterns []string framework.VirtualWorkspace } diff --git a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/server.go b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/server.go index f747aa55e38..a0537d91400 100644 --- a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/server.go +++ b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/server.go @@ -21,6 +21,7 @@ import ( "fmt" "net/http" "net/url" + "path" "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -35,8 +36,10 @@ import ( "k8s.io/apiserver/pkg/warning" componentbaseversion "k8s.io/component-base/version" + "github.com/kcp-dev/logicalcluster/v3" "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" + "github.com/kcp-dev/virtual-workspace-framework/framework" virtualcontext "github.com/kcp-dev/virtual-workspace-framework/pkg/context" "github.com/kcp-dev/virtual-workspace-framework/pkg/hops" ) @@ -64,6 +67,10 @@ type Server struct { } func NewServer(c CompletedConfig, delegationTarget genericapiserver.DelegationTarget) (*Server, error) { + if err := validatePatterns(c.Extra.VirtualWorkspaces); err != nil { + return nil, err + } + delegateAPIServer := delegationTarget for _, vw := range c.Extra.VirtualWorkspaces { var err error @@ -122,39 +129,143 @@ func getRootHandlerChain(c CompletedConfig, delegateAPIServer genericapiserver.D delegateAfterDefaultHandlerChain := genericapiserver.DefaultBuildHandlerChain( hops.WithRequestHops(auditAnnotationWrapper), c.Generic.Config) + router := newPatternRouter(c.Extra.VirtualWorkspaces, delegateAfterDefaultHandlerChain) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - requestContext := req.Context() // detect old kubectl plugins and inject warning headers if req.UserAgent() == "Go-http-client/2.0" { // TODO(sttts): in the future compare the plugin version to the server version and warn outside of skew compatibility guarantees. - warning.AddWarning(requestContext, "", + warning.AddWarning(req.Context(), "", fmt.Sprintf("You are using an old kubectl-kcp plugin. Please update to a version matching the kcp server version %q.", componentbaseversion.Get().GitVersion)) } - for _, vw := range c.Extra.VirtualWorkspaces { - if accepted, prefixToStrip, completedContext := vw.ResolveRootPath(req.URL.Path, requestContext); accepted { - req.URL.Path = strings.TrimPrefix(req.URL.Path, prefixToStrip) - newURL, err := url.Parse(req.URL.String()) - if err != nil { - responsewriters.ErrorNegotiated( - apierrors.NewInternalError(fmt.Errorf("unable to resolve %s, err %w", req.URL.Path, err)), - errorCodecs, schema.GroupVersion{}, - w, req) - return - } - req.URL = newURL + router.ServeHTTP(w, req) + }) + } +} - cluster := request.ClusterFrom(completedContext) - if cluster != nil && !cluster.Name.Empty() { - completedContext = context.WithValue(completedContext, auditClusterKey, cluster) - } +// newPatternRouter routes requests by the virtual workspaces' path patterns. +// Requests matching no pattern go to delegate unclaimed. +// Requests with a non-canonical path bypass the mux, which would redirect +// them, and go to delegate untouched. +// Panics on malformed or conflicting patterns. +func newPatternRouter(vws []NamedVirtualWorkspace, delegate http.Handler) http.Handler { + mux := http.NewServeMux() + mux.Handle("/", delegate) + for pattern, vws := range byPattern(vws) { + handler := newResolveHandler(vws, delegate) + mux.Handle(pattern, handler) + mux.Handle(pattern+"/{"+framework.TrailWildcard+"...}", handler) + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if escaped := req.URL.EscapedPath(); cleanPath(escaped) != escaped { + delegate.ServeHTTP(w, req) + return + } + mux.ServeHTTP(w, req) + }) +} - req = req.WithContext(virtualcontext.WithVirtualWorkspaceName(completedContext, vw.Name)) +// cleanPath is http.ServeMux's path canonicalization: path.Clean keeping a +// trailing slash. +func cleanPath(p string) string { + if p == "" { + return "/" + } + if p[0] != '/' { + p = "/" + p + } + np := path.Clean(p) + if p[len(p)-1] == '/' && np != "/" { + np += "/" + } + return np +} - break - } +// validatePatterns rejects virtual workspaces without a path pattern, +// which would never be routed to. +func validatePatterns(vws []NamedVirtualWorkspace) error { + for _, vw := range vws { + if len(vw.Patterns) == 0 { + return fmt.Errorf("virtual workspace %q has no path patterns", vw.Name) + } + } + return nil +} + +// byPattern groups virtual workspaces by path pattern, +// preserving registration order within a pattern. +func byPattern(vws []NamedVirtualWorkspace) map[string][]NamedVirtualWorkspace { + grouped := make(map[string][]NamedVirtualWorkspace, len(vws)) + for _, vw := range vws { + for _, pattern := range vw.Patterns { + grouped[pattern] = append(grouped[pattern], vw) + } + } + return grouped +} + +// newResolveHandler resolves the cluster wildcard, if the pattern has one, +// and lets the first accepting virtual workspace claim the request before delegating. +// Requests no virtual workspace accepts are delegated unclaimed. +func newResolveHandler(vws []NamedVirtualWorkspace, delegate http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + if segment := req.PathValue(framework.ClusterWildcard); segment != "" { + cluster, ok := clusterFromSegment(segment) + if !ok { + delegate.ServeHTTP(w, req) + return } - delegateAfterDefaultHandlerChain.ServeHTTP(w, req) - }) + ctx = request.WithCluster(ctx, cluster) + req = req.WithContext(ctx) + } + + match := framework.PathMatchFromRequest(req) + + for _, vw := range vws { + accepted, prefixToStrip, completedContext := vw.ResolveRootPath(ctx, match) + if !accepted { + continue + } + + req.URL.Path = strings.TrimPrefix(req.URL.Path, prefixToStrip) + newURL, err := url.Parse(req.URL.String()) + if err != nil { + responsewriters.ErrorNegotiated( + apierrors.NewInternalError(fmt.Errorf("unable to resolve %s, err %w", req.URL.Path, err)), + errorCodecs, schema.GroupVersion{}, + w, req) + return + } + req.URL = newURL + + if cluster := request.ClusterFrom(completedContext); cluster != nil && !cluster.Name.Empty() { + completedContext = context.WithValue(completedContext, auditClusterKey, cluster) + } + + req = req.WithContext(virtualcontext.WithVirtualWorkspaceName(completedContext, vw.Name)) + break + } + + delegate.ServeHTTP(w, req) + }) +} + +// clusterFromSegment parses a cluster path segment, +// either the wildcard or a logical cluster name. +func clusterFromSegment(segment string) (request.Cluster, bool) { + path := logicalcluster.NewPath(segment) + if path == logicalcluster.Wildcard { + return request.Cluster{Wildcard: true}, true + } + if !path.IsValid() { + return request.Cluster{}, false + } + name, ok := path.Name() + if !ok { + return request.Cluster{}, false } + return request.Cluster{Name: name}, true } diff --git a/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/server_test.go b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/server_test.go new file mode 100644 index 00000000000..1570333aa71 --- /dev/null +++ b/staging/src/github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver/server_test.go @@ -0,0 +1,269 @@ +/* +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 rootapiserver + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "k8s.io/apiserver/pkg/endpoints/request" + + "github.com/kcp-dev/logicalcluster/v3" + "github.com/kcp-dev/virtual-workspace-framework/framework" + virtualcontext "github.com/kcp-dev/virtual-workspace-framework/pkg/context" +) + +// resolverVW is a VirtualWorkspace with only a RootPathResolver. +type resolverVW struct { + framework.VirtualWorkspace + resolve framework.RootPathResolverFunc +} + +func (v resolverVW) ResolveRootPath(ctx context.Context, match framework.PathMatch) (bool, string, context.Context) { + return v.resolve(ctx, match) +} + +func named(name string, resolve framework.RootPathResolverFunc, patterns ...string) NamedVirtualWorkspace { + return NamedVirtualWorkspace{ + Name: name, + Patterns: patterns, + VirtualWorkspace: resolverVW{resolve: resolve}, + } +} + +func acceptAll(ctx context.Context, match framework.PathMatch) (bool, string, context.Context) { + return true, match.PrefixToStrip(), ctx +} + +type claimed struct { + called bool + path string + vw string + cluster *request.Cluster +} + +func capture(got *claimed) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + vw, _ := virtualcontext.VirtualWorkspaceNameFrom(req.Context()) + *got = claimed{ + called: true, + path: req.URL.Path, + vw: vw, + cluster: request.ClusterFrom(req.Context()), + } + }) +} + +func TestPatternRouter(t *testing.T) { + t.Parallel() + + vws := []NamedVirtualWorkspace{ + named("apiexport", acceptAll, "/services/apiexport/{exportCluster}/{exportName}/clusters/{cluster}"), + named("wildcardOnly", func(ctx context.Context, match framework.PathMatch) (bool, string, context.Context) { + if !request.ClusterFrom(ctx).Wildcard { + return false, "", ctx + } + return true, match.PrefixToStrip(), ctx + }, "/services/init/{initializer}/clusters/{cluster}"), + named("keepCluster", func(ctx context.Context, match framework.PathMatch) (bool, string, context.Context) { + // as the lifecycle content virtual workspaces do + return true, strings.TrimSuffix(match.PrefixToStrip(), request.ClusterFrom(ctx).Name.Path().RequestPath()), ctx + }, "/services/init/{initializer}/clusters/{cluster}"), + named("admin", acceptAll, "/services/admin", "/services/admin/clusters/{cluster}"), + } + + tests := map[string]struct { + url string + wantVW string + wantPath string + wantCluster *request.Cluster + }{ + "export named clusters": { + url: "/services/apiexport/root:org/clusters/clusters/abc/apis/v1/things", + wantVW: "apiexport", + wantPath: "/apis/v1/things", + wantCluster: &request.Cluster{Name: "abc"}, + }, + "no trail": { + url: "/services/apiexport/root:org/export/clusters/abc", + wantVW: "apiexport", + wantPath: "", + wantCluster: &request.Cluster{Name: "abc"}, + }, + "trailing slash, no trail": { + url: "/services/apiexport/root:org/export/clusters/abc/", + wantVW: "apiexport", + wantPath: "/", + wantCluster: &request.Cluster{Name: "abc"}, + }, + "wildcard": { + url: "/services/apiexport/root:org/export/clusters/*/apis/v1", + wantVW: "apiexport", + wantPath: "/apis/v1", + wantCluster: &request.Cluster{Wildcard: true}, + }, + "first accepting VW wins": { + url: "/services/init/foo/clusters/*/apis/v1", + wantVW: "wildcardOnly", + wantPath: "/apis/v1", + wantCluster: &request.Cluster{Wildcard: true}, + }, + "later VW claims and keeps cluster prefix": { + url: "/services/init/foo/clusters/abc/apis/v1", + wantVW: "keepCluster", + wantPath: "/clusters/abc/apis/v1", + wantCluster: &request.Cluster{Name: "abc"}, + }, + "later VW keeps cluster prefix, trailing slash": { + url: "/services/init/foo/clusters/abc/", + wantVW: "keepCluster", + wantPath: "/clusters/abc/", + wantCluster: &request.Cluster{Name: "abc"}, + }, + "clusterless pattern, trailing slash": { + url: "/services/admin/", + wantVW: "admin", + wantPath: "/", + }, + "clusterless pattern": { + url: "/services/admin/apis/v1", + wantVW: "admin", + wantPath: "/apis/v1", + }, + "clusterless pattern, bare clusters segment": { + url: "/services/admin/clusters", + wantVW: "admin", + wantPath: "/clusters", + }, + "cluster pattern wins over clusterless": { + url: "/services/admin/clusters/abc/apis/v1", + wantVW: "admin", + wantPath: "/apis/v1", + wantCluster: &request.Cluster{Name: "abc"}, + }, + "non-canonical path passes unclaimed and unchanged": { + url: "/apis//foo/./bar", + wantPath: "/apis//foo/./bar", + }, + "non-canonical VW path passes unclaimed and unchanged": { + url: "/services/admin//apis", + wantPath: "/services/admin//apis", + }, + "unmatched passes unclaimed": { + url: "/services/other/clusters/abc/apis/v1", + wantPath: "/services/other/clusters/abc/apis/v1", + }, + "workspace path is not a cluster": { + url: "/services/apiexport/root:org/export/clusters/root:org/apis/v1", + wantPath: "/services/apiexport/root:org/export/clusters/root:org/apis/v1", + }, + "invalid cluster segment": { + url: "/services/apiexport/root:org/export/clusters/Bad!/apis/v1", + wantPath: "/services/apiexport/root:org/export/clusters/Bad!/apis/v1", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var got claimed + router := newPatternRouter(vws, capture(&got)) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPost, tc.url, http.NoBody)) + + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, got.called) + require.Equal(t, tc.wantVW, got.vw) + require.Equal(t, tc.wantPath, got.path) + require.Equal(t, tc.wantCluster, got.cluster) + }) + } +} + +func TestPatternRouterMatch(t *testing.T) { + t.Parallel() + + var got framework.PathMatch + vws := []NamedVirtualWorkspace{ + named("apiexport", func(ctx context.Context, match framework.PathMatch) (bool, string, context.Context) { + got = match + return true, match.PrefixToStrip(), ctx + }, "/services/apiexport/{exportCluster}/{exportName}/clusters/{cluster}"), + } + router := newPatternRouter(vws, http.NotFoundHandler()) + + url := "/services/apiexport/root:org/export/clusters/abc/apis/v1/things" + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(t.Context(), http.MethodGet, url, http.NoBody)) + + require.Equal(t, url, got.Path) + require.Equal(t, "apis/v1/things", got.Trail) + require.Equal(t, "root:org", got.Value("exportCluster")) + require.Equal(t, "export", got.Value("exportName")) + require.Equal(t, "abc", got.Value("cluster")) + require.Empty(t, got.Value("unknown")) + require.Equal(t, "/services/apiexport/root:org/export/clusters/abc", got.PrefixToStrip()) +} + +func TestPatternRouterPanicsOnInvalidPattern(t *testing.T) { + t.Parallel() + + vws := []NamedVirtualWorkspace{ + named("a", acceptAll, "/services/x/{a}/{a}/clusters/{cluster}"), + } + require.Panics(t, func() { newPatternRouter(vws, http.NotFoundHandler()) }) +} + +func TestValidatePatterns(t *testing.T) { + t.Parallel() + + require.NoError(t, validatePatterns([]NamedVirtualWorkspace{named("a", acceptAll, "/services/a")})) + require.ErrorContains(t, validatePatterns([]NamedVirtualWorkspace{named("a", acceptAll)}), `virtual workspace "a" has no path patterns`) +} + +func TestClusterFromSegment(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + segment string + want request.Cluster + ok bool + }{ + "wildcard": {segment: "*", want: request.Cluster{Wildcard: true}, ok: true}, + "name": {segment: "abc", want: request.Cluster{Name: logicalcluster.Name("abc")}, ok: true}, + "system": {segment: "system:admin", want: request.Cluster{Name: logicalcluster.Name("system:admin")}, ok: true}, + "path": {segment: "root:org"}, + "invalid": {segment: "Bad!"}, + "empty": {segment: ""}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, ok := clusterFromSegment(tc.segment) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/test/e2e/virtual/apiexport/namedclusters_test.go b/test/e2e/virtual/apiexport/namedclusters_test.go new file mode 100644 index 00000000000..9f7cb33e317 --- /dev/null +++ b/test/e2e/virtual/apiexport/namedclusters_test.go @@ -0,0 +1,163 @@ +/* +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 apiexport + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + + kcpdynamic "github.com/kcp-dev/client-go/dynamic" + "github.com/kcp-dev/logicalcluster/v3" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/core" + 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" +) + +// TestAPIExportNamedClusters serves an APIExport named "clusters", whose VW +// URL carries a "clusters" segment before the clusters/ one. +func TestAPIExportNamedClusters(t *testing.T) { + t.Parallel() + framework.Suite(t, framework.ParallelSuite) + + server := kcptesting.SharedKcpServer(t) + cfg := server.BaseConfig(t) + + kcpClients, 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") + + orgPath, _ := kcptesting.NewWorkspaceFixture(t, server, core.RootCluster.Path(), kcptesting.WithType(core.RootCluster.Path(), "organization")) + providerPath, _ := kcptesting.NewWorkspaceFixture(t, server, orgPath) + consumerPath, consumerWorkspace := kcptesting.NewWorkspaceFixture(t, server, orgPath) + consumerClusterName := logicalcluster.Name(consumerWorkspace.Spec.Cluster) + + cowboysGVR := schema.GroupVersionResource{Group: "wildwest.dev", Version: "v1alpha1", Resource: "cowboys"} + consumerCowboys := dynamicClusterClient.Cluster(consumerPath).Resource(cowboysGVR).Namespace("default") + + t.Log("Install cowboys APIResourceSchema into provider") + mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(kcpClients.Cluster(providerPath).Discovery())) + err = helpers.CreateResourceFromFS(t.Context(), dynamicClusterClient.Cluster(providerPath), mapper, nil, "apiresourceschema_cowboys.yaml", testFiles) + require.NoError(t, err) + + t.Log("Create APIExport named clusters in provider") + apiExport := &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusters", + }, + Spec: apisv1alpha2.APIExportSpec{ + Resources: []apisv1alpha2.ResourceSchema{ + { + Name: "cowboys", + Group: "wildwest.dev", + Schema: "today.cowboys.wildwest.dev", + Storage: apisv1alpha2.ResourceSchemaStorage{ + CRD: &apisv1alpha2.ResourceSchemaStorageCRD{}, + }, + }, + }, + }, + } + _, err = kcpClients.Cluster(providerPath).ApisV1alpha2().APIExports().Create(t.Context(), apiExport, metav1.CreateOptions{}) + require.NoError(t, err) + + t.Log("Bind provider APIExport in consumer") + apiBinding := &apisv1alpha2.APIBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiExport.Name, + }, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: providerPath.String(), + Name: apiExport.Name, + }, + }, + }, + } + kcptestinghelpers.Eventually(t, func() (bool, string) { + _, err := kcpClients.Cluster(consumerPath).ApisV1alpha2().APIBindings().Create(t.Context(), apiBinding, metav1.CreateOptions{}) + return err == nil, fmt.Sprintf("error creating APIBinding: %v", err) + }, wait.ForeverTestTimeout, time.Millisecond*100) + + t.Log("Create a cowboy in the consumer workspace") + cowboy := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "wildwest.dev/v1alpha1", + "kind": "Cowboy", + "metadata": map[string]interface{}{ + "name": "woody", + }, + }, + } + kcptestinghelpers.Eventually(t, func() (bool, string) { + _, err := consumerCowboys.Create(t.Context(), cowboy, metav1.CreateOptions{}) + return err == nil, fmt.Sprintf("error creating cowboy: %v", err) + }, wait.ForeverTestTimeout, time.Millisecond*100) + + t.Log("Wait for VW URL of the provider APIExport") + providerVWCfg := rest.CopyConfig(cfg) + kcptestinghelpers.Eventually(t, func() (bool, string) { + apiExportEndpointSlice, err := kcpClients.Cluster(providerPath).ApisV1alpha1().APIExportEndpointSlices().Get(t.Context(), apiExport.Name, metav1.GetOptions{}) + if kcptestinghelpers.TolerateOrFail(t, err, apierrors.IsNotFound) { + return false, fmt.Sprintf("waiting on APIExportEndpointSlice to be available %v", err.Error()) + } + var found bool + providerVWCfg.Host, found, err = framework.VirtualWorkspaceURL(t.Context(), cfg, consumerWorkspace, framework.ExportVirtualWorkspaceURLs(apiExportEndpointSlice)) + if err != nil { + return false, fmt.Sprintf("error getting VW URL: %v", err) + } + return found, fmt.Sprintf("waiting for virtual workspace URLs to be available: %v", apiExportEndpointSlice.Status.APIExportEndpoints) + }, wait.ForeverTestTimeout, time.Millisecond*100) + require.True(t, strings.HasSuffix(providerVWCfg.Host, "/clusters"), "VW URL %q must end in the export name", providerVWCfg.Host) + providerVWClient, err := kcpdynamic.NewForConfig(providerVWCfg) + require.NoError(t, err) + + t.Log("Get the cowboy through the provider VW by consumer cluster") + kcptestinghelpers.Eventually(t, func() (bool, string) { + _, err := providerVWClient.Cluster(consumerClusterName.Path()).Resource(cowboysGVR).Namespace("default").Get(t.Context(), cowboy.GetName(), metav1.GetOptions{}) + return err == nil, fmt.Sprintf("error getting cowboy through VW: %v", err) + }, wait.ForeverTestTimeout, time.Millisecond*100) + + t.Log("List cowboys through the provider VW across clusters") + kcptestinghelpers.Eventually(t, func() (bool, string) { + list, err := providerVWClient.Resource(cowboysGVR).List(t.Context(), metav1.ListOptions{}) + if err != nil { + return false, fmt.Sprintf("error listing cowboys through VW: %v", err) + } + return len(list.Items) == 1, fmt.Sprintf("expected 1 cowboy, got %d", len(list.Items)) + }, wait.ForeverTestTimeout, time.Millisecond*100) +} diff --git a/test/e2e/virtual/replication/namedclusters_test.go b/test/e2e/virtual/replication/namedclusters_test.go new file mode 100644 index 00000000000..d536544b177 --- /dev/null +++ b/test/e2e/virtual/replication/namedclusters_test.go @@ -0,0 +1,190 @@ +/* +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 replication + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/rest" + "k8s.io/utils/ptr" + + kcpapiextensionsclientset "github.com/kcp-dev/client-go/apiextensions/client" + kcpdynamic "github.com/kcp-dev/client-go/dynamic" + "github.com/kcp-dev/logicalcluster/v3" + apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + cachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" + "github.com/kcp-dev/sdk/apis/core" + "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/test/e2e/fixtures/wildwest" + wildwestv1alpha1 "github.com/kcp-dev/kcp/test/e2e/fixtures/wildwest/apis/wildwest/v1alpha1" + "github.com/kcp-dev/kcp/test/e2e/framework" +) + +// TestClusterCachedResourceNamedClusters serves a ClusterCachedResourceEndpointSlice +// named "clusters", whose VW URL carries a "clusters" segment before the +// clusters/ one. +func TestClusterCachedResourceNamedClusters(t *testing.T) { + t.Parallel() + framework.Suite(t, framework.ParallelSuite) + + server := kcptesting.SharedKcpServer(t) + cfg := server.BaseConfig(t) + + kcpClusterClient, err := kcpclientset.NewForConfig(cfg) + require.NoError(t, err, "failed to construct kcp cluster client for server") + + kcpDynClusterClient, err := kcpdynamic.NewForConfig(cfg) + require.NoError(t, err, "failed to construct kcp dynamic cluster client for server") + + kcpApiExtensionClusterClient, err := kcpapiextensionsclientset.NewForConfig(cfg) + require.NoError(t, err) + + orgPath, _ := kcptesting.NewWorkspaceFixture(t, server, core.RootCluster.Path(), kcptesting.WithType(core.RootCluster.Path(), "organization")) + providerPath, providerWS := kcptesting.NewWorkspaceFixture(t, server, orgPath) + providerClusterName := logicalcluster.Name(providerWS.Spec.Cluster) + consumerPath, consumerWorkspace := kcptesting.NewWorkspaceFixture(t, server, orgPath) + consumerClusterName := logicalcluster.Name(consumerWorkspace.Spec.Cluster) + + const name = "clusters" + gvr := wildwestv1alpha1.SchemeGroupVersion.WithResource("sheriffs") + + t.Logf("Creating Sheriff CRD and APIResourceSchema in %q", providerPath) + crd := wildwest.CRD(t, metav1.GroupResource(gvr.GroupResource())) + sch, err := apisv1alpha1.CRDToAPIResourceSchema(crd, "today") + require.NoError(t, err) + wildwest.Create(t, providerPath, kcpApiExtensionClusterClient.ApiextensionsV1().CustomResourceDefinitions(), metav1.GroupResource(gvr.GroupResource())) + kcptesting.WaitForAPIReady(t, kcpClusterClient.Cluster(providerPath).Discovery(), gvr.GroupVersion()) + _, err = kcpClusterClient.Cluster(providerPath).ApisV1alpha1().APIResourceSchemas().Create(t.Context(), sch, metav1.CreateOptions{}) + require.NoError(t, err) + + t.Logf("Creating a Sheriff in %q", providerPath) + sheriff, err := createSheriff(t.Context(), kcpDynClusterClient, providerClusterName, &wildwestv1alpha1.Sheriff{ + ObjectMeta: metav1.ObjectMeta{Name: "sheriff-1"}, + }) + require.NoError(t, err) + + t.Logf("Creating ClusterCachedResource %q in %q", name, providerPath) + _, err = kcpClusterClient.Cluster(providerPath).CacheV1alpha1().ClusterCachedResources().Create(t.Context(), &cachev1alpha1.ClusterCachedResource{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: cachev1alpha1.ClusterCachedResourceSpec{ + GroupVersionResource: cachev1alpha1.GroupVersionResource(gvr), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + kcptestinghelpers.Eventually(t, func() (bool, string) { + ccr, err := kcpClusterClient.Cluster(providerPath).CacheV1alpha1().ClusterCachedResources().Get(t.Context(), name, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + if ccr.Status.ResourceCounts == nil || ccr.Status.ResourceCounts.Cache != 1 { + return false, fmt.Sprintf("waiting for 1 cached object, have %v", ccr.Status.ResourceCounts) + } + return true, "" + }, wait.ForeverTestTimeout, 100*time.Millisecond, "waiting until ClusterCachedResource replicates sheriff-1") + + t.Logf("Creating APIExport %q in %q", name, providerPath) + _, err = kcpClusterClient.Cluster(providerPath).ApisV1alpha2().APIExports().Create(t.Context(), &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: apisv1alpha2.APIExportSpec{ + Resources: []apisv1alpha2.ResourceSchema{ + { + Group: "wildwest.dev", + Name: "sheriffs", + Schema: "today.sheriffs.wildwest.dev", + Storage: apisv1alpha2.ResourceSchemaStorage{ + Virtual: &apisv1alpha2.ResourceSchemaStorageVirtual{ + Reference: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(cachev1alpha1.SchemeGroupVersion.Group), + Kind: "ClusterCachedResourceEndpointSlice", + Name: name, + }, + }, + }, + }, + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + t.Logf("Creating ClusterCachedResourceEndpointSlice %q in %q", name, providerPath) + _, err = kcpClusterClient.Cluster(providerPath).CacheV1alpha1().ClusterCachedResourceEndpointSlices().Create(t.Context(), &cachev1alpha1.ClusterCachedResourceEndpointSlice{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: cachev1alpha1.ClusterCachedResourceEndpointSliceSpec{ + ClusterCachedResource: cachev1alpha1.ClusterCachedResourceReference{Name: name}, + APIExport: cachev1alpha1.ExportBindingReference{Name: name}, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + var endpointSlice *cachev1alpha1.ClusterCachedResourceEndpointSlice + kcptestinghelpers.EventuallyCondition(t, func() (conditions.Getter, error) { + endpointSlice, err = kcpClusterClient.Cluster(providerPath).CacheV1alpha1().ClusterCachedResourceEndpointSlices().Get(t.Context(), name, metav1.GetOptions{}) + return endpointSlice, err + }, kcptestinghelpers.Is(cachev1alpha1.APIExportValid), "ClusterCachedResourceEndpointSlice APIExport reference should become ready") + + t.Logf("Binding %s|%s in %s", providerPath, name, consumerPath) + kcptestinghelpers.Eventually(t, func() (bool, string) { + _, err := kcpClusterClient.Cluster(consumerPath).ApisV1alpha2().APIBindings().Create(t.Context(), &apisv1alpha2.APIBinding{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: apisv1alpha2.APIBindingSpec{ + Reference: apisv1alpha2.BindingReference{ + Export: &apisv1alpha2.ExportBindingReference{ + Path: providerPath.String(), + Name: name, + }, + }, + }, + }, metav1.CreateOptions{}) + return err == nil, fmt.Sprintf("failed to create APIBinding: %v", err) + }, wait.ForeverTestTimeout, time.Second, "waiting to create apibinding") + + t.Logf("Waiting for VW URL of the endpoint slice") + vwCfg := rest.CopyConfig(cfg) + kcptestinghelpers.Eventually(t, func() (bool, string) { + endpointSlice, err = kcpClusterClient.Cluster(providerPath).CacheV1alpha1().ClusterCachedResourceEndpointSlices().Get(t.Context(), name, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + var found bool + vwCfg.Host, found, err = framework.VirtualWorkspaceURL(t.Context(), cfg, consumerWorkspace, framework.ReplicationVirtualWorkspaceURLs(endpointSlice)) + if err != nil { + return false, fmt.Sprintf("error getting VW URL: %v", err) + } + return found, fmt.Sprintf("waiting for virtual workspace URLs to be available: %v", endpointSlice.Status.ClusterCachedResourceEndpoints) + }, wait.ForeverTestTimeout, 100*time.Millisecond) + require.True(t, strings.HasSuffix(vwCfg.Host, "/"+name), "VW URL %q must end in the endpoint slice name", vwCfg.Host) + vwClient, err := kcpdynamic.NewForConfig(vwCfg) + require.NoError(t, err) + + t.Logf("Getting the sheriff through the VW by consumer cluster") + kcptestinghelpers.Eventually(t, func() (bool, string) { + _, err := getSheriff(t.Context(), vwClient, consumerClusterName, sheriff.Name) + return err == nil, fmt.Sprintf("error getting sheriff through VW: %v", err) + }, wait.ForeverTestTimeout, 100*time.Millisecond) +}