1
0
mirror of https://github.com/rancher/steve.git synced 2025-04-28 19:24:42 +00:00
steve/pkg/sqlcache/integration_test.go
Eric Promislow 3b45729415
Generate field names with brackets when needed. (#477)
* Generate field names with brackets when needed.

* Stop hard-wiring complex selectors as `["field1", "field2[sub-field3]"]`

and instead represent them as a more consistent `["field1", "field2", "sub-field3"]`

* Convert all filter strings ending with square brackets to an array.

Stop special-casing 'metadata.labels[X]' and handle any query string that ends with '[...]'.

* Stop checking for pre-bracketed terms in constant field-accessor arrays.

In this commit we stop converting string arrays like
`["metadata", "labels[k8s.io/deepcode]"]` into the database field
`"metadata.labels[k8s.io/deepcode]"` and instead will do a
naive `join` to give `metadata[labels[k8s.io/deepcode]]`.  The solution
is to never express the above terms in separate fields, like
`["metadata", "labels", "k8s.io/deepcode"]`. This also better reflects
the stucture of the referenced object.

* gofmt changes

* Simplify comment about 'smartJoin'.
2025-02-25 10:39:29 -08:00

366 lines
12 KiB
Go

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"},
Matches: []string{"somevalue"},
Op: informer.Eq,
Partial: false,
}),
wantNames: []string{"matches-filter"},
},
{
name: "partial matches filter",
filters: orFiltersForFilters(informer.Filter{
Field: []string{"metadata", "annotations", "somekey"},
Matches: []string{"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"},
Matches: []string{"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"},
Matches: []string{"somevalu%"},
Op: informer.Eq,
Partial: true,
}),
wantNames: nil,
},
{
name: "match with special characters",
filters: orFiltersForFilters(informer.Filter{
Field: []string{"metadata", "annotations", "somekey"},
Matches: []string{"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"},
Matches: []string{`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"},
Matches: []string{"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"},
Matches: []string{"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"},
Matches: []string{"somevalue"},
Op: informer.Eq,
Partial: true,
},
informer.Filter{
Field: []string{"metadata", "annotations", "somekey"},
Matches: []string{"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"},
Matches: []string{"somevalue"},
Op: informer.Eq,
Partial: true,
},
informer.Filter{
Field: []string{`metadata`, `name`},
Matches: []string{"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"},
Matches: []string{"somevalue"},
Op: informer.Eq,
Partial: true,
},
},
},
{
Filters: []informer.Filter{
{
Field: []string{`metadata`, `name`},
Matches: []string{"matches-filter"},
Op: informer.Eq,
Partial: false,
},
},
},
},
wantNames: []string{"matches-filter"},
},
{
name: "no matches",
filters: orFiltersForFilters(
informer.Filter{
Field: []string{"metadata", "annotations", "somekey"},
Matches: []string{"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(context.Background(), 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))
}