diff --git a/README.md b/README.md index 935b79e..49347ae 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,36 @@ item is included in the list. /v1/{type}?filter=spec.containers.image=alpine ``` +#### `projectsornamespaces` + +Resources can also be filtered by the Rancher projects their namespaces belong +to. Since a project isn't an intrinsic part of the resource itself, the filter +parameter for filtering by projects is separate from the main `filter` +parameter. This query parameter is only applicable when steve is runnning in +concert with Rancher. + +The list can be filtered by either projects or namespaces or both. + +Filtering by a single project or a single namespace: + +``` +/v1/{type}?projectsornamespaces=p1 +``` + +Filtering by multiple projects or namespaces is done with a comma separated +list. A resource matching any project or namespace in the list is included in +the result: + +``` +/v1/{type}?projectsornamespaces=p1,n1,n2 +``` + +The list can be negated to exclude results: + +``` +/v1/{type}?projectsornamespaces!=p1,n1,n2 +``` + #### `sort` Only applicable to list requests (`/v1/{type}` and `/v1/{type}/{namespace}`). diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index 2922481..6ac320a 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -11,6 +11,7 @@ import ( "github.com/rancher/steve/pkg/stores/proxy" "github.com/rancher/steve/pkg/summarycache" "github.com/rancher/wrangler/pkg/data" + corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" "github.com/rancher/wrangler/pkg/slice" "github.com/rancher/wrangler/pkg/summary" "k8s.io/apimachinery/pkg/api/meta" @@ -21,9 +22,10 @@ import ( func DefaultTemplate(clientGetter proxy.ClientGetter, summaryCache *summarycache.SummaryCache, - asl accesscontrol.AccessSetLookup) schema.Template { + asl accesscontrol.AccessSetLookup, + namespaceCache corecontrollers.NamespaceCache) schema.Template { return schema.Template{ - Store: metricsStore.NewMetricsStore(proxy.NewProxyStore(clientGetter, summaryCache, asl)), + Store: metricsStore.NewMetricsStore(proxy.NewProxyStore(clientGetter, summaryCache, asl, namespaceCache)), Formatter: formatter(summaryCache), } } diff --git a/pkg/resources/schema.go b/pkg/resources/schema.go index ff3fee1..148f9e2 100644 --- a/pkg/resources/schema.go +++ b/pkg/resources/schema.go @@ -19,6 +19,7 @@ import ( steveschema "github.com/rancher/steve/pkg/schema" "github.com/rancher/steve/pkg/stores/proxy" "github.com/rancher/steve/pkg/summarycache" + corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/client-go/discovery" ) @@ -46,9 +47,10 @@ func DefaultSchemaTemplates(cf *client.Factory, baseSchemas *types.APISchemas, summaryCache *summarycache.SummaryCache, lookup accesscontrol.AccessSetLookup, - discovery discovery.DiscoveryInterface) []schema.Template { + discovery discovery.DiscoveryInterface, + namespaceCache corecontrollers.NamespaceCache) []schema.Template { return []schema.Template{ - common.DefaultTemplate(cf, summaryCache, lookup), + common.DefaultTemplate(cf, summaryCache, lookup, namespaceCache), apigroups.Template(discovery), { ID: "configmap", diff --git a/pkg/server/server.go b/pkg/server/server.go index 788e0fc..06f1c0e 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -145,7 +145,7 @@ func setup(ctx context.Context, server *Server) error { summaryCache := summarycache.New(sf, ccache) summaryCache.Start(ctx) - for _, template := range resources.DefaultSchemaTemplates(cf, server.BaseSchemas, summaryCache, asl, server.controllers.K8s.Discovery()) { + for _, template := range resources.DefaultSchemaTemplates(cf, server.BaseSchemas, summaryCache, asl, server.controllers.K8s.Discovery(), server.controllers.Core.Namespace().Cache()) { sf.AddTemplate(template) } diff --git a/pkg/stores/partition/listprocessor/processor.go b/pkg/stores/partition/listprocessor/processor.go index a316a53..f028e5a 100644 --- a/pkg/stores/partition/listprocessor/processor.go +++ b/pkg/stores/partition/listprocessor/processor.go @@ -10,19 +10,24 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/wrangler/pkg/data" "github.com/rancher/wrangler/pkg/data/convert" + corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) const ( - defaultLimit = 100000 - continueParam = "continue" - limitParam = "limit" - filterParam = "filter" - sortParam = "sort" - pageSizeParam = "pagesize" - pageParam = "page" - revisionParam = "revision" - orOp = "," + defaultLimit = 100000 + continueParam = "continue" + limitParam = "limit" + filterParam = "filter" + sortParam = "sort" + pageSizeParam = "pagesize" + pageParam = "page" + revisionParam = "revision" + projectsOrNamespacesVar = "projectsornamespaces" + projectIDFieldLabel = "field.cattle.io/projectId" + + orOp = "," + notOp = "!" ) var opReg = regexp.MustCompile(`[!]?=`) @@ -36,12 +41,13 @@ const ( // ListOptions represents the query parameters that may be included in a list request. type ListOptions struct { - ChunkSize int - Resume string - Filters []OrFilter - Sort Sort - Pagination Pagination - Revision string + ChunkSize int + Resume string + Filters []OrFilter + Sort Sort + Pagination Pagination + Revision string + ProjectsOrNamespaces ProjectsOrNamespacesFilter } // Filter represents a field to filter by. @@ -127,11 +133,21 @@ func (p Pagination) PageSize() int { return p.pageSize } +type ProjectsOrNamespacesFilter struct { + filter map[string]struct{} + op op +} + // ParseQuery parses the query params of a request and returns a ListOptions. func ParseQuery(apiOp *types.APIRequest) *ListOptions { - chunkSize := getLimit(apiOp) + opts := ListOptions{} + + opts.ChunkSize = getLimit(apiOp) + q := apiOp.Request.URL.Query() cont := q.Get(continueParam) + opts.Resume = cont + filterParams := q[filterParam] filterOpts := []OrFilter{} for _, filters := range filterParams { @@ -168,6 +184,8 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions { } return fieldI.String() < fieldJ.String() }) + opts.Filters = filterOpts + sortOpts := Sort{} sortKeys := q.Get(sortParam) if sortKeys != "" { @@ -191,6 +209,8 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions { } } } + opts.Sort = sortOpts + var err error pagination := Pagination{} pagination.pageSize, err = strconv.Atoi(q.Get(pageSizeParam)) @@ -201,15 +221,29 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions { if err != nil { pagination.page = 1 } + opts.Pagination = pagination + revision := q.Get(revisionParam) - return &ListOptions{ - ChunkSize: chunkSize, - Resume: cont, - Filters: filterOpts, - Sort: sortOpts, - Pagination: pagination, - Revision: revision, + opts.Revision = revision + + projectsOptions := ProjectsOrNamespacesFilter{} + var op op + projectsOrNamespaces := q.Get(projectsOrNamespacesVar) + if projectsOrNamespaces == "" { + projectsOrNamespaces = q.Get(projectsOrNamespacesVar + notOp) + if projectsOrNamespaces != "" { + op = notEq + } } + if projectsOrNamespaces != "" { + projectsOptions.filter = make(map[string]struct{}) + for _, pn := range strings.Split(projectsOrNamespaces, ",") { + projectsOptions.filter[pn] = struct{}{} + } + projectsOptions.op = op + opts.ProjectsOrNamespaces = projectsOptions + } + return &opts } // getLimit extracts the limit parameter from the request or sets a default of 100000. @@ -360,3 +394,31 @@ func PaginateList(list []unstructured.Unstructured, p Pagination) ([]unstructure } return list[offset : offset+p.pageSize], pages } + +func FilterByProjectsAndNamespaces(list []unstructured.Unstructured, projectsOrNamespaces ProjectsOrNamespacesFilter, namespaceCache corecontrollers.NamespaceCache) []unstructured.Unstructured { + if len(projectsOrNamespaces.filter) == 0 { + return list + } + result := []unstructured.Unstructured{} + for _, obj := range list { + namespaceName := obj.GetNamespace() + if namespaceName == "" { + continue + } + namespace, err := namespaceCache.Get(namespaceName) + if namespace == nil || err != nil { + continue + } + projectLabel, _ := namespace.GetLabels()[projectIDFieldLabel] + _, matchesProject := projectsOrNamespaces.filter[projectLabel] + _, matchesNamespace := projectsOrNamespaces.filter[namespaceName] + matches := matchesProject || matchesNamespace + if projectsOrNamespaces.op == eq && matches { + result = append(result, obj) + } + if projectsOrNamespaces.op == notEq && !matches { + result = append(result, obj) + } + } + return result +} diff --git a/pkg/stores/partition/listprocessor/processor_test.go b/pkg/stores/partition/listprocessor/processor_test.go index a5e7c96..d17c0ca 100644 --- a/pkg/stores/partition/listprocessor/processor_test.go +++ b/pkg/stores/partition/listprocessor/processor_test.go @@ -3,8 +3,12 @@ package listprocessor import ( "testing" + corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" ) func TestFilterList(t *testing.T) { @@ -2567,3 +2571,669 @@ func TestPaginateList(t *testing.T) { }) } } + +func TestFilterByProjectsAndNamespaces(t *testing.T) { + tests := []struct { + name string + objects []unstructured.Unstructured + filter ProjectsOrNamespacesFilter + want []unstructured.Unstructured + }{ + { + name: "filter by one project", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n2", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "p-abcde": struct{}{}, + }, + op: eq, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + }, + }, + { + name: "filter by multiple projects", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "p-abcde": struct{}{}, + "p-fghij": struct{}{}, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + }, + }, + { + name: "filter by one namespace", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n2", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "n1": struct{}{}, + }, + op: eq, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + }, + }, + { + name: "filter by multiple namespaces", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "n1": struct{}{}, + "n2": struct{}{}, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + }, + }, + { + name: "filter by namespaces and projects", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "n1": struct{}{}, + "p-fghij": struct{}{}, + }, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + }, + }, + { + name: "no matches", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "foobar": struct{}{}, + }, + }, + want: []unstructured.Unstructured{}, + }, + { + name: "no filters", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{}, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + }, + { + name: "filter by one project negated", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "p-abcde": struct{}{}, + }, + op: notEq, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + }, + { + name: "filter by multiple projects negated", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "p-abcde": struct{}{}, + "p-fghij": struct{}{}, + }, + op: notEq, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + }, + { + name: "filter by one namespace negated", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n2", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "n1": struct{}{}, + }, + op: notEq, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n2", + }, + }, + }, + }, + }, + { + name: "filter by multiple namespaces negated", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "n1": struct{}{}, + "n2": struct{}{}, + }, + op: notEq, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + }, + { + name: "filter by namespaces and projects negated", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + "namespace": "n1", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + "namespace": "n2", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + filter: ProjectsOrNamespacesFilter{ + filter: map[string]struct{}{ + "n1": struct{}{}, + "p-fghij": struct{}{}, + }, + op: notEq, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + "namespace": "n3", + }, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := FilterByProjectsAndNamespaces(test.objects, test.filter, mockNamespaceCache{}) + assert.Equal(t, test.want, got) + }) + } +} + +var namespaces = map[string]*corev1.Namespace{ + "n1": &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n1", + Labels: map[string]string{ + "field.cattle.io/projectId": "p-abcde", + }, + }, + }, + "n2": &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n2", + Labels: map[string]string{ + "field.cattle.io/projectId": "p-fghij", + }, + }, + }, + "n3": &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n3", + Labels: map[string]string{ + "field.cattle.io/projectId": "p-klmno", + }, + }, + }, + "n4": &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n4", + }, + }, +} + +type mockNamespaceCache struct{} + +func (m mockNamespaceCache) Get(name string) (*corev1.Namespace, error) { + return namespaces[name], nil +} + +func (m mockNamespaceCache) List(selector labels.Selector) ([]*corev1.Namespace, error) { + panic("not implemented") +} +func (m mockNamespaceCache) AddIndexer(indexName string, indexer corecontrollers.NamespaceIndexer) { + panic("not implemented") +} +func (m mockNamespaceCache) GetByIndex(indexName, key string) ([]*corev1.Namespace, error) { + panic("not implemented") +} diff --git a/pkg/stores/partition/store.go b/pkg/stores/partition/store.go index 185a6cb..482d838 100644 --- a/pkg/stores/partition/store.go +++ b/pkg/stores/partition/store.go @@ -13,6 +13,7 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/stores/partition/listprocessor" + corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "k8s.io/apimachinery/pkg/api/meta" @@ -42,13 +43,14 @@ type Partitioner interface { // Store implements types.Store for partitions. type Store struct { - Partitioner Partitioner - listCache *cache.LRUExpireCache - asl accesscontrol.AccessSetLookup + Partitioner Partitioner + listCache *cache.LRUExpireCache + asl accesscontrol.AccessSetLookup + namespaceCache corecontrollers.NamespaceCache } // NewStore creates a types.Store implementation with a partitioner and an LRU expiring cache for list responses. -func NewStore(partitioner Partitioner, asl accesscontrol.AccessSetLookup) *Store { +func NewStore(partitioner Partitioner, asl accesscontrol.AccessSetLookup, namespaceCache corecontrollers.NamespaceCache) *Store { cacheSize := defaultCacheSize if v := os.Getenv(cacheSizeEnv); v != "" { sizeInt, err := strconv.Atoi(v) @@ -57,8 +59,9 @@ func NewStore(partitioner Partitioner, asl accesscontrol.AccessSetLookup) *Store } } s := &Store{ - Partitioner: partitioner, - asl: asl, + Partitioner: partitioner, + asl: asl, + namespaceCache: namespaceCache, } if v := os.Getenv(cacheDisableEnv); v == "" { s.listCache = cache.NewLRUExpireCache(cacheSize) @@ -203,6 +206,7 @@ func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.AP listToCache := &unstructured.UnstructuredList{ Items: list, } + list = listprocessor.FilterByProjectsAndNamespaces(list, opts.ProjectsOrNamespaces, s.namespaceCache) c := lister.Continue() if c != "" { listToCache.SetContinue(c) diff --git a/pkg/stores/partition/store_test.go b/pkg/stores/partition/store_test.go index fdd666c..6d557bd 100644 --- a/pkg/stores/partition/store_test.go +++ b/pkg/stores/partition/store_test.go @@ -12,9 +12,13 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/accesscontrol" + corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" "github.com/rancher/wrangler/pkg/schemas" "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/watch" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/endpoints/request" @@ -1928,6 +1932,118 @@ func TestList(t *testing.T) { {"all": 1}, }, }, + { + name: "with project filters", + apiOps: []*types.APIRequest{ + newRequest("projectsornamespaces=p-abcde", "user1"), + newRequest("projectsornamespaces=p-abcde,p-fghij", "user1"), + newRequest("projectsornamespaces=p-abcde,n2", "user1"), + newRequest("projectsornamespaces!=p-abcde", "user1"), + newRequest("projectsornamespaces!=p-abcde,p-fghij", "user1"), + newRequest("projectsornamespaces!=p-abcde,n2", "user1"), + newRequest("projectsornamespaces=foobar", "user1"), + newRequest("projectsornamespaces!=foobar", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").withNamespace("n1").Unstructured, + newApple("granny-smith").withNamespace("n1").Unstructured, + newApple("bramley").withNamespace("n2").Unstructured, + newApple("crispin").withNamespace("n3").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Objects: []types.APIObject{ + newApple("fuji").withNamespace("n1").toObj(), + newApple("granny-smith").withNamespace("n1").toObj(), + }, + }, + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").withNamespace("n1").toObj(), + newApple("granny-smith").withNamespace("n1").toObj(), + newApple("bramley").withNamespace("n2").toObj(), + }, + }, + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").withNamespace("n1").toObj(), + newApple("granny-smith").withNamespace("n1").toObj(), + newApple("bramley").withNamespace("n2").toObj(), + }, + }, + { + Count: 2, + Objects: []types.APIObject{ + newApple("bramley").withNamespace("n2").toObj(), + newApple("crispin").withNamespace("n3").toObj(), + }, + }, + { + Count: 1, + Objects: []types.APIObject{ + newApple("crispin").withNamespace("n3").toObj(), + }, + }, + { + Count: 1, + Objects: []types.APIObject{ + newApple("crispin").withNamespace("n3").toObj(), + }, + }, + { + Count: 0, + }, + { + Count: 4, + Objects: []types.APIObject{ + newApple("fuji").withNamespace("n1").toObj(), + newApple("granny-smith").withNamespace("n1").toObj(), + newApple("bramley").withNamespace("n2").toObj(), + newApple("crispin").withNamespace("n3").toObj(), + }, + }, + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -1947,7 +2063,7 @@ func TestList(t *testing.T) { store := NewStore(mockPartitioner{ stores: stores, partitions: test.partitions, - }, asl) + }, asl, mockNamespaceCache{}) for i, req := range test.apiOps { got, gotErr := store.List(req, schema) assert.Nil(t, gotErr) @@ -2022,7 +2138,7 @@ func TestListByRevision(t *testing.T) { }, }, }, - }, asl) + }, asl, mockNamespaceCache{}) req := newRequest("", "user1") t.Setenv("CATTLE_REQUEST_CACHE_DISABLED", "Y") @@ -2215,9 +2331,15 @@ func newApple(name string) apple { } func (a apple) toObj() types.APIObject { + meta := a.Object["metadata"].(map[string]interface{}) + id := meta["name"].(string) + ns, ok := meta["namespace"] + if ok { + id = ns.(string) + "/" + id + } return types.APIObject{ Type: "apple", - ID: a.Object["metadata"].(map[string]interface{})["name"].(string), + ID: id, Object: &a.Unstructured, } } @@ -2229,6 +2351,11 @@ func (a apple) with(data map[string]string) apple { return a } +func (a apple) withNamespace(namespace string) apple { + a.Object["metadata"].(map[string]interface{})["namespace"] = namespace + return a +} + type mockAccessSetLookup struct { accessID string userRoles []map[string]string @@ -2251,3 +2378,51 @@ func getAccessID(user, role string) string { h := sha256.Sum256([]byte(user + role)) return string(h[:]) } + +var namespaces = map[string]*corev1.Namespace{ + "n1": &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n1", + Labels: map[string]string{ + "field.cattle.io/projectId": "p-abcde", + }, + }, + }, + "n2": &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n2", + Labels: map[string]string{ + "field.cattle.io/projectId": "p-fghij", + }, + }, + }, + "n3": &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n3", + Labels: map[string]string{ + "field.cattle.io/projectId": "p-klmno", + }, + }, + }, + "n4": &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n4", + }, + }, +} + +type mockNamespaceCache struct{} + +func (m mockNamespaceCache) Get(name string) (*corev1.Namespace, error) { + return namespaces[name], nil +} + +func (m mockNamespaceCache) List(selector labels.Selector) ([]*corev1.Namespace, error) { + panic("not implemented") +} +func (m mockNamespaceCache) AddIndexer(indexName string, indexer corecontrollers.NamespaceIndexer) { + panic("not implemented") +} +func (m mockNamespaceCache) GetByIndex(indexName, key string) ([]*corev1.Namespace, error) { + panic("not implemented") +} diff --git a/pkg/stores/proxy/proxy_store.go b/pkg/stores/proxy/proxy_store.go index b32dfc6..13b18da 100644 --- a/pkg/stores/proxy/proxy_store.go +++ b/pkg/stores/proxy/proxy_store.go @@ -19,6 +19,7 @@ import ( metricsStore "github.com/rancher/steve/pkg/stores/metrics" "github.com/rancher/steve/pkg/stores/partition" "github.com/rancher/wrangler/pkg/data" + corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1" "github.com/rancher/wrangler/pkg/schemas/validation" "github.com/rancher/wrangler/pkg/summary" "github.com/sirupsen/logrus" @@ -85,7 +86,7 @@ type Store struct { } // NewProxyStore returns a wrapped types.Store. -func NewProxyStore(clientGetter ClientGetter, notifier RelationshipNotifier, lookup accesscontrol.AccessSetLookup) types.Store { +func NewProxyStore(clientGetter ClientGetter, notifier RelationshipNotifier, lookup accesscontrol.AccessSetLookup, namespaceCache corecontrollers.NamespaceCache) types.Store { return &errorStore{ Store: &WatchRefresh{ Store: partition.NewStore( @@ -96,6 +97,7 @@ func NewProxyStore(clientGetter ClientGetter, notifier RelationshipNotifier, loo }, }, lookup, + namespaceCache, ), asl: lookup, },