diff --git a/query/resource_selector.go b/query/resource_selector.go index 36ff0af4e..23783d448 100644 --- a/query/resource_selector.go +++ b/query/resource_selector.go @@ -25,6 +25,18 @@ import ( "github.com/flanksource/duty/types" ) +var resourceSelectorPEGCache = cache.New(time.Hour, 2*time.Hour) +var resourceSelectorLabelRequirementsCache = cache.New(time.Hour, 2*time.Hour) + +type parsedResourceSelectorPEG struct { + queryField grammar.QueryField + flatFields []string +} + +type parsedSelectorRequirements struct { + requirements []labels.Requirement +} + type SearchResourcesRequest struct { // Limit the number of results returned per resource type Limit int `json:"limit"` @@ -235,13 +247,12 @@ func SetResourceSelectorClause( } if peg := resourceSelector.ToPeg(false); peg != "" { - qf, err := grammar.ParsePEG(peg) + parsedPEG, err := getParsedResourceSelectorPEG(peg) if err != nil { return nil, fmt.Errorf("error parsing grammar[%s]: %w", peg, err) } - flatFields := grammar.FlatFields(qf) - searchSetAgent = slices.ContainsFunc(flatFields, func(s string) bool { + searchSetAgent = slices.ContainsFunc(parsedPEG.flatFields, func(s string) bool { field := strings.ToLower(s) if alias, ok := qm.Aliases[field]; ok { field = alias @@ -249,7 +260,7 @@ func SetResourceSelectorClause( return field == "agent_id" }) - searchSetDeleted = slices.ContainsFunc(flatFields, func(s string) bool { + searchSetDeleted = slices.ContainsFunc(parsedPEG.flatFields, func(s string) bool { field := strings.ToLower(s) if alias, ok := qm.Aliases[field]; ok { field = alias @@ -258,7 +269,7 @@ func SetResourceSelectorClause( }) var clauses []clause.Expression - query, clauses, err = qm.Apply(ctx, *qf, query) + query, clauses, err = qm.Apply(ctx, parsedPEG.queryField, query) if err != nil { return nil, fmt.Errorf("error applying query model: %w", err) } @@ -305,11 +316,10 @@ func SetResourceSelectorClause( if !qm.HasTags { return nil, api.Errorf(api.EINVALID, "tagSelector is not supported for table=%s", table) } else { - parsedTagSelector, err := labels.Parse(resourceSelector.TagSelector) + requirements, err := getSelectorRequirements(resourceSelector.TagSelector) if err != nil { return nil, api.Errorf(api.EINVALID, "failed to parse tag selector: %v", err) } - requirements, _ := parsedTagSelector.Requirements() for _, r := range requirements { query = jsonColumnRequirementsToSQLClause(query, "tags", r) } @@ -321,23 +331,21 @@ func SetResourceSelectorClause( return nil, api.Errorf(api.EINVALID, "labelSelector is not supported for table=%s", table) } - parsedLabelSelector, err := labels.Parse(resourceSelector.LabelSelector) + requirements, err := getSelectorRequirements(resourceSelector.LabelSelector) if err != nil { return nil, api.Errorf(api.EINVALID, "failed to parse label selector: %v", err) } - requirements, _ := parsedLabelSelector.Requirements() for _, r := range requirements { query = jsonColumnRequirementsToSQLClause(query, "labels", r) } } if len(resourceSelector.FieldSelector) > 0 { - parsedFieldSelector, err := labels.Parse(resourceSelector.FieldSelector) + requirements, err := getSelectorRequirements(resourceSelector.FieldSelector) if err != nil { return nil, api.Errorf(api.EINVALID, "failed to parse field selector: %v", err) } - requirements, _ := parsedFieldSelector.Requirements() for _, r := range requirements { query = jsonColumnRequirementsToSQLClause(query, "properties", r) } @@ -353,6 +361,49 @@ func SetResourceSelectorClause( return query, nil } +func getParsedResourceSelectorPEG(peg string) (parsedResourceSelectorPEG, error) { + if value, ok := resourceSelectorPEGCache.Get(peg); ok { + if parsed, ok := value.(parsedResourceSelectorPEG); ok { + return parsed, nil + } + resourceSelectorPEGCache.Delete(peg) + } + + qf, err := grammar.ParsePEG(peg) + if err != nil { + return parsedResourceSelectorPEG{}, err + } + + parsed := parsedResourceSelectorPEG{ + queryField: *qf, + flatFields: grammar.FlatFields(qf), + } + resourceSelectorPEGCache.SetDefault(peg, parsed) + + return parsed, nil +} + +func getSelectorRequirements(selector string) ([]labels.Requirement, error) { + if value, ok := resourceSelectorLabelRequirementsCache.Get(selector); ok { + if cached, ok := value.(parsedSelectorRequirements); ok { + return cached.requirements, nil + } + resourceSelectorLabelRequirementsCache.Delete(selector) + } + + parsedSelector, err := labels.Parse(selector) + if err != nil { + return nil, err + } + + requirements, _ := parsedSelector.Requirements() + resourceSelectorLabelRequirementsCache.SetDefault(selector, parsedSelectorRequirements{ + requirements: requirements, + }) + + return requirements, nil +} + // queryResourceSelector runs the given resourceSelector and returns the resource ids func queryResourceSelector[T any]( ctx context.Context, diff --git a/query/resource_selector_cache_test.go b/query/resource_selector_cache_test.go new file mode 100644 index 000000000..e3b934595 --- /dev/null +++ b/query/resource_selector_cache_test.go @@ -0,0 +1,62 @@ +package query + +import ( + "testing" + + "github.com/onsi/gomega" +) + +func TestGetParsedResourceSelectorPEGUsesCache(t *testing.T) { + g := gomega.NewWithT(t) + + resourceSelectorPEGCache.Flush() + + first, err := getParsedResourceSelectorPEG(`name="coredns",type="Kubernetes::Pod"`) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + second, err := getParsedResourceSelectorPEG(`name="coredns",type="Kubernetes::Pod"`) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + g.Expect(second.queryField).To(gomega.Equal(first.queryField)) + g.Expect(second.flatFields).To(gomega.Equal(first.flatFields)) + g.Expect(resourceSelectorPEGCache.ItemCount()).To(gomega.Equal(1)) +} + +func TestGetParsedResourceSelectorPEGCachesByPEGValue(t *testing.T) { + g := gomega.NewWithT(t) + + resourceSelectorPEGCache.Flush() + + _, err := getParsedResourceSelectorPEG(`name="coredns"`) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + _, err = getParsedResourceSelectorPEG(`name="metrics-server"`) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + g.Expect(resourceSelectorPEGCache.ItemCount()).To(gomega.Equal(2)) +} + +func TestGetSelectorRequirementsUsesCache(t *testing.T) { + g := gomega.NewWithT(t) + + resourceSelectorLabelRequirementsCache.Flush() + + first, err := getSelectorRequirements("cluster=aws") + g.Expect(err).ToNot(gomega.HaveOccurred()) + + second, err := getSelectorRequirements("cluster=aws") + g.Expect(err).ToNot(gomega.HaveOccurred()) + + g.Expect(second).To(gomega.Equal(first)) + g.Expect(resourceSelectorLabelRequirementsCache.ItemCount()).To(gomega.Equal(1)) +} + +func TestGetSelectorRequirementsReturnsErrorForInvalidSelector(t *testing.T) { + g := gomega.NewWithT(t) + + resourceSelectorLabelRequirementsCache.Flush() + + _, err := getSelectorRequirements("=aws") + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(resourceSelectorLabelRequirementsCache.ItemCount()).To(gomega.Equal(0)) +} diff --git a/types/resource_selector_canonical.go b/types/resource_selector_canonical.go index a81e928fc..17527bf67 100644 --- a/types/resource_selector_canonical.go +++ b/types/resource_selector_canonical.go @@ -21,6 +21,10 @@ const ( func (rs ResourceSelector) Canonical() ResourceSelector { out := rs + if !resourceSelectorHasWildcard(out) { + return out + } + if isWildcardValue(out.ID) { out.ID = "" } @@ -139,3 +143,30 @@ func filterWildcardCSV(value string) string { func isWildcardValue(value string) bool { return strings.TrimSpace(value) == wildcardValue } + +func resourceSelectorHasWildcard(rs ResourceSelector) bool { + if (rs.ID != "" && strings.Contains(rs.ID, wildcardValue)) || + (rs.Namespace != "" && strings.Contains(rs.Namespace, wildcardValue)) || + (rs.Scope != "" && strings.Contains(rs.Scope, wildcardValue)) || + (rs.Agent != "" && strings.Contains(rs.Agent, wildcardValue)) || + (rs.TagSelector != "" && strings.Contains(rs.TagSelector, wildcardValue)) || + (rs.LabelSelector != "" && strings.Contains(rs.LabelSelector, wildcardValue)) || + (rs.FieldSelector != "" && strings.Contains(rs.FieldSelector, wildcardValue)) || + (rs.Health != "" && strings.Contains(string(rs.Health), wildcardValue)) { + return true + } + + for _, item := range rs.Types { + if strings.Contains(item, wildcardValue) { + return true + } + } + + for _, item := range rs.Statuses { + if strings.Contains(item, wildcardValue) { + return true + } + } + + return false +} diff --git a/types/resource_selector_canonical_test.go b/types/resource_selector_canonical_test.go new file mode 100644 index 000000000..d263ee1ab --- /dev/null +++ b/types/resource_selector_canonical_test.go @@ -0,0 +1,25 @@ +package types + +import ( + "testing" + + "github.com/onsi/gomega" +) + +func TestResourceSelectorHasWildcard(t *testing.T) { + g := gomega.NewWithT(t) + + g.Expect(resourceSelectorHasWildcard(ResourceSelector{ + Name: "api-server", + TagSelector: "cluster=aws", + LabelSelector: "app=backend", + FieldSelector: "owner=platform", + Types: Items{"Kubernetes::Pod"}, + Statuses: Items{"healthy"}, + Health: "healthy", + })).To(gomega.BeFalse()) + + g.Expect(resourceSelectorHasWildcard(ResourceSelector{ + TagSelector: "cluster=*", + })).To(gomega.BeTrue()) +} diff --git a/types/resource_selector_test.go b/types/resource_selector_test.go index 2b0b68f5f..832d5f6e8 100644 --- a/types/resource_selector_test.go +++ b/types/resource_selector_test.go @@ -614,6 +614,20 @@ var _ = Describe("Resource Selector", func() { Expect(canonical.Statuses).To(Equal(types.Items{"healthy"})) Expect(canonical.Health).To(Equal(types.MatchExpression("warning"))) }) + + It("should keep selectors without wildcard unchanged", func() { + rs := types.ResourceSelector{ + Name: "api-server", + TagSelector: "cluster=aws", + LabelSelector: "app=backend", + FieldSelector: "owner=platform", + Types: []string{"Kubernetes::Pod"}, + Statuses: []string{"healthy"}, + Health: "healthy", + } + + Expect(rs.Canonical()).To(Equal(rs)) + }) }) }) })