package sql import ( "context" "fmt" "testing" "time" "github.com/stretchr/testify/suite" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" "sigs.k8s.io/controller-runtime/pkg/envtest" "github.com/rancher/steve/pkg/sqlcache/informer" "github.com/rancher/steve/pkg/sqlcache/informer/factory" "github.com/rancher/steve/pkg/sqlcache/partition" ) const testNamespace = "sql-test" var defaultPartition = partition.Partition{ All: true, } type IntegrationSuite struct { suite.Suite testEnv envtest.Environment clientset kubernetes.Clientset restCfg rest.Config } func (i *IntegrationSuite) SetupSuite() { i.testEnv = envtest.Environment{} restCfg, err := i.testEnv.Start() i.Require().NoError(err, "error when starting env test - this is likely because setup-envtest wasn't done. Check the README for more information") i.restCfg = *restCfg clientset, err := kubernetes.NewForConfig(restCfg) i.Require().NoError(err) i.clientset = *clientset testNs := v1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: testNamespace, }, } ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() _, err = i.clientset.CoreV1().Namespaces().Create(ctx, &testNs, metav1.CreateOptions{}) i.Require().NoError(err) } func (i *IntegrationSuite) TearDownSuite() { err := i.testEnv.Stop() i.Require().NoError(err) } func (i *IntegrationSuite) TestSQLCacheFilters() { fields := [][]string{{`metadata`, `annotations[somekey]`}} require := i.Require() configMapWithAnnotations := func(name string, annotations map[string]string) v1.ConfigMap { return v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: testNamespace, Annotations: annotations, }, } } createConfigMaps := func(configMaps ...v1.ConfigMap) { for _, configMap := range configMaps { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) configMapClient := i.clientset.CoreV1().ConfigMaps(testNamespace) _, err := configMapClient.Create(ctx, &configMap, metav1.CreateOptions{}) require.NoError(err) // avoiding defer in a for loop cancel() } } // we create some configmaps before the cache starts and some after so that we can test the initial list and // subsequent watches to make sure both work // matches the filter for somekey == somevalue matches := configMapWithAnnotations("matches-filter", map[string]string{"somekey": "somevalue"}) // partial match for somekey == somevalue (different suffix) partialMatches := configMapWithAnnotations("partial-matches", map[string]string{"somekey": "somevaluehere"}) specialCharacterMatch := configMapWithAnnotations("special-character-matches", map[string]string{"somekey": "c%%l_value"}) backSlashCharacterMatch := configMapWithAnnotations("backslash-character-matches", map[string]string{"somekey": `my\windows\path`}) createConfigMaps(matches, partialMatches, specialCharacterMatch, backSlashCharacterMatch) cache, cacheFactory, err := i.createCacheAndFactory(fields, nil) require.NoError(err) defer cacheFactory.Reset() // doesn't match the filter for somekey == somevalue notMatches := configMapWithAnnotations("not-matches-filter", map[string]string{"somekey": "notequal"}) // has no annotations, shouldn't match any filter missing := configMapWithAnnotations("missing", nil) createConfigMaps(notMatches, missing) configMapNames := []string{matches.Name, partialMatches.Name, notMatches.Name, missing.Name, specialCharacterMatch.Name, backSlashCharacterMatch.Name} err = i.waitForCacheReady(configMapNames, testNamespace, cache) require.NoError(err) orFiltersForFilters := func(filters ...informer.Filter) []informer.OrFilter { return []informer.OrFilter{ { Filters: filters, }, } } tests := []struct { name string filters []informer.OrFilter wantNames []string }{ { name: "matches filter", filters: orFiltersForFilters(informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalue", Op: informer.Eq, Partial: false, }), wantNames: []string{"matches-filter"}, }, { name: "partial matches filter", filters: orFiltersForFilters(informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalue", Op: informer.Eq, Partial: true, }), wantNames: []string{"matches-filter", "partial-matches"}, }, { name: "no matches for filter with underscore as it is interpreted literally", filters: orFiltersForFilters(informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalu_", Op: informer.Eq, Partial: true, }), wantNames: nil, }, { name: "no matches for filter with percent sign as it is interpreted literally", filters: orFiltersForFilters(informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalu%", Op: informer.Eq, Partial: true, }), wantNames: nil, }, { name: "match with special characters", filters: orFiltersForFilters(informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "c%%l_value", Op: informer.Eq, Partial: true, }), wantNames: []string{"special-character-matches"}, }, { name: "match with literal backslash character", filters: orFiltersForFilters(informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: `my\windows\path`, Op: informer.Eq, Partial: true, }), wantNames: []string{"backslash-character-matches"}, }, { name: "not eq filter", filters: orFiltersForFilters(informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalue", Op: informer.NotEq, Partial: false, }), wantNames: []string{"partial-matches", "not-matches-filter", "missing", "special-character-matches", "backslash-character-matches"}, }, { name: "partial not eq filter", filters: orFiltersForFilters(informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalue", Op: informer.NotEq, Partial: true, }), wantNames: []string{"not-matches-filter", "missing", "special-character-matches", "backslash-character-matches"}, }, { name: "multiple or filters match", filters: orFiltersForFilters( informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalue", Op: informer.Eq, Partial: true, }, informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "notequal", Op: informer.Eq, Partial: false, }, ), wantNames: []string{"matches-filter", "partial-matches", "not-matches-filter"}, }, { name: "or filters on different fields", filters: orFiltersForFilters( informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalue", Op: informer.Eq, Partial: true, }, informer.Filter{ Field: []string{`metadata`, `name`}, Match: "missing", Op: informer.Eq, Partial: false, }, ), wantNames: []string{"matches-filter", "partial-matches", "missing"}, }, { name: "and filters, both must match", filters: []informer.OrFilter{ { Filters: []informer.Filter{ { Field: []string{`metadata`, `annotations[somekey]`}, Match: "somevalue", Op: informer.Eq, Partial: true, }, }, }, { Filters: []informer.Filter{ { Field: []string{`metadata`, `name`}, Match: "matches-filter", Op: informer.Eq, Partial: false, }, }, }, }, wantNames: []string{"matches-filter"}, }, { name: "no matches", filters: orFiltersForFilters( informer.Filter{ Field: []string{`metadata`, `annotations[somekey]`}, Match: "valueNotRepresented", Op: informer.Eq, Partial: false, }, ), wantNames: []string{}, }, } for _, test := range tests { test := test i.Run(test.name, func() { options := informer.ListOptions{ Filters: test.filters, } partitions := []partition.Partition{defaultPartition} ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() cfgMaps, total, continueToken, err := cache.ListByOptions(ctx, options, partitions, testNamespace) i.Require().NoError(err) // since there's no additional pages, the continue token should be empty i.Require().Equal("", continueToken) i.Require().NotNil(cfgMaps) // assert instead of require so that we can see the full evaluation of # of resources returned i.Assert().Equal(len(test.wantNames), total) i.Assert().Len(cfgMaps.Items, len(test.wantNames)) requireNames := sets.Set[string]{} requireNames.Insert(test.wantNames...) gotNames := sets.Set[string]{} for _, configMap := range cfgMaps.Items { gotNames.Insert(configMap.GetName()) } i.Require().True(requireNames.Equal(gotNames), "wanted %v, got %v", requireNames, gotNames) }) } } func (i *IntegrationSuite) createCacheAndFactory(fields [][]string, transformFunc cache.TransformFunc) (*factory.Cache, *factory.CacheFactory, error) { cacheFactory, err := factory.NewCacheFactory() if err != nil { return nil, nil, fmt.Errorf("unable to make factory: %w", err) } dynamicClient, err := dynamic.NewForConfig(&i.restCfg) if err != nil { return nil, nil, fmt.Errorf("unable to make dynamicClient: %w", err) } configMapGVK := schema.GroupVersionKind{ Group: "", Version: "v1", Kind: "ConfigMap", } configMapGVR := schema.GroupVersionResource{ Group: "", Version: "v1", Resource: "configmaps", } dynamicResource := dynamicClient.Resource(configMapGVR).Namespace(testNamespace) cache, err := cacheFactory.CacheFor(fields, transformFunc, dynamicResource, configMapGVK, true, true) if err != nil { return nil, nil, fmt.Errorf("unable to make cache: %w", err) } return &cache, cacheFactory, nil } func (i *IntegrationSuite) waitForCacheReady(readyResourceNames []string, namespace string, cache *factory.Cache) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() return wait.PollUntilContextCancel(ctx, time.Millisecond*100, true, func(ctx context.Context) (done bool, err error) { var options informer.ListOptions partitions := []partition.Partition{defaultPartition} cacheCtx, cacheCancel := context.WithTimeout(ctx, time.Second*5) defer cacheCancel() currentResources, total, _, err := cache.ListByOptions(cacheCtx, options, partitions, namespace) if err != nil { // note that we don't return the error since that would stop the polling return false, nil } if total != len(readyResourceNames) { return false, nil } wantNames := sets.Set[string]{} wantNames.Insert(readyResourceNames...) gotNames := sets.Set[string]{} for _, current := range currentResources.Items { name := current.GetName() if !wantNames.Has(name) { return true, fmt.Errorf("got resource %s which wasn't expected", name) } gotNames.Insert(name) } return wantNames.Equal(gotNames), nil }) } func TestIntegrationSuite(t *testing.T) { suite.Run(t, new(IntegrationSuite)) }